Python GIL锁问题探究及解决

1. 什么是GIL?

GIL即全局解释器(global interpreter lock)。python的每个线程在执行时都需要先获取GIL,保证同一时刻只有一个线程可以执行代码,即同一时刻只有持有GIL锁的线程可以得到执行的机会,使用CPU。
这样,在跑python多线程程序时,只有当一个线程获取到全局解释器锁GIL后才能运行,而GIL只有一个,因此即使python应用在多核的情况下也只能发挥出单核的性能。

 

2. 什么时候会释放GIL锁?

1)在做IO操作时,GIL总是被释放。对所有底层是C代码的系统来说,GIL会在IO调用之前被释放,以允许其它的线程在等待IO的时候运行。
2)如果是纯计算的程序,没有IO操作,解释器会有一个专门ticks进行计数,每隔100次或每隔一定时间(15ms)去释放GIL,这时线程之间可以开始竞争Gil锁。

 

3. 如何解决GIL锁?

从上面得知,GIL的基本行为有下面三个:

  • 当前执行的线程持有GIL,其他线程得不到执行机会。
  • 线程遇到I/O阻塞时,会释放GIL。(阻塞等待时,就释放GIL,给另一个线程执行的机会)
  • 遇到CPU密集型的线程,解释器会周期性的进行ticks计数并释放GIL。

python多线程应用受GIL的影响,不能完全利用多核CPU资源,目前常用的解决方案是:

1) 更换实现语言,cpython为jpython(不建议),许多模块不兼容。

2) 使用多进程完成多线程的任务,python多进程没有GIL问题,每个进程都会拥有一个GIL。

3) 将python中进行密集计算的函数使用C++ 去实现,并在C++ 代码中主动释放当前线程的GIL锁,让其他线程执行,这也是目前最常用的方案。像许多第三方的扩展模块(OpenCV),都被设计成在进行密集计算任务时主动释放GIL。

 

下面详细介绍Python GIL导致的问题及解决办法。

引言

其实一开始并没有想到研究GIL,但是在研究如何让你的Python更快的过程中发现我们可以通过这种方式解决掉GIL,让我们的代码不被Python拖累

这篇博客相比于上面的博客更注重于代码的讲解,我们通过使用pybind11从一个Python调用C++的demo出发介绍如何让Python调用C++并且丢弃GIL

GIL简介

首先我们要知道什么是GIL,为什么它会拖累Python,首先我们看一下Python历史,Python是Guido van Rossum 在1989年发布的,那个时候计算机的主频还没有达到1G,程序全部都是运行在单核计算机上面,直到2005年多核处理器才被Intel开发出来

多核处理器意味着什么呢,就好比一个工厂,你原来只有一个工人干活,现在有很多个了,一开始设计出来只是为了能在每个核心上跑不同的应用,但是随着大家对多核计算机的使用,大家发现有的时候计算器其实很空闲,大部分CPU都在休息,假如只在一个核上跑一个应用的话,那么其他CPU就浪费了,所以大家就开始设计怎么并行在多个CPU上跑同样的任务

现在我们来考虑一下怎么能让CPU力往一处使,我们用数据库来做比方,假设我们计算机上安装一个银行数据库,为了让这个“银行”能够服务更多的人,我们把对钱的操作(增删查改)放到每个CPU上运行。假如我们的顾客一个一个排着队来取钱存钱,我们每个CPU查询都是唯一的,存取也是唯一的,那么我们的“银行”就能正常工作

但是现实的环境往往不是这样的,顾客它可能会因为网络原因个人原因同时进行多个操作,假如它同时取1千万的两次操作(它账号只有1千万),每个CPU上的程序查询时候正好都是账号有一千万,然后依次进行数据的更新,最后我们发现用户的账号变成了0,但是用户却取了两千万出来,你的银行损失了一千万,所以并行任务最重要的就是数据共享

