[Python] asyncio

asyncio 入门到实践

在 Python 并发编程里,asyncio 是一个绕不开的话题。很多人第一次接触它时,往往会被一堆概念劝退:协程、事件循环、任务、Future、取消、超时、线程切换……

但如果换个角度看,asyncio 本质上只是在解决一个问题:

当程序里有大量“等待”的操作时,如何让一个线程尽可能高效地把这些任务都调度起来。

这篇文章不追求面面俱到,而是希望用一篇内容把 asyncio 最常见的使用方式、核心概念和实际开发中的注意事项讲清楚。读完之后,你至少会明白:

  • async / await 到底在做什么
  • 事件循环是如何调度任务的
  • 什么场景应该用 asyncio
  • 哪些写法最容易踩坑

什么是 asyncio?

asyncio 是 Python 标准库中用于编写单线程并发程序的模块,它基于事件循环(Event Loop)机制,配合 async/await 语法,可以高效处理大量 I/O 密集型任务。

它尤其适合以下场景:

  • 网络请求
  • Web 服务
  • 消息队列消费
  • 长连接通信
  • 异步爬虫
  • 定时任务调度
  • 大量文件或数据库 I/O 操作

需要注意的是:

asyncio 擅长的是 I/O 密集型并发,不是 CPU 密集型并行。

如果你的任务主要是大量计算,asyncio 往往不是最佳选择。

先建立一个整体认知

在学习具体 API 之前,先看一张图,理解 asyncio 的运行机制。

asyncio 运行机制示意图

你可以把它理解成这样:

  • async def 定义的是“可以暂停和恢复的函数”
  • await 的意思是“我现在要等一下,你先去执行别的任务”
  • 事件循环就是那个“总调度员”,负责在各个任务之间切换

整个过程通常只发生在一个线程里
所以,asyncio 的高效,不是因为它让代码“同时执行”,而是因为它让程序在等待 I/O 时“不闲着”。

基础用法

  1. 定义协程
    使用 async def 定义协程函数。
import asyncio

async def say_hello():
    await asyncio.sleep(1)
    print("Hello")

这里的 asyncio.sleep(1) 并不会阻塞线程,而是告诉事件循环:“我 1 秒后再继续,先去做别的事。”

  1. 运行协程
    通常使用 asyncio.run() 作为程序入口。
asyncio.run(say_hello())

它会:

  • 创建事件循环
  • 执行协程
  • 等待协程完成
  • 最后关闭事件循环

对于绝大多数脚本程序来说,这是最推荐的启动方式。

  1. 创建任务,实现并发
    如果想让多个协程并发运行,可以使用 asyncio.create_task()。
async def main():
    task = asyncio.create_task(say_hello())
    await task

asyncio.run(main())

create_task() 会把协程包装成一个 Task,并立即交给事件循环调度。

如果没有它,协程只是“定义好了”,并不会自动并发执行。

  1. 等待多个协程
    使用 asyncio.gather()
async def main():
    results = await asyncio.gather(
        coro1(),
        coro2(),
        coro3()
    )
    print(results)

适合场景:

  • 希望并发执行多个任务
  • 希望等它们全部完成
  • 希望按传入顺序拿到结果

使用 asyncio.wait()

done, pending = await asyncio.wait(
    [task1, task2],
    return_when=asyncio.FIRST_COMPLETED
)

适合场景:

  • 只关心谁先完成
  • 想更灵活地控制任务状态
  • 需要手动处理 done 和 pending
    简单理解:
    gather() 更偏“批量拿结果”
    wait() 更偏“任务状态控制”
  1. 超时控制
    异步任务最怕无限等待,所以超时控制非常重要。
try:
    result = await asyncio.wait_for(long_task(), timeout=5.0)
except asyncio.TimeoutError:
    print("任务超时")

wait_for() 会在超时后抛出 TimeoutError,并尝试取消任务。

这在网络请求、外部依赖调用、队列消费等场景里非常常见。

  1. 异步队列:生产者-消费者模式
    asyncio.Queue 是异步程序中非常常用的数据结构。
queue = asyncio.Queue(maxsize=10)

await queue.put(item)
item = await queue.get()
queue.task_done()

它的好处是:

  • 队列满时,put() 自动挂起
  • 队列空时,get() 自动挂起
  • 非常适合任务分发和流式消费

常见应用包括:

  • 爬虫任务调度
  • 消息消费系统
  • 后台任务流水线
  • 多协程解耦
  1. 在线程中运行同步代码
    协程里最忌讳的事情之一,就是直接调用阻塞型同步代码。

例如下面这种写法就有问题:

async def bad():
    requests.get("https://example.com")

因为 requests.get() 会阻塞线程,导致整个事件循环卡住。

正确做法是把它扔到线程池中:

result = await asyncio.to_thread(requests.get, "https://example.com")

