13.Golang 调度器源码分析(一、数据结构、调度器启动与创建协程)

Golang 调度器源码分析(一、数据结构、调度器启动与创建协程)

注意当前go版本代码为1.23

介绍

关于Golang的协程调度器原理及GMP设计思想可以通过Golang的协程调度器原理及GMP设计思想进行了解。

Go 语言调度器三个重要组成部分 — 线程 M、Goroutine G 和处理器 P

  1. G — 表示 Goroutine,它是一个待执行的任务;
  2. M — 表示操作系统的线程,它由操作系统的调度器调度和管理;
  3. 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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
type g struct {
// 栈管理参数
stack stack // 描述实际栈内存范围:[stack.lo, stack.hi),偏移量对 runtime/cgo 可见
stackguard0 uintptr // 栈增长检查的指针(通常为 stack.lo+StackGuard),可设为 StackPreempt 触发抢占
stackguard1 uintptr // 系统栈(如g0、gsignal)的栈增长检查指针,其他协程设为 ~0 触发 morestackc 崩溃

// 异常处理
_panic *_panic // 当前最内层的 panic 结构(偏移量对 liblink 可见)
_defer *_defer // 当前最内层的 defer 结构

// 调度与上下文
m *m // 当前绑定的 M(操作系统线程),偏移量对 arm liblink 可见
sched gobuf // 协程切换时的寄存器上下文(SP、PC、BP 等)
syscallsp uintptr // 若状态为 Gsyscall,保存 sched.sp 供 GC 使用
syscallpc uintptr // 若状态为 Gsyscall,保存 sched.pc 供 GC 使用
syscallbp uintptr // 若状态为 Gsyscall,保存 sched.bp 用于栈回溯
stktopsp uintptr // 栈顶期望的 SP 值,用于回溯检查

// 通用参数传递
param unsafe.Pointer // 多场景临时指针参数:
// 1. Channel 操作唤醒时指向 sudog
// 2. GC 辅助完成信号
// 3. 调试调用传递参数(禁止闭包时)
// 4. panic 恢复时保存 defer 状态

// 状态与锁
atomicstatus atomic.Uint32 // 协程原子状态(如 _Grunnable、_Gwaiting)
stackLock uint32 // 栈扫描/性能分析锁(未来可能合并到 atomicstatus)
goid uint64 // 协程唯一 ID
schedlink guintptr // 调度链表指针,指向下一个待运行的 G
waitsince int64 // 协程进入阻塞的近似时间戳
waitreason waitReason // 阻塞原因(若状态为 Gwaiting)

// 抢占控制
preempt bool // 抢占标志(与 stackguard0=stackpreempt 冗余)
preemptStop bool // 抢占时是否转换为 _Gpreempted 状态(否则仅调度)
preemptShrink bool // 是否在同步安全点收缩栈

// 栈与内存管理
asyncSafePoint bool // 是否停在异步安全点(栈帧无精确指针信息)
paniconfault bool // 非法地址访问时 panic 而非崩溃
gcscandone bool // 栈是否已扫描完成(受 _Gscan 状态保护)
throwsplit bool // 禁止栈分裂
activeStackChans bool // 栈中有未锁定的 channel(栈复制需加锁)
parkingOnChan atomic.Bool // 是否即将在 channel 上停车(影响栈收缩)

// GC 与追踪
inMarkAssist bool // 是否在 GC 标记辅助阶段
gcAssistBytes int64 // GC 辅助分配信用(正数免辅助,负数需扫描)

// 协程控制
coroexit bool // 协程退出参数(用于 coroswitch_m)
lockedm muintptr // 锁定此 G 的 M(若在系统调用中)
timer *timer // time.Sleep 缓存计时器
selectDone atomic.Uint32 // select 操作是否已有结果

// 其他
// 忽略......
}


type gobuf struct {
// The offsets of sp, pc, and g are known to (hard-coded in) libmach.
//
// ctxt is unusual with respect to GC: it may be a
// heap-allocated funcval, so GC needs to track it, but it
// needs to be set and cleared from assembly, where it's
// difficult to have write barriers. However, ctxt is really a
// saved, live register, and we only ever exchange it between
// the real register and the gobuf. Hence, we treat it as a
// root during stack scanning, which means assembly that saves
// and restores it doesn't need write barriers. It's still
// typed as a pointer so that any other writes from Go get
// write barriers.
sp uintptr
pc uintptr
g guintptr
ctxt unsafe.Pointer
ret uintptr
lr uintptr
bp uintptr // for framepointer-enabled architectures
}
GO

