目录

Linux-28 同步原语:futex 为什么被发明

目录

上一篇讲调度器如何决定「谁能运行」。这一篇讲并发程序里更常见的问题:明明调度器愿意让线程运行,线程却因为一把锁主动放弃 CPU。

锁看起来只有两步:检查是否空闲,空闲就占有;否则等待。但真正困难的是等待方式:

  • 一直循环检查,会烧 CPU 和缓存带宽
  • 每次都睡进内核,无竞争时又把系统调用成本白白付了一遍
  • 唤醒所有等待者,会制造惊群
  • 只唤醒一个,又要防止丢失唤醒、饥饿和优先级反转

futex 的发明,就是为了把这些矛盾拆开:用户态负责判断和快速竞争,内核只负责真正需要睡眠的等待者。

先看五个问题:

  1. 有了 compare-and-swap,为什么还需要操作系统参与锁?
  2. 自旋锁和互斥锁的分界在哪里?「自旋更快」为什么经常是错的?
  3. futex 为什么叫 fast userspace mutex?它快在什么地方?
  4. futex 如何避免「线程检查锁时仍被占用,准备睡眠时锁却已释放」造成的丢失唤醒
  5. Go 的 sync.Mutex、channel 和 goroutine park,是否每次都会调用 futex

1. 同步的根问题:读—改—写不能被插队

1.1 counter++ 为什么会丢

一个看似简单的自增,通常至少分成读、计算、写三步:

初始 counter = 10

CPU 0                          CPU 1
load counter -> 10             load counter -> 10
add 1 -> 11                    add 1 -> 11
store 11                       store 11

期望结果:12
实际结果:11                  <- 一次更新丢了

问题不是某条指令执行到一半被切走,而是整个读—改—写序列不是一个不可分割的动作。调度、不同 CPU 并行、编译器重排和 CPU 内存序都可能暴露中间状态。

1.2 原子性、可见性、有序性是三件事

并发代码正确需要区分三个性质:

性质 要回答的问题 典型机制
原子性 操作会不会被观察到一半 原子 RMW、锁
可见性 一个 CPU 的写何时被另一个 CPU 看见 cache coherence、内存序
有序性 编译器/CPU 能否把操作移到锁前或锁后 acquire/release、barrier

一把正确的 mutex 不只把状态从 0 改成 1。加锁成功通常具有 acquire 语义:后续读写不能跑到锁前;解锁通常具有 release 语义:临界区写入必须先对后继持锁者可见。

线程 A:                        线程 B:
data = 42                      lock(m)
unlock(m)  -- release          print(data)  // 必须看到 42
                                -- acquire

锁变量本身只是同步媒介;真正受保护的是临界区里的共享数据。

1.3 原子指令如何抢锁

最小的用户态锁可以用 CAS(Compare-And-Swap)表达:

// 0 = unlocked, 1 = locked
while (!atomic_compare_exchange(&lock, 0, 1)) {
    // retry
}

critical_section();
atomic_store_release(&lock, 0);

CAS 的语义近似:

if *addr == expected:
    *addr = desired
    return success
else:
    expected = *addr
    return failure

检查旧值和写入新值是一个原子动作,所以多个 CPU 同时竞争时只有一个能成功。x86 常见实现使用带 lock 前缀的指令,ARM 常用独占加载/存储或 LSE 原子指令。

但这只解决了谁赢,没解决输家怎么办


2. 第一种等待:自旋

2.1 自旋锁把等待变成计算

输家可以不停重试:

while (atomic_exchange(&lock, 1) == 1)
    cpu_relax();

优点是没有系统调用、没有线程睡眠和上下文切换。若持锁者几十纳秒后就释放,自旋可能是最便宜的选择。

成本则非常直接:等待者在执行无业务价值的指令。

锁还要持有 2ms,8 个 CPU 上有 7 个等待者:

持锁者:完成临界区
等待者:7 × 2ms = 14ms CPU 时间全部烧在轮询

更糟的是多个 CPU 反复写同一条 cache line,会触发缓存一致性协议来回转移所有权,形成 cache line ping-pong。锁变量只有 4 字节,也能把整台机器的互联带宽打满。

2.2 先读后写,减少缓存抖动

一个粗糙的 exchange 循环每次都写锁变量。常见改进是 test-and-test-and-set:

for (;;) {
    while (atomic_load_relaxed(&lock) != 0)
        cpu_relax();                    // 只读,共享缓存行

    if (atomic_exchange_acquire(&lock, 1) == 0)
        break;                          // 看到空闲后才尝试写
}

锁被占用时,所有等待 CPU 可以共享只读 cache line;只有观察到释放后才一起发起写竞争。这仍会在解锁瞬间形成争抢,但比持续原子写好得多。

cpu_relax() 也不是装饰:x86 上通常是 PAUSE,能提示处理器这是自旋循环,减少流水线和 SMT 同胞线程受到的伤害;ARM 上有对应的等待提示指令。

2.3 自旋什么时候合适

粗略判断:

自旋成本 ≈ 等待时间 × 占用 CPU 数
睡眠成本 ≈ 系统调用 + 入队 + 调度 + 上下文切换 + 唤醒延迟

预计等待 << 睡眠成本  -> 自旋可能划算
预计等待较长           -> 应该睡眠

适合自旋:

  • 内核里不能睡眠的短临界区(例如某些中断/原子上下文)
  • 用户态临界区极短、竞争很低,并经过压测验证
  • 多核机器上持锁者正在另一个 CPU 运行

不适合自旋:

  • 单核:等待者自旋反而阻止持锁者获得 CPU
  • 持锁者被调度走了:等待者烧完时间片也等不到释放
  • 临界区里可能做 IO、缺页、日志或系统调用
  • 容器有紧 CPU quota:自旋会更快耗光整个 cgroup 的预算
  • 过量线程竞争:CPU 和缓存一致性流量一起爆炸

「锁只持有几微秒,所以应该自旋」仍不完整。还要看持锁者此刻是否在运行、等待者数量和 CPU 是否有富余。

2.4 内核自旋锁为什么不能简单照搬到用户态

