Heath Kang

进程、线程、协程、Fiber、Goroutine 与 Actor

“协程比线程轻量”只说对了一半。选择并发模型时,真正要回答的是:状态在哪里、谁负责调度、阻塞会占住什么、失败与取消如何传播。不同模型不是线性进化关系,而是在不同约束下的取舍。

模型主要隔离边界调度者典型通信方式
process地址空间OSIPC / socket
thread共享地址空间OS共享内存 + 同步原语
JS callback / event loop运行中的异步操作Node.js event loopcallback
Scala Future / Promise未来完成的结果ExecutionContextcompletion
Python coroutinecoroutine frameasyncio event loopawait
Scala fibereffect 的运行状态Cats Effect runtimeIO API / queue
Go goroutine用户态调用栈Go runtimechannel / shared memory
Elixir process / Actorprocess 私有状态BEAM VMmailbox message
Rust Tokio taskFuture state machineTokio executorawait / channel

一条不那么直的演进线:回调、Future 到协程

早期服务器常用“一个连接一个线程”来等待 I/O,思路直接,但连接数上来后,线程的栈内存、调度和同步成本会很快显现。事件循环提出了另一条路:不在 I/O 上等待,而是在事件完成后被通知。它先带来了 callback,随后有了 Future/Promise,再到把回调写回顺序代码形状的 async/await

这不是新模型替代旧模型的历史必然。今天的异步运行时仍需要 OS thread,CPU 密集工作仍需要线程或进程并行;变化的是,程序员可以把“等待时该做什么、任务何时取消”交给更合适的抽象。

Process:用内存隔离换取通信成本

进程拥有独立地址空间,崩溃和内存破坏不会直接污染其他进程,因此很适合隔离不可信或独立部署的组件。代价是 IPC、序列化、上下文切换和共享数据的复杂度。把服务拆成进程,并不自动获得高可靠性;仍要设计超时、重试、协议版本和观测。

Thread:共享内存很快,但共享状态很贵

线程共享堆和全局变量,传递大对象不需要拷贝;同时,任何可变对象都可能被并发访问。mutex、condition variable、semaphore 能解决同步,却把死锁、竞态和优先级反转带进程序。线程池可以控制并发度,但不能替你决定哪些状态应该共享。

一个稳健的原则是:在线程之间尽量传递不可变消息,把可变状态收敛在少量明确拥有者处。

不同 CPU 操作的成本差异;具体数值会因硬件而变,但线程切换与缓存失效通常远贵于普通计算

图中的周期数不应被当成今天机器的基准测试结果;它表达的只是数量级:一次线程上下文切换不只是保存几个寄存器,还可能带来调度、缓存失效和内核路径。大量 I/O 等待任务若各自占着 OS thread,就会把这些成本放大。这是事件循环与用户态调度想解决的问题,不是说 thread 从此“不好用”。

JavaScript callback 与 event loop:先把等待交还给事件循环

callback 的想法很朴素:发起异步操作后立即返回,完成时再调用调用方提供的函数。它避免了“等待一个 socket 就占一个线程”,也让单线程 event loop 能管理大量连接。

以 Node.js 为例,event loop 负责观察 socket、timer 等事件源;I/O 就绪后,它从队列中取出相应 callback,在 JavaScript 线程上快速执行。Node 也有 libuv worker pool,处理一部分文件系统、DNS、加密等不适合直接走非阻塞事件通知的工作。换句话说,event loop 不是“一台永不阻塞的 CPU”,而是一个必须尽快把控制权还出来的调度中心。

readFile(path, { result =>
  result match {
    case Right(data) => parse(data, onParsed)
    case Left(error) => onError(error)
  }
})

问题也很快出现:第二步依赖第一步结果时,嵌套会增长;错误、超时、取消和资源释放需要沿着多层回调手工传递。所谓 callback hell 不只是缩进难看,更是控制流被拆散后,谁拥有任务生命周期变得不清楚。

这也给出所有 event loop 实现最重要的一条纪律:在 loop 上运行的 callback 必须短小,I/O 必须是非阻塞的,CPU 密集或阻塞调用必须移出 loop。 否则一个请求的长循环、同步文件读取或慢锁,就会占住为其他所有请求服务的线程。

有栈与无栈:callback 之后,怎样保存暂停现场

callback 把“下一步做什么”拆成函数交给 event loop;协程则希望保留当前代码的执行现场,稍后从暂停处继续。有栈/无栈是协程与用户态任务的一条实现维度,不是所有并发模型都必须归属的分类。 Future/Promise、Actor 仍可在没有这种协程栈的基础上实现。

