参考博客:https://www.cnblogs.com/mindsbook/archive/2009/10/15/thread-safety-and-GIL.html
https://www.cnblogs.com/MnCu8261/p/6357633.html
http://python.jobbole.com/87743/
一、前言
在多核cpu的背景下,基于多线程以充分利用硬件资源的编程方式也不断发展,也就是在同一时间,可以运行多个任务。但是Cpython中由于GIL的存在,导致同一时间内只有一个线程在运行。GIL的全称为Global Interpreter Lock,也就是全局解释器锁。存在在Python语言的主流执行环境Cpython中,GIL是一个真正的全局线程排他锁,在解释器执行任何Python代码时,都需要获得这把GIL锁。虽然 CPython 的线程库直接封装操作系统的原生线程,但 CPython 进程做为一个整体,同一时间只会有一个获得了 GIL 的线程在跑,其它的线程都处于等待状态等着 GIL 的释放。GIL 直接导致 CPython 不能利用物理多核的性能加速运算。
不同的线程也是被分配到不同的核上面运行的,但是同一时间只有一个线程在运行
二、为什么存在GIL
2.1 线程安全
想要利用多核的优势,我们可以采用多进程或者是多线程,两者的区别是资源是否共享。前者是独立的,而后者是共享的。相对于进程而言,多线程环境最大的问题是如果保证资源竞争、死锁、数据修改等。于是就有了线程安全。
线程安全 是在多线程的环境下, 线程安全能够保证多个线程同时执行时程序依旧运行正确, 而且要保证对于共享的数据,可以由多个线程存取,但是同一时刻只能有一个线程进行存取.
既然,多线程环境下必须存在资源的竞争,那么如何才能保证同一时刻只有一个线程对共享资源进行存取?
加锁, 加锁可以保证存取操作的唯一性, 从而保证同一时刻只有一个线程对共享数据存取。
通常加锁也有2种不同的粒度的锁:
- fine-grained(所谓的细粒度), 那么程序员需要自行地加,解锁来保证线程安全
- coarse-grained(所谓的粗粒度), 那么语言层面本身维护着一个全局的锁机制,用来保证线程安全
前一种方式比较典型的是 java, Jython 等, 后一种方式比较典型的是 CPython (即Python)。
2.2 Python自身特点
依照Python自身的哲学, 简单 是一个很重要的原则,所以, 使用 GIL 也是很好理解的。多核 CPU 在 1990 年代还属于类科幻,Guido van Rossum 在创造 python 的时候,也想不到他的语言有一天会被用到很可能 多核的 CPU 上面,一个全局锁搞定多线程安全在那个时代应该是最简单经济的设计了。简单而又能满足需求,那就是合适的设计(对设计来说,应该只有合适与否,而没有好与不好)。
三、线程切换
一个线程无论何时开始睡眠或等待网络 I/O,其他线程总有机会获取 GIL 执行 Python 代码。这是协同式多任务处理。CPython 也还有抢占式多任务处理。如果一个线程不间断地在 Python 2 中运行 100次指令,或者不间断地在 Python 3 运行15 毫秒,那么它便会放弃 GIL,而其他线程可以运行。
3.1 协同式多任务处理
当一项任务比如网络 I/O启动,而在长的或不确定的时间,没有运行任何 Python 代码的需要,一个线程便会让出GIL,从而其他线程可以获取 GIL 而运行 Python。这种礼貌行为称为协同式多任务处理,它允许并发,多个线程同时等待不同事件。
def do_connect(): s = socket.socket() s.connect((‘python.org‘, 80)) # drop the GIL for i in range(2): t = threading.Thread(target=do_connect) t.start()
两个线程在同一时刻只能有一个执行 Python ,但一旦线程开始连接,它就会放弃 GIL ,这样其他线程就可以运行。这意味着两个线程可以并发等待套接字连接,这是一件好事。在同样的时间内它们可以做更多的工作。
3.2 抢占式多任务处理
如果没有I/O中断,而是CPU密集型的的程序,解释器运行一段时间就会放弃GIL,而不需要经过正在执行代码的线程允许,这样其他线程便能运行。在python3中,这个时间间隔是15毫秒。
四、Python中的线程安全
如果一个线程可以随时失去 GIL,你必须使让代码线程安全。 然而 Python 程序员对线程安全的看法大不同于 C 或者 Java 程序员,因为许多 Python 操作是原子的。
在列表中调用 sort(),就是原子操作的例子。线程不能在排序期间被打断,其他线程从来看不到列表排序的部分,也不会在列表排序之前看到过期的数据。原子操作简化了我们的生活,但也有意外。例如,+ = 似乎比 sort() 函数简单,但+ =不是原子操作。
在python 2中(python3中结果没有问题):
# -*- coding: UTF-8 -*- import time import threading n = 0 def add_num(): global n time.sleep(1) n += 1 if __name__ == ‘__main__‘: thread_list = [] for i in range(100): t = threading.Thread(target=add_num) t.start() thread_list.append(t) for t in thread_list: t.join() print ‘final num:‘, n
输出:
[[email protected] ~]# python mutex.py final num: 98 [[email protected] ~]# python mutex.py final num: 100 [[email protected] ~]# python mutex.py final num: 96 [[email protected] ~]# python mutex.py final num: 99 [[email protected] ~]# python mutex.py final num: 100
得到的结果本来应该是100,但是实际上并不一定。
原因就在于,运行中有线程切换发生,一个线程失去了GIL,当一个线程A获取n = 43时,还没有完成n +=1这个操作,就失去了GIL,此时正好另一个线程B获取了GIL,并也获取了 n = 43,B完成操作后,n = 44。可是先前那个线程A又获得了GIL,又开始运行,最后也完成操作 n = 44。所有最后的结果就会出现偏差。
上图就是n += 1运行到一半时失去GIL后又获得GIL的过程。
五、Mutex排他锁
如何解决上面的偏差,保证结果的正确性?其实我们要做的就是确保每一次的运行过程是完整的,就是每次线程在获取GIL后,要将得到的共享数据计算完成后,再释放GIL锁。那又如何能做到这点呢?还是加锁,给运行的程序加锁,就能确保在程序运行时,必须完全运行完毕。
# -*- coding: UTF-8 -*- import time import threading n = 0 lock = threading.Lock() # 添加一个锁的实例 def add_num(): global n with lock: # 获取锁 n += 1 if __name__ == ‘__main__‘: thread_list = [] for i in range(100): t = threading.Thread(target=add_num) t.start() thread_list.append(t) for t in thread_list: t.join() # 主线程等待所有线程执行完毕 print ‘final num:‘, n
注:给程序加锁,程序就变成串行的了。所以程序中不能有sleep,同样数据量也不能特别大,否则会影响效率