信号抢占 vs 信号量,理清两套调度底层机制
前言
学习 GMP 调度模型时,极易混淆两组概念:操作系统信号(Signal)、Go 运行时信号量(Semaphore)。 不少人会简单认为二者都是 “通知协程”,本质完全不同:
- 信号(Signal):操作系统内核提供的中断机制,用于实现 Go1.14 异步抢占调度;
- 信号量(Semaphore):运行态实现的休眠 / 唤醒原语,用于协程、线程阻塞等待(RWMutex、通道、调度休眠都依赖它)。
本文立足 GMP 模型,细致拆解两者实现、作用场景、执行流程,同时讲清二者如何配合完成 Goroutine 调度。
前置约定: G (Goroutine) 用户协程;M (OS 线程) 内核线程;P (Processor) 调度上下文。
一、操作系统信号 Signal:实现异步抢占调度
1. 什么是信号
信号是 Linux/Unix 内核提供的异步软件中断。内核可以主动向一个线程发送信号,强制打断线程当前执行流,转入预先注册的信号处理函数。 Go 调度器利用 SIGURG 信号 实现协程抢占。
2. 为什么需要信号抢占?
Go 1.14 之前只有协作式调度:Goroutine 必须在函数调用、通道阻塞等位置主动让出 P。 如果存在纯 CPU 密集循环(无任何函数调用),G 会持续霸占 P,其他协程饥饿。 Go 1.14 引入 异步抢占:依靠信号,强制中断长时间运行的 G。
3. 完整抢占流程(GMP 联动)
sysmon监控线程周期性扫描所有 P;- 判断某个 P 上正在运行的 G 连续运行超过 10ms;
- 获取当前绑定的 M(操作系统线程),向该线程发送
SIGURG; - OS 收到信号,中断线程正常执行流,进入 Go 预先注册的信号处理函数;
- 信号处理函数修改 G 的上下文,打上抢占标记;
- ⚠️ 不会立刻切换协程:Go 采用安全点机制,必须等到 G 执行到下一个函数调用安全点;
- G 检测抢占标记,主动保存运行现场;
- 当前 G 暂停,放回 P 的本地队列;P 寻找下一个可运行 G 执行。
4. 关键误区
- 信号发给 M(操作系统线程),不是发给 G;
- 信号只是 “通知标记”,不能直接中断任意代码;必须等待安全点;
- 信号抢占只解决:CPU 密集协程霸占 P 的场景;
- 阻塞系统调用(文件 IO、socket)不会触发信号抢占,走 P 与 M 解绑迁移逻辑。
二、运行时信号量 Semaphore:线程 / 协程休眠唤醒原语
1. 信号量是什么
Go runtime 内部实现的休眠原语,不依赖操作系统信号。 信号量维护一个计数器,提供两类操作:
acquire():计数器 > 0,则计数器 - 1,直接返回;计数器 = 0,当前 M 休眠阻塞;release():计数器 + 1,如果存在休眠的 M,唤醒其中一个。
标准库 sync.RWMutex、sync.Mutex、channel 底层等待队列,全部基于信号量实现。 RWMutex 内两个核心信号量:
readerSem:等待读锁的协程唤醒信号量writerSem:等待写锁的协程唤醒信号量
2. 信号量在 GMP 中的典型场景
场景 1:写锁到来,大量读协程被阻塞 读协程执行 RLock() 发现 readerCount < 0(存在写操作),调用 acquire(readerSem),对应的 M 休眠等待信号量释放。
场景 2:写锁释放 执行 Unlock(),调用 release(readerSem),唤醒所有等待读锁的 M,读协程重新竞争锁。
场景 3:多个协程等待通道数据 没有数据时,G 挂载到通道等待队列,底层依靠信号量休眠对应 M;写入数据后调用 release 唤醒等待的协程。
3. 信号量休眠时 GMP 行为重点
当 M 因为信号量休眠阻塞:
- M 会和 P 解绑;
- P 不会闲置,P 寻找其他 M 继续运行队列中的 G;
- M 休眠在内核态,等待信号量 release;
- 被唤醒后,M 尝试重新绑定 P;获取不到 P,则把 G 放入全局队列,M 进入休眠。
三、核心对比:信号 Signal vs 信号量 Semaphore
表格
| 维度 | 操作系统信号 Signal(SIGURG) | 运行时信号量 Semaphore |
|---|---|---|
| 提供者 | 操作系统内核 | Go runtime 用户态实现 |
| 作用目标 | 操作系统线程 M | 阻塞等待锁 /channel 的 M |
| 核心用途 | 异步抢占长时间运行的 G | 协程阻塞休眠、条件唤醒 |
| 触发方式 | 主动发送中断,异步通知 | 主动 acquire 阻塞,release 唤醒 |
| 是否强制中断 | 产生中断,设置抢占标记;依赖安全点切换 | 不会中断代码,主动进入休眠 |
| 典型案例 | Go1.14 协程抢占调度 | RWMutex 读写等待、channel 等待队列 |
四、一道经典面试串联题:两种机制如何分工?
场景:一个 P 上运行一个无限循环 CPU 密集 G,同时有若干协程等待 RWMutex 读锁。
- CPU 密集 G 长时间占用 P → sysmon 发送 SIGURG 信号,触发抢占;
- 到达安全点后,G 让出 P;
- 等待读锁的协程,之前因写锁阻塞,依靠 readerSem 信号量 唤醒;
总结分工: 信号负责「打断霸占 CPU 的协程」;信号量负责「协程阻塞等待与条件唤醒」。二者互不替代,是两套独立机制。
五、常见面试问题汇总
Q1:信号抢占会触发 M 和 P 解绑吗?
不会。抢占属于用户态调度,M 依旧绑定 P,只是切换运行的 G。 只有系统调用阻塞、信号量休眠时,才会发生 P 与 M 解绑。
Q2:信号量休眠和系统调用阻塞有区别吗?
有区别: 信号量休眠:Go runtime 主动调用操作系统休眠函数; 系统调用阻塞:M 主动进入内核等待 IO,两套路径,但都会触发 P 迁移。
Q3:可以用信号量实现抢占调度吗?
不行。信号量只能让协程主动休眠,无法强制打断正在运行、不主动让出的 CPU 密集协程,这也是 Go 必须引入信号抢占的根本原因。
结语
简单一句话记忆: 信号是内核中断,用来强制抢占;信号量是运行时休眠原语,实现阻塞等待唤醒。 理解两套机制,就能打通:抢占调度、RWMutex 原理、channel 等待队列、P-M 绑定迁移等整套 GMP 调度知识。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)