多线程实战最佳实践 C++ 篇——用 RAII 和 jthread 从结构上杜绝问题

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

更新记录(仅首版,2026年08月02日 发布)
首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22175879)

以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。

Go Komura(2026)。《多线程实战最佳实践 C++ 篇——用 RAII 和 jthread 从结构上杜绝问题》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/multithreading-best-practices-cpp/

DOI(已登记存档)
10.5281/zenodo.22175879
DOI(上次登记版本)
10.5281/zenodo.22175880

“一抛异常,std::thread 的收尾就把整个应用带走了”“立起了停止标志,工作线程却回不来”“把在 C# 上跑得好好的设计搬到 C++,就会偶尔崩溃”。在 C++ 的多线程里,把处理并排展开之前,必须先定下共享数据怎么保护、线程怎么结束

尤其在 C++ 中,数据竞争不是单纯算错了值,而是未定义行为(undefined behavior)。碰巧跑出了正确结果,不能拿来当作安全性的依据。1

本文面向用现代 C++(C++17/20)编写业务应用、设备控制和 DLL 的开发者,是多线程实战系列的 C++ 篇。按选择执行方式 → 减少共享 → 实现同步与停止 → 确认 Windows 特有的限制与验证方法的顺序梳理。只适用于 C++20 的示例,会在相应位置注明。

把同样的原则在其他语言上展开的“.NET 篇”“C 语言篇”“Java 篇”也有,不过本文单独阅读也没问题。

1. 先说结论——在“加同步”之前,先定下共享与生命周期

首先减少共享可变状态,剩下的共享交给标准库和 RAII 来保护。然后,把从停止请求到 join 完成串成同一套设计。1

决定的顺序 要判断的事 本文的参照处
1. 执行方式 是一次性任务、对集合的并行处理,还是长期运行的工作线程。是不是想靠增加线程来解决 I/O 等待 第 3 章
2. 数据的归属 能不能靠拆分、传值、不可变化、队列来减少对共享的写入 第 4 章
3. 剩余共享的保护 哪些数据由哪个 mutex 保护。是否区分了用 atomic 处理的单变量更新和多个变量之间的一致性 第 5 章
4. 结束的步骤 停止请求能否同时送达等待中和处理中的线程。能否记录异常,并在最后 join 第 6 章
5. 部署位置与验证 是否遵守 DLL、UI、COM 的限制,能否用日志、转储、压力测试来调查 第 7~8 章

工具的默认选择是:线程的生命周期用 std::jthread,锁用 RAII,等待用带谓词的 wait,单个共享标志或计数器用 std::atomicvolatile 不用于同步。C++20 之前的停止设计,用 atomic 标志加条件变量来搭。2345

不过,只是选好工具还不算完成。即使 jthread 会自动 join,只要处理本身结束不了,它就会一直等下去。即使用 atomic 换掉了指针,也保护不了旧对象的生命周期。这两点请结合后面的代码示例确认。

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 27 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

2. 先要理解的三个前提——竞争、互相等待与 RAII

2.1. 竞态条件取决于执行顺序,而 C++ 的数据竞争是未定义行为

竞态条件(race condition)是结果随多个线程到达处理的先后顺序而变化的缺陷。把共享计数器的 ++count 拆成“读取 → 加一 → 写回”来看,就能明白两个线程读到同一个值、其中一方的更新被抹掉是怎么发生的。

共享计数器的更新丢失的例子示意两个线程读到同一个值,各自加一后写回,于是更新被丢掉。count 是 10A 和 B 都读到 10各自在本地加到 11A 写回 11B 也写回 11在 C++ 中结果不限于此

图1:这是加法丢失的示意图,并不是说 C++ 的未定义行为结果仅限于此。

这只是用来理解竞争的示意图。在 C++ 中,多个线程访问同一个非 atomic 的内存位置,其中至少有一方是写入,而又缺少必要的同步时,按标准就构成数据竞争引发的未定义行为。实际结果并不会被限定成“加法丢了一次”。1

