Skip to content

第 2 章:异步程序入口与协程等待 ​

上一章已经跑通了异步学生查询。如果直接执行 result = query_student_async(1) 并打印,得到的却是 <coroutine object ...>,而不是学生字典;程序结束时还可能出现 coroutine was never awaited 警告。

怎样从普通 Python 脚本真正执行这个查询,并取得学生信息?我们先观察直接调用的结果,再用 await 取得返回值,最后整理脚本入口与函数调用关系。

本章只解决异步函数的最小调用规则。多个任务如何同时执行、并发数量控制、超时、异常、取消,以及 HTTP、FastAPI、数据库和 Redis,留到后续章节。

本章学习目标 ​

学完本章后,你需要能做到:

  1. 知道 async def 定义的是异步函数。
  2. 理解调用异步函数后得到的是协程对象,不是最终结果。
  3. 能用 await 等待一个异步查询函数执行完成。
  4. 能从异步函数的 return 中拿到结果。
  5. 能写出 async def main() 作为异步入口函数。
  6. 能用 asyncio.run(main()) 在普通 Python 脚本中运行异步程序。
  7. 能排查 coroutine was never awaited、await outside async function、asyncio.run() cannot be called from a running event loop 等常见错误。

学生信息查询样例 ​

我们继续使用学生信息查询场景。本章换用小明、小红、小刚这组独立的内存样例,与上一章的张三、李四、王五不是同一批数据;数据内容的变化与异步调用方式无关。

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

查询函数用 await asyncio.sleep(1) 模拟等待数据库、Redis 或外部接口返回结果。上一章已经比较过同步和异步的耗时;这里关注函数怎么调用、结果怎么取得。

异步函数的调用与脚本入口 ​

每一步的完整代码都直接显示在页面中。将对应文件保存到本地 _code_async/c02_asyncio_run_and_await/,然后在 _code_async 目录执行紧跟代码的命令。

1. 异步函数与协程对象 ​

async def 定义异步函数,让函数能够在异步等待时让出执行权。它常用于异步数据库、Redis、HTTP 查询,也会出现在 FastAPI 或 Starlette 的异步接口中。

普通函数调用后会执行函数体并返回结果;调用异步函数时,Python 先创建一个协程对象。可以把它理解为“准备执行的异步过程”,它还不是学生字典。协程对象需要被等待,函数体里的 await asyncio.sleep(1) 才会开始执行。如果只创建它却不等待,常见提示是 coroutine was never awaited。

先故意不等待查询函数,运行后观察返回值与警告。

功能分析:

  1. 准备一份 student_data 内存数据。
  2. 定义一个 async def query_student_async(student_id)。
  3. 在普通代码中直接调用它。
  4. 打印返回值。

文件名:01_coroutine.py

py
import asyncio

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):
    await asyncio.sleep(1)
    return student_data.get(student_id)


result = query_student_async(1)
print(result)

运行命令:

bash
uv run python c02_asyncio_run_and_await/01_coroutine.py

可能看到的输出:

text
<coroutine object query_student_async at 0x...>
RuntimeWarning: coroutine 'query_student_async' was never awaited

运行逻辑说明:

  1. query_student_async(1) 调用了一个异步函数。
  2. 这个调用不会直接返回学生字典。
  3. 它返回的是一个协程对象。
  4. 因为这个协程对象没有被 await,所以 Python 可能提示 coroutine was never awaited。
  5. 函数体里的 await asyncio.sleep(1) 和 return student_data.get(student_id) 并没有按你预期执行完成。

注意:这里的代码是故意写错,用来观察现象。

验收时看两点:打印出的是 <coroutine object ...>,而不是学生字典;函数体没有执行,因此不会等待 1 秒。未等待警告通常写到标准错误,出现位置和协程对象的内存地址可能因运行环境而变。

普通函数是“调用就执行”。

异步函数是“调用后得到协程对象,等待后才执行完成”。

普通函数调用 vs 异步函数直接调用:底层机理对比

2. await 与查询返回值 ​

await 等待异步操作完成,并取得函数的 return 值。它通常写在 async def 函数内部,不能直接写在普通 def 函数或普通 .py 文件顶层。

因此,我们先用 async def main() 放置查询流程,再从普通脚本调用 asyncio.run(main())。main() 只是常见的入口函数名;asyncio.run() 负责创建并管理运行异步代码所需的事件循环。日常脚本里用它启动最外层异步流程,在已经运行的异步函数中则直接用 await。

这次把查询放进 main(),运行后观察 result 的值。

功能分析:

  1. 继续保留 student_data。
  2. 继续使用 query_student_async(student_id)。
  3. 新增 async def main()。
  4. 在 main() 里用 await 等待查询结果。
  5. 用 asyncio.run(main()) 启动异步程序。

文件名:02_await_result.py

py
import asyncio

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):
    await asyncio.sleep(1)
    return student_data.get(student_id)


async def main():
    result = await query_student_async(1)
    print(result)


asyncio.run(main())

运行命令:

bash
uv run python c02_asyncio_run_and_await/02_await_result.py

可能看到的输出:

text
{'id': 1, 'name': '小明', 'age': 13}

