MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Python的GIL及其影响

2021-04-284.3k 阅读

Python的GIL是什么

在深入探讨Python的全局解释器锁(Global Interpreter Lock,简称GIL)及其影响之前,我们先来明确一下GIL究竟是什么。

简单来说,GIL是Python解释器(如CPython)中的一个机制,它确保在任何时刻,只有一个线程能在解释器中执行Python字节码。这意味着,尽管Python支持多线程编程,但在单核CPU环境下,多线程并不能真正利用多核的优势来提高计算密集型任务的执行效率。

从技术层面看,CPython的内存管理并不是线程安全的。为了避免多个线程同时操作共享数据结构(如Python对象的引用计数)时可能引发的内存错误,GIL被引入。当一个线程想要执行Python字节码时,它必须先获取GIL。一旦获取到GIL,该线程就可以执行字节码,直到它主动释放GIL(例如遇到I/O操作、调用外部函数或者达到一定的字节码执行步数等情况),其他线程才有机会获取GIL并执行。

GIL在CPython中的实现原理

  1. 字节码执行与GIL获取
    • CPython的解释器循环会不断地从字节码指令流中取出指令并执行。在进入解释器循环执行字节码之前,线程必须先获取GIL。例如,假设有一段简单的Python代码:
def add_numbers(a, b):
    return a + b
result = add_numbers(3, 5)
  • 当这段代码被执行时,Python解释器会将其编译为字节码。在执行字节码的过程中,线程需要先获取GIL,然后开始解释和执行字节码指令,像LOAD_CONST(加载常量)、BINARY_ADD(执行加法操作)等指令。
  1. GIL的释放机制
    • I/O操作:当线程执行到I/O相关的操作(如文件读取、网络请求等)时,会主动释放GIL。这是因为I/O操作通常会涉及等待外部设备的响应,这段时间线程不占用CPU资源,其他线程可以利用这段时间获取GIL并执行。例如:
import time

def io_bound_task():
    with open('test.txt', 'r') as f:
        data = f.read()
    time.sleep(1)
    return data
  • 在这个io_bound_task函数中,当执行f.read()操作时,线程会释放GIL,其他线程就有机会运行。
  • 时间片机制:CPython解释器还采用了一种近似时间片的机制。在执行一定数量的字节码指令后,当前持有GIL的线程会释放GIL,让其他线程有机会竞争。这个字节码指令的数量是由sys.getcheckinterval()函数获取的,默认值通常为100。例如,我们可以通过以下代码来获取这个值:
import sys
print(sys.getcheckinterval())
  • 这意味着在执行100条字节码指令后,当前线程会释放GIL,除非线程在此之前已经主动释放(如通过I/O操作等)。
  • 调用外部函数:当Python代码调用外部函数(如C扩展函数)时,GIL也会被释放。许多Python的标准库函数,尤其是涉及底层系统调用的函数,都是通过C扩展实现的。例如,math库中的一些数学计算函数,当调用这些函数时,GIL会被释放。
import math

def calculate_sqrt():
    return math.sqrt(16)
  • 在调用math.sqrt函数时,GIL会被释放,使得其他线程有机会运行。

GIL对多线程编程的影响

  1. 计算密集型任务
    • 单核CPU环境:在单核CPU环境下,GIL会限制多线程在计算密集型任务上的性能提升。例如,我们考虑一个简单的计算密集型任务,计算1到100000000的累加和。
import threading


def calculate_sum():
    total = 0
    for i in range(1, 100000001):
        total += i
    return total


thread1 = threading.Thread(target = calculate_sum)
thread2 = threading.Thread(target = calculate_sum)

start_time = time.time()
thread1.start()
thread2.start()
thread1.join()
thread2.join()
end_time = time.time()
print(f"Using threads, time taken: {end_time - start_time} seconds")

