从源码角度分析 golang 协程 goroutines

什么是golang 的 goroutines,线程,协程(Coroutine),进程,轻量级线程?从网上搜来的资料来看,应该是更接近第四种,在用户态运行的,避免了上下文切换的线程。

从官方手册得来的定义是:They’re called goroutines because the existing terms—threads, coroutines, processes, and so on—convey inaccurate connotations. it is a function executing concurrently with other goroutines in the same address space.

这里有一个连接,对几种并发和goroutine进行了一个比较详细的分析比较
http://www.csdn123.com/html/blogs/20130505/9353.htm

下面是goroutines的调度器设计示意图。

goroutine设计图

 

M : 下面汇编生成的线程,代表了实际处理运行代码的线程,通过对比pthread_create的汇编,结论是两者并不相同。包括所有的阻塞的,非阻塞的,空闲的等等。通过runtime·notewakeup,runtime·notesleep, runtime·noteclear 可以更改其运行状态。

G :就是通过 go func 创建的并发函数片段,主要存储在P(处理器)以及一个全局的G单向链表之中,如果一个G(任务)执行完毕,也会被收回空间,压缩栈之后,放在一个空闲G列表中,等待复用。

P : 虚拟出来的处理器,代表了能同时运行的M的个数,内部有一个长度为256的循环列表,存储了交给本P处理的G,多余的无法分配的,放在全局列表中,等待其他的P处理。

整个模型可以抽象成排队买票的情景,M是业务员,卖票的,P是窗口的个数,G是买票的人,每个窗口只能有一个业务员处理任务,每个CPU同时只能有一个线程,业务员之间,存在轮班倒的情况,也有可能业务员因为客户的需求比较特殊,外出办理去了,也就是线程处于阻塞状态,该窗口就交给别的业务员。排队的人分两个队,一个是每个窗口前面的小队伍,还有一个是没有确定窗口的长队伍。

G之所以分两处存储,主要是考虑到并发时候,读取全局列表需要锁和阻塞,经常读取,会影响效率。而全部存储在P中,又面临各个P之间分配不均,造成CPU闲置的问题。

我们说下整个流程,首先在程序开始的时候,go初始化出固定个数的P,整个数量默认是1,如果环境变量有GOMAXPROCS,则设置成该值,如果调用函数runtime.GOMAXPROCS(),则设置成传入的值,P的个数应该根据主机的情况设置。初始化出来的P,第一个用于处理本流程,其余暂时处于Pidle状态,所有的P都保存在runtime·allp中。

之后系统创建一个G,用于运行main函数,在main函数中,初始化栈大小,使用一个单独的M专门用于处理newsysmon,该函数用于系统监控,完成长时间没有触发的事件,比如垃圾回收,因syscall 阻塞的P,抢占G任务通知等。该线程会尽可能的保证运行,因为M(线程)必须占有一个P(处理器)才可以运行,所以main函数的时候,失去一个空闲的P(处理器)。

之后,每次编译器遇到go func就会调用newproc函数,创建并初始化一个G对象,该对象原始大小仅2K,这个也就是许多人号称的,”可以轻松创建几万个协程的原因吧”。

初始化栈和变量之后,将G优先存储在P(处理器)本地,如果没有空位,则存储在全局列表中,因为创建该G的P(处理器)肯定是已经在运行状态了,检查还有没有一个空闲的P(处理器),如果有,则唤醒或者新建一个M(线程),然后该M(线程)与空闲的P结合,开始处理任务。

新唤醒的M(线程)结合P(处理器)之后,需要获得G,需要被处理的任务 ,如何获取G呢,从前到后,依次检查 P的本地,全局列表,已经响应的网络任务,其他的P,再次检查全局队列,如果该过程中得到了可执行的G任务,则执行该任务。不然解除和P的绑定,P(处理器)进入空闲状态,再次检查其他的P和网络任务,如果能获得G任务,则再次申请处理器,执行G任务,如果还没有获得,只能休眠M(线程)。

在一个G任务执行完毕之后,系统会调用goexit函数,依次倒序执行所有注册过的defer ,将执行完毕的G放回P.gfree复用链表中,回收空间,压缩栈,然后查找可执行的G任务,如果能找到,则再次开始执行。注意,获取可执行的G任务后,跳转使用的指令是long jmp,也就是虽然是通过递归的方式循环执行任务的,但是一旦开始,就再也不会递归回调用的地方了。也就是说,两次G任务之间,是不会回朔的。当然,考虑到G之间的彼此独立,也没有回朔的理由。

这就是创建协程的汇编,不同平台略有不同,仔细和fork,pthread_create的汇编对比后,得到的结论是,这个既不是进程,也不是线程,而是go自己实现的协程。

// int32 clone(int32 flags, void *stack, M *mp, G *gp, void (*fn)(void));
TEXT runtime·clone(SB),NOSPLIT,$0
 MOVL $120, AX // clone
 MOVL flags+4(SP), BX
 MOVL stack+8(SP), CX
 MOVL $0, DX // parent tid ptr
 MOVL $0, DI // child tid ptr

 // Copy mp, gp, fn off parent stack for use by child.
 SUBL $16, CX
 MOVL mm+12(SP), SI
 MOVL SI, 0(CX)
 MOVL gg+16(SP), SI
 MOVL SI, 4(CX)
 MOVL fn+20(SP), SI
 MOVL SI, 8(CX)
 MOVL $1234, 12(CX)

 // cannot use CALL *runtime·_vdso(SB) here, because
 // the stack changes during the system call (after
 // CALL *runtime·_vdso(SB), the child is still using
 // the parent's stack when executing its RET instruction).
 INT $0x80

 // In parent, return.
 CMPL AX, $0
 JEQ 3(PC)
 MOVL AX, ret+20(FP)
 RET

 // Paranoia: check that SP is as we expect.
 MOVL mm+8(FP), BP
 CMPL BP, $1234
 JEQ 2(PC)
 INT $3

 // Initialize AX to Linux tid
 MOVL $224, AX
 CALL *runtime·_vdso(SB)

 // In child on new stack. Reload registers (paranoia).
 MOVL 0(SP), BX // m
 MOVL flags+0(FP), DX // g
 MOVL stk+4(FP), SI // fn

 MOVL AX, m_procid(BX) // save tid as m->procid

 // set up ldt 7+id to point at m->tls.
 // newosproc left the id in tls[0].
 LEAL m_tls(BX), BP
 MOVL 0(BP), DI
 ADDL $7, DI // m0 is LDT#7. count up.
 // setldt(tls#, &tls, sizeof tls)
 PUSHAL // save registers
 PUSHL $32 // sizeof tls
 PUSHL BP // &tls
 PUSHL DI // tls #
 CALL runtime·setldt(SB)
 POPL AX
 POPL AX
 POPL AX
 POPAL 

 // Now segment is established. Initialize m, g.
 get_tls(AX)
 MOVL DX, g(AX)
 MOVL BX, g_m(DX)

 CALL runtime·stackcheck(SB) // smashes AX, CX
 MOVL 0(DX), DX // paranoia; check they are not nil
 MOVL 0(BX), BX

 // more paranoia; check that stack splitting code works
 PUSHAL
 CALL runtime·emptyfunc(SB)
 POPAL 

 CALL SI // fn()
 CALL runtime·exit1(SB)
 MOVL $0x1234, 0x1005

Leave a comment

Your email address will not be published.

*