有栈协程拥有自己的用户态调用栈。运行时切换它时,保存/恢复的是整段调用链;因此它可以在很深的函数调用中 yield,调用者不必每层都改成异步函数。goroutine、greenlet 等常被归到这一类,优势是控制流自然;代价是 runtime 要管理许多栈、栈增长和上下文切换。

普通函数调用中,每一层 stack frame 保存参数、返回地址和局部变量

无栈协程通常不保留一条可独立切换的用户态调用栈。它只能在语言或编译器标记的 suspension point(例如 await)暂停;编译器/运行时将局部变量、下一次恢复位置和异常状态保存到 coroutine frame 中。常见实现会把 async 函数 lowering 为状态机:每个 await 对应一个恢复状态,完成事件到来后再跳回那个状态继续。

所以“无栈协程的本质是状态机”作为理解 async/await 很有用,但不应当作所有实现的严格定义。无栈描述的是它不保存独立调用栈;状态机是最常见的实现策略。手写状态机不一定就是协程,而不同语言仍可能在 frame、调度和异常处理上采用不同细节。真正的取舍是:无栈协程更容易由编译器生成、内存更可控,但每一层可能都要成为 async;有栈协程能从深层函数让出,却需要更复杂的栈管理与调度。

从实现看:切换栈,还是保存一个状态编号

有栈协程的运行时通常要为每个协程分配一段可增长的栈,并在切换时保存/恢复 stack pointer、程序计数器和必要的寄存器。恢复时像是把 CPU 放回那条调用链的中间继续执行。这不是操作系统必须提供的能力,但一般需要比普通语言库更低层的支持:语言 runtime、原生扩展或少量平台相关代码负责栈布局和上下文切换。Go runtime 与 Python 的 greenlet 都属于这类实现;这也是为什么它能在深层普通函数里 yield,却不能只靠几行纯业务代码“模拟”出来。

无栈协程则把暂停点限制在编译器看得见的位置。编译器可将:

await A
use A 的结果
await B

大致变换为:把局部变量与 state 放进一个 frame;首次执行 A,未就绪时记录 state = 1 并返回;下一次被唤醒时从 state = 1 继续,取得 A 的结果后再处理 B。没有独立 stack pointer 需要切换,因此它通常只需要语言/编译器提供 async 语法与 frame 生成,再配合 executor/event loop 来调度唤醒。Rust Future、C# async、Python 原生协程都能用这个模型理解。

因此“无栈协程只需要语言机制”可以作为近似说法,但还少了一半:它不需要独立栈切换机制,却仍需要 runtime 来注册 I/O 就绪事件、排队 task、恢复状态机;语言特性负责保存什么,运行时负责何时恢复。而无栈模型的 async 传染性,正来自编译器无法跨越一个完全普通的调用栈帧去恢复更深处的暂停点。

Scala Future / Promise:把“以后完成”变成一个值

Scala 的 Future 把未来的成功或失败结果表示为值,消费者可以对它 mapflatMaprecover;Promise 则是生产者完成这个结果的可写端。这样可以把回调藏到 Future 实现内部,让调用方重新用组合表达依赖:

val team: Future[Team] =
  findUser(id)
    .flatMap(findTeam)
    .recover { case _ => defaultTeam }

这比裸 callback 更易组合,却还留下几个仍需回答的问题:Future 往往在创建时就开始执行,谁负责取消它?多个 Future 并发后,一个失败是否该终止其他任务?连接和文件怎样在取消时关闭?此外 ExecutionContext 并不会把同步 JDBC 或慢 CPU 计算变成异步:若把阻塞任务丢进所有 Future 共用的线程池,只是把 event loop 的问题换成了线程池饥饿。这些问题也引出协程与结构化并发,而不是让 Future 失去价值。

ExecutionContext 一般是什么?通常是线程池,但它是抽象

ExecutionContext 是 Scala Future 用来提交计算和执行 completion callback 的执行策略接口。最常见的 ExecutionContext.global 在 JVM 上通常由 work-stealing/ForkJoinPool 一类线程池承载;应用也可以从自己的 Executor 创建 ExecutionContext。因此可以说 Future 的实际执行通常落在某个线程池上,但不要把 ExecutionContext 等同于某一种固定线程池实现。

given ExecutionContext = ExecutionContext.global

