云计算百科
云计算领域专业知识百科平台

当 GIL 不再是天花板:Free-Threaded Python × asyncio 的多事件循环架构实践

当 GIL 不再是天花板:Free-Threaded Python × asyncio 的多事件循环架构实践

过去谈 Python 高并发,我们很容易形成一种固定思维:

I/O 密集型用 asyncio,CPU 密集型用多进程。

这套经验长期以来没有错,但 Free-Threaded CPython 正在改变它背后的前提。

PEP 703 让 CPython 可以在关闭 GIL 的模式下运行。Python 3.13 首次提供实验性的 free-threaded build;到了 Python 3.14,PEP 779 将其推进到官方支持的 Phase II:它仍然是可选构建,而不是默认模式,但已经不再只是一个实验项目。

这意味着一个过去看起来有些“奇怪”的架构开始值得认真讨论:

一个 Python 进程

├── Thread 1 ── Event Loop 1
├── Thread 2 ── Event Loop 2
└── Thread 3 ── Event Loop 3

问题来了:

为什么不继续使用一个 event loop?什么时候多个线程各跑一个 asyncio event loop 反而更合理?

答案的核心并不是“asyncio 变快了”,而是:

Free-threaded Python 让多个 event loop 所在线程有机会真正同时执行 Python 代码。

这打开了一片新的设计空间。


一、先理解本质:Free-Threaded 并不会让一个 Event Loop 并行

这是整个问题最容易产生误解的地方。

即使关闭了 GIL,一个 asyncio event loop 的基本调度模型仍然没有改变:

Task A ──执行── await

Task B ──执行── await

Task C ──执行── await

一个 event loop 在一个线程中执行 callback 和 Task。某个 Task 正在执行 Python 代码时,同一个 loop 中其他 Task 不会同时执行;只有当前任务执行到 await 并让出控制权后,loop 才能切换到其他任务。

因此:

1 Thread

1 Event Loop

10000 Coroutines

依然主要属于 concurrency(并发),而不是 Python 字节码层面的真正多核 parallelism。

Free-threaded 真正有意思的地方在这里:

CPU Core 0 CPU Core 1 CPU Core 2
│ │ │
Thread 1 Thread 2 Thread 3
│ │ │
Loop 1 Loop 2 Loop 3
│ │ │
Tasks Tasks Tasks

当 GIL 被关闭后,这三个 OS 线程可以同时执行 Python 代码。

于是出现了一种很有意思的架构:

Reactor-per-thread / Loop-per-thread。

这种设计过去当然也能写,但 GIL 会严重限制多个线程同时执行 Python callback 的收益;在 free-threaded 环境下,它第一次真正成为值得 benchmark 的多核 asyncio 架构。


二、什么时候我会考虑三个线程、三个 Event Loop?

我不会把它作为普通 asyncio 服务的默认架构。

绝大多数 Web API、数据库 CRUD、HTTP 聚合服务,首先应该从:

1 Process
└── 1 Event Loop
└── many coroutines

开始。

只有当 profiling 明确告诉我 单 loop 本身开始成为执行瓶颈,我才会考虑多 loop。

一个典型场景是:

Socket I/O

Protocol decode

JSON / message parsing

Validation

Routing

Business rules

Aggregation

Network I/O

这里虽然整体看起来像“I/O 服务”,但每个请求之间实际上夹杂着大量 Python 计算。

假设单个请求只有 300 微秒 Python CPU 工作。

每秒 5000 个请求就是:

5000 × 300μs
≈ 1.5 CPU seconds / second

此时一个 event loop 已经不可能靠“更多协程”解决问题。

协程解决的是等待。

它不能制造第二颗 CPU。

在 free-threaded Python 下,我们可以按照连接、用户、tenant、partition 或 hash 对任务进行分片:

hash(client_id) % 3

┌── Loop 0 / Thread 0
Request ─────┼── Loop 1 / Thread 1
└── Loop 2 / Thread 2

每个 loop 独立处理自己的连接和状态。