怎么解决这个共享问题呢,很简单加“锁”,我们给需要共享的东西上个锁,每次你想用的时候你就把锁锁上,然后对共享的东西进行操作,当有别人想动这个东西的时候,他一看哎呀有人在用,那我等会。这样就不会造成上面的冲突了,但是这个也造成了一个问题由于我上了一把锁,每次我们想操作的时候,必须去看一下这个锁有没有被人锁上,假如没有我就锁上,有就等待,这一来一去就会造成一个效率问题(感觉这个也是国企的通病,权利依次掌握在领导上,要想完成工作得不断的进行开“锁”、关“锁”,有时候还会造成“死锁”),所以并行的4个任务运行速度不一定是一个任务的四倍,所以我们经常看到一些库在运行说明里面双核速度会比单核加速一点几倍,之说以达不到双倍就是因为这些“锁”的存在

“锁”帮我们能让单任务拆分成子任务并行化加速,但是在一定程度上拖累了运行速度,我们回到Python,因为多核是在2005年才出现的,但是在并行化上面,一个比多核更早出现的概率就是:线程进程

在还没有多核处理器的时候,操作系统为了让程序并行化跑,就创造了进程和线程的概率。用通俗的话来讲,进程就是一家大工厂,而线程就是工人,为了提高生产力,我们可以开很多家工厂,当然我们也可以开一家工厂,招很多工人。但是线程这个东西相比于进程要消耗的少的多,因为它“原材料”都是从“工厂”里面拿的,假如说工厂少了几个工人还可以生产,但是上万个工人没有工厂他们也办法工作。

所以对于Python来说首先得支持线程和进程的概率,对于进程来说很简单,就是多开几家工厂(多开几个Python程序)罢了,但是对于线程来说,由于Python是一门脚本语言,它需要一个解释器来执行代码,我们知道这个解释器它可以当做大一个共享变量,假如在不同的线程里面用“锁”来限制一下的话,环境变量就会乱了套

所以Python对于线程的支持就是给他加一个锁,也就是我们俗称的GIL,由于在操作系统在运行单核的时候就支持线程,一个工人加一个锁其实也没有什么,无非就是多了一点开锁关锁的时间,所以Python在2005前一直没有GIL这个概率,到了2005大家发现Python使用多线程竟然只能使用一个核,完全浪费了其他核,因为虽然Python的线程可以分配到不同的核上运行,但是当他们运行的时候发现这个锁没有被释放,所以每个核上的线程都傻乎乎的在等待,结果最后查看效果多线程比单线程速度还慢(要等GIL释放)

Python社区逐渐发现这个问题,他们也做了很多挽救工作,比如在线程睡觉(sleep)、等待连接的时候让线程主动释放GIL,这样就能让其他线程继续执行,但是对于纯粹的运算代码而不是IO密集代码总也避不开这个锁的存在,如果允许GIL释放,由于历史遗留问题很多代码都会乱了套(理论上其实就是需要重新修改锁的设计,可以参考MySQL的代码去掉“锁”花了5年时间),考虑到Python本来就运行的慢,Python开发者觉得假如你觉得代码很慢,你可以放到C/C++里面执行,所以对于这个GIL就没有继续啃下去,而是把中心放在Python调用C/C++中,提供了一些很方便的方式让我们在C/C++中控制GIL的释放以及获取

所以我们接下来通过一个来学习Python调用C++代码,来了解Python如何调用C++,并且通过一些实验来验证线程、进程和GIL。

 

测试GIL的存在

首先我们要做的第一件事就是测试GIL的存在,现在基本上主流电脑都是多核CPU,所以我们这个实验可以很轻松的在多核下进行

首先我们得安装一些环境:Python3gcchtop(在Windows可以用下任务管理器代替)

首先我得提一下我的一个认识误区,在以前我不太清楚线程、进程与多核直接的关系的时候我有一个误区,我以为C能在单线程里面使用多核(我也不清楚为什么我会这么想,可能是因为了解很少),而Python却不能,后面通过我实验我才发现,无论是CPython只要你的代码不使用线程、进程那么你的代码只能同时运行在同一个核上