或者使用更底层的方式:

loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, blocking_func, arg1, arg2)

推荐理解为:

  • to_thread() 更现代,也更简洁
  • run_in_executor() 更底层,控制更细
  1. 跨线程提交协程
    如果事件循环正在某个线程里运行,而另外一个线程需要向它提交协程,就要使用:
future = asyncio.run_coroutine_threadsafe(queue.put(event), loop)
future.result(timeout=1)

它的作用是:

  • 从其他线程安全地把协程提交到指定事件循环中执行。

这在下面这些场景里很常见:

  • 第三方同步回调运行在其他线程中
  • 线程池任务执行完后要通知异步系统
  • 后台线程要把结果送回异步队列

如果不用它,而是直接跨线程操作异步对象,通常会报错或者出现不稳定行为。

  1. 同步原语
    asyncio 也提供了一些常见的并发控制工具:
asyncio.Lock
asyncio.Event
asyncio.Semaphore
asyncio.Condition

例如:

lock = asyncio.Lock()

async with lock:
    # 临界区
    ...

注意一点:
这些是给协程之间用的,不是给线程之间用的。

  1. 子进程管理
    asyncio 也支持异步地启动和管理子进程。
proc = await asyncio.create_subprocess_exec(
    "ls",
    stdout=asyncio.subprocess.PIPE
)

stdout, _ = await proc.communicate()

适合场景:

  • 异步执行命令行工具
  • 读取外部程序输出
  • 构建流式处理管道
  1. 网络流(Streams)
    如果你需要做 TCP 通信,asyncio 也提供了原生支持。
reader, writer = await asyncio.open_connection("127.0.0.1", 8888)

服务端可以使用 asyncio.start_server()。

适用场景包括:

  • TCP 客户端/服务端
  • 长连接通信
  • 自定义协议实现
  • 简单代理服务
  1. 获取当前事件循环与调试
    在协程中可以这样获取当前运行中的事件循环:
loop = asyncio.get_running_loop()

如果当前线程没有运行中的事件循环,会直接抛异常。

调试时,还可以开启 asyncio 调试模式:

loop.set_debug(True)

或者设置环境变量:

PYTHONASYNCIODEBUG=1

这对排查以下问题很有帮助:

  • 协程忘记 await
  • 任务没有正确回收
  • 回调执行过慢
  • 某些 Future 状态异常
  1. 屏蔽取消操作:asyncio.shield()
    有时候,某些逻辑即使外层任务被取消,也希望它尽量执行完成,比如:
  • 写审计日志
  • 保存状态
  • 提交事务
  • 清理资源
    这时可以用 asyncio.shield():
task = asyncio.create_task(sensitive_coro())
result = await asyncio.shield(task)

它的作用不是“让任务永远无法取消”,而是:
避免外层取消直接传播到这个 await 的对象。
所以它适合“关键收尾逻辑”,但不能滥用,否则会让取消机制失去意义。

什么情况下应该使用 asyncio?

很多人学了 asyncio 之后,最常见的问题不是“怎么写”,而是“该不该用”。

我的建议很简单:

  • 适合使用 asyncio 的场景
  • 有大量网络请求
  • 有大量 I/O 等待
  • 有很多任务需要高并发调度
  • 需要维护大量连接
  • 任务之间切换频繁,但单个任务大部分时间都在等待

比如:

  • 爬虫
  • Web 服务
  • 聊天系统
  • 网关服务
  • 消息消费系统
  • 异步任务处理器

不适合使用 asyncio 的场景,主要是 CPU 密集型计算,代码依赖大量阻塞式第三方库,任务量不大,异步收益很低
团队对异步模型不熟,维护成本远高于收益
比如:图像处理,大规模数值计算,复杂机器学习推理,本质上是串行脚本的小工具

一句话总结:
如果瓶颈在“等待”,就考虑 asyncio;如果瓶颈在“计算”,优先考虑多进程或其他并行方案。

常见坑

这一部分很重要。很多人不是不会写 asyncio,而是会写但写得“像同步代码”,结果表面上用了异步,实际上还是低效甚至有 bug。

  1. 只写了 async def,却没有真正并发
    很多初学者以为写成 async def 就自动并发了,其实不是。
async def main():
    await task1()
    await task2()

这段代码仍然是顺序执行的。只有在适当的时候使用 create_task() 或 gather(),才会真正并发调度。

async def main():
    await asyncio.gather(task1(), task2())
  1. 在协程里调用阻塞函数
    这是最常见、也最隐蔽的坑。
async def main():
    time.sleep(3)  # 错误示例

time.sleep() 会直接阻塞线程,导致整个事件循环停住,所有协程都无法继续执行。

正确写法应该是:

await asyncio.sleep(3)

如果必须调用阻塞式同步函数,就用 asyncio.to_thread()。

  1. 忘记 await
    下面这种情况非常常见:
