Skip to content

第 3 章:并发任务组织:gather() 与 TaskGroup ​

我们继续做“学生信息查询助手”。

第 2 章我们已经能写出一个清晰的异步入口:

python
async def main():
    await print_student(1)

if __name__ == "__main__":
    asyncio.run(main())

这个写法可以查询一个学生。

现在需求变了:用户一次想查询多个学生的信息。

按照上一章的写法,我们很容易写成这样:

python
async def main():
    await print_student(1)
    await print_student(2)
    await print_student(3)

这段代码能运行,也能查到结果。

但它有个明显问题:第 2 个查询要等第 1 个查询结束,第 3 个查询要等第 2 个查询结束。

如果每个查询都在等网络、数据库或外部服务,这样就没有发挥异步的优势。

本章要解决 4 个问题:

  1. 多个 await 连着写,为什么还是顺序等待?
  2. 如何用 asyncio.gather() 同时等待多个异步查询?
  3. gather() 返回的结果顺序应该怎么看?
  4. 如何用 TaskGroup 把多个相关任务组织得更清晰?

本章只解决“多个异步任务如何同时等待”。

本章不解决“最多同时跑多少个任务”。这个问题第 4 章再讲。

本章也不系统讲超时、取消和复杂异常处理。这些放到第 5 章。

本章学习目标 ​

学完本章后,你应该能做到:

  1. 理解连续写多个 await 时,代码仍然会按顺序等待。
  2. 知道 asyncio.gather() 可以把多个异步操作放在一起等待。
  3. 能使用 asyncio.gather() 同时查询多个学生信息。
  4. 能理解 gather() 返回结果的顺序和传入顺序一致。
  5. 能使用 TaskGroup 组织多个相关异步任务。
  6. 能初步判断什么时候用 gather(),什么时候用 TaskGroup。
  7. 能排查忘记 await asyncio.gather(...)、gather() 参数写错、提前读取 task.result() 等常见错误。

多学生查询场景 ​

我们的学生信息查询助手目前只有一个内存字典:

python
student_data = {
    1: {"id": 1, "name": "小明", "age": 13},
    2: {"id": 2, "name": "小红", "age": 14},
    3: {"id": 3, "name": "小刚", "age": 13},
}

每次查询学生信息时,我们用 asyncio.sleep(1) 模拟外部服务等待 1 秒。

注意:这里不是真的查数据库,也不是真的请求 HTTP 接口。我们只是用 asyncio.sleep(1) 模拟“程序正在等外部结果”。

现在要查 3 个学生。

顺序等待与同时等待的流程对比如下:

多学生查询场景:顺序等待 vs 同时等待机制对比

本章的重点不是“异步一定更快”。

重点是:当多个任务都在等 I/O 结果时,我们可以让这些等待重叠发生。

并发查询方式 ​

下面每一步的完整代码都直接显示在页面里。将代码按标注文件名保存到本地 _code_async/c03_gather_and_taskgroup/,然后在 _code_async 目录执行命令。

1. 连续 await 与顺序等待 ​

await 的意思是:等这个异步操作完成,再继续往下执行。

所以如果代码这样写:

python
await print_student(1)
await print_student(2)
await print_student(3)

它的执行顺序就是:

  1. 等学生 1 查询完成。
  2. 再等学生 2 查询完成。
  3. 再等学生 3 查询完成。

这段代码没有错。

它只是顺序等待。

日常项目中,如果后一个查询必须依赖前一个查询的结果,就可以这样写。

但如果 3 个查询互不依赖,就不适合一个一个等。

需求:一次查询 3 个学生,但先按照第 2 章的写法,一个一个 await。

功能分析:

  1. 准备一个内存字典 student_data。
  2. 用 query_student_async() 模拟查询一个学生。
  3. 用 print_student() 打印查询结果。
  4. 在 main() 中连续等待 3 次。
  5. 用 time.perf_counter() 观察总耗时。

文件名:01_sequential_await.py

py
import asyncio
import time

student_data = {
    1: {"id": 1, "name": "小明", "age": 13},
    2: {"id": 2, "name": "小红", "age": 14},
    3: {"id": 3, "name": "小刚", "age": 13},
}