怎么来测试呢,我们可以在Python的解释器里面输入

while True:
  pass

然后我们打开htop,我们可以发现某一个CPU始终保持在100%(这个CPU可能会变化,因为操作系统控制每个进程切换CPU时间),假如你没有其他任务过多使用CPU的话,你其他的核心一直保持在很低的利用率,当你ctrl-c你的代码后,那个100%的CUP会立马降下来

然后你在编译一个C程序,使用gcc a.c && ./a.out命令编译下面代码然后运行

// a.c
int main(){while(1){};}

你会发现C也只能消耗一个CPU,这就印证了我们前面说过得,如果我们不主动使用线程或进程来,同时只能有一个在运行

接下来我们看看在多进程的基础上,使用Python来使用多核

from concurrent.futures import ProcessPoolExecutor

def f(a):
    while 1:
        pass
if __name__ == '__main__':
    pool = ProcessPoolExecutor()
    pool.map(f, range(100))

当我们运行上面代码的时候,我们会发现所有CPU会运行到100%,我们只要简单声明一个进程池(ProcessPoolExecutor),Python自动帮我们生成你CPU核数相同的进程,然后我们只要把任务分配到池中就能重复的并行化任务,把所有的核心都用起来。

然后我们来测试一下线程池,要使用Python线程池只需要初始化ThreadPoolExecutor就行

from concurrent.futures import ThreadPoolExecutor

def f(a):
    while 1:
        pass
if __name__ == '__main__':
    pool = ThreadPoolExecutor()
    pool.map(f, range(100))

我们从htop可以看到在Python线程中,只有一个能达到100%,这就是GIL的“威力”,它让我们多线程没有发挥多线程的力量,重复使用到多核CPU

接下来我们看看在C++里面使用多线程是否能够发挥多核的威力

// run.cpp
#include 
using namespace std;

#define NUM_THREADS 50

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

    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_dead();
}

我们使用g++ -pthread -std=c++11 run.cpp && ./a.out运行上面的C++程序,我们在htop里面能够发现,C++的多线程能够完全发挥多核的威力

上面的程序都很简单,但是具备一个多线程运行的基本构造,我们可以修改我们的调用的子任务来完成实际的任务,当然你程序越复杂也涉及到了各种锁的使用,这里我们就不谈了

从上面的程序我们可以知道C++的多线程能够充分使用多核,而Python的不行,接下来我们就开始探索Python调用C++

Python调用C++

在上面的博客我总结了Python调用C++的方式,总的来说Cython是控制能力最好的,效率也是最高的,但是由于存在一个学习新语言的难度,所以我这里就不提了,改天再写一篇关于Cython的博客,我们这里使用pybind11这个库作为介绍

安装非常简单pip install pybind11就行,接下来我们使用github上这个官方例子做介绍,最后我们以一个实际的C++项目为例子,看看如何在实际的项目使用

首先我们先把项目下载下来

git clone https://github.com/pybind/python_example.git

然后我们新建一个环境(避免安装到我们系统的环境,方便删除)

python -m venv venv

PS: 当前Python版本默认为py3.5以上(你可以使用pyenv安装Python多个版本,目前我在自己使用Python版本,但主要使用3.6以上)

source venv/bin/activate

然后我们激活我们的环境,我们顺便安装一下我们接下来要安装的Python

pip install ipython

然后我们进入项目cd python_example,假如你用Pycharm的话,你可以在项目目录下生成venv环境,然后在Pycharm里面打开会自动设定为默认环境

然后我们先测试一下代码可以不可以用

pip install .

假如我们安装成功了,恭喜你,我们的环境已经准备好了,打开ipython,我们先测试一下这个C++代码的速度

In [1]: import python_example

In [2]: python_example.add(1, 1)
Out[2]: 2

很好,代码运行正常,就是一个简单的加法运算,我们测试一下平均时间

