AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

异步、并发与超时:等待的时候,程序还能做什么

我的学习 →

正在读取学习记录…

资料助手要从 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 ,把当前例子的文件夹拖入终端并回车,运行下面的命令。

PowerShell
py -3.13 -m venv .venv
$env:Path = "$PWD\.venv\Scripts;$env:Path"
python -c "import sys; print(sys.executable)"
macOS 终端命令
Bash
python3.13 -m venv .venv
source .venv/bin/activate
python -c "import sys; print(sys.executable)"

最后应显示这个文件夹里的 .venv\Scripts\python.exe。新开窗口时重新执行选择和核对两行。把下面的代码保存为 sequential.py:

Python
# 按固定输入观察异步、并发与超时的处理过程和返回结果。
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:

Python
# 按固定输入观察异步、并发与超时的处理过程和返回结果。
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 提供让出执行机会的等待点;两项是否重叠,还取决于它们何时被安排。

对照时间线再看一遍:

A等待0.12秒、B等待0.04秒:依次开始合计0.16秒,同时安排时B先完成、总等待约0.12秒
等待时间线:蓝色是 A,绿色是 B。gather 把两项安排在一起,先返回的 B 不必等 A 才开始。
从开始起的时间顺序版本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:

Python
# 按固定输入观察异步、并发与超时的处理过程和返回结果。
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。

任务先等信号量名额,获得名额后才开始单项超时计时,最后释放名额并整理结果
图1:机制示意。任务先等信号量名额,再进入单项超时范围;超时不包含排队时间。图中的 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:

Python
"""比较依次等待、同时等待和超时三种任务执行方式。"""
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 指向该环境;无需执行激活脚本。

Bash
python -X utf8 async_lab.py

现在单独观察一项任务。在同目录保存 try_timeout.py:

Python
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”。这就是最小的“超过上限”和“在上限内完成”对照。

进一步读源码:统计并发数量与清理结果

核心区域如下。完整文件还包含统计和结果整理:

Python
# 按固定输入观察异步、并发与超时的处理过程和返回结果。
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

async 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. 怎样阅读结果,避免看错顺序

程序输出:

TXT
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
Python
# 核对异步、并发与超时的正常结果和错误分支;断言失败时会显示对应的测试。
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()
Bash
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 筛选复习。

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.0.0

选择题 · 并发上限为 2 意味着什么?

三个任务设置并发上限 2,最准确的描述是什么?

对应知识点:L0-async

选择一个答案
练习 2 / 4 · v1.0.0

简答题 · 排队时间算不算超时?

本篇把 timeout 放在获得信号量之后。请说明它覆盖什么、不覆盖什么;若要求从提交起计时,应如何改?

对应知识点:L0-async

练习 3 / 4 · v1.0.0

代码题 · 设计可观察的并发结果

写出伪代码:限制并发、每项超时、无论成功或超时都清理计数,并保留输入顺序结果。

对应知识点:L0-async

练习 4 / 4 · v1.0.0

场景题 · 超时后远端仍在执行

本地任务超时并取消等待,但远端写操作可能已经发生。请说明后续如何处理,为什么不能把 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 节的输出可用来核对任务顺序和并发数量;机器负载会影响耗时。

图片查看器
100%

文章配图 · 按 Esc 关闭