PEP703是未来去除GIL的计划,当然现在提案还在继续修改,但大致方向确定了。

对于实现细节我没啥兴趣多说,挑几个我比较在意的点讲讲。

尽量少依赖原子操作的引用计数

没了GIL之后会出现两个以上的线程同时操作同一个Python对象的情况,首先要解决的是引用计数的计算不能出岔子,否则整个内存管理就无从谈起了。

多线程间的引用计数有很多现成方案了,比如c++的shared_ptr,还有rust的Arc。这些方案都使用原子操作来维护引用计数并保证线程安全。

但原子操作是有代价的,虽然比mutex要小,但依旧会产生不少的性能倒退,这也是为什么c++里一般不推荐多用shared_ptr<T>的原因之一。

更重要的一点是,python是大量使用引用计数来管理内存的,原子操作带来的性能影响会被放大到不能接受的地步。

但想要保证线程安全又不得不做一些同步措施,所以python选择了这个方案:Biased Reference Counting

暂时没想到好的译名,字面意思就是不精确的引用计数。

大致思路是这样的:通过统计分析,大多数引用计数的修改只会发生在拥有引用计数对象的单个线程里(对于python来说通常是创建出对象的那个线程),跨线程共享并操作计数的情况没有那么多。所以可以对引用计数的操作分为两类,一类是拥有计数的那个线程(为了方便后面叫本地线程)的访问,这种访问不需要加锁也不需要原子操作;另一种是跨线程的访问,这种会单独分配一个计数器给本地线程之外的线程访问,访问采用原子操作。最后真正的引用计数是本地线程的计数加上跨线程访问使用的计数。

这样做的好处是减少了大量的不必要的原子操作,按原论文描述相比直接使用原子操作,上述的方法可以提升7%到20%的性能。

坏处也是显而易见的,某个时间点获得的引用计数的值不一定准确,这导致需要做很多补正措施,而且python为了避免计数器数值溢出的问题需要一个本地线程计数器和跨线程计数器,导致需要占用更多内存。

新的对象头暂定是这样子:

struct _object {
_PyObject_HEAD_EXTRA
uintptr_t ob_tid; // 本地线程的线程标识符 (4-8 bytes)
uint16_t __padding; // 内存填充,以后可能会变成其他字段也可能消失,不用在意 (2 bytes)
PyMutex ob_mutex; // 每个对象的轻量级互斥锁,后面细说 (1 byte)
uint8_t ob_gc_bits; // GC fields (1 byte)
uint32_t ob_ref_local; // 本地线程计数器 (4 bytes)
Py_ssize_t ob_ref_shared; // 跨线程共享计数器 (4-8 bytes)
PyTypeObject *ob_type;
};

另外跨线程共享计数器还有2bit用了表示引用计数的状态,以便python正确处理引用计数。

对于目前的引用计数处理也需要改造:

// low two bits of "ob_ref_shared" are used for flags
#define _Py_SHARED_SHIFT 2 void Py_INCREF(PyObject *op)
{
uint32_t new_local = op->ob_ref_local + 1;
if (new_local == 0)
// 3.12的永生对象,它们不参与引用计数,并会一直存在伴随整个程序的运行
// 看3.12源码的话会发现检查是不是永生对象的方法不太一样,反正这里是伪代码,别太在意
return;
if (op->ob_tid == _Py_ThreadId())
op->ob_ref_local = new_local;
else
atomic_add(&op->ob_ref_shared, 1 << _Py_SHARED_SHIFT);
}

需要检查的条件比原来多了很多,势必会对性能产生一定的负面影响。

另一个潜在的性能影响是如何获取线程的id,在linux上会使用gettid这个系统调用,如果这么做的话性能是会严重下降的,所以得用些hack:

static inline uintptr_t
_Py_ThreadId(void)
{
// copied from mimalloc-internal.h
uintptr_t tid;
#if defined(_MSC_VER) && defined(_M_X64)
tid = __readgsqword(48);
#elif defined(_MSC_VER) && defined(_M_IX86)
tid = __readfsdword(24);
#elif defined(_MSC_VER) && defined(_M_ARM64)
tid = __getReg(18);
#elif defined(__i386__)
__asm__("movl %%gs:0, %0" : "=r" (tid)); // 32-bit always uses GS
#elif defined(__MACH__) && defined(__x86_64__)
__asm__("movq %%gs:0, %0" : "=r" (tid)); // x86_64 macOSX uses GS
#elif defined(__x86_64__)
__asm__("movq %%fs:0, %0" : "=r" (tid)); // x86_64 Linux, BSD uses FS
#elif defined(__arm__)
__asm__ ("mrc p15, 0, %0, c13, c0, 3\nbic %0, %0, #3" : "=r" (tid));
#elif defined(__aarch64__) && defined(__APPLE__)
__asm__ ("mrs %0, tpidrro_el0" : "=r" (tid));
#elif defined(__aarch64__)
__asm__ ("mrs %0, tpidr_el0" : "=r" (tid));
#else
# error "define _Py_ThreadId for this platform"
#endif
return tid;
}

