Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 556 – 线程化垃圾回收

作者:
Antoine Pitrou <solipsis at pitrou.net>
状态:
推迟
类型:
标准跟踪
创建日期:
2017年9月8日
Python 版本:
3.7
发布历史:
2017年9月8日

目录

延迟通知

本PEP目前未积极开发。未来可能会恢复。主要缺失的步骤是:

  • 完善实现,必要时调整测试套件;
  • 确保设置线程化垃圾回收不会以意想不到的方式干扰现有代码(预期影响包括延长引用循环中对象的生命周期)。

摘要

本PEP提出了一种CPython循环垃圾回收器(GC)的新可选操作模式,其中隐式(即机会性)回收在一个专用线程中进行,而不是同步进行。

术语

“隐式”GC运行(或“隐式”回收)是指在每次请求新分配时,根据分配统计数据计算的特定启发式方法机会性地触发的回收。启发式方法的细节与本PEP无关,因为它不打算改变它。

“显式”GC运行(或“显式”回收)是指通过API调用(例如 gc.collect)以编程方式请求的回收。

“线程化”指的是GC运行发生在与应用程序代码的顺序执行分离的专用线程中。它不意味着“并发”(全局解释器锁,即GIL,仍然将Python线程(包括专用GC线程)之间的执行序列化),也不意味着“并行”(GC无法将其工作分配到多个线程上以降低GC运行的实际时钟延迟)。

基本原理

GC的操作模式始终是同步执行隐式回收。也就是说,当上述启发式方法被激活时,当前线程中应用程序代码的执行被暂停,并启动GC以回收死引用循环。

然而,有一个问题。在回收死引用循环(以及挂在这些循环上的任何辅助对象)的过程中,GC可以以 __del__ 方法和 weakref 回调的形式执行任意的终结代码。多年来,Python已被用于越来越复杂的目的,终结代码执行复杂任务的情况也越来越常见,例如在分布式系统中,对象丢失可能需要通知其他(逻辑或物理)节点。

在任意点中断应用程序代码以执行可能依赖于一致内部状态和/或获取同步原语的终结代码,会产生重入性问题,即使是经验最丰富的专家也难以妥善解决 [1]

本PEP基于这样一个观察:尽管表面上相似,但同线程重入是一个比多线程同步更基本的问题。本PEP不是让每个开发人员或库作者一个接一个地与极其困难的重入问题作斗争,而是建议允许GC在一个单独的线程中运行,在那里众所周知的多线程同步实践就足够了。

提案

根据本PEP,GC有两种操作模式:

  • “串行”模式,这是默认和传统模式,当检测到需要隐式GC运行时(基于上述分配启发式方法),在检测线程中立即执行隐式GC运行。
  • “线程化”模式,可以在运行时在每个进程的基础上显式启用,当触发分配启发式方法时,隐式GC运行被调度,但在专用后台线程中运行。

在“串行”模式中困扰终结回调复杂使用方式的硬重入问题,在“线程化”操作模式中变成了相对容易的多线程同步问题。

GC还传统上允许使用Python API gc.collect 和C API PyGC_Collect 进行显式GC运行。这两个API的可见语义保持不变:它们在被调用时立即执行GC运行,并且只有在GC运行完成后才返回。

新的公共API

gc 模块中添加了两个新的Python API:

  • gc.set_mode(mode) 设置当前操作模式(“串行”或“线程化”)。如果设置为“串行”且当前模式为“线程化”,则该函数还会等待GC线程结束。
  • gc.get_mode() 返回当前操作模式。

允许在操作模式之间切换。

预期用途

鉴于切换的每进程性质及其对所有终结回调语义的影响,建议在应用程序代码的开头(和/或在子进程的初始化器中,例如使用 multiprocessing 时)进行设置。库函数可能不应该修改此设置,就像它们不应该调用 gc.enablegc.disable 一样,但没有什么可以阻止它们这样做。

非目标

本PEP不解决其他类型的异步代码执行(例如使用 signal 模块注册的信号处理程序)的重入性问题。作者认为,绝大多数令人头痛的重入性问题发生在终结器中。大多数情况下,信号处理程序能够设置一个标志和/或唤醒一个文件描述符供主程序注意。至于那些引发异常的信号处理程序,它们必须在线程内执行。

