python异步教程
python异步asyncio教程
异步是 Python 处理 I/O 密集型高并发 任务的标准方案。读完这篇教程,你会理解:异步解决什么问题、怎么写、和线程/进程有什么区别、底层原理是什么,以及工程上怎么用才不会踩坑。所有代码都可以直接复制运行(Python 3.9+,部分示例标注了 3.11+ 专属写法)。
解决什么问题
同步代码的痛点:CPU 在等待时闲着
先看同步代码是怎么跑 I/O 的:
import time
def fetch_data():
time.sleep(2) # 模拟网络请求,等 2 秒
return "数据"
start = time.perf_counter()
data = fetch_data() # 这两秒里 CPU 什么都干不了,干等
print(f"拿到: {data}, 耗时: {time.perf_counter() - start:.1f}s")
time.sleep(2) 期间线程被阻塞,CPU 空转等待。真实场景里,网络请求、文件读写、数据库查询都是这种"慢 I/O":代码在等外部设备,CPU 却帮不上忙只能干等。
一个例子:串行发 5 次请求
假设要抓 5 个网页,每个 1 秒:
import time
def fetch(url):
time.sleep(1) # 模拟请求耗时 1 秒
return f"{url} 的数据"
start = time.perf_counter()
urls = [f"https://site.com/page{i}" for i in range(5)]
for url in urls:
print(fetch(url))
print(f"总耗时: {time.perf_counter() - start:.1f}s") # 约 5 秒
5 个请求串行执行,总耗时 5 秒。但这 5 个请求彼此没有依赖,完全可以同时发出——这就是异步要解决的问题。
异步的思路:等待时不闲着
异步的核心思想一句话:遇到 I/O 就挂起当前任务,让出 CPU 去执行别的任务;等 I/O 完成的通知来了,再回来接着跑。
生活类比:你去餐厅吃饭。
- 同步:点完菜干坐到上菜,再点下一道,再干坐……一个人占着桌子半小时
- 异步:点完菜先干别的(回邮件、写代码),菜好了服务员叫你,再回来吃
同一个人(单线程),却能同时"服务"多个任务。
异步适合 / 不适合什么
| 任务类型 | 特征 | 适合异步吗 |
|---|---|---|
| 网络请求、数据库查询、文件读写 | I/O 密集,大量时间在等待 | ✅ 非常适合 |
| 大量并发连接(爬虫、API 网关、Web 服务) | 连接数多,单请求耗时长 | ✅ 非常适合 |
| 循环计算、图像处理、加密解密 | CPU 密集,一直占着 CPU | ❌ 不适合,用多进程 |
一句话:等资源的时间多 → 用异步;算东西的时间多 → 用多进程。
怎么使用
最小入门:async def / await / asyncio.run
异步代码最小的完整骨架:
import asyncio
async def hello():
print("开始执行")
await asyncio.sleep(1) # 模拟 I/O 等待
print("执行完毕")
asyncio.run(hello())
三个新东西:
async def:定义一个协程函数,它返回一个协程对象await:遇到它就把当前任务挂起,等它后面的操作完成后继续asyncio.run():创建事件循环,运行协程,跑完自动关闭
关键认知:调用协程函数 ≠ 执行
这是新手最常见的坑:
import asyncio
async def say_hi():
print("你好")
return 42
coro = say_hi() # 只是创建了一个协程对象,什么都没执行!
print(coro) # <coroutine object say_hi at 0x...>
# say_hi() 不会打印 "你好"
协程对象必须被 await,或者交给事件循环(asyncio.run / create_task / gather),里面的代码才会执行。
同步 vs 异步对比实验
同样的 5 个 1 秒任务,同步和异步的耗时对比很直观:
# —— 同步版本:5 秒 ——
import time
def sync_version():
for i in range(5):
time.sleep(1)
print(f"同步任务{i}完成")
start = time.perf_counter()
sync_version()
print(f"同步总耗时: {time.perf_counter() - start:.1f}s") # 约 5 秒
# —— 异步版本:1 秒 ——
import asyncio
import time
async def task(i):
await asyncio.sleep(1)
print(f"异步任务{i}完成")
async def async_version():
# create_task: 把一个协程丢进事件循环,立即开始跑,不等它
tasks = [asyncio.create_task(task(i)) for i in range(5)]
for t in tasks:
await t # 等每个任务跑完
start = time.perf_counter()
asyncio.run(async_version())
print(f"异步总耗时: {time.perf_counter() - start:.1f}s") # 约 1 秒
5 个任务同时开始,谁先睡完谁先打印,总耗时约等于最慢的那一个(1 秒)。
并发三件套:create_task / gather / TaskGroup
1. create_task —— 单个任务跑起来
import asyncio
async def work(name):
await asyncio.sleep(1)
print(f"{name} 完成")
return name
async def main():
task = asyncio.create_task(work("任务A")) # 立即开始执行
await task # 等它完成
print("返回值:", task.result())
asyncio.run(main())
2. gather —— 一批任务一起跑,等全部完成
import asyncio
async def work(i):
await asyncio.sleep(1)
return i * 10
async def main():
results = await asyncio.gather(
work(1), work(2), work(3)
)
print(results) # [10, 20, 30],顺序和传入顺序一致
asyncio.run(main())
gather 开箱即用、代码最短,是最常用的写法。
3. TaskGroup —— 结构化并发(Python 3.11+)
TaskGroup 更"安全":组里任何一个任务抛异常,整个组立刻停止并等所有任务结束,异常自动传播,不会出现"某个任务失败了你却不知道"的情况。
import asyncio
async def work(name):
await asyncio.sleep(1)
if name == "坏任务":
raise ValueError("我出错了")
return name
async def main():
try:
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(work("好任务"))
t2 = tg.create_task(work("坏任务"))
except* ValueError as e:
print("捕获到组内异常:", e.exceptions)
# 离开 with 块时,组内所有任务都确定跑完了
asyncio.run(main())
3.11 以下用
asyncio.gather(),注意它默认静默吞掉异常,需要加上return_exceptions=True(见下)或自己遍历结果检查。
常用 API 逐个过一遍
asyncio.sleep —— 异步版的 time.sleep
import asyncio
async def main():
print("开始")
await asyncio.sleep(1) # 不阻塞事件循环,其他任务照常跑
print("结束")
asyncio.run(main())
记住:async 函数里
time.sleep()是禁止的(会卡死整个事件循环),一律用await asyncio.sleep()。
asyncio.wait_for —— 超时控制
防止某个任务永远挂起:
import asyncio
async def slow():
await asyncio.sleep(10)
async def main():
try:
await asyncio.wait_for(slow(), timeout=2)
except asyncio.TimeoutError:
print("超时了,任务被取消")
asyncio.run(main()) # 2 秒后打印 "超时了"
asyncio.timeout —— 更优雅的超时写法(3.11+)
import asyncio
async def slow():
await asyncio.sleep(10)
async def main():
try:
async with asyncio.timeout(2):
await slow()
except TimeoutError:
print("超时了")
asyncio.run(main())
asyncio.Semaphore —— 限流,控制并发数
一口气发 1000 个请求会把对方打爆,限流到最多同时 3 个:
import asyncio
sem = asyncio.Semaphore(3) # 最多同时 3 个任务进入
async def work(i):
async with sem: # 没名额就排队等
await asyncio.sleep(1)
print(f"任务{i}完成")
async def main():
await asyncio.gather(*(work(i) for i in range(10)))
asyncio.run(main()) # 10 个任务分 4 批跑完(3+3+3+1)
asyncio.Event —— 事件通知,多个任务等一个信号
import asyncio
async def worker(event, name):
print(f"{name} 等待启动信号...")
await event.wait() # 挂起,等 event.set()
print(f"{name} 收到信号,开始干活")
async def main():
event = asyncio.Event()
tasks = [asyncio.create_task(worker(event, f"worker{i}")) for i in range(3)]
await asyncio.sleep(0.5) # 让 worker 先进入等待
print("发信号!")
event.set() # 所有等待者同时被唤醒
await asyncio.gather(*tasks)
asyncio.run(main())
asyncio.Lock —— 保护共享资源
多个协程同时改一个变量会互相覆盖(类似多线程的竞态),需要加锁:
import asyncio
counter = 0
lock = asyncio.Lock()
async def add():
global counter
async with lock: # 同一时刻只有一个协程能进来
temp = counter
await asyncio.sleep(0.1) # 模拟耗时操作,中途切走也不怕
counter = temp + 1
async def main():
await asyncio.gather(*(add() for _ in range(10)))
print(f"counter = {counter}")
asyncio.run(main()) # 有锁: 10;把 lock 删掉重跑,经常不是 10
加锁会牺牲并发度,能不加锁就别加锁。协程之间传递数据优先用 Queue(下面),而不是共享变量。
asyncio.Queue —— 生产者 / 消费者模式
任务之间传数据的标准姿势:
import asyncio
async def producer(q):
for i in range(5):
await q.put(i)
print(f"生产了 {i}")
await asyncio.sleep(0.1)
await q.put(None) # 结束信号
async def consumer(q):
while True:
item = await q.get()
if item is None:
break
await asyncio.sleep(0.2) # 模拟处理耗时
print(f"消费了 {item}")
async def main():
q = asyncio.Queue()
await asyncio.gather(producer(q), consumer(q))
asyncio.run(main())
生产者和消费者速度不同也能配合:队列满了 put 会等待,空了 get 会等待,天然解耦。
真实场景:并发抓取网页(需要 httpx)
pip install httpx
import asyncio
import time
import httpx
URLS = [f"https://www.baidu.com/s?wd=python{i}" for i in range(20)]
async def fetch(client, url):
resp = await client.get(url, timeout=10)
return url, resp.status_code
async def main():
async with httpx.AsyncClient() as client:
results = await asyncio.gather(
*(fetch(client, u) for u in URLS),
return_exceptions=True, # 单个请求失败不连累其他
)
for r in results:
if isinstance(r, Exception):
print("失败:", r)
else:
print(r)
start = time.perf_counter()
asyncio.run(main())
print(f"总耗时: {time.perf_counter() - start:.2f}s")
20 个请求并发发出,总耗时约等于最慢的那一个,而不是 20 倍。
真实场景:只用标准库也能并发请求
不想装第三方库?用 asyncio.open_connection 手写一个极简 HTTP 请求:
import asyncio
async def fetch(host, path="/"):
reader, writer = await asyncio.open_connection(host, 80)
writer.write(
f"GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n".encode()
)
await writer.drain()
chunks = []
while True:
chunk = await reader.read(4096)
if not chunk:
break
chunks.append(chunk)
writer.close()
await writer.wait_closed()
return b"".join(chunks)
async def main():
# 3 个请求并发,总耗时 ≈ 单个请求耗时
results = await asyncio.gather(
fetch("www.baidu.com"),
fetch("www.baidu.com"),
fetch("www.baidu.com"),
)
print("总字节数:", [len(r) for r in results])
asyncio.run(main())
实战中没必要手写协议,用 httpx / aiohttp 即可;这里展示的是"异步 + 网络 I/O"的标准库最小闭包,帮你理解底层套路。
与多进程、多线程的区别
三兄弟先各自讲清
多线程(threading)
多个线程由操作系统调度,能真正同时执行同一进程内的代码吗?不能——因为有 GIL。GIL(全局解释器锁)保证同一时刻只有一个线程在解释器里跑 Python 字节码。所以 Python 多线程对 CPU 密集任务没有加速。但 I/O 时线程会释放 GIL,所以多线程对 I/O 密集任务依然有效。它的代价是:线程切换由系统调度,开销大;共享数据要加锁,容易写出 bug。
import threading
def work(name):
print(f"线程 {name} 干活")
threads = [threading.Thread(target=work, args=(i,)) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
多进程(multiprocessing)
每个进程有独立的解释器和内存,绕开 GIL,真正利用多核 CPU 并行计算。代价:进程间不能直接共享变量,要通信(Queue / Pipe / 共享内存),启动开销大。
from multiprocessing import Pool
def calc(n):
return sum(i * i for i in range(n))
if __name__ == "__main__": # Windows 必须加这层保护(否则子进程会递归启动)
with Pool(4) as pool: # 4 个进程并行
print(pool.map(calc, [10**6] * 4))
协程(asyncio)
单线程、单进程内,由事件循环在用户态调度(切换不经过操作系统,开销极小,微秒级)。没有锁竞争、没有 GIL 问题,能把成千上万个 I/O 任务摞在一起跑。代价:永远不能并行——同一时刻只有一个任务在真正执行,只是等待 I/O 时不浪费。
对比表格
| 维度 | 多线程 | 多进程 | 异步(协程) |
|---|---|---|---|
| 调度者 | 操作系统 | 操作系统 | 事件循环(用户态) |
| 切换开销 | 大(微秒~毫秒级) | 更大(进程切换) | 极小 |
| 能否并行利用多核 | ❌ 受 GIL 限制 | ✅ 能 | ❌ 单线程内串行 |
| 数据共享 | 共享内存,要加锁 | 需 IPC 通信 | 少量状态共享,靠 Queue 传消息 |
| 适合任务 | 少量 I/O 密集 | CPU 密集 | 大量 I/O 密集、高并发连接 |
| 代码复杂度 | 中(锁难写对) | 中(进程管理) | 中(全函数变 async) |
怎么选
- CPU 密集(大计算、加密、图像处理)→ 多进程,让每个核都干活
- I/O 密集 + 高并发(爬虫、网关、API 服务、消息消费)→ 异步,单机扛上万连接
- I/O 密集 + 量小(几个后台任务)→ 多线程就够,省事
- 混合场景 → 异步框架里用
asyncio.to_thread把 CPU 密集的活甩给线程池(见最佳实践)
一句话记忆:算得多用进程,等得多用异步,少量后台任务用线程。
基本原理是什么
事件循环:异步的心脏
asyncio.run(main()) 做的事情:创建一个事件循环,把 main() 这个协程丢进去跑。事件循环就是一个死循环(不退出前一直在转),反复干两件事:
- 检查有哪些任务可以继续执行了(I/O 完成、定时器到点)
- 按顺序执行这些任务,遇到
await再次挂起,接着转圈
# 教学用的极简事件循环(真实实现复杂得多)
def event_loop(tasks):
ready = list(tasks) # 待执行的任务队列
while ready:
task = ready.pop(0)
try:
task.send(None) # 让任务继续跑(这是协程的魔法)
except StopIteration:
continue # 任务跑完了,移除
else:
ready.append(task) # 又挂起了,排到队尾,下轮再跑
真实的事件循环用 select/epoll 等内核机制来判断"哪个 socket 有数据了",而不是无脑轮询,性能完全不同,但"循环 + 就绪队列 + 挂起恢复"的模型是一样的。
协程:可以暂停和恢复的函数
async def 定义的函数就是协程。它比普通函数多了一个能力:执行到 await 时可以暂停,把控制权交还给事件循环;之后可以从暂停的地方继续。普通函数一旦 return 就结束了,协程可以"暂停-恢复"很多次。
可以理解为:协程是一个自带状态的可挂起函数,Python 在底层把它编译成一个状态机。
Task:协程的包装
create_task() 把一个协程包成 Task 对象丢进事件循环。Task 有三个状态:pending(等待执行)、done(完成)、cancelled(被取消)。await task 就是等它从 pending 变成 done。TaskGroup 就是一批 Task 的管理容器。
await 背后发生了什么
await 一句话版:等到这个操作真正需要的数据(I/O 结果),期间先挂起让位。
await asyncio.sleep(1) 的执行过程:
- 告诉事件循环"1 秒后叫我",协程挂起
- 事件循环去跑别的就绪任务
- 1 秒到,事件循环把协程放回就绪队列
- 协程从
await处继续往下执行
网络请求也一样,只是"1 秒后"换成了"socket 可读时"。
非阻塞 I/O 与 select/epoll
异步能高效的根本:底层的 socket 都是非阻塞的。非阻塞 I/O 发出请求后立即返回(不空等),由内核在数据到达时通知应用程序。一次 select/epoll 调用可以同时监控成千上万个 socket,哪个就绪了返回哪个。单线程、O(1) 复杂度,这就是异步能扛十万并发连接的原因。
工程最佳实践
禁忌清单(先背下来)
| 禁忌 | 正确做法 |
|---|---|
async 函数里用 time.sleep() |
await asyncio.sleep() |
async 函数里用 requests.get()(阻塞) |
httpx.AsyncClient / aiohttp |
| async 函数里做 CPU 密集计算 | await asyncio.to_thread(...) 甩给线程池 |
| 同步代码里直接调用 async 函数 | asyncio.run() 包一层 |
for i in range(10): await task(i) |
await asyncio.gather(*(task(i) for i in range(10))) |
最后一条值得多说:写 async for 逐个 await 是串行的——等于同步代码,完全没用到异步。要并发一定要 gather / create_task / TaskGroup。
CPU 密集任务别堵事件循环
事件循环是单线程的,一个协程里做 10 秒的纯计算,所有其他任务全部卡死 10 秒。用 asyncio.to_thread 把它丢到线程池:
import asyncio
def cpu_heavy(n):
total = 0
for i in range(n):
total += i * i
return total
async def main():
start = asyncio.get_running_loop().time()
result = await asyncio.to_thread(cpu_heavy, 5_000_000) # 不阻塞事件循环
print("结果:", result, "耗时: %.2fs" % (asyncio.get_running_loop().time() - start))
asyncio.run(main())
用对异步生态库
| 场景 | 异步库 |
|---|---|
| HTTP | httpx、aiohttp |
| 数据库 | asyncpg(PostgreSQL)、aiomysql、aiosqlite |
| Redis | redis.asyncio |
| Web 框架 | FastAPI、aiohttp、Sanic |
原则:用了 asyncio 就换全套异步库,混用阻塞库会把事件循环卡死,异步白搭。
结构化并发优先 TaskGroup(3.11+)
手写 create_task + 逐个 await 收集结果,异常容易漏。TaskGroup 保证:组内任一任务异常 → 取消组内所有任务 → 等所有任务结束 → 抛给外面。绝不会有"任务挂在后台没人管"。老版本用 asyncio.gather(..., return_exceptions=True) 兜底。
永远设超时
真实网络没有"一定会回来"。所有可能挂起的 I/O 都套上超时:
import asyncio
async def fetch_with_timeout(client, url):
try:
async with asyncio.timeout(10): # 3.11+;老版本用 wait_for
return await client.get(url)
except TimeoutError:
print(f"{url} 超时")
return None
控制并发数量
无脑 gather 1000 个请求 = 把目标服务和自己的文件描述符打爆。统一用 Semaphore 限流:
import asyncio
sem = asyncio.Semaphore(10) # 最多 10 个并发
async def limited(client, url):
async with sem:
return await client.get(url, timeout=10)
异常处理与取消
- 批量任务:
gather(return_exceptions=True)逐个检查结果,谁失败都不连累别人 - 单个长任务:
task.cancel()主动取消,协程里捕获asyncio.CancelledError做清理(关连接、释放资源),然后必须raise继续传播:
import asyncio
async def worker():
try:
while True:
await asyncio.sleep(1) # 模拟长任务
except asyncio.CancelledError:
print("被取消,清理资源...")
raise # 必须重新抛出,取消才生效
async def main():
task = asyncio.create_task(worker())
await asyncio.sleep(2.5)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("任务已确认取消")
asyncio.run(main())
常见坑速查
- 忘了 await:
fetch_data()只是创建协程对象,代码根本没跑。诊断:打印出来是<coroutine object ...>就是忘了 await。 - 在同步函数里调 async 函数:
TypeError: 'coroutine' object is not callable或直接报 "never awaited"。同步函数里用asyncio.run()。 - 事件循环里跑阻塞代码:整个程序全部卡住,其他任务全停。CPU 计算用
to_thread,同步库换异步库。 - for 循环挨个 await 而不是 gather:写起来像异步,实际是串行,性能没提升。
asyncio.run()被多次调用:它只能在一个线程里调用且每次创建新循环。程序入口只调一次,里面用 gather 组织所有任务。- try/except 捕不到组内异常:TaskGroup 用的是
except*(异常组),普通except抓不到。
综合实战:完整爬虫骨架
把上面的最佳实践全部串起来——并发 + 限流 + 超时 + 异常兜底 + 统计耗时:
pip install httpx
import asyncio
import time
import httpx
URLS = [f"https://www.baidu.com/s?wd=page{i}" for i in range(100)]
CONCURRENCY = 10 # 最多同时 10 个请求
TIMEOUT = 10 # 单个请求超时 10 秒
sem = asyncio.Semaphore(CONCURRENCY)
async def fetch(client, url):
async with sem:
async with asyncio.timeout(TIMEOUT):
resp = await client.get(url)
return url, resp.status_code
async def main():
async with httpx.AsyncClient() as client:
results = await asyncio.gather(
*(fetch(client, u) for u in URLS),
return_exceptions=True, # 任何失败都不连累别人
)
ok = 0
for r in results:
if isinstance(r, Exception):
print("失败:", r)
else:
ok += 1
print(f"成功 {ok}/{len(URLS)}")
start = time.perf_counter()
asyncio.run(main())
print(f"总耗时: {time.perf_counter() - start:.2f}s") # 100 个请求 ≈ 10 秒出头,而不是 100 秒
学习路径建议
- 先跑熟"同步 vs 异步对比实验",感受耗时的变化
- 把常用 API 示例各跑一遍,改改参数看效果
- 自己写一个:抓 50 个网页,加限流和超时(对着综合实战改)
- 有基础后再看事件循环原理,理解"为什么快"
异步不难,难的是忘掉同步思维——记住你不是在排队,而是在同时等 100 个外卖。

浙公网安备 33010602011771号