# 内存管理 Deft 的堆具有高水位形态:内存会增长到应用历史上最大一次分配爆发所需的规模,死亡的值会被回收用于生成新值,但支撑这种回收的内存池在运行时的整个生命周期内永远不会归还给操作系统。本篇说明值存放在哪里、分配与 GC 实际如何工作、加载和卸载模块后什么会保留,以及如何观察这一切——这样,“空”应用与“加载后再卸载”应用之间的内存占用差距,读起来就是有文档记载的行为,而不是泄漏。 这是一篇面向 Deft 用户与嵌入者的设计层文档。函数层面的内容见 Runtimes;嵌入与 FFI 方面的内容见 Embedding 和 FFI。 ## 每个运行时一个堆 一个运行时恰好拥有一个堆(见 Runtimes)。值绝不会以引用方式跨堆传递——它们通过 `deft/stringify` / `deft/parse` 序列化——因此每个堆的分配行为可以单独推敲。 在 TUI 宿主中,每个面板应用都在自己的操作系统线程上运行自己的运行时,拥有自己的堆;预置文件会被深拷贝到每个堆上。由此产生两个后果: - 关闭面板会释放一切。 应用运行时会先被 join 再拆除,从而销毁整个堆——slab、驻留(intern)关键字、命名空间,全部包括在内。(宿主随后会调用 `malloc_trim`,因为 glibc 会在 RSS 层面保留已释放的 arena 块;musl 构建则直接通过 `munmap` 归还内存。) - 最小化或切换工作区则不会。 按设计,停靠和保活的应用会保持加载状态,其堆原封不动——在根据 RSS 得出任何结论之前,请先检查 `[tui/app-threads]`。 ## 值存放在哪里 标量值——数字、布尔值、nil——经过 NaN 装箱,内联存放在栈槽和对象字段中;它们从不分配(表示形式见 Embedding)。其他一切都是带有头部和种类标记的堆对象:字符串、关键字、向量、映射、闭包,等等。 分配按种类经由分层缓存进行路由: | 对象 | 布局 | 分配来源 | |---|---|---| | 小字符串(≤64 字节) | 一个块:头部 + 字节 | 先使用 64 KB bump slab,再使用精确长度的空闲链表桶 | | 小关键字(≤64 字节) | 一个块:头部 + 字节 | 相同:先从 slab 切分,再使用精确长度的桶 | | 大字符串 / 关键字 | 一个块 | 直接使用系统分配器 | | CHAMP 映射节点 | 固定节点,FAM 紧凑打包 | 64 KB slab + 2 的幂桶空闲链表 | | 映射外壳 | 固定大小的结构体 | 专用的外壳池 | | 其他一切 | 固定大小的结构体 | 按种类使用空闲链表,再使用系统分配器 | 这个设计有两点对内存占用很重要: - 字符串和关键字是单块的。 头部和字节一起分配——每个值一次分配,而不是两次。关键字和字符串各自拥有独立的 slab 和桶集合(布局相似,但头部大小不同,因此它们不共享块)。 - GC 从不移动对象。 块只有在所属对象被清扫之后才会被回收,而且只会回收成同一桶中的新值。正因如此,从另一个堆读取被根引用的值,或在 FFI 调用期间将其固定(pinned)才是安全的(见 FFI)。 ## 垃圾回收 回收器是标记-清扫式且不移动对象的。根包括 VM 栈和栈帧、命名空间、模块导出常量、协议方法体、原生调用前后压入的临时根,以及下文描述的关键字结构。当已分配字节数超过 `max(2 × live, 4 MiB)` 时,会运行一次回收周期——因此小规模爆发产生的垃圾会一直残留,直到跨过阈值。`[gc/collect]` 会立即强制执行一次回收周期(编辑器在关闭缓冲区时已经这样做,以便立即释放行,而不是等到下一个阈值)。 一次清扫之后,死亡的块不会归还给操作系统:小字符串和关键字被压入其长度对应的桶,CHAMP 节点进入其大小对应的桶,对象外壳进入其种类对应的链表。下一次分配匹配大小时,会弹出一个块,而不是从 slab 中切分新内存。这就是为什么分配密集的循环(字符串构建、映射构建)在预热后开销很低——也是为什么一次爆发之后内存占用不会回落。 ## 关键字与驻留 关键字有三条创建路径,某个键走的是哪条路径,决定了它是否有可能消失: 1. 编译期字面量。 已编译源码中每个不同的 `:name` 字面量都会驻留到该堆的关键字表中:每个名字一个规范对象,所有使用处共享。驻留的关键字是永久 GC 根,且该表永不收缩——驻留一个名字会让它在堆的整个生命周期内存活。这是有意为之(身份与指针稳定的切片),并且受到该堆上曾经编译过的不同字面量数量的限制。 2. 动态字符串键。 使用运行时计算的字符串键调用 `assoc` / `get` / `dissoc` 时,会自动将其转换为关键字,而不驻留——否则每个生成的键都会永远存活。这些普通关键字缓存在一个小的直接映射备忘表中(128 项,按内容作为键),因此重复转换会复用同一个对象;该备忘表有界且被根引用,最多只占用几 KB。 3. 副本。 携带元数据的副本是全新分配,遵循正常的 GC 规则。 这三者在用作映射键时可以互换:关键字相等性基于内容(哈希 + 长度 + 字节比较),因此驻留的 `:alpha`、动态转换得到的 `"alpha"`,以及两者的新副本,都会命中同一个映射条目。 ## 卸载后什么会保留 没有模块卸载 API。`[reload "path"]` 会把文件重新求值到同一个堆的同一个命名空间中(已有引用仍保留旧定义——见 Modules and Imports),并且导入按堆缓存,因此重新导入不会产生任何重复。在一次“加载后卸载”的周期之后,“空”与“回到空”之间的差距可分解为四个保留层: | 层 | 形态 | 何时回收 | |---|---|---| | Slab 高水位 | 缓存式:死亡的块会被回收,但 slab 内存和溢出过的桶会保留 | 仅在堆拆除时(关闭面板) | | 关键字驻留表 | 设计上常驻:该堆上曾经编译过的每个不同 `:literal` | 仅在堆拆除时 | | 命名空间 + 常量根 | 曾经创建过的所有命名空间都是 GC 根,包括被替换的;导出值和协议方法体保持被根引用 | 永不,原地保留 | | 惰性 GC | 低于 `max(2 × live, 4 MiB)` 阈值的垃圾尚未被清扫 | 在下一次回收时 | 这些层都不会无界增长:驻留表受已编译源码中不同字面量数量的限制,一旦桶能够服务传入的分配,slab 就会趋于平稳,而根则跟踪已加载的内容。唯一的轻微低效是桶碎片——字符串桶和关键字桶按精确的载荷长度作为键,因此在许多不同长度之间反复变动,会让每个桶都持有无法交给其他大小的死亡块。 作为参照:CPython 的 `pymalloc` 也将小对象放入 arena,但它会把完全为空的 arena 归还给操作系统。Deft 的 slab 没有“全死 slab”释放路径——这是为换取简单性而有文档记载的取舍。如果长期存活的宿主将来需要,空 slab 回收与弱驻留(在清扫时逐出已死亡的驻留表条目,使该表跟踪实际使用情况)是两个候选手段;目前两者均未实现。 ## 观察堆 | 工具 | 它能告诉你什么 | |---|---| | `[gc/stats]` | 回收周期数、存活与已分配字节、峰值字节、下一个阈值 | | `[gc/pools]` | 保留池:空闲链表、slab 字节数、关键字数量——为泄漏排查而构建 | | `[gc/census]` | 按种类统计的存活对象数量,以及最大的存活字符串 | | `[runtime/mem-stats]` | 进程 RSS 与分配器 arena 数据 | | `[tui/app-threads]` | 每个应用的堆 / 存活 / 峰值 KB,跨线程读取 | | sysmon(TUI) | 上述数据的实时图表——参见 sheet 应用的 sysmon 示例(sheet 应用帮助) | 一次加载/卸载审查分三步。在应用自己的控制台中进行(TUI 控制台会在应用的运行时中求值),或者在相关代码前后进行:
# 1. Baseline: force a collection, then record the pools
[gc/collect]
def before [gc/pools]

