讲解 python 多线程的文章有很多,但很多都解释的不清不楚,甚至有的文章还抛出 python 是伪多线程的观点。那 python 到底有没有多线程的能力呢?
python 中存在着全局解释锁(GIL),这也是很多文章重复了很多遍。GIL 限制了 python 同一时间只能有一条线程在跑。如果是这样,那些计算密集型的项目,比如 Opencv, TensorFlow 又是怎么利用 python 做并行计算呢?这里我抛出了两个问题:
1、python 能不能使用多线程?
2、python 能不能使用多核并发?
对于问题1,我的回答是能。 python 支持多线程,它的 threading 模块底层就是用 pthread 实现的(不信可以去看源码),所以 threading 起的线程就是实打实的线程。虽然可以通过 threading 起多个线程,但是由于 GIL 的存在,同一时间只能有一条线程在执行,其他线程都在阻塞。这里就有个前提条件,这些线程必须是 python 线程,如果不是则不受 GIL 的控制。那什么是 python 线程呢?简单来说被 GIL 锁住的线程,就是 python 线程,当某一线程释放了 GIL,那么它就不再是 python 线程,想怎么执行就怎么执行。
那 python 线程和非 python 线程有什么区别呢?区别在于 python 线程可以调用 python 的 API,比如 Py_INCREF, PyArg_ParseTupleAndKeywords 等开头为 Py 的接口函数。而非 python 线程不能调用 python 的 API,但好处是不受 GIL 的影响。
python 和非 python线程之间是可以自由切换的,python 线程调用 PyEval_SaveThread 就可以切换为非 python 线程,相反地调用 PyEval_RestoreThread 就切换回 python 线程。在浏览 python 关于 GIL 的 API 时,你可能还会注意到另外一对函数 PyGILState_Ensure 和 PyGILState_Release,这两个函数又是干嘛的呢?简单来讲,当线程不知道自己是 python 还是非 python 线程,但又要调用 python 的 API 时,就需要 ensure 一下,获取 GIL,确保自己是 python 线程,调用完后再用 PyGILState_Release 释放掉。
对于问题2,python 是能使用多核并发执行,由以一个问题的答案可知,把需要并行执行的任务用非 python 线程执行就可以了。
下面我们用一段代码来验证以上观点。
// test_extend.cc
#include
#include "Python.h"
using namespace std;
class PyAllowThreads
{
public:
PyAllowThreads() : _state(PyEval_SaveThread()) {}
~PyAllowThreads()
{
PyEval_RestoreThread(_state);
}
private:
PyThreadState* _state;
};
vector< vector > matrix_multiply(vector< vector > A, vector< vector > B) {
int row_a = A.size();
int col_a = A[0].size();
int row_b = B.size();
int col_b = B[0].size();
vector< vector > res;
if (col_a != row_b) {
return res;
}
res.resize(row_a);
for (int i = 0; i < row_a; i++) {
res[i].resize(col_b);
}
for (int i = 0; i < row_a; i++) {
for (int j = 0; j < col_b; j++) {
for (int k = 0; k < col_a; k++) {
res[i][j] += A[i][k] * B[k][j];
}
}
}
return res;
}
static PyObject * long_running_test(PyObject *self, PyObject *args, PyObject *kw) {
int dim;
int M, K, N;
M = K = N = 1;
const char* keywords[] = {
"dimension", NULL};
if( PyArg_ParseTupleAndKeywords(args, kw, "i:Extend.long_running_test",
(char**)keywords, &dim)) {
// 释放GIL,切换到非 python 线程
// 可以这行代码屏蔽,再观察 cpu 的占有率
PyAllowThreads allowThreads;
M = K = N = dim;
printf("running (%d, %d) * (%d, %d)n", M, K, K, N);
// 初始化 A, B 矩阵
vector< vector > A;
vector< vector > B;
A.resize(M);
for (int i = 0; i < M; i++) {
A[i].resize(K);
for (int j = 0; j < K; j++) {
A[i][j] = 1;
}
}
B.resize(K);
for (int i = 0; i < K; i++) {
B[i].resize(N);
for (int j = 0; j < N; j++) {
B[i][j] = 1;
}
}
// 计算矩阵相乘
vector< vector > C = matrix_multiply(A, B);
}
Py_RETURN_NONE;
}
static PyMethodDef ExtendMethods[] = {
{
"long_running_test", (PyCFunction)long_running_test, METH_VARARGS | METH_KEYWORDS},
{
NULL, NULL}
};
static struct PyModuleDef extendmodule = {
PyModuleDef_HEAD_INIT,
"Extend",
NULL,
-1,
ExtendMethods
};
PyMODINIT_FUNC
PyInit_Extend(void){
PyObject *m;
m = PyModule_Create(&extendmodule);
if(m == NULL) {
return NULL;
}
return m;
}
上面这段代码主要实现了两个 N*N 的矩阵的乘法,并提供了 python 接口。这是一段很笨的矩阵乘法代码,性能非常差,但是不要紧,我们的目的是把 CPU 跑满。
下面是CMake工程。
CMakeLists.txt
project(pyExtend)
cmake_minimum_required(VERSION 3.0 FATAL_ERROR)
set(CMAKE_CXX_STANDARD 11)
find_package(Python COMPONENTS Interpreter Development)
execute_process(COMMAND ${
Python_EXECUTABLE} -c "import distutils.sysconfig; print(distutils.sysconfig.get_config_var('EXT_SUFFIX'))"
RESULT_VARIABLE PYTHON_CVPY_PROCESS
OUTPUT_VARIABLE CVPY_SUFFIX
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT PYTHON_CVPY_PROCESS EQUAL 0)
set(CVPY_SUFFIX ".so")
endif()
include_directories(${
Python_INCLUDE_DIRS})
set(SRC test_extend.cc)
add_library(Extend SHARED ${
SRC})
target_link_libraries(Extend PUBLIC ${
Python_LIBRARIES})
set_target_properties(Extend PROPERTIES
PREFIX ""
OUTPUT_NAME Extend
SUFFIX ${
CVPY_SUFFIX})
将 CMakeLists.txt 和c文件放在同一目录,执行 cmake . && make
得到一个名字为 Extend.cpython-37m-x86_64-linux-gnu.so 库文件。如果想在 python 里面引入这个库,只需要加入代码 import Extend 就可以了。
下面是测试代码:
(test.py)
import threading
import time
import Extend as et
DIM = 1000
def test():
et.long_running_test(dimension=DIM)
# thread 1
t1 = threading.Thread(target=test)
t1.setDaemon(True)
t1.start()
# thread 2
t2 = threading.Thread(target=test)
t2.setDaemon(True)
t2.start()
# main thread
et.long_running_test(dimension=DIM)
当你跑起来,使用 htop 或者 top 查看进程的 cpu 占用率,在多核机器上(大于或等于3核),你会发现 cpu 的占有率为 300%,因为这里起了3条线程,2条子线程和1条主线程。所以 python 支持多核多线程。
当把 PyAllowThreads allowThreads 这行代码注释掉,无论起多少个线程 cpu 占有率永远为100%。
很多高性能计算库都使用了这个机制,numpy 就是其中之一,我们可以测试下面这段代码:
(test_numpy.py)
import threading
import numpy as np
DIM = 30000
def test():
A = np.ones((DIM, DIM))
B = np.ones((DIM, DIM))
C = np.multiply(A, B)
# thread 1
t1 = threading.Thread(target=test)
t1.setDaemon(True)
t1.start()
# thread 2
t2 = threading.Thread(target=test)
t2.setDaemon(True)
t2.start()
# main thread
test()
CPU 占有率同样为300%。
结论:
1、python 支持多线程多核并行执行,只要正确地释放 GIL 就可以了。
2、在做计算密集型任务时,应该使用 numpy 等高性能计算库,它们会把计算都下放到非 python 线程,应避免使用 python 自带的加减乘除。