futex:用户态锁的等待与唤醒

本页目录

futex 是 Linux 提供的低层等待与唤醒机制。常见互斥锁把无竞争路径留在用户态,只在需要阻塞或唤醒时进入内核。它不是一把完整的锁,也不是所有语言、平台或所有同步原语必经的唯一实现。

设计同步协议时,要分别回答:谁拥有资源、数据如何发布、谁需要睡眠、状态改变后谁应被唤醒。futex 主要解决后两项与共享状态之间的衔接。

为什么不能简单地先检查再睡眠

假设 W 检查 ready 为 0,正准备睡眠;P 随后把 ready 改为 1 并唤醒,但 W 此时还没入队。若 W 接着无条件睡眠,而之后再无通知,就会错过已经发生的变化。

FUTEX_WAIT 将读取 futex word、与 expected 比较、进入等待合成对相关并发操作有原子保证的“比较并阻塞”。若值已不相等,它返回 EAGAIN,不会再睡下去。FUTEX_WAIT

比较并阻塞:堵住“检查之后才睡眠”的竞态窗口 等待线程 W 发布线程 P 读到 state = 0 写入 state = 1 WAKE:可能还没有等待者 WAIT(expected=0) 值已改变,返回 EAGAIN 条件已改变,WAIT 就不再入睡;WAKE 自身不保存一张供未来消费的“通知票”。

这要求发布方先修改状态,再按协议唤醒。单独调用 WAKE 而不留下可观察的状态,不能可靠地通知尚未等待的线程。值若反复变化回同一值,还需用代数、序号或更完整的协议区分代次,不能认为一次比较消除了所有 ABA 问题。

两个基本操作

操作含义返回后还要做什么
FUTEX_WAIT(word, expected)仅当值仍匹配才入睡重新检查条件或争取锁
FUTEX_WAKE(word, n)最多唤醒 n 个匹配等待者发布方仍需遵守共享状态协议

传统 futex word 是按 4 字节对齐的 32 位值,即使进程运行于 64 位平台。普通 WAIT 不替用户修改锁状态,WAKE 不交付锁所有权,也没有普遍的 FIFO 公平保证。futex(2)

WAIT 返回 0 只说明此次等待被唤醒,不能据此直接进入临界区。EAGAIN 表示比较时状态已改变,EINTR 表示被信号中断,超时也不表示业务条件必然不成立;这些路径都需回到同步协议处理。

FUTEX_WAIT 的 timeout 是相对时间,其他操作可能使用绝对截止时间。重试相对等待若每次重新给足原始时长,会意外延长总超时;应以同一截止时间计算剩余量,或使用适合的绝对时间接口。

唤醒没有存下来,WAIT 为什么仍不会漏睡?

先发布再 WAKE,唤醒数可以为 0;此后 WAIT 比较到 word=1,返回 EAGAIN 而不入睡。反过来,先 WAIT 再发布和 WAKE,等待者会出队。还可以只 WAKE、不发布:等待者虽醒来,word 仍为 0,说明唤醒与条件成立是两回事。

正在呈现知识画面
唤醒没有存下来,WAIT 为什么仍不会漏睡?

WAKE 不保存未来通知,WAIT 通过比较当前 word 决定是否入队。被唤醒仍需重新检查条件,不能直接视为获得锁。

一个三状态普通互斥锁模型

下面是解释算法的伪代码,不是 pthread_mutex_t 的内部布局或可直接替换 libc 的实现。状态 0 表示空闲,1 表示已锁定且尚无等待标记,2 表示已锁定并可能存在等待者。

lock:
    若 CAS_acquire(state, 0, 1) 成功:返回
    循环:
        old = exchange_acquire(state, 2)
        若 old == 0:返回
        WAIT(state, expected=2)
        按返回值处理错误后,重新竞争

unlock:
    old = exchange_release(state, 0)
    若 old == 2:WAKE(state, 1)

将状态标成 2,是让释放者知道可能需要唤醒,不是精确统计等待者数量。被唤醒线程成功从 0 改成 2 后保留标记,可让后续解锁继续推动其他等待者。普通新到线程可能抢在被唤醒线程前拿到锁,因此这个模型不保证公平。

若释放发生在 WAIT 前,比较不匹配避免漏睡;若等待先入队,后续 WAKE 把它唤起。算法还需要处理地址生命周期、取消、超时、异常退出和边界错误;这也是应用通常使用成熟 pthread 或语言标准库锁的原因。

