Goroutines和线程
在上一章中我们说 goroutine 和操作系统的线程区别可以先忽略。尽管两者的区别实际上只是一个量的区别,但量变会引起质变的道理同样适用于 goroutine 和线程。现在正是我们来区分开两者的最佳时机。
动态栈
每一个 OS 线程都有一个固定大小的内存块(一般会是 2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。这个固定大小的栈同时很大又很小。因为 2MB 的栈对于一个小小的 goroutine 来说是很大的内存浪费,比如对于我们用到的,一个只是用来 WaitGroup 之后关闭 channel 的 goroutine 来说。而对于 go 程序来说,同时创建成百上千个 goroutine 是非常普遍的,如果每一个 goroutine 都需要这么大的栈的话,那这么多的 goroutine 就不太可能了。除去大小的问题之外,固定大小的栈对于更复杂或者更深层次的递归函数调用来说显然是不够的。修改固定的大小可以提升空间的利用率,允许创建更多的线程,并且可以允许更深的递归调用,不过这两者是没法同时兼备的。
相反,一个 goroutine 会以一个很小的栈开始其生命周期,一般只需要 2KB。一个 goroutine 的栈,和操作系统线程一样,会保存其活跃或挂起的函数调用的本地变量,但是和 OS 线程不太一样的是,一个 goroutine 的栈大小并不是固定的;栈的大小会根据需要动态地伸缩。而 goroutine 的栈的最大值有 1GB,比传统的固定大小的线程栈要大得多,尽管一般情况下,大多 goroutine 都不需要这么大的栈。
练习 9.4: 创建一个流水线程序,支持用 channel 连接任意数量的 goroutine,在跑爆内存之前,可以创建多少流水线阶段?一个变量通过整个流水线需要用多久?(这个练习题翻译不是很确定)
Goroutine调度
OS 线程会被操作系统内核调度。每几毫秒,一个硬件计时器会中断处理器,这会调用一个叫作 scheduler 的内核函数。这个函数会挂起当前执行的线程并将它的寄存器内容保存到内存中,检查线程列表并决定下一次哪个线程可以被运行,并从内存中恢复该线程的寄存器信息,然后恢复执行该线程的现场并开始执行线程。因为操作系统线程是被内核所调度,所以从一个线程向另一个“移动”需要完整的上下文切换,也就是说,保存一个用户线程的状态到内存,恢复另一个线程的到寄存器,然后更新调度器的数据结构。这几步操作很慢,因为其局部性很差需要几次内存访问,并且会增加运行的 cpu 周期。
Go 的运行时包含了其自己的调度器,这个调度器使用了一些技术手段,比如 m:n 调度,因为其会在 n 个操作系统线程上多工(调度)m 个 goroutine。Go 调度器的工作和内核的调度是相似的,但是这个调度器只关注单独的 Go 程序中的 goroutine(译注:按程序独立)。
和操作系统的线程调度不同的是,Go 调度器并不是用一个硬件定时器,而是被 Go 语言“建筑”本身进行调度的。例如当一个 goroutine 调用了 time.Sleep,或者被 channel 调用或者 mutex 操作阻塞时,调度器会使其进入休眠并开始执行另一个 goroutine,直到时机到了再去唤醒第一个 goroutine。因为这种调度方式不需要进入内核的上下文,所以重新调度一个 goroutine 比调度一个线程代价要低得多。
练习 9.5: 写一个有两个 goroutine 的程序,两个 goroutine 会向两个无 buffer channel 反复地发送 ping-pong 消息。这样的程序每秒可以支持多少次通信?
GOMAXPROCS
Go 的调度器使用了一个叫做 GOMAXPROCS 的变量来决定会有多少个操作系统的线程同时执行 Go 的代码。其默认的值是运行机器上的 CPU 的核心数,所以在一个有 8 个核心的机器上时,调度器一次会在 8 个 OS 线程上去调度 GO 代码。(GOMAXPROCS 是前面说的 m:n 调度中的 n)。在休眠中的或者在通信中被阻塞的 goroutine 是不需要一个对应的线程来做调度的。在 I/O 中或系统调用中或调用非 Go 语言函数时,是需要一个对应的操作系统线程的,但是 GOMAXPROCS 并不需要将这几种情况计算在内。
你可以用 GOMAXPROCS 的环境变量来显式地控制这个参数,或者也可以在运行时用 runtime.GOMAXPROCS 函数来修改它。我们在下面的小程序中会看到 GOMAXPROCS 的效果,这个程序会无限打印 0 和 1 。
for {
go fmt.Print(0)
fmt.Print(1)
}
$ GOMAXPROCS=1 go run hacker-cliché.go
111111111111111111110000000000000000000011111...
$ GOMAXPROCS=2 go run hacker-cliché.go
010101010101010101011001100101011010010100110...
在第一次执行时,最多同时只能有一个 goroutine 被执行。初始情况下只有 main goroutine 被执行,所以会打印很多 1。过了一段时间后,GO 调度器会将其置为休眠,并唤醒另一个 goroutine,这时候就开始打印很多 0 了,在打印的时候,goroutine 是被调度到操作系统线程上的。在第二次执行时,我们使用了两个操作系统线程,所以两个 goroutine 可以一起被执行,以同样的频率交替打印 0 和 1 。我们必须强调的是 goroutine 的调度是受很多因子影响的,而 runtime 也是在不断地发展演进的,所以这里的你实际得到的结果可能会因为版本的不同而与我们运行的结果有所不同。
练习9.6: 测试一下计算密集型的并发程序(练习8.5那样的)会被 GOMAXPROCS 怎样影响到。在你的电脑上最佳的值是多少?你的电脑CPU有多少个核心?
Goroutine没有ID号
在大多数支持多线程的操作系统和程序语言中,当前的线程都有一个独特的身份(id),并且这个身份信息可以以一个普通值的形式被很容易地获取到,典型的可以是一个 integer 或者指针值。这种情况下我们做一个抽象化的 thread-local storage(线程本地存储,多线程编程中不希望其它线程访问的内容)就很容易,只需要以线程的 id 作为 key 的一个 map 就可以解决问题,每一个线程以其 id 就能从中获取到值,且和其它线程互不冲突。
goroutine 没有可以被程序员获取到的身份(id)的概念。这一点是设计上故意而为之,由于thread-local storage 总是会被滥用。比如说,一个 web server 是用一种支持 tls 的语言实现的,而非常普遍的是很多函数会去寻找 HTTP 请求的信息,这代表它们就是去其存储层(这个存储层有可能是tls)查找的。这就像是那些过分依赖全局变量的程序一样,会导致一种非健康的“距离外行为”,在这种行为下,一个函数的行为可能并不仅由自己的参数所决定,而是由其所运行在的线程所决定。因此,如果线程本身的身份会改变——比如一些 worker 线程之类的——那么函数的行为就会变得神秘莫测。
Go 鼓励更为简单的模式,这种模式下参数(译注:外部显式参数和内部显式参数。tls 中的内容算是"外部"隐式参数)对函数的影响都是显式的。这样不仅使程序变得更易读,而且会让我们自由地向一些给定的函数分配子任务时不用担心其身份信息影响行为。
你现在应该已经明白了写一个 Go 程序所需要的所有语言特性信息。在后面两章节中,我们会回顾一些之前的实例和工具,支持我们写出更大规模的程序:如何将一个工程组织成一系列的包,如何获取,构建,测试,性能测试,剖析,写文档,并且将这些包分享出去。