Golang的学习(1)——调度管理
参考文章:
Go三关-典藏版]Golang调度器GPM原理与调度全分析 - 知乎
深入理解 Go 语言的调度原理 - daligh - 博客园
深入golang runtime的调度(这篇强烈推荐,讲得很细)
(本文以学习总结记录和知识分享为主,代码和图片均摘自参考文章。如有侵权请联系)
前置知识
进程:计算机中一个正在运行的程序实例,拥有独立的内存空间
线程:进程的基本单元。一个进程至少会有一个主线程,一个进程中多个线程共享进程资源
协程:线程又分用户级线程和内核级线程,用户级线程又叫做协程。协程更轻量,并且是在用户层进行协作式调度
并发:多个任务交替进行,但并不一定是同时进行。并发分为同步和互斥
并行:多个任务同时进行
区别如图:

Golang Runtime(运行时)(图例介绍的很清楚):

总结补充一下就是,他类似(只是在功能上类似,go没有虚拟机这个说法,我感觉本质上是一个包)于go的虚拟机,给程序提供一个运行环境,只不过这个核心“环境”是和用户代码一起被打包到程序里面的。待会要说的调度管理就离不开这个runtime。
Goroutine
goroutine是go中的协程,也就是一个在用户层面可以被调度和执行的任务单元。它十分轻量,一般只有几KB的大小,因此在有限的内存的空间里就可以执行很多个goroutine。goroutine记录着执行流,记录当下运行到哪一个函数的哪里,并且当阻塞发生时,它拥有能够随时切换出或者切换回这个执行流的功能,然后由runtime将其它goroutine调度到其他线程上执行,并且这种切换上下文的速度是远远快于线程在内核中的切换
下面是实现goroutine的结构体和切换函数:
1 | //协程结构体 |
(当然实际比这复杂得多,这里我先暂时只关注它主要的部分)
1 | //切换函数mcall实现了保存某个goroutine,切换到g0及其栈,并调用fn函数,其参数就是被保存的goroutine指针 |
当我们想要新建一个goroutine时,就用关键词go
1 | go func1(arg1 type1,arg2 type2){....}(a1,a2) |
GPM调度模型
了解了goroutine,就可以正式开始了解他的调度机制:GPM模型。G即为Goroutine,P(Processor)为逻辑调度器,M(Machine)为逻辑处理器,也叫做线程,goroutine刚刚说过了,下面详细说一下后两者
M:处于内核中的线程,同时也是实际工作的执行者。M的数量是有限制的,一般内核默认最大数量是10000。当然,runtime中的SetMaxThreads函数也会设置M的最大数量。然后M的数量一般比P多
P:Go1.0以后的版本才推出的概念。是用来调度goroutine,与线程M匹配让线程执行的调度器。P有一个本地队列来存放待运行的G,G的数量最多不超过256个。所有的P都是在程序启动后才创建,最大数量由环境变量$GOMAXPROCS或者runtime里的GOMAXPROCS()决定。下面展示一下P的结构,主要保存着可运行的G的本地队列:
1 | type p struct { |
下面贴一张很经典的图来看一下GPM模型运转的大概逻辑:

稍微解释一下,首先G就是我们要执行的任务,当我们用go func()创建一个G时,他被保存到本地队列或者全局队列中,一般新建一个G他会优先被保存到本地队列中,本地队列存放数量最多不超过256个,如果队列满了就把本地队列中的一半放入全局队列中,然后执行者M就要从P中获取一个G来执行
work stealing机制
P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。
hand off机制
当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。
总的来说,GPM模型的主要思想就是协程对线程的复用,这比频繁的创建销毁线程要节省很多时间和资源,并且与传统的M:N模型相比,GPM多了一个处理器(P)的概念,因此用户就不需要管理用户线程和内核线程之间的映射,一切对goroutine的调度都在用户层由runtime实现。此外还有一点,Go的一个强项就是天生支持并发,并发包含了并行的实现,并行度由GOMAXPROCS来控制,默认等于cpu数,当GOMAXPROCS大于1时,就最多有GOMAXPROCS个线程同时处于运行状态,这些线程可能分布在多个CPU核上同时运行,这样就可以充分利用多核。
启动过程
如图,主要经过以下流程:
Go 进程的启动是通过汇编代码进行的,入口函数在asm_amd64.s这个文件中的runtime.rt0_go部分代码
1 | // runtime·rt0_go |
下面详细讲讲每一步的函数干了什么
在程序启动开始,runtime会创建一个初始线程m0,主要负责执行初始化操作和启动第一个G;g0则是每次启动一个M都会创建的第一个goroutine,g0不指向任何一个可执行的函数,只是在在调度或系统调用时会使用g0的栈空间。开始的第一步则是创建最初的m0和g0,并把2者关联。
调用runtime.osinit函数。这个函数主要就是涉及一些操作系统相关的初始化操作,比如:设置M的数量,设置线程的栈大小,进行必要的系统调用等。不同的系统具体实现的操作不同,下面展示一种在linux平台上的函数实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// 在 Linux 上的初始化
func osinit() {
// 初始化 CPU 核心数
gomaxprocs()
// 创建操作系统线程(M)
createInitialThreads()
// 初始化 Linux 操作系统的特定资源
initLinuxResources()
// 调用其他操作系统特定的初始化代码
initPlatformSpecific()
}
func initLinuxResources() {
// Linux 上可能需要执行一些操作系统特定的初始化,如设置文件描述符、网络套接字等
// 示例:设置文件描述符、网络连接、I/O 操作等
setupFileDescriptors()
setupNetworkIO()
}调用runtime·schedinit函数来初始化调度系统,进行调度器P的初始化,并将M0与P绑定
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
52func schedinit() {
................
// g0
_g_ := getg()
if raceenabled {
_g_.racectx, raceprocctx0 = raceinit()
}
// 最多启动10000个工作线程
sched.maxmcount = 10000
worldStopped()
moduledataverify()
// 初始化协程堆栈,包括专门分配小栈的stackpool和分配大栈的stackLarge
stackinit()
// 整个堆内存的初始化分配
mallocinit()
fastrandinit() /
// 初始化m0
mcommoninit(_g_.m, -1)
cpuinit()
alginit()
modulesinit()
typelinksinit()
itabsinit()
sigsave(&_g_.m.sigmask)
initSigmask = _g_.m.sigmask
if offset := unsafe.Offsetof(sched.timeToRun); offset%8 != 0 {
println(offset)
throw("sched.timeToRun not aligned to 8 bytes")
}
goargs()
goenvs()
parsedebugvars()
gcinit()
// 这部分是初始化p,
// cpu有多少个核数就初始化多少个p
lock(&sched.lock)
sched.lastpoll = uint64(nanotime())
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}
unlock(&sched.lock)
worldStarted()
}调用runtime.newproc函数来创建主协程(main goroutine),他的任务函数就是runtime.main,建好后插入与m0绑定的那个p的本地队列里
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
55
56
57func newproc(siz int32, fn *funcval) {
// 获取fn函数的参数起始地址,可参考上例中的printAdd,sys.PtrSize的值是8。
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
// 获取一个g(m0.g0)
gp := getg()
// 调用者的pc,也就是执行完此函数返回调用者时的下一条指令地址,本例中是 POPQ AX
pc := getcallerpc()
systemstack(func() {
newproc1(fn, argp, siz, gp, pc)
})
}
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) {
_g_ := getg()
......
acquirem() // 禁止抢占
siz := narg
siz = (siz + 7) &^ 7 // 使siz为8的整数倍。&^为双目运算符,将运算符左边数据相异的保留,相同位清零。
......
_p_ := _g_.m.p.ptr()
newg := gfget(_p_) // 获取一个g,下有分析
if newg == nil {
newg = malg(_StackMin) // 分配一个新g
casgstatus(newg, _Gidle, _Gdead) // 更改状态
allgadd(newg) // 加入到allgs切片中
}
......
// 调整newg的栈顶指针
totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize beyond frame
totalSize += -totalSize & (sys.SpAlign - 1)
sp := newg.stack.hi - totalSize
spArg := sp
......
if narg > 0 {
memmove(unsafe.Pointer(spArg), argp, uintptr(narg)) // 将参数从调用newproc的函数栈帧中copy到新的g栈帧中。
......
}
// newg.sched存储的是调度相关的信息,调度器要将这些信息装载到cpu中才能运行goroutine。
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched)) // 将newg.sched结构体清零
newg.sched.sp = sp // 栈顶
newg.stktopsp = sp
// 此处只是暂时借用pc属性存储 runtime.goexit + 1 位置的地址。在gostartcallfn会用到。
newg.sched.pc = funcPC(goexit) + sys.PCQuantum
newg.sched.g = guintptr(unsafe.Pointer(newg)) // 存储newg指针
gostartcallfn(&newg.sched, fn) // 将函数与g关联起来。下有分析。
......
casgstatus(newg, _Gdead, _Grunnable) // 更改状态
......
runqput(_p_, newg, true) // 存储到运行队列中。
// 初始化时不会执行,mainStarted 在 runtime.main 中设置为 true
if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted {
wakep()
}
releasem(_g_.m)
}概括讲就是:获取g->复制参数->设置调度属性->放入队列等调度。
调用runtime.mstart函数,来启动m。
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
36func mstart() {
_g_ := getg()
// 在启动阶段,_g_.stack早就完成了初始化,所以osStack是false,下面被省略的也不会执行。
osStack := _g_.stack.lo == 0
......
_g_.stackguard0 = _g_.stack.lo + _StackGuard
_g_.stackguard1 = _g_.stackguard0
mstart1()
......
mexit(osStack)
}
//mstart函数没什么太多工作,主要是为了调用mstart1
func mstart1() {
_g_ := getg()
if _g_ != _g_.m.g0 {
throw("bad runtime·mstart")
}//调用 _g_ := getg()获取g,然后检查该g是不是g0,如果不是 g0,直接抛出异常
save(getcallerpc(), getcallersp()) // 保存调用mstart1的函数(mstart)的 pc 和 sp
asminit() // 空函数
minit() // 信号相关
if _g_.m == &m0 { // 初始化时会执行这里,也是信号相关
mstartm0()
}
if fn := _g_.m.mstartfn; fn != nil { // 初始化时 fn = nil,不会执行这里
fn()
}
if _g_.m != &m0 { // 不是m0的话,没有p。绑定一个p
acquirep(_g_.m.nextp.ptr())
_g_.m.nextp = 0
}
schedule()//调度函数
}
调度逻辑
schedule函数则为真正的调度函数
1 | func schedule() { |
整个过程则是在寻找可执行的g,然后调用execute函数来执行g上的任务函数。上面提到的main goroutine就是第一个被寻找到的可执行g,也就是m0的g0,下面来看一下main goroutine的执行函数runtime.main做了什么
1 | // The main goroutine. |
其中,间接执行的main_main函数就是我们用户代码中写的那个func main(){}
到此,从程序启动到用户的main函数执行完毕的整个过程算是理了一遍
那如果用户在代码中又利用关键字go来新建了一些协程,调度过程又是怎样的呢
调度策略和时机
下面这张图比较直观地体现了整个系统如何调度执行新建的G

1 | 大概流程则为: 1. go func() 语气创建G。 |
下面这张图片的类比我个人觉得十分形象:

总结
Go相较于其它语言的一个优势就在于它轻量级协程Goroutine与调度器的结合,提供了高效、简洁的并发处理机制。学习这个调度逻辑也帮助我在go语言逆向中,能够清楚地知道从程序启动到main函数调用中途经历了什么,以及该如何去寻找主要的函数逻辑。整个Go的调度管理以及runtime的机制还有很多其他的细节和知识,以后再来接着探索
- 标题: Golang的学习(1)——调度管理
- 作者: w1n9
- 创建于 : 2025-07-25 12:15:26
- 更新于 : 2026-01-14 19:48:35
- 链接: https://vv1n9.github.io/2025/07/25/Golang八股学习(1)——调度管理/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。