异步、并发与超时:等待的时候,程序还能做什么
正在读取学习记录…
资料助手要从 A、B 两处找资料。每处都要等对方答复。如果一直等 A 回来才联系 B,等待时间会相加;先联系两处,再收结果,两段等待就能重叠。模型请求、检索和远程工具调用也常遇到这种等待。
先亲手跑出两种结果,再看程序如何安排等待、限制同时进行的任务,以及让等得太久的任务停下来。
1. 等待 A 时先推进 B
假设 A 需要等 0.12 秒,B 需要等 0.04 秒。先查 A 再查 B,至少要等大约 0.16 秒。如果两项能在同一段时间里等待,全部完成大约只要 0.12 秒。这里比较的是等待方式,不是让计算机算得更快。
| 办法 | A 等待时 B 在做什么 | 两项等待合计 |
|---|---|---|
| 先 A 后 B | 还没开始 | 约 0.16 秒 |
| 同时推进 A、B | 也在等待答复 | 约 0.12 秒 |
后一种叫并发:两项工作的进行时间有重叠。它不要求有两颗 CPU 同时计算。等你运行下面的程序,这个区别会更直观。
2. 从协程到真正执行
新建 agentdepth-async 文件夹。在文件夹的地址栏输入 powershell 并回车,依次创建环境并选择当前窗口的解释器:
macOS:先在 Python 篇的学习文件夹中打开终端,执行 source .venv/bin/activate。再输入 cd ,把当前例子的文件夹拖入终端并回车,运行下面的命令。
py -3.13 -m venv .venv
$env:Path = "$PWD\.venv\Scripts;$env:Path"
python -c "import sys; print(sys.executable)"macOS 终端命令
python3.13 -m venv .venv
source .venv/bin/activate
python -c "import sys; print(sys.executable)"最后应显示这个文件夹里的 .venv\Scripts\python.exe。新开窗口时重新执行选择和核对两行。把下面的代码保存为 sequential.py:
# 按固定输入观察异步、并发与超时的处理过程和返回结果。
import asyncio
from time import perf_counter
async def read_one(name, delay):
await asyncio.sleep(delay)
return name
async def main():
started = perf_counter()
first = await read_one("A", 0.12)
second = await read_one("B", 0.04)
print([first, second])
print(f"耗时约 {perf_counter() - started:.2f} 秒")
asyncio.run(main())在 PowerShell 运行 python sequential.py。你会看到 ['A', 'B'] 和大约 0.16 秒。计时可能略有浮动。程序等 A 返回后才开始 B,所以两段等待相加。
现在把下面的代码完整保存为同一文件夹中的 concurrent.py:
# 按固定输入观察异步、并发与超时的处理过程和返回结果。
import asyncio
from time import perf_counter
async def read_one(name, delay):
await asyncio.sleep(delay)
return name
async def main():
started = perf_counter()
results = await asyncio.gather(
read_one("A", 0.12),
read_one("B", 0.04),
)
print(results)
print(f"耗时约 {perf_counter() - started:.2f} 秒")
asyncio.run(main())运行 python concurrent.py,结果仍是 ['A', 'B'],耗时会接近 0.12 秒。两次输出格式相同,差别只在等待时间。asyncio.gather 同时安排两项工作,并按传入顺序整理返回值;B 虽然先完成,列表中仍排在 A 后面。
把代码想成准备晚饭:水壶烧水时,可以去切菜;听到水开了,再回来处理水壶。工作的等待时间重叠,总时间就可能缩短。程序里的对应关系是:
async def read_one(...)定义一项能暂停、稍后继续的工作。调用它会得到一个协程对象,可以把它看成待安排的任务。await asyncio.sleep(delay)到了等待点:这项工作先暂停,负责调度的程序可以推进其他已安排的任务。这里用计时等待代替远程答复。asyncio.run(main())启动事件循环:它负责安排任务,在等待结束后让对应任务继续。
第一份代码是“烧完水再切菜”:A 结束后才安排 B。第二份代码用 gather 一起安排 A、B,像先启动水壶再开始切菜。await 提供让出执行机会的等待点;两项是否重叠,还取决于它们何时被安排。
对照时间线再看一遍:
| 从开始起的时间 | 顺序版本 | gather版本 |
|---|---|---|
| 0秒 | A开始等待 | A、B都开始等待 |
| 约0.04秒 | A还在等,B未开始 | B完成,A仍在等 |
| 约0.12秒 | A完成,然后B开始 | A完成,列表可以返回 |
| 约0.16秒 | B完成,main可以打印 | 已结束 |
表中时间是按设定推算的,实际数字会随电脑负载浮动。模型调用通常包含网络等待,适合考虑这种写法;连续计算大批数字的工作不会因为加上 async 就变快。gather 的结果顺序和异常行为可查 Python 协程与任务文档。
3. 给同时进行的任务分配名额
现在多一份资料 C,而服务同时只接两项请求。A、B 先进入,谁先完成就把名额让给 C。这像只有两口灶:第三口锅先等着,腾出一口灶再开始。管理这两个名额的工具叫信号量。
先把下面程序保存为 two_slots.py,运行 python two_slots.py:
# 按固定输入观察异步、并发与超时的处理过程和返回结果。
import asyncio
async def read_one(name, delay, slots):
async with slots:
print(name, "开始")
await asyncio.sleep(delay)
print(name, "结束")
async def main():
slots = asyncio.Semaphore(2)
await asyncio.gather(
read_one("A", 0.12, slots),
read_one("B", 0.04, slots),
read_one("C", 0.08, slots),
)
asyncio.run(main())先出现 A、B 开始,B 结束后才出现 C 开始。async with slots 就是“拿到名额才进入这块代码,离开时归还名额”。A、C 谁最后结束可能受调度影响,但同时工作的数量始终受两个名额限制。把 Semaphore(2) 改成 Semaphore(1) 再运行,就会一项结束后才开始下一项;看完恢复为2。
finally 只表示活跃计数清理,名额由 async with semaphore 释放。信号量可以先理解为有限个通行名额。asyncio.Semaphore(2)允许最多两项进入受保护区域。等待名额的协程会暂停,不需要你反复轮询一个数字。这个同步原语用于事件循环内的协程协调,不是跨机器限流器;进一步说明见asyncio同步原语。
4. 完整实验:把名额、超时和结果分开
复制下方完整代码,保存为 async_lab.py。代码用A等待0.12秒、B等待0.04秒、C等待0.08秒,分别执行并发上限1、上限2和单项超时0.06秒的场景。
保存为 async_lab.py:
"""比较依次等待、同时等待和超时三种任务执行方式。"""
import asyncio
import json
import time
async def run_batch(items, limit, timeout):
if type(limit) is not int or limit < 1 or timeout <= 0:
raise ValueError('positive limit and timeout required')
# limit 是同时运行的任务上限;拿到一个名额后,任务才能开始。
semaphore = asyncio.Semaphore(limit)
active = peak = 0
completion_order = []
async def job(name, delay):
nonlocal active, peak
# 先等运行名额,再开始计时;排队时间在这段超时计时之外。
async with semaphore:
active += 1
peak = max(peak, active)
try:
async with asyncio.timeout(timeout):
await asyncio.sleep(delay)
completion_order.append(name)
return {'name': name, 'status': 'done'}
except TimeoutError:
return {'name': name, 'status': 'timeout'}
finally:
active -= 1
start = time.perf_counter()
# gather 一起安排这些任务,并按传入顺序收集结果;完成顺序另外记录。
results = await asyncio.gather(*(job(name, delay) for name, delay in items))
return {'results': results, 'completion_order': completion_order, 'peak': peak, 'active_after': active, 'elapsed_seconds': round(time.perf_counter() - start, 4)}
async def main():
items = [('A', .12), ('B', .04), ('C', .08)]
for label, limit, timeout in [('serial', 1, 1), ('concurrent', 2, 1), ('timeout', 2, .06)]:
print(label, json.dumps(await run_batch(items, limit, timeout), ensure_ascii=False))
if __name__ == '__main__':
asyncio.run(main())把文件保存在第2节的目录,确认扩展名是 .py 而不是 .py.txt。先按第2节创建并选择 .venv,核对 python 指向该环境;无需执行激活脚本。
python -X utf8 async_lab.py现在单独观察一项任务。在同目录保存 try_timeout.py:
import asyncio
from async_lab import run_batch
result = asyncio.run(run_batch([("资料A", 0.11)], 1, 0.10))
print(result)运行 python -X utf8 try_timeout.py。任务要等0.11秒,却只能等0.10秒,因此结果中 status 为 timeout,completion_order 为空,peak 为1,active_after 为0。把0.11改成0.01再运行,应变为 done,完成顺序包含“资料A”。这就是最小的“超过上限”和“在上限内完成”对照。
进一步读源码:统计并发数量与清理结果
核心区域如下。完整文件还包含统计和结果整理:
# 按固定输入观察异步、并发与超时的处理过程和返回结果。
async with semaphore:
active += 1
peak = max(peak, active)
try:
async with asyncio.timeout(timeout):
await asyncio.sleep(delay)
completion_order.append(name)
return {"name": name, "status": "done"}
except TimeoutError:
return {"name": name, "status": "timeout"}
finally:
active -= 1async with semaphore 获取和释放名额。asyncio.timeout(timeout) 给内部这段等待设一个时间上限;超过上限会抛出 TimeoutError,于是进入 except 返回 timeout 状态。finally 在正常结束和异常结束时都会执行,所以把活跃计数减回去。名额释放由 async with 负责,计数清理由 finally 负责,各管一件事。Python 超时文档解释了内部取消如何转换为 TimeoutError。
本例计数的读取和更新之间没有await,并在单个事件循环线程内完成;不能据此推导多线程里任意共享计数都安全。加入更多并发机制时要重新检查这一假设。
读完整源码时,还会遇到两种先前没用过的语法:nonlocal active, peak 表示内部的 job 要更新外层 run_batch 定义的两个计数,而不是创建新的同名局部变量;*(job(...) for ... in items) 把按输入创建的多个协程展开为 gather 的多个参数。这是简洁写法,不要求初学时就这样写,展开成明确列表也可以表达同样的调度意图。
程序依次进行:检查上限→建立名额池→每项等待名额→进入超时范围→保存该项状态→归还名额→整理整批结果。results 回答“每项怎样结束”,peak 回答“同时最多进行几项”,两者不能互相代替。
5. 怎样阅读结果,避免看错顺序
程序输出:
serial {"results": [{"name": "A", "status": "done"}, {"name": "B", "status": "done"}, {"name": "C", "status": "done"}], "completion_order": ["A", "B", "C"], "peak": 1, "active_after": 0, "elapsed_seconds": 0.262}
concurrent {"results": [{"name": "A", "status": "done"}, {"name": "B", "status": "done"}, {"name": "C", "status": "done"}], "completion_order": ["B", "A", "C"], "peak": 2, "active_after": 0, "elapsed_seconds": 0.1411}
timeout {"results": [{"name": "A", "status": "timeout"}, {"name": "B", "status": "done"}, {"name": "C", "status": "timeout"}], "completion_order": ["B"], "peak": 2, "active_after": 0, "elapsed_seconds": 0.1087}先看 results:按输入顺序 A、B、C 排列。再看 completion_order:它记录正常完成的任务,超时任务不在里面。最后检查 peak 是否超过名额数、active_after 是否回到 0。每个字段回答的是不同问题。
理论上,串行等待时间约为0.24秒;并发上限2时,A与B一起开始,B结束后C获得名额,理想等待路径约0.12秒。但这只是由设置推导的理想值,不是运行结果。实际值还有调度与代码开销,测试不使用“必须快两倍”这种脆弱断言。
超时场景中,B的等待比0.06秒短,A和C的等待更长。你可以运行后对照状态。注意C等待名额的时间没有计入自己的0.06秒;如果产品要求“用户提交后总共不能超过0.06秒”,这个实现不满足要求,需要在整批任务外再设置一个总等待上限。
6. 超时、取消和回滚是三件事
超时描述不再继续等待的策略,取消是通知任务停止的一种机制,回滚则涉及业务状态。它们不能互相替代。本例的sleep支持取消,没有真实业务写入,所以超时之后容易清理;真实HTTP请求可能已经被远端执行,客户端取消并不能抹去结果。
假设工具创建工单时超时,你只能知道没有及时拿到完成结果,不能断言工单不存在。此时要查询状态或使用服务端幂等机制,而不是只扩大timeout或立即重复创建。可靠性课程会进一步实现这些策略。
取消通常是协作式的。长时间不让出控制的同步代码会阻碍事件循环响应取消。把time.sleep放进async函数,仍会阻塞当前线程;应使用适合场景的异步等待,或把必要的阻塞工作移到合适的执行器。把阻塞工作丢进线程也不代表线程会因取消等待而立即停止。
7. 一项失败,其他项怎样处理
job 捕获预期的 TimeoutError 并返回结构化状态。其他异常会由 gather 传给等待者,而同组任务未必同时停止。因此调用方看到异常时,还要核查其余任务是否结束。具体规则见 Python gather 文档。
常见选择有:每项独立处理已知错误并返回部分结果;要求整组共同成功并使用结构化任务管理;或者收集异常后统一决定如何交付。没有一种适合所有任务。Python 3.11 引入的 TaskGroup 在组内任务失败时有另一套异常与取消行为,后续可以对照官方文档研究。设计生产任务时,先写清“是否允许部分成功”和“失败后谁清理”,再选并发工具。
| 任务关系 | 可考虑的组织方式 | 交付时需要说明 |
|---|---|---|
| 三份资料互相独立,允许少一份 | 每项捕获预期失败并返回状态 | 哪些成功、哪些缺失,不能冒充完整结果 |
| 所有子步骤成功才有意义 | TaskGroup等结构化任务管理 | 一个任务异常后如何取消、等待其他任务清理 |
| 要完整收集所有异常再决定 | 显式收集结果及异常 | 异常对象不能直接当正常业务数据使用 |
gather 本身不会决定产品是否允许“部分成功”。这个决定来自任务要求。先写清楚缺一份资料时能不能回答,再选择代码结构,比见到并发就一律套同一个模板更可靠。
8. 测试与排错
把 async_lab.py 和 test_async_lab.py 放在同一目录,再执行:
完整代码:test_async_lab.py
# 核对异步、并发与超时的正常结果和错误分支;断言失败时会显示对应的测试。
import unittest
from async_lab import run_batch
class AsyncTests(unittest.IsolatedAsyncioTestCase):
async def test_results_keep_input_order(self):
result = await run_batch([('slow', .02), ('fast', .001)], 2, 1)
self.assertEqual([row['name'] for row in result['results']], ['slow', 'fast'])
self.assertEqual(result['completion_order'], ['fast', 'slow'])
async def test_concurrency_limit(self):
result = await run_batch([(str(index), .001) for index in range(6)], 2, 1)
self.assertLessEqual(result['peak'], 2)
async def test_timeout_settles_tasks(self):
result = await run_batch([('slow', 1)], 1, .001)
self.assertEqual(result['results'][0]['status'], 'timeout')
self.assertEqual(result['active_after'], 0)
async def test_invalid_limit(self):
with self.assertRaises(ValueError):
await run_batch([], 0, 1)
if __name__ == '__main__':
unittest.main()python -X utf8 -m unittest test_async_lab -v四项测试分别检查结果与完成顺序、并发上限、超时后计数归零、非法上限。若出现导入失败,先检查两个文件是否在同一目录。
正常结果是每项末尾 ok,最后 Ran 4 tests 与 OK。测试文件直接导入 async_lab.py,不需要 HTTP 篇的文件。
| 现象 | 可能原因 | 检查方法 |
|---|---|---|
| 写了async仍串行 | 连续await每一项 | 记录任务开始、结束事件 |
| 界面卡住或其他任务不动 | 混入同步阻塞调用 | 查await之间的大计算和阻塞I/O |
| 超时比设置值久 | 排队、清理或取消响应占时 | 区分端到端与单项执行预算 |
| 输出顺序与预期不符 | 混淆结果顺序和完成顺序 | 同时记录ID与完成事件 |
| 服务429更多 | 并发过高或忽略服务限额 | 降低提交速率并看服务契约 |
9. 自测、实践与讨论
课后练习展开习题收起习题
下面的统一题组会保存你的作答记录,适合在练习中心按 L0-async 筛选复习。
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · 并发上限为 2 意味着什么?
三个任务设置并发上限 2,最准确的描述是什么?
对应知识点:L0-async
简答题 · 排队时间算不算超时?
本篇把 timeout 放在获得信号量之后。请说明它覆盖什么、不覆盖什么;若要求从提交起计时,应如何改?
对应知识点:L0-async
代码题 · 设计可观察的并发结果
写出伪代码:限制并发、每项超时、无论成功或超时都清理计数,并保留输入顺序结果。
对应知识点:L0-async
场景题 · 超时后远端仍在执行
本地任务超时并取消等待,但远端写操作可能已经发生。请说明后续如何处理,为什么不能把 timeout 当回滚。
对应知识点:L0-async
题1:gather返回A、B、C,能证明A最先完成吗?
不能。本例按输入顺序整理结果,完成顺序另有记录。判断先后要看事件,而不是数组位置。
题2:信号量上限2,是否意味着整个程序最多只有两个任务对象?
不是。信号量限制进入区域的数量,其他协程仍可能排队。大量输入还要限制任务创建与积压。
题3:单项timeout放在信号量里面,覆盖排队等待吗?
不覆盖。本例获得名额后才开始计时。需要总预算时,在整批任务外设置时限,并在超时出口清理任务。
接着做一次小实践:沿用第4节的 try_timeout.py,把等待时间从0.01改回0.11,让资料A超过0.10秒上限,观察它返回 timeout。再运行完整的 async_lab.py,查看 timeout 那一行:A、C超时,B完成,active_after 为0。对照这两次结果,解释单项超时后同批任务怎样继续。下一篇会教你用 Git 保存这次修改。
10. 回顾等待、名额与超时
把等待、名额和超时连起来:
- 等待中的协程可以让出执行机会;这不等于多了一颗 CPU。结果列表按输入排列,也不代表实际完成顺序。
- 信号量限制同时进入工作的数量。本例拿到名额后才开始单项超时计时,
finally负责清理计数。 - 超时后活动数应回到0。对于远端写操作,停止等待后还需要查询最终状态,才能决定是否重试。
代码机制可查 Python asyncio 任务文档与同步原语。想沿源码继续看,按 CPython asyncio → AnyIO → HTTPX 的顺序即可。下一篇:Git、命令行与版本控制。
参考资料
参考资料:Python asyncio 总览、协程与任务、同步原语。第 5 节的输出可用来核对任务顺序和并发数量;机器负载会影响耗时。
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。