编程语言的颜色-同步与异步
编程语言的颜色-同步与异步
你的函数是什么“颜色”?—— 聊聊异步编程的那些事儿
大家好,我是个Java后端程序员。不知怎的,每当夜深人静,我还在与复杂的业务逻辑和性能瓶颈搏斗时,总会有些奇思妙想。今天,我想和大家聊聊一个在编程语言设计中,尤其是异步编程领域,一个非常有趣(也常常令人头疼)的问题。
这篇文章的灵感来源于一篇英文博客《What Color is Your Function?》。原文作者用一种巧妙的“颜色”比喻,揭示了异步编程模型给开发者带来的困境。我觉得这个比喻太经典了,所以想结合咱们Java的生态,和大家分享一下我的理解。
为了避免冒犯到任何特定语言的爱好者(毕竟,每种语言都有其闪光点和历史包袱),我们不妨也像原文作者一样,虚构一种语言来开始我们的讨论。
一种“带颜色”的虚构语言
假设我们有一种新的编程语言,它的语法和我们熟悉的Java有些类似,有类、方法、分号等等。
|
|
因为这是一种“现代”语言,它也支持高阶函数(比如Java 8引入的Lambda表达式和Stream API)。所以你可以写出这样的代码:
|
|
高阶函数非常强大,用熟了之后,你会发现它们无处不在,比如在集合处理、测试框架、事件处理等场景。
好了,精彩的部分来了。我们虚构的语言有一个奇特的特性:
1. 每个函数都有一个“颜色”。
每个函数——无论是命名函数还是匿名函数(比如Lambda)——要么是“蓝色”的,要么是“红色”的。我们不再只有一个public void
或public String
这样的声明方式,而是有两种:
|
|
这门语言里没有“无色”的函数。想定义一个函数?先选个颜色。规矩如此。而且,还有几条规则你必须遵守:
2. 调用函数的方式取决于它的颜色。
想象一下,有“蓝色调用”语法和“红色调用”语法。比如:
|
|
调用函数时,你必须使用与其颜色对应的调用方式。如果搞错了——用blue
方式调用红色函数,或者反之——就会出大问题。比如,抛出一个让你童年噩梦重现的异常。
听起来很烦人,对吧?别急,还有一条:
3. 你只能在红色函数内部调用红色函数。
你可以在红色函数内部调用蓝色函数,这没问题:
|
|
但反过来不行。如果你尝试这样做:
|
|
那么,你的程序可能就要和你说拜拜了。
这使得编写像我们之前的filter
这样的高阶函数变得棘手。我们必须为filter
本身选择一个颜色,而这会影响我们能传递给它的函数的颜色。一个显而易见的解决方案是让filter
变成红色。这样,它就可以接受红色或蓝色的Predicate
并调用它们。但这样我们又会遇到这门语言的另一个痛点:
4. 红色函数的调用“成本”更高,或者说更“麻烦”。
暂时我们不精确定义“麻烦”,就想象一下,程序员每次调用红色函数时都得费点周折。也许是语法特别冗长,也许是不能在某些类型的语句中使用,也许是调用红色函数会带来额外的性能开销或资源管理负担。
关键在于,如果你决定将一个函数做成红色,那么所有使用你API的人可能都会在心里默默吐槽你。
那么,显而易见的解决方案似乎是:永远不要使用红色函数。把所有东西都做成蓝色,我们就回到了一个所有函数颜色都一样的“正常”世界,这等同于所有函数都没有颜色,也等同于我们的虚构语言不那么愚蠢。
然而,这门语言的设计者(我们都知道编程语言设计者有时候挺“特立独行”的)给我们埋下了最后一根刺:
5. 一些核心库的函数是红色的。
总有一些平台内置的、我们必须使用、且无法自己编写替代品的函数,它们只有红色版本。到这里,一个理性的人可能会觉得这门语言简直是在和我们作对。
“颜色”的寓言
当然,我这里说的“颜色”并非字面意义。这是一个寓言。现在,你可能已经猜到“颜色”真正代表什么了。如果还没有,那么谜底揭晓:
红色函数代表异步函数,蓝色函数代表同步函数。
如果你在Java中使用传统的阻塞IO或者编写纯同步逻辑,你就在使用“蓝色”函数。而当你使用Future
、CompletableFuture
,或者在像Netty、Vert.x、Spring WebFlux这样的响应式框架中编程时,你定义的很多函数,尤其是那些涉及IO操作并“返回”结果(通过回调或响应式流)的函数,就变成了“红色”函数。
让我们回顾一下之前的规则,看看这个比喻如何对应到Java(以及其他很多语言)的异步编程现实:
- **同步函数(蓝色)**直接返回值。**异步函数(红色)**通常不直接返回值,而是返回一个“凭证”(如
Future<T>
、CompletableFuture<T>
),或者通过回调、响应式流来传递结果。 - 调用同步函数(蓝色),直接得到结果。调用异步函数(红色),你需要处理那个“凭证”(比如对
CompletableFuture
调用thenApply
、thenAccept
等),或者提供回调函数。 - 你不能真正意义上从一个同步函数(蓝色)中“同步地”调用一个异步函数(红色)并立即获得其最终结果,而不改变蓝色函数的性质。如果你在蓝色函数里调用了一个返回
CompletableFuture
的红色函数,并想拿到结果,你要么调用join()
或get()
阻塞当前线程(这会使蓝色函数在等待期间表现得像红色函数一样有“传染性”的阻塞),要么蓝色函数本身也必须变成异步的(比如返回CompletableFuture
),把异步的特性传递下去。这就是所谓的“异步的传染性”。 - **异步函数(红色)**的组合通常比同步函数更复杂。例如,
CompletableFuture
的链式调用虽然强大,但写起来比简单的顺序同步代码更冗长,错误处理(exceptionally
、handle
)也与同步的try-catch
有所不同。在响应式编程中,整个思维模式都需要转变。 - 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 的痛苦。
|
|
这看起来比原始回调或简单的Future.get()
要好得多。但是,我们仍然将世界分成了同步(蓝色)和异步(红色)两部分。
你仍然不能在一个纯粹的同步方法(蓝色)中调用一个返回CompletableFuture
的方法(红色)并“自然地”获得结果,除非你用join()
或get()
阻塞,或者将该同步方法也变成异步的(即让它也返回CompletableFuture
)。“颜色”的边界依然存在。
所以,即使你的语言(如Java)有了CompletableFuture
这样的优秀工具,其“面貌”仍然和我们虚构的“彩色”语言有几分相似。
async/await
的启示(即便Java没有原生支持)
C# 程序员可能会对async/await
关键字感到自豪。它允许以近乎同步的方式编写异步代码。
|
|
async/await
确实很棒,它极大地简化了异步代码的编写。但它仍然没有消除“颜色”的界限。那些标记了async
的函数本质上还是“红色”的。
- 同步函数返回值
T
,异步函数返回Task<T>
(在Dart中是Future<T>
,在Java中可以认为是CompletableFuture<T>
)。 - 同步函数直接调用,异步函数需要
await
(或在Java中进行CompletableFuture
的链式操作)。 - 如果你调用了一个异步函数,你得到的是一个包装对象 (
Task<T>
),而不是直接的T
。除非你把你的函数也变成异步的(标记async
并使用await
),否则无法“解包”。 - 除了代码写法更简洁,这条规则 (
async/await
确实改善了) - 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中,通过回调函数将后续操作封装起来,每个回调函数都捕获了其上下文(闭包)。当异步操作完成,事件循环调用这个回调。Promise
或CompletableFuture
也是类似的原理,它们将“后续操作”封装为对象,可以链式传递。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管理的轻量级线程,它们可以非常廉价地创建和切换,成千上万甚至数百万个虚拟线程可以映射到少量的平台线程上。
这意味着,你可以用看似同步的、直观的阻塞式代码来编写高并发应用:
|
|
在虚拟线程中,当readFromDatabase
或callRemoteService
这样的方法执行阻塞I/O操作时,它所运行的虚拟线程会被挂起(park),而它所占用的平台线程会被释放出来去执行其他任务(比如其他虚拟线程)。当I/O操作完成,这个虚拟线程会被**解除挂起(unpark)**并继续执行,可能在同一个或另一个平台线程上。
对于开发者来说,代码看起来是简单、线性的、同步的(蓝色的!),但底层却能实现非阻塞的高并发(红色的效果!)。虚拟线程有效地消除了“颜色”的界限。
你不再需要在CompletableFuture
的链式调用中挣扎,也不再需要担心“回调地狱”。你的函数可以保持“蓝色”,同时享受“红色”的并发优势。
总结
“函数颜色”的比喻帮助我们理解了异步编程模型(尤其是基于回调或Future的)带来的认知负担和代码结构的“割裂”。它们将我们的代码世界一分为二:同步的“蓝色”领域和异步的“红色”领域,以及它们之间难以逾越的鸿沟。
虽然CompletableFuture
等工具在Java中已经极大地改善了异步编程的体验,但“颜色”问题依然存在。
幸运的是,随着Project Loom和虚拟线程的成熟,Java开发者终于有了一个强大的武器,可以让我们编写出既简洁明了(像同步代码一样易于理解和维护)又具备高并发能力(充分利用系统资源)的程序。我们不再需要在“蓝色”和“红色”之间做出艰难的选择,而是可以拥抱一个更加“无色”、更加和谐的编程世界。
所以,下次当你再为异步API的设计和使用感到困惑时,不妨想想“函数颜色”的比喻。并期待一下,虚拟线程将如何为你的Java应用“褪去”那些令人烦恼的“颜色”。