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

大家好,我是桦说编程。

本文通过 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 都是"无色"方案

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

相关推荐
百度地图汽车版3 小时前
【AI地图 Tech说】第九期:让智能体拥有记忆——打造千人千面的小度想想
前端·后端
臣妾没空3 小时前
Elpis 全栈框架:从构建到发布的完整实践总结
前端·后端
喷火龙8号3 小时前
单 Token 认证方案的进阶优化:透明刷新机制
后端·架构
孟沐3 小时前
Java异常处理知识点整理(大白话版)
后端
ServBay3 小时前
告别面条代码,PSL 5.0 重构 PHP 性能与安全天花板
后端·php
孟沐4 小时前
Java 面向对象核心知识点(封装 / 继承 / 重写 / 多态)
后端
工边页字4 小时前
面试官:请详细介绍下AI中的token,越详细越好!
前端·人工智能·后端
JackyRoad4 小时前
Prometheus-Grafana-vLLM监控实战指南
性能优化·grafana·监控
LSTM974 小时前
确保文档安全:使用 C# 加密 Word 文档或设置文档权限
后端