PEP 651 – 健壮的堆栈溢出处理
- 作者:
- Mark Shannon <mark at hotpy.org>
- 状态:
- 已拒绝
- 类型:
- 标准跟踪
- 创建日期:
- 2021年1月18日
- 发布历史:
- 2021年1月19日
拒绝通知
此 PEP 已被 Python 核心开发团队拒绝。
摘要
此 PEP 提议 Python 应区分处理机器堆栈溢出和失控递归。
这将允许程序根据自身需求设置最大递归深度,并提供额外的安全保证。
如果此 PEP 被接受,则以下程序将安全地完成执行
sys.setrecursionlimit(1_000_000)
def f(n):
if n:
f(n-1)
f(500_000)
而以下程序将引发 StackOverflow,而不会导致 VM 崩溃
sys.setrecursionlimit(1_000_000)
class X:
def __add__(self, other):
return self + other
X() + 1
动机
CPython 使用单一的递归深度计数器来防止失控递归和 C 堆栈溢出。然而,失控递归和机器堆栈溢出是两个不同的概念。允许机器堆栈溢出可能是一个安全漏洞,但限制递归深度会阻碍在 Python 中使用某些算法。
目前,如果程序需要深度递归,它必须管理允许的最大递归深度,希望将其设置在正确运行所需的最小深度和避免内存保护错误的足够大深度之间。
通过将 C 堆栈溢出检查与递归深度检查分开,纯 Python 程序可以安全运行,使用它们所需的任何递归级别。
基本原理
CPython 目前依赖于单一限制来防止虚拟机中潜在危险的堆栈溢出,并防止 Python 程序中失控的递归。
这是实现方式耦合了 C 和 Python 调用堆栈的结果。通过打破这种耦合,我们可以同时提高 CPython 的可用性和安全性。
递归限制的存在是为了防止失控递归,虚拟机自身的完整性不应依赖于它。同样,递归也不应受实现细节的限制。
规范
将添加两个新的异常类,StackOverflow 和 RecursionOverflow,它们都将是 RecursionError 的子类。
StackOverflow 异常
每当解释器或内置模块代码确定 C 堆栈已达到或接近安全限制时,都会引发 StackOverflow 异常。StackOverflow 是 RecursionError 的子类,因此任何处理 RecursionError 的代码都会处理 StackOverflow。
RecursionOverflow 异常
当对 Python 函数的调用导致递归限制被超过时,将引发 RecursionOverflow 异常。这与当前引发 RecursionError 的行为略有不同。RecursionOverflow 是 RecursionError 的子类,因此任何处理 RecursionError 的代码将继续按原样工作。
解耦 Python 堆栈与 C 堆栈
为了提供上述保证并确保之前可以工作的任何程序继续工作,需要将 Python 堆栈和 C 堆栈分离。也就是说,从 Python 函数到 Python 函数的调用不应消耗 C 堆栈空间。对内置函数的调用将继续消耗 C 堆栈空间。
C 堆栈的大小将由实现定义,并且可能因机器而异。它甚至可能在不同线程之间有所不同。然而,预期任何能够以之前默认值设置的递归限制运行的代码将继续运行。
Python 中的许多操作在 C 级别执行某种形式的调用。其中大多数将继续消耗 C 堆栈,并且如果发生不受控制的递归,将导致 StackOverflow 异常。
其他实现
其他实现被要求无论递归限制设置为何值,都能安全地失败。
如果实现耦合了 Python 堆栈与底层 VM 或硬件堆栈,那么当递归限制被超过但底层堆栈未溢出时,它应该引发 RecursionOverflow 异常。如果底层堆栈溢出或接近溢出,则应引发 StackOverflow 异常。
C-API
将添加一个名为 Py_CheckStackDepth() 的新函数,并对 Py_EnterRecursiveCall() 的行为进行轻微修改。
Py_CheckStackDepth()
int Py_CheckStackDepth(const char *where) 将在没有 C 堆栈溢出即时危险时返回 0。如果 C 堆栈接近溢出,它将返回 -1 并设置一个异常。where 参数用于异常消息,其方式与 Py_EnterRecursiveCall() 的 where 参数相同。
Py_EnterRecursiveCall()
Py_EnterRecursiveCall() 将被修改为在执行当前功能之前调用 Py_CheckStackDepth()。
PyLeaveRecursiveCall()
Py_LeaveRecursiveCall() 将保持不变。
向后兼容性
此功能在 Python 级别完全向后兼容。一些低级工具,例如机器代码调试器,将需要进行修改。例如,Python 的 gdb 脚本将需要意识到可能存在每个 C 帧有多个 Python 帧。
使用 Py_EnterRecursiveCall() 和 PyLeaveRecursiveCall() 函数对的 C 代码将继续正常工作。此外,Py_EnterRecursiveCall() 可能会引发 StackOverflow 异常。
新代码应使用 Py_CheckStackDepth() 函数,除非代码希望它被计为 Python 函数调用,与递归限制相关。
我们建议“类似 Python”的代码,如 Cython 生成的函数,使用 Py_EnterRecursiveCall(),但其他代码使用 Py_CheckStackDepth()。
安全隐患
将不再可能通过递归来使 CPython 虚拟机崩溃。
性能影响
性能影响不太可能显著。
所需的额外逻辑可能会对性能产生非常小的负面影响。由于 C 堆栈使用减少而带来的改进的引用局部性将产生一些小的正面影响。
很难预测整体效果是正面还是负面,但很可能净效应小到无法测量。
实施
监控 C 堆栈消耗
要判断 C 堆栈溢出是否即将发生是困难的。因此,我们需要保守。我们需要确定堆栈的安全边界,这在可移植的 C 代码中是不可能实现的。
对于主要平台,将使用特定于平台的 API 来提供准确的堆栈边界。然而,对于次要平台,可能需要一些猜测。虽然这听起来不好,但它不比当前的情况更糟,在当前情况下,我们猜测 C 堆栈的大小至少是 _PyEval_EvalFrameDefault 到 _PyEval_EvalFrameDefault 的调用链所需的堆栈空间的 1000 倍。
这意味着在某些情况下,可能的递归量可能会减少。然而,总的来说,可能的递归量应该会增加,因为许多调用将不使用 C 堆栈。
我们确定 C 堆栈限制的通用方法是,尽早获取调用链中的一个 C 帧内的地址。然后可以通过向该地址添加一个常数来猜测限制。
在不消耗 C 堆栈的情况下进行 Python 到 Python 的调用
解释器中的调用由 CALL_FUNCTION、CALL_FUNCTION_KW、CALL_FUNCTION_EX 和 CALL_METHOD 指令处理。这些指令的代码将被修改,以便当调用 Python 函数或方法时,解释器不会在 C 中进行调用,而是会设置被调用者的帧并像往常一样继续解释。
RETURN_VALUE 指令将执行相反的操作,除非当前帧是解释器的入口帧,此时它将正常返回。
被拒绝的想法
尚未发现。
未解决的问题
尚未发现。
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0651.rst