多线程实务最佳实践 C++ 篇 ── 用 RAII 和 jthread 从结构上消除事故

· · Windows, 多线程, C++, Visual Studio, 业务应用, 故障排查, 设计

「在 C# 里能正常运行的设计搬到 C++ 之后,偶尔会崩溃」「用了 std::thread,结果一旦抛出异常,整个应用就因为 terminate 而当场死亡」「原本靠 volatile bool 标志来停止,却唯独在 Release 版本里停不下来」── C++ 的多线程存在托管语言所没有的独特恐怖之处。因为数据竞争会直接变成未定义行为(undefined behavior)。这并不仅仅是“读到损坏的值”那么简单,编译器优化的前提假设一旦崩塌,就会进入“发生什么都不奇怪”的状态。

本文是多线程实务系列的C++ 篇。面向用现代 C++(C++17/20)编写业务应用、设备控制、DLL 的开发者,把多线程设计的原则 ── 不直接增加线程、减少共享可变状态、锁的纪律、优先设计停止方式 ── 落实到 C++ 与 Windows 的工具上,并结合 C++ 特有的陷阱,基于 2026 年 8 月时点的一手资料进行梳理。本文写得可以单独阅读。用同样的原则在其他语言中展开的「.NET 篇」「C 语言篇」「Java 篇」也已发布。

1. 先说结论

  • 在 C++ 中,数据竞争不是“能读到损坏的值”这么简单,而是“未定义行为”。不留下任何一处未同步的共享可变访问,比其他语言更是绝对的前提条件。1
  • 请不要直接裸用 std::thread如果在仍然 joinable 的状态下让 std::thread 的析构函数运行,就会触发 std::terminate,进程当场终止。C++20 的 std::jthread 会在析构函数中自动 join,并且内置了停止请求(stop_token)机制。23
  • 锁必须始终通过 RAII 持有。不要手写 mtx.lock(),而是使用 lock_guard / scoped_lock。即使抛出异常,析构函数也会确保释放。同时获取多个锁时,scoped_lock 会用死锁规避算法来处理。4
  • volatile 不是同步工具。共享标志与计数器应使用 std::atomic,多个变量的保护则使用 std::mutexstd::atomic 提供操作的不可分性,以及基于 memory_order 的顺序保证。5
  • 等待应使用带谓词的 condition_variable::wait条件变量存在虚假唤醒(spurious wakeup,即未收到通知却醒来的现象),因此不带谓词的 wait 是滋生错误的温床。6
  • 停止方式的基本形态是 jthread + stop_token(C++20)。在更早的环境中,用 std::atomic<bool> + 条件变量来搭建协作式停止。请把“强制终止线程”当作 C++ 的世界里不存在的东西。3
  • 使用 std::async 之前,请先了解 future 的析构函数会阻塞这一点。如果丢弃返回值,就等同于串行执行。7
  • Win32 同步对象只在“与 Win32 等待 API 联动”和“跨进程”这两种场合才需要登场。除此之外用标准库来写,在可移植性和可维护性上都更有利。8

2. 为什么多线程很难 ── 竞态条件、死锁与未定义行为

多线程带来的问题,无论哪种语言,归根结底可以归为两类。

竞态条件(race condition)是指结果会因多个线程到达某段代码的先后顺序而改变的 bug。最经典的例子是共享计数器:++count 这一个表达式在机器码层面会拆分成“读取 → 加法 → 写回”三个步骤。如果两个线程同时进入这三个步骤,其中一方的加法结果就会被另一方的写回覆盖而消失。每次执行结果都会不同,且无法预测会得到哪种结果。

线程B共享变量 count线程A线程B共享变量 count线程Acount = 10加了两次,count 却还是 11线程A的加法结果丢失读取(10)读取(10)本地加1(11)本地加1(11)写回(11)写回(11)

图1: 共享计数器中加法结果丢失的典型竞态条件。当另一个线程在 ++count 的三个步骤之间插入进来时,后写回的一方会覆盖先写回的一方

死锁是指两个线程互相等待对方持有的锁,导致谁都无法继续前进的状态。线程A持有锁1并等待锁2,线程B持有锁2并等待锁1 ── 仅凭这一点,两者就会永远停滞。

