从 glibc 到 Linux 内核:我如何用源码真正理解 futex 的实现机制

2 阅读8分钟

前两篇我已经把互斥锁和条件变量在用户态(libstdc++/glibc)怎么“用 futex”讲清楚了。现在我想把最后一块拼图补上:Linux 内核里的 futex 到底怎么实现 WAIT/WAKE?为什么不会丢唤醒?等待队列怎么组织?唤醒为什么要先 mark 再 wake?

这篇我还是按同一套路:建立模型 → 用关键源码验证 → 最后自己总结一遍。
(所有代码片段都是源码节选)


1)我先把概念纠正:futex 在内核里不是“一个对象”,而是一套“按 key 排队”的机制

用户态说“我在 uaddr 上 wait”,内核不会给你创建一个“futex对象”。内核做的是:

  1. 把用户态地址 uaddr 映射成一个 futex_key
  2. 用 key 做哈希,找到一个 hash bucket
  3. 把当前线程对应的一个 futex_q 节点挂进 bucket 的等待链表
  4. wake 的时候:同样把 uaddr→key→bucket,然后在链表里筛选匹配 key 的 futex_q,逐个唤醒

1.1 核心结构:bucket 与 futex_q(每个等待线程一个节点)

// kernel/futex/futex.h(节选)
struct futex_hash_bucket {
    atomic_t waiters;
    spinlock_t lock;
    struct plist_head chain;
} ____cacheline_aligned_in_smp;

struct futex_q {
    struct plist_node list;
    struct task_struct *task;
    spinlock_t *lock_ptr;
    union futex_key key;
    u32 bitset;
    ...
};

我的理解:

  • hb->chain 是“这个桶里所有 futex 等待者的链表”(链表上会混多个 key)
  • 一个等待线程对应一个 futex_q
  • 匹配关系靠 q->key

1.2 key 的意义:private/shared 决定“uaddr 是否能跨进程匹配”

内核在 core.c 的注释里把 key 的规则写得很清楚:

/* kernel/futex/core.c(节选)
 * For shared mappings: (inode->i_sequence, page->index, offset_within_page)
 * For private mappings: (current->mm, address, 0)
 */
int get_futex_key(u32 __user *uaddr, bool fshared, union futex_key *key, ...)

所以“一个 uaddr 对应一个 key”要加前提:

  • 在同一进程里,private futex:uaddr→key(key 里包含 mm + address)
  • shared futex:uaddr→key(key 里包含 inode + page + offset,可以跨进程匹配)

1.3 哈希桶怎么找?

// kernel/futex/core.c(节选)
struct futex_hash_bucket *futex_hash(union futex_key *key)
{
    u32 hash = jhash2((u32 *)key, ..., key->both.offset);
    return &futex_queues[hash & (futex_hashsize - 1)];
}

总结:

全局有很多个 bucket(futex_queues[]),每个 bucket 一条 chain。不同 key 会落到不同 bucket;哈希冲突会让不同 key 共用同一个 bucket 的 chain,但最终用 futex_match(key) 精确筛选。


2)我最想搞懂的问题:为什么 futex 不会丢唤醒?

我在 waitwake.c 文件头看到一段“READ this before hacking futexes!” 的注释,它直接把“丢唤醒反例”写出来了,并给出正确序列化方式。

核心思想一句话:

  • WAIT 侧:算桶/加锁后再读一次用户态值确认没变,没变才入队睡眠
  • WAKE 侧:修改用户态值后再 wake,通过 bucket 里的 waiters 计数 + 内存屏障,避免 waker 误判“没人等”而直接返回

这段注释建议你认真读一遍,它基本就是 futex 正确性的教科书。


3)FUTEX_WAIT 的内核路径:setup(二次检查)→ queue(入队)→ schedule(睡)→ unqueue(醒/超时/信号)

3.1 futex_wait() 主流程(最核心的函数)

