Golang 并发编程
Golang 通过编译器运行时(Runtime),从语言上原生支持了并发编程。
并发与并行
学习 go 并发编程之前,我们需要弄清并发、并行的概念。
由于 CPU 同一时间只能执行一个进程/线程,在下文的概念中,我们把进程/线程统称为任务。不同的场景下,任务所指的可能是进程,也可能是线程。
并发(Concurrency)
并发是指计算机在同一时间段内执行多个任务。
并发的概念比较宽泛,它单纯是指计算机能够同时执行多个任务。比如我们当前是一个单核的 CPU,但是我们有5个任务,计算机会通过某种算法将 CPU 资源合理的分配给多个任务,从用户角度来看的话就是多个任务在同时执行。前面说的的算法比如“时间片轮转”。
并行(Parallelism)
并行是指在同一时刻执行多个任务。 当我们有多个核心的 CPU 的时候,我们同时执行两个任务,就不需要通过“时间片轮转”的方式让多个任务交替执行了,一个 CPU 负责一个任务,同一时刻,多个任务同时执行,这就是并行。
并发+并行
上面的并行图中所展示的任务执行机制,是一种理想化的情况,即执行任务的数量等于 CPU 的核心数量。但实际的场景中,任务数是远大于 CPU 的核心数量的。比如我的电脑是8核的,但是我开机就要启动几十个任务,这个时候就会出现并发和并行都存在的情况。
并行和并发的区别
并发和并行的根本区别是任务是否同时执行。 并发不是并行。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情。 在很多情况下,并发的效果比并行好,因为操作系统和硬件的总资源一般很少,但能支持系统同时做很多事情。这种“使用较少的资源做更多的事情”的哲学,也是指导Go语言设计的哲学。
goroutine
在了解 goroutine 之前我们先了解一下什么是协程(coroutine)。
协程(Coroutine)
这里我们不再多赘述进程、线程的关系,我们来看下协程。协程是其他编程语言中的一种叫法,但并不是所有编程语言都支持 coroutine。 所以协程是什么?
- 轻量级的“线程”:作用和线程差不多,都是并发执行一些任务的。
- 非抢占式多任务处理,即由协程主动交出控制权。这里需要了解一下抢占式和非抢占式的区别:
- 抢占式:以线程为例,线程在任何时候都可能被操作系统进行切换,所以线程就叫做抢占式任务处理,即线程没有控制权,任务即使做到一半,哪怕没有做完,也会被操作系统给抢占了,然后切换到其他任务去。
- 非抢占式:非抢占式的代表就是协程了,协程在任务做到一半的时候可以主动的交出任务的控制权,控制权是由协程内部决定,也正是因为这一特性,协程才是轻量级的。需要注意的是,当一个协程不主动交出控制权的时候,可能会造成死锁,也就是说控制权会一直在这个协程内部,程序将长时间等待,无法跳出。
- 编译器/解释器/虚拟机层面的多任务,非操作系统层面的,操作系统层面的多任务就只有进程/线程。
- 多个协程可能在一个或多个线程上运行,大多数情况下由调度器决定。
- 子程序(函数调用,比如
func a() {}
)是协程的一个特例。
这里需要解释一下第5点,为什么子程序是协程的一个特例的。
我们来看一下普通函数和协程的对比:
普通函数:
在一个线程内,有一个 mian 函数,main 函数调用函数 work, 然后 work 开始执行,work 执行结束后会把控制权交给 main 函数,然后 main 函数会执行后面的函数等。
协程:
协程中 main 函数和 work 函数之间是有个双向的通道(下图中是双箭头),彼此通过通道来进行通信,且两者的控制权也可以双向的交换。那么协程运行在哪里呢?可能是运行在同一个线程,也可以分别运行在不同的线程。协程具体是怎么被分配的,一般作为应用层的使用者来说,我们是不用关心的,这些完全是由调度器来完成操作的。
关于协程第2点控制权的部分,后面我们讲到 goroutine 的时候学习一下如何“迫使”协程主动交出控制权的方式,这里暂时就先不详细说明了。
go 语言的协程(goroutine)
goroutine 其实是一种协程,或者说和协程比较像。
在上文中我们了解了通用编程语言中的协程概念后,终于轮到今天的主角 goroutine 了。我们先来看一下 goroutine 模型。
看图比较容易理解,首先是 go 程序启动一个进程,同时启动一个调度器,在这个调度器之上会分配 goroutine 的调度,也就是上面提到的,多个 goroutine 可能分配在一个线程中,也可能被分配到不同的线程中。
goroutine 的定义
- 通常给函数加上 go 关键字,就能交给调度器调用:
go 函数名(参数列表) // 具名函数形式
go func(参数列表) { // 匿名函数形式
函数体
}(调用参数列表)
- 定义时无需区分函数是否异步,python 中协程的定义需要用到
async
关键字 - 调度器会在合适的时机切换 goroutine,即使 goroutine 是非抢占式的,但是操作权并不完全在 goroutine,这也是 goroutine 和传统协程的一点区别
使用 goroutine
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 10; i++ {
go func(i int) {
fmt.Print("i:", i, ", ")
}(i)
}
}
Go 程序一般从 main 包的 main 函数开始,在程序启动的时候,Go 程序就会为 main 函数创建一个默认的 goroutine,需要注意的是使用 go 关键字创建 goroutine时,被调用的函数的返回值会被忽略。 执行上面的代码输出结果是:
/private/var/folders/rh/6jh584kn2jb7fbp2ymcjw9800000gn/T/GoLand/___go_build_go_leaning_go_routine
Process finished with the exit code 0
很奇怪,明明fmt.Print
了,但第2行什么都没有打印,接着程序就直接退出了。原因是因为程序中 main 函数 和 其他 goroutine 是并发执行的,for 循环执行完之后就直接跳出循环,main 就退出了,代码中的 Print goruntine 还来不及打印就被程序干掉了。
如何看到结果呢?main 程序慢点退出就可以了,稍微加点料:
func main() {
for i := 0; i < 10; i++ {
go func(i int) {
fmt.Print("i:", i, ", ")
}(i)
}
time.Sleep(time.Millisecond) // 延迟 main 函数退出
}
// result:
// i:0, i:2, i:5, i:4, i:6, i:7, i:8, i:9, i:3, i:1,
// Process finished with the exit code 0
再稍微改动一下代码,让 goroutine 无法主动交出控制权:
func main() {
var arr [10]int
for i := 0; i < 10; i++ {
go func(i int) {
for {
arr[i]++
}
}(i)
}
time.Sleep(time.Minute) // 休眠1分钟
fmt.Print("arr: ", arr)
}

