第 30 章 / 共 58 章 卷六 并发

async/await 与 future

第 28 章手动开线程,第 29 章用 channel 传消息,本章把"把一段计算扔到后台、回头取结果"压缩成两个关键字:async 把函数变成后台任务,await 把结果取回来。中间的信使是 future<T>——一张"结果提货单"。求值时机、状态查询、异常传播、取消三态、链式组合,全部实测。

本章导读

回看第 28 章数素数的流程:工厂闭包造入口、thread 创建、wait 等待、exitCode 取返回值——每一步都对,每一步也都是样板。本章的 async/await 把这套样板收进语言:async func 声明的函数,调用它不会原地执行,而是立刻在后台线程启动、并返回一张 future<T>"提货单";想取结果时 await 它——好了直接拿值,没好就等,失败了原地抛异常。没有线程句柄,没有 exitCode,没有工厂闭包(async 函数本来就能带参数)。本章依次讲:async 的声明与"调用即启动"的求值时机(实测证据)、await 的合法位置与规则、future 的完整方法集(状态、等待、错误、取消)、异常如何沿 future 传播、then/catch/finally 链式组合,以及最重要的性能常识——先全部发起、再统一 await 的并行模式。读完你会看到:28 章的 thread 是"机床",本章的 async 是"数控机床"——同一台发动机,操控台完全不同。

本章学习目标

会声明 async func(含各种返回类型与无返回值形态)并理解"调用即后台启动、返回 future"的求值时机;掌握 await 的三个合法位置(顶层、async func、async generator)与重复 await、临时 await 规则;会用 future 的 isPending/isDone/isFailed/isCancelled/wait/waitTimeout/state/error 方法;理解异常沿 future 传播(kind=AsyncError、原消息、重复 await 重复抛);掌握 task.cancel 的三态语义;会用 then/catch/finally 组合 future(回调签名要求);养成"先发起后 await"的并行习惯;知道 async 的参数/返回值限制与 class async 方法边界。

30.1 从 thread 到 future

第 28 章的 thread 模型里,"后台执行一段计算并取回结果"需要五样东西:入口函数(签名锁死 func() as int)、闭包或工厂传参、thread 句柄、wait、exitCode。async 模型把这五样合并成两样:

  • async func——什么签名都行,参数照带、返回值照写,加一个 async 前缀就获得了"后台执行"的能力;
  • future<T>——调用返回的提货单,T 就是函数声明的返回类型。 await 取货,取消、查询、链式加工都在这张单子上办。

两者的关系要摆正:async 不是取代 thread,而是站在 thread 肩膀上的任务抽象。底层每一次 async 调用仍然是一个真实线程(深入细节 1),所以你 28 章学的竞态防御规则在 async 函数体里照样适用——async 函数若写共享全局变量,该加锁还得加锁。第 29 章的 channel 也与 async 正交:channel 管"线程间送消息",future 管"取一次性结果"。第 31 章还会把 future 聚合成 task.all/race 与 TaskGroup,那是后话。

30.2 async func 声明与调用

在普通 func 前加 async 即得异步函数;声明的返回类型是什么,调用就返回 future<什么>;不写返回类型则返回"无值 future",await 它只等完成不取值:

async.xl
async func addAsync(int a, int b) as int
    return a + b
end

future<int> f = addAsync(2, 3)   // 显式类型接收
int v = await f                      // 5

auto g = addAsync(4, 6)             // auto 推断为 future<int>
println(await g)                     // 10

async func doneAsync()               // 无返回值:无值 future
    time.sleep(50)
end
await doneAsync()                     // 只等完成,不取任何值

返回类型的开放面相当宽:int/float/bool/string、class 实例、func 值、var,以及 array<int>/list<int>/set<int>/dict<int> 四种静态容器——官方测试套件对每种类型都锁了"首次 await、重复 await、临时 await"三样行为。

求值时机是本章第一个必记实测:调用 async 函数时函数体立即在后台开始执行,不是等到 await 才动。判别实验——调用后主线程先 sleep 300ms 再 await:

timing.xl(输出顺序)
[main] call
[main] sleep 300ms...
[work] body running        // 主线程还在睡,函数体已经在跑
[main] woke, now await
[main] done v=1

