本文以典型的 FastAPI/Starlette + Uvicorn + asyncio 为例,分析两个请求 req-1req-2 同时调用同一个异步 API 时,从硬件、操作系统、事件循环、协程栈帧到结果返回的完整过程。

示例 API:

async def get_user(request):
    user_id = request.query["id"]
    user = await db.fetch_user(user_id)
    return {"user": user}

一、核心结论

req-1req-2 不共享同一个请求协程、局部变量或 Python 协程栈帧。它们通常由两个独立的 asyncio.Task 表示,在同一个事件循环线程中交替执行。

异步高并发的核心不是为每个请求创建一个线程,而是:

  1. 一个事件循环监听大量 Socket 的 I/O 状态。
  2. 每个请求对应一个独立的协程和 Task。
  3. 协程执行到未完成的 await 时主动挂起。
  4. 事件循环趁机执行其他已经就绪的 Task。
  5. I/O 完成后,事件循环重新唤醒原来的 Task。

二、从硬件到 Python 服务的完整链路

客户端 req-1

NIC 网卡

客户端 req-2

操作系统内核

TCP/IP 协议栈

服务端 Socket 接收缓冲区

事件循环 Event Loop

Selector epoll/kqueue/IOCP

HTTP 协议解析

创建 Task-1

创建 Task-2

req-1 协程

req-2 协程

业务处理

业务处理

数据库/Redis/其他网络服务

生成 req-1 响应

生成 req-2 响应

2.1 硬件层

  1. 客户端发送 TCP 数据包。
  2. 网卡接收数据,并通过 DMA 将数据放入内存。
  3. CPU 处理网卡事件和内核网络协议栈。
  4. TCP 数据被放入服务端 Socket 的接收缓冲区。
  5. 内核通知事件循环:某个 Socket 可读。

2.2 操作系统层

操作系统主要负责:

  • TCP 连接管理;
  • Socket 缓冲区;
  • epollkqueue 或 IOCP 等 I/O 多路复用;
  • 将服务进程或线程调度到 CPU 核心;
  • 保存和恢复线程的寄存器、程序计数器、栈指针。

2.3 Python 层

Python 服务负责:

  • 解析 HTTP 请求;
  • 创建请求上下文;
  • 创建协程和 asyncio.Task
  • 调用业务函数;
  • await 处挂起;
  • I/O 完成后恢复协程;
  • 序列化并返回响应。

三、req-1 和 req-2 的处理过程

请求到达顺序、任务执行顺序、响应完成顺序可能不同。

数据库 Task-2 Task-1 Event Loop 内核 Socket 客户端 req-2 客户端 req-1 数据库 Task-2 Task-1 Event Loop 内核 Socket 客户端 req-2 客户端 req-1 发送 req-1 发送 req-2 Socket 可读 读取并解析 req-1 创建 Task-1 执行协程 读取参数、创建局部变量 await 查询数据库 挂起,等待 DB 结果 读取并解析 req-2 创建 Task-2 执行协程 读取参数、创建局部变量 await 查询数据库 挂起,等待 DB 结果 req-2 查询完成 恢复 Task-2 继续执行 await 后面的代码 写入 req-2 响应 req-1 查询完成 恢复 Task-1 继续执行 await 后面的代码 写入 req-1 响应 返回 req-2 的结果 返回 req-1 的结果

可能出现如下顺序:

req-1 到达
req-1 等待数据库
req-2 到达
req-2 等待数据库
req-2 先完成
req-2 先返回
req-1 后完成
req-1 后返回

因此,不能假设先到达的请求一定先返回。

四、事件循环如何实现高并发

事件循环可以抽象为下面的逻辑:

while True:
    events = selector.select()

    for event in events:
        callback = event.callback
        ready_queue.append(callback)

    while ready_queue:
        task = ready_queue.pop()

        # 执行 Task,直到:
        # 1. 协程结束;
        # 2. 协程遇到未完成的 Future;
        # 3. 协程抛出异常。
        task.step()

对应流程:

await 未完成

await 已完成

协程结束

抛出异常

事件循环启动

检查 Socket、定时器、Future

是否有就绪事件

阻塞等待 epoll/kqueue/IOCP

将回调或 Task 放入 ready queue

取出一个 Task

执行 Python 协程

遇到什么情况

保存协程状态

注册 Future 完成回调

生成响应

写入 Socket

异常处理

高并发时,典型执行方式是:

Task-1 运行少量代码
Task-1 await 网络 I/O
Task-2 运行少量代码
Task-2 await 数据库 I/O
Task-3 运行少量代码
Task-3 await Redis I/O
...

当数据库、Redis 或其他网络服务正在处理请求时,CPU 不需要一直等待,可以执行其他 Task。

4.1 await 是协作式让出执行权

await 会在等待的对象尚未完成时,让当前协程暂停:

async def handler():
    result = await db.query()
    return result

但是,以下代码会阻塞事件循环:

time.sleep(10)

应该使用:

await asyncio.sleep(10)

或者将 CPU 密集、阻塞型工作交给线程池、进程池或独立 Worker。

五、一个请求对应哪些内存和执行对象

req-1 为例:

事件循环线程

Task-1

Coroutine-1

协程帧 Frame-1

局部变量

Python 字节码位置

当前 await 状态

Future-1

数据库 I/O

Task-2

Coroutine-2

协程帧 Frame-2

req-2 局部变量

req-2 字节码位置

每个请求通常拥有独立的:

对象 req-1 req-2
Task Task-1 Task-2
协程对象 Coroutine-1 Coroutine-2
协程帧 Frame-1 Frame-2
请求参数 独立 独立
局部变量 独立 独立
await 状态 独立 独立
Future/回调 独立 独立
请求上下文 Context-1 Context-2

例如:

async def handler(request):
    request_id = request.id
    user_id = request.query["user_id"]

    user = await db.fetch(user_id)

    return {
        "request_id": request_id,
        "user": user,
    }

可以概念性地表示为:

Frame-1:
    request_id = req-1
    user_id = 100
    await 状态 = 等待 Future-1

Frame-2:
    request_id = req-2
    user_id = 200
    await 状态 = 等待 Future-2

两个请求调用同一个函数,但使用的是不同的调用实例和不同的局部变量空间。

六、栈帧和程序计数器如何变化

这里需要区分 CPU 层面的程序计数器和 Python 协程的逻辑执行位置。

6.1 CPU 程序计数器

CPU 的程序计数器通常称为:

PC / RIP / EIP

它指向当前正在执行的机器指令。

对于一个事件循环线程:

CPU Core
  └── Event Loop Thread
        └── 当前只有一个 CPU PC

同一个线程不会同时执行 req-1 和 req-2 的机器指令。操作系统在线程切换时,会保存和恢复线程的寄存器、程序计数器和栈指针。