// kernel/futex/waitwake.c(节选)
int futex_wait(u32 __user *uaddr, unsigned int flags, u32 val,
               ktime_t *abs_time, u32 bitset)
{
    struct futex_hash_bucket *hb;
    struct futex_q q = futex_q_init;
    int ret;

    if (!bitset)
        return -EINVAL;
    q.bitset = bitset;

retry:
    ret = futex_wait_setup(uaddr, val, flags, &q, &hb);
    if (ret)
        goto out;

    futex_wait_queue(hb, &q, to);

    ret = 0;
    if (!futex_unqueue(&q))
        goto out;

    ret = -ETIMEDOUT;
    if (to && !to->task)
        goto out;

    if (!signal_pending(current))
        goto retry;

    ret = -ERESTARTSYS;
    ...
out:
    ...
    return ret;
}

我把它拆成四句话:

  • futex_wait_setup:准备等待(算 key/桶,加锁,二次读用户态值校验 expected)
  • futex_wait_queue:把 q 入队并进入调度睡眠
  • 醒来后:futex_unqueue(&q) 检查自己是否已被 waker 从链表摘掉(摘掉则成功返回)
  • 否则根据超时/信号/虚假唤醒决定重试或返回

3.2 “二次检查用户态值(expected 语义)”在哪里?

我关心的就是:内核怎么保证 “只有 *uaddr == val 才会睡”。

关键逻辑在 setup 阶段(你在同文件能看到的这段就是核心):

// kernel/futex/waitwake.c(节选)
ret = futex_get_value_locked(&uval, uaddr);
...
if (uval != val) {
    futex_q_unlock(*hb);
    ret = -EWOULDBLOCK;
}

这就是 futex 的灵魂语义:

expected 不匹配就绝不入队睡眠,直接返回 -EWOULDBLOCK(用户态通常把它当作 EAGAIN)。

这也解释了你前面学 mutex/condvar 时反复看到的模式:

  • 用户态先读值,带 expected 调用 WAIT
  • 内核在加锁后再读一次,如果值变了就不睡(避免睡死)

3.3 真正“入队 + 睡眠”在哪?

futex_wait_queue

// kernel/futex/waitwake.c(节选)
void futex_wait_queue(struct futex_hash_bucket *hb, struct futex_q *q,
                      struct hrtimer_sleeper *timeout)
{
    set_current_state(TASK_INTERRUPTIBLE|TASK_FREEZABLE);
    futex_queue(q, hb);   // 挂到 hb->chain,然后释放 hb->lock

    if (timeout)
        hrtimer_sleeper_start_expires(timeout, HRTIMER_MODE_ABS);

    if (likely(!plist_node_empty(&q->list))) {
        if (!timeout || timeout->task)
            schedule();
    }
    __set_current_state(TASK_RUNNING);
}

我的理解:

  • set_current_state 先把自己标成可睡,再入队并放锁,避免“waker 已经 wake 了我但我还没睡”这类竞态
  • futex_queue 负责把 q 挂进 hb->chain(一个桶的一条链)
  • 如果 q 已经被移除(别人已经标记唤醒),就跳过 schedule

4)FUTEX_WAKE 的内核路径:uaddr→key→bucket→遍历 chain→match→mark→wake_up_q

4.1 futex_wake():筛选匹配 key 的等待者

// kernel/futex/waitwake.c(节选)
int futex_wake(u32 __user *uaddr, unsigned int flags, int nr_wake, u32 bitset)
{
    struct futex_hash_bucket *hb;
    struct futex_q *this, *next;
    union futex_key key = FUTEX_KEY_INIT;
    int ret;
    DEFINE_WAKE_Q(wake_q);

    if (!bitset)
        return -EINVAL;

    ret = get_futex_key(uaddr, flags & FLAGS_SHARED, &key, FUTEX_READ);
    if (unlikely(ret != 0))
        return ret;

    hb = futex_hash(&key);

    if (!futex_hb_waiters_pending(hb))
        return ret;

    spin_lock(&hb->lock);

    plist_for_each_entry_safe(this, next, &hb->chain, list) {
        if (futex_match(&this->key, &key)) {

            if (!(this->bitset & bitset))
                continue;

            futex_wake_mark(&wake_q, this);
            if (++ret >= nr_wake)
                break;
        }
    }

    spin_unlock(&hb->lock);
    wake_up_q(&wake_q);
    return ret;
}