async def query_student_async(student_id):
    print(f"开始查询学生 {student_id}")
    await asyncio.sleep(1)
    print(f"查询学生 {student_id} 完成")
    return student_data.get(student_id)


async def print_student(student_id):
    result = await query_student_async(student_id)

    if result is None:
        print(f"没有找到 ID 为 {student_id} 的学生")
        return

    print(result)


async def main():
    start_time = time.perf_counter()

    await print_student(1)
    await print_student(2)
    await print_student(3)

    end_time = time.perf_counter()
    print(f"总耗时:{end_time - start_time:.2f} 秒")


if __name__ == "__main__":
    asyncio.run(main())

运行命令:

bash
uv run python c03_gather_and_taskgroup/01_sequential_await.py

可能看到类似结果:

text
开始查询学生 1
查询学生 1 完成
{'id': 1, 'name': '小明', 'age': 13}
开始查询学生 2
查询学生 2 完成
{'id': 2, 'name': '小红', 'age': 14}
开始查询学生 3
查询学生 3 完成
{'id': 3, 'name': '小刚', 'age': 13}
总耗时:3.01 秒

运行逻辑:

  1. main() 先执行 await print_student(1)。
  2. 学生 1 查询完成后,才会执行 await print_student(2)。
  3. 学生 2 查询完成后,才会执行 await print_student(3)。
  4. 每次查询都等待 1 秒,所以总耗时接近 3 秒。

注意:这段代码是正确的。

问题不是“不能运行”,而是“互不依赖的等待没有重叠”。

如果 3 个查询之间没有依赖关系,我们可以让它们同时开始等待。

连续 await 顺序等待与 asyncio.gather 并发推进对比

2. gather() 与并发结果收集 ​

asyncio.gather() 可以先简单理解为:

把多个异步操作放在一起等待,等它们都完成后,一次性拿到结果。

常见写法是:

python
results = await asyncio.gather(
    query_student_async(1),
    query_student_async(2),
    query_student_async(3),
)

注意两点:

  1. 外面要写 await。
  2. results 的顺序和传入顺序一致。

也就是说,第一个结果对应 query_student_async(1),第二个结果对应 query_student_async(2),第三个结果对应 query_student_async(3)。

它不按谁先完成来排列。

要验证这一点,可以在 02_gather_query.py 中把查询函数的等待改为 await asyncio.sleep({1: 0.3, 2: 0.1, 3: 0.2}[student_id])。三个查询仍按 1、2、3 的顺序传入,但完成日志会依次显示学生 2、3、1;返回列表仍依次对应学生 1、2、3。总耗时接近最长的一次等待,即 0.3 秒,而不是三次等待之和。

asyncio.gather 结果槽位保序机制

先记住一个会影响项目写法的区别:默认情况下,一个操作抛出异常,gather() 会把异常立即交给调用方,但其他操作不会因此自动取消,它们仍可能继续运行。第 5 章再练习具体的异常处理。

协程对象列表与参数解包 ​

如果学生 ID 放在列表里,常见写法是先生成一组协程对象:

python
student_ids = [1, 2, 3]
tasks = [query_student_async(student_id) for student_id in student_ids]

这里变量名虽然叫 tasks,列表元素此时仍是协程对象,还不是已经运行的 Task。gather() 接收它们后,才会安排这些查询一起执行。

然后传给 gather():

python
results = await asyncio.gather(*tasks)

这里的 *tasks 是 Python 的解包语法。

它的作用是把列表里的多个元素展开,分别传给 gather()。

不要写成:

python
results = await asyncio.gather(tasks)

这样传进去的是一个列表本身,不是多个异步操作。

需求:一次查询 3 个学生,并让 3 个查询同时开始等待。

功能分析:

  1. 保留 query_student_async()。
  2. 在 main() 中创建 3 个异步查询。
  3. 用 await asyncio.gather(...) 一起等待它们完成。
  4. 打印结果列表。
  5. 观察耗时接近 1 秒。

文件名:02_gather_query.py

py
import asyncio
import time

student_data = {
    1: {"id": 1, "name": "小明", "age": 13},
    2: {"id": 2, "name": "小红", "age": 14},
    3: {"id": 3, "name": "小刚", "age": 13},
}