内核知道当前是否处于中断上下文、是否允许抢占,也能在持锁期间关闭本 CPU 抢占,确保持锁代码不会被普通任务切走。用户态不能禁止调度:

线程 A 获得用户态自旋锁
-> 时间片到,被调度器切走
线程 B 在另一个 CPU 自旋等待 A
-> A 没在运行,B 的循环不可能让锁更快释放

因此普通用户态同步不能只靠无界自旋。它需要一种办法:短等时自旋,确认要久等时把线程交给内核睡眠。


3. 第二种等待:每次都进内核

3.1 内核能真正让线程停下来

内核阻塞锁可以维护等待队列:

lock():
  进入内核
  if 锁空闲:
      占有并返回
  else:
      当前线程挂入 wait queue
      标记为 sleeping
      schedule() 切给别的任务

unlock():
  进入内核
  释放锁
  从 wait queue 唤醒一个线程

等待者不再烧 CPU;调度器可以运行持锁者或其他有用任务。这解决了长等待,却引入了另一个浪费:绝大多数锁获取其实没有竞争。

3.2 无竞争也系统调用,太贵

典型服务里,同一把锁可能调用一百万次,但真正碰撞只有几百次。如果每次加锁/解锁都陷入内核:

1,000,000 次 lock
1,000,000 次 unlock
≈ 2,000,000 次 syscall

其中 99.9% 根本没有等待者

系统调用的成本不只是指令本身:用户态/内核态切换、安全检查、内核数据结构查找、可能的调度,以及 Spectre/Meltdown 缓解措施都会增加固定开销。

锁理想的形态应当是:

没有竞争:只用几条用户态原子指令
发生竞争:才进入内核睡眠
解锁无人等待:也不进入内核
解锁有人等待:进入内核唤醒必要数量

这正是 futex 的设计目标。


4. futex:内核只管理等待,不管理锁

4.1 futex 不是一把锁

futex 是 fast userspace mutex 的缩写,但 Linux 的 futex 系统调用不是一个完整 mutex 对象。它提供的是两个核心动作:

futex_wait(uaddr, expected)
futex_wake(uaddr, n)
  • WAIT:仅当用户地址 *uaddr == expected 时,原子地把当前线程睡到与该地址关联的内核等待队列
  • WAKE:唤醒等待在该地址上的最多 n 个线程

真正的锁状态、所有者、竞争位和快速路径都在用户态库里。内核只在争用时扮演「停车场」。

用户态内存中的 32-bit word
        |
        | CAS 成功                      无竞争快路径
        +-----------------------------> 直接进入临界区
        |
        | CAS 失败,多次确认仍被占用
        v
futex(FUTEX_WAIT, expected)             慢路径
        |
        v
内核按 (地址空间, 虚拟地址) 哈希到等待队列 -> 当前线程睡眠

这带来三个关键收益:

  1. 无竞争加锁无需系统调用
  2. 无等待者解锁无需系统调用
  3. 内核无需为每一把用户锁永久创建对象,只在有等待者时临时维护队列

4.2 一个简化的三状态 mutex

用户态 mutex 常把一个整数编码成三种状态:

0 = unlocked
1 = locked, no known waiters
2 = locked, contended / may have waiters

伪代码:

lock(m):
    if CAS(m, 0, 1):
        return                         // 快路径:全用户态

    for (;;) {
        old = exchange(m, 2)           // 标记存在竞争
        if old == 0:
            return                     // 刚好抢到
        futex_wait(m, 2)               // 仍为 2 才睡
    }

unlock(m):
    old = exchange_release(m, 0)
    if old == 2:
        futex_wake(m, 1)               // 可能有等待者,唤醒一个

真实的 glibc、musl 和运行时实现复杂得多:需要处理自适应自旋、递归锁、健壮锁、PI、取消、超时、公平性以及伪唤醒。但核心分层就是这几行。

4.3 futex 如何避免丢失唤醒

最危险的竞态发生在「用户态检查」和「内核睡眠」之间:

等待线程 B                       解锁线程 A
看到 m == 2(锁着)
                                m = 0
                                futex_wake():此时队列没人
调用 futex_wait()
永久睡眠?                      <- 如果 WAIT 盲睡,就丢失唤醒

futex 的关键契约是:WAIT 在内核里再次检查 *uaddr == expected,并把比较与入队睡眠组织成不会错过对应唤醒的原子序列。

在上面的竞态中,B 进入内核后发现 m 已经不是 2,立即返回 EAGAIN,回到用户态重试,不会睡下去。

futex_wait(m, 2):
  内核读取 m
  if m != 2:
      return -EAGAIN      <- 状态已变,绝不能睡
  把线程加入 m 对应的等待队列
  再进入睡眠

这也是为什么 expected 参数不能省略。内核不理解「锁是否可用」的业务语义,它只帮用户态验证:从你最后观察到现在,这个 32 位值仍然没变。

4.4 唤醒不是把锁直接交给线程

futex_wake(m, 1) 只把一个睡眠线程变成 runnable,放回调度器运行队列。它并不保证:

  • 被唤醒者立即运行
  • 被唤醒者一定抢到锁
  • 唤醒顺序严格 FIFO
  • 返回 WAIT 就代表条件成立

被唤醒者必须回用户态重新检查条件。因为在它真正上 CPU 前,一个刚到的线程可能已经通过 CAS 抢走锁。这称为 barging(插队),吞吐通常更高,但可能损害公平。

所以所有条件等待都必须写成循环:

pthread_mutex_lock(&mu);
while (!condition)
    pthread_cond_wait(&cv, &mu);
consume();
pthread_mutex_unlock(&mu);

不能写成 if:伪唤醒、竞争者插队或条件被另一个线程消耗后,醒来时条件可能仍不成立。

4.5 futex 地址为什么必须稳定

内核用用户地址对应的 key 找等待队列。私有 futex 通常按进程地址空间和虚拟地址识别;进程共享 futex 需要解析到共享映射背后的内核对象。

