GIL再探:解析Python多线程的限制与突破

前言

你可能经常听到Python是伪多线程相关的话,那真相到底是啥呢,本篇内容将解密相关知识。

python多线程真的存在吗?

scss 复制代码
import time
​
def Calculation(n):
    while n > 0:
        n -= 1
​
start_time = time.perf_counter()
Calculation(100000000)
end_time = time.perf_counter()
print('Calculation takes {} seconds'.format(end_time - start_time))

执行时间为:Calculation takes 4.652364985 seconds。笔者这是在4核的mac机器上运行的结果。

那我们改为多线程,是不是会大大缩短运行时间呢?改进上面的例子,我们电脑是四核,这里我用三个线程来计算

less 复制代码
import time
from threading import Thread
​
def Calculation(n):
    while n > 0:
        n -= 1
​
start_time = time.perf_counter()
n = 100000000
​
t1 = Thread(target=Calculation, args=[n // 3])
t2 = Thread(target=Calculation, args=[n // 3])
t3 = Thread(target=Calculation, args=[n // 3])
t1.start()
t2.start()
t3.start()
t1.join()
t2.join()
t3.join()
end_time = time.perf_counter()
print('Calculation takes {} seconds'.format(end_time - start_time))

惊喜来啦,执行时间为:Calculation takes 6.852961705 seconds。难以置信,我用多线程计算的时间居然比单线程时间还长,那我还用啥多线程。python多线程是假的?带着这个疑问,我们继续进行探索。

GIL(全局解释器锁)

针对上面的疑问,我们先抛出答案,就是因为GIL(全局解释器锁)。Python中的多线程是不能利用多核的,因为Python虚拟机使用一个全局解释器锁(GIL)来控制线程对程序的执行,这个结果就使得无论你的cpu有多少核,但是同时被线程调度的cpu只有一个。

GIL是什么

GIL(全局解释器锁)是一种特性,它存在于 CPython 解释器中(CPython 是 Python 的参考实现)。GIL 是一把互斥锁,用于保证在解释器级别上只有一个线程能够执行 Python 字节码。这意味着在任何给定的时间点,只有一个线程能够在解释器中运行 Python 代码。

GIL存在的意义

CPython 使用引用计数来管理内存,所有 Python 脚本中创建的实例,都会有一个引用计数,来记录有多少个指针指向它。当引用计数只有 0 时,则会自动释放内存。

scss 复制代码
import sys
​
list_obj = [1, 2, 3]
​
print(sys.getrefcount(list_obj))

我们使用sys.getrefcount()输出引用次数,可以看到此时list_obj的引用次数为2。假设有两个线程A和B,内部都引用了全局变量list_obj,此时list_obj指向的对象的引用计数为2,然后让两个线程都执行del list_obj这行代码。其中A线程先执行,会将对象的引用计数减一,但不幸的是这个时候调度机制将A挂起了,唤醒了B。而B也执行del list_obj,但是它比较幸运,将两步都一块执行完了。而由于之前A已经将引用计数减1,所以再减1之后会发现对象的引用计数为0,从而执行了对象的销毁动作,内存被释放。

然后A又被唤醒了,此时开始执行第二个步骤,但由于已经被减少到0,所以条件满足,那么A依旧会对list_obj指向的对象进行释放,但是这个对象所占内存已经被释放了,所以list_obj此时就成了悬空指针。如果再对list_obj指向的对象进行释放,就找不到有效内存了。

为了解决此问题,GIL来了。它存在的意义就是:

  • 规避类似于内存管理这样的复杂的竞争风险问题(race condition);
  • CPython 大量使用 C 语言库,但大部分 C 语言库都不是原生线程安全的(线程安全会降低性能和增加复杂度)。

GIL工作原理

  1. 当多个线程同时运行时,GIL 会确保每个线程在一段时间内都能获得执行的机会。

  2. 在每个线程开始执行之前,它必须先获得 GIL。如果该线程已经获得了 GIL,则继续执行;否则,它会被阻塞,等待 GIL 的释放。

  3. 当一个线程获得 GIL 后,它可以运行一段时间,直到发生以下情况之一:

    • 当该线程的时间片用完(达到预定的执行时间上限)。
    • 当该线程主动释放 GIL。
    • 当该线程需要进行 I/O 等操作时。
  4. 当一个线程释放 GIL 或因为其他原因失去 GIL 时,其他线程中的某个线程将获得 GIL,并开始执行字节码。

这里我们提一下,CPython中的另一个机制check_interval,CPython 解释器会去轮询检查线程 GIL 的锁住情况。每隔一段时间,Python 解释器就会强制当前线程去释放 GIL,这样别的线程才能有执行的机会。

有了GIL锁,线程就安全吗?

答案是否定的。虽然GIL只允许一个线程运行,但因为check_interval抢占机制,会造成线程不安全。

scss 复制代码
import threading
​
count = 0
​
def increment_count():
    global count
    for i in range(10000000):
        count += 1
​
# 创建两个线程并启动它们
t1 = threading.Thread(target=increment_count)
t2 = threading.Thread(target=increment_count)
t1.start()
t2.start()
​
# 等待两个线程都结束
t1.join()
t2.join()
​
# 打印输出 count 的值
print(count)

我们定义了一个全局变量 count,并创建了两个线程来并行地递增它的值。由于 GIL 的存在,每个线程只能执行一小段时间就需要释放 GIL,等待其他线程执行。因此,在理论上,count 的最终值应该是 20000000。但是,实际上可能会小于这个值,或者大于这个值。这是由于多个线程同时访问和修改同一个共享资源时,可能会发生数据竞争和资源争用,从而导致线程安全问题。

我们也可以用dis.dis(increment_count)查看这个函数的字节码,是由好多行bytecode组成,任何一行都有可能被打断。

那如何确保线程安全呢?

方法一:使用互斥锁(Lock):使用互斥锁可以确保在任意时刻只有一个线程可以访问共享资源。在访问共享资源之前,线程需要获取互斥锁,在访问完成后释放锁。这样可以有效避免多个线程同时修改共享资源导致的竞争问题

csharp 复制代码
import threading
​
count = 0
lock = threading.Lock()
​
def increment_count():
    global count
    for i in range(10000000):
        with lock:
            count += 1
​
# 创建两个线程并启动它们
t1 = threading.Thread(target=increment_count)
t2 = threading.Thread(target=increment_count)
t1.start()
t2.start()
​
# 等待两个线程都结束
t1.join()
t2.join()
​
# 打印输出 count 的值
print(count)
​

方法二:使用线程安全的数据结构:Python 提供了一些线程安全的数据结构,如 threading 模块中的 ThreadSafe 类和 queue.Queue 类。这些数据结构在内部实现了适当的同步机制,确保线程安全性。

scss 复制代码
import threading
import queue
​
​
q = queue.Queue()
​
def increment_count():
    for i in range(10000000):
        q.put(1)
​
# 创建两个线程并启动它们
t1 = threading.Thread(target=increment_count)
t2 = threading.Thread(target=increment_count)
t1.start()
t2.start()
​
# 等待两个线程都结束
t1.join()
t2.join()
​
# 统计队列中元素的个数即为 count 的值
count = q.qsize()
print(count)
​

在这个示例中,我们使用了线程安全的队列 queue.Queue,通过调用 put() 方法向队列中放入元素来完成计数。由于队列是线程安全的数据结构,可以安全地在多个线程之间共享,从而避免了竞争条件。

绕过GIL

Python 的 GIL,是通过 CPython 的解释器加的限制。如果你的代码并不需要 CPython 解释器来执行,就不再受 GIL 的限制。

有位大佬提供了这两种思路:

  • 绕过 CPython,使用 JPython(Java 实现的 Python 解释器)等别的实现;
  • 把关键性能代码,放到别的语言(一般是 C++)中实现。

最后

针对开头的例子,相信你一定知道,为啥使用多线程比单线程时间还长了吧?正是因为GIL采用轮流运行线程的机制,GIL需要在线程之间不断轮流进行切换,线程如果较多或运行时间较长,切换带来的性能损失可能会超过单线程。

虽然GIL的存在导致多线程只有一个线程在运行,也就是伪并行,损失了一些性能,但在保证资源不冲突、预防死锁方面还是有一定的作用的。

相关推荐
CodeClimb几秒前
【华为OD-E卷 - 猜字谜100分(python、java、c++、js、c)】
java·javascript·c++·python·华为od
Channing Lewis6 分钟前
python递归最多多少层
python
minstbe6 分钟前
AI开发:决策树模型概述与实现:从训练到评估和可视化 - Python
python·深度学习·知识图谱·集成学习
明月逐人归4648 分钟前
输出语句及变量定义
开发语言·python
是十一月末17 分钟前
opencv实现KNN算法识别图片数字
人工智能·python·opencv·算法·k-近邻算法
m0_7482548824 分钟前
Spring Boot实现多数据源连接和切换
spring boot·后端·oracle
程序员shen16161131 分钟前
注意⚠️:矩阵系统源码开发/SaaS矩阵系统开源/抖音矩阵开发优势和方向
java·大数据·数据库·python·php
菜鸟xiaowang33 分钟前
Android.bp java_library_static srcs配置
开发语言·python
Ven%1 小时前
DeepSpeed的json配置讲解:ds_config_zero3.json
人工智能·python·ubuntu·json·aigc
韩数1 小时前
Nping: 支持图表实时展示的多地址并发终端命令行 Ping
后端·rust·github