做 C++ 服务端这些年,我经常会看到这样的讨论:
- Go 最大的优势是天然支持高并发;
- Node.js 适合高并发,因为它是异步非阻塞;
- Python 因为 GIL,无法真正并行;
- 而 C++ 社区则经常讨论 epoll、线程池、协程、io_uring……
它们好像都在讨论“高性能”,却又像在讨论完全不同的事情。
这些说法单看都没错。但如果继续追问一句:Async(异步)、Concurrency(并发)和 Parallelism(并行)到底是什么关系? 很多人的回答就会开始混乱。
有人认为异步就是多线程,有人觉得并发就是多个任务在同一时刻执行,还有人坚信线程开得越多程序自然就越快。这些混乱的本质,并不是因为定义没背熟,而是把三个不同层面的概念混在了一起。
事实上,它们回答的是三个完全不同的问题:
- Async(异步): 解决的是 “等待的时候,线程不要闲着”。
- Concurrency(并发): 解决的是 “越来越多的任务,应该如何组织和推进”。
- Parallelism(并行): 解决的是 “一个 CPU 核心不够用了,如何榨干多核性能”。
今天,借助这篇文章,通过示例的方式聊聊这三者的前世今生。
用厨房理解三个概念
我们先用一个最生活化的场景——厨房,来建立这三个概念的直观认知。
假设只有一个厨师,他需要同时做三道菜:
厨师把牛排推进烤箱,设置好 20 分钟的定时器。他没有站在烤箱前死等 20 分钟,而是立刻转身去切蔬菜、洗盘子。等烤箱“叮”的一声响起,他再回来处理牛排。
这是异步(Async)。
厨房里依然只有这一个厨师。他要在一段时间内把三道菜都做出来。于是他左右开弓:先给第一盘菜倒点油,趁着油热的间隙去切第二道菜的葱花,然后又去搅拌第三道菜的汤。
从食客的角度看,三道菜都在同时制作;但从物理微观上看,任何一个特定瞬间,厨师都只在做一件事,他只是在不同的菜品之间快速轮流交替。
这是并发(Concurrency)。
餐厅生意太火爆,一个厨师实在忙不过来了。于是老板大手一挥,又雇了两位厨师。现在厨房里有三个人、三个灶台。厨师 A 专心煎牛排,厨师 B 专心炒蔬菜,厨师 C 专心熬汤。三个人在同一个一秒钟内,都在挥舞锅铲。
这是并行(Parallelism)。
将这三个策略映射到计算机系统里,就是现代软件架构的演进史。
同步时代:CPU 忙个不停
今天我们写代码,总觉得程序慢的罪魁祸首是数据库慢、网络慢、磁盘慢。
但如果把时间倒回二十多年前互联网还没发展起来的时代,完全是另一番模样。
在那个年代,绝大多数程序都是单机运行的本地程序。没有复杂的分布式微服务,没有 Redis 缓存,也没有 HTTP、RPC 请求。
那时的典型代码长这样:
read_file(); // 从本地硬盘读取数据
process(); // 进行大量的数学计算或数据处理
write_file(); // 将结果写回本地硬盘
整个执行流程是一条笔直的单行道,串行执行。在这个过程中,CPU 几乎没有任何停歇。它从第一条指令疯狂执行到最后一条指令,时钟周期被塞得满满当当。
为什么?因为那个年代真正耗费时间的,就是纯粹的计算算力。
如果程序跑得慢,解决办法简单暴烈:换一颗主频更高的 CPU。
也正是从那个时期开始,CPU 厂商(Intel 和 AMD)开始了主频的狂飙之旅。33MHz、100MHz、1GHz、2GHz、一直冲到 3GHz 以上。软件开发者什么都不用改,只要换一台新机器,程序的运行速度就能获得近乎线性的提升。
那个时代,CPU 算力是整个系统唯一的瓶颈,程序是纯粹的“同步(Synchronous)”执行,上一步不完,下一步不动。
互联网时代:CPU 开始发呆
后来,互联网时代轰轰烈烈地降临了。
单机计算变成了分布式协同,程序开始频繁地与网络、数据库交互。
代码变成了我们今天最熟悉的模样:
User user = mysql.query(uid); // 查询数据库
Order order = rpc.query(uid); // 调用下游 RPC 服务
Profile profile = redis.get(uid); // 读取 Redis 缓存
return build_response(user, order, profile);
这段代码在语法上依然是顺序执行的。但是,它在底层引发的物理效应,与二十年前的本地程序已经有了天壤之别。以前 CPU 一直在高速运转;现在,CPU 绝大部分时间都在尴尬地发呆。
我们以第一行 mysql.query(uid) 为例。很多开发者在直觉上会认为:程序正在执行数据库查询,所以 CPU 此时一定在疯狂运转。
事实上,CPU 一点都不忙。
当这行代码执行时,内核里发生的事情是这样的:CPU 花了几微秒,把 SQL 语句封装成网络报文,调用内核的系统调用丢给网卡缓冲区。随后,CPU 的工作就结束了。
接下来开始漫长的长征:网卡把信号发出去,经过交换机、路由器,跨越数个机房传输到数据库服务器;数据库服务器接收数据,唤醒它的 CPU,解析 SQL,去扫描磁盘,找到数据后再原路返回。整个过程快则几毫秒,慢则几十毫秒。而在这期间,我们本地服务器的 CPU 真正执行指令的时间,可能只有可怜的几微秒。
换句话说,99% 的时间,系统都在等待。
为了对等待时间有个更好的体验,下面是等待时间:
| 计算机真实操作 |
实际耗时 |
放大后的体感时间 |
| CPU 执行一条指令 |
|
1 秒 |
| 访问 L1 缓存 |
|
0.5 秒(顺手拿个杯子) |
| 访问主内存 |
|
1.6 分钟(下楼充个值) |
| SSD 固态硬盘随机读取 |
|
14 小时(熬夜坐个长途车) |
| 一次普通的 MySQL 查询 |
|
4 个月(经历一个学期) |
| 互联网跨境网络传输 |
|
4.7 年(读完一个大学) |
也就是说,当你的代码执行到 mysql.query() 时,由于它是同步阻塞的,这就相当于 CPU 帮数据库发了个快递,然后在原地傻傻地站了 4 个月,期间什么别的工作都不接,直到快递送回,才继续执行下一行。
这就是互联网时代最大的性能瓶颈:CPU 不再是算不过来,而是等不过来。
这种瓶颈,被称为 I/O 密集型(I/O-Bound) 瓶颈。
我们将上面这种串行执行称之为 同步。
很多人容易把同步和阻塞搞混,其实:同步(Synchronous)描述的是程序如何获得结果;阻塞(Blocking)描述的是线程在等待期间是否继续运行,它们并不是同一个概念。
Async:等待时别闲着
既然同步程序的瓶颈在于“等”,那最自然的破局思考就是:CPU 为什么一定要等?
为什么等待数据库返回的时候,线程不能先去执行当前任务中的其他可执行部分,或者暂时把执行权交出去,让系统去推进其他已经准备好的任务?
这就催生了现代软件架构的第一次革命:Async(异步编程模型)。
在传统的同步多线程模型中,一个线程对应一个请求。当请求在等待 I/O 时,操作系统内核会将该线程切换为“睡眠/阻塞(Blocked)”状态。
表面上看,线程不占 CPU 了,挺好。但别忘了,线程本身也是极其昂贵的系统资源。
在 Linux 系统中,一个原生线程通常会预留数 MB 级别的栈空间。线程数量一旦上去,光是栈内存和调度成本就已经非常可观。
更致命的是,当大量线程在阻塞和唤醒之间来回拉扯时,会产生大量的上下文切换(Context Switch)开销。
我们再来看前文提及的三步查询:
User user = queryUser(); // 等待 20ms
Order order = queryOrder(); // 等待 15ms
Coupon coupon = queryCoupon(); // 等待 5ms
其真实的工作状态如下图所示:
CPU工作 █
等待MySQL ███████████████████ (20ms)
CPU工作 █
等待RPC ███████████████ (15ms)
CPU工作 █
等待Redis █████ (5ms)
真正由 CPU 执行计算的时间很少,剩下的几乎全是在等待。总耗时是无情的累加。
如果引入异步化改造,代码的逻辑就变成了:“网卡,帮我把请求 A 的 SQL 发给用户库。我先不等了,你收到回复后通知我!好,现在我抽空去把请求 B 的 Redis 查了……”
当大量的 I/O 等待时间在时序上被重叠(Overlap)后,原本串行的等待变成了同时等待。最终的总耗时不再是累加,而是取决于最慢的那一个。
异步的底层核心通常是一个事件循环(Event Loop)。线程就像一个永不停歇的传送带,谁的 I/O 数据准备好了,谁就注册一个事件丢到队列里,线程抢到事件就执行一小段业务代码,遇到下一次 I/O 就再度挂起。
它的神奇之处在于,编译器在底层把你的顺序代码偷偷拆碎,重构成了一个复杂的“状态机(State Machine)”。
对于 C++ 服务端开发者来说,这件事并不陌生。早期我们更多依赖 epoll、线程池、回调、Future/Promise 来组织异步逻辑;后来有了 C++20 Coroutine,才终于可以用更接近同步代码的方式写异步逻辑。
但本质没有变:异步并不是让 MySQL、Redis、RPC 变快了,而是让线程不再把时间浪费在等待上。
Concurrency:任务太多如何组织
当异步革命成功后,线程终于不再傻等了。单线程的程序凭借事件循环,可以轻松抗住几万个网络长连接。
当服务器里只有一个请求或几十个请求时,世界很美好。但随着互联网用户爆发式增长,服务器在同一秒钟涌入了 10,000 个请求。由于我们用了异步,这 10000 个请求不会各自占着一个线程死等,而是被拆成大量可恢复的任务状态:有些在等待 I/O,有些已经就绪,有些正在被事件循环调度执行。
新的瓶颈出现了:任务太多了,只有一个 CPU 核心,到底该先执行哪一个? 如果让请求 1 一直执行完,那请求 10000 的用户就会遭遇长时间的白屏。
这已经不是 Async(怎么等)能回答的问题了,而是变成了:越来越多的任务,应该如何在有限的资源里组织、调度和分发?
这,就是 Concurrency(并发) 讨论的维度。
在现代语言中,并发已经不仅依赖操作系统线程。对 C++ 服务端来说,并发也不等于盲目创建大量 std::thread。更常见的做法是线程池、任务队列、协程或者 Fiber:把大量业务任务映射到有限数量的工作线程上,让系统既能处理更多任务,又不会被线程数量拖垮。
Go 语言的创作者 Rob Pike 曾说过:
"Concurrency is about structure, parallelism is about execution."(并发关乎程序结构,并行关乎执行方式。)
并发的精髓在于:程序具有同时处理多个任务的设计能力。
哪怕系统只有一个 CPU 核心,操作系统通过将时间切成极其微小的微秒级碎片——时间片(Time Slicing),在任务 A、B、C 之间展开疯狂的轮流上下文切换:
单核CPU微观视角:
[ Task A ] -> [ Task B ] -> [ Task C ] -> [ Task A ] -> [ Task B ]
由于切换速度快到人类根本无法感知,宏观上给人的错觉就是:“所有任务都在同时运行!”但请记住,在任何一个绝对的时钟周期里,单核 CPU 仍然只有一条指令在运行。
并发带来了系统的极速响应,但也带来了昂贵的代价。每一次线程或进程的切换,CPU 都需要保存当前任务的寄存器状态、刷新缓存,这会导致昂贵的上下文切换开销。
如果线程数开得过多,系统就会陷入“线程抖动”。CPU 往往辛辛苦苦切换了半天,刚准备执行业务代码,时间片又到了,又得切换走。最后,系统的算力全被上下文切换给内耗掉了。
Parallelism:榨干多核算力
通过 Async 和 Concurrency,我们成功在单核 CPU 上实现了“等待不阻塞”和“海量任务有序调度”。
然后随着互联网的发展,单核 CPU 的主频在冲到一定高度之后,几乎彻底停止了增长。
二十年前那种“只要买个新 CPU 程序就能自动变快”的免费午餐,彻底结束了。芯片厂商被迫改变路线:既然单核无法变得更快,那就在一块芯片上塞进更多的核心。双核、四核、八核、到今天的几十核甚至上百核。
想必我们听过一句调侃的话:一核有难,三核围观。
如何真正把这多核的算力全部压榨出来?这就是 Parallelism(并行) 的使命。
并行是纯粹的物理执行(Execution)。
并行讨论的不再是代码的组织结构,而是硬件的物理能力。
并行意味着在同一物理时刻,多个任务在不同的 CPU 核心上真正同时并排奔跑:
多核CPU物理视角:
Core 0: [=== 线程1-任务A 物理运行 ===]
Core 1: [=== 线程2-任务B 物理运行 ===]
Core 2: [=== 线程3-任务C 物理运行 ===]
要实现并行,有两个雷打不动的前提条件:
- 硬件层面: 必须拥有多核 CPU 或多台独立的计算机。在单核 CPU 上,无论你怎么优化代码,都永远无法做到并行。
- 任务层面: 多个任务之间必须是相互独立、没有数据依赖的。
并发代码难写,而并行代码则是难写到了极致。
即使在单核并发系统里,只要任务可能在关键位置被切走,共享变量也可能出现竞态。到了多核并行环境,这种问题会被进一步放大,因为多个核心真的可能在同一时刻修改同一块内存,此时数据竞态(Race Condition)就发生了。
为了防范并行带来的不确定性,不得不发明了大量的同步原语(如互斥锁 Mutex、原子操作 Atomic)。然而,锁的引入又会带来全新的噩梦:如果核心太多,大家都抢同一个锁,会导致多核退化成了单核串行(锁竞争);甚至引发线程之间互相死等,导致系统瞬间永久卡死(死锁)。
三者并非对立,而是配合
到此,我们已经分别拆解了这三个概念。你会发现,它们根本不是并列关系,更不是对立关系,它们甚至不在同一个维度上。
在实际生产环境中,一个成熟的后端架构从来不会只选其一,而是将三者融合在一起配合使用。
以一个高并发的现代 Web 网关为例:
- 底层采用 Async 异步网络模型(如 Epoll / Netty),用极少数的事件循环线程,去咬住前端涌入的 500,000 个网络长连接,解决“等待”瓶颈。
- 上层采用轻量级并发结构(如协程、Fiber、任务队列、线程池),将大量连接拆成可调度的业务任务,清晰地组织业务逻辑,解决“任务管理”瓶颈。
- 核心计算层采用 Parallelism 多核并行,调度器将这几十万个协程均匀地分发到物理服务器的 64 个 CPU Core 上,榨干多核性能,解决“算力”瓶颈。
回过头来看,Async、Concurrency 和 Parallelism 从来不是三种互相竞争的技术路线,而是现代软件系统面对三类不同瓶颈时给出的三种答案:
- Async 回答的是:“等待的时候怎么办?”
- Concurrency 回答的是:“任务越来越多怎么办?”
- Parallelism 回答的是:“算力不够怎么办?”
它们解决的是不同层次的问题,因此并不存在谁取代谁。在今天的大多数高性能服务中,这三者往往同时存在、相互配合:Async 减少等待,Concurrency 组织任务,Parallelism 利用多核,共同支撑起现代后端系统的性能。