Python asyncio:一个线程玩转高并发的"调度中心"
引言:从"自己换挡"到"自动驾驶"
上一篇我们学了协程:用 yield 和 send() 手动在生产者和消费者之间传递执行权,就像手动挡汽车——什么时候换挡、换到哪一挡,全靠自己操作。灵活,但麻烦,任务一多就手忙脚乱。
asyncio 就是 Python 协程的"自动挡"。
你只需告诉它:"这里有几个任务,都可能需要等待。"asyncio 内置的**事件循环(Event Loop)**会像一位经验丰富的调度员,自动决定:哪个任务该运行了、哪个任务在等待就先挂起、哪个任务的数据到了就唤醒它。你不再需要手写 send(),一切切换自动完成。
asyncio 是 Python 3.4 引入的标准库,直接内置了对异步 IO 的支持。从 Python 3.5 开始,又有了专门的 async / await 语法,让协程代码像普通同步代码一样清晰易读。
这一篇,我们从"消息循环"讲起,说清楚 async/await 到底做了什么、事件循环如何调度多个任务、如何用 asyncio.gather() 实现并发,最后通过一个"单线程同时抓取三个网站"的实战和常见误区解析,让你真正掌握 asyncio 这把高并发利器。
一、asyncio 的核心:消息循环(Event Loop)
1.1 生活化理解:餐厅服务员的"巡台"模式
想象餐厅里只有一个服务员,却要照顾 10 桌客人:
- 同步模式(笨办法):站在 1 号桌旁,等客人慢慢看菜单、点菜、吃完、结账,全程服务完才去 2 号桌。10 桌客人从中午等到天黑。
- asyncio 模式(聪明办法):到 1 号桌,客人说"我先看看菜单"——服务员立刻在本子上记下"1 号桌:看菜单中",转身去 2 号桌;2 号桌说"我要点这个"——记下菜单送到厨房,又记下"2 号桌:等上菜",再去 3 号桌……服务员手里有个小本子(事件循环),记录每桌的状态,谁准备好了就去服务谁,绝不空等。
这个小本子就是 Event Loop(事件循环),每桌客人就是一个协程任务,"看菜单""等上菜"就是 IO 等待。
1.2 asyncio 的编程模型
asyncio 的编程模型就是一个消息循环:
asyncio模块内部实现了EventLoop;- 把需要执行的协程(coroutine)扔进
EventLoop; EventLoop循环检查:哪些协程可以运行?哪些在等待 IO?IO 数据到了就唤醒对应的协程继续执行。
全程只有一个线程,但因为等待时间被充分利用,一个线程就能并发处理成百上千个任务。
1.3 语法演进:从装饰器到 async/await
| 版本 | 写法 | 现状 |
|---|---|---|
| Python 3.4 | @asyncio.coroutine + yield from | 已淘汰,了解即可 |
| Python 3.5+ | async def + await | 标准写法,强烈推荐 |
新语法让协程"看起来"就是普通函数,只是多了 async 和 await 两个关键字,可读性大幅提升。本文全部使用新语法。
二、第一个 asyncio 程序:Hello World
2.1 代码
import asyncio
async def hello():
print("Hello world!")
# 异步调用 asyncio.sleep(1)
await asyncio.sleep(1)
print("Hello again!")
asyncio.run(hello())2.2 执行结果
Hello world!
(等待约1秒)
Hello again!2.3 逐行拆解
async def hello():async把一个普通函数变成协程(coroutine)。注意:此时调用hello()并不会真正执行函数体,只会返回一个协程对象。await asyncio.sleep(1):await用于调用另一个协程并等待它完成。asyncio.sleep(1)是一个异步版的"睡觉",表示一个耗时 1 秒的 IO 操作。关键区别:如果是普通的
time.sleep(1),整个线程会被卡住 1 秒,什么也干不了;而await asyncio.sleep(1)会让当前协程主动让出执行权,线程立刻转去执行事件循环中其他就绪的任务。1 秒后再回来接着执行下一行。asyncio.run(hello()):把协程扔进事件循环并启动它。这是程序的入口,负责创建 Event Loop、运行协程、结束后关闭循环。
2.4 生活化理解
await 就像点外卖:你下单后不会站在原地干等,而是对调度员说"外卖到了叫我",然后去做别的事。外卖(IO 结果)一到,调度员通知你回来继续。
三、真正的并发:用 gather() 同时跑多个协程
上面的例子只有一个任务,看不出并发。现在让两个 hello() 同时执行。
3.1 代码
import threading
import asyncio
async def hello(name):
# 打印 name 和当前线程
print("Hello %s! (%s)" % (name, threading.current_thread()))
await asyncio.sleep(1)
print("Hello %s again! (%s)" % (name, threading.current_thread()))
return name
async def main():
# 同时调度多个协程
L = await asyncio.gather(hello("Bob"), hello("Alice"))
print(L)
asyncio.run(main())3.2 执行结果
Hello Bob! (<_MainThread(MainThread, started 12360)>)
Hello Alice! (<_MainThread(MainThread, started 12360)>)
(等待约1秒)
Hello Bob again! (<_MainThread(MainThread, started 12360)>)
Hello Alice again! (<_MainThread(MainThread, started 12360)>)
['Bob', 'Alice']3.3 结果解读:三个关键信息
- 同一个线程:两次打印的线程 ID 完全相同(
MainThread),证明所有协程都在一个线程里执行; - 真正的并发:两个
hello()都先打印了 "Hello ...",然后一起等待 1 秒,再先后打印 "again"。总耗时约 1 秒,而不是 2 秒——如果是串行执行,就要等 2 秒; - 能拿到返回值:
asyncio.gather()把所有协程的return结果收集成列表['Bob', 'Alice']返回。
3.4 asyncio.gather() 的作用
gather() 就像一个"任务发射器":把多个协程同时交给事件循环,它们齐头并进,谁等待就让出执行权给别人,全部完成后统一返回结果列表。
生活化理解:你同时烧一壶水、烤一片面包、煮一个鸡蛋。三件事都在"进行中",你什么都不用盯着,定时器一响(各自完成)就处理对应那件。gather() 就是帮你"同时开火"的那个灶台开关。
四、实战:一个线程同时抓取三个网站首页
理论说够了,来一次真正的网络 IO 并发。用 asyncio 的异步网络连接,同时获取 sina、sohu 和 163 的首页响应头。
4.1 代码
import asyncio
async def wget(host):
print(f"wget {host}...")
# 第 1 步:异步连接 80 端口(IO 操作,会让出执行权)
reader, writer = await asyncio.open_connection(host, 80)
# 第 2 步:发送 HTTP 请求
header = f"GET / HTTP/1.0\r\nHost: {host}\r\n\r\n"
writer.write(header.encode("utf-8"))
await writer.drain() # 等待数据真正发出去(IO 操作)
# 第 3 步:逐行读取 HTTP 响应头
while True:
line = await reader.readline() # 异步读一行(IO 操作)
if line == b"\r\n": # 空行表示响应头结束
break
print("%s header > %s" % (host, line.decode("utf-8").rstrip()))
# 第 4 步:忽略 body,关闭连接
writer.close()
await writer.wait_closed()
print(f"Done {host}.")
async def main():
# 三个网站的抓取任务同时并发执行
await asyncio.gather(
wget("www.sina.com.cn"),
wget("www.sohu.com"),
wget("www.163.com")
)
asyncio.run(main())4.2 执行结果
wget www.sohu.com...
wget www.sina.com.cn...
wget www.163.com...
(等待一段时间)
(打印出 sohu 的 header)
www.sohu.com header > HTTP/1.1 200 OK
www.sohu.com header > Content-Type: text/html
...
(打印出 sina 的 header)
www.sina.com.cn header > HTTP/1.1 200 OK
www.sina.com.cn header > Date: Wed, 20 May 2015 04:56:33 GMT
...
(打印出 163 的 header)
www.163.com header > HTTP/1.0 302 Moved Temporarily
www.163.com header > Server: Cdn Cache Server V2.0
...
Done www.sina.com.cn.
Done www.sohu.com.
Done www.163.com.4.3 为什么快?
注意开头的三行 wget ... 几乎同时打印——三个连接同时发起,没有先后顺序的等待。
整个过程中,一个线程通过 3 个 await 点(连接、发送、读取)在三个任务之间来回切换:
- 任务 A 发起连接后要等服务器响应 → 让出执行权;
- 事件循环切到任务 B,B 也在等 → 切到任务 C;
- 哪个服务器先返回数据,就唤醒对应的任务继续读取。
如果把每个 await 的 IO 操作想象成"等快递":三个包裹同时发货,谁先到你先拆谁,而不是傻等第一个包裹到了再发第二个。
4.4 对比同步写法
如果用同步的 requests 依次抓三个网站,每个假设 0.5 秒,总共要 1.5 秒;用 asyncio 并发,总耗时约等于最慢的那个(约 0.5 秒)。网站越多、网络越慢,asyncio 的优势越夸张——抓 1000 个网站,同步要几百秒,asyncio 可能只需几秒。
五、核心概念总结:三个关键字记住 asyncio
| 概念 | 作用 | 类比 |
|---|---|---|
async def | 定义一个协程(可以暂停和恢复的函数) | 一个"可被调度"的任务单 |
await | 调用另一个协程,等待时主动让出执行权 | "外卖到了叫我" |
asyncio.run() | 启动事件循环,运行入口协程 | 调度中心开机 |
asyncio.gather() | 并发调度多个协程,收集所有返回值 | 灶台同时开多个火 |
| Event Loop | asyncio 内部的调度器,决定谁运行、谁等待、唤醒谁 | 服务员的"巡台小本子" |
最重要的三条使用规则:
- 用
asyncio.run()调度一个入口协程; - 在
async函数内部,用await调用另一个async函数——代码看起来是串行的,实际由消息循环自动调度; - 用
await asyncio.gather()并发执行若干个async函数。
六、常见误区解析
误区一:协程函数直接调用就会执行
错误。调用 async 函数(如 hello())只是创建了一个协程对象,函数体一行都没执行:
hello() # 只返回协程对象,什么都没打印
asyncio.run(hello()) # 这样才真正执行如果你在调试时发现"明明调用了却没反应",多半是忘了交给事件循环。
误区二:在协程里用 time.sleep() 模拟等待
错误。time.sleep() 是同步阻塞的,会把整个线程卡死,事件循环里所有协程一起停摆:
async def bad():
time.sleep(1) # 大错特错:整个线程睡死 1 秒
async def good():
await asyncio.sleep(1) # 正确:只挂起当前协程,线程去忙别的记住:协程里一切 IO 和等待都必须用异步版本——asyncio.sleep() 替代 time.sleep(),aiohttp 替代 requests,asyncio.open_connection() 替代普通 socket。
误区三:await 可以加在任何函数调用前
错误。await 后面只能跟可等待对象(协程、Task、Future)。对普通函数用 await 会直接报错:
def normal_func():
return 42
await normal_func() # TypeError: object int can't be used in 'await' expression误区四:asyncio 适合所有场景
错误。asyncio 是 IO 密集型任务的王者(网络请求、数据库查询、文件读写),但对 CPU 密集型任务(大规模计算、图像处理)毫无帮助——因为计算不会"等待",协程没有切换的机会,单线程还是一个核在算。CPU 密集任务请用多进程(multiprocessing)。
误区五:gather() 会按顺序执行协程
不准确。gather() 是并发启动所有协程,它们的执行是交错的,谁先谁后不确定(上面例子中 Bob 和 Alice 的打印顺序就是并发的体现)。但返回的结果列表顺序与传入顺序一致,方便对应。
如果需要严格按顺序执行,直接连续 await 即可,不需要 gather()。
误区六:asyncio 程序里可以随便用普通库
危险。混用同步库和 asyncio 是最常见的线上事故。比如在一个高并发的 aiohttp 服务里,某个角落调用了同步的 requests.get(),一次请求就会卡死整个事件循环,所有在线用户全部卡住。排查这类问题时,重点检查依赖库里是否藏着同步 IO。
七、小结
- asyncio 是 Python 标准库提供的异步 IO 框架,核心是事件循环(Event Loop)——一个自动调度协程的"消息循环";
async def定义协程,await在协程内调用其他协程并让出执行权,asyncio.run()启动事件循环;asyncio.gather()让多个协程并发执行,一个线程同时处理多个 IO 任务,总耗时约等于最慢的那个任务;- 把
await后的耗时操作想象成"等快递":发出请求后绝不干等,先处理别的,到了再回来; - 适用边界要牢记:IO 密集用 asyncio,CPU 密集用多进程;协程内禁用一切同步阻塞调用;
- 掌握了 asyncio,下一步就是学习基于它的生态库:
aiohttp(异步 Web 框架/HTTP 客户端)、aiomysql(异步数据库)等,构建真正的高并发服务。