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

从 C10K 到协程:并发模型的演化史

发布于 2026-05-25 12:26 · 最后编辑于 2026-07-31 15:53 · 字数 4,058 👁 109 次阅读

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

目录

章节说明
并发的本质问题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):

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

这就是 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),其核心问题不只是"丑":

  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,调用它的所有函数都要用 .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/catchfor 循环、普通函数调用全部可以正常使用。

协程的机制

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

  • 调度由运行时(而非 OS)控制:当协程遇到 IO 等待时,运行时调度器挂起当前协程,切换到另一个可运行的协程
  • 切换成本极低:协程切换只需要保存/恢复少量寄存器,不涉及内核态切换,耗时 ~100ns(比线程切换快 10-100 倍)
  • 内存占用小:Go goroutine 初始栈只有 2KB(可动态增长),1 万个 goroutine 只需要 ~20MB

协程的代价

协程不是银弹,它需要:

  • 运行时支持:调度器、IO 多路复用集成、栈管理
  • async 传染性(Python/JS):调用 async 函数必须在 async 上下文中(Go 的 goroutine 模型规避了这个问题)
  • CPU 密集任务仍需多线程:协程切换依赖主动让出,纯 CPU 计算不会触发调度,需要配合多线程(如 Go 的 GOMAXPROCS)

各模型本质对比

维度多进程多线程异步回调协程
调度者OSOS用户态(事件循环)用户态(调度器)
切换开销高(~10μs,切页表)中(~1μs)无切换极低(~100ns)
内存开销高(~几 MB/个)中(~8MB 栈/个)极低(~几百字节/连接)低(~2-8KB/个)
代码可读性好(线性)好(线性,但锁复杂)差(回调地狱)好(线性)
IO 密集
CPU 密集好(真并行)好(真并行)差(单线程)中(需配合多线程)
隔离性弱(共享内存)无进程级隔离弱(同进程)
代表实现Apache preforkJava Tomcat(早期)Node.js、nginxGo、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)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论