结构体 runtime.gatomicstatus 字段存储了当前 Goroutine 的状态。除了几个已经不被使用的以及与 GC 相关的状态之外,Goroutine 可能处于以下 9 种状态:

状态 描述
_Gidle 刚刚被分配并且还没有被初始化
_Grunnable 没有执行代码,没有栈的所有权,存储在运行队列中
_Grunning 可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P
_Gsyscall 正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
_Gwaiting 由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
_Gdead 没有被使用,没有执行代码,可能有分配的栈
_Gcopystack 栈正在被拷贝,没有执行代码,不在运行队列上
_Gpreempted 由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
_Gscan GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在

虽然 Goroutine 在运行时中定义的状态非常多而且复杂,但是我们可以将这些不同的状态聚合成三种:等待中、可运行、运行中,运行期间会在这三种状态来回切换:

  • 等待中:Goroutine 正在等待某些条件满足,例如:系统调用结束等,包括 _Gwaiting_Gsyscall_Gpreempted 几个状态;
  • 可运行:Goroutine 已经准备就绪,可以在线程运行,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间,即 _Grunnable
  • 运行中:Goroutine 正在某个线程上运行,即 _Grunning

Goroutine 的常见状态迁移

上图展示了 Goroutine 状态迁移的常见路径,其中包括创建 Goroutine 到 Goroutine 被执行、触发系统调用或者抢占式调度器的状态迁移过程。

M

Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行。

在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数,我们也可以在程序中使用 runtime.GOMAXPROCS 来改变最大的活跃线程数。

Go 语言会使用私有结构体 runtime.m 表示操作系统线程,这个结构体包含了几十个字段,这里介绍几个主要的字段:

1
2
3
4
5
g0      *g     // 调度栈 goroutine。每个 m 都拥有一个 g0,它是一个特殊的 goroutine,用于执行调度和 runtime 内部任务。g0 的栈(m->stack)不是用户 goroutine 的栈,而是 m 在执行调度代码时使用的栈。
curg *g // 当前正在 m 上运行的 goroutine。如果 m 没有运行任何 goroutine,则为 nil。
p puintptr // 附加到 m 的 P (Processor)。P 是 Go 调度器的处理器,负责运行 goroutine。如果 m 没有执行 Go 代码,则 p 为 nil (例如,在 syscall 或 idle 时)。
nextp puintptr // 下一个要附加到 m 的 P。用于 P 的迁移和负载均衡。
oldp puintptr // 在执行 syscall 之前附加到 m 的 P。当 syscall 返回后,P 会被恢复。
AWK

P

调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源,提高线程的利用率。

因为调度器在启动时就会创建 GOMAXPROCS 个处理器,所以 Go 语言程序的处理器数量一定会等于 GOMAXPROCS,这些处理器会绑定到不同的内核线程上。

runtime.p 是处理器的运行时表示,作为调度器的内部实现,它包含的字段也非常多,这里不一样介绍,我们主要关注处理器中的线程和运行队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type p struct {
id int32
status uint32 // P 的状态,取值可以是 pidle (空闲), prunning (运行中), psyscall (系统调用中), pgcstop (GC 停止中), pdead (已死亡) 等常量。
m muintptr // 反向链接到关联的 m (Machine, 操作系统线程)。如果 P 处于空闲状态 (pidle),则 m 为 nil。
mcache *mcache // mcache (M 缓存)。每个 P 都有一个 mcache,用于快速分配小对象,减少对全局堆锁的竞争。
pcache pageCache // pcache (P 缓存)。每个 P 都有一个 pageCache,用于缓存 span 页,进一步优化内存分配性能。
// 可运行 goroutine 队列。无锁访问。
runqhead uint32 // 可运行队列的头部索引。
runqtail uint32 // 可运行队列的尾部索引。
runq [256]guintptr // 可运行队列的环形缓冲区,存储等待运行的 goroutine 的 gptr。
// runnext, 如果非 nil,表示当前 G 准备好的一个可运行 G,应该优先于 runq 中的 goroutine 运行。
// 如果当前 G 的时间片还有剩余,它会继承剩余的时间片。如果一组 goroutine 锁定在一个
// 通信-等待模式中,这将作为一个单元调度这组 goroutine,并消除 (潜在的大量) 调度
// 延迟,否则会因将准备好的 goroutine 添加到运行队列的末尾而产生延迟。
//
// 注意,虽然其他 P 可以原子地 CAS 将其设置为零,但只有所有者 P 可以 CAS 将其设置为有效的 G。
runnext guintptr // 下一个要运行的 goroutine 的 gptr。当一个 goroutine 使用 `go` 关键字创建新的 goroutine 时,新的 goroutine 可能会被设置为 runnext,以便立即执行,提高调度效率,尤其是在 goroutine 之间有紧密协作的情况下。


}
GO

