Archipelago

Lec 4调度进阶:系统调用、抢占与工作窃取

讲义完成更新于 2026-09-28

调度进阶:系统调用、抢占与工作窃取

TL;DR

  • 阻塞式系统调用通过 sysmon 巡查(约 10ms 阈值)触发 handoffp,剥离被阻塞 M 的关联 P 并转交给其他物理线程,防止 P 本地队列中的 Goroutine 被饿死。
  • Go 1.14 引入基于 SIGURG 信号的异步抢占,彻底终结了无函数调用紧密死循环(for{})导致整个调度器与 GC STW 永久卡死的问题。
  • 工作窃取(Work-Stealing)遵循严格的防饥饿优先级:优先消费本地队列,每 61 轮强制扫描一次全局队列,随后检查网络轮询器,最后随机从其他 P 偷取一半任务(先 runq 后 runnext)。
  • 容器环境下必须使用 automaxprocs 识别 cgroup CPU 配额,避免 Go 默认读取物理机 CPU 核数导致线程过度创建与上下文切换震荡。

系统调用与 P 的解绑重绑(handoffp)

当物理线程 M 执行内核系统调用时,可能会被操作系统长时间阻塞挂起(如阻塞读取文件或本地套接字)。如果此时 M 依然牢牢持有逻辑处理器 P,那么该 P 本地队列中排队的所有其他 Goroutine 都将陷入饥饿停滞。

为了化解该矛盾,Go 在系统调用前后设计了非对称的解绑与重绑流水线:

[发起系统调用]
     │
     ├── 快系统调用: entersyscall ──> 仅标记 P 为 _Psyscall,不立即解绑
     │                                 (押注调用迅速返回,减少重绑开销)
     │
     └── 慢系统调用: 超过 10ms ──────> sysmon 介入执行 handoffp
                                       (剥离 P,唤醒新 M 继续跑本地队列)

[系统调用返回: exitsyscall]
     │
     ├── 1. 尝试重新绑定原 P ───────> 成功则继续在同一 P 上运行(缓存局部性最高)
     ├── 2. 尝试获取任意空闲 P ─────> 成功则继续执行
     └── 3. 无可用 P ───────────────> 当前 G 放入全局队列,M 释放并进入休眠

两种系统调用路径处理

  1. 快系统调用(如简单非阻塞时间查询): 进入 entersyscall 时,仅原子性将 P 的状态变更为 _Psyscall,并不立即剥离 P。运行时预期此类系统调用将在微秒内返回,避免了频繁解绑和分配线程的高昂上下文开销。
  2. 慢/阻塞系统调用: 后台常驻的系统监控线程 sysmon 每隔 10ms 左右进行一次全系统巡检。当发现某个处于 _Psyscall 状态的 P 停留时间超过一个时间片且其本地队列非空时,立即执行 handoffp:强行将该 P 与当前阻塞的 M 解绑,并在空闲 M 链表中唤醒或新建一个物理线程接管该 P,继续消费本地队列。

返回后的重绑竞争(exitsyscall)

当阻塞在内核中的 M 终于完成系统调用返回用户态时,执行 exitsyscall:

  1. 优先抢回原 P:如果原来的 P 依然处于空闲状态,直接重新绑定(获得极高的 CPU 缓存命中率)。
  2. 退而求其次抢空闲 P:若原 P 已被其他工作线程接管,则尝试在全局空闲 P 列表中获取任意一个可用 P。
  3. 兜底入全局队列:若当前所有 P 均满载,该 M 无法继续运行 Go 代码。此时它将当前 Goroutine 置为 _Grunnable 并放入全局队列,当前 M 则清空上下文进入休眠状态(mPark)。

抢占调度演进:从协作到信号异步

抢占机制是现代多任务运行时不可或缺的基础设施,它确保任何恶意或耗时计算无法无限期霸占物理核心。

协作式抢占缺陷(Go 1.14 之前)

在 Go 1.14 之前,调度器完全依赖编译器在函数序言中插入的栈边界检查进行协作式抢占。

常见误区

紧密循环卡死陷阱:如果一段用户代码处于一个完全没有函数调用的紧密计算循环中(例如密集矩阵运算或 for { i++ }),由于执行流永远不会触发函数序言中的 morestack 栈检查,调度器根本没有任何途径强行打断该 Goroutine。 这会带来灾难性后果:

  • 该 Goroutine 永久霸占一个 P 与一个 CPU 物理核心;
  • 当垃圾回收器触发 STW(Stop-The-World)需要所有 Goroutine 停在安全点时,由于该循环无法停下,整个应用程序的所有请求都会被永久阻塞,导致服务僵死。 :::

异步信号抢占(Go 1.14+)

为了彻底根除上述漏洞,Go 1.14 引入了基于操作系统信号的非合作式异步抢占机制:

[sysmon 巡检] 发现 Goroutine 连续运行 > 10ms
      │
      ▼
发送 POSIX 信号: pthread_kill(M, SIGURG)
      │
      ▼
[目标 M 接收信号] 触发预注册的 runtime.sighandler
      │
      ▼
[异步安全点校验] 检查当前指令是否位于写屏障/运行锁等非安全区
      │ (若是安全点)
      ▼
修改目标线程上下文寄存器 (RIP/PC),将其强行转向 runtime.asyncPreempt
      │
      ▼
保存通用寄存器现场 ──> 注入 gopark ──> 切换回调度器 schedule()
  • 信号选型:在 UNIX 体系下选用了 SIGURG(紧急外带数据信号),因为该信号极少被用户程序或第三方 C 动态链接库使用,不会引起信号冲突。
  • 异步安全点(Async Safe Point):信号可能在任意 CPU 指令执行时触发,但并不是任何指令都可以安全打断。若当前线程正在执行写屏障代码、持有调度器内部自旋锁,或者处于系统栈 g0 上,运行时会标记此区域不可打断,推迟抢占直至退出临界区。

工作窃取算法(Work-Stealing)

当一个物理线程 M 将自身绑定的 P 本地队列以及 runnext 全部执行完毕后,为了避免 CPU 闲置,调度器驱动其进入 findRunnable() 流水线执行工作窃取。

整个任务搜寻算法遵循严格的防饥饿策略:

  1. 第 61 次调度规则(避免全局队列饥饿): 为了防止本地任务无限循环生成导致全局队列中的任务被长期遗忘,每个 P 维护一个计数器 schedtick。每当本地调度完成 61 次任务后,调度器会无条件优先检查一次全局队列并出队执行。
  2. 网络轮询器探测(Netpoller Check): 检查非阻塞网络 I/O 轮询器(基于 Linux epoll 或 macOS kqueue)。若有网络数据到达或套接字就绪,将对应的等待 Goroutine 唤醒并投入运行。
  3. 随机窃取其他 P 的本地任务:
    • 调度器通过伪随机算法遍历所有其他 P,尝试从某个繁忙的 P 队列中窃取**一半(Half)**的 Goroutine 放入自己的本地队列中。
    • 两阶段窃取原则:窃取方优先从目标 P 的普通环形队列 runq 中偷取;只有当连续三次从其 runq 偷取失败后,才会尝试动用目标 P 的最高优先级槽位 runnext。这最大限度保护了目标 P 刚刚生成的子协程的时间局部性。

高频工程追问与深度认知

GOMAXPROCS 与容器 CPU Quota 踩坑

在 Docker 或 Kubernetes 容器环境中,容器通常通过 Linux Cgroups 限制 CPU 使用配额(例如 cpu.cfs_quota_us 限制为 2 核,而物理宿主机拥有 128 核)。 由于 Go 运行时默认调用 runtime.NumCPU() 读取宿主机的物理核数,导致在 2 核容器内创建了 128 个逻辑处理器 P 和大量的物理线程 M。 这会引发灾难性的系统上下文切换风暴、锁争用加剧以及高昂的线程栈内存浪费。工程实践中必须在服务 main 函数第一行显式引入 go.uber.org/automaxprocs,自动根据 Cgroup 配额纠正 GOMAXPROCS。

Netpoller 异步多路复用模型

Go 将底层复杂的非阻塞 I/O(O_NONBLOCK)与事件驱动封装为看似同步阻塞的用户 API:

  • 当用户调用 net.Conn.Read() 遇到数据未就绪时,底层系统调用返回 EAGAIN 或 EWOULDBLOCK。
  • Go 运行时不会让物理线程睡眠,而是调用 runtime.netpollblock(),将当前 Goroutine 挂入特定套接字的等待队列,并触发 gopark 让出执行权,当前 M 继续执行其他 Goroutine。
  • 操作系统完成网络收包后,epoll_wait 捕获到套接字就绪事件,sysmon 或自旋的 M 在调用 netpoll() 时将其批量捞出并标记为 _Grunnable,重新投递至就绪队列继续执行。

我的理解

Go 调度器最根本的工程洞见在于打破了操作系统内核的盲区:内核只认识物理线程,它不知道哪个线程正在等网络、哪个线程正在进行纯计算、哪个线程正在运行关键业务。 通过在用户空间引入逻辑处理器 P 和 Netpoller,Go 将成千上万个网络连接的 I/O 阻塞转换为纯粹的协程让出(gopark),而将昂贵的物理线程解绑严格限制在极少数真正的阻塞式系统调用(如磁盘 I/O)上。这使得开发者能够继续享用最朴素自然的同步顺序编程心智,同时在底层压榨出等同于事件驱动框架(Node.js / Netty)的高并发性能。