因此等待期间不能随意:

  • 释放承载 futex word 的内存
  • 移动对象却让旧地址仍有等待者
  • 把私有 futex 错用于跨进程共享
  • 在不同映射语义下误以为「数值相同就是同一把锁」

futex word 通常要求 4 字节对齐。它本身只是一段用户内存,生命周期必须由上层同步对象保证。


5. futex 系统调用的主要能力

5.1 WAIT、WAKE 与 PRIVATE

常见操作:

操作 作用 典型用途
FUTEX_WAIT 值等于 expected 才睡眠 基本互斥/条件等待
FUTEX_WAKE 唤醒最多 N 个等待者 解锁、广播
FUTEX_WAIT_BITSET 带 bitset 和更灵活超时的等待 条件分组、绝对时间
FUTEX_WAKE_BITSET 只唤醒 bitset 匹配者 选择性唤醒
FUTEX_REQUEUE 唤醒少量,其余搬到另一个 futex 队列 condvar 避免惊群
FUTEX_CMP_REQUEUE 比较值后再 requeue 防止竞态
FUTEX_WAKE_OP 原子修改一个值并按条件唤醒 复合同步操作
FUTEX_LOCK_PI 内核参与的优先级继承锁 实时系统

进程内同步应使用 FUTEX_PRIVATE_FLAG(封装宏常叫 FUTEX_WAIT_PRIVATE/WAKE_PRIVATE)。内核知道它不跨地址空间,可以省掉共享 key 的处理。

5.2 超时、信号与返回值

WAIT 返回并不等于「你等的事情发生了」。常见结果包括:

0           被唤醒,但仍要重新检查用户态条件
-EAGAIN     uaddr 的值已不等于 expected,未睡眠
-ETIMEDOUT  超时
-EINTR      被信号中断(具体重启语义取决于操作和内核)
-EFAULT     地址无效
-EINVAL     地址未对齐或参数无效

上层库必须把这些结果转换为 mutex/condvar/semaphore 的语义。应用通常不应直接调用 futex;应该用 pthread、C++ 标准库或语言运行时。

5.3 REQUEUE 为什么重要

条件变量广播可能有几百个线程等待:

pthread_cond_broadcast()
-> 如果 wake 500
-> 500 个线程同时 runnable
-> 但它们醒来后都要争同一把 mutex
-> 最终只有 1 个进入,499 个再次睡眠

这是典型惊群。FUTEX_REQUEUE 可以:

condvar 队列上的 500 人
  唤醒 1 个
  其余 499 个从 condvar futex 队列搬到 mutex futex 队列
  搬队列不需要先让线程运行

之后 mutex 每次释放再逐个唤醒,避免制造 500 个 runnable 线程。这说明 futex 不只追求「少一次 syscall」,还在减少无意义调度。

5.4 robust futex:持锁线程死了怎么办

普通 mutex 的持锁线程崩溃后,锁字仍可能显示被占有,其他线程会永远等待。robust mutex 让每个线程向内核登记一条自己持有锁的链表;线程退出时,内核遍历并标记 owner died,再唤醒等待者。

pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST);

后继线程加锁可能得到 EOWNERDEAD:它拿到了锁,但必须先修复被前任中途破坏的共享状态,再调用 pthread_mutex_consistent()。robust 解决的是检测持有者死亡,不是自动恢复业务不变量。

5.5 PI futex:优先级反转

三个线程:

L:低优先级,持有锁
H:高优先级,等待 L 的锁
M:中优先级,不需要锁,持续占 CPU

H 被 L 阻塞
L 又被 M 抢占
-> 最高优先级 H 间接被 M 拖住,这叫优先级反转

优先级继承(Priority Inheritance)临时把 L 提升到 H 的优先级,让 L 尽快运行并释放锁,随后恢复。普通 futex 快路径主要在用户态,内核看不到 owner 关系;PI futex 因此需要更多内核参与和 rt_mutex 支撑。

PI 不是普通 Web 服务的默认性能开关。它主要解决有严格调度优先级的实时系统中的延迟上界问题,代价是更复杂、更贵的慢路径。


6. pthread mutex:futex 上面的策略层

6.1 快路径与慢路径

Linux 上 glibc 的 pthread_mutex_t 在用户态保存锁字、owner、递归计数和类型等状态。普通 mutex 的无竞争路径大体是:

pthread_mutex_lock
  -> 原子 CAS 锁字
  -> 成功:返回                       无 syscall
  -> 失败:进入 __lll_lock_wait
           -> 自适应自旋/更新竞争状态
           -> futex WAIT               有竞争才 syscall

pthread_mutex_unlock
  -> release 原子操作
  -> 确认无等待者:返回               无 syscall
  -> 可能有等待者:futex WAKE(1)      必要时 syscall

因此用 strace 看不到 futex,不代表程序没有使用 pthread mutex,反而往往说明锁一直走快速路径。

# 只追踪 futex 慢路径
strace -f -e trace=futex ./app

# 统计系统调用:futex 次数高说明“进入内核的竞争”多,
# 但看不到纯用户态自旋和 CAS 失败
strace -f -c -e trace=futex ./app

6.2 mutex 类型不是只有一种

类型 重复加锁 非 owner 解锁 主要用途
PTHREAD_MUTEX_NORMAL 可能死锁 未定义行为 最低开销、正确程序默认选择
PTHREAD_MUTEX_ERRORCHECK 返回错误 返回错误 调试错误使用
PTHREAD_MUTEX_RECURSIVE owner 可递归,维护计数 返回错误 特殊递归设计,容易掩盖结构问题
adaptive(非标准扩展) 先短暂自旋再睡 依实现 多核短临界区

递归锁并不能修复锁顺序问题。它允许同一线程再次获取同一把锁,却让调用层级与状态不变量更难推理;多数场景应重构为明确的「已持锁内部函数」。

6.3 条件变量为什么必须和 mutex 配合

条件变量不保存业务条件,它只通知「某件事可能变化了」。正确顺序必须避免检查条件与进入等待之间的窗口:

pthread_mutex_lock(&mu);
while (queue_empty())
    pthread_cond_wait(&cv, &mu);
