为什么需要并发
诚实的说哈,笔者真的有时候怕“并发”,这个东西的引入,让我们甚至对读写本身都要仔细思考和衡量。
并发不像 RAII 或者移动语义那样有一个明确的概念边界——它是一整个思维方式。你可能写了好多年的单线程代码,一切都那么确定、那么可控,函数调用的顺序就是执行顺序,变量读出来的值就是你刚写进去的值。然后突然有一天,你发现某个任务处理不过来了,或者某个网络服务需要同时响应几百个连接,你不得不引入多线程——然后一切就开始变得不可预测了。
并发与并行:不是一回事
这两个词在日常交流里经常被混着用,但在计算机科学里它们有明确的区别。简单来说,并发(concurrency)是关于"结构"的,并行(parallelism)是关于"执行"的。
并发是指你的程序被设计成可以同时处理多个任务——这些任务可能交替执行,也可能真的同时执行。并发是一种程序组织方式:你把一个复杂的问题拆成多个独立的、可以交替推进的子任务,然后用某种机制(线程、协程、事件循环)来管理它们的执行顺序。关键在于,并发不要求多个 CPU 核心,在单核机器上你完全可以写并发程序——操作系统通过时间片轮转让多个线程交替使用 CPU,从宏观上看它们好像在"同时"运行。
并行则是指多个操作真正在同一时刻物理上同时执行。这需要硬件支持——多核 CPU、多处理器、GPU。并行是一种执行方式:你有多个计算资源,把不同的任务分别交给它们,让它们在同一个时钟周期里各自干活。
Rob Pike 有一句很经典的话:"Concurrency is about dealing with a lot of things at once. Parallelism is about doing a lot of things at once." 翻译过来就是,并发是关于应对很多事情,并行是关于同时做很多事情。一个并发程序在单核上可以运行得很好(只是没有加速效果),而一个并行程序必须有多个硬件执行单元才能发挥价值(要不然没CPU核心给他处理,搞Linux的朋友要是好奇自己的核心数,很简单,nproc一下就好了)
这个区分为什么重要?因为在 C++ 里,我们用 std::thread、std::async、协程这些机制来表达并发——至于这些并发任务最终是分时复用一个核心,还是真的跑到不同核心上,取决于操作系统的调度和硬件能力。我们作为程序员的责任是保证并发程序的正确性(不管它跑在几个核心上),性能提升只是正确性之上的一层优化。
两个定律:Amdahl 与 Gustafson
学习计算机组成原理和架构的朋友不会感到陌生的两个定律。
我们知道了并发和并行的区别,下一个自然的问题是:如果我引入了并行,到底能快多少?这就有两个经典的定律可以帮我们建立直觉。
Amdahl 定律:固定负载下的加速上限
Amdahl 定律的核心思想是:一个程序的加速比受限于它的串行部分。假设一个程序的总工作量为 1,其中比例为
直觉很直白:不管你用多少个核心,那
举个例子,如果你的程序有 5% 是串行的(
这个定律看起来很悲观——它在告诉我们,串行比例是性能的天花板。但也正因为如此,它在工程上非常有价值:在你花大量时间去并行化一个程序之前,先用 Amdahl 定律估算一下可能的收益。如果串行比例太高,并行化的投入可能根本不值得。(笔者常说的,工程分为代码之内和代码之外,代码之外的内容往往可能发挥难以被忽略的作用)
Gustafson 定律:规模扩展下的加速视角
Amdahl 定律的假设是问题规模不变——我们用更多的核心来解决同样大小的问题。但在现实中,当我们有了更多的计算资源时,往往会选择去解决更大的问题。Gustafson 定律从另一个角度来看这个问题。
假设一个程序在单个处理器上的运行时间为
这个公式要乐观得多:如果串行部分
两个定律不是矛盾的,它们只是从不同的角度看待同一个问题。Amdahl 说"在固定负载下你能快多少",Gustafson 说"在相同时间内你能多做多少活"。在实际工程中,这两种场景都会遇到,关键是要清楚你的目标是什么,你打算使用哪个评估你的问题。
吞吐量与延迟的权衡
吞吐量(throughput)和延迟(latency)往往不可兼得。
吞吐量是指单位时间内能完成的任务总数,延迟是指单个任务从提交到完成的时间。在并发设计中,这两者的优化方向经常冲突。
一个很典型的例子是批处理。假设你有一个任务队列,每处理一个任务需要 1ms 的 CPU 时间。如果你来一个任务就立刻处理,每个任务的延迟是 1ms,但线程切换、锁竞争的开销会让总吞吐量不高。如果你把任务攒成一批,每批 100 个一起处理,批内可以做一些优化(比如合并 I/O 操作),总吞吐量会大幅提升,但排在队列后面的任务延迟就从 1ms 变成了接近 100ms。
另一个经典的例子是负载均衡策略。最短队列优先(把新任务分配给当前队列最短的 worker)可以最小化平均延迟,但它的调度开销比简单的轮询(round-robin)更高。轮询的吞吐量通常更好,但个别任务可能被分配到已经很忙的 worker 上,导致尾部延迟(tail latency)飙升。
这种权衡没有"正确答案",取决于你的业务需求。实时交易系统优先降低延迟,批处理数据管道优先提高吞吐量,而大多数 Web 服务需要在一个合理的延迟范围内最大化吞吐量。在开始设计并发架构之前,先想清楚你的系统更关心哪个指标。
任务粒度:太细和太粗都不行
另一个需要建立的判断力是任务粒度(granularity)——你把工作拆成多大的单元交给并发处理。
粒度太细是有问题的。每次创建或调度一个并发任务都有开销:线程的创建和销毁、上下文切换、锁的获取和释放、缓存失效。如果任务本身的计算量比这些开销还小,引入并发反而会拖慢程序——你花了更多的 CPU 时间在管理上而不是在计算上。举个极端的例子,如果你给一个数组的每个元素都开一个线程去做加法,那线程创建和调度的开销可能是实际计算的上千倍。
粒度太粗也有问题。如果你把所有工作打包成一个大任务交给一个线程处理,那跟单线程没什么区别,并发度上不去,多核 CPU 的计算资源白白浪费。
所以,任务粒度的选择需要在"并发开销"和"并发收益"之间找平衡点。一般来说,一个并发任务的计算量至少应该是其调度开销的 10 倍以上,否则不值得。具体的阈值取决于你的硬件和运行时环境,但这个数量级的判断是通用的。
在实际工程中,任务粒度往往通过实验确定。你可以从较大的粒度开始,逐步细化,每次都测量总执行时间和吞吐量,找到性能最佳的拐点。这种 benchmark 驱动的调优方式,比凭感觉猜粒度可靠得多。
什么时候不该用并发
到这一步我们已经谈了很多为什么要用并发,但同样重要的是知道什么时候不该用并发。
我们知道,并发/并行的根本就在于CPU是否多核,主频能力是否达到预期。核心在于CPU对吧!如果你的程序是 CPU 密集型的单任务,而且没有 I/O 等待(比如一个纯数值计算的程序),引入多线程可能没有帮助甚至会变慢——除非你的算法本身就是可并行化的(简而言之,就是本身可以被拆分成无前后依赖的多个模块)。如果你的程序已经很足够快了——处理延迟远低于业务要求的阈值——那么引入并发的复杂度成本是不值得的。如果你的程序对确定性有严格要求(比如某些控制系统),多线程引入的不可预测性可能无法接受(比如说嵌入式的一些场景)。
还有一个容易被忽视的场景:当你需要的不是并行计算而是异步 I/O 时,多线程不一定是最好的选择。一个网络服务需要同时处理上千个连接,如果你给每个连接开一个线程,线程数量会很快成为瓶颈。这种场景更适合事件驱动或者协程的方式,用少量线程通过 I/O 多路复用管理大量连接——这部分我们会在卷五后面的异步 I/O 章节详细讨论。
最后也是最根本的一点:并发引入的复杂度是真实的,不是想象出来的。data race、死锁、条件变量的虚假唤醒、对象的生命周期问题——这些 bug 的特征是难以复现、难以调试、难以测试。如果单线程就能解决问题,不要为了"炫技"去引入并发。并发的唯一正当理由是单线程真的不够用了。
我们的位置
这一篇我们建立了并发的基本认知框架:并发和并行不是一回事,Amdahl 定律和 Gustafson 定律帮助我们理解加速比的上下界,吞吐量和延迟的权衡指导架构选择,任务粒度需要在开销和收益之间找到平衡点,而有些场景根本不需要并发。
但知道"为什么"只是第一步。下一篇我们要面对一个更现实的问题:当你真的写了并发代码之后,到底会出什么问题?我们将逐一拆解 data race、race condition、死锁、活锁、饥饿和优先级反转——这些是并发编程里最常见的 bug 来源,也是整卷后续内容要解决的问题。先正确性,再性能,记住这个原则。
参考资源
- Multi-threaded executions and data races (cppreference)
- Amdahl's Law — Wikipedia
- Gustafson's Law — Wikipedia
- Concurrency Is Not Parallelism — Rob Pike (Heroku Waza 2012, YouTube) — 提出"并发是关于应对很多事情,并行是关于同时做很多事情"的经典区分,强调并发是设计工具(structuring),并行是执行属性(execution)
- Concurrency Is Not Parallelism — Rob Pike (Slides)
- Why Undefined Semantics for C++ Data Races? — Hans Boehm