运行逻辑说明:

  1. 程序先执行 asyncio.run(main())。
  2. asyncio.run() 启动最外层异步函数 main()。
  3. main() 里执行到 await query_student_async(1)。
  4. query_student_async(1) 开始异步执行。
  5. await asyncio.sleep(1) 模拟异步等待 1 秒。
  6. 等待结束后,函数返回 student_data.get(student_id)。
  7. result 拿到返回的学生字典。
  8. print(result) 打印查询结果。

注意:

await 后面拿到的是异步函数 return 的值。

这一点很重要。

python
result = await query_student_async(1)

这里的 result 才是学生数据。

如果少了 await:

python
result = query_student_async(1)

这里的 result 只是协程对象。把两次运行连起来看:

mermaid Diagram

await 的双重作用:推进协程执行与返回值解包

3. 脚本入口与模块导入 ​

前一个版本已经能拿到学生字典。接下来把 asyncio.run(main()) 放在 if __name__ == "__main__" 下:只有直接运行当前文件时才启动脚本,其他文件导入它时不会自动执行查询。

查询过程也可以继续拆成异步函数:main() 用 await 调用 print_student(),后者再用 await 查询学生。查询不到时返回 None,打印提示后结束这次处理。

功能分析:

  1. 查询一个存在的学生 ID。
  2. 查询一个不存在的学生 ID。
  3. 如果查询结果是 None,打印友好提示。
  4. 使用 if __name__ == "__main__": asyncio.run(main()) 启动程序。

文件名:03_async_entry.py

py
import asyncio

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):
    await asyncio.sleep(1)
    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"没有找到学生:{student_id}")
        return

    print(f"查询到学生:{result}")


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


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

运行命令:

bash
uv run python c02_asyncio_run_and_await/03_async_entry.py

可能看到的输出:

text
查询到学生:{'id': 1, 'name': '小明', 'age': 13}
没有找到学生:99

运行逻辑说明:

  1. 程序从文件底部开始判断 if __name__ == "__main__"。
  2. 当前文件被直接运行时,这个条件成立。
  3. 程序执行 asyncio.run(main())。
  4. main() 作为异步主流程开始运行。
  5. await print_student(1) 查询存在的学生。
  6. print_student() 内部继续 await query_student_async(student_id)。
  7. 查询到数据后,打印学生信息。
  8. await print_student(99) 查询不存在的学生。
  9. 查询结果是 None,所以打印“没有找到学生”。

注意:

main() 只是一个常见名字,不是 Python 强制要求。

但在脚本里使用 main() 可以让程序结构更清楚:

text
普通入口 -> asyncio.run(main()) -> async main() -> await 具体异步函数

if __name__ == "__main__" 的够用理解是:

只有这个文件被直接运行时,才执行下面的启动代码。

这个写法适合脚本,也方便后续把函数拿到其他文件里复用。

标准异步脚本分层架构:从同步世界穿透到异步世界

调用规则与错误排查 ​

普通脚本的调用规则 ​

  1. 在 async def 函数里用 await 等待异步查询并取得返回值。
  2. 用 async def main() 组织脚本的异步主流程。
  3. 在普通脚本入口用 asyncio.run(main()) 启动,不要在已运行的异步函数里再次调用。

常见调用错误 ​

看到的现象原因调整方式
coroutine was never awaited创建了协程对象,但没有等待它;演示一故意这样写。在异步函数中 await,或通过脚本入口启动异步流程。
SyntaxError: 'await' outside async function(顶层写法可能提示 outside function)把 await 写在普通 def 函数或普通脚本顶层。把等待逻辑放进 async def main(),再用 asyncio.run(main()) 启动。
asyncio.run() cannot be called from a running event loop已在运行的异步流程里再次调用 asyncio.run()。异步函数之间直接使用 await;只在普通脚本入口使用 asyncio.run()。

三大高频报错因果诊所:排查与自愈矩阵

本章小结 ​

本章只讲了一件事:异步函数怎么正确运行。

你需要记住 4 个结论:

  1. async def 定义的是异步函数。
  2. 调用异步函数后,先得到协程对象,不是最终结果。
  3. 想拿到异步函数的返回值,要在异步函数内部使用 await。
  4. 普通 Python 脚本通常使用 asyncio.run(main()) 启动异步程序。

从本章开始,后续代码会经常使用这个结构。下面是沿用前文 asyncio 导入和 query_student_async() 定义的入口片段:

python
async def main():
    result = await query_student_async(1)
    print(result)

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

把这个结构记熟,后面学习多个任务、超时、HTTP 请求和 FastAPI 时会轻松很多。

练习 ​

练习 1:先直接执行 result = query_student_async(1),打印结果。检查是否只看到协程对象、没有等 1 秒,也没有打印学生字典;未等待警告可能随后出现。

练习 2:把查询逻辑放进 async def main(),再用 await 拿结果。检查是否等约 1 秒后打印学生字典,且不再出现未等待警告。

练习 3:故意把 await 写到普通 def 函数里,观察 SyntaxError;再放回 async def 函数,并从脚本入口运行,确认能重新拿到学生字典。

练习 4:在 03_async_entry.py 的 main() 内,把 await print_student(1) 故意改成 asyncio.run(print_student(1))。运行后应看到 RuntimeError: asyncio.run() cannot be called from a running event loop;改回 await print_student(1),确认两个查询都恢复正常。报错后还可能出现未等待警告,因为内层 asyncio.run() 没能运行传入的协程。