你的函数什么颜色?—— 深入理解异步编程的本质问题(上)

大家好,我是桦说编程。

本文通过 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);
    });
    // ← 立刻返回,没有结果
}

同理,greetgetUsername 拿不到返回值,只能把逻辑塞进回调 ------ 又红了。

第三步: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. 时刻1和时刻2之间,调用栈是空的 。原来的 handleRequest → greet → getUsername 这条栈已经完全退出、销毁了。
  2. 数据没丢,是因为闭包。每个回调闭包捕获了它需要的变量,这些闭包对象分散在堆内存上。本来整齐住在栈上的局部变量,变成了堆上一个个零散的对象。
  3. 要拿到异步结果,必须先退栈。IO 完成后,事件循环才能拿到控制权去调用回调。而"退栈"意味着当前函数已经 return 了。

这就是为什么蓝色函数不能调红色函数 ------ 假设你强行让 greet 保持同步:

javascript 复制代码
function greet(id) {
    var name = getUsername(id, ???);
    return "Hello, " + name;     // name 是 undefined!
}

getUsername 调用后立刻返回,结果还在路上。nameundefined,你返回的是 "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 都是"无色"方案

如果这篇文章对你有帮助,欢迎关注我,持续分享高质量技术干货,助你更快提升编程能力。

相关推荐
Patrick_Wilson6 分钟前
最佳实践是有保质期的:从一次 CDN external 白屏事故说起
前端·性能优化·前端工程化
侑虎科技37 分钟前
解决URP角色渲染短板,自研IllusionRP管线落地移动端
性能优化
Nturmoils1 小时前
订单查询丢数据,查了半天原来是 WHERE 惹的祸
数据库·后端
叫我Paul就好1 小时前
自从用上 Agents View 之后, 我的心力交瘁感消失了
后端
SamDeepThinking1 小时前
组合还是继承?我在项目里主要看这两个判断
java·后端·面试
niuju1 小时前
一种基于 Agent 的需求规范化闭环实践:让合规与规范内建于流程
后端
团团崽_七分甜1 小时前
四大数据库底层原理详解
后端
glartchen1 小时前
使用 Pixi 配置 Whisper 环境,实现采用本地大模型读取视频字幕
后端
站大爷IP1 小时前
明明传了参数,进程池却说没找到?记一次变量丢失的排查实录
后端
用户6757049885022 小时前
从失真到绝对精准:Go语言中如何进行高精度计算?
后端·go