等待锁2释放等待锁1释放线程A持有锁1线程B持有锁2

图2: 死锁的循环等待。当等待的箭头形成一个环的瞬间,环中所有线程都会永远停止

麻烦的地方在于,这两者都依赖于时机。在开发机上几万次才会中一次的执行顺序组合,在核心数和时机都不同的客户机上却可能天天发生。“接上调试器就不再重现”“加了日志就消失了”,这也是因为观测行为本身改变了时机,是竞态 bug 的典型表现。正因如此,本文的所有原则,都朝着“在‘正确同步’之前,先‘减少需要同步的地方’”这一个方向。

2.1. 在 C++ 中,数据竞争直接等同于未定义行为

在此基础上,C++ 还有一层比其他语言更深的隐情。按照 C++ 标准的定义,只要多个线程在没有同步的情况下访问同一内存位置,且其中至少一方是写入,那就是数据竞争,即未定义行为。C++ Core Guidelines 的并发章节(CP.2“避免数据竞争”)把这一点作为最开头的绝对规则提出。1 未定义行为并不是“能读到旧值或新值中的一个”这种温和的说法。编译器会在“不存在数据竞争”的前提下进行优化,因此循环中的条件判断消失、写入被重排或合并等源代码中无法想象的行为,都会被正当化地发生。“volatile bool 停止标志唯独在 Release 版本中不生效”这种经典事故,正是其典型表现。

2.2. RAII 是基础

另一个 C++ 特有的前提是异常与资源管理。C++ 没有 finally,取而代之的是 RAII(通过析构函数自动释放),多线程的工具集也是以 RAII 为前提设计的。“用对象的生命周期来管理锁”“线程的合流也由对象的生命周期来保证”── 顺应这一套做法,正是在 C++ 中安全编写多线程代码的基础。

3. 如何启动线程 ── thread 的陷阱与 jthread

3.1. std::thread 的析构函数是“会出事的规格”

std::thread 有一个著名的陷阱:如果在仍处于 joinable 状态(既没有 join 也没有 detach)时让析构函数运行,就会调用 std::terminate,进程当场终止9

void process()
{
    std::thread worker([]{ HeavyWork(); });
    DoSomething();      // ← 如果这里抛出异常……
    worker.join();      // ← 无法到达 join,worker 的析构函数会调用 terminate
}

要做到异常安全,就必须用 try/catch 来保证 join 一定会执行,这是一种明明是 RAII 语言,却唯独线程需要手动管理的扭曲状态。C++20 的 std::jthread 解决了这个问题。它会在析构函数中自动发出停止请求并 join,因此上面的代码只需换成 std::jthread,就能变得异常安全。2 在 MSVC 中,Visual Studio 2019 16.9 及以后的版本可以使用 <stop_token>jthread3

std::thread既未 join 也未 detachstd::thread已完成 joinstd::jthread (C++20)启动了线程离开作用域时会怎样?std::terminate进程立即终止安全合流自动执行 request_stop + join即使抛出异常也安全

图3: 线程对象的生命周期与结束方式。std::thread 的规格是“忘记 join 就会当场终止”,因此从 C++20 起应把 jthread 作为默认选择

原则上不使用 detach()。失去合流手段的线程,会在进程结束时与静态变量、堆的析构发生竞争,是退出时崩溃的常见原因。

3.2. “比线程更高层”的工具 ── async、future 与并行算法

.NET 篇中“不要自己创建线程”的原则,在 C++ 中对应以下这些工具。

  • std::async + std::future:用于一次性的异步任务及结果接收。但有一个重要规格必须注意:与 std::async 启动的任务相关联的 future(或最后一个 shared_future),如果在任务尚未完成时就让析构函数运行,会一直阻塞到任务完成为止7 如果是用 std::launch::async 实际启动的工作,那么在丢弃返回的 future 的瞬间,就等价于同步执行。更糟的是,如果不指定启动策略,默认情况下实现可能会选择 deferred(延迟执行),这种情况下如果没有人调用 get() / wait()任务根本不会被执行,就这样悄无声息地消失。如果需要确保并发执行,请明确指定 std::launch::async,并由持有者管理 future 的生命周期。
  • PPL(Parallel Patterns Library)的 concurrency::parallel_for / parallel_for_each:对集合的全部元素进行并行处理。但如果每次迭代的工作量太小,fork/join 的开销就会把并行带来的收益吃掉,因此原则上应在更外层的循环上做并行化。10
  • C++17 的并行算法(std::execution::par):在 MSVC 中,主要的算法已经实现了并行化(并非全部)。11 需要注意的是,如果在执行策略下的元素处理中泄漏出异常,就会调用 std::terminate。在回调内部放置自己的异常边界(try/catch),与第 6 章的线程边界是同一个思路。