执行修改后的代码发现,IDE 显示程序一直处于运行状态,我们再来通过 top 命令查看一下电脑 CPU 使用情况:
由于我电脑的 CPU 是8核的,如果 CPU 打满的话是 占用率应该是800%,上图能看到的是 goroutine 已经占用了716.%。休眠结束后可以看一下具体输出:
arr: [10582140406 10463247362 10747009051 10642989545 10505259520 10456203629 10500957117 10661942229 10440913209 10357335909]
goroutine 交出控制权
上面说协程(coroutine)的时候讲到,非抢占式任务可以主动交出控制权,我们看下 goroutine 是如何交出控制权的方式:
1)I/O 操作交出控制权:
其实 I/O 操作我们上面已经看到过了,就是 fmt.Print()
等...
func main() {
for i := 0; i < 10; i++ {
go func(i int) {
fmt.Print("i:", i, ", ")
}(i)
}
time.Sleep(time.Millisecond)
}
2)runtime.Gosched() :
func main() {
for i := 0; i < 10; i++ {
go func(i int) {
fmt.Print("i:", i, ", ")
runtime.Gosched()
}(i)
}
time.Sleep(time.Millisecond)
}
3)select 操作,大致结构如下:
select {
case <- chan1: // 如果 chan1 成功读到数据就执行该 case 语句处理
case chan2 <- 1 // 如果成功向 chan2 写入数据,就执行该 case 语句
default: // 如果以上都没成功就执行该语句
}
需要注意的是,每个 case 语句都必须是面向 channel
操作的。
4)channel:
func getData(values chan int) {
value := rand.Intn(20)
values <- value
}
func main() {
values := make(chan int)
go getData(values)
value := <-values
fmt.Println("Channel value: ", value)
}
5)等待锁,即传统模式的锁同步机制:
可以通过 sync.Mutex
实现,这里就不多赘述了。
6)函数调用(有时会):
我个人理解是,如 time.sleep()
的 Sleep 函数,func Sleep(){}
的官方 API 解释是:
Sleep pauses the current goroutine for at least the duration d 即 sleep 当前的 goroutine d duration 时间。
7)其他...
goroutine 闭包陷阱
还是先来看一段代码:
func main() {
var arr [10]int
for i := 0; i < 10; i++ {
go func() {
for {
arr[i]++ // look!
runtime.Gosched()
}
}()
}
time.Sleep(time.Millisecond)
fmt.Print("arr:", arr)
}
这段代码只是把参数列表和调用参数列表给移除了,变量 i
直接使用了 for 循环定义的 i
,这时候再运行代码,控制台就会报错,程序 panic 了:
panic: runtime error: index out of range [10] with length 10
如果不明白这个问题的话,go 语言提供了我们一个命令去排查问题,
-race
命令就是我们去检测数据的冲突:
go run -race routine1.go
使用该命令运行程序之后会打印很多日志,我们挑重点的看:
==================
WARNING: DATA RACE
Read at 0x00c00013c018 by goroutine 7:
...
Previous write at 0x00c00013c018 by main goroutine:
...
WARNING: DATA RACE
Read at 0x00c00013e010 by goroutine 7:
...
Previous write at 0x00c00013e010 by goroutine 8:
重点看一下 WARNING: DATA RACE
,这里的 RACE
指的就是竞态(race condition)。
再看下上面的日志,Read at 0x00c00013c018 by goroutine 7
这是个通过 goroutine 7
进行的读操作,Previous write at 0x00c00013c018 by main goroutine
这是个通过 main goroutine
进行的写操作。这两个操作都指向了同一个内存地址 0x00c00013c018
,这个内存地址代表了什么呢?答案是代表了变量i
。
造成上面竞态的根本原因就是闭包陷阱,即 for 循环执行完之后 i
被设置为10,最终每个 goroutine 操作的都是 arr[10]
,所以就会出错。
解决方式的话就可以通过参数列表和调用参数列表每次拷贝一个新 i
的值给 goroutine 就可以了。
我们加上参数列表和调用参数列表再运行一下程序,结果:
arr:[582 592 628 647 489 568 490 618 529 400]
再通过 -race
命令跑一下:
==================
WARNING: DATA RACE
Read at 0x00c00013e000 by main goroutine:
runtime.racereadrange()
<autogenerated>:1 +0x1b
Previous write at 0x00c00013e000 by goroutine 7:
main.main.func1()
/Users/li2/Code/go_learning/go_routine/routine1.go:14 +0x64
...
arr:[2695 1962 1651 1580 1519 1604 1275 1477 1484 1506]Found 1 data race(s)
exit status 66
奇怪了,还是有 data race warning,这里错误有两个,一个是 main goroutine
的读,一个是代码中第 14
行的写,这两行代码分别是:
// main goroutine
fmt.Print("arr:", arr)
// 14 行的
arr[i]++
也就是说程序一边在 fmt.Print(arr)
,又一边在并发执行 arr[i]++
,这个问题要怎么解决呢?
答案是通过 channel
来解决,这篇文章里就不过多做介绍了。
以上。
转载自:https://juejin.cn/post/7201444843137794085