https://github.com/colesbury/nogil/blob/f7e45d6bfbbd48c8d5cf851c116b73b85add9fc6/Include/object.h#L428-L455

现在至少是不需要系统调用了。

这东西看着简单,然而细节问题非常多,整个增强提案快有三分之一的篇幅在将这东西怎么实现的。有兴趣可以研读PEP703,大多数人我觉得了解到这个程度就差不多了。

延迟的引用计数

先简单说下3.12将带来的“永生代对象”。如字面意思,有些对象从创建之后就永远不会被回收,也永远不会被改变(None, True/False, 小整数),对于这些对象来说引用计数的操作是没什么必要的,所以干脆就不去更新引用计数了。减少这些不必要的引用计数维护操作之后能提升一点性能,也能保证这些对象的在去除GIL之后更安全。

延迟引用计数又是什么呢?有一些对象的生命周期比其他对象长的多,但不如永生代对象那样会始终存在,后面可能会被回收也可能会被修改;同时相比一般的对象大多数的访问都发生在本地线程,这类对象会更频繁地被跨线程访问。这类对象上更新引用计数在多数情况下会需要用原子操作更新跨线程计数器,使用原先的引用计数策略在性能上会很不划算,所以出现了延迟引用计数来缓解这一问题。

这种对象通常是function,class,module等。python很灵活,可以运行时创建或修改这些对象,仔细想想是不是很符合上面的描述。

对于这类对象,python解释器会考虑跳过一些引用计数的更新,然后把跳过更新的数量放在线程本地的计数器里,等到GC运行的时候,会检查对象本身的引用计数和各个线程里缓存的跳过操作的数量,再加上可达性分析来确定这个对象是不是需要被回收。

好处是减少了引用计数的更新,大部分时间只需要更新线程本地的数据因此没有数据冲突也不需要原子操作;坏处是实现比较复杂,判断对象是否需要回收需要gc参与进来。

gc不再会分代

去除GIL后gc可能不会在分代,gc的策略会变成按内存压力或者定时触发。

真正支持多线程并行运行之后,gc需要STW,即暂停除gc线程之外的所有线程运行直到gc运行结束。以前有GIL的时候实际上也差不多,gc开始运行之后会锁住GIL,之后只有gc能运行其他所有操作都会阻塞住。

分代垃圾回收的核心理念是大部分的对象在年轻代的时候就会被回收,因此分出年轻代中年代老年代之后可以减少不必要的gc操作。

这个理论很对,而且对python也适用。但不巧的是python里大多数年轻代对象在引用计数变成0之后就立即释放了,根本不需要垃圾回收器参与。雪上加霜的是python的年轻代回收策略是进行了N次对象创建后运行一次年轻代gc,中年代回收策略是N次年轻代回收后会扫描一般中年代的对象,因为引用计数的存在很多时候这种gc扫描是在空转。

在真正实现并行之后STW带来的影响是不容忽略的,频繁的gc空转会浪费资源和性能。所以分代回收策略不再合适。

另一个原因是目前分代的对象被存在双链表里,而python的gc算法对这些链表的操作比较平凡,想要实现一个等价的多线程并发安全、足够高效并尽量兼容现有api的算法会非常困难,所以干脆放弃分代回收算法了。

虽然gc几乎要完全重构,但针对gc的性能优化策略还是没怎么变的:不要无节制创建对象,做好资源复用。

对象锁

有GIL存在的时候,python可以保证同一时间只有一个线程在操作python对象,虽然这根本避免不了“数据竞争”问题(当前线程的某个操作可以中途被打断的话即使有GIL也不可能保证数据不会被其他线程修改导致数据损坏),但可以保护python自己运行所依赖的各种数据不会被损坏,因此即使你的数据损坏了python本身也能继续安全地运行下去。

想象一下这样的代码:

listOne.extend(listTwo)

extend并不是原子操作,且整个流程不止调用一个Python C API,因此从参数传递到添加完listTwo所有元素前都有可能会暂停当前线程的执行让其他线程得到机会运行,假如这个时候有个线程2会改变listTwo或者往listOne里添加/删除了某些元素,这句表达式的运行结果就会和你所预期的大相径庭,GIL并不能防止数据竞争这样的问题。

