写在前面
最近工作中又遇到了GIL和多线程的问题,借此机会重新梳理一下。大致脉络仿照陈儒的《Python源码剖析》第15章Python多线程机制
,但会将平台转移到Linux下的Pthread线程库上。主要介绍CPython解释器的C实现,具体的Python库(thread,threading)的介绍可以参考Python Threading Module。同时会介绍下相关函数在Linux平台中的表现等。
python中的GIL锁
CPython中有一个全局的解释器锁叫做GIL
(global interpreter lock),对解释器中的共享变量提供保护。GIL不是必须的,可以通过对每个资源单独加锁的方式去掉GIL,也就是将GIL换成更细粒度的锁。实际上也有这么做的,不过结果是在单核上的性能不如有GIL的版本(2倍的差距),大量的细粒度锁的开销消耗了大量的资源。所以,Guido有篇很著名的文章It isn’t easy to remove GIL讨论这个问题。如果去掉GIL需要考虑两件事:
|
|
总之从Python3的尴尬处境可以简单知道,CPython中的GIL是不可能去除的。
做技术很多时候是在折中(tradeoff),就比如当年Linux用宏内核架构会被认为过时一样。性能高、简单(实现简单)好用(使用快速)几乎立于不败之地,更多参考见这本书The Unix Hackers Handbook。
GIL锁的类型以及语义
代码中的interpreter_lock
就是全局解释器锁,类型为PyThread_type_lock
,简单的void指针,然后再根据不同的平台转换成对应类型的指针,在Linux中的类型是pthread_lock
指针。
|
|
之所以需要pthread_lock
而不是直接使用原生的pthread_mutex_t
,是因为Pthread的标准有未定义的(undefined)部分,主要是:
|
|
因此,Python实现了pthread_lock
规避标准中的未定义部分。pthread_lock
分为3个成员:
|
|
从上面的结构可以看到一个共识,加锁与代码之间的关系是为了使不同进程/线程串行执行代码,串行执行的结果就是共享资源操作结果的一致性。保证并行执行的正确性有几种不同的方法:
|
|
pthread_lock
有1组4个函数(接口)调用,分别是:
|
|
Python中只使用了默认类型的锁,pthread_mutex_init
中的第二个参数为NULL,mut本身是一个多次请求会等待的锁,不过Python本身不使用等待的语义。Phtread_lock
和一组操作函数创造了一个这样的线程锁:通过waitflag指定获取锁时是否等待,成功获取返回0,失败返回非0;释放锁总能成功并唤醒至少1个等待锁的线程。
最后补充一下PyThread_allocate_lock
中会调用PyThread_init_thread
,进而调用PyThread__init_thread
进行线程初始化。这是因为有些平台上进程和线程是完全分离的概念,需要调用相应的函数启动多线程库。在Linux的Pthread平台下是一进程多线程的模型,默认情况下一个进程也是一个线程,因此在这种情况下PyThread__init_thread
是空的函数,完全不需要启动线程库的动作。
python中的线程
Python中通过thread等Python库启动的线程就是一个普通的Pthread线程,与C程序中调用pthread_create
启动的线程没有本质区别,只不过Python中同一时间只有一个线程在运行,具体哪个线程能运行通过竞争GIL决定的。Python中线程的本质:
|
|
python启动
python中的线程启动通过thread.start_new/start_new_thread
函数,然后调到CPython中的thread_PyThread_start_new_thread
。这个函数主要处理用户调用时传入的参数,然后将启动函数和参数包装入bootstate结构,然后以t_bootstrap
启动原生线程(linux下的pthread线程)。需要bootstate的主要原因有两点:
|
|
|
|
从调用thread模块一直到PyThread_start_new_thread
的线程都是主线程在运行;调用pthread_create
然后进入t_bootstrap
的线程是子线程。先看下主线程的调用路径,主路径通过pthread_create
创建了子线程然后返回。注意这个时候主线程是拿者GIL锁的。
|
|
这里面需要注意一点,主线程创建子线程后就detach了,所以Python中的子线程都是分离的。然后看下子线程的调用路径,需要说明从pthread_create
创建子线程开始运行到t_bootstrap
中的PyEval_AcquireThread
的这段代码是没有运行在Python的虚拟机中的,也就是说这段代码和GIL没有关系,在这期间主线程和子线程(在多核机器中)是可以同时运行的(不排除争夺其它的锁而导致挂起)。
|
|
上面是子线程的调用路径。到这里还有两个问题没有解决,第一个是子线程如何进入虚拟机运行的(进入PyEval_EvalFrame
);第二个是主线程何时释放GIL以便子线程在t_bootstrap
中获取到而运行。
第一个问题
先说第一个问题,在子线程调用路径中最后会调用tp_call
,假设用户的子线程函数是函数,类似下面这样:
|
|
那么tp_call
对应的是Python实现的function_call
函数,如下所示:
|
|
function_call
也在同一个文件中定义,它的调用路径见下。从中可以看到,每个线程对应一个Frame对象,也就是一个Python虚拟机,而不是一个Python虚拟机对应多个Python线程。(不像CPU那样,每个CPU对应多个线程,每个线程通过保存上下文设置寄存器进行切换)。
|
|
第二个问题
第二个问题,主线程何时释放GIL锁。Python代码在虚拟机Frame中运行,其中有个变量_Py_Ticker
。当_Py_Ticker
小于0时,Python会释放GIL锁进行一次Python线程的调度。
|
|
需要说明几点:
|
|
补充
上面提到Pthread线程和Python线程,按照CPython实现来看,Pthread线程和Python是一一对应的。称为Python线程侧重于正在运行Python代码时的线程(PyEval_EvalFrame
部分);称为Pthread线程侧重于CPython中线程的创建/销毁时的线程,等同起来看也没有任何问题。