3. Python 异步编程基础:async/await 语法、事件循环、协程与任务

在构建高吞吐的 Redis 缓存中间件时,I/O 等待(网络延迟、磁盘刷写)是性能瓶颈的核心来源。Python 的 async/await 语法提供了一种协作式多任务模型,让单线程在等待 I/O 时主动让出控制权,从而并发处理成百上千个连接。本章将拆解异步编程的四大基石:协程、任务、事件循环与 async/await 语法,并展示它们如何协同工作。

3.1 核心概念与关系

异步编程的四个核心元素构成一个分层协作体系。下表梳理了每个角色的职责与生命周期:

概念 角色 生命周期 类比(餐厅)
协程(Coroutine) 可暂停/恢复的函数 从 async def 调用到 return 或 StopIteration 菜谱步骤
任务(Task) 协程的并发调度单元 从 asyncio.create_task() 到协程完成或被取消 厨师手上的订单
事件循环(Event Loop) 全局调度器,管理所有任务 从 asyncio.run() 启动到所有任务完成 餐厅经理
await 表达式 挂起点,让出控制权 在协程内部,遇到 I/O 时触发 “等菜”信号

下图展示了事件循环如何驱动多个协程任务交错执行:

事件循环驱动协程任务交错执行 时间 事件循环(Event Loop) 任务1: Redis GET await 挂起 任务2: Redis SET await 挂起 任务1 恢复执行 任务2 恢复执行 任务1 完成 任务2 完成 关键点 单线程内 任务交错执行 I/O 等待不阻塞 CPU 利用率高
💡 核心认知:异步不是并行。它通过协作式多任务在单线程内实现并发,适合 I/O 密集型场景(如 Redis 网络调用),但不适合 CPU 密集型计算。

3.2 async/await 语法详解

async def 定义一个协程函数,调用它返回一个协程对象。await 只能在协程内部使用,用于挂起当前协程直到可等待对象(另一个协程、Future 或 Task)完成。

3.2.1 基础用法

import asyncio

async def fetch_redis(key: str) -> str:
    """模拟 Redis GET 操作"""
    await asyncio.sleep(0.1)  # 模拟网络 I/O
    return f"value_{key}"

async def main():
    # 直接 await 协程:顺序执行
    result1 = await fetch_redis("user:1")
    result2 = await fetch_redis("user:2")
    print(result1, result2)

asyncio.run(main())
⚠️ 常见错误:直接调用 fetch_redis("user:1") 而不 await,只会得到一个协程对象,不会执行实际逻辑。必须通过 await 或 asyncio.create_task() 驱动执行。

3.2.2 并发执行:创建任务

使用 asyncio.create_task() 将协程包装为 Task,事件循环会在后台调度它,无需等待即可继续执行后续代码。

async def main():
    # 创建两个任务,并发执行
    task1 = asyncio.create_task(fetch_redis("user:1"))
    task2 = asyncio.create_task(fetch_redis("user:2"))

    # 等待所有任务完成
    result1 = await task1
    result2 = await task2
    print(result1, result2)

asyncio.run(main())

对比两种模式:

模式 执行方式 总耗时(假设每个 I/O 100ms) 适用场景
await 顺序执行 串行 200ms 依赖前一个结果
create_task + await 并发 ~100ms 独立 I/O 请求

3.3 事件循环的启动与生命周期

asyncio.run() 是 Python 3.7+ 推荐的入口函数,它自动完成三件事:

  1. 创建一个新的事件循环实例
  2. 运行传入的协程直到完成
  3. 关闭事件循环并清理资源

在更复杂的场景(如集成 Redis 客户端时),你可能需要手动管理事件循环:

async def redis_pipeline():
    # 模拟多个 Redis 命令
    tasks = [
        asyncio.create_task(fetch_redis("key1")),
        asyncio.create_task(fetch_redis("key2")),
        asyncio.create_task(fetch_redis("key3")),
    ]
    results = await asyncio.gather(*tasks)
    return results

# 手动管理事件循环(高级用法)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
    results = loop.run_until_complete(redis_pipeline())
    print(results)
finally:
    loop.close()
📌 最佳实践:在绝大多数应用代码中,使用 asyncio.run() 即可。手动管理循环仅在需要长期运行的事件循环(如 Web 服务器、自定义调度器)时使用。

3.4 协程与任务的深入对比

理解协程与任务的区别,是正确编排异步流程的关键:

特性 协程(Coroutine) 任务(Task)
本质 可暂停的函数对象 协程 + 调度上下文
创建方式 async def 调用 asyncio.create_task(coro)
是否自动调度 否,需要 await 或包装为 Task 是,创建后立即加入事件循环
取消支持 不支持直接取消 支持 task.cancel()
结果获取 await coro 直接返回 await task 或 task.result()
调试信息 有限 包含名称、状态、堆栈

下图展示了从协程定义到任务调度的完整生命周期:

协程与任务生命周期 async def 定义协程函数 coro() 创建协程对象 await coro create_task(coro) 任务 调度 执行 生命周期关键节点 ① 定义阶段:使用 async def 声明协程函数,此时尚未执行。 ② 创建阶段:调用协程函数得到协程对象,仍处于暂停状态。 ③ 调度阶段:通过 await 直接执行,或包装为 Task 由事件循环调度。 ④ 完成阶段:协程返回结果或抛出异常,Task 状态变为 FINISHED。

3.5 异步上下文管理器与异步迭代器

在 Redis 客户端封装中,连接池的获取与释放非常适合使用异步上下文管理器:

class RedisConnection:
    async def __aenter__(self):
        self.conn = await create_redis_connection()
        return self.conn

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        await self.conn.close()

# 使用
async def main():
    async with RedisConnection() as conn:
        value = await conn.get("key")
        print(value)

异步迭代器则适用于处理 Redis 的 SCAN 或流式数据:

class RedisScanner:
    def __init__(self, pattern: str):
        self.pattern = pattern
        self.cursor = 0

    def __aiter__(self):
        return self

    async def __anext__(self):
        # 模拟 SCAN 迭代
        if self.cursor > 100:
            raise StopAsyncIteration
        self.cursor += 10
        return f"key_{self.cursor}"

async def main():
    async for key in RedisScanner("user:*"):
        print(f"Found: {key}")

3.6 常见陷阱与调试技巧

  • 忘记 await:协程对象未被调度,日志中会出现 coroutine was never awaited 警告。
  • 阻塞事件循环:在协程内使用 time.sleep() 会阻塞整个事件循环,应使用 asyncio.sleep()。
  • 任务未捕获异常:Task 内部的异常不会自动传播,必须 await task 或调用 task.exception() 才能捕获。
  • 调试工具:设置 PYTHONASYNCIODEBUG=1 环境变量,或调用 loop.set_debug(True) 可输出详细的调度日志。
🔧 调试命令速查:
asyncio.all_tasks(loop) — 查看当前所有未完成的任务
task.get_coro() — 获取任务关联的协程对象
task.print_stack() — 打印任务当前的调用栈