专栏文章
专栏文章
协程系列
1. 协程系列 #01:从 C10K 到协程:并发模型的演化史 2. 协程系列 #02:协程的实现机制:有栈与无栈、调度模型深度解析 3. 协程系列 #03:各语言的协程权衡:Go、Python、Kotlin、Java VT、C++20 横向对比

协程系列 #01:从 C10K 到协程:并发模型的演化史

发布于 2026-05-25 12:26 👁 20 次阅读
#协程#并发#c10k#异步

本文以问题驱动视角梳理并发模型的演化——每一代模型的出现都是在回应上一代的具体缺陷,协程是这条演化链的必然结果,而非凭空出现的新技术。


目录

章节 说明
并发的本质问题 CPU 等待与 IO 等待的本质
多进程时代 C10K 问题量化分析
多线程时代 新的内存瓶颈
异步回调时代 C10K 被解决,但代码可读性崩塌
Promise/Future 的过渡 改善语法,未解根本
协程:鱼与熊掌兼得 同步代码,异步调度
各模型本质对比 7 维度横向比较
为什么协程在 2010 年代才爆发 场景与工程成熟度

并发的本质问题

CPU 是顺序执行的。所谓"并发",本质上是为了解决等待的浪费

程序运行中的等待分两类:

以一次典型的数据库查询为例:应用发出请求后,CPU 等待网络 IO 的时间可能占总耗时的 99% 以上。如果这段时间内 CPU 什么都不做,吞吐量就被白白浪费掉了。

并发模型要解决的核心矛盾始终是:如何在等待时让 CPU 去做其他事,同时又不破坏代码的可理解性。两个目标,前者是性能需求,后者是工程需求。历史上每一次并发模型的演进,都是在这两个目标之间重新寻找平衡点。


多进程时代

模型

最早的服务器并发模型是 fork/exec。Apache prefork 是典型代表:每来一个连接,主进程 fork 出一个子进程来处理,请求结束子进程退出(或归还进程池)。

优点

代价

进程是操作系统资源分配的基本单位,代价高昂:

C10K 问题

1999 年,工程师 Dan Kegel 在他的论文中提出了著名的 C10K 问题(C = Concurrently,10K = 10,000):

一台服务器如何同时处理 1 万个并发连接?

用多进程模型套一下数字:

结论:多进程模型在 C10K 场景下直接被内存撑死,还没到 CPU 调度的问题。


多线程时代

模型

线程是进程内的执行单元,共享进程的地址空间。Java 早期 Web 服务器(如 Tomcat)采用 one-thread-per-request 模型:线程池预先创建一批线程,每个请求分配一个线程处理。

改进

相比进程,线程的开销确实小了不少:

新的瓶颈

多线程并没有真正解决 C10K:

根本矛盾

多线程的根本问题在于:线程既是执行单元,也是 OS 调度单位,粒度太粗。你为了处理 IO 等待而创建线程,但线程本身的开销已经大到无法承受 1 万个并发。


异步回调时代

模型

既然线程太贵,换个思路:不阻塞,用事件通知

核心机制是 OS 提供的 IO 多路复用接口(select/poll/epoll/kqueue):

  1. 把所有 IO 描述符注册到一个事件循环
  2. 单线程阻塞在 epoll_wait
  3. 某个描述符就绪时,调用对应的回调函数处理数据

这就是 reactor 模式,nginx、Node.js(libuv)都基于此。

C10K 被真正解决

从资源消耗的角度,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),其核心问题不只是"丑":

  1. 执行流程非线性:代码的视觉顺序和实际执行顺序是颠倒的,内层回调实际上是后执行的
  2. 错误处理分散:每一层都要单独处理 err,容易遗漏
  3. 调试困难:调用栈是碎片化的,异常的 stack trace 往往看不出业务逻辑
  4. 难以复用:异步函数很难组合,无法使用 try/catchfor 循环等普通控制流

本质问题

根本原因是:异步代码破坏了代码的顺序语义。人脑是线性思维的,天然适合阅读"先做 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 解决了"写起来丑"的问题,但没有解决"思维模型的割裂"。


协程:鱼与熊掌兼得

核心洞见

协程的核心洞见只有一句话:

让程序员写同步代码,让运行时做异步调度。

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/catchfor 循环、普通函数调用全部可以正常使用。

协程的机制

协程本质上是用户态的轻量级线程

协程的代价

协程不是银弹,它需要:


各模型本质对比

维度 多进程 多线程 异步回调 协程
调度者 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 利用率极高。

工程原因:语言和运行时的成熟

协程需要语言和运行时的深度支持:

结论

协程的爆发是场景驱动的:网络 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)

暂无评论,来留下第一条吧。

发表评论