Contents

编程语言的颜色-同步与异步

编程语言的颜色-同步与异步

你的函数是什么“颜色”?—— 聊聊异步编程的那些事儿

大家好,我是个Java后端程序员。不知怎的,每当夜深人静,我还在与复杂的业务逻辑和性能瓶颈搏斗时,总会有些奇思妙想。今天,我想和大家聊聊一个在编程语言设计中,尤其是异步编程领域,一个非常有趣(也常常令人头疼)的问题。

这篇文章的灵感来源于一篇英文博客《What Color is Your Function?》。原文作者用一种巧妙的“颜色”比喻,揭示了异步编程模型给开发者带来的困境。我觉得这个比喻太经典了,所以想结合咱们Java的生态,和大家分享一下我的理解。

为了避免冒犯到任何特定语言的爱好者(毕竟,每种语言都有其闪光点和历史包袱),我们不妨也像原文作者一样,虚构一种语言来开始我们的讨论。

一种“带颜色”的虚构语言

假设我们有一种新的编程语言,它的语法和我们熟悉的Java有些类似,有类、方法、分号等等。

1
2
3
4
// 这是一个普通的方法 (或者叫函数)
public String thisIsAFunction() {
  return "这很棒";
}

因为这是一种“现代”语言,它也支持高阶函数(比如Java 8引入的Lambda表达式和Stream API)。所以你可以写出这样的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 假设我们有个类似JavaScript的filter函数,但用Java风格实现
public <T> List<T> filter(List<T> collection, Predicate<T> predicate) {
  List<T> result = new ArrayList<>();
  for (T item : collection) {
    if (predicate.test(item)) {
      result.add(item);
    }
  }
  return result;
}

// 使用
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> evenNumbers = filter(numbers, n -> n % 2 == 0);

高阶函数非常强大,用熟了之后,你会发现它们无处不在,比如在集合处理、测试框架、事件处理等场景。

好了,精彩的部分来了。我们虚构的语言有一个奇特的特性:

1. 每个函数都有一个“颜色”。

每个函数——无论是命名函数还是匿名函数(比如Lambda)——要么是“蓝色”的,要么是“红色”的。我们不再只有一个public voidpublic String这样的声明方式,而是有两种:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 假设的语法
public blue String doSomethingAzure() {
  // 这是一个蓝色函数...
  return "蓝色结果";
}

public red String doSomethingCarnelian() {
  // 这是一个红色函数...
  return "红色结果";
}

这门语言里没有“无色”的函数。想定义一个函数?先选个颜色。规矩如此。而且,还有几条规则你必须遵守:

2. 调用函数的方式取决于它的颜色。

想象一下,有“蓝色调用”语法和“红色调用”语法。比如:

1
2
3
// 假设的调用语法
String result1 = doSomethingAzure()blue; // 调用蓝色函数
String result2 = doSomethingCarnelian()red; // 调用红色函数

调用函数时,你必须使用与其颜色对应的调用方式。如果搞错了——用blue方式调用红色函数,或者反之——就会出大问题。比如,抛出一个让你童年噩梦重现的异常。

听起来很烦人,对吧?别急,还有一条:

3. 你只能在红色函数内部调用红色函数。

可以在红色函数内部调用蓝色函数,这没问题:

1
2
3
4
5
public red String doSomethingCarnelian() {
  String blueResult = doSomethingAzure()blue;
  // ...做其他红色操作
  return "混合结果";
}

但反过来不行。如果你尝试这样做:

1
2
3
4
5
public blue String doSomethingAzure() {
  // 尝试在蓝色函数中调用红色函数
  String redResult = doSomethingCarnelian()red; // 编译器报错!或者运行时崩溃!
  return "不可能的结果";
}

那么,你的程序可能就要和你说拜拜了。

这使得编写像我们之前的filter这样的高阶函数变得棘手。我们必须为filter本身选择一个颜色,而这会影响我们能传递给它的函数的颜色。一个显而易见的解决方案是让filter变成红色。这样,它就可以接受红色或蓝色的Predicate并调用它们。但这样我们又会遇到这门语言的另一个痛点:

4. 红色函数的调用“成本”更高,或者说更“麻烦”。

暂时我们不精确定义“麻烦”,就想象一下,程序员每次调用红色函数时都得费点周折。也许是语法特别冗长,也许是不能在某些类型的语句中使用,也许是调用红色函数会带来额外的性能开销或资源管理负担。

