Go 语言中的并发程序主要是通过基于 CSP (communicating sequential processes)的 goroutine 和 channel 来实现,当然也支持使用传统的多线程共享内存的并发方式。
goroutine
Goroutine 是 Go 语言支持并发的核心,在一个 Go 程序中同时创建成百上千个 goroutine 是非常普遍的,一个 goroutine 会以一个很小的栈开始其生命周期,一般只需要 2 KB,区别于操作系统线程由系统内核进行调度, goroutine 是由 Go 运行时(runtime)负责调度,例如 Go 运行时会智能地将 m 个 goroutine 合理地分配给 n 个操作系统线程,实现类似 m: n 的调度机制,不再需要 Go 开发者自行在代码层面维护一个线程池。
Goroutine 是 Go 程序中最基本的并发执行单元,每一个 Go 程序都至少包含一个 goroutine——main goroutine,当 Go 程序启动时它会自动创建。
go 关键字
Go 语言中使用 goroutine 非常简单,只需要在函数或方法调用前加上 go 关键字就可以创建一个 goroutine,从而让该函数或方法在新创建的 goroutine 中执行。
funcmain() { go hello() // 启动另外一个goroutine去执行hello函数。 fmt.Println("你好") }
将上述代码重新编译后执行,得到输出结果如下。
1
你好。
这一次的执行结果只在终端打印了”你好”,并没有打印 hello,这是为什么呢?
其实在 Go 程序启动时, Go 程序就会为 main 函数创建一个默认的 goroutine,在上面的代码中我们在 main 函数中使用 go 关键字创建了另外一个 goroutine 去执行 hello 函数,而此时 main goroutine 还在继续往下执行,我们的程序中此时存在两个并发执行的 goroutine,当 main 函数结束时整个程序也就结束了,同时 main goroutine 也结束了,所有由 main goroutine 创建的 goroutine 也会一同退出,也就是说我们的 main 函数退出太快,另外一个 goroutine 中的函数还未执行完程序就退出了,导致未打印出"hello”
所以我们要想办法让 main 函数‘"等一等”将在另一个 goroutine 中运行的 hello 函数,其中最简单粗暴的方式就是在 main 函数中"time. Sleep”一秒钟了(这里的 1 秒钟只是我们为了保证新的 goroutine 能够被正常创建和执行而设置的一个值)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
package main
import ( "fmt" "time" )
funchello() { fmt.Println("hello") }
funcmain() { go hello() fmt.Println("你好") time.Sleep(time.Second) }
将我们的程序重新编译后再次执行,程序会在终端输出如下结果,并且会短暂停顿一会儿。
1 2
你好。 hello
为什么会先打印 你好 呢?这是因为在程序中创建 goroutine 执行函数需要一定的开销,而与此同时 main 函数所在的 goroutine 是继续执行的。
在上面的程序中使用 time.Sleep 让 main goroutine 等待 hello goroutine 执行结束是不优雅的,当然也是不准确的。
Go 语言中通过 sync 包为我们提供了一些常用的并发原语,当你并不关心并发操作的结果或者有其它方式收集并发操作的结果时, WaitGroup 是实现等待一组并发操作完成的好方法。
下面的示例代码中我们在 main goroutine 中使用 sync.WaitGroup 来等待 hello goroutine 完成后再退出。
操作系统的线程一般都有固定的栈内存(通常为 2 MB),而 Go 语言中的 goroutine 非常轻量级,一个 goroutine 的初始栈空间很小(一般为 2 KB),所以在 Go 语言中一次创建数万个 goroutine 也是可能的,并且 goroutine 的栈不是固定的,可以根据需要动态地增大或缩小, Go 的 runtime 会自动为 goroutine 分配合适的栈空间。
goroutine 调度
操作系统的线程会被操作系统内核调度时会挂起当前执行的线程并将它的寄存器内容保存到内存中,选出下一次要执行的线程并从内存中恢复该线程的寄存器信息,然后恢复执行该线程的现场并开始执行线程,从一个线程切换到另一个线程需要完整的上下文切换,因为可能需要多次内存访问,索引这个切换上下文的操作开销较大,会增加运行的 cpu 周期。
区别于操作系统内核调度操作系统线程, goroutine 的调度是 Go 语言运行时(runtime)层面的实现,是完全由 Go 语言本身实现的一套调度系统——go scheduler,它的作用是按照一定的规则将所有的 goroutine 调度到操作系统线程上执行。
在经历数个版本的迭代之后,目前 Go 语言的调度器采用的是 GPM 调度模型。
G:表示 goroutine,每执行一次 go f() 就创建一个 G,包含要执行的函数和上下文信息。
全局队列(Global Queue):存放等待运行的 G
P:表示 goroutine 执行所需的资源,最多有 GOMAXPROCS 个。
P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个,新建 G 时, G 优先加入到 P 的本地队列,如果本地队列满了会批量移动部分 G 到全局队列。
M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,当 P 的本地队列为空时, M 也会尝试从全局队列或其他 P 的本地队列获取 G, M 运行 G, G 执行之后, M 会从 P 获取下一个 G,不断重复下去。
Goroutine 调度器和操作系统调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,操作系统调度器负责把内核线程分配到 CPU 的核上执行。
单从线程调度讲, Go 语言相比起其他语言的优势在于 OS 线程是由 OS 内核来调度的, goroutine 则是由 Go 运行时(runtime)自己的调度器调度的,完全是在用户态下完成的,不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池,不直接调用系统的 malloc 函数(除非内存池需要改变),成本比调度 OS 线程低很多,另一方面充分利用了多核的硬件资源,近似的把若干 goroutine 均分在物理线程上,再加上本身 goroutine 的超轻量级,以上种种特性保证了 goroutine 调度方面的性能。
GOMAXPROCS
Go 运行时的调度器使用 GOMAXPROCS 参数来确定需要使用多少个 OS 线程来同时执行 Go 代码,默认值是机器上的 CPU 核心数,例如在一个 8 核心的机器上, GOMAXPROCS 默认为 8, Go 语言中可以通过 runtime.GOMAXPROCS 函数设置当前程序并发时占用的 CPU 逻辑核心数, (Go 1.5 版本之前,默认使用的是单核心执行, Go 1.5 版本之后,默认使用全部的 CPU 逻辑核心数,)