我们能从PEP 703中学到什么
阅读原文时间:2023年09月05日阅读:3

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参与进来。

去除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扩展的迁移,我觉得这个估计有点过于乐观了。而且现在谁也没法预言三五年以后会怎么样。