Skip to content
Go back

从源码对比 Go 与 CPython 的垃圾回收机制

Edit page

Go 和 Python 都能自动管理内存,但“都有垃圾回收”很容易掩盖两套实现的根本差异:Go 从根对象出发追踪整张对象图,找出仍然可达的对象;CPython 则先在每个对象上维护引用计数,计数归零时立即释放,再用循环垃圾回收器处理引用计数无法识别的环。

本文以 Go 1.26.7CPython 3.14.7 为准。这里的 Python 特指 CPython,而不是 PyPy 等其他实现。Go 1.26 默认启用了 Green Tea GC;CPython 3.14.5 则恢复了传统的三代循环 GC,因此本文不会套用较早的 3.14 资料中“两代增量 GC”的描述。

先看全文的核心区别:

Go:
roots -> trace reachable objects -> mark live -> sweep unmarked objects

CPython:
DECREF -> refcount becomes zero -> deallocate immediately
                         |
                         +-> cyclic GC handles remaining cycles

同一个引用环,两种回收思路

假设堆上有两个对象互相引用,程序已经无法从变量、栈或全局对象访问它们:

roots

        A <----> B

对 Go 来说,A 和 B 都无法从 roots 到达。下一次追踪时它们不会被标记,清扫阶段便可以回收它们。A 和 B 互相引用并不特殊,因为 Go 判断的是从根出发是否可达,不是还有多少指针指向对象。

对 CPython 来说,删除外部变量后,A 和 B 仍各自保留一个来自对方的引用,引用计数不会降到 0。普通的 Py_DECREF 无法释放它们,必须由循环 GC 计算“候选集合外部还有没有引用”,再识别并打破这个环。

这张小图已经解释了两套实现为什么会选择完全不同的数据结构、执行时机与性能权衡。

Go:从根出发的并发标记清扫

Go runtime 在 mgc.go 开头直接给出了算法定位:类型精确、并行执行的 concurrent mark and sweep,带写屏障,同时是 non-generational、non-compacting 的回收器。

  • 类型精确:runtime 通过编译器生成的指针位图区分指针与普通数值,不会把任意整数猜成地址;
  • 非分代:不会像常见的 JVM 或 CPython 默认构建那样,把对象按存活时间分成年轻代和老年代;
  • 不压缩:回收后不会为了消除空洞而移动仍然存活的对象;
  • 并发标记清扫:大部分标记与清扫工作可以和用户 goroutine 同时进行,但阶段切换仍包含短暂的 Stop The World(STW)。

一次 GC 周期如何推进

mgc.go 的总注释和 gcStartgcMarkDonegcMarkTermination 三个函数串起了一次完整周期:

sweep termination (STW)
          |
          v
concurrent mark + write barrier + mark assists
          |
          v
mark termination (STW)
          |
          v
concurrent and lazy sweep

第一步是 sweep terminationgcStart 停止所有 P,完成上一个周期可能尚未结束的清扫,并把 gcphase_GCoff 切换到 _GCmark。这个 STW 不负责扫描整个堆,它的关键作用是建立一个一致的阶段边界:开启写屏障、启用标记辅助,并准备根扫描任务。

Go GC 周期:STW 阶段与并发阶段

随后重新启动程序,进入并发标记。runtime 会扫描:

  • goroutine 栈中的指针;
  • 全局变量;
  • runtime 自己维护的堆外数据结构中的堆指针;
  • 已标记对象内部继续指向的其他对象。

gcDrain 先处理 root jobs,再不断从本地或全局工作队列取得灰色对象。扫描一个灰色对象时,它自身变黑,内部发现的白色对象被着色并加入后续工作。gcMarkDone 通过分布式终止检测确认所有 P 的本地工作、全局队列和写屏障缓冲区都已经排空,才允许进入标记终止。

标记终止会再次 STW。gcMarkTermination 完成统计和缓存刷新,关闭并发标记工作,然后进入清扫阶段。清扫不需要再次遍历指针图,只需检查 span 中对象的标记位:未标记的对象空间重新变为可分配状态。

bgsweep 在后台逐个清扫 span;如果分配路径急需内存,也会通过 sweepone 主动偿还清扫工作。于是 Go 的“并发清扫”实际上同时包含后台清扫和 allocation-time lazy sweep。

三色标记只是状态模型

常用的白、灰、黑三色可以这样理解:

颜色含义
白色当前周期尚未证明对象可达
灰色已经证明可达,但它内部的指针还没有全部扫描
黑色已经证明可达,内部指针也已经扫描

