Skip to main content

Chapter 32: Reference Counting

引用计数是 CPython 最直接的对象生命周期规则:每个普通堆对象都会在对象头附近保存一个计数,runtime 用它记录当前还有多少条强引用路径正在使用这个对象。读完本章后,读者应能从一段 Python 代码或一段 C API 片段出发,追踪对象从创建、传递、保存到释放的引用变化,并判断内存问题来自少释放、多释放、引用环,还是来自错误理解 borrowed reference 和 strong reference 的所有权边界。

本章使用一个贯穿材料:两个 Python 对象互相持有,以及一个 C 扩展对象保存回调函数。Python 层看到的是“名字还在不在、对象能否访问、析构何时发生”;CPython 层看到的是 PyObject 的引用计数、持有引用的容器字段、Py_INCREF() / Py_DECREF() 的配对关系,以及循环垃圾回收在引用计数无法归零时介入的边界。

需要先固定版本边界:本章默认讨论 CPython。其它 Python 实现可以提供同样的语言语义,却使用不同内存管理策略。CPython 3.12 起引入 immortal object 后,某些长期存活对象的引用计数读数不再等同于真实引用数量;Python 3.14 的 C API Reference Counting 文档 已把 Py_INCREF() 表述为“取得一个 strong reference”,把 Py_DECREF() 表述为“释放一个 strong reference”。因此本章把引用计数视为所有权协议和生命周期触发条件,少用“数字精确反映全部引用条数”这种脆弱理解。

贯穿材料先从 Python 现象开始:

class Node:
def __init__(self, name):
self.name = name
self.peer = None

left = Node("left")
right = Node("right")
left.peer = right
right.peer = left

external = left
left = None
right = None

这段代码发生了三类引用变化。leftright 这两个名字最初分别绑定到两个 Node 对象;left.peer = rightright.peer = left 让两个实例的属性字典互相保存对象引用;left = Noneright = None 只释放两个局部名字对对象的引用。由于 external 仍然指向原来的 left 对象,整个对象组仍从外部可达,两个对象都不应释放。若随后执行 external = None,两个对象之间还保留互相指向的引用环,单纯引用计数无法把它们降到 0,这就是 cyclic GC 参与的入口。

32.1 ob_refcnt, incref, and decref

ob_refcnt 是 CPython 对象头中的引用计数字段,普通对象通过它记录当前有多少 strong reference 正在承担“保持对象存活”的责任。这里的“引用”需要放在 runtime 语境里理解:Python 名字、容器槽位、实例属性、frame 临时值、C 扩展字段都可能成为持有对象的引用路径;只有 strong reference 会让对象的释放条件延后。

从 Python 层看,引用计数变化常由绑定和容器操作触发。下面的代码用同一个 Node 对象经过名字绑定、列表保存和属性保存,展示多个路径如何共同决定生命周期:

class Node:
def __init__(self, name):
self.name = name
self.peer = None

node = Node("core")
holder = [node]
alias = node
node.peer = holder

这段代码里的 nodealiasholder[0]node.peer 指向同一个核心对象和相关容器。它们共同构成四条可达路径,其中 node.peer 指向的是列表对象,列表对象再通过槽位指回 node。当读者排查生命周期时,第一步应先列出对象图,再讨论计数变化。直接盯着一个名字是否还存在,会漏掉容器、属性、闭包、traceback 和 C 扩展字段这些更隐蔽的持有者。

在 CPython C 层,引用计数操作集中体现为 Py_INCREF()Py_DECREF()Py_XINCREF()Py_XDECREF()Py_NewRef()Py_XNewRef()Py_CLEAR()Py_SETREF() 这些接口。它们的共同目标是维护 strong reference 的所有权,单纯加一减一只是对象头字段层面的表现。Py_INCREF(o) 表示当前代码取得了一个新的 strong reference;Py_DECREF(o) 表示当前代码释放一个已经拥有的 strong reference;当最后一个 strong reference 被释放,CPython 调用对象类型的 deallocator 进入销毁路径。

这个过程可以压缩成一张生命周期图。图中只表达普通引用计数路径,不展开 cyclic GC 的分代扫描细节:

图里的关键判断是 refcnt == 0 只触发普通销毁路径。若对象之间互相保存引用,所有对象的计数都可能大于 0,图就会停在 CyclicCandidate,需要 cyclic GC 通过可达性分析识别这个孤立对象组。引用计数负责快速处理无环对象,cyclic GC 负责处理引用计数无法单独解决的环。

