言归正传, 之前看到openstack源码中使用了eventlet的库, 在整个项目中还占了比较重要的地位, Eventlet库在OpenStack服务中上镜率很高,尤其是在服务的多线程和WSGI Server并发处理请求的情况下,了解eventlet库是很必要的。网上找找有很多介绍eventlet的资料, 推荐几篇:
http://blog.csdn.net/gaoxingnengjisuan/article/details/12913275
http://eventlet.net/doc/patching.html
https://code.google.com/p/libhjw/wiki/notes_on_greenlet#greenlet_not_stated
总的来说eventlet是一个线程库, 用以提供线程服务, 它对python内核库的很多部分都做了修改, 而evenlet底层又依赖于greenlet. 这个greenlet就比较有意思了, 它提供了一种伪进程的服务, 最大的特点是greenlet的进程间使用让渡的方式进行进程切换, 也就是只有当前进程主动放弃的情况下, 后续进程才能获得cpu的使用权. 学过操作系统的小伙伴肯定都有印象, 这种类型的进程调度有其优势, 可以有效的提高资源的利用率, 避免了不必要的进程切换, 但是缺点也同样突出, 就是不安全, 一旦有一个进程出现异常没有释放cpu, 后续的进程都无法调度. 所以现代操作系统已经不再采用这种调度方式了. eventlet大概也是觉得这种方式太过麻烦, 所以在greenlet基础上增加了扩展的调度器,使其复合现代进程调度的模型.
看完上面的内容让我隐隐觉得有点不安, 啥样的情况需要openstack修改语言核心模块的包, 这种情况在其他语言的使用中应该是比较罕见的, 这么大的项目不担心外部库不稳定么? 直到有一天, 我认识了它 GIL
先来解释一下GIL, GIL
并不是Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念。就好比C++是一套语言(语法)标准,但是可以用不同的编译器来编译成可执行代码。有名的编译器例如GCC,INTEL C++,Visual C++等。Python也一样,同样一段代码可以通过CPython,PyPy,Psyco等不同的Python执行环境来执行。像其中的JPython就没有GIL。然而因为CPython是大部分环境下默认的Python执行环境。所以在很多人的概念里CPython就是Python,也就想当然的把GIL
归结为Python语言的缺陷。所以这里要先明确一点:GIL并不是Python的特性,Python完全可以不依赖于GIL
那么CPython实现中的GIL又是什么呢?GIL全称Global Interpreter Lock
为了避免误导,我们还是来看一下官方给出的解释:
In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)
好吧,是不是看上去很糟糕?一个防止多线程并发执行机器码的一个Mutex,乍一看就是个BUG般存在的全局锁嘛!
GIL也是个挺复杂的概念, 大家可以自行搜索一下, 总的来说这东西是个历史遗留问题, 由于cpython在设计之初对多线程的支持不足, 而造成多线程程序执行效率非常低(和它的调度算法有关系), 可当人们认识到这个问题的时候, 大量的核心库已经对其产生了依赖, 而要消除这种依赖所花费的工作是非常惊人的. 基本上个人是无能为力了, 只能等那些计算机界的大佬门去想办法吧. 既然我们做不了什么就来看看它的影响到底有多大吧
下面我们就对比下Python在多线程和单线程下得效率对比。测试方法很简单,一个大循环计数器函数。一个通过单线程执行两次,一个多线程执行。最后比较执行总时间。测试环境为双核的Mac pro。注:为了减少线程库本身性能损耗对测试结果带来的影响,这里单线程的代码同样使用了线程。只是顺序的执行两次,模拟单线程。
顺序执行的单线程(single_thread.py)
#! /usr/bin/python
from threading import Thread
import time
def my_counter():
i = 0
for _ in range(40000000):
i = i + 1
return True
def main():
thread_array = {}
start_time = time.time()
for tid in range(2):
t = Thread(target=my_counter)
t.start()
t.join()
end_time = time.time()
print("Total time: {}".format(end_time - start_time))
if __name__ == '__main__':
main()
total time: 4.30995702744
同时执行的两个并发线程(multi_thread.py)
#! /usr/bin/python
from threading import Thread
import time
def my_counter():
i = 0
for _ in range(40000000):
i = i + 1
return True
def main():
thread_array = {}
start_time = time.time()
for tid in range(2):
t = Thread(target=my_counter)
t.start()
thread_array[tid] = t
for i in range(2):
thread_array[i].join()
end_time = time.time()
print("Total time: {}".format(end_time - start_time))
if __name__ == '__main__':
main()
total time: 7.66145706177
各位看官看到这里是不是和我一样震惊, 本人的小本怎么说也是i5两核四线程(领导领导看这里, 有没有给我换本的冲动, 这么个破循环跑这么久还让不让人开发了), 同一个计算型任务, 并行两进程居然比单个进程还要慢, 这不科学啊. 为了让比较更为明显, 我又用java实现了相同的任务, 由于语言特性的差别, 代码执行的速度相差很大, 我调整了循环的周期. 比较结果如下
single test
run time:10 second
run time:4 second
当前GIL设计的缺陷
基于pcode数量的调度方式
按照Python社区的想法,操作系统本身的线程调度已经非常成熟稳定了,没有必要自己搞一套。所以Python的线程就是C语言的一个pthread,并通过操作系统调度算法进行调度(例如linux是CFS)。为了让各个线程能够平均利用CPU时间,python会计算当前已执行的微代码数量,达到一定阈值后就强制释放GIL。而这时也会触发一次操作系统的线程调度(当然是否真正进行上下文切换由操作系统自主决定)。
伪代码
while True:
acquire GIL
for i in 1000:
do something
release GIL
/* Give Operating System a chance to do thread scheduling */
这种模式在只有一个CPU核心的情况下毫无问题。任何一个线程被唤起时都能成功获得到GIL(因为只有释放了GIL才会引发线程调度)。但当CPU有多个核心的时候,问题就来了。从伪代码可以看到,从release GIL
到acquire GIL
之间几乎是没有间隙的。所以当其他在其他核心上的线程被唤醒时,大部分情况下主线程已经又再一次获取到GIL了。这个时候被唤醒执行的线程只能白白的浪费CPU时间,看着另一个线程拿着GIL欢快的执行着。然后达到切换时间后进入待调度状态,再被唤醒,再等待,以此往复恶性循环。
PS:当然这种实现方式是原始而丑陋的,Python的每个版本中也在逐渐改进GIL和线程调度之间的互动关系。例如先尝试持有GIL在做线程上下文切换,在IO等待时释放GIL等尝试。但是无法改变的是GIL的存在使得操作系统线程调度的这个本来就昂贵的操作变得更奢侈了。
关于GIL影响的扩展阅读
为了直观的理解GIL对于多线程带来的性能影响,这里直接借用的一张测试结果图(见下图)。图中表示的是两个线程在双核CPU上得执行情况。两个线程均为CPU密集型运算线程。绿色部分表示该线程在运行,且在执行有用的计算,红色部分为线程被调度唤醒,但是无法获取GIL导致无法进行有效运算等待的时间。
由图可见,GIL的存在导致多线程无法很好的立即多核CPU的并发处理能力。
那么Python的IO密集型线程能否从多线程中受益呢?我们来看下面这张测试结果。颜色代表的含义和上图一致。白色部分表示IO线程处于等待。可见,当IO线程收到数据包引起终端切换后,仍然由于一个CPU密集型线程的存在,导致无法获取GIL锁,从而进行无尽的循环等待。
简单的总结下就是:Python的多线程在多核CPU上,只对于IO密集型计算产生正面效果;而当有至少有一个CPU密集型线程存在,那么多线程效率会由于GIL而大幅下降。
eventlet登场
按照前面看到的python在处理多线程中存在很大的问题, 那么引入eventlet又会怎么样呢?
顺序执行的单线程(single_eventlet_thread.py)
from eventlet.green import threading import time def my_counter(): i = 0 for _ in range(40000000): i += 1 return True def main(): thread_array = {} start_time = time.time() for _ in range(2): t = threading.Thread(target=my_counter) t.start() t.join() end_time = time.time() print "total time: {}".format(end_time - start_time) main()
total time: 4.17306399345
同时执行的两个并发线程(multi_eventlet_thread.py)
from eventlet.green import threading import time def my_counter(): i = 0 for _ in range(40000000): i += 1 return True def main(): thread_array = {} start_time = time.time() for tid in range(2): t = threading.Thread(target=my_counter) t.start() thread_array[tid] = t for i in range(2): thread_array[i].join() end_time = time.time() print "total time: {}".format(end_time - start_time) main()