6.2 Python 协程的逻辑程序位置

每个 Python 协程都需要保存自己的执行状态,概念上包括:

  • 局部变量;
  • 参数;
  • 当前调用链;
  • 下一步要执行的 Python 字节码位置;
  • 当前等待的 Future;
  • 异常和返回值状态。

可以理解为:

Frame-2 Frame-1 Event Loop CPU Frame-2 Frame-1 Event Loop CPU 恢复 Frame-1 执行 Python 字节码 执行到 await 保存 locals 和下一条指令位置 恢复 Frame-2 执行 Python 字节码 执行到 await 保存 locals 和下一条指令位置 I/O 完成,重新调度 从 await 后继续执行

await 发生时,通常不是操作系统切换线程,而是 Python 协程主动暂停,把控制权返回给事件循环。

因此,通常不是:

req-1 一个完整线程栈
req-2 一个完整线程栈

而更接近:

一个事件循环线程栈
多个保存在堆上的协程状态

七、req-1 的详细执行过程

1. 客户端发送 req-1。
2. 网卡接收数据。
3. 内核 TCP 栈处理数据。
4. Socket 变为可读。
5. 事件循环收到可读事件。
6. HTTP 层解析出 req-1。
7. 创建 Coroutine-1。
8. 创建 Task-1。
9. Task-1 进入 ready queue。
10. 事件循环执行 Task-1。
11. 创建 Frame-1,保存 request_id、参数等。
12. CPU 执行 Python 字节码。
13. 执行到 `await db.fetch(...)`。
14. 创建或获取 Future-1。
15. Task-1 注册 Future-1 完成后的回调。
16. Frame-1 保存局部变量和下一步执行位置。
17. Task-1 挂起。
18. 事件循环执行其他 Task。
19. 数据库返回结果。
20. Future-1 被设置为完成。
21. Task-1 被重新放入 ready queue。
22. 事件循环恢复 Frame-1。
23. 数据库结果被传回 `await` 表达式。
24. 继续执行 `await` 后面的 Python 代码。
25. 生成响应对象。
26. 序列化为 HTTP 字节。
27. 写入 req-1 对应的 Socket 或 HTTP Stream。
28. 客户端收到 req-1 的响应。
29. Task-1 结束并释放相关对象。

八、req-2 的详细执行过程

req-2 的处理过程完全类似,但使用不同的对象:

Coroutine-2
Task-2
Frame-2
Future-2
Context-2
Socket-2 或 Stream-2

即使两个请求调用同一个函数,也不是共用同一个函数栈帧:

Task-1 -> handler(request=req-1) -> Frame-1
Task-2 -> handler(request=req-2) -> Frame-2

而不是:

Task-1 \
        -> 同一个 Frame
Task-2 /

九、为什么结果不会混乱

请求结果的隔离通常依赖四层关联关系:

请求 req-1

Socket/HTTP Stream-1

Request Context-1

Task-1

Future-1

数据库结果-1

响应-1

请求 req-2

Socket/HTTP Stream-2

Request Context-2

Task-2

Future-2

数据库结果-2

响应-2

9.1 Socket 或 HTTP Stream

如果是两个 TCP 连接:

req-1 -> connection-1
req-2 -> connection-2

如果使用 HTTP/2,则通过不同的 stream_id 区分请求。

9.2 Task

框架一般为每个请求创建一个 Task:

Task-1 负责 req-1
Task-2 负责 req-2

9.3 Future

数据库查询、网络请求、定时器等异步操作通常对应不同的 Future:

Future-1 -> req-1 的数据库结果
Future-2 -> req-2 的数据库结果

数据库结果完成时,只会唤醒关联的 Future,进而唤醒对应的 Task。

9.4 局部变量和请求上下文

局部变量位于各自的协程帧中:

Frame-1.request_id = "req-1"
Frame-2.request_id = "req-2"

所以 Task-1 恢复时,拿到的是自己的数据库结果和自己的局部变量。

十、容易导致请求数据混乱的代码

下面的代码有风险:

current_request = None

async def handler(request):
    global current_request

    current_request = request
    await db.fetch()
    return current_request

可能发生:

req-1 设置 current_request = req-1
req-1 await
req-2 设置 current_request = req-2
req-2 await
req-1 恢复
req-1 读取到 req-2

正确做法是使用局部变量:

async def handler(request):
    current_request = request
    result = await db.fetch(current_request.user_id)
    return current_request, result

需要特别注意以下共享资源:

  • 全局变量;
  • 模块级可变字典、列表;
  • 不安全的单例对象;
  • 共享数据库 Cursor;
  • 复用但未加锁的临时缓存;
  • 非线程安全的第三方库对象。

如果必须共享内存,可以使用:

asyncio.Lock()
asyncio.Queue()
asyncio.Semaphore()

跨进程则需要 Redis、数据库、消息队列等外部协调机制。

十一、单个事件循环中的并发模型

00 00 01 01 02 02 03 03 04 Task-1 CPU 执行 Task-1 等待数据库 Task-2 CPU 执行 Task-2 等待数据库 Task-2 恢复并返回 Task-1 恢复并返回 Event Loop Thread 单事件循环中的协作式调度

这里的“并发”是:

Task-1 等待 I/O 时,Task-2 得到执行机会

而不是:

CPU 同时执行 Task-1 和 Task-2 的 Python 指令

十二、多 Worker 和多核并行

负载均衡或监听 Socket

Worker Process-1

Worker Process-2

Worker Process-3

Event Loop-1

Event Loop-2

Event Loop-3

CPU Core-1

CPU Core-2

CPU Core-3

常见部署模型是:

多个进程 Worker
    每个 Worker 一个事件循环
        每个事件循环管理大量异步 Task

此时可以获得真正的多核并行:

Worker-1 在 CPU Core-1 执行
Worker-2 在 CPU Core-2 执行
Worker-3 在 CPU Core-3 执行

十三、完整总结模型

req-1
  -> Socket/Stream-1
  -> RequestContext-1
  -> Task-1
  -> Coroutine-1
  -> Frame-1
  -> Future-1
  -> DB Result-1
  -> Response-1

req-2
  -> Socket/Stream-2
  -> RequestContext-2
  -> Task-2
  -> Coroutine-2
  -> Frame-2
  -> Future-2
  -> DB Result-2
  -> Response-2

事件循环负责:

监听 I/O
取出就绪 Task
运行到 await
保存协程状态
运行其他 Task
I/O 完成后恢复原 Task

CPU 负责:

一次执行当前线程中的一段机器指令

操作系统负责:

线程、进程、CPU 核心、内核 Socket 和寄存器调度

每个调用者能拿到自己的结果,是因为请求、Task、协程帧、Future、Socket/Stream 和响应之间存在独立且连续的关联链路。只要业务代码不错误地修改共享状态,req-1req-2 的结果就不会混淆。

