记一例 vibe coding + gcc bug 导致的线程池死锁问题

1 导言

几个月前,我让 AI 帮我写了一个线程池,GitHub Repo。不得不说,就最终结果而言,确实惊艳,和 github 上几个同类线程池项目相比,在多个评估维度上明显领先[1]。但就中间过程而言,也并不全是"眩晕瘫坐,就像看到原子弹爆炸"的既视感,在个别环节上,AI 也会犯错,甚至不知道错在了哪里。

故事,哦不,事故,是这样的。

话说,那还是本人没有广泛使用 Agent 的落后时代,也是可以在 arena.ai 上与 claude opus 4.6 无限对话的美好时代。

有一天,我突发奇想,让 opus 4.6 帮我实现一个 C++ 线程池,于是,它"背"出了那个"C++11 100 行实现线程池"的经典代码(<progschj/ThreadPool>)[2]

我自然是不满意的,于是让 opus 4.6 分析当前实现有否有性能优化的空间。"啪"地一下,很快啊,它指出,当前实现采用"单一队列 + mutex + cv"的模式,高并发下,会存在激烈的锁争用和严重的系统调用开销,并提出使用"无锁队列 + 任务窃取"的优化方案。

我一看,哎呦,不错哦!虽说是线程池优化的基操,但 AI 能很快地给出来,说明基本的推理能力以及知识的广度还是在线的。那还等什么,麻溜的,开干!用 C++23!测试用例也安排上!

于是,又是"啪"地一下,很快啊,代码都吐出来了。我先在 Windows 试了一下,代码无需任何修改,直接就能跑!

丝滑,真丝滑!厉害,真厉害!完啦,感觉明天我就要被淘汰了!

激动的心,颤抖的手,我点开了虚拟机,想在 Linux 上再感受一波 AI 的暴击。然而,不出意外地出意外了------直接卡死!

1 仅基于我的 benchmark,不代表 AI 版本优于所有同类项目,也不代表在所有方面"完胜"参与对比的其它项目。

2 不光是 opus 4.6,其它 AI 也是如此。

2 死锁现场

那时的我,还没有用上 Agent,也还没有懒到"帮我解决这个bug"的地步。于是,我 gdb attach 上去,很快获得了现场。下面,我将提供此次事故的源码、环境、dgb 信息。

2.1 源码

点击展开 thread_pool.h

cpp 复制代码
#ifndef THREAD_POOL_H
#define THREAD_POOL_H

#include <atomic>
#include <cstddef>
#include <functional>
#include <future>
#include <memory>
#include <semaphore>
#include <stdexcept>
#include <thread>
#include <type_traits>
#include <vector>

class ThreadPool {
public:
    explicit ThreadPool(size_t n) : thread_count_(n)
    {
        queues_.reserve(n);
        for (size_t i = 0; i < n; ++i)
            queues_.emplace_back(std::make_unique<WorkQueue>());
        threads_.reserve(n);
        for (size_t i = 0; i < n; ++i)
            threads_.emplace_back(&ThreadPool::worker, this, i);
    }

    ~ThreadPool()
    {
        stop_.store(true, std::memory_order_release);
        for (size_t i = 0; i < thread_count_; ++i)
            sem_.release();
        for (auto& t : threads_)
            t.join();
    }

    ThreadPool(const ThreadPool&)            = delete;
    ThreadPool& operator=(const ThreadPool&) = delete;

    template <typename F, typename... Args>
        requires std::invocable<F, Args...>
    void execute(F&& f, Args&&... args)
    {
        enqueue(std::move_only_function<void()>(
            [f  = std::forward<F>(f),
             ...a = std::forward<Args>(args)]() mutable {
                std::invoke(std::move(f), std::move(a)...);
            }));
    }

    template <typename F, typename... Args>
    auto push(F&& f, Args&&... args)
        -> std::future<std::invoke_result_t<F, Args...>>
    {
        using R = std::invoke_result_t<F, Args...>;
        std::packaged_task<R()> pt(
            [f  = std::forward<F>(f),
             ...a = std::forward<Args>(args)]() mutable -> R {
                return std::invoke(std::move(f), std::move(a)...);
            });
        auto future = pt.get_future();
        enqueue(std::move_only_function<void()>(std::move(pt)));
        return future;
    }

private:
    // ================================================================
    //  无锁 MPMC 有界队列 ------ 基于 Dmitry Vyukov 算法
    //
    //  每个槽位 (Cell) 持有一个原子序列号 seq:
    //    · seq == pos          → 槽位可写入(生产者可用)
    //    · seq == pos + 1      → 槽位已写入、待消费
    //    · seq == pos + kCap   → 槽位已消费、可供下一轮写入
    //
    //  head_: 生产者竞争的写入游标
    //  tail_: 消费者竞争的读取游标
    //  二者分别对齐到独立缓存行以消除伪共享
    // ================================================================
    struct alignas(64) WorkQueue {
        // 容量必须为 2 的幂;可根据负载调大
        static constexpr size_t kCapacity = 4096;
        static constexpr size_t kMask     = kCapacity - 1;

        struct Cell {
            std::atomic<size_t>             seq;
            std::move_only_function<void()> data;
        };

