第 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 怎么做
Section titled “Python 怎么做”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())的返回值扔掉后,事件循环对任务只持有弱引用,任务可能在中途被垃圾回收。官方文档的做法是把任务存进一个集合,完成时再移除。
JS/TS 怎么做
Section titled “JS/TS 怎么做”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)进入微任务队列。
按编号看:① 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# 怎么做
Section titled “C# 怎么做”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); // 一调用就开始执行,返回的是正在进行的 Taskvar 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 怎么做
Section titled “Go 怎么做”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 让能同步完成的任务不进事件循环。
旁注:别的语言
Section titled “旁注:别的语言”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 的虚拟线程让运行时承担这件事。