Python GIL 对线程并发性能的影响

目录

Python GIL 对线程并发性能的影响

说到这里,不妨继续引入 Python GIL 的问题。

在多处理器时代,程序要想充分的利用计算平台的性能,就必须按照并发方式进行设计。但是很遗憾,对于 Python 程序而言,不管你的服务器拥有多少个处理器,任何时候总是有且只能有一个线程在运行。这就是 GIL 为 Python 带来的最困难的问题。并且目前看来短时间内这个问题是难以得到解决的,以至于 Python 专家们通常会建议你 “不要使用多线程,请使用多进程”。

Python 是解释型语言,程序代码被编译成二进制格式的字节码,然后再由 Python 解释器的主回路 pyeval_evalframeex() 边读取字节码,边逐一执行其中的指令。显然,解释器在程序运行之前对程序本身并不是完全了解的,解释器只知道 Python 既定的规则以及在执行过程中怎样动态的去遵守这些规则。Python 解释器无法像 C/C++ 编译器那般在程序进入到处理器运行之前就已经对程序代码拥有了全局的语义分析和理解能力。作为解释型语言,Python 解释器无法在程序真正运行之前就告诉你,你的多线程代码实现到底有多糟糕(隐含的逻辑错误要到真正运行时才会触发)。

你是否也曾面对过这样的窘境,使用 Python 多线程以后,程序的执行效率反而比使用单线程的时候更低了?即便 Python 多线程没有完成真正的并行,那也应该和串行的单线程差不太多才是啊?实际情况可以比你想象的更加糟糕,Python 的多线程在某些场景中会比单线程的效率下降 45%。这是由于 GIL 的设计缺陷导致的。

Python 社区认为操作系统的调度器已经非常成熟,可以直接使用,所以 Python 的线程实际上是 C 语言的一个 pthread,并交由系统调度器根据调度算法和策略进行调度。同时,为了让各线程能够平均的获得 CPU 时间片,Python 会自己维护一个微代码(字节码指令)执行计数器(Python2:1000 字节码指令,Python3:15 毫秒),达到一定的计数阈值后就会强制当前线程释放 GIL,让其他线程得到进入 CPU 的机会,这意味着 GIL 的释放与获取是伴随着操作系统线程切换一起进行的。

这样的模式在单处理器计算平台中是没有问题的,每触发一次线程切换,当前线程都能够如愿获取 GIL 并执行字节码指令,所以单个处理器始终是忙碌的。但在多处理器计算平台中这样的模式会发生什么呢?GIL 只有一个,给了在 CPU1 的当前线程,就不能给 CPU2 的当前线程,所以 CPU2 的当前线程只能白白浪费 CPU 执行时间(线程只有获取了 GIL 才能执行字节码指令)。而且在多处理器计算平台中还平添了线程切换甚至是进程切换的各种开销,赔了夫人又折兵。

这里写图片描述

  • 绿色:CPU 的有效执行时间
  • 红色:线程因为没拿到 GIL 白白浪费的 CPU 时间

那么,Python 的多线程到底还能不能用?就结果而言,如果业务系统中存在任意一个 CPU 密集型的任务,那么我会告诉你 “多进程或者协程都是不错的选择”。如果业务系统中全都是 I/O 密集型任务,那么恭喜你,多线程将会起到积极的作用。

Python 多线程在 I/O 密集型场景中允许真正的并发,是因为一个等待 I/O 的当前线程会在长的或者不确定的一段时间内,可能并没有任何 Python 代码会被执行,那么该线程就会将 GIL 让出给其他处理器上的当前线程使用(一个在 I/O,一个在执行 Python 代码)。这种礼貌行为称为协同式多任务处理,它允许并发。不同的线程在等待不同的事件。

综上,对于复杂的 Python 业务系统而言,分布式架构(解耦 CPU 密集型业务和 I/O 密集型业务并分别部署到不同的服务器上进行调优)是一个不错选择。

Python 的线程安全问题

GIL 解决的问题本质就是 Python 多线程的线程安全问题(thread-safe)。从上文中我们了解到,同一进程的多个线程间存在数据共享。为了避免内存可见性的并发安全问题,编程语言大多会提供用户可控的数据的保护机制,也就是线程同步功能。使用线程同步功能,可以控制程序流以及安全访问共享数据,从而并发执行多个线程。常见的同步模型大致有以下四种:

  • 互斥锁:仅允许每次使用一个线程来执行特定代码块或者访问特定的共享数据。
  • 读写锁:允许对受保护的共享数据进行并发读取和独占写入(多读单写)。要修改共享数据,线程必须首先获取互斥写锁。只有释放所有的读锁之后,才允许使用互斥写锁。
  • 条件变量:一直阻塞线程,直到特定的条件为真。
  • 计数信号量:通常用来协调对共享数据的访问。使用计数,可以限制访问某个信号的线程数量。达到计数阈值时,信号被阻塞,直至线程执行接收,计数减少为止。

为了线程安全,Python 提供了下列 3 种常见的实现:

  • 原子性操作
  • 线程库锁(e.g. threading.Lock)
  • GIL

Python 的原子性操作

Python 提供的许多内置函数都是具有原子性的,例如排序函数 sort()

