Skip to content

异步编程 asyncio

事件循环机制、生成器到 async/await 演进、Task 与信号量、同步阻塞卡死排查 (to_thread),以及与多线程本质对比。

Updated View as Markdown
For humans

异步编程 asyncio

校招考察频度:★ 高频(核心必背·高频生死线) 一句话速记:asyncio 是 Python 突破单线程 I/O 瓶颈的物理外挂,也是现代 FastAPI 与 AI 智能体编排的物理地基;其本质是用户态协作式调度 + Linux epoll 多路复用;协程通过 await 主动让出 CPU 控制权,严禁任何同步阻塞调用(必须用 to_thread 丢入线程池隔离)。

在 Python 后端与大模型面试中,面试官对并发的考察从早期的“死背 GIL”全面转向了“asyncio 底层事件循环是如何工作的?为什么你的 FastAPI 吞吐量能翻数倍?为什么有人用了 async 接口反而变得更卡了?”


一、0 基础生动类比:单人厨房做大餐模型

如果把高并发网络请求比作**“一位厨师为 100 桌客人做大餐”**:

并发模型现实做饭场景优缺点分析
传统多线程模型招聘 100 位厨师各守一个灶台,各做各的菜。只要有炉灶等待(如慢火炖汤),厨师就在那干瞪眼。100 位厨师占满厨房(内存暴涨、操作系统线程上下文切换打死 CPU)。
asyncio 协程事件循环只有 1 位身手敏捷的米其林大厨(单线程),配一台带定时提醒的蜂鸣器(epoll 事件循环)。大厨把汤炖在 1 号锅上,按好定时器(await 让出控制权),一秒钟都不闲着,立刻转身去 2 号锅炒菜;当 1 号锅炖好蜂鸣器响起,大厨再转回来出锅。1 个人玩转 100 桌!

二、底层运行机制:从生成器 yield 到原生协程

asyncio 协作式单线程调度时序

1. 协程的演进本质

  • Python 2.x/3.4 之前:通过生成器的 yield 和 yield from 实现暂停与恢复,代码晦涩难懂。
  • Python 3.5+:引入原生的 async def(定义协程)与 await(挂起让出)。
  • 执行规则:调用 async def 函数不会立即执行任何代码,它仅仅返回一个“协程对象(coroutine object)”,必须放入事件循环中(通过 await 或 asyncio.create_task)才会被真正排期调度。

2. await 的严格约束

  • await 只能出现在 async def 函数内部。
  • await 后面的对象必须是 Awaitable(可等待对象),分为三种:
    1. Coroutine(协程对象)
    2. Task(任务):对协程的包装,放入循环立即启动。
    3. Future(未来对象):底层低级对象,代表一个尚未完成的异步操作结果。

三、高并发控制工具箱:Task、Gather 与信号量

import asyncio
import httpx

# 1. 限制最大并发量为 10 (防止打爆下游目标服务器或被封禁)
sem = asyncio.Semaphore(10)

async def fetch_url(client: httpx.AsyncClient, url: str) -> str:
    async with sem: # 争抢令牌,超出 10 个自动挂起排队
        resp = await client.get(url, timeout=5.0)
        return resp.text

async def main():
    urls = [f"https://api.example.com/items/{i}" for i in range(100)]
    
    async with httpx.AsyncClient() as client:
        # 并发创建 100 个 Task 并通过 gather 聚合等待
        tasks = [fetch_url(client, u) for u in urls]
        # 并发执行,收集所有结果
        results = await asyncio.gather(*tasks, return_exceptions=True)
        print(f"成功抓取 {len(results)} 条数据")

# 启动事件循环
asyncio.run(main())
工具核心职责避坑提醒
asyncio.create_task()将协程包装为 Task 并立即提交给事件循环调度(非阻塞,后台并发跑)。必须保留其对象引用,否则可能被垃圾回收器意外回收。
asyncio.gather(*tasks)并发等待一批任务,按传入顺序返回结果列表。建议加上 return_exceptions=True,防止单个任务报错导致全部崩溃。
asyncio.Semaphore(n)信号量并发限制器。限制同一时刻执行临界区的协程数量。爬虫与微服务外呼必配,防止耗尽系统文件句柄或下游熔断。

四、致命隐患:同步阻塞卡死事件循环与排障

初学者常犯的弥天大祸:在 async def 协程函数里写了阻塞式同步代码!

# ❌ 错误示范:一粒老鼠屎坏了一锅粥!
async def bad_handler():
    import time
    time.sleep(5) # 致命!直接卡死主线程 5 秒!
    # 导致全服务所有其他成千上万个协程在这个 5 秒内全盘冻结,无法响应任何新请求!

为什么会卡死?

因为 asyncio 的事件循环是单线程的。协程的切换全靠代码在 I/O 时主动通过 await 让出控制权(协作式多任务)。如果调用了 time.sleep()、同步 requests.get()、同步数据库驱动或密集的 CPU 大计算,主线程被操作系统霸占,事件循环根本无法推进,导致全站“假死”!

治本良方:三大排障方案

  1. 换用异步库(最佳):
    • 替换 requests ➔ httpx.AsyncClient
    • 替换 time.sleep ➔ await asyncio.sleep
    • 替换 pymysql ➔ asyncmy / aiomysql
  2. 使用 asyncio.to_thread 隔离同步调用(Python 3.9+ 推荐):
    # 把遗留的同步阻塞函数,优雅抛到后台底层的独立线程池中执行
    result = await asyncio.to_thread(legacy_blocking_io_function, arg1, arg2)
    主线程的事件循环不受任何干扰,依然飞速运转。
  3. 密集 CPU 计算抛入进程池:
    • 使用 loop.run_in_executor(ProcessPoolExecutor, cpu_heavy_task),彻底绕过 GIL 利用多核。

五、知识脉络关联网(Knowledge Highway)

  • ⬅️ 底层系统支撑:asyncio 的事件循环在 Linux 下底层基于 epoll(I/O 多路复用)非阻塞事件通知机制。
  • ➡️ 现代 Web 落地:FastAPI 是如何利用 asyncio 实现数万并发长连接的?参见 FastAPI 异步高并发与 ASGI 架构。
  • 🔄 前沿 AI 智能体应用:大模型流式输出(SSE)与多工具异步并发调用实战,参见 AI 智能体流式输出与异步实践。

六、校招高频面试追问

  1. 既然 Python 有 GIL,为什么 asyncio 还能实现高并发?
    • GIL 限制的是多核真并行计算,而限制不了单核的 I/O 多路复用。
    • 在高并发网络服务中(如 Web API),99% 的时间都在等待数据库查询、Redis 响应或第三方 RPC 传输(I/O 密集型)。asyncio 在单线程内借助 epoll 管理海量 Socket,当一个请求在等待网络 I/O 时,CPU 迅速去处理下一个请求。由于全在用户态切换,没有线程上下文切换和锁竞争开销,因此吞吐量呈几何级暴涨。
  2. asyncio.run() 的底层做了什么?可以在已经运行的循环里再次调用吗?
    • asyncio.run() 会新建一个全新的事件循环、将传入的协程封装为 main 任务执行、运行所有未完成的任务,并在结束后优雅关闭循环与线程池。
    • 严禁在已有运行事件循环的环境中再次调用 asyncio.run()(会抛出 RuntimeError: This event loop is already running,常见于 Jupyter Notebook 或 Tornado/FastAPI 内部),此时应使用 asyncio.create_task() 或直接 await。
Navigation

Type to search…

↑↓ navigate↵ selectEsc close