前言
你可能经常听到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工作原理
-
当多个线程同时运行时,GIL 会确保每个线程在一段时间内都能获得执行的机会。
-
在每个线程开始执行之前,它必须先获得 GIL。如果该线程已经获得了 GIL,则继续执行;否则,它会被阻塞,等待 GIL 的释放。
-
当一个线程获得 GIL 后,它可以运行一段时间,直到发生以下情况之一:
- 当该线程的时间片用完(达到预定的执行时间上限)。
- 当该线程主动释放 GIL。
- 当该线程需要进行 I/O 等操作时。
-
当一个线程释放 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
的存在导致多线程只有一个线程在运行,也就是伪并行,损失了一些性能,但在保证资源不冲突、预防死锁方面还是有一定的作用的。