反向存储的线程维护着线程与处理器之间的关系,而 runqheadrunqtailrunq 三个字段表示处理器持有的运行队列,其中存储着待执行的 Goroutine 列表,runnext 中是线程下一个需要执行的 Goroutine。

runtime.p 结构体中的状态 status 字段会是以下五种中的一种:

状态 描述
_Pidle 处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
_Prunning 被线程 M 持有,并且正在执行用户代码或者调度器
_Psyscall 没有执行用户代码,当前线程陷入系统调用
_Pgcstop 被线程 M 持有,当前处理器由于垃圾回收被停止
_Pdead 当前处理器已经不被使用

通过分析处理器 P 的状态,我们能够对处理器的工作过程有一些简单理解,例如处理器在执行用户代码时会处于 _Prunning 状态,在当前线程执行 I/O 操作时会陷入 _Psyscall 状态。

调度器启动

调度器的启动过程是我们平时比较难以接触的过程,不过作为程序启动前的准备工作,理解调度器的启动过程对我们理解调度器的实现原理很有帮助,运行时通过 runtime. 初始化调度器:

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
//这里主要介绍核心代码,省略其他代码
func schedinit() {
// 获取当前 goroutine 的 g 结构体指针。 getg() 是一个汇编实现的函数,用于获取当前 goroutine 的 g 指针。
gp := getg()

// 设置系统可以拥有的最大 M (machine, 即系统线程) 数量。
// 默认值为 10000,这意味着 Go 程序最多可以创建 10000 个操作系统线程。
sched.maxmcount = 10000

// 确定 P (processor, 逻辑处理器) 的数量。
// 默认情况下,P 的数量等于 CPU 核心数 (ncpu)。
procs := ncpu

// 如果设置了环境变量 GOMAXPROCS,则使用 GOMAXPROCS 的值作为 P 的数量。
// atoi32 将字符串转换为 int32。
// gogetenv 获取环境变量的值。
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}

// 调用 procresize 调整 P 的数量,并初始化相关的运行时资源。
// procresize 会根据 procs 的值调整全局 P 数组的大小,并初始化每个 P。
// 如果在初始化过程中发现有 runnable 的 goroutine,则抛出异常,
// 因为在调度器初始化完成之前,不应该有 runnable 的 goroutine。
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}

}
GO

在调度器初始函数执行的过程中会将 maxmcount 设置成 10000,这也就是一个 Go 语言程序能够创建的最大线程数,虽然最多可以创建 10000 个线程,但是可以同时运行的线程还是由 GOMAXPROCS 变量控制。

我们从环境变量 GOMAXPROCS 获取了程序能够同时运行的最大处理器数之后就会调用 runtime.procresize 更新程序中处理器的数量,在这时整个程序不会执行任何用户 Goroutine,调度器也会进入锁定状态,runtime.procresize 的执行过程如下:

  1. 如果全局变量 allp 切片中的处理器数量少于期望数量,会对切片进行扩容;
  2. 使用 new 创建新的处理器结构体并调用 runtime.p.init 初始化刚刚扩容的处理器;
  3. 通过指针将线程 m0 和处理器 allp[0] 绑定到一起;
  4. 调用 runtime.p.destroy 释放不再使用的处理器结构;
  5. 通过截断改变全局变量 allp 的长度保证与期望处理器数量相等;
  6. 将除 allp[0] 之外的处理器 P 全部设置成 _Pidle 并加入到全局的空闲队列中;