编译器是按不存在数据竞争这个前提来优化的。因此,包括条件判断和读写的重排、合并在内,源代码所期待的行为都无法再得到保证。不能认为“读到的要么是旧值、要么是新值”。把 volatile bool 当停止标志,在 C++ 中也不构成线程间同步,所以解决不了这个问题。15

2.2. 死锁源于“等待的对方”连成了环

死锁是双方都在等对方释放手里的锁,谁也没法继续前进的状态。只要线程 A 持有锁 1 并等待锁 2,线程 B 持有锁 2 并等待锁 1,它就成立了。

两把锁的循环等待互相等待对方持有的锁,两边都无法继续前进。等待锁 2等待锁 1A 持有锁 1B 持有锁 2

图2:等待的箭头连成环之后,双方就停在等对方前进的状态。

无论是竞争还是死锁,出问题的执行顺序都可能在开发机上不出现,却在核数和负载不同的客户现场出现。调试器和加日志会改变时序,导致复现不出来。所以,在正确地做同步之前,先减少需要同步的地方才是关键。

2.3. 用 RAII 把包括异常路径在内的收尾变成结构

C++ 没有 finally,取而代之的是把对象生命周期与资源管理绑在一起的 RAII(Resource Acquisition Is Initialization)。形式是:获取了锁的对象离开作用域时释放锁,拥有线程的对象销毁时完成合流。1

不只是正常结束,提前 return 和因异常离开作用域的路径,也都挂到同一套收尾上。后面的 jthread 和锁包装器,当作这个思路的实现来读,就容易理解它们该怎么区分使用。

3. 选择执行方式——先用任务,必要时才用线程

3.1. 区分一次性工作、对集合的处理和 I/O 等待

“不要自己增加线程”这个原则,在 C++ 里同样成立。先看工作的形态,再选择表达它的工具。

工作的形态 主要选项 首先要确认的
一次性的异步处理并接收结果 std::asyncstd::future 启动策略与 future 的生命周期
对集合的并行处理 PPL、C++17 的并行算法 单次迭代的工作量与异常的处理
有生命周期的工作线程 C++20 的 std::jthread 在哪里观测停止请求以及 join
I/O 等待 Windows 上是 OVERLAPPED I/O 或 IOCP 使用异步 I/O,而不是增加线程
先选执行方式再定生命周期选择与工作形态相符的执行方式,并据此决定结果与停止的管理方式。单次、集合处理长期运行的处理等待 I/O确认工作的形态执行什么任务或并行算法管理工作线程的生命周期考虑异步 I/O设计结果、异常与结束

图3:在增加线程之前,先定下工作的形态,以及由谁来管理结束。

不要只为了等 I/O 就增加线程。在 Windows 原生代码中承接这类需求的 OVERLAPPED I/O 和 IOCP,在“Windows I/O 的深层 第 2 回”里讨论过。

3.2. async 要把“启动的条件”和“持有结果的期间”成套决定

std::async 很省事,但不能把返回的 future 丢掉。有两点需要注意。6

第一点是销毁时的等待。std::launch::async 启动的工作尚未完成时,如果销毁了最后持有其共享状态的 future 或 shared_future,就会产生完成等待。把返回值当场丢掉,即便本意是异步,也会因为在那个表达式结束处等待而变成与串行执行相同的形态。

第二点是延迟执行。 不指定启动策略时,实现可能会选择 deferred。这种情况下,只要没有人调用 get()wait(),工作就不会执行。确实想在另一个线程上执行的地方,要明确写出 std::launch::async,并定好由谁持有 future、持有到什么时候。

async 的启动方针与 future 的生命周期区分以 async 启动时销毁处的等待,以及 deferred 下不等待就不执行的情况。asyncdeferred调用 std::async选中的启动方针在另一个线程上执行销毁最后一个 future未完成则等待完成推迟到 get 或 wait 才执行不调用就不执行

图4:行为不只取决于启动策略,还取决于把 future 持有到什么时候。

3.3. 并行循环要确认粒度和异常边界

PPL(Parallel Patterns Library)的 concurrency::parallel_forparallel_for_each,是把处理并行地施加到集合各元素上的工具。不过,单次迭代的工作太小时,fork/join 和调度的开销会超过收益。原则是尽量在外层循环上表达并行7

