什么是并发 Part 4:实例分析——Go 语言并发实现

24 分钟阅读

本文的内容基于 go1.25.6 源代码,该版本发布于 2026-01-16。

Go 语言的源代码可以在 https://github.com/golang/go/ 找到,文章中所有在代码段中标注的文件路径都是相对于该源码仓库根的路径。

引言

Go 语言,是一门从设计之初,就把简化并发编程作为语言目之一来进行实现的程序语言。

在笔者所使用过的所有语言中,Go 语言所使用的并发机制也算是极其地方便——一般用户不需要考虑什么函数是阻塞的,什么函数不是;不用像 async/await 模型一样考虑函数的染色问题;语言中还提供了channel 类型用于在并发任务之间完全地传递数据。

协程这一在其它语言中相对进阶的编程概念,在 Go 语言中不过是一个 go 关键字的事,比如,想要写一个能够使用 10 个协程进行数组初始化的函数,只需要这样:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		size  = 100
		step  = 10
		list  = make([]int, size, size)
		group = new(sync.WaitGroup)
	)

	for i := range size / step {
		group.Add(1)
		go listInit(group, list, i*step, step)
	}
	group.Wait()

	for i, val := range list {
		fmt.Printf("%d: %d\n", i, val)
	}
}

func listInit(g *sync.WaitGroup, list []int, base, cnt int) {
	lastIndex := base + cnt
	for i := base; i < lastIndex; i++ {
		list[i] = i
	}
	g.Done()
}

该函数创建了一个包含有 100 个元素的切片(可以理解为其它语言中的数组、列表),和一个任务组。随后在循环中,创建了 10 个 goroutine1 将数组分成 10 个区间,并发地对数组中的元素进行赋值。只要设备上的 CPU 硬件线程足够多,这 10 个 goroutine 将会是并行执行的。任务组的作用是让主 goroutine 在 10 个子 goroutine 完成执行前保持活动。

就这样,用户不需要进行太多的思考,就可以用上自己 CPU 上的所有算力。

在 Go 语言中,并发并不使用特殊的数据类型进行抽象(Future、Promise),也不需要使用特殊的方式修饰函数声明(async)。在其易用的并发之后,是一个针对协程的调度机制运行时。

本文将以源代码解析的形式,探索 Go 语言中对并发编程的实现。

这一篇文章已经是这个系列的第 4 篇,本文将会用到本系列 Part 1与 Part 3 中出现的概念。读者可以通过这两篇文章的目录确认自身是否已经拥有足够的基础知识。

Go 语言本身的语法相对简单,相信即使对 Go 语法没有完整的了解,也可以看懂本文中的大部分内容。不过部分内容不可避免地会出现指针与内存地址相关的内容,读者需要对相关的概念有了解。

Go 启动流程

下面是一个 Go 语言的 Hello World 程序:

package main

import (
	"fmt"
)

func main() {
	fmt.Println("hello world")
}

首先解释其中的几个概念:

  • package main 指示当前的文件属于 main 包。
  • import 用于指示该文件中需要引入的包名。
  • func main,定义程序的主函数。
  • fmt.Println,调用 fmt 包对外导出的函数 Println。

对于像 Go 这样带有运行时的语言,其编译得到的可执行文件里,用户定义的 main 函数并不是程序的入口(这与 C 语言不同)。Go 的可执行文件启动之后首先进行的是运行时的启动。

在编译结果中,用户定义的 main 函数将会以 main_main 这一名称出现,该名称的含义是 main 包中的 main 函数。这只是一个普通函数,用户主函数的启动将以一次常规函数调用的形式发生。

Go 可执行文件真正的入口是一个定义在汇编文件中的 main 函数:

// src/runtime/rt0_linux_arm64.s
TEXT main(SB),NOSPLIT|NOFRAME,$0
	MOVD	$runtime·rt0_go(SB), R2
	BL	(R2)
exit:
	MOVD $0, R0
	MOVD	$94, R8	// sys_exit
	SVC
	B	exit

Go 编译器所使用的汇编语言并不是某一种特定硬件的汇编语言,而是一种 Plan 9 风格的半抽象汇编语言。汇编语言中的内容会由汇编器翻译成对应的机器语言。