        WorkQueue() : buf_(new Cell[kCapacity])
        {
            for (size_t i = 0; i < kCapacity; ++i)
                buf_[i].seq.store(i, std::memory_order_relaxed);
        }

        ~WorkQueue() { delete[] buf_; }

        WorkQueue(const WorkQueue&)            = delete;
        WorkQueue& operator=(const WorkQueue&) = delete;

        // ---------- 多生产者入队(队满时自旋让步)----------
        void push(std::move_only_function<void()> task)
        {
            size_t pos = head_.load(std::memory_order_relaxed);
            for (;;) {
                Cell&          cell = buf_[pos & kMask];
                size_t         seq  = cell.seq.load(std::memory_order_acquire);
                std::ptrdiff_t d    = static_cast<std::ptrdiff_t>(seq)
                                    - static_cast<std::ptrdiff_t>(pos);
                if (d == 0) {
                    // 槽位可用------尝试占位
                    if (head_.compare_exchange_weak(
                            pos, pos + 1, std::memory_order_relaxed))
                    {
                        cell.data = std::move(task);
                        cell.seq.store(pos + 1, std::memory_order_release);
                        return;
                    }
                    // CAS 失败:pos 已被 compare_exchange_weak 更新为最新值
                } else if (d < 0) {
                    // 队列已满,让出 CPU 后重试
                    std::this_thread::yield();
                    pos = head_.load(std::memory_order_relaxed);
                } else {
                    // 其他生产者已推进 head_,重新加载
                    pos = head_.load(std::memory_order_relaxed);
                }
            }
        }

        // ---------- 多消费者出队 ----------
        bool try_pop(std::move_only_function<void()>& out)
        {
            size_t pos = tail_.load(std::memory_order_relaxed);
            for (;;) {
                Cell&          cell = buf_[pos & kMask];
                size_t         seq  = cell.seq.load(std::memory_order_acquire);
                std::ptrdiff_t d    = static_cast<std::ptrdiff_t>(seq)
                                    - static_cast<std::ptrdiff_t>(pos + 1);
                if (d == 0) {
                    // 槽位有数据------尝试占位
                    if (tail_.compare_exchange_weak(
                            pos, pos + 1, std::memory_order_relaxed))
                    {
                        out = std::move(cell.data);
                        cell.seq.store(pos + kCapacity,
                                       std::memory_order_release);
                        return true;
                    }
                    // CAS 失败:pos 已被更新
                } else if (d < 0) {
                    return false;   // 队列为空
                } else {
                    // 其他消费者已推进 tail_,重新加载
                    pos = tail_.load(std::memory_order_relaxed);
                }
            }
        }

        // ---------- 窃取 = 出队(MPMC 只有一个消费端)----------
        bool try_steal(std::move_only_function<void()>& out)
        {
            return try_pop(out);
        }

    private:
        Cell* buf_;
        alignas(64) std::atomic<size_t> head_{0};   // 生产者游标
        alignas(64) std::atomic<size_t> tail_{0};   // 消费者游标
    };

    // ===================== 任务分发 =====================
    void enqueue(std::move_only_function<void()> task)
    {
        if (stop_.load(std::memory_order_acquire)) [[unlikely]]
            throw std::runtime_error("thread pool has stopped");

        static thread_local size_t local_next =
            std::hash<std::thread::id>{}(std::this_thread::get_id());

        const size_t target =
            (local_pool_ == this)
                ? local_id_
                : local_next++ % thread_count_;
        
        queues_[target]->push(std::move(task));

        if (sleeping_.load(std::memory_order_seq_cst) > 0)
            sem_.release();
    }

    // ============ 先查自己队列,再窃取邻居 ============
    bool try_pop_or_steal(size_t id,
                          std::move_only_function<void()>& task)
    {
        if (queues_[id]->try_pop(task)) return true;
        for (size_t i = 1; i < thread_count_; ++i)
            if (queues_[(id + i) % thread_count_]->try_steal(task))
                return true;
        return false;
    }

    // ================ 工作线程循环 ================
    void worker(size_t id)
    {
        local_pool_ = this;
        local_id_   = id;

        while (true) {
            std::move_only_function<void()> task;
            if (try_pop_or_steal(id, task)) { task(); continue; }
            if (stop_.load(std::memory_order_acquire)) return;
            sleeping_.fetch_add(1, std::memory_order_seq_cst);
            if (try_pop_or_steal(id, task)) {
                sleeping_.fetch_sub(1, std::memory_order_relaxed);
                task();
                continue;
            }
            if (stop_.load(std::memory_order_acquire)) {
                sleeping_.fetch_sub(1, std::memory_order_relaxed);
                return;
            }

            sem_.acquire();
            sleeping_.fetch_sub(1, std::memory_order_relaxed);
        }
    }

    // ================ 成员变量 ================
    const size_t                              thread_count_;
    std::vector<std::unique_ptr<WorkQueue>>   queues_;
    std::vector<std::thread>                  threads_;

    alignas(64) std::atomic<bool>             stop_{false};
    alignas(64) std::atomic<int>              sleeping_{0};
    alignas(64) std::counting_semaphore<>     sem_{0};

    static inline thread_local ThreadPool*    local_pool_ = nullptr;
    static inline thread_local size_t         local_id_   = 0;
};

