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::jthread、std::latch、std::barrier、std::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++20:jthread + stop_token的组合太香了,能省掉很多手动管理线程的麻烦。
- latch和barrier选哪个?:一次性同步用latch,循环同步用barrier。别搞混了。
- 信号量别滥用:大多数场景下mutex + condition_variable就够用了。信号量更适合「资源池」这种场景。
- 注意平台兼容性: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了。标准库给的这些工具,足够应对绝大多数场景。