关键在于,如果你决定将一个函数做成红色,那么所有使用你API的人可能都会在心里默默吐槽你。

那么,显而易见的解决方案似乎是:永远不要使用红色函数。把所有东西都做成蓝色,我们就回到了一个所有函数颜色都一样的“正常”世界,这等同于所有函数都没有颜色,也等同于我们的虚构语言不那么愚蠢。

然而,这门语言的设计者(我们都知道编程语言设计者有时候挺“特立独行”的)给我们埋下了最后一根刺:

5. 一些核心库的函数是红色的。

总有一些平台内置的、我们必须使用、且无法自己编写替代品的函数,它们只有红色版本。到这里,一个理性的人可能会觉得这门语言简直是在和我们作对。

“颜色”的寓言

当然,我这里说的“颜色”并非字面意义。这是一个寓言。现在,你可能已经猜到“颜色”真正代表什么了。如果还没有,那么谜底揭晓:

红色函数代表异步函数,蓝色函数代表同步函数。

如果你在Java中使用传统的阻塞IO或者编写纯同步逻辑,你就在使用“蓝色”函数。而当你使用FutureCompletableFuture,或者在像Netty、Vert.x、Spring WebFlux这样的响应式框架中编程时,你定义的很多函数,尤其是那些涉及IO操作并“返回”结果(通过回调或响应式流)的函数,就变成了“红色”函数。

让我们回顾一下之前的规则,看看这个比喻如何对应到Java(以及其他很多语言)的异步编程现实:

  1. **同步函数(蓝色)**直接返回值。**异步函数(红色)**通常不直接返回值,而是返回一个“凭证”(如Future<T>CompletableFuture<T>),或者通过回调、响应式流来传递结果。
  2. 调用同步函数(蓝色),直接得到结果。调用异步函数(红色),你需要处理那个“凭证”(比如对CompletableFuture调用thenApplythenAccept等),或者提供回调函数。
  3. 不能真正意义上从一个同步函数(蓝色)中“同步地”调用一个异步函数(红色)并立即获得其最终结果,而不改变蓝色函数的性质。如果你在蓝色函数里调用了一个返回CompletableFuture的红色函数,并想拿到结果,你要么调用join()get()阻塞当前线程(这会使蓝色函数在等待期间表现得像红色函数一样有“传染性”的阻塞),要么蓝色函数本身也必须变成异步的(比如返回CompletableFuture),把异步的特性传递下去。这就是所谓的“异步的传染性”。
  4. **异步函数(红色)**的组合通常比同步函数更复杂。例如,CompletableFuture的链式调用虽然强大,但写起来比简单的顺序同步代码更冗长,错误处理(exceptionallyhandle)也与同步的try-catch有所不同。在响应式编程中,整个思维模式都需要转变。
  5. Java的核心库(尤其是NIO和现代网络库)以及许多第三方库(如数据库驱动、HTTP客户端)都提供了异步API(红色)。例如,Java NIO的AsynchronousSocketChannel,或者像OkHttp、Netty HttpClient等都提供了异步请求方法。

当我们谈论“回调地狱”(Callback Hell)或者“CompletableFuture 链式调用的复杂性”时,我们其实就是在抱怨语言中存在“红色函数”所带来的不便。当社区出现各种异步编程库和框架(如RxJava, Project Reactor)时,它们正是在尝试在库层面解决语言本身(或其传统编程模型)带来的问题。

CompletableFuture:更好的未来,但“颜色”仍在

Java社区很早就意识到了传统Future的局限性,并在Java 8中引入了CompletableFuture

CompletableFuture确实让异步代码更容易编写和组合。它通过链式调用(thenApply, thenCompose, thenAccept等)和更完善的异常处理机制,在一定程度上缓解了规则 #4 的痛苦。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 模拟一个异步获取用户信息的红色函数
public CompletableFuture<String> getUserDetailsAsync(String userId) {
    return CompletableFuture.supplyAsync(() -> {
        // 模拟IO耗时操作
        try { Thread.sleep(1000); } catch (InterruptedException e) { /* ... */ }
        return "Details for " + userId;
    });
}

// 模拟一个异步获取订单信息的红色函数
public CompletableFuture<String> getOrderDetailsAsync(String orderId) {
    return CompletableFuture.supplyAsync(() -> {
        try { Thread.sleep(1000); } catch (InterruptedException e) { /* ... */ }
        return "Details for "
                + orderId;
    });
}

