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 下一代垃圾回收器若要同时改善吞吐量和尾延迟,很可能需要在这两个维度上共同推进;而真正决定它能否进入生产环境的,仍然是正确性、可预测性和真实负载下的数据。