想要详细了解 Go 使用的汇编语言的读者可以查阅官方文档。

可以看到,该入口函数的前两个指令,就是对 runtime·rt0_go 函数的调用。接下来在 runtime·rt0_go 函数中会运行多个启动流程函数。完整的启动调用流程如下:

    main @ src/runtime/rt0_*.s
    → runtime·rt0_go       @ src/runtime/asm_*.s     // 文件名的 * 部分由硬件架构决定
      runtime.args         @ src/runtime/runtime1.go
      runtime.osinit       @ src/runtime/os_*.go     // * 部分意义同上
*     runtime.schedinit    @ src/runtime/proc.go
*     runtime.newproc      @ src/runtime/proc.go
    → runtime.mstart       @ src/runtime/proc.go
    → runtime.mstart0      @ src/runtime/proc.go
    → runtime.mstart1      @ src/runtime/proc.go
*   → runtime.schedule     @ src/runtime/proc.go
    → runtime.execute      @ src/runtime/proc.go
    → runtime·gogo         @ src/runtime/asm_*.s
    → runtime·gogo<>       @ src/runtime/asm_*.s

在上面的文本中,开头带有 → 的行,表示调用该函数并进入到其函数体中执行后续内容;没有 → 开头的行则表示调用该函数并返回后,再执行下一行的调用。每行中 @ 后的部分提示了该行的函数被定义在哪个文件中。

使用 * 标记的是与接下来的讨论密切相关的几个函数。

首先,我们要提到的是 goroutine。对于 Go 程序来说,所有的任务都是以 goroutine 的形式创建、调度的,用户定义的 main 函数所在的主 goroutine ,是程序后续创建创建更多 goroutine 的起点。

上述调用流程中使用 * 标记的 3 个函数,完成了对主 goroutine 初始化过程:

  • runtime.schedinit 对 goroutine 的调度器进行初始化。
  • runtime.newproc 创建主 goroutine。
  • runtime.schedule 执行调度器的一次步进,启动主 goroutine 的执行。

runtime.newproc 就是在上一小结中使用过的 go 关键字的实体。该函数同 go 关键字一样,创建一个 goroutine 并将一个指定的任务函数与之绑定。

运行时启动流程中的这一次 runtime.newproc 调,用将创建一个指向 runtime.main 函数的 goroutine,读者可以在 src/runtime/proc.go 找到这个函数的定义。

runtime.main 函数中最重要的部分是,它会进行 main_main(用户定义的 main 函数)的调用。当 main_main 被调用时,Go 运行时的初始化流程就正式结束了。

互斥锁实现

互斥锁是一个能够体现并发任务间数据交换方式的重要机制,同时其结构非常简单,很容易理解。本节将从互斥锁的实现入手,开始对 Go 运行时的并发支持进行探索。

当五个 goroutine 因为没有抢夺到互斥锁的锁权而进入等待流程时,会将其中用的系统线程转让给其它的 goroutine。这样的设计让用户在 Go 语言里编写并发函数时,无须再像使用其它语言时一样,担心互斥锁空转浪费一整条线程的算力。

type Mutex struct {
	state int32
	sema  uint32
}

Mutex 结构体定义于 sync 包中。

其定义很简单,只有一个表示锁的状态的字段,和一个信号量字段。

信号量字段在下文关于等待队列的部分中会提到,现在我们先关心锁的状态。

互斥锁结构体的初始值,是一个解锁状态的互斥锁,其状态值是 0。要注意,互斥锁结构体若是发生复制,复制结果与原数据表示的就不再是同一个锁。所以在实际的代码中,互斥锁几乎总是以指针的形式出现的。

下面是一段使用互斥锁进行数据的并发修改的例子:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		value = 0
		lock  = new(sync.Mutex)
	)

	for _ = range 2 {
		go safeAdd(lock, &value)
	}

	fmt.Println("Done")
}

func safeAdd(lock *sync.Mutex, value *int) {
	lock.Lock()
	*value++
	lock.Unlock()
}

上面的例子并没有等待子 goroutine 结束就直接退出了 main 函数,因此两次加法未必都能完成。

在互斥锁的 Lock 方法中,锁定操作分成快、慢两种。

