[Python学习日记-93] 并发编程之多线程 —— 互斥锁与 Python GIL(Global Interpreter Lock)

发布于:2025-06-23 ⋅ 阅读:(14) ⋅ 点赞:(0)

[Python学习日记-93] 并发编程之多线程 —— 互斥锁与 Python GIL(Global Interpreter Lock)

简介

GIL 介绍

一、什么是 GIL

二、GIL 存在的原因

1、简化内存管理

2、历史兼容性

3、降低扩展难度

GIL 与 Lock 的区别

一、作用范围

二、粒度大小

三、释放机制

GIL 对多线程的影响

一、计算密集型任务

二、I/O 密集型任务

三、多线程和多进程方案对比分析

多线程性能测试

一、计算密集型测试:多进程效率高

二、I/O 密集型测试:多线程效率高

简介

        在 Python 的并发编程领域中,多线程编程是一个强大的工具,然而,其中的全局解释器锁(Global Interpreter Lock,简称 GIL)却是一把双刃剑,深刻影响着多线程程序的性能与行为。理解 GIL 的工作机制、它与多线程的关系以及如何在编程中合理应对 GIL 带来的影响,对于编写高效的 Python 并发程序至关重要。本文将深入探讨 Python GIL 的各个方面。

GIL 介绍

一、什么是 GIL

        GIL 并不是 Python 的一个特性,它是为实现 CPython 解释器时而引入的一个概念。众所周知 C++ 是一套语言(语法)标准,可以选择不同的编译器来编译成可执行代码。比较有名的编译器有 GCC、INTEL C++、Visual C++等。而 Python 也一样,同样的一段代码可以选择 CPython、PyPy、Psyco 等不同的 Python 执行环境来执行。但并不是所有执行环境都有 GIL,像 JPython 就没有。之所以很多人概念里 CPython 就是 Python,这是因为大部分环境下 CPython 都是 Python 默认的执行环境,所以也有的人会把 GIL 归结为 Python 语言的缺陷,但这是一个错误的认识。需要再次强调的是:GIL 并不是 Python 的特性,Python 完全可以不依赖 GIL。如果想再深入理解建议看看这篇文章《UnderstandingGIL》,它深入的剖析了 GIL 对 Python 的影响。

        GIL 是 CPython 解释器中的一个互斥锁机制,其核心作用是确保同一时刻只有一个线程能够执行 Python 字节码,即将所有并发运行都变成串行运行,以此来控制同一时间内共享数据只能被一个任务所修改,进而保证数据安全,在保护不同的数据安全的时候就应该加不同的锁。但这意味着,即便在拥有多核 CPU 的系统上,Python 的多线程也无法实现真正意义上的并行执行,而是通过快速切换线程来达成并发效果。

        我们在每次执行 Python 程序时都会产生一个独立的进程。例如使用下面三条命令执行三个 Python 程序

python aaa.py

python bbb.py

python ccc.py

        这是会产生三个不同的 Python 进程,下面来验证一下执行 aaa.py 时只会产生一个进程

aaa.py:

import os,time

print(os.getpid())
time.sleep(1000)

终端输入命令:

# 在aaa.py 所在目录下

python aaa.py

# 查看线程

# 在 Windows 下

tasklist | findstr python

# 在 Linux 下

ps aux | grep python

        在这一个 Python 进程当中,不单单只有 aaa.py 的主线程或该主线程开启的其他线程,还会有解释器开启的垃圾回收机制等解释器级别的线程,总的来说,所有线程都运行在这一个进程内。在这一个进程内有以下特性:

  1. 进程内的所有数据对进程内的所有线程都是共享的,代码也作为一种数据被共享(包括 aaa.py 与 CPython 解释器的所有源码);
  2. 所有线程想要执行代码都需要把需要执行的代码当作参数传给解释器的代码去执行的,那也就是说,所有线程先要执行自己的代码就需要先解决是否能够访问到解释器的代码。

        假设 aaa.py 定义了一个 work 函数,现在有多个线程的 target=work,那么执行流程时多个线程先访问到解释器的代码拿到执行权限,然后再把 target 的代码交给解释器去执行。

        但这里面存在一个问题,解释器的代码是所有线程共享的,所以垃圾回收线程也是可以访问解释器,让其执行自己的代码的,那么当对于同一个数据 n,可能线程1执行 n=10 的同时,垃圾回收线程执行的是回收数据 n 的操作。要解决这种问题就需要加锁处理,这就是 GIL 需要解决的问题,如下图的 GIL 来保证 Python 解释器同一时间只能执行一个线程的代码

同一进程内的多个线程

二、GIL 存在的原因

