导航目录
1. 什么是Python GIL?
1.1 什么是解释器?
Python作为一门解释性语言,先把源代码编译为字节码,再放进虚拟机中执行,整个过程是由解释器执行并完成的。类似的还有JavaScript和PHP等。
但是解释器并不是只有一种,官方的解释器是基于C语言开发的CPython。但是除了CPython,还有基于Java实现的Jython、基于R 语言实现的RPython等等。
1.2 什么是GIL?
GIl 是一种互斥锁
1.2.1 什么是互斥锁?
在编程中,引入了对象互斥锁的概念,来保证共享数据操作的完整性。每个对象都对应于一个可称为” 互斥锁” 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。
当多个线程几乎同时修改某一个共享数据的时候,需要进行同步控制。
线程同步能够保证多个线程安全访问竞争资源,最简单的同步机制是引入互斥锁。
互斥锁为资源引入一个状态:锁定/非锁定
某个线程要更改共享数据时,先将其锁定,此时资源的状态为“锁定”,其他线程不能更改;直到该线程释放资源,将资源的状态变成“非锁定”,其他的线程才能再次锁定该资源。互斥锁保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。
1.3 为什么会产生GIL?
GIL的产生是因为CPython的内存管理不安全
In CPython, the global interpreter lock, or GIL, is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once. The GIL prevents race conditions and ensures thread safety. A nice explanation of how the Python GIL helps in these areas can be found here. In short, this mutex is necessary mainly because CPython’s memory management is not thread-safe。
在CPython中,GIL是一个互斥锁,它在任一时刻只允许一个线程对字节码进行执行。这样避免了竞争危害,从而保证了线程安全。简单来说就是,互斥锁之所以存在是因为CPython的内存管理不是“线程安全的”。
由此Python的官方文档可知, 因为因为CPython的内存管理不是“线程安全的”, 所以需要互斥锁, 那么自然会引出另外两个问题。
GIL的产生是因为CPython的内存管理不安全
1.4 为什么CPython的内存管理不安全?
Python 第一次发布是在 1991 年,当时的 CPU 都是单核,单核中,多线程主要为了一边做IO,一边做 CPU 计算而设计的,Python 编译器是由 C 语言编写的,因此也叫 CPython,那时候很多编程语言没有自动内存管理的功能,为了实现自动垃圾回收,Python 为每一个对象进行了引用计数,当引用计数为 0 的时候说明该对象可以回收,从而释放内存了,比如:
>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3
这里 a对象就有 3 个引用,
- 一个是本身,
- 一个是变量 b,
- 一个是 getrefcount 函数的参数,
如果此时又有一个线程引用了 a,那么引用计数再增加 1,如果某个线程使用了 a 后运行结束,那么引用计数就减少 1,多线程对同一个变量「引用计数」进行修改,就会遇到 race conditions(竞争)。
1.5 怎么解决内存管理不安全的问题?
为了避免 race conditions,最简单有效的办法就是加一个互斥锁。但如果对每一个对象都加锁,有可能引发另一个问题,就是死锁,而且频繁的获取和释放会导致性能下降。
所以至此, 最简单有效的方法就是加一个解释器锁,线程在执行任何字节码时都先获取解释器锁,这就避免了死锁,而且不会有太多的性能消耗。当时 CPU 都是单核,而且这种 GIL 设计简单,并不会影响性能,因此一直沿用至今天。GIL 存在最主要的原因,就是因为 Python 的内存管理不是线程安全的,这就是 GIL 产生并存在的主要缘由。
1.5.1 互斥锁的代码实例
threading模块中定义了Lock类,可以方便的处理锁定:
# 创建锁
mutex = threading.Lock()
# 锁定
mutex.acquire()
# 释放
mutex.release()
- 如果这个锁之前是没有上锁的,那么acquire不会堵塞
- 如果在调用acquire对这个锁上锁之前,它已经被 其他线程上了锁,那么此时acquire会堵塞,直到这个锁被解锁为止
1.5.1.1 互斥锁在for循环外面
import threading
import time
# 定义一个全局变量
g_num = 0
def test1(num):
global g_num
# 上锁,如果之前没有被上锁,那么此时 上锁成功
# 如果上锁之前 已经被上锁了,那么此时会堵塞在这里,直到 这个锁被解开位置
mutex.acquire()
for i in range(num):
g_num += 1
mutex.release() # 解锁
print("-----in test1 g_num=%d----" % g_num)
def test2(num):
global g_num
mutex.acquire() # 上锁
for i in range(num):
g_num += 1
mutex.release() # 解锁
print("-----in test2 g_num=%d=----" % g_num)
# 创建一个互斥锁,默认是没有上锁的
mutex = threading.Lock()
def main():
t1 = threading.Thread(target=test1, args=(1000000,))
t2 = threading.Thread(target=test2, args=(1000000,))
t1.start()
t2.start()
# 等待上面的2个线程执行完毕....
time.sleep(2)
print("-----in main Thread g_num = %d---" % g_num)
if __name__ == "__main__":
main()
#-----in test1 g_num=1000000----
#-----in test2 g_num=2000000=----
#-----in main Thread g_num = 2000000---
1.5.1.2 互斥锁在for循环里面
import threading
import time
# 定义一个全局变量
g_num = 0
def test1(num):
global g_num
for i in range(num):
mutex.acquire() # 上锁
g_num += 1
mutex.release() # 解锁
print("---test1---g_num=%d"%g_num)
def test2(num):
global g_num
for i in range(num):
mutex.acquire() # 上锁
g_num += 1
mutex.release() # 解锁
print("---test2---g_num=%d"%g_num)
# 创建一个互斥锁
# 默认是未上锁的状态
mutex = threading.Lock()
# 创建2个线程,让他们各自对g_num加1000000次
p1 = threading.Thread(target=test1, args=(1000000,))
p1.start()
p2 = threading.Thread(target=test2, args=(1000000,))
p2.start()
# 等待计算完成
while len(threading.enumerate()) != 1:
time.sleep(1)
print("2个线程对同一个全局变量操作之后的最终结果是:%s" % g_num)
# ---test1---g_num=1909909 # ---test2---g_num=2000000 # 2个线程对同一个全局变量操作之后的最终结果是:2000000
1.5.1.3 上锁解锁过程
- 当一个线程调用锁的acquire()方法获得锁时,锁就进入“locked”状态。
- 每次只有一个线程可以获得锁。如果此时另一个线程试图获得这个锁,该线程就会变为“blocked”状态,称为“阻塞”,直到拥有锁的线程调用锁的release()方法释放锁之后,锁进入“unlocked”状态。
- 线程调度程序从处于同步阻塞状态的线程中选择一个来获得锁,并使得该线程进入运行(running)状态。
1.5.1.4 锁的好处
- 确保了某段关键代码只能由一个线程从头到尾完整地执行
1.5.1.5 锁的坏处
- 阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了
- 由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁。
1.5.2 死锁代码实例
import threading
import time
#创建互斥锁
lock = threading.Lock()
#根据下标去取值, 保证同一时刻只能有一个线程去取值
def get_value(index):
# 上锁
lock.acquire()
print(threading.current_thread())
my_list = [3,6,8,1]
# 判断下标释放越界
if index >= len(my_list):
print("下标越界:", index)
return
value = my_list[index]
print(f'值是:{value}')
time.sleep(0.2)
# 释放锁
lock.release()
if __name__ == '__main__':
# 模拟大量线程去执行取值操作
for i in range(30):
sub_thread = threading.Thread(target=get_value, args=(i,))
sub_thread.start()
出现死锁的情况, 程序无法正常停止, 一直在等待
<Thread(Thread-1, started 30364)>
值是:3
<Thread(Thread-2, started 27120)>
值是:6
<Thread(Thread-3, started 29632)>
值是:8
<Thread(Thread-4, started 29988)>
值是:1
<Thread(Thread-5, started 20984)>
下标越界: 4
1.5.3
1.5.4 避免死锁的代码示例
# 在合适的地方释放锁
import threading
import time
#创建互斥锁
lock = threading.Lock()
#根据下标去取值, 保证同一时刻只能有一个线程去取值
def get_value(index):
# 上锁
lock.acquire()
print(threading.current_thread())
my_list = [3,6,8,1]
if index >= len(my_list):
print("下标越界:", index)
# 当下标越界需要释放锁,让后面的线程还可以取值
lock.release()
return
value = my_list[index]
print(value)
time.sleep(0.2)
# 释放锁
lock.release()
if __name__ == '__main__':
# 模拟大量线程去执行取值操作
for i in range(10):
sub_thread = threading.Thread(target=get_value, args=(i,))
sub_thread.start()
<Thread(Thread-1, started 30336)>
3
<Thread(Thread-2, started 5920)>
6
<Thread(Thread-3, started 28308)>
8
<Thread(Thread-4, started 27324)>
1
<Thread(Thread-5, started 26840)>
下标越界: 4
<Thread(Thread-6, started 30104)>
下标越界: 5
<Thread(Thread-7, started 28900)>
下标越界: 6
<Thread(Thread-8, started 2676)>
下标越界: 7
<Thread(Thread-9, started 28912)>
下标越界: 8
<Thread(Thread-10, started 30068)>
下标越界: 9
Process finished with exit code 0
2. 最后, GIL导致了什么结果?
2.1 正面
解决安全问题。
2.2 负面
2.2.1 单线程CPU消耗
约16% (i5 11代)
import threading
def dead_loop():
while True:
pass
dead_loop()
2.2.2 双线程CPU消耗
仍然约16%, 而不是32%。
import threading
def dead_loop():
while True:
pass
# 新起一个死循环线程
t = threading.Thread(target=dead_loop)
t.start()
# 主线程也进入死循环
dead_loop()
t.join()
dead_loop()
2.2.3 结论:
- 当双线程时, Cpython 缩小好的CPU的资源, 和单线程时一致。
- Cpython 当前只能运行一个GIL线程。
如果再更进一步, 尝试十个或N个线程, Python的CPU利用率仍然不变。
但是用C、C++或Java来改写相同的死循环,直接可以把全部核心跑满,为什么Python不行?正是GIL。
Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁:Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。
在Python中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过C扩展来实现,不过这样就失去了Python简单易用的特点。
不过,也不用过于担心,Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。
2.3 其它
- 对于 “Python的GIL” 这种表述是不够严谨, 但也不算错。
- GIL是相对于Cpython 解释器而言, 而不是Python 语言。
- Cpython是用来解析Python代码.
- Cpython是目前最流行的, 主流的解释器.
最后, 只要你愿意, 你可以自己开发一个没有GIL的解释器.
3.
本站文章除单独注明外均为原创,本文链接https://bowmanjin.com/592,未经允许请勿转载。
请先
!