函数体的打印出现在主线程 sleep 期间——证明"调用即启动"。await 的角色因此非常单纯:它只是取结果,没好就等,绝不承担"启动"职责。这条规则直接决定 30.8 的并行写法。

30.3 await 的位置与规则

await 的合法位置有三个:顶层、async func 内、async generator 内。普通 func 与普通 generator 里使用会在编译期拦下(坑 1)。xlang 程序从顶层语句开始执行,所以本书所有示例直接在顶层 await,不需要包一层"异步 main"。

使用规则四条,全部实测:

  • 只接 future/task:await 一个 int、string 这类普通值直接编译报错(坑 2)。
  • 已完成可重复 await:同一个 named future 反复 await,结果稳定——future 是提货单,取过货单子不作废。
  • 支持临时 await:int v = await addAsync(8, 9)——调用、等待、取值写在一行,不必先命名 future。
  • 裸声明是 null future:future<int> f 未赋值就 await,运行期抛 AsyncError"await on null future"(可用 try/catch 接住,30.9)。

async 函数内部当然可以 await 别的 async 函数——调用链就这样一层层搭起来,异常也沿这条链传播(30.5)。

30.4 future 方法全集

future<T> 不只是"await 的靶子",它带一套完整的状态查询与等待方法:

future.xl(实测输出)
async func slow(int v) as int
    time.sleep(200)
    return v
end
future<int> f = slow(10)
println(f.isPending())       // true:还在跑
println(f.waitTimeout(10))   // false:10ms 内没等到
println(f.wait())            // true:阻塞等到完成(成功)
println(f.isDone())          // true:已完成(含失败/取消)
println(f.isFailed())        // false
println(f.state())           // 1
println(await f)             // 10:wait 之后仍可 await

方法语义一览:

方法返回语义
isPending()bool仍在执行
isDone()bool已结束(成功、失败、取消都算)
isFailed()bool以异常结束
isCancelled()bool被取消(30.6)
wait() / waitTimeout(ms)bool等待完成;成功 true,失败/超时 false
state()int1=成功,2=失败,3=取消(实测值)
error()string失败消息原文
cancel()bool请求取消(等价 task.cancel,30.6)

wait 系列与 await 的分工:wait 只等不问结果(返回成败 bool,不抛异常),await 既等又取(失败就抛)。想做"带超时的取结果",模式是 if f.waitTimeout(500) then v = await f end——先限时等,等到了再 await(此时必然立即返回)。

30.5 异常传播

async 函数里 throw,异常不会消失在后台线程里,而是存进 future,在 await 的那一刻原地重新抛出:

throw.xl(实测输出)
async func bad() as int
    throw "async boom"
    return 0
end
future<int> f = bad()
try
    int v = await f
catch e
    println(e.kind)       // AsyncError
    println(e.message)    // async boom:消息原文无损
end
// 重复 await 同一个失败 future,重复抛同一个异常

三条实测规则。一,异常种类统一为 AsyncError,消息保留 throw 原文;future.error() 拿到的也是这条原文。二,失败的 future 重复 await 重复抛——错误像结果一样被 future 记住。三,异常沿 await 链传播:outer() 里 await inner(),inner 抛了而 outer 不 catch,那么 outer 的 future 就带着 inner 的原始消息失败,await outer 的人接到 "inner boom"——和同步调用栈的异常冒泡完全同构,只是冒泡的"栈"由 future 链扮演。async 函数体内部的 try/catch/finally 照常工作(28 章语义不变),finally 里再抛会覆盖原异常——这些规则一条不改地搬进异步帧。

30.6 task.cancel 取消

task.cancel(f)(或等价的 f.cancel())向 future 发出取消请求。实测三态:

时机cancel 返回后续 await
pending(还在跑)true抛 AsyncError"future cancel requested."
已完成(成功或失败)false照常取值/抛原异常,不受取消影响
pending 取消后 worker 晚到完成—仍抛"future cancel requested."——晚到不覆盖取消态

两点要理解透。一,取消是"请求"不是"刺杀":后台函数体不会被中途掐死,它会跑到自然结束;取消改变的是 future 的结果态——await 的人拿到取消异常而非返回值。想真正中途停下,函数体要自己周期性检查协作信号(第 31 章 task 编排再展开)。二,取消态是终态且粘滞:实测 pending 取消后 sleep 等 worker 跑完,再 await 仍抛取消——worker 的真实完成不能覆盖已宣告的取消,这保证了"取消之后再 await"的行为可预测。

