Stackful vs Stackless
理解异步编程时,一个常见误区是将其简单等同于“提升程序运行速度”或“让多个任务同时执行”。实际上,异步机制首先解决的并不是计算性能问题,而是执行资源的利用效率问题:当程序发起一个无法立即完成的操作,尤其是网络、磁盘、数据库等 I/O 操作时,是否有必要让当前执行线程始终阻塞并等待该操作完成。
以远程网络请求为例。假设一次网络往返需要 100 毫秒,而用于构造请求和处理响应的实际 CPU 时间不足 1 毫秒。如果采用直接的阻塞式执行模型,线程在发出请求后通常会停留在 read 或类似的系统调用上,直到数据返回。在这 100 毫秒的等待期间,线程并未执行有效计算,而只是等待外部事件发生。
当系统仅需同时处理少量请求时,这种模型通常不会带来明显问题,使用异步也不会带来多大提升。然而,在高并发场景下,情况会发生显著变化。假设系统需要同时维护数万条网络连接,并且为每条连接分配一个操作系统线程,那么大量系统资源将被消耗在线程栈、线程调度以及上下文切换等方面。与此同时,这些线程中的绝大多数时间可能都处于 I/O 等待状态,而非实际执行计算任务。
因此,异步 I/O 的核心目标可以概括为:当某个任务正在等待外部事件时,不应让执行线程同步处于等待状态,而应释放该线程,使其能够继续处理其他已经具备执行条件的任务。
因此,异步机制提升的主要是等待期间的执行资源利用率以及系统处理大量并发 I/O 任务的能力,而不是直接缩短单个 I/O 操作本身的完成时间。
Callback
一旦决定“不让线程停在那里等”,马上就会出现一个新的问题。同步代码之所以容易理解,是因为控制流天然由调用栈维护。一个函数调用另一个函数,后者完成以后返回,程序自动回到调用点的下一条语句继续执行。程序员通常不需要考虑“之后应该从哪里继续”,因为调用栈已经替我们记住了这件事。例如下面是一个同步写法:
function showUser() {
const user = readUserFromNetwork(); // 阻塞等待
console.log(user.name);
}
异步 I/O 打破了这个简单模型。假设一个函数发起网络读取,但数据暂时没有到达。如果不允许当前线程继续阻塞,那么这个函数就必须先返回,释放线程使其可以执行别的任务。然而一旦它返回,原来的那段顺序执行过程就被中断了。等网络数据稍后真正到达时,系统必须知道接下来应该执行什么。
最直接的办法就是回调函数。调用异步操作时,不要求它现在返回最终结果,而是把“完成以后该做什么”作为一个函数传进去。于是,一个原本看起来像“读取数据,然后解析,然后保存”的顺序过程,会变成“读取数据,完成后调用解析回调;解析完成后再调用保存回调”。
function showUser() {
readUserFromNetwork(function (user) {
console.log(user.name);
});
}
从机器的角度看,这种设计非常合理。回调函数本质上就是一个 continuation,也就是“等待的事件完成后要执行的内容”。异步操作不需要保留当前线程,只需要保存这个 continuation;当操作完成时,再调用它即可。这样就解决了“以后如何继续”的问题。
真正的问题出在人类可读性上。随着异步步骤增加,控制流会逐渐从自然的顺序结构变成层层嵌套的回调,产生了回调地狱:
getUser(id, function (user) {
getOrders(user, function (orders) {
saveOrders(orders, function () {
console.log("done");
});
});
});
异常处理、提前返回、循环、资源释放等原本依赖语言结构自然表达的逻辑,也需要被重新组织。
Promise
Promise 对这种模型做了一层抽象。异步操作启动以后,不再要求调用者立即提供回调,而是先返回一个代表“未来结果”的对象:
const userPromise = getUser(id);
此时 getUser() 并没有返回真正的 User,而是返回一个 Promise<User>。调用者可以随后告诉这个 Promise:等结果产生以后,再执行什么操作:
getUser(id).then(user => {
console.log(user.name);
});
doSomethingElse();
getUser() 随即返回一个处于 pending 状态的 Promise,而 then() 所传入的函数 user => console.log(user.name) 会被登记到这个 Promise 上,表示“将来 Promise 成功以后执行这段逻辑”。
因此,Promise 并没有消除回调,而是改变了回调的组织方式。then 中传入的函数本质上仍然是 continuation。Promise 对象本身表示一个尚未完成或已经完成的异步结果,并维护其状态和值;调用 then 时,continuation 会被注册到这个 Promise 上。由于每次 then 都返回一个新的 Promise,后续异步计算可以通过 Promise 链进行组合,从而把原本层层嵌套的回调结构转化为线性的链式控制流。
async/await
async/await 最大的价值,是可以用接近同步代码的方式来组织异步流程。原本需要通过多层回调或一连串 then 才能表达的逻辑,现在可以重新写成从上到下的顺序结构。例如,使用 Promise 时可能要写成 getUser().then(user => getOrders(user)).then(...),而使用 async/await 后则可以写成:
async function loadData() {
const user = await getUser();
const orders = await getOrders(user);
return orders;
}
从代码外观上看,它几乎和普通同步函数没有区别:先取得 user,再根据 user 获取 orders,最后返回结果。局部变量、条件判断、循环、try/catch 等语言原本提供的控制流结构,也都可以自然地继续使用。因此 async/await 常被概括为“用同步的方式写异步代码”。但这里的“同步方式”仅仅指代码的书写形式和控制流结构,并不意味着 async 函数真的变成了普通同步函数。比如在 JavaScript 中:
async function getNumber() {
return 42;
}
const x = getNumber();
这里的 x 并不是数字 42,而是一个 Promise。如果调用者真正想取得 42,需要再执行:
const x = await getNumber();
这说明普通函数和 async 函数在调用语义上仍然有本质区别。普通函数调用后,最终直接返回结果本身;async 函数调用后,则先返回一个表示“未来结果”的对象,真正的结果需要等异步计算完成以后才能取得。这种区别在 Rust 中表现得更加明确。一个 async fn get_number() -> i32,可以概念性地理解为一个返回 impl Future<Output = i32> 的函数。Python asyncio 也类似,调用 async def 定义的函数时,首先得到的是 coroutine object,而不是函数最终计算出来的结果。
正因为 async 函数不会像普通函数那样立即提供最终结果,调用它的外层函数如果需要这个结果,就必须等待它完成。例如:
async function getUser() {
// 异步网络请求
}
function showUser() {
const user = getUser();
console.log(user.name);
}
这里的 user 实际上是一个 Promise,而不是用户对象,因此不能直接访问 user.name。如果 showUser 想在不阻塞线程的前提下等待真正的用户数据,就必须使用 await;而 await 表示当前函数可能在这里暂停、稍后再恢复执行,因此**showUser 也变成了一个可能暂停的 async 函数,showUser必须使用 async**:
async function showUser() {
const user = await getUser();
console.log(user.name);
}
再上一层如果需要等待 showUser 完成,同样也要使用 await,并因此进入 async 上下文。异步性就这样沿着调用链不断向上传播。
这正是所谓的“函数染色”:同步函数和异步函数具有不同的调用协议,而一个函数只要需要直接等待另一个异步函数的最终结果,它自己通常也必须变成异步函数。async/await 虽然让异步代码在语法上重新获得了同步代码的外观,却并没有消除同步函数和异步函数之间的语义差异。
Stackless
异步编程最根本的问题可以表述为:
当异步操作等待的事件完成以后,如何知道应该从哪里继续执行,以及恢复执行所需的状态保存在哪里?
在普通同步程序中,这个问题几乎不可见,因为调用栈天然替我们解决了。假设当前的调用关系是 A 调用 B,B 调用 C,而 C 中执行了一个阻塞式网络读取 read。在线程阻塞模型下,当 read 因为数据尚未到达而阻塞时,整个调用链仍然保留在线程的调用栈中。C 的栈帧保存着局部变量以及 read 返回后应当继续执行的位置,B 和 A 的栈帧也分别保存着各自的局部状态和返回地址。因此,当网络数据到达、线程重新被调度运行后,并不需要额外回答“接下来从哪里执行”。系统恢复线程的寄存器、栈指针等执行上下文,read 从阻塞点返回,C 自然从下一条指令继续运行;随后 C 返回到 B,B 再返回到 A。调用栈本身已经完整记录了这条尚未完成的执行路径。
典型的 async/await 则采用了不同的策略。它不希望某个异步任务在等待 I/O 时一直占用一个操作系统线程,因此,当 async 函数执行到 await,而等待的操作尚未完成时,当前任务必须暂停,并把执行线程交还给 event loop 或 executor,让这个线程可以继续运行其他任务。
假设逻辑上的调用关系仍然是:
A → B → C → read
其中,C 需要等待 read 完成,而当前线程又不能一直停在这里等待,因此 C 必须暂停执行并返回,把自己的普通栈帧从当前线程的调用栈中弹出。由于 B 正在等待 C 的结果,C 尚未完成时,B 也没有办法继续执行,因此 B 同样需要暂停并返回;A 也是如此。这样,整条普通调用链会逐层退栈,最终把控制权交还给 event loop 或 executor,使当前线程能够被复用去执行其他任务。
这里真正发生变化的是:原本由线程调用栈保存的“未完成计算状态”会被弹出,因此不能再依赖这条调用栈长期存在。
于是,当 I/O 稍后完成时,就会出现几个必须解决的问题:C 上次执行到哪个 await?await 之后应该从哪段代码继续?暂停之前哪些局部变量之后还需要使用?C 完成以后,又应该如何使正在等待它的 B 继续,而 B 完成以后又如何使 A 继续?
async/await 方案的解决办法,是把这些跨越暂停点仍然需要的信息保存到调用栈之外。编译器通常会把 async 函数转换成某种 Future、coroutine 或状态机对象,其中记录当前执行状态、恢复位置以及跨 await 仍需保留的局部变量。这些状态通常具有比一次普通函数调用更长的生命周期,因此一般存放在堆上。
等 I/O 完成以后,event loop 或 executor 再次调度这个异步任务,线程读取之前在堆上保存的状态,确定自己应该从哪个暂停点之后继续,并恢复所需的局部变量。也就是说,async/await 所谓的“恢复”,并不是简单地把之前那条操作系统线程调用栈重新复用(实际上之前的调用栈已经销毁,根本无法复用),而是依靠事先保存下来的异步状态,重新进入那段尚未完成的计算。
正是因为 async/await 方案不会保存线程的调用栈,而会在异步函数挂起后逐层退栈,因此 sync/await 方案的异步通常称为无栈(Stackless)协程。
Stackful
但 async/await 并不是实现异步并发的唯一方式。另一种思路是参考操作系统线程的实现:既然调用栈本身已经保存了函数的返回地址、局部变量以及完整的调用关系,那么与其像典型的 stackless async/await 那样,在挂起时让普通线程调用栈逐层退栈,再把恢复所需的信息转移到 Future 或 coroutine 的状态对象中,不如在等待时直接保留整条调用栈,等条件满足后再恢复这条调用栈继续执行。
操作系统线程采用的就是这种模型。线程执行到阻塞 I/O 时,操作系统可以暂停该线程,但线程自身的调用栈、寄存器以及其他执行上下文仍然保留。等 I/O 完成后,操作系统重新调度这个线程,它就可以直接从原来的阻塞点继续执行。由于整个调用链始终存在,所以函数就是普通的同步调用语义:调用一个函数,逻辑上就是等它执行完成并直接拿到返回值,而不需要先得到 Promise<T> 或 Future<T>,也不需要通过 await 来描述“这里可能暂停”。
因此,这种模型里“异步”发生在调度层,而不是函数接口层。对函数来说,readFromNetwork() 仍然是“调用后得到一个结果”的普通函数;对运行时来说,当前执行单元在等待期间却可以被挂起,让底层 OS 线程去执行其他任务。也就是说,代码保持同步语义,但执行过程可以是异步调度的。
这种方式的问题在于,如果直接使用操作系统线程作为这种可挂起的执行单元,成本通常比较高。每个线程都需要维护独立的线程栈和内核调度状态,大量线程还会带来较高的内存占用和上下文切换成本。因此,如果希望支撑几十万甚至更多并发任务,简单地为每个任务创建一个 OS 线程通常并不经济。
绿色线程、fiber 和 goroutine 延续了同样的思路,但把这个“拥有独立调用栈、可以整体暂停和恢复的执行单元”做得更加轻量。它们通常由语言运行时或用户态调度器管理,因此大量这样的轻量执行单元可以复用在少量 OS 线程之上,而不要求每个并发任务都对应一个真正的操作系统线程。
Go 的 goroutine 就是这种设计的典型代表。每个 goroutine 都维护自己的调用栈和执行状态。当某个 goroutine 因网络 I/O 无法继续时,Go runtime 可以暂停这个 goroutine,同时让底层线程去运行其他可执行的 goroutine;等 I/O 就绪以后,再恢复原来的 goroutine。由于它的调用栈从未被拆解成一串 Future 或 continuation,函数之间依然保持普通的 call/return 关系,因此 Go 的 I/O API 可以继续采用同步、阻塞式的外观,而程序整体仍然能够实现高并发。
这种方案中,每个 goroutine 都保存自己的调用栈,这种异步方案被叫做有栈(Stackful)协程。
另一个推导角度
某个线程正在执行一个函数,假设需要这个函数的内部暂停,从而实现线程的复用:
std::vector<char> read_all(int fd) {
char buf[4096]; // 4KB 在机器栈上
int n = read(fd, buf, sizeof buf); // 想在这里暂停
...
}
要暂停,就得保存 buf、n、返回地址。此外,当前函数暂停了,它的所有上游调用者也必须暂停(因为有线性的调用依赖关系),因此有两条路:
- 把整段栈帧整体保存/切换出去 → 这就是有栈协程;
- 一层一层地
return回去,每层各自把自己的局部变量存到自己的状态机对象里 → 这就是无栈协程。
有栈协程和无栈协程
理解有栈协程和无栈协程,关键在于:协程暂停后,必须保存足够的执行上下文,才能在未来继续运行。
有栈协程直接保存完整调用栈。栈中已经包含返回地址、局部变量和函数间的调用关系,因此恢复时只需恢复栈和寄存器,就可以从原来的位置继续执行。
上游函数无需感知下层函数是否可能挂起,保持普通同步函数嵌套的写法即可,如果内层函数发生了挂起,整个函数调用链路都会被暂停保存。
无论是有栈协程和无栈协程,当内层函数挂起时,整个调用链路都需要被挂起。
区别是无栈协程的整个链路都需要被编译成状态机,也就是 async/await 的传染问题;
而有栈协程的调用链路信息保存在了调用栈里,因此只需要编写同步代码(即写正常的函数调用逻辑),无需引入额外标记显示标注挂起。与线程的区别就是遇到阻塞 I/O 时,由 Runtime 负责协程调度,而线程是由操作系统负责调度。
func fetch(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func main() {
go func() {
data, err := fetch("https://example.com")
if err != nil {
log.Fatal(err)
}
fmt.Println(len(data))
}()
// 当前线程还可以继续调度、执行其他 goroutine
}
例如在 Go 中,同步代码和异步代码是没区别的:这里的 fetch 看起来完全就是一个普通的同步函数。当底层网络读取暂时无法完成时,运行时可以挂起当前 goroutine。此时 fetch → http.Get → read 这条调用链仍然保存在 goroutine 的栈中。相当于 main 中协程执行的整个闭包被挂起了:
// func 执行被完全挂起
go func() {
data, err := fetch("https://example.com")
if err != nil {
log.Fatal(err)
}
fmt.Println(len(data))
}()
用 C++ 的线程理解,就是:
std::thread t([] {
// 当前线程执行 fetch
auto data = fetch("https://example.com");
// 如果 fetch 内部发生阻塞式网络 I/O,
// 那么当前线程会被操作系统挂起
//
// 此时:
// - 线程调用栈仍然保留
// - fetch 的局部变量仍然保留
// - 返回地址仍然保留
// - 但这个线程暂时不占用 CPU 执行
std::cout << data.size() << std::endl;
});
t.join();
同时,Golang 的标准库或者自己封装接口时,通常不需要像一些无栈协程语言那样,同时提供“同步 API”和“异步 API”两套接口,而只需要提供一套看起来像同步调用的 API。例如 io.ReadAll、http.Get 这些调用在代码层面都是普通的同步函数调用;如果底层 I/O 暂时无法完成,Go runtime 会挂起当前 goroutine,并调度其他 goroutine 运行。等 I/O 就绪后,再恢复原 goroutine 继续执行。
无栈协程则不同。它没有自己独立的机器栈,而是借用当前线程的普通调用栈执行。一旦函数返回,这次调用的栈帧就会被销毁。因此,如果协程想在未来继续执行,那么暂停前仍然需要使用的局部变量,以及“执行到哪里了”这两个信息,都必须提前保存到一个能够跨越这次返回继续存在的对象中。
例如这段 C++ 代码:
task<std::vector<char>> fetch(std::string url) {
Response resp = co_await http_get(url);
std::vector<char> body = co_await read_all(resp.body);
co_return body;
}
编译器大致会把它变成一个状态机(仅供示意):
struct fetch_frame {
// ==== 本该由机器栈免费提供的东西,现在要手动存 ====
int state = 0; // ← 相当于“返回地址”,但只能是离散编号
std::coroutine_handle<> continuation; // ← 相当于“调用者栈帧的链接”:谁在等我
std::variant<std::vector<char>, std::exception_ptr> result;
// ==== 跨暂停点存活的局部变量,从机器栈搬进来 ====
std::string url;
Response resp;
// ==== 被等待的子操作,也得嵌在帧里(它的生命周期要跨过 return)====
http_get_awaiter a1;
read_all_awaiter a2;
void resume() {
switch (state) {
case 0:
a1.start(url, handle()); // 注册回调:就绪时来调 resume()
state = 1;
return; // ★ co_await 的“暂停” == 普通 return
case 1:
resp = a1.take_result(); // 恢复:从帧里把上下文读回来
a2.start(resp.body, handle());
state = 2;
return; // ★
case 2:
result = a2.take_result();
state = 3;
continuation.resume(); // 手动“返回给调用者”
return;
}
}
};
无栈协程的“暂停”,本质上就是一次普通的 return,return 以后,函数栈帧被销毁,因此:
url、resp这些跨越co_await的局部变量,一个都不能留在机器栈上,必须搬进fetch_frame;- “下次从哪继续”没法用返回地址,只能用
state这个编译期就枚举完的整数; sizeof(fetch_frame)(活跃变量集合的并集 + 各 awaiter)必须编译期算出来 —— 这也是为什么帧通常由operator new分到堆上,而不能放在那个即将销毁的栈帧里。
并且可以进一步看出:**在一条线性的等待依赖链上,暂停必须逐层向上传播。**如果当前函数在等待下游结果,那么它自己也无法继续执行;而上游调用者如果同样需要等待当前函数的结果,也必须随之进入可暂停状态。因此,调用者必须知道:**这次函数调用可能不会立即产生最终结果,而是先返回一个代表“尚未完成计算”的对象。**一旦调用者选择等待这个结果,它自己也必须能够保存当前状态、结束本次执行,并登记后续恢复的位置。
由此可以自然推导出无栈协程的两个核心机制:
- “这个函数可能暂停,最终结果以后才产生” → 返回类型不能再只是普通的
T,而需要是task<T>、lazy<T>之类能够表示未完成计算的返回类型。 - “函数在这里暂时无法继续,保存当前状态,等下游完成后再恢复执行” →
co_await,它描述的正是暂停点以及后续 continuation 的衔接关系。
**特殊返回类型描述“这个计算还没有完成”,co_await 描述“当前计算要在这里等待它完成”。**前者把“未完成”暴露给调用者,后者把“如何暂停并继续”编码进当前协程的状态机。
评论