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

相关推荐
AlienZHOU3 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
科技苑3 小时前
Python简单网络爬虫教程
爬虫·python
狗头大军之江苏分军5 小时前
《潮水漫过十七岁》开学了
后端
Patrick在香港5 小时前
Claude Prompt 香港场景:公文里「今日」落在 6 个日历日上,「翌日」锚错了 5 天
python·自然语言处理·正则表达式·claude·数据清洗
苏三说技术5 小时前
如何看待GPT-6在UP主众测中碾压夺冠?它是现在最强大模型吗?
后端
mldong6 小时前
一份 JSON,一条能跑的审批流:把报销流程送上工作流引擎
后端·架构
wno7046 小时前
Spring Boot WebFlux增删改查
java·spring boot·后端
Captaincc6 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
YsyaaabB6 小时前
Python 数值分析
python
阿洛学长6 小时前
计算机二级 Python 基本操作题(15 分)真题笔记(0101 ~ 1903 全套)
python·pycharm