当 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
网硕互联帮助中心


评论前必须登录!
注册