Python MPI
全局解释器锁(GIL,Global Interpreter Lock)¶
Python代码的执行由Python虚拟机(解释器)来控制。
对Python虚拟机的访问由全局解释器锁(GIL)来控制,正是这个锁能保证同时只有一个线程在运行。所以就会出现尽管你设置了多线程的任务,但是只能跑一个的情况。
但是I/O密集的程序(爬虫)相对好一点,因为I/O操作会调用内建的操作系统C代码,所以这时会释放GIL锁,达到部分多线程的效果。
通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。
多线程(鸡肋)¶
多进程(正常实现)¶
通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。
子进程调用实例¶
def TIMEOUT_COMMAND(command, timeout=10):
"""call shell-command and either return its output or kill it
if it doesn't normally exit within timeout seconds and return None"""
import subprocess, datetime, os, time, signal
cmd = command.split(" ")
start = datetime.datetime.now()
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,encoding="utf-8",preexec_fn=os.setsid) #让 Popen 成立自己的进程组
# https://www.cnblogs.com/gracefulb/p/6893166.html
# 因此利用这个特性,就可以通过 preexec_fn 参数让 Popen 成立自己的进程组, 然后再向进程组发送 SIGTERM 或 SIGKILL,中止 subprocess.Popen 所启动进程的子子孙孙。当然,前提是这些子子孙孙中没有进程再调用 setsid 分裂自立门户。
ic("BHive-noUseOSACA-before",process.pid,process.poll())
while process.poll() != 208: # poll()(好像BHive208是正常结束)返回0 正常结束, 1 sleep, 2 子进程不存在,-15 kill,None 在运行
ic("BHive-noUseOSACA-During",process.pid,process.poll())
time.sleep(0.2)
now = datetime.datetime.now()
if (now - start).seconds> timeout:
# BHive有子进程,需要杀死进程组。但是需要新生成进程组,不然会把自己kill掉
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
# os.killpg(process.pid, signal.SIGTERM) SIGTERM不一定会kill,可能会被忽略,要看代码实现
# https://blog.csdn.net/zhupenghui176/article/details/109097737
# os.waitpid(-1, os.WNOHANG)
(killPid,killSig) = os.waitpid(process.pid, 0)
if killPid != process.pid or killSig!=9:
errorPrint("TIMEOUT_COMMAND kill failed! killPid %d process.pid %d killSig %d" % (killPid, process.pid, killSig))
ic("Killed",process.pid,process.poll())
return None
ic("BHive-noUseOSACA-Finished",process.pid,process.poll())
return process.stdout.readlines()
使用Queue或者Pipe通讯¶
调用C语言的链接库¶
把一些计算密集型任务用C语言编写,然后把.so链接库内容加载到Python中,因为执行C代码,GIL锁会释放,这样一来,就可以做到每个核都跑一个线程的目的!
C++代码不会受到GIL锁的影响,但是C++里调用python api,也就是执行python字节码需要上GIL锁
类似PTA的代码中
PyObject* THNPModule_attachOutOfMemoryObserver(PyObject* _unused, PyObject* observer)
{
HANDLE_TH_ERRORS
Py_XINCREF(observer);
auto obs = [observer](int64_t device, int64_t alloc, int64_t device_allocated, int64_t device_free) {
py::gil_scoped_acquire g;
PyObject* result = PyObject_CallFunction(observer, "LLLL", device, alloc, device_allocated, device_free);
if (!result) {
throw py::error_already_set();
}
Py_XDECREF(result);
};
torch_npu::utils::npu_lazy_init();
c10_npu::NPUCachingAllocator::attachOutOfMemoryObserver(std::move(obs));
Py_RETURN_NONE;
END_HANDLE_TH_ERRORS
}
进程池¶
pybind11 / GIL in C++¶
Pybind11 中的 GIL 锁¶
Pybind11 是一个 C++ 库,用于方便地创建 Python 的 C++ 扩展模块。通过 Pybind11,你可以将 C++ 的函数和类暴露给 Python,使得 Python 能直接调用这些 C++ 的功能。它的设计目标是简化 Python 和现代 C++(C++11 及更高版本)之间的绑定工作,同时提供一个高效、直观的接口。
Pybind11 的特点
- 轻量级:不需要复杂的配置,头文件即用(header-only)。
- 现代化:支持 C++11/14/17 的特性,比如智能指针、模板和 lambda。
- 易用性:极简的语法,几乎不需要写样板代码。
- 高性能:生成的模块接近直接调用 C++ 的性能。
如何使用 Pybind11
- 安装 Pybind11
如果尚未安装,可以通过 pip 直接安装:
或者通过包管理器安装(例如 Ubuntu 上):
- 创建一个简单的绑定
假设你有一个 C++ 文件 example.cpp
,它提供了一个简单的 C++ 函数:
#include <pybind11/pybind11.h>
// 命名空间简化
namespace py = pybind11;
// 一个简单的 C++ 函数
int add(int a, int b) {
return a + b;
}
// 定义模块
PYBIND11_MODULE(example, m) {
m.doc() = "Pybind11 example module"; // 模块文档
m.def("add", &add, "A function that adds two numbers");
}
- 编译绑定代码
使用 Pybind11 提供的 pybind11-config
脚本获取编译选项:
c++ -O3 -Wall -shared -std=c++11 -fPIC `python3 -m pybind11 --includes` example.cpp -o example`python3-config --extension-suffix`
这里生成了 example.cpython-<version>-<platform>.so
文件,这是一个可以在 Python 中直接导入的模块。
- 在 Python 中使用
运行 Python,导入刚刚生成的模块并调用函数:
自动获取和释放 GIL¶
默认情况下,Pybind11 在调用 Python API 时会自动管理 GIL。如果你调用 Python 代码或与 Python 对象交互,Pybind11 会自动获取 GIL 并在调用结束后释放它。
#include <pybind11/pybind11.h>
namespace py = pybind11;
void example_function() {
py::print("Hello from C++!");
// GIL 被自动获取
}
PYBIND11_MODULE(example, m) {
m.def("example_function", &example_function);
}
在这个简单的示例中,py::print
调用会自动获取 GIL,并在返回时释放它。这保证了多线程环境下的线程安全。
手动获取和释放 GIL¶
如果你的 C++ 代码中有与 Python 无关的操作,或者你想在执行耗时的 C++ 计算时释放 GIL,从而让其他 Python 线程可以执行,你可以手动控制 GIL。
Pybind11 提供了 py::gil_scoped_acquire
和 py::gil_scoped_release
来显式地获取和释放 GIL。
- 获取 GIL:
如果你需要调用 Python 代码(例如操作 Python 对象),你必须先获取 GIL。
- 释放 GIL:
如果你在 C++ 代码中执行计算密集型操作,可以释放 GIL,从而允许其他 Python 线程执行。在 py::gil_scoped_release
的作用域内,GIL 会被释放,允许其他线程在这个时间段执行 Python 代码。C++ 代码执行完后,GIL 会重新自动获取。
void long_running_task() {
{
py::gil_scoped_release release; // 释放 GIL
// 执行耗时的计算,避免阻塞其他 Python 线程
for (int i = 0; i < 100000000; ++i) {
// 长时间的计算任务
}
}
// 在此作用域内 GIL 被释放, 在作用域结束时自动恢复 GIL。
}
快捷语法:定义函数时释放 GIL¶
py::call_guard<py::gil_scoped_release>()
确保了在调用 broadcast_coalesced
时,C++ 层的计算不会占用 GIL,从而允许其他 Python 线程继续执行。
m.def(
"_broadcast_coalesced",
[](std::vector<at::Tensor>& tensors,
std::vector<int64_t> devices,
size_t buffer_size) {
return torch_npu::data_parallel::broadcast_coalesced(tensors, devices, buffer_size);
},
py::arg("tensors"),
py::arg("devices"),
py::arg("buffer_size"),
py::call_guard<py::gil_scoped_release>())
释放 GIL后还是调用python代码¶
如果你在 C++ 代码中使用了 py::call_guard<py::gil_scoped_release>()
来释放 GIL (Global Interpreter Lock),并且在释放 GIL 后又调用了 Python 代码(比如 py::print("GIL is re-acquired.")
),那么 会导致未定义行为,并且在大多数情况下会导致 崩溃(通常是 segmentation fault 或其他类似错误)。原因如下:
-
GIL 被释放:当你使用
py::call_guard<py::gil_scoped_release>()
时,GIL 会被释放,允许其他 Python 线程执行。这意味着 Python 解释器的锁被释放,其他线程可以访问 Python 对象和执行 Python 代码。 -
GIL 被释放后调用 Python 代码:如果在释放 GIL 后,你再尝试调用 Python 代码(例如
py::print
),就会出现问题,因为没有 GIL 的保护,Python 解释器的线程安全机制会被破坏。Python 的 C API 并不是线程安全的,必须在有 GIL 的情况下才能调用 Python 代码,否则可能会出现以下几种问题: - Segmentation Fault:由于没有 GIL,Python 解释器内部的内存管理可能会出错,导致访问无效的内存位置,进而触发 segmentation fault。
-
未定义行为:因为没有 GIL 保护,Python 内部的对象和数据结构可能在多线程环境下发生竞争条件,导致不可预期的错误。
-
正确的做法:如果你需要在释放 GIL 后执行 Python 代码,你应该重新获取 GIL。你可以通过
py::gil_scoped_acquire
来手动获取 GIL,然后安全地执行 Python 代码。这样做能够确保 Python 代码在执行时能安全地访问 Python 的内部数据结构。
示例
以下是一个错误的示例(会导致崩溃或未定义行为):
void bad_example() {
{
py::gil_scoped_release release; // 释放 GIL
// 执行一些计算密集型的 C++ 代码
py::print("This will crash!"); // 错误:在释放 GIL 后调用 Python 代码
} // GIL 会被恢复,但这个时候 GIL 已经被释放过了
}
正确的做法: 如果你必须在释放 GIL 后调用 Python 代码,你应该重新获取 GIL。例如:
常见报错信息
这时gdb的信息会显示python的cpython栈。
PTA在DEBUG模式下,GIL锁已释放,但是对象的引用计数还在增加,说明在没有正确持有 GIL 的情况下,尝试操作 Python 对象,导致引用计数操作失败。可能会触发segfault。
但是通过编译选项-DNDEBUG
掩盖报错信息, 但是问题还是实际存在的,导致难以定位。
实例¶
python的子程序实现有问题,运行中,会有bhive-reg遗留下来(多达20个,需要按照下面手动kill,这也是核数建议为总核数的1/3的原因
- check process create time
- kill all process by name
- 以为的原因
subProcess.pool 返回程序状态的时候,除了运行和结束状态,还有休眠等其他状态。也就是程序在发射之后并不是直接进入运行状态的。判断程序是否超时不能通过判断是否运行,因为一开始while循环进不去
而应该是判断是否正常结束(208是BHive结束返回值,不同程序不同)
- 继续分析
在debug输出里没有这些pid
check了,输出的个数是符合的。
不懂了,我都没调用,这僵尸进程哪里来的?除非是BHive产生的。
- 实际原因
调用的Bhive会产生子进程,原本的python实现不能杀死子进程的子进程。需要改用杀死进程组的实现
杀死进程组
可能设定是timeout是20秒,但是htop程序运行了2分钟也没有kill。这是正常的,因为主程序挤占资源导致挂起了,导致无法及时判断和kill
参考文献¶
无