Py_DECREF() 的风险来自“释放引用可能运行任意 Python 代码”。当引用计数降到 0,类型的析构路径可能触发 __del__、weakref callback、descriptor 清理或容器元素释放。官方 C API 文档明确提醒,释放对象前应让外部可见的数据结构先进入一致状态。工程上这条规则意味着:替换对象字段时,应先让字段指向新值或 NULL,再释放旧值;清理容器槽位时,应先断开容器到旧对象的边,再释放临时保存的旧对象。

下面是一个简化 C 片段,用于说明字段替换时引用计数和一致性状态的顺序。它是一个用于讲解的片段,只展示所有权变化:

static int
store_callback(MyObject *self, PyObject *callback)
{
PyObject *old = self->callback;

self->callback = Py_NewRef(callback);
Py_XDECREF(old);
return 0;
}

这段代码的输入是一个已有对象字段 self->callback 和一个外部传入的 callbackPy_NewRef(callback) 取得新 strong reference,并把字段更新到新对象;old 保留旧字段值,最后用 Py_XDECREF(old) 释放旧 strong reference。顺序的工程含义是:如果旧对象析构时运行 Python 代码,self->callback 已经指向新对象,外部观察到的 self 处于一致状态。

Py_CLEAR() 进一步把这个顺序封装成“先把位置清空,再释放旧引用”。当字段可能被 GC 遍历或析构回调观察到时,Py_CLEAR(self->callback) 比手写 Py_DECREF(self->callback); self->callback = NULL; 更稳,因为它先让容器不再暴露旧对象,再执行可能带副作用的释放。读者在源码中看到 Py_CLEAR(),应把它读成生命周期边界上的一致性保护,普通减计数只是其中的一个效果。

Py_REFCNT() 能观察对象头中的引用计数字段,但它不适合作为业务逻辑判断依据。CPython 3.12 起,immortal object 的引用计数可能是特殊大值,Py_INCREF()Py_DECREF() 对这类对象可能无实际修改;free-threaded build 中,返回 1 也不足以证明当前线程可以独占对象。稳定判断应围绕“我是否拥有 strong reference、是否需要释放、对象是否仍从外部可达”展开。

32.2 Ownership model: borrowed, strong, and temporary references

引用计数真正约束的是所有权。strong reference 表示当前代码拥有保持对象存活的责任,也承担最后调用 Py_DECREF() 释放这份责任的义务。borrowed reference 表示当前代码临时看到了对象,但对象生命周期由别人维护;temporary reference 是执行路径上短暂持有的 strong reference,通常用于让对象跨过一段可能释放旧引用或运行 Python 代码的危险区间。

C API 文档和 CPython 源码中常见两类返回语义:new reference 与 borrowed reference。new reference 返回后,调用方拥有一个 strong reference;borrowed reference 返回后,调用方只能在被借出者仍保证对象存活的范围内使用。这个范围可能很短,尤其当随后调用的代码可能修改容器、触发析构、释放 GIL 或运行用户 Python 代码时,borrowed reference 会失去安全前提。

下面的简化例子展示 borrowed reference 转 strong reference 的必要时机。PyList_GetItem() 返回 borrowed reference;如果后续代码会运行 Python 回调,就应先把它提升成 strong reference:

static PyObject *
call_with_first(PyObject *items, PyObject *callback)
{
PyObject *borrowed = PyList_GetItem(items, 0);
if (borrowed == NULL) {
return NULL;
}

PyObject *first = Py_NewRef(borrowed);
PyObject *result = PyObject_CallOneArg(callback, first);
Py_DECREF(first);
return result;
}

这段代码的核心在调用边界。PyObject_CallOneArg() 可能执行任意 Python 代码,回调内部可能修改 items,也可能释放原来保存在列表里的对象。first 作为 temporary strong reference,把第一个元素的生命周期延长到回调结束之后。没有这一步,C 代码可能继续使用一个已经失效的 borrowed reference。