“I/O 等待不是增加线程的对象”这条界线在这里同样成立。如果是 Windows 原生代码,OVERLAPPED I/O 与 IOCP 就是承接这类需求的机制(具体原理请参考「Windows I/O 的深层机制 第2回」)。

4. 减少共享可变状态 ── 拆分、值传递、const、队列

竞争只有在“多个线程”与“共享的可变数据”同时具备时才会发生。线程数量由需求决定,因此设计上能削减的是共享这一侧。手段分为“拆分”“不变化”“传递”三大类,在 C++ 中可以这样写。

拆分。在并行汇总这类处理中,与其让各个线程都写入一个共享的合计变量,不如让每个线程持有一份本地的小计,最后只合并一次。这样一来,对共享数据的写入就从“每次迭代”减少到“每个线程一次”,同步成本和竞争窗口都会大幅缩小。最后那一次合并,无论用 std::mutex 还是对 std::atomic 调用 fetch_add,都可以。

按值传递。在启动线程时,把所需的数据以拷贝(或移动)的方式传递过去,这份数据就会归线程独占,无需同步。用引用捕获([&])lambda 而碰到已失效变量的事故很常见,因此传给线程的 lambda 应当采用显式捕获,原则上使用拷贝或移动。不过,“拷贝了所以就独占了”这个说法,只有在值不包含指针或 shared_ptr 之类别名(alias)、是一张深层值图的情况下才成立。即使拷贝了含有裸指针的结构体,其所指向的目标仍然是共享的。

以 const 方式共享。只被读取的数据,无论有多少线程同时读取都是安全的。配置值、主数据、计算的输入等,只要构建完成后不再改写,做成 const 共享(例如 std::shared_ptr<const Config>),就可以无需同步地共享。需要注意的是,shared_ptr<const T> 所禁止的仅仅是经由那个句柄进行的修改。如果某处还残留着非 const 的别名,或者 mutable 成员被改写,竞争依然会存在,因此设计时要连“构建完成后放弃非 const 引用,此后谁都不再写入”这一点也一并考虑进去。只要决定“需要变更时,不是改写而是创建新对象来替换”,需要守护的可变状态就会少一个(替换本身的生命周期管理请参见 5.2 节的注意事项)。

通过队列传递。线程间的数据流动,应当依托生产者/消费者队列,而不是共享变量。由于 C++ 标准没有 channel,用 std::mutex + std::condition_variable 写一个小型队列是常见做法。

template <typename T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
    {
        if (capacity == 0)                          // 容量为0会让所有 Push 永远等待,是个陷阱
            throw std::invalid_argument("capacity must be positive");
    }

    // 满了就等到出现空位(或收到停止请求)为止。false 表示收到了停止请求。
    bool Push(T item, std::stop_token st)
    {
        {
            std::unique_lock lock(mtx_);
            if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
                return false;                       // 因停止请求而被唤醒
            if (st.stop_requested())                // 如果空位和停止同时出现,优先处理停止,
                return false;                       // 停止开始后不再接受新的投入
            queue_.push(std::move(item));
        }
        not_empty_.notify_one();   // 通知放在锁外面进行
        return true;
    }

    // 等待停止请求(stop_token)或元素到达。停止时返回 nullopt。
    std::optional<T> Pop(std::stop_token st)
    {
        std::optional<T> item;
        {
            std::unique_lock lock(mtx_);
            if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
                return std::nullopt;                // 因停止请求而被唤醒
            if (st.stop_requested())                // 如果元素和停止同时出现,优先处理停止,
                return std::nullopt;                // 停止开始后不再着手新的工作
            item = std::move(queue_.front());
            queue_.pop();
        }
        not_full_.notify_one();
        return item;
    }

