asyncio 是 Python 3.4 引入、3.5+ 转正的异步 I/O 框架,凭借单线程协程模型在 I/O 密集场景下展现出远超多线程的并发能力。本文从协程本质讲起,覆盖事件循环、任务调度,并以一个高并发异步爬虫收尾,帮你把 asyncio 真正落地到工程中。
一、协程:可暂停的函数
协程(Coroutine)是一种用户态的轻量级并发单元。与普通函数"一调到底"不同,协程可以在执行中途挂起(await),交出控制权,等条件满足后再恢复执行。它不依赖操作系统线程切换,开销极小。
# 用 async def 定义协程函数
async def fetch_data(url):
print(f"开始请求 {url}")
await asyncio.sleep(1) # 模拟 I/O 等待,挂起协程
print(f"完成 {url}")
return f"data:{url}"
# 直接调用协程函数只会得到协程对象,不会执行
coro = fetch_data("https://mzph.cn")
# 必须交给事件循环调度才会真正运行
关键字 async def 定义的是协程函数,调用它返回一个协程对象(coroutine object),对象本身不会执行,必须由事件循环驱动。
二、事件循环:asyncio 的心脏
事件循环(Event Loop)负责调度协程:它不断从就绪队列取出可执行的协程运行,遇到 await 挂起的就放回等待队列,等 I/O 完成再唤醒。所有协程都在同一个线程内被循环调度。
import asyncio
async def main():
# 并发执行多个协程
results = await asyncio.gather(
fetch_data("url-1"),
fetch_data("url-2"),
fetch_data("url-3"),
)
print(results)
# Python 3.7+ 推荐入口
asyncio.run(main())
asyncio.run() 是推荐的顶层入口,它会自动创建事件循环、运行主协程、并在结束后清理。注意 asyncio.run 在一个进程内应只调用一次。
核心心智模型:asyncio 是"协作式"并发,协程主动 await 才会切换;不要在协程里写同步阻塞代码(如 time.sleep、requests.get),否则会卡死整个循环。
三、Task 与并发调度
把协程包装成 Task 后,事件循环会立即调度它运行,而不必等 await。这是实现并发的关键。
async def worker(name, seconds):
for i in range(3):
await asyncio.sleep(seconds)
print(f"{name}: 第 {i+1} 次")
async def main():
# 方式一:create_task 显式创建任务,立即开始
t1 = asyncio.create_task(worker("A", 0.5))
t2 = asyncio.create_task(worker("B", 0.7))
await t1
await t2
# 方式二:gather 一次性并发收集结果
await asyncio.gather(
worker("C", 0.3),
worker("D", 0.4),
)
asyncio.run(main())
gather 会按传入顺序返回结果列表,即使后启动的任务先完成,结果顺序也保持一致。如果某个任务抛异常,默认会取消其他任务。
四、异步 I/O 与 await 的本质
await 的作用是:把当前协程挂起,把控制权交回事件循环,等到 await 的对象(Future/Task/协程)完成后恢复。挂起期间线程可以去执行其他就绪协程,从而实现并发。
# 真实异步 HTTP 请求(需 aiohttp)
import aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
# await 让出控制权,等网络 I/O 完成
return await resp.text()
async def main():
async with aiohttp.ClientSession() as session:
html = await fetch(session, "https://mzph.cn")
print(len(html))
asyncio.run(main())
五、实战:高并发异步爬虫
下面用 asyncio + aiohttp 实现一个限制并发数的批量爬虫,演示信号量控制、超时处理与错误重试。
import asyncio, aiohttp, time
class AsyncSpider:
def __init__(self, urls, concurrency=10, timeout=10):
self.urls = urls
self.sem = asyncio.Semaphore(concurrency) # 并发上限
self.timeout = aiohttp.ClientTimeout(total=timeout)
self.results = {}
async def fetch(self, session, url, retry=2):
async with self.sem: # 获取信号量,超出则等待
for attempt in range(retry + 1):
try:
async with session.get(url) as resp:
text = await resp.text()
self.results[url] = len(text)
print(f"OK {url} -> {len(text)} bytes")
return
except Exception as e:
if attempt == retry:
print(f"FAIL {url}: {e}")
else:
await asyncio.sleep(0.5)
async def run(self):
start = time.time()
async with aiohttp.ClientSession(timeout=self.timeout) as session:
tasks = [self.fetch(session, u) for u in self.urls]
await asyncio.gather(*tasks)
print(f"共 {len(self.urls)} 个,耗时 {time.time()-start:.2f}s")
if __name__ == "__main__":
urls = [f"https://httpbin.org/delay/{i%3}" for i in range(30)]
asyncio.run(AsyncSpider(urls, concurrency=10).run())
这段代码的核心在于 asyncio.Semaphore:它把并发数限制在 10,避免瞬时打开过多连接打垮目标服务器或本地资源。30 个请求理论耗时约等于 30/10 × 2s ≈ 6s,而同步爬虫需要 30 × 2 = 60s,提升近 10 倍。
六、常见踩坑提示
- 在协程里调用阻塞函数:如
time.sleep、requests.get会卡住整个循环。改用asyncio.sleep、aiohttp,或用run_in_executor丢给线程池。 - 忘记 await:协程不 await 只是创建了对象,不会执行,运行时会得到 "coroutine was never awaited" 警告。
- 在 async 函数里用 asyncio.run:
run只能在同步代码入口调用,协程内应直接 await。 - 混用同步库:传统同步库(如 requests、pymysql)会破坏异步模型的并发优势,应选用 async 版本。
asyncio 的精髓在于"用同步的代码风格写异步逻辑"。掌握协程与事件循环的协作模型后,你会发现它在爬虫、API 网关、WebSocket 服务等 I/O 密集场景下几乎是性价比最高的方案。