十四、同一个异步方法究竟是如何被两个请求分别执行的

下面这个例子是理解“同一个方法如何处理多个请求”的关键:

async def get_user(request):
    request_id = request.headers["x-request-id"]
    user_id = request.query["user_id"]

    user = await db.fetch_user(user_id)

    return {
        "request_id": request_id,
        "user": user,
    }

假设同时有两个请求:

req-1: x-request-id=req-1, user_id=100
req-2: x-request-id=req-2, user_id=200

Python 中调用 async def 方法时,第一次发生的事情不是立即执行方法体,而是创建一个协程对象:

coro1 = get_user(request1)
coro2 = get_user(request2)

此时可以理解为:

coro1 = Coroutine-1
coro2 = Coroutine-2

方法的代码本身只有一份,两个协程对象各自保存自己的执行状态:

同一个代码对象 CodeObject-get_user
       ├── Coroutine-1 -> Frame-1 -> request1 的局部变量
       └── Coroutine-2 -> Frame-2 -> request2 的局部变量

也就是说:

  • 方法指令、常量、函数定义通常是共享的;
  • 协程对象不是共享的;
  • 栈帧不是共享的;
  • 参数和局部变量不是共享的;
  • await 的等待状态不是共享的。

十五、从调用方法到第一次 await

典型框架会把协程包装成 Task:

coro1 = get_user(request1)
task1 = asyncio.create_task(coro1)

coro2 = get_user(request2)
task2 = asyncio.create_task(coro2)

此时事件循环中有两个独立任务:

共享只读代码

共享只读代码

同一个方法代码对象
get_user

Coroutine-1

Coroutine-2

Task-1

Task-2

Frame-1
request_id=req-1
user_id=100

Frame-2
request_id=req-2
user_id=200

事件循环从就绪队列取出 Task-1,内部会驱动协程,大致相当于:

result = coro1.send(None)

第一次 send(None) 会让 get_user(request1) 从方法第一行开始执行:

1. 创建或恢复 Frame-1
2. 读取 request1
3. 读取 x-request-id,得到 req-1
4. 读取 user_id,得到 100
5. 调用 db.fetch_user(100)
6. 执行到 await

这里的 request_iduser_id 属于 Frame-1,不会写入 Frame-2

然后事件循环可能取出 Task-2

1. 创建或恢复 Frame-2
2. 读取 request2
3. 读取 x-request-id,得到 req-2
4. 读取 user_id,得到 200
5. 调用 db.fetch_user(200)
6. 执行到 await

因此同一个方法的两次执行可以表示为:

Frame-2 Task-2 Frame-1 get_user 方法代码 Task-1 Event Loop Frame-2 Task-2 Frame-1 get_user 方法代码 Task-1 Event Loop 取出 Task-1 驱动 Coroutine-1 创建独立调用帧 request_id=req-1 user_id=100 执行到 await Task-1 暂停 取出 Task-2 驱动 Coroutine-2 创建独立调用帧 request_id=req-2 user_id=200 执行到 await Task-2 暂停

十六、栈、堆和程序计数器的准确关系

16.1 代码区:同一个方法代码通常只有一份

编译 Python 文件后,方法会对应一个代码对象,里面包含 Python 字节码、常量、变量名等信息。

CodeObject-get_user
    ├── Python 字节码
    ├── 常量
    ├── 局部变量名称
    └── 代码范围信息

req-1req-2 执行的是同一个代码对象,不会因为请求数量增加而复制两份方法代码。

16.2 堆:每次调用需要独立的运行状态

每次调用 async def,概念上会创建或关联以下堆对象:

Task-1
  ├── Coroutine-1
  ├── Frame-1
  ├── RequestContext-1
  ├── Future-1
  ├── 请求参数对象
  └── 响应对象

Task-2
  ├── Coroutine-2
  ├── Frame-2
  ├── RequestContext-2
  ├── Future-2
  ├── 请求参数对象
  └── 响应对象

这些对象通常由 Python 内存管理器分配在进程堆或 Python 管理的内存区域中。

需要注意,下面的说法是概念模型:

每个协程有一个保存在堆上的可暂停执行状态

CPython 内部会对解释器帧进行优化,未必在所有时刻都创建一个完整的、用户可见的 PyFrameObject。但从程序行为看,每个协程必须独立保存局部变量和恢复位置。

16.3 原生线程栈:只保存当前正在运行的调用链

假设只有一个事件循环线程:

EventLoopThread 的原生栈
    ├── event_loop()
    ├── task_step()
    ├── Python 解释器执行函数
    └── 当前正在运行的 get_user()

Task-1 遇到未完成的 await 后,当前调用链返回给事件循环。此时 Task-1 不会继续占着一个线程栈等待数据库,而是把恢复所需的状态保存在协程对象、帧和 Future 关联中。

随后 Task-2 可以复用同一个事件循环线程栈执行。

所以异步模型更接近:

一个线程栈
多个堆上的协程状态

而不是:

每个请求一个线程栈

16.4 CPU 程序计数器和 Python 逻辑位置不是同一个东西

CPU 的 PC/RIP 指向机器指令,例如:

事件循环 C 函数的某条机器指令
Python 解释器取指令的某条机器指令
Socket 写入函数的某条机器指令

CPU 同一时刻只有一个当前 PC。

但每个 Python 协程还要保存自己的“逻辑下一步位置”:

Frame-1: 下一步从 await db.fetch_user(100) 后继续
Frame-2: 下一步从 await db.fetch_user(200) 后继续

这不是两个 CPU 同时存在的 PC,而是两个协程各自保存的解释器恢复状态。

完整关系可以表示为:

挂起时保存

挂起时保存

CPU Core

CPU PC/RIP
当前机器指令

事件循环线程

原生线程栈
当前调用链

Event Loop

当前执行 Task-1

等待或就绪 Task-2

Frame-1
Python 逻辑位置 IP-1
局部变量 req-1

Frame-2
Python 逻辑位置 IP-2
局部变量 req-2

堆上的协程状态

堆上的协程状态

十七、执行到 await 时到底发生什么

以这一行代码为例:

user = await db.fetch_user(user_id)

req-1 来说,过程可以抽象为:

1. Frame-1 当前执行到 await 表达式。
2. 调用 db.fetch_user(100),得到一个异步操作或 Future。
3. 检查 Future 是否已经完成。
4. 如果已经完成,直接取结果并继续执行。
5. 如果尚未完成,Task-1 记录正在等待 Future-1。
6. Future-1 注册 Task-1 的唤醒回调。
7. Coroutine-1 把控制权交还给 Event Loop。
8. Frame-1 保存局部变量和 await 后的恢复位置。
9. Event Loop 继续运行 Task-2 或其他 Task。

