Go并发初探
1,前言
在这里我们不再赘述进程和线程的同步方式、并行程序和并发程序的关系、亦或是多进程与多线程的开销等基础内容,直接切入Go语言的并发线程模型和内部实现。
(1)多线程模型
多线程模型即用户级线程和内核级线程的不同连接方式。① 多对一模型(M : 1)
将多个用户级线程映射到一个内核级线程,线程管理在用户空间完成。此模式中,用户级线程对操作系统不可见(即透明)。
优点:
这种模型的好处是线程上下文切换都发生在用户空间,避免的模态切换(mode switch),从而对于性能有积极的影响。
缺点:
所有的线程基于一个内核调度实体即内核线程,这意味着只有一个处理器可以被利用,在多处理环境下这是不能够被接受的,本质上,用户线程只解决了并发问题,但是没有解决并行问题。
如果线程因为 I/O 操作陷入了内核态,内核态线程阻塞等待 I/O 数据,则所有的线程都将会被阻塞,用户空间也可以使用非阻塞的I/O,但是还是有性能及复杂度问题。② 一对一模型(1:1)
将每个用户级线程映射到一个内核级线程。每个线程由内核调度器独立的调度,所以如果一个线程阻塞则不影响其他的线程。
优点:
在多核处理器的硬件的支持下,内核空间线程模型支持了真正的并行,当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强。
缺点:
每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能。
③ 多对多模型(M : N)
内核线程和用户线程的数量比为 M : N,内核用户空间综合了前两种的优点。这种模型需要内核线程调度器和用户空间线程调度器相互操作,本质上是多个线程被绑定到了多个内核线程上,这使得大部分的线程上下文切换都发生在用户空间,而多个内核线程又可以充分利用处理器资源。
(2)并发机制的调度实现
golang中的协程实现了M:N的线程模型,golang内置的调度器,可以让多核CPU中每一个CPU执行一个协程,理解goroutine机制的原理,关键是理解Go语言scheduler的实现。
① 调度器是如何工作的
Go语言中支撑整个scheduler实现的主要有4个重要结构,分别是G,P,M,调度器,
前三个定义在runtime.h中,调度器定义在proc.c中。
· G是goroutine实现的核心结构,它包含了栈,指令指针,以及其他对调度goroutine很重要的信息,例如其阻塞的channel。
· P结构是Processor,处理器,它的主要用途就是用来执行goroutine的,它维护了一个goroutine队列,即runqueue。Processor是让我们从N:1调度到N:M调度的重要部分。
· M结构是Machine,系统线程,它由操作系统管理的,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息。
· Sched结构就是调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。
在这里我们介绍一下runtime.GOMAXPROC函数,这个函数是指定P的数量而不是M的数量,这点需要注意。2,G
一个G就相当于一个Goroutine,也与我们使用go语句欲并发执行的一个匿名或命名的函数相对应,我们使用go语句想Go语言的运行时系统提交了一个并发执行任务,而Go语言的运行时系统则会按照我们的要求并发地执行并完成这一任务。
在runtime.h中定义了这样一个结构体
struct G
{
uintptr stackguard; // 分段栈的可用空间下界
uintptr stackbase; // 分段栈的栈基址
Gobuf sched; //进程切换时,利用sched域来保存上下文
uintptr stack0;
FuncVal* fnstart; // goroutine运行的函数
void* param; // 用于传递参数,睡眠时其它goroutine设置param,唤醒时此goroutine可以获取
int16 status; // 状态Gidle,Grunnable,Grunning,Gsyscall,Gwaiting,Gdead
int64 goid; // goroutine的id号
G* schedlink;
M* m; // for debuggers, but offset not hard-coded
M* lockedm; // G被锁定只能在这个m上运行
uintptr gopc; // 创建这个goroutine的go表达式的pc
...
};
3,P
P是使G能够在M中运行的关键。Go语言的运行时系统会适时地让P与不同的M建立或断开关联,以使P中的那些可运行的G能在需要的时候及时获得运行时机。
前文说过,可以通过runtime.GOMAXPROCS改变单个Go程序间接拥有P的最大数量。每个P都需要关联一个M才能使其中的可运行的G得到执行。当M因系统调用进而被阻塞的时候,运行时系统会将M和与之关联的P分离开来。这时,如果这个P的可运行G队列中还有未被运行的G,那么运行时系统会找到一个空闲M,或创建出一个新的M,并与该P关联已满足这些G的运行需要。(p的最大数值为256)
struct P
{
Lock;
uint32 status; // Pidle、Prunning、Psyscall、Pgcstop和Pdead
P* link;
uint32 schedtick; // 每次调度时将它加一
M* m; // 链接到它关联的M (nil if idle)
MCache* mcache;
G* runq[256];
int32 runqhead;
int32 runqtail;
// Available G's (status == Gdead)
G* gfree;
int32 gfreecnt;
byte pad[64];
};
容器:P的可运行G队列、P的*G队列4,M
一个M代表了一个内核线程,创建一个M的原因都是由于没有足够的M来关联P并运行其中的可运行的G。
M在被创建之初会被加入到全局的M列表中。紧接着,它的起始函数和准备关联的P会被设置。最后,运行时系统会为他们创建一个新的内核线程并与之相关联。为执行G做好了准备。
struct M
{
G* g0; // 带有调度栈的goroutine
G* gsignal; // signal-handling G 处理信号的goroutine
void (*mstartfn)(void);
G* curg; // M中当前运行的goroutine
P* p; // 关联P以执行Go代码 (如果没有执行Go代码则P为nil)
P* nextp;
int32 id;
int32 mallocing; //状态
int32 throwing;
int32 gcing;
int32 locks;
int32 helpgc; //不为0表示此m在做帮忙gc。helpgc等于n只是一个编号
bool blockingsyscall;
bool spinning;
Note park;
M* alllink; // 这个域用于链接allm
M* schedlink;
MCache *mcache;
G* lockedg;
M* nextwaitm; // next M waiting for lock
GCStats gcstats;
...
};
g0是带有调度栈的goroutine,这是一个比较特殊的goroutine。普通的goroutine的栈是在堆上分配的可增长的栈,而g0的栈是M对应的线程的栈。5,调度器
proc.c中有如下结构体
struct Sched {
Lock;
uint64 goidgen;
M* midle; // idle m's waiting for work
int32 nmidle; // number of idle m's waiting for work
int32 nmidlelocked; // number of locked m's waiting for work
int3 mcount; // number of m's that have been created
int32 maxmcount; // maximum number of m's allowed (or die)
P* pidle; // idle P's
uint32 npidle; //idle P的数量
uint32 nmspinning;
// Global runnable queue.
G* runqhead;
G* runqtail;
int32 runqsize;
// Global cache of dead G's.
Lock gflock;
G* gfree;
int32 stopwait;
Note stopnote;
uint32 sysmonwait;
Note sysmonnote;
uint64 lastpoll;
int32 profilehz; // cpu profiling rate
}
调度器容器:调度器的空闲M列表、调度器的空闲P列表、调度器的可运行G队列和调度器的*G列表
运行时系统容器:全局M列表、全局P列表和全局G列表。
6,总结
(1)调度详情
我们分别用三角形,矩形和圆形表示Machine Processor和Goroutine。
在单核处理器的场景下,所有goroutine运行在同一个M系统线程中,每一个M系统线程维护一个Processor,任何时刻,一个Processor中只有一个goroutine,其他goroutine在runqueue中等待。一个goroutine运行完自己的时间片后,让出上下文,回到runqueue中。
多核处理器的场景下,为了运行goroutines,每个M系统线程会持有一个Processor。
在正常情况下,scheduler会按照上面的流程进行调度,但是线程会发生阻塞等情况,看一下goroutine对线程阻塞等的处理。
① 线程阻塞
当正在运行的goroutine阻塞的时候,例如进行系统调用,会再创建一个系统线程(M1),当前的M线程放弃了它的Processor,P转到新的线程中去运行。
② runqueue执行完成
当其中一个Processor的runqueue为空,没有goroutine可以调度。它会从另外一个上下文偷取一半的goroutine。
7,参考
http://morsmachine.dk/go-scheduler下一篇: php 数学运算验证码实现代码