所有权判断可以按三问展开。第一,当前函数是否从 API 获得 new reference;如果是,函数结束前需要在每条成功或失败路径上释放,或把所有权转交给返回值、对象字段、容器。第二,当前函数是否只获得 borrowed reference;如果后续没有运行 Python 代码、没有修改持有者、没有跨线程或跨生命周期保存,可以直接读取;否则应转成 strong reference。第三,当前函数是否把对象保存到了结构体字段或容器里;如果保存,就需要新增 strong reference,字段清理时再释放。

这套规则同样解释 Python 层的直觉。下面代码里 alias 是普通 Python 局部名字,名字绑定会让对象保持可达;函数内部的局部变量、参数、临时表达式也会在 frame 中持有对象引用:

def use_node(node, callback):
alias = node
callback(alias)
return alias.name

nodealias 都在当前 frame 的局部变量区域里指向同一个对象。只要 frame 仍在执行,对象就由这些局部引用保持可达。若 callback(alias) 删除了全局容器里的同一对象,局部变量仍能保证 return alias.name 可以访问原对象。Python 层看不见 Py_INCREF() 的细节,但 frame、参数绑定、局部变量保存的效果就是延长对象存活。

borrowed reference 的错误常发生在“看起来只是读取”的代码里。读取容器元素、读取属性缓存、从内部结构拿指针,这些动作本身成本低;风险来自读完之后跨过了会改变所有权环境的边界。工程上可执行的检查顺序是:先看对象由谁持有,再看当前代码是否要跨过回调、释放、容器修改、异常退出、线程切换或长期保存,再决定是否用 Py_NewRef() 把 borrowed reference 转成 strong reference。

错误处理路径需要和成功路径同等完整。下面的模式展示 C API 函数中常见的单出口清理。每一个已经取得的 strong reference 都在 done 区统一释放;返回给调用方的 result 作为 new reference 转交出去:

static PyObject *
build_pair(PyObject *left, PyObject *right)
{
PyObject *pair = NULL;
PyObject *result = NULL;

pair = PyTuple_New(2);
if (pair == NULL) {
goto done;
}

PyTuple_SET_ITEM(pair, 0, Py_NewRef(left));
PyTuple_SET_ITEM(pair, 1, Py_NewRef(right));
result = Py_NewRef(pair);

done:
Py_XDECREF(pair);
return result;
}

这个示例有简化边界:PyTuple_SET_ITEM() 会偷走传入引用,真实代码要严格确认目标 API 是 steal reference、borrowed reference 还是 new reference 语义。这里要建立的能力是读懂“所有权转移”。当一个 API steals a reference,调用方传入的 strong reference 不再由调用方释放;当一个 API returns a new reference,调用方接过释放责任;当一个 API returns a borrowed reference,调用方需要根据后续边界决定是否临时提升。

32.3 Reference leak and reference graph

reference leak 是引用关系和生命周期预期不匹配造成的对象残留。它的表面现象可能是进程常驻内存上升、对象数量持续增长、缓存无法收缩、析构逻辑迟迟不运行;它的 runtime 解释是对象仍然从某条 strong reference 路径可达,或者 C 扩展忘记释放自己持有的 strong reference。

排查 leak 时应先画引用图,再判断生命周期。以下 Python 片段看起来只创建了短生命周期对象,但全局列表把对象保存了下来:

registry = []

class Job:
def __init__(self, payload):
self.payload = payload

def submit(payload):
job = Job(payload)
registry.append(job)
return job.payload

job 局部变量会在函数返回后释放,但 registry 仍然持有 Job 对象。若 registry 是有界缓存,这条引用路径符合设计;若业务预期是请求结束后释放,registry.append(job) 就是泄漏入口。引用计数不会区分“符合设计的长寿命引用”和“开发者忘记清理的长寿命引用”,它只根据可达路径工作。

C 扩展中的 reference leak 更直接。下面的简化片段每次调用都取得一个 strong reference,却没有在成功路径释放:

static PyObject *
leaky_name(PyObject *obj)
{
PyObject *name = PyObject_GetAttrString(obj, "name");
if (name == NULL) {
return NULL;
}

return PyUnicode_FromFormat("name=%S", name);
}

PyObject_GetAttrString() 返回 new reference,name 的释放责任落在当前函数。PyUnicode_FromFormat() 创建返回值后,当前函数仍然拥有 name。正确路径需要在返回前释放它:

static PyObject *
format_name(PyObject *obj)
{
PyObject *name = PyObject_GetAttrString(obj, "name");
if (name == NULL) {
return NULL;
}

PyObject *result = PyUnicode_FromFormat("name=%S", name);
Py_DECREF(name);
return result;
}