private:
    const std::size_t capacity_;
    std::mutex mtx_;
    std::condition_variable_any not_empty_;   // 为了使用支持 stop_token 的 wait,选择 any 版本
    std::condition_variable_any not_full_;
    std::queue<T> queue_;
};

设计上有两个要点。第一,给容量设置上限,满了就让生产方等待。没有上限的队列,在生产速度快于消费速度的结构中,会变成一颗“表面上还在运行,内存却持续增长”的定时炸弹。满时让 Push 阻塞,会自然地起到背压(backpressure)的作用,把过载机械式地传递给上游。第二,条件变量存在虚假唤醒(未收到通知却醒来的现象),因此调用 wait 时必须带上谓词。带谓词的 wait 会在内部替你执行“循环直到条件为真”。6

5. 锁的纪律 ── RAII 与 scoped_lock

即使减少了共享可变状态,往往也无法把它降到零。剩下的共享部分需要使用排他控制,但没有纪律的锁只会掩盖竞争。

首先,锁的单位应该按“数据”来考虑,而不是“代码区间”。为每一组需要保护的可变数据对应一个互斥量(做成不对外公开的 private 成员),在触碰这份数据的所有地方都获取同一个互斥量 ── 竞态 bug 的真实面目,往往就是这张对应表崩坏了。而且,持有锁期间只应该做被保护数据的读写。在持有锁期间进行文件 I/O、网络调用、回调(调用外部代码),不仅会拉长持有时间,还会造出“被调用方试图获取另一个锁从而死锁”的路径。在锁外做好准备,在锁内只做替换是基本形态。

5.1. 禁止手写 lock()/unlock()

直接调用 std::mutexlock() / unlock() 的代码,会因为异常或提前 return 而漏放锁。锁的获取与释放必须交给 RAII 包装器。

包装器 用途
std::lock_guard 在作用域内握住单个互斥量的最基本形式
std::scoped_lock(C++17) 同时获取多个互斥量。用死锁规避算法解决顺序问题4
std::unique_lock 需要在中途释放・重新获取,或需要传给 condition_variable::wait 的场合

当存在两个以上的锁时,获取顺序因线程而异,正是死锁的经典模式(图2的循环等待就是这样产生的)。对策是把“所有线程按相同顺序获取”规则化,但如果是同时获取的场景,C++ 有更好的答案:只要把多个互斥量一并传给 std::scoped_lock库就会保证获取顺序不会导致死锁4 在两个对象之间转账这类“想同时锁住双方”的场景中,不要分别获取,必须一并获取。

void Transfer(Account& from, Account& to, int amount)
{
    if (&from == &to) return;                  // 同一账户则什么都不做(见下文说明)
    std::scoped_lock lock(from.mtx, to.mtx);   // 两把锁一并获取,顺序由库来解决
    from.balance -= amount;
    to.balance   += amount;
}

开头的同一性检查并不是摆设。如果同一个 Account 同时被传给 fromto,就会把同一个不可重入的互斥量传给 scoped_lock 两次,从而导致挂起或未定义行为。凡是“同时锁住两者”的函数,都必须附带对同一对象的排除处理。

对于“读多写少”的数据,可以用 std::shared_mutex(C++17)实现读写锁。12 另外,recursive_mutex 是一种“即使同一线程重复获取也不会出问题”的类型,但需要递归获取的设计,往往是锁的职责边界变得模糊的信号,请优先考虑重新审视结构。

5.2. atomic 的正确定位

std::atomic 提供对单个变量的不可分操作,以及基于 memory_order 的顺序保证。5 它的用武之地和 .NET 篇中的 Interlocked 相同,都是计数器、标志这类单个变量的更新。它无法把多个变量整体地保持一致,遇到那种情况就要回到 std::mutex

