大家好,我是桦说编程。
本文通过 Robert Nystrom 的经典"函数颜色"比喻,用具体代码示例拆解异步编程的本质困境:为什么异步会"传染"整条调用链?调用栈在这个过程中到底发生了什么?以及线程/协程模型为什么能根治这个问题。
问题背景
2015 年 Robert Nystrom 写了一篇 What Color is Your Function?,用一个精妙的比喻揭示了异步编程模型的根本性缺陷。这篇文章至今仍是理解异步编程最好的切入点之一。
核心比喻只有一句话:假如每个函数都有"颜色",蓝色代表同步,红色代表异步,那么你的代码将被颜色规则支配。
五条颜色规则
规则一:每个函数都有颜色
要么蓝色(同步),要么红色(异步),没有中间状态。
javascript
// 蓝色函数 - 同步
function add(a, b) {
return a + b;
}
// 红色函数 - 异步
async function fetchUser(id) {
return await db.query("SELECT * FROM users WHERE id=" + id);
}
规则二:调用方式不同
蓝色函数直接调用拿结果,红色函数需要 await / 回调 / .then()。
规则三:蓝色函数不能调红色函数
这是最致命的一条。同步函数里没法"等"异步结果。
规则四:红色函数用起来更麻烦
回调嵌套、Promise 链、额外的 await 关键字...... 红色函数的调用成本始终高于蓝色。
规则五:标准库里有些函数天生是红色的
IO 操作(网络请求、数据库查询、文件读写)在异步模型下天然是红色的,你躲不掉。
颜色传染:一个函数变红,整条链变红
这五条规则合在一起产生了一个极其恼人的效应:红色会沿着调用链向上传染。
用一个具体场景来看:根据用户 ID 查数据库,返回问候语。
同步世界:岁月静好
javascript
function getUsername(id) {
var user = queryDB("SELECT * FROM users WHERE id=" + id);
return user.name;
}
function greet(id) {
var name = getUsername(id);
return "Hello, " + name;
}
function handleRequest(id) {
var message = greet(id);
console.log(message);
}
handleRequest(42);
调用栈清晰直观,一层压一层,一层返一层:
scss
handleRequest(42)
→ greet(42)
→ getUsername(42)
→ queryDB(...) // 阻塞等待,结果返回
← {name: "Alice"}
← "Alice"
← "Hello, Alice"
← console.log("Hello, Alice")
异步世界:传染开始
现在 queryDB 变成异步的了(红色)------ 它不阻塞,立刻返回,结果稍后通过回调给你。
第一步:getUsername 被迫变红。
javascript
function getUsername(id, callback) {
queryDB("SELECT * FROM users WHERE id=" + id, function(user) {
callback(user.name);
});
// ← 立刻返回,没有结果
}
queryDB 调用后立刻返回,此刻数据库结果还在路上。你写不了 return user.name,只能把后续逻辑包成闭包交给回调。getUsername 自己也无法 return 了,必须接受 callback ------ 变红。
第二步:greet 被迫变红。
javascript
function greet(id, callback) {
getUsername(id, function(name) {
callback("Hello, " + name);
});
// ← 立刻返回,没有结果
}
同理,greet 调 getUsername 拿不到返回值,只能把逻辑塞进回调 ------ 又红了。
第三步:handleRequest 被迫变红。
javascript
function handleRequest(id, callback) {
greet(id, function(message) {
console.log(message);
callback();
});
// ← 立刻返回
}
一个 queryDB 变红,整条调用链全红。 这就是所谓的"回调地狱"的本质原因 ------ 不是缩进层数多,而是颜色传染无法阻止。
调用栈视角:为什么蓝色不能调红色?
这是整篇文章最关键的部分。让我们看看异步模式下调用栈到底发生了什么:
css
时刻1: handleRequest(42) 入栈
→ greet(42, cb1) 入栈
→ getUsername(42, cb2) 入栈
→ queryDB("...", cb3) 入栈
// queryDB 把 cb3 注册到事件循环,立刻返回
← queryDB 出栈
← getUsername 出栈
← greet 出栈
← handleRequest 出栈
============ 调用栈已经完全清空 ============
(事件循环运转,处理其他事情)
时刻2: 数据库结果到达,事件循环调用 cb3
→ cb3(user) 入栈 // getUsername 里的闭包
→ cb2("Alice") 入栈 // greet 里的闭包
→ cb1("Hello, Alice") 入栈 // handleRequest 里的闭包
→ console.log("Hello, Alice")
关键观察:
- 时刻1和时刻2之间,调用栈是空的 。原来的
handleRequest → greet → getUsername这条栈已经完全退出、销毁了。 - 数据没丢,是因为闭包。每个回调闭包捕获了它需要的变量,这些闭包对象分散在堆内存上。本来整齐住在栈上的局部变量,变成了堆上一个个零散的对象。
- 要拿到异步结果,必须先退栈。IO 完成后,事件循环才能拿到控制权去调用回调。而"退栈"意味着当前函数已经 return 了。
这就是为什么蓝色函数不能调红色函数 ------ 假设你强行让 greet 保持同步:
javascript
function greet(id) {
var name = getUsername(id, ???);
return "Hello, " + name; // name 是 undefined!
}
getUsername 调用后立刻返回,结果还在路上。name 是 undefined,你返回的是 "Hello, undefined"。你没有任何办法在蓝色函数里"等"红色函数的结果,因为 要触发回调就必须先退掉整个调用栈,而退栈意味着你的函数已经 return 了。
Promise 和 async/await 解决了吗?
缓解了,没根治。
javascript
// Promise 版本 ------ 颜色仍在
function getUsername(id) {
return queryDB("SELECT * FROM users WHERE id=" + id)
.then(user => user.name);
}
// async/await 版本 ------ 语法接近同步,但函数签名上仍分红蓝
async function getUsername(id) {
var user = await queryDB("SELECT * FROM users WHERE id=" + id);
return user.name;
}
async/await 让第四条规则(用起来更麻烦)基本被解决了,但前三条依然成立:
- 函数仍然分 async 和非 async(规则一)
- 调用 async 函数仍然需要 await(规则二)
- 你仍然不能在同步函数里 await 一个异步函数(规则三)
颜色之分依然存在。
根治方案:协程/线程 ------ 挂起而不销毁
没有颜色问题的语言:Go、Lua、Ruby、Java(虚拟线程)。
它们的共同特征是拥有线程或协程 (goroutine、coroutine、fiber、virtual thread),可以挂起调用栈而不销毁它。
以 Go 为例:
go
func getUsername(id int) string {
user := queryDB("SELECT * FROM users WHERE id=?", id) // "同步"写法
return user.Name
}
func greet(id int) string {
name := getUsername(id)
return "Hello, " + name
}
func handleRequest(id int) {
message := greet(id)
fmt.Println(message)
}
所有函数写法一样,调法一样,没有 callback,没有 async,没有 await。
调用栈的行为:
arduino
goroutine 的调用栈:
handleRequest(42)
→ greet(42)
→ getUsername(42)
→ queryDB(...)
// IO 发起,goroutine 挂起(调用栈完整保留在内存中)
// runtime 切换到其他 goroutine 执行别的事
// ......
// IO 完成,goroutine 恢复,从挂起点继续执行
← 返回 {Name: "Alice"} // 栈还在,正常返回
← 返回 "Alice"
← 返回 "Hello, Alice"
调用栈从头到尾没有被销毁,只是被"暂停"了。不需要闭包搬运数据,不需要回调重建上下文,不需要区分颜色。所有函数用同一种方式编写和调用。
Java 的 Virtual Thread(Project Loom)也是同样的思路:
java
// Java 虚拟线程 ------ 没有颜色区分
String getUsername(int id) {
User user = queryDB("SELECT * FROM users WHERE id=" + id); // 虚拟线程挂起,不阻塞OS线程
return user.getName();
}
String greet(int id) {
String name = getUsername(id);
return "Hello, " + name;
}
写法和传统同步代码完全一样,但底层在 IO 时会自动挂起虚拟线程、释放载体线程。
总结
- "函数颜色"问题的本质:异步操作要求调用栈必须退掉才能让事件循环运转,这导致异步函数只能被异步函数调用,形成不可逆的传染链
- async/await 是语法糖,不是解药:它让写法更接近同步,但函数签名上的红蓝之分依然存在,规则三(蓝色不能调红色)依然成立
- 线程/协程模型从根本上消除了颜色区分:通过挂起而非销毁调用栈,让所有函数用统一的方式编写和调用
- 选择语言/框架时,思考它是否给函数染了色:Go 的 goroutine、Java 的虚拟线程、Lua 的 coroutine 都是"无色"方案
如果这篇文章对你有帮助,欢迎关注我,持续分享高质量技术干货,助你更快提升编程能力。