20 - 协程与异步编程

20 - 协程与异步编程

这章讲 Python 的异步编程。说实话,对入门来说偏深了,但协程在现代 Python 生态里越来越重要(FastAPI、httpx、aiohttp 都用),至少得知道怎么回事。


为什么需要异步

假设你要请求 10 个网页。同步方式:

python 复制代码
import requests

urls = [f"https://example.com/page/{i}" for i in range(10)]

# 同步:一个接一个请求
for url in urls:
    response = requests.get(url)  # 每次都要等网络响应
    print(response.status_code)

每个请求要等 0.5 秒的话,10 个就要 5 秒。但实际上 CPU 在等网络的时候是空闲的------它在干等。

异步方式可以在等待网络响应的时候去做别的事

python 复制代码
import asyncio
import httpx  # 异步 HTTP 客户端(uv add httpx)

async def fetch(url):
    async with httpx.AsyncClient() as client:
        response = await client.get(url)
        return response.status_code

async def main():
    urls = [f"https://example.com/page/{i}" for i in range(10)]
    tasks = [fetch(url) for url in urls]
    results = await asyncio.gather(*tasks)
    print(results)

asyncio.run(main())

10 个请求同时发出,总耗时约 0.5 秒(取决于最慢的那个)。快了 10 倍。


并发 vs 并行

这两个概念很多人搞混,先搞清楚:

  • 并发(Concurrency):多个任务交替执行。一个 CPU 就够了,任务之间轮流来。
  • 并行(Parallelism):多个任务同时执行。需要多个 CPU 核心。

Python 的 asyncio 做的是并发,不是并行。它在一个线程里让多个任务交替运行,利用"等待 I/O"的空隙去做别的事。

打个比方:并发是一个厨师同时做几道菜(这道菜炖着的时候去切那道菜的菜),并行是好几个厨师各做一道菜。


协程 vs 线程 vs 进程

进程 线程 协程
内存 各自独立 共享内存 共享内存
切换开销 大(系统级) 极小(用户级)
并发数量 几十 几百 几万甚至几十万
GIL 影响 受限 不受限(但只能在一个核上跑)
适用场景 CPU 密集 I/O 密集(传统方式) I/O 密集(现代方式)

协程的优势:轻量。一个线程里可以跑几万个协程,内存和切换开销都极小。

线程的切换由操作系统决定(你不知道什么时候切),协程的切换由你的代码控制(在 await 的地方切)。后者叫"协作式多任务",前者叫"抢占式多任务"。


事件循环(Event Loop)

协程的执行靠事件循环驱动。你可以把它理解成一个调度员:

复制代码
事件循环:
  - A 协程在等网络响应?好,先挂起 A
  - 让 B 协程跑一会
  - B 也在等数据库?挂起 B
  - A 的网络响应回来了?恢复 A
  - ...

所有协程都在同一个线程 里跑,靠事件循环来切换。这就是为什么 await 那么重要------它是告诉事件循环"我现在可以歇一歇,你去忙别的"的信号。


协程基础

python 复制代码
import asyncio

# 用 async def 定义协程函数
async def say_hello():
    print("Hello")
    await asyncio.sleep(1)  # 模拟 I/O 操作
    print("World")

# 调用协程函数不会执行它,而是返回一个协程对象
coro = say_hello()
print(coro)  # <coroutine object say_hello at 0x...>

# 必须用事件循环来驱动它
asyncio.run(coro)

关键概念 :调用 async def 定义的函数不会立即执行,它返回一个协程对象。你需要 await 它或者交给事件循环运行。

这个跟生成器有点像------调用 def gen(): yield 1 也不会立即执行,返回的是生成器对象。不是巧合,后面会讲它们的关系。


await 到底干了什么

await 做两件事:

  1. 等待后面的异步操作完成,拿到结果
  2. 交出控制权给事件循环,让它去跑别的协程
python 复制代码
async def fetch_data(url):
    print(f"开始请求 {url}")
    response = await httpx.AsyncClient().get(url)  # 等网络响应,同时别人可以跑
    print(f"收到响应 {url}")
    return response.status_code

如果 await 后面不是异步操作(比如普通的 time.sleep()),那事件循环就被阻塞了,所有协程都得等。这是新手最常犯的错:

python 复制代码
import time

async def bad_example():
    time.sleep(5)  # 错!阻塞了整个事件循环
    # 应该用 await asyncio.sleep(5)