1、简化内存管理

        Python 采用引用计数的方式进行内存管理,每个对象都有一个__refcount__属性来记录当前对象被引用的次数。当引用计数变为 0 时,对象所占用的内存就会被释放。在多线程环境下,如果没有 GIL,多个线程同时对同一个对象的引用计数进行修改,就可能引发竞态条件(Race Condition)。

        比如,线程 A 和线程 B 同时读取并尝试修改同一个对象的引用计数。线程 A 读取到引用计数为 2,然后线程 B 也读取到引用计数为 2。接着线程 A 将引用计数减 1,此时引用计数变为 1。然而,在线程 A 还未将修改后的引用计数写回内存时,线程 B 也将其读取到的引用计数减 1,结果引用计数错误地变为 0,导致对象被提前释放,后续对该对象的操作就会引发程序崩溃。​
        GIL 的存在保证了同一时刻只有一个线程能够操作对象的引用计数,使得引用计数的修改成为原子操作,从而避免了上述内存管理的问题。

2、历史兼容性

        Python 诞生于 20 世纪 90 年代,当时多核 CPU 尚未普及,单线程编程是主流。在这种背景下,为了实现简单且高效的多线程支持,Python 引入了 GIL。通过 GIL,Python 在不需要为每个对象都添加复杂锁机制的情况下,就能够实现线程安全,大大降低了开发难度,也使得早期的 Python 代码能够较为轻松地支持多线程功能。这种设计决策在当时的环境下是非常合理的,并且也保证了 Python 在后续版本中对大量已有代码的兼容性。

3、降低扩展难度

        CPython 的一大优势是可以方便地使用 C 语言编写扩展模块,许多高性能的第三方库(如 Numpy、Pandas)都是通过 C 扩展来实现的。在 C 扩展中,常常需要直接操作内存。GIL 的存在使得 C 扩展代码在多线程环境下无需额外处理复杂的线程安全问题,降低了第三方库开发者的门槛。开发者在编写 C 扩展时,无需担心多个线程同时访问和修改内存可能带来的冲突,因为 GIL 已经确保了同一时刻只有一个线程能够执行相关代码,从而简化了 C 扩展的开发过程。

GIL 与 Lock 的区别

        总的来说,GIL 保护的是解释器级的数据,保护用户自己的数据则需要自己加锁(Lock)处理,如下图所示

        下面将从作用范围、粒度大小和释放机制来分析 GIL 与 Lock 的区别。

一、作用范围

        GIL 是 CPython 解释器层面的全局锁,它作用于整个解释器进程,控制着所有线程对 Python 字节码执行的访问。也就是说,在任何时刻,整个 Python 进程中只有一个线程能够执行字节码。而 Lock(threading.Lock)是用户层面的锁,由开发者在代码中根据具体的需求来创建和使用,其作用范围仅限于开发者指定的共享资源访问代码块。例如,当多个线程需要访问共享的列表、字典等数据结构时,可以使用Lock来保护对这些数据结构的操作,确保同一时刻只有一个线程能够对其进行修改。

二、粒度大小

        GIL 的粒度较粗,因为它是对整个解释器进程进行控制,影响所有线程的执行。一旦一个线程获取了 GIL,其他所有线程都必须等待其释放 GIL 才能有机会执行。这意味着在多线程环境下,即使线程之间操作的是完全不同的资源,只要有一个线程持有 GIL,其他线程就无法执行 Python 字节码。相比之下,Lock 的粒度较细,开发者可以精确地控制哪些代码块需要同步访问。例如,在一个包含多个独立功能模块的程序中,如果每个模块都有自己独立的共享资源,那么可以为每个模块的共享资源分别创建 Lock,不同模块的线程在访问各自的共享资源时,不会因为其他模块的 Lock 而相互阻塞,只有当多个线程试图访问同一 Lock 保护的资源时才会发生阻塞。

三、释放机制

        GIL 的释放是由解释器自动控制的。当线程执行过程中遇到 IO 操作(如文件读写、网络请求)、调用 time.sleep() 函数或者主动调用 sched_yield() 函数时,解释器会自动释放 GIL,让其他线程有机会获取 GIL 并执行。此外,Python 解释器还会在特定的字节码指令执行一定数量后,强制当前线程释放 GIL。而 Lock 的释放则完全由开发者通过代码来控制。当线程调用 lock.acquire() 获取锁后,必须显式地调用 lock.release() 来释放锁,否则其他等待该锁的线程将永远处于阻塞状态。通常,为了确保锁一定会被释放,可以使用 with 语句来管理锁的获取和释放,如下所示

import threading

lock = threading.Lock()
with lock:    # with 语句会在代码块执行结束时自动调用 lock.release() 释放锁,即使代码块中发生异常也能保证锁的正确释放
    # 这里是需要多线程执行的代码
    print('多线程执行的代码...')

GIL 对多线程的影响

        有了 GIL 的存在,同一时刻同一进程中只有一个线程被执行。这种因素下进程可以利用多核,但是开销大,线程无法利用多核但是开销小,那这样看来 Python 是没有用武之地了吗?在此之前我们要清楚 CPU 到底是用来计算还是 I/O 的,CPU 是用于计算的,但是计算需要 I/O 读取计算的原始数据,所以说多核 CPU(多 CPU)提升的是计算性能,而每个 CPU 遇到 I/O 阻塞的时候仍然是需要等待的。所以我们把程序执行的任务分为了计算密集型任务和 I/O 密集型任务。

