PEP 651 – 稳健的堆栈溢出处理
- 作者:
- Mark Shannon <mark at hotpy.org>
- 状态:
- 已拒绝
- 类型:
- 标准轨迹
- 创建:
- 2021-01-18
- 历史记录:
- 2021-01-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
最后修改时间:2023-09-09 17:39:29 GMT