async def query_student_async(student_id):
    print(f"开始查询学生 {student_id}")
    await asyncio.sleep(1)
    print(f"查询学生 {student_id} 完成")
    return student_data.get(student_id)


async def main():
    start_time = time.perf_counter()

    results = await asyncio.gather(
        query_student_async(1),
        query_student_async(2),
        query_student_async(3),
    )

    end_time = time.perf_counter()

    print("查询结果:")
    print(results)
    print(f"总耗时:{end_time - start_time:.2f} 秒")


if __name__ == "__main__":
    asyncio.run(main())

运行命令:

bash
uv run python c03_gather_and_taskgroup/02_gather_query.py

可能看到类似结果:

text
开始查询学生 1
开始查询学生 2
开始查询学生 3
查询学生 1 完成
查询学生 2 完成
查询学生 3 完成
查询结果:
[{'id': 1, 'name': '小明', 'age': 13}, {'id': 2, 'name': '小红', 'age': 14}, {'id': 3, 'name': '小刚', 'age': 13}]
总耗时:1.00 秒

运行逻辑:

  1. query_student_async(1)、query_student_async(2)、query_student_async(3) 都是异步查询。
  2. asyncio.gather() 把它们放在一起等待。
  3. 3 个查询都在等待 asyncio.sleep(1) 时,等待时间可以重叠。
  4. 所以总耗时接近 1 秒,而不是 3 秒。
  5. results 的顺序和传入顺序一致。

注意:

python
results = await asyncio.gather(
    query_student_async(1),
    query_student_async(2),
    query_student_async(3),
)

这里最容易漏掉的是外面的 await。

如果学生 ID 已经放在列表里,可以这样写:

python
student_ids = [1, 2, 3]
tasks = [query_student_async(student_id) for student_id in student_ids]
results = await asyncio.gather(*tasks)

*tasks 表示把列表里的多个协程对象展开传入 gather()。

本章先掌握这个写法即可。异常处理后面再讲。

3. TaskGroup 与任务生命周期 ​

前一个示例需要收集全部查询结果,gather() 已经够用。如果需求进一步变成“某个查询失败后,取消并等待尚未完成的同组查询”,就需要考虑这批任务的整体生命周期。

例如,学生信息聚合必须同时取得基本信息、成绩和课程表;其中一个查询失败后,剩余查询即使完成,也无法交付完整结果。TaskGroup 可以统一管理这组任务的失败。本章先理解这一选型依据,异常捕获与取消的具体处理留到第 5 章。

TaskGroup 可以先简单理解为:

用一个任务组管理一批相关异步任务。

够用写法是:

python
tasks = []

async with asyncio.TaskGroup() as tg:
    for student_id in student_ids:
        task = tg.create_task(query_student_async(student_id))
        tasks.append(task)

results = [task.result() for task in tasks]

注意:

  1. tg.create_task() 会把协程加入任务组。
  2. async with 正常退出时,任务组里的任务都已经成功执行完成。
  3. 所以 task.result() 要放在 async with 代码块结束后读取。

简单收集结果,可以先用 gather()。如果必须等一组操作全部结束后再处理失败,可以使用 gather(..., return_exceptions=True),然后逐个检查结果;异常此时也会占据一个结果位置。

TaskGroup 适合一组任务需要统一管理失败的场景。组中某个任务抛出普通异常时,它会取消尚未完成的同组任务,等待它们结束,再向外抛出 ExceptionGroup。已经完成的外部操作不会自动回滚。本章只要求理解这一行为,不展开异常组的高级用法。

本章示例没有主动取消任务。若任务失败导致任务组向外抛出异常,程序不会直接执行后面的结果收集语句;被取消的任务也不能当作成功结果读取。

需求:还是查询 3 个学生,但这次用 TaskGroup 把这 3 个查询放到同一个任务组里。

功能分析:

  1. 准备 student_ids = [1, 2, 3]。
  2. 使用 async with asyncio.TaskGroup() as tg 创建任务组。
  3. 在任务组中使用 tg.create_task() 添加查询任务。
  4. 保存每个任务对象。
  5. 任务组结束后,再读取每个任务的结果。