item = queue_pop();
pthread_mutex_unlock(&mu);

pthread_cond_wait() 原子地完成语义上的:

  1. 把自己登记为条件等待者
  2. 释放 mutex
  3. 睡眠
  4. 被唤醒后重新获得 mutex 才返回

如果先手工 unlockwait,生产者可能在窗口中发信号,而消费者尚未入队,产生丢失唤醒。

信号方通常应在修改条件时持有同一把 mutex:

pthread_mutex_lock(&mu);
queue_push(item);
pthread_cond_signal(&cv);
pthread_mutex_unlock(&mu);

关键不是 signal 必须机械地放在 unlock 前,而是条件检查和条件修改必须由同一同步协议建立 happens-before


7. 锁的真正性能问题

7.1 临界区长度只是第一层

一把锁慢,可能慢在四个不同位置:

持有时间(hold time)
  临界区本身太长:IO、日志、缺页、复杂计算

等待时间(wait time)
  队列太长、持锁者被调度走、优先级反转

交接时间(handoff time)
  解锁到后继真正上 CPU 的调度延迟

一致性成本(coherence cost)
  即使没睡眠,多个 CPU 争同一 cache line 也很贵

只看 futex 系统调用只能看到睡眠慢路径。锁竞争可能全部表现为用户态自旋和原子操作,此时 strace 很安静,但 perf 会显示大量原子指令和 cache miss。

7.2 公平与吞吐的冲突

严格 FIFO 锁让最早等待者先拿,延迟可预测;但它可能唤醒一个已经睡眠、位于远端 CPU 的线程。非公平锁允许当前正在运行的新线程插队,避免上下文切换和 cache line 迁移,吞吐更高。

公平交接:
unlock -> 唤醒队首 -> 等调度 -> 队首运行 -> 获取锁

非公平竞争:
unlock -> 当前 CPU 上的新线程立刻 CAS 成功
       -> 被唤醒者以后再争

代价是持续新流量可能让老等待者饥饿。高性能 mutex 往往混合两种模式:正常时允许插队追求吞吐;发现等待过久后切换到饥饿/直接交接模式保障上界。Go 的 sync.Mutex 就采用这种思路。

7.3 convoy:一把慢锁拖出车队

持锁者被抢占或在临界区阻塞,后面线程会排成长队:

T1 持锁 -> 被调度走
T2 等锁
T3 等锁
T4 等锁
...
T1 恢复并释放
每次只处理一个,队列需要很久才能消散

这叫 lock convoy。即使后续请求都很快,延迟仍会在队列中传播。容器 CPU throttling 会把它放大:持锁线程所属 cgroup 没额度时,等待者再多也没有用。

7.4 false sharing:没有逻辑共享也会互相打架

两个互不相关的锁或计数器如果落在同一个 cache line,不同 CPU 修改它们仍会争夺整条缓存行:

同一条 64-byte cache line:
+----------------+----------------+----------------------+
| lock A (4B)    | lock B (4B)    | other fields         |
+----------------+----------------+----------------------+
CPU 0 改 A  <-> cache line ownership <-> CPU 1 改 B

逻辑上没有锁竞争,硬件上却有一致性竞争。解决方法是重新布局、按 CPU 分片、适当 padding/alignment;但盲目 padding 会增加内存和 cache footprint,必须测量。

# 查看缓存与锁热点(事件名依 CPU 型号变化)
sudo perf c2c record -p "$PID" -- sleep 20
sudo perf c2c report

# 常规 profile 中寻找原子/自旋热点
sudo perf record -F 99 -g -p "$PID" -- sleep 30
sudo perf report

8. Go 的 sync.Mutex:先挡在用户态与运行时里

8.1 两个字段承载一把锁

Go 的 sync.Mutex 概念上只有两个字段:

type Mutex struct {
    state int32
    sema  uint32
}

state 的位编码了多种状态:

mutexLocked     已加锁
mutexWoken      已安排唤醒一个等待者,避免重复唤醒
mutexStarving   饥饿模式
waiter count    等待者数量(高位)

sema            运行时信号量的等待键

快速路径仍是一条 CAS:

if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    return
}

成功时没有 futex,也不需要进入 Go 调度器。失败才进入 lockSlow()

8.2 慢路径先自旋,再 park

lockSlow() 会结合锁状态和运行条件选择:

CAS 失败
  |
  +-- 适合主动自旋?
  |     多核、当前 P 可运行、次数有限、队列状态合适
  |     -> procyield / osyield -> 再试
  |
  +-- 不适合或自旋失败
        增加 waiter count
        runtime_SemacquireMutex(&m.sema, ...)
        -> 当前 goroutine park,不一定阻塞整个线程

这里和 pthread 有根本差异:pthread mutex 等待的单位是内核线程;Go 首先可以只停下当前 goroutine,让 M 继续执行同一个 P 上的其他 G。只有运行时本身需要让某个 M 睡眠时,才可能落到 OS 的 futex/ulock 等原语。

所以答案是:Go 的每次 Mutex 竞争不会直接一对一变成 futex WAIT。 中间还有用户态自旋、runtime semaphore、G 的队列与 GMP 调度。

8.3 正常模式与饥饿模式

正常模式强调吞吐:

  • 等待者按近似 FIFO 入队
  • 被唤醒者仍需与新到 goroutine 竞争
  • 新到者正在 CPU 上,通常更容易赢

如果一个等待者等待超过大约 1ms,mutex 会进入饥饿模式:

  • 所有权直接交给队首等待者
  • 新到者不尝试抢锁,也不自旋,排到队尾
  • 队首获得锁后,如果自己是最后一个等待者或等待时间已较短,可退出饥饿模式
正常模式:允许 barging -> 吞吐高,可能饿老等待者
饥饿模式:直接 handoff -> 公平、延迟有界,交接成本高

这不是永远公平或永远不公平,而是根据实际等待时间动态切换策略。

8.4 Unlock 为什么会唤醒一个

快速解锁先原子清除 mutexLocked。若没有等待者,直接返回;有等待者时进入 unlockSlow()

  • 正常模式下,确保只标记/唤醒一个等待者,避免惊群
  • 饥饿模式下,把所有权交给等待队首
  • 通过 runtime_Semrelease 让相应 goroutine 可运行