本PEP也不改变终结回调的执行,当它们作为常规引用计数的一部分被调用时,即当释放可见引用导致对象的引用计数降为零时。由于此类执行发生在代码中确定性的点,因此通常不是问题。

内部细节

待办:更新此部分以符合当前实现。

gc 模块

添加了一个内部标志 gc_is_threaded,指示GC是串行还是线程化。

添加了一个内部结构 gc_mutex,以避免同时进行两次GC运行。

static struct {
    PyThread_type_lock lock;  /* taken when collecting */
    PyThreadState *owner;  /* whichever thread is currently collecting
                              (NULL if no collection is taking place) */
} gc_mutex;

添加了一个内部结构 gc_thread,用于处理与GC线程的同步。

static struct {
   PyThread_type_lock wakeup; /* acts as an event
                                 to wake up the GC thread */
   int collection_requested; /* non-zero if collection requested */
   PyThread_type_lock done; /* acts as an event signaling
                               the GC thread has exited */
} gc_thread;

threading 模块

threading 模块中添加了两个私有函数:

  • threading._ensure_dummy_thread(name) 为当前线程创建一个并注册一个带有给定名称Thread 实例,并返回它。
  • threading._remove_dummy_thread(thread) 从线程模块的内部状态中移除给定的线程(由 _ensure_dummy_thread 返回)。

这两个函数的目的是通过在GC线程的终结回调中调用 threading.current_thread() 时返回一个更有意义名称的对象来改进调试和自省。

伪代码

以下是实现本PEP所需的主要原语(公共和内部)的拟议伪代码。除非另有说明,所有这些都将在C中实现并存在于 gc 模块中:

def collect_with_callback(generation):
    """
    Collect up to the given *generation*.
    """
    # Same code as currently (see collect_with_callback() in gcmodule.c)


def collect_generations():
    """
    Collect as many generations as desired by the heuristic.
    """
    # Same code as currently (see collect_generations() in gcmodule.c)


def lock_and_collect(generation=-1):
    """
    Perform a collection with thread safety.
    """
    me = PyThreadState_GET()
    if gc_mutex.owner == me:
        # reentrant GC collection request, bail out
        return
    Py_BEGIN_ALLOW_THREADS
    gc_mutex.lock.acquire()
    Py_END_ALLOW_THREADS
    gc_mutex.owner = me
    try:
        if generation >= 0:
            return collect_with_callback(generation)
        else:
            return collect_generations()
    finally:
        gc_mutex.owner = NULL
        gc_mutex.lock.release()


def schedule_gc_request():
    """
    Ask the GC thread to run an implicit collection.
    """
    assert gc_is_threaded == True
    # Note this is extremely fast if a collection is already requested
    if gc_thread.collection_requested == False:
        gc_thread.collection_requested = True
        gc_thread.wakeup.release()


def is_implicit_gc_desired():
    """
    Whether an implicit GC run is currently desired based on allocation
    stats.  Return a generation number, or -1 if none desired.
    """
    # Same heuristic as currently (see _PyObject_GC_Alloc in gcmodule.c)


def PyGC_Malloc():
    """
    Allocate a GC-enabled object.
    """
    # Update allocation statistics (same code as currently, omitted for brevity)
    if is_implicit_gc_desired():
        if gc_is_threaded:
            schedule_gc_request()
        else:
            lock_and_collect()
    # Go ahead with allocation (same code as currently, omitted for brevity)


def gc_thread(interp_state):
    """
    Dedicated loop for threaded GC.
    """
    # Init Python thread state (omitted, see t_bootstrap in _threadmodule.c)
    # Optional: init thread in Python threading module, for better introspection
    me = threading._ensure_dummy_thread(name="GC thread")

    while gc_is_threaded == True:
        Py_BEGIN_ALLOW_THREADS
        gc_thread.wakeup.acquire()
        Py_END_ALLOW_THREADS
        if gc_thread.collection_requested != 0:
            gc_thread.collection_requested = 0
            lock_and_collect(generation=-1)

    threading._remove_dummy_thread(me)
    # Signal we're exiting
    gc_thread.done.release()
    # Free Python thread state (omitted)


