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。这两个 API 的可见语义保持不变:它们在被调用时立即执行 GC 运行,并且仅在 GC 运行完成时返回。

新的公共 API

两个新的 Python API 被添加到 gc 模块中

  • 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) 使用给定的name为当前线程创建并注册一个 Thread 实例,并返回它。
  • threading._remove_dummy_thread(thread) 从 threading 模块的内部状态中移除给定的thread(如 _ensure_dummy_thread 返回的那样)。

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

伪代码

以下是实现此 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 分支 [2]threaded_gc 分支 [3] 中找到。

参考文献


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

上次修改:2023-09-09 17:39:29 GMT