被唤醒的 G 进入 runnable 队列,不保证立刻运行。它需要某个 P 和 M;如果 cgroup 被 throttle,Go 运行时也无法绕过内核调度器。

8.5 Mutex 不可复制,也不记录普通 owner

Go 文档要求:Mutex 首次使用后不得复制。复制会把 state/sema 复制到新地址,等待者可能睡在旧对象的运行时信号量队列上,解锁新副本无法唤醒它们。

普通 sync.Mutex 也不绑定 goroutine owner:

mu.Lock()
go func() {
    mu.Unlock() // 从 API 语义上允许由另一个 goroutine 解锁
}()

但解锁未锁 mutex 会 panic。因为不记录 owner,Go mutex 不是递归锁;同一 goroutine 重复 Lock 会像其他竞争一样死锁。goroutine 没有稳定线程身份,也不鼓励 owner/递归语义。


9. runtime semaphore 与 netpoll:goroutine 怎样停下来

9.1 sema 不是简单映射成 POSIX semaphore

Go runtime 的 semaphore 是一个内部睡眠/唤醒机制,用于 sync.MutexRWMutexWaitGroup 等。它围绕用户地址维护等待 goroutine:

runtime_Semacquire(addr)
  尝试减少 token
  失败 -> sudog 入运行时的 semaRoot 等待结构
       -> gopark 当前 G
       -> M 切去运行其他 G

runtime_Semrelease(addr)
  增加 token
  找到等待 sudog
  goready(G) -> 放回可运行队列

运行时把地址哈希到一组 semaRoot,避免为每把锁分配重量级内核对象。这个思路与 futex 很像:状态在用户态,按地址找等待者,竞争时停车。 但停车单位是 G,不是直接由内核管理的线程。

9.2 gopark 发生了什么

简化过程:

G1 在 M1/P1 上运行
G1 等锁 -> gopark
  保存 G1 的调度上下文
  把 G1 状态改为 waiting
  M1 不再执行 G1
  调度器从 P1 的 run queue 找 G2
M1/P1 继续执行 G2

这是用户态调度切换,不需要切换进程地址空间。只要还有 runnable G,M 通常不必睡进内核。

当某个 M 没有可运行工作时,runtime 才可能让它休眠,平台相关实现最终可使用 futex(Linux)、ulock(Darwin)等 OS 原语。网络 IO 则通常交给 epoll/kqueue/io completion 后端,M 不必陪一个 G 阻塞。

9.3 syscall 阻塞与锁等待不同

G 在普通 Go mutex 上等待,runtime 明确知道并可 park G。G 进入可能阻塞的系统调用时:

G -> M 进入 syscall
P 与该 M 分离
另一个 M 接管 P,继续运行其他 G
原 syscall 返回后,旧 M 再尝试获得 P

这就是 GMP 把 goroutine 阻塞与内核线程阻塞解耦的价值。但 cgo、某些不可识别的阻塞、线程锁定和大量 syscall 仍可能增加 M 数量与调度成本。


10. channel:锁、等待队列与直接交接

10.1 channel 不是无锁队列

Go channel 的运行时结构概念上包含:

hchan
├── qcount / dataqsiz       缓冲区已用量 / 容量
├── buf                     环形缓冲区
├── sendx / recvx           发送/接收索引
├── recvq                   等待接收的 sudog 队列
├── sendq                   等待发送的 sudog 队列
├── closed                  关闭标记
└── lock                    保护 channel 状态的 mutex

发送和接收通常要短暂获取 hchan.lock。channel 的优势不是「无锁」,而是把数据、条件、等待队列和唤醒协议封装成不易丢失唤醒的通信语义。

10.2 无缓冲 channel:发送者与接收者会合

发送者先到:
  sendq 入队 -> gopark
  接收者到来 -> 从 sendq 取出发送者
              -> 直接复制值到接收方
              -> goready 发送者

接收者先到:同理,进入 recvq 等待

这叫 rendezvous(会合)。值可能直接从发送者栈复制到接收者栈,不必先绕缓冲区。

10.3 有缓冲 channel:容量就是背压边界

发送:
  有等待接收者 -> 直接交接
  否则 buffer 未满 -> 写环形队列,立即返回
  否则 -> sendq 入队并 park

接收:
  有等待发送者且 buffer 有数据 -> 取队首,并推进发送者数据
  否则 buffer 有数据 -> 取出
  否则已关闭 -> 返回零值, false
  否则 -> recvq 入队并 park

容量决定生产者最多领先消费者多少。容量过大不是免费吞吐:它延后背压、增加排队内存,并把延迟藏在队列里;容量过小则增加 goroutine 交接和调度。

10.4 select 如何避免在多个 channel 上丢唤醒

select 可能同时等待多个 channel。运行时必须:

  1. 按稳定顺序给涉及的 channel 加锁,避免死锁
  2. 先检查是否已有 case 可执行
  3. 若都不可执行,把同一个 G 的多个 sudog 分别挂入各 channel 队列
  4. 原子地 park
  5. 某个 case 唤醒后,从其他 channel 队列撤销剩余等待项

这与 futex expected-value 契约解决的是同一类问题:检查条件与登记等待之间不能留下丢失唤醒窗口。

10.5 Mutex 还是 channel

用 Mutex:
  保护一组共享状态与不变量
  临界区短、操作是同步读改写
  所有权不需要转移成消息

用 channel:
  明确传递数据/任务/所有权
  需要背压、流水线、select、取消
  希望由单个 goroutine 串行维护状态

「不要通过共享内存通信」是设计建议,不是说 channel 底层没有共享内存和锁。热点计数器、缓存、连接状态机可能用 mutex 更清晰、更快;任务队列和生命周期协调常适合 channel。


11. RWMutex、原子与无锁结构的边界

11.1 RWMutex 不是“读多就一定更快”

读写锁允许多个读者并发,写者独占。但它需要维护读者计数、写者等待状态和两个信号量;读锁也会修改共享计数器,引起 cache line 竞争。