30.7 then/catch/finally

future 支持三个链式加工方法,每个都返回新 future,可以一路点下去:

chain.xl(实测输出)
async func produce() as int
    return 20
end
func plusTwo(int value) as int
    return value + 2
end
func recover(error e) as int
    println(e.kind + ": " + e.message)   // AsyncError: task boom
    return 77
end

// then:func(T) as U → future<U>
println(await produce().then(plusTwo))                    // 22

// then 接 lambda,可捕获外层变量
int delta = 100
println(await produce().then(func(int v) as string => "v=" + string(v + delta)))
// v=120

// catch:func(error) as T → 把失败恢复成 T 值;成功则跳过
println(await fail().catch(recover))                      // 77
println(await produce().catch(recover))                   // 20(recover 没运行)

// 一路链下去:then→then→catch→finally
println(await produce().then(plusTwo).then(plusTwo).catch(recover).finally(cleanup))
// 24

回调签名是硬约束(违反在编译期拦下,坑 3/坑 4):

方法回调签名返回语义
then(cb)func(T) as Ufuture<U>成功时对结果做变换;源失败则原样传播
catch(cb)func(error) as Tfuture<T>失败时用回调返回值恢复;源成功则原样通过
finally(cb)func()(无参无返回)future<T>成败都执行清理,保持原结果;回调自身抛错才覆盖

三处细节实测:catch 的参数是 error 类型(带 kind/message 字段),不是裸 string;then 支持重载选择——同名函数有 int 版和 string 版时按源 future 的 T 自动挑选;finally 的"覆盖"语义与同步 try/finally 一致:finally 里 throw "cleanup boom",await 拿到的就是它,原结果被顶替。

30.8 并行模式

本章最值钱的一句话:先全部发起,再统一 await。因为"调用即启动"(30.2),future 拿到手时任务已经在跑;若 await 完一个再调下一个,就把并行写成了串行。实测对照——两个各 sleep 400ms 的任务:

写法实测总耗时
并行:先取两个 future 再逐个 await0.472s(≈ 一次 400ms)
串行:await 完第一个才调第二个0.887s(≈ 两次 400ms)

批量场景用循环把 future 收进 typed 数组,再统一 await:

parallel.xl
array<future<int>> jobs
for i in range(1, 6)
    jobs.append(step(i))        // 五个任务全部在跑
end
int sum = 0
for j in range(0, jobs.length)
    sum += await jobs[j]        // 统一收割
end
println(sum)                    // 30

与 thread 模型对照:这里没有一个线程句柄、一句 wait、一次 exitCode,但底层同样是五个后台线程在真跑。async 把"开线程"降维成了"调函数",你要操心的只剩一件事——别提前 await。

30.9 边界与限制

四条实测边界,写代码前应该知道:

  • async 参数/返回值不能是不可复制类型:future、generator、channel、sem、thread 这些 move-only 句柄按值传入或返回都会在语义阶段被拒(坑 5)——异步帧要独立持有参数,复制不了的资源进不去。
  • class 里定义 async 方法当前有缺口:定义本身 check 通过,但调用 c.addAsync(5) 报 [checked] 找不到 class 方法——官方测试套件里 class 与 async 的组合都是"async 函数返回 class 实例",async 方法请写成接收 class 参数的普通 async func(已按缺陷上报)。
  • 裸声明的 future 是 null future:future<int> f 不赋值就 await,运行期抛 AsyncError"await on null future"。条件分支里可能留下未赋值分支时,用 try/catch 兜底或先赋默认值。
  • async 可以读顶层变量:与 thread 闭包一样,async 函数体能见全局——便利与竞态风险并存,28 章的防御准则原样适用。

实战:async 数素数

把第 28 章的四线程数素数原题用 async 重写:同样是 [2, 100000] 分四段、同样答案 9592,代码量对比一下:

primes_async.xl(实测 9592,约 0.17s)
async func countPrimes(int lo, int hi) as int
    int count = 0
    for n in range(lo, hi + 1)
        bool prime = n >= 2
        int d = 2
        while d * d <= n
            if n % d == 0 then
                prime = false
                break
            end
            d += 1
        end
        if prime then
            count += 1
        end
    end
    return count