没了GIL后这些就不一样了,现在不仅会有race condition,还会有多个线程同时修改python对象导致运行时需要的各种元数据损坏,这轻则导致数据错乱内存泄漏,重则会让进程直接崩溃。

有人可能会想这些不是很自然的规矩么,c++,java,golang里哪个不是这样的?然而python之前并不是,也不存在这类问题。为了兼容,python也不可能大幅修改已有的语言行为。

一个更现实的问题是,很多时候上面这样的问题只在python代码里加锁是解决不了的,解决不了python的稳定性就会大打折扣,谁敢用一个不知道什么时候就崩溃了的程序呢?

目前提出的解决办法是在每个python对象里加个轻量级的锁:

struct _object {
_PyObject_HEAD_EXTRA
...
PyMutex ob_mutex; // 每个对象的轻量级互斥锁 (1 byte)
...
PyTypeObject *ob_type;
};

每个线程操作这个对象的时候都要去获取锁,这样保证同一时间只会有一个线程在访问python对象。

多个线程访问同一个对象的时候会阻塞在对象的锁上,但如果访问的是不同的对象,就能真正实现并行运行了。

这么干好处是没了GIL也能尽量保证对象数据的安全,坏处是占用内存,且实现复杂非常容易犯错(为了提升性能,还整了不少特定条件下不需要锁的fast path,更复杂了),而且再轻量也是锁,会降低性能。

还有一点,对象锁粒度比GIL细得多,GIL尚且不能保证数据的并发安全,新的对象锁就更不能了,老老实实用mutex就行:

from threading import Thread, Lock

mutex = Lock()

def processData(data):
with mutex:
print('Do some stuff with data')

性能代价

香农计划还在如火如荼进行中,增强提案本身也在修改演进,所以最后内存占用和运行性能要为这些改动付出多少代价还是个未知数。

目前来看内存占用的问题其实不是很突出,但引用计数的原子操作以及更新操作更多的条件判断、延迟引用计数和不分代后gc每次回要扫描更多对象、对象上的锁等会带来客观的性能损耗。

按照PEP703给的数据,每个核心上的性能损耗超过5%但不到9%,多线程时损耗会稍大一点。

但由于去除GIL之后python可以真正地利用多核心进行并行计算,所以单个核心损耗了5%最后依靠并行的优势依旧能大幅提升性能。

一个简单的数学题:假设以前单核单线程在单位时间能处理100w个数据,现在每个核心有10%性能损耗,在此基础上线程间调度和同步又会带来10%的性能下降,那么利用双核两线程后单位时间能处理多少数据:100w x 90% x 90% x 2 = 162w。以这样极端的情况计算仍然能获得60%以上的性能提升。

另外提案里还提到703和多解释器并不冲突(703是建立在进程里只有一个解释器的基础上的),也可以期待两个方案共存后的化学反应。

总结

想写这篇文章的主要原因是记录下python社区在性能上的取舍,尤其让我觉得该多说两句的就是引用计数上的取舍和gc算法的选择,充分体现了软件开发中的“权衡”。

整个提案看下来我就一个想法:当初要是没选择用引用计数来管理内存,也许今天去除GIL的时候就用不着费这么大劲儿了,而且为了兼容老代码不得不做了大量的妥协。

目前整个方案在不断修改,社区有讨论到第一个能拿来测试non-GIL代码的版本最快也得3.17了,考虑到改动的规模和难度以及各种库和c扩展的迁移,我觉得这个估计有点过于乐观了。而且现在谁也没法预言三五年以后会怎么样。