这类 workload 特别适合:

高并发网络服务
实时行情/Telemetry
WebSocket Gateway
消息协议解析
IoT Gateway
流式数据处理
RPC Proxy
实时规则计算

它的一个巨大优势是:

不需要像 multiprocessing 一样通过 IPC 才能访问同一地址空间。

但这也是最大的危险。

因为现在大家真的在同时碰同一块内存。


三、Shared State:共享内存既是优势,也是陷阱

Free-threaded 最诱人的地方之一是:

shared_cache = {}

三个线程都能直接访问它。

但“Python 不崩”与“程序逻辑正确”完全是两回事。

Free-threaded CPython 对 dict、list、set 等内置容器增加了内部同步,以避免很多并发修改造成的数据结构损坏;官方文档同时明确建议,不要把这种内部锁当成应用程序同步协议,应在需要时使用 threading.Lock 等同步机制。

例如:

if key not in cache:
cache[key] = expensive_load(key)

即使:

key in cache

和:

cache[key] = value

单独都不会把字典结构搞坏,这组操作仍然不是一个原子的业务事务。

两个线程可能同时发现 key 不存在,然后计算两遍。

真正安全的写法可能是:

import threading

cache = {}
cache_lock = threading.Lock()

def get_or_create(key):
with cache_lock:
if key in cache:
return cache[key]

value = expensive_load(key)
cache[key] = value
return value

但新的问题又来了:

如果 expensive_load() 很慢,你把三个 CPU 核重新串行化了。

所以实际工程中,我更推荐:

Shared everything

尽量避免

Shared immutable data

很好

Partitioned mutable state

最好

例如:

Loop 0 -> cache shard 0
Loop 1 -> cache shard 1
Loop 2 -> cache shard 2

根据:

shard_id = hash(key) % worker_count

确定某个 key 的唯一 owner。

这样系统从:

shared-memory concurrency

逐渐变成:

ownership-based concurrency

而 ownership 往往比 lock 更容易推理。


四、Thread Safety:要同时理解 threading 与 asyncio 两套同步模型

多 loop 架构里最危险的一件事,是混淆:

asyncio.Lock

和:

threading.Lock

它们解决的不是同一个问题。

asyncio.Lock 用于:

同一个 Event Loop
不同 coroutine

而跨线程共享状态应该使用:

threading.Lock
threading.RLock
queue.Queue
Semaphore
Condition

更重要的是:

asyncio 中绝大多数对象不是 thread-safe 的。

官方建议,从其他 OS 线程操作一个 event loop 时,应使用:

loop.call_soon_threadsafe(...)

或者:

asyncio.run_coroutine_threadsafe(...)

而不是直接操作属于另一个 loop 的 Future、Task、Queue 等对象。

例如不要这样:

# Thread A
worker_loop.create_task(coro())

应该:

asyncio.run_coroutine_threadsafe(
coro(),
worker_loop,
)

还有一个非常实用的原则:

不要拿着 threading.Lock 去 await。

例如:

with shared_lock:
await query_database()

意味着这把跨线程锁可能在一次网络等待期间一直被占用。

更合理的是:

with shared_lock:
snapshot = shared_state.copy()

result = await query_database(snapshot)

把同步区域缩到最小。


五、Loop Ownership:谁创建,谁使用,谁销毁

在多 event loop 系统里,我认为最重要的设计原则只有一句:

Loop-bound resource belongs to one loop for its entire lifetime.

比如:

asyncio.Future
asyncio.Queue
asyncio.Lock
HTTP ClientSession
DB AsyncEngine
Connection Pool
Transport
Task

不要:

Main Thread 创建

Thread 2 使用

Thread 3 close

而应该:

Thread 2
└── Event Loop 2
├── create pool
├── use pool
└── close pool

一个简单的 worker 可以这样写:

import asyncio
import threading
from concurrent.futures import Future

class LoopWorker:
def __init__(self, worker_id: int):
self.worker_id = worker_id
self._ready = threading.Event()