In [3]: %timeit python_example.add(1, 1)
313 ns ± 3.03 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

很好,我们的C代码还是跑到很快,313纳秒就跑完了,接下来我们看看纯粹的Python代码速度

In [4]: def add(a, b):
   ...:     return a + b
   ...: 
   ...: 

In [5]: %timeit add(1, 1)
113 ns ± 9.04 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

什么竟然比C++还要快,快了近3倍,记得我当时第一次运行出来的这个结果的时候的震惊,说好的快呢,你骗我。

接下来我们就来分析一下出现这个的原因,会不会是因为类型转换出现问题呢,因为pyblind11使用了很多自动转换的技术来帮我们转换,我们看看原函数(在src/main.cpp)

int add(int i, int j) {
    return i + j;
}

首先Python调用它,要把第一个参数由Pythonint对象转换成C++int基本类型,C++运行完之后,又得转换将C++基本int类型转换成Pythonint对象,这一来一回就得多花三个操作,为了验证我们猜想,我们插入一个nothing函数在add函数后面

void nothing(){
}

然后模仿m.def仿照写一行插入nothing函数(你会发现语法特别简单,这也是我喜欢pyblind11的原因)

m.def("nothing", ¬hing, R"pbdoc(
        do nothing
    )pbdoc");

接下来我们安装一下我们的新库pip install .

然后我们再开一个新的ipython(你可以用importlib来重新加载库)

In [1]: import python_example

In [2]: %timeit python_example.nothing()
125 ns ± 0.6 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

125ns,我们的猜想成功了,类型转换的确拖累了C++运行的速度,我们再看看原生的速度如何

In [4]: def nothing():
   ...:     pass
   ...: 
   ...: 

In [5]: %timeit nothing()
85.1 ns ± 0.262 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

竟然还是比C++快,虽然没有上面那么夸张,但是快了25%,我们再来分析原因,首先现在没有类型转换所以理论上那只能是代码运行问题,我们知道Python优化里面提过一句,少用.,因为Python要搜寻很多东西才能获得到对象的属性、方法等,所以我们这边使用了python_example.nothing来调用nothing函数,假如我们去掉.速度会不会提高呢

怎么去掉呢,用局部变量

In [6]: pn = python_example.nothing

In [7]: %timeit pn()
90 ns ± 0.761 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

从上面可以看到的确,.“害人不浅”,我们的速度又快了一大截,基本上同原生没有太多差距了,一开始我以为是概率问题,运行了多次但是结果都是一样,原生就是比C++快了5ns,可能是pyblind11“偷偷”的在哪个地方偷跑了一条语句吧,或者有可能是C++CPythonC写的)稍微慢了一点

一开始我以为C++一定会比Python快,但是我们从上面测试可以看出来,在“起跑”阶段,C++甚至比Python要慢,我们使用C++主要是为了加速大段Python代码,只要在这场“长征”中C++能够胜出,那么我们的努力就没白费,那好我们继续测试,看看在长征过程中C++表现如何

首先我们把add函数魔改一下,我们让他进行100次运算

int add(int i, int j) {
    int s = 0, x = 0;
    for(;x<100;x++){
        s = s + i + j;
    }
    return s;
}

我们再把模块给安装一下pip install .,重新打开新的ipython

In [1]: import python_example

In [2]: python_example.add(1,2)
Out[2]: 300

In [3]: padd = python_example.add

In [4]: %timeit padd(1,1)
282 ns ± 3.9 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

In [5]: %timeit python_example.add(1,1)
316 ns ± 3.78 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

我们这次重要见到了C++的威力,我们进行100次运算,相比于上面一次运算,我们只增加了4ns的平均时间,我们来看看原生Python的表现如何

In [6]: def add(a, b):
   ...:     s = 0
   ...:     for i in range(100):
   ...:         s += a + b
   ...:     return s
   ...: 
   ...: 