快请求是大多数情况下,互斥锁会采用的操作。该操作尝试使用 Compare-and-Swap进行一次锁激活。如果当前锁恰好空闲,并且没有进入饥饿状态 CAS 操作就会成功;如果 CAS 操作失败,流程就会进入慢请求循环。

慢请求相对复杂,是一个持续到当前 goroutine 成功获得锁权为止的循环。其目的是防止某个 goroutine 总是在锁权争夺中落败导致无法推进任务,也就是防止饥饿。

Mutex.state 字段中的低位侧第 1 位,是用于表示锁定状态的标记位。

低位侧的第 3 位,是锁饥饿状态的标记位。当锁进入到饥饿状态后,任何一个 goroutine 都无法通过快请求获取到锁权。若是不加以限制,旧的 goroutine 在锁权争夺中落败进入休眠状态后,只要其它 goroutine 发起锁权占有请求的时机合适,就能通过快速请求在旧 goroutine 休眠时锁上恢复空闲的锁,而旧 goroutine 每次苏醒,都会发现数据锁处于锁定状态。在旧 goroutine 的那一头,可能是一个看着客户端无尽处于“加载中”的状态的用户。

如果一个锁的锁权请求者,等待了超过 1ms 的时间还没有获得过锁权,就会将锁切换到饥饿状态。通常来说,处于普通模式的锁,性能要明显高于饥饿模式的锁。因为一个 goroutine 能够连续好几次抢到锁权,说明其足够快。允许这样的 goroutine 通过快请求,连续占有锁权,将系统的运算资源分配给更快的任务,从程序全局的角度来看是更优的。

每一个锁,都对应一个等待队列,队列里是因为没有争夺到锁权而暂时进入休眠状态的 goroutine。如果锁已经进入到饥饿状态,当前持有锁权的 goroutine 在调用 Mutex.Unlock 时,就会定向地将我锁权递给等待队列中处于队首位置的 goroutine,以此达到防饥饿的效果。

Mutex.state 低位侧的第 2 位是用于指示当前是否有 goroutine 以空转状态等待获取锁权的标记。如果调用 Mutex.Unlock 时,锁既没有处于饥饿状态,又有空转状态的 goroutine 在等待,就不需要在解锁时从等待队列中唤醒 goroutine 了。

Mutex.state 中,锁状态标记只用到了 3 位,这个字段中剩余的部分被用于记录慢请求等待队列中等待者的数量。

在锁的饥饿模式下,获得锁权的 goroutine 如果等待时间不超过 1ms,或者等待队列中只剩一个等待者,新获得锁权的 goroutine 就会去除锁上的饥饿状态标记。

慢请求的完整循环步骤如下:

  • 判断当前是否符合使用自旋锁进行等待的条件。

    若符合条件,则为锁添加 mutexWoken 状态标记,并使用 yield 命令开始空转等待。空转结束后,跳过后续步骤,重新重新开始慢请求循环。

  • 判断当前锁是否处于饥饿模式。

    如果互斥锁当前处于饥饿状态,则不在本循环内尝试使用 CAS 获取锁权,而是准备进入等待队列。

    反之则准备 CAS 操作所需的数据。

  • 准备增加锁的等待者计数所需的数据。

  • 尝试使用 CAS 对锁的状态数据进行更新。

    如果经过前述数据准备,在此次 CAS 操作中成功获得锁权,则慢请求循环结束。

  • 经过前述操作后若还没有获得锁权,则将当前 goroutine 放入锁对应的等待队列中。

    如果当前 goroutine 是首次进入慢请求等待队列,则从队尾加入到队列中;反之则从队头加入到队列,在互斥锁锁权释放时能被优先唤醒。

空转

Mutex.Lock 的慢请求流程在极少数情况下,会使用自旋锁的形式等待互斥锁回归空闲。

首先,如果锁当前处于饥饿模式,自然是不会允许使用空转等待的,因为锁权会定向转交给等待队列中最靠队头的等候者,使用空转锁是没有机会直接等到锁权释放的。

只有总空转的 goroutine 数量小于 4,设备有多个 CPU 核心,且有空闲核心时,Mutex.Lock 才会使用空转等待。

