# 并发等待:先启动,再收齐结果
学习目标:同时发起独立操作,按输入顺序收集结果,并在失败时仍等待所有已启动任务结束。
前置知识:函数、异常、数组;需要知道 await 会挂起当前异步任务,不等于阻塞整个线程。本文选择 Python 与 JavaScript 的事件循环,全部使用标准库,不引入 HTTP 客户端。服务是本机事件门闩模拟的异步 I/O,不是公网请求,也不测网络性能。
# 问题与边界
输入为有限的服务名称列表。本例 alpha、beta 成功,bad 表示可预期的服务失败;成功结果按输入顺序返回,不依赖完成顺序。空列表返回空列表。有任何失败时,整批不返回部分成功值,而是在全部任务结束后报告第一个输入位置上的错误。
门闩先关闭,所有服务都被调度后才释放。这使等待真实交叠,又无需依赖毫秒数和调度顺序。结束计数在 finally 中更新,调用方断言返回或抛出前所有子任务已经清理。无限列表、请求超时和主动取消不在本篇契约内,见超时与取消。
# 关键差异
| 维度 | Python | JavaScript |
|---|---|---|
| 开始执行 | 协程需包装成 Task 或交给 gather | 调用 async 函数立即执行至首次挂起 |
| 等待全部结束 | gather,return_exceptions=True | Promise.allSettled |
| 失败出口 | 收齐后重新抛出异常 | 收齐后抛出 rejected 的 reason |
| 生命周期 | 保留任务并 await gather | 保留 Promise 并 await allSettled |
这里刻意选择“收齐后失败”。Python TaskGroup 的失败取消兄弟任务策略与此不同;JS Promise.all 的提前拒绝也不保证兄弟操作已经结束。机制不是同名方法之间的机械替换。
# 各语言实现
# Python
gather 保留输入顺序,return_exceptions=True 把异常放入结果集合;本例只在内部暂存异常,随后显式传播。
Pythonimport asyncio
async def fetch_all(names):
gate = asyncio.Event()
finished = []
async def service(name):
try:
await gate.wait()
if name == "bad":
raise ValueError("service failed")
return name.upper()
finally:
finished.append(name)
tasks = [asyncio.create_task(service(name)) for name in names]
# 让已创建的任务进入门闩等待,再统一释放。
await asyncio.sleep(0)
assert all(not task.done() for task in tasks)
gate.set()
results = await asyncio.gather(*tasks, return_exceptions=True)
assert len(finished) == len(names)
for result in results:
if isinstance(result, BaseException):
raise result
return results
async def main():
assert await fetch_all([]) == []
result = await fetch_all(["alpha", "beta"])
assert result == ["ALPHA", "BETA"]
print(",".join(result))
try:
await fetch_all(["alpha", "bad"])
except ValueError as error:
assert str(error) == "service failed"
print(error)
else:
raise AssertionError("failure was lost")
if __name__ == "__main__":
asyncio.run(main())
asyncio.sleep(0) 是显式让出调度机会,不是模拟工作所需耗时。这里收集的异常只可能来自任务;业务成功值限定为字符串,避免把“异常对象也是合法数据”与失败混淆。若外层任务被取消,gather 会向未结束子任务传播取消;本篇驱动不触发这条路径。
# JavaScript
使用 CommonJS 以免依赖额外的模块配置。Node 提供 node:assert,Promise 的结果形状见 ECMAScript Promise.allSettled。
JavaScriptconst assert = require("node:assert/strict");
async function fetchAll(names) {
let release;
const gate = new Promise(resolve => { release = resolve; });
let started = 0;
let finished = 0;
async function service(name) {
started += 1;
try {
await gate;
if (name === "bad") throw new Error("service failed");
return name.toUpperCase();
} finally {
finished += 1;
}
}
const tasks = names.map(service);
assert.equal(started, names.length);
assert.equal(finished, 0);
release();
const results = await Promise.allSettled(tasks);
assert.equal(finished, names.length);
const failure = results.find(result => result.status === "rejected");
if (failure) throw failure.reason;
return results.map(result => result.value);
}
async function main() {
assert.deepEqual(await fetchAll([]), []);
const result = await fetchAll(["alpha", "beta"]);
assert.deepEqual(result, ["ALPHA", "BETA"]);
console.log(result.join(","));
await assert.rejects(fetchAll(["alpha", "bad"]), /service failed/);
console.log("service failed");
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});
失败调用由 assert.rejects 显式消费并检查;非预期错误进入最外层处理器并设置非零退出状态。没有任何被遗忘的 Promise,也没有为“更快返回”而遗留后台请求。本篇没有重复 TypeScript 代码:比较的是 Python 与 JS 运行时调度语义,而非静态签名。
# 迁移误区与适用限制
- 把循环里的
await service(name)当并发:第一个操作完成后才启动第二个,门闩场景甚至会卡死。先创建全部任务,再统一等待。 - 把
Promise.all提前拒绝当作自动取消:其他操作依旧可能写文件或占连接;必须另行取消并收束,或者像本例等待全部完成。 - 把“并发等待”当“CPU 并行”:本例只有一个事件循环线程,CPU 长循环仍会阻挡其他异步任务。
- 对百万条输入全部创建任务会占用大量内存;本例只有有限的小批量,生产批处理应使用限制并发量的固定工作者与有界队列。
# 运行与验证
两个程序的精确预期输出相同:
纯文本ALPHA,BETA
service failed
空输入、成功顺序、失败消费、全部任务结束由程序内断言覆盖,不使用计时断言。分别在独立目录运行两个文件。语言版本引用示例基线,实际运行状态以验证记录为准;本文给出的是预期行为,不代表所有基线工具链已经执行。