跳转到内容

第 19 章 · 异步:async/await 的四种实现,Go 为什么没有它

解决什么问题:等一次查询的时候,怎样不占住一条线程,同时又能把“先查这个、再查那个”写成顺序代码。

四种语言的态度:Python、JS/TS 和 C# 都有 async/await,底下分别是生成器式协程加事件循环、引擎挂起加微任务、编译器生成的状态机加线程池;Go 没有它,阻塞写法由运行时在底下变成不阻塞。

显示一个用户主页要做三次查询。先按 ID 查用户,拿到用户名后才能查他的订单,这两步有先后;推荐商品和用户无关,可以和前两步同时查。每次查询用一小段延迟模拟:查用户和查订单各 100 毫秒,查推荐 150 毫秒。全部顺序做要 350 毫秒,把推荐和“用户加订单”这条链并发起来只要约 200 毫秒。三项结果都到齐后,按固定顺序打印用户名、订单数和推荐商品。

阻塞调用(blocking call)在结果回来之前不返回,调用它的线程就停在那里。一条线程停着等 100 毫秒,CPU 不忙,但这条线程占着的栈和调度资源也做不了别的事。要同时等一万个连接,就得有一万条线程,每条预留 MB 级的栈。非阻塞(non-blocking)的做法是:发起操作后立刻返回,等结果到了再通知程序。问题在于“结果到了之后要做的事”写在哪里。