这类 leak 的判断顺序很稳定:找出每个 new reference 的来源,沿成功路径和失败路径分别追踪它的去向。去向只有三类:释放掉、转交给返回值、转交给持有者。若路径结束时仍没有去向,就是 leak。若同一份引用被释放两次,就是 use-after-free 或崩溃风险。若 borrowed reference 被误当作 strong reference 释放,持有者仍以为对象有效,后续访问会进入未定义状态。

Python 层 leak 常常需要把 frame、traceback 和闭包也画进引用图。异常对象保存 traceback,traceback 保存 frame,frame 保存 locals,locals 可能保存大量临时对象。若程序把异常对象放入全局缓存,就可能把整个调用链的局部对象延长到缓存生命周期。下面代码展示这种引用链:

errors = []

def process(payload):
try:
raise RuntimeError("failed")
except RuntimeError as exc:
errors.append(exc)
return None

exc 被追加到 errors 后,异常对象可以继续关联 traceback;traceback 再关联发生异常的 frame;frame 的局部变量里有 payload。这条链解释了“一个小异常对象为什么能留住大对象”。工程上处理这类问题时,应明确错误记录需要保存什么:保存字符串、错误码、摘要或轻量结构,生命周期就跟完整 traceback 分离;保存异常对象本体,生命周期就会扩展到异常对象能到达的整条引用链。

引用图还解释缓存和单例的边界。模块全局对象、类属性、functools.lru_cache、注册表、回调列表、logger handler 都可能是长寿命 strong reference 持有者。它们不一定是 bug;只有当对象生命周期预期短于持有者生命周期,且没有清理策略时,才构成泄漏。判断 leak 的核心证据是“对象是否仍从某个设计上长寿命的 root 可达”;单次 sys.getrefcount() 数值只能作为局部观察。

sys.getrefcount(obj) 会因为函数参数传递临时增加引用,而且在 immortal object 上可能返回特殊大值。它适合做局部观察,不适合作为完整结论。更可靠的思路是围绕对象图提问:谁持有它,持有者何时释放,释放是否覆盖异常路径,是否存在回调或 traceback 延长生命周期,是否存在 C API new reference 未配对释放。

32.4 Cyclic reference boundary

引用计数在无环对象图上非常直接:最后一条 strong reference 消失,对象就进入销毁路径。引用环改变了这个条件。对象组内部互相持有,每个对象的引用计数都大于 0;从程序外部看,这组对象已经不可达;从单个对象的引用计数看,它们仍然“有人引用”。cyclic GC 负责识别这种外部不可达、内部互相支撑的对象组。

回到本章开头的 Node 例子:

left = Node("left")
right = Node("right")
left.peer = right
right.peer = left

left = None
right = None

若没有其它外部引用,两个 Node 对象形成一个孤立环。left = Noneright = None 释放了两个名字对对象的引用,但 left.peerright.peer 仍然互相指向。引用计数看不到“外部不可达”这个整体事实,只能看到每个对象仍被另一个对象引用。cyclic GC 通过遍历容器对象的引用关系,把从外部 root 进不去的对象组识别出来,再按对象生命周期规则处理。

CPython 的 Object Life Cycle 文档 把可参与 GC 的类型放进更完整的生命周期:对象需要通过 tp_traverse 暴露自己持有的 Python 对象,让 GC 能遍历引用图;在需要打断环时,tp_clear 负责清理对象持有的引用;最终 tp_dealloc 完成对象销毁并释放内存。对扩展类型来说,tp_traverse 漏报引用会让 GC 看不见环,tp_clear 无法打断环会让对象组残留。

这个边界可以用“外部可达性”和“内部计数”两个维度理解。引用计数回答单个对象是否还有 strong reference;GC 回答一组对象是否还能从 root 到达。二者关注层级不同,结果可以同时成立:对象计数大于 0,同时对象组从外部不可达。此时对象组应被回收,单对象计数无法独立给出这个结论。

带 finalizer 的引用环需要更谨慎。__del__、weakref callback、C 层 tp_finalize 可能运行用户代码,甚至让对象重新变得可达。CPython 使用分阶段处理来维护一致性:先发现 cyclic isolate,再处理 finalization,再通过 clear 打断引用,再进入 deallocation。读者不需要记住每个内部阶段的全部细节,但要保留一个判断:引用环中的对象销毁是对象组级别的过程,顺序、回调和复活都会影响可观察行为。

