Python 垃圾回收的下一步:分代与增量为什么应该同时存在

2026-09-30 28 预计阅读时间: 1 分钟
来源: blog.python.org AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

Python 3.14 的增量垃圾回收器被回退后,问题并没有退回到“继续使用旧方案”这么简单。在 Python Language Summit 2026 上,Mark Shannon 提出了一条更值得关注的路线:未来的 Python 垃圾回收策略不必在分代回收和增量回收之间二选一,而可以把两者组合起来。

这里讨论的是未来方向,而不是已经确定进入某个 Python 版本的实现承诺。要理解这项提议的价值,关键是看清两个概念解决的其实不是同一个问题:分代回收决定“优先检查哪些对象”,增量回收决定“每次做多少工作”。

两种策略并不冲突

以 CPython 为例,大部分对象会在引用计数降到零时立即释放;循环引用无法单靠引用计数处理,因此还需要循环垃圾回收器。

分代回收利用了一个常见经验:大多数对象生命周期很短。新对象接受更频繁的检查,多次存活的对象则进入更老的集合,降低扫描频率。它优化的是总工作量:不要每次都检查所有长期存活的对象。

增量回收关注的则是延迟。一次完整扫描可能在某个时间点集中占用较长时间;增量策略尝试把这项工作拆成多个较小步骤,穿插到程序执行过程中。

可以把两者理解为两个独立旋钮:

策略 回答的问题 主要目标
分代回收 这次应该检查哪些对象? 减少无效扫描,提高吞吐量
增量回收 这次最多处理多少对象或工作量? 缩短单次停顿,平滑延迟

因此,混合方案在概念上很自然:年轻对象仍可频繁回收,较老对象的检查则分批推进,而不是偶尔集中处理一大批对象。

真正困难的是调度与正确性

“分批扫描老对象”听起来简单,但实现时会碰到几个硬问题。

对象图会持续变化。 当回收器只完成了一部分扫描时,应用代码可能创建、删除或修改引用。实现必须保证这些变化不会让可达对象被误判,也不能永久漏掉已经成为垃圾的循环。是否需要写屏障、脏集合或其他记账机制,是设计时必须回答的问题;来源摘要并未给出最终机制,因此不宜把某一种方案当作既定事实。

工作预算需要动态调整。 每次只做很少的工作,单次停顿会变短,但回收进度可能追不上对象分配速度;每次处理太多,又会重新制造明显的延迟尖峰。理想的调度器需要在分配速率、待处理对象数量、内存压力和延迟目标之间找到平衡。

吞吐量与尾延迟可能互相交换。 增量步骤会带来更多调度和状态维护成本。Web API、交互式应用和实时数据服务往往更在意 P95/P99 延迟,而批处理任务可能更愿意接受少量长停顿,以换取更高的整体吞吐量。

这也解释了为什么一次增量 GC 实现被回退,并不意味着增量思想本身无效。回退通常意味着具体实现尚未在正确性、性能或维护成本之间达到足够稳妥的平衡。Mark Shannon 的提议值得关注之处,在于重新把“对象年龄”和“单次预算”作为两个可以协同设计的维度。

用一个小脚本观察当前 GC 停顿

未来实现尚未确定,但开发者现在就可以测量自己的程序是否容易产生循环垃圾,以及不同 Python 版本或 GC 阈值下的停顿分布。

下面的脚本会持续创建循环引用,通过 gc.callbacks 记录每次循环垃圾回收的大致耗时。保存为 gc_probe.py 后即可运行:

import argparse
import gc
import statistics
import time
from collections import defaultdict


class Node:
    __slots__ = ("peer", "payload")

    def __init__(self):
        self.peer = None
        self.payload = b"x" * 128


def percentile(values, ratio):
    if not values:
        return 0.0
    ordered = sorted(values)
    index = min(len(ordered) - 1, int((len(ordered) - 1) * ratio))
    return ordered[index]


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--rounds", type=int, default=300_000)
    parser.add_argument("--threshold0", type=int, default=200)
    args = parser.parse_args()

    pauses = defaultdict(list)
    started_at = {}

    def on_gc(phase, info):
        generation = info.get("generation", -1)
        if phase == "start":
            started_at[generation] = time.perf_counter_ns()
        elif phase == "stop" and generation in started_at:
            elapsed_ms = (
                time.perf_counter_ns() - started_at.pop(generation)
            ) / 1_000_000
            pauses[generation].append(elapsed_ms)

    original_thresholds = gc.get_threshold()
    print("Original thresholds:", original_thresholds)

    # 较低的阈值用于制造更多观测样本,并不代表生产环境推荐值。
    gc.set_threshold(
        args.threshold0,
        original_thresholds[1],
        original_thresholds[2],
    )
    gc.callbacks.append(on_gc)

    retained = []
    workload_start = time.perf_counter()

    for i in range(args.rounds):
        left = Node()
        right = Node()
        left.peer = right
        right.peer = left

        # 让少量循环存活更久,模拟长生命周期对象。
        if i % 500 == 0:
            retained.append((left, right))

        if i and i % 50_000 == 0:
            retained.clear()

    retained.clear()
    gc.collect()
    workload_ms = (time.perf_counter() - workload_start) * 1000

    gc.callbacks.remove(on_gc)
    gc.set_threshold(*original_thresholds)

    print(f"Workload: {workload_ms:.2f} ms")
    for generation in sorted(pauses):
        values = pauses[generation]
        print(
            f"generation={generation} "
            f"collections={len(values)} "
            f"median={statistics.median(values):.3f} ms "
            f"p95={percentile(values, 0.95):.3f} ms "
            f"max={max(values):.3f} ms"
        )


if __name__ == "__main__":
    main()

运行示例:

python gc_probe.py --rounds 300000 --threshold0 200

如果机器上装有多个 Python 版本,可以使用相同参数做对照:

python3.13 gc_probe.py --rounds 300000 --threshold0 200
python3.14 gc_probe.py --rounds 300000 --threshold0 200

这个脚本只是观测工具,不是严格的 GC 基准测试。回调本身会产生少量开销,降低阈值也会改变回收行为,而且不同 Python 版本可能调整阈值参数的内部含义。真正评估应用时,应记录端到端吞吐量、P95/P99 请求延迟、峰值内存和 GC 次数,而不是只看平均回收耗时。

面向开发者的采用建议

在新的回收策略真正落地前,不建议根据一项峰会提议提前修改生产配置。更实际的准备方式是建立可重复的基线:

  • 使用真实流量或可代表真实负载的数据,而不只运行微基准测试。
  • 同时观察吞吐量、尾延迟和峰值内存,避免只优化其中一个指标。
  • 区分普通短命对象与循环引用;减少不必要的循环结构通常比调整 GC 参数更可靠。
  • 不要随意在请求路径中调用 gc.collect(),它可能把不可预测的停顿变成确定发生的停顿。
  • 升级 Python 后重新测试,不要假设旧版本上的最佳阈值仍然有效。
  • 如果未来版本引入混合式分代增量 GC,应重点验证长时间运行后的内存稳定性,以及 P99 延迟是否真的改善。

“分代还是增量”并不是一个好的二选一问题。分代策略负责减少要做的工作,增量策略负责限制每次工作的规模。Python 下一代垃圾回收器若要同时改善吞吐量和尾延迟,很可能需要在这两个维度上共同推进;而真正决定它能否进入生产环境的,仍然是正确性、可预测性和真实负载下的数据。


相关推荐