Golang的学习(1)——调度管理

w1n9 Lv1

参考文章:

万字长文深入浅出 Golang Runtime - 知乎

Go三关-典藏版]Golang调度器GPM原理与调度全分析 - 知乎

深入理解Go调度原理和实现-腾讯云开发者社区-腾讯云

深入理解 Go 语言的调度原理 - daligh - 博客园

深入golang runtime的调度(这篇强烈推荐,讲得很细)

虾敏四把刀 - 博客园

(本文以学习总结记录和知识分享为主,代码和图片均摘自参考文章。如有侵权请联系)

前置知识

进程:计算机中一个正在运行的程序实例,拥有独立的内存空间

线程:进程的基本单元。一个进程至少会有一个主线程,一个进程中多个线程共享进程资源

协程:线程又分用户级线程和内核级线程,用户级线程又叫做协程。协程更轻量,并且是在用户层进行协作式调度

并发:多个任务交替进行,但并不一定是同时进行。并发分为同步和互斥

并行:多个任务同时进行

区别如图:

并发和并行
并发和并行

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

img
img

总结补充一下就是,他类似(只是在功能上类似,go没有虚拟机这个说法,我感觉本质上是一个包)于go的虚拟机,给程序提供一个运行环境,只不过这个核心“环境”是和用户代码一起被打包到程序里面的。待会要说的调度管理就离不开这个runtime。

Goroutine

goroutine是go中的协程,也就是一个在用户层面可以被调度和执行的任务单元。它十分轻量,一般只有几KB的大小,因此在有限的内存的空间里就可以执行很多个goroutine。goroutine记录着执行流,记录当下运行到哪一个函数的哪里,并且当阻塞发生时,它拥有能够随时切换出或者切换回这个执行流的功能,然后由runtime将其它goroutine调度到其他线程上执行,并且这种切换上下文的速度是远远快于线程在内核中的切换

下面是实现goroutine的结构体和切换函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//协程结构体
typedef g struct{
goid int64 //协程id
states uint32 //协程状态
type stack struct{
lo uint32 //协程栈的最低位
hi uint32 //协程栈的最高位
}
sched gobuf //保存与goroutine运行位置相关的寄存器和指针
startaddr uintptr //程序起始位置
}

typedef gobuf struct{ //用于保存切换上下文的信息
sp uintptr //栈顶指针
bp uintptr //栈底指针
pc uintptr //指向程序当前运行的位置,类似于ip
g guintptr //就是所谓的goroutine
ctxt unsafe.Pointer //垃圾回收时使用
ret uintptr
lr uintptr //保存系统调用时的返回值
}

(当然实际比这复杂得多,这里我先暂时只关注它主要的部分)

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
//切换函数mcall实现了保存某个goroutine,切换到g0及其栈,并调用fn函数,其参数就是被保存的goroutine指针
TEXT runtime.mcall(SB), NOSPLIT, $0-8
MOVQ fn + 0(FP), DI

get_tls(CX) //从tls中获取g
MOVQ g(CX), AX
MOVQ 0(SP), BX
MOVQ BX, (g_sched + gobuf_pc)(AX)
LEAQ fn + 0(FP), BX
MOVQ BX, (g_sched + gobuf_sp)(AX)
MOVQ AX, (g_sched + gobuf_g)(AX)
MOVQ BP (g_sched + gobuf_bp)(AX)

//恢复函数gogo实现了从g0切换到某个goroutine,执行关联函数
TEXT runtime.gogo(SB), NOSPLIT, $16-8
MOVQ buf + 0(FP), BX
MOVQ gobuf_g(BX), DX //DX存放gobuf结构
MOVQ 0(DX), CX //保证该协程为有效值
get_tls(CX)
MOVQ DX, g(CX)
MOVQ gobuf_sp(BX), SP //恢复SP
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP
MOVQ gobuf_pc(BX), BX //恢复pc
JMP BX

当我们想要新建一个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
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
type p struct {
lock mutex

// id也是allp的数组下标
id int32
status uint32
// 单向链表,指向下一个P的地址
link puintptr
// 每调度一次加1
schedtick uint32
// 每一次系统调用加1
syscalltick uint32
sysmontick sysmontick
// 回链到关联的m
m muintptr
mcache *mcache
racectx uintptr

deferpool [5][]*_defer /
deferpoolbuf [5][32]*_defer


// goroutine的ID的缓存
goidcache uint64
goidcacheend uint64

// 可运行的goroutine的队列
runqhead uint32
runqtail uint32
runq [256]guintptr
// 下一个运行的g,优先级最高
runnext guintptr

gfree *g
gfreecnt int32

sudogcache []*sudog
sudogbuf [128]*sudog

...
}