裸指针的替换(std::atomic<T*>)有一个固有的陷阱。即便替换本身是不可分的,替换之后旧对象的生命周期却没有人来保护。如果读者刚加载完旧指针,写者随即就替换并 delete,那就会访问已释放的内存。如果要在 C++ 中实现“替换并共享不可变对象”的设计,请选择像用锁保护的 std::shared_ptr<const T> 替换(或 C++20 的 std::atomic<std::shared_ptr<T>>)这样,与生命周期管理配套的手段

再重复一次,volatile 不是线程同步的工具。自行指定 memory_order 的无锁编程,属于拥有“放宽默认值(seq_cst)的正当理由和验证手段”的专家领域。在业务应用中,请保持默认值使用,或者干脆直接用 mutex 来写。

6. 停止方式的设计 ── stop_token 与协作式停止

多线程设计评审时首先应该问的问题是“这个东西是怎么停下来的”。而 C++ 并不存在从外部安全停止线程的手段(Win32 的 TerminateThread 有多危险,会在C 语言篇中详细说明)。因此,停止方式要用 C++ 的工具来搭建协作式停止 ── 发起停止的一方只发出请求,何时、如何结束由线程自己在收尾状态良好的地方决定,以 join 的完成来判定“已经停止”。

C++20 中 std::jthread 内置了停止机制。调用 request_stop(),线程函数所接收到的 std::stop_token 上就会立起停止请求,循环通过轮询来检测它。condition_variable_anywait 可以直接接收 stop_token,因此“正在等待工作到来的线程”也能被停止请求立即唤醒(第 4 章的 BlockingQueue::Pop 就是这种形态)。

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // 拒绝在运行期间被二次 Start。
            throw std::logic_error("already running"); // 如果不拒绝而是直接赋值,新线程开始运行后,
                                                       // 在等待旧线程停止期间,
                                                       // 两个 worker 就会并行运行
        thread_ = std::jthread([this](std::stop_token st) {
            try {
                while (!st.stop_requested()) {
                    if (auto item = queue_.Pop(st)) {   // 收到停止请求时也会被唤醒
                        try {
                            Process(*item, st);          // 把 st 也传给内部可能阻塞的处理
                        } catch (...) {
                            ReportError(std::current_exception());  // 单个失败记录下来后继续
                        }
                    }
                }
            } catch (...) {
                // 线程边界的最后一道防线(Pop 或移动的失败也在这里接住)。
                // 如果异常从这里泄漏出去,会因 std::terminate 导致整个进程崩溃,
                // 所以要把 ReportError 实现成不会抛出异常
                ReportError(std::current_exception());
            }
        });
    }
    // 不需要显式的 Stop:
    // Worker 的析构函数 → jthread 的析构函数 → request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // 带容量上限(第4章)
    std::jthread thread_;
};
停止请求发起停止的一方(jthread 的析构函数或调用 request_stop)stop_token计算循环:轮询 stop_requested()等待中的线程:condition_variable_any::wait(lock, st, pred)会立即被唤醒做完清理后自行 returnjoin 完成合流到这里才能说是“已停止”

图4: C++20 的协作式停止。发起停止的一方只发出请求,结束方式由线程自己决定,以 join 的完成来判定停止

还有一点,worker 内部的 try/catch 不可省略。jthread 能保证异常安全的仅仅是 join,如果异常从线程函数中泄漏出去,就会和 std::thread 一样因 std::terminate 而导致进程崩溃。单个任务的失败该如何处理(是记录下来继续,还是通过错误通道告知持有者),应当在线程边界处明确决定好。

出于同样的理由,请注意 Process 也传入了 stop_token。如果单个任务的处理内部会阻塞(等待网络、长时间计算等),而那个地方无法观测到停止请求,析构函数隐式的 join 就会一直等待那一个任务完成。只有当令牌送达“所有等待的地方”,协作式停止才能成立。如果其中包含无法中断的外部调用,请附加超时,为单个任务的执行时间设置上限。

在 C++17 之前的环境中,用 std::atomic<bool> 停止标志 + condition_variablenotify_all 手动搭建同样的结构。此时的要点是,要把停止标志的检查纳入条件变量的谓词中(如果只立起标志却忘了通知,等待中的线程就会永远不醒来)。

7. Windows 特有的注意事项 ── 与 Win32 API 的边界

