← [ BACK_TO_NOTES ]

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 分别该放到哪里执行。

#concept #growing #dev #python #python

[!info] related notes

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

它让业务代码不必自己反复创建/销毁线程或进程。

三种调度一定不要混

层次谁调度工作单位典型机制
asyncioEvent LoopTask / coroutine协作式,在 await 点切换
OSKernel schedulerThread / Process抢占式,时间片与优先级
CPythonGILPython 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 的对应关系

这个类比非常适合建立直觉:

JavaScriptPython
await fetch()native async HTTP/DB
Main Thread long taskEvent Loop Thread blocking section
手动 chunk + yieldcoroutine 分段 + await/yield
Web Workerworker 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 被杀掉”

通常不成立。外层可以停止等待,但已经运行的同步函数常会继续执行到返回。

自测题

  1. Process、Thread、Coroutine 各自由谁调度?
  2. Executor 为什么不是 Thread 的同义词?
  3. GIL 限制的是 CPU Core,还是同一 interpreter 中 Python bytecode 的并行执行?
  4. 为什么 native async DB 通常不需要一个线程专门等数据库?
  5. to_thread() 对纯 Python CPU-heavy 任务最大的价值是什么?
  6. 为什么 PyTorch/SentenceTransformer 不能只凭“Python 调用”就断言一定不能并行?
  7. 什么情况下应该优先 ProcessPool?
  8. 为什么 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;需要共享多少内存;以及这个资源允许多少并发。

创建于 2026/8/23 更新于 2026/8/23
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本