它不是要求每个对象头里真的保存一个三值颜色字段。Go 使用标记位、扫描位和工作队列共同表达状态。传统对象队列中,“已标记且仍在工作队列”相当于灰色;“已标记且已经离开工作队列”相当于黑色。

并发标记的难点在于:GC 扫描对象图时,用户 goroutine 仍在修改指针。如果一个黑色对象新指向白色对象,而 GC 又错过了这次写入,仍然存活的白色对象就可能被误回收。

混合写屏障如何保持可达性

mbarrier.go 描述了 Go 的 hybrid write barrier,它组合了 Yuasa deletion barrier 与 Dijkstra insertion barrier。简化后的伪代码是:

func writePointer(slot **Object, next *Object) {
	shade(*slot)
	if currentStackIsGrey() {
		shade(next)
	}
	*slot = next
}

第一处 shade 保护即将被覆盖的旧指针,防止 goroutine 把一个对象从堆上的唯一引用移动到尚未扫描的栈中;第二处 shade 在当前栈仍为灰色时保护新指针,防止它从栈进入已经扫描过的堆对象。栈扫描完成后,当前 goroutine 的栈可以视为黑色,第二部分便不再必要。

Go 混合写屏障:保护旧指针与新指针

写屏障发生在真正发布新指针之前。它不是互斥锁,也不会阻止业务 goroutine 改写对象;它的工作是把本次指针变化转换为 GC 不能遗漏的新标记任务。

分配速度太快时,业务 goroutine 也要帮忙

仅靠后台标记 worker 可能追不上分配速度。gcAssistAlloc 会把分配量换算成标记债务:goroutine 分配得越多,越可能被要求执行一部分扫描工作,直到偿还债务后才能继续分配。

这解释了为什么 Go GC 即使以“并发、低停顿”著称,业务延迟仍可能受到影响:

  • 阶段切换需要短暂 STW;
  • 标记阶段的指针写入要执行写屏障;
  • 高分配速率会触发 mutator assist;
  • GC worker 会消耗 CPU 时间,与业务 goroutine 竞争执行资源。

GOGC 根据上一周期存活堆大小控制下一次 GC 的目标堆增长比例,GOMEMLIMIT 则给 runtime 一个软内存上限。它们调节的是 CPU、内存与延迟之间的交换关系,并不会改变可达性算法本身。

Green Tea GC 改变了什么

Go 1.26 默认启用的 Green Tea GC 仍然是并发、非分代、非移动的标记清扫器。变化集中在“灰色对象怎样排队和扫描”。

传统路径更接近逐对象入队;mgcmark_greenteagc.go 则尽量延迟同一 span 中的小对象扫描,先积累一批已发现对象,再按 span 批量处理。源码为小对象维护 marks 与 scans 两组位图:marks 表示对象已经被发现,scans 表示对象已经完成扫描,marks &^ scans 就是当前真正需要扫描的集合。

discover object -> set mark bit -> enqueue its span
                                  |
                                  v
                   batch objects from the same span
                                  |
                                  v
                         set scan bits and trace

gcDrain 在 Go 1.26.7 中因此既会取得单个对象,也会取得 span;scanSpan 可以集中读取相邻对象和元数据,并在条件合适时使用更紧凑的扫描路径。优化目标是提升缓存局部性、减少元数据访问成本,并提高多核扩展性。Green Tea 改变了“怎样更快地完成标记”,没有改变“只保留从根可达对象”的语义。

CPython:引用计数为主,循环 GC 补洞

CPython 的 InternalDocs/garbage_collector.md 开篇就说明:主垃圾回收算法是 reference counting。循环 GC 虽然通常被简称为“Python GC”,但它只是处理引用计数的盲区。

Py_DECREF 为什么能立即释放对象

默认 GIL 构建中的每个普通对象都以 PyObject 头开始,其中包含 ob_refcntob_type。创建、保存或交出一个强引用时,解释器和 C 扩展必须遵守引用所有权约定,执行 Py_INCREFPy_DECREF

Include/refcount.h 中默认构建的核心路径可以缩写为:

static inline void Py_DECREF(PyObject *op) {
    if (is_immortal(op)) {
        return;
    }
    if (--op->ob_refcnt == 0) {
        _Py_Dealloc(op);
    }
}

_Py_Dealloc 最终调用对象类型提供的 tp_dealloc。容器析构时又会递减其成员的引用计数,因此一次归零可能引发一串级联释放。源码还专门使用 trashcan 机制避免深层容器链的递归析构耗尽 C 栈。

