谈下python的gil_再谈Python的GIL

我之前在系列文章中写过Python多线程的文章,这篇文章主要核心就是讲因为GIL的存在导致Python的多线程实际上没法利用多核。大龙:Python线程、协程探究(1)——Python的多线程困境​zhuanlan.zhihu.com

稍微对python有多一点了解的同学都知道Python的GIL的存在,也知道由于GIL的存在,python的虚拟机只能再同一时刻解释执行一个线程进而导致python多线程没法有效的利用多核。但是为什么Cpython中会有GIL?其实原因还是在于python虚拟机中的C API是线程不安全的,GIL是为了保证这些C API能够被安全的调用。

我们以python垃圾回收为例来介绍如果没有GIL会出现什么问题。在此之前,我们先简单的介绍下python的垃圾回收机制。

python中一切都是对象,对象的使用和释放都是依赖引用计数机制, 比如一个Cpython中的整数对象定义如下。其中ob_refcnt就是该对象的引用计数值,ob_ival是该整数对象的值。

typedef struct {

int ob_refcnt;

struct _typeobject *obtype;

long ob_ival

} PyIntObject

我们以下面的代码为例展示引用计数是如何工作的。下面的python代码中执行b =10000时, 虚拟机实际上是先创建了一个整数对象ObjectA,ObjectA的ob_ival字段是10000,并让b指向ObjectA, ObjectA的引用计数初始化为1。当执行 a = b时,实际上只是简单的让a指向ObjectA, ObjectA的引用计数加1变成2。

当执行b = 29999时,此时创建了第二个整数对象ObjectB, ObjectB的ob_ival值为29999, 令b = 29999时,相当于b指向ObjectB,此时ObjectA的引用计数减一变成1, a = b时,a也指向ObjectB,此时ObjectA的引用计数再减1变成0。此时由于ObjectA的引用计数变成0,系统就会选择释放ObjectA。

b = 10000

a = b

b = 29999

a = b

上述提到的对象引用计数的修改主要是两个宏来完成Py_INCREF以及Py_DECREF, 代码定义如下。可以发现就是简单的对ob_refcnt进行加一或者减一,只不过在Py_DECREF中,如果引用次数变成0时,就会释放掉该对象。

#define Py_INCREF(op) ( \

_Py_INC_REFTOTAL _Py_REF_DEBUG_COMMA \

((PyObject *)(op))->ob_refcnt++)

#define Py_DECREF(op) \

do { \

PyObject *_py_decref_tmp = (PyObject *)(op); \

if (_Py_DEC_REFTOTAL _Py_REF_DEBUG_COMMA \

--(_py_decref_tmp)->ob_refcnt != 0) \

_Py_CHECK_REFCNT(_py_decref_tmp) \

else \

_Py_Dealloc(_py_decref_tmp); \

} while (0)

现在我们来继续探讨没有GIL的时候,垃圾回收机制会出现什么问题。我们发现上面的引用计数代码是线程不安全的。如果有两个线程中同时引用了ObjectA,ObjectA的初始引用计数是1。两个线程分别引用后正确的引用计数应该变成3。但当时间线如下时,会出现引用计数的更新错误:线程1引用了ObjecA,进入Py_INCREF,将obj_refcnt加1变成2并准备保存时,操作系统发生了线程调度。

线程2引用了ObjectA,进入到Py_INCREF,将obj_refcnt加1变成2并保存

操作系统调度到线程1,线程1将obj_refcnt=2写入。

我们发现在上述的时间线下两个线程引用完ObjectA之后,引用计数的结果变成了2,发生了错误。

那GIL的存在又是怎么怎么帮助上面的问题进行解决的呢?说白了上述的问题就是因为C API线程不安全导致的,而解决API线程不安全的常规方法就是使用锁将API改造成线程安全。

我们的第一种办法就是改造所有不安全的API,让他变成线程安全。这样python的多线程就可以和正常的多线程一样利用多核,但是这样带来后果就是数不尽的加锁、解锁。就以简单的引用计数更新来说,由于python无类型限制,上面展示的四行简单的python代码,每执行一步都会涉及到引用计数的更新,每一步执行基本都会涉及到大量的加锁解锁。这将严重的消耗性能,尤其是单线程环境下的性能损失(因为再不需要多线程的情况下也需要疯狂的加锁和解锁)。事实上,很多人都曾经对Cpython中的GIL不满,而上述的方案就是曾经试图替代掉GIL的方案尝试,即尝试用大量的锁将CPython中的C API改造成线程安全,但结果最终测试发现单线程下性能远远不及GIL,多线程下性能也不如GIL方法表现的优秀。所以Guido本人一直对替代GIL使得Python可以支持多核没什么大的兴趣,因为在目前的这套C API机制上基本上其他提出的方案性能都比GIL差。A Gilectomy update​lwn.net