C++17 的并行算法可以使用 std::execution::par。MSVC 并行化了主要算法,但这并不意味着所有算法总是并行运行。8

另外,使用标准执行策略的算法,如果异常从元素处理里漏出来,就会导致 std::terminate。需要的 try/catch 不只放在调用方,还要放进元素处理的回调里面。这与第 6 章讨论的线程函数异常边界是同一个思路。

3.4. 要拥有线程,就得保证异常路径下也会 join

std::thread 在既没 join 也没 detach、仍然 joinable 的状态下被销毁时会调用 std::terminate。这与线程函数的工作是否做完无关,必须在对象一侧把合流做完。9

下面是展示这个陷阱的例子。

void process()
{
    std::thread worker([]{ HeavyWork(); });
    DoSomething();      // ← 这里一旦抛出异常……
    worker.join();      // ← 到不了 join,在 worker 的析构函数里 terminate
}

只在末尾写 join(),守不住中途的异常路径。要用 std::thread,就用 try/catch 或 RAII 保证合流。如果能用 C++20,就std::jthread 作为默认,它在 joinable 时会由析构函数先发出停止请求再 join。MSVC 从 Visual Studio 2019 16.9 起可以使用 <stop_token>jthread210

线程对象的销毁与合流joinable 的 thread 被销毁会导致 terminate,而 jthread 的销毁会发出停止请求并 join。threadjthread销毁线程对象是否 joinable没有要合流的对象类型是什么std::terminate发出停止请求并 join处理本身必须能结束

图5:jthread 把合流自动化了,但不会强制终止工作。

这里被自动化的,是拥有方发出停止请求和合流。它不是用来接住从线程函数漏出的异常,也不是用来强制终止停不下来的处理。这两件事在第 6 章分别设计。

detach() 原则上不用。失去合流手段之后,静态变量和堆的销毁会与线程的执行相互竞争,导致退出时崩溃。请把“能等到它结束”作为设计的基本要求。

4. 减少共享——拆分、传值、不可变化、队列

4.1. 不要共享总和,而是每个线程各持一份小计

成为竞争原因的共享可变状态,在加锁之前就能减少。并行汇总时,与其让所有线程去更新共享的总和,不如各自做本地小计,最后只合流一次

对共享的写入从“每次迭代”降到“每个线程一次”,同步成本和可能发生竞争的位置都能变小。合流时的更新,用 std::mutexstd::atomicfetch_add 都可以。

从各线程的小计最后合流不是每次都更新共享总和,而是把各线程的小计在最后一次性汇入合计。拆分输入线程 A 的小计线程 B 的小计最后同步合流共享的合计值

图6:迭代过程中只更新独占的数据,把对共享的写入集中到合流时刻。

4.2. 用值传递,但要确认拷贝之后指向的对象

启动时把需要的数据以拷贝或移动的方式传过去,让它成为这份工作专属的数据,之后的同步就能减少。lambda 不要依赖 [&],而要显式捕获,原则上用拷贝或移动。使用引用捕获时,被引用的对象必须比线程活得更久。

不过,拷贝了指针,它指向的对象并不会变成独占的。含有裸指针或 shared_ptr 的结构体,仍然通过别名(alias)残留着共享。只有当包括引用目标在内构成一张不含共享可变状态的深值图时,才能判断“拷贝了值所以安全”。

拷贝指针后引用目标仍然是共享的拷贝含有指针的值之后,两个变量虽然不同,引用目标的对象仍然是同一个。拷贝指针值原值里的指针同一个引用目标对象拷贝出的指针要改写就需要同步

图7:不只看拷贝出来的值,还要看它引用到的对象。

4.3. 要用 const 共享,就得做出“谁都不会修改”的状态

配置、主数据、计算的输入这类构造之后不再修改的东西,可以只读地共享。例如 std::shared_ptr<const Config> 会禁止通过这个句柄进行修改。

但是,如果别处还留着非 const 的引用,或者 mutable 成员被修改,竞争依然存在。构造结束后就放弃非 const 的引用,此后谁都不再改写,要把这一点也纳入设计。