对应图:

Event Loop Future-1 DB Driver Frame-1 Task-1 Event Loop Future-1 DB Driver Frame-1 Task-1 保存 user_id=100、request_id=req-1、恢复位置 执行 user = await db.fetch_user(100) 发起异步查询 返回未完成 Future-1 记录等待 Future-1 注册 Task-1 唤醒回调 挂起 Coroutine-1 返回控制权 执行其他 Task 查询完成,结果为 User-100 将 Task-1 放入 ready queue 恢复 Task-1 向协程注入 User-100 user = User-100 继续执行 await 后面的代码

从实现角度看,恢复时可以近似理解为:

result = future1.result()
coro1.send(result)

如果异步操作失败,则近似是把异常注入协程:

coro1.throw(DatabaseError(...))

十八、结果是如何返回到正确的请求者的

这是最容易混淆的地方。结果返回通常不是简单地:

数据库结果 + request_id -> 找到客户端

而是多个对象通过引用和回调连接起来:

req-1 到达

RequestContext-1

Task-1

Future-1

数据库查询-1

响应对象-1

Connection/Stream-1 写入器

客户端 req-1

req-2 到达

RequestContext-2

Task-2

Future-2

数据库查询-2

响应对象-2

Connection/Stream-2 写入器

客户端 req-2

具体过程如下:

18.1 请求进入时建立关联

框架解析 req-1 后,会建立类似的请求上下文:

RequestContext-1
    request = request1
    connection = connection1
    stream = stream1 或 None
    task = Task-1
    response_writer = writer1
    request_id = req-1

req-2 会建立另一份:

RequestContext-2
    request = request2
    connection = connection2
    stream = stream2 或 None
    task = Task-2
    response_writer = writer2
    request_id = req-2

18.2 数据库完成时唤醒正确 Task

req-1 查询数据库时,数据库驱动会创建或返回 Future-1

Task-1 -> Future-1 -> 数据库查询-1

req-2 则是:

Task-2 -> Future-2 -> 数据库查询-2

当数据库查询 1 完成时,驱动会将结果放入 Future-1,然后触发 Future-1 上注册的回调。这个回调知道应该唤醒 Task-1

数据库结果-1
    -> Future-1 完成
    -> Future-1 的回调
    -> Task-1 进入 ready queue
    -> Frame-1 恢复

数据库结果 2 同理,只会进入 Future-2Task-2 的链路。

18.3 Task 返回值进入对应的响应写入器

Frame-1 执行完方法:

return {
    "request_id": "req-1",
    "user": user1,
}

返回值会沿着调用链返回给 Task-1。框架在创建 Task-1 时,已经把它和 RequestContext-1writer1 绑定,所以框架可以执行:

Task-1 完成
    -> 得到 result1
    -> 序列化 result1
    -> 使用 writer1 写入 Connection-1/Stream-1
    -> 客户端 req-1 收到响应

Task-2 使用 writer2,不会使用 writer1

十九、唯一 ID 到底起什么作用

需要区分三种 ID。

19.1 业务请求 ID

例如:

x-request-id: req-1
x-request-id: req-2

它主要用于:

  • 日志关联;
  • 链路追踪;
  • 排查问题;
  • 在业务响应中标识请求。

它不是事件循环调度 Task 的唯一依据。

19.2 Task/Future 关联

真正让数据库结果唤醒正确协程的关键通常是对象引用关系:

Future-1 的完成回调 -> Task-1
Future-2 的完成回调 -> Task-2

在 Python 进程内,不需要先根据字符串 req-1 查找 Task。Future 对象已经保存了自己的回调,回调又关联着正确的 Task。

19.3 连接或 HTTP Stream ID

真正把响应发送给客户端的网络层依据通常是:

  • HTTP/1.1 独立连接对应的 Socket;
  • HTTP/1.1 同一连接上的协议顺序;
  • HTTP/2 的 stream_id
  • WebSocket 或 RPC 协议自己的消息关联 ID。

因此,正确的完整理解是:

业务 request_id:帮助观察和追踪
Task/Future:帮助在进程内恢复正确协程
Connection/Stream:帮助把响应写回正确网络通道

二十、结果返回的完整时序图

Writer-2 Writer-1 DB Driver Future-2 Future-1 Task-2 Task-1 Event Loop HTTP Parser 客户端-2 客户端-1 Writer-2 Writer-1 DB Driver Future-2 Future-1 Task-2 Task-1 Event Loop HTTP Parser 客户端-2 客户端-1 请求 req-1 创建 Context-1、Coroutine-1、Task-1 请求 req-2 创建 Context-2、Coroutine-2、Task-2 执行 Task-1 await 查询-1 发起查询 user_id=100 挂起,等待 Future-1 执行 Task-2 await 查询-2 发起查询 user_id=200 挂起,等待 Future-2 查询-1 完成,结果 User-100 回调 Task-1 恢复 Coroutine-1 Frame-1 继续执行并 return result-1 序列化并写入响应-1 返回 result-1 查询-2 完成,结果 User-200 回调 Task-2 恢复 Coroutine-2 Frame-2 继续执行并 return result-2 序列化并写入响应-2 返回 result-2

二十一、把整个过程压缩成一句话

同一个 async 方法只有一份代码,但每个请求都会创建独立的 Coroutine、Task、Frame、Future 和请求上下文;事件循环轮流驱动这些 Task,I/O 完成时通过 Future 的回调恢复原 Task,原 Task 完成后再通过绑定的 Connection/Stream Writer 将结果写回原请求通道。

所以,结果不会混乱的根本原因不是“方法代码知道自己是哪个请求”,而是:

每次方法调用都有独立的执行实例,
每个异步等待都有独立的 Future,
每个请求都有绑定的响应写入通道。

## 二十二、核心概念名词解释

这一节不从抽象定义开始,而是先回答一个程序员最关心的问题:

