本章导读
第 30 章结尾留了一个口子:批量任务我们用手写循环收集 array<future<T>> 再逐个 await——能用,但"等全部完成"这种集团军操作该有正式编制。本章上半补齐它:task.spawn 把任意普通函数直接后台化(连 async 声明都省),task.all 一组 future 等齐收数组,task.race/whenAny 先到先得,TaskGroup 给一组长寿命任务提供结构化编组与关闭。下半换主角:生成器。普通函数一口气 return 全部结果,生成器用 yield 一件一件地交——调用时不执行、迭代才推进、退出即关闭,惰性让它能表达"无穷数列"这种数组装不下的东西;加上 await 能力就是 async generator,于是"从异步来源逐件拉数据"也有了标准写法。实战把两半焊在一起:生成器供料、task.all 并发加工的批处理管道。读完本章,卷六四件武器——thread、channel、async/future、task/generator——就全部到手了。
会用 task.spawn 把具名 func 值/lambda 后台化并逐传参数;掌握 task.all(等全部、按输入顺序收 array<T>、可重复 await)与 task.race/whenAny(先到先得,一个给值一个给索引)的语义差异;理解取消在 group 与 source 之间的传播规则;会用 TaskGroup 的 add/waitTimeout/count/valid/child/joinAsync/reapCompleted/close 管理批量任务;会用 generator<T> 与 yield 写惰性序列并用 for-in(含索引)消费;牢记单次消费铁律(循环退出即关闭)与元素类型限制;会写两种形态的 async generator 并用 for await 消费;能在生成器与返回数组之间做选型。
31.1 从 future 到编排
第 30 章的 future 是"一张提货单",本章的 task API 回答三个批量问题:
- 怎么把普通函数直接变成任务?——task.spawn:不用写 async 声明,任何签名完整的 func 值扔进后台线程,拿回 future。
- 怎么等一组任务?——task.all 等全部、task.race 抢第一、task.whenAny 抢第一并报告是谁。
- 怎么管理一组长寿命任务?——TaskGroup:可以加人、等人、开子组、关门,比散装 future 数组多一层生命周期。
它们与 future 的关系:task 函数生产与消费的都是 future<T>,30 章的 await/cancel/then/catch/finally 全部原样适用——编排不是新类型,是 future 之上的组合运算。
31.2 task.spawn
task.spawn(fn, 参数...) 把普通函数放到后台线程执行,返回 future。函数以 func 变量或 lambda 的形式给出,参数逐个跟在后面:
func delayed(int value, int delayMs) as int time.sleep(delayMs) return value end // 先存 func 值,再 spawn func(int, int) as int job = delayed future<int> f = task.spawn(job, 7, 50) println(await f) // 7 // lambda 直接传 future<int> g = task.spawn(func(int x) as int => x * 2, 21) println(await g) // 42
一个容易踩的门槛:裸函数名不行——task.spawn(delayed, 7, 50) 报 [sem] task.spawn first argument must be a func value with a complete signature,先赋给 func 变量即可(官方用例也是这个写法)。spawn 与手写 async func 的取舍:函数已经存在且签名合适时用 spawn 零改造;要 await 别的 future、要精细控制时才写 async func。spawn 产生的 future 与 async 产生的完全同族——cancel、then、入组 task.all 都可以。
31.3 task.all 等全部
task.all([f1, f2, ...]) 接收 future 数组(字面量或 array<future<T>>),等全部完成,返回 future<array<T>>——await 它得到按输入顺序排列的结果数组:
async func slow(int v, int ms) as int time.sleep(ms) return v end // 数组字面量输入;结果按输入顺序,与完成先后无关 array<int> all = await task.all([slow(1, 100), slow(2, 50), slow(3, 10)]) println(all[0]) // 1:最慢的那个仍在首位 // typed 数组输入;named group future 可重复 await array<future<int>> jobs = array[slow(10, 20), slow(20, 10)] future<array<int>> grp = task.all(jobs) grp.wait() array<int> a1 = await grp array<int> a2 = await grp // 重复 await,结果稳定
约束两条:数组非空、所有 future 的完成值类型一致(违反在编译期拦下,坑 4)。任何一个 source 失败,group 传播那个原始异常(慢的同伙不受影响,可以继续各自 await)。30.8 手写循环收割的模式,正式场合就换成 task.all——语义更明确,还自带"按输入顺序"的保证。
31.4 task.race 与 whenAny
这对兄弟都是"先到先得",区别在给你什么:
| 函数 | await 得到 | 典型用途 |
|---|---|---|
| task.race([...]) | T:最快完成者的结果值 | 多源择优:镜像下载、超时兜底 |
| task.whenAny([...]) | int:最快完成者的输入索引 | 需要知道"是谁赢了"再决定下一步 |
int winner = await task.race([slow(1, 200), slow(2, 50), slow(3, 300)]) println(winner) // 2:50ms 的最快 int idx = await task.whenAny([slow(30, 200), slow(40, 50), slow(50, 300)]) println(idx) // 1:第二路(索引从 0 起)
race 的陪跑者会被请求取消(协作式,与 30.6 同规则)——赢家的值到手后,loser 的后续 await 抛"future cancel requested."。whenAny 只报告索引不动 source,想取消谁你自己决定。参数约束与 task.all 相同:非空、完成值类型一致。
31.5 取消传播
取消一组任务有两条路,实测行为各不相同:
| 操作 | group 的 await | 其他 source |
|---|---|---|
| task.cancel(group) 取消整组 | 抛"future cancel requested." | 被同步取消,后续 await 同抛取消 |
| 入组后 task.cancel(某个 source) | group 进入取消态,await 抛取消 | 不受影响,各自正常完成 |
future<int> s1 = slow(10, 50) future<int> s2 = slow(20, 400) future<array<int>> grp = task.all([s1, s2]) task.cancel(s2) try array<int> r = await grp catch e println(e.message) // future cancel requested. end println(await s1) // 10:无辜者照常
设计直觉:task.all 承诺"给齐一组结果",任何一票作废承诺就无法兑现,所以一票取消整组即废——但它不搞连坐,未取消的 source 该交付还交付。记住 task.cancel 只认 future/task:往里面塞 channel、thread、generator 都在编译期拒绝([sem] task.cancel 参数必须是 future<T> 或 task<T>)。
31.6 TaskGroup
task.all/race 是"一次性合影"——组建立刻、成员固定。要"边跑边加人、统一管理、关门打烊"的长寿命编组,用 task.group():
TaskGroup g = task.group() println(g.valid) // true println(g.count) // 0 println(g.add(123)) // false:只收 future future<int> a = task.spawn(job, 10, 30) future<int> b = task.spawn(job, 20, 60) g.add(a) // true g.add(b) // true,count=2 println(g.waitTimeout(2000)) // true:等全员完成,count 归零 // 子组:parent 可以孵化 child,各自编组 TaskGroup child = g.child() child.add(task.spawn(job, 99, 20)) future<null> done = child.joinAsync() // 异步等待全组 await done println(child.reapCompleted()) // 1:收割已完成数 // 关门:close 后 add 返回 false g.close() println(g.add(a)) // false println(g.joinTimeout(100)) // true:已关闭且清空,立即会合
方法/属性一览:valid 句柄有效、count 在组任务数、add(future) 加人(非 future/已关闭返回 false)、waitTimeout(ms) 限时等全员、child() 开子组、joinAsync() 返回 future<null> 供 await、joinTimeout(ms) 限时会合、reapCompleted() 收割已完成数、close() 关门。与 task.all 的分工:一次性并发批处理用 all;服务式的"任务来了就加、定期收割、收工关门"用 TaskGroup。
31.7 generator 与 yield
把函数返回类型写成 generator<T>,它就不再"一口气算完返回",而是用 yield 一件一件交货;调用方用 for-in 逐件消费(支持带索引的双变量形态):
func nums(int count) as generator<int> int i = 0 while i < count i += 1 yield i end end int total = 0 for v in nums(3) total += v end println(total) // 6 for i, v in nums(3) println(string(i) + " -> " + string(v)) // 0->1 1->2 2->3 end
规则要点:yield 只能出现在返回 generator<T> 的函数体内(坑 1);yield 的值类型必须匹配 T(坑 3);函数里可以用裸 return 提前收尾(后续 yield 不再执行),但不能携带返回值(坑 2);生成器内 throw 会在消费方的迭代处原样抛出。元素类型开放 bool/整数/浮点/string/time/point——string 与 point 本章实测可用(var、class、func、静态容器被拒,报错口径与 channel 同款)。
31.8 惰性与单次消费
生成器有两条铁律,都是实测钉死的。铁律一,惰性:调用时函数体一行都不执行,每次迭代推进到下一个 yield 停住,break 后剩余部分永远不再跑:
[main] before call [main] after call, before iterate // 调用已返回,函数体纹丝未动 [gen] body started // 第一次迭代才启动 [main] got 1 [gen] after yield 1 [main] got 2 // break:after yield 2 与 body ending 永不出现 [main] after loop
与 async 的"调用即启动"恰好相反——同一个语言里两种求值时机,别记混:async 是先跑起来等你取,generator 是你拉一下它动一下。
铁律二,单次消费:for 循环退出即关闭——无论是耗尽还是 break。已关闭的生成器再迭代,抛 GeneratorError"iterate null generator"(不捕获则进程以退出码 1 终止);裸声明未赋值的 generator 迭代同样抛这个错。没有"暂停后继续"的公开操作:想消费多少,在同一次 for 循环里决定。这条限制是刻意的(深入细节 3),写代码时的推论是:生成器随用随造,别囤。
31.9 状态与无限流
生成器函数体内的局部变量在两次 yield 之间原样保持——这正是它能表达"无穷数列"的原因:数组必须装得下才存在,生成器只记"下一个怎么算":
func fib() as generator<int> int a = 0 int b = 1 while true // 无限循环不可怕:惰性只在你拉的时候走一步 yield a int next = a + b a = b b = next end end int n = 0 int sum = 0 for v in fib() print(string(v) + " ") // 0 1 1 2 3 5 8 13 21 34 sum += v n += 1 if n == 10 then break // 限量靠 break(同时关闭生成器) end end println(sum) // 88
多个生成器实例状态各自独立(实测:fib() 开两个实例,一个只拉首件得 0,另一个拉到 8,互不干扰)。一个边界要知道:generator 是 move-only 句柄,不能作为参数传递——想写"过滤生成器"接收另一个生成器会吃 [sem] generator 参数当前只支持 ...;管道组合要写在一个函数体内(练习 2 演示)。
31.10 async generator
生成器体内要用 await(从异步来源逐件拉数据),就是 async generator。两种声明形态完全等价,消费端用 for await(也支持带索引):
async func addAsync(int a, int b) as int time.sleep(20) return a + b end // 形态一:async func ... as generator<int> async func streamA(int start) as generator<int> int first = await addAsync(start, 1) yield first yield await addAsync(first, 2) end // 形态二:func ... as async generator<int> func streamB(int start) as async generator<int> int first = await addAsync(start, 10) yield first yield await addAsync(first, 20) end int totalA = 0 for await v in streamA(100) totalA += v end println(totalA) // 204(101 + 103) for await i, v in streamB(1000) println("B[" + string(i) + "]=" + string(v)) end // B[0]=1010 B[1]=1030
实测要点:async generator 同样惰性(调用不执行,迭代才推进);for await 要求对方是 generator<T>(拿数组喂它报 [sem] for await requires generator<T>),但消费端 for 与 for await 对 sync/async 两种生成器都兼容——写法自由,for await 更像一种"这里面有异步"的阅读提示。throw、return、单次消费规则与同步生成器一致。
31.11 生成器 vs 数组
"返回一组值"有两条路,选型对照:
| 返回 array<T> | 返回 generator<T> | |
|---|---|---|
| 求值时机 | 调用时全部算完 | 惰性,迭代一件算一件 |
| 内存 | 全量驻留 | 只留当前一件与续算状态 |
| 无穷序列 | 不可能 | 天然支持(break 限量) |
| 重复消费 | 随便遍历 | 一次性,退出即关闭 |
| 随机访问/长度 | 有 | 无(只能顺序拉) |
经验法则:结果集小且要反复用、要按下标取——返回数组;数据量大、逐件处理、边算边用、或者根本没有"全部"这个概念(无限流、实时流)——生成器。生成器还是"关注点分离"的好工具:生产逻辑(怎么算出下一个)与消费逻辑(拿到每个做什么)各自独立演化,中间的接口只有 yield 出来的 T。
实战:批处理管道
把本章两半焊起来:生成器惰性供料,每拉出一批就 task 化并发加工,task.all 统一收割。8 批任务各含 50ms 模拟 I/O——串行要 400ms 起步,并发实测 0.143s 收工:
// 供料:惰性产生批次 func batches(int count) as generator<int> int i = 0 while i < count i += 1 yield i * 100 // 每批的加工基数 end end // 加工:对 [base, base+100) 内奇数计数,sleep 模拟 I/O async func process(int base) as int int count = 0 for n in range(base, base + 100) if n % 2 == 1 then count += 1 end end time.sleep(50) return count end // 管道:拉一批发一批,全部发出后统一收割 array<future<int>> jobs for base in batches(8) jobs.append(process(base)) end array<int> results = await task.all(jobs) int total = 0 for i in range(0, results.length) total += results[i] end println(total) // 400(8 批 × 50 个奇数)
这个结构的美妙之处在于三层各司其职:生成器决定"有什么活"(改供料逻辑不动加工)、async func 决定"怎么干"(改加工逻辑不动编排)、task.all 决定"怎么会合"(改汇总逻辑不动前两层)。卷六到此收官:thread 给你发动机,channel 给你传动轴,async/future 给你操控台,task 与 generator 给你编队与流水线——四轮齐了。
深入细节
取消一个 source,整组进入取消态——初看霸道,细想必然。task.all 的契约是"交付一组按输入顺序排列的结果":N 个位置缺了任何一个,array<T> 就拼不出来,部分交付反而让消费方处理半截数据更危险。所以语义选择是:组内任何一票失败/取消,group 整体失败/取消,原始异常原样传播;但不连坐——其他 source 是独立 future,该完成完成、该交付交付,你想抢救谁可以单独 await 谁。race 的镜像规则是"赢家通吃":胜者交付结果,loser 被协作式取消,因为它们的结果已经没有意义了。两条规则共同的底色:future 的结果态一旦宣告(成功/失败/取消)就是终态,晚到的真相不能改写——30.6 的取消粘滞性在编排层原样成立。
30 章深入细节说"xlang 的 async 不是协程",本章可以把话说全:协程在 xlang 里是有的——它藏在生成器里。cgen 证据:generator 编译为 XL6_Generator 结构体,核心成员 xcoro co 是 XRT 协程句柄,配套 xrtCoCreate / xrtCoResume / xrtCoCancel / xrtCoGetState 四操作;每次 for 迭代本质是一次 xrtCoResume——把协程推进到下一个 yield 并带回值。至此 xlang 的并发谱系完整:thread/async 是抢占式真线程(并行),generator 是协作式真协程(切换),channel 是跨两者的传送带。生成器与消费方在同一线程里交替执行,所以生成器内访问局部状态无需任何同步——这也是它"状态保持"毫不费力的底层原因。
很多语言允许"迭代器暂停后换个人继续",xlang 生成器为什么一刀切成"循环退出即关闭"?看 XL6_Generator 的成员就明白:协程句柄、执行帧、当前值、错误对象——一整套要精确释放一次的运行时资源。允许多方交替消费,就得引入引用计数与竞态防护(两个线程同时 resume 同一协程是什么后果?);允许"暂停后挂着",就得回答"谁负责最终关闭"。RC0 的选择是把生命周期绑定到唯一消费点上:for 循环进场即接管、出场即关闭,句柄本身 move-only 不可复制、不可传参——所有权没有第二个人沾手,泄漏与双释在结构上就不存在。代价你已看到:不能分段消费、不能管道传接。这个取舍与第 27 章的拷贝语义、29 章 channel 的 move-only 一脉相承:xlang 宁可限制组合方式,也要让资源归谁、何时灭一目了然。
⚠️ 常见错误
func bad() as int yield 1 end
编译报错:bad.xl:2:5: [sem] yield 必须位于返回 generator<T> 的函数体内(第 2 行)。yield 是生成器函数的专属语法,普通函数、顶层语句里都不许出现。正确做法:把返回类型改成 generator<T>,return 全部换成 yield(或裸 return 收尾)。
func bad() as generator<int> yield 1 return 99 end
编译报错:bad.xl:3:5: [sem] generator 函数中的 return 不能携带返回值(第 3 行)。生成器的"产出"全部走 yield,return 只负责提前收尾——写裸 return 即可。想让 99 也被消费到,改成 yield 99 再裸 return。
func bad() as generator<int> yield 1.5 end
编译报错:bad.xl:2:11: [sem] yield 表达式类型和 generator<T> 元素类型不匹配: target int, value float(第 2 行)。生成器是静态类型流水线,不做隐式转换。另注意元素类型只开放 bool/整数/浮点/string/time/point——yield 一个 class 实例报"generator<T> 当前只支持 ... 具备稳定复制语义的元素类型"。
array<int> r = await task.all([])
编译报错:bad.xl:1:21: [sem] task.all/task.race 参数必须是非空且完成值类型一致的 array<future<T>> 或 future<T> 数组字面量(第 1 行)。三种触发:空数组、元素不是 future、多个 future 的完成值类型不一致。正确做法:空集合场景在调用前先判 length;类型不一致时用 then 把各 future 映射成同一类型再入组。
channel<int> ch = channel::<int>(1) task.cancel(ch)
编译报错:bad.xl:2:13: [sem] task.cancel 参数必须是 future<T> 或 task<T>(第 2 行)。task.cancel 只认 future/task 句柄。channel 用 close() 关门(29 章),thread 用 stop() 请求停止(28 章)——每种资源句柄各归各的生命周期 API,别串门。
本章小结
| 主题 | 要点 |
|---|---|
| task.spawn | func 变量/lambda 后台化,参数逐传;裸函数名被拒;产物与 async future 同族 |
| task.all | 等全部;返回 future<array<T>> 按输入顺序;named group 可重复 await;非空且类型一致 |
| task.race / whenAny | 先到先得;race 给 T 值并取消 loser,whenAny 给输入索引 |
| 取消传播 | cancel group 全组取消;cancel 单个 source 整组作废但不连坐;task.cancel 只认 future/task |
| TaskGroup | valid/count/add/waitTimeout/child/joinAsync/joinTimeout/reapCompleted/close;关门后拒绝加人 |
| generator | 返回 generator<T> + yield;for-in(含索引)消费;惰性;单次消费退出即关闭 |
| 元素类型 | bool/整数/浮点/string/time/point(string/point 实测可用);var/class/func/容器被拒;move-only 不可传参 |
| async generator | 两种等价形态;for await 消费(含索引);同样惰性;for await 只认 generator<T> |
| 选型 | 反复用/随机访问→数组;大流量/逐件/无限流→生成器 |
复习题
- task.spawn 的第一个参数接受什么形态?为什么裸函数名会被拒?
- task.all 的结果数组按什么顺序排列?named group future 能否重复 await?
- task.race 与 task.whenAny 的 await 结果分别是什么?race 对陪跑者做什么?
- 入组后取消其中一个 source,group 和其他 source 各自什么下场?
- 调用一个生成器函数时函数体执行吗?for 循环 break 之后再迭代该生成器会怎样?
- 生成器与返回数组在内存占用和重复消费上有什么根本差别?什么场景必须选生成器?
编程练习
slowQuery() 要 2000ms 才返回"query result",timeoutGuard(ms) 过 ms 毫秒返回"TIMEOUT"。用 task.race 实现"300ms 内查不到就放弃"。参考结果:输出 TIMEOUT。进阶:把 race 换成 whenAny,按索引打印"赢家是第几路"。
在 evenFib() 生成器内联完成"斐波那契 + 只留偶数"(生成器 move-only 不能传参,组合写在一个函数体内),消费端取前 6 个。参考结果:0 2 8 34 144 610。体会:无限流 + break 限量 = 无限序列的有限视图。
task.spawn 启动 5 个"下载任务"(各 sleep 随机 50~250ms 后返回字节数),全部 add 进 TaskGroup;主线程 waitTimeout(2000) 会合后打印每个结果与总和,最后 close 并验证 add 返回 false。提示:随机区间用 random.range(50, 250)。