self._thread = threading.Thread(
target=self._thread_main,
name=f"loop-worker-{worker_id}",
)

self.loop = None
self._stop = None

def start(self):
self._thread.start()
self._ready.wait()

def _thread_main(self):
asyncio.run(self._main())

async def _main(self):
self.loop = asyncio.get_running_loop()
self._stop = asyncio.Event()

# 在这里创建属于当前 loop 的资源
# self.http = aiohttp.ClientSession(…)
# self.db = create_async_engine(…)

self._ready.set()

try:
await self._stop.wait()
finally:
# 在它所属的 loop 中释放
# await self.http.close()
# await self.db.dispose()
pass

def submit(self, coro) > Future:
return asyncio.run_coroutine_threadsafe(
coro,
self.loop,
)

def stop(self):
self.loop.call_soon_threadsafe(
self._stop.set
)
self._thread.join()

启动三个 worker:

workers = [
LoopWorker(i)
for i in range(3)
]

for worker in workers:
worker.start()

然后进行 hash routing:

def choose_worker(key):
return workers[hash(key) % len(workers)]

future = choose_worker(user_id).submit(
handle_request(user_id)
)

result = future.result()

这个模型最重要的不是“三个 loop”。

而是:

key

固定 shard

固定 Thread

固定 Loop

固定 Resource Set

这样系统会容易理解很多。


六、Connection Pools:最容易踩坑的地方

多 event loop 后,连接池不能再想当然地全局共享。

假设:

Loop 1 ──┐
Loop 2 ──┼── Global Async DB Pool
Loop 3 ──┘

这往往不是一个好设计。

以 SQLAlchemy 的异步引擎为例,官方明确说明:使用默认 pool 时,不应把同一个 AsyncEngine 在不同 event loop 之间共享;否则可能出现典型的:

Future attached to a different loop

错误。如果确实要跨 loop 转移,需要先 dispose();如果必须跨 loop 使用,则可考虑 NullPool,不过这通常意味着放弃连接复用。

因此更推荐:

Loop 1 -> DB Pool 1
Loop 2 -> DB Pool 2
Loop 3 -> DB Pool 3

HTTP client 也是类似思路。aiohttp.ClientSession 自身维护 connection pool,应让 session 与创建、使用它的异步运行环境保持一致。

但这里还有第二个坑:

假设数据库最多允许:

100 connections

原来:

1 loop × pool_size 60
= 60

改成:

4 loops × pool_size 60
= 240

数据库可能先被你打死。

所以多 loop 后所有资源限制都必须重新理解成:

Process Budget

Shard Budget

例如:

DB max = 100

4 loops

每个 pool ≈ 20
预留 ≈ 20

HTTP concurrency、rate limiter、Redis connection、Kafka producer 等资源都要遵循同样原则。


七、Signal Handling:必须设计一个 Control Plane

多线程多 loop 架构另一个很容易被忽略的问题是 signal。

Python 的 signal handler 由 main Python thread of the main interpreter 执行,而且只有主线程可以安装新的 signal handler;asyncio 文档也明确指出,处理 signals 的 event loop 必须运行在主线程。

因此不要让三个 worker 都尝试:

loop.add_signal_handler(...)

更好的设计是:

Main Thread
Control Plane
SIGTERM/SIGINT

┌────────┼────────┐
↓ ↓ ↓
Loop 1 Loop 2 Loop 3

主线程收到:

SIGTERM

之后调用:

worker.loop.call_soon_threadsafe(
worker.stop_event.set
)

通知每个 owner loop 自己:

停止接受新任务
→ drain existing tasks
→ close HTTP pool
→ close DB pool
→ shutdown async generators
→ stop loop

不要由主线程直接关闭 worker 的异步对象。

仍然遵循那条原则:

谁拥有资源,谁负责 teardown。


八、Debugging Complexity:性能提高了,认知成本也提高了

一个 event loop 出问题时,我们通常只需要理解:

Task A
Task B
Task C