调用 runtime.procresize 是调度器启动的最后一步,在这一步过后调度器会完成相应数量处理器的启动,等待用户创建运行新的 Goroutine 并为 Goroutine 调度处理器资源。

创建 Goroutine

想要启动一个新的 Goroutine 来执行任务时,我们需要使用 Go 语言的 go 关键字,编译器会通过 cmd/compile/internal/gc.state.stmtcmd/compile/internal/gc.state.call 两个方法将该关键字转换成 runtime.newproc 函数调用:

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
58
59
60
61
func (s *state) call(n *Node, k callKind) *ssa.Value {
if k == callDeferStack {
...
} else {
switch {
case k == callGo:
call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, newproc, s.mem())
default:
}
}
...
}

// newproc 函数创建一个新的 goroutine(协程),并将其放入运行队列.
//
// 参数:
// fn: *funcval 类型,指向要执行的函数的指针。funcval 是一个内部结构,用于表示闭包。
func newproc(fn *funcval) {
// 获取当前 goroutine 的指针 (g)。
gp := getg()

// 获取调用 newproc 函数的调用者的程序计数器 (PC)。
// sys.GetCallerPC() 是一个汇编级别的函数,用于获取调用者的返回地址,从而确定调用者的位置。
pc := sys.GetCallerPC()

// systemstack 切换到系统栈(g0 栈)上执行匿名函数。
// 这样做是为了防止在分配新的 goroutine 时用户栈空间不足,因为 newproc1 可能会分配内存。
systemstack(func() {
// newproc1 创建一个新的 goroutine 结构 (g)。
//
// 参数:
// fn: *funcval 类型,指向要执行的函数的指针。
// gp: 调用 newproc 的 goroutine 的指针。
// pc: 调用 newproc 函数的调用者的程序计数器。
// async: 布尔值,表示是否为异步调用创建的g, 这里是false, 表示同步创建.
// reason: waitReason, 表示等待原因, 这里使用 waitReasonZero, 表示无等待原因.
newg := newproc1(fn, gp, pc, false, waitReasonZero)

// 获取当前处理器 (P) 的指针。
// getg().m.p.ptr() 获取当前 goroutine 所属的 M (machine) 上的 P (processor) 的指针。
pp := getg().m.p.ptr()

// runqput 将新创建的 goroutine (newg) 放入当前处理器 (P) 的本地运行队列。
//
// 参数:
// pp: 当前处理器 (P) 的指针。
// newg: 要放入运行队列的 goroutine 的指针。
// next: 布尔值,表示是否将 goroutine 放入本地运行队列的下一个可运行位置。
// true 表示放入下一个可运行位置,这通常用于新创建的 goroutine,以提高局部性。
runqput(pp, newg, true)

// 如果主 goroutine 已经启动,则唤醒一个 P 来运行新的 goroutine。
// mainStarted 是一个全局变量,表示主 goroutine 是否已经启动。
if mainStarted {
wakep() // 唤醒一个 P (如果需要的话)
}
})
}



GO

runtime.newproc1 会根据传入参数初始化一个 g 结构体,我们可以将该函数分成以下几个部分介绍它的实现:

  1. 获取或者创建新的 Goroutine 结构体;
  2. 将传入的参数移到 Goroutine 的栈上;
  3. 更新 Goroutine 调度相关的属性;

首先是 Goroutine 结构体的创建过程:

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
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
// 获取当前 goroutine 的 g 结构体指针。
_g_ := getg()

// 计算参数所需的栈空间大小。
siz := narg
// 对 siz 进行 8 字节对齐。
// &^7 是一个位操作技巧,等价于 siz = (siz + 7) / 8 * 8,确保 siz 是 8 的倍数。
// 这是因为在大多数架构上,栈的对齐方式是 8 字节。
siz = (siz + 7) &^ 7

// 获取当前 goroutine 所在的处理器(P)的指针。
_p_ := _g_.m.p.ptr()

// 尝试从 P 的空闲 g 列表中获取一个可重用的 g 结构体。
newg := gfget(_p_)