start_time = time.time()
result1 = calculate_sum()
result2 = calculate_sum()
end_time = time.time()
print(f"Without threads, time taken: {end_time - start_time} seconds")
  • 在上述代码中,我们分别测试了使用多线程和不使用多线程执行相同的计算密集型任务的时间。由于GIL的存在,在单核CPU环境下,多线程并不能真正并行执行,两个线程实际上是交替执行的,所以使用多线程执行计算密集型任务的时间并不比单线程执行快,甚至可能因为线程切换的开销而变慢。
  • 多核CPU环境:同样在计算密集型任务中,即使在多核CPU环境下,由于GIL的存在,Python的多线程也无法充分利用多核优势。每个线程在执行字节码时都需要获取GIL,而GIL在同一时刻只能被一个线程持有,这就导致多个线程不能同时在不同的CPU核心上执行计算任务。例如,我们有一个多核CPU的机器,如果运行上述计算密集型的多线程代码,性能提升也非常有限。
  1. I/O密集型任务
    • 优势体现:对于I/O密集型任务,GIL的影响较小,并且多线程在这种情况下能发挥较大优势。因为在I/O操作期间,线程会主动释放GIL,其他线程可以利用这段时间获取GIL并执行。比如一个网络爬虫程序,它需要不断地发送HTTP请求并等待响应,这是典型的I/O密集型任务。
import requests
import threading


def fetch_url(url):
    response = requests.get(url)
    return response.text


urls = ["http://example.com", "http://example.org", "http://example.net"]
threads = []
for url in urls:
    thread = threading.Thread(target = fetch_url, args = (url,))
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()
  • 在这个代码示例中,每个线程在执行requests.get时会释放GIL,其他线程就可以在这个时间段内获取GIL并发起自己的HTTP请求,从而大大提高了整体的效率,相比于单线程顺序执行HTTP请求,多线程可以显著减少总执行时间。

如何规避GIL的限制

  1. 使用多进程
    • 原理:Python的multiprocessing模块提供了多进程编程的支持。与多线程不同,每个进程都有自己独立的Python解释器实例和内存空间,不存在GIL的问题。每个进程可以充分利用CPU的多核资源进行并行计算。
    • 示例:我们以之前的计算密集型任务为例,使用多进程来实现。
import multiprocessing


def calculate_sum():
    total = 0
    for i in range(1, 100000001):
        total += i
    return total


if __name__ == '__main__':
    pool = multiprocessing.Pool(processes = 2)
    results = pool.map(calculate_sum, [])
    pool.close()
    pool.join()
  • 在这个代码中,我们使用multiprocessing.Pool创建了一个进程池,包含两个进程。每个进程独立执行calculate_sum函数,由于进程之间不存在GIL的限制,它们可以真正地并行执行,在多核CPU环境下,这种方式能够显著提高计算密集型任务的执行效率。需要注意的是,在Windows系统上,由于其进程创建的机制,if __name__ == '__main__'这部分代码是必需的,以避免在创建新进程时出现一些问题。
  1. 使用C扩展模块
    • 原理:通过编写C扩展模块,可以在调用C函数时释放GIL。C语言可以直接操作硬件资源,并且不受到Python GIL的限制。我们可以将计算密集型的部分代码用C语言实现,然后在Python中通过C扩展模块调用。
    • 示例:下面是一个简单的使用Cython(一种可以将Python代码转换为C代码的工具)创建C扩展模块的例子。首先,创建一个sum_cython.pyx文件:
def calculate_sum():
    cdef int total = 0
    cdef int i
    for i in range(1, 100000001):
        total += i
    return total
  • 然后创建一个setup.py文件用于编译:
from setuptools import setup
from Cython.Build import cythonize

setup(
    ext_modules = cythonize('sum_cython.pyx')
)
  • 接着在命令行中运行python setup.py build_ext --inplace来编译生成C扩展模块。之后在Python代码中就可以像调用普通Python函数一样调用这个C扩展函数:
import sum_cython
result = sum_cython.calculate_sum()
  • 在这个过程中,当调用Cython生成的C函数时,GIL会被释放,从而可以利用多核CPU资源,提高计算密集型任务的执行效率。
  1. 使用异步编程
    • 原理:Python的asyncio库提供了异步编程的支持。异步编程通过事件循环、协程等机制,在单线程内实现非阻塞式的I/O操作。它不需要多线程或多进程,也就不存在GIL的问题。在执行I/O操作时,协程会暂停执行,将控制权交回事件循环,事件循环可以调度其他协程执行,从而提高了程序的整体效率。
    • 示例:我们以一个简单的异步I/O任务为例,模拟多个HTTP请求。