// 调用它们 (这也是一个红色函数,因为它返回CompletableFuture)
public CompletableFuture<Void> processUserOrder(String userId, String orderId) {
    return getUserDetailsAsync(userId)
        .thenCompose(userDetails -> {
            System.out.println("User: " + userDetails);
            return getOrderDetailsAsync(orderId);
        })
        .thenAccept(orderDetails -> {
            System.out.println("Order: " + orderDetails);
            // ...做更多处理
        })
        .exceptionally(ex -> {
            System.err.println("出错了: " + ex.getMessage());
            return null;
        });
}

这看起来比原始回调或简单的Future.get()要好得多。但是,我们仍然将世界分成了同步(蓝色)和异步(红色)两部分。

你仍然不能在一个纯粹的同步方法(蓝色)中调用一个返回CompletableFuture的方法(红色)并“自然地”获得结果,除非你用join()get()阻塞,或者将该同步方法也变成异步的(即让它也返回CompletableFuture)。“颜色”的边界依然存在。

所以,即使你的语言(如Java)有了CompletableFuture这样的优秀工具,其“面貌”仍然和我们虚构的“彩色”语言有几分相似。

async/await的启示(即便Java没有原生支持)

C# 程序员可能会对async/await关键字感到自豪。它允许以近乎同步的方式编写异步代码。

1
2
3
4
5
// C# 示例
public async Task<string> GetUserDataAsync() {
    string data = await FetchDataFromServerAsync(); // 看似同步调用
    return "Processed: " + data;
}

async/await确实很棒,它极大地简化了异步代码的编写。但它仍然没有消除“颜色”的界限。那些标记了async的函数本质上还是“红色”的。

  1. 同步函数返回值 T,异步函数返回 Task<T> (在Dart中是 Future<T>,在Java中可以认为是 CompletableFuture<T>)。
  2. 同步函数直接调用,异步函数需要 await (或在Java中进行CompletableFuture的链式操作)。
  3. 如果你调用了一个异步函数,你得到的是一个包装对象 (Task<T>),而不是直接的 T。除非你把你的函数也变成异步的(标记async并使用await),否则无法“解包”。
  4. 除了代码写法更简洁,这条规则 (async/await确实改善了)
  5. C#的核心库比async出现得早,所以它们最初没有这个问题,但后来的异步API也遵循了这个模式。

async/await很好,但我们不能自欺欺人地认为所有麻烦都消失了。一旦你开始编写高阶函数,或者重用代码,你很快就会发现“颜色”依然存在,渗透到你的代码库中。

哪些语言没有“颜色”问题?

JavaScript (Node.js环境尤甚)、Dart、C#、Python 都有这个问题。那么,哪些语言没有这个问题,或者说处理得更好呢?

原文提到了 Go、Lua、Ruby。它们有什么共同点?

线程(Threads)。 或者更准确地说:多个可以被切换的独立调用栈。不一定是操作系统线程,Go的Goroutines、Lua的Coroutines、Ruby的Fibers 都可以。

