goroutine 是 Golang的最大卖点之一,它让并发编程变的十分简单,仅仅使用 go
关键字就能快速的创建goroutine。与其他语言设计并发程序相比,这极大的减少了程序员的心智负担。
goroutine的特点
- 轻量级
goroutine是用户态"线程",开销非常小,最新golang版本默认为goroutine分配的初始栈大小为2k,同时会根据运行状况动态扩展或收缩。一个有2G内存的机器,理论上可以容纳一百万 goroutine。
- 协作式调度
golang的runtime采用协作式调度,goroutine的运行原则上不能被抢占,除非goroutine主动让出CPU,否则goroutine会运行到结束,所以context switch 开销基本可以忽略。
- 高效的线程模型
golang为了充分发挥多核机器的优势,采用了M:N线程模型,即M个内核线程,每个内核线程可以为N个goroutine提供运行环境,最大限度的发挥了多核机器的能力。
几个关键的数据结构
- g
g代表一个goroutine实例,在golang源码src/runtime/runtime2.go 中,可以看到g的详细定义。和普通的线程一样,g主要包含:可伸缩的运行栈,goroutine切换时的上下文环境(gobuf),程序计数器,基地址,可执行代码等。
type g struct {
stack stack // offset known to runtime/cgo
sched gobuf
goid int64
gopc uintptr // pc of go statement that created this goroutine
startpc uintptr // pc of goroutine function
... ...
}
- m
m代表一个内核线程,是goroutine真正的执行环境。一般会有一个内核线程池,当goroutine因为等待网络数据或者读取文件等阻塞时,goroutine会绑定在这个m上,等到阻塞操作的完成后重新绑定到一个p上继续运行。若暂时找不到可用的p,那么这个goroutine会放到全局的 run queue 中。
type m struct {
g0 *g // goroutine with scheduling stack
mstartfn func()
curg *g // current running goroutine
.... ..
}
- p
早起版本的golang实现不包含p这一结构,p表示一个逻辑处理器,p的数量一般为机器的CPU核心数,每个p下面挂载有等待被调度的goroutine. 每个 goroutine想要运行需要首先获得p才能被调度。p数量决定了系统的最大并发度。
type p struct {
lock mutex
id int32
status uint32 // one of pidle/prunning/...
mcache *mcache
racectx uintptr
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr
// Available G's (status == Gdead)
gfree *g
gfreecnt int32
... ...
}
g, m, p 的关系入下图所示
G,M,P关系图(图片来自网络)上图左半部分,M1为空闲线程,M0线程下面有一个P和它绑定,P下面有一个正在运行的G0,还有其他等待运行的G。在某个时候,G0中发生了系统调用,P与M0解绑,寻找空闲的线程M1,绑定到上面继续执行P下的其他G,M0与G0陷入系统调用,入上图有半部分所示。
为何需要抢占式调度
goroutine里面的代码执行没有确定的时间,如果一个goroutine长期占有p运行,甚至一个死循环,那么p下面的其他g就无法得到调度,这种情况是我们不希望看到的。幸好,系统监控线程 sysmon可以判断这种情况,它可以打断当前goroutine的执行,使P下的其他G得到调度。
sysmon主要完成如下工作:
- 释放闲置超过5分钟的span物理内存;
- 如果超过2分钟没有垃圾回收,强制执行;
- 将长时间未处理的netpoll结果添加到任务队列;
- 向长时间运行的G任务发出抢占调度;
- 收回因syscall长时间阻塞的P;
因此,我们不应该在goroutine里面设计长时间运行的任务。这种抢占机制在一定程度上保证了同一P下G的公平调度。
work stealing 算法
当p下面没有可供调度的goroutine时,他会从global run queue或者其他p下的goroutine中“偷” 一部分goroutine来运行,这样最大限度的利用多核。这在一定程度上保证了在各个CPU核上的负载均衡。
如何处理阻塞的系统调用
对于普通的文件IO操作一旦阻塞,那么m就会进入sleep状态,IO完成之后才会被唤醒。这种情况下,p将与m分离,选择其他空闲的m继续执行。如果没有空闲的m,那么就会新创建一个m。可想而知,如果有大量的这样的文件IO操作,大量的m将会被创建出来,这时候操作系统对m的调度开销就不能忽视了。
针对网络IO,golang使用netpoller做出了特别的优化,这样goroutine里面发起网络IO也不会导致m被阻塞,从而不会引起创建大量的内核线程m。
goroutine发生调度的时机
goroutine在获得m时一般不能一直运行到完毕,它们往往可能要等待其他资源才能执行完成,比如说一个http请求收到服务器响应这个goroutine才算完成了他的任务。在等待服务器响应的这一段时间它不会占用CPU时间 ,调度器会调度其他goroutine继续执行。goroutine遇到下面的情况下可能会产生重新调度
- 阻塞 I/O
- select操作
- 阻塞在channel
- 等待锁
- 主动调用 runtime.Gosched()
参考链接
- 聊一聊goroutine stack
- A complete journey with Goroutines
- go-internals
- http://morsmachine.dk/go-scheduler
- https://news.ycombinator.com/item?id=12459841
- https://golang.org/src/runtime/runtime2.go
- https://www.zhihu.com/question/20862617
- goroutine与调度器
- golang密集场景下协程调度饥饿问题
- Handling 1 Million Requests per Minute with Go
- LearnConcurrency
- Go's work-stealing scheduler
- Golang源码探索(二) 协程的实现原理
- 也谈goroutine调度器
- Goroutine浅析
- Twelve Go Best Practices
- golang netpoller
网友评论