全局解释器锁GIL

引子

import time

def count_down(n):
    while n > 0:
        n -= 1

start = time.time()
count_down(100000000)
end = time.time()

print("耗时:", end-start)
---------------输出结果---------------
耗时:4.507282018661499
  • 想使用多线程来加速?
import threading
import time

def count_down(n):
    while n > 0:
        n -= 1

COUNT = 100000000

t1 = threading.Thread(target=count_down, args=(COUNT // 2,))
t2 = threading.Thread(target=count_down, args=(COUNT // 2,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print("耗时:", end - start)
-------------------输出结果----------------------
耗时: 4.646654844284058

发现耗时基本差不多,并没有加速,那为什么多线程没有作用呢?
事实上,正是因为今天的主角,也就是 GIL,导致了 Python 线程的性能并不像我们期望的那样

全局解释器锁

  • GIL,是最流行的 Python 解释器 CPython 中一个机制,它在解释器进程级别有一把锁,叫做GIL(Global Interpreter Lock)即全局解释器锁,它的作用是保护Python解释器内部数据结构不受并发访问的影响
  • 在CPython解释器中,GIL是由一个互斥锁来实现的,它在同一个时刻只允许一个线程执行Python字节码,因此会影响Python的并发性能。甚至是在多核CPU的情况下,也只允许同时只能有一个CPU上运行该进程的一个线程
  • 本质上是类似操作系统的 Mutex。每一个 Python 线程,在 CPython 解释器中执行时,都会先锁住自己的线程,阻止别的线程执行。这样一来,用户看到的其实是“伪并行”,Python 线程在交错执行,来模拟并行的线程

python为什么需要GIL

  1. 设计者为了规避类似于内存管理这样的复杂的竞争风险问题(race condition)
    • race conditon:发生在多个线程同时访问同一个共享代码、变量、文件等没有进行锁操作或者同步操作,更多可参看线程安全
  2. 因为 CPython 大量使用 C 语言库,但大部分 C 语言库都不是原生线程安全的(线程安全会降低性能和增加复杂度)

python的GIL如何工作

其中,Thread 1、2、3 轮流执行,每一个线程在开始执行时,都会锁住 GIL,以阻止别的线程执行;同样的,每一个线程执行完一段后,会释放 GIL,以允许别的线程开始利用资源

  • Check interval
    • Check interval是GIL的一个参数,定义了线程之间相互切换的时间间隔
    • GIL会在每个check interval的时间内检查是否有其他线程需要运行。如果有,它会释放GIL并让另一个线程运行一段时间,如果没有则当前线程继续运行
    • 不同版本的 Python 中,check interval 的实现方式并不一样。早期的 Python2 是 100 个 ticks(可通过sys.getcheckinterval()查看),大致对应了 1000 个 bytecodes(字节码),Python 3 以后,interval 是 5 毫秒(可通过sys.getswitchinterval()查看)
    • GIL的check interval值可以通过修改Python解释器源代码中的宏定义来进行配置。减小check interval值会增加GIL切换的频率,从而使得线程之间的切换更加平滑,但也会增加一定的性能开销。相反,增大check interval值则会减少GIL切换的频率,从而减小性能开销,但可能会导致线程之间的响应时间变长
  • GIL 的切换时机受到以下因素的影响:
  1. check interval:Python 3 中 GIL 的 check interval 默认值是 5ms,也就是说,每5ms后GIL 就会进行一次检查,决定是否释放 GIL
  2. 线程调度器:GIL 的切换时机还受到操作系统线程调度器的影响,例如在一个 CPU 密集型线程中,GIL 可能会持有很长时间,而在一个 I/O 密集型线程中,GIL 可能会更频繁地释放
  3. Python 代码本身:GIL 的切换时机还受到 Python 代码本身的影响,例如代码中的 sleep、time、I/O 操作等函数都可能触发 GIL 的主动释放

并发和并行

  • 并发:并不是指同一时刻有多个操作(thread、task,分别对应 Python 中并发的两种形式:threadingasyncio)同时进行。相反,由于GIL的存在,某个特定的时刻,它只允许有一个操作发生,只不过线程 / 任务之间会互相切换
  • 并行:指的才是同一时刻、同时发生。Python 中的 multi-processing 便是这个意思,对于 multi-processing,你可以简单地这么理解:比如你的电脑是 6 核处理器,那么在运行程序时,就可以强制 Python 开 6 个进程,同时执行,以加快运行速度

【以下内容来自ChatGPT】

  • 并发(Concurrency)指在同一时间间隔内,多个任务都在执行,但是并发任务之间不一定需要同时执行。它是指多个任务在同一时间段内交替执行,因为每个任务只占据处理器的一小段时间,所以看起来好像是同时执行的。在Python中,实现并发的方式包括协程和异步编程。协程是一种轻量级的线程,它在单个线程中可以同时执行多个任务,通过yield语句实现任务间的切换。异步编程使用事件循环机制(例如asyncio模块)来管理多个任务,通过非阻塞I/O和回调函数来实现任务之间的切换
  • 并行(Parallelism)是指多个任务真正同时执行,每个任务都有自己的处理器或核心。在Python中,实现并行的方式包括多进程和多线程。多进程并行使用多个进程来同时执行不同的任务,每个进程都拥有自己的Python解释器和内存空间。多线程并行使用多个线程来同时执行不同的任务,但由于GIL的存在,多线程并行在CPU密集型任务上的性能不如多进程并行

IO密集型和CPU密集型

  • IO密集型(I/O-bound)
    • IO密集型指的是系统的CPU性能相对硬盘、内存要好很多,此时,系统运作,大部分的状况是CPU在等I/O (硬盘/内存) 的读/写操作,此时CPU Loading并不高
    • 某个线程阻塞,就会调度其他就绪线程
  • CPU密集型(CPU-bound)
    • CPU密集型也叫计算密集型,指的是系统的硬盘、内存性能相对CPU要好很多,此时,系统运作大部分的状况是CPU Loading 100%,CPU要读/写I/O(硬盘/内存),I/O在很短的时间就可以完成,而CPU还有许多运算要处理,CPU Loading很高
    • 当前线程可能会连续的获得GIL,导致其它线程几乎无法使用CPU

在CPython中由于有GIL存在,IO密集型,使用多线程较优
CPU密集型,使用多进程,来绕开GIL(可参看多进程)
补充参看python中为什么io密集要用多线程

  • IO密集型任务,多线程为什么能提高程序运行效率?
    在IO密集型任务中,程序的运行时间大部分都是在等待IO操作的结果,比如从磁盘读取数据或者向网络发送请求等。而在等待IO的过程中,CPU资源大部分是空闲的。这时候,如果使用多线程,可以让线程在等待IO的时候切换到其他的线程去执行,从而利用CPU的空闲时间,提高程序的运行效率
    举个例子,如果一个程序需要从网上下载一些文件,并将这些文件存储到本地磁盘上,那么这个过程中大部分时间都是在等待下载和写入操作的完成。如果使用单线程,程序会在等待IO的过程中阻塞,等待IO操作完成后才会继续执行。而如果使用多线程,可以让一个线程负责下载数据,另一个线程负责写入数据,这样在等待IO的过程中,两个线程可以交替执行,提高程序的运行效率
  • CPU密集型任务,为什么多线程不能提高程序运行效率?
    CPU密集型任务指的是需要进行大量计算的任务,例如大规模的矩阵计算、加密解密等。在这种情况下,多线程并不能提高程序运行效率,因为GIL的存在会导致同一时间只有一个线程在执行Python字节码,其他线程在等待GIL的释放。因此,多线程在这种情况下不能利用多核CPU的优势,反而会增加上下文切换的开销,降低程序的运行效率
    为了处理CPU密集型任务,可以使用多进程、异步编程等方式来提高程序的运行效率。多进程可以让程序同时在多个进程中运行,每个进程都有自己的Python解释器和GIL,能够充分利用多核CPU的优势。异步编程利用事件循环机制,在等待IO操作的时候自动切换任务,提高CPU利用率,从而提高程序的运行效率

综上:

  1. 并发通常应用于 I/O 操作频繁的场景,比如你要从网站上下载多个文件,I/O 操作的时间可能会比 CPU 运行处理的时间长得多
  2. 并行则更多应用于 CPU heavy 的场景,比如 MapReduce 中的并行计算,为了加快运行速度,一般会用多台机器、多个处理器来完成

GIL的问题

补充示例

  • 串行
# 真串行执行示例
import logging
import datetime

logging.basicConfig(level=logging.INFO, format="%(thread)s %(threadName)s %(message)s")

start = datetime.datetime.now()

# CPU密集型,大量的计算
def calc():
    res = 0
    for _ in range(100000000):  # 多补几个0试试,效果更明显
        res += 1

calc()
calc()
calc()

delta = (datetime.datetime.now() - start).total_seconds()
logging.info(delta)
-----------输出结果-------
4581498368 MainThread 14.428465
  • 多线程
# 由于GIL锁的存在,其实多线程是假并行(对于CPU密集型)
import logging
import datetime
import threading

logging.basicConfig(level=logging.INFO, format="%(thread)s %(threadName)s %(message)s")

start = datetime.datetime.now()

# CPU密集型,大量的计算
def calc():
    res = 0
    for _ in range(100000000): # 多补几个0试试,效果更明显
        res += 1

ts = []
for i in range(3):
    t = threading.Thread(target=calc, name=f'calc-{i + 1}')
    ts.append(t)
    t.start()

for t in ts:
    t.join()

delta = (datetime.datetime.now() - start).total_seconds()
logging.info(delta)
--------输出结果(执行时间与串行时间相当)-------------
4677254656 MainThread 14.848266

由于GIL的存在,线程的执行变成了假并发
但是这些线程可以被调度到不同的CPU核心上执行,只不过GIL让同一时间该进程只有一个线程被执行

处理GIL问题

  1. 对于CPU密集型使用多进程,来绕开GIL(I/O密集型则推荐使用多线程协程

针对以上问题的多进程解决方案可参看多线程

  1. 绕过 CPython,使用 JPython(Java 实现的 Python 解释器)等别的实现
  2. 把关键性能代码,放到别的语言(一般是 C++)中实现。很多高性能应用场景都已经有大量的 C 实现的 Python 库,例如 NumPy 的矩阵运算,就都是通过 C 来实现的,Python 的调用接口,因此并不受 GIL 影响

参考及更多阅读