下面的 Python 片段展示 finalizer 和引用环共同出现时的观察边界:

class Resource:
def __init__(self, name):
self.name = name
self.peer = None

def __del__(self):
print("close", self.name)

left = Resource("left")
right = Resource("right")
left.peer = right
right.peer = left
left = None
right = None

这段代码的结论应写成生命周期判断:两个对象可以由 cyclic GC 处理,__del__ 的执行时机由 GC 周期和对象可达性共同决定,程序的资源释放设计不应依赖它在某个精确语句后立即运行。更稳的资源管理应使用 context manager,在 with 块退出时释放外部资源,把内存回收和外部资源关闭分离。

对 C 扩展类型,引用环边界会落到类型设计上。如果对象字段可能保存 Python 对象,并且对象可参与环,就需要让类型支持 GC traversal。简化结构如下:

typedef struct {
PyObject_HEAD
PyObject *peer;
} LinkObject;

static int
Link_traverse(LinkObject *self, visitproc visit, void *arg)
{
Py_VISIT(self->peer);
return 0;
}

static int
Link_clear(LinkObject *self)
{
Py_CLEAR(self->peer);
return 0;
}

Link_traverse() 告诉 GC:self 持有 peer 这条引用边。Link_clear() 告诉 GC:需要打断环时,可以清空 peer 并释放对应 strong reference。这个例子说明 cyclic GC 依赖类型把内部引用边如实暴露出来;它的对象关系来自 tp_traverse 提供的边。源码阅读时看到 tp_traversetp_clear,应把它们和对象字段、引用环风险、销毁阶段一起读。

32.5 Reference counting checklist

引用计数问题可以按固定顺序排查。第一步,确定讨论对象和实现边界:本章默认 CPython;若现象来自 PyPy、MicroPython 或嵌入式 runtime,应先确认其内存管理策略。第二步,画出对象图:名字、frame locals、实例属性、容器元素、闭包 cell、traceback、模块全局、C 结构体字段都要纳入可达路径。第三步,标注每条路径是否是 strong reference,以及谁负责释放。

第四步,沿执行路径追踪引用变化。创建对象、函数参数绑定、容器保存、属性赋值、返回值、异常保存、C API new reference 都可能增加持有关系;重新绑定、容器删除、字段清空、frame 返回、错误路径清理、Py_DECREF() 可能释放持有关系。第五步,识别边界动作:运行 Python 回调、调用可能 re-enter 的 API、释放旧对象、修改容器、跨线程、长期保存 borrowed reference、保存异常对象,这些动作都会改变原先的生命周期前提。

第六步,区分 leak、dangling 和 cycle。leak 的证据是对象仍从长寿命 root 可达,或 C 层 strong reference 没有释放。dangling 的证据是 borrowed reference 被跨越危险边界使用,或者旧对象释放后仍通过缓存指针访问。cycle 的证据是对象组外部不可达、内部互相持有,单对象引用计数无法归零。三类问题的修复方向不同:leak 需要释放或缩短持有者生命周期;dangling 需要提升引用或调整顺序;cycle 需要打断引用、支持 GC traversal,或使用 weak reference 改变对象图。

下面给出一个可以直接套用到源码阅读和扩展模块审查中的检查表:

  • 先确定对象身份:当前名字、字段、容器槽位是否指向同一个对象。
  • 再列出持有者:module、frame、container、attribute、closure、traceback、C struct 谁在保存它。
  • 对每个 C API 返回值标注语义:new reference、borrowed reference、steals reference 或返回内部指针。
  • 对每个 strong reference 找到释放点:成功路径、失败路径、异常路径和提前返回路径都要覆盖。
  • 在每次 Py_DECREF() 前检查外部可见状态:字段、容器和缓存是否已进入一致状态。
  • 在 borrowed reference 跨过回调、容器修改、释放、线程或长期保存前,用 Py_NewRef() 建立 temporary strong reference。
  • 对可能形成环的类型检查 tp_traversetp_clear:字段引用边是否完整暴露,清理动作是否能打断环。
  • 对 Python 层内存增长检查长寿命 root:全局缓存、注册表、异常列表、闭包、任务队列和回调集合。
  • sys.getrefcount()Py_REFCNT() 结果保留版本边界:immortal object、临时参数引用和 free-threaded build 都会影响读数解释。