这套机制带来两个直接结果:

  1. 大多数非循环对象在最后一个引用消失时立即释放,不必等待一次全堆追踪;
  2. 每次引用所有权变化都可能修改对象头,引用计数本身成为持续存在的运行时成本。

“通常立即释放”是 CPython 的实现特征,不是 Python 语言对资源释放时刻的承诺。文件、锁和事务等外部资源仍应使用 with 或显式关闭,而不是依赖对象析构。

循环 GC 只跟踪可能形成环的容器

整数、纯字符串等不会持有其他对象引用,本身不可能成为引用环中的连接点。CPython 的循环 GC 重点跟踪 list、dict、自定义实例等容器对象。支持循环 GC 的扩展类型需要设置 Py_TPFLAGS_HAVE_GC,并实现:

  • tp_traverse:枚举当前对象持有的其他 Python 对象;
  • tp_clear:在确认对象不可达后清空内部引用、打破环。

默认构建会在普通对象头之前额外放置一个 PyGC_Head,其中的 _gc_next_gc_prev 把受跟踪对象组织成侵入式双向链表。这些链表既表示代,也会在回收过程中被重新划分为 reachable、unreachable 等集合。为了节省额外空间,源码还会临时复用指针低位和 gc_prev 保存回收状态与计算用引用计数。

如何从引用计数中扣掉环内引用

CPython 3.14.7 的主流程在 gc_collect_main 中,识别不可达环的关键被封装为 deduce_unreachable

update_refs
    copy ob_refcnt into temporary gc_refs
          |
          v
subtract_refs
    subtract references coming from candidates
          |
          v
move_unreachable
    propagate reachability from objects with gc_refs > 0

第一步 update_refs 把每个候选对象真实的 ob_refcnt 复制到临时的 gc_refs。GC 不能直接修改真实引用计数,否则扫描本身就可能触发析构。

第二步 subtract_refs 对每个候选容器调用 tp_traverse,把候选集合内部的每一条引用从目标对象的 gc_refs 中减掉。扣除完成后,gc_refs > 0 说明对象至少有一个来自候选集合外部的引用,它是确定可达的起点。

第三步 move_unreachable 从这些外部可达起点继续遍历,把间接可达的对象也留在原链表。其余对象进入 unreachable 链表。以前面的 A、B 环为例:

real refcounts:      A=1, B=1
internal edges:      A->B, B->A
adjusted gc_refs:    A=0, B=0
external roots:      none
result:              A and B are unreachable

这不是普通的“发现引用计数为 0 就释放”。这里的 0 是对候选子图扣除内部边后得到的临时值,用来推断哪些对象仍被子图外部引用。

CPython 循环 GC:从候选引用环到打破循环

找到垃圾后还不能直接 free

gc_collect_main 在得到 unreachable 集合后仍要按顺序处理弱引用、终结器与对象复活:

  1. 清除指向不可达对象的 weak reference,并按规则调用仍然可达的弱引用回调;
  2. 处理旧式 tp_del 终结器;
  3. 调用 tp_finalize,并记录已经调用过,避免对象复活后重复执行;
  4. 再次运行不可达性判断,把被终结器复活的对象移回存活集合;
  5. 对最终不可达对象调用 tp_clear 打断内部引用,让真实引用计数降到 0,随后由正常的析构路径释放。

因此循环回收并不是绕过引用计数直接释放内存。它先证明一个环不可达,再主动拆掉环,最后仍让引用计数和 tp_dealloc 完成对象销毁。

三代只优化循环扫描,不接管普通对象生命周期

CPython 3.14.7 的默认 GIL 构建在 _gc_runtime_state 中维护三代链表。新跟踪对象进入 generation 0;对象熬过一次本代回收后晋升,越老的代扫描越少。这利用了“多数对象朝生夕死”的弱分代假说。

自动回收会比较各代的 allocation count 与 threshold,并选择需要检查的最老一代;实际回收时还会合并所有更年轻的代。对最老代,源码额外用 long_lived_pending / long_lived_total 的比例限制昂贵的 full collection,避免长寿命对象不断增长时出现近似二次方的扫描成本。

这里要注意:代际链表只属于循环 GC 的候选集优化。一个 generation 2 对象如果真实引用计数降到 0,仍会立刻走 Py_DECREF -> _Py_Dealloc,不需要等老年代回收。