我们能从PEP 703中学到什么的更多相关文章

  1. 从史上八大MySQL事故中学到的经验

    本文列举了史上八大MySQL宕机事件原因.影响以及人们从中学到的经验,文中用地震级数来类比宕机事件的严重性和后果,排在最严重层级前两位的是由于亚马逊AWS宕机故障(相当于地震十级和九级). 一.Per ...

  2. 做中学(Learning by Doing)之背单词-扇贝网推荐

    做中学(Learning by Doing)之背单词-扇贝网推荐 看完杨贵福老师(博客,知乎专栏,豆瓣)的「继续背单词,8个月过去了」,我就有写这篇文章的冲动了,杨老师说: 有时候我会感觉非常后悔,如 ...

  3. 20155315庄艺霖--对做中学的理解及对c语言和Java的看法

    关于做中学的理解及技能训练的思考 在写这篇博客之前,我首先阅读了娄老师的博客,对做中学的概念很感兴趣.我们常说知识要学以致用,做中学强调的是在用的过程中有新的收获和体会来进一步巩固学习.细数我学过的课 ...

  4. 1Z0-053 争议题目解析703

    1Z0-053 争议题目解析703 考试科目:1Z0-053 题库版本:V13.02 题库中原题为: 703.Given below are RMAN commands to enable backu ...

  5. [Head First设计模式]生活中学设计模式——组合模式

    系列文章 [Head First设计模式]山西面馆中的设计模式——装饰者模式 [Head First设计模式]山西面馆中的设计模式——观察者模式 [Head First设计模式]山西面馆中的设计模式— ...

  6. [Head First设计模式]生活中学设计模式——外观模式

    系列文章 [Head First设计模式]山西面馆中的设计模式——装饰者模式 [Head First设计模式]山西面馆中的设计模式——观察者模式 [Head First设计模式]山西面馆中的设计模式— ...

  7. [Head First设计模式]生活中学设计模式——状态模式

    系列文章 [Head First设计模式]山西面馆中的设计模式——装饰者模式 [Head First设计模式]山西面馆中的设计模式——观察者模式 [Head First设计模式]山西面馆中的设计模式— ...

  8. PEP 8

    官方文档: PEP 8 :Style Guide for Python Code 部分翻译: http://www.blogjava.net/lincode/archive/2011/02/02/34 ...

  9. 做中学之Vim实践教程

    做中学之Vim实践教程 Vim VIM是一个非常好的文本编辑器,很多专业程序员使用VIM编辑代码,即使以后你不编写程序,只要跟文本打交道,都应该学学VIM,可以浏览参考一下普通人的编辑利器--Vim这 ...

  10. Sublime Text编辑工具带有 PEP 8 格式检测插件

    Sublime Text编辑工具带有 PEP 8 格式检测插件

随机推荐

  1. 如何将jq动画做出高帧的感觉?(丝滑顺畅)

    前言 我最近在一点一点研究我 博客园 的前端代码,算是边敲边学吧,还算是挺有意思的. 是这样的,之前见过一个效果,就是先显示博客的背景,然后博客主界面缓缓的上升到正确位置,于是乎,干他!开撸代码! 各 ...

  2. ABP - 缓存模块(1)

    1. 与 .NET Core 缓存的关系和差异 ABP 框架中的缓存系统核心包是 Volo.Abp.Caching ,而对于分布式缓存的支持,abp 官方提供了基于 Redis 的方案,需要安装 Vo ...

  3. MAIXIII(爱芯派)的一种配网并安装nmtui的实现方法

    关于一种MAIXIII(爱芯派)的一种配网并安装nmtui的实现方法 特别感谢sipped的大佬鼠以及多位群友这几天提供的帮助与支持! 0.目录 一,MAIXIII简介 二,到手图展示 三,具体操作方 ...

  4. THM红队基础

    Red Team Fundamentals Learn the core components of a red team engagement, from threat intelligence t ...

  5. .NET指定图片地址下载并转换Base64字符串

    需求描述 需要调用第三方图片上传接口上传图片,对方图片格式只能接收Base64字符串.所以我们需要将系统服务器的图片通过Url下载下来,然后转换成Base64字符串.接下来我们将使用HttpClien ...

  6. Code Generate 代码生成器 V1.0

    Code Generate V1.0 代码生成器 根据配置的模板,根据建表语句,生成Code. 例如java代码.vue代码.jsp代码以及html代码等等,均可根据自己的代码写作习惯进行配置. 缺点 ...

  7. Oracle分区表设置详解

    Oracle分区表详解 Oracle建议单表超过2G就需要进行分表,一万数据大概3MB,单表最多分区为1024*1024-1个分区,我感觉够我们使用了哈 废话不多说,上示例,Oracle分表具体sql ...

  8. Cilium 系列-7-Cilium 的 NodePort 实现从 SNAT 改为 DSR

    系列文章 Cilium 系列文章 前言 将 Kubernetes 的 CNI 从其他组件切换为 Cilium, 已经可以有效地提升网络的性能.但是通过对 Cilium 不同模式的切换/功能的启用,可以 ...

  9. .NET下数据库的负载均衡“经典方案”(大项目必备,建议收藏)

    [前言] 本文讲述的"数据库负载均衡"方案,为市面上最经典(没有之一),由.NET界骨灰级大佬推出.采用该技术方案的大公司,一年省下了几个亿的支出. [正文] 支持.Net Cor ...

  10. Java 生态需要新鲜的血液、需要狂飙的刺激。Solon v2.4.1 发布

    Solon 是什么开源项目? 一个,Java 新的生态型应用开发框架.它从零开始构建,有自己的标准规范与开放生态(历时五年,已有全球第二级别的生态规模).与其他框架相比,它解决了两个重要的痛点:启动慢 ...