// 如果没有可重用的 g 结构体,则分配一个新的 g 结构体。
if newg == nil {
// 使用 _StackMin 大小分配一个新的 g 结构体。
// malg 函数负责分配 g 结构体所需的栈空间。
newg = malg(_StackMin)
// 将新分配的 g 的状态从 _Gidle 更改为 _Gdead。
// casgstatus 是一个原子操作,用于安全地更改 g 的状态。
casgstatus(newg, _Gidle, _Gdead)
// 将新分配的 g 添加到 allgs 列表中,以便垃圾回收器可以跟踪它。
allgadd(newg)
}
...
}
GO

上述代码会先从处理器的 gFree 列表中查找空闲的 Goroutine,如果不存在空闲的 Goroutine,会通过 runtime.malg 创建一个栈大小足够的新结构体。

接下来,调用 runtime.memmovefn 函数的所有参数拷贝到栈上,argpnarg 分别是参数的内存空间和大小,我们在该方法中会将参数对应的内存空间整块拷贝到栈上:

1
2
3
4
5
6
7
8
9
...
totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize
totalSize += -totalSize & (sys.SpAlign - 1)
sp := newg.stack.hi - totalSize
spArg := sp
if narg > 0 {
memmove(unsafe.Pointer(spArg), argp, uintptr(narg))
}
...
GO

拷贝了栈上的参数之后,runtime.newproc1 会设置新的 Goroutine 结构体的参数,包括栈指针、程序计数器并更新其状态到 _Grunnable 并返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
	...
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
newg.sched.sp = sp
newg.stktopsp = sp
newg.sched.pc = funcPC(goexit) + sys.PCQuantum
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)
newg.gopc = callerpc
newg.startpc = fn.fn
casgstatus(newg, _Gdead, _Grunnable)
newg.goid = int64(_p_.goidcache)
_p_.goidcache++
return newg
}
GO

上述在分析 runtime.newproc 的过程中,保留了主干省略了用于获取结构体的 runtime.gfgetruntime.malg、将 Goroutine 加入运行队列的 runtime.runqput 以及设置调度信息的过程,下面依次分析这些函数。

初始化结构体

runtime.gfget 通过两种不同的方式获取新的 runtime.g

  1. 从 Goroutine 所在处理器的 gFree 列表或者调度器的 sched.gFree 列表中获取 runtime.g
  2. 调用 runtime.malg 生成一个新的 runtime.g 并将结构体追加到全局的 Goroutine 列表 allgs 中。

图 获取 Goroutine 结构体的三种方法

runtime.gfget 中包含两部分逻辑,它会根据处理器中 gFree 列表中 Goroutine 的数量做出不同的决策:

  1. 当处理器的 Goroutine 列表为空时,会将调度器持有的空闲 Goroutine 转移到当前处理器上,直到 gFree 列表中的 Goroutine 数量达到 32;
  2. 当处理器的 Goroutine 数量充足时,会从列表头部返回一个新的 Goroutine;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func gfget(_p_ *p) *g {
retry:
if _p_.gFree.empty() && (!sched.gFree.stack.empty() || !sched.gFree.noStack.empty()) {
for _p_.gFree.n < 32 {
gp := sched.gFree.stack.pop()
if gp == nil {
gp = sched.gFree.noStack.pop()
if gp == nil {
break
}
}
_p_.gFree.push(gp)
}
goto retry
}
gp := _p_.gFree.pop()
if gp == nil {
return nil
}
return gp
}
GO

当调度器的 gFree 和处理器的 gFree 列表都不存在结构体时,运行时会调用 runtime.malg 初始化新的 runtime.g 结构,如果申请的堆栈大小大于 0,这里会通过 runtime.stackalloc 分配 2KB 的栈空间:

1
2
3
4
5
6
7
8
9
10
func malg(stacksize int32) *g {
newg := new(g)
if stacksize >= 0 {
stacksize = round2(_StackSystem + stacksize)
newg.stack = stackalloc(uint32(stacksize))
newg.stackguard0 = newg.stack.lo + _StackGuard
newg.stackguard1 = ^uintptr(0)
}
return newg
}
GO