而GIL则是采用了线程粒度的锁。只有当一个线程获得了GIL之后,才能被解释器执行,进而也才能调用C API。同时什么时候切换线程实际上是由虚拟机自己决定的。比如常规的一种线程切换方式,就是当前线程执行N个字节码后进行线程调度。这样就避开API 线程不安全的问题了,因为永远只有一线程同时调用执行某个API。也不会出现原先一个线程执行某个API,执行到一半因为线程调度而被打断的情况,因为线程切换时机是由虚拟机自行决定的,虚拟机的执行粒度是字节码,而不是汇编码。而一个字节码的执行中包含着完整的C API的执行调用。

这种方法虽然使得python多线程没法利用多核,但是从加锁的角度来讲是足够简单的,对线程的性能影响也是足够小的。当然了,不能使用多核也不是说python的多线程没用,当遇到IO比较多的场景下,python的多线程还是比较有用的。虽然没法多核,但是却能总体上让进程使用更多的CPU时间,进而加速了完成任务所需要的时间。因为当python线程调用一些阻塞调用时,会事先释放掉GIL锁让别的线程继续执行,然后自己继续执行阻塞调用,执行完阻塞调用之后再重新申请获得GIL。当释放GIL锁执行阻塞调用时,此时的python线程与操作系统其它的线程一致,可以被多核调度。比如以python中的sleep为例, 当调用sleep时,其底层的源码实现如下,我们看到再调用select/Sleep这样的阻塞调用之前,都会有 Py_BEGIN_ALLOW_THREADS, 阻塞调用结束之后,都会有Py_END_ALLOW_THREADS, 这两个分别就是释放锁和重新申请获得锁的操作。一旦重新申请获得锁,该线程就得等待其他线程释放锁。而在此期间,由于自己释放了锁,别的线程得到了继续往下执行的机会,没有浪费自己等待的时间。

static int

pysleep(_PyTime_t secs)

{

_PyTime_t deadline, monotonic;

#ifndef MS_WINDOWS struct timeval timeout;

int err = 0;

#else _PyTime_t millisecs;

unsigned long ul_millis;

DWORD rc;

HANDLE hInterruptEvent;

#endif

deadline = _PyTime_GetMonotonicClock() + secs;

do {

#ifndef MS_WINDOWS if (_PyTime_AsTimeval(secs, &timeout, _PyTime_ROUND_CEILING) < 0)

return -1;

Py_BEGIN_ALLOW_THREADS

err = select(0, (fd_set *)0, (fd_set *)0, (fd_set *)0, &timeout);

Py_END_ALLOW_THREADS

if (err == 0)

break;

if (errno != EINTR) {

PyErr_SetFromErrno(PyExc_OSError);

return -1;

}

#else millisecs = _PyTime_AsMilliseconds(secs, _PyTime_ROUND_CEILING);

if (millisecs > (double)ULONG_MAX) {

PyErr_SetString(PyExc_OverflowError,

"sleep length is too large");

return -1;

}

/* Allow sleep(0) to maintain win32 semantics, and as decreed* by Guido, only the main thread can be interrupted.*/

ul_millis = (unsigned long)millisecs;

if (ul_millis == 0 || !_PyOS_IsMainThread()) {

Py_BEGIN_ALLOW_THREADS

Sleep(ul_millis);

Py_END_ALLOW_THREADS

break;

}

hInterruptEvent = _PyOS_SigintEvent();

ResetEvent(hInterruptEvent);

Py_BEGIN_ALLOW_THREADS

rc = WaitForSingleObjectEx(hInterruptEvent, ul_millis, FALSE);

Py_END_ALLOW_THREADS

if (rc != WAIT_OBJECT_0)

break;

#endif

/* sleep was interrupted by SIGINT */

if (PyErr_CheckSignals())

return -1;

monotonic = _PyTime_GetMonotonicClock();

secs = deadline - monotonic;

if (secs < 0)

break;

/* retry with the recomputed delay */

} while (1);

return 0;

}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值