最早的答案是回调(callback):把后续工作包成一个函数传进去,操作完成时调用它。几步有先后的查询就成了层层嵌套的回调,错误要在每一层分别处理。第二个答案是 Promise(C# 里叫 Task,Python 里叫 Future):一个代表“将来会有的结果”的对象,可以在上面挂后续步骤,也可以把几个合并成一个,错误沿着链条往后传。第三个答案是 async/await:写法看起来和阻塞代码一样,await 一个 Promise 时,函数在这里暂停,把控制权交还给调度者,结果到了再从这里继续。

await 不阻塞线程。它做的事是把“这个函数剩下的部分”打包成一个延续(continuation),登记到被等待的对象上,然后返回。线程空出来去跑别的任务;等待的对象完成时,调度者把延续拿出来接着执行。所以 await 需要两样东西:一个能在中途暂停、之后再恢复的函数,第 13 章的生成器和迭代器状态机正是这种东西;还有一个调度者,负责在结果到来时把延续排上队。

代价是函数从此分成两种。Bob Nystrom 2015 年写过一篇《What Color is Your Function?》,把它叫作函数染色(function coloring):异步函数是“红色”的,只有红色函数能直接 await 它;普通函数是“蓝色”的,要调用红色函数,自己也得变红。底层的一个查询改成异步,调用链上一路到 main 都要跟着改,同一个功能的库也常常要出同步和异步两个版本。

Python 3.5(2015 年,PEP 492)加入了 async def 和 await,事件循环和任务调度由标准库 asyncio 提供。这之前的 3.4 用生成器加 yield from 写协程,async/await 是在那套机制上的专门语法。

import asyncio
async def find_user(user_id: int) -> str:
await asyncio.sleep(0.1) # 模拟查询:让出事件循环,不占线程
return {1: "alice"}[user_id]
async def find_orders(user: str) -> list[str]:
await asyncio.sleep(0.1)
return {"alice": ["A-1", "A-2", "A-3"]}[user]
async def find_recommendations() -> list[str]:
await asyncio.sleep(0.15)
return ["书", "咖啡"]
async def load_orders(user_id: int) -> tuple[str, list[str]]:
user = await find_user(user_id) # 先要用户
orders = await find_orders(user) # 订单依赖用户,只能等上一步
return user, orders
async def main() -> None:
(user, orders), recs = await asyncio.gather(
load_orders(1), find_recommendations() # 两条链同时进行
)
print(f"用户:{user}")
print(f"订单数:{len(orders)}")
print("推荐:" + ", ".join(recs))
asyncio.run(main())
# 输出:
# 用户:alice
# 订单数:3
# 推荐:书, 咖啡

运行:python profile.py。有先后的两步放进 load_orders,按顺序 await;没有依赖的两件事交给 asyncio.gather,它把两个协程包装成任务(Task)同时交给事件循环,结果按传入的顺序返回,和谁先完成无关。总耗时约 200 毫秒。

调用 load_orders(1) 本身不执行任何代码,只得到一个协程对象;要么被 await,要么被包装成任务,函数体才会开始跑。这一点和 JS、C# 不同,后两者一调用就执行到第一个 await。

底层怎么实现。 3.5 起原生协程是一个独立的类型 coroutine,不是生成器,不能放进 for 里迭代;但 CPython 内部和生成器共用同一套挂起机制。用 dis 看 3.12 编译出的字节码,await asyncio.sleep(0) 是 GET_AWAITABLE、LOAD_CONST、SEND、YIELD_VALUE,和生成器里的 yield from 除了第一条以外完全一样。暂停时,局部变量和执行位置留在协程的帧里。

驱动协程的是任务:Task 反复调用协程的 send(None),协程一路 await 下去,最底层的 Future(asyncio.sleep 里就有一个)把自己 yield 给任务。任务看到一个未完成的 Future,就在它上面挂一个回调,然后返回事件循环。定时器到期后,Future 被设置结果,回调通过 call_soon 把任务排进就绪队列,事件循环下一轮再次 send,协程从暂停处继续。整个过程都在一条线程上。

  • 在协程里调用阻塞函数:time.sleep(0.1)、同步的 HTTP 库、大文件读写都会让整个事件循环停住,所有任务一起等。确实绕不开的阻塞调用,用 asyncio.to_thread 放到线程里。asyncio.run(main(), debug=True) 会报告执行超过 100 毫秒的回调。
  • 忘了 await:user = find_user(1) 拿到的是 <coroutine object find_user at 0x…>,函数体一行也没执行。协程对象被回收时,Python 3.12 打印 RuntimeWarning: coroutine 'find_user' was never awaited,只是警告,程序继续跑,错误要到用这个值时才暴露。
  • 只创建任务不保存引用:asyncio.create_task(job()) 的返回值扔掉后,事件循环对任务只持有弱引用,任务可能在中途被垃圾回收。官方文档的做法是把任务存进一个集合,完成时再移除。

Promise 在 ES2015 进入标准,async 函数和 await 在 ES2017。JS 的异步是原生的,也是主要的选择:主线程只有一条,浏览器和 Node 的 I/O API 以回调或 Promise 为主;Node 只在 fs、child_process 等模块里另外保留 readFileSync、execSync 这类同步版本,在服务器代码里调用它们会停住整个事件循环。

import { setTimeout as sleep } from "node:timers/promises";
async function findUser(userId: number): Promise<string> {
await sleep(100); // 模拟查询:登记定时器后让出主线程
return new Map([[1, "alice"]]).get(userId)!;
}
async function findOrders(user: string): Promise<string[]> {
await sleep(100);
return new Map([["alice", ["A-1", "A-2", "A-3"]]]).get(user)!;
}
async function findRecommendations(): Promise<string[]> {
await sleep(150);
return ["书", "咖啡"];
}
async function loadOrders(userId: number): Promise<[string, string[]]> {
const user = await findUser(userId); // 先要用户
const orders = await findOrders(user); // 订单依赖用户,只能等上一步
return [user, orders];
}
const [[user, orders], recs] = await Promise.all([
loadOrders(1), findRecommendations(), // 两条链同时进行
]);
console.log(`用户:${user}`);
console.log(`订单数:${orders.length}`);
console.log("推荐:" + recs.join(", "));
// 输出:
// 用户:alice
// 订单数:3
// 推荐:书, 咖啡

运行:tsx profile.ts。loadOrders(1) 一调用就开始执行,跑到 await findUser 的第一个 await 才返回一个未完成的 Promise;findRecommendations() 也是如此。所以传给 Promise.all 时,两条链已经都在等了,Promise.all 只负责等它们全部完成,按传入的顺序给出结果。TS 能从 Promise.all 的元组参数推断出 [[string, string[]], string[]],解构出来的每个变量都有准确的类型。

底层怎么实现。 每个 Promise 带一张反应列表。await p 时,引擎保存当前函数的寄存器和恢复位置(V8 复用了生成器的挂起机制),在 p 上挂一个反应,函数立即返回。p 完成时,反应不会马上执行,而是作为一个微任务(microtask)进入微任务队列。

一次 await 怎样回来

按编号看:① findUser 里的 sleep(100) 登记定时器,findUser 和等着它的 loadOrders 随之挂起,调用栈空出来;② 定时器到期,回调进入宏任务队列;③ 事件循环执行这个回调,sleep 的 Promise 完成,微任务队列里先排进 findUser 的延续,它的 Promise 完成后再排进 loadOrders 的延续;④ 当前宏任务一结束,事件循环就清空微任务队列,延续回到调用栈,从 await 的下一句继续。

Node 的事件循环由 libuv 驱动,每轮依次经过几个阶段:定时器(timers)、待处理的回调、轮询(poll,等待和处理 I/O 完成)、检查(check,setImmediate 的回调)、关闭回调。每执行完一个回调,Node 都先清空微任务队列再执行下一个。所以 await 之后的代码总比下一个定时器、下一个 I/O 回调先跑;反过来,一个不断产生新微任务的循环会让定时器和 I/O 永远轮不上。

  • forEach 里 await 不会等:names.forEach(async (n) => { await sleep(10); done.push(n); }) 之后立刻打印 done.length 得到 0。forEach 丢掉了回调返回的 Promise。要顺序执行用 for…of,要并发用 Promise.all(names.map(…))。
  • 未处理的拒绝让进程退出:一个 async 函数抛了异常,返回的 Promise 被拒绝,却没有任何地方 await 它或挂 .catch。Node 15 起默认把它当成未捕获异常,打印错误和栈后以退出码 1 结束,别的定时器和请求都不再执行。
  • 一个 Promise 被拒绝,Promise.all 立刻跟着拒绝,但其他 Promise 不会被取消,它们仍在后台跑完,只是结果没人要;要全部结果用 Promise.allSettled,要取消得配合 AbortSignal(第 21 章)。

C# 5(2012 年)随 .NET 4.5 加入 async/await,是这种写法第一次进入主流语言。Task 和 Task<T> 来自 .NET 4(2010 年)的任务并行库,代表一个将来完成的操作。

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
var ordersTask = LoadOrders(1); // 一调用就开始执行,返回的是正在进行的 Task
var recsTask = FindRecommendations(); // 和订单无关,同时开始
await Task.WhenAll(ordersTask, recsTask); // 两条链都完成
var (user, orders) = await ordersTask; // 已经完成,直接拿结果
var recs = await recsTask;
Console.WriteLine($"用户:{user}");
Console.WriteLine($"订单数:{orders.Count}");
Console.WriteLine("推荐:" + string.Join(", ", recs));
static async Task<(string, List<string>)> LoadOrders(int userId)
{
var user = await FindUser(userId); // 先要用户
var orders = await FindOrders(user); // 订单依赖用户,只能等上一步
return (user, orders);
}
static async Task<string> FindUser(int userId)
{
await Task.Delay(100); // 模拟查询:交给计时器,不占线程
return new Dictionary<int, string> { [1] = "alice" }[userId];
}
static async Task<List<string>> FindOrders(string user)
{
await Task.Delay(100);
return new Dictionary<string, List<string>> { ["alice"] = ["A-1", "A-2", "A-3"] }[user];
}
static async Task<List<string>> FindRecommendations()
{
await Task.Delay(150);
return ["书", "咖啡"];
}
// 输出:
// 用户:alice
// 订单数:3
// 推荐:书, 咖啡

运行:dotnet run profile.cs。两个任务先启动、后等待,这是 C# 的惯用并发写法;Task.WhenAll 的两个参数类型不同,所以先等全部完成,再分别 await 已完成的任务取结果,第二次 await 不会再等。

底层怎么实现。 编译器把每个 async 方法改写成一个状态机类型,实现 IAsyncStateMachine,局部变量变成它的字段,MoveNext() 里一个 switch 按 state 字段跳到上次停下的地方,和第 13 章的迭代器同一种改写。

编译器生成的状态机

看 LoadOrders 的两个 await:每个对应一个状态。MoveNext() 走到 await 时先问 IsCompleted,已完成就直接往下,不挂起;未完成就记下 state、通过方法构建器(AsyncTaskMethodBuilder)登记延续,然后 return。被等待的任务完成后,延续再调一次 MoveNext(),从新的 state 继续。最后 SetResult 让外面拿到的那个 Task 完成。

状态机在 Release 配置下编译成结构体,方法同步完成时完全不分配;第一次真的要挂起时,运行时才把它装箱搬到堆上。Debug 配置下编成类,便于调试器编辑并继续。dotnet run profile.cs 默认按 Debug 构建,本机用反射检查得到的是类,加 -c Release 后是结构体。延续在哪里跑取决于 await 时的同步上下文(SynchronizationContext):控制台程序和 ASP.NET Core 没有,延续在线程池线程上执行,await 前后可能换了线程;WPF、WinForms 有,延续被送回 UI 线程。库代码写 await t.ConfigureAwait(false),就是告诉运行时不必回到原上下文。ValueTask<T> 用于经常同步完成的方法,命中缓存时直接带着结果返回,省掉一个 Task 对象。

  • async void:调用者拿不到 Task,无法等它,也接不住它的异常。本机让一个 async void 方法在 await 之后抛异常,外面的 try/catch 没起作用,异常在线程池线程上抛出,打印 Unhandled exception. System.InvalidOperationException 后进程直接终止。async void 只用于事件处理器。
  • 在有同步上下文的地方用 .Result 或 .Wait():UI 线程阻塞在 .Result 上等任务,任务的延续却要回到 UI 线程才能执行,双方互相等,程序卡死。控制台程序不会这样死锁,但会白占一条线程池线程。
  • 在 async 方法里调用异步方法而不 await:编译器给出警告 CS4014,中文版写作“由于此调用不会等待,因此在此调用完成之前将会继续执行当前方法”。这个任务的异常没人观察,会被悄悄丢掉。

Go 没有 async/await,也没有 Promise 类型。所有函数都用阻塞的写法,需要同时做的事用 go 放进另一个 goroutine,结果通过通道或 sync.WaitGroup 汇合。

package main
import (
"fmt"
"strings"
"time"
)
func findUser(userID int) string {
time.Sleep(100 * time.Millisecond) // 模拟查询:只挂起当前 goroutine
return map[int]string{1: "alice"}[userID]
}
func findOrders(user string) []string {
time.Sleep(100 * time.Millisecond)
return map[string][]string{"alice": {"A-1", "A-2", "A-3"}}[user]
}
func findRecommendations() []string {
time.Sleep(150 * time.Millisecond)
return []string{"书", "咖啡"}
}
func main() {
recsCh := make(chan []string, 1) // 留一格缓冲:没人接收时发送方也不会卡住
go func() { recsCh <- findRecommendations() }() // 和订单无关,另开一个 goroutine
user := findUser(1) // 先要用户:普通的函数调用
orders := findOrders(user) // 订单依赖用户,只能等上一步
recs := <-recsCh // 在这里汇合
fmt.Printf("用户:%s\n", user)
fmt.Printf("订单数:%d\n", len(orders))
fmt.Println("推荐:" + strings.Join(recs, ", "))
}
// 输出:
// 用户:alice
// 订单数:3
// 推荐:书, 咖啡

运行:go run profile.go。findUser 和 findOrders 是普通函数,同一个函数既能直接调用,也能放进 go 后面并发执行,调用方决定要不要并发,被调用的函数不用改签名。这就是“函数没有颜色”:Go 里不存在只能被某一类函数调用的函数。

底层怎么实现。 第 16 章讲过 G、M、P:goroutine 在 time.Sleep、通道或锁上阻塞时,运行时只挂起这个 G,系统线程 M 去执行别的 G。网络读写在运行时内部是非阻塞的,没就绪就把 G 挂到网络轮询器(netpoller)上,就绪后放回运行队列。Python 和 JS 里由 await 显式标出的挂起点,在 Go 里藏在每一次可能阻塞的调用里;事件循环做的事,Go 的调度器也在做,只是程序员看不到。这能成立,是因为 goroutine 足够便宜:初始栈只有几 KB,不够时运行时复制到更大的栈上,一台机器上开几十万个很平常,于是“每件事一个 goroutine、阻塞着等”就是可行的写法。

  • goroutine 泄漏:如果这段逻辑放在一个普通函数里(比如每个请求调用一次的处理函数),recsCh 没有缓冲,函数在查用户出错时提前返回、不再接收,发推荐的那个 goroutine 就永远阻塞在发送上。本机让这样的函数调用 100 次,runtime.NumGoroutine() 得到 101,不报任何错,只表现为内存上涨。留一格缓冲,或者用 context 通知它退出(第 21 章)。
  • 主 goroutine 不等就退出:main 返回时整个程序结束,还没跑完的 goroutine 直接消失,没有“未处理”的警告。
  • 被调用的函数里有真正阻塞线程的系统调用或 cgo 调用时,挂起的是整条线程,运行时只能另起线程接手 P;这类调用多了,线程数会涨上去。
维度 Python JS/TS C# Go
支持程度 原生 async/await,标准库 asyncio 原生 async/await 和 Promise 原生 async/await,标准库 Task 没有,用 goroutine 代替
实现机制 生成器式协程 + 单线程事件循环 引擎挂起函数 + 微任务队列 编译器生成状态机 + 线程池 运行时调度 goroutine + netpoller
await 挂起的是什么 协程的帧 函数的寄存器和恢复位置 状态机的 state 和字段 没有 await;阻塞时挂起整个 goroutine
延续在哪个线程跑 事件循环所在的那一条 主线程 线程池线程,或回到原同步上下文 任意一条 M
并发等待多个任务 asyncio.gather Promise.all Task.WhenAll go + 通道或 WaitGroup
函数是否有颜色 有 有 有 没有
阻塞调用的后果 整个事件循环停住 主线程停住,所有回调等待 占住一条线程池线程,多了会饥饿 只挂起这个 goroutine

差别最大的是最后一行:同一个阻塞调用,在 Python 和 JS 的异步代码里会卡住所有任务,在 C# 里只浪费一条线程,在 Go 里就是正确的写法。

async/await 的主流形态来自 C#。F# 在 2007 年已经有异步工作流,C# 5 在 2012 年把这个思路做成 async/await 两个关键字,目标是让 Windows 上的 UI 程序在等网络时界面不卡住,又不用把代码拆成回调。Python 3.5(2015 年)和 JavaScript 的 ES2017 先后跟进,三种语言用的关键字几乎一样,底下的实现却各随运行时:CLR 不能暂停一个方法的栈帧,C# 就在编译期把方法拆成状态机,和 yield return 一样;CPython 的帧由解释器自己管理、不在 C 栈上,生成器早已能暂停,协程顺势建在上面;V8 已经有生成器的挂起和恢复,async 函数复用它,再把恢复动作交给规范里本来就有的微任务队列。

三者都选择显式的 await,是被各自的约束逼出来的。JS 和 Python 的 asyncio 只有一条线程执行用户代码,线程不能被阻塞,挂起点必须让调度者知道;C# 有线程池,但线程是操作系统线程,每条预留 MB 级的栈,一个服务器开不了几万条,等待时必须把线程还回去。显式挂起点还带来一个好处:在单线程的事件循环里,两次 await 之间的代码不会被打断,共享数据不必加锁。

Go 2009 年发布时,直接让运行时自己管理轻量的 goroutine 和可增长的栈,阻塞调用在运行时内部转成非阻塞,从根上避开了“线程太贵”这个前提。代价是运行时更重,调用 C 代码时要在 goroutine 栈和系统栈之间切换,开销比普通函数调用大得多;goroutine 在多条线程上并行执行,随时可能被抢占,共享数据一律要加锁或改用通道。

这些选择带来几条后果。第一,生态分裂:Python 的同步代码世界已经存在二十多年,asyncio 来得晚,于是 HTTP 客户端、数据库驱动常常有同步和异步两套,混用时要在 asyncio.to_thread 和 asyncio.run 之间来回转;C# 的 .NET 类库给很多 I/O 方法同时提供 Read 和 ReadAsync;JS 从一开始就以非阻塞 I/O 为主,分裂最轻,只有 Node 的少数 …Sync API 是例外;Go 没有这个问题。第二,调试:异步函数挂起后,原来的调用栈已经不在了,异常栈只能从最近一次恢复处开始。C# 和 V8 都会尽量把 await 链拼回栈跟踪,但链条底下接的是调度者而不是真正的调用者,本机 C# 的 async void 异常栈最底下就是 ThreadPoolWorkQueue.Dispatch;Go 的 goroutine 有真正的栈,崩溃时打印的就是普通的调用栈。第三,性能:每次挂起都有一次分配(Python 的任务和 Future、JS 的 Promise、C# 的状态机装箱),所以 C# 有 ValueTask,Python 3.12 加了 eager_task_factory 让能同步完成的任务不进事件循环。

Java 走了和 Go 相同的路,而且是在已有的线程 API 上补出来的。JEP 444 让虚拟线程(virtual thread)在 Java 21(2023 年)正式可用:虚拟线程由 JVM 调度,跑在少量平台线程(载体线程,carrier thread)上;虚拟线程在 JDK 的 I/O、sleep、锁上阻塞时,JVM 把它从载体线程上卸下,载体线程去跑别的虚拟线程。代码照样用阻塞写法,Thread 和 ExecutorService 的 API 都不变,函数也就没有颜色。

try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
Future<List<String>> recs = exec.submit(() -> findRecommendations());
Future<List<String>> orders = exec.submit(() -> findOrders(findUser(1))); // 阻塞写法,只卸下这个虚拟线程
System.out.println(orders.get().size() + " " + recs.get()); // 在这里汇合;get() 的受检异常处理从略
}

