Skip to the content.

进程/线程/协程

说到GPM,进程、线程、协程是绕不开的话题。web服务器经历了从进程处理请求模型(代表作php),再到用线程处理请求模型(代表作Java、.Net),后来出现风靡一时的Node.js用“事件”的方式处理请求模型,现如今golang带起来的协程,请求处理模型一代比一代先进,把服务器资源充分的利用更在有意义的事情上,使服务性能得到一次又一次提升。

进程:进程是系统进行资源分配的基本单位,有独立的内存空间,进程的管理模块为PCB。

线程:线程是 CPU 调度和分派的基本单位,线程依附于进程存在,每个线程会共享父进程的资源,线程的管理模块为TCB。

CPU在上下文切换时,PCB消耗的资源远大于TCB,所以诞生了用线程处理请求的模型,绕开了PCB。 一个线程的最小占用内存是2M,在使用线程模型并发处理请求时,CPU大量的时间片会消耗在TCB上。想要提高服务器并发处理的性能,就要绕开TCB。

Node.js的思路是在单进程上利用事件驱动+非阻塞IO来处理请求,这种模型中上下文的切换是在单线程下完成的,也就没有了CPU时间片在PCB和TCB上的消耗。缺陷是难以利用CPU多核,加上JavaScript的历史包袱,最终也只是风靡一时。

协程:是一种用户态的轻量级线程,最小占用内存只有2kb,协程的调度完全由用户控制,没有内核的开销,减少了TCB对CPU时间片的开销。

虽然其他语言(python和php-swoole的co-routine)也逐渐引入了协程库的概念,但在协程的调度上,golang是在语言级别实现,用更少的CPU指令完成了协程的调度,这个调度也就是我们现在常叫的G-P-M模型。

go调度器历史

go的调度器模型也是经历了多次迭代才有现在优异性能

GPM模型

GPM调度器

  1. 全局队列(Global Queue):存放等待运行的G
  2. P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G’时,G’优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列
  3. P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个
  4. M:Go语言本身是限定M的最大量是10000,runtime/debug包中的SetMaxThreads函数来设置。如果有一个M阻塞,会创建一个新的M。如果有M空闲,那么就会回收和睡眠

schedule 循环调度

线程M想运行任务就得获取G,调度器启动之后,Go 语言运行时会调用 runtime.mstart 以及 runtime.mstart1,前者会初始化 g0 的 stackguard0 和 stackguard1 字段,后者会初始化线程并调用 runtime.schedule入调度循环

  1. 首先从P的本地队列获取G
  2. 每调度61次就会主动去全局队列拿g,避免全局队列饥饿
  3. 本地P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列
  4. 如果全局队列为空,就会从其他P的本地队列偷一半放到自己P的本地队列
  5. M运行G,G执行之后,M会从P获取下一个G,不断重复下去

部分代码:

func schedule() {
	_g_ := getg()

top:
	var gp *g
	var inheritTime bool

	if gp == nil {
		if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
			lock(&sched.lock)
			gp = globrunqget(_g_.m.p.ptr(), 1)
			unlock(&sched.lock)
		}
	}
	if gp == nil {
		gp, inheritTime = runqget(_g_.m.p.ptr())
	}
	if gp == nil {
		gp, inheritTime = findrunnable()
	}

	execute(gp, inheritTime)
}

由 runtime.execute 执行获取的 Goroutine,做好准备工作后,它会通过 runtime.gogo 将 Goroutine 调度到当前线程上,当 Goroutine 中运行的函数返回时,程序会跳转到 runtime.goexit 所在位置执行该函数

TEXT runtime·goexit(SB),NOSPLIT,$0-0
	CALL	runtime·goexit1(SB)

func goexit1() {
	mcall(goexit0)
}

func goexit0(gp *g) {
	_g_ := getg()

	casgstatus(gp, _Grunning, _Gdead)
	gp.m = nil
	...
	gp.param = nil
	gp.labels = nil
	gp.timer = nil

	dropg()
	gfput(_g_.m.p.ptr(), gp)
	schedule()
}

在最后 runtime.goexit0 会重新调用 runtime.schedule 触发新一轮的 Goroutine 调度,Go 语言中的运行时调度循环会从 runtime.schedule 开始,最终又回到 runtime.schedule,我们可以认为调度循环永远都不会返回

调度器的设计策略

“go func()”经历了什么过程

  1. 我们通过 go func()来创建一个goroutine;
  2. 有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先 保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中
  3. G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地列列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行
  4. 一个M调度G执行的过程是一个循环机制
  5. 当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P
  6. 当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中。

调度器的生命周期

调度器的生命周期

参考文献

Golang的协程调度器原理及GMP设计思想
并发编程-调度器