end

// 四段并行:先发起四个 future
future<int> f1 = countPrimes(2, 25000)
future<int> f2 = countPrimes(25001, 50000)
future<int> f3 = countPrimes(50001, 75000)
future<int> f4 = countPrimes(75001, 100000)

// 再统一 await 汇总——可以连写
int total = await f1 + await f2 + await f3 + await f4
println("primes <= 100000: " + string(total))   // 9592

对照 28 章版本:工厂闭包没了(async 直接带参数)、thread 句柄没了、wait 没了、exitCode 没了,连"加锁汇总"都省了——每个任务的计数在自己帧里完成,主线程只对四个结果求和,共享可变状态从结构上消失。答案、耗时(约 0.17s)与 thread 版完全一致,因为发动机是同一台:四个后台线程。同样的并行,更少的仪式——这就是 async 存在的理由。

深入细节

1. async 编译成了什么:真线程,不是协程

cgen 证据说话。`async func addAsync(int a, int b)` 编译后变成三样东西:一个参数打包结构体 `xl_async_args_xl_fn_addAsync`(附带 free 函数,异步帧独立持有参数副本);一个 worker 函数 `xl_async_worker_xl_fn_addAsync`,解包参数、跑原函数体、把返回值写进 `xfuture_result`;外壳 `xl_fn_addAsync` 分配参数包、调用 xTaskRunThread(...)、把返回的 `xfuture*` 交给你。每一次 async 调用都对应一个真实后台线程——这不是 JS/Python 那种单线程事件循环协程,而是"线程即任务"的模型。含义有两层:并行是真的(30.8 的计时对照),成本也是真的(每个任务一个线程,别开几百个 async 去数素数——第 31 章的 task.group 提供结构化编排)。await 则编译为 xFutureWait + xFutureValue:先等 future 落地,再把结果值取出来。

2. 重复 await 为什么安全:结果值的 borrow 与 own

named future 可以反复 await、结果稳定,这背后是一套明确的所有权规则:future 完成时值存在 future 内部,对 named future 的 await 是"借用"——把内部值复制/引用出来给你,future 自己那份不销毁;而临时 await(`await addAsync(8, 9)`)的 future 没有名字、用后即弃,值被"接管"后整个 future 随之释放。条件表达式 `await (c ? namedF : makeNew())` 把这套规则演得最清楚:走 named 分支是借用(namedF 之后还能 await),走新建分支是接管。cgen 里能看到对应的 `xFutureValueIsOwned` 查询——运行库对每个 future 明确知道"这份结果归谁"。对 class、func、容器这些非平凡类型,规则同样成立(官方套件逐类型锁了重复 await)。

3. "await 不挂起":xlang 与主流协程模型的分野

在 JS/Python/C# 里,await 是挂起点:当前函数让出执行权,事件循环去跑别的任务,一个线程伺候成千上万个协程。xlang 的 await 不挂起任何东西——它就是一次阻塞等待(xFutureWait),与 thread.wait 同族;异步性全部来自"函数体早就在另一个线程里跑着"。这套设计的取舍:优点是模型直白——没有"函数染色"(async 传染性在 xlang 只剩签名一个前缀)、没有事件循环概念、调试时栈就是真栈;代价是并发度受线程数约束,且 await 的位置是真阻塞,嵌在深层调用里会一直占着当前线程。理解这个分野,后面读 async generator(31 章)时就不会把 yield 想象成事件循环调度——它跑的是同一套线程模型。

⚠️ 常见错误

坑 1:在普通 func 里 await
bad.xl
async func valueAsync() as int
    return 1
end
func bad() as int
    auto value = await valueAsync()
    return value
end

编译报错:bad.xl:6:18: [sem] await can only be used at top level, inside async func, or inside async generator(第 6 行)。await 的三个合法位置之外一律拒绝。正确做法:把调用方也声明为 async func(调用链整体异步化),或把取结果的动作挪到顶层。普通 generator 同样不行——要 async generator(第 31 章)。

坑 2:await 一个普通值
bad.xl
int x = await 42

编译报错:bad.xl:2:9: [sem] await 只能用于 future<T> 或 task<T> 表达式(第 2 行)。await 不是"取值"的通用语法糖,它只认 future/task 句柄。同步函数的返回值直接拿来用,别加 await。

