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() 这个协程丢进去跑。事件循环就是一个死循环(不退出前一直在转),反复干两件事:

  1. 检查有哪些任务可以继续执行了(I/O 完成、定时器到点)
  2. 按顺序执行这些任务,遇到 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 秒后叫我",协程挂起
  2. 事件循环去跑别的就绪任务
  3. 1 秒到,事件循环把协程放回就绪队列
  4. 协程从 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())

常见坑速查

  1. 忘了 await:fetch_data() 只是创建协程对象,代码根本没跑。诊断:打印出来是 <coroutine object ...> 就是忘了 await。
  2. 在同步函数里调 async 函数:TypeError: 'coroutine' object is not callable 或直接报 "never awaited"。同步函数里用 asyncio.run()。
  3. 事件循环里跑阻塞代码:整个程序全部卡住,其他任务全停。CPU 计算用 to_thread,同步库换异步库。
  4. for 循环挨个 await 而不是 gather:写起来像异步,实际是串行,性能没提升。
  5. asyncio.run() 被多次调用:它只能在一个线程里调用且每次创建新循环。程序入口只调一次,里面用 gather 组织所有任务。
  6. 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 秒

学习路径建议

  1. 先跑熟"同步 vs 异步对比实验",感受耗时的变化
  2. 把常用 API 示例各跑一遍,改改参数看效果
  3. 自己写一个:抓 50 个网页,加限流和超时(对着综合实战改)
  4. 有基础后再看事件循环原理,理解"为什么快"

异步不难,难的是忘掉同步思维——记住你不是在排队,而是在同时等 100 个外卖。

posted @ 2026-08-30 23:54  LemHou  阅读(24)  评论(0)    收藏  举报