完整讲解 · 5 段教学·配 4 道练习题·预计 35 分钟
本页是本章的通读版,可直接读完全部讲解。想动手写代码、跑判分,去 闯关模式。
各位写过爬虫吗?随手一段:
import time
def fake_get(url):
time.sleep(0.01)
return f'GOT {url}'
urls = [f'https://example.com/{i}' for i in range(5)]
results = [fake_get(u) for u in urls]
print(f'拿到 {len(results)} 个')每个请求假装耗时 0.01 秒,5 个串起来就是 0.05 秒。如果是真实网络请求,每个 200 毫秒,100 个串起来就是 20 秒——慢得想骂人。
「那好办,开线程嘛!」有人立刻反应过来,「100 个 URL 就开 100 个线程,一起跑。」
线程当然能解决问题。但开 100 个线程是不是有点奢侈?这 100 个线程 99% 的时间都在干同一件事:等网络包回来。本质上这是个等待问题,不是个计算问题。为了「等」而开 100 个操作系统级的线程,CPU 不开心,内存也不开心。
而且线程一多,「锁、竞态、死锁」这些老朋友就会接连找上门。你只是想抓个网页,怎么忽然要研究操作系统了?
Python 给了我们另一条路:asyncio。它的卖点很直白——
一个线程,同时等一百件事。
各位听到这句话的第一反应大概是:「这不科学吧,单线程怎么同时做一百件事?」
魔法的关键就一句话:
当某个协程在等 IO 的时候,让出 CPU 给其他协程;等回来再继续。
asyncio 没有偷偷开线程,也没有把 CPU 加速。它做的事很朴素——「等」可以重叠。三件事每件等 1 秒,串行要 3 秒;让它们的「等」重叠起来,1 秒就够了。
async def、await、asyncio.run() 这三个最基本的概念asyncio.gather 同时跑多个协程注意:浏览器里的 Python 沙盒不能联网,所以咱们用
asyncio.sleep来模拟「等」的过程。逻辑和真实网络请求是一样的。
普通函数长这样:
def hello():
return '你好'
print(hello()) # 你好加一个 async 关键字,它就成了协程函数(coroutine function):
async def hello():
return '你好'
print(hello())输出大致是这样:
<coroutine object hello at 0x...>
RuntimeWarning: coroutine 'hello' was never awaited
各位看出区别了吗——同样写 print(hello()),普通函数返回的是字符串,协程函数返回的是一个 协程对象,而且还附赠一句警告:「这协程从来没被 await 过」。
async def定义的不是一个「会立刻跑的函数」——它返回一张「待执行的任务单」。
打个比方:
| 函数类型 | f() 的行为 |
|---|---|
| 普通函数 | 立刻派人去执行,立刻拿到结果 |
| 协程函数 | 写一张任务单递给你;什么时候做、谁做,另说 |
这张「任务单」就是协程对象。它必须被 await 或者扔给事件循环,里面的代码才会真正跑起来。
await:让协程跑起来那怎么让任务单真的执行?用 await:
import asyncio
async def hello():
return '你好'
async def main():
result = await hello() # ← 这里让 hello 真正跑
print(result)
asyncio.run(main())输出:
你好
await hello() 的语义是「请帮我把这张任务单完成,然后把结果给我」。
但**await 只能写在 async def 函数体内部**。在普通函数里写 await,Python 会报 SyntaxError: 'await' outside async function。
asyncio.run():进入异步世界的大门「那我想在普通脚本里调一个 async def 怎么办?」答案就是 asyncio.run():
import asyncio
async def main():
print('我在异步世界里')
print('我还在异步世界里')
asyncio.run(main())输出:
我在异步世界里
我还在异步世界里
asyncio.run() 是同步代码和异步代码之间的「门」:
记住一条:整个程序里通常只调一次 asyncio.run()——它就是从同步世界踏进异步世界的入口。
async def + await + asyncio.run到这里,最基础的三件套你已经齐了:
| 工具 | 作用 |
|---|---|
async def f() | 定义协程函数(写出一张任务单的能力) |
await x | 等一张任务单跑完,拿结果 |
asyncio.run(c) | 从同步世界进入异步世界,跑一个协程 |
下一题就用这三个东西写一个最小的 hello world。
asyncio.sleep:模拟「等」真实场景里,协程主要在等什么?答案是 IO——等网络包、等磁盘、等数据库。
浏览器里咱们没法发真请求,但可以用 asyncio.sleep 模拟「等」的过程,行为完全等价:
import asyncio
async def task():
print('开始')
await asyncio.sleep(0.01) # 模拟 IO 等待
print('结束')
asyncio.run(task())输出:
开始
结束
注意中间那行——await asyncio.sleep(0.01)。这就是协程的「等待点」:执行到这一句时,协程会告诉事件循环「我要等 0.01 秒,这段时间你随便调度别人」,然后让出 CPU。
只要把 await 一个接一个写下来,多个协程就是串行执行的——前一个跑完,后一个才开始:
import asyncio
async def step(name):
print(f'{name} 开始')
await asyncio.sleep(0)
print(f'{name} 结束')
async def main():
await step('A')
await step('B')
asyncio.run(main())输出:
A 开始
A 结束
B 开始
B 结束
await step('A') 的意思是「等 A 跑完,再继续」。所以 B 一定排在 A 后面。
划重点:
async/await不会自动并发。一个await接一个await,本质上是按顺序跑的。
异步编程最经典的陷阱是调了协程函数但没 await:
import asyncio
async def task():
print('我跑了吗?')
async def main():
task() # ← 漏了 await!
print('main 跑完了')
asyncio.run(main())输出:
main 跑完了
'我跑了吗?' 根本没打印——task() 只是创建了一个协程对象就被丢掉了,里面的代码一行都没跑。同步世界里写函数名加括号就等于执行;异步世界里,没 await 的协程等于没写。
正确写法当然是 await task()。
time.sleep 不能用最后一条铁律。在 async 函数里,绝对不要用 time.sleep:
import time
async def task():
time.sleep(0.5) # 这里把整个事件循环卡死了time.sleep 是同步阻塞的——调用时不会让出 CPU。整个事件循环会被卡住,所有协程一起停。
| 同步(阻塞,禁用) | 异步(替代品) |
|---|---|
time.sleep(s) | await asyncio.sleep(s) |
requests.get(...) | await httpx.AsyncClient().get(...) |
open(f).read() | aiofiles 库 / 线程池 |
记一条原则:在 async 函数里,凡是会阻塞的同步调用都要换成异步版本。
asyncio.gather:一次等多个上一节我们看到 await 串行写下来是排队执行的——和同步代码没区别,并不会更快。
要真正享受到「同时等多件事」的并发效果,得明确告诉事件循环「这几件事可以一起开始」。asyncio.gather 就是干这个的。
await asyncio.gather(coro1, coro2, ...)import asyncio
async def task(name):
await asyncio.sleep(0.01)
return f'{name} 完成'
async def main():
r1, r2, r3 = await asyncio.gather(
task('A'),
task('B'),
task('C'),
)
print(r1)
print(r2)
print(r3)
asyncio.run(main())输出:
A 完成
B 完成
C 完成
asyncio.gather 接收任意多个协程,把它们一起扔给事件循环跑,然后按你传进去的顺序把结果打包返回。
task('X') 几乎同时开始await asyncio.sleep(0.01) 时让出 CPUgather 把结果按顺序收回来如果是真实网络请求,三个请求 200 毫秒——串行 600 毫秒,gather 大约 200 毫秒。
| 要点 | 说明 |
|---|---|
| 结果顺序 | 和传入顺序一致,不是「谁先跑完谁先返回」 |
| 任意一个抛异常 | 默认情况下整个 gather 都会抛 |
| 同一个协程对象不能被多次 await | 想跑同一函数多次就写多个调用 [task() for _ in range(N)] |
对照看一下,「等」的方式不一样,效率天差地别:
# 串行:1 个 sleep 等完再开下一个
async def serial():
await task('A')
await task('B')
await task('C')
# 并发:3 个 sleep 一起等
async def concurrent():
await asyncio.gather(task('A'), task('B'), task('C'))gather 是 asyncio 里出现频率最高的函数之一。看到「同时跑多个」的需求,第一反应就该是它。
Python 3.11+ 还有个升级版
asyncio.TaskGroup——更安全、出错时会自动取消其他任务。日常入门用gather就够。
讲了这么多,最后回到一个本质问题:什么场景适合 async?
asyncio 的强项是 IO bound——程序大部分时间在「等」:
这种场景下「等待」是可以重叠的,async 能让一个线程同时等无数件事——立竿见影。
asyncio 的弱项是 CPU bound——程序大部分时间在「算」:
这种场景下没有「等」可以利用,CPU 一直在干活,async 帮不上忙。一个线程也只能用一个核——剩下的核全在睡大觉。
| 场景 | 用什么 |
|---|---|
| 抓 100 个 URL | asyncio + httpx |
| 同时读写一堆文件 | asyncio + aiofiles |
| 高并发 Web 服务(一台机器扛几千连接) | asyncio + FastAPI / aiohttp |
| 视频转码、大矩阵计算 | multiprocessing 或 C 扩展 |
| 海量数据本地排序 | 多进程 + 分块 |
| 大部分 IO 夹一点点 CPU | async 主框架 + asyncio.to_thread |
各位可以这么记:
async 是用来「等」的,不是用来「算」的。
回顾一下你现在掌握的工具:
| 工具 | 用途 |
|---|---|
async def f() | 定义协程函数 |
await x | 等一个协程的结果 |
asyncio.run(c) | 同步代码进入异步世界的入口 |
await asyncio.sleep(s) | 异步等待 s 秒(替代 time.sleep) |
asyncio.gather(...) | 并发跑多个协程,按顺序收结果 |
asyncio 的世界还有很多东西,本课不展开,列个清单方便你后面查阅:
asyncio.create_task(coro):把协程「派出去」在后台跑,可以单独 cancelasyncio.wait_for(coro, timeout) / asyncio.timeout(...):超时控制asyncio.TaskGroup(3.11+):gather 的安全升级版async with / async for:异步上下文管理器、异步迭代器asyncio.Semaphore:限制并发数(避免把对方服务器打挂)asyncio.to_thread(fn):把同步阻塞调用扔到线程池里asyncio 的语法看起来花哨,本质上只是给「等待」加了一层调度。各位写代码时多想一句:「这一步是不是在等?」如果是,套个 await 试试,可能就有惊喜。
读完了?动手练一遍才算真会。
去闯关模式练习 →