坑 3:then 回调签名不符
bad.xl
func noReturn(int v)
    println(v)
end
future<int> m = produce().then(noReturn)

编译报错:bad.xl:11:34: [sem] future.then named callback must have one matching parameter and one return value(第 11 行)。then 的回调必须"一个匹配参数 + 一个返回值":参数类型要配得上源 future 的 T,返回值决定新 future 的 U。无参、无返回、多返回、参数类型不符,统统这一个报错。

坑 4:finally 回调带参数或返回值
bad.xl
func tidy(int v) as int
    return v
end
future<int> f = produce().finally(tidy)

编译报错:bad.xl:11:37: [sem] future.finally named callback must match func()(第 11 行)。finally 的回调必须是 func()——无参无返回。它只做清理,不看结果也不改结果;想处理结果用 then,想处理失败用 catch。

坑 5:给 async 函数传 channel/thread 等句柄参数
bad.xl
async func consume(channel<int> ch) as int
    // ...
    return 0
end

编译报错:bad.xl:1:16: [sem] 不可复制类型不能作为按值参数(第 1 行)。async 函数参数要进入独立持有的异步帧,future/generator/channel/sem/thread 这类 move-only 句柄复制不了,按值传递一律被拒(作返回值则报"不可复制类型不能作为返回值类型")。正确做法:让 async 函数自己在帧内创建 channel 并通过 future 之外的共享结构协作,或改用 thread + 闭包捕获(28/29 章的模式)——句柄活在顶层,任务只带数据。

本章小结

主题要点
async func前缀 async;调用即后台启动(不等 await),返回 future<T>;无返回类型则无值 future
await位置:顶层/async func/async generator;只接 future/task;named future 可重复 await;支持临时 await
future 状态isPending/isDone/isFailed/isCancelled;state() 1=成功 2=失败 3=取消;error() 取失败原文
等待wait()/waitTimeout(ms) 返回 bool 不抛异常;await 取值、失败则抛
异常kind=AsyncError,消息原文;重复 await 重复抛;沿 await 链传播
取消task.cancel/f.cancel;pending→true 且 await 抛"future cancel requested.";已完成→false 不影响结果;晚到不覆盖取消态
链式then: func(T) as U;catch: func(error) as T;finally: func() 保持原结果、自抛才覆盖
并行先全部发起再统一 await;批量收进 array<future<T>> 循环收割
边界参数/返回值禁 move-only 句柄;class async 方法有缺口;裸 future await 抛"await on null future"

复习题

  1. 调用 async 函数后,函数体何时开始执行——调用时、await 时,还是不确定?用哪个实验可以判别?
  2. await 可以出现在哪三个位置?为什么 xlang 的示例不需要"异步 main"包装?
  3. future.state() 返回 1、2、3 各代表什么?isDone() 为 true 时任务一定是成功了吗?
  4. 对一个 throw 失败的 future 重复 await 两次,会发生什么?异常的 kind 和 message 分别是什么?
  5. task.cancel 对一个 pending future 和一个已完成 future 分别返回什么?取消后 worker 晚到完成会覆盖取消态吗?
  6. 两个各耗时 400ms 的任务,"先取两个 future 再 await"与"await 完一个再调下一个"的总耗时各约多少?差距的根源是什么?

编程练习

练习 1:容错汇总器

写 async func job(int x):x == 3 时 throw "job3 failed",否则返回 x * 10。并行发起 job(1..4) 四个任务,用 array<future<int>> 收集后循环 await,用 try/catch 让单个失败不影响其余汇总。参考结果:ok sum=70、failed=1(10+20+40,job3 被 catch 吸收)。

练习 2:进度轮询器

慢任务 slowJob() sleep 300ms 返回 42。主线程不直接 await,而是 while not f.waitTimeout(60) 循环、每轮 print("."),等到后再 await 取值。参考结果:打印 4 个点后输出 result=42。体会 wait 与 await 的分工:一个探测,一个取货。

练习 3:加工流水线

用 then/catch/finally 搭一条链:async 任务返回 20 → then 加 2 → then 乘 3 → catch 把任何失败恢复为 0 → finally 打印"pipeline done"。分别用成功源和 throw 源各跑一遍。参考结果:成功链输出 66 与 pipeline done;失败链 catch 运行、最终得 0,pipeline done 同样打印。