跳转至

异步 1 · 事件循环与协程机制

第 11 章给了 asyncio 的概览(gather、协程概念),web/02 讲了 FastAPI 的 async 实战。本章深入机制async/await 到底在做什么、事件循环如何用单线程实现并发、Task 是什么。理解机制,才能写出正确、不踩坑的异步代码。

对照 Java:asyncio ≈ CompletableFuture + 响应式 + 虚拟线程的混合体,但心智模型不同——它是单线程协作式调度。


1.1 协程函数与协程对象

async def 定义的是协程函数,调用它不执行代码,而是返回一个协程对象

async def fetch(url):
    print(f"start {url}")
    await asyncio.sleep(0.1)      # 模拟 IO 等待
    return f"done {url}"

coro = fetch("x")                 # ✅ 只是创建协程对象,啥都没执行
print(coro)                       # <coroutine object fetch at 0x...>

⚠️ 最常见的初学者错误

写了 async def 却忘了 await/调度,协程从不执行,Python 还会警告 coroutine was never awaited。协程对象必须交给事件循环(awaitcreate_taskasyncio.run)才会跑。


1.2 await 做了什么

await让出点:执行到 await <可等待对象> 时,当前协程暂停,把控制权交还事件循环,循环去跑别的就绪任务;等被 await 的东西完成后,再恢复当前协程继续。

async def work():
    result = await some_async_op()   # 这里暂停,让出 CPU 给其他任务
    return result

关键await 期间不阻塞线程——事件循环能运行其他协程。这就是单线程并发的来源。对照 Java:Future.get()阻塞线程,而 await挂起协程、不阻塞线程

await 的对象(awaitable):协程、TaskFuture、以及实现了 __await__ 的对象。


1.3 事件循环:单线程的"并发幻觉"

事件循环是一个调度器,在单线程里循环执行:取出就绪任务 → 跑到下一个 await 让出 → 取下一个。因为切换极快、IO 等待时让出,宏观上像"同时"做多件事。

事件循环(单线程)
  ├─ 任务A: await IO ──让出──┐
  ├─ 任务B: 跑到 await ──让出─┤
  ├─ 任务C: 跑到 await ──让出─┤
  └─ IO 完成 → 恢复任务A ──────┘

对照 Java

  • Java 的线程是抢占式多任务(OS 调度,随时切换)。
  • Python 协程是协作式多任务(只在 await 处切换)。
  • 协作式的代价:一个协程若不让出(不 await、跑阻塞代码),整个循环卡死(见第 3 章陷阱)。

1.4 Task:让协程真正并发

await fetch()串行的——等它完才继续。要并发,得用 asyncio.create_task 把协程包装成 Task 丢进循环调度:

import asyncio

async def main():
    # ❌ 串行:总共 ~0.3s
    a = await fetch("a")
    b = await fetch("b")
    c = await fetch("c")

    # ✅ 并发:总共 ~0.1s
    t1 = asyncio.create_task(fetch("a"))
    t2 = asyncio.create_task(fetch("b"))
    t3 = asyncio.create_task(fetch("c"))
    a, b, c = await t1, await t2, await t3

create_task 立即把协程排入循环(开始跑),返回 Task 句柄。后续 await task 等它完成取结果。三个 task 并发,总耗时≈最长那个(0.1s)而非总和(0.3s)。

  • Java(CompletableFuture)

    var a = CompletableFuture.supplyAsync(() -> fetch("a"));
    var b = CompletableFuture.supplyAsync(() -> fetch("b"));
    a.join(); b.join();   // 并发
    
  • Python(Task)

    t1 = asyncio.create_task(fetch("a"))
    t2 = asyncio.create_task(fetch("b"))
    await t1; await t2    # 并发
    

1.5 入口:asyncio.run

普通函数不能直接 await——必须有一个运行中的事件循环。asyncio.run 是程序的异步入口,创建循环、运行顶层协程、关闭循环:

async def main():
    ...

asyncio.run(main())      # 整个程序的唯一入口

⚠️ Java 程序员的陷阱

  • asyncio.run 一个程序通常只调一次(最外层)。不要嵌套调用。
  • 在已有事件循环里再调 asyncio.run 会报 RuntimeError
  • 顶层逻辑放 main() 协程里,用 asyncio.run(main()) 启动。

1.6 协程的生命周期

创建(调用 async def)→ 协程对象(未运行)
   ↓ asyncio.run / create_task / await
调度中(Task,在循环里跑)
   ↓ 跑到 await
暂停(让出,等恢复)
   ↓ 完成 / 异常
完成(结果 / 异常)
  • 正常完成await task 拿到返回值。
  • 抛异常await task 重新抛出协程里的异常(异常不会"丢失",但会传播到 await 处——第 3 章详讲)。
  • 取消task.cancel() 请求取消(第 2 章详讲)。

1.7 协程 vs 线程:心智对照

线程(Java/Python threading) 协程(asyncio)
调度 OS 抢占式 事件循环协作式(await 处切换)
阻塞 一个线程阻塞,其他仍可跑(Java 并行;Python 受 GIL 限 CPU) 阻塞 = 卡死整个循环
数量上限 几百~几千 几万~几十万(轻量)
切换成本 内核切换,较重 用户态切换,极轻
共享状态 需锁(竞态) 单线程内无竞态(但 await 处仍需小心)
Python 特殊 受 GIL(CPU 不并行) 单线程,GIL 无影响

何时用协程:IO 密集 + 高并发(成千上万连接),如 Web 服务、爬虫、消息消费者。何时不用:CPU 密集(协程不解决 CPU 并行,走多进程)。


本章练习

练习 1.1

解释为什么下面代码的协程从不执行,并修复。

async def greet():
    print("hi")
greet()

参考答案

greet() 只创建协程对象、不执行(且会警告 coroutine was never awaited)。修复:用事件循环调度它——asyncio.run(greet())

练习 1.2

create_task 让三个 asyncio.sleep 协程并发,验证总耗时≈最长那个(而非总和)。

参考答案
async def delay(s): await asyncio.sleep(s); return s
async def main():
    t0 = asyncio.create_task(delay(0.3))
    t1 = asyncio.create_task(delay(0.1))
    t2 = asyncio.create_task(delay(0.2))
    return await asyncio.gather(t0, t1, t2)
# asyncio.run(main()) 约 0.3s 完成
练习 1.3

协程的"协作式"调度意味着什么?如果一个协程里调用 time.sleep(5)(同步阻塞)会发生什么?

参考答案

协作式 = 只在 await 处切换。time.sleep(5) 是同步阻塞、不让出事件循环,会卡死整个循环 5 秒——期间所有其他协程都无法运行。应改用 await asyncio.sleep(5)(让出,循环可跑别的)。


← 回首页 | 下一章:异步 2 · 并发控制