异步IO
前面聊进程、线程的时候,我们已经知道:CPU 跑得飞快,但磁盘、网络这些 IO 设备慢得像蜗牛。一个程序里,CPU 吭哧吭哧执行代码,一旦遇到读写文件、收发网络数据这种 IO 操作,就只能干等着。这一章就来盘一盘怎么用「异步 IO」把这个等待时间利用起来。
1. 同步 IO 与异步 IO
1.1 什么是同步 IO
所谓同步 IO,就是代码一行一行顺序执行,遇到 IO 操作就停下来,等 IO 完成才能继续往下走。
看个例子:
# 模拟同步 IO 的执行过程
do_something()
# 打开文件并读取
f = open('/path/to/file', 'r')
r = f.read() # 线程在这里停住,等待文件读取完成
# 拿到结果后才能继续
handle_result(r)在 f.read() 这段等待时间里,当前线程被挂起,其他需要 CPU 执行的代码也没法在这个线程里跑了。对于单线程来说,这就是赤裸裸的浪费。
1.2 多线程/多进程不是万能药
为了解决这个问题,很多人会想到多线程或者多进程:每个用户分配一个线程,某个线程被 IO 卡住,别的线程还能继续跑。
但线程不能无限创建:
- 每个线程都要占用内存和系统资源。
- 线程数量太多,CPU 大部分时间花在线程切换上,真正执行代码的时间反而少了。
- 线程同步、锁机制还会引入额外的复杂度和性能开销。
说白了,多线程/多进程只是「用更多线程来掩盖等待」,并没有从根本上解决 CPU 和 IO 速度不匹配的问题。
1.3 异步 IO 的思路
异步 IO的核心思想是:只发 IO 请求,不等结果,先去干别的;等 IO 完成后,再通知 CPU 回来处理结果。
这听起来像多线程,但关键区别在于:
- 异步 IO 只有一个线程(或者少数线程)。
- 线程不会被 IO 阻塞,而是在等待期间去执行其他任务。
- 没有线程切换的开销,也不需要复杂的锁机制。
异步 IO 需要一种消息循环机制,主线程不断重复「读取消息 → 处理消息」:
loop = get_event_loop()
while True:
event = loop.get_event() # 获取下一个事件
process_event(event) # 处理事件这个模型其实和 GUI 程序很像。GUI 主线程一直监听键盘、鼠标消息,消息来了就处理,处理完立刻回到循环里等待下一个消息。某个消息处理太慢,界面就会卡住,因为后续消息得不到及时处理。
IO 操作也可以包装成事件:
- 发出 IO 请求,不等待结果。
- 结束本轮消息处理,继续处理其他消息。
- IO 完成后,产生一个「IO 完成」事件。
- 处理该事件时读取结果。
从「发出请求」到「收到完成通知」这段时间,CPU 没闲着,而是在处理别的消息。所以异步 IO 特别适合 IO 密集型场景,比如网络爬虫、Web 服务器、聊天服务器等。
2. 协程
异步 IO 需要一个东西来承载「可以在 IO 等待时让出 CPU」的代码单元,这个东西就是协程(Coroutine)。
2.1 协程是什么
协程又叫微线程、纤程。它看起来像函数,但执行过程中可以中断,转而去执行别的协程,之后再回来接着执行。
def a():
print('1')
print('2')
print('3')
def b():
print('x')
print('y')
print('z')如果是普通函数调用,输出一定是 1 2 3 x y z 这种顺序。但如果是协程调度,A 执行到一半可以中断,让 B 跑一段,再切回 A,结果可能是:
1
2
x
y
3
z注意:A 中并没有调用 B,所以协程的切换不是函数调用,更像是 CPU 中断。这是协程和多线程最大的区别之一。
2.2 协程的优势
协程运行在单个线程里,有两个明显优势:
- 切换开销极小:协程切换由程序自己控制,不需要操作系统介入,没有线程切换的上下文开销。
- 不需要锁:因为同一时刻只有一个协程在运行,不存在多个线程同时修改共享变量的问题,只要通过状态判断就能控制共享资源。
那协程怎么利用多核 CPU?答案是多进程 + 协程。进程负责利用多核,协程负责单核内的高并发,二者结合可以获得极高的性能。
2.3 从生成器到协程
Python 最早对协程的支持是通过生成器(generator)实现的。生成器函数里有 yield 关键字,调用时不会立刻执行,而是返回一个生成器对象。
更妙的是,yield 不但能返回一个值,还能接收调用者传进来的参数。
经典的生产者-消费者模型:
def consumer():
r = ''
while True:
# 接收生产者发来的消息,同时返回处理结果
n = yield r
if not n:
return
print('[CONSUMER] Consuming %s...' % n)
r = '200 OK'
def produce(c):
# 启动生成器
c.send(None)
n = 0
while n < 5:
n = n + 1
print('[PRODUCER] Producing %s...' % n)
# 发送消息给消费者,并接收消费者的返回值
r = c.send(n)
print('[PRODUCER] Consumer return: %s' % r)
c.close()
c = consumer()
produce(c)执行结果:
[PRODUCER] Producing 1...
[CONSUMER] Consuming 1...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2...
[CONSUMER] Consuming 2...
[PRODUCER] Consumer return: 200 OK
...整个流程里,生产者和消费者在一个线程里协作完成,没有锁,没有线程切换,这就是协程的雏形。
不过这种写法比较原始,需要手动调用 send() 和 close()。Python 3.3 引入了 yield from,可以更方便地委托子生成器:
def sub_generator():
yield 1
yield 2
yield 3
def main_generator():
yield 'start'
yield from sub_generator()
yield 'end'
for x in main_generator():
print(x)输出:
start
1
2
3
endyield from 可以把一个生成器完全委托给另一个生成器,调用者感觉就像直接迭代 main_generator() 一样。它也是后来 async/await 的语法前身。
3. 使用 asyncio
Python 3.4 引入了 asyncio 标准库,专门用来支持异步 IO。到 Python 3.5,又引入了 async 和 await 关键字,让协程代码写起来更清爽。
3.1 初识 async / await
用 async 定义的函数叫做协程函数,调用它返回的是一个协程对象,而不是直接执行。
import asyncio
async def hello():
print('Hello world!')
# 模拟一个耗时 1 秒的 IO 操作
await asyncio.sleep(1)
print('Hello again!')
# 运行协程
asyncio.run(hello())输出:
Hello world!
(等待约 1 秒)
Hello again!这里的 await 表示:现在我要等待一个异步操作完成,但等待期间我不阻塞线程,线程可以跑去执行别的协程。asyncio.sleep(1) 本身就是一个协程函数,所以 await 它的时候,事件循环会调度其他任务执行。
3.2 事件循环
asyncio 的核心是事件循环(Event Loop)。
asyncio.run(coro)会创建一个新的事件循环,运行协程,运行完后关闭循环。- 事件循环负责调度所有协程:哪个可以执行就执行哪个,哪个在等待 IO 就先挂起。
简单理解:asyncio.run() 是入口,把协程「扔」进事件循环里,让它自己跑。
3.3 并发执行多个协程
单看上面的 hello() 还没体现出并发。我们改造一下,让两个 hello() 同时跑:
import asyncio
async def hello(name):
print(f'Hello {name}!')
await asyncio.sleep(1)
print(f'Hello {name} again!')
return name
async def main():
# 同时调度两个协程,等它们都完成
result = await asyncio.gather(
hello('Bob'),
hello('Alice'),
)
print(result)
asyncio.run(main())输出:
Hello Bob!
Hello Alice!
(等待约 1 秒)
Hello Bob again!
Hello Alice again!
['Bob', 'Alice']注意:两个 hello() 是同一个线程执行的,总耗时约 1 秒,而不是 2 秒。如果没有并发,顺序执行就是 2 秒。
asyncio.gather() 会把多个协程打包起来,一起交给事件循环调度,等所有协程完成后再返回结果列表。
3.4 用 create_task 创建任务
除了 gather(),我们还可以用 asyncio.create_task() 把协程包装成任务(Task),任务会被事件循环尽快调度执行:
import asyncio
async def do_work(name, delay):
print(f'{name} 开始')
await asyncio.sleep(delay)
print(f'{name} 完成')
return f'{name} 的结果'
async def main():
# 创建两个任务
task1 = asyncio.create_task(do_work('任务A', 2))
task2 = asyncio.create_task(do_work('任务B', 1))
print('任务已创建,主协程继续干点别的...')
await asyncio.sleep(0.5)
print('主协程等任务完成')
# 等待两个任务完成并获取结果
result1 = await task1
result2 = await task2
print(result1, result2)
asyncio.run(main())create_task() 和 gather() 的区别:
create_task()是「立即安排执行,但我可以稍后等待它」。gather()是「等所有协程一起完成,并返回结果列表」。
两者也可以结合使用,比如先把多个协程都 create_task() 出来,再用 gather() 等待它们。
3.5 实战:并发下载网页
把 asyncio.sleep() 换成真正的网络 IO,效果更明显。下面用 asyncio 内置的 open_connection() 并发抓取几个网站的首页:
import asyncio
async def wget(host):
print(f'wget {host}...')
# 建立 TCP 连接
reader, writer = await asyncio.open_connection(host, 80)
# 构造 HTTP 请求
header = f'GET / HTTP/1.0\r\nHost: {host}\r\n\r\n'
writer.write(header.encode('utf-8'))
await writer.drain()
# 读取响应头
while True:
line = await reader.readline()
if line == b'\r\n':
break
print(f'{host} header > {line.decode("utf-8").strip()}')
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())三个网站同时发请求,连接、等待、读取响应都是并发进行的,最终由同一个线程完成。这就是异步 IO 的威力。
4. 使用 aiohttp
asyncio 已经实现了 TCP、UDP、SSL 等协议,但 HTTP 请求用它原生的 API 写起来比较繁琐。实际项目中,我们通常用 aiohttp,一个基于 asyncio 的 HTTP 框架。
4.1 安装
pip install aiohttp4.2 简单的异步 HTTP 请求
下面用 aiohttp 发起一个 GET 请求,并获取响应内容:
import aiohttp
import asyncio
async def fetch(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
print(f'状态码: {response.status}')
text = await response.text()
print(f'内容长度: {len(text)}')
return text
async def main():
html = await fetch('https://www.example.com')
print(html[:200])
asyncio.run(main())注意几个点:
aiohttp.ClientSession()要用async with管理,确保会话正确关闭。session.get()也要用async with管理响应对象。response.text()是协程,需要await才能拿到内容。
4.3 并发请求多个 URL
aiohttp 配合 asyncio.gather() 并发请求非常自然:
import aiohttp
import asyncio
async def fetch(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
urls = [
'https://www.example.com',
'https://www.baidu.com',
'https://www.bing.com',
]
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
results = await asyncio.gather(*tasks)
for url, html in zip(urls, results):
print(f'{url} -> {len(html)} 字节')
asyncio.run(main())三个请求同时发出去,等待响应期间线程不会空等,而是去处理其他请求。对于网络爬虫、API 批量调用这类场景,性能提升非常明显。
4.4 写一个最简单的 aiohttp Web 服务
aiohttp 也可以做服务端。下面实现一个极简的 Web 服务:
from aiohttp import web
async def index(request):
text = '<h1>Index Page</h1>'
return web.Response(text=text, content_type='text/html')
async def hello(request):
name = request.match_info.get('name', 'World')
text = f'<h1>Hello, {name}!</h1>'
return web.Response(text=text, content_type='text/html')
app = web.Application()
app.add_routes([
web.get('/', index),
web.get('/{name}', hello),
])
if __name__ == '__main__':
web.run_app(app)启动后访问:
http://localhost:8080/返回首页。http://localhost:8080/Bob返回Hello, Bob!。
这里的 index 和 hello 都是 async 函数,aiohttp 会在收到 HTTP 请求时调用它们。因为底层是 asyncio,处理请求时如果涉及 IO 等待,不会阻塞其他请求,所以单线程也能支持大量并发连接。
总结
- 同步 IO 遇到 IO 操作会阻塞线程,多线程/多进程虽然能并发,但线程切换和资源开销会限制性能。
- 异步 IO 只发请求不等待,利用消息循环让一个线程同时处理多个 IO 任务,特别适合 IO 密集型应用。
- 协程是异步 IO 的载体,比线程切换轻量,单线程内通过状态协作即可,不需要复杂锁机制。
- Python 早期用
yield和yield from模拟协程,Python 3.5 以后用async/await写协程更直观。 asyncio提供事件循环,asyncio.run()启动协程,await挂起当前协程并让出 CPU,asyncio.gather()并发等待多个协程,asyncio.create_task()把协程包装成可调度任务。aiohttp是基于asyncio的 HTTP 框架,既能做客户端并发请求,也能做服务端处理高并发 HTTP 请求。- 记住核心原则:
await的地方,就是程序可以切换去干别的的地方。只要把 IO 操作都写成await,就能让异步 IO 跑起来。