设为首页 加入收藏

TOP

我们能从PEP 703中学到什么(二)
2023-09-09 10:25:30 】 浏览:142
Tags:能从 PEP 703
变(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++,javagolang里哪个不是这样的?然而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扩展的迁移,我觉得这个估计有点过于乐观了。而

首页 上一页 1 2 3 下一页 尾页 2/3/3
】【打印繁体】【投稿】【收藏】 【推荐】【举报】【评论】 【关闭】 【返回顶部
上一篇Python教程(12)——Python数据结.. 下一篇【matplotlib基础】--刻度

最新文章

热门文章

Hot 文章

Python

C 语言

C++基础

大数据基础

linux编程基础

C/C++面试题目