#endif // THREAD_POOL_H

点击展开 main.cpp

cpp 复制代码
#include "thread_pool.h"

#include <atomic>
#include <cmath>
#include <iostream>
#include <cassert>
#include <random>
#include <chrono>
#include <format>
#include <functional>
#include <string>
#include <algorithm>
#include <latch>

void run_bench(const std::string& name,
               std::function<void()> fn,
               int warmup  = 2,
               int repeats = 10) 
{
    for (int i = 0; i < warmup; ++i) fn();

    std::vector<double> samples(repeats);
    for (int i = 0; i < repeats; ++i) {
        auto t0 = std::chrono::steady_clock::now();
        fn();
        auto t1 = std::chrono::steady_clock::now();
        samples[i] = std::chrono::duration<double, std::milli>(t1 - t0).count();
    }

    double sum  = std::accumulate(samples.begin(), samples.end(), 0.0);
    double mean = sum / repeats;
    double sq   = 0;
    for (auto s : samples) sq += (s - mean) * (s - mean);
    double stddev = std::sqrt(sq / repeats);

    std::cout << std::format(
        "  {:>20s}  |  mean {:>10.3f} ms  |  std {:>8.3f} ms  |"
        "  min {:>10.3f} ms  |  max {:>10.3f} ms  |  iters {:>3d}\n",
        name, mean, stddev,
        *std::min_element(samples.begin(), samples.end()),
        *std::max_element(samples.begin(), samples.end()),
        repeats);
}

void bench_fire_and_forget(int task_count) {
    std::atomic<int> counter{0};
    {
        ThreadPool pool(std::thread::hardware_concurrency());
        for (int i = 0; i < task_count; ++i) {
            [[maybe_unused]] auto f = pool.push([&counter] {
                counter.fetch_add(1, std::memory_order_relaxed);
            });
        }
    }
}