空转操作的函数定义在汇编文件中,其内容是一个持续 30 次的 yield 循环:

// src/runtime/asm_arm64.s
TEXT runtime·procyield(SB),NOSPLIT,$0-0
	MOVWU	cycles+0(FP), R0
again:
	YIELD
	SUBW	$1, R0
	CBNZ	R0, again
	RET

yield 命令在不同的硬件架构上作用会略有差异,但是基本上都起着对 CPU 进行能效优化提示的作用。CPU 可以通过该命令的执行,了解到进程当前的任务不紧急、不要求高算力,随后对应降低分配给进程所在核心的运行频率,或者降低该进程在核上的执行优先级。

需要注意的是,yield 命令并不一定意味着直接放弃本次线程调度的 CPU 占有权,一旦放弃执行权,线程就需要再经过操作系统调度才能恢复执行。 yield 这样降低执行优先级的方式,可以让进程在后续需要时快速回到活跃状态。

等待队列

与互斥锁相关的等待队列,通过 sync.Mutex 的 sema 字段来确定的。在执行过程中首先要关注的,不是 Mutex.sema 的值,而是这个字段的内存地址,这个字段的地址相当于每个 Mutex 对象在创建时自动生成的唯一识别 ID。

互斥锁的等待队列是一个结构很简单的哈希表:

// src/runtime/sema.go
var semtable semTable

// 选用索引作为哈希表的原因与素数的模 n 乘法群有关,原理在本文中并不重要
const semTabSize = 251

type semTable [semTabSize]struct {
	root semaRoot
	pad  [cpu.CacheLinePadSize - unsafe.Sizeof(semaRoot{})]byte
}

可以看到全局变量 semtable 只不过是一个定长数组。该数组中是所有互斥锁的等待队列。在实际的程序中,能够触发互斥锁慢请求的高热度关键区域预计并不会很多,所以这一哈希表的长度没有设计得很大。


semTable 类型只一个方法,该方法使用内存地址来查找与之对应的等待队列根结点:

func (t *semTable) rootFor(addr *uint32) *semaRoot {
	return &t[(uintptr(unsafe.Pointer(addr))>>3)%semTabSize].root
}

我们忽略函数实现中的类型转换操作,能把哈希过程看得更清楚:

addr>>3 % semTabSize

该步骤中将 addr 值右移 3 位是因为 addr 指向的类型 uint32 在内存中的自然对齐是 4 字节,导致 addr 最后 3 位不是 0b000 就是 0b100。为了让不同地址的哈希值尽可能不同,刻意选择将这 3 位数值移除。

互斥锁查找自身对应的等待队列的函数调用链如下:

Mutex.lockSlow()                                  // @ src/internal/sync/mutex.go
runtime_SemacquireMutex(                          // @ src/runtime/sema.go
    &m.sema,   // Mutex.sema 字段的地址
    queueLifo, // 指示当前 goroutine 进入等候队列时需要加入头部还是尾部
    1
)
semacquire1(                                      // @ src/runtime/sema.go
    addr,
    lifo,
    semaBlockProfile|semaMutexProfile,
    skipframes,
    waitReasonSyncMutexLock
)
semtable.rootFor(addr)                            // @ src/runtime/sema.go

哈希表内,各个哈希槽位上的有效数据所对应的类型是:

type semaRoot struct {
	lock  mutex
	treap *sudog
	nwait atomic.Uint32
}

其中 treap 是一个用于储存等待队列的平衡树,树中结点的类型是 sudog 指针。

每一个 sudog 结点的 elem 字段,都对应一个内存地址。在 goroutine 通过互斥锁触发进入等待队列的操作时,semaRoot 就会使用 Mutex.sema 的地址在平衡树中查找对应的结点。


等待队列的数据的索引结构完整描述如下:

  • semTable,全局哈希表变量,通过 Mutex.sema 字段的地址索引一个平衡树。

  • semRoot 结构体是 semTable 的有效数据,treap 字段是基储存平衡树的部分。

  • 平衡树会使用 Mutex.sema 字段的地址在其内部寻找对应该地址的结点。

  • sudog 结构体是用于表示平衡树结点的类型。

    平衡树上的 sudog,既是平衡树的结点,也是一个等待队列的入口。

    sudog.waitlink 字段是一个 sudog 指针,起链表的作用,指向等待队列中的下一对象。链表中上的每一个 sudog 里,都储存着一个 goroutine 对象,用于表示因为同一个锁而进入休眠的 goroutine 们。