Java 21 里,虚拟线程在 synchronized 块里阻塞时会钉住(pin)载体线程,卸不下来;JDK 24 的 JEP 491 解决了这一点。

  • 有依赖的步骤按顺序 await,没依赖的先全部启动再一起等:Python 用 asyncio.gather 或 3.11 起的 TaskGroup,JS 用 Promise.all,C# 先拿到几个 Task 再 Task.WhenAll,Go 每件事一个 goroutine 加通道或 WaitGroup。
  • 异步要一路到底:Python 和 JS 的异步函数里不调用阻塞 API,C# 不用 .Result 和 .Wait();确实要调阻塞代码时,Python 用 asyncio.to_thread,C# 交给 Task.Run。
  • 限制同时进行的任务数:Python 用 asyncio.Semaphore,跨 await 保护共享状态用 asyncio.Lock;JS 标准库里没有现成的信号量,常见做法是起固定数量的工人(worker),从同一个队列里取任务;C# 用 SemaphoreSlim,WaitAsync 等待时不占线程;lock 块里不能 await(CS1996),SemaphoreSlim(1, 1) 可当异步锁;Go 用容量为 N 的带缓冲通道当信号量,或者起固定数量的工人 goroutine。
  • 每个启动的任务都要有人等:Python 保存 create_task 的返回值,JS 每个 Promise 都 await 或挂 .catch,C# 不写 async void、不忽略 CS4014,Go 每个 goroutine 都想好它的结果谁接收、出错时怎样退出。
  • 一个 Python 项目若大部分依赖都只有同步版本,用线程池往往比改成 asyncio 省事;需要同时维持十万级连接又不想给函数分颜色,Go 或 Java 的虚拟线程更合适。