int main() {
    run_bench("Fire-and-forget", [&] { bench_fire_and_forget(100'000); }, 2, 10);
    return 0;
}

2.2 环境信息

  • OS:Ubuntu 虚拟机
bash 复制代码
$ uname -a
Linux user1 5.15.0-171-generic #181-Ubuntu SMP Fri Feb 6 22:44:50 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
  • g++ 版本:
bash 复制代码
$ g++ --version
g++ (Ubuntu 15.2.0-15ubuntu1~22~ppa2) 15.2.0
Copyright (C) 2025 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
  • 编译命令:g++ -std=c++23 -O0 -g -fno-omit-frame-pointer -o bench main.cpp
  • 执行命令:./bench
  • std::thread::hardware_concurrency()输出:2

2.3 gdb 调试信息

点击展开 gdb 调试信息

plain 复制代码
(gdb) info thread
  Id   Target Id                                Frame
* 1    Thread 0x7f41af8f4780 (LWP 1410) "bench" __futex_abstimed_wait_common64 (private=128, cancel=true, abstime=0x0, op=265, expected=1423, futex_word=0x7f41af891910) at ./nptl/futex-internal.c:57
  2    Thread 0x7f41af891640 (LWP 1423) "bench" syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:38
(gdb) bt 6
#0  __futex_abstimed_wait_common64 (private=128, cancel=true, abstime=0x0, op=265, expected=1423, futex_word=0x7f41af891910) at ./nptl/futex-internal.c:57
#1  __futex_abstimed_wait_common (cancel=true, private=128, abstime=0x0, clockid=0, expected=1423, futex_word=0x7f41af891910) at ./nptl/futex-internal.c:87
#2  __GI___futex_abstimed_wait_cancelable64 (futex_word=futex_word@entry=0x7f41af891910, expected=1423, clockid=clockid@entry=0, abstime=abstime@entry=0x0, private=private@entry=128) at ./nptl/futex-internal.c:139
#3  0x00007f41af98f624 in __pthread_clockjoin_ex (threadid=139920094598720, thread_return=0x0, clockid=0, abstime=0x0, block=<optimized out>) at ./nptl/pthread_join_common.c:105
#4  0x00007f41afd27a1b in std::thread::join() () from /lib/x86_64-linux-gnu/libstdc++.so.6
#5  0x0000558ba2cb5f14 in ThreadPool::~ThreadPool (this=0x7ffee66d4940) at /home/xxx/code/thread/thread_pool.h:33
(More stack frames follow...)
(gdb) t 2
[Switching to thread 2 (Thread 0x7f41af891640 (LWP 1423))]
#0  syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:38
38      ../sysdeps/unix/sysv/linux/x86_64/syscall.S: No such file or directory.
(gdb) bt 6
#0  syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:38
#1  0x0000558ba2cb9fc9 in std::__detail::__platform_wait<int> (__addr=0x7ffee66d4a00, __val=1) at /usr/include/c++/15/bits/atomic_wait.h:114
#2  0x0000558ba2cbb0f2 in std::__atomic_wait_address_bare<std::__atomic_semaphore::_M_acquire()::{lambda()#1}>(int const*, std::__atomic_semaphore::_M_acquire()::{lambda()#1}) (__addr=0x7ffee66d4a00, __pred=...) at /usr/include/c++/15/bits/atomic_wait.h:451
#3  0x0000558ba2cbd052 in std::__atomic_semaphore::_M_acquire (this=0x7ffee66d4a00) at /usr/include/c++/15/bits/semaphore_base.h:202
#4  std::counting_semaphore<2147483647l>::acquire (this=0x7ffee66d4a00) at /usr/include/c++/15/semaphore:77
#5  0x0000558ba2cb7105 in ThreadPool::worker (this=0x7ffee66d4940, id=0) at /home/xxx/code/thread/thread_pool.h:228
(More stack frames follow...)
(gdb) f 5
#5  0x0000558ba2cb7105 in ThreadPool::worker (this=0x7ffee66d4940, id=0) at /home/xxx/code/thread/thread_pool.h:228
228                 sem_.acquire();
(gdb) p sleeping_
$4 = std::atomic<int> = { 1 }
(gdb) p stop_
$5 = std::atomic<bool> = { true }
(gdb) p &sem_
$1 = (std::counting_semaphore<2147483647> *) 0x7ffee66d4a00
(gdb) p sem_
$6 = {_M_sem = {static _S_max = 2147483647, _M_counter = 52993}}

3 诊断过程

很明显,一个 worker 线程卡在了 sem_.acquire(),导致主线程也卡在了析构函数的 std::thread::join()。但 worker 线程为什么会卡住,我却不知道了。那问 AI 呗,我把栈都抓出来了,剩下的交给 AI,还不是手拿把掐?

然而,问题喂给 opus 4.6 后,它开始疯狂思考,"等等,我再看一遍","让我再检查 xxx",......,直到平台返回错误码。(我猜测是思考太多,触发了 claude 或者 arena.ai 的限制)

我又把同样的问题丢给 GPT 和 Gemini,它们倒是给出了答案,但一试,全都不对。

最后,您猜怎么着?谁解决了这个问题?

是 Grok!

惊不惊喜?意不意外?"啪"地一下,Grok 告诉我,代码没问题,是 libstdc++ std::counting_semaphore::acquire() 的已知 bug,GCC PR104928

眩晕瘫坐,原子弹爆炸!

作为事后诸葛亮,我忽然明白了,为什么 opus 4.6 "卡死"了,因为它和我一样,压根没往"gcc 自身 bug"方面去想,反而在一遍遍疯狂审查一份压根就不存在逻辑问题的代码!

为什么 Grok 做对了?它是这样想的:

  1. 代码逻辑没有问题!
  2. sem_._M_counter = 52993 (非 0 值),但 sem_.acquire()却陷入了 wait,这不正常!
  3. 我去找找是否有 std::counting_semaphore::acquire()的已知 bug。
  4. 找到了!和当前问题对得上,就是它!

现在回过头来想想,这不就是排查这类问提的正常套路吗?只要注意到了第 2 步的异常,剩下的不是顺理成章,水到渠成吗?很可惜,我没有注意到,更没敢怀疑是 GCC 自己的问题。我真傻,真的。

一个 AI 有一个 AI 的长处,联网搜索这一块,不得不说,Grok 还是能打的。

4 PR104928 bug 分析

4.1 背景知识

为帮助读者理解后文的 bug 分析,这里简单介绍必要知识。

std::counting_semaphore配合其 release()acquire()方法,可以实现一种事件通知机制。

  • release():
    • 逻辑上相当于"发放通行证",只有获得通行证的线程,才可以做某种动作,比如访问共享资源。
    • 底层实现上,会对计数器 _M_counter原子加 1,表示"发放 1 张通行证"。同时,如果 _M_counter加 1 前的值是 0,意味着可能有其它线程正在等待通行证(陷入了睡眠),因此,会执行 notify 操作以唤醒正在等待的线程。
  • acquire():
    • 逻辑上相当于"获取通行证"。
    • 底层实现上,会通过 CAS 操作对计数器 _M_counter原子减 1,表示"抢占 1 张通行证"。如果 CAS 操作之前 _M_counter == 0,表明"没有可用通行证",线程就会 wait(),等待生产者发放通行证时被唤醒。换言之,在没有 bug 的前提下,若线程在调用 acquire()时陷入睡眠,必然有 _M_counter == 0

以上只是 std::counting_semaphore的冰山一角,其它内容因与本文主题无关,故不做介绍,感兴趣的读者请自行学习。

4.2 修复前代码

acquire()的底层实现是 _M_acquire()

cpp 复制代码
_GLIBCXX_ALWAYS_INLINE void
_M_acquire() noexcept
{
  auto const __vfn = [this]{ return _S_get_current(&this->_M_counter); };
  auto const __pred = [this](__count_type __cur) {
    return _S_do_try_acquire(&this->_M_counter, __cur);          // 关键:predicate 里做 CAS
  };
  std::__atomic_wait_address(&_M_counter, __pred, __vfn, true);  // 直接等待
}

_S_do_try_acquire的实现是:

cpp 复制代码
static _GLIBCXX_ALWAYS_INLINE bool
_S_do_try_acquire(__count_type* __counter, __count_type __old) noexcept
{
  if (__old == 0)
    return false;
  return __atomic_impl::compare_exchange_strong(     // CAS
      __counter, __old, __old - 1,
      memory_order::acquire, memory_order::relaxed);
}

不难看出:

  • _M_acquire()的核心就是执行 std::__atomic_wait_address,根据源码注释的描述,如果 __pred(__vfn)的结果是 false,那么 std::__atomic_wait_address就会 wait&_M_counter这个地址上。

  • __vfn就是用来加载 _M_counter的。

  • __pred是一个基于 _M_counter做判断的谓词(Predicate):

    • 如果 _M_counter的旧值(__vfn读到的那个)已经是 0 了,不能再减,直接返回 false
    • 否则,通过 CAS 操作(__atomic_impl::compare_exchange_strong)尝试将 _M_counter减 1,返回 CAS 结果(如果减 1 成功返回 true,否则返回 false)。
  • std::__atomic_wait_address根据 __pred返回结果决定是否 wait

4.3 bug 触发

根因

bug 出在 __pred的实现上。作为一个 Predicate,__pred理论上应该是一个 Pure Function,并且应该是 No Side Effects 的,即除了返回 true/false外,它不应该修改输入或者全局状态。但这里,GCC 犯了一个教科书级别的错误:在 __pred中使用 CAS 修改计数器 _M_counter

过程

  1. 在高并发场景下,假设线程 A 在执行 CAS 操作前的一瞬间,另一个线程改了 _M_counter的值(生产者线程执行了 sem_.release(),或者其它执行 sem_.acquire()的 worker 线程 CAS 成功),导致线程 A 的 CAS 失败。
  2. 于是,false沿 std::__atomic_compare_exchange_strong --> _S_do_try_acquire --> __pred一路返回给 std::__atomic_wait_address
  3. 线程 A 陷入睡眠。如果此后再没有线程触发 notify,线程 A 将永久睡死!

4.4 bug 修复

对应 commit

修复后代码:

cpp 复制代码
void _M_acquire() noexcept
{
  auto const __vfn = [this]{ return _S_get_current(&this->_M_counter); };
  auto __val = __vfn();
  // 注意,这里按引用捕获 __val
  auto const __pred = [&__val](__count_type __cur) {
    if (__cur > 0)
    {
      __val = __cur;  // 一个很有意思的细节,后面解释
      return true;
    }
    return false;
  };
  while (!_S_do_try_acquire(&_M_counter, __val))
    if (__val == 0)
      std::__atomic_wait_address(&_M_counter, __pred, __vfn, true);
}

// 另外的修改是,_S_do_try_acquire 的第二个参数 __old 由传值改为传引用
static _GLIBCXX_ALWAYS_INLINE bool
_S_do_try_acquire(__count_type* __counter, __count_type& __old) noexcept
{ /* ... */}

对于不想深究细节的读者,只需明白,核心修复就是让 __pred恢复一个 Predicate 该有的样子,把 CAS 操作移出去。(注:__val是局部变量,__val = __cur不违背"不应该修改输入或者全局状态"的约束)。

想要深入了解的读者,请接着往下看。

要更好地理解这个修复,需要了解两点信息:

  1. 抛开 bug 不谈,按照设计预期,只要调用了 std::counting_semaphore::acquire(),就一定会对计数器 _M_counter减 1:
    • 要么 _M_counter大于 0 时 CAS 成功(已减 1),acquire()直接返回。
    • 要么发现 _M_counter等于 0,睡眠等待,被唤醒后再减 1。
  2. std::__atomic_wait_address中,线程被唤醒后,还会再调用 __pred(__vfn())(逻辑上是这样,实际代码不是这么写的),详见源码

修复前的逻辑(没意识到 bug 的视角)

  • 如果 __pred返回 true,说明 CAS 中减 1 成功,_M_acquire()直接返回。
  • 如果 __pred返回 false
    • 说明 _M_counter为 0,陷入睡眠。(命中 bug: 写这份代码的人没有意识到,并发竞争可能导致 CAS 失败,返回 false,但此时 _M_counter > 0
    • std::__atomic_wait_address内部,线程被唤醒后会再次执行 __pred,此时会再次通过 CAS 做减 1 操作。若 _pred返回 false,就继续睡;否则,acquire()结束,从用户视角看,线程真的被唤醒。

修复后的逻辑

  • 先执行 while循环中的条件 _S_do_try_acquire,注意两个关键事实,它们保证了 _S_do_try_acquire函数退出后,__val一定保存了 _M_counter的最新值。
    • _S_do_try_acquire中,__val按引用传递。
    • 对于 __atomic_impl::compare_exchange_strong(__counter, __old, __old - 1, ...),如果 CAS 失败,__old会被更新为 __counter指向的内存(即 _M_countdr)的最新值,这是 C++ 下 CAS 操作的一个特性。
  • 如果 _S_do_try_acquire返回 true,说明通过 CAS 减 1成功,while (!_S_do_try_acquire(&_M_counter, __val))不命中,_M_acquire()直接结束。
  • 否则,进入到 while循环的内部。如前所述,此时 __val保存了 _M_counter的最新值。
    • 如果 _val == 0,说明 _M_counter可能一开始就是 0(根本没进入 CAS);或者别的线程 CAS 成功,将其由 1 改成了 0,当前线程 CAS 失败。但不管哪种情况,当前线程不得不进入睡眠(通行证为 0,啥也干不了)。
      • 当线程被唤醒,会再次进入 while循环,再次执行 _S_do_try_acquire,如果 _S_do_try_acquire返回 true,说明减 1 成功,_M_acquire()直接结束;否则接着进入 if (__val == 0)的逻辑,继续睡眠......
    • 否则,说明当前线程在 CAS 竞争中失败了(不然 _S_do_try_acquire不会返回 false),被别人抢先拿走了通行证,但剩余通行证数量不为0,于是再次进入 while循环,继续争抢下一张通行证。

一个细节

修改后的代码,lambda表达式 __pred按引用捕获了 __val,并且当 __cur(即 _M_counter的最新值)大于 0 时,将其赋值给 __val。这有什么作用呢?

前面说过,在 std::__atomic_wait_address中,当线程被唤醒时,会再次执行调用 __pred(__vfn())。在 __pred内部将 _M_counter最新值赋值给 __val,意味着,当线程从 std::__atomic_wait_address中退出,回到 while (!_S_do_try_acquire(&_M_counter, __val))时,__val的值就是 _M_counter的最新值,这省去了一次 atomic load,是一个性能优化。否则,代码就需要这样写:

cpp 复制代码
void _M_acquire() noexcept
{
  // 其它保持不变...
  
  // 如果 __pred 内部不执行 __var = __cur 
  auto const __pred = [](__count_type __cur) {
    if (__cur > 0) {
      return true;
    }
    return false;
  };
  while (!_S_do_try_acquire(&_M_counter, __val))
    if (__val == 0) {
       std::__atomic_wait_address(&_M_counter, __pred, __vfn, true);
       __val = __vfn(); // 那么,这里就必须重新加载 _M_counter
    }
}

5 线程池死锁分析

5.1 Ubantu libstdc++ 代码段

我的代码是在 Ubantu 系统上构建的,与 PR104928 在细节上有些不一样。下面,我将提供 Ubantu 上 libstdc++ 相关代码段,这些代码与 gdb 调试信息中引用的代码完全一致。
点击展开 semaphore_base.h 中的代码段

cpp 复制代码
static _GLIBCXX_ALWAYS_INLINE bool
_S_do_try_acquire(__detail::__platform_wait_t* __counter) noexcept
{
  auto __old = __atomic_impl::load(__counter, memory_order::acquire);
  if (__old == 0)
    return false;

  return __atomic_impl::compare_exchange_strong(
    __counter, __old, __old - 1,
    memory_order::acquire,
    memory_order::relaxed);
}

_GLIBCXX_ALWAYS_INLINE void
_M_acquire() noexcept
{
  auto const __pred =
    [this] { return _S_do_try_acquire(&this->_M_counter); };
  std::__atomic_wait_address_bare(&_M_counter, __pred);
}

_GLIBCXX_ALWAYS_INLINE void
_M_release(ptrdiff_t __update) noexcept
{
  // 注:fetch_add 返回的是自增前的值
  if (0 < __atomic_impl::fetch_add(&_M_counter, __update, memory_order_release))
    return;
  if (__update > 1)
    __atomic_notify_address_bare(&_M_counter, true);
  else
    __atomic_notify_address_bare(&_M_counter, true);
  // FIXME - Figure out why this does not wake a waiting thread
  //  __atomic_notify_address_bare(&_M_counter, false);
}

点击展开 atomic_wait.h 中的代码段

cpp 复制代码
template<typename _Pred,
         typename _Spin = __default_spin_policy>
static bool
_S_do_spin(const __platform_wait_t* __addr,
           _Pred __pred,
           __platform_wait_t& __val,
           _Spin __spin = _Spin{ })
{
  __atomic_load(__addr, &__val, __ATOMIC_ACQUIRE);
  return __atomic_spin(__pred, __spin);
}

template<typename _Pred>
void
__atomic_wait_address_bare(const __detail::__platform_wait_t* __addr,
                _Pred __pred) noexcept
{
#ifdef _GLIBCXX_HAVE_PLATFORM_WAIT
  do {
    __detail::__platform_wait_t __val;
    if (__detail::__bare_wait::_S_do_spin(__addr, __pred, __val))
      return;
    __detail::__platform_wait(__addr, __val);
  } while (!__pred());
#else // !_GLIBCXX_HAVE_PLATFORM_WAIT
  __detail::__bare_wait __w(__addr);
  __w._M_do_wait(__pred);
#endif
}

5.2 关键信息

结合上述代码段,以及 gdb 打印的栈帧,可以发现以下关键信息:

信息1:worker 线程等在哪里

worker 线程 #1 栈帧:

#1 0x0000558ba2cb9fc9 in std::__detail::__platform_wait<int> (__addr=0x7ffee66d4a00, __val=1) at /usr/include/C++/15/bits/atomic_wait.h:114

sem_地址:

(gdb) p &sem_

$1 = (std::counting_semaphore<2147483647> *) 0x7ffee66d4a00

结合 std::__atomic_wait_address_bare源码,不难看出,死锁发生时,worker 线程卡在 __platform_wait(&sem_._M_counter, 1)上。

信息2: sem_计数值

当形成死锁局面(worker 线程在 wait(),主线程在 join())时,sem_._M_counter值为 52993。

(gdb) p sem_

$6 = {_M_sem = {static_S_max = 2147483647,_M_counter = 52993}}

5.3 死锁时间线

结合线程池代码、Ubantu libstdc++ 代码段、gdb 调试信息、PR14928,让我们来看看具体的死锁时间线。

5.3.1 Phase 1:快照读取与 CAS 失败

  • T1 :假设线程池运行到某个时刻时,刚好 _M_counter == 1
  • T2 :Worker A 进入 _M_acquire(),调用 __atomic_wait_address_bare,继而进入 _S_do_spin
  • T3 (快照定格) :Worker A 执行 __atomic_load(__addr, &__val, ...),读取到 sem_._M_counter当前值为 1,该值作为快照信息被存到局部变量 __val中。
  • T4 :Worker A 开始进入 __atomic_spin 循环,调用 __pred(),即 _S_do_try_acquire
  • T5 :Worker A 在 _S_do_try_acquire 中读取到 __old = 1,准备执行 compare_exchange_strong(expected=1, desired=0)
  • T6 (关键抢占) :在 Worker A 执行 CAS 指令前的一瞬间,Worker B 冲入并率先 CAS 成功,将 _M_counter 减为 0。(在线程池测试中,主线程一次性投递 10W 个任务,而每个任务极轻,几乎瞬间就被执行完。因此,两个 worker 线程会疯狂竞争信号量,所以,信号量被抢占的概率大大增加。)
  • T7 :Worker A 执行 CAS,发现内存值已经是 0 而非预期的 1,CAS 失败。__pred() 返回 false
  • T8 :Worker A 在剩下的 spin 循环中,看到的值都是 0,自旋周期耗尽,_S_do_spin 彻底返回 false

5.3.2 Phase 2:Lost Wakeup

  • T9 :Worker A 退出自旋,准备执行 __detail::__platform_wait(__addr, __val)。注意,此时 __val 依然是 T3 时期快照下来的 1
  • T10 :就在 Worker A 准备发起 futex 系统调用但尚未进入内核态 的刹那,主线程(Producer) 推入了一个新任务,并调用了 sem_.release()
  • T11 :主线程执行 _M_release(),调用 fetch_add_M_counter0 变回 1。因为此时旧值是 00 < 0false),主线程触发了 __atomic_notify_address_bare(即 futex_wake)。
  • T12 :然而 Worker A 尚在用户态,这次 futex_wake 扑空(Lost Wakeup)。

5.3.3 Phase 3:ABA 问题和 _M_release()优化导致永久睡死

  • T13 :Worker A 终于陷入内核态,执行真正的等价操作:futex(addr, FUTEX_WAIT, expected=1)

  • T14 :事实上,为了避免错误地陷入睡眠(不该睡眠却睡眠了),内核是做了兜底的。在真正陷入睡眠的前一刻,内核会再次读取 addr指向的内存值,如果发现和 expected不相等,就立即返回而不是陷入睡眠。但这一检查在这里也失效了,因为此时 *addr真的是 1,*addr == expected 条件完美成立。于是 Worker A 彻底陷入睡眠。这里有一个经典的 ABA 现象:

    • Worker A 在调用 _M_acquire()时,_M_counter的值是 1(T2)。
    • CAS 竞争时,Worker B 将 _M_counter改为了 0(T6)。
    • Worker A 陷入睡眠前,主线程执行了 sem_.release()_M_counter加 1 后又变成了 1(T11)。
  • T15 :主线程继续推入剩余的任务,由于此时 Worker A 在睡眠,sleeping_值为 1,满足 sleeping_ > 0的条件,于是,主线程不断调用 sem_.release()

  • T16 (优化帮倒忙) :每次主线程调用 _M_release()fetch_add 返回的旧值依次是 1, 2, 3... 直到 52992。因为所有旧值都 > 0,主线程每次都命中 if (0 < __atomic_impl::fetch_add(...)) return; 这条快速路径,彻底跳过了 __atomic_notify_address_bare

  • T17 :现在只剩 Worker B 在工作,它完成上一个任务后,几乎总是能拿到下一个任务,不会再调用 sem_.acquire()[3],即使偶尔调用,也不能把 _M_counter从几万降到 0。因此,所有的 sem_.release(),包括析构函数中的两次,都会在 _M_release()内部走到快速返回路径,根本不会触发 notify, Worker A 永久睡死。

3 注意:在线程池的设计中,sem_.acquire()sem_.release()并不是 1:1 的。只要有 worker 在 sleep,生产者线程在 push 任务时就会调用 sem_.release()来唤醒 worker 线程;而对 worker 线程而言,只有在拿不到任务(从自己的队列 & 从别人的队列)时,才会执行 sem_.acquire()。这种设计,在 _M_acquire()没有 bug 时,是没有问题的,即 _M_acquire()只有在 _M_counter_为 0 时才会陷入睡眠,这样,后序的 _M_release()一定会唤醒它。

6 总结

基于实现线程池的整个过程(不限于这个死锁 bug),总结以下个人主观体验。

6.1 LLM 擅长干什么?

超级浏览器,知识的搬运工 。LLM 掌握了远超人类的知识,并能基本正确地运用这些知识。在知识的搜集与运用方面,LLM 远胜人类,当然,效率也远胜人类。

例如,对于线程池的常规优化方案(无锁队列、工作窃取等)、常见的无锁队列(Vyukov's MPMC Queue 和 Chase-Lev Deque 等)、复杂的 C++ 模板、C++20/C++23 新特性等知识,国内外主流模型都是了解的。并且,在将这些知识落地方面,尽管不同模型写的代码在简洁性、可读性、精确程度([[nodiscard]]noexcept的使用等)上存在细微差距,但都能跑,不需要人工反复调试。

6.2 LLM 在哪些地方做的还不够好?

6.2.1 精细化和 100% 正确

线程池这样的项目,看起来不大,连测试代码算上,也不过 1000 来行,更没有复杂的业务逻辑或者创新的技术。但是,你依然不能说它简单。

  • 你需要考虑各种并发场景以避免 Lost Wakeup 或其它形式的死锁;
  • 你需要仔细斟酌每一处内存序(Memory Order)的使用以便在安全的前提下最大化效率;
  • 你需要了解 C++ 并发编程中的 memory visibility and execution order (Sequenced-Before, Happens-Before, Synchronizes-With, etc.);
  • 你需要了解编译器重排以及不同架构(x86 / ARM)下的 CPU 指令重排、缓存一致性协议、Store Buffer、Invalidate Queue。

要真正写好一个线程池,需要处理好上述每一个问题,这需要 LLM 做精细的控制和复杂的推理。比如,将 xxx 的内存序由 memory_order_acquire改为 memory_order_relaxed行不行?会不会破坏内存可见性保证?会不会引发死锁?是否考虑了所有的并发情况?

以上这些,即便是头部模型,也依然做得不够好。换言之,写出一个能 run 的线程池,主流模型都可以做到。但是,写出一个没有死锁隐患、内存序控制得恰到好处的线程池,鲜有模型可以做到。

6.2.2 LLM 有和人类一样的通病

6.2.2.1 过度强化知识点

不知读者是否有过和我一样的经历:哎,这题我会,这不是那个 xxx 知识点嘛!结果,题做错了。

这一点上,LLM 的表现很像人类,在"脑海"中过度强化了某些"划重点"的知识并在以下两种情况下错误地运用它们:

  • 这个场景我熟,看已知信息,不就是 xxx 嘛!结果,忽略了题目的细节和差异性,用错了知识点,答错了问题。
  • 这题我真不会了,但我掌握的知识是 xxx,没办法,死马当活马医吧,生搬硬套,凑个答案吧。

举个例子,Gemini 3.1 Pro Priview,在解决 memory order 相关问题时,过分强化了" Dekker 算法解决 Store-Load 重排"的知识点,错误地识别了场景,导致选择了过于严格的 memory order。

6.2.2.2 不敢质疑权威

本文所述的事件就是一个典型案例,Opus 4.6 疯狂审查自己的代码,也没有怀疑是标准库的 bug。

6.3 哪些方面能拉开不同模型的差距?

  • 编码小有差距。如前所述,这不是对错的差距,只是质量的差距。同样的编码任务(比如写一个通用的 benchmark 框架,把对不同线程池的调用统一起来),Opus 4.6 可以用最新的 C++ 特性,用最简洁的方法实现;而 Gemini 3.1 Pro Priview 和 GLM 5.1(那时还没有 GLM 5.2)也能完成任务,但代码要复杂很多。
  • 推理大有差距。这是对与错的差距。比如,我说"依次审查所有内存序的使用,看是否有死锁隐患,或者可以安全降级的地方"。Opus 4.6 的分析和结论全部正确;Gemini 3.1 Pro Priview 的结论正确,但分析过程有瑕疵;国产模型的结论就是错的,按它的建议修改代码,会导致死锁!可见,在需要"复杂思考"的问题上,国产模型还是差点意思。

6.4 经验

  • 把问题分得再细点,再小点,给模型提供更加详尽的信息,一次只解决一个具体问题。
  • 同一个复杂问题,多问几个模型。
  • 传统艺能不能丢,AI 时代需要克服思维惰性,多动脑子,想想"假如没有 AI,我该如何解决这个问题"。
相关推荐
我叫洋洋3 小时前
C ++ [ hello world ]
c语言·c++·算法
王维同学7 小时前
[自学][Windows C++]RunOnceEx 注册表分组键的安全遍历
c++·windows·安全·开源
Scott9999HH8 小时前
告别流量波动玄学!从法拉第电磁定律到底层 C/C++ 流量累积算法,深度解密工业级电磁流量计选型与开发
c语言·c++·算法
脱胎换骨-军哥8 小时前
C++/Rust无缝互操作:混合系统新常态
开发语言·c++·rust
旖-旎9 小时前
LeetCode 279:完全平方数(完全背包)—— 题解
c++·算法·leetcode·动态规划·背包问题
胖大和尚11 小时前
C++ 多线程编程的实现方式
c++·thread
在水一缸12 小时前
深入浅出 Catch2:现代 C++ 测试框架的优雅实践
开发语言·c++·单元测试·log4j·测试框架·catch2
2401_8414956412 小时前
【数据结构】B*树
数据结构·c++·b树·算法·删除·插入·三分分裂
斐夷所非13 小时前
在纷繁竞逐中稳步前行:C++ 2006–2020
c++