原则:异步函数里不要调用阻塞的同步函数。 如果你不确定一个函数是不是阻塞的,看它有没有 async 版本。


并发执行多个协程

方式一:gather(最常用)

python 复制代码
import asyncio

async def task(name, delay):
    print(f"{name} 开始")
    await asyncio.sleep(delay)
    print(f"{name} 完成")
    return f"{name} 的结果"

async def main():
    results = await asyncio.gather(
        task("A", 2),
        task("B", 1),
        task("C", 3),
    )
    print(results)  # ['A 的结果', 'B 的结果', 'C 的结果']
    # 总耗时约 3 秒(取最长的),而不是 2+1+3=6 秒

asyncio.run(main())

方式二:create_task(更灵活)

python 复制代码
async def main():
    t1 = asyncio.create_task(task("A", 2))
    t2 = asyncio.create_task(task("B", 1))

    # 中间可以做别的事
    print("做点别的")

    # 等结果
    r1 = await t1
    r2 = await t2
    print(r1, r2)

create_task 立即把协程调度到事件循环,不用等 await 才开始。

方式三:as_completed(谁先完成谁先来)

python 复制代码
async def main():
    tasks = [task("A", 2), task("B", 1), task("C", 3)]
    for coro in asyncio.as_completed(tasks):
        result = await coro
        print(f"完成了一个:{result}")
    # 输出顺序:B → A → C(按完成时间)

适合"完成一个处理一个"的场景,不用等所有任务都结束。


超时和取消

python 复制代码
async def main():
    # 超时控制
    try:
        result = await asyncio.wait_for(task("A", 10), timeout=3)
    except asyncio.TimeoutError:
        print("超时了!")

    # 取消任务
    t = asyncio.create_task(task("B", 10))
    await asyncio.sleep(1)
    t.cancel()
    try:
        await t
    except asyncio.CancelledError:
        print("任务被取消了")

超时和取消在实际项目中很重要------网络请求不能无限等,用户关闭页面后后台任务应该取消。


异步上下文管理器和异步迭代器

async with

跟普通的 with 一样,但进入和退出可以是异步操作:

python 复制代码
# 异步打开文件(uv add aiofiles)
import aiofiles

async def read_file_async(path):
    async with aiofiles.open(path, "r") as f:
        content = await f.read()
    return content

# 异步 HTTP 客户端
async def fetch(url):
    async with httpx.AsyncClient() as client:
        response = await client.get(url)
    return response

async for

异步版的 for 循环,每次迭代可以是异步的:

python 复制代码
async def stream_lines(path):
    async with aiofiles.open(path, "r") as f:
        async for line in f:
            yield line.strip()

async def main():
    async for line in stream_lines("big_file.txt"):
        print(line)

async forasync with 只能在 async def 函数里使用。


异步队列:生产者-消费者模式

这是异步编程里最经典的模式:

python 复制代码
import asyncio

async def producer(queue: asyncio.Queue, name: str):
    """生产者:往队列里放数据"""
    for i in range(5):
        item = f"{name}-{i}"
        await queue.put(item)
        print(f"[生产者 {name}] 放入:{item}")
        await asyncio.sleep(0.5)

async def consumer(queue: asyncio.Queue, name: str):
    """消费者:从队列里取数据"""
    while True:
        item = await queue.get()
        print(f"[消费者 {name}] 取出:{item}")
        await asyncio.sleep(1)  # 模拟处理时间
        queue.task_done()

async def main():
    queue = asyncio.Queue(maxsize=10)

    # 2 个生产者 + 3 个消费者
    producers = [
        asyncio.create_task(producer(queue, "P1")),
        asyncio.create_task(producer(queue, "P2")),
    ]
    consumers = [
        asyncio.create_task(consumer(queue, f"C{i}"))
        for i in range(3)
    ]

    await asyncio.gather(*producers)  # 等生产者完成
    await queue.join()                 # 等队列清空

    for c in consumers:
        c.cancel()  # 取消消费者

asyncio.run(main())

生产者产出数据,消费者处理数据,队列做缓冲。生产快消费慢的时候队列堆起来,生产慢消费快的时候消费者等着。很灵活。


异步中的异常处理

python 复制代码
async def risky_task():
    await asyncio.sleep(1)
    raise ValueError("出错了")