7.1. 标准库与 Win32 同步对象的使用区分

微软的文档建议,重视可移植性的 C++ 代码应使用 std::mutex / std::shared_mutex,并把 Win32 同步对象的用武之地归纳为“需要 Win32 等待 API”和“跨进程同步”这两种情况。8

情况 选择
通常的进程内排他 std::mutex + RAII(默认)
读多写少 std::shared_mutex
想用 WaitForMultipleObjects 同时等待多个对象 Win32 的事件、Mutex 等内核对象
跨进程的排他・通知 命名的 Mutex、事件、信号量
直接使用 Win32 API 的进程内锁 SRW 锁(只有需要递归时才用 CRITICAL_SECTION)8

跨进程共享内存的具体排他设计,请参考「共享内存的陷阱与实务最佳实践」。

7.2. 不要在 DllMain 中碰触线程

编写 DLL 时的一项重大约束是加载器锁(loader lock)DllMain 是在持有加载器锁的状态下被调用的,因此在其中与其他线程同步、等待线程结束、调用 LoadLibrary 之类的操作,都会成为死锁或不确定行为的原因。涉及启动、合流线程的初始化工作,请放到 DllMain 之外(显式的初始化函数)去做。13

7.3. UI 线程与 COM 单元(apartment)

Windows 的桌面应用有一条无关语言、始终生效的强约束:窗口和控件只能由创建它们的线程(UI 线程)去碰触。Windows 会把窗口消息投递到创建该窗口的线程的消息队列中,因此 UI 的创建与操作必须集中在那一个线程上。如果想从工作线程更新画面,不应直接去碰触,而是用 PostMessage(异步)委托给 UI 线程,在 UI 线程一侧的窗口过程中处理。同步形式的 SendMessage,如果在 UI 线程正在等待该 worker 完成的状况下调用,就会形成互相等待的死锁,因此来自 worker 的通知应默认使用异步形式。涉及 COM 时的 STA/MTA,在「COM STA/MTA 基础」中有说明。另外还需要注意,在用 /clr 编译的 C++/CLI 代码中,<thread><mutex> 等标准线程头文件会被阻止使用。14

8. 验证与调试 ── 以“无法重现”为前提做好准备

竞态 bug 无法指望靠测试发现。因为普通的测试会把“碰巧没有发生竞争”的那次执行计为成功。防范措施要分三层来考虑。

第一道防线,就是前面这些设计原则本身。在评审时,用表格确认“哪些是共享的可变数据”“各自由哪个互斥量保护”“多个锁的获取顺序是否唯一(或者是否用 scoped_lock 一并获取)”“停止路径在哪里”。写不出这张表的设计,即使能跑,也还没有完成。

第二,不要隐藏异常,让它可以被观测到。对于本不应该拿不到的锁,用 timed_mutextry_lock_forcondition_variablewait_for 加上超时,把超时当作异常记录到日志中,就能把永远的挂起转变为可以检测到的失败。线程边界的 try/catch(第6章)所接住的异常必须记录下来。在挂起或崩溃的现场,要采集转储并确认所有线程的调用栈,查看彼此的锁等待是否形成了循环。转储与日志的建设,在「Windows 应用崩溃时保留日志与转储的设计」中有讲解。

第三,通过施加负载来晃动系统。用超过核心数的并行度长时间运行、将处理顺序随机化、人为插入延迟,这类压力测试,是在开发机上更容易“抽中”竞争的现实手段。有些在调试版本中消失的 bug,在优化过的 Release 版本加上高负载的情况下,往往能够重现。

9. 总结 ── C++ 版核对清单

在语言通用的原则(不直接创建线程、最小化共享可变状态、锁与数据一一对应、协作式停止)之上,再叠加 C++ 特有的检查项。

  1. 是否在裸用 std::thread(能否换成 jthread?异常路径下 join 是否也有保证?)
  2. 是否使用了 detach()
  3. lambda 捕获是否显式?以引用方式捕获的变量的生命周期是否比线程更长
  4. 能否断言不存在任何一处未同步的共享可变访问(=未定义行为)
  5. 是否存在手写的 lock() / unlock()?多个锁是否用 scoped_lock 一并获取
  6. 所有的 condition_variable::wait 是否都带有谓词
  7. 共享标志是否使用了 volatile(是否已经改成 std::atomic)
  8. 停止路径是否用 stop_token(或 atomic 标志+通知)设计,并通过 join 确认合流完成
  9. 是否丢弃了 std::async 的 future
  10. 是否在 DllMain 中进行了线程的启动・同步・join

