CPython marshal 格式 v6 修复:共享引用的 frozendict 正确往返序列化
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
本文基于 CPython 主仓库中的一条 NEWS 条目,讲解marshal模块在格式版本 6 中针对frozendict共享引用(shared reference)往返序列化的缺陷修复。文章围绕该修复展开,结合 Python/marshal.c 源码、Lib/test/test_marshal.py 测试用例与 Doc/library/marshal.rst 文档,说明问题成因、修复原理、验证方法及影响范围,帮助读者理解 CPython 内部序列化格式的引用追踪机制。
背景:一条 NEWS 条目与它所指向的缺陷
CPython 仓库的 Misc/NEWS.d/next/Core_and_Builtins/2026-08-07-14-45-02.gh-issue-155315.Mk9Fd2.rst 记录了一条简短的变更说明:
Fix
marshalso that afrozendictreferenced more than once in the serialized data round-trips correctly, instead of failing to load withValueError. Patch by tonghuaroot.
这条条目虽然只有两句话,却指向了marshal序列化器在支持frozendict类型后遗留的一个真实缺陷:当一个frozendict对象在待序列化数据中被引用多次(例如同一个frozendict同时出现在一个列表的两个元素中,或作为另一个frozendict的两个键值)时,marshal.dumps可以成功写出数据,但marshal.loads在读回时却可能抛出ValueError,导致往返(round-trip)失败。
本文将依次回答四个问题:
marshal是什么,为什么它需要处理共享引用?frozendict与dict在marshal支持上有何差异,缺陷为什么只出现在frozendict上?- 修复在源码层面做了什么,测试如何验证?
- 该修复对格式版本、兼容性和用户代码有什么影响?
marshal模块与格式版本机制
marshal是 CPython 内置的序列化模块,位于 Lib/marshal.py(纯 Python 包装层),核心实现为 C 代码 Python/marshal.c。其官方定位(见 Doc/library/marshal.rst)是内部序列化工具,主要用于读写编译后的字节码(code object),因此:
- 它不保证跨 Python 版本的兼容性("The format of code objects is not compatible between Python versions"),每个 Python 版本的格式都可能变化;
- 它支持的类型集合有限,不覆盖任意 Python 对象(相比
pickle的通用性); - 官方文档明确建议:需要版本无关、安全可靠的跨语言/跨进程数据交换时,应优先使用
pickle或 JSON 等方案。
marshal的二进制格式通过版本号演进。在 Include/cpython/marshal.h 中定义了当前版本:
#define Py_MARSHAL_VERSION 6根据 Doc/library/marshal.rst 的版本历史说明:
| 格式版本 | 引入于 | 新增能力 |
|---|---|---|
| 0 | 历史首个版本 | 基础标量类型 |
| 3 | Python 3.4 | 支持递归容器(recursive containers)与引用共享(reference sharing) |
| 4 | Python 3.4 | 更高效的字符串与小型元组表示 |
| 5 | Python 3.14 | 支持序列化slice对象 |
| 6 | Python 3.15 | 支持序列化frozendict对象 |
本文讨论的修复正是围绕格式版本 6的frozendict支持展开的。在 Python/marshal.c 的类型标签定义中可以看到frozendict的类型码:
/* Supported types */ #define TYPE_DICT '{' #define TYPE_FROZENDICT '}' #define TYPE_REF 'r' // References (added in version 3)版本 3 引入的TYPE_REF(类型码'r')是理解本缺陷的关键:它让marshal能够在序列化流中表达"同一个对象被引用多次"这一事实,从而在加载时恢复对象身份(identity)的共享关系。
问题本质:共享引用与frozendict的引用注册时机
引用追踪的两个阶段
marshal格式版本 3 起支持循环引用和共享引用。序列化端(w_object)与反序列化端(r_object)分别维护一套引用表(ref table):
- 序列化时:每当一个容器对象被写出,序列化器会把它登记到引用表中,并用一个自增的索引代表它。后续再次遇到同一个对象时,不重复写出内容,而是只写一个
TYPE_REF加索引号。 - 反序列化时:
r_object读到容器类型标签后,会先在引用表中为该对象预留/登记一个位置(R_REF或r_ref_reserve),再填充其内容;后续遇到TYPE_REF时,通过索引直接取回先前登记的对象。
这段逻辑的核心代码如下(Python/marshal.c):
case TYPE_DICT: case TYPE_FROZENDICT: v = PyDict_New(); if (v == NULL) { break; } if (type == TYPE_DICT) { R_REF(v); /* dict:立即登记引用 */ ... } else { idx = r_ref_reserve(flag, p); /* frozendict:先预留索引 */ if (idx < 0) { Py_CLEAR(v); break; } } for (;;) { PyObject *key, *val; key = r_object(p); if (key == NULL) break; val = r_object(p); if (val == NULL) { Py_DECREF(key); break; } if (PyDict_SetItem(v, key, val) < 0) { ... } Py_DECREF(key); Py_DECREF(val); } if (PyErr_Occurred()) { Py_CLEAR(v); } if (type == TYPE_FROZENDICT && v != NULL) { Py_SETREF(v, PyFrozenDict_New(v)); /* 将 dict 转为 frozendict */ v = r_ref_insert(v, idx, flag, p); /* 完成引用登记 */ } retval = v; break;注意这里dict与frozendict处理方式的差异:
dict是可变的,可以先PyDict_New()创建空 dict、立即R_REF(v)登记引用,再逐条填充键值对;后续任何指向该 dict 的TYPE_REF都能立刻取到"正在构建中"的对象。frozendict是不可变的,不能"先建空对象再填充"——它必须先构建一个临时dict,填充完所有键值后再调用PyFrozenDict_New(v)一次性转换为不可变的frozendict。因此反序列化器无法在构建完成前就把最终对象登记进引用表,只能先用r_ref_reserve预留索引、在构建完成后用r_ref_insert补登记。
缺陷的触发路径
问题就出在frozendict补登记(r_ref_insert)与TYPE_REF解析之间的时序上。
设想如下数据:
import marshal fd = frozendict({'a': 1, 'b': 2}) out = marshal.loads(marshal.dumps([fd, fd]))序列化[fd, fd]时,列表写出后,第一个fd被完整写出并登记引用索引 0;第二个fd写为TYPE_REF指向索引 0。
反序列化时,r_object读到列表,然后读第一个TYPE_FROZENDICT:创建临时 dict、填充键值,再PyFrozenDict_New转为frozendict、r_ref_insert登记。此时若r_ref_insert的返回路径存在问题(例如返回 NULL 或未正确更新引用表条目),当r_object继续读到第二个元素是TYPE_REF并尝试解析索引 0 时,就可能取不到有效对象,最终抛出ValueError。
此外还有一个更隐蔽的嵌套场景(Lib/test/test_marshal.py 中的test_shared_reference_frozendict覆盖):
nested = marshal.loads(marshal.dumps(frozendict({'x': fd, 'y': fd}))) self.assertIs(nested['x'], nested['y'])同一个fd作为外层frozendict的两个值。外层frozendict自身要等两个值都反序列化完成后才能PyFrozenDict_New并补登记;而在解析第二个值y的TYPE_REF时,需要能从引用表中取回x解析出的那个frozendict。这条"值引用外层尚未完成登记对象"的路径正是修复的关键所在。
循环引用与错误路径的防御
测试文件 Lib/test/test_marshal.py 中同时还覆盖了frozendict的循环引用与非法自引用数据:
def test_reference_loop_frozendict(self): a = frozendict({None: []}) a[None].append(a) # frozendict 通过可变值间接自引用 for v in range(marshal.version + 1): self.assertRaises(ValueError, marshal.dumps, a, v) def test_loads_abnormal_reference_loops(self): ... b'\xfdN[\x01\x00\x00\x00r\x00\x00\x00\x000', # frozendict({None: [<R>]}) b'\xfdN{Nr\x00\x00\x00\x0000', # frozendict({None: {None: <R>}) ...由于frozendict不可变、其键必须可哈希,直接让frozendict自引用(如frozendict({None: <自身>}))在 Python 层无法构造,但这些"异常引用环"仍可通过手工构造的字节串喂给marshal.loads,因此测试以ValueError断言这些畸形数据被正确拒绝——这正是 NEWS 条目中提到的"instead of failing to load withValueError"所指的另一侧:合法共享引用必须成功,畸形引用环必须报错。
修复方案:r_ref_reserve+r_ref_insert延迟登记
从源码结构可以推断,本次修复在反序列化TYPE_FROZENDICT分支中引入了与TYPE_FROZENSET分支(Python/marshal.c)一致的**延迟登记(delayed registration)**模式:
- 先预留索引:
idx = r_ref_reserve(flag, p)在引用表中占一个坑位,但不立即放入对象; - 构建临时 dict:逐条读入键值对,
PyDict_SetItem填充; - 转换为不可变对象:
PyFrozenDict_New(v)生成最终frozendict; - 补登记:
r_ref_insert(v, idx, flag, p)把最终对象写入预留索引,使后续TYPE_REF能正确解析到它。
对应地,序列化端(Python/marshal.c)在写出TYPE_FROZENDICT后调用w_complete(v, p)完成引用登记,与tuple、frozenset等不可变容器的处理保持一致。frozenset分支中注释 "must use delayed registration of frozensets because they must be init with a refcount of 1"(Python/marshal.c)同样适用于frozendict:不可变对象在完全构造前不能被引用表持有,否则引用计数与共享语义会出错。
修复后,Lib/test/test_marshal.py 中的共享引用测试能够通过:
def test_shared_reference_frozendict(self): # A frozendict referenced more than once must round-trip with the # shared identity preserved, like frozenset. fd = frozendict({'a': 1, 'b': 2}) out = marshal.loads(marshal.dumps([fd, fd])) self.assertEqual(out[0], fd) self.assertIs(out[0], out[1]) nested = marshal.loads(marshal.dumps(frozendict({'x': fd, 'y': fd}))) self.assertIs(nested['x'], nested['y'])其中assertIs断言的是对象身份共享被保留:反序列化出的两个fd是同一个对象,而非内容相等的两个独立对象——这正是共享引用语义("round-trips with the shared identity preserved, like frozenset")的验证。
版本约束与兼容性影响
需要特别强调的是,frozendict的marshal支持是格式版本 6 专有的。序列化端在 Python/marshal.c 中做了显式检查:
if (PyFrozenDict_CheckExact(v)) { if (p->version < 6) { w_byte(TYPE_UNKNOWN, p); p->error = WFERR_UNMARSHALLABLE; return; } W_TYPE(TYPE_FROZENDICT, p); }即:当用户指定marshal.dumps(value, version=N)且N < 6时,写出frozendict会直接失败(ValueError: unmarshallable object),而不是降级为普通dict——因为降级会破坏往返后类型的一致性。
对使用方的实际影响可归纳为:
- 默认版本行为:
marshal.dumps/marshal.dump默认使用Py_MARSHAL_VERSION(当前为 6),因此默认情况下frozendict可正常往返; - 低版本兼容:若与旧版本(< 6)生成的
marshal数据交互,包含frozendict的数据无法互操作,需使用支持 v6 的 CPython 3.15+; - 嵌套共享:修复同时保证了"同一
frozendict出现在多个容器中"与"同一frozendict出现在另一个frozendict内部"两种共享场景都能保持对象身份一致; - 安全边界:
marshal的官方文档(Doc/library/marshal.rst)始终警告:不应在不可信数据上调用marshal.loads,因为它可能构造任意对象,存在安全风险。本文讨论的ValueError防御仅是格式健壮性的一部分,不代表marshal可安全处理任意输入。
总结
本次修复针对 CPythonmarshal格式版本 6 中frozendict支持遗留的共享引用缺陷:由于frozendict不可变,无法像dict那样"先登记、再填充",导致被多次引用的frozendict在反序列化时引用表解析失败、抛出ValueError。修复在 Python/marshal.c 的TYPE_FROZENDICT分支中引入r_ref_reserve预留 +PyFrozenDict_New转换 +r_ref_insert补登记的延迟登记流程,使frozendict与frozenset一样能够正确保留共享对象身份。配套测试 Lib/test/test_marshal.py 与循环引用防御测试(Lib/test/test_marshal.py)共同保证了合法共享引用可往返、畸形引用环被拒绝。
对普通开发者而言,该修复的直接影响是:在 CPython 3.15+ 上,包含重复frozendict引用的数据结构可以放心交给marshal.dumps/marshal.loads往返处理;而若需要跨版本或跨语言交换数据,仍应遵循官方建议优先选用pickle或 JSON。
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考