val user: Future[User] = Future {
  // 这段函数体被提交给 ExecutionContext 执行
  userService.load(id)
}

user.map(toProfile) // completion 后也通过 ExecutionContext 运行 callback

Promise 则把生产者和消费者分开:网络库、回调适配器或测试代码持有 Promise[User],在结果到达时调用 success/failure/tryComplete;其他代码只拿它的只读 Future[User] 去组合。它非常适合把 callback API 接到 Future 风格的业务代码上。

val p = Promise[User]()
legacyClient.fetch(id) { result => p.tryComplete(result) }
val user: Future[User] = p.future

实践中最重要的是线程池隔离,而不是“到处包一层 Future”。短小、不阻塞的 callback 可以用通用 ExecutionContext;阻塞 JDBC、旧 SDK、文件访问或大计算应使用容量受控的专用 ExecutionContext,并监控队列、活跃线程和等待时间。Future 没有内建的结构化取消和资源作用域,这也是 Cats Effect IO/fiber 在服务端程序中常被选用的原因。

Python coroutine:从 yield 到 gevent,再到 async/await

Python 的协程概念不是从 async def 才开始。早期 generator 用 yield 暂停并恢复函数;PEP 342 让 generator 能以 send() 接收值、以 throw() 注入异常,因此可以作为简单的协作式 coroutine。它的限制也很鲜明:要在深层调用中让出,调用链上的函数往往都得写成 generator 并手工转发;yield from 后来缓解了这种委托样板。

def ticker():
    while True:
        event = yield          # scheduler 通过 send(event) 恢复它
        handle(event)

另一条很有代表性的 Python 路线是 gevent:它以 greenlet 提供有栈的协作式 greenlet,并在 libev/libuv event loop 上提供看起来近似同步的网络 API。greenlet 在 I/O 等待时切回 hub,其他 greenlet 得以运行;好处是旧的顺序代码不必层层改成 async。代价是它依然依赖协作式让出,调用了未被 gevent 接管的阻塞库就会卡住整个解释器;monkey patch 也会让依赖边界更隐蔽。

async def/await 则把异步边界变成语言语法。原生协程在 await 处主动让出执行权,asyncio event loop 可以运行其他 task,因此单线程也能维持很高的连接数。它对应的是更显式的无栈风格:异步调用链必须通过 await 传递,但暂停点、取消与任务边界也更容易看见。

理解 Python 协程的关键,是分清并发(等待时切换任务)和并行(多核同时执行)。async def 本身不会把任意库调用变成异步:要使用支持 asyncio 的网络/数据库客户端;不得不调用阻塞库时,应显式交给线程或进程执行器。否则一个 time.sleep、同步 HTTP 或重计算,就会冻结整个 event loop。

Rust Tokio:Future 是惰性的状态机,runtime 负责 poll

Rust 的 async fn 返回的是 Future;仅仅调用它不会执行 I/O。Tokio runtime 反复 poll 可运行的 Future,Future 遇到尚未就绪的 I/O 时返回 Pending 并注册唤醒通知;就绪后再被 runtime poll,继续从上一次 await 的状态执行。这是无栈、状态机模型很直接的一种实现。

async fn load_user(id: UserId) -> Result<User, Error> {
    let row = repo.find(id).await?;
    Ok(User::from(row))
}

tokio::spawn 才会把 Future 变成由 runtime 独立调度的 task;而 join! 可以在同一个 task 内复用等待多个 Future。Rust 的所有权和 Send 约束让“任务会在哪个线程运行、它持有什么引用”更早暴露在编译期,但并不消除运行时纪律:如果 task 在两次 .await 之间做很久的 CPU 计算,或调用阻塞 API,它仍会占住 executor worker。Tokio 的做法是使用异步网络/定时器 API,并将不可避免的阻塞或 CPU 工作送到 spawn_blocking 或专用线程池。

Go goroutine:M:N 调度与 channel 纪律

goroutine 由 Go runtime 调度到较少的 OS thread 上,栈可增长,因此创建成本低。channel 把同步与数据传递合在一起,鼓励“不要通过共享内存通信,而要通过通信共享内存”。这是一种有效风格,不是强制规则:共享状态仍存在,仍可能数据竞争;channel 也可能阻塞、泄漏或形成死锁。

为什么 Go 选择有栈 goroutine

