前言

学习 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 联动)

  1. sysmon 监控线程周期性扫描所有 P;
  2. 判断某个 P 上正在运行的 G 连续运行超过 10ms
  3. 获取当前绑定的 M(操作系统线程),向该线程发送 SIGURG
  4. OS 收到信号,中断线程正常执行流,进入 Go 预先注册的信号处理函数;
  5. 信号处理函数修改 G 的上下文,打上抢占标记;
  6. ⚠️ 不会立刻切换协程:Go 采用安全点机制,必须等到 G 执行到下一个函数调用安全点;
  7. G 检测抢占标记,主动保存运行现场;
  8. 当前 G 暂停,放回 P 的本地队列;P 寻找下一个可运行 G 执行。

4. 关键误区

  1. 信号发给 M(操作系统线程),不是发给 G;
  2. 信号只是 “通知标记”,不能直接中断任意代码;必须等待安全点;
  3. 信号抢占只解决:CPU 密集协程霸占 P 的场景;
  4. 阻塞系统调用(文件 IO、socket)不会触发信号抢占,走 P 与 M 解绑迁移逻辑。

二、运行时信号量 Semaphore:线程 / 协程休眠唤醒原语

1. 信号量是什么

Go runtime 内部实现的休眠原语,不依赖操作系统信号。 信号量维护一个计数器,提供两类操作:

  • acquire():计数器 > 0,则计数器 - 1,直接返回;计数器 = 0,当前 M 休眠阻塞;
  • release():计数器 + 1,如果存在休眠的 M,唤醒其中一个。

标准库 sync.RWMutexsync.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 因为信号量休眠阻塞:

  1. M 会和 P 解绑
  2. P 不会闲置,P 寻找其他 M 继续运行队列中的 G;
  3. M 休眠在内核态,等待信号量 release;
  4. 被唤醒后,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 读锁。

  1. CPU 密集 G 长时间占用 P → sysmon 发送 SIGURG 信号,触发抢占;
  2. 到达安全点后,G 让出 P;
  3. 等待读锁的协程,之前因写锁阻塞,依靠 readerSem 信号量 唤醒;

总结分工: 信号负责「打断霸占 CPU 的协程」;信号量负责「协程阻塞等待与条件唤醒」。二者互不替代,是两套独立机制。

五、常见面试问题汇总

Q1:信号抢占会触发 M 和 P 解绑吗?

不会。抢占属于用户态调度,M 依旧绑定 P,只是切换运行的 G。 只有系统调用阻塞、信号量休眠时,才会发生 P 与 M 解绑。

Q2:信号量休眠和系统调用阻塞有区别吗?

有区别: 信号量休眠:Go runtime 主动调用操作系统休眠函数; 系统调用阻塞:M 主动进入内核等待 IO,两套路径,但都会触发 P 迁移。

Q3:可以用信号量实现抢占调度吗?

不行。信号量只能让协程主动休眠,无法强制打断正在运行、不主动让出的 CPU 密集协程,这也是 Go 必须引入信号抢占的根本原因。

结语

简单一句话记忆: 信号是内核中断,用来强制抢占;信号量是运行时休眠原语,实现阻塞等待唤醒。 理解两套机制,就能打通:抢占调度、RWMutex 原理、channel 等待队列、P-M 绑定迁移等整套 GMP 调度知识。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