并发性能模型

内容介绍

想用 C++ 写出高性能并发,关键不是“线程开得多”,而是让有限的系统线程持续做有效工作。

Go 的并发体验来自运行时调度器、轻量任务、网络轮询器和阻塞管理。C++ 标准库没有这些运行时能力,所以工程上要靠设计补齐:

  • 用线程池复用系统线程。
  • 用任务队列把任务和线程解耦。
  • 用有界队列做背压。
  • 用合理任务粒度减少调度开销。
  • 用局部数据、批处理、分片锁减少竞争。
  • 用协作取消和关闭协议避免线程卡死。

性能指标

吞吐量

单位时间完成多少任务。线程池、批处理、减少锁竞争主要提升吞吐。

延迟

单个任务从提交到完成的时间。队列过长、锁等待、线程饥饿都会增加延迟。

资源占用

包括线程数、栈内存、队列内存、CPU 使用率、上下文切换次数。

可预测性

工程并发不只看平均值,还要看长尾延迟。偶发锁竞争、队列堆积、阻塞扩散会让尾延迟很差。

线程数量

CPU 密集任务:

线程数 ≈ CPU 核心数

IO 密集任务:

线程数可以多于核心数,但必须受控

不要用“任务数 = 线程数”的模型。大量任务应该进入 cpp线程池 的队列,由固定数量工作线程执行。

任务粒度

任务太大:

  • 单个任务占用线程太久。
  • 负载不均衡。
  • 取消不及时。

任务太小:

  • 入队、出队、唤醒、调度成本超过业务计算。
  • 锁竞争变多。
  • 吞吐下降。

最佳实践是把任务切到“足够大,值得调度;足够小,便于负载均衡”的粒度。

背压

没有背压的队列会把性能问题变成内存问题。

// 危险倾向:生产者无限 push,消费者处理不过来
while (true) {
    queue.push(make_task());
}

工程上应使用有界队列:

  • 队列满时阻塞生产者。
  • 队列满时丢弃低优先级任务。
  • 队列满时返回失败,让上游降速。

背压是高性能服务稳定运行的核心。

减少锁竞争

常用方法:

  • 缩小锁作用域。
  • 持锁期间不做 IO。
  • 每个线程先写局部结果,最后合并。
  • 用分片锁代替全局锁。
  • 读多写少场景考虑 std::shared_mutex
  • 简单计数器使用 std::atomic

批处理

批处理能显著减少锁和调度成本。

单条处理:
lock -> pop 1 个任务 -> unlock -> 执行
 
批处理:
lock -> pop 一批任务 -> unlock -> 连续执行

适合日志、消息消费、数据清洗、批量计算等场景。

最佳代码实践

  • 默认从线程池开始,而不是手写大量 std::thread
  • 明确任务是 CPU 密集还是 IO 密集,再设置线程数。
  • 队列要有容量上限或降级策略。
  • 优先通过队列传递任务,减少多个线程直接改同一个对象。
  • 用局部累积加最终合并,替代每次操作都抢全局锁。
  • 性能优化要测量:吞吐、延迟、CPU、上下文切换、队列长度。

常见错误用法

for (int i = 0; i < 100000; ++i) {
    std::thread([i] {
        do_small_work(i);
    }).detach();
}

问题:创建 10 万个系统线程会导致内存、调度、上下文切换成本失控。

std::lock_guard<std::mutex> lock(m);
write_log_to_disk();

问题:持锁做慢 IO,会让其他线程被慢设备拖住。

注意事项

  • C++ 标准库不会替你调度海量轻量任务,必须自己控制线程数。
  • 性能瓶颈常常不是计算,而是锁竞争、队列堆积、内存分配、上下文切换。
  • 并发性能优化必须用数据说话,不要凭感觉加线程。
  • 想进一步接近高性能服务模型,需要继续学习事件循环、异步 IO、协程和成熟线程池实现。

学习路径