当前的 goroutine 因为争夺锁权失败而被记录到等待队列中后,就会调用 goparkunlock 函数使自身进入休眠状态。

接下来,介绍 goparkunlock 背后的调度机制。

Goroutine 调度模式

Go 语言中,使用 3 个概念层级进行任务的调度抽象:

  • G,goroutine,代表用户定义的任务
  • M,machine,代表一个工作线程
  • P,processor,代表设备上的一个处理器

G 是协程,是轻量的。其轻量体现在对一个 G 进行启动、停止是不需要进行系统调用的。只要内存充足,一个进程可以拥有大量被管理的 G 同时活动。

M 对应实际的系统线程,该对象对于操作系统来说是轻量级的进程,但是对于用户进程而言,是一种有限的资源。M 负责绑定 G,并推进 G 的任务执行。

P,是代表物理处理器核心的资源抽象,每一个 M 进行 Go 代码的执行时都必须与一个 P 绑定。

每一个 G 包含有用于记录 PC(程序计数器)、SP(栈指针)、BP(栈帧指针)、LR(链接寄存器)的字段,还通过动态分配持有一个栈结构,完全地模拟了一个可以中断、恢复执行状态的进程。

G 被 M 唤醒执行时,会将 M 所在的处理器核心切换到使用 G 的私有栈内存,来进行后续的函数调用。

这 3 种对象分别对应 src/runtime/runtime2.go 中的 3 种类型定义:

type g struct {
    // ...
	m         *m      // 当前用于执行 G 的 M
    // ...
}

type m struct {
    // ...
	curg            *g       // 正在该 M 上运行的 G
	p               puintptr // 现在被用于进行 Go 代码执行的 P,为空时表示 M 被挂起
    // ...
}

type p struct {
    // ...
	m           muintptr   // 指向当前正在使用该 P 的 M,为空时表示 P 空闲
    // ...
}

Go 运行时中,以全局变量的形式定义了一对特殊的 G 和 M:

// src/runtime/proc.go
var (
	m0           m
	g0           g
)

这一对 G 与 M 会在运行时启动时进行初始化。

m0 代表主线程,也就是进程启动时就有的线程;g0 是一个特殊的 goroutine,专门用于进行其它 goroutine 的调度。

注意,g0 并不是主 goroutine,g0 上是不会执行用户任务代码的。运行时初始化的过程中,正是 g0 创建了主 goroutine 并通过触发调度器的首次步进,唤醒了主 goroutine 的执行。

实际上每一个 M 对象都包含有一个名为 g0 的字段,该字段指向的是 M 用于进行自身任务调度的 goroutine 对象。每当 M 需要进行 G 切换时,就会先切换到这一调度 goroutine 来启动下一步需要执行的 G。

全局变量 g0 是专属于 m0 的调度 goroutine。

任务休眠

了解了上述内容后,我们再来看互斥锁实现中,慢请求用于将当前 goroutine 变为休眠状态的 goparkunlock 函数。

在 goparkunlock 函数中,直接进行调度的部分如下:

func goparkunlock(lock *mutex, reason waitReason, traceReason traceBlockReason, traceskip int) {
	gopark(parkunlock_c, unsafe.Pointer(lock), reason, traceReason, traceskip)
}

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceReason traceBlockReason, traceskip int) {
    // ...
	mcall(park_m)
}

调度出现在 gopark 函数的最后一行,如果没有异常发生的话,在执行完 mcall 调用后,发起 goparkunlock 的 goroutine 就会进入休眠状态。

mcall 由于需要访问寄存器,完全使用汇编进行实现,下面是笔者根据 Go 源码中的 AMR64 实现翻译成的 Go 代码版本(全大写字母表示的变量名是寄存器):