```text
这几个东西在程序里到底是什么对象?它们之间如何连接?

可以先记住下面这组对应关系:

名词 程序员可以理解成 主要作用 是否等于线程
EventLoop 调度器、总管 发现 I/O 完成,选择下一个可运行任务
ready queue 待执行任务队列 保存当前可以继续执行的回调或 Task
Task 一个异步工作的控制对象 驱动一个 Coroutine,并记录它是否完成
Frame 一次方法调用的执行现场 保存参数、局部变量和暂停位置
Future 未来结果的占位对象 表示“结果还没回来,但回来后通知我”

生活类比可以是:

EventLoop    = 调度员
ready queue  = 待处理工作篮
Task         = 一张工作单
Frame        = 工作做到哪一步的书签和现场记录
Future       = 外部部门返回结果的取件凭证

二十三、EventLoop:异步程序的调度器

23.1 EventLoop 是什么

EventLoop 是一个持续运行的调度程序。它不断检查:

  • 哪些网络 Socket 已经可读或可写;
  • 哪些定时器已经到期;
  • 哪些 Future 已经完成;
  • 哪些 Task 可以继续执行。

它不是业务方法,也不是一个请求。它更像整个异步程序的“交通指挥中心”。

简化代码如下:

while True:
    events = wait_for_io_or_timer()
    schedule_callbacks(events)

    while ready_queue:
        callback = ready_queue.pop()
        callback()

asyncio 中,事件循环通常运行在一个线程里:

一个 EventLoop
    └── 一个运行它的线程
          └── 轮流执行很多 Task

但要注意:EventLoop 和线程是两个概念。

  • 一个事件循环通常由一个线程运行;
  • 一个线程也可以在不同时间运行不同的事件循环;
  • 一个进程可以有多个线程和多个事件循环,但通常一个事件循环不应该被多个线程同时操作。

23.2 EventLoop 的形态

从程序结构看,它通常是一个对象:

loop = asyncio.get_running_loop()

从内部结构看,它通常包含:

EventLoop
    ├── I/O 多路复用器:epoll/kqueue/IOCP
    ├── ready queue:当前可执行的回调
    ├── timer heap:定时器和超时任务
    ├── 已注册的 Socket
    └── 关闭、异常和任务处理逻辑

在 CPython asyncio 的具体实现中,内部属性名称可能是 _ready_scheduled 等。它们是实现细节,不应作为业务代码依赖,但有助于理解事件循环的内部结构。

二十四、ready queue:已经可以继续执行的工作队列

24.1 ready queue 是什么

ready queue 是事件循环维护的一块内存队列,里面放的是“现在可以运行”的回调或任务步骤。

例如:

ready queue:
    [Task-2 的恢复回调, Task-5 的定时器回调, Task-1 的 Future 回调]

它不表示所有请求,也不表示所有协程。正在等待数据库的 Task 不在 ready queue 中;只有数据库完成,Task 被重新调度后,它才会再次进入 ready queue。

24.2 ready queue 的形态

从数据结构上,它通常类似:

from collections import deque

ready_queue = deque()
ready_queue.append(callback)
callback = ready_queue.popleft()

asyncio 的实现角度,它通常是事件循环内部的回调队列,而不是业务代码中的一个全局队列。

24.3 一个 Task 什么时候进入 ready queue

常见情况包括:

1. Task 刚创建,需要第一次执行。
2. Task 等待的 Future 完成。
3. await asyncio.sleep(...) 到期。
4. Socket 变为可读或可写。
5. 某个回调主动调用 call_soon(...)。

24.4 ready queue 里的任务如何运行

事件循环从队列取出一个任务,然后执行它的一小段:

取出 Task-1
    -> 驱动 Coroutine-1
    -> 执行 Python 代码
    -> 遇到未完成 await
    -> Task-1 移出 ready queue,等待 Future

取出 Task-2
    -> 驱动 Coroutine-2
    -> 执行 Python 代码
    -> 遇到未完成 await
    -> Task-2 移出 ready queue,等待 Future

事件循环不会在任意 Python 指令中强制切换 Task。异步 Task 通常运行到以下位置才会把控制权交回事件循环:

  • 遇到未完成的 await
  • 协程正常返回;
  • 协程抛出异常;
  • 任务被取消。

二十五、Task:一个异步工作的控制对象

25.1 Task 是什么

Task 可以理解成:

“请事件循环负责把这个协程从开始驱动到结束”的工作对象

例如:

async def handle(request):
    result = await db.query(request.user_id)
    return result

coro = handle(request)
task = asyncio.create_task(coro)

这里有三个不同概念:

handle       = 方法定义,代码只有一份
coro         = 一次具体调用的协程对象
task         = 负责调度和跟踪这个协程的控制对象

25.2 Task 的形态

从 Python 程序员角度,Task 是一个对象:

task = asyncio.create_task(handle(request))

task.done()       # 是否完成
task.cancel()     # 请求取消
task.result()     # 获取结果,完成后使用
task.exception()  # 获取异常

概念上可以表示为:

Task-1
    ├── 关联 Coroutine-1
    ├── 当前状态:PENDING/RUNNING/DONE/CANCELLED
    ├── 等待中的 Future:Future-1 或 None
    ├── 完成后的回调列表
    └── 最终结果或异常

25.3 Task 不是什么

Task 不是:

  • 一个操作系统线程;
  • 一个 CPU 核心;
  • 一份方法代码;
  • 一个独立的调用者;
  • 自动提供并行执行能力的对象。

在一个事件循环中,多个 Task 通常由同一个线程轮流驱动:

线程-1
    ├── 驱动 Task-1
    ├── 驱动 Task-2
    ├── 驱动 Task-3
    └── 驱动 Task-4

25.4 Task 的生命周期

create_task(coro)

放入 ready queue

EventLoop 驱动

await 未完成 Future

Future 完成

return

抛出异常

被取消

被取消

被取消

PENDING

READY

RUNNING

WAITING

DONE

FAILED

CANCELLED

二十六、Frame:一次方法调用的执行现场

26.1 Frame 是什么

Frame 可以理解成:

一次方法调用的“执行现场快照”

它记录方法恢复执行所需要的信息,例如:

  • 参数;
  • 局部变量;
  • 临时变量;
  • 当前执行的代码对象;
  • 下一步 Python 字节码位置;
  • 调用上下文;
  • 异常处理状态。

普通同步方法通常执行完就返回,Frame 随着调用栈退出而释放或回收。

异步方法遇到未完成的 await 时,Frame 的状态需要保留下来,未来才能从 await 后面继续执行。

26.2 同一个方法的两个 Frame

async def get_user(request):
    request_id = request.id
    user_id = request.user_id
    user = await db.fetch_user(user_id)
    return request_id, user

两个请求执行后,概念上有:

Frame-1
    code = get_user 的代码
    request_id = req-1
    user_id = 100
    user = 尚未得到
    next_position = await 后面

Frame-2
    code = get_user 的代码
    request_id = req-2
    user_id = 200
    user = 尚未得到
    next_position = await 后面

两者的 code 可以相同,但局部变量和执行位置独立。

26.3 Frame 和原生线程栈的区别

容易产生误解的说法是:

每个协程都有一个线程栈。

更准确的说法是:

每个协程都有独立的可恢复执行状态;
当前运行时会暂时使用事件循环线程的原生栈。

当协程挂起时:

原生线程栈:返回到 EventLoop
Frame-1:保留在 Python 管理的内存中
Task-1:记录等待 Future-1

因此 Task-1 挂起等待 10 秒时,并不需要独占一个线程栈 10 秒。

二十七、Future:未来结果的占位对象

27.1 Future 是什么

Future 表示一个“未来才会知道的结果”:

现在:结果还没有
未来:结果、异常或取消状态会被写入这里

可以把它理解成外部系统发给当前协程的一张取件凭证:

Task-1:我需要数据库查询结果
Future-1:结果回来后通知我
数据库:查询完成,把结果写入 Future-1
Future-1:通知等待它的 Task-1

27.2 Future 的形态

概念上,Future 类似下面的对象:

class SimpleFuture:
    state = "PENDING"
    result = None
    exception = None
    callbacks = []

生命周期通常是:

PENDING
    -> set_result(value)
    -> DONE

或者:

PENDING
    -> set_exception(error)
    -> FAILED

也可能:

PENDING
    -> cancel()
    -> CANCELLED

27.3 Future 不负责执行任务

Future 本身通常不负责真正执行数据库查询或网络请求。

例如:

数据库驱动负责发起查询
Future 负责保存查询结果和通知回调
EventLoop 负责调度回调
Task 负责恢复协程

这四者职责不同。

27.4 Future 和 Task 的关系

可以把关系简化为:

Task-1 正在执行 Coroutine-1
Coroutine-1 执行 await Future-1
Task-1 等待 Future-1
Future-1 完成
Future-1 通知 Task-1
Task-1 恢复 Coroutine-1

代码形态可以近似表示为:

task = asyncio.create_task(handler(request))

# handler 内部执行到:
result = await future

# Task 内部概念上记录:
task.waiting_for = future
future.add_done_callback(task_wakeup)

当 Future 完成:

future.set_result(database_result)

事件循环会安排:

task_wakeup(future)

然后 Task 回到 ready queue,最终从 await future 后继续执行。

二十八、用时序图看清两个请求的完整路径

前面的流程图容易把“方法结果”和“网络连接”画成直接相连,造成误解。实际过程需要分成两段:

第一段:请求进入
客户端 -> TCP 连接 -> HTTP 解析器 -> RequestContext -> Task -> async 方法

第二段:响应返回
async 方法 return -> Task 完成 -> 框架生成响应 -> Writer -> TCP 连接/HTTP Stream -> 客户端

Writer-1 不是一个新的客户端连接,也不是一个通过字符串 ID 查找客户端的全局发送器。它通常是框架创建的一个“绑定发送路径”,内部持有:

Writer-1
    ├── Connection-1 或 Connection-1 上的 Stream-1
    ├── 底层 Transport
    ├── Socket 文件描述符或连接对象
    ├── HTTP 协议状态
    └── 写入、缓冲、背压和关闭逻辑

在 ASGI 这类接口中,可以把它近似理解成一个已经绑定好的 send1 函数:

await send1({
    "type": "http.response.start",
    "status": 200,
})

await send1({
    "type": "http.response.body",
    "body": response_bytes,
})

send1 只能把数据写入 req-1 对应的连接或 HTTP/2 Stream。req-2 使用的是另一个 send2

28.1 严格对应 1~15 步骤的完整时序图

下面这张图专门保留完整的协程执行过程,并把 ready queue 画成独立参与者。这里的 ready queue 不是数据库队列,也不是 TCP 队列,而是事件循环内部保存“当前可以继续执行的 Task 或回调”的内存队列。

Connection-1 或 HTTP Stream-1 Writer-1 其他 Task Future-1 数据库驱动 Frame-1 Task-1 Coroutine-1 RequestContext-1 ready queue EventLoop Socket/I-O 事件 客户端 req-1 Connection-1 或 HTTP Stream-1 Writer-1 其他 Task Future-1 数据库驱动 Frame-1 Task-1 Coroutine-1 RequestContext-1 ready queue EventLoop Socket/I-O 事件 客户端 req-1 7. Task-1 从 ready queue 移出,等待 Future-1 此时不会继续占用事件循环线程 请求字节到达 1. EventLoop 发现请求到达 2. 创建 RequestContext-1 3. 调用 async 方法,创建 Coroutine-1 4. 创建 Task-1 4. Task-1 放入 ready queue 5. 取出 Task-1 Task-1 当前可以执行 5. 驱动 Coroutine-1 5. 创建或恢复 Frame-1 保存 request_id、参数、局部变量 6. 执行 db.fetch_user(user_id) 6. 返回未完成的 Future-1 6. await Future-1 7. 注册 Future-1 完成回调 7. Task-1 进入 WAITING 8. 取出其他已就绪 Task Task-2 8. 执行其他 Task Task-2 运行到 await 或结束 9. 数据库查询完成 保存 result-1 或异常 10. 触发 Future-1 的完成回调 10. 将 Task-1 放回 ready queue 11. 再次取出 Task-1 Task-1 当前可以继续执行 11. 再次驱动 Task-1 12. 恢复 Frame-1 12. 向 Coroutine-1 传入 result-1 12. 从 await 后继续执行 13. 方法 return result-1 13. Task-1 完成并交回结果 14. 使用 Context-1 绑定的 Writer-1 15. 写入 req-1 对应连接或 HTTP Stream 响应字节最终发送给 req-1 调用者

这张图可以压缩成下面的队列变化:

初始:
    ready queue = [Task-1]

Task-1 开始执行:
    ready queue = []
    当前运行 = Task-1

Task-1 遇到 await Future-1:
    ready queue = []
    Task-1 状态 = WAITING
    Future-1 的回调 = 唤醒 Task-1

EventLoop 执行其他任务:
    ready queue = [Task-2]
    当前运行 = Task-2

Future-1 完成:
    Future-1 回调执行
    ready queue = [Task-1]

EventLoop 恢复 Task-1:
    ready queue = []
    当前运行 = Task-1
    Frame-1 从 await 后继续

Task-1 return:
    Task-1 = DONE
    result-1 交给 RequestContext-1
    RequestContext-1 使用 Writer-1 写响应

这里必须特别区分两种“等待”:

Task-1 在 ready queue 中:表示现在可以运行
Task-1 等待 Future-1:表示现在不能继续运行

因此,Task-1 等待数据库时,不是一直停留在 ready queue 里反复轮询,而是从 ready queue 中移出,等 Future-1 完成回调把它重新放回去。

28.2 HTTP/1.1 使用两个 TCP 连接时

很多客户端会通过不同的 TCP 连接并发发送请求。此时最容易理解:

req-1 -> Connection-1 -> Writer-1 -> 客户端连接 1
req-2 -> Connection-2 -> Writer-2 -> 客户端连接 2
数据库 Task-2 / Frame-2 Task-1 / Frame-1 Context-2 Writer-2 Context-1 Writer-1 EventLoop HTTP 协议解析器 Socket-2 Connection-2 Socket-1 Connection-1 内核 TCP/IP 网卡/NIC 客户端请求 req-2 客户端请求 req-1 数据库 Task-2 / Frame-2 Task-1 / Frame-1 Context-2 Writer-2 Context-1 Writer-1 EventLoop HTTP 协议解析器 Socket-2 Connection-2 Socket-1 Connection-1 内核 TCP/IP 网卡/NIC 客户端请求 req-2 客户端请求 req-1 TCP 数据包 req-1 网卡中断/DMA 后提交数据 按四元组放入 Socket-1 接收缓冲区 TCP 数据包 req-2 网卡中断/DMA 后提交数据 按四元组放入 Socket-2 接收缓冲区 Socket-1 和 Socket-2 可读 read Socket-1 req-1 的 TCP 字节流 创建 Context-1,绑定 Writer-1 创建并调度 Task-1 read Socket-2 req-2 的 TCP 字节流 创建 Context-2,绑定 Writer-2 创建并调度 Task-2 执行 get_user(req-1) 查询 user_id=100 await Future-1,暂时挂起 执行 get_user(req-2) 查询 user_id=200 await Future-2,暂时挂起 Future-2 完成 恢复 Frame-2 return result-2 Writer-2 写 HTTP 响应字节 写入 Socket-2 发送缓冲区 TCP 分段、编号、发送 req-2 对应响应 Future-1 完成 恢复 Frame-1 return result-1 Writer-1 写 HTTP 响应字节 写入 Socket-1 发送缓冲区 TCP 分段、编号、发送 req-1 对应响应

这个图表达了一个重要事实:即使 req-2req-1 先完成,Writer-2 也只会写 Socket-2Writer-1 仍然只会写 Socket-1

28.3 HTTP/1.1 中 Socket 是如何被识别的

TCP 连接通常由四元组唯一标识:

客户端 IP
客户端临时端口
服务端 IP
服务端端口

例如:

Connection-1:
    client = 192.168.1.10:50100
    server = 10.0.0.8:8000

Connection-2:
    client = 192.168.1.10:50101
    server = 10.0.0.8:8000

虽然两个请求访问相同的服务端端口 8000,但客户端临时端口不同,所以内核会把它们放入两个不同的已连接 Socket:

TCP 四元组-1 -> Socket-1 -> Connection-1 -> Writer-1
TCP 四元组-2 -> Socket-2 -> Connection-2 -> Writer-2

TCP 层并不知道 req-1req-2 这些业务 ID。它只负责:

  • 根据四元组找到连接;
  • 按 TCP 序列号重排字节;
  • 丢包重传;
  • 流量控制和拥塞控制;
  • 把有序字节交给 Socket。

HTTP 解析器才负责从 TCP 字节流中解析出请求边界和响应边界。

28.4 TCP 不是消息队列,而是有序字节流

一次 HTTP 请求不一定对应一个 TCP 数据包:

一个 HTTP 请求可能被拆成多个 TCP 包
多个 HTTP 请求也可能合并在一次 read 中

因此服务端通常是:

TCP 包
    -> TCP 重组为有序字节流
    -> Socket 接收缓冲区
    -> HTTP Parser 按 HTTP 规则切分请求
    -> RequestContext-1 / RequestContext-2

这也是为什么 RequestContext 不能简单等同于一个 TCP 数据包。

28.5 HTTP/1.1 同一个 Keep-Alive 连接

如果多个 HTTP/1.1 请求复用同一个 TCP 连接:

Connection-1
    ├── HTTP Request-1
    └── HTTP Request-2

这时 Writer 不能仅仅通过 Connection-1 区分两个请求,因为两个请求共享同一个 Socket。HTTP/1.1 的处理还要遵守协议中的响应顺序约束:

Request-1 -> Response-1
Request-2 -> Response-2

框架会为同一连接维护请求顺序、响应状态和写入队列。即使业务层面 Task-2 先完成,也不能随意把 Response-2 的字节插入 Response-1 的字节中。

实际客户端通常会通过多个 TCP 连接来实现 HTTP/1.1 并发;HTTP/2 则专门解决了同一连接多请求复用问题。

28.6 HTTP/2 使用一个 TCP 连接和多个 Stream

HTTP/2 的情况不同:多个请求共享一个 TCP 连接,但每个请求有独立的 stream_id

Connection-1 / TCP Socket-1
    ├── Stream-1 -> req-1 -> Writer-1
    └── Stream-3 -> req-2 -> Writer-2
Task-2 Task-1 Stream-3 Context-2 / Writer-2 Stream-1 Context-1 / Writer-1 EventLoop HTTP/2 帧解析器 服务端内核 Socket-1 一个 TCP Connection-1 客户端 Task-2 Task-1 Stream-3 Context-2 / Writer-2 Stream-1 Context-1 / Writer-1 EventLoop HTTP/2 帧解析器 服务端内核 Socket-1 一个 TCP Connection-1 客户端 HEADERS stream_id=1 req-1 HEADERS stream_id=3 req-2 一个有序 TCP 字节流 读取字节 按 stream_id=1 创建 Stream-1 按 stream_id=3 创建 Stream-3 Context-1 绑定 Writer-1 Context-2 绑定 Writer-2 执行 req-1 执行 req-2 result-2 先完成 Writer-2 编码 DATA stream_id=3 HEADERS/DATA stream_id=3 客户端 HTTP/2 解复用到 req-2 result-1 后完成 Writer-1 编码 DATA stream_id=1 HEADERS/DATA stream_id=1 客户端 HTTP/2 解复用到 req-1

这里两个 Writer 可能共享底层 TCP Socket,但不共享 HTTP/2 Stream:

Writer-1 = (Connection-1, stream_id=1)
Writer-2 = (Connection-1, stream_id=3)

Writer-2 写出的数据会被 HTTP/2 编码器标记为 stream_id=3。客户端收到数据后,HTTP/2 协议栈根据 stream_id=3 将响应交给发起 req-2 的请求对象。

三十、RequestContext 为什么要绑定 Writer

30.1 RequestContext 保存一次请求的全部运行环境

RequestContext-1 可以理解成一个请求的“工作文件夹”:

RequestContext-1
    ├── HTTP 请求头、路径、查询参数
    ├── 客户端地址
    ├── request_id=req-1
    ├── Connection-1 或 Stream-1
    ├── receive1:读取请求体的入口
    ├── send1:发送响应的入口
    ├── Task-1
    └── 超时、取消和日志上下文

RequestContext-2 是完全独立的工作文件夹:

RequestContext-2
    ├── request_id=req-2
    ├── Connection-2 或 Stream-3
    ├── receive2
    ├── send2
    └── Task-2

30.2 Writer-1 的本质

在不同框架里,Writer 的具体类名可能不同,但概念上都表示:

“把这个请求产生的 HTTP 响应,编码并写到这个请求对应的网络出口”

它可能是:

  • 一个协议对象的方法;
  • 一个绑定了连接的回调函数;
  • 一个 ASGI send callable;
  • 一个 HTTP/2 Stream writer;
  • 一个封装底层 Transport 的响应对象。

它通常不会只保存一个字符串 request_id,而是直接持有或闭包捕获网络路径:

def make_send(connection, stream_id=None):
    async def send(message):
        data = encode_http_message(message, stream_id)
        connection.transport.write(data)
    return send

send1 = make_send(connection1, stream_id=None)
send2 = make_send(connection2, stream_id=None)

HTTP/2 时则可能是:

send1 = make_send(connection1, stream_id=1)
send2 = make_send(connection1, stream_id=3)

因此:

send1 不是“先查 req-1 再找连接”
send1 本身就已经绑定了 Connection-1 或 Stream-1

30.3 框架如何使用 Writer

用户的业务方法通常只返回 Python 对象:

async def get_user(request):
    user = await db.fetch_user(request.user_id)
    return {"user": user}

框架外层负责把返回值转换成 HTTP 响应:

get_user(req-1)
    -> return result-1
    -> 框架把 dict 编码成 JSON bytes
    -> 调用 Context-1 的 Writer-1
    -> Writer-1 写 Connection-1/Stream-1

对于 ASGI 风格接口,可以概念性地表示为:

async def app(scope, receive, send):
    result = await get_user(scope)

    await send({
        "type": "http.response.start",
        "status": 200,
        "headers": [(b"content-type", b"application/json")],
    })

    await send({
        "type": "http.response.body",
        "body": encode_json(result),
    })

对 req-1 来说,sendsend1;对 req-2 来说,sendsend2。虽然业务代码调用的是同名参数 send,但每次请求传入的是不同的绑定对象。

三十一、多个不同请求如何最终被客户端区分

服务端进程

HTTP Parser

Context-1

Context-2

Writer-1

Writer-2

Socket-1 或 Stream-1

Socket-2 或 Stream-3

TCP 字节流/连接标识

TCP 字节流或 HTTP/2 Stream ID

客户端请求对象 req-1

客户端请求对象 req-2

31.1 HTTP/1.1 独立连接

服务端 Writer-1
    -> Socket-1 的发送缓冲区
    -> TCP Connection-1
    -> 客户端 HTTP Connection-1
    -> 客户端 req-1 的等待对象
服务端 Writer-2
    -> Socket-2 的发送缓冲区
    -> TCP Connection-2
    -> 客户端 HTTP Connection-2
    -> 客户端 req-2 的等待对象

客户端 HTTP 库通常提前保存了“请求对象和连接”的关系,所以收到 Connection-1 的响应时,会唤醒 req-1 对应的调用者。

31.2 HTTP/2 共享连接

Writer-1
    -> Connection-1 + stream_id=1
    -> HTTP/2 DATA stream_id=1
    -> 客户端 HTTP/2 stream_id=1
    -> req-1 的等待对象
Writer-2
    -> Connection-1 + stream_id=3
    -> HTTP/2 DATA stream_id=3
    -> 客户端 HTTP/2 stream_id=3
    -> req-2 的等待对象

这时响应可以乱序完成,因为客户端不是只看 TCP 连接,而是看 HTTP/2 帧中的 stream_id

31.3 业务 request_id 的位置

业务 ID 可以出现在:

X-Request-ID: req-1

或者 JSON 响应中:

{
  "request_id": "req-1",
  "data": {}
}

它适合日志和链路追踪,但底层网络返回路径通常依赖:

HTTP/1.1:连接和请求顺序
HTTP/2:stream_id
HTTP/3:QUIC connection ID + stream ID

三十二、从网卡到客户端的响应发送过程

Writer-1 写响应时,数据还没有立即“跳到”客户端。完整过程是:

req-1 调用者 客户端 HTTP 层 客户端 TCP/IP 客户端网卡 网络 服务端网卡 TCP/IP 协议栈 内核 Socket 用户态 Transport Writer-1 Task-1 req-1 调用者 客户端 HTTP 层 客户端 TCP/IP 客户端网卡 网络 服务端网卡 TCP/IP 协议栈 内核 Socket 用户态 Transport Writer-1 Task-1 return result-1 后发送响应 JSON 编码、HTTP 头和 body write(bytes) 放入 Socket 发送缓冲区 处理序列号、窗口、拥塞控制 交给网卡发送 拆成 TCP/IP 数据包 到达客户端网卡 DMA/中断交给客户端内核 按 TCP 序列号重组字节流 交给 HTTP/1.1 或 HTTP/2 解析器 按连接顺序或 stream_id 匹配请求 唤醒 req-1 的调用者

在这个过程中:

  • Python 的 Writer-1 只负责把正确响应交给正确的服务端 Transport;
  • 服务端内核负责 TCP 传输;
  • 网络中间设备只处理 IP 和 TCP/QUIC 等协议;
  • 客户端 HTTP 层负责把响应交给正确的请求对象。

三十三、最终的返回关系

可以用下面这条链路记忆:

业务方法调用
    -> Coroutine-1
    -> Task-1
    -> Frame-1 保存 req-1 的局部状态
    -> Future-1 完成
    -> Task-1 恢复
    -> Task-1 return result-1
    -> RequestContext-1
    -> Writer-1
    -> Connection-1 或 Connection-1/Stream-1
    -> 服务端 Socket
    -> TCP/IP 字节流
    -> 客户端连接或 HTTP/2 Stream
    -> 客户端 req-1 等待对象

对于 req-2,只需把所有 1 替换成 2,但 HTTP/2 场景中可能是:

req-1 -> Connection-1/Stream-1
req-2 -> Connection-1/Stream-3

最关键的结论是:

request_id 主要用于业务关联和日志追踪;
Task/Future 负责在服务端恢复正确的协程;
Connection/Stream 负责把响应写入正确的网络通道;
客户端协议栈再根据连接顺序或 stream_id 交给正确的调用者。

三十四、最重要的区别

最后用一句程序员容易记住的话区分它们:

EventLoop:决定“下一步运行谁”。
ready queue:保存“现在可以运行谁”。
Task:负责“把哪个协程运行到结束”。
Frame:保存“这个协程上次运行到哪里、局部变量是什么”。
Future:表示“外部结果什么时候回来,回来后通知谁”。

它们的协作关系是:

EventLoop
    -> 从 ready queue 取出 Task
    -> Task 驱动 Coroutine
    -> Coroutine 使用 Frame 执行
    -> 遇到 await Future 时暂停
    -> Future 完成后把 Task 重新放入 ready queue
    -> EventLoop 再次驱动 Task

这就是“两个请求执行同一个方法,但各自获得各自结果”的底层逻辑。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