Aptos MonoMove 运行时堆内存与垃圾回收设计全解:两级内存管理、Bump 分配与 Cheney 复制式 GC
【免费下载链接】aptos-coreAptos is a layer 1 blockchain built to support the widespread use of blockchain through better technology and user experience.项目地址: https://gitcode.com/GitHub_Trending/ap/aptos-core
导读
本文以 Aptos 仓库内 mono-move 运行时设计文档 为主线,系统讲解 MonoMove 虚拟机在执行微指令(micro-ops)时的堆内存管理方案:BlockSTM 模型下的块级(block)/ 交易级(transaction)两级内存架构、以Bump 分配 + Cheney 复制式 GC为核心的单交易内存管理器、全局值(global value)跨交易共享与写隔离(Copy-on-Write)、以及 GC 安全所需的三条不变量与四种备选 GC 方案的对比取舍。读完本文,你将掌握 MonoMove 如何把"每条交易独立内存区 + 块级共享缓存"落为可并行执行的现实,理解frame_layout/safe_point_layouts双级指针槽扫描机制为何是"常见路径零开销"的关键,并能结合仓库源码 runtime/src/heap/mod.rs 印证每一个设计结论。
配套文档:栈与调用约定见 stack_and_calling_convention.md,堆上值布局见 value_representation.md,全局存储读写设计见 global_storage_design.md。
一、为什么需要专门的堆内存管理
在 MonoMove 的扁平化执行模型中,运行时的值(value)分为几类,各有不同的内存需求:
- 局部变量与中间结果:生命周期限于单条交易,通常驻留在栈帧 slot 中;
- 向量(vector):动态增长,必须上堆;
- 大型结构体 / 枚举:超出栈 slot 的固定容量时需要堆分配;
- 全局值(global values / resources):来自链上存储或其它交易写入,可能需要独立的内存区域。
而类型(types)、代码(code)与全局上下文(global context)的内存则属于另一套体系,由 loader 与全局上下文管理(见 main design doc 与 loader/DESIGN.md),不在本文范围。
MonoMove 围绕BlockSTM 执行模型组织内存,核心思想是:
- 块级(Block Level):管理整块(block)内所有交易共享的状态——即存储缓存(storage cache),并为每笔交易分配独立的内存子空间。
- 交易级(Transaction Level):每笔交易获得一块专属内存区,由自己的内存管理器负责。
注(设计文档原话为 TBD):未来可能希望某些数据跨块存活——块级缓存有可能被保留(而非丢弃)供后续块复用,尚未定论。
这种两级分离的价值在于:既能限制总内存用量(每交易有界、每块有界),又能支撑并行交易执行——交易间通过共享访问全局值进行协作,而不是各自为政。
二、块内存管理器(Block Memory Manager)
块级内存管理器承担两项职责:
- 存储缓存(Storage Cache):缓存从存储层加载的资源,块内所有交易共享。缓存内容是该资源在块开始时刻的状态快照。对于被高频访问的资源,这能避免重复的存储读取与重复的 BCS 反序列化。
- 交易内存分配(Transaction Memory Allocation):向各笔交易发放内存子空间。每笔交易拿到一块专属区域,随后由交易级内存管理器自行管理。
2.1 全局值如何跨交易共享
执行期间产生的临时值(局部变量、中间结果、新分配的 struct 与 vector)天然是交易私有的;但对全局值的写入必须对后续交易可见。文档给出了两条实现路线:
方案一:冻结即完成(Freeze-on-Finish)
交易结束后,将其内存空间冻结,并以只读方式暴露给后续交易。
- 优点:实现简单、不易出错,为读者提供一致的视图。
- 缺点:粒度粗,读写冲突被延迟检测。
在源码中,"冻结堆"已经具象化为FrozenHeap:它把已冻结的会话堆包装起来,不暴露任何 API,"持有者唯一能做的就是让堆保持存活"——这正是其它交易对其做只读 pin 所需的全部能力(见 runtime/src/heap/mod.rs)。FrozenHeap实现了ReadPintrait,与存储层懒加载使用的SharedArena(只追加、永不移动、永不回收的共享竞技场)一起,构成了"只读共享、指针永不过期"的支撑类型。
方案二:并发数据结构(Concurrent Data Structures)
在块级维护一个多版本数据结构,为全局值提供并发共享访问。
- 优点:读写冲突即时检测。
- 缺点:复杂度高——需要在块级另设一个共享可变子空间(类似存储缓存但可变);可能向读者暴露不一致视图;可能需要跨内存区拷贝数据;与 GC 管理内存交互不良(块级结构持有的引用在 GC 移动内存时必须被更新,而 freeze-on-finish 下冻结区不受 GC 影响,则无此问题)。
TODO(原文档):分析主网交易历史,理解真实的读写模式,以在两个方案之间做出取舍。
2.2 每块内存上限(Per-Block Memory Limits)
块级内存上限规定了节点在任意时刻为"值"所能占用的内存上界,这对资源规划与防止 OOM 至关重要。
为什么重要:当前节点配置为了低延迟,每块只容纳几十笔交易;但面向吞吐量的基准测试可能让每块跑几百上千笔交易。
假设默认上限为每交易 10 MB(仅计算值,不含代码与全局上下文数据):
- 1,000 笔交易 × 10 MB =10 GB 基准内存占用。
这已经很高,且多个因素会进一步推高内存:
- 内存冻结 + 重执行:正在重执行的交易可能需要两份内存空间——一份冻结(已完成的旧状态)、一份活动(投机执行的新状态);
- 垃圾回收:复制式 GC 需要额外的 "to-space",收集期间内存占用实际翻倍。
最坏情况下(所有因素叠加),峰值内存可达30–40 GB。典型使用远低于此,但必须按对抗性场景做规划。此类限制今天尚可接受,但随着 VM 执行速度提升,未来希望在不明显牺牲延迟的前提下把更多交易放进一块,这将成为可扩展性的隐忧。
缓解手段(原文档列举三项):
- 保守的初始分配:每笔交易从小额起步、按需增长(例如 1 MB → 4 MB → 10 MB)。高频典型交易在默认分配内即可舒适完成;高内存需求交易可以申请更多,但可能需要预先声明或支付显著的内存费(memory fees)。
- 硬性块级上限:在块级设上限,逼近上限时截断剩余交易。已有按块的气体上限(per-block gas limit)以类似方式工作。
- 冻结时压缩(Compact-on-freeze):冻结交易内存空间时,只保留全局值写入、丢弃临时值。这能显著缩小典型交易冻结区的占用,但对最大化全局值写入的恶意交易效果有限。注意:写集(write-set)生成本来就需要做这一步;该扫描可能也是计量气体(gas metering)所必需的。
三、交易内存管理器(Transaction Memory Manager)
每笔交易在自己的子空间内拥有独立的内存管理器,设计目标有二:
- 极速分配:分配在热路径上,必须是极低开销;
- 批量回收:按批次回收内存而非逐个对象——既在交易结束时(丢弃临时值),也在 GC 运行期间(如需)。
选定方案:Bump 分配 + 使用直接指针(direct pointers)的 Cheney 复制式 GC。
分配用 bump allocator(撞针分配器)保证速度;堆满时运行复制式收集器(Cheney 算法):借助每个函数携带的frame_layout(以及每个安全点携带的safe_point_layouts)遍历调用栈找到根,随后把所有可达对象广度优先复制进全新的 to-space,并在原地修正所有指针。这样既保留了 bump 分配的速度,又获得了交易中途回收内存的能力。
3.1 源码印证:Heap与分配路径
运行时 runtime/src/heap/mod.rs 中Heap结构体由三部分组成:
pub struct Heap { buffer: MemoryRegion, // 后备内存;每次 GC 换新 MemoryRegion(to_space 成为新 buffer) bump_ptr: *mut u8, // buffer 中下一个空闲字节;返回给调用者的对象指针 = bump_ptr + OBJECT_HEADER_SIZE gc_count: usize, // GC 运行次数,供测试/诊断 }- 内存来自 MemoryRegion:一块按
MAX_ALIGN对齐的、可零初始化(new_zeroed,适合"先读后写"的栈 slot)或非初始化(new_uninit,要求"先写后读",debug 构建下用0xAA毒化以暴露违规)的分配。OOM 通过handle_alloc_error中止。 - 分配核心
heap_alloc(见 runtime/src/heap/mod.rs):把total_size按MAX_ALIGN取整(带溢出保护),校验不超过MAX_SINGLE_ALLOCATION_SIZE(当前绑定DEFAULT_HEAP_SIZE),用整数地址比较判断bump_ptr + size是否仍在缓冲区范围内(避免形成越界裸指针的 UB),然后零初始化、写对象头、返回数据区指针。 - 单次分配上限
MAX_SINGLE_ALLOCATION_SIZE = DEFAULT_HEAP_SIZE(runtime/src/heap/mod.rs),而默认堆大小在 runtime/src/types.rs 中定义:DEFAULT_HEAP_SIZE = 10 * 1024 * 1024(10 MiB)——与文档中"默认每交易 10 MB"的假设完全吻合。 - 交易上下文通过 InterpreterOptions 暴露
heap_size配置项,默认值即DEFAULT_HEAP_SIZE,目前是编译期常量,未来可能改为每上下文可配置(很可能由气体上限驱动)。 - 分配失败时采用"先 GC 再重试"策略:
alloc_or_gc(runtime/src/heap/mod.rs)在OutOfHeapMemory时运行一次gc_collect后重试;仍失败则转为RuntimeError::OutOfHeapMemory。深度拷贝(deep_copy_or_gc)与 BCS 反序列化(deserialize_or_gc)走同样的"GC 一次 + 重试"模式,其中反序列化对应"从存储读取全局资源"的热路径。
其它分配入口还包括:alloc_vec/alloc_vec_no_gc(向量,容量按元素数计,长度字段默认 0)、alloc_enum_no_gc(按最宽变体分配并写 tag)、alloc_captured_data(闭包捕获区)、realloc_vec(向量扩容,采用摊销倍增:old_cap * 2,与"一次性大增长按required"取大)、grow_vec_ref(通过 fat pointer 引用原地扩容并回写新指针)。
3.2 对象头(Object Header)与负偏移布局
堆对象指针obj_ptr指向对象数据区的首字节;其前的 8 字节存放[descriptor_id: u32 | size: u32],即偏移 -8 与 -4(runtime/src/memory.rs)。当MAX_ALIGN > 8时,分配器在数据区前预留OBJECT_HEADER_SIZE = MAX_ALIGN字节,使数据区起始保持MAX_ALIGN对齐;descriptor_id + size始终位于该预留的最后 8 字节(紧邻数据区,利于缓存局部性,且负偏移在不同MAX_ALIGN下保持不变)。GC 复制对象时,gc_copy_object(runtime/src/heap/mod.rs)把[header | payload]整块搬到 to-space,并在 from-space 原对象数据区首 8 字节写转发指针(forwarding pointer)、把描述符字段改写为FORWARDED_MARKER——这是 Cheney 算法防重复复制的标准手法。
3.3 全局值的读写与写隔离
交易内存管理器还负责全局资源操作(move_from、move_to、borrow_global等)。配合 global_storage_design.md,其机制是:
读取全局值:值可能来自——块级存储缓存(块开始时的基值),或另一笔交易的写入/修改(若采用并发共享)。交易必须记录自己读过什么供后续校验(BlockSTM 需要检测读写冲突)。可能优化:不只记录值,还记录"读约束"(例如"检查过资源存在" vs "读取了实际内容")以实现更细粒度的冲突检测。一个悬而未决的问题是:读入是否需要把值拷贝进本地内存,还是可以直接引用源地址——这取决于内存管理器设计与共享方案。
修改全局值:交易修改全局资源时执行**写时复制(Copy-on-Write, CoW)**进入自己的内存子空间。这使所有修改相互隔离,从而:
- 支持交易中止或需要重执行时的回滚;
- 允许其它交易引用这些修改(本地内存持有交易写入的权威版本)。
在 BlockSTM 中,每笔交易的修改与MVHashMap集成——交易本地内存实际上成为多版本结构中的一个 slot,取代现有的Arc<Value>方案。实现细节上,交易把全局值读取缓存在working map(HashMap<(AccountAddress, InternedType), Entry>)中;Entry区分只读的ExternalHeap(指向其它交易竞技场或块缓存,只读零拷贝,携带 Block-STM 版本号)与写后的LocalHeap(CoW 后的本地副本),小资源走Inline优化直接拷贝字节。
两个悬而未决的问题(原文档):
- CoW 时机:应在
borrow_global_mut时急切执行,还是在真正写入时惰性执行?惰性 CoW 避免多余拷贝,但需要跟踪借用的引用来探测写入发生。 - GC 交互:若 GC 在交易中途运行,指向交易已修改值(为共享而由块级结构持有)的引用必须更新到移动后的地址。若采用 freeze-on-finish 则大概率无需担心——冻结内存不再参与后续 GC 运行。
四、内存安全(Memory Safety)
4.1 引用有效性(Reference Validity)
Move 字节码验证器对引用安全(无悬垂引用、正确的借用语义)提供静态保证;但运行时检查仍可作为纵深防御,抵御验证器 bug 或解释器错误。文档提出的候选运行时检查:
- 代际/世代计数器(Epoch/generation counters):每个分配的内存块携带世代号,引用携带期望世代,不匹配则访问失败——可捕获 use-after-free;
- 边界检查(Bounds checking):引用访问校验索引/偏移在界内。
这些检查的成本需要与安全收益权衡,实现成熟后可能在生产环境关闭。
4.2 内存区域隔离(Memory Region Isolation)
交易只应访问:
- 自己的本地内存区;
- 块级存储缓存(只读基值);
- 其它交易的冻结内存(若采用 freeze-on-finish 共享)。
违反即为解释器的严重 bug。这正是FrozenHeap设计的意义所在——冻结堆不暴露任何分配/改写 API,从类型层面杜绝了越权访问。
4.3 GC 安全:三条不变量
所有指针在 GC 移动内存后必须被更新;漏掉一个指针就会产生悬垂引用。运行时安全模型建立在三条不变量上(详见 runtime/AGENTS.md 与 gc_collect 的安全假设注释):
- 帧元数据完整性(Frame metadata integrity)——保存的
fp/pc/func_ptr只由 call/return 写入,用户微指令绝不触碰。GC 遍历调用栈时依赖META_SAVED_FP_OFFSET、META_SAVED_FUNC_PTR_OFFSET等固定偏移读取元数据。 - 指针槽准确性(Pointer-slot accuracy)——
Function::frame_layout(以及在安全点匹配的safe_point_layouts条目)必须精确匹配持有存活堆指针的槽位:漏项 → GC 后悬垂指针;多余项 → 非指针数据被当作指针(UB)。 - 对象头完整性(Object header integrity)——
descriptor_id与size位于固定负偏移(obj_ptr - 8与obj_ptr - 4),由分配器写入;用户微指令只访问数据区(≥ 0)偏移,永远够不到头部。
此外,执行前verify_program会校验帧访问边界、元数据重叠、跳转目标与描述符有效性(runtime/AGENTS.md)。
4.4 辅助 GC 根:RootPool
栈帧是主要根集,但某些场景需要在指针被存入帧布局描述的槽位之前就让它保持存活:
- 融合式多分配微指令(如
PackClosure):先分配对象 A,再分配对象 B——B 的分配可能触发 GC,而 A 尚未链接进帧; - 原生函数(未来):Rust 代码在可能触发 GC 的调用期间于局部变量中持有堆指针。
RootPool(core/src/root_pool.rs)即为此设计的句柄表式根集:调用者通过root_object(ptr)(或root_reference(base, offset))取得一个RAII 句柄,GC 除扫描调用栈外还扫描该池,对象搬移时原地改写被 root 的指针;句柄支持任意顺序 drop,槽位经 free list 复用。机制上与 JNI local refs 或 V8Local<T>同源。GC 阶段 1b 即执行extra_roots.relocate_each(|base| scanner.relocate(base))(见 gc_collect)。
4.5 类型安全(Type Safety)
扁平内存表示下,值就是按类型信息解释的原始字节,运行时须确保以正确的类型访问值。原文档将此列为 TBD:内存管理器还能为此风险做什么?
五、GC 设计空间:四种方案对比与最终取舍
文档对四种内存管理方案做了系统比较。所有方案均假设 bump-allocated 堆 + 复制式收集(或等价物)。最终实现是方案 A 与方案 B 的混合体。
5.1 方案 A:直接指针 + 安全点栈图(Direct Pointers + Stack Maps at Safe Points)
帧槽直接保存原始堆指针。重编译器(specializer)在每个 GC 安全点(分配点、调用返回点)发射栈图(stack map),列出哪些帧偏移持有存活堆指针。GC 扫栈图找根,再用对象描述符做传递式追踪(Cheney 复制收集器)。
- 优点:堆访问零开销(直接指针、单次加载);无过度保留(只有真正存活的指针是根);无每次写操作簿记。
- 缺点:重编译器必须在安全点做活跃性分析并发射栈图(控制流汇合点的 "maybe alive" 问题、空初始化纪律等);GC 搬移时必须重写栈上及堆内对象的每个指针;fat pointer 基址也需重写;跨 GC 触发调用持有裸指针的原生函数会得到悬垂指针。
- 状态:作为 A+B 混合设计的一部分部分实现。
5.2 方案 B:直接指针 + 分区帧(Direct Pointers + Partitioned Frames)
与 A 相同,但重编译器只标记哪些帧槽持有指针,而非发射安全点栈图。两种做法:
- 连续分区:指针区(
fp+0..fp+K)、标量区(fp+K..end); - 槽列表:每函数一个
Vec<u32>列出持指针的帧偏移。
任选其一,GC 扫描每帧被标记的指针槽即可——无需活跃性追踪。指针区中"逻辑已死但尚未覆写"的过期指针造成过度保留:对象在槽被复用或帧弹出前一直存活。对生命周期极短(短则毫秒级)的区块链交易而言可忽略。
- 优点:与 A 相同的直接访问性能;重编译器大幅简化——只需在帧布局中标记指针槽。
- 缺点:过期指针的过度保留(短交易下影响很小);GC 搬移时仍需重写全部指针(栈 + 内部);fat pointer 基址重写仍需处理;原生函数 GC 安全仍未解决;追踪内部堆引用仍需对象描述符。
- 状态:以 A+B 混合形式实现(细节见下)。
5.3 混合设计(A+B)在源码中的落点:frame_layout+safe_point_layouts
每个Function(core/src/function.rs)声明两级指针槽信息:
frame_layout: FrameLayoutInfo——任何 PC 处都恒为堆指针的帧偏移,GC 在每个 PC 处都扫描(方案 B);safe_point_layouts: SortedSafePointEntries——仅特定安全点额外有效的指针偏移,只有帧的当前 PC 命中安全点条目时才被扫描(方案 A)。
在任意安全点,GC 扫描二者的并集。安全点 = 分配类指令(在其自身 PC)与调用返回点(call_pc + 1)。当zero_frame为 true 时,运行时在执行 call 指令时把参数区之外(param_sizes_sum..extended_frame_size)的区域清零,使指针槽以 null 起步——GC 看到的是空指针而非垃圾。safe_point_layouts按code_offset严格排序,支持 O(log n) 二分查找(SortedSafePointEntries::layout_at)。
GC 阶段 1a 的实现(gc_collect)与之精确对应:栈顶帧扫frame_layout.heap_ptr_offsets+ 命中当前 PC 时的safe_point_layouts条目(若栈顶是原生帧则扫其 ABI 参数指针槽);栈顶以下的调用者帧只用frame_layout。
该混合方案让常见情况保持简单——稳定指针槽走frame_layout,无逐 PC 开销;同时支持跨调用边界改变类型(如共享的参数/返回区、不同被调方参数布局)的槽位。specializer 可自由选用任一机制:类型固定的槽用frame_layout,类型随 PC 变化的槽用safe_point_layouts。
5.4 方案 C:句柄表 + 分区帧(Handle Table + Partitioned Frames)
堆指针换成句柄 ID——对象句柄表的索引,表中存真实堆地址。帧布局分区(句柄区 vs 标量区)同方案 B,GC 无需栈图即知哪些槽是句柄。GC 扫描句柄区找根句柄、经描述符传递追踪;对象搬移只更新句柄表条目,不必重写栈上及对象内每个指针。
- 优点:无栈图;GC 搬移廉价(只改表项);fat pointer 变为
(handle_id, offset),跨 GC 稳定、无需重写基址;原生函数持有句柄 ID,跨 GC 仍有效——解决了原生 GC 安全;引用(borrow)自然成立——持有句柄 ID,经表解引用。 - 缺点:每次堆访问多一次间接(表查找 + 数据加载);句柄表本身竞争缓存(短交易下可能仍放得进 L1);需要 free list 回收表槽;指针区过期句柄过度保留(同 B);追踪内部堆引用仍需要对象描述符。
- 状态:未实现。
5.5 方案 D:句柄表 + 所有权树(Handle Table + Ownership Tree / Parent Pointers)
在 C 之上,每个句柄表条目再存一个父字段:所属容器的句柄 ID(或 "stack root" 哨兵),构成一棵镜像 Move线性所有权模型的树——每个值恰有一个所有者。
GC 不扫帧、不追踪对象图,而是:
- 重编译器在栈根死亡时发射
Drop→ O(1) 置空父字段; - GC 遍历句柄表,逐句柄向上追踪父链;
- 链到存活栈根 → 句柄存活;链到 null/死亡父 → 不可达,释放;
- 结果缓存在并行数组中,每句柄每轮收集至多解析一次。
重编译器必须上报每一次所有权变更:Drop(栈根死亡)、Store入容器(ObjStore/VecPushBack/VecStoreElem设置子句柄父)、从容器移出(ObjLoad/VecPopBack/VecLoadElem重新归属到接收栈槽)、WriteRef/ReadRef(可变引用写穿导致的所有权转移/重归属)、Mov/Mov8(栈槽间搬移句柄需重新归属)。引用(borrow)不参与父树——它们持有句柄 ID 但非所有者;Move 借用检查器保证所有者比所有借用长寿,运行时信任该不变量。
早期 PoC 的 GC 算法(压缩式 bump 收集器):① 遍历句柄表划分 active/inactive(沿父链判定,结果缓存,摊还 O(1)/句柄,迭代无递归);② 回收 inactive 表槽进 free list;③ 分配新内存区,把每个 active 句柄的数据连续拷入并更新handle.mem_ptr——因所有访问都经句柄表,其余无需任何重写;④ 释放旧区。向量扩容同理:bump 分配更大块、拷贝内容、更新单个表项。
- 优点:无栈扫描、无栈图、无帧分区要求;GC 追踪不需要对象描述符(父树取代描述符驱动图遍历);无传递式对象图遍历;O(1) Drop、延迟惰性收集;原生 GC 安全。
- 缺点:每次句柄变更都有开销(父指针更新);需要句柄感知指令变体——重编译器必须在处处区分句柄类型运算与标量运算;重编译器必须在每个值死亡点发射
Drop——漏一个即泄漏;GC 需遍历所有句柄(存活 + 死亡)做分区,而追踪式收集器(A/B/C)只访问可达对象。 - 状态:当前 PoC 未实现;早期独立 PoC 验证了核心算法。
5.6 四种方案横向对比
| 维度 | A:直接 + 安全点栈图 | B:直接 + 分区 | C:句柄 + 分区 | D:句柄 + 所有权树 |
|---|---|---|---|---|
| 堆访问成本 | 直接(1 次加载) | 直接(1 次加载) | 间接(2 次加载) | 间接(2 次加载) |
| GC 根发现 | 逐 PC 栈图 | 扫描指针区 | 扫描指针区 | 父链(无扫描) |
| GC 图遍历 | 全量传递追踪 | 同左 | 同左 | 父链行走(迭代) |
| GC 搬移成本 | 重写全部指针 | 同左 | 更新句柄表 | 更新句柄表 |
| Drop | 不适用(GC 回收) | 不适用(GC 回收) | 不适用(GC 回收) | O(1) 置空父 |
| 过度保留 | 无 | 过期指针 | 过期句柄 | 无(所有权精确) |
| Fat pointer GC | 重写基址 | 同左 | 稳定 | 稳定 |
| 原生 GC 安全 | 不安全 | 不安全 | 安全 | 安全 |
| 每次变更开销 | 无 | 无 | 无 | 每次句柄操作更新父 |
| 重编译器负担 | 重 | 轻 | 轻 | 中 |
| 描述符需求 | 需要 | 需要 | 需要 | 不需要 |
| GC 复杂度 | 中(Cheney + 栈图) | 中(Cheney) | 中(mark-sweep + 句柄表) | 低(父链行走) |
| 指令集复杂度 | 低 | 低 | 低 | 中(句柄感知变体) |
5.7 推荐结论:为什么是 A+B 混合
对绝大多数区块链交易而言,堆能舒适地装进预分配区域,GC 从不触发。收集是安全网,而非稳态机制;同时我们也不关心 stop-the-world 暂停延迟。这彻底改变了权衡:
- GC 算法成本几乎无关紧要——如果几乎不运行,父链行走还是全图遍历都无所谓;D 在 GC 复杂度上的优势权重很小。
- 每次变更开销才是主导成本——D 的每次父指针更新无论 GC 是否触发都在热路径上付出,而簿记几乎从不带来收益,这是纯开销。
- 过度保留不是问题——收集器几乎不运行,B/C 的过期指针只是闲置到交易结束,与被收集无异。
在此假设下,A+B 混合是明确赢家:热路径零开销;重编译器负担最小——只有跨调用边界改变指针状态的槽位需要安全点条目,稳定指针槽用更简单的frame_layout。其缺点(过度保留、GC 期间指针重写)要么有限,要么是几乎不用付出的成本。
纯方案 A(处处栈图)让重编译器在每个安全点做活跃性分析,在常见情形下无收益;纯方案 B(无逐 PC 信息)无法正确描述跨调用边界改变类型的槽位。混合方案兼得二者之长。
方案 C仅在原生 GC 安全成为现实障碍时才值得考虑;否则它用每次访问的句柄间接成本换取几乎不会兑现的 GC 收益。
方案 D是最优雅的设计、GC 也最简单,但在 GC 几乎不触发时,每次变更的父更新是错误权衡——它优化了稀有情形(收集),牺牲了常见情形(每次句柄操作)。
5.8 为什么需要 GC?仅 Bump vs Bump + 收集
文档坦诚记录了团队的反复权衡。如果 GC 几乎不触发,何不干脆只做 bump 分配、跑完交易、丢弃整个 arena?交易能分配的内存量有硬上限——简单、快速,对大多数交易数学上成立。
但一直困扰团队的是:纯 bump 下所有分配都是内存泄漏。挥之不去的情形是循环分配瞬时对象——每轮迭代的结果被消费,但堆持续增长,因为没有任何回收。程序在存活数据上完全在内存限额内做着合法工作,却因无法复用死内存而死亡。
更深层的担忧是承诺的代价:若只发布 bump-only,合约模式与内存上限将围绕那个天花板构建;日后追加 GC 意味着把指针重定位改造成一个本未为此设计的运行时,并引入新的故障模式——这是团队不愿签署的痛苦返工。
最终没有争论的必要:已有一个可用的复制收集器、对象描述符与指针重写——工程成本已经付过。方案 B 下常见路径本就是 bump 分配(零开销),收集器只是堆满时的安全网(罕见)。"我们没有为 GC 付钱,我们已经有它了"——它是廉价保险,换来处理高分配工作负载而不撞硬墙的能力,以及不必担心未来工作负载形态的自由。
六、如何继续深入:仓库阅读路线
若要进一步验证本文观点,建议按以下路径阅读源码:
- 堆与 GC 核心实现:runtime/src/heap/mod.rs(
Heap、heap_alloc、alloc_or_gc、gc_collect、RootScanner、gc_copy_object、gc_scan_object、realloc_vec、FrozenHeap/SharedArena); - 对象头与内存原语:runtime/src/memory.rs(
MemoryRegion、write_object_header、转发指针读写); - GC 根句柄表:core/src/root_pool.rs(
RootPool、root_object/root_reference、RAII 句柄); - 帧布局与安全点条目:core/src/function.rs(
Function::frame_layout、safe_point_layouts、zero_frame、extended_frame_size); - 默认堆大小等常量:runtime/src/types.rs(
DEFAULT_HEAP_SIZE = 10 MiB); - 解释器接线与上下文:runtime/src/interpreter.rs(
InterpreterOptions::heap_size、InterpreterContext、堆冻结); - 全局存储读写设计:global_storage_design.md(working map、
ExternalHeap/LocalHeap/Inline、CoW 与回滚日志、Exists/BorrowGlobal/BorrowGlobalMut/MoveFrom/MoveTo微指令); - 安全模型速览:runtime/AGENTS.md(三条不变量、
verify_program、编码规范)。
如需在本地构建与测试该运行时,可执行:
cargo check -p mono-move-runtime cargo test -p mono-move-runtime cargo test -p mono-move-runtime -- <测试名>说明:以上内容均以 heap_and_gc.md 为骨架,结合仓库内对应实现与设计文档整理而成;文档中标注的 TODO/TBD 项(块级缓存跨块保留、读写模式分析、CoW 时机、GC 与共享内存交互、内存管理器的类型安全手段等)仍属未决设计,引用时请注意区分"已实现"与"规划中"。
【免费下载链接】aptos-coreAptos is a layer 1 blockchain built to support the widespread use of blockchain through better technology and user experience.项目地址: https://gitcode.com/GitHub_Trending/ap/aptos-core
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考