被唤醒者可能仍然输给新到达线程

释放者先把 state 写为 0,再唤醒 W;W 还在等待调度时,新线程 N 已经 CAS(0, 1) 成功。W 运行后看到 state=1,仍须重新竞争,不能因 WAIT 返回 0 就直接进入临界区。

顺序state谁拥有锁
P 仍在临界区,W 等待2P
P 释放并唤醒 W0暂时无人
N 抢先 CAS 成功1N
W 恢复运行,exchange 为 2 得到旧值 12仍是 N
N 释放,看到旧值 2,继续唤醒0等待下一次竞争

状态 2 的价值在于保留“后续释放可能需要通知”的信息。它不是 W 的所有权凭证,也不是当前等待人数。这个调度解释了为什么普通 futex 锁可能有较好吞吐,却不能直接承诺等待公平或最坏等待时间。

比较不匹配时,WAIT 不会睡眠

下面只验证单线程 futex 接口的比较与返回,不实现并发锁。word 已为 1,却请求等待值 0;即使超时指针为 NULL,也应立即得到 EAGAIN。

#define _GNU_SOURCE
#include <errno.h>
#include <linux/futex.h>
#include <stdint.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>
int main(void) {
    _Alignas(4) uint32_t word = 1;
    errno = 0;
    long result = syscall(SYS_futex, &word, FUTEX_WAIT_PRIVATE,
                          0, NULL, NULL, 0);
    int saved = errno;
    printf("mismatch=%d\n", result == -1 && saved == EAGAIN);
    return !(result == -1 && saved == EAGAIN);
}

保存为 futex-mismatch.c,编译运行预期 mismatch=1。此例通过 libc syscall,所以看到 -1 与 errno;它没有并发修改普通 uint32_t。将其扩展为多线程协议时,必须重新满足下面的原子对象与内存序约束。WAIT 比较语义

原子变量的内存序不能省略

锁保护的数据通常在 unlock 的 release 与成功 lock 的 acquire 之间建立同步关系。futex 的队列原子性不能替代语言内存模型中的数据发布协议,也不能把普通共享整数的并发读写自动变成合法原子操作。

volatile 只影响特定编译器访问语义,不能替代跨线程原子性和 acquire/release。若直接实现 futex 包装,还需确认原子对象的大小、对齐、无锁支持和目标 ABI 与内核接口兼容。

PRIVATE 标志表示等待双方在同一地址空间中使用该对象,允许内核优化查找。进程间共享对象须使用适合共享映射的操作,不能把 PRIVATE 当作无条件更快选项。销毁、解除映射或复用 futex 地址前,也必须确保没有旧操作仍引用它。

PI 与 robust 解决不同的问题

优先级继承 PI 针对低优先级持锁者被中优先级任务不断抢占、导致高优先级等待者无法前进的情况。内核可以沿等待链提升持锁者的有效优先级,帮助它完成临界区。PI 需要特定所有者编码与操作协议,不能随意混用普通 WAIT/WAKE 实现。

pthread 接口通过互斥锁属性选择 PTHREAD_PRIO_INHERIT。它不解决死锁、不缩短临界区本身,也不保证任意调度条件下达到截止时间。

Robust mutex 则处理持锁线程死亡:下一个 pthread_mutex_lock 可能返回 EOWNERDEAD,此时调用方已经获得锁,但保护的数据可能不一致。应用应修复数据并调用 pthread_mutex_consistent,再解锁。若在未恢复一致性的情况下解锁,后续可能得到 ENOTRECOVERABLE。

不能对 EOWNERDEAD 直接当作“加锁失败,没有拿到锁”,也不能不检查数据就一律标记 consistent。内核提供异常所有者状态,不会自动回滚业务修改。Robust mutex

EOWNERDEAD:已经拿到锁,接下来修复受保护数据 原持有者异常退出 留下未完成的数据修改 下一次 lock 返回 EOWNERDEAD,持有锁 修复应用不变量 然后 consistent + unlock 无法修复时不能谎报一致;未 consistent 就解锁会留下不可恢复状态 robust 提供恢复机会,既不是自动事务回滚,也不是所有 lock 错误都未持锁 EOWNERDEAD 与 ENOTRECOVERABLE 必须区别处理;业务需要事先定义怎样恢复。

