Python中实现多线程(真正的)的探索

使用Python多线程的人都知道,Python中由于GIL(全局解释锁:Global Interpreter Lock)的存在,在多线程时并没有真正的进行多线程计算。知道Python历史的就知道Python在1989年发布,当时计算机全是基于单核,知道2000多年多核处理器才发布。因此,GIL一致沿用到现在,而且没有非常好的替代方式。如果涉及到计算密集型的任务,在多核CPU的加持下使得效率急速上升,如果是IO密集型的效果可能没有那么明显。这个Python的官网也进行了说明。为了说明Python的多线程问题,下面可以通过一些代码来实现Python多线程的验证。

Python和C++多线程的对比

首先使用Python中的多线程运行,观察CPU的情况,下面定义了一个函数,不进行任何计算,但是一致保持运行即可,再查看CPU的情况,代码如下:

1
2
3
4
5
6
7
8
9
from concurrent.futures import ThreadPoolExecutor

def f(a):
while 1:
pass

if __name__ == '__main__':
pool = ThreadPoolExecutor()
pool.map(f, range(100))

CPU的情况如下,乐意看出只有其中一个CPU的使用率为100%以上,其他没有使用,而且每次只运行一个线程,所以可以确定Python的多线程并没有利用多核运行。 1561516460850

下面我们使用C++的多线程来进行测试,使用同样的方式,下面是c++多线程的简单实现,f函数也是一直保持运行,便于后面的CPU使用情况观察:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <thread>

#define NUM_THREADS 50
using namespace std;

void f(){
while(1){
continue;
};
}

void run_thread(){
std::thread threads[NUM_THREADS];
for(int i = 0; i < NUM_THREADS; ++i)
{
threads[i] = std::thread(f);
}
for (int i = 0; i < NUM_THREADS; ++i) {
threads[i].join();

}
};
int main(void){
run_thread();
}

经过编译后并运行:g++ -pthread -std=c++11 ./main_thread_cpp.cpp && ./a.out,CPU的使用情况如下,可以看出CPU的使用率为1200%,12核全部为100%的使用率,多线程真正使用了多核。那么我们能将 python的多线程改为多核的多线程么

1561516889704

Python多线程并行计算探索

我们知道Python中的多线程主要是由于GIL的存在,多线程之间相互竞争,而Python的官网文档C API Reference Manua对GIL进行了详细的说明。GIL只用来限制pure python code,对其他的没有限制。因此,我们可以通过一些测试来验证这个idea。下面就进行验证。

Python多线程调用C++中的多线程

首先我们使用python来调用C++的多线程代码,来观察CPU的使用情况,来验证之前的结论,这里使用pybind11来进行混合编程,下面是c++代码,多线程来直线任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <thread>
#include "pybind11/pybind11.h"

#define NUM_THREADS 50

using namespace std;
namespace py = pybind11;

void f(){
while(1){
continue;
}
}

py::none nothing(){
std::thread threads[NUM_THREADS];
for(int i = 0; i < NUM_THREADS; ++i)
{
threads[i] = std::thread(f);
}

for (int i = 0; i < NUM_THREADS; ++i) {
threads[i].join();

}
return py::none();
}

PYBIND11_MODULE(example, m){
m.def("nothing", &nothing, "do nothing");
}

通过下面的编译,得到一个example的.so文件的包,再使用python来调用包里面的nothing函数,再观察CPU的运行情况,如下图所示.