async def main():
    # gather 里某个任务出错,默认会直接抛异常
    try:
        await asyncio.gather(
            task("A", 1),
            risky_task(),
            task("C", 1),
        )
    except ValueError as e:
        print(f"捕获到异常:{e}")

    # 用 return_exceptions=True 让出错的任务返回异常对象而不是抛出
    results = await asyncio.gather(
        task("A", 1),
        risky_task(),
        task("C", 1),
        return_exceptions=True
    )
    for r in results:
        if isinstance(r, Exception):
            print(f"任务失败:{r}")
        else:
            print(f"任务成功:{r}")

return_exceptions=True 很实用------一个任务挂了不影响其他任务的结果收集。


协程和生成器的关系

第 17 章学了生成器(yield),其实协程就是从生成器演化来的:

python 复制代码
# 生成器(第 17 章)
def gen():
    yield 1
    yield 2

# 老式协程(Python 3.5 之前,基于生成器)
@asyncio.coroutine
def old_coro():
    yield from asyncio.sleep(1)

# 现代协程(Python 3.5+,用 async/await)
async def new_coro():
    await asyncio.sleep(1)

async/await 本质上是给生成器加了专门的语法,让异步代码读起来更像同步代码。底层原理还是"暂停 + 恢复"------跟生成器的 yield 是一个思路。

区别在于:

  • 生成器:yield 暂停,外部用 next() 恢复 → 数据生产者
  • 协程:await 暂停,事件循环恢复 → 任务调度单元

异步生成器(async for 用的)和异步上下文管理器(async with 用的)也是这个思路的延伸。


同步代码和异步代码混用

有时候你需要在异步代码里调用同步库(比如 requests),或者反过来。

异步里调同步(run_in_executor)

python 复制代码
import asyncio
import time

async def main():
    loop = asyncio.get_event_loop()
    # 把阻塞操作扔到线程池里跑,不阻塞事件循环
    result = await loop.run_in_executor(None, time.sleep, 3)
    print("完成")

asyncio.run(main())

run_in_executor 本质上是起了一个线程去跑阻塞代码。所以它其实是异步 + 多线程的混合。

同步里调异步

python 复制代码
# 用 asyncio.run() 启动事件循环
def sync_main():
    result = asyncio.run(some_async_function())
    print(result)

注意 asyncio.run() 只能在没有正在运行的事件循环时调用。如果在 Jupyter Notebook 里(已经有事件循环了),要用 await 直接调用或者用 nest_asyncio


什么时候用异步?

场景 用什么
网络请求(爬虫、API 调用) ✅ 异步
数据库查询 ✅ 异步(用 asyncpg、motor 等异步驱动)
WebSocket ✅ 异步
文件读写 ⚠️ 可以(用 aiofiles),但提升不如网络明显
CPU 密集计算 ❌ 用多进程(multiprocessing)
简单脚本/工具 ❌ 同步就够了,别给自己找麻烦

异步编程的代码复杂度比同步高不少------调试更难、错误处理更复杂、不是所有库都有异步版本。小项目或脚本用同步就够了,等到确实有性能需求的时候再上异步。


一个实际的例子

异步爬虫,同时抓取多个页面:

python 复制代码
import asyncio
import httpx
import time


async def fetch(session: httpx.AsyncClient, url: str) -> dict:
    """抓取单个页面"""
    try:
        response = await session.get(url, timeout=10)
        return {
            "url": url,
            "status": response.status_code,
            "size": len(response.text),
        }
    except Exception as e:
        return {"url": url, "error": str(e)}


async def main():
    urls = [
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/2",
        "https://httpbin.org/delay/0",
        "https://httpbin.org/status/404",
        "https://httpbin.org/status/500",
    ]

    start = time.time()

    async with httpx.AsyncClient() as session:
        tasks = [fetch(session, url) for url in urls]
        results = await asyncio.gather(*tasks)

    elapsed = time.time() - start

    for r in results:
        if "error" in r:
            print(f"  ❌ {r['url']} --- {r['error']}")
        else:
            print(f"  ✅ {r['url']} --- {r['status']} ({r['size']} bytes)")

    print(f"\n总耗时:{elapsed:.2f} 秒")
    # 同步方式大约需要 1+2+0+... ≈ 3+ 秒
    # 异步方式约 2 秒(取决于最慢的那个)


asyncio.run(main())

