理解Python中的GIL

在上文《Python 线程基础》提到了Python的GIL机制,本文进行进一步介绍。

Python 的 GIL(Global Interpreter Lock,全局解释器锁)是 CPython 解释器中的一个机制,用于确保同一时刻只有一个线程执行 Python 字节码。

可以从三个方面理解 GIL 的作用和影响:

  • 多线程并发对CPU密集型任务无效。
  • 多线程并发对IO密集型任务有效。
  • 优化CPU密集型任务: 使用多进程、使用优化过的三方库。

1 多线程并发对CPU密集型任务无效

先看示例1,这个示例是对两个矩阵进行乘法运算。

import numpy as np
import time


def matrix_multiply(n, seed=0, dtype=np.float32) -> np.ndarray:
    """
    计算两个 n x n 随机矩阵的乘积,并返回结果。

    参数:
        n (int): 矩阵的维度(n x n)
        seed (int or None): 随机种子,用于复现结果;设为 None 则不固定
        dtype (np.dtype): 矩阵数据类型,默认 float32(节省内存)

    返回:
        result (np.ndarray): 结果矩阵 (n x n)
    """
    if seed is not None:
        np.random.seed(seed)

    matrix1 = np.random.rand(n, n).astype(dtype)
    matrix2 = np.random.rand(n, n).astype(dtype)
    result = np.zeros((n, n), dtype=dtype)

    for i in range(n):
        for j in range(n):
            total = 0
            for k in range(n):
                total += matrix1[i][k] * matrix2[k][j]
            result[i][j] = total

    return result


if __name__ == "__main__":
    n = 128

    start_time = time.perf_counter()  # 更准确的计时器

    matrix_multiply(n)

    end_time = time.perf_counter()
    elapsed = end_time - start_time

    print(f"完成计算,花费时间是 {elapsed:.2f} 秒")

示例1的运行时间大约为0.7秒。

接下来看示例2,示例2使用三个线程同时执行matrix_multiply()。

import threading

import numpy as np
import time


def matrix_multiply(n, seed=0, dtype=np.float32) -> np.ndarray:
    ...


if __name__ == "__main__":
    n = 128

    start_time = time.perf_counter()  # 更准确的计时器

    threads = []
    for i in range(3):
        t = threading.Thread(target=matrix_multiply, args=(n,))
        t.start()
        threads.append(t)

    for t in threads:
        t.join()

    end_time = time.perf_counter()
    elapsed = end_time - start_time

    print(f"完成计算,花费时间是 {elapsed:.2f} 秒")

示例2的运行时间大约为2.1秒,也就是说,由于 GIL 机制,虽然使用了三个线程进行并发,并没有并发效果,实际上三个线程还是串行运行。

2 多线程并发对IO密集型任务有效

Python 在进行“阻塞式 I/O”时会释放 GIL,使得多线程对阻塞 I/O 密集型任务有效。对于“非阻塞 I/O”,多线程无意义甚至是负优化,应使用事件循环(asyncio)。

看一个示例,通过多线程下载网络内容。

import time
import requests
from concurrent.futures import ThreadPoolExecutor

# 5个网站
urls = [
    'https://www.qq.com',
    'https://www.qq.com',
    'https://www.qq.com',
    'https://www.qq.com',
    'https://www.qq.com',
]

def download(url) -> int:
    response = requests.get(url, timeout=10)
    return len(response.content)

if __name__ == "__main__":
    # 单线程
    print("单线程下载:")
    start = time.time()
    for url in urls:
        size = download(url)
        print(f"  {url}: {size} bytes")
    single_time = time.time() - start
    print(f"耗时: {single_time:.2f} 秒
")

    # 多线程
    print("多线程下载:")
    start = time.time()
    with ThreadPoolExecutor(max_workers=len(urls)) as executor:
        results = executor.map(download, urls)
        for url, size in zip(urls, results):
            print(f"  {url}: {size} bytes")
    multi_time = time.time() - start
    print(f"耗时: {multi_time:.2f} 秒
")

    # 对比
    print(f"性能对比: {single_time/multi_time:.2f}x")

上述示例中,单线程耗时约为1.25秒,多线程耗时约为0.25秒。

3 CPU密集型任务的优化

3.1 使用多进程进行并发

from concurrent.futures import ProcessPoolExecutor
from multiprocessing import Pool

def compute(n):
    return sum(i*i for i in range(n))

if __name__ == '__main__':
    # 多进程方案1: multiprocessing
    with Pool(processes=4) as pool:
        results = pool.map(compute, [1000000] * 8)

    # 多进程方案2: multiprocessing的高级封装
    with ProcessPoolExecutor(max_workers=4) as executor:
        results = executor.map(compute, [1000000] * 8)

3.2 使用优化过的三方库

Python中的一些三方库针对科学计算进行过专门的优化,应该优先使用这些库,而不是使用纯粹的Python代码。

import time

import numpy as np


def matrix_multiply_numpy(n, seed=0, dtype=np.float32):
    """使用NumPy的 @ 或 dot 运算符"""
    if seed is not None:
        np.random.seed(seed)

    matrix1 = np.random.rand(n, n).astype(dtype)
    matrix2 = np.random.rand(n, n).astype(dtype)

    # 方法1: @ 运算符(推荐)
    result = matrix1 @ matrix2

    # 方法2: np.dot(等价)
    # result = np.dot(matrix1, matrix2)

    # 方法3: np.matmul(等价)
    # result = np.matmul(matrix1, matrix2)

    return result


if __name__ == "__main__":
    n = 128

    # 单线程
    start_time = time.perf_counter()

    matrix_multiply_numpy(n)

    end_time = time.perf_counter()
    elapsed = end_time - start_time

    print(f"完成计算,花费时间是 {elapsed:.2f} 秒")

上述示例中,matrix_multiply_numpy()的执行时间约为 0.03 秒,刚开始的示例函数matrix_multiply()的执行时间约为 0.7 秒,性能提升了 23 倍左右。

4 总结

Python GIL 会被释放的主要场景: 执行阻塞I/O操作;某些高性能三方库执行CPU密集计算;调用 time.sleep();多线程中,等待线程通过5ms超时机制触发GIL释放请求,持有线程响应该请求释放GIL (也就是说,GIL可以保证同一时刻只有一个线程执行 Python 字节码,但是这个线程是可以切换的)。

© 版权声明

相关文章

暂无评论

none
暂无评论...