把这套顺序应用到开头的 Node 代码,结论很清楚:external = left 让对象组仍从外部可达,left = Noneright = None 不会释放整组对象;当 external = None 后,对象组可能只剩内部互相引用,普通引用计数无法触发 refcnt == 0,cyclic GC 才能通过对象图可达性处理它。若其中某个对象定义了 finalizer,销毁会进入更复杂的 finalization 和 clear 阶段,资源关闭应交给显式协议。

把这套顺序应用到 C 扩展字段替换,结论也稳定:保存对象字段时必须取得 strong reference;替换字段时先让字段进入新状态,再释放旧引用;从容器拿到 borrowed reference 后,若要跨过回调或所有权变化边界,应提升为 temporary strong reference;函数返回前,每条路径都要让已经取得的 new reference 有明确去向。

最小自检任务

阅读下面代码,判断 leftright 两个对象在每一步之后是否应被普通引用计数立即释放,并说明当 keep 被清空后,为什么仍可能需要 cyclic GC 参与:

class Node:
def __init__(self, name):
self.name = name
self.peer = None

left = Node("left")
right = Node("right")
left.peer = right
right.peer = left

keep = [left]
left = None
right = None
keep.clear()

答案要点

创建 leftright 后,两个名字分别持有两个 Node 对象。执行 left.peer = rightright.peer = left 后,两个对象通过属性互相持有,形成内部引用环。keep = [left] 又让列表从外部持有原来的 left 对象;由于 left.peer 能到达 right,整个对象组仍可达。

执行 left = Noneright = None 只释放两个局部名字的引用,keep[0] 仍然指向原来的 left 对象,因此对象组仍从外部可达,普通引用计数不应释放它们。执行 keep.clear() 后,外部列表不再持有 left,对象组可能只剩 left.peer → rightright.peer → left 两条内部引用。此时每个对象的引用计数仍可能大于 0,但从外部 root 已经到达不了这组对象,普通引用计数无法单独触发释放,需要 cyclic GC 通过对象图可达性识别并清理这个环。

可复用的判断步骤是:先确认对象身份,再列出外部 root 到对象的路径,然后区分内部互相持有和外部可达路径。只要还存在外部可达路径,对象组仍然存活;当只剩内部环,引用计数读数不能单独决定释放,GC traversal 和 clear 才能处理对象组级别的生命周期。

本章知识点总结

  • 引用计数:CPython 用对象头中的计数记录 strong reference 对对象生命周期的持有责任。
  • 对象图:生命周期判断应先列出名字、容器、属性、frame、traceback 和 C 字段形成的可达路径。
  • INCREFPy_INCREF() 表示取得新的 strong reference,并让当前代码承担后续释放责任。
  • DECREFPy_DECREF() 表示释放已经拥有的 strong reference,最后一次释放会进入类型销毁路径。
  • 一致状态:释放旧对象前应先更新外部可见字段或容器,降低析构回调观察到不一致状态的风险。
  • borrowed 引用:borrowed reference 只在借出者保证对象存活的范围内安全,跨过回调或修改边界前应提升为 strong reference。
  • temporary 引用:temporary strong reference 用来让对象跨过可能释放原持有者的危险区间。
  • 所有权转移:new reference、borrowed reference 和 steals reference 决定调用方是否需要释放或放弃释放责任。
  • 泄漏判断:reference leak 的证据是对象仍从长寿命 root 可达,或 C 层 strong reference 没有覆盖释放路径。
  • traceback 链:异常对象可能通过 traceback 持有 frame,再让 frame locals 延长大对象生命周期。
  • 引用环:对象组外部不可达且内部互相持有时,单个对象的引用计数可能无法归零。
  • cyclic GC:cyclic GC 通过 tp_traverse 发现对象引用边,并通过 tp_clear 打断环。
  • finalizer 边界:带 finalizer 的对象销毁可能运行 Python 代码,资源关闭应优先使用显式上下文协议。
  • 版本边界:immortal object、Py_REFCNT() 读数和 free-threaded build 会改变引用计数观察方式。
  • 检查顺序:排查时先确定对象身份,再列持有者和所有权,随后覆盖成功、失败、异常和回调路径。