ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

monty-alloc 源码深度解析:用 Rust 全局分配器为 Monty 沙箱会话计量并限制内存

monty-alloc 源码深度解析:用 Rust 全局分配器为 Monty 沙箱会话计量并限制内存 monty-alloc 源码深度解析用 Rust 全局分配器为 Monty 沙箱会话计量并限制内存【免费下载链接】montyA minimal, secure Python interpreter written in Rust for use by AI项目地址: https://gitcode.com/GitHub_Trending/monty3/montyMonty 是一个用 Rust 编写的、面向 AI 场景的安全 Python 解释器仓库根目录说明见 README.md它执行的是不可信 Python 代码宿主必须有能力封顶单个会话可分配的内存。monty-alloc正是这条防线的最底层它以进程级全局分配器的身份统计 worker 向系统申请过的每一个存活字节同时对照会话的软/硬内存上限并在硬上限被突破或系统分配器拒绝请求时以可分类的方式结束进程。读完本文你将掌握 Monty 的内存限制架构、LimitedAllocator的计数与终止机制、set_limit的完整语义以及exit-code特性在 native worker 与 wasm 模块下的差异。为什么内存限制必须落在分配器上Monty 执行的是不可信代码因此宿主必须能够控制一个会话到底能向进程要多少内存。把这条约束放在全局分配器上意味着进程内无论从哪里发起的内存请求解释器堆、字节码对象、异常栈、内置函数临时缓冲……都会被同一个计数器捕获——monty-alloc的 crate 描述正是 Global allocator enforcing a Monty workers hard memory ceiling见 Cargo.toml。与之相对让内核用RLIMIT_AS限制虚拟地址空间的做法存在两个问题不可移植RLIMIT_AS是 POSIX 概念wasm 等目标上并不存在单位失真它限制的是虚拟地址空间而不是进程请求了多少字节于是被映射的二进制镜像mapped text、线程栈、文件映射都会白白消耗预算。monty-alloc改用计数它只统计经过这个分配器的请求字节数因此多少钱花在了 Python 数据上一目了然且跨平台语义一致。代价是它只约束能走到这个分配器的路径——沙箱代码能引发的所有分配都在其中但绕过分配器的直接mmap不在其列该边界在 limitations/resource_limits.md 中有专门说明它不是内核强制的进程内存上限ulimit -v或 cgroup 仍然需要单独配置、且可以叠加生效。软限制与硬限制两级防线monty-alloc维护两个静态原子上限初始值均为usize::MAX即不限见 src/lib.rsstatic SOFT_LIMIT: AtomicUsize AtomicUsize::new(usize::MAX); static HARD_LIMIT: AtomicUsize AtomicUsize::new(usize::MAX);二者的分工如下限制计算方式触发后果软限制soft limitworker 基线 会话预算解释器在执行检查点读到实时用量并抛出终端MemoryError沙箱内不可捕获硬限制hard limit软限制 固定头部空间headroom直接结束进程native 下以专属退出码退出或 abortwasm 下触发 trap软限制的意义是给 Python 一个体面的失败方式超过它时解释器能走到自己的检查点把未完成操作解卷unwind并上报MemoryErrorworker 与会话得以存活。而硬限制则是为检查点之间的突发分配预留的兜底空间——它还要容纳异常机制异常对象、traceback本身的分配。默认头部空间BASE_HEADROOM 4 * 1024 * 10244 MiB类型检查场景因为其 stub 与缓存分配发生在 Python 执行之外头部空间放大到TYPE_CHECK_HEADROOM 32 * 1024 * 102432 MiB。换句话说检查点之间的分配最多能超支这么多一旦跨越硬天花板分配必须立即停止无论软限制是否已超。各结果如何上浮到宿主见 资源限制文档 的 Exceedingmax_memoryin a worker 一节。安装#[global_allocator]声明monty-alloc的核心是一个实现了GlobalAlloc的零大小类型LimitedAllocator。要让它生效最终可执行文件必须在 binary 或 wasm module 的顶层声明它#[global_allocator] static ALLOC: monty_alloc::LimitedAllocator monty_alloc::LimitedAllocator; // After each request, from the session the worker now holds. monty_alloc::set_limit(Some(8 * 1024 * 1024), false).unwrap();仓库中的真实声明点包括monty-runtime/src/main.rs ——monty二进制本身subprocess与 CLI 模式共用monty-wasm-runtime/src/lib.rs —— wasm 运行时的实例monty-bench/benches/main.rs —— 基准测试程序。为什么必须是二进制或 wasm 模块在 native cdylib动态库里声明#[global_allocator]会劫持宿主进程的分配器因此monty-alloc只允许在进程边界上安装。这一点也有测试背书tests/not_installed.rs 验证了仅仅链接monty-alloc并不声称计量内存——在没有安装LimitedAllocator的程序里调用set_limit会返回错误#[test] fn set_limit_rejects_the_default_allocator() { let error monty_alloc::set_limit(Some(1024), false).unwrap_err(); assert_eq!( error.to_string(), monty-alloc is not installed as the global allocator ); }这里的实现机制是LIVE_MEMORY计数器由全局分配器写入未安装时它恒为 0set_limit据此拒绝武装从而避免静默不生效的陷阱。武装set_limit参数与基线语义set_limit的签名与核心逻辑如下src/lib.rspub fn set_limit(max_memory: Optionusize, type_check: bool) - Result(), static str参数语义max_memory: Optionusize会话的内存预算字节。Some(bytes)表示软限制 基线 bytesNone表示同时解除软、硬两个限制双双恢复到usize::MAX。type_check: bool是否为类型检查场景。为true时使用更大的 32 MiB 头部空间否则用默认的 4 MiB。基线baseline的获取方式软限制并不是进程当前用量 预算而是worker 最瘦时的用量 预算let live LIVE_MEMORY.load(Ordering::Relaxed); let baseline BASELINE_MEMORY.fetch_min(live, Ordering::Relaxed).min(live);BASELINE_MEMORY定义于 monty-types/src/resource.rs初始usize::MAX记录的是进程在武装点位上最省内存的历史最低值——即worker 存在本身要花多少钱而没有任何会话在跑。fetch_min既读取又降低基线首次武装pristine worker设定它此后某个更瘦的时刻只会让它更好。一个 worker 会服务多次 checkout每次会话都从这个固定基线重新推导上限因此会话之间残留的内存消耗的是头部空间而非抬高上限残留超过上限的 worker 会被杀死并替换而不是无限增长详见 limitations/resource_limits.md 的 Per session, but against a fixed baseline。发布顺序先硬后软HARD_LIMIT.store(hard, Ordering::Relaxed); SOFT_LIMIT.store(soft, Ordering::Relaxed);代码先发布保护性天花板再降低软检查点避免窗口期内出现软限制已生效而硬限制尚未更新的中间状态。为什么要每次请求后都调用set_limit因为会话可能通过恢复 dump、reset 或全新 checkout 到达/结束每次请求都重新武装才能确保当前会话的预算正确。中途重复调用是无害的 no-opbaseline 只会被fetch_min降低。底层实现LimitedAllocator的四个分配入口LimitedAllocator是一个单元结构体pub struct LimitedAllocator;它把System系统分配器的请求原样转发但在两侧加上记账与空指针检查。安全文档明确说明每个方法都把参数原封不动转发给System并返回其结果或发散不伪造、别名或释放任何指针因此完全继承System的不变量见 src/lib.rs 的 SAFETY 注释。四个入口的要点alloc先charge(layout.size())再调System.alloc返回空指针系统拒绝时走out_of_memory。dealloc先refund(layout.size())再调System.dealloc——注意这里必须精确配对计数从进程启动就武装绝不能在某个时机才启动计数器否则会退回去计量从未计费过的dealloc导致计数下溢。alloc_zeroed重写而非走默认默认会路由到alloc这样System可以继续使用 calloc 的预清零页。realloc重写而非走默认默认是重新分配并拷贝这样System往往能在原地扩块。差额记账用checked_sub区分扩容charge与缩容refund同样检查返回空指针。计数的两条核心路径#[inline] fn charge(size: usize) { let live LIVE_MEMORY.fetch_add(size, Ordering::Relaxed).saturating_add(size); if live SOFT_LIMIT.load(Ordering::Relaxed) live HARD_LIMIT.load(Ordering::Relaxed) { out_of_memory(format_args!( monty worker: allocation of {size} bytes exceeds the memory limit )); } } #[inline] fn refund(size: usize) { LIVE_MEMORY.fetch_sub(size, Ordering::Relaxed); }charge是热路径先与软限制比较常用路径只有同时越过软、硬两个限制才立即终止——也就是说处于软与硬之间的分配会放行等解释器在下一个检查点抛MemoryError。LIVE_MEMORY定义于 monty-types/src/resource.rs是所有分配累计的存活字节计数。全程使用Ordering::Relaxed这个计数最终正确即可它不用于发布任何其他内存。与之配套的probe_memory()计算LIVE_MEMORY - BASELINE_MEMORY正是解释器检查点读取的会话实时用量。值得注意的一点是计数单位它统计的是请求字节而非常驻字节。每次分配的元数据开销与碎片化位于计数与真实 footprint 之间因此 RSS 会略高于上限——这是计数型限制的固有属性limitations/resource_limits.md 明确指出了这一点。终止进程为什么不能 panic也不能普通 abort超过硬限制不可能抛 Python 异常——失败发生在解释器之下没有 Python 级异常可用所以 worker 只能死掉、由宿主换一个新的。那么为什么不能 panic 或普通 abortpanic 不可行panic 机制本身要分配内存构造 panic 消息、展开栈在内存耗尽时这会导致递归失败普通 abort 不可行SIGABRT也正是栈溢出产生的信号。如果宿主无法区分内存耗尽与栈溢出就无法正确上报MemoryError。exit-code特性决定死法Cargo.toml中的特性开关[features] # Report a breach by exiting with monty_types::OOM_EXIT_CODE rather than # aborting. For a worker whose host reads a process exit status and classifies # it (the subprocess pool); without it a breach aborts, which is all a wasm # module can do — see the crate docs. exit-code []exit-codeonprocess::exit(monty_types::OOM_EXIT_CODE)。OOM_EXIT_CODE 65取自 BSDsysexits.h的EX_DATAERR见 monty-types/src/resource.rs是父进程读取以分类死亡原因的专属状态码。用于monty subprocessworker其父进程是monty-poolcrate 链接见 Cargo.toml 描述pool 架构见 limitations/pool-architecture.md。exit-codeoff默认process::abort()在 wasm 上表现为 trap。wasm 模块没有可提供的退出状态而其宿主本来就把没有终止事件的轮次视为死实例。out_of_memory的实现细节#[cold] #[inline(never)] fn out_of_memory(reason: fmt::Arguments_) - ! { static REPORTING: AtomicBool AtomicBool::new(false); // 先解除限制——写 stderr 会分配句柄锁在超限状态下会递归进入并丢失消息 SOFT_LIMIT.store(usize::MAX, Ordering::Relaxed); HARD_LIMIT.store(usize::MAX, Ordering::Relaxed); if !REPORTING.swap(true, Ordering::Relaxed) { let _ writeln!(io::stderr(), {reason}); } #[cfg(feature exit-code)] process::exit(OOM_EXIT_CODE); #[cfg(not(feature exit-code))] process::abort(); }几个值得注意的设计写 stderr 之前先解除限制因为写 stderr 本身要分配句柄锁在超限状态下会重新进入charge从而被静默REPORTING原子标志保证真正内存耗尽的宿主连这条 stderr 都写不出来时不会无限重入第一个调用者负责写重入者直接走向终点跳过析构器可能在传输层留下不完整帧而这本来就被宿主视为worker 已死。monty-alloc的 crate 文档还说明任何分配被拒绝宿主 OOM或请求超出可用地址空间例如 * (1 60)都走这条同一路径因为失败发生在解释器之下CPython 那种进程内可捕获的MemoryError在这里不可能出现详见 limitations/resource_limits.md。防患于未然大结果预检查等到检查点再失败只适用于普通路径。对于结果大小可由输入简单推算的操作解释器在分配之前就做预检查pre-check避免像2 ** 10_000_000这样在检查点到达前就撑爆内存的操作。LARGE_RESULT_THRESHOLD 100_000100 KB定义于 monty-types/src/resource.rs之上的估算结果会对照剩余预算提前拒绝涉及整数乘法、左移、整数幂、序列重复x * n、str/bytes.replace、填充ljust/center/zfill、f-string 动态宽度/精度等bigint.pow以bits(base) * exp再乘 4 倍安全系数估算覆盖平方重复的中间值。检查入口check_allocation/check_large_result位于 monty-types/src/resource.rs 的ResourceTracker中而分配器硬限制则作为最后兜底。从 CLI 到分配器一条完整的调用链以独立 CLI 为例monty二进制启动时的链路是monty-runtime/src/main.rs 声明#[global_allocator] static ALLOC: monty_alloc::LimitedAllocator并解析--max-memory支持1024、512KB、10MB、1GB等单位后缀见parse_memory_sizemonty-runtime/src/run.rs 中构造ResourceLimits后调用monty_alloc::set_limit(limits.max_memory, type_check.is_some()) .expect(monty-runtime must install LimitedAllocator globally);会话执行期间ResourceTracker::check_memory_time读取probe_memory()即LIVE_MEMORY - BASELINE_MEMORY越过软限制即在检查点返回ResourceError::Memory由 VM 上浮为MemoryError若在检查点之间撞上硬限制则charge直接走out_of_memory结束进程。同样的模式也出现在子进程路径monty-runtime/src/subprocess.rs 对set_limit(budget.max_memory, budget.type_check)的调用与 wasm 运行时monty-wasm-runtime/src/lib.rs 中安装分配器并检查set_limit的返回值。需要注意的是ResourceLimits::max_memory的文档明确要求必须安装并武装monty-alloc否则该限制静默不生效见 monty-types/src/resource.rs。边界与限制何时它不生效只对经过全局分配器的请求生效线程栈、二进制自身映射镜像、直接mmap均不在计数之内它是沙箱可引发的所有分配的边界不是内核级进程内存上限。只在 binary / wasm module 中可安装native cdylib 会劫持宿主分配器被禁止。32 位目标上的饱和问题在 32 位目标如 wasm32上接近 4 GiB 的限制会让算术饱和worker 实际上处于无上限状态——没有可表达的上限src/lib.rs 与 limitations/resource_limits.md 均指出wasm worker 的usize是 32 位的。wasm 无法分类硬性违约软违约是正常的MemoryError硬违约则 trap 实例宿主上报MontyCrashedError。仅限本 workspace 内部使用monty-alloc发布出来是为了让monty二进制能够发布并不面向直接使用。小结monty-alloc用最朴素的手段解决了沙箱化最棘手的问题之一它把内存上限下沉到进程内每一个分配的必经之路上以LIVE_MEMORY存活字节计数配合软/硬两级阈值实现了软限制给解释器一个抛MemoryError的机会、硬限制兜住检查点之间的突发、专属退出码让宿主能区分内存耗尽与栈溢出。对想在自己的宿主进程中嵌入 Monty、或希望深入理解沙箱内存计量的读者crates/monty-alloc/src/lib.rs 的约 160 行代码是极好的阅读起点而 limitations/resource_limits.md 则完整列出了所有边界情形与宿主视角的行为。【免费下载链接】montyA minimal, secure Python interpreter written in Rust for use by AI项目地址: https://gitcode.com/GitHub_Trending/monty3/monty创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进