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的隐喻中,同步函数是"蓝色"的,异步函数是"红色"的。红色函数有一些恼人的规则:

  1. 你不能在蓝色函数中直接调用红色函数
  2. 红色函数只能通过特殊方式(回调)返回结果
  3. 一旦你的函数调用了红色函数,它自己也被"传染"成红色
  4. 红色函数难以与异常处理、控制流语句配合工作

这不是简单的语法糖问题,而是控制流的根本性断裂。当你执行同步代码时,调用栈完整地记录了执行位置;但当异步操作发生时,你必须放弃整个调用栈,把所有状态打包成回调闭包——一种被称为"续体传递风格"(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()
}()
// 主流程继续...

这导致了几个问题:

  1. 生命周期不明:子任务可能比父任务活得更久,形成"孤儿任务"
  2. 错误传播困难:子任务抛出异常时,难以将其传播给正确的处理者
  3. 取消传播复杂:当父任务被取消时,如何确保子任务也被取消?
  4. 资源泄漏风险:子任务可能持有资源,但永远不会释放

结构化并发要求:所有并发任务必须在一个明确的作用域内开始和结束

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的异步抽象不应该比手写状态机有任何额外开销

这意味着:

  1. 没有隐式运行时:Rust不自带事件循环或调度器,你选择自己的运行时(Tokio、async-std等)
  2. 无堆分配:Future对象直接存储在栈上,直到第一次被poll时才可能需要堆分配
  3. 无虚拟调用:Future的组合通过泛型静态分发,编译器可以内联优化

Rust的Future是一个状态机enum:

enum MyFuture {
    State0 { /* 初始状态 */ },
    State1 { x: i32, /* 等待第一个操作 */ },
    State2 { x: i32, y: i32, /* 等待第二个操作 */ },
    Complete,
}

当Future被poll时,根据当前状态执行相应逻辑,然后转换到下一状态。这种设计使得Rust可以在嵌入式设备上运行异步代码,甚至可以在没有堆分配的环境中使用。

代价是复杂性:Rust的异步编程需要理解Future trait、PinUnpin等概念,学习曲线陡峭。而且,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一样,成为历史遗迹。


参考资料

  1. Conway, M. E. (1958). Design of a Separable Transition-Diagram Compiler. Communications of the ACM.
  2. Nystrom, R. (2015). What Color is Your Function? journal.stuffwithstuff.com.
  3. Smith, N. J. (2018). Notes on structured concurrency, or: Go statement considered harmful. vorpus.org.
  4. Turon, A. (2016). Zero-cost futures in Rust. aturon.github.io.
  5. Elizarov, R. (2018). Structured concurrency. Medium.
  6. Lattner, C. (2017). Swift Concurrency Manifesto. GitHub Gist.
  7. Torgersen, M. (2012). Language Support for Asynchronous Programming. Lang.NEXT 2012.
  8. Selivanov, Y. (2015). PEP 492 – Coroutines with async and await syntax. Python Enhancement Proposals.
  9. Chen, Y. et al. (2023). Structured Concurrency: A Review. ACM Computing Surveys.
  10. Microsoft .NET Blog (2023). How Async/Await Really Works in C#.
  11. Syme, D. (2020). The Early History of F#. ACM HOPL.
  12. Sústrik, M. (2016). libdill: Structured Concurrency for C. GitHub.