本文描述 Go 协程(goroutine)从创建到销毁的完整生命周期,包括创建、运行、调度、回收和销毁等各个阶段。
协程生命周期概览
协程的一生可以概括为以下几个阶段:
stateDiagram-v2
[*] --> 创建: newproc
创建 --> 可运行: _Grunnable
可运行 --> 运行中: 被调度执行
运行中 --> 可运行: 主动让出/被抢占
运行中 --> 等待中: 阻塞操作
等待中 --> 可运行: 被唤醒
运行中 --> 系统调用: 进入系统调用
系统调用 --> 可运行: 系统调用返回
运行中 --> 已死亡: 执行完成
已死亡 --> [*]: 回收销毁
创建协程
协程的创建通过 go 关键字触发,底层调用 runtime.newproc 函数。
newproc 函数
1 | // runtime/proc.go |
主要流程:
- 获取当前 goroutine 和调用者 PC
- 在系统栈上调用
newproc1创建新的 goroutine - 将新创建的 goroutine 放入当前 P 的本地运行队列
- 如果主函数已启动,尝试唤醒或创建新的 M 来执行
newproc1 函数
newproc1 是实际创建 goroutine 的函数:
1 | // runtime/proc.go |
关键步骤:
获取或创建 G:
- 优先从 P 的空闲 G 列表(
gFree)中获取,实现 G 的复用 - 如果没有空闲的 G,调用
malg创建新的 G,并分配最小栈空间(2KB)
- 优先从 P 的空闲 G 列表(
初始化栈空间:
- 计算所需栈大小(参数大小 + 帧大小)
- 从高地址向低地址分配栈空间
- 将函数参数复制到新栈上
设置调度上下文:
sched.pc:设置为goexit函数地址,当 goroutine 执行完成后会调用此函数sched.sp:栈指针- 通过
gostartcallfn设置实际的函数入口地址
分配 goroutine ID:
- 从 P 的 ID 缓存中获取,如果缓存用完则批量从全局分配
状态转换:
- 从
_Gdead转换为_Grunnable,表示可以调度执行
- 从
协程状态
协程在生命周期中会经历以下状态:
stateDiagram-v2
_Gidle: 空闲状态(刚分配)
_Gdead: 已死亡(未初始化或已销毁)
_Grunnable: 可运行(在运行队列中)
_Grunning: 运行中(正在执行)
_Gsyscall: 系统调用中
_Gwaiting: 等待中(阻塞)
_Gcopystack: 栈复制中
_Gpreempted: 被抢占
_Gidle --> _Gdead: 初始化失败
_Gdead --> _Grunnable: newproc1
_Grunnable --> _Grunning: 被调度
_Grunning --> _Grunnable: 主动让出/被抢占
_Grunning --> _Gsyscall: 进入系统调用
_Grunning --> _Gwaiting: 阻塞操作
_Gsyscall --> _Grunnable: 系统调用返回
_Gwaiting --> _Grunnable: 被唤醒
_Grunning --> _Gdead: 执行完成
运行协程
协程创建后会被放入运行队列,等待被调度执行。
调配资源
协程要运行,需要绑定 M(内核线程)和 P(处理器)。
绑定M
M 是执行 goroutine 的内核线程。M 的绑定过程:
获取空闲 M:
- 从全局空闲 M 列表(
sched.midle)中获取 - 如果没有空闲的 M,创建新的 M
- 从全局空闲 M 列表(
绑定 P:
- M 必须绑定 P 才能执行 G
- 从空闲 P 列表(
sched.pidle)中获取 P - 如果所有 P 都被占用,M 会进入自旋状态等待
执行调度循环:
- M 绑定 P 后,进入调度循环(
schedule函数) - 从 P 的本地队列获取 G 执行
- M 绑定 P 后,进入调度循环(
绑定P
P 是管理 goroutine 执行资源的处理器:
P 的状态:
_Pidle:空闲状态,没有绑定 M_Prunning:运行状态,已绑定 M 并执行 G_Psyscall:系统调用状态,M 进入系统调用时 P 处于此状态_Pgcstop:GC 停止状态_Pdead:已死亡状态
P 的本地队列:
- 每个 P 维护一个本地运行队列(
runq),最多 256 个 G - 还有一个高优先级的
runnext,用于立即执行下一个 G
- 每个 P 维护一个本地运行队列(
工作窃取(Work Stealing):
- 当 P 的本地队列为空时,会从全局队列或其他 P 的队列中”偷取” G
- 这避免了全局锁,提高了并发性能
调度
调度是协程运行的核心机制,决定哪个协程在哪个 M 上执行。
调度流程
flowchart TD
A[schedule 函数] --> B{本地队列有 G?}
B -->|是| C[从本地队列获取]
B -->|否| D{全局队列有 G?}
D -->|是| E[从全局队列获取]
D -->|否| F[工作窃取]
F --> G{找到 G?}
G -->|是| H[执行 G]
G -->|否| I[进入自旋/休眠]
C --> H
E --> H
H --> J[G 执行完成]
J --> A
I --> A
schedule 函数核心逻辑:
1 | // runtime/proc.go |
主动让出调度
协程可以主动让出 CPU,让其他协程执行。
runtime.Gosched
runtime.Gosched 让当前 goroutine 主动让出 CPU:
1 | // runtime/proc.go |
流程:
- 将当前 G 的状态从
_Grunning改为_Grunnable - 解除 G 与 M 的绑定(
dropg) - 将 G 放入全局运行队列
- 调用
schedule重新调度
gopark
gopark 是 Go 运行时中用于将 goroutine 从运行状态切换到等待状态的核心函数。当 goroutine 需要进行阻塞操作(如 channel 操作、定时器等待、同步原语等)时,都会调用 gopark 来让出 CPU。
函数签名:
1 | // runtime/proc.go |
参数说明:
unlockf:解锁函数,当 goroutine 被唤醒时,如果条件满足会调用此函数尝试解锁lock:需要解锁的锁对象reason:等待原因(如waitReasonChanSend、waitReasonChanReceive、waitReasonSleep等)traceEv:跟踪事件类型traceskip:跟踪跳过的帧数
执行流程:
1 | // runtime/proc.go |
park_m 函数:
park_m 是实际执行 park 操作的函数,运行在 g0 栈上:
1 | // runtime/proc.go |
关键步骤:
- 状态转换:将 G 的状态从
_Grunning转换为_Gwaiting - 解除绑定:调用
dropg()解除 G 与 M 的绑定 - 尝试解锁:如果提供了
unlockf函数,尝试调用它- 如果解锁成功(返回 false),说明条件已满足,立即将 G 状态改为
_Grunnable并执行 - 如果解锁失败(返回 true),说明需要等待,进入调度
- 如果解锁成功(返回 false),说明条件已满足,立即将 G 状态改为
- 重新调度:调用
schedule()让 M 执行其他 G
goready
goready 是与 gopark 对应的唤醒函数,用于将处于 _Gwaiting 状态的 goroutine 唤醒并设置为 _Grunnable:
1 | // runtime/proc.go |
流程:
- 检查 G 的状态必须是
_Gwaiting - 将状态从
_Gwaiting转换为_Grunnable - 将 G 放入当前 P 的本地运行队列(
next=true表示高优先级,放入runnext) - 调用
wakep()尝试唤醒空闲的 M 或创建新的 M
gopark 的使用场景
gopark 在 Go 运行时中被广泛使用,主要场景包括:
Channel 操作:
- 当 channel 发送或接收阻塞时,调用
gopark等待 - 将 G 包装成
sudog加入 channel 的等待队列 - 当条件满足时,通过
goready唤醒
- 当 channel 发送或接收阻塞时,调用
定时器:
time.Sleep会创建定时器并调用gopark- 定时器到期后通过
goready唤醒
同步原语:
sync.Mutex、sync.RWMutex、sync.Cond等都会使用gopark- 当锁被占用时,调用
gopark等待 - 锁释放时通过
goready唤醒等待的 G
网络 IO:
- 网络操作未就绪时,通过
gopark等待 - IO 就绪后通过 epoll/kqueue 机制唤醒
- 网络操作未就绪时,通过
gopark 与 Gosched 的区别
| 特性 | gopark | Gosched |
|---|---|---|
| 状态转换 | _Grunning → _Gwaiting |
_Grunning → _Grunnable |
| 是否可被调度 | 否,需要被唤醒 | 是,立即可被调度 |
| 使用场景 | 阻塞操作(channel、锁等) | 主动让出 CPU |
| 唤醒方式 | 需要调用 goready |
自动进入调度队列 |
| 等待原因 | 有明确的等待原因 | 无等待原因 |
总结:gopark 是 Go 运行时实现阻塞操作的核心机制,它让 goroutine 能够高效地等待各种条件,避免了忙等待,提高了 CPU 利用率。
进入阻塞的系统调用
当 G 在执行过程中调用了会阻塞的系统调用(如 read、accept、epoll_wait 等)时,会进入与 gopark 不同的路径:G 不会变成 _Gwaiting,而是变为 _Gsyscall,同时当前 P 被标记为 _Psyscall。这样在 G 阻塞在内核期间,该 P 可以被其他 M 接管,继续运行队列里的其他 G,从而避免“一个 G 卡在 syscall 导致整个 P 闲置”。
进入系统调用时的状态变化
sequenceDiagram
participant G as 当前 G
participant M as 当前 M
participant P as 当前 P
participant Sched as 调度器/其他 M
G->>M: 即将执行阻塞型 syscall(如 read)
M->>M: entersyscall() / entersyscallblock()
Note over G: _Grunning → _Gsyscall
Note over P: _Prunning → _Psyscall
M->>P: P 与 M 仍关联,但 P 可被“窃取”
Sched->>P: 其他 M 可窃取该 P 运行本地队列的 G
Note over G,M: G 与 M 一起阻塞在内核
G->>M: 系统调用返回
M->>M: exitsyscall()
Note over G: _Gsyscall → _Grunnable
M->>M: 尝试重新获取 P(原 P 或空闲 P)
alt 获取到 P
M->>G: 继续执行 G
else 获取不到 P
M->>Sched: 将 G 放入全局队列,M 休眠
end
- G 的状态:
_Grunning→_Gsyscall- 表示“当前正在执行系统调用”,既不是可运行,也不是在用户态等待某条件(后者用
_Gwaiting)。
- 表示“当前正在执行系统调用”,既不是可运行,也不是在用户态等待某条件(后者用
- P 的状态:
_Prunning→_Psyscall- 表示“绑定的 M 正在系统调用中”,调度器可以把该 P 从当前 M 上解绑,交给别的 M 去跑该 P 本地队列里的 G(即 P 被“窃取”)。
因此,进入阻塞的系统调用时,协程状态是 _Gsyscall,而不是 _Gwaiting。_Gwaiting 用于在用户态被 runtime 挂起等待(channel、锁、timer、网络 poll 等);_Gsyscall 专门表示“正在内核里执行 syscall”。
与 gopark(_Gwaiting)的区别
| 维度 | 阻塞的系统调用(_Gsyscall) | gopark(_Gwaiting) |
|---|---|---|
| 触发方式 | 用户代码或 runtime 调用 syscall | runtime 内部(channel、锁等) |
| G 状态 | _Gsyscall |
_Gwaiting |
| 阻塞位置 | 内核态(卡在 syscall) | 用户态(被 runtime 挂起) |
| P 状态 | _Psyscall,P 可被其他 M 窃取 |
P 仍可继续跑其他 G(当前 G 已解绑) |
| 恢复方式 | syscall 返回后 exitsyscall |
其他 G 调用 goready 等唤醒 |
系统调用返回后
exitsyscall()会把 G 从_Gsyscall改回_Grunnable。- 当前 M 会尝试重新绑定一个 P(优先拿回原来的 P),若拿到则继续执行该 G;若拿不到则把 G 放进全局运行队列,M 休眠,等待被再次唤醒。
这样在 G 进入阻塞的系统调用时,协程状态为 _Gsyscall,P 可被复用,从而在大量阻塞 IO 场景下仍能保持较高的并发度。
被抢占
为了防止某个协程长时间占用 CPU,Go 实现了抢占式调度。
超时抢占
sysmon(系统监控线程)会定期检查运行时间过长的 G:
1 | // runtime/proc.go |
抢占机制:
异步抢占(Go 1.14+):
- 通过信号(SIGURG)实现异步抢占
- 在函数调用时检查抢占标志
- 如果被抢占,保存上下文并让出 CPU
同步抢占(旧版本):
- 在栈增长时检查抢占标志
- 在函数调用时检查抢占标志
抢占标志:
- G 的
preempt字段表示需要被抢占 - P 的
preempt字段表示需要进入调度
- G 的
栈增长抢占
在栈增长时也会检查抢占:
1 | // runtime/stack.go |
回收协程
当协程执行完成后,不会立即销毁,而是进入回收流程以便复用。
回收队列
协程执行完成后会调用 goexit 函数:
1 | // runtime/proc.go |
回收流程:
- 状态转换:从
_Grunning转为_Gdead - 清理资源:
- 解除 G 与 M 的绑定
- 清理栈相关的 GC 统计
- 放入空闲列表:调用
gfput将 G 放入 P 的空闲 G 列表(gFree) - 重新调度:调用
schedule继续执行其他 G
空闲G列表
每个 P 维护一个空闲 G 列表:
1 | // runtime/runtime2.go |
复用机制:
- 获取空闲 G:
gfget从 P 的空闲列表中获取 - 放回空闲列表:
gfput将 G 放回空闲列表 - 限制数量:空闲列表最多保存 64 个 G,多余的会被释放
这样可以避免频繁创建和销毁 G,提高性能。
销毁协程
协程的销毁发生在以下情况:
- 空闲列表溢出:当空闲 G 列表超过限制时,多余的 G 会被释放
- 程序退出:所有 G 都会被清理
- 栈收缩:如果 G 的栈过大,可能会被释放并重新分配
栈释放
1 | // runtime/stack.go |
全局清理
程序退出时会清理所有 G:
1 | // runtime/proc.go |
完整生命周期流程图
sequenceDiagram
participant User as 用户代码
participant Newproc as newproc
participant Newproc1 as newproc1
participant P as P(处理器)
participant M as M(线程)
participant G as G(协程)
participant Sched as 调度器
participant Sysmon as sysmon
User->>Newproc: go func()
Newproc->>Newproc1: 创建新 G
Newproc1->>G: 分配栈空间
Newproc1->>G: 初始化上下文
Newproc1->>G: 设置状态为 _Grunnable
Newproc1->>P: 放入本地队列
Newproc1->>M: wakep() 唤醒 M
M->>Sched: schedule()
Sched->>P: 从本地队列获取 G
P-->>Sched: 返回 G
Sched->>M: 绑定 G 到 M
M->>G: 执行 G 代码
G->>G: 运行中 (_Grunning)
alt 主动让出
G->>Sched: Gosched()
Sched->>P: 放入全局队列
Sched->>M: 重新调度
else 被抢占
Sysmon->>G: 设置抢占标志
G->>Sched: 检查抢占
Sched->>P: 放入队列
Sched->>M: 重新调度
else IO 阻塞
G->>G: 进入等待 (_Gwaiting)
G->>Sched: 让出 CPU
Note over G: 等待 IO 就绪
Note over G: IO 就绪后唤醒
G->>P: 重新放入队列
else 执行完成
G->>G: goexit()
G->>G: 状态转为 _Gdead
G->>P: 放入空闲列表
G->>Sched: 重新调度
end
总结
协程的生命周期包括:
- 创建阶段:通过
newproc创建,分配栈空间,初始化上下文,状态为_Grunnable - 运行阶段:被调度执行,绑定 M 和 P,状态为
_Grunning - 调度阶段:可能主动让出、被抢占或阻塞等待
- 回收阶段:执行完成后放入空闲列表,状态为
_Gdead,等待复用 - 销毁阶段:空闲列表溢出或程序退出时释放资源
这种设计实现了高效的协程复用,避免了频繁创建和销毁的开销,是 Go 高并发性能的重要基础。