>>> lst = [4, 1, 3, 2]
>>> def foo():
...     lst.sort()
...
>>> import dis
>>> dis.dis(foo)
  2           0 LOAD_GLOBAL              0 (lst)
              3 LOAD_ATTR                1 (sort)
              6 CALL_FUNCTION            0
              9 POP_TOP
             10 LOAD_CONST               0 (None)
             13 RETURN_VALUE

我们使用 dis 模块来编译出上述代码的字节码,最关键的字节码指令为:

  1. LOAD_GLOBAL:将全局变量 lst 的数据 load 到堆栈
  2. LOAD_ATTR:将 sort 的实现 load 到堆栈
  3. CALL_FUNCTION:调用 sort 对 lst 的数据进行排序

真正执行排序的只有 CALL_FUNCTION 一条指令,所以说该操作具有原子性。

Python 的线程库锁

我们再举个例子看看非原子操作下,怎么保证线程安全。

>>> n = 0
>>> def foo():
...     global n
...     n += 1
...
>>> import dis
>>> dis.dis(foo)
  3           0 LOAD_GLOBAL              0 (n)
              3 LOAD_CONST               1 (1)
              6 INPLACE_ADD
              7 STORE_GLOBAL             0 (n)
             10 LOAD_CONST               0 (None)
             13 RETURN_VALUE

代码编译后的字节码指令:

  1. 将全局变量 n 的值 load 到堆栈
  2. 将常数 1 的值 load 到堆栈
  3. 在堆栈顶部将两个数值相加
  4. 将相加结果存储回全局变量 n 的地址
  5. 将常数 0(None) 的值 load 到堆栈
  6. 从堆栈顶部返回常数 0 给函数调用者

语句 n += 1 被编译成了前 4 个字节码,后两个字节码是 foo 函数的 return 操作,解释器自动添加。

我们在上文提到,Python2 的线程每执行 1000 个字节码就会被动的让出 GIL。现在假如字节码指令 INPLACE_ADD 就是那第 1000 条指令,这时本应该继续执行 STORE_GLOBAL 0 (n) 存储到 n 地址的数据就被驻留在了堆栈中。如果同一时刻,变量 n 被别的处理器当前线程中的代码调用了。那么请问现在的 n 还是 +=1 之后的 n 吗?答案是此时的 n 发生了更新丢失,在两个当前线程中的 n 已经不是同一个 “n” 了。这就是上面我们提到过的内存可见性数据安全问题的又一个佐证。

下面的代码正确输出为 100,但在 Python 多线程多处理器场景中,可能会得到 99 或 98 的结果。

import threading




n = 0
threads = []


def foo():
    global n
    n += 1


for i in range(100):
    t = threading.Thread(target=foo)
    threads.append(t)


for t in threads:
    t.start()


for t in threads:
    t.join()


print(n)

此时,Python 程序员应该要想到使用 Python 线程库的锁来解决为。

import threading




n = 0
lock = threading.Lock()
threads = []


def foo():
    global n
    with lock:
        n += 1


for i in range(100):
    t = threading.Thread(target=foo)
    threads.append(t)


for t in threads:
    t.start()


for t in threads:
    t.join()


print(n)

显然,即便 Python 已经存在了 GIL,但依旧要求程序员坚持「始终为共享可变状态的读写上锁」。至于 Python 多线程既然也实现诸如此类的细粒度的锁,为什么还要固执的坚持使用 GIL 这把巨大无比的锁呢?很抱歉,除了引用官方文档,笔者实在不能给出更多的答案了,这是一个令人着迷又深感挫折的问题。

static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary primarily because CPython’s memory management is not thread-safe. (However, since the GIL exists, other Features have grown to depend on the guarantees that it enforces.)

翻译:在 CPython(最常用的 Python 解释器实现)中,全局解释器锁(GIL)是一个全局的互斥锁,它可以防止多线程同时执行 Python 程序的字节码。 这种锁是必要的,主要因为 CPython 的内存管理不是线程安全的。

当然也有人尝试过将 GIL 改废,Greg Stein 在 1999 年提出的 “Free Threading” patch 中移除了 GIL。但结果就是单线程执行性能下降了 40%,同时多线程的性能提升也未能达到线性增长标准。至今为止有许多乐于挑战的开发者们在尝试解决这一难题,甚至发布了多种没有 GIL 的 Python 解释器实现(e.g. JPython、IronPython)。不过很可惜的是,由于这些 “特殊” 解释器不属于 C 语言生态圈,所以没能享受到社区众多优秀 C 语言模块的福利,也就注定无法成为主流,只能在特定的场景中发挥着属于自己的特长。

无论如何,GIL 作为 Python 的文化基因,深远的影响了每一位 Pythoner,但却并不完全是正面的影响。例如:Python 程序员对多线程安全问题的理解与任何 C 或 Java 程序员都是大相径庭的。GIL 和 Python 原子性操作的 “溺爱” 让大多数 Python 程序员产生了 “Python 是原生线程安全的编程语言” 的幻觉,并最终在大规模并发应用场景中屡屡受挫。或许真是应了那一句 “Python 的门很好进,但进了门之后才发现 Python 的殿堂在天上”。

那么 GIL 是万恶之源吗?也不尽然,编程的世界永远是「时间和空间」的权衡,简单优雅或许才是真正的 Python 之美。

相关阅读:

原文地址:https://www.cnblogs.com/hzcya1995/p/13309290.html