1
g++ -O3 -Wall -shared -std=c++11 -fPICpython3 -m pybind11 --includesmix_thread.cpp -o examplepython3-config --extension-suffix`
1
2
3
4
5
6
7
from example import nothing
from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor()

for i in range(12):
pool.submit(nothing)

1561517891382

从上图中的执行可以看出,缺失利用到了多核,实现了多线程的并行计算。但是这个多线程实际上是在C++中实现的,Python只是作为接口调用了C++中的函数而已。这样还不是Python的多线程。下面我们继续对代码进行修改。

Python多线程调用C++中的普通函数

首先我们去掉C++中的多线程,通过Python的多线程直接调用C++的函数来看是什么情况呢?下面的代码来进行测试,将多线程去掉后,在Python中调用nothing函数来进行测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <thread>
#include "Python.h"
#include "pybind11/pybind11.h"

#define NUM_THREADS 50

using namespace std;
namespace py = pybind11;

int f(){
int n = 1000000000;
while(n > 0){
n--;
sleep(0.2);
}
return n;
}

py::int_ nothing(){
int y;
y = f();
return py::int_(y);
}

PYBIND11_MODULE(example1, m){
m.def("nothing", &nothing, "do nothing");
}

编译,生成example1.so文件,在使用Python直接调用该包即可:

1
g++ -O3 -Wall -shared -std=c++11 -fPIC `python3 -m pybind11 --includes` main_multi_nothread.cpp -o example1`python3-config --extension-suffix`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from example1 import nothing
from concurrent import futures
from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor()

future_list = []
for i in range(12):
future = pool.submit(nothing)
future_list.append(future)

for future in futures.as_completed(future_list):
res = future.result()
print(res)

经过编译,再在Python中进行调用得到如下图所示,每次只有一个线程在运行,因此并没有使用多线程进行并行计算。如下图所示,但是我们能实现前面所说的多线程真正的并行计算吗?

1561518345849

Python多线程调用C++中的释放GIL的函数

经过一番Google和官方文档的查询之后,基本了解,我们上面的代码中,在Python执行时会自动加GIL,因此我们需要手动的将GIL进行释放,将上面的代码进行修改,在调用函数f的前面加入Py_BEGIN_ALLOW_THREADS,后面加入Py_END_ALLOW_THREADS。再和上面相同的步骤运行,来观察CPU的使用情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "Python.h"
#include "pybind11/pybind11.h"

#define NUM_THREADS 50

using namespace std;
namespace py = pybind11;

int f(){
int n = 1000000000;
while(n > 0){
n--;
sleep(0.2);
}
return n;
}

py::int_ nothing(){
int y;
Py_BEGIN_ALLOW_THREADS
y = f();
Py_END_ALLOW_THREADS
return py::int_(y);
}

PYBIND11_MODULE(example1, m){
m.def("nothing", &nothing, "do nothing");
}

下面是python多线程运行时CPU使用情况,可以看到,所有的CPU均为100%,而且同时12个线程在运行。现在可以确定我们的猜想是正确的。整个python多线程最核心的就是GIL的限制,但是可以手动来释放GIL,来进行多线程。上面的代码,就是Python真正的多线程实现方式了。

1561519146831

结论

根据上面的过程,我们可以确定得到结论:(1)Python中的GIL只作用于pure python code,其他的GIL限制不了也不会限制;(2)我们可以手动管理GIL来实现Python下的多线程;(3)GIL其实就是一把锁而已,并不需要对GIL进行困惑,当面对GIL时,需要打开”锁”。

附录

  1. Python C API中提供了Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS宏来释放GIL,这两个宏的原型定义如下:{PyThreadState *_save; _save = PyEval_SaveThread();PyEval_RestoreThread(_save); },可以参考官方文档。

  2. pybind11中也提供了释放GIL的方法:py::gil_scoped_acquire acquire;该方法是通过一个class来实现,类似于下面的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class gil_scoped_acquire{
    public:
    inline ScopedGILRelease() {
    m_thread_state = PyEval_SaveThread();
    }

    inline ~ScopedGILRelease() {
    PyEval_RestoreThread(m_thread_state);
    m_thread_state = NULL;
    }
    private:
    PyThreadState * m_thread_state;
    };
  3. 只要使用c/c++/cython等来释放GIL的函数调用就可以实现Python下的多线程,C使用1中的方式,C++使用1,2中的方式均可,Cython使用with nogil可以实现。

参考文档:

  1. GIL实现细节解析
  2. 触摸Python的GIL
  3. 通过C/C++扩展的方式在python中进行并发)
  4. Initialization, Finalization, and Threads