func mcall(fn func(*g)) {
    gp := getg()           // getg 返回 `*g` 类型值,是指向当前执行中的 G 的指针

    gp.sched.sp = RSP      // 此处是对 goroutine 当前的执行环境的保存
    gp.sched.bp = R29
    gp.sched.pc = LR
    gp.sched.lr = 0

    oldgp := gp
    gp = gp.m.g0           // 取出属于当前 M 的 g0,准备进行调度操作
    runtime.save_g()
    if (gp == oldg) {
        runtime.badmcall()
    }

    SP = gp.sched.sp        // M 在此时切换到了 g0 的栈上
    R29 = 0
    RSP[-16] = 0

    fn(oldg)               // 在设计上 fn 应该执行调度操作且永不返回
    runtime.badmcall2()    // 如果执行到此行,说明 fn 的实现不符合要求
}

可以看到 mcall 是一个保存当前 G 运行环境,并切换到 g0 执行调度函数的流程。而参数 fn 是实际进行对当前 G 调度操作的函数。

在 goparkunlock 中,对应 fn 的就是 park_m:

func park_m(gp *g) {
    // 承接上文的 mcall 调用
    // 运行到 park_m 函数体时,M 上活跃中的 G 已变为属于 M 的 g0
	mp := getg().m
    // ...

	// 将 gp 的状态码从“运行中”切换到“等待”
	casgstatus(gp, _Grunning, _Gwaiting)
    // ...

    // 断开 m 与 m.curg 的绑定
    // 到此,gp 不再被认为是当前 M 上活跃的 goroutine
	dropg()

	if fn := mp.waitunlockf; fn != nil {
        // 如果 M 上附有切换 G 时需要执行的解锁函数,则在此处进行执行
		ok := fn(gp, mp.waitlock)
		mp.waitunlockf = nil
		mp.waitlock = nil
		// 如果解锁函数执行失败,则本次休眠也失败,再次将 gp 唤醒
		if !ok {
            // ...
			casgstatus(gp, _Gwaiting, _Grunnable)
            // ...
			execute(gp, true)
		}
	}

	// 进行一次调度步进
	schedule()
}

可以看到在 park_m 中将当前与 M 绑定的 G 移除并改为休眠状态后,便尝试发起调度器步进。如果调度器此时能够找到新的待执行任务,那么 M 就会将新找到的任务作为自身接下来的执行内容。

任务唤醒

接续上文中 park_m 实现。

其函数体中调用的 schedule 函数是 gorouitne 调度器的一次步进执行。该函数调用在运行时初始化流程中也有提到过。其最主要的内容描述起来非常简单:

  • 寻找当前处于可执行状态的 G,如果没有目标,则阻塞当前线程,直到目标出现。
  • 将找到的 G 绑定到当前的 M 上,并把 G 的状态从可执行切换到正在执行。
  • 从 G 的数据中恢复 SP、LR 与上下文寄存器。
  • 跳转到 G 在进入休眠状态前保存的 PC 位置,开始进行代码的执行。

注意,schedule 函数找到需要执行的 G 之后,是通过跳转指令与 PC 寄存器的值离开函数体的,而不是通过返回指令。也就是说,对 schedule 的函数调用是不会返回的,这样的操作在高级程序语言中并不多见。

恢复寄存器与跳转到 PC 位置的操作都是在 runtime.gogo 函数中实现的。为了操作寄存器,这个函数是直接使用汇编语言写成的。

下面是 schedule 函数中直接与调度操作相关的代码节选:

// src/runtime/proc.go
func schedule() {
	mp := getg().m
	// ...
	gp, inheritTime, tryWakeP := findRunnable() // 在寻找任务时阻塞
	// ...
	execute(gp, inheritTime)
}

func execute(gp *g, inheritTime bool) {
	mp := getg().m
    // ...
	mp.curg = gp
	gp.m = mp
    //...
	casgstatus(gp, _Grunnable, _Grunning)
	// ...
	gogo(&gp.sched)
}

G、M、P 对象的管理

在上文中提到,Go 的运行时定义了 g0 与 m0 两个全局变量,用于启动 G 调度。

在初始化运行时调度器的过程中,runtime.schedinit 会调用 procresize 函数,如果用户没有指定 GOMAXPROCS 环境变量的值,那么该函数就会创建与设备 CPU 核心对应数量的 P 对象。P 的数量,在整个程序的运行生命周期里,几乎不会发生变化。

