目录

golang 调度器底层原理及 GMP 工作原理

文章简介:golang scheduler 相关的内容

简介

现在主流的线程模型分三种:内核级线程模型、用户级线程模型和两级线程模型(也称混合型线程模型),他们都很复杂。golang 属于 两级线程模型,是由 goroutine 实现的, goroutine 是 golang 最重要的特性之一,具有使用成本低、消耗资源低、能效高等特点,官方宣称原生 goroutine 并发成千上万不成问题. CSP(通信顺序进程)golang 中的并发模型。本片文档将带领读者深入理解 golang 的 goroutine 的工作方式。

stack 大小:

  • goroutine:2KB
  • 线程:8MB

线程切换需要陷入内核;goroutine 只需要 getcontext 和 setcontext,30+ 个寄存器的读合写,非常快速

coroutine 的工作方式

说到 goroutine,就不得不说一下 coroutine。goroutine 是coroutine 在 golang 中的实现和优化。golang 作者之一 Russ Cox 有实现轻量的 libtask ,400 行左右代码描述清楚了 coroutine 是怎么工作的。

建议亲自下载代码,看一下具体的实现逻辑, 需要比较简单的 c 基础

一个 coroutine 比较核心的工作包括:

  • task 管理, 包括创建,销毁,切换等
  • coroutine 调度算法

先看一个简单的使用例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// https://github.com/mixinlib/libtask/blob/651d7b69a4f7b10c798dcd544e6a25fd7505632c/primes.c#L12
void
primetask(void *arg)
{
	Channel *c, *nc;
	int p, i;
	c = arg;

	p = chanrecvul(c);
	if(p > goal)
		taskexitall(0);
	if(!quiet)
		printf("%d\n", p);
	nc = chancreate(sizeof(unsigned long), buffer);
	taskcreate(primetask, nc, 32768);
	for(;;){
		i = chanrecvul(c);
		if(i%p)
			chansendul(nc, i);
	}
}

void
taskmain(int argc, char **argv)
{
	int i;
	Channel *c;

	if(argc>1)
		goal = atoi(argv[1]);
	else
		goal = 100;
	printf("goal=%d\n", goal);

	c = chancreate(sizeof(unsigned long), buffer);
	taskcreate(primetask, c, 32768); // 创建 coroutine,开始执行任务 primetask
	for(i=2;; i++)
		chansendul(c, i);
}

会发现程序中没有常见的 c main 函数,实际是在 libtask 中实现了。整个程序是在一个线程中执行的,main 中创建了第一个 task,并启动 task 调度器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// https://github.com/mixinlib/libtask/blob/main/task.c#L353
static void
taskmainstart(void *v)
{
	taskname("taskmain");
	taskmain(taskargc, taskargv);
}

int
main(int argc, char **argv)
{
	struct sigaction sa, osa;

	memset(&sa, 0, sizeof sa);
	sa.sa_handler = taskinfo;
	sa.sa_flags = SA_RESTART;
	sigaction(SIGQUIT, &sa, &osa);

#ifdef SIGINFO
	sigaction(SIGINFO, &sa, &osa);
#endif

	argv0 = argv[0];
	taskargc = argc;
	taskargv = argv;

	if(mainstacksize == 0)
		mainstacksize = 256*1024;
	taskcreate(taskmainstart, nil, mainstacksize);
	taskscheduler(); // task 调度器启动
	fprint(2, "taskscheduler returned in main!\n");
	abort();
	return 0;
}

调度器的工作是从 runnable_queue 中获得一个可执行的任务,然后切换过去

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static void
taskscheduler(void)
{
	int i;
	Task *t;

	taskdebug("scheduler enter");
	for(;;){
		if(taskcount == 0)
			exit(taskexitval);
		t = taskrunqueue.head;
		if(t == nil){
			fprint(2, "no runnable tasks! %d tasks stalled\n", taskcount);
			exit(1);
		}
		deltask(&taskrunqueue, t);
		t->ready = 0;
		taskrunning = t;
		tasknswitch++;
		taskdebug("run %d (%s)", t->id, t->name);
		contextswitch(&taskschedcontext, &t->context);
//print("back in scheduler\n");
		taskrunning = nil;
		if(t->exiting){
			if(!t->system)
				taskcount--;
			i = t->alltaskslot;
			alltask[i] = alltask[--nalltask];
			alltask[i]->alltaskslot = i;
			free(t);
		}
	}
}

coroutine 是如何切换的呢?由计算机的工作原理知道,程序的执行是根据 pc 寄存器中的位置取指令执行的,执行 task 需要 stack 和寄存器。寄存器中的数据就是一个 coroutine 的 上下文,记录下来,set 成其他的 上下文的 寄存器的值,即可达到切换 coroutine 的目的。由于不同平台的寄存器不同,指令集不同,需要区分平台做不同的事情:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// linux x86 平台的指令
// https://github.com/mixinlib/libtask/blob/651d7b69a4f7b10c798dcd544e6a25fd7505632c/asm.S#L43
#ifdef NEEDX86CONTEXT
.globl SET
SET:
	movl	4(%esp), %eax

	movl	8(%eax), %fs
	movl	12(%eax), %es
	movl	16(%eax), %ds
	movl	76(%eax), %ss
	movl	20(%eax), %edi
	movl	24(%eax), %esi
	movl	28(%eax), %ebp
	movl	36(%eax), %ebx
	movl	40(%eax), %edx
	movl	44(%eax), %ecx

	movl	72(%eax), %esp
	pushl	60(%eax)	/* new %eip */
	movl	48(%eax), %eax
	ret