适合:

  • 临界区读操作明显长于锁开销
  • 读远多于写
  • 多个读者的实际工作能并行

不适合:

  • 临界区只有一次 map 查找或几个字段
  • 核数高导致读者计数 cache line 抖动
  • 写操作虽少但对尾延迟敏感
  • 临界区可通过复制快照或分片消除共享

必须基准测试 MutexRWMutex,不能只数读写比例。

11.2 原子变量适合单一状态,不适合偷偷造锁

原子操作很适合:

  • 计数器
  • 状态位
  • 指针/配置快照发布
  • 明确证明过的 lock-free 算法

当多个字段必须共同满足不变量时,分别 atomic 不等于整体原子:

balance -= 100
version++

读者可能看到新 balance + 旧 version

此时 mutex 往往更正确、更容易维护。无锁(lock-free)只保证系统中总有某个操作推进,不保证每个线程无饥饿,也不保证比锁快;内存回收还会引入 ABA、hazard pointer、epoch 等新问题。

11.3 分片通常比换一把更聪明的锁有效

如果一把全局锁保护百万级请求,优化锁实现只能减少常数。真正有效的是减少共享:

全局 map + 1 把锁
        ↓
按 hash 分成 64 个 shard,每个 shard 1 把锁
        ↓
竞争概率约降低,cache line 也分散

进一步可以使用:

  • per-P / per-CPU 计数,读取时聚合
  • immutable snapshot + 原子指针替换
  • single-owner goroutine
  • copy-on-write
  • 缩小临界区,把 IO 和计算移到锁外

最高性能的锁竞争优化通常是:不去竞争同一把锁。


12. 死锁:每把锁都正确,系统仍能停住

12.1 四个必要条件

经典死锁同时满足:

  1. 互斥:资源一次只能被一个执行者持有
  2. 持有并等待:拿着一部分资源,又等另一部分
  3. 不可剥夺:资源不能被强制抢走
  4. 循环等待:A 等 B,B 等 C,C 又等 A

破坏任意一个条件即可避免死锁。工程上最常用的是规定全局锁顺序

// 统一规定:总是先 lock account ID 小的,再 lock ID 大的
first, second := order(a, b)
first.mu.Lock()
second.mu.Lock()
// transfer
second.mu.Unlock()
first.mu.Unlock()

12.2 自死锁、ABBA 与升级死锁

自死锁:
  同一 goroutine 对非递归 mutex Lock 两次

ABBA:
  G1: Lock(A) -> Lock(B)
  G2: Lock(B) -> Lock(A)

RWMutex 升级:
  RLock -> 在不释放读锁时 Lock
  写者等所有读者退出,其中包括自己 -> 永远等

超时只能让调用者放弃等待,不自动恢复已经执行一半的状态。TryLock 也不是普遍解法;它容易把清晰的锁顺序变成重试、活锁和饥饿。

12.3 Go 如何暴露死锁

当运行时发现所有 goroutine 都不可运行,且没有计时器、网络事件等可能唤醒它们时,会报:

fatal error: all goroutines are asleep - deadlock!

但服务中的部分死锁不会触发这个检查:只要还有监控 goroutine、网络轮询或其他请求能运行,进程就不会整体判死。此时表现为某类请求永久卡住、goroutine 数持续上升。

# 抓取所有 goroutine 栈
curl -o goroutines.txt 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2'

# 或向 Go 进程发送 SIGQUIT(会把完整栈写到 stderr,先确认日志承载能力)
kill -QUIT "$PID"

栈里大量 goroutine 停在同一 sync.(*Mutex).Lockchan sendchan receive 并不自动证明死锁,也可能只是正常热点或下游变慢。要找到谁持锁、为什么没释放、等待链是否成环


13. 线上锁竞争如何观测

13.1 Go:mutex profile 与 block profile

Go 自带两类相关 profile:

  • mutex profile:统计 goroutine 因竞争锁而等待的时间,样本通常归因到解锁/持锁路径,帮助找「谁阻塞了别人」
  • block profile:统计 channel、select、cond、mutex 等阻塞事件的等待,范围更广
import "runtime"

func init() {
    // 平均每 5 次互斥竞争采 1 次;生产值应控制开销
    runtime.SetMutexProfileFraction(5)

    // 记录阻塞事件;1 表示全部采样,开销可能较高
    runtime.SetBlockProfileRate(1)
}
# 服务已暴露 net/http/pprof 时
curl -o mutex.pprof http://127.0.0.1:6060/debug/pprof/mutex
curl -o block.pprof http://127.0.0.1:6060/debug/pprof/block

go tool pprof -http=:8081 mutex.pprof
go tool pprof -http=:8082 block.pprof

# benchmark 也可输出
go test -run '^$' -bench . -mutexprofile mutex.out -blockprofile block.out ./...

注意:profile 是采样和累计视角,不等于每次请求时间线。P99 毛刺要结合 execution trace:

curl -o trace.out 'http://127.0.0.1:6060/debug/pprof/trace?seconds=5'
go tool trace trace.out

trace 能看到 goroutine 因 sync、syscall、network、GC 停顿的时间,以及 P/M/G 调度关系;采集时间过长会产生大文件和开销。

13.2 Linux:futex 只是慢路径证据

# 哪些线程频繁进入 futex
sudo strace -ff -ttT -e trace=futex -p "$PID"

# -ttT:绝对时间 + 每次 syscall 耗时
# 注意 strace 会显著扰动高并发服务,只做短时、受控采样

# 系统级调度与热点
sudo perf top -p "$PID"
sudo perf sched record -p "$PID" -- sleep 10
sudo perf sched timehist

可以使用 eBPF/bpftrace 低扰动统计 futex 系统调用,但 syscall ABI 与 tracepoint 参数随架构/内核而异,先查看本机格式:

sudo cat /sys/kernel/tracing/events/syscalls/sys_enter_futex/format
sudo bpftrace -l 'tracepoint:syscalls:sys_*futex*'

