Python 协程:从 yield 到 async/await,异步编程的进化之路
写 Python 做网络请求、爬虫、Web 服务的时候,你有没有遇到过这种情况:程序卡在某个地方等响应,CPU 明明闲着,但就是干等着?
协程就是解决这个问题的。它让你在等待 I/O 的时候去做别的事,而不是傻等。
协程到底是什么?跟线程有什么区别?
先回答一个问题:协程比线程轻量多少?
- 一个线程的内存占用大约 8MB,一个协程大约 2KB
- 线程切换涉及内核态,协程切换只在用户态
- 一台服务器能跑几百个线程,但能跑几万个协程
协程是”协作式”的:它主动让出 CPU(遇到 await 的时候),而不是被操作系统强行打断。这意味着同一时刻只有一个协程在运行,不需要考虑锁的问题。
什么时候用协程? I/O 密集型任务——网络请求、文件读写、数据库查询。这些操作大部分时间在等外部响应,协程可以利用这些等待时间做别的事。
什么时候别用? CPU 密集型计算。协程解决不了计算慢的问题,这种场景应该用多进程。
协程的演进:从 yield 到 async/await
Python 的协程经历了几个阶段,了解一下演进脉络,能帮你理解为什么现在是 async/await。
第一阶段:yield(生成器)
Python 2 时代,用生成器来模拟协程。yield 可以暂停函数、返回一个值,下次调用 next() 继续执行。
1 | def generator(): |
这已经具备了”暂停-恢复”的能力,但用来做异步调度还太原始。
第二阶段:yield from(委托生成器,Python 3.3)
让一个生成器可以把执行委托给另一个生成器,解决了嵌套调用的问题。
1 | def sub(): |
第三阶段:@asyncio.coroutine + yield from(Python 3.4)
Python 3.4 引入了 asyncio 库,用装饰器标记协程。
1 |
|
这套写法能用,但不够直观——明明是协程,看起来却像生成器。
第四阶段:async/await(Python 3.5+,现在的标准)
这才是你现在应该用的方式。async def 定义协程,await 暂停等待。
1 | async def modern(): |
一段话总结演进: 从生成器到 yield from 到装饰器到原生语法,async/await 是最终形态——语义清晰、写法直观。写新代码就用这个,前面的了解就行。
async/await 基础用法
定义和运行一个协程:
1 | import asyncio |
关键点:
async def定义协程函数,调用它返回协程对象,不会立即执行await只能在async def里用,作用是”暂停当前协程,等异步操作完成”asyncio.run()是 Python 3.7+ 的入口,创建事件循环并运行
能 await 什么? 协程对象、Task 对象、Future 对象。不能 await 普通函数。
1 | # 错误 |
并发执行多个协程
单个协程其实看不出优势,协程真正的威力在于同时执行多个。
gather:并发执行,一起等结果
1 | async def task(name, delay): |
三个任务总共耗时约 2 秒(最长的那个),而不是 2+1+1.5=4.5 秒。这就是并发的价值。
gather 的结果按传入顺序返回,不是按完成顺序。
create_task:提前创建任务
1 | async def main(): |
create_task 让任务”先跑起来,后面再等结果”,比先收集再 gather 更灵活。
as_completed:谁先完成先处理谁
1 | async def main(): |
适合”谁先回来先处理谁”的场景,比如爬虫爬多个页面,哪个先回来就先用哪个。
超时控制和并发限制
超时:别让一个慢任务拖死全部
1 | async def main(): |
wait_for 会在超时后取消任务,适合对响应时间有要求的场景。
并发限制:别一次性发太多请求
1 | sem = asyncio.Semaphore(10) # 最多10个并发 |
爬虫、API 调用时很有用——你不希望一次性发 1000 个请求把服务器打挂。
实战:异步爬虫
用 aiohttp 替代 requests,用协程并发请求。
1 | import asyncio |
4 个请求串行要等 1+2+1+3=7 秒,协程并发只等最长的 3 秒。
异步库 vs 同步库:
| 场景 | 同步库 | 异步库 |
|---|---|---|
| HTTP 请求 | requests |
aiohttp |
| 文件读写 | open() |
aiofiles |
| 数据库 | psycopg2 |
asyncpg / aiomysql |
用协程的时候,必须用对应的异步库,同步库会阻塞事件循环。
容易踩的坑
坑1:协程里用同步 I/O
1 | # 错误: 阻塞了整个事件循环 |
坑2:忘记 await
1 | async def fetch(): |
坑3:事件循环重复运行
1 | # 错误: 不行 |