In [7]: add(1, 2)
Out[7]: 300

In [8]: %timeit add(1, 1)
4.74 µs ± 40.5 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

C++完爆Python4.74us = 4750nsPython用时是C++的10倍多,只还只是100次运算,假如我们上万次运算,那结果更加夸张,C++在长征的过程中胜利了,但是我们也不能说Python是慢毕竟us的单位其实非常小,1us=1000ms=1000000s,在1s内可以执行上面函数几十万次,只能说C++速度太可怕了

调用总结

我们从上面可以看到,虽然Python调用C++在类型转换上会有速度损失,但是在进入到函数提内运行过程中的速度是不影响的,假如我们的运算量够大,完全可以弥补那一点点性能影响,所以要想重复利用C++的速度,尽量少调用C++,把计算结果竟然一次性返回,而不是我们多次进行交互,这样就能最大化利用C++

C++线程中测试GIL

接下来我们来考虑这么一个问题,前面我们测试了C++的线程能使用多核,我们假如在让Python在调用C++的代码中中使用线程,那么我们的C++的线程能不能使用多核呢进而解除GIL的作用

我们把nothing函数改成多线程

#include 
#define NUM_THREADS 50
using namespace std;

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

void 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();

    }
}

然后我们再重新编译一下pip install .,我们来跑一下我们这个多线程的nothing函数

In [1]: import python_example

In [2]: python_example.nothing()

我们在htop里面可以看到在单线程的Python程序中,成功的将所有核心都利用上了,也就是是说假如我们在C++扩展中使用线程的话,是不会被GIL影响的

说实话当我第一次运行的时候我直觉是还是会被GIL影响,结果最后跑出来的结果大吃我一惊,现在我们分析为什么不会被受影响,因为GIL锁的是Python解释器,当我们的代码进入到C++中的时候,我们已经不在Python解释器中了,这样即使我在C++中声明线程,那也是C++的线程,所以就不会造成无法使用多核的情况

这里我们学到一点,如果我们想摆脱GIL可以把线程放到C++中,这样线程的不再依赖Python解释器,前面我们知道其实Python底层是用C写的,所以基本上所以的语法都是基于C代码实现加上语法糖来完成的,Python线程也就是C线程,我们能不能模拟一下Python来构建这个GIL

首先我们知道GIL是一把锁,所以我们第一件事就是查看这把锁,在这里我们通过PythonC头文件来引入一个函数PyGILState_Check这个函数会返回一个10值,假如是1那么意思该线程拿着GIL锁,反之。

所以我们先在头部加上#include "Python.h",在Linux系统上要安装python-dev或者python-devel开发包才有这个头文件,接下来我们在nothing函数加上这个检测状态

cout << "GIL is " <<  ((PyGILState_Check() == 1) ? "hold" : "not hold")<

提一句为了使用cout,我们得在头部加上C++输出库#include

先在我们重新安装一下并运行nothing函数,程序会输出GIL is hold,为什么会出现这个情况呢,因为Python默认会锁住GIL当运行C++或者C代码的时候,但是为什么我们虽然锁住了GIL但是我们还是能够使用C++的线程来运行多核呢,其实很简单因为我们的线程没有像Python一样每次运行的时候去获取这个GIL锁,为了证明这一点,我们来做个实验

首先我们得在nothing函数里面释放GIL,然后让线程去获取GIL(如果nothing主函数不释放GIL,会造成死锁,线程无法运行,一直获取不了GIL锁),我们可以用PythonC头文件的函数来释放GIL锁,但是pybind11提供了一个更加方便的函数让我们来释放GIL锁,我们把nothing函数定义修改一下,在后面添加一条语句py::call_guard()

//    m.def("nothing", ¬hing);
    m.def("nothing", ¬hing, py::call_guard());

然后我们在重新编译安装运行一下代码,我们的结果就会是GIL is not hold,我们通过简单的一条语句就释放GIL锁,接下来我们来测试在线程中获取GIL锁来模拟Python的情况

