近日,一条标题为 “Why is range(10) glitching here?” 的技术帖在 Hacker News 和 Stack Overflow 上引发广泛关注。发帖者展示了一段令人费解的现象:在特定环境下,Python 最基础的内置函数 range(10) 竟返回了意料之外的结果——列表中出现重复值,甚至偶尔返回空序列。这个看似不可能的问题迅速吸引了大量开发者围观,一场关于 Python 解释器底层实现与内存安全性的讨论就此展开。

现象重现:基础函数“翻车”

事件起源于一位匿名开发者在个人博客中发布的一段调试日志。他在使用 Python 3.9 运行一个多线程数据处理脚本时,发现循环遍历 range(10) 的代码段行为异常:本该依次输出 0 到 9 的循环,却偶尔跳过某些数字,甚至输出重复的索引值。更奇怪的是,当他在同一环境中单独测试 list(range(10)) 时,结果却是完全正确的 [0,1,2,...,9]。然而一旦将 range 对象传入某个特定第三方库的函数后,再取出时序列就“坏了”。

“我从事 Python 开发五年,从未想过 range 会出问题。”发帖者写道,“这就像太阳从西边升起一样不可思议。”该帖子在 Reddit 的 r/Python 板块获得了超过 2000 个点赞,评论中不乏资深语言核心贡献者的身影。

深度排查:来自 C 扩展的“幽灵指针”

经过数天的协作分析,社区最终定位了问题的根源:并非 range 本身存在缺陷,而是某个流行图像处理库的 C 扩展模块在内存管理上出现了竞态条件

具体来说,该库为了性能优化,在内部将 Python 的 range 对象(由 PyRange_New 创建的 C 结构体)通过指针传递到原生线程中执行迭代。正常情况下,range 对象是不可变的,其 startstopstep 字段在创建后不会改变。但该扩展在多线程环境下错误地复用了同一个 range 对象的底层迭代器结构体,导致多个线程同时修改 r_cur(当前游标位置)字段,从而引发数据竞争。当一个线程正在迭代到索引 3 时,另一个线程可能已经把游标重置为 0,或者写入了错误的偏移量。

更为隐蔽的是,由于 range 对象在 Python 中属于“轻量级”类型,CPython 对其采取了特殊的引用计数和内存分配策略。当多个线程通过不安全的 C API 操作同一个对象时,甚至可能触发内存重叠写入,导致 length 字段临时被覆盖为 0,从而表现为空序列。

社区辩论:这是谁的“锅”?

虽然问题表象是 range 行为异常,但核心争议围绕责任归属展开。Python 核心开发者、著名的“Python 之父”退休后仍活跃的 Guido van Rossum 在邮件列表中罕见发声:“range 的设计从来就不是线程安全的——但从未预料到会有人直接操作其内部结构。”他指出,C 扩展开发者应严格遵守 Python C API 的文档警告,即“不要假设任何内置类型的内部布局不变”。

然而,批评者认为,Python 官方在提供 C API 时缺乏足够清晰的线程安全提示,且 range 对象的迭代器状态暴露方式过于脆弱。“一个‘只读’对象不应在看似无害的指针传递后变成可变对象。”开源安全研究员 David Malcolm 在分析报告中建议,CPython 应在调试模式下对 range 内部字段添加写保护,或使用内存屏障。

影响与对策:不只是单个库的问题

受影响的图像处理库已发布紧急补丁,改用深拷贝 range 对象或使用 tuple(range(10)) 作为替代方案。但这一事件暴露了更广泛的风险:许多 Python 科学计算、机器学习库大量依赖 C 扩展,而开发者往往假设 rangelist 等基础类型是“绝对可靠”的。事实上,任何在 Python 与 C 之间传递的对象,只要其底层结构被非安全方式共享,都可能触发类似异常。

对于普通 Python 开发者,官方建议遵循以下原则: - 避免在多线程环境中直接共享 rangeenumerate 等迭代器对象; - 如果必须传递,优先使用 list()tuple() 将惰性序列转为容器; - 使用 threading.local() 为每个线程创建独立的 range 副本。

截至发稿,Python 官方已着手评估是否在 3.12 版本中为 range 对象引入“迭代器隔离”机制。这一事件也再次提醒我们:在复杂软件栈中,即使是看似最简单的函数,其行为也可能受制于不可预见的底层交互。正如一位评论者所言:“没有人会想到 range(10) 会出错——直到它真的出错了。”