初始化时,m0 负责执行 procresize 时,程序会将 P 对象列表中的首个 P 绑定到 m0 上。建立好首次的 G、M、P 协作状态。

M 与 G 对象的活跃数量则是相对不稳定的,尤其是 G 对象,会随着程序的任务密集程度明显起伏。

Go 运行时为 G 与 M 两个对象做了对象池。

用户可以通过 go 关键字进行 G 的创建。在前文中提到过,这实际上是对 runtime.newproc 函数的调用。运行时会首先检查是否有已被回收到对象池的 G 对象,如果当前没有可复用的 G 对象,才会创建新的 G(runtime.gfget 是从对象池中取出 G 的函数)。

新的 G 被创建后,并不会在进行 G 创建的 M 上立即开始执行。创建新 G 的 M 会将这个 G 标记为当前使用的 P 的下一个执行目标,并发起 wakep 调用,尝试将一个空闲中的 P 唤醒加入到任务的执行中。

新创建的 G 成为 P 的下一执行目标,是通过把 G 记录在 p.runnext 字段实现的。而对于通过其它方式被唤醒的 G,与 P 绑定的方式是加入到 P 的等待队列中。为了防止P 的等待队列过长,等待队列设有长度上限。若 P 的等待队列已满,G 就无法绑定到 P 上,只有加入到全局的等待队列中。

完成 G 的创建后,不论 wakep 有没有成功找到空闲的 P,进行创建操作的 M 都会继续其原本的执行流程。若当前没有空闲的 P,新 G 的执行就会被暂搁置。如果有空闲的 P 被唤醒,则 P 的唤醒会连带触发一个 M 的唤醒。

M 的对象池也就是线程池。runtime.mget 可以从现有的对象池中取出一个空闲的 M。在需要 M 对象时,对象池若没有足够的 M 对象,Go 运行时就会创建新的线程(实际创建线程的方式与操作系统有关),组装出一个新的 M 对象。

G 的生命周期

我们暂时将注意转回 G 对象。

每一个 G 对象在创建时都,被分配了专属于这个 G 的栈内存块,当 G 获得执行权时,就 会将 CPU 核心上的栈指针寄存器值指向自己的私有栈。

而在创建 G 的私有栈时,G 中用于记录 G 当前执行位置的 PC 寄存器值字段,会被设置为 goexit 函数所在的内存位置。

这伪造了绑定在 G 上的任务函数开始执行时,G 正处于 goexit 函数体中的效果。G 的任务函数一开始执行,CPU 的 PC 寄存器就会从 goexit 函数体,变为指向任务函数的函数体。这从 G 的私有栈上来看,就好像是 goexit 调用了任务函数一样。

这样一来,G 的任务函数在执行完成,运行返回语句时,就会回到 goexit 的函数体中,继续 goexit 的执行。

这一机制为所有的 G 提供了一个统一的执行结束出口。下面是 goexit 中发挥实质性功能的代码节选:

// src/runtime/proc.go
func goexit1() {
    // ...
	mcall(goexit0)
}

func goexit0(gp *g) {
	gdestroy(gp)
	schedule()
}

可以看到,G 的退出流程是使当前的 M 切换到 g0,并发起一次新的 schedule 调用,这让因为一个 G 的执行结束而回归空闲的 M,能够立即进入到下一个 G 的执行流程中去。

特别注意,当主 goroutine 上的 main_main 函数执行结束后,会返回到 runtime.main 函数中。而 runtime.main 函数接下来,会进行环境的清理,并直接向操作系统返回退出码 0 退出进程,并不会再进入 goexit 执行流程中:

// src/runtime/proc.go
func main() {
    // ...
	fn := main_main
	fn()
	// ...
	exit(0)
    // ..
}

任务派发

上面的内容介绍了创建 G 的操作在运行里是如何与 M、P 的管理联动的。但是缺少了关于新 G 如何绑定到 M 上的步骤。

G 是代表任务的抽象,所谓任务的派发,其实就是让活跃的 M 找到需要执行的 G 并推进其任务进度的过程。

在创建新 M 使用的线程时,会将 runtime.mstart 作为 M 的任务函数。而 runtime.mstart 会最终变为 schedule 函数的调用,这将会为当前的 M 找到一个需要执行的 G:

