PEP703是未来去除GIL的计划,当然现在提案还在继续修改,但大致方向确定了。
对于实现细节我没啥兴趣多说,挑几个我比较在意的点讲讲。
尽量少依赖原子操作的引用计数
没了GIL之后会出现两个以上的线程同时操作同一个Python对象的情况,首先要解决的是引用计数的计算不能出岔子,否则整个内存管理就无从谈起了。
多线程间的引用计数有很多现成方案了,比如c++的shared_ptr
但原子操作是有代价的,虽然比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;
}
现在至少是不需要系统调用了。
这东西看着简单,然而细节问题非常多,整个增强提案快有三分之一的篇幅在将这东西怎么实现的。有兴趣可以研读PEP703,大多数人我觉得了解到这个程度就差不多了。
延迟的引用计数
先简单说下3.12将带来的“永生代对象”。如字面意思,有些对象从创建之后就永远不会被回收,也永远不会被改