本文以问题驱动视角梳理并发模型的演化——每一代模型的出现都是在回应上一代的具体缺陷,协程是这条演化链的必然结果,而非凭空出现的新技术。
目录
| 章节 | 说明 |
|---|---|
| 并发的本质问题 | CPU 等待与 IO 等待的本质 |
| 多进程时代 | C10K 问题量化分析 |
| 多线程时代 | 新的内存瓶颈 |
| 异步回调时代 | C10K 被解决,但代码可读性崩塌 |
| Promise/Future 的过渡 | 改善语法,未解根本 |
| 协程:鱼与熊掌兼得 | 同步代码,异步调度 |
| 各模型本质对比 | 7 维度横向比较 |
| 为什么协程在 2010 年代才爆发 | 场景与工程成熟度 |
并发的本质问题
CPU 是顺序执行的。所谓"并发",本质上是为了解决等待的浪费。
程序运行中的等待分两类:
- IO 等待:网络请求、磁盘读写,耗时毫秒(ms)级,期间 CPU 完全空闲
- CPU 等待:锁竞争、同步屏障,耗时微秒(μs)级,CPU 在自旋或睡眠
以一次典型的数据库查询为例:应用发出请求后,CPU 等待网络 IO 的时间可能占总耗时的 99% 以上。如果这段时间内 CPU 什么都不做,吞吐量就被白白浪费掉了。
并发模型要解决的核心矛盾始终是:如何在等待时让 CPU 去做其他事,同时又不破坏代码的可理解性。两个目标,前者是性能需求,后者是工程需求。历史上每一次并发模型的演进,都是在这两个目标之间重新寻找平衡点。
多进程时代
模型
最早的服务器并发模型是 fork/exec。Apache prefork 是典型代表:每来一个连接,主进程 fork 出一个子进程来处理,请求结束子进程退出(或归还进程池)。
优点
- 隔离性强:进程间地址空间独立,一个子进程崩溃不影响其他连接
- 模型简单:每个进程处理一个请求,代码是线性的,易于理解和调试
代价
进程是操作系统资源分配的基本单位,代价高昂:
- 上下文切换:OS 切换进程需要保存/恢复寄存器、刷新 TLB、切换页表,耗时 ~1-10μs
- 内存占用:每个进程有独立的虚拟内存空间,即使是空进程也要消耗几 MB 内存
C10K 问题
1999 年,工程师 Dan Kegel 在他的论文中提出了著名的 C10K 问题(C = Concurrently,10K = 10,000):
一台服务器如何同时处理 1 万个并发连接?
用多进程模型套一下数字:
- 1 万个并发连接 → 1 万个进程
- 每进程内存 4MB → 1 万进程需要 40GB 内存
- 1999 年的服务器内存通常只有 1-4GB
结论:多进程模型在 C10K 场景下直接被内存撑死,还没到 CPU 调度的问题。
多线程时代
模型
线程是进程内的执行单元,共享进程的地址空间。Java 早期 Web 服务器(如 Tomcat)采用 one-thread-per-request 模型:线程池预先创建一批线程,每个请求分配一个线程处理。
改进
相比进程,线程的开销确实小了不少:
- 线程切换不需要切换页表,上下文切换成本更低(~1μs 量级)
- 线程间共享内存,通信不需要 IPC
新的瓶颈
多线程并没有真正解决 C10K:
- 内存:Linux 默认线程栈大小 8MB,1 万线程 = 80GB 内存,反而比多进程更糟
- 调度上限:Linux 内核调度的线程数实际上限在数万级,超过后调度开销急剧上升
- 锁与竞态:共享内存带来了数据竞争,需要锁来同步,锁引入了新的等待,并且极易出错
根本矛盾
多线程的根本问题在于:线程既是执行单元,也是 OS 调度单位,粒度太粗。你为了处理 IO 等待而创建线程,但线程本身的开销已经大到无法承受 1 万个并发。
异步回调时代
模型
既然线程太贵,换个思路:不阻塞,用事件通知。
核心机制是 OS 提供的 IO 多路复用接口(select/poll/epoll/kqueue):
- 把所有 IO 描述符注册到一个事件循环
- 单线程阻塞在
epoll_wait - 某个描述符就绪时,调用对应的回调函数处理数据
这就是 reactor 模式,nginx、Node.js(libuv)都基于此。
C10K 被真正解决
- 1 万个并发连接 → 1 个线程 + 1 万个文件描述符(fd)
- Linux
epoll的时间复杂度是 O(1),1 万个 fd 和 1 个 fd 的开销几乎一样 - 内存:每个连接的状态只需要几百字节,1 万连接只需要 ~几 MB
从资源消耗的角度,C10K 问题在 2000 年代中期被彻底解决了。
代价:回调地狱
但性能的代价是代码可读性的崩塌。看一个典型的异步代码示例:
// 读取用户信息 → 查询订单 → 获取商品详情 → 返回结果
// 三层嵌套,这还只是"快乐路径",没有加错误处理
db.getUser(userId, function(err, user) {
if (err) { return callback(err); }
db.getOrders(user.id, function(err, orders) {
if (err) { return callback(err); }
db.getProduct(orders[0].productId, function(err, product) {
if (err) { return callback(err); }
// 终于拿到数据,但我们已经缩进了三层
callback(null, {
user: user,
order: orders[0],
product: product
});
});
});
});
这种代码被称为回调地狱(Callback Hell),其核心问题不只是"丑":
- 执行流程非线性:代码的视觉顺序和实际执行顺序是颠倒的,内层回调实际上是后执行的
- 错误处理分散:每一层都要单独处理
err,容易遗漏 - 调试困难:调用栈是碎片化的,异常的 stack trace 往往看不出业务逻辑
- 难以复用:异步函数很难组合,无法使用
try/catch、for循环等普通控制流
本质问题
根本原因是:异步代码破坏了代码的顺序语义。人脑是线性思维的,天然适合阅读"先做 A,再做 B,最后做 C"这样的代码。一旦逻辑被拆散成回调,大脑就需要额外的认知负担来重建执行顺序。
Promise/Future 的过渡
改进
Promise(JavaScript)/ Future(Java、Scala)/ CompletableFuture 是对回调的一层包装,把异步操作表示为一个"承诺将来会有结果"的对象,支持链式调用:
// 用 Promise 改写上面的回调地狱
db.getUser(userId)
.then(user => db.getOrders(user.id))
.then(orders => db.getProduct(orders[0].productId))
.then(product => ({ user, order: orders[0], product }))
.catch(err => console.error(err));
可读性比嵌套回调好了很多,错误处理也集中到了最后的 .catch()。
仍然存在的问题
Promise 是异步代码的语法糖,本质没有变:
- 传染性:一旦某个函数返回 Promise,调用它的所有函数都要用
.then()处理,改造成本高 - 不能用普通控制流:在
.then()链中不能直接用for循环、try/catch - 调试仍然困难:Promise 链的 stack trace 依然不直观
Promise 解决了"写起来丑"的问题,但没有解决"思维模型的割裂"。
协程:鱼与熊掌兼得
核心洞见
协程的核心洞见只有一句话:
让程序员写同步代码,让运行时做异步调度。
async/await 语法把这个洞见变成了现实。看同样的逻辑用协程怎么写:
# Python async/await —— 看起来和同步代码几乎一样
async def get_order_detail(user_id):
user = await db.get_user(user_id) # 等待时挂起,让出 CPU
orders = await db.get_orders(user.id) # 等待时挂起,让出 CPU
product = await db.get_product(orders[0].product_id)
return {"user": user, "order": orders[0], "product": product}
// Go goroutine —— 直接写同步代码,调度器处理并发
func getOrderDetail(userID int) OrderDetail {
user := db.GetUser(userID) // 底层是异步 IO,但代码是同步的
orders := db.GetOrders(user.ID)
product := db.GetProduct(orders[0].ProductID)
return OrderDetail{User: user, Order: orders[0], Product: product}
}
// 启动协程
go getOrderDetail(123)
代码恢复了线性结构,try/catch、for 循环、普通函数调用全部可以正常使用。
协程的机制
协程本质上是用户态的轻量级线程:
- 调度由运行时(而非 OS)控制:当协程遇到 IO 等待时,运行时调度器挂起当前协程,切换到另一个可运行的协程
- 切换成本极低:协程切换只需要保存/恢复少量寄存器,不涉及内核态切换,耗时 ~100ns(比线程切换快 10-100 倍)
- 内存占用小:Go goroutine 初始栈只有 2KB(可动态增长),1 万个 goroutine 只需要 ~20MB
协程的代价
协程不是银弹,它需要:
- 运行时支持:调度器、IO 多路复用集成、栈管理
- async 传染性(Python/JS):调用
async函数必须在async上下文中(Go 的 goroutine 模型规避了这个问题) - CPU 密集任务仍需多线程:协程切换依赖主动让出,纯 CPU 计算不会触发调度,需要配合多线程(如 Go 的 GOMAXPROCS)
各模型本质对比
| 维度 | 多进程 | 多线程 | 异步回调 | 协程 |
|---|---|---|---|---|
| 调度者 | OS | OS | 用户态(事件循环) | 用户态(调度器) |
| 切换开销 | 高(~10μs,切页表) | 中(~1μs) | 无切换 | 极低(~100ns) |
| 内存开销 | 高(~几 MB/个) | 中(~8MB 栈/个) | 极低(~几百字节/连接) | 低(~2-8KB/个) |
| 代码可读性 | 好(线性) | 好(线性,但锁复杂) | 差(回调地狱) | 好(线性) |
| IO 密集 | 差 | 中 | 好 | 好 |
| CPU 密集 | 好(真并行) | 好(真并行) | 差(单线程) | 中(需配合多线程) |
| 隔离性 | 强 | 弱(共享内存) | 无进程级隔离 | 弱(同进程) |
| 代表实现 | Apache prefork | Java Tomcat(早期) | Node.js、nginx | Go、Python asyncio、Kotlin |
为什么协程在 2010 年代才爆发
协程并不是新技术。1963 年,Melvin Conway(对,就是 Conway's Law 那个 Conway)就在论文中提出了协程的概念,比 Unix 的诞生还早 6 年。
那为什么协程沉寂了几十年,到 Go(2009)才真正进入主流工程界?
历史原因:场景不匹配
1970s-1990s:主流计算任务以 CPU 密集为主——科学计算、编译器、图形渲染。这类任务 CPU 几乎不等待,协程的"等待时让出 CPU"优势根本发挥不出来。多进程/多线程的真并行反而更合适。
2000s:互联网爆发,Web 服务成为主流。Web 服务的特征是大量并发、短连接、IO 密集(数据库查询、RPC 调用)。这正是协程的主场——大量连接在等待 IO,协程可以在等待期间处理其他连接,CPU 利用率极高。
工程原因:语言和运行时的成熟
协程需要语言和运行时的深度支持:
- Go 把 goroutine 作为一等公民内置到语言和运行时,M:N 调度模型,工程师不需要关心协程的调度细节
- Python 3.4 引入
asyncio,3.5 引入async/await语法 - JavaScript ES2017 标准化了
async/await - Kotlin 的
coroutines库让 JVM 生态也有了轻量级协程
结论
协程的爆发是场景驱动的:网络 IO 密集的 Web 服务规模化,使得协程的优势(低开销 + 高可读性)变得不可忽视;同时语言生态的成熟降低了使用门槛。技术不是凭空普及的,它需要等待与之匹配的问题规模。
参考资料
参考资料
- Dan Kegel — The C10K Problem
- Melvin Conway, Design of a Separable Transition-Diagram Compiler, CACM, 1963
- Rob Pike, Concurrency is not Parallelism (2012)
- The Go Programming Language — Alan A. A. Donovan & Brian W. Kernighan
- Designing Data-Intensive Applications — Martin Kleppmann
- Node.js — The Event Loop
- 协程的实现机制(协程在 CPU 层面如何实现暂停与恢复)
- 各语言的协程权衡(各语言在有栈/无栈框架下的具体实现与取舍)
- 网络与 IO 模型(epoll/reactor 模式是异步回调时代的底层机制)
- 进程与线程(多进程/多线程时代的原理,本文讨论的起点)
- 并发模型总览(协程所处的并发模型大图,CSP/Actor/锁的横向比较)
评论 (0)
发表评论