// src/runtime/asm_arm64.s
// 原实现为汇编函数
func mstart() {
    mstart0()
}

// src/runtime/proc.go
func mstart0() {
    // ...
	mstart1()
    // ...
}

func mstart1() {
    // ...
	schedule()
}

func schedule() {
	mp := getg().m
	// ...
	gp, inheritTime, tryWakeP := findRunnable() // 在寻找任务时阻塞
	// ...
	execute(gp, inheritTime)
}

findRunnable 是提供将任务相对均匀地分配到各个 CPU 核心上的逻辑。该函数会按照下面的顺序进行 G 的选择:

  • trace 任务 G
  • 全局任务队列中的 G,这一步骤是定期执行的,目的在于防止有 G 占据了所有可用的 P,使得部分任务无法被执行。
  • finalizer 任务 G,该 G 用于处理通过 runtime.SetFinalizer 所设定的对象终结函数。
  • GC 清理 G。
  • 当前 P 的本地等待队列中的 G
  • 全局等待队列中的 G
  • 粗查 net poll 队列中的 G
  • 随机从属于其它 P 的等待队列中抽取的 G
  • 检查低优先级的 GC 任务 G
  • 再次检查 net poll 任务 G

总结

Go 语言为了给用户提供简单易用的并发,实现了一套在所有函数中都可以触发并发操作的运行时。

用户定义的 main 函数在编译的结果中名称为 main_main,并不是编译得到的可执行文件的执行入口。可执行文件开始运行后,第一件要做的事是启动运行时。

本文中关注的,是关于并发支持的协程运行时的部分。该部分中将并发分成 3 层不同的抽象:

  • G,goroutine,是 Go 语言中对协程的实现,用于表示一个任务。每一个 G 都带有自己的调用栈内存,当核心开始一个 G 的执行时,就会切换到该 G 的调用栈上。
  • M,machine,是一个内核级的线程,用于支持 G 的执行,不同的 G 通过被 M 绑定来获得任务进度的推进机会。
  • P,processor,是用于表示 CPU 要核心的抽象,M 在进行 G 的执行时,需要先绑定一个 P。

在进程运行时完成初始化之后,就已经完成一组 P 的创建。P 对象的数量很少在运行过程中发生变化,而 G 和 M 是可以根据进程中对并发任务需求的高低,有较大的数量浮动的。

用户每次使用 go 关键字,实际上就是调用了 runtime.newproc 函数进行了一次 G 创建,并将一个函数作为 G 的任务函数绑定到 G 上。创建的 G 的行为会触发唤醒 P 的尝试;而 P 又会尝试唤醒一个 M。

新创建的 G 被加入到 P 的待执行队列中,而 P 唤醒 M 之后被绑定到 M 上。M 通过schedule 函数的调用,获取接下来在该 M 上执行的 G。

schedule 函数是挑选一个需要执行的 G 并跳转到 G 的任务函数中开始执行的无返回函数。当 G 中发生需要等待 I/O 响应、需要等待获取互斥锁权时,都会将自身改为休眠状态再通过 schedule 将 M 的使用权转让给其它 G。

而 G 在任务函数执行完成后,会跳转到 goexit 函数的函数体。goexit 最终会再次调用 schedule 函数,来唤醒另一个 G 顶替刚刚结束任务的 G。

在运行时中有一对特别的 G 和 M——g0 和 m0。它们都以全局变量的形式被定义。m0 代表的是主线程,而 g0 是专用于为 m0 提供 G 调度支持的 G,不会执行用户的任务代码。

在初始化运行时的过程中,是 g0 创建了主 goroutine。主 goroutine 的任务函数是 runtime.main。正是 runtime.main 最终使用 main_main 这一名称进行用户定义的主函数的执行。

在用户主函数执行完后,主 goroutine 的执行位置就重新回到了 runtime.main 之中。接下来 runtime.main 会进行进程最后的清理工作,并向操作系统返回退出码结果进程。

以上,就是 Go 语言的运行时对并发支持的大致架构。


  1. goroutine 是 Go 语言对其实现的协程(coroutine)的称呼。 ↩︎

Last Update 2026-01-20