# 示例思路:按进程统计 futex 入口;具体字段按上面的 format 调整
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_futex
/comm == "my-service"/
{ @[pid] = count(); }
interval:s:10 { print(@); clear(@); }'

如果 futex 次数不高但 CPU profile 显示 runtime.procyieldsync.(*Mutex).lockSlow 或原子指令热点,说明竞争主要在用户态自旋/运行时层。反过来,futex 多也可能是正常的条件变量、线程池休眠,不一定是某把业务锁。

13.3 指标组合

现象 更可能的原因 下一步
mutex profile 集中在一个调用点 热锁、临界区过长 看持锁代码,移出 IO/计算,考虑分片
block profile 主要是 channel send 消费者慢或容量不足 看队列长度、消费耗时、背压策略
futex WAIT 很多且耗时长 内核线程级竞争或运行时 M 睡眠 对齐线程栈、sched delay、CPU quota
CPU 高,futex 少,原子/自旋热点高 用户态竞争与 cache ping-pong perf/c2c、减少共享、减少自旋
goroutine 数涨,CPU 不高 锁/channel/IO 等待泄漏 goroutine profile 分组看栈
锁等待与 nr_throttled 同步 持锁者被 cgroup 断粮 修 CPU quota/GOMAXPROCS/资源分组
解锁后很久才运行 调度延迟而非临界区慢 perf sched、run queue、优先级/亲和性

13.4 一套排查顺序

① 确认“慢”是 CPU、锁、channel、IO 还是 quota
   CPU profile + mutex/block profile + cgroup cpu.stat

② 找等待者聚集点
   goroutine dump / pprof top / flame graph

③ 找造成等待的人,而不只看等待的人
   mutex profile 的归因、持锁临界区、下游调用

④ 判断是哪种成本
   自旋烧 CPU / futex 睡眠 / 调度交接 / cache line 抖动

⑤ 先减共享和缩临界区,再换锁
   分片、快照、批量、移出 IO、限制并发

⑥ 用同一负载复测吞吐与 P50/P99
   只看平均耗时可能把公平性退化藏起来

14. 常用命令知识点扩展

14.1 命令参数

短参 长参 英文全称 作用
strace -f strace --follow-forks follow forks 跟踪线程/子进程
strace -e strace --trace=... trace expression 只跟踪 futex 等指定调用
strace -c strace --summary-only count/summary 汇总调用次数与耗时
strace -T strace --syscall-times syscall times 显示系统调用内部耗时
perf record -g perf record --call-graph call graph 记录调用栈
go test -bench go test -bench=REGEXP benchmark 运行基准测试
go test -race 无长参 race detector 检测数据竞争
go tool pprof -http 无统一长参 profile HTTP UI 启动交互分析界面

14.2 数据竞争、锁竞争和死锁不是一回事

data race:
  多个执行者并发访问同一内存,至少一个写,且缺少同步
  -> 结果未定义/不满足语言内存模型
  工具:go test -race

lock contention:
  同步正确,但很多执行者等待同一把锁
  -> 性能问题
  工具:mutex/block profile、perf

deadlock:
  等待关系无法再推进
  -> 活性问题
  工具:goroutine dump、锁图、超时现场

-race 没报告,不代表没有锁竞争或死锁;mutex profile 很高,也不代表代码数据竞争。

14.3 最小复现实验

验证无竞争 pthread mutex 不调用 futex:

#include <pthread.h>

int main(void) {
    pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&m);
        pthread_mutex_unlock(&m);
    }
}
cc -O2 -pthread mutex.c -o mutex
strace -c -e futex ./mutex
# 通常没有与每次 lock/unlock 对应的 200 万次 futex
# 环境初始化可能仍出现少量 futex,结论应看数量级

给 Go benchmark 输出锁 profile:

go test -run '^$' -bench BenchmarkHotMap -benchtime=10s \
  -mutexprofile mutex.out -blockprofile block.out ./...
go tool pprof -top mutex.out

基准必须模拟真实并发度和临界区;只在单 goroutine 下测锁,测到的只是快速路径。


15. 面试题

Q:已经有 CAS,为什么还需要 futex?

CAS 只能原子地决定谁获得锁,不能让失败者停止消耗 CPU。失败者若无限重试会烧时间片并制造 cache line ping-pong;若每次失败都进内核又让低竞争快路径付出系统调用成本。futex 把两者结合:锁状态与无竞争 CAS 留在用户态,只有确认需要等待时才由内核把线程睡眠,解锁也只在可能有等待者时唤醒。

Q:futex 是锁吗?它快在哪里?

futex 不是完整锁,而是基于用户地址的等待/唤醒机制。内核不负责普通 mutex 的全部状态和策略,只提供「值仍等于 expected 才睡」与「唤醒至多 N 个等待者」等操作。它所谓 fast 主要不是 futex syscall 本身特别快,而是无竞争时根本不调用 syscall;绝大多数 lock/unlock 只执行用户态原子指令。

Q:FUTEX_WAIT 如何避免丢失唤醒?

等待者先在用户态观察值,再调用 WAIT(uaddr, expected)。内核入队前重新读取 uaddr;若已不等于 expected,返回 EAGAIN 而不睡。若仍相等,内核把比较与挂入对应等待队列组织为不会错过 wake 的原子序列。这样释放发生在用户检查和系统调用之间时,等待者不会在 wake 已过去后永久睡下。返回后仍必须循环检查业务条件。

Q:为什么条件变量一定用 while 检查,而不能用 if?

唤醒只表示条件可能变化,不保证当前线程重新拿到 mutex 时条件仍成立。可能发生伪唤醒、广播唤醒多个线程、另一个线程先消费条件,或新到线程插队。因此 cond_wait 返回并重新持锁后必须再次判断 predicate;while 才能在条件不成立时继续等待。

Q:自旋锁什么时候比 mutex 好?

当预计等待远短于睡眠与唤醒成本、持锁者确实正在另一个 CPU 运行、临界区不会阻塞且竞争者很少时,有限自旋可能更便宜。单核、持锁者已被调度走、临界区可能 IO/缺页、竞争者多或容器 quota 很紧时,自旋通常更坏。不能只看临界区代码长度,还要看持锁者运行状态、并发数和缓存一致性成本。

