GIL 已经被杀死了么?
GIL 已经被杀死了么?
本文原创并首发于公众号【Python猫】,未经授权,请勿转载。
原文地址:https://mp.weixin.qq.com/s/8KvQemz0SWq2hw-2aBPv2Q
花下猫语: Python 中最广为人诟病的一点,大概就是它的 GIL 了。由于 GIL 的存在,Python 无法实现真正的多线程编程,因此很多人都把这视作 Python 最大的软肋。
PEP-554 提出后(2017年9月),大伙似乎看到了一线改善的曙光。然而,GIL 真的可以被彻底杀死么,如果可以的话,它会怎么实现呢,为什么等了一年多还没实现,仍需要我们等待多长时间呢?
英文 | 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 中的多线程编程变得简单。
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 ——
因为解释器的状态包含内存分配竞技场(memory allocation arena),即所有指向 Python 对象(局地和全局)的指针的集合,所以 PEP-554 中的子解释器无法访问其它解释器的全局变量。
与多进程类似,在解释器之间共享对象的方法是采用 IPC 的某种形式(网络、磁盘或共享内存)来做序列化。在 Python 中有许多方法可以序列化对象,例如 marshal
模块、 pickle
模块、以及像 json
和 simplexml
这样更标准化的方法 。这些方法褒贬不一,但无一例外会造成额外的开销。
最佳方案是开辟一块共享的可变的内存空间,由主进程来控制。这样的话,对象可以从主解释器发送,并由其它解释器接收。这将是 PyObject 指针的内存管理空间,每个解释器都可以访问它,同时由主进程拥有对锁的控制权。
这样的 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】
References
[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
公众号【Python猫】, 本号连载优质的系列文章,有喵星哲学猫系列、Python进阶系列、好书推荐系列、技术写作、优质英文推荐与翻译等等,欢迎关注哦。后台回复“爱学习”,免费获得一份学习大礼包。
GIL 已经被杀死了么?的更多相关文章
- 聊聊 Python 的内置电池
本文原创并首发于公众号[Python猫],未经授权,请勿转载. 原文地址:https://mp.weixin.qq.com/s/XzCqoCvcpFJt4A-E4WMqaA (一) 最近,我突然想到一 ...
- 进程,线程,GIL,Python多线程,生产者消费者模型都是什么鬼
1. 操作系统基本知识,进程,线程 CPU是计算机的核心,承担了所有的计算任务: 操作系统是计算机的管理者,它负责任务的调度.资源的分配和管理,统领整个计算机硬件:那么操作系统是如何进行任务调度的呢? ...
- 网络编程 生产者消费者模型 GiL
守护进程: 注意事项: 1.必须在p.start()前 2.守护进程不能开子进程 3.如果主进程的运行时间快于子进程,那么就只有主进程的结果,没有守护进程的结果,因为守护进程没有进行完.反之会得到两个 ...
- 15 GIL 全局解释器锁 C语言解决 top ps
1.GIL 全局解释器锁:保证同一时刻只有一个线程在运行. 什么是全局解释器锁GIL(Global Interpreter Lock) Python代码的执行由Python 虚拟机(也叫解释器主循环, ...
- day37 GIL、同步、异步、进程池、线程池、回调函数
1.GIL 定义: GIL:全局解释器锁(Global Interpreter Lock) 全局解释器锁是一种互斥锁,其锁住的代码是全局解释器中的代码 为什么需要全局解释器锁 在我们进行代码编写时,实 ...
- Win7 命令行强制杀死进程
原理 1.查看当前正在运行的进程 tasklist 如下图所示 2.强制杀死映像名称为imagename的进程,映像名称可通过任务管理器或tasklist命令查看 taskkill /im image ...
- sql杀死进程
查询SQL所有的链接 并可以查看连接当前正在做什么操作..使用的什么语句.. SELECT spid, blocked, DB_NAME(sp.dbid) AS DBName, program_na ...
- 使用极光/友盟推送,APP进程杀死后为什么收不到推送(转)
为什么会存在这样的 问题,刚开始的时候我也搞不清楚,之前用极光的时候杀死程序后也会收到推送,但最近重新再去集成时就完全不好使了,这我就纳闷了,虽然Google在高版本上的android上面不建议线程守 ...
- window 和 linux 环境下杀死tomcat进程——也可以解决其他端口被占用的问题
1.应用场景 在Windows或者linux操作系统中,我们在启动一个tomcat服务器时,经常会发现8080端口已经被占用的错误,而我们又不知道如何停止这个tomcat服务器. 2.window环境 ...
随机推荐
- C# WPF DataGrid控件实现三级联动
利用DataGrid控件实现联动的功能,在数据库客户软件中是随处可见的,然而网上的资料却是少之又少,令人崩溃. 本篇博文将介绍利用DataGrid控件模板定义的三个ComboBox实现“省.市.区”的 ...
- 【windows phone】CollectionViewSource的妙用
在windows phone中绑定集合数据的时候,有时候需要分层数据,通常需要以主从试图形式显示.通常的方法是将第二个ListBox(主视图)的数据源绑定到第一个ListBox (从视图)的Selec ...
- js比较3个数字的大小
<script> var a = [1, 5, 2, 123, 34, 43, 7]; var i = j = t = 0; //sort方法, 推荐使用 a.sort(function( ...
- IOS之禁用UIWebView的默认交互行为
本文转载至 http://my.oschina.net/hmj/blog/111344 UIKit提供UIWebView组件,允许开发者在App中嵌入Web页面.通过UIWebView组件 ...
- UML类图组成
本文转载至 http://blog.csdn.net/fengsh998/article/details/8105666 UML类图的相关知识,UML类图(Classdiagram)是最常用的 ...
- service oriented architecture 构造分布式计算的应用程序的方法 面向服务的架构 分解技术
zh.wikipedia.org/wiki/面向服务的架构 [程序功能做为服务] 面向服务的体系结构(英语:service-oriented architecture)是构造分布式計算的应用程序的方法 ...
- DuiLib笔记,基于WindowImplBase的基础模板
Main.cpp #include <UIlib.h> using namespace DuiLib; class MainWindow : public WindowImplBase { ...
- socket 学习笔记
#include <sys/socket.h> ---------------------------------------------------------------------- ...
- wav音频文件头解析
wav概述 WAV为微软公司(Microsoft)开发的一种声音文件格式,它符合RIFF(ResourceInterchange File Format)文件规范,用于保存Windows平台的音频信息 ...
- 绝对定位(absolute)
绝对定位(absolute),作用是将被赋予此定位方法的对象从文档流中拖出,使用left,right,top, bottom等属性相对于其最接近的一个最有定位设置的父级对象进行绝对定位,如果对象的父级 ...