27、C++标准库并发演进:从C++11到C++23的并发特性

聊到C++的并发演进,我其实挺感慨的。从C++11开始,标准库终于有了自己的线程库,到C++23的jthread和stop_token,这十几年变化太大了。今天咱们就好好捋一捋这条演进路线,看看每个版本带来了什么好东西。

一、C++11:并发编程的起点

C++11之前,你想写多线程程序?要么用pthread,要么用Windows API,要么用Boost。每个平台的接口都不一样,移植起来特别痛苦。

C++11一口气带来了这些核心组件:

  • std::thread:线程创建和管理
  • std::mutex:互斥锁
  • std::lock_guard:RAII锁管理
  • std::condition_variable:条件变量
  • std::atomic:原子操作
  • std::future / std::promise:异步任务

说实话,C++11的线程库在当时已经算很良心了。但用久了你会发现一些问题——比如线程一旦启动,你就很难优雅地让它停下来。我早期做项目时,经常用全局的std::atomic<bool>来通知线程退出,说实话挺土的。

核心痛点:C++11的std::thread没有内置的取消机制,线程一旦启动,要么等它自然结束,要么用粗暴的方式终止(不推荐)。

二、C++14 & C++17:小步快跑

C++14和C++17在并发方面没有大动作,但有几个实用补充:

  • C++14:std::shared_lock(读写锁的读端)
  • C++17:std::scoped_lock(一次性锁多个互斥量,避免死锁)

我个人觉得std::scoped_lock是个被低估的好东西。以前写代码要手动lock多个mutex,还得小心死锁。有了它,一行代码搞定:

std::scoped_lock lock(mutex1, mutex2, mutex3);
// 安全地操作共享数据

嗯,这里要注意——std::scoped_lock用的是死锁避免算法,它会按照固定顺序加锁,比你自己手动加锁靠谱多了。

三、C++20:并发工具箱大升级

C++20是并发特性的大爆发。咱们重点说三个:std::jthreadstd::latchstd::barrierstd::counting_semaphore

3.1 std::jthread:自动join的线程

你想想看,C++11的std::thread有个烦人的问题——如果你忘记join或detach,程序会直接崩溃(调用terminate)。

std::jthread解决了这个问题。它在析构时自动join,再也不用担心忘记释放线程资源了。

// C++11:必须手动join
std::thread t([]{
    std::this_thread::sleep_for(1s);
    std::cout << "done\n";
});
t.join(); // 忘了?程序崩给你看

// C++20:自动join
std::jthread jt([]{
    std::this_thread::sleep_for(1s);
    std::cout << "done\n";
});
// 析构时自动join,安全省心

我在项目中遇到过这样的bug——某个异常路径下忘记join线程,导致程序崩溃。排查了好久才发现。换成jthread后,这类问题再也没出现过。

3.2 stop_token:优雅地停止线程

jthread还带了一个杀手锏——std::stop_token。它提供了一种标准化的线程停止机制。

以前我们怎么停止线程?用原子变量呗:

std::atomic<bool> stop_flag{false};
std::thread t([&stop_flag]{
    while (!stop_flag) {
        // 干活...
    }
});
// 设置标志
stop_flag = true;
t.join();

这种做法有什么问题?如果线程正在阻塞等待(比如在condition_variable上wait),你没法及时唤醒它。stop_token配合jthread,可以做到:

std::jthread jt([](std::stop_token st){
    while (!st.stop_requested()) {
        // 干活...
        std::this_thread::sleep_for(100ms);
    }
});
// 请求停止
jt.request_stop(); // 优雅地通知线程退出

小技巧:stop_token可以传递给子函数,让子函数也能响应停止请求。这在复杂的多线程流水线中特别有用。

3.3 std::latch:一次性屏障

std::latch是个计数器,所有线程都到达某个点后,一起继续执行。它只能用一次。

我举个例子——假设你有4个线程加载数据,主线程要等所有数据加载完才能继续:

std::latch completion_latch(4); // 计数4