我抓三点:

  • 同桶不同 key 混在一条链上,靠 futex_match(&this->key, &key) 过滤
  • nr_wake 表示最多唤醒几个
  • bitset 是 WAIT_BITSET/WAKE_BITSET 的过滤器(this->bitset & bitset

4.2 bitset 是什么?

一句话:按位过滤唤醒。waiter 带 q->bitset,waker 带 bitset,按位与不为 0 才唤醒。

它不是 glibc condvar 的 G1/G2 分组机制(glibc condvar 主要靠两个不同的 uaddr:__g_signals[0/1] 分开),bitset 是内核提供的额外过滤能力。


5)我最关键的细节理解:为什么要先 markwake_up_q

5.1 futex_wake_mark() 做了什么?(不是立即唤醒)

// kernel/futex/waitwake.c(节选)
void futex_wake_mark(struct wake_q_head *wake_q, struct futex_q *q)
{
    struct task_struct *p = q->task;

    get_task_struct(p);
    __futex_unqueue(q);

    smp_store_release(&q->lock_ptr, NULL);

    wake_q_add_safe(wake_q, p);
}

我对这段的理解是:

  • 它“拆的是 futex_q 节点”,不是“拆 addr”
  • __futex_unqueue(q):把这个等待者从 hb->chain 摘掉
  • q->lock_ptr = NULL(release):发布一个可靠的“我已经被 waker 真正出队/标记唤醒”状态
    这不是为了禁止虚假唤醒,而是为了让 waiter 能安全判断/释放自己的 futex_q,避免并发生命周期问题
  • wake_q_add_safe:把任务加入本次待唤醒队列(只是收集)

5.2 真正唤醒发生在哪里?

真正让线程 runnable 的动作发生在 wake_up_q(&wake_q),而且是在释放 hb->lock 之后:

spin_unlock(&hb->lock);
wake_up_q(&wake_q);

我的理解:

  • wake_q 就像一个“本次需要唤醒的任务列表”
  • 先在持锁区把要唤醒的任务收集起来(避免在 spinlock 内做重活)
  • 放锁后统一 wake(性能和锁竞争更好)

6)把内核 futex 和用户态 mutex/condvar 拼起来

现在我脑子里能把三层拼成一个闭环:

  • 用户态协议(glibc/libstdc++)负责“什么时候 wait / 什么时候 wake / 状态字怎么变 / signal 资格怎么判定”
  • 内核 futex 负责“按 key 排队、按 key 唤醒”,并用 二次读 expected + waiters 计数与屏障 保证不丢唤醒
  • 所以 mutex/condvar 的所有模式最终都归结为一句话:
    • *WAIT:只有当 uaddr == expected 才会入队睡
    • WAKE:按 key 在桶链表里找到匹配等待者,摘掉 futex_q,再统一 wake

7)我自己的最后总结

  • futex 在内核里不是一个对象,而是 “uaddr→key→bucket→等待链表” 的机制;每个等待线程对应一个 futex_q 节点。
  • FUTEX_WAIT 的正确性来自两件事:
    1)加锁后二次读取用户态值并对比 expected,不匹配就不睡(返回 -EWOULDBLOCK);
    2)入队与睡眠顺序正确(设置 task state、入队、释放锁、schedule),配合 waiters 计数与屏障避免丢唤醒。
  • FUTEX_WAKE 做的是:uaddr→key→桶→遍历链表筛选 key 匹配的 futex_q;对每个目标 q 做 mark(出队 + 发布 lock_ptr=NULL + 加入 wake_q),释放桶锁后再 wake_up_q 真正唤醒。
  • bitset 是内核提供的“按位过滤唤醒”能力,用于 WAIT_BITSET/WAKE_BITSET;glibc condvar 的 G1/G2 分组主要不是靠 bitset,而是靠两个不同 uaddr(__g_signals[0/1])。
  • 最后:用户态的 mutex/condvar/semaphore 协议之所以可靠,是因为内核 futex 提供了“expected 等待 + 按 key 唤醒”的基础语义。