三个 loop 后变成:

Thread
├── Loop
│ ├── Task
│ ├── Future
│ └── Connection

Shared State

Lock

Cross-loop message

Race condition 也可能从:

每次都发生

变成:

生产环境一天一次。

这才是真正可怕的 bug。

因此建议从第一天就让日志包含:

timestamp
thread_name
worker_id
loop_id
task_name
request_id
trace_id

例如:

threading.current_thread().name

可以直接告诉你代码正在哪个 worker 中运行。

同时强烈建议在开发环境开启:

PYTHONASYNCIODEBUG=1

asyncio debug mode 可以检测部分错误线程调用,并记录慢 callback;当前文档中默认会报告执行时间超过 100ms 的 callback,也可以通过 loop.slow_callback_duration 调整。

比如:

loop = asyncio.get_running_loop()

loop.set_debug(True)
loop.slow_callback_duration = 0.02

把 20ms 以上的 callback 暴露出来。

在低延迟系统中,这种指标甚至应该直接纳入监控:

loop lag p50
loop lag p95
loop lag p99

因为 CPU 利用率 60% 并不代表 event loop 健康。

一个 worker 如果执行了 200ms 不带 await 的 Python 代码:

async def handler():
result = expensive_python_work()

其他 CPU 上的 loop 可以继续运行,但 这个 worker 自己的所有 I/O 都会停 200ms。

Free-threaded 解决的是:

跨线程并行

它没有解决:

单 event loop cooperative scheduling

这一区别非常重要。


九、一个更成熟的架构:Shared-Nothing-ish,而不是 Shared-Everything

如果让我真正设计这样的生产系统,我会倾向于:

Main Thread
Control Plane

┌─────────────┼─────────────┐
↓ ↓ ↓
Thread 0 Thread 1 Thread 2
│ │ │
Loop 0 Loop 1 Loop 2
│ │ │
HTTP-0 HTTP-1 HTTP-2
DB-0 DB-1 DB-2
Cache-0 Cache-1 Cache-2
│ │ │
Shard 0 Shard 1 Shard 2

进程里真正共享的东西尽量只剩下:

Immutable Config
Metrics
Atomic-ish counters / protected state
Thread-safe message channels

业务状态尽可能 shard。

这样虽然运行在 shared-memory process 中,编程模型却更接近 actor:

一个状态
只有一个 owner
其他线程向 owner 发消息

这往往比“大家共享一个 dict,然后到处加锁”稳定得多。


十、什么时候不要使用这种模型?

一个非常重要的工程能力,是知道什么时候不应该使用新技术。

我会把判断标准压缩成下面这张表:

场景建议
大量 I/O,CPU 占用很低 单 event loop
单 loop 已经跑满一个 CPU 考虑多 loop
I/O + 大量 Python callback/解析 很值得 benchmark
完全 CPU batch 先考虑普通线程池
需要进程级故障隔离 multiprocessing
大量 mutable global state 谨慎使用
第三方 C 扩展尚未支持 free-threading 暂缓
workload 可以天然 shard 非常适合
团队缺乏并发调试经验 从简单模型开始

尤其要注意最后一个现实问题:

某些第三方 C 扩展如果没有声明支持 free-threading,导入后可能重新启用 GIL。Python 官方文档明确说明了这一行为。

所以启动服务时最好检查运行环境:

import sys
import sysconfig

print(
"free-threaded build:",
sysconfig.get_config_var("Py_GIL_DISABLED"),
)

if hasattr(sys, "_is_gil_enabled"):
print(
"GIL enabled:",
sys._is_gil_enabled(),
)

不要以为你安装了 free-threaded Python,就意味着实际 workload 始终处于无 GIL 状态。


十一、怎样证明它真的值得?

不要通过“理论上三颗 CPU”决定架构。

通过 benchmark。

建议至少同时观察:

throughput
requests / second

latency
p50 / p95 / p99 / max

event-loop lag

CPU utilization
per-core utilization

memory / RSS

DB connections