要想获取GIL锁,pybind11也提供了一个非常简单的方法来实现这个:py::gil_scoped_acquire acquire;

我们接下来把f函数改成下面的

void f(){
    cout << "entner F: GIL is " <<  ((PyGILState_Check() == 1) ? "hold" : "not hold")<

我们在获取GIL前后,添加了一些输出,方便我们调试,接下来我们再运行我们的代码,我们发现程序输出50个进入entner F: GIL is not hold(在我的电脑上,因为线程同时运行,获取GIL锁需要时间,所以在我电脑上每次运行f函数时锁都打开着),但是只有一行GIL is hold now is runing,因为当一个线程获取到GIL后,其他线程就没法获取到了,而且看htop我们也能发现只有一个核到了100,在我们强行模拟下C++也没能使用多核

其实从这里我们可以看出来,GIL问题其实就是一个死锁的问题,线程获取后不释放锁,导致所有线程相互竞争,用一个谚语来说就是:一个和尚挑水喝、两个和尚抬水喝、三个和尚没水喝。

那么我们怎么来解决这个问题呢,很简单就是在你不需要的锁的时候去释放它,接下来我们来模拟一下怎么释放这个锁达到多线程“和平共处”,首先我们引入C++时间库来使用sleep函数(#include ),接下来我们引入PythonC头文件中的宏来释放GIL,我们把f函数改成下面的形式

void f(){
    cout << "entner F: GIL is " <<  ((PyGILState_Check() == 1) ? "hold" : "not hold")<

我们使用Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS这一对宏来释放GIL,这样我们重新编译运行nothing函数我们就能看到50个enter和50个runing,而且在htop中我们也能发现C++的线程再次使用所有的核心了(利用率达到不了100%,不知道是因为宏的“副作用”还是其他原因,但是每个核还是能够到70%作用),这种在一个函数中获取和释放GIL锁还是不推荐的,最好在函数一开始的时候释放GIL,在函数结束的时候获取GIL返回到Python解释器中(假如你需要与Python进行交互的话),毕竟获取一次锁的成本还是挺大的,而且一不小心就会造成死锁

Python线程中测试GIL

接下来我们来看看一个已经存在的问题,就是如何解决掉使用Python线程时遇到的GIL问题,其实我们在上面的C++线程已经模拟出来了,解决这个问题的关键就是释放GIL锁,我们先测试一下在GIL锁下,线程调用C++代码的速度

我们首先添加一个新死循环函数

void run_dead(){
    while(1){};
}

然后在后面加上pybind11的定义

m.def("run_dead", &run_dead);

接着我们运行下面函数

from concurrent.futures import ThreadPoolExecutor
import python_example

pool = ThreadPoolExecutor()

for i in range(100):
    pool.submit(python_example.run_dead)

在这个函数里面我们声明了一个线程池,并且向池蕾加入了100函数,接着我们在htop里面查看CPU利用率,我们可以看到只有1个CPU能够跑满100%,其实从前面的实验我们就能猜到这个结果,解决方案其实前面也给了,有两种方法,第一种就是使用Python的C的头文件函数宏
Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS,第二种就是在函数声明的地方使用 pybind11提供的py::call_guard()来释放GIL,两种方法都可以,但是第二种更加简单一点,在这里我就不测试释放GIL之后的性能了,前面已经做过了

GIL总结

通过前面我们的测试,GIL这个东西其实只是一把锁,我们经常能听到很多人抨击Python关于GIL问题,这就给人一种错觉Python这种语言在设计上有弊端,在前面测试我们也发现了就算是C++或者C假如不正确的使用锁其实也会有这个GIL问题,GIL的问题的并不是“编程语言”的锅,主要是我们自己的代码造成的死锁,所以面对GIL的时候,不需要困惑,它就是一把“锁”,把它打开,而不是碰到它就跑,你会发现它也就是一把“锁”而已。

你可能感兴趣的:(Python)