并发核心思维
内容介绍
并发编程的核心不是线程 API,而是如何组织多个任务之间的关系。
Go 让并发看起来简单,是因为它把“启动任务、任务通信、等待结束、取消传播”做成了很顺手的语言和运行时能力。C++ 不同:C++ 给你的不是轻量运行时,而是更底层、更显式的控制权。
所以学习 C++ 并发时,可以借鉴 Go 的任务组织清晰性,但必须用 C++ 的方式处理:
- 线程和任务不是一回事。
- 对象生命周期必须明确。
- 内存和所有权必须明确。
- 同步必须显式。
- 性能成本必须可控。
一、任务拆分
先问任务,不要先问线程。
这个程序里有哪些事情可以同时推进?
哪些事情必须等待前一步结果?
哪些任务可以失败?
哪些任务可以取消?
哪些任务需要返回结果?如果任务边界不清楚,线程越多,代码越乱。
二、执行资源
任务只是“要做的事”,线程才是“执行任务的资源”。
常见选择:
- 一个后台长期线程:适合日志、监控、定时刷新。
- 一组工作线程:适合批量任务、计算任务、生产消费模型。
std::async/future:适合少量有返回值的异步任务。- 事件循环或异步 IO:适合大量网络连接。
C++ 里不要把每个任务都做成一个 std::thread。大量任务应进入 cpp线程池。
三、数据所有权
并发程序最容易坏在这里。
每个跨线程对象都要问:
- 谁创建它?
- 谁拥有它?
- 哪些线程能读?
- 哪些线程能写?
- 它什么时候销毁?
- 线程结束前它是否还活着?
如果回答不清楚,就容易出现数据竞争、悬空引用、重复释放、对象已经析构但线程仍在访问。
四、同步与通信
并发任务之间通常有两种关系。
共享内存
多个线程访问同一份数据。
需要:
std::mutexstd::lock_guardstd::unique_lockstd::atomic
适合共享状态较少、关系明确的场景。
消息传递
线程之间传任务、事件或数据。
需要:
- 阻塞队列
- 条件变量
future/promise- 线程池任务队列
适合生产者消费者、后台任务、流水线处理。它的优点是减少直接共享可变对象。
五、等待与返回值
等待关系必须明确:
- 等一个线程结束:
join - 等一个结果:
future.get - 等队列有数据:
condition_variable - 等一组任务完成:完成计数模型
不要用 sleep 猜测另一个线程是否执行完。
六、退出与取消
所有线程都必须有退出设计。
常见方法:
- 原子停止标志。
- 关闭阻塞队列。
- 条件变量
notify_all。 - C++20
std::jthread+stop_token。
退出设计要在写线程逻辑之前考虑,而不是最后补。
七、性能边界
并发代码必须知道成本在哪里:
- 创建线程有成本。
- 上下文切换有成本。
- 锁竞争有成本。
- 条件变量唤醒有成本。
- 队列堆积会吃内存。
- 任务太小会被调度成本吞掉。
这部分继续看 cpp并发性能模型。
最佳代码实践
- 先写任务图,再写线程代码。
- 优先明确所有权,再传递对象。
- 能传值就不要传引用;必须传引用时保证生命周期。
- 能用队列传任务,就不要让多个线程到处改同一个对象。
- 所有线程启动后,都必须有明确的等待或停止路径。
- 正确性优先于性能;性能优化必须建立在可测量指标上。
常见错误用法
void start() {
std::string data = "hello";
std::thread([&] {
use(data);
}).detach();
}问题:线程使用了局部变量引用,但 start 返回后 data 已经销毁。
// 看到任务就开线程
std::thread t1(task1);
std::thread t2(task2);
std::thread t3(task3);问题:没有区分任务和执行资源,小规模演示可以,工程里会变成线程数量失控。
注意事项
- C++ 并发的难点不是“怎么启动”,而是“怎么拥有、怎么同步、怎么退出”。
- 不要把 Go 的语法形式当成目标;真正值得借鉴的是任务组织清晰、通信边界清楚、取消路径明确。
- C++ 的强项是资源控制和性能边界显式,代价是你必须自己负责这些边界。