.globl GET
GET:
	movl	4(%esp), %eax

	movl	%fs, 8(%eax)
	movl	%es, 12(%eax)
	movl	%ds, 16(%eax)
	movl	%ss, 76(%eax)
	movl	%edi, 20(%eax)
	movl	%esi, 24(%eax)
	movl	%ebp, 28(%eax)
	movl	%ebx, 36(%eax)
	movl	%edx, 40(%eax)		 
	movl	%ecx, 44(%eax)

	movl	$1, 48(%eax)	/* %eax */
	movl	(%esp), %ecx	/* %eip */
	movl	%ecx, 60(%eax)
	leal	4(%esp), %ecx	/* %esp */
	movl	%ecx, 72(%eax)

	movl	44(%eax), %ecx	/* restore %ecx */
	movl	$0, %eax
	ret
#endif

// c 中的 Context
struct Context
{
	ucontext_t	uc;
};

到此,用户级 task( coroutine )切换/调度/运行工作基本可以正常工作了。

总结一下: coroutine 是单线程程序,可以串行的跑多个任务;多个任务通过一个 scheduler task 切换;切换的方式是通过替换cpu的寄存器实现的;切换的过程 os kernel 是不知道的; 由于 context switch 只是执行 30+ 个汇编指令,(os context switch 需要至少两次 context switch,以及一些状态维护,相比较来说)开销更小,cpu 的利用率会提高不少;使用同步的方式写出可以异步执行的代码,符合人的普遍思考方式。

channel 的工作方式

这一节内容和协程机理没有直接联系,但是因为channel总是伴随着goroutine出现,所以我们 顺便了解一下channel的原理也颇有好处。幸运的是,libtask 中也提供了channel的参考实现.

channel 分为有 buf 和 无 buf channel

对于无 buf 的 channel

某一个 task 执行 chansendul 会直接将当前 task 加入 channel 的 send 队列中,然后触发一次 context switch。由于此时 channel 的 task 已经不在 taskrunqueue 中,发送数据到 channel 的 task 不会再被 taskscheduler 调度到,直到 chanrecvul task 被调度,且消费 channel 中的数据,此时 发送端 task 重新被放到可执行队列中。

对于 有 buf 的 channel

某一个 task 执行 chansendul,如果 channel buf 没有满,则会将数据复制到 channel buf 中,task 会继续执行。 如果 channel buf 已满,会将 send task 进 channel send 等待队列,并让出 cpu。 chanrecvul task 接受数据时候,如果 channel buf 不为空,则会直接消费 buf 中的数据;如果 buf 为空,则会 加入 channel 的 recv 等待队列,并让出 cpu,直到有新的数据被send 到 channel 为止。

golang goroutine 工作方式

了解了传统 coroutine 的实现方式,我们来看一下 golang 的 goroutine 的工作方式.

golang 实现了一种叫做 工作窃取任务调度.

具体的功能介绍见: [翻译]The Go scheduler 具体的更详细的源码分析见:Go 调度器

基本的组成元素和工作方式

/media/img/golang/scheduler-syscall.jpg

G、P、M

  • G: 表示 Goroutine,每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。
  • P: Processor,表示逻辑处理器, 对 G 来说,P 相当于 CPU 核,G 只有绑定到 P(在 P 的 local runq 中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量),P 的数量由用户设置的 GOMAXPROCS 决定,但是不论 GOMAXPROCS 设置为多大,P 的数量最大为 256。
  • M: Machine,OS 线程抽象,代表着真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从 Global 队列、P 的 Local 队列以及 wait 队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础,M 的数量是不定的,由 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。

任务队列共有两种,一种是 p 上的本地队列,另一种是 global queue。当使用 go 关键词启动一个新的 goroutine 的时候,这既可能是全局的运行队列,也可能是处理器本地的运行队列。 runtime.runqput 可知,

  1. 当 next 为 true 时,会将 Goroutine 设置到处理器的 runnext 作为下一个处理器执行的任务;
  2. 当 next 为 false 并且本地队列还有剩余空间,Goroutine 会被放到本地运行队列上;
  3. 当处理器的本地运行队列已经没有剩余空间时就会把本地队列中的一部分 Goroutine 和待加入的 Goroutine 通过 runtime.runqputslow 添加到调度器持有的全局运行队列上;

调度可执行任务的时候。 runtime.schedule 函数会从下面几个地方查找待执行的 Goroutine:

  1. 为了保证公平,当全局运行队列中有待执行的 Goroutine 时,通过 schedtick 保证有一定几率会从全局的运行队列中查找对应的 Goroutine;
  2. 从处理器本地的运行队列中查找待执行的 Goroutine;
  3. 如果前两种方法都没有找到 Goroutine,会通过 runtime.findrunnable 进行阻塞地查找 Goroutine.

如果处理器没有任务可处理,它会按以下规则来执行,直到满足某一条规则:

  • 从本地队列获取任务
  • 从全局队列获取任务
  • 从网络轮询器获取任务
  • 从其它的处理器的本地队列窃取任务

基于 goroutine 工作方式,有哪些优点点缺点,缺点如何规避

优缺点

优点:

  1. 由于 golang goroutine 实现的 scheduler 比较简单,在大量 goroutine 调度时候性能消耗相比较 os thread 的调度来说会低至少一个数量级。

缺点:

  1. scheduler 的实现细节非常多,复杂
  2. 用户想 hack 自己的线程模型基本不可能 (当然 golang 的 scheduler 已经足够优秀了)

goroutine pool

10M(百万) goroutine 执行轻量操作会出现瓶颈,需要考虑 goroutine pool 来做优化了

golang 有 gc,goroutine 的创建与删除还是需要申请内存的,如果可以,尽量使用 pool,让 goroutine调度压力尽量小一点,用户程序复用 goroutine。可以使用 goroutine pool 实现 goroutine 的复用。同时不好的 pool 实现也会让一把锁破坏掉好的性能。是否使用,需要视情况而定.

ref