- 科学计算
- 数据分析
【免费下载链接】numpy
The fundamental package for scientific computing with Python.
多线程环境下的正确使用是 NumPy 开发者绕不开的议题。本文基于官方文档 doc/source/reference/thread_safety.rst 展开,系统梳理 NumPy 在标准库threading模块下的线程安全模型:哪些操作会释放 GIL 从而获得并行收益、共享数组时存在哪些数据竞争风险、基于contextvars的上下文本地状态如何在多线程 / asyncio 场景下隔离配置,以及 NumPy 2.1 起对 free-threaded Python(无 GIL 运行时)的实验性支持。读完本文,你将掌握在 Python 线程中安全高效使用 NumPy 的边界条件与最佳实践,并能理解其底层实现机制。
多线程并行:NumPy 为什么能突破 Python 的 GIL 限制
CPython 的全局解释器锁(GIL)使得同一进程内多个线程无法真正并行执行 Python 字节码。但 NumPy 的核心数值运算都在 C 层实现,大量低层操作会主动释放 GIL,从而让多线程并行成为可能。这是 NumPy 与大多数纯 Python 代码在并发模型上的本质区别。
文档明确指出(thread_safety.rst):
Many NumPy operations release the GIL, so unlike many situations in Python, it is possible to improve parallel performance by exploiting multithreaded parallelism in Python.
在源码层面,"释放 GIL" 对应着一组贯穿整个_core的 C 宏。以数组赋值路径为例,array_assign_array.c 和 array_assign_scalar.c 中普遍使用NPY_BEGIN_THREADS_DEF、NPY_BEGIN_THREADS_THRESHOLDED(nitems)与NPY_END_THREADS的组合:当待处理元素个数nitems超过阈值时才真正释放 GIL,兼顾了小数组的低开销与大数组的高并行度。类似模式也出现在归约、ufunc 计算等大量数值路径中(如 calculation.c)。
最容易获得性能提升的线程模型
官方推荐的并行策略非常简单:让每个工作线程独占自己的数组或数组集合,线程之间不共享数据。由于 NumPy 在低层代码中释放 GIL,那些大部分时间停留在 C 层数值计算中的线程可以真正并行运行,从而获得接近硬件核心数的加速比。
import threading import numpy as np results = [None] * 4 def worker(i): # 每个线程操作自己私有的数组,无共享数据 x = np.linspace(i, i + 1, 1_000_000) results[i] = np.sin(x).sum() threads = [threading.Thread(target=worker, args=(i,)) for i in range(4)] for t in threads: t.start() for t in threads: t.join() print(results)这种"无共享数据"模型能获得收益的前提是线程大部分时间停留在释放 GIL 的低层代码中;若线程大量时间在 Python 层执行(如小数组上的逐元素循环、频繁的 Python 回调),并行收益会大打折扣。
线程间共享数组:可以,但必须极度谨慎
NumPy 数组对象本身可以跨线程共享,但对共享数组的并发变更极易引发线程安全问题。官方文档给出两个层面的风险:
- 结果不一致且不可复现:多个线程同时读写同一数组,最坏情况下产生的是竞态(racey)、无法复现的结果;
- 解释器崩溃:例如一个线程正在对数组做 ufunc 运算时,另一个线程对同一数组执行
resize等改变内存布局的操作,可能直接导致 Python 解释器崩溃。
因此文档给出的务实建议是:共享数组时优先只读访问;若必须并发变更,则自行引入锁(locking)保护。NumPy 目前没有计划为numpy.ndarray内置锁机制(文档注明"in the future, we may add locking",即未来可能加入,但当下需要开发者自己负责同步)。
不释放 GIL 的操作:何时应该改用 multiprocessing
并非所有 NumPy 操作都会释放 GIL。文档特别强调:对dtype=np.object_的数组执行的操作不会释放 GIL。这是因为 object 数组的元素是任意 Python 对象,访问它们必须回到 Python 对象层,无法在脱离 GIL 的情况下保证安全。
这类操作使用threading模块看不到任何性能收益,此时更适合改用标准库的multiprocessing模块(进程级并行,绕开 GIL)。判断一个运算是否会释放 GIL,可以从操作是否涉及 Python 对象回调入手:
- 纯数值类型(int/float/complex 等)的 ufunc、归约、赋值、广播等 → 释放 GIL;
- object 数组上的操作、涉及
__array_ufunc__/__array_function__协议回调的操作 → 通常不释放 GIL。
上下文本地状态(Context Local State):多线程下的配置隔离
NumPy 将用户可调整的配置项存储在 Python 标准库的context variables(contextvars模块)中。这意味着配置状态具有上下文本地性:多线程程序中的每个线程、asyncio 程序中的每个任务,都拥有彼此独立的配置快照。官方文档列举了三类上下文本地状态:
| 状态类别 | 对应配置 | 相关文档 |
|---|---|---|
| 浮点错误处理与 ufunc 缓冲 | numpy.errstate、numpy.setbufsize | routines.err、use-of-internal-buffers |
| 打印与文本格式化 | numpy.printoptions及全部文本格式化选项 | text_formatting_options |
| 内存分配器 | 数据分配策略(NEP 49) | data_memory、NEP 49 |
源码中的 contextvars 实现
在源码中可以看到这些状态的真实载体:
打印选项:独立定义在 numpy/_core/printoptions.py,模块注释解释了为何要单独放置——多数组 C 模块在导入初始化时需要引用它,放在
arrayprint模块中会引入循环依赖。其默认值字典给出了各参数的出厂设置,例如precision=8、threshold=1000、edgeitems=3、linewidth=75等,用户通过np.printoptions修改的就是这个ContextVar。浮点错误处理与缓冲:
seterr、setbufsize均通过_extobj_contextvar.set(extobj)写入上下文,见 numpy/_core/_ufunc_config.py 与 setbufsize 实现。注意 NumPy 2.0 起,setbufsize的作用域已与errstate上下文绑定——退出with np.errstate():时缓冲大小也会被恢复。C 层面对应的 contextvar 由 extobj.c 的PyContextVar_New("numpy._legacy_resolver_promoting", ...)/npy_extobj_contextvar创建。内存分配器:C API 的
PyDataMem_SetHandler/PyDataMem_GetHandler使用PyContextVar_Get/PyContextVar_Set读写当前的分配策略处理器,见 numpy/_core/src/multiarray/alloc.c。这使 NEP 49 提出的按上下文切换内存分配策略得以在无锁前提下线程安全地实现。
用 with 语句设置上下文状态
上下文变量的状态通过with语句语法设置。官方文档以打印精度为例:
>>> with np.printoptions(precision=2): ... np.array([2.0]) / 3 array([0.67]) >>> np.array([2.0]) / 3 array([0.66666667])with块内的配置只对当前上下文生效,退出后自动恢复。这一性质适用于所有上下文本地状态,而不只是printoptions——例如:
>>> import numpy as np >>> with np.errstate(divide='raise', invalid='ignore'): ... print(np.geterr()) {'divide': 'raise', 'over': 'warn', 'under': 'ignore', 'invalid': 'ignore'} >>> np.geterr() # 退出后恢复 {'divide': 'warn', 'over': 'warn', 'under': 'ignore', 'invalid': 'warn'}与 threading 模块的交互:Python 3.14 前后的行为差异
上下文变量在新创建的线程中如何初始化,是线程安全的关键细节:
- Python 3.14 之前:新线程总是从全新的、初始化过的上下文状态开始,主线程中通过
with np.printoptions(...)设置的配置不会传导到子线程。官方示例:
$ python3.12 >>> import numpy, threading >>> def print_printoptions(): ... print(numpy.get_printoptions()['precision']) >>> with numpy.printoptions(precision=2): ... threading.Thread(target=print_printoptions).start() 8即主线程精度为 2,但子线程打印出的仍是默认精度 8。
- Python 3.14 起:CPython 新增了
thread_inherit_context启动配置项(也可通过环境变量PYTHON_THREAD_INHERIT_CONTEXT启用)。开启后,新线程继承创建它的上下文的配置,代码行为符合直觉——子线程在with块内启动时会看到精度 2:
$ python3.14 -Xthread_inherit_context=1 >>> import numpy, threading >>> def print_printoptions(): ... print(numpy.get_printoptions()['precision']) >>> with numpy.printoptions(precision=2): ... threading.Thread(target=print_printoptions).start() 2因此,在 Python 3.14 之前(当前大多数发行版默认),如果需要在子线程中使用特定的 NumPy 配置,最稳妥的做法是在子线程函数内部显式地用with语句设置配置,而不是依赖主线程的上下文传导。
Free-threaded Python(无 GIL 运行时)的实验性支持
自NumPy 2.1起,配合CPython 3.13,NumPy 提供了对 free-threaded Python(禁用 GIL 的运行时)的实验性支持。官方说明文档标注versionadded:: 2.1。
free-threaded 运行时没有 GIL 来串行化对 Python 对象的访问,因此:
- 线程共享状态被并发变更的机会更多,更易触发线程安全问题;
dtype=np.object_的数组不再受 GIL 保护——在常规 Python 中 object 数组由 GIL 隐式保护,而在 free-threaded 环境下,多线程读写 object 数组中的 Python 对象会产生常规环境不存在的数据竞争(data races)。
此外,free-threaded Python 在 3.14 版本起默认开启thread_inherit_context,其上下文行为与上节描述一致(见本文"上下文本地状态"一节)。
从源码看,NumPy 为无 GIL 环境做了针对性适配:例如 numpy/_core/src/common/npy_pycompat.h 中,在Py_GIL_DISABLED编译条件下提供了基于 CPython 临界区(critical section)的NPY_BEGIN_CRITICAL_SECTION_SEQUENCE_FAST宏,用于安全调用PySequence_Fast等 API;在非 free-threaded 构建下这些宏退化为空操作,保证两套运行时使用同一份代码路径。
需要说明的是,该支持目前仍处于实验性阶段:安装与使用 free-threaded Python 的具体方法、以及第三方库如何适配支持,请参考官方相关文档与社区的 free-threading 指南。
C-API 线程支持:C 扩展开发者的参考
对于编写 C 扩展并需要与 NumPy 交互的开发者,NumPy 的 C-API 数组文档(doc/source/reference/c-api/array.rst)中提供了大量与多线程相关的细节,包括:
- 何时可以安全释放 GIL(
NPY_BEGIN_THREADS系列宏的使用规范); - 数组内存布局在多线程读写下的约束;
- 与 Python 对象交互时必须重新获取 GIL 的场景。
编写 C 扩展时,应遵循与 Python 层相同的原则:对共享 ndarray 的并发写操作需要自行同步,纯数值循环可安全地使用NPY_BEGIN_THREADS释放 GIL 换取并行度。
实战示例:多线程随机数生成(官方推荐的正确姿势)
官方文档的 See Also 一节指向了一个可直接落地的多线程实践:doc/source/reference/random/multithreading.rst。它演示了如何安全地在多线程中填充数组,核心思想是每个线程持有独立的Generator(由SeedSequence派生),各自写入预分配数组的不同切片,线程间不共享可变状态:
from numpy.random import default_rng, SeedSequence import multiprocessing import concurrent.futures import numpy as np class MultithreadedRNG: def __init__(self, n, seed=None, threads=None): if threads is None: threads = multiprocessing.cpu_count() self.threads = threads seq = SeedSequence(seed) self._random_generators = [default_rng(s) for s in seq.spawn(threads)] self.n = n self.executor = concurrent.futures.ThreadPoolExecutor(threads) self.values = np.empty(n) self.step = np.ceil(n / threads).astype(np.int_) def fill(self): def _fill(random_state, out, first, last): random_state.standard_normal(out=out[first:last]) futures = {} for i in range(self.threads): args = (_fill, self._random_generators[i], self.values, i * self.step, (i + 1) * self.step) futures[self.executor.submit(*args)] = i concurrent.futures.wait(futures) def __del__(self): self.executor.shutdown(False)使用方式:
In [2]: mrng = MultithreadedRNG(10000000, seed=12345) ...: print(mrng.values[-1]) Out[2]: 0.0 In [3]: mrng.fill() ...: print(mrng.values[-1]) Out[3]: 2.4545724517479104该模式的关键点在于:
- 预分配数组:
np.empty(n)只创建一次,out=参数让各核心分布函数直接填充既有连续、可写、对齐的数组,避免每次调用重新分配的开销; - 切片隔离:每个线程只写
values[first:last]自己的区间,互不重叠,从根本上规避数据竞争; - 可复现性:同一
seed、相同线程数下结果完全可复现(线程数变化会改变切片边界,输出随之变化); - 长生命周期线程:
ThreadPoolExecutor中的线程长期复用,避免频繁建线程的额外开销。
这也从实践角度印证了本文核心原则:线程私有状态 + 只读/分区访问共享数组,是 NumPy 多线程编程最安全高效的组合。
小结:NumPy 多线程编程的准则清单
- 利用 GIL 释放特性:让线程的大部分时间停留在 C 层数值计算中,即可获得真实并行收益;
- 优先采用"每线程独立数组"模型;确需共享数组时只读访问,或自行加锁;
- 严禁在另一线程读取/计算数组时对其执行
resize等改变内存布局的操作; dtype=np.object_的操作不释放 GIL,多线程场景下应考虑multiprocessing;- 依赖上下文本地状态(
errstate、printoptions、内存分配策略)时,在 Python 3.14 之前需在子线程内部自行设置配置; - 在 free-threaded Python 下,NumPy 2.1+ 提供实验性支持,但 object 数组存在新的数据竞争风险,务必额外加锁;
- C 扩展开发者参考 C-API 数组文档 中关于 GIL 释放与多线程的规范。
- 科学计算
- 数据分析
【免费下载链接】numpy
The fundamental package for scientific computing with Python.
相关推荐
Perspective Python 多线程编程指南:线程安全 API、GIL 释放与并发性能调优
Perspective Python 多线程编程指南:线程安全 API、GIL 释放与并发性能调优 Perspective 是一个面向大规模与流式数据集的交互式
数据可视化数据分析流处理WebAssembly图表库macOS 上的 Python(CPython):官方安装器、free-threaded 构建与开发实践全指南
macOS 上的 Python(CPython):官方安装器、free threaded 构建与开发实践全指南 本文基于 CPython 仓库中的官方文档 Do
编程语言语言运行时解释器标准库CPython C API 线程编程参考:GIL、线程状态(PyThreadState)与线程附着/分离全指南
CPython C API 线程编程参考:GIL、线程状态(PyThreadState)与线程附着/分离全指南 本文基于 CPython 官方文档 线程状态与全
编程语言语言运行时解释器标准库
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考