import asyncio


async def fetch_url(url):
    await asyncio.sleep(1)
    return f"Response from {url}"


async def main():
    urls = ["http://example.com", "http://example.org", "http://example.net"]
    tasks = [fetch_url(url) for url in urls]
    results = await asyncio.gather(*tasks)
    for result in results:
        print(result)


if __name__ == '__main__':
    asyncio.run(main())
  • 在这个代码中,fetch_url是一个异步函数(协程),await asyncio.sleep(1)模拟了一个I/O操作,在这个操作期间,协程会暂停,事件循环可以调度其他协程执行。asyncio.gather函数用于并发运行多个协程,并等待它们全部完成。通过这种方式,在单线程内就可以高效地处理多个I/O任务,避免了GIL对多线程I/O任务的限制,同时也减少了线程切换的开销。

GIL在不同Python实现中的情况

  1. CPython
    • 如前文所述,CPython是最广泛使用的Python解释器,它实现了GIL机制。这是因为CPython的内存管理基于引用计数,而引用计数在多线程环境下不是线程安全的。为了保证内存安全,GIL被引入,虽然它在一定程度上限制了多线程的并行执行能力,但也使得CPython在实现和维护上相对简单,并且在许多情况下,通过合理的编程方式(如使用多进程处理计算密集型任务、利用异步编程处理I/O密集型任务等),可以有效地规避GIL的负面影响。
  2. Jython
    • Jython是将Python代码编译成Java字节码并在Java虚拟机(JVM)上运行的Python实现。由于JVM本身提供了线程安全的内存管理机制,Jython没有GIL。这使得Jython在多线程编程方面可以充分利用JVM的多线程能力,对于计算密集型和I/O密集型任务,多线程在Jython中都能更好地发挥并行执行的优势。例如,在Jython中编写多线程计算密集型任务代码,多个线程可以同时在不同的CPU核心上执行,而不会受到类似CPython中GIL的限制。
  3. IronPython
    • IronPython是运行在.NET Framework上的Python实现。与Jython类似,.NET Framework提供了线程安全的内存管理和线程调度机制,所以IronPython也不存在GIL。在IronPython中进行多线程编程,可以充分利用.NET Framework的多线程能力,实现真正的并行计算。这对于需要在.NET生态系统中使用Python进行多线程开发的场景非常有利,例如在开发基于.NET的企业级应用时,使用IronPython进行多线程的数据处理或并发任务执行。
  4. PyPy
    • PyPy是一个具有即时编译(JIT)功能的Python解释器。在早期版本中,PyPy也实现了GIL,原因与CPython类似,为了保证内存管理的线程安全性。然而,随着版本的发展,PyPy引入了一些实验性的无GIL实现方式,例如通过使用更复杂的内存管理技术和线程同步机制,使得部分情况下可以在不依赖GIL的前提下实现多线程的并行执行。但这些无GIL实现目前还处于实验阶段,在稳定性和性能方面可能还需要进一步优化。例如,在某些特定的应用场景下,使用PyPy的无GIL版本可以观察到多线程计算密集型任务的性能有显著提升,但在一些复杂的Python应用中,可能会因为无GIL实现的不完善而出现一些兼容性问题。

