1. 为什么我们需要asyncio?
2009年,当Guido van Rossum首次在Python 3.4中引入asyncio时,很多人并不理解这个看似复杂的异步IO框架到底解决了什么问题。直到今天,仍有不少开发者在使用asyncio时感到困惑——为什么不用多线程?为什么需要事件循环?协程和生成器有什么区别?让我们从一个真实的场景开始。
假设你正在开发一个网络爬虫,需要从100个不同的网站抓取数据。如果使用传统的同步方式,代码可能是这样的:
python复制import requests
def fetch(url):
response = requests.get(url)
return response.text
urls = ['http://example.com/1', 'http://example.com/2', ...] # 100个URL
for url in urls:
content = fetch(url) # 每次请求都会阻塞程序
process(content)
这种同步方式的最大问题是:当程序在等待网络响应时,CPU是完全空闲的。对于100个URL,如果每个请求耗时1秒,总耗时就是100秒——其中99%的时间都在等待IO。
1.1 传统解决方案的局限性
开发者首先想到的替代方案通常是多线程:
python复制from threading import Thread
def worker(url):
content = fetch(url)
process(content)
threads = []
for url in urls:
t = Thread(target=worker, args=(url,))
t.start()
threads.append(t)
for t in threads:
t.join()
多线程确实可以缩短总耗时(理想情况下可能接近1秒),但它带来了新的问题:
- 线程切换开销:每次线程切换需要保存/恢复寄存器状态,消耗CPU周期
- 竞争条件:需要复杂的锁机制来保护共享数据
- 内存消耗:每个线程需要分配独立的栈空间(通常8MB)
- GIL限制:Python的全局解释器锁使多线程无法真正并行执行CPU密集型任务
1.2 asyncio的核心理念
asyncio采用了完全不同的思路——单线程+协程+事件循环。它的工作原理可以用餐厅服务来类比:
- 同步模型:就像只有一个服务员,必须等一桌客人点完菜才能服务下一桌
- 多线程模型:雇佣多个服务员,但需要协调他们的工作,可能发生冲突
- asyncio模型:一个超级服务员,当某桌客人看菜单时,就去服务其他桌
具体到技术层面,asyncio的核心组件包括:
- 事件循环(Event Loop):中枢调度器,决定下一个执行哪个协程
- 协程(Coroutine):可暂停/恢复的函数,使用
async def定义 - Future/Task:表示异步操作结果的对象
- Awaitables:任何可以被
await的对象(协程、Future、Task等)
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. asyncio基础:从零开始理解协程
2.1 协程的本质
协程是asyncio的基本执行单元。与普通函数不同,协程可以在执行过程中暂停,将控制权交还给事件循环,待某些条件满足后再恢复执行。这种特性使得单个线程可以"同时"处理多个IO密集型任务。
一个最简单的协程示例:
python复制import asyncio
async def hello():
print("Hello")
await asyncio.sleep(1) # 模拟IO操作
print("World")
# 运行协程
asyncio.run(hello())
关键点解析:
async def:定义协程函数的关键字await:暂停当前协程,直到awaitable对象完成asyncio.run():启动事件循环的入口函数
2.2 事件循环的工作原理
事件循环是asyncio的核心引擎,它的工作流程可以简化为:
- 维护一个任务队列(就绪队列和等待队列)
- 从就绪队列中取出一个任务执行
- 当任务遇到
await时,将其挂起到等待队列 - 检查等待队列,将已完成IO的任务移回就绪队列
- 重复步骤2-4,直到所有任务完成
python复制# 手动创建事件循环的示例
async def task1():
print("Task 1 started")
await asyncio.sleep(2)
print("Task 1 completed")
async def task2():
print("Task 2 started")
await asyncio.sleep(1)
print("Task 2 completed")
loop = asyncio.get_event_loop()
tasks = [task1(), task2()]
loop.run_until_complete(asyncio.gather(*tasks))
loop.close()
输出顺序将是:
code复制Task 1 started
Task 2 started
Task 2 completed
Task 1 completed
2.3 await背后的魔法
await关键字是协程调度的关键。当解释器遇到await时,会发生以下操作:
- 挂起当前协程
- 将控制权返回给事件循环
- 事件循环检查被await的对象是否已完成:
- 如果已完成,恢复协程执行
- 如果未完成,将该协程放入等待队列
- 事件循环继续执行其他就绪任务
值得注意的是,await只能用在协程函数内部。如果在普通函数中使用,会引发语法错误。
3. 实战:构建异步网络爬虫
现在让我们用asyncio实现文章开头提到的网络爬虫。我们将使用aiohttp库代替requests,因为后者是同步的HTTP客户端。
3.1 基础版本
python复制import aiohttp
import asyncio
async def fetch(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
urls = [
'http://example.com',
'http://example.org',
'http://example.net'
]
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
results = await asyncio.gather(*tasks)
for url, content in zip(urls, results):
print(f"{url}: {len(content)} bytes")
asyncio.run(main())
这个版本已经比同步实现快得多,但它有几个可以改进的地方:
- 没有错误处理
- 没有并发控制(可能对服务器造成压力)
- 没有重试机制
3.2 增强版本
让我们添加这些功能:
python复制async def fetch_with_retry(session, url, max_retries=3):
for attempt in range(max_retries):
try:
async with session.get(url, timeout=5) as response:
response.raise_for_status()
return await response.text()
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
if attempt == max_retries - 1:
print(f"Failed to fetch {url}: {str(e)}")
return None
await asyncio.sleep(1 * (attempt + 1)) # 指数退避
async def worker(session, queue, results):
while True:
url = await queue.get()
try:
content = await fetch_with_retry(session, url)
if content is not None:
results.append((url, len(content)))
finally:
queue.task_done()
async def main():
urls = [...] # 100个URL
queue = asyncio.Queue(maxsize=20) # 控制并发度
results = []
async with aiohttp.ClientSession() as session:
workers = [
asyncio.create_task(worker(session, queue, results))
for _ in range(10) # 10个worker
]
for url in urls:
await queue.put(url)
await queue.join()
for worker_task in workers:
worker_task.cancel()
await asyncio.gather(*workers, return_exceptions=True)
print(f"Fetched {len(results)} pages")
asyncio.run(main())
这个版本实现了:
- 带重试的请求逻辑
- 使用队列控制并发度
- 工作线程池模式
- 完善的错误处理
4. 常见陷阱与最佳实践
4.1 阻塞操作会破坏事件循环
asyncio最大的陷阱是在协程中执行阻塞操作。例如:
python复制async def bad_example():
# 这些操作会阻塞事件循环
time.sleep(1) # 同步sleep
requests.get(url) # 同步HTTP请求
open('file').read() # 同步文件IO
正确的做法是使用对应的异步版本:
python复制async def good_example():
await asyncio.sleep(1) # 异步sleep
async with aiohttp.ClientSession() as session: # 异步HTTP
await session.get(url)
async with aiofiles.open('file') as f: # 异步文件IO
await f.read()
4.2 任务取消的正确处理
asyncio的任务取消机制需要特别注意:
python复制async def cancellable_task():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
print("Task was cancelled")
raise # 必须重新抛出异常
return "Done"
async def main():
task = asyncio.create_task(cancellable_task())
await asyncio.sleep(1)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("Main caught cancellation")
asyncio.run(main())
关键点:
- 捕获
CancelledError后必须重新抛出,除非有特殊需求 - 取消后仍然需要
await任务,否则可能产生警告 - 在清理资源时实现取消逻辑
4.3 调试技巧
调试异步代码比同步代码更复杂,以下是一些实用技巧:
-
启用调试模式:
python复制asyncio.run(main(), debug=True) -
查看当前任务:
python复制
current_task = asyncio.current_task() all_tasks = asyncio.all_tasks() -
设置超时:
python复制try: await asyncio.wait_for(task(), timeout=5) except asyncio.TimeoutError: print("Task timed out") -
使用日志:
python复制logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger('asyncio')
4.4 性能优化建议
-
限制并发量:使用信号量或队列控制并发度
python复制sem = asyncio.Semaphore(10) async with sem: await fetch(url) -
复用连接:为HTTP客户端重用Session
python复制async with aiohttp.ClientSession() as session: # 多个请求使用同一个session -
批量操作:使用
gather代替顺序awaitpython复制# 不好 result1 = await task1() result2 = await task2() # 好 result1, result2 = await asyncio.gather(task1(), task2()) -
避免CPU密集型任务:asyncio不适合计算密集型场景,考虑使用
run_in_executorpython复制loop = asyncio.get_event_loop() await loop.run_in_executor(None, cpu_intensive_func)
