前言
本章我们学习全局解释器锁,在学习之前,我们先看这一段代码:
import threading
# 全局变量
shared_data = {'counter': 0}
def increment():
for _ in range(100000):
shared_data['counter'] += 1
def decrement():
for _ in range(100000):
shared_data['counter'] -= 1
# 创建两个对立操作(增加与减少)执行于不同 threads
thread1 = threading.Thread(target=increment)
thread2 = threading.Thread(target=decrement)
# 启动 threads
thread1.start()
thread2.start()
# 等待两个 threads 完成执行
thread1.join()
thread2.join()
# 输出最终结果
print("Final counter value:", shared_data['counter'])
在上述代码中:
- 我们定义了一个字典
shared_data
包含键'counter'
。 - 创建两个函数
increment()
和decrement()
分别增加和减少'counter'
的值。 - 启动两个分别运行这些函数的threads。
- 最后输出
shared_data['counter']
的值来查看结果。
从输出结果我们可以看到,在没有适当锁机制保护下,最终计数器值经常会得到正确的0。
这是因为在CPython中,由于全局解释器锁(GIL)的存在,任何时候只有一个线程可以执行Python字节码。这意味着即使没有锁,也不会有两个线程同时执行全局变量的递增或递减操作。
全局解释器锁
全局解释器锁(Global Interpreter Lock,简称GIL)是Python中用于同步线程的机制,特别是在CPython解释器中。GIL确保在任何时刻只有一个线程可以执行Python字节码。这意味着即使在多核处理器上运行,使用标准的CPython解释器的Python程序也无法实现真正的并行执行多个线程。
由来
1. 简化内存管理
在Python的早期设计中,内存管理是一个核心问题。Python使用自动内存管理,其中包括垃圾收集机制来处理内存分配和回收。为了简化内存管理并避免与多线程相关的复杂性,GIL 被引入作为一种确保在任何时刻只有一个线程可以执行Python字节码的机制。这意味着开发者不需要担心在使用标准库时多线程访问同一对象的线程安全问题。
2. 保护CPython的内部数据结构
Python的核心是用C语言实现的,许多底层数据结构并不是线程安全的。没有GIL,开发者需要在CPython的内部实现中添加大量的锁,以防止数据结构在并发访问时被破坏。这会导致执行效率降低和代码复杂度增加。因此,GIL是一种折中方案,用来保护数据结构不被并发修改导致的错误或不一致。
3. 避免性能损失
如果没有GIL,每次访问Python对象时都必须使用锁,这可能引入大量的锁操作,其开销在高并发场景下会非常显著。因此,GIL实际上避免了这种频繁加锁的开销,虽然它限制了并行执行的能力,但对于I/O密集型应用,这种限制的影响较小。
4. 历史原因
当Python首次引入线程时,多核处理器还不普遍,因此设计选择重点不在于利用多核的并行计算能力上。GIL的引入使得初期的多线程实现相对简单,并且足以应对当时的需求。
特点
-
限制多核利用:对于计算密集型任务(CPU-bound tasks),GIL成为了性能瓶颈。因为无论系统有多少核心,同时只能有一个核心被利用来执行Python字节码。
-
不适合CPU密集型任务:对于CPU密集型任务(如大规模数学计算等),多线程由于GIL的存在,在提升性能方面受到限制。而使用多进程可以绕过GIL,因为每个进程拥有自己独立的内存空间和解释器,从而能够真正并行运行在多核处理器上。
我们前面的例子就算是CPU密集型任务。
-
适合I/O密集型任务:对于I/O密集型任务(如网络请求、文件读写等),由于大部分时间都花费在等待外部事件完成上,并不频繁进行CPU计算,因此受到GIL影响较小。因为I/O操作期间线程会被挂起,其他线程可以利用这段时间进行运行,所以多线程仍然是有效的选择。但即便如此,在某些情况下开发者可能仍倾向于使用多进程来避免复杂性或其他限制。
相比之下,在其他编译语言如Java、C#等中,并不存在类似Python GIL这样的机制。这些语言支持真正意义上的并行多线程,并且它们各自拥有成熟且高效率管理并发和内存分配等问题的方法和工具。因此,在这些语言中使用多线程序更加普遍和有效。
这就是为什么python中使用多进程比多线程更常见,而在其他编程语言中基本用的都是多线程的原因。
如何应对GIL
尽管存在全局解释器锁,它可能会限制某些类型应用程序在标准CPython环境下运行时候达到真正并行处理数据流量或计算量级增长带来挑战, 但通过合理选择工具和设计策略, 可以有效规避其负面影响。
-
使用多进程:
- 通过
multiprocessing
模块创建进程而非线程。每个进程拥有自己的Python解释器和内存空间,因此各个进理间不存在GIL问题。
- 通过
-
使用其他实现的Python解释器:
- 如Jython或IronPython等没有GIL限制的实现版本。
- PyPy也提供了STM(软件事务内存)功能作为试验性质替代方案来避免使用传统意义上的锁。
-
C扩展编写关键代码:
- 使用C或C++编写那些需要高度优化且与并行计算相关联的代码段,并通过诸如
cython
这样工具将其集成到你的Python程序中。这些扩展可以自由地管理它们自己内部状态而不受到GIL约束。
- 使用C或C++编写那些需要高度优化且与并行计算相关联的代码段,并通过诸如
-
专注于I/O密集型设计:
- 利用异步编程模式和库(例如asyncio),以及其他技术来最大化I/O操作效率,并减少CPU空闲时间。
仍存在的一致性问题
在前言的例子中,如果有GIL,那么应该每次输出都是0才对,但实际上,还是有不为0的情况,这是为什么呢?
1. GIL不是数据一致性的万能钥匙
尽管GIL确保了同一时间内只有一个线程可以执行Python字节码,但这并不意味着它可以自动解决所有的线程安全问题。如果代码中包含非原子操作或需要多步骤完成的操作,仍然需要额外的同步机制来保证操作的完整性。例如,如果一个线程在修改一个共享对象的多个属性时被中断,即使有GIL,其他线程看到的也可能是一个中间状态的对象。
2. I/O操作和系统调用
当线程执行阻塞的I/O操作或某些系统调用时,它会释放GIL,让其他线程运行。这种情况下,GIL不会阻止其他线程修改共享数据,如果这些数据没有被适当地保护(例如使用锁),就可能导致数据不一致。
3. GIL的释放和获取
在Python中,GIL有固定的释放和再获取的周期(默认为15毫秒),这意味着即使在CPU密集型任务中,线程也会周期性地释放GIL以确保其他线程有机会运行。在这些释放和获取的时间点上,如果共享数据没有被正确同步,就可能发生线程间的竞争条件,从而导致数据错误。
4. 扩展和C模块
使用C语言编写的Python扩展可能会直接操作Python对象而绕过GIL,或者可能会在自己的代码中实现独立的多线程。如果这些扩展没有妥善管理其内部的线程安全问题,即使主Python解释器有GIL,也可能导致数据一致性问题。
5. 开发者对GIL的误解
有时候,开发者可能对GIL的保护作用有误解,认为有了GIL就无需考虑线程同步问题。这种误解可能导致开发者忽略在多线程环境下适当使用锁或其他同步机制的重要性。
结论
虽然GIL提供了一定程度的线程安全保护,但它并不是一个全能的解决方案。在设计多线程应用时,仍然需要考虑合适的同步策略,确保数据的一致性和完整性。在某些情况下,可能还需要使用额外的锁或其他并发控制机制来补充GIL的不足。
与普通锁的区别
GIL(全局解释器锁)和普通的Lock(互斥锁)是两种在Python多线程编程中用于同步机制的工具,它们有以下主要区别:
-
作用范围:
-
GIL是Python解释器级别的锁,确保在任何时刻只有一个线程可以执行Python字节码。这是CPython解释器特有的机制,用于保护内存管理的引用计数系统,避免多线程同时修改Python对象。
-
普通的Lock是线程级别的锁,用于防止多个线程同时访问某个共享资源,以保证数据的一致性和线程安全。它是由Python标准库
threading
模块提供的,可以在用户定义的多线程程序中使用。
-
-
目的:
-
GIL的主要目的是为了简化CPython的内存管理,防止多线程在执行时导致引用计数出错,从而避免内存泄漏或者不一致。
-
普通的Lock则是用于用户层面的线程同步,确保在进行共享资源访问时,不会发生数据竞争或其他并发问题。
-
-
性能影响:
-
GIL实际上限制了Python程序利用多核CPU的能力,即使在多核处理器上,也只能有一个线程执行Python字节码。这在CPU密集型程序中可能导致性能瓶颈。
-
普通的Lock如果使用不当,虽然可以导致死锁等问题,但它本身不会限制多核处理器的性能发挥,并且可以通过精心设计锁的粒度和作用范围来优化多线程程序的性能。
-
-
适用场景:
-
GIL是CPython解释器内部的机制,通常不需要程序员直接操作,除非涉及到C扩展或者JIT编译器等高级主题。
-
普通的Lock适用于普通的多线程编程,任何需要线程同步的场合都可以使用,例如访问共享计数器、数据库连接池等。
-
-
实现:
-
GIL的实现是自动的,不需要程序员在代码中显式声明或管理。
-
普通的Lock需要程序员在代码中显式地声明、获取和释放。
-
-
与多核处理器的关系:
-
GIL与多核处理器的使用没有直接关系,因为它是针对CPython解释器的全局锁。
-
普通的Lock在多核处理器上可以实现真正的并行执行,允许多个线程同时运行在不同的CPU核心上。
-
-
死锁风险:
-
GIL本身不涉及死锁问题,因为它是单一的全局锁。
-
普通的Lock如果管理不当,可能会导致死锁,需要程序员合理设计锁的使用顺序和释放策略。
-