runtime.malg 返回的 Goroutine 会存储到全局变量 allgs 中。

简单总结一下,runtime.newproc1 会从处理器或者调度器的缓存中获取新的结构体,也可以调用 runtime.malg 函数创建。

运行队列

runtime.runqput 会将 Goroutine 放到运行队列上,这既可能是全局的运行队列,也可能是处理器本地的运行队列:

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
func runqput(_p_ *p, gp *g, next bool) {
// 如果 next 为 true,则尝试将 gp 放入 P 的 runnext 字段。
// runnext 字段用于存放下一个要运行的 goroutine,具有最高优先级。
if next {
retryNext:
// 获取 P 的 runnext 字段的旧值。
oldnext := _p_.runnext
// 使用 CAS 操作尝试将 P 的 runnext 字段设置为 gp。
// 如果 runnext 的当前值与 oldnext 相同,则将其更新为 gp 的指针,并返回 true;
// 否则,说明其他线程修改了 runnext,返回 false,并跳转到 retryNext 标签重试。
if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
goto retryNext
}
// 如果 oldnext 为 0,说明 runnext 之前没有存放任何 goroutine,
// 此时已经成功将 gp 放入 runnext,直接返回。
if oldnext == 0 {
return
}
// 如果 oldnext 不为 0,说明 runnext 之前已经存放了一个 goroutine,
// 将 gp 赋值为 oldnext.ptr(),也就是之前存放在 runnext 中的 goroutine,
// 准备将其放入本地运行队列。
gp = oldnext.ptr()
}

// 如果 next 为 false,或者将 gp 放入 runnext 失败,则将 gp 放入 P 的本地运行队列。
retry:
// 以原子方式加载 P 的 runqhead。LoadAcq 保证了内存可见性和顺序性。
h := atomic.LoadAcq(&_p_.runqhead)
// 获取 P 的 runqtail。
t := _p_.runqtail

// 检查本地运行队列是否已满。
// t - h 表示队列中元素的数量。
// len(_p_.runq) 表示队列的容量。
if t-h < uint32(len(_p_.runq)) {
// 如果队列未满,将 gp 放入队列的下一个可用位置。
// t%uint32(len(_p_.runq)) 计算 gp 应该放入的索引位置。
_p_.runq[t%uint32(len(_p_.runq))].set(gp)
// 以原子方式递增 P 的 runqtail。StoreRel 保证了内存可见性和顺序性。
atomic.StoreRel(&_p_.runqtail, t+1)
return
}

// 如果本地运行队列已满,则调用 runqputslow 函数添加到调度器持有的全局运行队列上处理。
if runqputslow(_p_, gp, h, t) {
return
}
// runqputslow 可能已经将部分 goroutine 转移到了全局运行队列,
// 本地运行队列可能已经有空闲位置,因此跳转到 retry 标签重试。
goto retry
}
GO
  1. nexttrue 时,将 Goroutine 设置到处理器的 runnext 作为下一个处理器执行的任务;
  2. nextfalse 并且本地运行队列还有剩余空间时,将 Goroutine 加入处理器持有的本地运行队列;
  3. 当处理器的本地运行队列已经没有剩余空间时就会把本地队列中的一部分 Goroutine 和待加入的 Goroutine 通过 runtime.runqputslow 添加到调度器持有的全局运行队列上;

处理器本地的运行队列是一个使用数组构成的环形链表,它最多可以存储 256 个待执行任务。

图 全局和本地运行队列

简单总结一下,Go 语言有两个运行队列,其中一个是处理器本地的运行队列,另一个是调度器持有的全局运行队列,只有在本地运行队列没有剩余空间时才会使用全局队列。

调度信息

运行时创建 Goroutine 时会通过下面的代码设置调度相关的信息,前两行代码会分别将程序计数器和 Goroutine 设置成 runtime.goexit 和新创建 Goroutine 运行的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
//把newg.sched结构体成员的所有成员设置为0
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))

//设置newg的sched成员,调度器需要依靠这些字段才能把goroutine调度到CPU上运行。
newg.sched.sp = sp //newg的栈顶
newg.stktopsp = sp
//newg.sched.pc表示当newg被调度起来运行时从这个地址开始执行指令
//把pc设置成了goexit这个函数偏移1(sys.PCQuantum等于1)的位置,
//至于为什么要这么做需要等到分析完gostartcallfn函数才知道
newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
newg.sched.g = guintptr(unsafe.Pointer(newg))

