前两日帮同学解决的问题中涉及到python的线程、协程概念及其调度过程,加上之前总听说同学们去面试的时候会被问到python的多线程问题,以及同学们平常自己写一些数据处理的程序的时候也会想要使用并行的手段进行程序加速。基于这些就想写一篇关于线程、协程的总结。本篇文章假定读者已经有一些操作系统知识的基础,并且几乎不涉及到具体编程,主要研究总结python独特的线程切换调度问题,以及最近用的越来越多的协程的概念和协程切换调度问题。需要注意的是,由于Windows系统和Linux系统的一些差别,为统一理解,接下来的所有进程线程讨论我们都是基于Windows系统。
一、进程、线程概念回顾
Windows系统中,进程只是资源的分配单位,而线程才是CPU调度运行的基本单位。也就是说,即使是多个进程的程序,调度依然是按照多个线程去进行调度,由于CPU时间片分配给每个独立调度的线程,拥有四个线程的进程比拥有一个线程的进程会拥有更多的CPU时间片,就像原本一个人干的活,现在四个人同时做,自然也就达到了加速程序的效果。就CPU利用率来讲,如果一个有四个线程的进程运行在一个四核的CPU机器上,那么核的利用率可以达到100%,即所有的核都可以调度运行一个线程, 不会出现一方有难,八方围观的情况。同样,四个单线程进程也能使四核的CPU机器计算资源利用率达到100%,因为每个进程中的线程被独立调度执行。那么如果CPU按照线程独立调用,我们跑python程序的时候,如果同时使用四个线程进行运算是不是会加速程序四倍呢?很不幸的是,并不是这样的。
一核有难,多核围观
二、成也GIL,败也GIL
当我们被问到python多线程的时候,回答一般都会涉及到GIL,但是GIL其实不是python本身的特性,而是CPython实现时引入的一种机制, 而JPython的实现里面里就没有GIL。这里我们主要研究CPython中由于GIL的存在而导致的独特的多线程困境,我们可以先看下GIL的官方说明:
In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once.
也就是说,GIL的存在本身就是为了阻止多个原生的线程同时执行python的字节码, 我们可以看下锁的实现数据结构
GIL锁数据结构
NRMUTEX中的thread_id就表明GIL锁目前被哪个thread拥有,只有一个线程拥有了GIL锁,他才能被解释器解释执行,同一个python进程里面的其他线程就需要等待NRMUTEX的释放。举个例子类比下,正常的多线程程序就像多个人同时干原本一个人干的活,由于多个人同时干,那么自然就会快不少,但是在Python的情况里面,这多个工人都得拿到一张令牌后才能干活,而令牌只有一个,一次只能发给一个工人,其他没拿到令牌的工人就得原地等待,直到拿到令牌为止,这样时时刻刻其实仍然只有最多一个工人在干活。这就会导致如下这个场景的问题出现:
python多线程困境:线程拿到GIL锁才能运行
比如一个拥有2个线程的python进程运行在2核的CPU上,我们假设每个线程都只涉及到纯CPU计算,不会被阻塞,只有线程运行的时间片到达才会进行线程切换,每个线程任务完成需要运行4s。我们编号2个线程为T1,T2,编号2个核为C1,C2.如果是两个个非python线程,是可以上做到上图所示的C1调度执行T1,C2调度执行T2, 2个线程并行执行,那么上述进程执行结束共需要4s。
但是由于CPython中GIL锁的存在,C1调度执行T1的时候,GIL锁被T1占着,T2拿不到GIL锁,处于阻塞的状态,等到T1执行结束或者执行的字节码行数到了设定的阈值,T1就会释放GIL锁,然后T2获得GIL锁之后再继续执行。这样的结果就是,这个拥有2个纯CPU计算线程的python程序进程运行结束需要8s,因为每个时刻,python进程中永远只有一个线程再被运行。那这就很胃疼了,这么看似乎python的多线程就没用了?也不是的, 上述情况下多线程没用,是因为我们假定的是每个线程运行代码都是纯CPU计算过程,不会遇到IO等阻塞操作,只在执行结束或者“轮转时间片”到了之后才会被切换,( 之所以打引号,是因为python的多线程调度的轮转时间片并不是常规CPU时间片,而是按照字节码来算的)。但是如果T1线程有IO操作会被阻塞,会在IO操作前提前释放GIL锁,进而T2线程获得GIL,可以正常被CPU调度执行,这样Python程序进程仍然处于继续运行的状态,而不会像单线程的时候遇到IO会被阻塞等待。话虽如此,除了少部分高端玩家,大部分情况下,我们用python的多线程时,不但没有发挥出多线程的并行威力,反而还承受了多线程的高昂的切换开销以及应对复杂的锁同步的问题。那么这个开销到底有多高昂呢?
三、Talk is cheap, show me the code
这个例子我是从[1]中的文章直接拿过来的,觉得还比较好的能说明在计算密集的时候python的多线程切换开销的影响。my_counter()就是一个纯CPU计算代码段,不会被阻塞。当线程运行my_counter()的时候只有在线程结束或者线程轮转时间片到达之后才会释放GIL锁,进行线程切换。
顺序执行单个线程,执行完一个再执行另一个,运行时间为10.8s
并发执行的多线程,运行时间17s
我们可以看到,由于主线程一直在阻塞等待,所以我们不考虑主线程的切换的情况下,顺序执行的过程中,线程切换只发生一次,就是第一个线程运行结束,然后切换到第二个线程进行运行,总共的运行时间为10.5s,在第二个程序中,我们同时创建两个子线程,“同时运行”my_counter(),python程序进程运行过程中,两个子线程会频繁的切换直到结束,操作系统就得不停的保存上下文,切换上下文,带来了很多额外的开销,两个子线程“同时运行”程序,时间非但没有缩短,反而长了近一倍,这就是python线程切换带来的开销。
这个例子中,我们看到频繁的线程切换开销还是很高昂的, 这样的话,我们就干脆用python的单线程好了,但是单线程进程运行过程中当线程调用IO被阻塞时任务就停滞了,有没有一种办法,既能让单线程进程即使运行到阻塞操作如读取文件时,线程能不被阻塞,继续完成一些其他的任务,同时还不用承担这么高昂的切换代价呢?有的,那就是协程该登场的时候了,感兴趣的话,可以考虑订阅下我的专栏,并欢迎每位读者提出自己的意见和建议哦~。
四、引用
[1] https://cloud.tencent.com/developer/article/1489753