并发核心思维

内容介绍

并发编程的核心不是线程 API,而是如何组织多个任务之间的关系。

Go 让并发看起来简单,是因为它把“启动任务、任务通信、等待结束、取消传播”做成了很顺手的语言和运行时能力。C++ 不同:C++ 给你的不是轻量运行时,而是更底层、更显式的控制权。

所以学习 C++ 并发时,可以借鉴 Go 的任务组织清晰性,但必须用 C++ 的方式处理:

  • 线程和任务不是一回事。
  • 对象生命周期必须明确。
  • 内存和所有权必须明确。
  • 同步必须显式。
  • 性能成本必须可控。

一、任务拆分

先问任务,不要先问线程。

这个程序里有哪些事情可以同时推进?
哪些事情必须等待前一步结果?
哪些任务可以失败?
哪些任务可以取消?
哪些任务需要返回结果?

如果任务边界不清楚,线程越多,代码越乱。

二、执行资源

任务只是“要做的事”,线程才是“执行任务的资源”。

常见选择:

  • 一个后台长期线程:适合日志、监控、定时刷新。
  • 一组工作线程:适合批量任务、计算任务、生产消费模型。
  • std::async / future:适合少量有返回值的异步任务。
  • 事件循环或异步 IO:适合大量网络连接。

C++ 里不要把每个任务都做成一个 std::thread。大量任务应进入 cpp线程池

三、数据所有权

并发程序最容易坏在这里。

每个跨线程对象都要问:

  1. 谁创建它?
  2. 谁拥有它?
  3. 哪些线程能读?
  4. 哪些线程能写?
  5. 它什么时候销毁?
  6. 线程结束前它是否还活着?

如果回答不清楚,就容易出现数据竞争、悬空引用、重复释放、对象已经析构但线程仍在访问。

四、同步与通信

并发任务之间通常有两种关系。

共享内存

多个线程访问同一份数据。

需要:

  • std::mutex
  • std::lock_guard
  • std::unique_lock
  • std::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++ 的强项是资源控制和性能边界显式,代价是你必须自己负责这些边界。

学习路径