43

GIL 已经被杀死了么?

 4 years ago
source link: https://www.tuicool.com/articles/7J3MZjv
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

ER7fQ33.gif

花下猫语: Python 中最广为人诟病的一点,大概就是它的 GIL 了。由于 GIL 的存在,Python 无法实现真正的多线程编程,因此很多人都把这视作 Python 最大的软肋。

PEP-554 提出后(2017年9月),大伙似乎看到了一线改善的曙光。然而,GIL 真的可以被彻底杀死么,如果可以的话,它会怎么实现呢,为什么等了一年多还没实现,仍需要我们等待多长时间呢?

q2mMrqn.jpg!web

图片来源:pexels

英文  | Has the Python GIL been slain? 【1】

作者| Anthony Shaw

译者| 豌豆花下猫

声明: 本文获得原作者授权翻译,转载请保留原文出处,请勿用于商业或非法用途。

2003 年初,Intel 公司推出了全新的奔腾 4 “HT” 处理器,该处理器的主频(译注:CPU 内核工作的时钟频率)为 3 GHz,采用了“超线程”技术。

在接下来的几年中,Intel 和 AMD 激烈竞争,通过提高总线速度、L2 缓存大小和减小芯片尺寸以最大限度地减少延迟,努力地实现最佳的台式机性能。3Ghz 的 HT 在 2004 年被“Prescott”的 580 型号取代,该型号的主频高达 4 GHz。

似乎提升性能的最好方法就是提高处理器的主频,但 CPU 却受到高功耗和散热会影响全球变暖的困扰。

你电脑上有 4Ghz 的 CPU 吗?不太可能,因为性能的前进方式是更高的总线速度和更多的内核。Intel 酷睿 2 代在 2006 年取代了奔腾 4 ,主频远低于此。

除了发布消费级的多核 CPU,2006 年还发生了其它事情,Python 2.5 发布了!Python 2.5 带来了人见人爱的 with 语句的 beta 版本 。

在使用 Intel 的酷睿 2 或 AMD 的 Athlon X2 时,Python 2.5 有一个重要的限制—— GIL

什么是 GIL?

GIL 即全局解释器锁(Global Interpreter Lock),是 Python 解释器中的一个布尔值,受到互斥保护。这个锁被 CPython 中的核心字节码用来评估循环,并调节用来执行语句的当前线程。

CPython 支持在单个解释器中使用多线程,但线程们必须获得 GIL 的使用权才能执行操作码(做低级操作)。这样做的好处是,Python 开发人员在编写异步代码或多线程代码时,完全不必操心如何获取变量上的锁,也不需担心进程因为死锁而崩溃。

GIL 使 Python 中的多线程编程变得简单。

buy6Fbm.gif

GIL 还意味着虽然 CPython 可以是多线程的,但在任何给定的时间里只能执行 1 个线程。这意味着你的四核 CPU 会像上图一样工作 (减去蓝屏,但愿如此)。

当前版本的 GIL 是在2009年编写的 【2】,用于支持异步功能,几乎没被改动地存活了下来,即使曾经多次试图删除它或减少对它的依赖。

所有提议移除 GIL 的诉求是,它不应该降低单线程代码的性能。任何曾在 2003 年启用超线程(Hyper-Threading)的人都会明白为什么 这很重要 【3】。

在 CPython 中避免使用 GIL

如果你想在 CPython 中使用真正的并发代码,则必须使用多进程。

在 CPython 2.6 中,标准库里增加了 multiprocessing 模块。multiprocessing 是 CPython 大量产生的进程的包装器(每个进程都有自己的GIL)——

from multiprocessing import Process

def f(name):
    print 'hello', name

if __name__ == '__main__':
    p = Process(target=f, args=('bob',))
    p.start()
    p.join()

进程可以从主进程中“孵出”,通过编译好的 Python 模块或函数发送命令,然后重新纳入主进程。

multiprocessing 模块还支持通过队列或管道共享变量。它有一个 Lock 对象,用于锁定主进程中的对象,以便其它进程能够写入。

多进程有一个主要的缺陷:它在时间和内存使用方面的开销很大。CPython 的启动时间,即使没有非站点(no-site),也是 100-200ms(参见 这个链接 【4】)。

因此,你可以在 CPython 中使用并发代码,但是你必须仔细规划那些长时间运行的进程,这些进程之间极少共享对象。

另一种替代方案是使用像 Twisted 这样的三方库。

PEP-554 与 GIL 的死亡?