GIL对Python生态系统的影响

  1. 对库和框架开发的影响
    • 计算密集型库:对于一些计算密集型的Python库,如numpy(用于数值计算)和scipy(科学计算库),它们在设计和实现时需要考虑GIL的影响。虽然这些库的核心部分通常是用C或Fortran编写的,在调用这些底层函数时会释放GIL,但在Python层面的接口调用仍然会受到GIL的限制。例如,numpy在进行大规模数组计算时,虽然底层的计算函数可以利用多核进行并行计算,但如果在Python代码中通过循环对numpy数组进行多次操作,由于GIL的存在,多线程并不能有效提高性能。库开发者需要提供一些优化的方法,如使用numpy的向量化操作,尽量减少在Python层面的循环,以充分利用底层函数释放GIL的优势,提高整体计算效率。
    • I/O密集型库:对于I/O密集型库,如requests(用于HTTP请求)和pymysql(用于数据库连接),GIL的影响相对较小。这些库的设计理念是在I/O操作时释放GIL,让其他线程有机会运行。例如,requests库在发送HTTP请求时,会释放GIL,使得多个线程可以并发地进行HTTP请求,提高了网络爬虫等应用的效率。库开发者在设计这些库时,需要确保I/O操作的实现能够正确地释放GIL,以保证多线程环境下的性能。
  2. 对应用开发的影响
    • Web开发:在Python的Web开发框架中,如Django和Flask,GIL的影响需要被考虑。大多数Web应用主要是I/O密集型的,例如处理HTTP请求、读取数据库等操作。在这种情况下,多线程可以在一定程度上提高并发处理能力,因为在I/O操作期间GIL会被释放。然而,如果Web应用中包含一些计算密集型的任务(如数据加密、复杂的数据分析等),在多线程环境下,这些任务可能会受到GIL的限制。开发者可能需要将这些计算密集型任务放到单独的进程中处理(如使用multiprocessing模块),或者使用异步编程(如在Flask中结合asyncio)来提高整体性能。
    • 数据处理和机器学习应用:在数据处理和机器学习领域,Python被广泛使用。对于数据处理任务,如果是计算密集型的,如大规模数据的清洗、转换和聚合操作,GIL可能会成为性能瓶颈。在这种情况下,开发者可以选择使用多进程来并行处理数据,或者使用像numpypandas这样的库,并结合它们的优化方法(如向量化操作)来减少在Python层面的循环计算。在机器学习训练过程中,一些模型训练算法(如神经网络的反向传播算法)也是计算密集型的。虽然一些深度学习框架(如TensorFlow和PyTorch)在底层使用了C++等语言实现并行计算,并且在一定程度上规避了GIL的影响,但在Python层面的代码编写和数据预处理等操作中,仍然可能受到GIL的限制。开发者需要根据具体情况,合理选择多进程、异步编程等方式来优化性能。

总结GIL相关的常见误解与真相

  1. 误解一:Python多线程完全没用
    • 真相:虽然在计算密集型任务上,由于GIL的存在,Python多线程在单核CPU环境下不能真正并行执行,性能提升有限甚至可能因为线程切换开销而变慢,但在多核CPU环境下,对于I/O密集型任务,Python多线程能发挥较大优势。例如在网络爬虫、文件I/O等场景中,多线程可以在I/O操作期间释放GIL,让其他线程有机会运行,从而提高整体效率。所以Python多线程并非完全没用,而是在不同类型的任务中有不同的表现。
  2. 误解二:GIL是Python语言本身的缺陷
    • 真相:GIL主要是CPython解释器实现的特性,而不是Python语言本身的特性。其他Python实现,如Jython和IronPython,由于运行在具有线程安全内存管理机制的平台(JVM和.NET Framework)上,不存在GIL。即使在CPython中,GIL的存在也有其合理性,它使得CPython的内存管理相对简单和易于实现,保证了内存安全。虽然GIL在多线程计算密集型任务上有限制,但通过合理的编程方式(如使用多进程、异步编程等)可以规避其负面影响,并且在很多实际应用场景中,Python的生态优势和开发效率仍然非常突出。
  3. 误解三:移除GIL能解决所有性能问题
    • 真相:移除GIL并不一定能解决所有性能问题。虽然移除GIL可以让多线程在计算密集型任务中实现真正的并行执行,但这也会带来其他挑战。例如,在CPython中移除GIL后,需要重新设计内存管理机制以确保线程安全,这可能会增加实现的复杂性和性能开销。此外,许多现有的Python库和代码都是基于GIL的存在进行设计和优化的,移除GIL可能会导致兼容性问题,需要对大量代码进行修改和重新测试。而且,对于一些I/O密集型任务,即使没有GIL,性能提升可能也不明显,因为瓶颈可能在于外部设备的响应速度,而不是CPU的并行计算能力。所以移除GIL只是解决性能问题的一个方面,还需要综合考虑其他因素。

通过对GIL的深入了解,我们可以在Python编程中更加合理地选择编程模型(多线程、多进程、异步编程等),充分发挥Python的优势,避免GIL带来的性能瓶颈,开发出高效、稳定的应用程序。无论是在开发计算密集型的科学计算程序,还是I/O密集型的网络应用,对GIL的理解和掌握都是Python开发者必备的技能。