lock contention

context switches

error rate

测试:

A:
1 process
1 thread
1 event loop

B:
1 process
2 threads
2 event loops

C:
1 process
4 threads
4 event loops

D:
4 processes
1 loop/process

然后比较。

真正值得关注的不是:

平均吞吐提升 20%

而是:

p99 是否下降?

CPU 是否真正均匀使用?

connection 数量是否失控?

锁竞争是否抵消了并行收益?

RSS 相比多进程节省多少?

故障和 shutdown 是否仍然可控?

如果四个 loop 最终都在抢:

global_cache_lock

那么你只是把过去的:

GIL

换成了:

MyOwnGIL™

架构并没有真正进步。


十二、Free-Threaded Python 真正带来的,不只是“Python 可以多线程”

我认为 free-threaded Python 最有价值的意义,并不是简单地把:

ThreadPoolExecutor(max_workers=8)

改成 CPU 并行。

更有意思的是,它重新打开了 Python 服务端架构的设计空间。

以前:

asyncio
≈ 单线程事件驱动

threading
≈ I/O concurrency

multiprocessing
≈ CPU parallelism

现在边界开始变成:

asyncio
+
free-threaded threading
+
shared memory
+
ownership / sharding

我们可以构建一种介于传统 asyncio 与 multiprocessing 之间的模型:

单进程
多核
多 reactor
共享地址空间
局部状态隔离
低成本线程间通信

这对实时系统、网络网关、数据流系统和高性能 Python 服务尤其值得探索。

但新的自由也意味着新的责任。

GIL 曾经无意中替大量 Python 程序隐藏了一部分并发问题。

当它离开以后,我们必须重新认真学习:

ownership
synchronization
memory sharing
resource lifecycle
backpressure
sharding
observability

所以真正的问题并不是:

“Free-threaded Python 能不能运行三个 asyncio loop?”

当然能。

更值得问的是:

你的系统能否被清晰地划分为三个 ownership domain?

如果答案是肯定的:

Thread 1 -> Loop 1 -> Resource Set 1 -> Shard 1
Thread 2 -> Loop 2 -> Resource Set 2 -> Shard 2
Thread 3 -> Loop 3 -> Resource Set 3 -> Shard 3

那么 free-threaded Python + asyncio 很可能成为一种相当有吸引力的新架构。

如果答案是:

三个线程
三个 loop
一个巨大的 mutable global state
一个全局 DB pool
几十把互相嵌套的锁

那么建议尽早回头。

因为真正优秀的并发架构,从来不是:

让更多东西同时运行。

而是:

让每一份状态的归属更加清楚。


实战检查清单

在把 Multi-Loop Free-Threaded 架构投入生产之前,我至少会确认:① 当前解释器确实是 free-threaded build,并检查运行时 GIL 状态;② 所有关键 C 扩展已经支持 free-threading;③ 每个异步连接池都有明确的 loop owner;④ asyncio 对象绝不直接跨线程使用;⑤ mutable shared state 要么 shard,要么明确同步;⑥ asyncio.Lock 与 threading.Lock 的职责严格分离;⑦ 主线程统一负责 SIGINT/SIGTERM;⑧ shutdown 在资源所属 loop 中执行;⑨ 监控包含 thread、worker、loop、task、trace 信息;⑩ 用 1-loop、N-loop 和 multiprocess 做真实 workload benchmark,而不是依赖微基准做架构决策。


参考资料

Python Free-Threading 官方指南:Python support for free threading

PEP 703:Making the Global Interpreter Lock Optional in CPython

PEP 779:Criteria for supported status for free-threaded Python

asyncio 多线程开发指南:Developing with asyncio

asyncio Event Loop 文档:Python asyncio Event Loop

SQLAlchemy 多 Event Loop 指南:SQLAlchemy AsyncIO Documentation

赞(0)
未经允许不得转载:网硕互联帮助中心 » 当 GIL 不再是天花板:Free-Threaded Python × asyncio 的多事件循环架构实践
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!