文章目录
前言
全局解释器锁(Global Interpreter Lock,简称GIL)是CPython解释器中一个重要的机制,其存在主要是为了确保线程安全和简化内存管理。它限制了单个进程在同一时间只能执行其中一个线程的代码。
一、python为什么会有GIL?
Python 的全局解释器锁(Global Interpreter Lock,简称 GIL)是 CPython 解释器(Python 最常用的实现)中的一项设计特性,其主要目的是为了简化内存管理并保证线程安全。GIL 的存在对 Python 的多线程编程和性能有显著的影响,下面将详细解释 GIL 的由来、好处和坏处。
-
GIL 的由来
CPython的内存管理机制依赖于引用计数,即通过跟踪对象的引用数量来决定何时释放内存。在多线程环境中,如果没有适当的同步机制,多个线程同时修改对象的引用计数可能会导致数据不一致或内存泄漏。为了避免这种竞争条件(racecondition),CPython 引入了 GIL,确保任何时候只有一个线程能够执行 Python字节码,从而简化了内存管理并避免了复杂的线程同步问题。 -
GIL 的好处
简化内存管理:GIL 使得 CPython 的内存管理更加简单,因为不需要复杂的线程同步机制来处理引用计数的更新。
保证线程安全:GIL 防止了多线程环境下的数据竞争,确保了线程安全,降低了编写多线程程序的复杂度。
减少开发者负担:由于 GIL 的存在,开发者在编写多线程 Python 程序时,不需要过多关注线程同步问题,降低了学习和开发的门槛。
-
GIL 的坏处
限制多核处理器的并行能力:GIL 的存在意味着即使在多核处理器上,Python的多线程程序也无法充分利用所有核心进行并行计算。这在 CPU密集型任务中尤为明显,导致性能瓶颈。性能瓶颈:对于计算密集型的应用,GIL 成为性能的限制因素。在多线程环境下,线程频繁地争夺GIL,导致上下文切换和额外的开销,降低了程序的执行效率。
增加开发复杂性:为了绕过 GIL 的限制,开发者可能需要采用多进程、异步编程或其他技术,这增加了开发的复杂性和难度。
二、GIL和线程锁有什么联系
GIL(Global Interpreter Lock)锁和线程锁(通常指 threading 模块中的 Lock 或 RLock)在 Python 中是两个不同的概念,它们各自解决的问题领域不同,但又在多线程编程中相互关联。下面详细解释它们之间的联系:
1. GIL 锁
GIL 是 CPython 解释器中的一种机制,用于确保任何时候只有一个线程在执行 Python 字节码。GIL的主要目的是简化内存管理并保证线程安全,避免了多线程环境下复杂的线程同步问题。GIL通过限制多线程的并行执行来达到这一目的,这意味着即使在多核处理器上,Python 的多线程程序也无法充分利用所有核心进行并行计算。
2. 线程锁
线程锁(如 threading.Lock 或 threading.RLock)是 Python程序员在多线程编程中用于同步线程访问共享资源的工具。当多个线程试图访问或修改同一份数据时,线程锁可以确保同一时刻只有一个线程能够访问该资源,从而避免数据竞争和不一致状态。线程锁由程序员显式地控制,需要在代码中明确地加锁和解锁。
3. 联系
- 互补作用:GIL 保证了在CPython解释器层面的线程安全,而线程锁则是在应用层面提供更细粒度的控制,确保多线程访问共享资源时的正确性。GIL解决的是全局的线程安全问题,而线程锁解决的是局部的资源访问同步问题。
- 使用场景:在 I/O 密集型任务中,GIL 的影响较小,因为线程在等待 I/O 操作时会释放 GIL,此时其他线程可以获取 GIL并执行。然而,在 CPU 密集型任务中,GIL 成为性能瓶颈,此时线程锁可以用来保护共享数据,但并不能绕过 GIL 的限制。
- 绕过 GIL:在某些情况下,使用线程锁并不能完全解决 GIL 带来的性能问题。为了绕过 GIL 的限制,可以使用多进程(每个进程有自己的GIL)、异步 I/O(如 asyncio)或使用其他 Python 实现(如 PyPy,它没有 GIL)。
三、线程安全与锁
3.1 线程安全
线程安全(Thread Safety)是多线程编程中的一个关键概念,它指的是在多线程环境中,当多个线程同时访问和修改共享资源时,能够保证数据的一致性、完整性和正确性的能力。具体来说,线程安全涉及以下三个方面:
- 原子性(Atomicity):原子性确保操作要么完全执行,要么完全不执行,不会被其他线程中断。例如,如果一个线程正在执行一个涉及多个步骤的更新操作,原子性保证了这个操作不会在中间被另一个线程打断,从而避免了数据的不一致状态。
- 可见性(Visibility):可见性确保当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。在多线程环境中,如果没有适当的同步机制,一个线程对共享变量的修改可能不会立即对其他线程可见,导致数据竞争和不一致。
- 有序性(Ordering):有序性保证了程序执行的顺序按照代码的先后顺序进行,防止了指令重排序带来的问题。在多线程环境下,为了提高性能,处理器可能会重新排序指令,这可能导致一些意外的行为。有序性确保了代码的执行顺序与程序员的预期一致。
为了保证线程安全,常见的实现方法可以给加锁。
先来个线程安全的例子:
import threading
v1=[]
def func(arg):
v1.append(arg)
print(v1)
for i in range(20):
t = threading.Thread(target=func,args=(i,))
t.start()
这里是有20个线程,然后每个线程都把自己当时的 i 追加到 v1 列表中,线程安全在这里体现的应该是:B线程不会在A线程追加的过程中自己追加,它会等待A线程追加完执行完之后自己再追加。
此时如果给func加一个功能,比如在列表追加完之后,再执行一个读取列表最后一个值。类似于压栈和出栈的功能。如下例:
import threading
import time
v1=[]
def func(arg):
v1.append(arg)
time.sleep(0.1)
v1_tail = v1[-1]
print(arg,v1_tail)
for i in range(20):
t = threading.Thread(target=func,args=(i,))
t.start()
31071916 64171518 19148 19192 5 19 191919 1919 19 0 131919
121919 191119 19
19 1919
19
这个结果是啥阿?不如人意。原因是因为此时加读取列表最后一个值这一步之后,线程已经不再安全了。即B线程虽然不会在A线程追加的过程中自己追加,但是它会等待A线程追加完之后,自己再追加,从而先于A线程的读取最后一个值这一步。同理其他线程也是这样,并且每次执行这段时,得到的结果打印都是不一样的。
3.2 Lock(一次放生一个)
解决办法,可以给线程加锁:
import threading
import time
v1=[]
# lock 是一个 threading.Lock() 对象,用于确保在多线程环境中对 v1 的访问是线程安全的。当一个线程获取了锁,其他线程必须等待直到锁被释放。
lock = threading.Lock()
def func(arg):
# 当前线程获得锁,防止其他线程同时修改 v1
lock.acquire()
v1.append(arg)
time.sleep(0.1)
v1_tail = v1[-1]
print(arg,v1_tail)
# 释放锁,允许其他等待的线程获取锁并往下执行
lock.release()
for i in range(20):
t = threading.Thread(target=func,args=(i,))
t.start()
0 0
1 1
2 2
3 3
4 4
5 5
6 6
7 7
8 8
9 9
10 10
11 11
12 12
13 13
14 14
15 15
16 16
17 17
18 18
19 19
这样的结果和我们的预期一样,可以想象成每个线程入栈立刻出栈的操作。此时加了锁之后,线程安全了。
3.3 RLock(一次放生一个)
有如下程序实例,想想看运行结果是怎样的?
import threading
import time
v1 = []
# lock 是一个 threading.Lock() 对象,用于确保在多线程环境中对 v1 的访问是线程安全的。当一个线程获取了锁,其他线程必须等待直到锁被释放。
lock = threading.Lock()
def func(arg):
# 当前线程获得锁,防止其他线程同时修改 v1
lock.acquire()
lock.acquire() # 二次加锁
v1.append(arg)
time.sleep(0.1)
v1_tail = v1[-1]
print(arg, v1_tail)
# 释放锁,允许其他等待的线程获取锁并往下执行
lock.release()
lock.release()
for i in range(20):
t = threading.Thread(target=func, args=(i,))
t.start()
没错,程序像是挂死了一样,阻塞住了,不会往下运行。这里像是遇到了“死锁”。那怎么样修复这个问题?我们会用到RLock,如下
import threading
import time
v1 = []
# lock 是一个 threading.Lock() 对象,用于确保在多线程环境中对 v1 的访问是线程安全的。当一个线程获取了锁,其他线程必须等待直到锁被释放。
lock = threading.RLock()
def func(arg):
# 当前线程获得锁,防止其他线程同时修改 v1
lock.acquire()
lock.acquire()
v1.append(arg)
time.sleep(0.1)
v1_tail = v1[-1]
print(arg, v1_tail)
# 释放锁,允许其他等待的线程获取锁并往下执行
lock.release()
lock.release()
for i in range(20):
t = threading.Thread(target=func, args=(i,))
t.start()
这里会正常运行了。所以我们多用 RLock().
拓展:
Lock 和 RLock 都用于保护对共享资源的访问,但 RLock 提供了额外的可重入性,允许同一个线程多次获取同一个锁。
RLock 是一种特殊的锁,它允许同一个线程多次获取同一个锁,而不会导致死锁。当一个线程第一次调用 acquire()时,它会像普通锁一样获取锁。但是,如果同一个线程再次调用 acquire(),它不会被阻塞,而是可以继续执行。这使得 RLock 在需要嵌套锁定的情况下非常有用,例如,在一个锁内部调用另一个可能再次尝试获取相同锁的函数。
RLock 内部维护了一个计数器,记录了当前线程调用 acquire()的次数。只有当这个计数器减到零时,锁才会被完全释放,允许其他线程获取锁。
再来一个RLock的学习例子:
import threading
class Counter:
def __init__(self):
self.lock = threading.RLock()
self.count = 0
def increment(self):
with self.lock:
self.count += 1
print(self.count)
self.decrement()
def decrement(self):
with self.lock:
self.count -= 1
print(self.count)
c = Counter()
for i in range(10):
t = threading.Thread(target=c.increment)
t.start()
# 等待所有线程完成
for t in threading.enumerate():
if t != threading.current_thread():
t.join()
print(c.count)
拓展说明:
在这个例子中,Counter 类包含两个方法 increment 和 decrement,它们都使用 “with self.lock: ”语句来确保线程安全。由于 self.lock 是一个 RLock 对象,即使在 increment 方法内部调用 decrement,也不会导致死锁。
使用 with 语句时,Python 的上下文管理器协议会被触发。在进入 with 语句块之前,__enter__ 方法会被调用,这通常用于执行一些设置操作,如获取锁。当 with 语句块执行完毕或在其中抛出异常时,__exit__ 方法会被调用,这通常用于执行清理操作,如释放锁。
对于 threading.Lock 和 threading.RLock,__enter__方法会调用 acquire() 来获取锁,而__exit__方法会调用 release() 来释放锁。这意味着在 with 语句块的末尾,锁将被自动释放,即使在块中发生了异常。
3.4 BoundedSemaphore(一次放生定值个)
1. 假设有一个数据库连接池,最多允许5个线程同时访问数据库。我们可以使用BoundedSemaphore 来控制对数据库的访问,确保不会超过5个线程同时访问。即使 GIL 限制了 CPU 的并行执行,BoundedSemaphore 仍然可以有效地管理数据库连接的使用,确保资源的合理分配和使用。
import threading
import time
lock = threading.BoundedSemaphore(5)
def func(arg):
lock.acquire()
print(arg)
time.sleep(1)
lock.release()
for i in range(20):
t = threading.Thread(target=func, args=(i,))
t.start()
思考和理解:
这里学习的时候我有个疑问,不是规定由于GIL的原因,同一时刻,只允许一个线程在被CPU调度吗?为什么还能用BoundedSemaphore设置最多线程的数量是5个。其实是这样:
① GIL 确保在任何时刻只有一个线程在执行 Python 字节码,但这并不意味着其他线程完全停止。当线程执行 I/O 操作或系统调用时,GIL 会被释放,允许其他线程执行。即使在 CPU 密集型任务中,GIL 也会定期释放,允许其他线程有机会执行。因此,BoundedSemaphore 控制的是资源访问的顺序和数量,而不是 CPU 的分配。
② “控制的是资源访问的顺序和数量”意思是说,比如创建了20个线程,我们可以通过BoundedSemaphore设置最多5个线程同时对资源访问,即最多5个线程执行IO操作和系统调用,当第6个线程尝试获取信号量时,它将被阻塞,直到其他线程释放信号量,等这5个中的释放信号,释放一个,多调用一个,释放一个,多调用一个,直到20个被调用完。而已被调用的这5个线程,他们在执行过程中,也同样遵循GIL限制。只不过就是CPU对它们进行轮询调度。这样可以在多线程环境中有效地管理资源访问,避免资源耗尽和竞争条件,同时确保线程之间的同步和协调。
③ 其实也可以这么理解,针对已被系统调用的这5个线程来说,他们之间的运行就像是之前没被加锁的20个线程一样,是遵循线程调度与 CPU 使用的GIL的。而其他15个线程,和这5个线程这两个对象来说,他们是遵循BoundedSemaphore的控制的,被控制同一时刻并发的数量是5个。
2. 然后再说下BoundedSemaphore的作用和简单原理:
BoundedSemaphore 通过限制同时获取资源的线程数量来控制并发访问。当一个线程调用 acquire() 方法时,它会等待直到信号量的计数器大于0,然后将计数器减1。当线程完成任务并调用 release() 方法时,计数器加1,允许其他等待的线程继续执行。即使 GIL 限制了 CPU 的并行执行,BoundedSemaphore 仍然可以有效地控制对共享资源的并发访问,确保不会超过指定的并发线程数量。
思考和理解:
比如设置BoundedSemaphore(5),然后计数器会从5减小为0,第6个线程调用acquire()时,会等待计数器大于0的时刻出现。接着这时候有一个线程执行完毕了,释放了锁,那么计数器会由0加到1,然后第6个线程就可以成功被CPU调度,然后同时把计数器再减1,以此类推。
3.5 Condition(一次放生任意个,可变化)
threading.Condition() 是 Python 的 threading 模块中用于线程间同步的高级工具,它提供了一种基于锁的机制,允许线程在满足特定条件之前等待,并在条件满足时被其他线程唤醒。
import threading
import time
# 创建了一个 Condition 对象 lock,它将用于同步线程之间的执行
lock = threading.Condition()
def func(arg):
"""
当线程开始执行时,它会调用 lock.acquire() 来获取锁,然后调用 lock.wait() 进入等待状态。
这意味着线程将释放锁并阻塞,直到其他线程调用 notify 或 notify_all 方法唤醒它。
一旦被唤醒,线程将继续执行,打印参数 arg,然后休眠1秒,最后调用 lock.release()来释放锁
"""
print("线程%s即将到来" % arg)
lock.acquire()
print(f"线程{arg}已经获得锁了")
lock.wait() # 释放锁并进入等待状态,如果在 wait() 调用中指定了超时时间,那么线程将在超时后自动唤醒
print(arg)
time.sleep(1)
lock.release()
for i in range(20):
t = threading.Thread(target=func, args=(i,))
t.start()
while True:
"""
在创建所有线程之后,主程序进入一个无限循环,等待用户输入。当用户输入一个整数时,
主程序将调用 lock.acquire() 获取锁,然后调用 lock.notify(num) 唤醒与输入整数相匹配的线程
之后,主程序调用 lock.release() 释放锁。
"""
num = int(input(">>请输入要唤醒的线程个数:"))
lock.acquire()
lock.notify(num)
lock.release()
线程0即将到来
线程0已经获得锁了
线程1即将到来
线程1已经获得锁了
…
线程19即将到来
线程19已经获得锁了
>>请输入要唤醒的线程个数:
上面的结果体现的行为:20个线程都运行到lock.wait()然后就释放锁并等待阻塞住了,这意味着这20个线程将不再持有锁。直到有别的线程(这里是主线程)获得锁之后,利用lock.notify(num)去唤醒num个等待的线程,然后被唤醒的线程重新尝试获取锁,继续运行子线程中的代码运行完之后,再释放自己的锁,最后再释放主线程唤醒程序的锁。
重新尝试获取锁,并不保证能够立即获取到锁。这是因为其他线程可能在它等待期间已经获取了锁,或者在它被唤醒时正在尝试获取锁。因此,线程需要重新竞争锁,这通常通过再次调用 lock.acquire() 来实现。一旦线程重新获取了锁并完成了其任务,它应该调用 lock.release() 来释放锁,以便其他线程有机会获取锁并执行它们的任务
在 while True 循环中使用 lock.acquire() 和 lock.release() 是为了确保 Condition 对象的正确使用和线程之间的同步,具体地:
- 在 while True 循环中,lock.release() 被调用在 lock.notify(num) 之后。这个 lock.release() 的目的是在唤醒一个或多个等待的线程后释放锁,允许其他线程获取锁并执行它们的任务。这是因为在调用 lock.notify() 或 lock.notify_all() 后,通常会释放锁,以提高并发性,允许其他线程在等待的线程被唤醒之前获取锁并执行。
- notify 方法用于唤醒一个或所有等待的线程。然而,notify 方法只能在持有锁的情况下调用。如果在没有获取锁的情况下调用notify,将引发 RuntimeError。因此,lock.acquire() 确保在调用 notify 之前锁被正确获取,而lock.release() 确保锁在 notify 调用完成后被释放,允许其他线程有机会获取锁。
3.6 Event(一次放生所有)
在 Python 的 threading 模块中,Event 类提供了一种简单而有效的方式来协调多个线程的执行。Event 对象管理一个内部标志,该标志可以被设置为 True 或者 False。这个标志可以被多个线程检查,从而控制它们的执行流程。Event 对象提供了以下主要方法:
- set(): 将内部标志设置为 True。所有处于等待状态的线程将被唤醒,并继续执行。
- clear(): 将内部标志重置为 False。
- wait(timeout=None): 阻塞当前线程直到事件被设置或超时。如果 timeout 参数为None(默认值),则线程将无限期地等待,直到事件被设置。如果提供了 timeout参数,它应该是一个浮点数,表示等待的秒数。如果在超时时间内事件没有被设置,线程将继续执行。
- is_set(): 返回事件的当前状态,即内部标志是否为 True
import threading
lock = threading.Event()
def func(arg):
print("线程%s即将到来" % arg)
print(lock.is_set())
lock.wait() # self._flag = False,加锁
print(arg)
for i in range(20):
t = threading.Thread(target=func, args=(i,))
t.start()
input("输入任意字符解锁:")
lock.set() # self._flag = True,解锁
print(lock.is_set()) # 此时打印为True
lock.clear() # self._flag = False,加锁
print(lock.is_set()) # 此时打印为False
# 再来20个线程
for i in range(20):
t = threading.Thread(target=func, args=(i,))
t.start()
input("输入任意字符解锁:")
lock.set() # self._flag = True,解锁
print(lock.is_set()) # 此时打印为True
hreading.Event 被用作一种信号机制,用于控制多个线程的执行。
思考和理解:
Event对象的内部标志被用来决定线程是否应该等待或继续执行。lock.wait()将内部标志设置为False,所有线程都锁住呈阻塞状态;然后调用lock.set()将内部标志设置为True,唤醒解锁所有线程。此时lock.is_set()检验到内部标志确实为True,然后可以通过lock.clear()将内部标志再次还原设置为False,又一次锁住,同理再创建线程,再通过调用lock.set()将内部标志设置为True,唤醒解锁所有线程。
四、总结
- GIL 是 CPython解释器为了简化内存管理、保证线程安全而引入的设计决策。它在简化开发和保证程序稳定性方面发挥了重要作用,但同时也限制了 Python在多核处理器上的并行执行能力,特别是在 CPU 密集型任务中。尽管如此,Python 社区已经开发了多种方法来缓解 GIL的影响,如使用多进程、异步 I/O 或者采用其他 Python 实现(如 Jython、PyPy)等。
- GIL 和线程锁在 Python 的多线程编程中扮演着不同的角色。GIL 是 CPython解释器层面的机制,用于保证全局的线程安全;而线程锁是应用层面的工具,用于同步线程对共享资源的访问。两者在多线程编程中相互补充,共同确保程序的正确性和性能。