ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

CPython marshal 格式 v6 修复:共享引用的 frozendict 正确往返序列化

CPython marshal 格式 v6 修复:共享引用的 frozendict 正确往返序列化 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 记录了一条简短的变更说明Fixmarshalso 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历史首个版本基础标量类型3Python 3.4支持递归容器recursive containers与引用共享reference sharing4Python 3.4更高效的字符串与小型元组表示5Python 3.14支持序列化slice对象6Python 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.ccase 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_reserver_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 1Python/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, versionN)且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),仅供参考
返回列表