并发与多线程

内容介绍

并发编程解决的是“多个任务在同一段时间内推进”的问题。

  • 并发:多个任务交替推进,不一定同时执行。
  • 并行:多个任务在多个 CPU 核心上真正同时执行。
  • 进程:操作系统分配资源的基本单位。
  • 线程:进程内的执行流,共享进程资源。

C++ 多线程的核心难点不是创建线程,而是共享数据、生命周期、同步和退出。

先用生活模型理解

可以把一个程序想成一家小店:

  • 任务:要完成的事情,例如接单、做菜、收银、清理。
  • 线程:员工,真正执行任务的人。
  • 共享数据:订单本、库存表、收银台余额。
  • 锁:同一时间只允许一个人改订单本。
  • 条件变量:没有新订单时厨师休息,有订单时前台叫醒厨师。
  • 退出:打烊时通知所有员工停止接新活,做完手头工作后下班。

多线程代码也是这个结构。不要一开始就想“我要开几个线程”,先问:

我有哪些任务?
哪些任务可以同时推进?
哪些数据会被多个任务访问?
谁负责通知谁?
程序结束时这些线程怎么停?

从单线程到多线程

单线程写法通常是按顺序执行:

读输入 -> 处理数据 -> 写结果

多线程适合把等待或可并行的部分拆出来:

主线程:响应用户 / 接收请求
后台线程:写日志 / 存文件 / 清理缓存
工作线程:处理计算任务

但拆线程会带来新成本:

  • 线程创建和切换有成本。
  • 共享数据要同步。
  • 执行顺序不再固定。
  • 错误可能偶发,难复现。
  • 程序退出时必须等待或通知线程。

所以并发的第一原则是:先证明值得并发,再引入线程。

什么时候需要多线程

  • 任务之间相对独立,可以并行计算。
  • 主线程不能被阻塞,例如 GUI、游戏主循环、服务主循环。
  • 有明显 IO 等待,例如网络、磁盘、数据库、日志写入。
  • 需要后台任务,例如定时清理、监控、异步保存。

可以按下面方式判断:

场景是否适合多线程原因
大量图片批量压缩适合每张图片相对独立,CPU 可以并行工作
GUI 点击按钮后保存大文件适合保存文件不能卡住界面
日志写入磁盘适合主逻辑不必等待慢 IO
一个 vector 里做很少量计算通常不适合线程开销可能比计算更大
多个线程频繁修改同一个复杂对象通常不适合锁竞争高,正确性难保证

什么时候不该用多线程

  • 数据高度共享,线程之间总是互相等待。
  • 任务很短,但频繁创建销毁线程。
  • 单线程已经足够快,瓶颈不在 CPU 或 IO。
  • 只是为了“看起来高级”。

一个实用判断:如果你不能清楚说出“哪些数据是共享的,哪个锁保护它,线程如何退出”,先不要写多线程。

第一个分岔:共享内存还是消息传递

多线程协作大致有两种风格。

共享内存

多个线程访问同一份对象:

线程 A -> shared_data
线程 B -> shared_data

这种方式需要锁或原子操作。适合共享状态少、访问规则清楚的场景。

继续看:cpp互斥锁与RAIIcpp原子操作

消息传递

线程之间通过队列传任务或数据:

生产者 -> 队列 -> 消费者

这种方式更容易维护,因为共享状态集中在队列里。

继续看:cpp条件变量cppChannel模型

第二个分岔:一次性异步还是长期工作线程

如果只是“开一个任务,之后拿结果”,优先考虑 future

auto result = std::async(std::launch::async, compute);
auto value = result.get();

继续看:cppfuture和promise

如果是长期后台工作,例如日志线程、任务队列消费者、线程池 worker,就要设计:

  • 工作循环什么时候等待。
  • 有任务时怎么唤醒。
  • 没任务时是否睡眠。
  • 程序退出时怎么通知停止。
  • 谁负责 join

继续看:cpp线程生命周期cpp协作取消cpp线程池

最佳代码实践

  • 先写清楚任务边界,再决定线程边界。
  • 优先让线程之间传消息,而不是共享大量可变对象。
  • 一个线程负责一类职责,例如消费队列、写日志、处理连接。
  • 线程退出路径要和启动路径一样明确。
  • 并发设计先保证正确,再讨论性能。
  • 性能目标是让有限线程持续做有效工作,而不是把线程数量堆上去。

常见错误用法

// 错误倾向:每个小任务都创建一个线程
for (int i = 0; i < 10000; ++i) {
    std::thread([i] {
        // very small task
    }).detach();
}

问题:线程创建成本高,数量失控会拖垮系统。大量小任务应该交给 cpp线程池

注意事项

  • 线程共享同一个进程地址空间,所以修改同一份数据必须同步。
  • 多线程程序的输出顺序通常不稳定,这是正常现象。
  • sleep 只能模拟等待,不能作为可靠同步手段。
  • C++ 标准库多线程从 C++11 开始,建议至少按 C++17 学,能用 C++20 更好。
  • 看任何多线程示例时,先标出:线程、共享数据、锁、等待条件、退出条件。

学习路径