开发多线程的应用系统,是在日常的软件开发中经常会遇到的需求。现在的编程语言都为多线程开发提供了很好的支持,无论是通过库的支持还是将多线程机制内建在语言之中。Python 也为多线程系统的开发提供了很好的支持。

同样身为动态语言,Ruby 也提供了多线程的支持,但是在Ruby 1.9 之前的多线程机制是在语言的实现中模拟了线程及线程调度机制,而并没有使用操作系统本身的线程机制(在以后的描述中,我们称为原生线程)。Ruby 1.9 中整合了YARV 作为Ruby 新的虚拟机,在YARV 中,将操作系统的原生线程引入了Ruby。每一个Ruby 线程都对是操作系统上的一个线程,在Ruby 内部,维护着一个全局资源锁,一个Ruby 线程必须首先获得这个锁,才能成为活动的线程,从而使用Ruby 虚拟机的全局资源。

这一切,在Python 中早已实现,Python 中的线程从一开始就是操作系统的原生线程,而Python 虚拟机也同样使用一个全局解释器锁(Global Interpreter Lock,GIL)来互斥线程对Python 虚拟机的使用。

15.1 GIL 与线程调度

为了理解Python 为什么需要Global Interpreter Lock(GIL),考虑这样的情形:假设有两个线程A、B,在两个线程中,都同时保存着对内存中同一对象obj 的引用,也就是说,这时obj->ob_refcnt 的值为2。如果A 销毁对obj 的引用,显然,A 将通过Py_DECREF调整obj 的引用计数值。我们知道,Py_DECREF 的整个动作可以分为两个部分:

--obj->ob_refcnt;
if(obj->ob_refcnt == 0) destory object and free memory。

如果A 在执行完第一个动作之后,obj->ob_refcnt 的值变为1。不幸的是,恰恰在这个时候,线程调度机制将A 挂起,而唤醒了B。更为不幸的是,B 同样也开始销毁对obj的引用。B 完成第一个动作之后,obj->ob_refcnt 为0,B 是一个幸运儿,它没有被线程调度打断,而是顺利地完成了接下来的第二个动作,将对象销毁,内存释放。好了,现在A 又被重新唤醒,可惜现在已经物是人非,obj->ob_refcnt 已经被B 减少到0,而不是当初的1。按照约定,傻乎乎的A 开始再一次地对已经销毁的对象进行对象销毁和内存释放的动作。这样的结局是什么?只有天知道。

为了支持多线程机制,一个基本的要求就是需要实现不同线程对共享资源访问的互斥,Python 也不例外,这正是引入GIL 的根源所在。Python 中的GIL 是一个非常霸道的互斥实现,正如它的名字所暗示的,GIL 是一个解释器(Interpreter)——为了呼应GIL中的Interpreter,本章中,我们会以解释器来称呼虚拟机——级的互斥机制,也就是说,在一个线程拥有了解释器的访问权之后,其他的所有线程都必须等待它释放解释器的访问权,即使这些线程的下一条指令并不会互相影响。初看上去,这样的保护机制粒度太大了,我们似乎只需要将可能被多个线程共享的资源保护起来即可,对于不会被多个线程共享的资源,完全可以不用保护。实际上,在Python 的发展历史中,确实出现过这样的解决方案,但是令人惊奇的,这样的方案在单处理器上的多线程实现的效率上却没有GIL 的方案好。所以现在Python 中的多线程机制是在GIL 的基础上实现的。

当然,这样的方案也意味着,无论如何,在同一时间,只能有一个线程能访问Python所提供的API。注意这里的同一时间对于单处理器是毫无意义的,因为单处理器的本质是不可能并行的,但是对于多处理器,情形就完全不同了,同一时间,确实可以有多个线程独立运行,然而Python 的GIL 限制了这样的情形,使得多处理器最终退化为单处理器,性能大打折扣。这一点其实早已被Python 社区所认识,也进行了大量的探索。大约在99年的时候,Greg Stein 和Mark Hammond 两位老兄基于Python 1.5 创建了一份去除GIL 的branch,但是很不幸,这个分支在很多基准测试上,尤其是单线程操作的测试上,效率只有使用GIL 的Python 的一半左右。因为细粒度的锁机制会导致大量的加锁、解锁的操作,而加锁、解锁对于操作系统来说,是一个比较重量级的动作;另一方面,没有了GIL 的保护,编写Python 扩展模块的难度大大增加。所以,到目前Python 的最新版本2.5 为止,GIL 仍然是多线程机制的基石,而我们也仍然将视线集中在单处理器上。实际上,在去年5 月份Python 3000 的邮件列表上,Python 的创造者,Guido,提出了一个比较可行的的解决方案,在多处理器的情况下,完全可以创建多个Python 进程,充分使用多处理器,进程之间通过IPC 的方式进行通信。当然,Guido 也仅仅是提出了这么一个想法,并没有太多的实现细节透露出来。

