Monty 资源限制机制详解:内存、时间、递归与挂起的多层防护体系
2026/9/16 11:25:18 网站建设 项目流程

Monty 资源限制机制详解:内存、时间、递归与挂起的多层防护体系

【免费下载链接】montyA minimal, secure Python interpreter written in Rust for use by AI项目地址: https://gitcode.com/GitHub_Trending/monty3/monty

Monty 是一个用 Rust 编写的极简、安全的 Python 解释器,专为 AI 场景下的沙箱执行而设计。本篇文章以仓库 limitations/resource_limits.md 为骨架,系统讲解 Monty 对不可信代码的四类资源约束——内存(max_memory)、执行时间(max_duration)、递归深度(max_recursion_depth)与主机挂起次数(max_suspensions),并结合 docs/resource-limits.md 的宿主侧配置说明、crates/monty-types/src/resource.rs 的ResourceTracker实现、crates/monty/src/resource_checks.rs 的预检逻辑以及 crates/monty-alloc/src/lib.rs 的分配器实现,说明每项限制的配置方法、底层原理、边界行为与失效模式。读完本文,你将掌握如何为 Monty 会话配置安全的资源预算、理解软/硬内存双上限的运作机制,以及在资源限制触发后如何正确处理会话。

总览:谁限制什么

Monty 与宿主(host)在资源管控上分工明确:

  • 解释器侧限制内存、执行时间和递归深度;
  • 宿主侧限制挂起(suspension)事件数量。

当超出限制时,返回值分别是:

超出限制宿主可见异常沙箱内可否捕获
max_memoryMemoryError
max_durationTimeoutError
max_suspensionsRuntimeError
max_recursion_depthRecursionError是(与 CPython 一致)

这套映射关系在 crates/monty/src/resource_checks.rs 的From<ResourceError> for RunError实现中固化:Memory映射为不可捕获的MemoryErrorTime映射为不可捕获的TimeoutErrorRecursion映射为可捕获的RecursionError。其依据是 crates/monty-types/src/resource.rs 中ResourceError的注释:除Recursion外的所有变体在沙箱内都不可捕获,因为不可信代码绝不能拦截资源强制手段。

五个可配置项的语义与默认值如下(出自 docs/resource-limits.md 的“The five settings”一节):