async def main():
    say_hello()

这不会真正执行协程,而只是创建了一个协程对象,通常还会伴随告警:coroutine was never awaited。

正确写法是:

await say_hello()

或者:

asyncio.create_task(say_hello())
  1. 创建了任务,却没有回收
    例如:
asyncio.create_task(worker())

这样虽然任务跑起来了,但如果你后面完全不管它,就可能出现:

  • 异常被吞掉
  • 程序结束时任务未完成
  • 调试时很难定位问题

更稳妥的做法是:

  • 保存任务引用
  • 在合适的时机 await
  • 或统一管理任务生命周期
task = asyncio.create_task(worker())
await task
  1. 把 asyncio 当成多线程使用
    asyncio 是单线程并发模型,不是多线程模型。

所以:

  • asyncio.Lock 不是线程锁
  • 协程安全不等于线程安全
  • 跨线程不能随便操作事件循环对象

如果涉及线程和事件循环交互,要用 run_coroutine_threadsafe() 或者线程安全队列等机制。

  1. 取消任务时没有做好清理
    异步任务是可以被取消的,所以代码里最好考虑取消时的清理逻辑。
async def worker():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        print("任务被取消,开始清理")
        raise

如果忽略这一点,可能导致:资源未释放,状态不一致以及完成操作残留

  1. 对 gather() 的异常传播理解不清
await asyncio.gather(task1(), task2(), task3())

默认情况下,只要其中一个任务抛异常,gather() 就会把异常抛出来。

如果你希望“即使某个任务失败,也收集其他任务结果”,可以使用:

results = await asyncio.gather(
    task1(), task2(), task3(),
    return_exceptions=True
)

但要注意,这时返回结果里可能混有异常对象,需要你自己判断处理。

  1. 滥用 shield(),让取消失控
    shield() 适合保护关键收尾逻辑,但如果什么都用它包起来,任务就很难被正常取消,程序退出时也可能拖很久。

原则上:

  • 关键清理可以保护
  • 主体业务逻辑不要轻易屏蔽取消
  1. 在不该用异步的地方强行用异步
    有些项目明明只有两三个请求,或者逻辑几乎全是计算,却为了“看起来高级”硬上 asyncio,结果代码复杂度显著上升,收益却很有限。

技术选型最怕“为了用而用”。异步不是目标,解决问题才是目标。

六、关于 run_coroutine_threadsafe(),到底什么时候该用?
这个 API 很多人看文档时能看懂,但实际写代码时还是容易混乱。
它的适用条件非常明确:
当前线程不是事件循环线程,但你需要把一个协程提交到那个事件循环中执行。
例如:

  • 某个同步 SDK 的回调发生在后台线程
  • 线程池任务执行完成,要把结果塞回异步队列
  • 非异步线程想触发某个异步发送逻辑
    示例:
future = asyncio.run_coroutine_threadsafe(queue.put(event), loop)
future.result(timeout=1)

它解决的问题不是“少写一个 await”,而是:跨线程安全地调度协程。
如果你的代码本身已经在协程里,那通常直接 await queue.put(event) 就够了,没必要使用它。

所以可以这样记:
在协程里:直接 await
在线程里:run_coroutine_threadsafe()

实战选型建议

如果把常见开发场景做一个简单归纳,可以参考下面这套思路:

简单脚本
使用:

asyncio.run()
asyncio.gather()

适合快速并发执行多个 I/O 任务。

Web 服务
结合:FastAPI,aiohttp,让请求处理函数天然支持协程模型。

长时间运行的后台任务
使用:

asyncio.create_task()
asyncio.Queue

超时控制
取消机制
适合任务调度、消息消费、异步工作流。

混合同步代码
使用:

asyncio.to_thread()
run_in_executor()
把阻塞逻辑隔离出去,避免卡住事件循环。

跨线程协作
使用:

asyncio.run_coroutine_threadsafe()

不要直接跨线程操作异步对象。

总结

如果你想真正学会 asyncio,不需要一开始就钻进所有底层细节。先抓住这三件事就够了:

  • async def 定义协程
  • await 表示挂起等待
  • 事件循环负责调度任务

在此基础上,再逐步掌握:

  • create_task() 做并发
  • gather() 管理批量任务
  • wait_for() 做超时控制
  • Queue 组织任务流
  • to_thread() 兼容同步阻塞代码
  • run_coroutine_threadsafe() 处理跨线程协作

说到底,asyncio 不是什么“高深黑魔法”,它只是提供了一种更高效的 I/O 并发编程方式。真正重要的,不是记住多少 API,而是理解一句话:当程序在等待时,如何不要浪费这段时间。

参考:
Python 异步编程入门
事件循环

posted @ 2026-04-26 14:10  Jamest  阅读(6)  评论(0)    收藏  举报