图15-1 显示了我们对Python 的多线程机制所建立的一个粗略的模型。

图15-1 Python 线程机制的粗略模型

从之前的分析中,我们知道,对于Python 而言,字节码解释器是Python 的核心所在,所以Python 通过GIL 来互斥不同线程对解释器的使用。在图15-1 中,三个拟人化的线程A,B 和C 都需要使用解释器来执行字节码,以完成某种计算,但是在这之前,它们必须获得GIL,因为GIL 把守着通往字节码解释器的大门。当某个线程(A)获得了GIL 之后,其他的两个线程(B,C)只能等待A 释放GIL 之后,然后才能进入解释器,执行一些计算。实际上,Python 的GIL 背后所保护的不仅仅是Python 的解释器,同样还有Python 的C API,在C/C++和Python 的混合开发中,在涉及到原生线程和Python 线程的相互协作时,也需要通过GIL 进行互斥。关于这一点,我们将在后面详细阐述。

那么A 在何时释放GIL 呢?如果等到A 使用完了解释器之后才释放GIL,这也就意味着,并行的计算退化为了串行的计算,要这样的多线程机制有什么意义呢?所有毫无疑问的,Python 拥有一套线程的调度机制。

对于线程调度机制而言,同操作系统的进程调度一样,最关键的是要解决两个问题:

在何时挂起当前线程,选择处于等待状态的下一个线程?

在众多的处于等待状态的候选线程中,选择激活哪一个线程?

在Python 的多线程机制中,这两个问题是分别由不同的层次解决的。对于何时进行线程调度的问题,是由Python 自身决定的。考虑一下操作系统是如何进行进程的切换的,当一个进程执行了一段时间之后,发生了时钟中断,操作系统响应时钟中断,并在这时开始进行进程的调度。同样,Python 中也是通过软件模拟了这样的时钟中断,来激活线程的调度。我们知道,Python 字节码解释器的工作原理是按照指令的顺序一条一条地顺序执行,Python 内部维护着一个数值,这个数值就是Python 内部的时钟,如果这个数值为N,则意味着Python 在执行了N 条指令以后应该立即启动线程调度机制,图15-2 显示了如何获得Python 内部默认设定的这个值。

图15-2 Python2.5 内部的“
时钟中断”间隔值


图15-2 显示的结果意味着,在当前的2.5 中,Python 的默认行为是在执行了100 条指令以后启动线程调度机制。实际上,这个值不仅仅是用来进行线程调度的,在内部,Python也使用它来检查是否有异步的事件(event)发生,需要处理。我们可以通过sys.setcheckinterval()来调节这个值。

现在我们知道了,Python 控制着什么时候进行线程调度,当一个线程获得了访问Python 解释器的所必须的GIL 并进入Python 解释器后,Python 内部的监测机制就开始启动,当这个线程执行了100 条指令之后,Python 解释器将强制挂起当前线程,开始切换到下一个处于等待状态的进程。

那么究竟Python 会在众多的等待线程中选择哪一个幸运儿呢?答案是,不知道。没错,对于这个问题,Python 完全没有插手,而是交给了底层的操作系统来解决。也就是说,Python 借用了底层操作系统所提供的线程调度机制来决定下一个进入Python 解释器的线程究竟是谁。

这一点至关重要,这就意味着Python 中的线程实际上就是操作系统所支持的原生线程,而并非如坊间所流传的那样:Python 的线程并非原生线程,而是模拟出来的。Python中的多线程机制正是建立在操作系统的原生线程的基础之上,对应不同的操作系统,有不同的实现,然而最终,在各不相同的原生线程的基础之上,Python 提供了一套统一的抽象机制,给Python 的使用者一个非常简单而方便的多线程工具箱,这就是Python 中的两个module:thread 以及在其之上的threading。

 

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