Q:futex_wake(1) 会把锁直接交给被唤醒线程吗?

不会。它通常只是把一个等待线程变成 runnable。该线程何时上 CPU 由调度器决定,上 CPU 后还要重新竞争用户态锁;在非公平 mutex 中,新到且正在运行的线程可能先 CAS 成功,这叫 barging。直接所有权交接是上层 mutex 的策略,例如 Go Mutex 饥饿模式或某些 handoff 锁,不是普通 WAKE 自动提供的保证。

Q:pthread_mutex_lock 每次都会触发 futex 系统调用吗?

不会。普通无竞争路径用用户态 CAS 获锁;unlock 发现无等待者也只做 release 原子操作。竞争失败并需要睡眠时才 futex WAIT,解锁检测到等待者时才可能 WAKE。因此 strace 看不到 futex 常说明锁走快速路径;但也可能存在纯用户态自旋竞争,需要配合 perf 和锁 profile。

Q:Go 的 sync.Mutex 竞争是否一对一映射到 futex?

不是。快速路径先 CAS,慢路径可能有限自旋;需要等待时通过 runtime semaphore 把当前 goroutine 作为 sudog 排队并 gopark,M/P 可以继续运行其他 G。只有 Go runtime 需要让没有工作的 OS 线程睡眠时,Linux 实现才可能进一步使用 futex。因此 goroutine park、runtime semaphore 和内核 futex 是不同层级,不能把每次 Lock 阻塞等同一次 futex WAIT。

Q:Go Mutex 为什么有正常模式与饥饿模式?

正常模式允许被唤醒的老等待者与正在运行的新 goroutine 竞争,新来者可能插队;这样减少交接和调度成本,吞吐高,却可能让老等待者持续失败。等待超过阈值后进入饥饿模式,锁直接交给队首,新到者排队,改善公平与尾延迟。队列消散后再回正常模式。它是在吞吐与饥饿上界之间动态折中。

Q:Go channel 是不是无锁的?

通常不是。hchan 有一把 mutex,保护缓冲区索引、关闭状态、sendq 和 recvq 等。阻塞发送/接收会把 goroutine 的 sudog 挂入等待队列并 park;匹配方直接交接数据或操作环形缓冲,再 goready 对方。channel 的价值是封装通信、背压、等待与唤醒协议,不是承诺底层无锁。

Q:什么是优先级反转,PI futex 如何处理?

低优先级 L 持锁,高优先级 H 等锁,而中优先级 M 持续抢占 L,导致 H 间接被 M 阻塞,这就是优先级反转。PI futex 让内核知道 owner 和等待关系,临时把 L 提升到 H 的优先级,使其尽快释放锁,再恢复优先级。它用于有严格优先级需求的实时系统,慢路径更复杂,不是普通服务的默认性能优化。

Q:mutex profile 与 block profile 有什么区别?

mutex profile 聚焦互斥竞争造成的等待,并尽量帮助定位阻塞别人的解锁/持锁路径;block profile 覆盖更广,包括 channel send/receive、select、cond、mutex 等阻塞。两者都是采样累计视角,不能完整还原单次 P99 时间线;需要时结合 Go trace、goroutine dump、CPU profile 和 cgroup throttling 指标。

Q:锁竞争严重时,最有效的优化是什么?

先找造成等待的临界区,把 IO、日志、可能阻塞的调用和重计算移到锁外;再减少共享,例如按 key 分片、per-P/per-CPU 计数、不可变快照、批处理或 single-owner goroutine。只有在共享模型不能改变时,才调自旋、公平策略或更换锁。换成“更快的锁”通常只优化常数,消除同一热点才改变扩展性。

Q:怎样区分数据竞争、锁竞争与死锁?

数据竞争是缺少同步导致同一内存并发读写,属于正确性问题,可用 race detector;锁竞争是同步正确但大量执行者排队,属于性能问题,用 mutex/block profile、perf;死锁是等待关系无法推进,属于活性问题,用 goroutine/thread dump 和锁依赖分析。三者互不等价:无 data race 的程序仍可能严重争锁或死锁。


小结

  • 原子指令解决「谁赢」,没有解决失败者如何等待;无限自旋浪费 CPU,每次进内核又浪费无竞争快路径
  • futex 的核心分层是:状态与竞争策略在用户态,内核只负责真正需要睡眠和唤醒的线程
  • futex 快的关键不是 syscall 更快,而是绝大多数无竞争 lock/unlock 完全没有 syscall
  • FUTEX_WAIT(uaddr, expected) 在内核再次比较值,封住检查条件与入队之间的窗口,从而避免丢失唤醒
  • WAKE 只让线程 runnable,不保证立刻执行、拿到锁或 FIFO;条件等待必须循环检查 predicate
  • REQUEUE 减少条件变量广播惊群,robust futex 检测 owner 死亡,PI futex 处理实时系统的优先级反转
  • 锁成本包括临界区、排队、交接调度和 cache coherence;只数 futex 调用会漏掉用户态自旋与 false sharing
  • Go sync.Mutex 先 CAS、有限自旋,再通过 runtime semaphore park goroutine;竞争不会一对一映射成 futex
  • Go Mutex 在正常模式追求吞吐,等待过久后切换饥饿模式直接交接,以控制饥饿与尾延迟
  • channel 底层通常也有锁与 sudog 等待队列;它提供的是通信、背压和正确的唤醒协议,不是“无锁”
  • 优化热锁应优先缩短临界区、移出阻塞操作、分片与减少共享,而不是先换一把更复杂的锁
  • 排查要联合 mutex/block profile、Go trace、goroutine dump、perf、futex 慢路径与 cgroup CPU 限流,找到真正造成等待的人

下一篇讲 信号:背着 40 年债的机制 —— 为什么信号被设计成异步通知、早期不可靠信号怎样丢失、EINTRSA_RESTART 为什么让系统调用语义如此混乱,以及 Go 的 signal.Notify 如何把异步信号转成可控的 channel 事件。