Python Executor、Thread、Process 与 asyncio.to_thread
从 Python asyncio 实战解释 Executor、OS Thread、Process、GIL 与 asyncio.to_thread 的关系,以及 blocking I/O、native compute、纯 Python CPU-bound 分别该放到哪里执行。
[!info] related notes
- 所属 MOC: python-concurrency-moc
- 前置概念: python-async-programming、python-gil-and-threading
- 并列概念: python-multiprocessing-and-process-pools
- 易混淆概念:
- 关系笔记:
Python Executor、Thread、Process 与 asyncio.to_thread
一句话定义
Executor 不是线程,也不是进程,而是一个“把普通同步函数提交给其他执行资源”的统一抽象;ThreadPoolExecutor 背后管理一组 OS Thread,ProcessPoolExecutor 背后管理一组独立 Process,而 asyncio.to_thread() 是 asyncio 中最方便的线程 offload 接口之一。
[!abstract] 通俗记忆 Event Loop 像前台调度员;Thread 像同一办公室里的不同员工;Process 像不同办公室;Executor 像派工系统。
to_thread()的意思不是“让函数变成 async”,而是“把这个同步函数交给线程池执行,我这个 coroutine 先去等结果”。
先分清六个不同层次
Machine
├─ CPU Core 0 / 1 / 2 / ...
│
└─ OS Scheduler
├─ Process A
│ ├─ Thread 1 → Event Loop → Task A/B/C
│ └─ Thread 2 → blocking/native work
│
└─ Process B
└─ own interpreter / memory / GIL
Process:进程
一个独立的程序运行实例,通常拥有自己的:
- 虚拟内存空间;
- Python interpreter;
- heap/global state;
- 文件描述符;
- 一组线程。
两个进程默认不能直接共享普通 Python 对象。
Thread:线程
进程内部的一条 OS 执行流。线程之间共享同一进程的大部分内存,所以共享方便,但也会带来:
- race condition;
- lock;
- thread safety;
- shared mutable state。
线程由操作系统抢占式调度,可以被分配到不同 CPU Core。
Event Loop
Event Loop 不是线程。它通常运行在某一个 OS Thread 内,用协作式调度驱动许多 coroutine/Task。
Thread 1
└─ Event Loop
├─ Task A
├─ Task B
└─ Task C
Task 在真正的 await suspend point 让出控制权;这和 OS 强制抢占 Thread 是两套不同机制。
Coroutine / Task
它们是 asyncio 的轻量并发单元,不是 OS Thread。典型过程:
Task A runs
→ await I/O
→ suspend
→ Event Loop runs Task B
GIL
在传统 GIL-enabled CPython 中,同一个 interpreter 内通常只有一个线程能执行 Python bytecode。
Thread A ─┐
Thread B ─┼─ compete for one GIL
Thread C ─┘
OS 可以把 A/B 调度到不同 CPU Core,但如果二者都在执行普通 Python bytecode,仍要竞争同一把 GIL。
Executor
Executor 是“任务提交 + worker 管理 + Future/result”的抽象:
function
↓ submit
Executor
├─ ThreadPoolExecutor → worker threads
└─ ProcessPoolExecutor → worker processes
它让业务代码不必自己反复创建/销毁线程或进程。
三种调度一定不要混
| 层次 | 谁调度 | 工作单位 | 典型机制 |
|---|---|---|---|
| asyncio | Event Loop | Task / coroutine | 协作式,在 await 点切换 |
| OS | Kernel scheduler | Thread / Process | 抢占式,时间片与优先级 |
| CPython | GIL | Python bytecode 执行资格 | 不是 scheduler,而是一把 interpreter-level lock |
因此:
Event Loop scheduling
≠ OS thread scheduling
≠ GIL competition
asyncio.to_thread() 到底做了什么
result = await asyncio.to_thread(blocking_fn, arg)
可以理解为:
Event Loop Thread
→ submit blocking_fn to a thread executor
→ current coroutine awaits Future
→ Event Loop continues running other Tasks
Worker Thread
→ blocking_fn(arg)
→ result ready
→ Future wakes original coroutine
它首先解决的是:
不要让同步 blocking function 长时间占住 Event Loop Thread。
它并不自动保证:
- 这个函数自身更快;
- 真正获得多核 Python bytecode parallelism;
- 并发越高吞吐越高;
- coroutine cancel 后 worker thread 会立刻停止。
Native async I/O 与 to_thread 完全不是一回事
Native async I/O
await async_http_client.get(...)
await async_db_cursor.execute(...)
主要依赖:
non-blocking socket
+ OS I/O readiness
+ Event Loop
通常不需要创建一个 worker thread 专门在那里等待网络。
to_thread
await asyncio.to_thread(sync_sdk_call)
是真的把同步函数放到 Worker Thread 执行。
所以:
native async I/O
≠ sync I/O + thread
如果已有成熟 native async API,通常优先 native async;to_thread 更像同步世界与 asyncio 世界之间的兼容桥。
哪些任务最适合 to_thread
1. 只有同步接口的 Blocking I/O
例如:
- 同步 HTTP SDK;
- 同步文件 I/O;
- 某些同步云服务 SDK;
- 旧数据库/设备 API。
如果没有 async client:
result = await asyncio.to_thread(sync_sdk_call)
通常是合理方案。
2. 会进入 Native Code、可能释放 GIL 的同步重计算
例如部分:
- NumPy;
- PyTorch;
- SentenceTransformer;
- Pillow/OpenCV;
- 压缩/加密 native library。
这类函数虽然 Python 调用是同步的,但重计算可能在 C/C++/BLAS/CUDA 里执行,并可能释放 GIL。
to_thread() 的首要价值仍然是保护 Event Loop;实际 throughput 是否提高必须 benchmark。
3. 偶尔会明显卡住的同步第三方函数
如果一个函数大多数时候很快,但可能触发文件读取、token refresh、metadata request 等阻塞,也可以通过线程隔离尾延迟。
哪些任务不适合把 to_thread 当终局方案
已经有原生 async API
能 await 真异步 HTTP/DB 时,优先 native async,不要无意义占用 worker thread。
很轻的小函数
text.strip().lower()
这种工作比提交线程/Future/context switch 还轻,直接执行即可。
极重的纯 Python CPU-bound
例如:
def heavy():
total = 0
for i in range(2_000_000_000):
total += i * i
to_thread(heavy) 可以保护 Event Loop,但多个线程仍会竞争 GIL,所以通常不是真正的 CPU 加速方案。
更适合:
ProcessPoolExecutor;- 独立 worker;
- native implementation。
GIL 为什么会释放
传统 CPython 中,线程不会永久霸占 GIL。常见释放/切换来源可以记三类。
1. 纯 Python 长时间执行:周期性切换机会
一个线程长时间运行 Python bytecode 时,解释器会周期性给其他线程竞争 GIL 的机会。
因此两个 CPU-heavy Python Thread 的效果更像:
T1: █████ █████ █████
T2: █████ █████
而不是两份 Python bytecode 真正同时执行。
2. Blocking I/O
线程进入文件、socket 等阻塞等待时,没有必要继续持有 GIL;CPython 的许多 I/O 路径会在等待时释放 GIL,让其他线程运行。
3. Native Extension 主动释放
C/C++ extension 可以在不需要操作 Python 对象的耗时区间释放 GIL。
这解释了为什么:
Python Thread 受 GIL 限制
不等于:
NumPy/PyTorch 的底层计算永远只能单核
为什么 to_thread() 不保证任务本身更快
假设一个纯 Python CPU 任务需要 10 秒 CPU 时间。
直接在 Event Loop:
Event Loop Thread
└─ heavy() ██████████
其他 Task 长时间饿死
移到 Thread:
Thread A: Event Loop
Thread B: heavy()
↕ compete for GIL
Event Loop 至少能周期性重新获得执行机会,所以 responsiveness 改善;但计算量没有消失,而且线程调度/GIL competition 还有额外开销。
因此:
Event Loop responsiveness ↑
!=
CPU task latency ↓
为什么 ProcessPool 能做纯 Python 多核并行
不同 Process 拥有独立 interpreter 和独立 GIL:
Process A → GIL A → Core 0
Process B → GIL B → Core 1
Process C → GIL C → Core 2
于是可以真正同时执行多份 Python bytecode。
代价是:
- 进程启动更重;
- memory 独立;
- 参数/结果通常需要 pickle/IPC;
- 大模型可能被复制多份。
所以 ProcessPool 不是“更高级的 ThreadPool”,而是另一套资源模型。
和 JavaScript 的对应关系
这个类比非常适合建立直觉:
| JavaScript | Python |
|---|---|
await fetch() | native async HTTP/DB |
| Main Thread long task | Event Loop Thread blocking section |
| 手动 chunk + yield | coroutine 分段 + await/yield |
| Web Worker | worker Thread/Process 的 offload 思想 |
JS 手动时间片切分仍然是在一个线程里 cooperative scheduling;Python to_thread() 则是把整个同步黑盒移到另一个真实 OS Thread。
BodySense 中的直接映射
Remote Embedding
network I/O
→ native async client
Sync psycopg
可以用 thread 临时隔离,但 production north-star 是:
AsyncConnectionPool + async cursor
因为数据库天然属于 I/O-bound resource。
Local SentenceTransformer
model.encode()
→ sync native/CPU/GPU boundary
→ bounded to_thread / executor
还必须额外考虑:
- Semaphore / bounded concurrency;
- model thread safety;
- PyTorch/BLAS internal threads;
- GPU memory;
- cancellation reality。
一个实用决策树
这个同步操作会明显耗时吗?
│
├─ 否 → 直接执行
│
└─ 是
│
├─ 大部分时间在等 I/O?
│ ├─ 有 native async API → await native async
│ └─ 只有 sync API → to_thread / ThreadPool
│
└─ 大部分时间在计算?
├─ native library / 可能释放 GIL → bounded thread + benchmark
└─ heavy pure Python → ProcessPool / worker
然后永远再问一句:
这个资源的并发是否有明确上限?
因为 offload 解决“在哪里执行”,并不自动解决“允许同时执行多少”。
常见误区
“Python Thread 只能运行在一个 CPU Core”
错。OS Thread 可以被调度到不同 Core;真正限制传统 CPython 多线程 Python bytecode parallelism 的是 GIL。
“Event Loop 会给 Thread 做时间片调度”
错。Event Loop 调度 coroutine;Thread 由 OS 调度。
“async I/O 就是偷偷开线程”
错。成熟 native async network/database driver 通常通过 non-blocking I/O + Event Loop 工作。
“to_thread = CPU 加速”
错。它首先是 blocking-work isolation;是否加速取决于 GIL、native code、CPU/GPU 与 workload。
“coroutine cancel = worker thread 被杀掉”
通常不成立。外层可以停止等待,但已经运行的同步函数常会继续执行到返回。
自测题
- Process、Thread、Coroutine 各自由谁调度?
- Executor 为什么不是 Thread 的同义词?
- GIL 限制的是 CPU Core,还是同一 interpreter 中 Python bytecode 的并行执行?
- 为什么 native async DB 通常不需要一个线程专门等数据库?
to_thread()对纯 Python CPU-heavy 任务最大的价值是什么?- 为什么 PyTorch/SentenceTransformer 不能只凭“Python 调用”就断言一定不能并行?
- 什么情况下应该优先 ProcessPool?
- 为什么 bounded concurrency 是 executor 之外的第二个问题?
最终心智
Coroutine
→ Event Loop cooperative scheduling
Thread
→ OS preemptive scheduling
→ traditional CPython Python bytecode still constrained by GIL
Process
→ OS scheduling
→ own interpreter / own GIL / own memory
Executor
→ task-submission abstraction over threads or processes
asyncio.to_thread
→ move synchronous blocking work away from Event Loop Thread
真正做工程选型时,不要问“async、thread、process 哪个更高级”,而要先判断:任务是在等 I/O,还是在消耗 CPU;它能否原生 await;底层是否释放 GIL;需要共享多少内存;以及这个资源允许多少并发。