【基础知识】Python 异步 API 并发请求全流程分析
本文以典型的 FastAPI/Starlette + Uvicorn + asyncio 为例,分析两个请求 req-1 和 req-2 同时调用同一个异步 API 时,从硬件、操作系统、事件循环、协程栈帧到结果返回的完整过程。
示例 API:
async def get_user(request):
user_id = request.query["id"]
user = await db.fetch_user(user_id)
return {"user": user}
一、核心结论
req-1 和 req-2 不共享同一个请求协程、局部变量或 Python 协程栈帧。它们通常由两个独立的 asyncio.Task 表示,在同一个事件循环线程中交替执行。
异步高并发的核心不是为每个请求创建一个线程,而是:
- 一个事件循环监听大量 Socket 的 I/O 状态。
- 每个请求对应一个独立的协程和 Task。
- 协程执行到未完成的
await时主动挂起。 - 事件循环趁机执行其他已经就绪的 Task。
- I/O 完成后,事件循环重新唤醒原来的 Task。
二、从硬件到 Python 服务的完整链路
2.1 硬件层
- 客户端发送 TCP 数据包。
- 网卡接收数据,并通过 DMA 将数据放入内存。
- CPU 处理网卡事件和内核网络协议栈。
- TCP 数据被放入服务端 Socket 的接收缓冲区。
- 内核通知事件循环:某个 Socket 可读。
2.2 操作系统层
操作系统主要负责:
- TCP 连接管理;
- Socket 缓冲区;
epoll、kqueue或 IOCP 等 I/O 多路复用;- 将服务进程或线程调度到 CPU 核心;
- 保存和恢复线程的寄存器、程序计数器、栈指针。
2.3 Python 层
Python 服务负责:
- 解析 HTTP 请求;
- 创建请求上下文;
- 创建协程和
asyncio.Task; - 调用业务函数;
- 在
await处挂起; - I/O 完成后恢复协程;
- 序列化并返回响应。
三、req-1 和 req-2 的处理过程
请求到达顺序、任务执行顺序、响应完成顺序可能不同。
可能出现如下顺序:
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()
对应流程:
高并发时,典型执行方式是:
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 为例:
每个请求通常拥有独立的:
| 对象 | 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;
- 异常和返回值状态。
可以理解为:
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 /
九、为什么结果不会混乱
请求结果的隔离通常依赖四层关联关系:
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、数据库、消息队列等外部协调机制。
十一、单个事件循环中的并发模型
这里的“并发”是:
Task-1 等待 I/O 时,Task-2 得到执行机会
而不是:
CPU 同时执行 Task-1 和 Task-2 的 Python 指令
十二、多 Worker 和多核并行
常见部署模型是:
多个进程 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-1 和 req-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)
此时事件循环中有两个独立任务:
事件循环从就绪队列取出 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_id 和 user_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
因此同一个方法的两次执行可以表示为:
十六、栈、堆和程序计数器的准确关系
16.1 代码区:同一个方法代码通常只有一份
编译 Python 文件后,方法会对应一个代码对象,里面包含 Python 字节码、常量、变量名等信息。
CodeObject-get_user
├── Python 字节码
├── 常量
├── 局部变量名称
└── 代码范围信息
req-1 和 req-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,而是两个协程各自保存的解释器恢复状态。
完整关系可以表示为:
十七、执行到 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。
对应图:
从实现角度看,恢复时可以近似理解为:
result = future1.result()
coro1.send(result)
如果异步操作失败,则近似是把异常注入协程:
coro1.throw(DatabaseError(...))
十八、结果是如何返回到正确的请求者的
这是最容易混淆的地方。结果返回通常不是简单地:
数据库结果 + request_id -> 找到客户端
而是多个对象通过引用和回调连接起来:
具体过程如下:
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-2 和 Task-2 的链路。
18.3 Task 返回值进入对应的响应写入器
当 Frame-1 执行完方法:
return {
"request_id": "req-1",
"user": user1,
}
返回值会沿着调用链返回给 Task-1。框架在创建 Task-1 时,已经把它和 RequestContext-1、writer1 绑定,所以框架可以执行:
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:帮助把响应写回正确网络通道
二十、结果返回的完整时序图
二十一、把整个过程压缩成一句话
同一个 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 的生命周期
二十六、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 或回调”的内存队列。
这张图可以压缩成下面的队列变化:
初始:
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
这个图表达了一个重要事实:即使 req-2 比 req-1 先完成,Writer-2 也只会写 Socket-2;Writer-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-1、req-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
这里两个 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
sendcallable; - 一个 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 来说,send 是 send1;对 req-2 来说,send 是 send2。虽然业务代码调用的是同名参数 send,但每次请求传入的是不同的绑定对象。
三十一、多个不同请求如何最终被客户端区分
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 写响应时,数据还没有立即“跳到”客户端。完整过程是:
在这个过程中:
- 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
这就是“两个请求执行同一个方法,但各自获得各自结果”的底层逻辑。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)