文件名:03_task_group.py

py
import asyncio
import time

student_data = {
    1: {"id": 1, "name": "小明", "age": 13},
    2: {"id": 2, "name": "小红", "age": 14},
    3: {"id": 3, "name": "小刚", "age": 13},
}

student_ids = [1, 2, 3]


async def query_student_async(student_id):
    print(f"开始查询学生 {student_id}")
    await asyncio.sleep(1)
    print(f"查询学生 {student_id} 完成")
    return student_data.get(student_id)


async def main():
    start_time = time.perf_counter()

    tasks = []

    async with asyncio.TaskGroup() as tg:
        for student_id in student_ids:
            task = tg.create_task(
                query_student_async(student_id),
                name=f"query-student-{student_id}",
            )
            tasks.append(task)

    results = [task.result() for task in tasks]

    end_time = time.perf_counter()

    print("查询结果:")
    print(results)
    print(f"总耗时:{end_time - start_time:.2f} 秒")


if __name__ == "__main__":
    asyncio.run(main())

运行命令:

bash
uv run python c03_gather_and_taskgroup/03_task_group.py

可能看到类似结果:

text
开始查询学生 1
开始查询学生 2
开始查询学生 3
查询学生 1 完成
查询学生 2 完成
查询学生 3 完成
查询结果:
[{'id': 1, 'name': '小明', 'age': 13}, {'id': 2, 'name': '小红', 'age': 14}, {'id': 3, 'name': '小刚', 'age': 13}]
总耗时:1.00 秒

运行逻辑:

  1. async with asyncio.TaskGroup() as tg 创建一个任务组。
  2. tg.create_task() 把每个学生查询加入任务组。
  3. 这些任务会在任务组中一起运行。
  4. async with 正常退出时,本例中的任务都已成功完成。
  5. 所以我们在代码块外面用 task.result() 读取结果。

注意:不要在 async with 代码块内部提前读取 task.result()。

因为任务可能还没有完成。

name=f"query-student-{student_id}" 是给任务起名字。这里知道可以命名即可,后面做排查时会更有用。

TaskGroup 结构化并发沙箱与生命周期屏障

并发工具选择 ​

日常项目里可以先这样判断:

场景推荐写法
简单并发查询,并按传入顺序收集结果asyncio.gather()
一组任务需要统一管理失败,并取消尚未完成的同组任务asyncio.TaskGroup
一组任务需要全部结束后再统一检查错误asyncio.gather(..., return_exceptions=True)
需要严格控制并发数量第 4 章学习 Semaphore
需要处理超时、取消、异常细节第 5 章专门处理

本章只先把“同时等待多个任务”的基本写法掌握住。

4. 批量学生信息查询 ​

本章完整案例做一个普通 Python 脚本。

它能批量查询多个学生,并用 asyncio.gather() 同时等待多个查询结果。

项目结构

text
_code_async/c03_gather_and_taskgroup/
└── 04_batch_query.py

完整代码

文件名:04_batch_query.py

py
import asyncio
import time

student_data = {
    1: {"id": 1, "name": "小明", "age": 13},
    2: {"id": 2, "name": "小红", "age": 14},
    3: {"id": 3, "name": "小刚", "age": 13},
    4: {"id": 4, "name": "小丽", "age": 15},
}


async def query_student_async(student_id):
    print(f"开始查询学生 {student_id}")
    await asyncio.sleep(1)
    print(f"查询学生 {student_id} 完成")
    return student_data.get(student_id)


def format_student(student):
    if student is None:
        return "没有找到学生"

    return f"ID:{student['id']},姓名:{student['name']},年龄:{student['age']}"


async def query_students_with_gather(student_ids):
    tasks = [query_student_async(student_id) for student_id in student_ids]
    results = await asyncio.gather(*tasks)
    return results


async def query_students_with_task_group(student_ids):
    tasks = []

    async with asyncio.TaskGroup() as tg:
        for student_id in student_ids:
            task = tg.create_task(
                query_student_async(student_id),
                name=f"query-student-{student_id}",
            )
            tasks.append(task)

    return [task.result() for task in tasks]