gostartcallfn(&newg.sched, fn) //调整sched成员和newg的栈
...
GO

上述调度信息 sched 不是初始化后的 Goroutine 的最终结果,它还需要经过 runtime.gostartcallfnruntime.gostartcall 的处理:

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
// adjust Gobuf as if it executed a call to fn
// and then did an immediate gosave.
func gostartcallfn(gobuf *gobuf, fv *funcval) {
var fn unsafe.Pointer
if fv != nil {
fn = unsafe.Pointer(fv.fn) //fn: goroutine的入口地址,初始化时对应的是runtime.main
} else {
fn = unsafe.Pointer(funcPC(nilfunc))
}
gostartcall(gobuf, fn, unsafe.Pointer(fv))
}
// adjust Gobuf as if it executed a call to fn with context ctxt
// and then did an immediate gosave.
func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
sp := buf.sp //newg的栈顶,目前newg栈上只有fn函数的参数,sp指向的是fn的第一参数
if sys.RegSize > sys.PtrSize {
sp -= sys.PtrSize
*(*uintptr)(unsafe.Pointer(sp)) = 0
}
sp -= sys.PtrSize //为返回地址预留空间,
//这里在伪装fn是被goexit函数调用的,使得fn执行完后返回到goexit继续执行,从而完成清理工作
*(*uintptr)(unsafe.Pointer(sp)) = buf.pc //在栈上放入goexit+1的地址
buf.sp = sp //重新设置newg的栈顶寄存器
//这里才真正让newg的ip寄存器指向fn函数,注意,这里只是在设置newg的一些信息,newg还未执行,
//等到newg被调度起来运行时,调度器会把buf.pc放入cpu的IP寄存器,
//从而使newg得以在cpu上真正的运行起来
buf.pc = uintptr(fn)
buf.ctxt = ctxt
}
GO

gostartcall函数的主要作用有两个:

  1. 调整newg的栈空间,把goexit函数的第二条指令的地址入栈,伪造成goexit函数调用了fn,从而使fn执行完成后执行ret指令时返回到goexit继续执行完成最后的清理工作;
  2. 重新设置newg.buf.pc 为需要执行的函数的地址,即fn,我们这个场景为runtime.main函数的地址,如果是在运行中go aa()启动的协程,那么newg.buf.pc会为aa()函数的地址

问题

1.goruntine与thread有什么区别?

核心区别概括:

  • 级别: Goroutine 是用户级的轻量级线程,由 Go 语言运行时 (runtime) 管理;Thread 是操作系统级的线程,由操作系统内核 (kernel) 管理。
  • 资源消耗: Goroutine 比 thread 更轻量级,资源消耗更少,创建和销毁速度更快。
  • 调度: Goroutine 的调度由 Go 运行时负责,采用 M:N 调度模型,将多个 goroutine 调度到少量的 OS 线程上;Thread 的调度由操作系统内核负责,通常是 1:1 调度模型 (每个 thread 对应一个 OS 线程)。
  • 上下文切换: Goroutine 的上下文切换比 thread 更快,因为 goroutine 的切换发生在用户态,无需内核参与;Thread 的上下文切换需要内核参与,开销更大。
  • 内存占用: Goroutine 的初始栈大小比 thread 更小,且可以动态增长;Thread 的栈大小通常是固定的,占用内存更多。
  • 编程模型: Goroutine 与 Go 语言的 channel 机制紧密结合,提供了简洁高效的并发编程模型;Thread 通常需要使用操作系统提供的同步机制 (如锁、信号量) 来进行线程间的通信和同步,编程模型相对复杂。

2.关于Golang的协程调度器原理及GMP设计思想

可参考Golang的协程调度器原理及GMP设计思想

参考链接

1.1.Go 语言设计与实现

1.2理解Go协程调度的本质


13.Golang 调度器源码分析(一、数据结构、调度器启动与创建协程)
https://blog.longpi1.com/2025/03/08/13-Golang-调度器源码分析(一、数据结构、调度器启动与创建协程)/