需要修改时,方针是不改写既有对象,而是新建一个再替换。不过,替换用的指针本身的同步,以及旧对象的生命周期管理,仍然要另外处理。这在 5.4 节确认。

4.4. 队列要准备容量上限,以及可以停下来的等待

线程之间的传递,比起互相直接碰共享变量,更应该收敛到生产者与消费者的队列上。本文涉及的 C++17/20 标准库里没有 channel,所以把 mutex 和条件变量组合起来的小队列就是基本形态。

下面的示例以 C++20 为前提。因为要用接收 std::stop_tokenwait,所以条件变量用的是 std::condition_variable_anyPushPop 用返回值告知:观测到停止请求后停下了处理。

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 中等待空位。容量 0 直接拒绝 生产快于消费,内存不断增长
从等待返回后要确认什么 wait 传入检查队列状态的谓词 没有通知也返回,或者醒来时条件已经变了
停止时能否从等待返回 使用支持 stop_tokenwait,并在操作前再确认一次停止 在空队列或满队列上一直等待,结束不了

让满的时候生产方等待,就构成了背压(backpressure),把过载向上游传递。另外,条件变量存在没有通知也会醒来的虚假唤醒,所以等待要带谓词。带谓词的 wait 会替你完成重新确认条件的循环。4

带容量上限的队列与解除等待生产方在满时等待空位,消费方在空时等待元素,停止请求也能送达两边的等待。生产方满时等待空位带容量上限的队列空时等待元素消费方停止请求

图8:容量上限形成背压,停止请求也能送达生产方和消费方的等待。

这个示例的方针是:观测到停止之后,不再投入和取出下一个,而不是把剩下的全部处理完。不过,它并没有把停止请求和已经在进行中的操作做成一个不可分的处理。刚通过停止检查之后才到来的请求,并不保证能把那个操作回滚,处理中的工作由第 6 章的协作式停止来处理。

示例只是同步与停止的骨架。需要的头文件和业务特有的类型请在接入处准备,元素移动和队列操作失败的情况,也在第 6 章的异常边界里处理。

5. 保护剩下的共享——锁的纪律与 atomic 的适用范围

5.1. 让锁对应要保护的数据,而不是对应代码

如果共享可变状态无法归零,就要为每一组要保护的数据对应一个 mutex,所有访问都取同一个 mutex。mutex 不对外公开,基本做法是与数据一起作为 private 成员持有。1

持锁期间,只短促地读写被保护的数据。在持锁时调用文件 I/O、网络调用、回调这类外部代码,不仅会拉长持有时间,还会造出与被调用方的锁互相等待的路径。要追求的形态是在锁外准备好,锁内只做替换

在锁外准备并在锁内替换把耗时的准备放到锁的外面,只在短暂的持锁区间内修改共享数据。在锁外准备用 RAII 获取锁修改共享数据离开作用域并释放执行对外通知等处理

图9:让被保护的数据与 mutex 对应,不把外部处理带进持有区间。

5.2. 获取与释放交给 RAII,多把锁一并获取

手写 lock()unlock() 来配对,遇到提前 return 或异常就会忘记释放。获取与释放交给下面这些包装器。

包装器 用途
std::lock_guard 在作用域期间握住一把互斥量,最基本的形态
std::scoped_lock(C++17) 同时获取多把互斥量。用死锁规避算法解决顺序问题3
std::unique_lock 想中途释放、重新获取,或者要传给 condition_variable::wait

如果分别获取多把锁,就在所有线程上统一获取顺序。如果是同时需要的锁,一并传给 std::scoped_lock,就能把获取时的死锁规避交给库。这是处理所传入那组 mutex 的获取的机制,并不能连持锁期间的外部调用等其他形式的循环等待也一起防住。3

下面是同时保护两个账户的例子。请不要看作为业务的余额检查之类,而是关注锁的获取方式。

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;
}

开头的同一性检查是必需的。把同一个账户传给两个参数,就等于把同一把不可递归的 mutex 传了两次,会成为挂起或未定义行为的原因。在“两边都上锁”的函数里,要把同一个对象排除掉。

5.3. shared_mutex 与 recursive_mutex 要看用途来选

读取多、写入少的数据,可以用 C++17 的 std::shared_mutex 实现读写锁。11

recursive_mutex 是允许同一线程重复获取的类型。不过,需要递归获取往往是锁的职责变得含糊的信号,所以在换个类型了事之前,先重新审视结构。

5.4. atomic 的更新与对象的生命周期是两回事

std::atomic 提供对单个变量的不可分操作,以及基于 memory_order 的顺序。主要的用武之地是计数器和标志的更新。要把多个变量作为一组保持一致时,仅把各个变量做成 atomic 是不够的,要用 mutex 一并保护。5

尤其是 std::atomic<T*>,它只让指针的替换变得不可分,并不延长引用目标的生命周期。读方取到旧指针之后,写方紧接着替换并 delete 掉旧对象,读方碰到的就是已经释放的内存。

仅靠 atomic 的指针交换保护不了生命周期读方取得的旧指针,在写方交换之后删除时会失去引用目标。读方取得旧指针写方交换指针写方 delete 旧对象读方访问旧对象访问已释放的内存仅有交换的不可分性还不够

图10:指针的交换与引用目标的生命周期,需要分别保护。

如果要通过替换不可变对象来共享,就选择带生命周期管理的手段,比如在锁的保护下更换 std::shared_ptr<const T>,或者 C++20 的 std::atomic<std::shared_ptr<T>>。选了 shared_ptr 并不等于可以随意改写引用目标的数据,这一点如 4.3 节所述。

另外,volatile 在这里同样不是替代品。业务应用里要么用 atomic 默认的 seq_cst,要么用 mutex 来写。放宽 memory_order 的无锁设计是一个专业性的选项,只有在能说明其必要性和验证手段时才采用。

6. 把停止做完整——请求、解除等待、异常处理、join

6.1. request_stop 只是请求,join 完成才是停止的确认

在评审中,比起启动方式,更需要先能讲清楚“怎么停”。C++ 里没有从外部安全地强制终止线程的手段。Win32 的 TerminateThread 的危险性在“C 语言篇”中讨论过。

基本做法是协作式停止。停止方发出请求,线程自己在能够收尾的地方结束,拥有方确认 join 完成。在 C++20 中,jthreadrequest_stop() 会把请求传给交给线程函数的 stop_token2

计算循环里检查 stop_requested(),处于等待中时则通过 condition_variable_any::wait(lock, st, pred) 接收停止请求。第 4 章的队列,就是在空、满的等待中也能经由这条路径返回的形态。能靠停止请求解除等待,与包含调度器和重新获取锁在内能在一定时间内完成停止,并不是一回事。

协作式停止把直到 join 的过程串成一条路径拥有方发出停止请求,计算和等待观测到它并结束,以 join 完成来确认停止。拥有方发出停止请求传达给 stop_token计算过程中检查请求解除对应的等待收尾之后 return拥有方的 join 完成

图11:确认停止的依据不是发出请求的那一刻,而是 join 完成。

6.2. 从生命周期和重复启动的角度来读 Worker 示例

下面是使用第 4 章 BlockingQueue 的 C++20 工作线程。WorkItemProcessReportError 是业务侧准备的类型和函数。特别是 ReportError,要实现成不抛异常。

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // 拒绝运行期间的重复 Start。
            throw std::logic_error("already running"); // 不拒绝而直接赋值的话,新线程开始
                                                       // 运行之后还要等旧线程停止,这段时间
                                                       // 里会有两条工作线程并行运行
        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_;
};

Start() 的开头拒绝了对 joinable 线程的重复启动。不做检查就赋一个新的 jthread,新线程开始运行之后还要等旧线程停止,这段时间里两条线程可能并行运行。这个检查不是用来同步多个调用方同时 Start() 的锁。前提是启动和销毁由拥有方一侧串行管理

成员的声明顺序也是生命周期的一部分。示例中 thread_ 声明在 queue_ 之后,所以销毁时 thread_ 在先。等它的停止请求和 join 完成之后,再销毁工作线程使用的队列。