含义可否禁用
max_memory最大堆内存(字节)可(省略或None
max_duration_secs累计执行时间(秒)可(省略或None
max_recursion_depth函数调用栈最大深度(默认 1000)不可
gc_interval每 N 次分配运行一次垃圾回收可(默认内置每 100,000 次分配,回收无法关闭)
max_suspensions每次会话允许的宿主往返次数(默认 1000)不可

在 JavaScript 中对应字段为maxMemorymaxDurationSecsmaxRecursionDepthgcIntervalmaxSuspensions;在 Rust 中则是 crates/monty-types/src/resource.rs 定义的ResourceLimits结构体字段(max_durationDuration类型)。ResourceLimits::default()返回“仅递归+挂起有界”的默认配置:max_durationmax_memorygc_interval均为Nonemax_recursion_depthmax_suspensions均为DEFAULT_MAX_RECURSION_DEPTH/DEFAULT_MAX_SUSPENSIONS,即 1000。

之所以递归和挂起不能禁用,源码注释给出了原因:无界的递归会让沙箱代码撑爆原生栈从而 abort 进程;无界的挂起会让代码在max_duration暂停期间无限循环调用宿主(见 crates/monty-types/src/resource.rs)。

宿主侧配置示例

Python 侧(pydantic_monty)通过pool.checkout(limits=...)传入限制:

from pydantic_monty import Monty, MontyRuntimeError limits = { 'max_memory': 10_000_000, 'max_duration_secs': 1.0, 'max_recursion_depth': 100, } with Monty() as pool: with pool.checkout(limits=limits) as session: try: session.feed_run('x = [0] * 100_000_000') except MontyRuntimeError as exc: print(exc.display(format='type-msg').split(':')[0]) #> MemoryError

TypeScript 侧(@pydantic/monty)写法对称:

import { Monty, MontyRuntimeError } from '@pydantic/monty' const limits = { maxMemory: 10_000_000, maxDurationSecs: 1, maxRecursionDepth: 100, } await using pool = await Monty.create() await using session = await pool.checkout({ limits }) try { await session.feedRun('x = [0] * 100_000_000') } catch (err) { if (!(err instanceof MontyRuntimeError)) throw err console.log(err.display('type-msg').split(':')[0]) // MemoryError }

注意示例中'x' = [0] * 100_000_000触发的正是预检机制:结果大小可预测,因此在实际分配前就被拒绝并抛出MemoryError

编译期:不占时间预算,但有结构上限

max_durationVM 开始执行那一刻起计,因此解析(parsing)、预处理(preparation)与字节码编译(bytecode compilation)都不消耗时间预算。但在 worker 场景下,编译产物被保留的分配会计入max_memory;瞬态编译分配会在执行到达第一个内存检查点前被释放。

编译阶段有自己的结构上限(structural caps),独立于执行期限制:

  • 解析器嵌套深度(AST 嵌套 200 层);
  • 字节码操作数大小;
  • 推导式(comprehension)嵌套深度;
  • 重复finally展开:一个代码对象若需要超过1,024finally体副本,会被以SyntaxError拒绝——CPython 没有对应的等价限制。

正因如此,docs/resource-limits.md 提醒:接收不可信源码的生产宿主仍应隔离编译过程,就像子进程与 WebAssembly 运行时所做的那样。这一点与 docs/limitations/pool-architecture.md 中“worker 与宿主之间通过 protobuf 协议通信、崩溃只波及 worker”的架构一脉相承——编译期无法完全防住的栈溢出与分配器 abort 只杀死 worker,而不影响宿主进程。

内存限制:分配器计数的软上限与硬顶

计数口径:全局分配器

内存用量由worker 进程的全局分配器度量,而配置的预算属于单个会话。Worker 统计从其全局分配器请求的每一个字节。

  • 直接使用 Rust 的用户必须把monty-alloc安装为全局分配器,并在使用max_memory前通过set_limit武装它;否则用量恒读为 0,限制被静默地不执行。
  • 它绑定的是 worker 的分配器,而非进程:只统计经 Rust 全局分配器请求的字节——即沙箱代码能导致分配的一切——但不包括线程栈、二进制自身的映射镜像或直接mmap获得的内存。它不是内核强制的进程内存上限;ulimit -v或 cgroup 限制才是对应工具,且二者独立生效:内核拒绝分配时,worker 报告同样的MemoryError
  • 它统计的是请求的字节而非驻留字节:每次分配的开销与碎片位于计数与进程真实占用之间,因此 RSS 会略高于限制值。

在实现层面,crates/monty-alloc/src/lib.rs 用LIVE_MEMORYBASELINE_MEMORY两个原子变量维护“活动字节数”与“进程最瘦基线”:charge()在每次alloc/realloc时累加请求大小,refund()dealloc时归还(crates/monty-alloc/src/lib.rs)。set_limit()BASELINE_MEMORY.fetch_min(live)取进程曾达到的最瘦值作为基线,再叠加会话预算得到软上限(crates/monty-alloc/src/lib.rs)。probe_memory()读取LIVE_MEMORY - BASELINE_MEMORY(crates/monty-types/src/resource.rs),这正是解释器在执行检查点读取的当前用量。

软限制:解释器检查点

配置的限制是软性的。解释器在执行检查点读取当前分配器用量,越过后向宿主报告终止性MemoryError;未完成的操作被回卷(unwound),worker 与会话存活,但沙箱内 Python 无法捕获资源错误。

硬顶:突发仍可能杀死 worker

在配置限制之上还有一道硬顶(hard ceiling),为异常与回溯机制留出运行空间。两道限制之间的突发分配会以专属 OOM 退出状态结束子进程(或在 wasm 上触发 trap),池会替换 worker、会话丢失。结果大小已知的大操作通过预检规避此路径。

在代码中,软/硬两个上限由SOFT_LIMITHARD_LIMIT承载:charge()只有在live > SOFT_LIMIT && live > HARD_LIMIT时才直接触发out_of_memory(crates/monty-alloc/src/lib.rs)。硬顶 = 基线 + 软限 + 固定余量:常规为 4 MiB(BASE_HEADROOM),启用类型检查时为 32 MiB(TYPE_CHECK_HEADROOM,因为其存根与缓存分配在执行之外)。OOM_EXIT_CODE = 65取自 BSDsysexits.hEX_DATAERR,使裸退出码在日志中可读。

Python 执行之外的路径:只有硬顶

请求组帧(request framing)、输入解码、加载快照与类型检查都不会到达解释器检查点。这些路径上一次足够大的分配就可能越过硬顶杀死 worker。

跨边界值的“三份拷贝”问题

一个值跨界到宿主需要约三份自身的空间:返回结果(或传给宿主函数的参数)会同时持有沙箱值、其宿主侧转换形态以及编码帧。因此单个值的有效上限约为max_memory的三分之一——对普通负载远低于限制,但在紧预算下,数 MiB 的参数可能在宣布调用时就越过硬顶。

其他内存边界行为

  • max_memory单独无法约束 worker 内存:硬顶包含 worker 基线加软限之上的固定余量(几 MiB,类型检查时更多)。用max_processes与 OS 级限制来约束宿主。
  • 按会话计,但基于固定基线:一个 worker 服务多次 checkout,每次会话都从进程曾达到的最瘦状态重新推导上限。会话之间保留的内存消耗的是余量而非抬高上限;残留超出者被杀死并替换,而不是无限增长。
  • 恢复 dump 受其落地的 checkout 限制load_session/load_snapshot会恢复 dump 自身的限制(见 docs/limitations/pool-architecture.md),会话存在后上限从它们重新推导,但加载本身在checkout()配置应用的限制下运行。把大 dump 恢复到max_memory小得多的 checkout 中,加载时可能超出;应向checkout()传入相当的限制。
  • wasm worker 无法分类硬性越界:软性越界是正常MemoryError,但超出硬顶会 trap 实例,宿主报告MontyCrashedError。其usize为 32 位,接近 4 GiB 的限制会让模块失去上限。
  • WebSocket worker 完全没有分配器强制限制:它们是本池不派生的远程进程。

分配器拒绝:底层失败如何呈现

独立于任何限制,只要 worker 的分配器拒绝任何分配——普通宿主 OOM,或超出可用地址空间的请求如' ' * (1 << 60)——都会走同一条路径:在有退出状态的 worker 上,宿主看到MemoryError且会话消失;在 wasm 上同样的拒绝触发 trap,报告为MontyCrashedError。CPython 会在进程内抛出可捕获的MemoryError并继续运行,Monty 做不到:失败发生在解释器之下,那里无法抛出任何 Python 级异常,因此 worker 将失败归类为专属退出码然后终止。若不这样做,进程将以SIGABRT中止,而它与栈溢出无法区分(docs/limitations/pool-architecture.md 对此有同样的阐述:SIGABRT由栈溢出与分配器 abort 共同产生,不可分类)。

大结果预检:在分配之前拒绝

结果大小可由输入尺寸的简单算术界定的操作,在分配前会经过预检。涉及的操作为:整数乘法、左移、整数幂、序列重复('x' * n)、替换(str.replacebytes.replace)、re.sub、填充(str.ljuststr.centerstr.zfillbytes.ljust等)、整数除法与divmod、deque 旋转、切片与重复、把迭代器物化为容器,以及带动态宽度或精度的字符串格式化——包括 f-string(f"{v:>{w}}"f"{v:.{p}f}")与str.format()"{0:>{1}}".format(v, w)"{0:.{1}f}".format(v, p))。

预检阈值为100 KB:估算超过该值的操作才会对照剩余预算检查,超出即在实际分配前以MemoryError拒绝。因此'x' * 10**12会立即失败,而不是耗尽机器内存后才失败。

阈值常量LARGE_RESULT_THRESHOLD = 100_000定义于 crates/monty-types/src/resource.rs,其文档注释明确:这是防止2 ** 10_000_000这类操作在内存检查捕获前分配海量内存的 DoS 防护。核心入口是 crates/monty/src/resource_checks.rs 的check_estimated_size()——仅当估算超过阈值时才调用tracker.check_large_result(),避免小操作的开销。

各操作的具体估算逻辑(同样在 crates/monty/src/resource_checks.rs):

  • 整数乘法:结果最多a_bits + b_bits位(check_mult_size);
  • 左移:结果约value_bits + shift位,零值直接跳过(check_lshift_size);
  • 整数除法:结果受被除数大小约束(check_div_size);
  • 序列重复item_len * count字节(check_repeat_size,饱和乘法防溢出);
  • 替换:扩大型替换按最大可能匹配数估算,缩小型按input_len估算(零匹配也会复制完整输入),空模式特殊处理为input_len + 1check_replace_size);
  • bigint.pow(base, exp):以bits(base) * exp估算结果大小,并带4× 安全乘数覆盖重复平方的中间值——因为BigInt::pow使用重复平方,乘法每一步新旧 base 与新旧累加器并存,峰值约需最终结果 4 倍内存(check_pow_size,见 crates/monty/src/resource_checks.rs)。

位转字节时使用饱和运算,溢出意味着结果天文数字般巨大,饱和确保限制检查必然触发而非被静默跳过(crates/monty/src/resource_checks.rs)。fstring 侧还有一个配套细节:大容量格式化会延迟到format_builder并逐片复制,使截止时间在复制期间被轮询(crates/monty/src/fstring.rs)。

整数专属上限

max_memory无关,若干整数操作自带硬性上限:

  • pow(base, exp)/base ** exp的指数大于u32::MAX(约 4.3 × 10⁹)时抛出OverflowError: "exponent too large",但基数 0、1 与 -1 例外,会被正常计算(结果恒为小值)。
  • pow(base, exp, mod)要求所有参数为整数,并拒绝负指数(ValueError)。
  • int(str_or_bytes, base)在有效基数不是 2 的幂时,会在可能二次方的 BigInt 解析前拒绝超过4,300 位的输入。这个固定上限与 CPython 的sys.int_info.default_max_str_digits一致。

递归限制:唯一可捕获的资源错误

  • Python 级调用深度默认1000 帧,第 1001 层嵌套调用抛出RecursionError。宿主通过max_recursion_depth按会话设置上限,但无法移除——与时间和内存限制不同,它没有“禁用”状态。
  • 生产沙箱代码不能修改递归限制。测试构建可能把sys.setrecursionlimit()暴露为仅用于降低限制的 fixture 钩子,它无法提高宿主配置的上限。实现上,ResourceTracker::lower_recursion_limittest-hooksfeature 下只能把活动上限调低,高于当前上限的请求会被拒绝(crates/monty-types/src/resource.rs)。
  • 异步栈会计入限制,但每个await边界算一帧,因此await链不会放大深度。
  • 解释器自身同步求值的回调在原生 Rust 调用栈上重入,而非普通函数调用使用的堆分配帧栈。包括:map()filter()sorted()/list.sort(key=...)min()/max(key=...)、递归__repr__/__str__、构造期间递归的非普通函数__init__值,以及调用functools.partial。原生重入以低于1000 帧 Python 限制的固定深度独立设限,因此 Monty 会在原生栈溢出 abort 进程之前抛出RecursionError。其主要用户可见差异见 docs/limitations/classes.md 中__repr__/__str__条目。

check_recursion_depth的实现(crates/monty-types/src/resource.rs)在压入新帧前检查:current_depth >= limit即失败,报告的深度为current_depth + 1

挂起限制:宿主的计数职责

max_suspensions限制会话向宿主挂起的次数:外部函数调用、宿主对象方法调用、属性查找与构造、OS 调用、名字查找,以及每次ResolveFutures往返(部分 future 解析后重新挂起会再次计数)。

  • 默认 1000,且不可禁用(同max_recursion_depth):省略或传None都保留默认值;合法宿主调用更多的会话应设置更大的数。
  • monty-poolpydantic_monty、JavaScript napi 池与 monty-server 强制执行它;wasm worker 池与 CLI 也执行。直接宿主必须自行计数并在超限时调用abort
  • 超预算的第一次挂起不会返回给调用者。宿主多用一次 worker 往返,在挂起点以回溯抛出不可捕获的RuntimeError: suspension limit N exceeded
  • 计数在 checkout 生命周期内持续存在。一旦耗尽,之后每次 feed 都在第一次挂起时结束。不挂起的 feed 仍可运行,堆保持一致,会话仍可 dump。
  • dump 中只携带限制本身。恢复的会话保留max_suspensions但计数归零;恢复 checkout 上配置的限制会封顶 dump 的(两者取较小者,若 worker 回复省略则只用配置值)。宿主对恢复会话“重新收养”的限制以 checkout 自身配置为上限,因为回复不可信(见 docs/limitations/pool-architecture.md)。
  • 沙箱内无法观测预算或剩余计数。

时间限制:累计执行时间与非抢占式轮询

宿主可设置max_duration预算;超限后 VM 在下一个检查点以ResourceError停止。

  • 执行是轮询而非抢占式:单条字节码指令可能运行很长的原生操作(bytes子串扫描、排序、迭代器排空),它们以较粗粒度轮询时钟。因此运行可能在停止前超出max_duration
  • 检查点是摊销而非逐指令:分发循环每 256 条指令读一次时钟;自行轮询的原生循环(迭代器推进、序列重复、比较、repr)每 64 项轮询一次(LOOP_CHECK_INTERVAL = 64,见 crates/monty-types/src/resource.rs)。这两者都是普通max_duration强制之外的无条件超出来源。
  • 每个宿主轮次返回时都重查两项限制,因此一个未到检查点就结束的轮次仍会失败而非返回结果。两个后果:轮次中 Python 代码抛出的异常会被资源错误取代;内部吞掉超时的操作(如repr...[timeout]截断)仍会使包含它的轮次失败。
  • bytes子序列搜索操作(用 bytes-like 探针的infindcountsplitpartitionreplace及变体)每 64 KiB 轮询一次时钟;若被搜索序列更长,则每两倍其长度轮询一次。因此搜索超过 64 KiB 的序列时,超出量与其长度成正比。
  • 相邻的无子序列扫描bytes操作不轮询,无论输入多大都运行到完成:整型探针的in(单字节扫描)与默认sep=None(空白切分)的split()/rsplit()
  • base64.a85decode()每 64 个不匹配 Ascii85 数字的字节轮询一次(即到达ignorechars时);每个这样的字节都是一次针对容器的in测试,因此大的显式ignorechars会造成与其长度成正比的超时。
  • 预算覆盖累计执行时间而非墙钟时间:时钟仅在解释器执行字节码时运行,在挂起等待宿主(外部函数调用、OS 回调)以及 REPL feed 之间暂停。它在会话生命周期内跨 feed 累积。宿主侧不会维护第二套时钟——worker 的时钟是唯一事实来源(见 docs/limitations/pool-architecture.md)。
  • 累计时间被序列化进 dump/快照,恢复的会话从离开处续用预算,而不是从零重启。
  • 沙箱内无法观测预算或剩余时间。

ResourceTracker的时间实现细节(crates/monty-types/src/resource.rs):on_execution_start/on_execution_stop界定执行窗口,running_since仅在窗口打开期间持有Instant,累计时间存入total_execution_time并被序列化。elapsed()返回累计值加进行中的窗口。wasm32-unknown-unknown 目标上标准库Instant::now()会 panic,因此该目标换用web_time::Instant(crates/monty-types/src/resource.rs)。

宿主侧的两个兜底

沙箱内检查运行于解释器检查点,无法捕获卡死解释器本身的代码。两个宿主侧兜底覆盖该场景(详见 docs/resource-limits.md):

  • request_timeout:池上的硬性单轮截止时间。worker 超时即被杀,调用抛出带timed_out=TrueMontyCrashedError。每次宿主函数或 mount 调用后的恢复都会开启新截止时间,因此频繁挂起的程序可以活过任何单次超时。
  • 时长兜底(duration backstop):对有max_duration_secs限制的会话,worker 在每个协议轮次报告执行时间,宿主在预算到期后宽限期杀死 worker。宽限期默认 1 秒;JavaScript 中是durationLimitGrace池选项(null禁用),Python 侧目前不可配置。

针对可能反复挂起的不可信代码应设置max_duration_secs;仅靠request_timeout无法约束整体调用。

JSON 与“未覆盖”领域

  • json.loads拒绝嵌套超过200 层的输入,抛出json.JSONDecodeError(独立于 Python 递归限制)。
  • 以下资源不在max_memory/max_duration覆盖内(docs/resource-limits.md 的“What is not covered”一节):
    • 编译时间:解析与字节码编译发生在 VM 存在之前,不计入时长预算;worker 中编译代码保留的内存计入max_memory。编译有自身的结构上限(见上文)。
    • 打印收集器CollectString/CollectStreams存活于宿主进程,其默认 10 MiB 上限与max_memory相互独立。
    • 挂载内存:每个 mount 有各自的memory_usage_limit,默认 100 MB,保留的 overlay 数据与瞬态结果共享该预算;CPython 没有等价的默认限制。超过 256 MiB 会重新暴露线上帧上限。
    • 宿主实例存储:每个送入会话的ClassInstance/ClassType包装(含嵌套包装、init=True构造与convert_value包装)在会话结束前保留在宿主进程,且不受沙箱max_memory覆盖;长时间运行的暴露对象方法的会话应限制分发量或定期回收会话。

终止性资源错误之后

软性内存或时间限制触发后,worker 仍保持响应,其会话还能接收下一次 feed,但执行不是事务性的,堆状态与引用计数不做任何保证——堆上可能残留引用计数错误的孤儿对象。宿主应当丢弃会话;worker 本身可复用。而可捕获的RecursionError是例外:它不会使任何东西失效,沙箱内可正常继续执行。

池不会替你收尾:checkout 保持打开并接受后续feed_run调用。由于max_duration_secs是累计预算,一旦耗尽,之后每个 feed 都会立即以同样的TimeoutError失败;max_memory触发后,后续 feed 可能在你已无法信任的堆上悄悄成功。结束会话是你的职责。这与 docs/limitations/pool-architecture.md 中“资源耗尽对会话是终止性的,但 worker 进程为下一个 checkout 复用”的表述一致。

参考与延伸阅读

  • 本文主体:limitations/resource_limits.md
  • 宿主侧配置总览(五设置、兜底与未覆盖领域):docs/resource-limits.md
  • ResourceTracker/ResourceLimits/ResourceError实现:crates/monty-types/src/resource.rs
  • 大结果预检实现:crates/monty/src/resource_checks.rs
  • 软/硬内存双上限与 OOM 退出码实现:crates/monty-alloc/src/lib.rs
  • Worker 执行模型与资源限制在池中的呈现:docs/limitations/pool-architecture.md
  • 时间限制的测试覆盖(执行时间与 GC 间隔):crates/monty/tests/resource_limits.rs

【免费下载链接】montyA minimal, secure Python interpreter written in Rust for use by AI项目地址: https://gitcode.com/GitHub_Trending/monty3/monty

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询