C++ 的多线程编程,是一项紧贴着“未定义行为”这道悬崖行走的工作,但反过来说,只要老老实实顺着 RAII 与标准库的做法走,就能与悬崖拉开相当大的距离。jthreadscoped_lock、带谓词的 waitatomic ── 正确选择工具的默认用法,在 C++ 中就是设计原则本身的实践。

相关文章

相关咨询领域

合同会社小村软件承接 C++ 应用与 DLL 的多线程设计评审,“偶尔崩溃、唯独 Release 版本才出问题”这类由竞争引发的故障排查(转储分析),以及把遗留线程代码迁移到现代 C++ 的咨询。

参考链接

  1. ISO C++,C++ Core Guidelines - CP: Concurrency and parallelism。关于并发・并行章节开头提出的规则 CP.1(假设自己的代码会在多线程下运行)与 CP.2(避免数据竞争)、存在数据竞争时任何保证都不成立、锁的持有范围与 RAII 的使用等并发代码设计规则被系统化整理的说明。  2

  2. cppreference.com,std::jthread。关于 C++20 的 jthread 与 std::thread 不同,会在析构函数中自动调用 request_stop() 之后再 join、可以将 std::stop_token 作为线程函数的首个参数接收、由此即使发生异常也能保证线程的合流与停止请求的说明。  2

  3. Microsoft Learn,Microsoft C/C++ language conformance by Visual Studio version。关于 P0660R10(<stop_token> 与 jthread)及 P1135R6(C++20 同步库)在 Visual Studio 2019 16.9 中获得支持、C++ 标准库功能按版本的支持情况的说明。  2 3

  4. Microsoft Learn,scoped_lock Class。关于 C++17 的 scoped_lock 在构造时获取一个或多个互斥量并在析构函数中释放、传入多个互斥量时会以相当于 std::lock 的死锁规避算法来获取、即使抛出异常也能确保释放、单个互斥量时 lock_guard/unique_lock 也是可选方案的说明。  2 3

  5. Microsoft Learn,<atomic>。关于原子操作因不可分而使其他线程只能观测到操作前或操作后的状态、基于 memory_order 参数为其他原子操作的可见性建立顺序要求并抑制违反该要求的编译器优化、atomic_flag 始终是无锁的、/clr:pure 下该头文件会被阻止使用的说明。  2

  6. Microsoft Learn,<condition_variable>。关于条件变量的等待需要互斥量、等待期间锁会被释放、存在未收到通知却醒来的虚假唤醒,因此等待方应在恢复时显式确认条件、带谓词的 wait(lock, pred) 会代为执行该循环、condition_variable_any 可以与任意互斥量类型组合使用的说明。  2

  7. Microsoft Learn,<future>。关于 future 与 shared_future 的析构函数原则上不会阻塞,唯一的例外是与 std::async 启动的任务相关联的 future(或最后一个 shared_future),如果任务尚未完成就让析构函数运行,会一直阻塞到共享状态变为 ready 为止,这一行为在标准的注记中有明确记载的说明。  2

  8. Microsoft Learn,About Synchronization。关于 Win32 同步原语的选择指南:重视可移植性的 C++ 代码推荐使用 std::mutex / std::shared_mutex 与 RAII、需要 Win32 等待 API 或跨进程同步时才使用 Win32 同步对象、进程内新代码的默认选择是 SRW 锁,只有需要递归获取时才用 CRITICAL_SECTION、在进程内同步中使用 Mutex 会始终伴随内核转换,是一种“常见错误”的说明。  2 3

  9. cppreference.com,std::thread::~thread。关于 std::thread 的析构函数在线程仍处于 joinable 状态(既未 join 也未 detach)时被调用会触发 std::terminate、也就是在销毁线程对象之前必须先完成 join 或 detach 的判断的说明。 

  10. Microsoft Learn,Best Practices in the Parallel Patterns Library。关于并行化应尽量在更高的层级(外层循环)表达、在每次迭代工作量小・负载不均衡的并行循环中,fork/join 的调度开销可能会超过并行执行带来的收益、且该倾向会随处理器数量增加而增强的说明。 

  11. Microsoft Learn,Microsoft C/C++ language conformance by Visual Studio version。关于 C++17 的并行算法库虽已完成,但“完成”并不意味着所有算法在所有情况下都会被并行化,而是最重要的算法得到并行化,未被并行化的算法也提供执行策略签名这一实现方针的说明。 

  12. Microsoft Learn,C++ standard library header files。关于多线程相关的标准头文件整理如下:<atomic>(C++11)、<mutex>(C++11)、<shared_mutex>(C++14)、<condition_variable>(C++11)、<future>(C++11)、<stop_token>・<semaphore>・<latch>・<barrier>(C++20)、<thread>(C++11)的说明。 

  13. Microsoft Learn,Dynamic-Link Library Best Practices。关于 DllMain 是在持有加载器锁的状态下被调用的,因此可以调用的 API 存在重大限制、在 DllMain 内与其他线程同步可能导致死锁、调用 LoadLibrary 或等待线程结束是典型的禁止事项、初始化应尽量延迟并放到 DllMain 之外、应当定义锁层级并把加载器锁置于最高层的说明。 

  14. Microsoft Learn,<thread>。关于 <thread> 头文件定义了 thread 类以及 sleep_for 等辅助函数、用 /clr 编译的代码中该头文件会被阻止使用、可以用 STDCPP_THREADS 宏判断是否支持线程的说明。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

std::mutex 与 Win32 的 CRITICAL_SECTION、SRW 锁,应该如何区分使用?
在重视可移植性的普通 C++ 代码中,std::mutex / std::shared_mutex 与 RAII 包装器(lock_guard / scoped_lock)是第一候选。选择 Win32 同步对象的场合,是想与 WaitForMultipleObjects 这类 Win32 等待 API 组合使用时,或者需要用命名对象实现跨进程同步时。如果在进程内直接使用 Win32 API,新代码的默认选择是 SRW 锁,只有在需要同一线程递归获取时才使用 CRITICAL_SECTION。在进程内排他中使用 Win32 Mutex,是一个典型的错误,因为它总是伴随内核转换,速度较慢。
可以使用 std::thread 的 detach() 吗?
原则上应当避免。detach 之后的线程会失去合流(join)的手段,无法控制该线程在进程结束时是否仍在运行。静态变量或堆被析构之后,已 detach 的线程仍在继续运行从而导致退出时崩溃,是一种典型的事故。“能够等待结束”是线程设计的基本要求,因此请使用 jthread(自动 join),或者如果用 thread,就构造成在离开作用域之前一定会 join 的结构。允许使用 detach 的,仅限于可以与进程共存亡、并且能够保证完全不碰触共享状态的有限场景。
volatile 在 C++ 中也能用于线程间同步吗?
不能。C++ 的 volatile 是为内存映射 I/O 之类“不希望被编译器优化掉的读写”而设的修饰符,并不能保证线程间的可见性或顺序性。如果多个线程在没有同步的情况下访问同一变量,那就是数据竞争,属于未定义行为。线程间共享的标志或计数器应使用 std::atomic,如果要把多个变量整体地保护起来,则应使用 std::mutex。std::atomic 同时提供操作的不可分性,以及基于 memory_order 的顺序保证。
std::async 看起来很方便,有什么陷阱吗?
最大的陷阱在于 future 的析构函数。与 std::async 启动的任务相关联的 future(或最后一个 shared_future),如果任务尚未完成就让析构函数运行,会一直阻塞到任务完成为止。如果不接收返回的 future 就直接丢弃,就会当场等同于同步执行,产生“本想做成异步却变成串行”的事故。另外,如果不指定启动策略,是否真的会在另一个线程中运行,取决于实现的裁量。如果要使用它,请显式管理 future 的生命周期,并在确实需要并发执行的地方明确指定 std::launch::async。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表