// 4个工作线程
for (int i = 0; i < 4; ++i) {
    std::jthread([&completion_latch]{
        // 加载数据...
        std::this_thread::sleep_for(1s);
        completion_latch.count_down(); // 完成一个
    }).detach();
}

// 主线程等待
completion_latch.wait(); // 等4个线程都完成
std::cout << "所有数据加载完毕\n";

说白了,latch就是一次性的栅栏。适合「所有线程都准备好了,再一起出发」的场景。

3.4 std::barrier:可复用的屏障

std::barrier和latch类似,但可以重复使用。每次所有线程到达屏障后,会执行一个回调函数,然后继续下一轮。

我曾经用barrier实现过一个并行渲染管线——每帧分4个线程处理,所有线程处理完当前帧后,同步进入下一帧:

std::barrier sync_barrier(4, []() noexcept {
    std::cout << "所有线程完成当前帧\n";
});

// 每个线程循环渲染
auto render_loop = [&sync_barrier]{
    for (int frame = 0; frame < 60; ++frame) {
        // 渲染当前帧的一部分...
        sync_barrier.arrive_and_wait(); // 等待其他线程
    }
};

注意:barrier的回调函数必须是noexcept的,而且执行期间所有线程都在等待。回调要尽量快,别在里面做耗时操作。

3.5 std::counting_semaphore:信号量

信号量是个老概念了,但C++20才正式进入标准库。std::counting_semaphore管理一个计数器,支持acquire(减1,不够就阻塞)和release(加1,唤醒等待者)。

我常用它来做「生产者-消费者」队列的限流:

std::counting_semaphore<10> sem(5); // 最多5个并发

void process_task() {
    sem.acquire(); // 获取信号量
    // 处理任务...
    sem.release(); // 释放信号量
}

还有一个std::binary_semaphore,其实就是计数为1的信号量,相当于一个轻量级的互斥量。

四、C++23:小修小补

C++23在并发方面没有大动作,主要是对C++20特性的完善:

  • std::jthread的stop_token支持更完善
  • std::move_only_function(可移动的函数包装器,适合异步任务)
  • std::out_ptr / std::inout_ptr(C风格API的智能指针适配)

说实话,C++23的并发改进更多是「查漏补缺」,核心框架在C++20已经搭好了。

五、知识体系总览

下面这张图总结了C++11到C++23的并发演进路线:

C++并发演进路线图 C++11 std::thread std::mutex std::future std::atomic C++14/17 std::shared_lock std::scoped_lock C++20 std::jthread std::stop_token std::latch/barrier std::semaphore C++23 核心特性对比 线程管理 C++11: std::thread C++20: std::jthread 自动join + 停止令牌 同步原语 C++11: mutex, cv C++20: latch, barrier C++20: semaphore 异步任务 C++11: future/promise C++20: jthread + stop C++23: move_only_fn

六、实战建议

说了这么多,我给大家几个实际项目中的建议:

  1. 新项目优先用C++20:jthread + stop_token的组合太香了,能省掉很多手动管理线程的麻烦。
  2. latch和barrier选哪个?:一次性同步用latch,循环同步用barrier。别搞混了。
  3. 信号量别滥用:大多数场景下mutex + condition_variable就够用了。信号量更适合「资源池」这种场景。
  4. 注意平台兼容性:C++20的并发特性在GCC 11+、Clang 14+、MSVC 2022+才完整支持。老编译器可能不行。

避坑指南:我曾经在项目里混用C++11的thread和C++20的jthread,结果在某个异常路径下,jthread自动join了,但另一个thread没处理,导致死锁。记住——要么全用jthread,要么全用thread,别混着来。

好了,关于C++并发演进就聊这么多。从C++11的蹒跚起步,到C++20的成熟完善,这条路走了将近十年。现在写并发代码,终于不用再依赖平台特定的API了。标准库给的这些工具,足够应对绝大多数场景。


公众号:蓝海资料掘金营,微信 deep3321