我之前在系列文章中写过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 updatelwn.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;
}