完整恢复例子:用已提交副本恢复未完成更新

以下只在进程内创建一个工作线程。正常不变量为 working == committed;工作线程持锁把 working 改为 99 后直接结束,模拟未提交更新。恢复方选择“丢弃未提交内容”,将 working 恢复为 committed=7,再标记一致。

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
static pthread_mutex_t mutex;
static int committed = 7, working = 7;
static void require(int result) { if (result != 0) abort(); }
static void *unfinished(void *unused) {
    (void)unused;
    require(pthread_mutex_lock(&mutex));
    working = 99;
    return NULL;  /* 故意持有 robust mutex 结束线程。 */
}
int main(void) {
    pthread_mutexattr_t attr;
    require(pthread_mutexattr_init(&attr));
    require(pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST));
    require(pthread_mutex_init(&mutex, &attr));
    require(pthread_mutexattr_destroy(&attr));
    pthread_t thread;
    require(pthread_create(&thread, NULL, unfinished, NULL));
    require(pthread_join(thread, NULL));
    int result = pthread_mutex_lock(&mutex);
    if (result != EOWNERDEAD) abort();
    if (working != 99 || committed != 7) abort();
    working = committed;
    require(pthread_mutex_consistent(&mutex));
    require(pthread_mutex_unlock(&mutex));
    require(pthread_mutex_lock(&mutex));
    int recovered = working == 7 && working == committed;
    require(pthread_mutex_unlock(&mutex));
    require(pthread_mutex_destroy(&mutex));
    printf("recovered=%d\n", recovered);
    return !recovered;
}

保存为 robust-demo.c,用 cc -Wall -Wextra -pthread robust-demo.c -o robust-demo 编译。预期 recovered=1,且恢复后的下一次 lock 正常返回 0。Robust mutex 恢复协议

本例的提交副本没有被异常线程修改,因此恢复规则明确。复杂对象可能还涉及部分写入、链表结构和外部副作用;如果 committed 本身可能不完整,简单赋值就不构成有效恢复。应先定义可验证的不变量与恢复数据,再决定何时调用 consistent。

EOWNERDEAD:拿到锁了,数据却还没修好

先模拟退出再 lock,可以同时看到 EOWNERDEAD 与“恢复线程持锁”。比较两条后续路径:直接 unlock 会进入不可恢复;恢复 working、声明 consistent、再 unlock,下一次 lock 才正常成功。修复数据和声明一致是两个操作,少做后者也不能完成恢复协议。

正在呈现知识画面
EOWNERDEAD:拿到锁了,数据却还没修好

EOWNERDEAD 表示获得锁但数据可能不一致。恢复不变量并声明 consistent 后可继续;未恢复一致状态就解锁,会使后续获得 ENOTRECOVERABLE。

其他操作与 futex2 的边界

FUTEX_REQUEUE 等操作可以移动等待队列,减少某些条件变量模式的惊群。FUTEX_WAKE_OP 将对第二个 word 的原子操作与条件唤醒结合;它不是“原子加法之后再 WAIT”,FUTEX_OP 是编码操作的宏。FUTEX_WAKE_OP

futex_waitv 接受一组待比较 word,并等待其中某个被唤醒,适合多个等待源。返回某个索引后仍要检查业务状态,不代表同时取得多个锁。该接口使用绝对超时与指定时钟。

“futex2”曾涉及多项扩展设想,不能把提案里的所有宽度、NUMA 和调度特性写成所有 6.x 内核已具备的事实。本文核对的内核 futex2 文档对 waitv 明确要求 FUTEX_32,虽然结构字段为扩展保留了空间。内核 futex2 文档

如何理解性能

无竞争路径可能没有系统调用,但原子读改写仍会带来缓存一致性开销。竞争时还可能包含自旋、队列操作、上下文切换、调度延迟与跨 NUMA 数据传输。不存在固定“20 ns”或每次竞争必定恰好一次 WAIT 加一次 WAKE 的普遍结论。

观察锁慢时,区分持锁时间、等待时间、争用频率与调度影响。函数入口到返回追踪方法见 ftrace 与 bpftrace。

关键词:futex, compare and block, lost wakeup, FUTEX_WAIT, FUTEX_WAKE, acquire release, PI, robust mutex, EOWNERDEAD, futex_waitv