Worker 的成员销毁顺序与队列的生命周期先销毁后声明的 jthread,合流完成之后再销毁工作线程使用的队列。销毁 Worker销毁后声明的 thread_停止请求与 join确认工作线程已结束销毁 queue_

图12:不要在 join 之前销毁工作线程引用的队列。

6.3. jthread 不会接住从工作线程漏出的异常

自动 join 与线程函数的异常处理是两码事。异常一旦从函数漏出,jthread 也和 std::thread 一样会导致 std::terminate

示例里的 try/catch 有两个职责。

异常边界 对象 示例的方针
内层 Process 造成的单件工作失败 记录下来并进入下一件工作
外层 包括 Pop 和元素移动在内的整个循环的失败 作为最后防线记录下来,不让它漏到函数外
单件工作与整个线程的异常边界单件工作的失败与队列操作等失败由不同的边界接住,不让异常漏出线程函数。单件的异常取出等的异常工作线程循环从队列取出处理单件工作在内层记录并继续在外层记录并结束记录处理本身也不抛异常

图13:不要依赖自动 join,要明确写出线程函数的异常边界。

实际业务中要决定:一件失败之后是继续,还是通过错误通道告知拥有方并停下来。不要 catch 之后默默丢掉,要让它能被观测到。

6.4. 把能停下来的路径一直通到 Process 内部

之所以也把令牌传给 Process(*item, st),是因为不只是等待队列的时间,处理单件工作的过程中也需要对停止作出反应。长时间的计算或网络等待如果不观测请求,析构函数隐式的 join 就会一直等到那个处理结束。

协作式停止,只有当停止的路径送达所有等待的地方时才成立。对无法中断的外部调用要加超时,并给单件的执行时间设上限。仅仅在参数里加上停止令牌,并不会让那个外部 API 变得可以中断。

6.5. C++17 及更早的版本,把停止标志和通知配成一组

在用不了 C++20 停止机制的环境里,用 std::atomic<bool> 的停止标志加条件变量的 notify_all 搭出同样的结构。等待方的谓词里也要包含停止标志。因为只立标志而不通知,等待中的线程不会醒来。4

此外,即使标志是 atomic,为了不在确认条件与开始等待之间漏掉通知,纪律仍然必要。停止状态的变更也用等待方的同一个 mutex 来协调,改完状态再通知。请把防止值的数据竞争和不漏掉唤醒通知分开确认

7. 接入 Windows——同步 API、DLL 与 UI 的边界线

7.1. 普通的 C++ 用标准库,Win32 联动按需求来选

在重视可移植性的 C++ 代码中,默认使用 std::mutexstd::shared_mutex 与 RAII。选择 Win32 同步对象的理由,是与 Win32 等待 API 的联动,或者跨进程的同步。12

情况 选择
普通的进程内互斥 std::mutex + RAII(默认)
读取多、写入少 std::shared_mutex
想用 WaitForMultipleObjects 同时等待多个对象 Win32 的事件、Mutex 等内核对象
跨进程的互斥与通知 命名 Mutex、事件、信号量
直接使用 Win32 API 的进程内锁 SRW 锁(只有需要递归时才用 CRITICAL_SECTION)12
标准同步与 Win32 联动的选择普通的 C++ 代码默认用标准同步,需要 Win32 等待或跨进程同步时才选 Win32 对象。确认同步的需求是否 Win32 等待或跨进程默认用标准 mutex 与 RAII选择 Win32 对象直接用 Win32 就选 SRW 等

图14:从与操作系统的联动需求出发来选,不要与普通的进程内互斥混为一谈。

把进程内的普通互斥换成 Win32 Mutex 的做法要避免。因为它伴随内核态切换,用在这种用途上只是多余的开销。直接使用 Win32 的新代码就用 SRW 锁,只有需要同一线程递归获取时才用 CRITICAL_SECTION,界线就划在这里。12

跨进程保护共享内存的具体设计,请参考“共享内存的陷阱与实战最佳实践”。

7.2. 不要在 DllMain 里做启动、同步和等待结束