def gc.set_mode(mode):
    """
    Set current GC mode.  This is a process-global setting.
    """
    if mode == "threaded":
        if not gc_is_threaded == False:
            # Launch thread
            gc_thread.done.acquire(block=False)  # should not fail
            gc_is_threaded = True
            PyThread_start_new_thread(gc_thread)
    elif mode == "serial":
        if gc_is_threaded == True:
            # Wake up thread, asking it to end
            gc_is_threaded = False
            gc_thread.wakeup.release()
            # Wait for thread exit
            Py_BEGIN_ALLOW_THREADS
            gc_thread.done.acquire()
            Py_END_ALLOW_THREADS
            gc_thread.done.release()
    else:
        raise ValueError("unsupported mode %r" % (mode,))


def gc.get_mode(mode):
    """
    Get current GC mode.
    """
    return "threaded" if gc_is_threaded else "serial"


def gc.collect(generation=2):
    """
    Schedule collection of the given generation and wait for it to
    finish.
    """
    return lock_and_collect(generation)

讨论

默认模式

人们可能会想,默认模式是否应该简单地更改为“线程化”。对于多线程应用程序,这可能不是问题:这些应用程序必须已经准备好在任意线程中运行终结处理程序。然而,在单线程应用程序中,目前保证终结器将始终在主线程中调用。破坏此属性可能会导致微妙的行为更改或错误,例如,如果终结器依赖于某些线程局部值。

另一个问题是当程序使用 fork() 进行并发时。从单线程程序调用 fork() 是安全的,但如果程序是多线程的,则它是脆弱的(至少可以说是)。

显式回收

有人可能会问,显式回收是否也应该委托给后台线程。答案是这并不重要:因为 gc.collectPyGC_Collect 实际上会等待回收结束(破坏此属性会破坏兼容性),将实际工作委托给后台线程不会减轻与请求显式回收的线程的同步问题。

最终,本PEP根据上述伪代码选择了似乎更简单的实现行为。

对内存使用的影响

与默认的“串行”模式相比,“线程化”模式会导致隐式回收略有延迟。这显然可能会改变某些应用程序的内存配置文件。实际使用中延迟量仍有待测量,但我们预计影响将保持轻微且可承受。首先,因为隐式回收是基于启发式方法,其效果无论如何都不会导致确定性的可见行为。其次,因为GC处理引用循环,而许多对象在其最后一个可见引用消失时会立即被回收。

对CPU消耗的影响

上述伪代码为“线程化”模式中的每个隐式回收请求增加了两次锁操作:一次在发出请求的线程中(release 调用),另一次在GC线程中(acquire 调用)。它还在每次实际回收周围增加了另外两次锁操作,无论当前模式如何。

我们预计在现代系统上,这些锁操作的成本非常小,与回收过程中爬行指针链的实际成本(“指针追踪”是现代CPU上最困难的工作负载之一,因为它不适用于推测和超标量执行)相比。

对最坏情况迷你基准的实际测量可能有助于提供令人放心的上限。

对GC暂停的影响

虽然本PEP不关注GC暂停,但实际上在隐式回收的某个时刻释放GIL(例如,通过执行纯Python终结器)将允许应用程序代码在期间运行,从而降低某些应用程序的可见GC暂停时间。

如果本PEP被接受,未来的工作可能会尝试通过在回收期间推测性地释放GIL来更好地实现这一潜力,尽管尚不清楚这是否可行。

开放问题

  • gc.set_mode 可能应该受到保护,以防止多个并发调用。此外,当在GC运行内部调用时(即从终结器中),它应该引发异常。
  • 关机时会发生什么?GC线程是否一直运行直到调用 _PyGC_Fini()

实施

作者的Github分支 [3]threaded_gc 分支 [2] 中提供了草稿实现。

参考资料


来源:https://github.com/python/peps/blob/main/peps/pep-0556.rst

最后修改:2025-02-01 08:55:40 GMT