这是我参与8月更文挑战的第 29 天,活动详情查看: 8月更文挑战
goroutine与线程
可增长的栈
每个OS线程都有一个固定大小的栈内存(通常为2MB),栈内存区域用于保存在其他函数调用期间那些正在执行或临时暂停的函数中的局部变量。这个固定的栈大小既太大又太小。对于一个小的goroutine,2MB的栈是一个巨大的浪费,比如有的goroutine仅仅等待一个WaitGroup再关闭一个通道。在Go程序中,一次创建十万左右的goroutine也不罕见,对于这种情况,栈就太大了。另外,对于最复杂和深度递归的函数,固定大小的栈始终不够大。改变这个固定大小可以提高空间效率并允许创建更多的线程,或者也可以容许更深的递归函数,但无法同时做到上面的两点
作为对比,一个 goroutine在生命周期开始时,只有一个很小的栈,典型情况下为2KB。与OS线程类似,goroutine的栈也用于存放那些正在执行或临时暂停的函数中的局部变量。但与OS线程不同的是,goroutine的栈不是固定大小的,它可以按需增大和缩小。 goroutine的栈大小限制可以达到1GB,比线程典型的固定大小栈高几个数量级。当然,只有极少的goroutine会使用这么大的栈
goroutine调度
OS线程由OS内核来调度。每隔几毫秒,一个硬件时钟中断发到CPU,CPU调用一个叫调度器的内核函数。这个函数暂停当前正在运行的线程,把它的寄存器信息保存到内存,查看线程列表并决定接下来运行哪一个线程,再从内存恢复线程的注册表信息,最后继续执行选中的线程。因为OS线程由内核来调度,所以控制权限从一个线程到另外一个线程需要个完整的上下文切换( context switch):即保存一个线程的状态到内存,再恢复另外一个线程的状态,最后更新调度器的数据结构。考虑这个操作涉及的内存局域性以及涉及的内存访问数量,还有访问内存所需的CPU周期数量的增加,这个操作其实是很慢的
Go运行时包含一个自己的调度器,这个调度器使用一个称为m:n调度的技术(因为它可以复用/调度m个 goroutine到n个OS线程)。Go调度器与内核调度器的工作类似,但Go调度器只需关心单个Go程序的goroutine调度问题
与操作系统的线程调度器不同的是,Go调度器不是由硬件时钟来定期触发的,而是由特定的Go语言结构来触发的。比如当一个goroutine调用time.sleep或被通道阻塞或对互斥量操作时,调度器就会将这个goroutine设为休眠模式,并运行其他goroutine直到前一个可重新唤醒为止。因为它不需要切换到内核语境,所以调用一个goroutine比调度一个线程成本低很多
GOMAXPROCS
Go调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU数量,所以在一个有8个CPU的机器上,调度器会把Go代码同时调度到8各OS线程上。( GOMAXPROCS是m:n调度中的n)正在休眠或者正被通道通信阻塞的goroutine不需要占用线程。阻塞在IO和其他系统调用中或调用非Go语言写的函数的goroutine需要一个独立的OS线程,但这个线程不计算在GOMAXPROCS内
可以用 GOMAXPROCS环境变量或者runtime.GOMAXPROCS函数来显式控制这个参数。可以用一个小程序来看看GOMAXPROCS的效果,这个程序无止境地输出0和1
for {
go fmt.Print(0)
fmt.Print(1)
}
$ GOMAXPROCS=1 go run hacker-cliche.go
111111111111111111110000000000000000000011111
$ GOMAXPROCS=2 go run hacker-cliche.go
01010101010101010101100110010101110010100110..
在第一次运行时,每次最多只能有一个goroutine运行。最开始是主 goroutine,它输出在一段时间以后,Go调度器让主goroutine休眠,并且唤醒另一个输出0的goroutine让它有机会执行。在第二次运行时,这里有两个可用的OS线程,所以两个 goroutine可以同时运行,以一个差不多的速率输出两个数字。必须强调的是,影响goroutine调度的因素很多,运行时也在不断演化,所以你的结果可能与上面展示的结果会有所不同
你还可以通过runtime.NumCPU() ,来查看CPU数量
goroutine没有标识
在大部分支持多线程的操作系统和编程语言里,当前线程都有一个独特的标识,它通常可以取一个整数或者指针。这个特性让我们可以轻松构建一个线程的局部存储,它本质上就是一个全局的map,以线程的标识作为键,这样每个线程都可以独立地用这个map存储和获取值,而不受其他线程干扰
goroutine没有可供程序员访问的标识。这个是由设计来决定的,因为线程局部存储有种被滥用的倾向。比如,当一个web服务器用一个支持线程局部存储的语言来实现时,很多函数都会通过访问这个存储来查找关于HTTP请求的信息。但就像那些过度依赖于全局变量的程序一样,这也会导致一种不健康的“超距作用”,即函数的行为不仅取决于它的参数,还取决于运行它的线程标识。因此,在线程的标识需要改变的场景(比如需要使用工作线程时),这些函数的行为就会变得诡异莫测
Go语言鼓励一种更简单的编程风格,其中,能影响一个函数行为的参数应当是显式指定的。这不仅让程序更易阅读,还让我们能自由地把一个函数的子任务分发到多个不同的goroutine而无需担心这些goroutine的标识
参考
《Go程序设计语言》—-艾伦 A. A. 多诺万
《Go语言学习笔记》—-雨痕