小结一下,CPython 中使用多线程很容易,但它并不是真正的并发,多进程虽然是并发的,但开销却极大。

有没有更好的方案呢?

绕过 GIL 的线索就在其名称中,全局 解释器 锁是全局解释器状态的一部分。 CPython 的进程可以有多个解释器,因此可以有多个锁,但是此功能很少使用,因为它只通过 C-API 公开。

在为 CPython 3.8 提出的特性中有个 PEP-554,提议实现子解释器(sub-interpreter),以及在标准库中提供一个新的带有 API 的 interpreters 模块。

这样就可以在 Python 的单个进程中创建出多个解释器。Python 3.8 的另一个改动是解释器都将拥有单独的 GIL ——

yayy2yI.jpg!web

因为解释器的状态包含内存分配竞技场(memory allocation arena),即所有指向 Python 对象(局地和全局)的指针的集合,所以 PEP-554 中的子解释器无法访问其它解释器的全局变量。

与多进程类似,在解释器之间共享对象的方法是采用 IPC 的某种形式(网络、磁盘或共享内存)来做序列化。在 Python 中有许多方法可以序列化对象,例如 marshal 模块、 pickle 模块、以及像 jsonsimplexml 这样更标准化的方法 。这些方法褒贬不一,但无一例外会造成额外的开销。

最佳方案是开辟一块共享的可变的内存空间,由主进程来控制。这样的话,对象可以从主解释器发送,并由其它解释器接收。这将是 PyObject 指针的内存管理空间,每个解释器都可以访问它,同时由主进程拥有对锁的控制权。

qAFvquy.jpg!web

这样的 API 仍在制定中,但它可能如下所示:

import _xxsubinterpreters as interpreters
import threading
import textwrap as tw
import marshal

# Create a sub-interpreter
interpid = interpreters.create()

# If you had a function that generated some data
arry = list(range(0,100))

# Create a channel
channel_id = interpreters.channel_create()

# Pre-populate the interpreter with a module
interpreters.run_string(interpid, "import marshal; import _xxsubinterpreters as interpreters")

# Define a
def run(interpid, channel_id):
    interpreters.run_string(interpid,
                            tw.dedent("""
        arry_raw = interpreters.channel_recv(channel_id)
        arry = marshal.loads(arry_raw)
        result = [1,2,3,4,5] # where you would do some calculating
        result_raw = marshal.dumps(result)
        interpreters.channel_send(channel_id, result_raw)
        """),
               shared=dict(
                   channel_id=channel_id
               ),
               )

inp = marshal.dumps(arry)
interpreters.channel_send(channel_id, inp)

# Run inside a thread
t = threading.Thread(target=run, args=(interpid, channel_id))
t.start()

# Sub interpreter will process. Feel free to do anything else now.
output = interpreters.channel_recv(channel_id)
interpreters.channel_release(channel_id)
output_arry = marshal.loads(output)

print(output_arry)

此示例使用了 numpy ,并通过使用 marshal 模块对其进行序列化来在通道上发送 numpy 数组 ,然后由子解释器来处理数据(在单独的 GIL 上),因此这会是一个计算密集型(CPU-bound)的并发问题,适合用子解释器来处理。

这看起来效率低下

marshal 模块相当快,但仍不如直接从内存中共享对象那样快。

PEP-574 提出了一种新的 pickle  【5】协议(v5),它支持将内存缓冲区与 pickle 流的其余部分分开处理。对于大型数据对象,将它们一次性序列化,再由子解释器反序列化,这会增加很多开销。

新的 API 可以( 假想 ,并没有合入)像这样提供接口:

import _xxsubinterpreters as interpreters
import threading
import textwrap as tw
import pickle

# Create a sub-interpreter
interpid = interpreters.create()

# If you had a function that generated a numpy array
arry = [5,4,3,2,1]

# Create a channel
channel_id = interpreters.channel_create()

# Pre-populate the interpreter with a module
interpreters.run_string(interpid, "import pickle; import _xxsubinterpreters as interpreters")

buffers=[]

# Define a
def run(interpid, channel_id):
    interpreters.run_string(interpid,
                            tw.dedent("""
        arry_raw = interpreters.channel_recv(channel_id)
        arry = pickle.loads(arry_raw)
        print(f"Got: {arry}")
        result = arry[::-1]
        result_raw = pickle.dumps(result, protocol=5)
        interpreters.channel_send(channel_id, result_raw)
        """),
                            shared=dict(
                                channel_id=channel_id,
                            ),
                            )