DllMain 是在持有加载器锁的状态下被调用的。在那里与其他线程同步、等待结束,或者调用 LoadLibrary,都会成为死锁等问题的原因。13

启动、合流线程这类初始化和结束处理,要挪到 DllMain 之外的显式函数里。不要以为用的是 jthread 就算例外,要连自动 join 发生在什么地方也确认清楚。

把 DLL 的线程处理分到显式函数里在持有加载器锁的 DllMain 里不做同步,把线程的启动和等待结束分到外侧的函数。DllMain持有加载器锁期间不放启动、同步和 joinDllMain 之外的显式函数管理启动与停止、合流

图15:线程对象销毁带来的隐式 join,也要确认它执行的位置。

7.3. UI 更新委托给创建线程,不要用同步通知互相等待

窗口和控件的操作,集中到创建它们的 UI 线程上。不要从工作线程直接更新界面,基本做法是用异步的 PostMessage 委托,由 UI 侧的窗口过程处理

同步形式的 SendMessage,在 UI 线程正等待工作线程完成时调用,就会造出循环等待。把来自工作线程的通知做成异步形式,正是为了避开这条路径。

UI 与工作线程之间同步通知造成的循环等待UI 等待工作线程完成,而工作线程等待 SendMessage 被处理完,就形成循环等待。等待工作线程完成等待 SendMessage 完成UI 线程工作线程来自工作线程的通知用 PostMessage 委托在 UI 侧处理

图16:对 UI 的委托默认用异步形式,不要造出互相等待完成的关系。

涉及 COM 时的 STA/MTA 限制,请在“COM STA/MTA 基础知识”中确认。C++/CLI 还有另外的限制,用 /clr 编译的代码中,<thread><mutex> 等标准线程头文件会被阻止使用。14

8. 验证——用设计表、观测和压力测试三层来准备

8.1. 比起运行结果,先确认能不能讲清楚共享与停止

普通的测试通过了,也可能只是那次执行没有发生竞争。第一道防线是到这里为止的设计。评审时把下面这些列成表来确认。

对象 必须能说明的内容
共享的可变数据 谁读谁写,由哪个 mutex 保护
多把锁 是否统一了获取顺序,是否用 scoped_lock 一并获取
传出去的数据 拷贝之后是否还残留别名,生命周期是否够长
停止 在哪里观测请求,怎样解除等待,由谁 join

说明不了这些对应关系的设计,即使能跑起来也不算完成。

8.2. 用超时、日志和转储让异常变得可见

对于本不该取不到的锁和结束不了的等待,可以考虑 timed_mutex::try_lock_forcondition_variable::wait_for 等超时。把超时记进日志,无声的挂起就变成了可以被检出的失败。在线程边界捕获到的异常,也一定要记录下来。

现场发生挂起或崩溃时,要从转储确认所有线程的栈,追查锁等待是不是形成了环。准备方法在“Windows 应用崩溃时保留日志与转储的设计”中讨论过。

8.3. Release 版本上也要摇动执行顺序和负载

用高于核数的并行度长时间运行、把处理顺序随机化、插入人工延迟,这类压力测试能更容易撞上有问题的执行顺序。不只是 Debug 版本,也要对优化过的 Release 版本施加负载。

准备好设计与观测之后再做压力测试先用设计确认共享与停止,准备好日志和转储以便观测,再改变负载与执行顺序进行试验。确认共享、锁、生命周期与停止准备日志与转储改变负载与执行顺序进行试验把发现的问题带回设计

图17:不要把试验通过当成安全性的证明,要把设计、观测和试验组合起来。

试验不是设计的替代品。顺序是先用结构保护住共享与生命周期,再让异常可观测,然后用试验找出薄弱的地方

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 和标准库的做法走,就能从结构上减少收尾和同步上的失误。

选择执行方式,减少共享,保护剩下的共享,把从停止请求到 join 做完整。 在这个顺序中使用 jthreadscoped_lock、带谓词的 waitatomic,就是实战的基本功。

相关文章

相关咨询领域

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

参考链接

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

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

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

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

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

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

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

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

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

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

  11. 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)的说明。 

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

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

返回博客列表