Go 的目标之一就是让网络服务能用接近普通阻塞代码的方式组织并发,而不是强迫每一层都改写为 callback 或 async 状态机。为此,语言和 runtime 一起承担了有栈协程的复杂度:goroutine 从很小的栈开始,按需增长;runtime 负责把它们多路复用到 OS thread,并在网络 I/O 等待时运行其他 goroutine。早期 Go 的设计材料也直接指出,这需要编译器与 runtime 支持,不能只是包在 OS thread 之上的普通库。

这让下面这种代码成立:handle(conn) 里可以继续调用普通函数、继续阻塞式地读写 socket;当它等待 I/O 时,运行时保存这条 goroutine 的调用栈,调度别的 goroutine。对业务代码而言,异步状态机被藏进 runtime,而不是扩散到每一层函数签名:

for {
    conn, err := listener.Accept()
    if err != nil { continue }
    go handle(conn) // 每个连接是一段顺序、可阻塞式书写的流程
}

为什么这很契合 Web 与 I/O 密集服务

更合理的解释是,Go 在 Web 与基础设施项目中的流行来自一组设计特征的合力,而非“因为 goroutine 所以流行”的单一因果:

  • 大量请求多数时间在等网络、磁盘、下游服务;goroutine 在等待时不应长期占住一个 OS thread,因而适合高并发 I/O。
  • go、channel、select 让“一个请求、一条连接、一个后台工作单元”仍可写成顺序过程,比手写 callback 更容易读、测和排错。
  • 标准库的 net/http、网络包、context 以及静态编译/单二进制部署,让这套并发模型能较直接地落到服务端工程中。
  • CSP 风格把通信与同步放在 channel 上,避免一开始就把所有状态塞进共享内存和锁;它不禁止 mutex,但给了服务端程序一个更明确的默认组织方式。

这正是“有栈 runtime 换编程简单”的收益:把调度、栈增长和很多 I/O 等待细节集中在语言实现里,让应用层先表达独立的工作单元。Go 官方也以 Web server 作为典型场景,强调 goroutine 是轻量、可增长栈、由多个 OS thread 复用的执行单元。

但这不是无限制地 go 一下就结束了。每个 goroutine 仍有栈、对象和调度成本;输入无限快时,按请求无限创建 goroutine 一样会耗尽内存。channel 需要有界缓冲、worker pool 或 semaphore 来形成背压;请求链路还应通过 context.Context 传递超时与取消。CPU 密集工作、长时间 syscall、阻塞 C 库同样需要单独观察和隔离。有栈模型让代码更像同步代码,却没有替应用决定资源上限与生命周期。

Go 程序将 goroutine 创建、channel 和内存分配交给 runtime,再由 runtime 使用 OS kernel 提供的线程与系统调用

Go 常用 G-M-P 描述其调度:G 是 goroutine,M 是 OS thread,P 是运行 Go 代码所需的逻辑处理器资源。runtime 把大量 G 调度到有限数量的 M 上,并让 P 管理可运行队列;这就是常说的 M:N 调度。它既避免“一任务一 OS thread”的固定成本,也能用多个 M 利用多核。

Go GMP 模型中,P 维护 goroutine 队列,M 执行它们

阻塞 syscall 是 M:N runtime 的现实考验。一个 goroutine 进入可能长时间阻塞的系统调用时,runtime 会尽量把 P 交给其他 M,避免同一 P 上的其他 goroutine 全部停住;图中展示了 M0 因 syscall 被占用后,另一个 M 接手继续运行可执行 G 的情况。具体调度细节会随 Go 版本变化,但诊断“goroutine 很多却不工作”时,仍要区分 CPU 忙、channel 阻塞、网络等待和 syscall 阻塞。

Go runtime 在阻塞 syscall 附近将 P 与 M 重新配对,尽量维持其他 goroutine 的运行

Scala fiber:把取消和资源释放纳入模型

Cats Effect 的 fiber 是运行时调度的轻量计算单元。它和 Future 的最大差异不只在于轻量,更在于 effect runtime 能将取消、错误传播与资源释放纳入组合语义。一个 fiber 被取消时,Resource 管理的资源仍应被释放;多个 fiber 并发时,需要明确是失败快速终止其他任务,还是收集所有结果。

这类“结构化并发”思路把任务生命周期从约定提升为程序结构。

早期 fiber 方案可将每个 fiber 的解释循环提交到 JVM thread pool

上图表达了一种直观但有局限的思路:每个 fiber 在遇到异步边界时把 continuation 提交给线程池。它容易理解,却较难兼顾公平抢占和高效 M:N 调度;如果 fiber 长时间不让出,仍可能霸占工作线程。