并发等待三个延迟不同的任务 a、b、c(比如 a 等 120 毫秒、b 等 60 毫秒、c 等 90 毫秒),每个任务完成时返回自己的名字。三个全部完成后,按名字打印一行 全部完成:a b c。用 TS 和 C# 各写一遍。

提示:三个任务的完成顺序是 b、c、a,怎样保证打印出来仍是 a b c?Promise.all 和 Task.WhenAll 返回的结果数组按什么排列?总耗时应该接近哪个任务的延迟?

  • await 不阻塞线程:它把函数剩下的部分登记成延续,交出控制权,等待的对象完成后再由调度者恢复执行。
  • Python 的协程和生成器共用挂起机制,由 Task 在单线程事件循环上驱动;JS 的延续作为微任务回到唯一的主线程;C# 把 async 方法编译成状态机(Release 下是结构体,Debug 下是类),延续默认跑在线程池上。
  • Go 没有 async/await:goroutine 便宜,阻塞调用只挂起 goroutine,函数没有颜色,代价是要自己防 goroutine 泄漏。
  • 常见的坑都和实现挂钩:Python 忘了 await 只得到协程对象,在协程里阻塞会卡住事件循环;JS 未处理的拒绝让 Node 退出;C# 的 async void 让异常无处可接。
  • 差异来自约束:C#、Python、JS 都要在线程昂贵或只有一条线程的前提下等待,只能显式标出挂起点,于是函数分出颜色、库分出两套;Go 和 Java 21 的虚拟线程让运行时承担这件事。