async def main():
    student_ids = [1, 2, 3, 99]

    start_time = time.perf_counter()

    results = await query_students_with_gather(student_ids)

    end_time = time.perf_counter()

    print("\n批量查询结果:")
    for student_id, student in zip(student_ids, results):
        print(f"学生 ID {student_id}:{format_student(student)}")

    print(f"\n总耗时:{end_time - start_time:.2f} 秒")


if __name__ == "__main__":
    asyncio.run(main())

运行命令 ​

从本地 _code_async 目录运行脚本:

bash
uv run python c03_gather_and_taskgroup/04_batch_query.py

运行结果 ​

可能看到类似结果:

text
开始查询学生 1
开始查询学生 2
开始查询学生 3
开始查询学生 99
查询学生 1 完成
查询学生 2 完成
查询学生 3 完成
查询学生 99 完成

批量查询结果:
学生 ID 1:ID:1,姓名:小明,年龄:13
学生 ID 2:ID:2,姓名:小红,年龄:14
学生 ID 3:ID:3,姓名:小刚,年龄:13
学生 ID 99:没有找到学生

总耗时:1.00 秒

说明:

  1. 4 个查询都同时开始等待。
  2. 每个查询等待 1 秒。
  3. 总耗时接近 1 秒,而不是 4 秒。
  4. student_id = 99 查不到,所以返回 None,再由 format_student() 转成友好提示。
  5. query_students_with_task_group() 是本章对照写法,完整案例主流程先使用 gather()。

注意:实际耗时可能是 1.00、1.01 或略有浮动,这是正常现象。

并发调用错误排查 ​

gather 与 TaskGroup 选型矩阵与三大报错自愈诊所

现象原因修复方式
打印出 gather() 返回的 Future,而不是学生列表;脚本退出时还可能出现取消相关提示遗漏了 await asyncio.gather(...) 中的 await。gather() 已安排查询执行,但当前流程没有等待其结果;这与仅创建一个未运行的协程对象不同。用 results = await asyncio.gather(...) 等待并取得结果。
TypeError: cannot use 'list' as a dict key (unhashable type: 'list')写成 asyncio.gather(tasks),把普通列表作为一个参数传入;创建的查询协程没有被接管,还可能出现未等待警告。使用 asyncio.gather(*tasks) 解包协程对象列表。
InvalidStateError: Result is not set.(在本例中包含于 ExceptionGroup)在任务完成前调用 task.result()。在任务组正常退出后读取本例的任务结果。

本章小结 ​

本章我们解决了“多个异步查询如何同时等待”的问题。

核心结论有 4 个:

  1. 连续写多个 await,代码仍然会按顺序等待。
  2. asyncio.gather() 可以同时等待多个异步操作,并按传入顺序返回结果。
  3. 如果异步操作来自列表,要用 *tasks 解包传给 gather()。
  4. TaskGroup 会在一个任务失败时取消并等待同组其他任务;读取 task.result() 要放在任务组结束后。

本章先掌握“同时等待”。

任务数量过多时怎么控制,第 4 章继续讲。

练习 ​

练习 1:先把 3 个查询写成连续 await,记录一次耗时;再改成 asyncio.gather(),对比两次耗时。

练习 2:在 02_gather_query.py 中,按正文把学生 1、2、3 的等待时间分别改为 0.3、0.1、0.2 秒。检查完成日志是否按 2、3、1 排列,而结果列表仍按 1、2、3 排列。再在 04_batch_query.py 中把 student_ids 改为 [99, 1, 2, 3],确认“没有找到学生”位于结果第一项。

练习 3:把本章的 gather() 版本再手动改写成 TaskGroup 版本,体会“收集结果”和“组织任务”这两个关注点的区别。

练习 4:分别验证错误排查表中的三种写法,每次只改一处,观察后恢复:① 在 02_gather_query.py 中去掉 gather() 前的 await,观察 results 是否变成 Future;② 在 04_batch_query.py 中把 gather(*tasks) 改成 gather(tasks),观察列表参数错误;③ 在 03_task_group.py 的 tasks.append(task) 后立即加入 print(task.result()),观察结果未就绪错误。修复后确认各示例重新打印学生结果。