我们能从PEP 703中学到什么

这篇具有很好参考价值的文章主要介绍了我们能从PEP 703中学到什么。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

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扩展的迁移,我觉得这个估计有点过于乐观了。而且现在谁也没法预言三五年以后会怎么样。文章来源地址https://www.toymoban.com/news/detail-695021.html

到了这里,关于我们能从PEP 703中学到什么的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 学到什么程度可以去找日常实习?

    大家好,我是帅地。 学到什么程度才能xxx ,这种问题我被问的太多了,例如算法学到什么程度才能通过笔试… 事实上,这是一个很难衡量的问题,面试能否拿到 offer,还和运气,公司剩下的名额,你是否符合面试官胃口等等相关。 不过今天咱们不去具体衡量这些,今天就和

    2024年02月05日
    浏览(48)
  • JAVA 学到什么水平就可以转战 Android 了?

    先简单的分两种情况: 一、 有编程基础,或者科班出身的,直接上吧 。 强烈推荐郭霖老师的《第一行代码-Android》 二、没编程基础的,先入门java,如果天天都有比较多的空闲时间的话,这个入门时间要在2周完成,如果没有的话,1个月内完成吧, 不要让拖延耽搁你的激情

    2024年03月26日
    浏览(44)
  • 自学软件测试,学到什么程度可以出去找工作?

    其实初级测试学的东西并不多,如果脱产学习的话2~3个月差不多就能简单入门。 另外不要担心,初级测试对于Python/Java编程,自动化测试,性能测试这些都是初步的了解和学习。如果说要深度掌握,那确实是还需要很多时间。 好了,现在开始正题。 自学软件测试,学到什么

    2024年02月07日
    浏览(52)
  • 自学Android开发至少要学到什么程度才可以去面试

    前不久,有位网友私信找到我,说自己自学Android已经有两个月左右了,每天至少学习了五个小时,基本都是在网上找视频看跟着做笔记学的,然后就问我,说想这样学,至少需要学到什么程度才可以出去找工作啊? **对于这个问题,我想说的是你需要先了解互联网公司Andro

    2024年02月15日
    浏览(53)
  • AR的未来:如何塑造我们的未来生活

    增强现实(Augmented Reality,AR)是一种将数字信息呈现在现实世界中的技术。它通过将虚拟对象与现实世界的对象相结合,使用户在现实世界中与虚拟世界进行互动。AR技术的发展与虚拟现实(Virtual Reality,VR)、混合现实(Mixed Reality,MR)等相关,它们共同构成了现实增强现实(Spatia

    2024年04月10日
    浏览(43)
  • 当我焦虑时,我从CSDN的博主身上学到了什么?

    我们在学习的过程当中总会遇到一些比我们自己优秀的人,不论你是在更好的985或211院校学习,还是在普通的双飞本科学习,都存在对比,也就存在了差异,我们总是因为 “我不如别人” 就产生自卑或者难受的心理,所以本篇博客我们不谈技术,只谈我们《感觉和身边其他

    2024年02月11日
    浏览(38)
  • 什么是数学建模?如何在数学建模中拿奖?通过建模学到了啥?

    本人大一开始参加建模,先后参加过多项数学建模比赛和数学竞赛,拿过多项一等奖,二等奖。 提起模型,其实在初高中时期,我们就接触过,分别是数学模型,物理模型,概念模型。那么什么是数学模型?大部分人都会与 数字,符号,公式 等联系起来,这是非常正确的,

    2024年02月05日
    浏览(40)
  • 元宇宙下我们未来生活是怎样的

    元宇宙一般指的是平行于现实世界的全真数字虚拟3D空间, 如果换个更好理解的方式来说 ,元宇宙就是下一代互联网。 一般认为,元宇宙的内涵是吸纳了信息革命(5G/6G)、互联网革命(web3.0)、人工智能革命,以及VR、AR、MR,特别是游戏引擎在内的虚拟现实技术革命的成果

    2024年02月15日
    浏览(45)
  • Kafka 的未来:为何我们要抛弃 ZooKeeper?

    一、ZooKeeper 的核心功能 ZooKeeper 是一个广泛使用的开源分布式协调服务框架,它在确保数据一致性方面表现出色,同时也可以作为一个轻量级的分布式存储系统。它特别适合用来存储那些需要多个系统共享的配置信息、集群的元数据等。ZooKeeper 提供了持久节点和临时节点两种

    2024年02月02日
    浏览(51)
  • 我们和ChatGPT聊了聊BI的未来

    ChatGPT是OpenAI开发的聊天机器人,2022年11月上线,迅速火爆全球,1周突破100万用户,仅用2个月全球突破1亿用户,碾压史上所有应用程序。美国有学生用ChatGPT写论文拿下全班最高分,ChatGPT可以编程,通过了谷歌L3工程师的入职测试,年薪达18.3万美刀。 我们在第一时间注册了账

    2024年02月07日
    浏览(35)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包