本章小结

  • 并发是交替执行,并行是同时执行。asyncio 做的是并发
  • 协程比线程更轻量,一个线程里能跑几万个协程
  • async def 定义协程函数,await 暂停并交出控制权
  • 事件循环(Event Loop)是调度员,驱动所有协程运行
  • gather() 并发执行多个协程,create_task() 更灵活,as_completed() 按完成顺序处理
  • async with / async for 是异步版的上下文管理器和迭代器
  • 不要在异步函数里调用阻塞的同步代码,用 run_in_executor 扔到线程池
  • 协程从生成器演化而来,底层都是"暂停 + 恢复"

面试题

Q1:协程、线程、进程有什么区别?Python 的 asyncio 属于哪种?
点击查看答案

进程 线程 协程
内存空间 独立 共享 共享
切换开销 大(系统级) 极小(用户级)
并发数量 几十 几百 几万+
GIL 不受影响 受 GIL 限制 在单线程内,不涉及 GIL

asyncio 的协程是用户态的协作式多任务 ------所有协程跑在同一个线程里,靠事件循环调度切换。切换由代码中的 await 触发(协作式),不像线程由操作系统抢占式调度。

适合 I/O 密集型(网络、数据库),不适合 CPU 密集型(应该用 multiprocessing)。

Q2:await 到底做了什么?为什么不能在异步函数里调用 time.sleep()
点击查看答案

await 做两件事:

  1. 等待后面的 awaitable 对象完成,获取结果
  2. 挂起当前协程,把控制权交还给事件循环,让其他协程有机会运行

time.sleep() 是阻塞调用------它会卡住整个线程(包括事件循环),导致所有协程都被阻塞。

python 复制代码
# 错误:阻塞事件循环
async def bad():
    time.sleep(5)  # 所有协程都得等 5 秒

# 正确:让出控制权
async def good():
    await asyncio.sleep(5)  # 事件循环可以去跑别的协程

同理,requests.get()open().read()(大文件)等同步 I/O 都会阻塞事件循环,应该用异步替代(httpx、aiofiles)或 run_in_executor

Q3:asyncio.gather()asyncio.create_task() 有什么区别?
点击查看答案

  • gather():一次性提交多个协程,等全部完成后返回结果列表。更简洁,适合"一起跑、一起收"的场景。
  • create_task():立即将协程调度到事件循环,返回 Task 对象。可以分别 await、取消、查状态。更灵活。
python 复制代码
# gather:一起跑,一起收
results = await asyncio.gather(coro1(), coro2(), coro3())

# create_task:先启动,后收集
t1 = asyncio.create_task(coro1())
t2 = asyncio.create_task(coro2())
# 中间可以做别的事
r1 = await t1
r2 = await t2

gather() 内部其实就是对每个协程调用 ensure_future()(类似 create_task),所以两者在并发行为上没有本质区别,区别在于 API 的灵活度。

Q4:协程和生成器有什么关系?
点击查看答案

协程是从生成器演化来的。两者核心机制相同:暂停 + 恢复

  • 生成器用 yield 暂停,外部用 next() 恢复 → 数据生产者
  • 协程用 await 暂停,事件循环恢复 → 任务调度单元

Python 3.5 之前,协程就是加了 @asyncio.coroutine 装饰器的生成器,用 yield from 实现异步。3.5 引入了 async/await 语法,语义更清晰,但底层原理一样。

异步生成器(async for)和异步上下文管理器(async with)也是这个思路的延伸。


相关推荐
rising start1 小时前
Python 实战:Redis 的基础操作与连接池(Pool)深度解析
redis·python·bootstrap
白日与明月2 小时前
pip下载库指定操作系统及python版本
开发语言·python·pip
折哥的程序人生 · 物流技术专研2 小时前
Qoder 1.0 完全指南:从安装到Agents驱动开发实战
开发语言·人工智能·python·ai编程
买大橘子也用券2 小时前
26软件系统安全赛-Fake Emotion(复盘)
python·深度学习·安全·网络安全
輕華2 小时前
Flask_GET请求与JSON响应实战详解
python·flask·json
weelinking2 小时前
【产品】10_搭建前端框架——把你的原型变成真实页面
java·大数据·前端·数据库·人工智能·python·前端框架
yaoxin5211232 小时前
421. Java 日期时间 API - 包结构 & 方法命名规范
java·前端·python
开开心心就好2 小时前
解决图片无页码添加功能的实用工具
javascript·python·安全·智能手机·pdf·音视频·1024程序员节
风吹夏回11 小时前
Python 全局异常处理:从“满屏 try-except”到优雅兜底
开发语言·python