1. 背景:为什么需要协程?
最开始的计算机系统并没有什么进程(或线程)的概念。与现在的单片机系统类似,编写特定功能的应用程序,然后上电以后就开始运行。这样做的问题就是CPU的利用率会很低,长期被一个应用程序独占,后来为了对CPU进行分时复用,就慢慢有了操作系统,通过进程(或线程)来实现对CPU的复用。
随着CPU多核心的发展趋势,进程(或线程)就可以用来提高程序的并发度,利用多核心来提高程序的并发能力。
多进程(或线程)的程序性能为什么好,主要就是这两个原因,1.可以提高CPU的使用效率,当程序在等待比较慢的设备时让出CPU来干别的事情(比如常见的互联网web服务中的各种网络等待);2.利用CPU的多核性能,实现真正的并行。
那么为什么在已经有了进程(或线程)来实现并发以后,还需要有协程呢?主要就是因为切换进程(或线程)的代价是非常大的,切换页表,切换内核堆栈,切换硬件的各种寄存器,执行调度等等,另外现代CPU有非常多的各级cache,切换进程(或线程)以后很有可能会导致cache失效,这都是切换的代价。
基于这些代价,很多天才程序员开始考虑不利用进程(或线程)的切换来实现一些并发能力。比如像NGINX这样的应用程序,可以在一个进程里面处理多个请求,通过IO复用和注册回调的的方式,不阻塞进程,不做切换,一直占用CPU来计算,碰到需要阻塞的时候就先干别的,等到事件准备好了就执行回调函数。这种做法的缺点就是代码看起来不太好理解,不直观,对程序员的要求比较高。有没有办法让程序员正常的写着各种同步的方法,又不用付出多进程(或线程)带来的资源消耗,那么协程就出现了。对程序员来说,代码写的就像用到了多进程(或线程)来实现了并发,但是实际上操作系统并没有做进程(或线程)级别的切换。
2. 如何理解GMP
很多编程语言现在都实现了协程或者类似的概念,包括C++,Java,Python,Golang,甚至C和PHP也有一些三方的库支持了类似的能力。当然目前Golang在这一方面名气还是最大的,毕竟在诞生之初就在主打这个概念。
Golang大名鼎鼎的GMP模型就是实现协程的原理。Golang经过很多版本的迭代,确定了现在GMP模型,G表示goroutine,M表示真正的系统线程(每个M都意味着一个系统上真实线程),P(processer)代表的处理器(Golang虚拟出来的处理器概念,表示并发度,每个M想要真正运行都必须绑定P)。
一句话总结:一个用Golang开发的应用程序可以在操作系统上创建M个线程,但是这M个线程中只有P个能够同时处于运行状态从而被操作系统调度来运行,所以M肯定是大于等于P的,另外M-P个线程处于休眠状态。可以创建无数的goroutine,它们会被通过某种神秘力量分配在M上来执行。
3. 核心源码
go进程启动代码的入口在runtime/asm_amd64(和具体硬件相关).s文件里面。大部分寄存器操作细节可以不用理解,最核心的流程见中文注释。
TEXT runtime·rt0_go<ABIInternal>(SB),NOSPLIT,$0
//省略
CALL runtime·args(SB) // 复制参数
CALL runtime·osinit(SB) // 具体OS相关,比如os_darwin.go的 func osinit()
CALL runtime·schedinit(SB) // 初始化调度器
// 创建goroutine,运行的地址是 runtime/proc.go文件里面的 func main()
MOVQ $runtime·mainPC(SB), AX
CALL runtime·newproc(SB)
// 启动一个M
CALL runtime·mstart(SB)
3.1 P的生命周期
P的创建是在上面的runtime·schedinit(SB)函数中调用func procresize(nprocs int32)时生成。
func procresize(nprocs int32) *p {
(...省略...)
// initialize new P's
for i := old; i < nprocs; i++ {
pp := allp[i]
if pp == nil {
pp = new(p)
}
pp.init(i)
atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
}
(...省略...)
}
从调用关系上看,除了在启动时调用外,GC的时候也会调用这个函数。我们可以认为初始化创建后一般不会再新增或减少P。
3.2 M的生命周期
M的创建发生在runtime/proc.go文件的newm函数调用时,newm函数主要负责产生M结构体,再调用newm1函数通过newosproc函数来具体产生一个系统的线程。
func newm(fn func(), _p_ *p, id int64) {
mp := allocm(_p_, fn, id)
mp.doesPark = (_p_ != nil)
mp.nextp.set(_p_)
mp.sigmask = initSigmask
(...省略...)
newm1(mp)
}
func newm1(mp *m) {
(...省略...)
execLock.rlock() // Prevent process clone.
newosproc(mp) // 根据具体系统调用产生系统线程
execLock.runlock()
}
newm函数主要的调用方(可能新建M的场景)
3.3 G的生命周期
G的创建由runtime/proc.go文件中的newproc函数产生。在应用开发中如果用户调用了go func(),编译器实际上就调用了此函数。此函数的核心逻辑就是新生成一个G结构体,然后将G放入当前的P的可执行队列中,等待调度器在调度到,然后开始执行。
func newproc(siz int32, fn *funcval) {
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg()
pc := getcallerpc()
systemstack(func() {
newg := newproc1(fn, argp, siz, gp, pc)
_p_ := getg().m.p.ptr()
runqput(_p_, newg, true) // 将新产生的goroutine放在当前P的可执行队列中
if mainStarted {
wakep()
}
})
}
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
_g_ := getg()
(...省略...)
// 每一个goroutine在创建的时候都已经注册好执行完以后跳转的地址
newg.sched.pc = funcPC(goexit) + sys.PCQuantum
return newg
}
3.4 调度
runtime/proc.go文件里面的schedule函数执行goroutine调度,每个M(系统线程)就是一直执行schedule函数,然后来驱动每个goroutine的调度运行。
func schedule() {
_g_ := getg() // 获取当前的goroutine
(...省略...)
var gp *g
if gp == nil {
// 从正在运行的goroutine对应的M中绑定的P中找到本地的goroutine
gp, inheritTime = runqget(_g_.m.p.ptr())
}
if gp == nil {
// 全局goroutine或者别的P中找可以运行的goroutine
gp, inheritTime = findrunnable() // blocks until work is available
}
(...省略...)
execute(gp, inheritTime) // 执行被调度出来的goroutine
}
func execute(gp *g, inheritTime bool) {
_g_ := getg()
(...省略...)
gogo(&gp.sched) // 跳转到goroutine的程序地址执行
}
切换到goroutine具体的程序地址运行,运行完以后会重新跳到之前创建goroutine时注册的goexit地址。然后继续跳转到schedule,进行下一次的调度。找到下一个goroutine并进行执行。
TEXT runtime·gogo(SB), NOSPLIT, $16-8
MOVQ buf+0(FP), BX // gobuf
MOVQ gobuf_g(BX), DX
MOVQ 0(DX), CX // make sure g != nil
get_tls(CX)
MOVQ DX, g(CX)
MOVQ gobuf_sp(BX), SP // restore SP
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP
MOVQ $0, gobuf_sp(BX) // clear to help garbage collector
MOVQ $0, gobuf_ret(BX)
MOVQ $0, gobuf_ctxt(BX)
MOVQ $0, gobuf_bp(BX)
MOVQ gobuf_pc(BX), BX
JMP BX
func goexit0(gp *g) {
_g_ := getg()
(...省略...)
schedule()
}
4. 几个例子
4.1 G的数量小于P时,M的数量
func main() {
runtime.GOMAXPROCS(2)
for i := 0; i < 500; i++ {
time.Sleep(time.Second)
fmt.Println(runtime.NumGoroutine())
}
fmt.Println("finish")
G=1,P=2,M为5。
G=1,P=4,M为5。
4.2 G的数量大于P时,M的数量
func main() {
runtime.GOMAXPROCS(4)
for i := 0; i < 100000; i++ {
go process()
}
for i := 0; i < 500; i++ {
time.Sleep(time.Second)
fmt.Println(runtime.NumGoroutine())
}
fmt.Println("finish")
}
G=100001,P=4,M为7。CPU使用率为400%,说明占用了4个核心。
4.3 存在大量系统阻塞调用时,M的数量
G从100101降低为100001,P=4,M为104一直没有变化,说明M在创建以后并不会删除,会一直处于idle状态。CPU使用率为400%,说明占用了4个核心。
func main() {
runtime.GOMAXPROCS(4)
for i := 0; i < 100000; i++ {
go process()
}
for i := 0; i < 100; i++ {
go lockfile()
}
for i := 0; i < 500; i++ {
time.Sleep(time.Second)
fmt.Println(runtime.NumGoroutine())
}
fmt.Println("finish")
}
func process() {
for {
time.Sleep(10 * time.Millisecond)
i := 0
i++
}
}
func lockfile() {
f, err := os.Open("a.txt")
if err != nil {
fmt.Println(err)
}
//err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX)
if err != nil {
fmt.Println(err)
} else {
fmt.Println("lock success")
}
time.Sleep(1 * time.Second)
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
}
根据前面的分析M的创建时机是,在有可运行的goroutine时和可用的P资源前提下,如果没有空闲的M,就会创建。在产生系统调用时,会产生很多的M可以理解。
至于为什么在1个goroutine和4个P时会产生5个M,应该是跟进程占用了一些系统资源文件,发生系统调用时产生,细节后面再深入研究下。
参考资料:
这篇文章的图画的特别好,https://learnku.com/articles/41728
这篇文章的源码分析比较详细,https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine/#m,https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/mpg/