# 2. Load and unload the thing under test
[reload "big-file.dft"]

[gc/collect]
def after [gc/pools]

# 3. Attribute the gap
echo "keywords:"      ($after~keywords - $before~keywords)
echo "str slabs:"     ($after~str_slab_bytes - $before~str_slab_bytes)
echo "kw slabs:"      ($after~kw_slab_bytes - $before~kw_slab_bytes)
echo "champ slabs:"   ($after~champ_slab_bytes - $before~champ_slab_bytes)
echo "live bytes:"    ([gc/stats]~live_bytes)
如何解读结果: - `keywords` 上升,`live_bytes` 持平 → 来自文件字面量的驻留表增长。受源码限制;对该堆而言是永久的。 - `*_slab_bytes` 上升 → 加载爆发造成的 slab 高水位。在未来的加载中循环使用;在拆除时归还。 - `live_bytes` 上升 → 确实有更多被根引用的数据:命名空间、导出常量,或应用自身持有的状态。 - RSS 上升,但以上各项均持平 → 分配器层面的保留(glibc arena);`[gc/collect]` 已经会修剪这部分,musl 则不需要。 ## 设计说明 - 不移动的 GC 换来了简单、快速的跨堆读取被根引用的值,以及廉价的 FFI 固定(pinning),代价是永远无法用压缩来对抗碎片。 - Slab 分配的存在,是因为一旦分配器快速路径频繁变动,逐对象调用系统 malloc 会在字符串和映射密集的工作负载中占据主导(仅 CHAMP slab 一项,在 musl 下的映射基准测试中就带来了约 25% 的提升)。 - 永久驻留的存在,是为了让关键字身份、表键以及 `~field` 访问快速路径能够依赖稳定、规范的对象。动态键路径有意避开它,以保持生成的键可回收。 - 其他各处都是有界缓存:动态键备忘表是固定的 128 项数组;空闲链表桶持有已回收的块,但只能由先前该大小的清扫来填充。 ## 快速参考 | 问题 | 答案 | |---|---| | 关闭面板会释放其内存吗? | 会——整个堆被销毁;RSS 残留是分配器层面的 | | 最小化会释放吗? | 不会——停靠的应用保持其运行时和堆存活 | | 为什么 `[reload]` 之后内存没有下降? | 驻留表、命名空间根和 slab 高水位按设计会保留 | | 为什么 GC 之后内存没有下降? | `live-bytes` 会下降;slab 字节和桶会保留(被回收,而不是被释放) | | 动态映射键会泄漏吗? | 不会——不驻留、按内容相等、缓存在有界的 128 项备忘表中 | | `:literals` 会泄漏吗? | 它们被永久驻留,受已编译的不同字面量数量限制 | | 如何测量? | 在前后分别执行 `[gc/collect]`,然后执行 `[gc/pools]` / `[gc/stats]` | | 每个应用的视图在哪里? | `[tui/app-threads]`,或 sysmon 应用 |