1958年,一位名叫Melvin Conway的程序员在编写汇编程序时,遇到了一个困扰:他想让程序在执行到某处时"暂停",稍后从暂停点继续执行。这个看似简单的需求,催生了一个被命名为"协程"(coroutine)的概念。
六十七年后的今天,异步编程已成为现代软件开发的基石。当你点击网页按钮时,JavaScript在等待服务器响应;当你滑动手机屏幕时,Kotlin协程在后台加载图片;当你运行微服务时,Rust的Future在高效处理成千上万的并发连接。然而,这场漫长演进的终点——结构化并发——直到最近五年才逐渐成型。
回调地狱:不是缩进的问题
2011年,Node.js开始流行,“回调地狱”(Callback Hell)或"金字塔厄运"(Pyramid of Doom)成为开发者最头疼的问题之一。代码不断向右缩进,形成一座令人窒息的金字塔:
fetchUser(userId, function(user) {
fetchPermissions(user.role, function(perms) {
fetchResources(perms.level, function(resources) {
processResources(resources, function(result) {
// 终于到了...
});
});
});
});
但缩进只是表象。Bob Nystrom在2015年的经典文章《What Color is Your Function?》中揭示了更深层的问题:异步函数把整个程序分裂成两个世界。
在Nystrom的隐喻中,同步函数是"蓝色"的,异步函数是"红色"的。红色函数有一些恼人的规则:
- 你不能在蓝色函数中直接调用红色函数
- 红色函数只能通过特殊方式(回调)返回结果
- 一旦你的函数调用了红色函数,它自己也被"传染"成红色
- 红色函数难以与异常处理、控制流语句配合工作
这不是简单的语法糖问题,而是控制流的根本性断裂。当你执行同步代码时,调用栈完整地记录了执行位置;但当异步操作发生时,你必须放弃整个调用栈,把所有状态打包成回调闭包——一种被称为"续体传递风格"(Continuation-Passing Style, CPS)的技术。
讽刺的是,CPS最初是编译器研究者在1970年代发明的一种中间表示形式,用于优化编译器。没人想到,几十年后程序员会直接用这种方式写代码。
Promise与Future:重新包装回调
Promise(或Future,两个术语在大多数语境下可互换)的出现,让情况有所改善。一个Promise对象代表一个未来才会有的值,它提供了.then()方法来链式组合异步操作:
fetchUser(userId)
.then(user => fetchPermissions(user.role))
.then(perms => fetchResources(perms.level))
.then(resources => processResources(resources));
缩进问题消失了,错误处理可以通过.catch()统一处理。但Nystrom指出,Promise并没有解决函数着色问题。你仍然不能在同步函数中直接获取Promise的值;你仍然需要用.then()或await来"解包";红色函数仍然是红色的。
Promise的真正价值在于提供了一种组合异步操作的标准接口。在Promise标准化之前,每个异步库都有自己的API风格,难以互操作。2015年,ECMAScript 6将Promise纳入标准,为后续的async/await奠定了基础。
async/await:编译器的魔法
2012年,C# 5.0引入了async/await语法,这是异步编程史上的里程碑。随后,Python(2015年)、JavaScript(2017年)、Rust(2019年)、Swift(2021年)纷纷跟进。
async/await的魔力在于:它让异步代码看起来和同步代码几乎一样。
async Task<User> GetUserWithPermissions(int userId) {
var user = await fetchUser(userId);
var perms = await fetchPermissions(user.role);
var resources = await fetchResources(perms.level);
return await processResources(resources);
}
但表面之下发生了什么?答案是:状态机变换。
当你写下async函数时,编译器会把它变换成一个状态机。每个await点都是一个状态转换的边界。当异步操作挂起时,状态机保存所有局部变量,记录当前状态,然后返回给调用者。当操作完成时,运行时从保存的状态恢复执行。
.NET官方博客详细解释了这个过程:
// 你写的代码
async Task<int> ExampleAsync() {
var x = await FooAsync();
var y = await BarAsync();
return x + y;
}
// 编译器生成的简化版伪代码
struct ExampleStateMachine : IAsyncStateMachine {
int x, y, result;
int state = -1;
TaskAwaiter<int> awaiter;
void MoveNext() {
if (state == -1) {
awaiter = FooAsync().GetAwaiter();
if (!awaiter.IsCompleted) {
state = 0;
AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
goto case 0;
}
if (state == 0) {
x = awaiter.GetResult();
awaiter = BarAsync().GetAwaiter();
if (!awaiter.IsCompleted) {
state = 1;
AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
goto case 1;
}
if (state == 1) {
y = awaiter.GetResult();
result = x + y;
SetResult(result);
}
}
}
这正是CPS变换——只不过由编译器自动完成了。
然而,async/await仍然没有消除函数着色问题。一个async函数返回的是Task<T>或Promise<T>,而不是直接的T。要获取值,调用者必须也变成async。函数着色像病毒一样传播。
Go的道路:没有着色的世界
有没有一种方案能完全避免函数着色?Bob Nystrom指向了一个出人意料的答案:Go语言。
在Go中,你不需要区分同步和异步函数。读取网络数据的代码看起来和读取内存一样:
func handleConnection(conn net.Conn) {
buf := make([]byte, 1024)
n, err := conn.Read(buf) // 看起来是阻塞的
// 但实际上,Go运行时会自动暂停这个goroutine
// 让其他goroutine继续执行
}
秘密在于goroutine——一种由Go运行时管理的轻量级线程。当你执行一个可能阻塞的操作时,Go运行时不会阻塞整个操作系统线程,而是暂停当前的goroutine,切换到其他可运行的goroutine。当操作完成时,goroutine恢复执行。
这种模型的代价是:Go需要一个运行时来管理goroutine的调度。一个goroutine初始栈只有2KB,可以根据需要增长。你可以在单个程序中创建数百万个goroutine,而不会耗尽内存。
Go的设计哲学是:并发应该是一种结构化的组织方式,而不是函数的属性。这引出了我们下一个主题。
结构化并发:范式的终极形态
2018年,Nathaniel Smith发表了《Notes on structured concurrency, or: Go statement considered harmful》,这篇博客文章成为结构化并发运动的宣言。
Smith的核心观点是:go语句(或任何"发射后不管"的并发原语)是有害的,正如1968年Dijkstra论证goto语句有害一样。
在非结构化并发中,你可以随意启动一个并发任务,然后继续做其他事情:
go func() {
// 这个goroutine何时结束?没人知道
// 它出错时会发生什么?没人知道
// 如何等待它完成?不太方便
doSomething()
}()
// 主流程继续...
这导致了几个问题:
- 生命周期不明:子任务可能比父任务活得更久,形成"孤儿任务"
- 错误传播困难:子任务抛出异常时,难以将其传播给正确的处理者
- 取消传播复杂:当父任务被取消时,如何确保子任务也被取消?
- 资源泄漏风险:子任务可能持有资源,但永远不会释放
结构化并发要求:所有并发任务必须在一个明确的作用域内开始和结束。
async def main():
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(fetch_data(url1))
task2 = tg.create_task(fetch_data(url2))
# 当离开这个作用域时,所有子任务必须完成
# 如果任何子任务失败,异常会传播到这里
# 如果main被取消,所有子任务也会被取消
Python的asyncio.TaskGroup(Python 3.11引入)、Kotlin的coroutineScope、Swift的Task都遵循这个原则。
Roman Elizarov在介绍Kotlin结构化并发时举了一个生动的例子。假设你要加载两张图片并合并:
suspend fun loadAndCombine(name1: String, name2: String): Image {
val deferred1 = async { loadImage(name1) }
val deferred2 = async { loadImage(name2) }
return combineImages(deferred1.await(), deferred2.await())
}
这段代码看起来没问题,但隐藏着陷阱:
- 如果
loadAndCombine被取消,两个async任务仍会继续运行 - 如果
loadImage(name1)失败,loadImage(name2)仍会继续运行
结构化并发的版本是:
suspend fun loadAndCombine(name1: String, name2: String): Image =
coroutineScope {
val deferred1 = async { loadImage(name1) }
val deferred2 = async { loadImage(name2) }
combineImages(deferred1.await(), deferred2.await())
}
coroutineScope块创建了一个作用域:当它退出时,所有子任务必须完成;如果任何子任务失败或作用域被取消,所有子任务都会被取消。
Rust的抉择:零成本抽象
Rust选择了另一条道路。2016年,Aaron Turon在博客《Zero-cost futures in Rust》中宣布了一个设计目标:Rust的异步抽象不应该比手写状态机有任何额外开销。
这意味着:
- 没有隐式运行时:Rust不自带事件循环或调度器,你选择自己的运行时(Tokio、async-std等)
- 无堆分配:Future对象直接存储在栈上,直到第一次被poll时才可能需要堆分配
- 无虚拟调用:Future的组合通过泛型静态分发,编译器可以内联优化
Rust的Future是一个状态机enum:
enum MyFuture {
State0 { /* 初始状态 */ },
State1 { x: i32, /* 等待第一个操作 */ },
State2 { x: i32, y: i32, /* 等待第二个操作 */ },
Complete,
}
当Future被poll时,根据当前状态执行相应逻辑,然后转换到下一状态。这种设计使得Rust可以在嵌入式设备上运行异步代码,甚至可以在没有堆分配的环境中使用。
代价是复杂性:Rust的异步编程需要理解Future trait、Pin、Unpin等概念,学习曲线陡峭。而且,Rust也面临函数着色问题——async fn和普通fn是两种不同的类型。
Java的回归:虚拟线程
2023年,Java 21引入了虚拟线程(Virtual Threads),这是Project Loom的成果。Java走了另一条路:让线程变得足够便宜,这样就不需要async/await了。
传统的Java线程是操作系统线程的包装,创建一个线程需要分配约1MB的栈空间。而虚拟线程由JVM管理,初始栈只有几百字节,可以动态增长。你可以在单个JVM中创建数百万个虚拟线程。
更重要的是,现有的同步代码可以不加修改地运行在虚拟线程上。你不需要把代码重写成async/await风格。JVM会在阻塞操作时自动挂起虚拟线程,让出底层操作系统线程。
// 传统风格,现在运行在虚拟线程上
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
Thread.sleep(1000);
return null;
});
}
} // 等待所有虚拟线程完成
Java虚拟线程代表了结构化并发的一种实现:每个虚拟线程都有明确的创建者和生命周期,当你关闭ExecutorService时,所有子任务都会被等待。
各语言异步模型对比
| 语言 | 异步模型 | 是否有着色问题 | 运行时 | 学习曲线 |
|---|---|---|---|---|
| JavaScript | async/await + Event Loop | 是 | 浏览器/Node内置 | 低 |
| Python | async/await + asyncio | 是 | 需要显式启动事件循环 | 中 |
| C# | async/await + Task | 是 | .NET内置 | 低 |
| Rust | async/await + Future | 是 | 需要选择运行时 | 高 |
| Kotlin | 协程 + suspend函数 | 部分 | 需要协程作用域 | 中 |
| Swift | async/await + Actor | 是 | Swift运行时 | 中 |
| Go | goroutine + channel | 否 | Go运行时 | 低 |
| Java 21+ | 虚拟线程 | 否 | JVM | 低 |
结构化并发的三个核心原则
2023年,ACM发表了一篇题为《Structured Concurrency: A Review》的论文,系统总结了结构化并发的三个核心原则:
1. 任务层级化
所有并发任务形成一个树状结构。父任务的生命周期覆盖所有子任务的生命周期。当父任务完成时,所有子任务必须已完成。
2. 错误传播
子任务的失败可以传播到父任务。父任务可以选择处理错误,或者继续向上传播。这解决了异步代码中错误处理混乱的问题。
3. 取消传播
当父任务被取消时,取消信号自动传播到所有子任务。这避免了"孤儿任务"继续运行、浪费资源的问题。
这三个原则的实现方式因语言而异,但目标一致:让并发代码像顺序代码一样容易推理。
权衡的艺术
每种方案都有其权衡。Go和Java虚拟线程的代价是需要一个相对重的运行时,不适合嵌入式系统或需要精细控制的环境。Rust的零成本抽象带来了陡峭的学习曲线。async/await在大多数语言中仍然意味着函数着色。
选择哪种方案,取决于你的具体需求:
- 前端开发:async/await是唯一选择,因为JavaScript运行时已经固定
- 服务端开发:Go、Kotlin、Java虚拟线程都是不错的选择
- 系统编程:Rust提供了最好的控制力和性能
- 快速原型:Python的asyncio足够用
但无论选择哪种方案,理解底层原理——状态机变换、续体传递、结构化并发——都能帮助你写出更好的代码。异步编程四十年博弈的本质,一直是同一个问题:如何在保持性能的同时,让并发代码像顺序代码一样清晰。
结构化并发不是终点,而是我们目前找到的最好答案。正如Dijkstra在1968年废除goto后,结构化编程成为主流;也许在不久的将来,非结构化的并发代码会像当年的goto一样,成为历史遗迹。
参考资料
- Conway, M. E. (1958). Design of a Separable Transition-Diagram Compiler. Communications of the ACM.
- Nystrom, R. (2015). What Color is Your Function? journal.stuffwithstuff.com.
- Smith, N. J. (2018). Notes on structured concurrency, or: Go statement considered harmful. vorpus.org.
- Turon, A. (2016). Zero-cost futures in Rust. aturon.github.io.
- Elizarov, R. (2018). Structured concurrency. Medium.
- Lattner, C. (2017). Swift Concurrency Manifesto. GitHub Gist.
- Torgersen, M. (2012). Language Support for Asynchronous Programming. Lang.NEXT 2012.
- Selivanov, Y. (2015). PEP 492 – Coroutines with async and await syntax. Python Enhancement Proposals.
- Chen, Y. et al. (2023). Structured Concurrency: A Review. ACM Computing Surveys.
- Microsoft .NET Blog (2023). How Async/Await Really Works in C#.
- Syme, D. (2020). The Early History of F#. ACM HOPL.
- Sústrik, M. (2016). libdill: Structured Concurrency for C. GitHub.