Python 3.14.0 至 3.14.4 曾使用两代增量循环 GC,但 3.14.5 起恢复了 3.13 风格的三代设计。阅读 3.14 早期文章或文档时需要核对补丁版本。

free-threaded CPython 是另一条实现分支

Python 3.14 的 free-threaded build 不再依赖 GIL 串行化引用计数操作。Py_DECREF 会区分对象所属线程,在 ob_ref_localob_ref_shared 上执行 biased reference counting;必要时合并计数或走共享递减路径。

它的循环 GC 与默认构建使用同样的“扣除内部引用、识别外部可达性、清除环”思想,但数据结构和同步方式不同:回收时会暂停其他执行线程,而且当前实现每次扫描整个堆,不使用默认构建的三代链表。讨论 CPython GC 性能时,应先说明解释器是否使用 free-threaded build。

两种机制放在一起比较

维度Go 1.26.7CPython 3.14.7 默认构建
主判断标准是否能从 roots 追踪到强引用计数是否归零
循环引用天然由可达性判断处理额外的 cyclic GC 负责识别
回收时机周期性标记后清扫多数对象归零时立即析构;环等待 cyclic GC
并发方式标记和清扫大部分与程序并发GIL 构建的循环回收与 Python 执行不并发
分代非分代cyclic GC 使用三代优化
对象移动不移动、不压缩不通过循环 GC 搬移对象
热路径成本分配、写屏障、标记辅助INCREFDECREF 与可能的级联析构
典型延迟来源STW 阶段切换、assist、CPU 竞争归零析构链、代际循环扫描、终结器

两者并不存在脱离负载的绝对优劣。

Go 把多数回收工作集中到 GC 周期,允许业务代码不为每次普通引用赋值维护计数,适合大量并发 goroutine 与跨线程对象图;代价是必须追踪存活对象、执行写屏障,并通过 pacing 在内存和 GC CPU 之间折中。

CPython 把成本分散到每次引用所有权变化,大部分对象能及时释放,内存行为在简单代码中更直观;代价是引用计数更新非常频繁,循环引用需要第二套算法,析构和终结器还可能把释放成本落在某一次看似普通的赋值或作用域退出上。

如何观察,而不是猜测

Go 程序可以先用下面几类工具确认问题究竟来自分配、标记还是清扫:

  • GODEBUG=gctrace=1 查看每轮 GC 的堆大小、CPU 与暂停摘要;
  • runtime/metrics 读取 GC cycle、heap goal、pause 和 allocation 指标;
  • go tool pprof 的 allocs、heap profile 定位主要分配来源;
  • go tool trace 观察 STW、GC worker 与 goroutine 调度的时间关系。

CPython 可以从循环 GC 的状态与对象分配来源分别观察:

import gc

print(gc.get_count())
print(gc.get_threshold())
print(gc.get_stats())

gc.callbacks 可以记录每次循环回收的开始和结束;gc.DEBUG_SAVEALL 适合调试不可达环,但会把发现的对象保存在 gc.garbage 中,本身就会阻止它们释放;tracemalloc 用于定位 Python 内存分配来源,不等同于循环引用检测器。

优化时最可靠的顺序仍然是:先测量,再减少无意义分配或缩短对象存活时间,最后才调整 GC 参数。关闭 GC、反复手动 gc.collect()、盲目增大 GOGC 都可能把当前指标变好,却把峰值内存或尾延迟推到别处。

总结

理解两套 GC 最重要的不是背诵术语,而是抓住它们各自回答的问题:

Go asks:       Can roots still reach this object?
CPython asks:  Does anyone still own a strong reference?
               If a cycle says yes, is that cycle reachable externally?

Go 用并发追踪统一处理普通对象图和引用环,借助写屏障、后台 worker 与 mutator assist 保持回收进度;Go 1.26 的 Green Tea 又把小对象按 span 批量扫描,改善标记阶段的局部性。

CPython 用引用计数快速处理绝大多数对象,再让循环 GC 对容器候选集执行“复制计数、扣除内部引用、传播外部可达性、打断环”的流程。三代策略只是在减少循环扫描成本,并没有替代引用计数。

从源码顺着 gcStart -> gcDrain -> gcMarkDone -> gcMarkTermination -> sweepone,以及 Py_DECREF -> _Py_Deallocgc_collect_main -> deduce_unreachable -> delete_garbage 两条调用链阅读,两种语言看似相同的“自动回收”就会变成两套非常具体的工程选择。

参考资料


Edit page