(这也是为什么C#可以“规避”这个问题:在async/await出现之前,你可以用传统的多线程方式进行并发编程,尽管那有其自身的复杂性。)

对于我们Java程序员来说,答案也是线程,尤其是即将到来的虚拟线程(Virtual Threads, Project Loom)

症结所在:操作完成后的“断点续传”

根本问题是:“当一个耗时操作(通常是I/O)完成时,如何回到之前中断的地方继续执行?”

你构建了一个调用栈,然后调用某个I/O操作。为了性能,这个操作通常会利用操作系统底层的异步API。你不能傻等它完成,因为那会阻塞当前线程,浪费资源。你必须将控制权交还给事件循环或调度器,让CPU去做其他事情。

当操作完成后,你需要恢复之前的执行流程。语言通常通过**调用栈(Call Stack)**来“记住当前位置”。但为了实现非阻塞I/O,你往往需要“解开”或放弃当前的调用栈(至少在单线程事件循环模型中是这样)。

在Node.js中,通过回调函数将后续操作封装起来,每个回调函数都捕获了其上下文(闭包)。当异步操作完成,事件循环调用这个回调。PromiseCompletableFuture也是类似的原理,它们将“后续操作”封装为对象,可以链式传递。async/await则是编译器层面的语法糖,它会把async函数转换成类似状态机的结构,将await点之后的部分也封装起来。

所有这些方法(回调、Promise/Future、async/await)最终都是在手动或自动地进行一种叫做**“延续传递风格(Continuation-Passing Style, CPS)”**的转换。它们把一个看似连续的函数逻辑,切割成片段,并通过某种机制(闭包、对象、状态机)保存这些“延续点”的状态,以便在异步操作完成后能从正确的地方恢复。

但这意味着,从发起异步操作的函数开始,一直到调用栈的顶层(比如main方法或事件处理器),都必须适应这种“异步”模式。这就是“红色函数只能被红色函数(有效地)调用”规则的根源——“颜色”会向上蔓延。

虚拟线程:Java的“消色差”方案

但如果你有轻量级线程(如Go的Goroutine,或者Java的虚拟线程),情况就不同了。你可以“阻塞”一个轻量级线程来等待I/O,而不会阻塞底层的操作系统线程。调度器会简单地挂起这个轻量级线程,去执行其他可运行的轻量级线程。当I/O操作完成后,调度器再唤醒它。

Go语言在这方面做得非常漂亮。它的I/O操作在代码层面看起来是同步阻塞的,但实际上,当一个Goroutine执行I/O操作时,它会被调度器“停放”,而其他Goroutine可以继续运行。Go语言在很大程度上消除了同步和异步代码之间的明显界限。

现在,让我们回到Java。传统的平台线程(OS Threads)虽然也能实现类似效果(一个线程阻塞等待I/O,其他线程继续运行),但平台线程是相对昂贵的资源,我们不能无限制地创建。

Project Loom带来的虚拟线程(Virtual Threads),则为Java带来了曙光。虚拟线程是由JVM管理的轻量级线程,它们可以非常廉价地创建和切换,成千上万甚至数百万个虚拟线程可以映射到少量的平台线程上。

这意味着,你可以用看似同步的、直观的阻塞式代码来编写高并发应用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 使用虚拟线程 (Java 21+ 语法)
void handleRequest(Request request, Response response) {
    // 每个请求都在一个虚拟线程中处理
    Thread.startVirtualThread(() -> {
        try {
            String data1 = readFromDatabase(request.getParam("db_query")); // 看似阻塞,但实际是异步的
            String data2 = callRemoteService(request.getParam("service_url")); // 同上
            response.send(process(data1, data2));
        } catch (IOException e) {
            response.sendError(500, e.getMessage());
        }
    });
}

// 这些方法内部可以是传统的阻塞IO代码
String readFromDatabase(String query) throws IOException { /* ... */ return "db_data"; }
String callRemoteService(String url) throws IOException { /* ... */ return "service_data"; }
String process(String d1, String d2) { /* ... */ return "processed_result"; }

在虚拟线程中,当readFromDatabasecallRemoteService这样的方法执行阻塞I/O操作时,它所运行的虚拟线程会被挂起(park),而它所占用的平台线程会被释放出来去执行其他任务(比如其他虚拟线程)。当I/O操作完成,这个虚拟线程会被**解除挂起(unpark)**并继续执行,可能在同一个或另一个平台线程上。

对于开发者来说,代码看起来是简单、线性的、同步的(蓝色的!),但底层却能实现非阻塞的高并发(红色的效果!)。虚拟线程有效地消除了“颜色”的界限。

你不再需要在CompletableFuture的链式调用中挣扎,也不再需要担心“回调地狱”。你的函数可以保持“蓝色”,同时享受“红色”的并发优势。

总结

“函数颜色”的比喻帮助我们理解了异步编程模型(尤其是基于回调或Future的)带来的认知负担和代码结构的“割裂”。它们将我们的代码世界一分为二:同步的“蓝色”领域和异步的“红色”领域,以及它们之间难以逾越的鸿沟。

虽然CompletableFuture等工具在Java中已经极大地改善了异步编程的体验,但“颜色”问题依然存在。

幸运的是,随着Project Loom和虚拟线程的成熟,Java开发者终于有了一个强大的武器,可以让我们编写出既简洁明了(像同步代码一样易于理解和维护)又具备高并发能力(充分利用系统资源)的程序。我们不再需要在“蓝色”和“红色”之间做出艰难的选择,而是可以拥抱一个更加“无色”、更加和谐的编程世界。

所以,下次当你再为异步API的设计和使用感到困惑时,不妨想想“函数颜色”的比喻。并期待一下,虚拟线程将如何为你的Java应用“褪去”那些令人烦恼的“颜色”。