在异步边界重新提交 continuation 的 fiber 调度示意

现代 effect runtime 会在内部维护 fiber 的运行状态、取消标记和调度队列,而不是把“一个 fiber 等于一个 thread”。下图强调这种运行时解释器加调度器的形态:它更容易实现协作式让出与公平性,但要在性能、可观测性和 M:N 调度间持续权衡。

由 effect runtime 统一调度 fiber 的示意;公平性与 M:N 调度是运行时设计取舍

结构化并发还要求任务有明确归属。launch/start 一类 API 若只是把任务丢到后台,就形成 fire-and-forget:调用者很难知道它是否成功、何时结束或该由谁取消。更稳妥的做法是把 child fiber 放在父任务的作用域里,在父任务结束时等待或取消它,并让资源释放跟随作用域。

fire-and-forget 会让协程脱离调用者生命周期,需要额外处理失败、取消和资源释放

Elixir process / Actor:让状态只属于一个邮箱

Actor 将状态封装在 actor 内部,外部只能投递消息;同一 actor 通常顺序处理邮箱,因此避免了其内部状态的显式锁竞争。代价是消息延迟、邮箱堆积、监管策略和跨 actor 一致性。Actor 并不会消灭共享问题,它只是把共享改成消息协议问题。

Elixir/BEAM 把这一模型做成语言运行时的基本单位:Elixir process 不是 OS process,而是极轻量、彼此隔离、通过消息通信的 BEAM process。实际项目通常不手写 receive 循环,而使用 GenServer 管理状态、Task 表示短任务、Supervisor 管理故障域。这让“状态归谁、谁负责重启它”从库层约定变成应用结构的一部分。

Actor 由唯一地址、邮箱和私有行为/状态组成

调用者只需知道 Actor 的地址并投递消息,不直接读取或修改其状态。每个 Actor 从自己的 mailbox 取消息、更新内部状态,再选择回复或向其他 Actor 发消息;因此锁竞争被转化为队列长度、消息顺序、背压与协议演进问题。

Actor 之间通过消息连接;每个 Actor 各自串行消费 mailbox 并维护状态

Actor 模型通常还将 Actor 组织成监督树:父 Actor 创建子 Actor,并在子 Actor 失败时决定停止、重启、升级或转交。图中的 /user、parent、child 是这种层级的示意。它不是普通的函数调用树,而是把故障处理和生命周期也作为运行时结构的一部分。

Actor 的监督层级:父 Actor 负责子 Actor 的创建与故障处置策略

一个共同底线:不要阻塞调度者

无论它叫 event loop、ExecutionContext、Tokio executor、Go runtime 还是 Cats Effect runtime,轻量任务能跑得多的前提都一样:运行时线程不能被一个任务长期占住。更准确的说法不是“所有 I/O 都必须天生异步”,而是:

  • 网络 I/O 优先使用与运行时集成的非阻塞驱动;等待时任务应能让出。
  • 文件 I/O、旧 JDBC 驱动或第三方同步 SDK 若无法真正异步,应放入允许阻塞的专用线程池,而不是 worker/event-loop 线程。
  • CPU 密集计算需要分片主动让出,或转到计算线程池/进程;仅把函数标成 async 没有用。
  • 锁不要跨 await 长时间持有;否则一个等待中的任务会把其他任务也堵住。

这也是为什么“用了协程却仍然卡住”很常见:语法只描述了可暂停的位置,真正决定吞吐的是底层 I/O 驱动、线程池隔离、背压与任务是否及时让出。

选择时先问六个问题

  1. 是否需要强隔离和独立故障域?优先 process。
  2. 是否有大量 I/O 等待?coroutine/fiber/goroutine 往往合适。
  3. 后续步骤是否依赖前一步异步结果?Future、async/await 或 fiber 的组合模型比手写 callback 更清晰。
  4. 是否需要从任意调用深度暂停?有栈协程更自然;能接受 await 传染时,无栈协程通常更直接。
  5. 状态是否天然属于单个实体?Actor 值得考虑。
  6. CPU 密集任务是否会阻塞调度器?需要线程池或多进程并行。

References

Notion 相关零散笔记

  • 协程
  • Future/Promise/CSP/Actor/coroutine
  • Actor
  • Akka
  • Node.JS
  • Python
  • golang
  • Rust
  • Reactive program
  • Cats effect
  • ZIO
  • IO
  • Java

相关文档