下面贴一张很经典的图来看一下GPM模型运转的大概逻辑:

img
img

稍微解释一下,首先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核上同时运行,这样就可以充分利用多核。

启动过程

如图,主要经过以下流程:

0
0

Go 进程的启动是通过汇编代码进行的,入口函数在asm_amd64.s这个文件中的runtime.rt0_go部分代码

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
// runtime·rt0_go
// 程序刚启动的时候必定有一个线程启动(主线程)
// 将当前的栈和资源保存在g0
// 将该线程保存在m0

get_tls(BX)
LEAQ runtime·g0(SB), CX
MOVQ CX, g(BX)
LEAQ runtime·m0(SB), AX

// m0和g0互相绑定
MOVQ CX, m_g0(AX)
MOVQ AX, g_m(CX)
CALL runtime·args(SB)
// os初始化, os_linux.go
CALL runtime·osinit(SB)
// 调度系统初始化, proc.go
CALL runtime·schedinit(SB)

// 创建一个goroutine,然后开启执行程序
MOVQ $runtime·mainPC(SB), AX
PUSHQ AX
PUSHQ $0
CALL runtime·newproc(SB)
POPQ AX
POPQ AX

// 启动线程,并且启动调度系统
CALL runtime·mstart(SB)

下面详细讲讲每一步的函数干了什么

  • 在程序启动开始,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
    52
     func 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
    57
    func 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
    36
    func 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
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
func schedule() {
_g_ := getg()
...
top:
// 如果当前GC需要停止整个世界(STW), 则调用gcstopm休眠当前的M
if sched.gcwaiting != 0 {
// 为了STW,停止当前的M
gcstopm()
// STW结束后回到 top
goto top
}
...
var gp *g
var inheritTime bool
...
if gp == nil {
// 每隔61次调度,尝试从全局队列种获取G
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 {
// 从p的本地队列中获取
gp, inheritTime = runqget(_g_.m.p.ptr())
if gp != nil && _g_.m.spinning {
throw("schedule: spinning with local work")
}
}
if gp == nil {
// 想尽办法找到可运行的G,找不到就不用返回了
gp, inheritTime = findrunnable()
}
...
// 找到了g,那就执行g上的任务函数
execute(gp, inheritTime)
}

整个过程则是在寻找可执行的g,然后调用execute函数来执行g上的任务函数。上面提到的main goroutine就是第一个被寻找到的可执行g,也就是m0的g0,下面来看一下main goroutine的执行函数runtime.main做了什么

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
// The main goroutine.
func main() {
// 获取 main goroutine
g := getg()

...

// 在系统栈上运行 sysmon
systemstack(func() {
// 分配一个新的m,运行sysmon系统后台监控
// (定期垃圾回收和调度抢占)
newm(sysmon, nil)
})

...

// 确保是主线程
if g.m != &m0 {
throw("runtime.main not on m0")
}
// runtime 内部 init 函数的执行,编译器动态生成的。
runtime_init()
...
// gc 启动一个goroutine进行gc清扫
gcenable()
...
// 执行init函数,编译器动态生成的,
// 包括用户定义的所有的init函数。
fn := main_init
fn()
...
// 真正的执行main func in package main
fn = main_main
fn()
...
// 退出程序
exit(0)
// 为何这里还需要for循环?
// 下面的for循环一定会导致程序崩掉,这样就确保了程序一定会退出
for {
var x *int32
*x = 0
}
}

其中,间接执行的main_main函数就是我们用户代码中写的那个func main(){}

到此,从程序启动到用户的main函数执行完毕的整个过程算是理了一遍

那如果用户在代码中又利用关键字go来新建了一些协程,调度过程又是怎样的呢

调度策略和时机

下面这张图比较直观地体现了整个系统如何调度执行新建的G

img
img

1
2
3
4
5
6
大概流程则为: 1. go func() 语气创建G。
2. 将G放入P的本地队列(或者平衡到全局队列)。
3. 唤醒或新建M来执行任务。
4. 进入调度循环
5. 尽力获取可执行的G,并执行
6. 清理现场并且重新进入调度循环

下面这张图片的类比我个人觉得十分形象:

image-20250725120332480
image-20250725120332480

总结

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 进行许可。
评论