一、计算密集型任务

        在 CPU 密集型任务中,线程主要的时间花费在 CPU 计算上。由于 GIL 的存在,同一时刻只有一个线程能够执行代码,这就导致多线程无法充分利用多核 CPU 的优势。例如,在一个进行大量数学计算的程序中,假设有两个线程分别执行复杂的数学运算任务。由于 GIL 的限制,这两个线程只能交替地在 CPU 上执行,每个线程在获取到 GIL 后执行一段时间,然后释放 GIL 让另一个线程执行。这样的执行方式,实际上与单线程顺序执行任务的效果类似,甚至可能因为线程切换带来的额外开销(保存和恢复线程上下文环境),使得多线程执行的效率比单线程更低。

二、I/O 密集型任务

        对于 I/O 密集型任务,线程大部分时间都在等待外部资源(如磁盘、网络等)的响应,而不是进行 CPU 计算。在这种情况下,GIL 对多线程性能的影响相对较小。当一个线程发起 I/O 操作时,它会释放 GIL,此时其他线程就有机会获取 GIL 并执行。例如,在一个网络爬虫程序中,线程需要频繁地发送 HTTP 请求并等待响应。当一个线程发送完 HTTP 请求后,在等待响应的过程中,它会释放 GIL,其他线程就可以获取 GIL 并继续发送 HTTP 请求。通过这种方式,多线程可以在一定程度上提高 I/O 密集型任务的并发性能,充分利用等待 I/O 的空闲时间来执行其他任务。

三、多线程和多进程方案对比分析

假设现在有四个任务需要并发执行,解决方案有:

方案一:同时开启四个进程

方案二:在一个进程下开启四个线程

核心数 任务类型 分析结果
单核 计算密集型 在单核的情况下多进程并不能很好利用 I/O 时的空闲时间,反而会增加创建进程的开销,所以方案二更具优势
I/O 密集型任务 多进程创建进程的开销大,且进程的切换速度远不如线程,所以方案二更具优势
多核 计算密集型 在多核的情况下多进程能调用多个核心并行计算,而在 Python 中一个进程内同一时刻只有一个线程在执行,用不上多核,所以方案一更具优势
I/O 密集型任务 I/O 密集型任务瓶颈并不是在计算方面,而是在 I/O 读写数据方面,所以再多的核也解决不了 I/O 问题,所以方案二更具优势

        总的来说,现在计算机基本上都是多核的,Python 对于计算密集型的任务开多线程的效率并不能带来多大性能上的提升,由于多线程的线程之间会存在大量切换,这会导致其甚至还不如串行的性能,但对于 I/O 密集型任务效率还是有显著提升的。

多线程性能测试

一、计算密集型测试:多进程效率高

测试代码: 

from multiprocessing import Process
from threading import Thread
import os,time


def cpu_bound_task():
    result = 0
    for i in range(10000000):
        result *= i
    return result


if __name__ == '__main__':
    print(os.cpu_count())   # 本机为 i9 14900kf 8+16/32
    lp = []
    lt = []

    # 多线程
    start_time = time.time()
    for i in range(20):
        t = Thread(target=cpu_bound_task)   # 耗时2.54秒
        lt.append(t)
        t.start()
    for t in lt:
        t.join()
    print(f"多线程执行时间: {time.time() - start_time}")

    # 多进程
    start_time = time.time()
    for i in range(20):
        p = Process(target=cpu_bound_task)  # 耗时1.35秒
        lp.append(p)
        p.start()
    for p in lp:
        p.join()
    print(f"多进程执行时间: {time.time() - start_time}")

代码输出如下:

计算密集型的应用:金融分析、科学计算等

二、I/O 密集型测试:多线程效率高

测试代码:  

from multiprocessing import Process
from threading import Thread
import os,time


def io_bound_task():
    time.sleep(2)
    return "===> I/O任务完成"


if __name__ == '__main__':
    print(os.cpu_count())   # 本机为 i9 14900kf 8+16/32
    lp = []
    lt = []

    # 多线程
    start_time = time.time()
    for i in range(100):
        t = Thread(target=io_bound_task)   # 耗时2.01秒
        lt.append(t)
        t.start()
    for t in lt:
        t.join()
    print(f"多线程执行时间: {time.time() - start_time}")

    # 多进程
    start_time = time.time()
    for i in range(100):
        p = Process(target=io_bound_task)  # 耗时16.27秒,大部分时间都耗费在进程创建上
        lp.append(p)
        p.start()
    for p in lp:
        p.join()
    print(f"多进程执行时间: {time.time() - start_time}")

代码输出如下:

I/O 密集型的应用:Socket、爬虫、Web 等


网站公告

今日签到

点亮在社区的每一天
去签到