input = pickle.dumps(arry, protocol=5, buffer_callback=buffers.append)
interpreters.channel_send(channel_id, input)

# Run inside a thread
t = threading.Thread(target=run, args=(interpid, channel_id))
t.start()

# Sub interpreter will process. Feel free to do anything else now.
output = interpreters.channel_recv(channel_id)
interpreters.channel_release(channel_id)
output_arry = pickle.loads(output)

print(f"Got back: {output_arry}")

这看起来像极了很多样板

确实,这个例子使用的是低级的子解释器 API。如果你使用了多进程库,你将会发现一些问题。它不像 threading 那么简单,你不能想着在不同的解释器中使用同一串输入来运行同一个函数(目前还不行)。

一旦合入了这个 PEP,我认为 PyPi 中的其它一些 API 也会采用它。

子解释器需要多少开销?

简版回答:大于一个线程,少于一个进程。

详版回答:解释器有自己的状态,因此虽然 PEP-554 可以使创建子解释器变得方便,但它还需要克隆并初始化以下内容:

  • main 命名空间与 importlib 中的模块

  • sys 字典的内容

  • 内置的方法(print、assert等等)

  • 线程

  • 核心配置

核心配置可以很容易地从内存克隆,但导入的模块并不那么简单。在 Python 中导入模块的速度很慢,因此,如果每次创建子解释器都意味着要将模块导入另一个命名空间,那么收益就会减少。

那么 asyncio 呢?

标准库中 asyncio 事件循环的当前实现是创建需要求值的帧(frame),但在主解释器中共享状态(因此共享 GIL)。

在 PEP-554 被合入后,很可能是在 Python 3.9,事件循环的替代实现 可能 是这样(尽管还没有人这样干):在子解释器内运行 async 方法,因此会是并发的。

听起来不错,发货吧!

额,还不可以。

因为 CPython 已经使用单解释器的实现方案很长时间了,所以代码库的许多地方都在使用“运行时状态”(Runtime State)而不是“解释器状态”(Interpreter State),所以假如要将当前的 PEP-554 合入的话,将会导致很多问题。

例如,垃圾收集器(在 3.7 版本前)的状态就属于运行时。

PyCon sprint 期间(译注:PyCon 是由 Python 社区举办的大型活动,作者指的是官方刚在美国举办的这场,时间是2019年5月1日至5月9日。sprint 是为期 1-4 天的活动,开发者们自愿加入某个项目,进行“冲刺”开发。该词被敏捷开发团队使用较多,含义与形式会略有不同),更改已经开始 【6】将垃圾收集器的状态转到解释器,因此每个子解释器将拥有它自己的 GC(本该如此)。

另一个问题是在 CPython 代码库和许多 C 扩展中仍残存着一些“全局”变量。因此,当人们突然开始正确地编写并发代码时,我们可能会遭遇到一些问题。

还有一个问题是文件句柄属于进程,因此当你在一个解释器中读写一个文件时,子解释器将无法访问该文件(不对 CPython 作进一步更改的话)。

简而言之,还有许多其它事情需要解决。

结论:GIL 死亡了吗?

对于单线程的应用程序,GIL 仍然存活。因此,即便是合并了 PEP-554,如果你有单线程的代码,它也不会突然变成并发的。

如果你想在 Python 3.8 中使用并发代码,那么你就会遇到计算密集型的并发问题,那么这可能是张入场券!

什么时候?

Pickle v5 和用于多进程的共享内存可能是在 Python 3.8(2019 年 10 月)实现,子解释器将介于 3.8 和 3.9 之间。

如果你现在想要使用我的示例,我已经构建了一个分支,其中包含所有必要的代码 【7】

相关链接

[1] Has the Python GIL been slain? https://hackernoon.com/has-the-python-gil-been-slain-9440d28fa93d
[2] 是在2009年编写的: https://github.com/python/cpython/commit/074e5ed974be65fbcfe75a4c0529dbc53f13446f
[3] 这很重要: https://arstechnica.com/features/2002/10/hyperthreading
[4] 这个链接 : https://hackernoon.com/which-is-the-fastest-version-of-python-2ae7c61a6b2b
[5] PEP-574 提出了一种新的 pickle : https://www.python.org/dev/peps/pep-0574/
[6] 更改已经开始: https://github.com/python/cpython/pull/13219
[7] 必要的代码 : https://github.com/tonybaloney/cpython/tree/subinterpreters

ieYVBr2.jpg!web


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK