双重哈希：从开放寻址到 Bloom Filter 工程实践双重哈希（Double Hashing）常见于两个场景：在开放寻址哈希表中生成冲突后的探测位置；在 Bloom Filter 中由两个基础哈希值派生多个位图索引。两种场景使用相同的数学形式，但解决的问题和约束并不完全相同。本文先介绍其数学原理，再分别分析它在哈希表和 Bloom Filter 中的用法，最后给出一个经过匿名化处理的工程实例。一、数学原理双重哈希使用两个基础哈希函数生成一个位置序列：p_i(x) = (h_1(x) + i × h_2(x)) mod m其中：x：待处理的元素；h_1(x)：第一个哈希值，决定序列起点；h_2(x)：第二个哈希值，决定序列步长；i：序号，从 0 开始；m：可用位置的总数。当 i 依次取 0、1、2…… 时，得到：p_0(x) = h_1(x) mod m p_1(x) = (h_1(x) + h_2(x)) mod m p_2(x) = (h_1(x) + 2 × h_2(x)) mod m ...可以把它理解为：第一个哈希决定从哪里开始 第二个哈希决定每次向前走多远不同元素不仅起点可能不同，步长也可能不同，因此生成的位置序列通常比固定步长更分散。1.1 递增计算直接使用公式时，每一轮都要计算 i × h_2(x)。实际实现可以使用递增方式：position_0 = h1 position_1 = position_0 + h2 position_2 = position_1 + h2 ...每次递增后对 m 取模即可，从而把乘法转换为加法。1.2 步长为零的问题如果：h_2(x) = 0那么所有位置都相同：p_i(x) = h_1(x) mod m因此工程实现通常会把零步长修正为一个非零值。二、开放寻址哈希表中的双重哈希2.1 哈希冲突哈希表通过哈希函数将键映射到槽位：index = h(x) mod m不同键可能得到相同槽位，这就是哈希冲突。开放寻址法不使用额外链表保存冲突元素，而是在哈希表内部继续寻找其他槽位。常见探测方式包括：线性探测；二次探测；双重哈希。双重哈希的探测序列为：index_i = (h_1(x) + i × h_2(x)) mod m查询或插入时，从 i = 0 开始依次探测，直到：找到目标键；找到可插入的空槽；或者确认探测失败。2.2 示例假设哈希表长度为 7：h_1(x) = x mod 7 h_2(x) = 5 - (x mod 5)依次插入 14、21、28。插入 14h_1(14) = 0槽位 0 为空，直接插入。插入 21初始位置仍然是 0，发生冲突：h_2(21) = 4第一次探测：(0 + 1 × 4) mod 7 = 4槽位 4 为空，将 21 插入槽位 4。插入 28初始位置同样是 0：h_2(28) = 2第一次探测：(0 + 1 × 2) mod 7 = 2槽位 2 为空，将 28 插入槽位 2。最终分布如下：槽位元素014228421三个键具有相同起点，但第二个哈希值不同，因此沿不同路径探测。2.3 为什么步长要与表长互质开放寻址哈希表通常要求：gcd(h_2(x), m) = 1这样可以保证探测序列在重复前遍历所有槽位。例如，表长为 8、步长为 2 时：0 → 2 → 4 → 6 → 0该序列只能访问一半槽位。即使其他槽位仍然为空，插入过程也无法到达。如果步长改为 3：0 → 3 → 6 → 1 → 4 → 7 → 2 → 5 → 0由于 3 和 8 互质，因此能够遍历全部槽位。2.4 表长为 2 的幂如果表长满足：m = 2^r那么任意奇数都与 m 互质。因此可以把第二个哈希值修正为奇数：normalize_step(Value, Modulus) -> case (Value bor 1) rem Modulus of 0 -> 1; Step -> Step end.同时，模运算可以转换为位与：hash mod m等价于：hash & (m - 1)但这个优化只在 m 确实为 2 的整数幂时成立。长度按某个块大小对齐，并不代表总长度一定是 2 的幂。2.5 优缺点双重哈希的优点：不同键通常具有不同的探测步长；能缓解线性探测的一级聚集；相比二次探测，具有相同起点的键不容易共享探测路径；在较高负载因子下通常比简单线性探测稳定。它的代价包括：需要计算两个基础哈希值；跳跃访问的缓存局部性较差；删除通常需要墓碑标记；步长设计不当时可能无法遍历整个表。三、Bloom Filter 中的双重哈希3.1 Bloom Filter 的基本原理Bloom Filter 是一种空间效率较高的概率型集合结构，由以下部分组成：一个长度为 m 的位图；k 个索引位置；插入和查询操作。插入元素时，将对应的 k 个位置设置为 1。查询元素时，检查这些位置是否全部为 1。判断结果具有以下语义：存在任意一个 0：元素一定不存在 所有位置均为 1：元素可能存在因此，Bloom Filter 允许假阳性（false positive），但正常实现不应产生假阴性（false negative）。3.2 多次完整哈希的成本理论描述通常假设 Bloom Filter 使用 k 个相互独立的哈希函数：f_1(x), f_2(x), ..., f_k(x)如果每次插入或查询都执行 k 次完整哈希，哈希计算本身可能成为高频路径上的开销。双重哈希只计算两个基础哈希值：a = h_1(x) b = h_2(x)然后派生出 k 个位置：g_i(x) = (a + i × b) mod m其中：i = 0, 1, ..., k - 1这样，一次操作只需要：2 次完整哈希；k 次简单整数运算。3.3 参数计算设：n：预计插入的元素数量；m：位图长度；k：哈希次数；b：平均为每个元素分配的位数。可以先估算位图长度：m ≈ n × b理论上较合适的哈希次数约为：k = (m / n) × ln(2)工程实现通常会对 k 取整，并设置合理的上下限，避免异常容量导致单次操作计算过多索引。Bloom Filter 的假阳性概率近似为：p ≈ (1 - e^(-k × n / m))^k增加位图长度通常可以降低误判率，但会增加内存占用；增加哈希次数只在一定范围内有效，超过理论最优值后反而可能降低效率。3.4 Bloom 场景是否要求步长互质开放寻址哈希表可能需要遍历全部槽位，因此通常要求步长与表长互质。Bloom Filter 只使用序列中的前 k 个位置，不需要遍历完整位图。因此，步长与位图长度互质并不是正确性的硬性条件。不过，如果两者存在较大的公因数，可能造成：派生位置更容易重复；实际有效哈希次数减少；位分布质量下降；假阳性概率升高。因此仍应避免零步长，并尽量选择分散性较好的步长。将步长修正为奇数是一种成本很低的工程手段。如果位图长度是 2 的幂，奇数步长可以保证互质；如果位图长度只是按块对齐，则奇数步长并不能严格保证互质。四、匿名化工程实例4.1 问题背景某在线服务维护了一个规模较大的持久化键集合。最初的实现会在进程启动时把全部记录加载到内存索引中。随着历史数据增长，这种方式逐渐暴露出两个问题：大量低频数据长期占用内存；某些内部流程会反复查询不存在的键，造成大量无效持久化读取。优化后的结构分为三层：热点缓存 ↓ miss Bloom Filter ↓ possibly present 持久化存储查询流程如下：热点缓存命中 → 直接返回 热点缓存未命中，Bloom Filter 返回 false → 确定不存在，不访问持久化存储 热点缓存未命中，Bloom Filter 返回 true → 查询持久化存储 → 命中后写入热点缓存Bloom Filter 不是权威数据源，也不能替代持久化存储。它只负责快速判断一个键是否一定不存在。4.2 启动构建服务启动时遍历持久化数据，完成以下工作：统计实际元素数量；根据元素数量计算位图长度；计算哈希次数；将已有键加入 Bloom Filter；只把真正的热点记录预热到内存缓存。这样可以避免把全部业务数据常驻内存，同时保留对不存在键的快速拦截能力。如果启动时元素数量为 0，也不应创建长度为 0 的位图。通常需要使用一个合理的默认容量，以便运行期新增元素仍然能够写入过滤器。4.3 使用双重哈希生成索引下面是一段经过泛化处理的 Erlang 风格伪代码：base_hashes(Item, BitCount) -> First = erlang:phash2({seed_a, Item}, BitCount), RawStep = erlang:phash2({seed_b, Item}, BitCount), Step = normalize_step(RawStep, BitCount), {First, Step}. normalize_step(Value, Modulus) -> case (Value bor 1) rem Modulus of 0 -> 1; Step -> Step end. position(First, Step, Round, BitCount) -> (First + Round * Step) rem BitCount.插入或查询时，只计算一次基础哈希：{First, Step} = base_hashes(Item, BitCount), Positions = [ position(First, Step, Round, BitCount) || Round <- lists:seq(0, HashCount - 1) ].两个 salt 用于区分两次基础哈希的输入域，不需要包含任何业务信息。4.4 分块位图如果使用一个很大的二进制保存整个位图，那么修改其中一个位可能需要重新构造较大的二进制。一种更灵活的方案是将位图切分成固定大小的块：完整位图 ├── block 0 ├── block 1 ├── block 2 └── ...这些块可以按需保存在 Map 中：#{ BlockNumber => BlockBinary }给定一个全局位索引，可以这样定位：BlockNumber = Index div BlockBits, OffsetInBlock = Index rem BlockBits, ByteOffset = OffsetInBlock div 8, BitOffset = 7 - (OffsetInBlock rem 8).设置对应位：<<Prefix:ByteOffset/binary, Byte:8, Suffix/binary>> = Block, UpdatedBlock = << Prefix/binary, (Byte bor (1 bsl BitOffset)):8, Suffix/binary >>.这种设计只需要重新构造命中的块，不需要重建整个逻辑位图。没有使用过的块也不需要提前分配。4.5 插入流程插入一个元素时：1. 计算两个基础哈希值； 2. 派生 k 个位索引； 3. 将每个索引转换为块编号和块内偏移； 4. 读取对应块，不存在时创建全 0 块； 5. 设置目标位； 6. 保存更新后的块。4.6 查询流程查询时检查全部派生位置：任意块不存在 → false 任意目标位为 0 → false 全部目标位为 1 → true可以使用 lists:all/2 表达这种语义：lists:all( fun(Index) -> bit_is_set(Index, Blocks) end, Positions ).空 Bloom Filter 可以直接返回 false，避免不必要的哈希和位图计算。4.7 新增和删除的正确性新增键时，必须同步更新 Bloom Filter：写入持久化存储 + 加入 Bloom Filter如果持久化存储已经存在某个键，而 Bloom Filter 没有记录它，查询可能被错误拦截，从而产生假阴性。普通 Bloom Filter 通常不支持安全删除。因为同一个位可能同时被多个元素使用，直接清零可能影响其他元素。常见处理方式是：删除持久化记录 保留 Bloom Filter 中原有的位删除后可能增加假阳性，但不会造成假阴性。Bloom Filter 返回 true 时继续查询持久化存储，仍能得到正确结果。4.8 失败降级Bloom Filter 是性能优化层，不应成为正确性的单点。如果过滤器构建失败、状态不可用，或者无法确认是否包含全部已有键，安全的降级方式是：跳过 Bloom Filter 直接查询数据库不能把“过滤器不可用”解释为“键不存在”。
Neovim 使用 ELP 内存占用过高的一次排查记录
Neovim 使用 ELP 内存占用过高的一次排查记录最近在 Neovim 中使用 ELP（Erlang Language Platform） 作为 Erlang 的 LSP 服务器时，遇到了一次比较典型的内存占用过高问题。ELP 本身是 Rust 实现，语义能力比较强，理论上性能也不错。但在实际项目中，ELP 进程的常驻内存一度达到 1.3GB～1.4GB，明显偏高。本文记录这次排查过程，以及最终定位到的根因。一、问题背景项目是一个中等规模的 Erlang 服务端工程：Erlang 源码与头文件数量：约 1000+ 个 .erl / .hrl项目磁盘占用：数百 MB编辑器：NeovimLSP：ELP初始现象：ELP 进程 RSS 常驻约 1.3GB～1.4GB一开始的直觉是：这个内存占用有些不正常。虽然 ELP 是偏重型的语义分析型 LSP，不是简单的补全工具，但对这个规模的项目来说，1GB+ 的常驻内存仍然偏高。二、初步优化尝试最开始先做了几个常规方向的优化。1. 使用 .elpignore 排除无关目录首先创建 .elpignore，排除构建产物、依赖目录、日志目录、测试目录、运行数据目录等，只保留必要的 include 目录。处理后，ELP 索引的源码数量明显下降，但内存只从约 1.3GB+ 降到约 1.0GB+，收益有限。这说明确实有一部分无关文件被 ELP 处理了，但这不是主要矛盾。2. 关闭部分重量级 diagnostics随后尝试关闭一些重量级 diagnostics，例如 cross reference、unused variable 等，只保留比较轻量的检查。内存进一步下降了一些，但仍然在 800MB～1GB+ 区间。这说明 diagnostics 有成本，但仍然不是根因。3. 切换 Erlang / OTP 版本早期环境中，ELP 启动的 Erlang sidecar 进程存在 OTP 版本兼容问题。切换到符合 ELP 要求的 OTP 版本后，子进程崩溃问题消失。但 ELP 主进程的内存占用仍然偏高。这说明 OTP 兼容问题只是一个干扰项，不是这次内存异常的根因。三、确认不是 RSS 虚高为了判断是否只是 top / htop 看到的 RSS 虚高，直接查看进程的 smaps_rollup：PID=<elp_pid> cat /proc/$PID/smaps_rollup | egrep 'Rss|Pss|Private|Shared|Swap'典型结果类似：Rss: 1300000+ kB Pss: 1300000+ kB Private_Dirty: 1200000+ kB重点看三个指标：Rss Pss Private_Dirty如果只是 RSS 虚高，通常 PSS 不会这么高。但这里的结果是：PSS ≈ RSS Private_Dirty 很高这说明不是共享库、mmap、文件缓存导致的 RSS 虚高，而是 ELP 主进程确实持有了大量私有堆内存。换句话说，这次不是观察工具误判，而是 ELP 真的吃了这么多内存。四、使用 .elp.toml 收敛项目模型为了避免 ELP 自动 discovery 过宽，开始使用 .elp.toml 明确声明项目结构。示例配置：[build_info] apps = [ { name = "app", dir = ".", src_dirs = ["src"], include_dirs = ["include"], deps = [], build_deps = [] } ] deps = [] build_deps = [] [eqwalizer] enable_all = false这里有几个注意点：src_dirs 只应该放真正需要 LSP 索引的源码目录。include_dirs 只应该放头文件目录。不要把生成代码目录、运行数据目录、构建产物目录误放进 src_dirs。某些 ELP 版本要求 deps / build_deps 在 app 对象中也存在，否则会报 schema 解析错误。如果遇到类似：missing field `build_deps`可以把 app 配置写成：[build_info] apps = [ { name = "app", dir = ".", src_dirs = ["src"], include_dirs = ["include"], deps = [], build_deps = [] } ] deps = [] build_deps = []这样可以避免 TOML 表归属带来的歧义。五、关闭 EqWAlizer 后的效果通过以下配置关闭默认全量 EqWAlizer 分析：[eqwalizer] enable_all = false关闭后，内存确实下降了一部分，但没有根本解决问题。排查中的现象大致是：关闭 EqWAlizer 后，ELP 内存下降了一些； 但仍然维持在 1GB 左右。因此可以判断：EqWAlizer 有内存成本； 但不是这次 1GB+ 常驻内存的主因。六、按目录拆分测试接下来开始分区测试：只让 ELP 索引某一个源码子目录，观察内存变化。测试配置模板如下：[build_info] apps = [ { name = "app", dir = ".", src_dirs = ["某个子目录"], include_dirs = [], deps = [], build_deps = [] } ] deps = [] build_deps = [] [eqwalizer] enable_all = false每次修改 .elp.toml 后，重启 ELP：pkill -9 -f 'elp server'然后重新打开 Neovim，再观察 ELP 进程内存：ps aux | grep elp或者：PID=<elp_pid> cat /proc/$PID/smaps_rollup | egrep 'Rss|Pss|Private|Shared|Swap'七、分区测试结果测试结果大致如下：测试范围文件数量RSS 估算普通单文件最小测试1约 450 MB主要业务源码目录数百个文件约 600 MB+协议 / 辅助源码目录数十个文件约 500 MB生成数据源码目录数十个文件约 1.0 GB+完整源码目录数百个文件约 1.0 GB+这个结果非常关键。原本以为内存高是因为业务源码数量多，但测试结果表明：真正异常的不是主要业务源码目录； 而是生成数据源码目录。虽然生成数据源码目录的文件数量并不多，但只索引它时，ELP 的内存几乎等同于索引完整项目。这说明问题不在“文件数量”，而在“某些文件的结构和规模”。八、定位巨型生成 Erlang 模块继续统计生成数据目录下的 .erl 文件大小：find <generated_data_dir> -type f -name '*.erl' -printf '%s %p\n' | sort -nr | head -50发现其中存在多个 MB 级别的自动生成 .erl 文件。再统计行数：wc -l <generated_data_dir>/*.erl部分文件达到：数十万行 二十多万行 十多万行这些文件通常不是人类手写代码，而是由配置表、Excel、CSV 或其他数据源生成的 Erlang 数据模块。常见结构类似：get(1) -> {...}; get(2) -> {...}; get(3) -> {...}; ...或者包含大量 tuple、list、map literal。这种文件对运行时可能有一定优势，但对 LSP、编译器前端、xref、dialyzer 等工具都非常不友好。九、单文件验证为了确认问题不是整个目录，而是巨型生成文件本身，单独复制一个巨大生成模块到临时目录，只让 ELP 索引它。测试结果类似：测试范围RSS普通单文件约 450 MB单个巨大生成数据模块约 750 MB+也就是说：一个数 MB / 数十万行的生成 Erlang 文件， 就可能让 ELP 额外增加数百 MB 内存。继续将几个最大的生成数据模块放入临时目录测试，合计约数十万行，ELP RSS 直接接近 1GB+。至此基本可以坐实根因：ELP 高内存主要由巨型自动生成 Erlang 数据模块触发。十、为什么几 MB 源码会放大成几百 MB 内存？源码文件本身只是文本，但 LSP / 编译器前端不会只保存文本。ELP 需要构建很多中间结构：源码文本 → token stream → syntax tree / AST → module symbols → function clauses → references → diagnostics context → incremental database cache对于普通业务模块，这个放大倍率通常可以接受。但对数十万行生成代码来说，情况完全不同。这类文件通常包含：大量函数子句 大量 literal tuple/list/map 大量 AST 节点 大量位置信息 大量索引项 大量增量分析缓存所以，一个几 MB 的文本文件，在 LSP 内部变成数百 MB 的内存占用并不意外。关键问题不是文件大小本身，而是：这个文件被解析后产生了多少语法节点、语义节点和索引数据。对于 20 万行级别的生成代码，AST 和语义索引的规模会非常夸张。十一、最终解决方案最终选择在 ELP 的 .elp.toml 中排除生成数据目录，只索引业务源码、协议源码、系统辅助源码等。示例：[build_info] apps = [ { name = "app", dir = ".", src_dirs = ["src/core", "src/protocol", "src/system", "src/lib"], include_dirs = ["include"], deps = [], build_deps = [] } ] deps = [] build_deps = [] [eqwalizer] enable_all = false [otp] exclude_apps = [ "megaco", "diameter", "wx", "observer", "debugger", "common_test", "eunit", "et", "reltool", "runtime_tools", "ssh", "ftp", "tftp", "snmp", "odbc" ]调整后，ELP 从最初的：1.3GB～1.4GB下降到大约：800MB虽然 800MB 仍然不算特别轻，但已经比原来稳定很多，也进入了可接受范围。十二、如果仍想继续降低内存如果希望进一步降低 ELP 内存，可以继续收敛 src_dirs。例如：src_dirs = ["src/core"]只索引日常主要开发目录。这样内存可能进一步下降，但代价是跨目录跳转、引用查找和补全信息会变少。可以根据日常开发习惯做取舍：需要完整跳转：索引更多业务目录 需要低内存：只索引当前主要开发目录 生成数据目录：尽量不要索引十三、按需拆分生成源码目录如果有些生成模块确实需要 LSP 支持，可以把生成源码拆成两类：src/generated_lsp/ src/generated_heavy/其中：generated_lsp：小型、偶尔需要跳转的生成模块 generated_heavy：巨大生成模块，不给 LSP 索引.elp.toml 只包含：src_dirs = ["src/core", "src/protocol", "src/generated_lsp"]构建系统仍然可以编译 generated_heavy，只是 LSP 不处理它。这样可以在保留运行时结构的同时，避免 Neovim 打开项目时被巨型生成文件拖垮。十四、长期优化方向从长期看，更好的方式是减少巨型 Erlang 源码生成。如果这些模块本质上只是静态配置数据，可以考虑不要生成几十万行 Erlang 函数子句，而是生成外部数据文件，例如：.term .bin .json ets dump dets fileErlang 侧只保留轻量查询接口，例如：data_lookup:get(Key) -> persistent_term:get({data_lookup, Key}).或者：data_lookup:get(Key) -> ets:lookup_element(data_table, Key, 2).这样做的好处是：LSP 不需要解析巨型 Erlang 源码。编译器前端压力下降。xref / dialyzer / formatter / editor 插件都更轻。运行时仍然可以通过 ETS / persistent_term 保持较好的查询性能。生成数据与业务代码边界更清晰。当然，这种改造会影响构建流程、热更方式、运行时加载方式，不一定适合短期马上做。短期最实用的方式仍然是：LSP 层面排除巨型生成源码。十五、这次排查的关键经验这次排查有几个比较有价值的经验。1. 不要只看 RSS，要看 PSS 和 Private_Dirty如果 PSS 和 Private_Dirty 也很高，说明不是虚高，而是真实内存占用。2. .elpignore 有用，但不一定能解决根因.elpignore 可以排除无关目录，但如果真正的问题文件仍然在 src_dirs 中，内存不会明显下降。3. EqWAlizer 有成本，但不一定是主因关闭 EqWAlizer 可以减少一部分内存，但如果存在巨型生成源码，关闭 EqWAlizer 也救不了根本问题。4. 要用分区测试定位问题目录不要凭感觉猜哪个目录最重。直接修改 .elp.toml，一次只索引一个目录，看内存变化，是最快的定位方式。5. 文件数量不等于索引成本几百个普通业务模块可能只增加一两百 MB，但一个数十万行的生成模块就可能增加几百 MB。真正决定 LSP 成本的是：AST 节点数量 函数子句数量 literal 数量 符号和引用数量 语义索引规模6. 生成源码不一定适合交给 LSP运行时喜欢的代码结构，不一定适合开发工具链。大量 pattern matching 子句对运行时可能很好，但对 LSP 来说是巨大的分析负担。十六、最终结论这次 ELP 内存过高的根因不是 Rust 实现问题，也不是单纯 ELP “无故吃内存”。真正原因是：项目中存在巨型自动生成 Erlang 数据模块； 这些模块达到数 MB、数十万行； ELP 对它们构建 AST 和语义索引后，内存产生高倍放大。最终处理方式：业务源码交给 ELP； 巨型生成源码排除出 LSP； 必要时拆分小型生成模块； 长期考虑改为外部数据文件或运行时加载结构。这次优化后，ELP 常驻内存从约 1.3GB～1.4GB 降到约 800MB，问题基本可控。对于类似 Erlang 项目，如果 ELP、erlang_ls、dialyzer、xref 等工具出现异常高内存或异常慢，建议优先排查：是否存在巨大自动生成 .erl 文件 是否存在几十万行级别的函数子句 是否存在大量 literal 数据写在源码中 是否可以从 LSP 索引中排除这些文件很多时候，问题不在 LSP 本身，而在“把数据生成成源码”这件事对开发工具链并不友好。
[译注与拓展] 哈希数组映射树（HAMT）的原理与Erlang对其的实现
声明： 本文主要内容编译自 Nick M 的英文技术分享 《Grokking Hash Array Mapped Tries (HAMTs)》。笔者在译介的基础上，修正了原作中关于哈希冲突处理的逻辑细节，并观察了 Erlang 源码分析出 Erlang 虚拟机对 HAMT 的实际工业级优化策略。这是一种结合了哈希表和 Tries（字典树）优势的数据结构，用于高效地存储和检索键值对。在 HAMT 里面，Keys 都被 hash 了来确认在 Array 的位置，被称之为 hash array。为了确保高效的内存利用，如果多个 Key 在同一个 index 上冲突了，那么 trie-like（类字典树）的结构就是用来解决这种冲突的。用个例子来解释 HAMT 怎么运作：假设我们有一个 HAMT 来存一些单词和单词对应的定义。我们用一个简单的哈希表说明，它只有四个 Slots 来存 Value，和只用 2 个 bits 来代表 index。我们存下面这几个键值对："apple" -> "a fruit""banana" -> "a tropical fruit""cat" -> "a small animal"现在我们的 HAMT 还是空的：根表 (Root Table) +------+------+------+------+ | [00] | [01] | [10] | [11] | +------+------+------+------+ | 空 | 空 | 空 | 空 | +------+------+------+------+我们开始插入第一个键值对 "apple" -> "a fruit"，"apple" 这个 Key 被哈希之后，在哈希表中的下标位置就是确定的。我们假设 "apple" 的哈希值是 32bits，但是我们只用前两个 bits 来找到在这个 Hash 表中正确的下标位置。在这个例子里面，我们假设这两个 bits 就是 01。我们一看这个下标在上面的哈希表中还是空的，于是直接存进去这个位置。根表 (Root Table) +------+-----------------------+------+------+ | [00] | [01] | [10] | [11] | +------+-----------------------+------+------+ | 空 | "apple" -> "a fruit" | 空 | 空 | +------+-----------------------+------+------+接下来我们来插入 "banana" -> "a tropical fruit"。我们故技重施，hash 一下这个 Key，相应的 index 就能找到了。我们假设计算出来这个 Key 的哈希值是以 10 开始的，这个 10 在上面的哈希表内的位置依旧是空的，所以也是直接插入。根表 (Root Table) +------+-----------------------+-----------------------------+------+ | [00] | [01] | [10] | [11] | +------+-----------------------+-----------------------------+------+ | 空 | "apple" -> "a fruit" | "banana" -> "a trop. fruit" | 空 | +------+-----------------------+-----------------------------+------+最后我们来插入 "cat" -> "a small animal"。哈希一下这个 Key，确定其 index。我们假设 "cat" 的哈希值的头两位又变成了 01。哦，这个位置我们之前放入过 "apple"，发生了冲突！正常来说，当一个传统的哈希表变满时，我们需要分配一个更大、更多的哈希表，然后重新计算并搬迁所有旧的值（re-hash），这是一个又慢又昂贵（expensive）的过程。所以我们有没有能避免这种不得不执行 resize 操作的方法？哈哈，HAMTs 就是来搞这个的。与其去执行一个 resize 我们的表，不如我们直接分配一个新的子哈希表（也是只有 4 个空 Slots），我们用这个新哈希表链接到我们发生冲突的 Slots（01）位置。这里有一个非常关键的步骤：原本在第一层 01 槽位上的 "apple" 不能继续留在原地了（因为该槽位现在要变成一个指向新子表的指针）。我们需要把原有的 "apple" 和新来的 "cat" 一起向下推（Push Down），重新分配到第二层的新哈希表里。根表 (Root Table) +------+--------+-----------------------------+------+ | [00] | [01] | [10] | [11] | +------+--+-----+-----------------------------+------+ | 空 | | | "banana" -> "a trop. fruit" | 空 | +------+--v-----+-----------------------------+------+ | v [指向子表的指针] | v 子表 (Sub-Table) +------+------+------+------+ | [00] | [01] | [10] | [11] | +------+------+------+------+ | 空 | 空 | 空 | 空 | +------+------+------+------+现在我们要把 "apple" 和 "cat" 安置在第二个表里面，但是我们需要更多的 bits 去定位它们。这种情况下，我们引入下一组 bits（第 3 和第 4 个 bits）来确定它们在第二层表的位置：我们假设 "apple" 的前 4 位哈希值是 01 00。头两位 01 帮我们找到了第一层的槽位，下两位 00 则决定了它在第二层子表中的下标为 00。我们假设 "cat" 的前 4 位哈希值是 01 10。头两位 01 帮我们找到了第一层的槽位，下两位 10 则决定了它在第二层子表中的下标为 10。根表 (Root Table) +------+--------+-----------------------------+------+ | [00] | [01] | [10] | [11] | +------+--+-----+-----------------------------+------+ | 空 | | | "banana" -> "a trop. fruit" | 空 | +------+--v-----+-----------------------------+------+ | +-------------------------+ | v 子表 (Sub-Table) +-----------------------+------+---------------------------+------+ | [00] | [01] | [10] | [11] | +-----------------------+------+---------------------------+------+ | "apple" -> "a fruit" | 空 | "cat" -> "a small animal" | 空 | +-----------------------+------+---------------------------+------+看，通过这种“向下推”和“子表局部挂载”的方法论，我们完美避免了全局的 re-hashing。如果我们继续这样搞，我们就能增加尽可能多的 values，而且不用重新分配整张大表的内存。当我们需要查找一个值时，只需要简单地根据哈希值一路找下去。我们先看最低的两位，在根表（Root Table）寻找：如果对应位置是空，说明值不存在；如果对应位置是一个直接的值，且 Key 对上了，就直接返回；如果对应位置是一个指针（指向子表），我们就继续提取哈希值的下两位，去第二层子表里找，以此类推。工业界是如何处理的？以 Erlang 为例在实际的工业级编程语言实现中，比如 Erlang 的虚拟机（BEAM），对 HAMT 的处理更加极致和聪明，解决了很多现实中的性能和内存痛点：大 Map 与小 Map 的双重策略（Flatmap vs Hashmap）：在 Erlang 中，并不是所有的 Map 一上来就会用 HAMT。如果键值对数量很少（通常少于 32 个），Erlang 会使用一种非常简单、扁平的数组结构（称为 flatmap），因为在小数据量下，直接遍历数组比遍历树还要快。只有当元素数量超过 32 个时，它才无缝“升级”为真正的 HAMT（在源码中称为 hashmap）。用 Bitmap（位图）消灭空槽，节省内存：在我们上面的简化例子中，第二层表虽然只存了 2 个值，却分配了 4 个空槽。如果树很深，会浪费大量的内存。Erlang 的 HAMT 节点通常是 16 叉的（每次看 4 个 bits）。为了不浪费这 16 个槽位，Erlang 内部使用了一个 **16 位的无符号整数（Bitmap）**来标记哪些位置是有值的。物理上，它只为有值的槽位分配连续的内存，再通过极快的位运算（如 popcount 指令）计算出元素在物理内存中的实际偏移量。终极哈希冲突的兜底：万一遇到极罕见的“完全哈希冲突”（即两个不同的 Key，它们的 32 位或 64 位哈希值完全一样），再怎么向下建树也分不开了。Erlang 在源码中对此做了兜底：它会在树的最底部创建一个特殊的冲突节点（Collision Node），其内部退化成一个简单的链表，用来安全地容纳这些具有相同哈希值的不同 Key。总结通过这种方式，HAMTs 高效地用类 trie 的数据结构处理了哈希冲突，本质上这是一种结合了二进制搜索和哈希表、但是没有它们那么麻烦的东西。在二进制搜索树中，我们每次插入新元素时，为了使搜索路径更有逻辑和高效，不得不进行复杂的旋转去重新平衡（Rebalance）。而在 HAMT 里面，元素的位置通过哈希值决定，分布更加随机，根本不需要专门去使其平衡。至于哈希表，我们已经提及过当表太大了的时候需要重新分配更多内存和 re-hash 的问题了。HAMT 不会变得很 "full"，因为本质上这是一棵树，我们需要分配新的值只需要往上面挂子节点就行了。HAMT 被频繁用于函数式编程语言，比如 Clojure、Scala 以及 Erlang，用来实现持久化数据结构比如 maps 和 sets。HAMT 在确保不可变性（Immutability）时提供了高效的查询、插入和删除操作。它们也很适合多线程或者多进程去同时访问和修改共享数据结构这种并发情况。这种结构性的共享特征允许高效地复制共享数据，减少了昂贵的锁机制需要。相关链接：Grokking Hash Array Mapped Tries (HAMTs) - by Nick Mhttps://github.com/erlang/otp/blob/master/erts/emulator/beam/erl_map.c
一次 Erlang 线上内存优化分析：ETS 大对象、compressed 表与 eheap allocator 碎片背景近期对一批 Erlang 线上节点做内存采样时，发现整体内存水位偏高。采样内容主要包含两部分：ETS 表维度的内存占用；ets_alloc 与 eheap_alloc 分配器的 block、carrier、usage 情况。从采样结果看，这批节点的内存问题并不是单一来源，而是由两类问题叠加造成：部分核心业务 ETS 表中存在大对象，尤其是超长 list、复杂 record、大 map 或嵌套结构，导致单条记录非常重；eheap_alloc 的 mbcs carrier 利用率偏低，部分高负载节点在进程堆分配高峰后留下较大的 carrier 空洞。这两类问题性质不同：ETS 数据结构优化，解决的是业务数据本体过大的问题；allocator 参数调整，解决的是进程堆分配碎片和 carrier 利用率偏低的问题。两者可以叠加优化，但需要分别评估收益、性能成本和适用场景。一、线上内存现状1. 节点整体情况本次采样覆盖节点数：节点数：444从 ETS 表占用看，内存主要集中在少数几个大表上。尤其是某个核心业务表，单表全服可见占用已经达到：核心业务表 X 当前可见 ETS 占用：约 227.70 GiB从 allocator 看，eheap_alloc 的 mbcs 当前全服情况如下：eheap_alloc mbcs: block = 74.87 GiB carrier = 157.78 GiB waste = 82.91 GiB也就是说，eheap_alloc 的 mbcs 中有相当一部分 carrier 已经被申请出来，但当前并没有被有效使用。2. ETS 表占用特征ETS 侧的问题比较明确：内存主要集中在少数大表中，其中最突出的表是核心业务表 X。这类表有几个典型特征：单条记录很大；记录数量不一定特别夸张，但 value 结构非常重；value 中包含超长 list、复杂 record、大 map、嵌套结构或冗余缓存字段；数据长期常驻 ETS/DETS，生命周期较长。在抽样分析中，某个核心记录的大致结构占用如下：单条核心记录：约 52,576 bytes 其中超长列表字段：约 51,792 bytes也就是说，单条记录的大部分内存都来自某个超长 list 字段，而不是 record 的其他字段。这个现象说明，ETS 表本身不是简单的“记录数太多”问题，而是存在明显的单条对象过胖问题。3. allocator 占用特征allocator 侧主要观察了 ets_alloc 与 eheap_alloc。ets_alloc 的整体使用率相对较高，说明 ETS 这部分主要是业务数据真实占用，并不是 allocator 碎片导致的假性膨胀。相比之下，eheap_alloc 的 mbcs 问题更明显：eheap_alloc mbcs block = 74.87 GiB eheap_alloc mbcs carrier = 157.78 GiB eheap_alloc mbcs waste = 82.91 GiB这说明进程堆分配曾经被拉高，carrier 扩张后没有充分回收，导致保留了大量低利用率 carrier。部分同类负载节点的 eheap_alloc mbcs usage 只有：10% - 30%单节点碎片空间可能达到：500 MiB - 1 GiB因此，这批节点的内存问题可以拆成两层：ETS 大表是真实业务数据占用，是主因；eheap_alloc 存在明显 carrier 浪费，是额外放大的因素。二、核心问题定位1. 核心业务表 X 中存在超长 list 字段抽样结果显示，某个核心业务表 X 中有一个超长列表字段，占用了单条记录的大部分空间。抽样数据如下：单条记录：约 52,576 bytes 超长列表字段本身：约 51,792 bytes将该列表字段压缩后：term_to_binary(List, [compressed]) 后正文：约 1,612 bytes 压缩后整条 record：约 2.4 KB 单条缩小比例：约 95%这说明该字段非常适合压缩存储。对于这类“写入后长期存放、读取频率相对可控、字段内容重复度较高”的列表数据，直接以 Erlang list 常驻 ETS 的成本非常高。2. 大 term 表适合 ETS compressed除了针对某个字段做 term_to_binary(..., [compressed]) 之外，还测试了 ETS 表自身的 compressed 参数。例如某类大对象业务表中存放的是完整的大 record。开启 ETS compressed 后，内存下降非常明显。测试结果中，某张大对象业务表的内存从：25167931 words下降到：6677897 words压缩后约为原来的：26.5%也就是：内存减少约 73.5% 内存约减少 3.77 倍这说明，对于 value 是大 record、大 tuple、大 map 或嵌套 term 的 ETS 表，compressed 是一个非常有效的内存优化手段。3. 小对象高密度表不一定适合 compressed并不是所有 ETS 表都适合开启 compressed。在测试中，有一张高密度业务表开启 compressed 后反而变大：优化前：约 499.6 MiB 优化后：约 518.1 MiB 增加：约 18.5 MiB这类表的特点是：记录数量非常多；单条 value 很小；平均单条只有几百字节；压缩元数据、对象布局和访问结构开销可能抵消压缩收益。因此，ETS compressed 更适合“大 term 表”，不适合盲目推广到所有 ETS 表。4. eheap_alloc 的 mbcs carrier 利用率偏低另一个问题来自 eheap_alloc。在未调整 allocator 参数的节点上，很多同类负载节点存在较大的 mbcs carrier 浪费：usage = 10% - 30% 单节点 waste = 500 MiB - 1 GiB而某个已调整 allocator 参数的样本节点表现如下：mbcs block = 329.8 MiB mbcs carrier = 458.6 MiB waste = 128.8 MiB usage = 71.9%对比来看，调整后 eheap_alloc mbcs 的 carrier 利用率明显更好。这说明对于该类负载，适当调整 eheap_alloc 的 mbcs 策略，有机会减少进程堆 allocator 的碎片和 carrier 保留。三、局部压缩实测为了验证 ETS 压缩收益，选取了一个测试节点，对部分大表进行了压缩对比。1. 优化前 Top ETS 表优化前，Top ETS 表大致如下：业务表 X 1,024,774,056 bytes 19,626 rows 业务表 A 523,826,456 bytes 2,422,681 rows 业务表 B 296,529,864 bytes 974,885 rows 业务表 H 217,739,000 bytes 6,876 rows 业务表 J 201,343,448 bytes 635 rows 业务表 C 197,554,088 bytes 31,176 rows 业务表 D 172,530,048 bytes 31,918 rows 业务表 E 171,194,880 bytes 750,870 rows 业务表 F 98,440,520 bytes 33,684 rows 业务表 G 90,481,488 bytes 24,000 rows可以看到，业务表 X 单表接近 1 GiB，是该节点上最突出的 ETS 内存来源。2. 优化后 Top ETS 表优化后，Top ETS 表发生了明显变化：业务表 A 543,211,160 bytes 2,422,696 rows 业务表 B 287,866,928 bytes 974,885 rows 业务表 C 197,554,072 bytes 31,176 rows 业务表 D 172,530,624 bytes 31,918 rows 业务表 E 171,194,880 bytes 750,870 rows 业务表 F 98,450,456 bytes 33,684 rows 业务表 G 90,481,488 bytes 24,000 rows 业务表 H 60,932,032 bytes 6,755 rows 业务表 I 54,894,752 bytes 12,914 rows 业务表 J 53,423,176 bytes 635 rows其中，原先占用最高的业务表 X 已经不再出现在 Top 列表中，说明压缩后该表的内存占用已经从主要矛盾变成了非主要项。进一步查看该表的 ets:info/1：[{compressed,true}, {memory,1355986}, {size,19626}, {type,set}, {keypos,2}, {protection,public}]需要注意：ets:info(Tab, memory) 的单位是 word，不是 byte。在 64 位 VM 下，1 word = 8 bytes，因此：1355986 * 8 = 10847888 bytes也就是：优化后业务表 X 约占 10.35 MiB3. 业务表 X 压缩收益对比优化前后：优化前：1,024,774,056 bytes，约 977.3 MiB 优化后：10,847,888 bytes，约 10.35 MiB 节省：1,013,926,168 bytes，约 967.0 MiB 降幅：约 98.9%这个结果和之前对超长 list 字段的抽样判断是一致的。也就是说，该表的大头确实来自超长列表字段，压缩后内存占用出现数量级下降。4. 几张典型表的压缩对比这次测试中，几张典型表的变化如下：表类型优化前优化后变化业务表 X977.3 MiB10.35 MiB节省 967.0 MiB业务表 H207.7 MiB58.1 MiB节省 149.5 MiB业务表 J192.0 MiB51.0 MiB节省 141.1 MiB业务表 B282.8 MiB274.5 MiB节省 8.3 MiB业务表 A499.6 MiB518.1 MiB增加 18.5 MiB这 5 张目标表合计：优化前合计：约 2158.4 MiB 优化后合计：约 912.0 MiB 净节省：约 1246.4 MiB 总降幅：约 57.7%从结果看：业务表 X 压缩非常成功，单表节省接近 1 GiB；业务表 H 和业务表 J 也非常适合 ETS compressed；业务表 B 收益很小；业务表 A 反而变大，不适合保留 compressed。四、ETS compressed 的性能代价ETS compressed 的收益并不是免费的。在业务表 J 的读写测试中，开启 compressed 后，内存收益明显，但读写性能会下降。以未压缩表作为基准，压缩表表现大致如下：命中读 lookup hit：慢约 24% - 29% 随机命中读 rand lookup：慢约 22% - 26% 未命中读 lookup miss：快约 27% - 31% 更新写 insert update：慢约 75% - 85% 写入覆盖类测试：慢约 74% - 83%可以总结为：内存节省非常明显，约 73.5% 命中读取性能下降约 20% - 30% 写入性能下降约 75% - 85% miss 读取略快，但通常业务价值不如 hit/read 和 write 重要所以对大 record、大 tuple、大 map 这类对象，ETS compressed 的典型特征是：很省内存；读稍慢；写明显更慢。因此，是否开启 compressed，不能只看内存收益，还要结合表的访问模式。更适合开启 compressed 的表：value 很大；读多写少；单条 term 重复度高；不在极致低延迟路径上；内存压力明显大于 CPU 压力。不适合开启 compressed 的表：value 很小；写入非常频繁；每次请求都高频命中读取；单条数据压缩收益有限；开启后内存不降反升。五、优化项与收益预估1. 压缩核心业务表 X 中的超长列表字段建议做法将核心业务表 X 中的超长 list 字段从直接存储 list，改为压缩后的 binary。示意结构：{compressed, Bin}写入时：Bin = term_to_binary(List, [compressed])读取时：List = binary_to_term(Bin)也就是说，整条记录可以保持压缩形态在业务流程中传递，只有访问该字段时才解压。这类字段级压缩比整表 compressed 更可控，适合特别明确的大字段。收益预估当前该核心业务表 X 全服可见占用：227.70 GiB按保守 80% 回收估算：227.70 GiB * 80% = 182.16 GiB如果按更谨慎的 50% 回收估算：227.70 GiB * 50% = 113.85 GiB因此，该项优化的预估收益区间为：约 114 GiB - 182 GiB其中，按 80% 回收估算是相对合理的保守目标；按 50% 回收估算则是非常谨慎的底线口径。从局部实测看，业务表 X 单节点压缩后降幅达到约 98.9%。全服估算继续使用 50% - 80% 的口径，属于偏保守估算。2. 对大 term ETS 表启用 compressed除了字段级压缩外，还可以对部分大 term 表启用 ETS compressed。适合的对象包括：存放大 record 的业务表；存放大 tuple 的业务表；存放大 map 的业务表；value 明显大于 key 和表结构开销的 ETS 表。局部实测中，业务表 J 内存约下降 73.5%；业务表 H 也从约 207.7 MiB 降到 58.1 MiB，节省约 149.5 MiB。但是，表级 compressed 需要逐表验证，不应全局盲开。尤其是小对象高密度表，可能出现不降反升：业务表 A： 优化前约 499.6 MiB 优化后约 518.1 MiB 增加约 18.5 MiB因此，ETS compressed 的使用原则应该是：大对象表优先测试 小对象高密度表谨慎使用 写频繁表谨慎使用 压缩后必须用真实数据回归内存和性能3. 调整 eheap_alloc 的 mbcs 策略建议做法在全服逐步启用：+MHlmbcs 1024该参数用于调整 eheap_alloc 中 mbcs carrier 的行为，目标是改善进程堆 allocator 的 carrier 利用率，减少高峰后遗留的大量低利用率 carrier。从已启用样本看，效果比较明显：mbcs block = 329.8 MiB mbcs carrier = 458.6 MiB waste = 128.8 MiB usage = 71.9%而未调整的同量级节点中，部分节点仍然存在：usage = 10% - 30% 单节点 waste = 500 MiB - 1 GiB收益预估基于已启用样本节点，对全服 eheap_alloc mbcs 做推断：很保守：约回收 46.22 GiB 保守：约回收 50.51 GiB 中性偏好：约回收 55.29 GiB对外建议采用更稳妥的口径：保守口径：46 GiB 中性口径：50 GiB这项优化不是业务数据本体压缩，而是减少 allocator carrier 浪费。因此它的收益不一定在所有节点上完全一致，但对高负载、高碎片节点更可能有效。六、两项主优化叠加后的收益这里先只按两项主优化估算：核心业务表 X 超长字段压缩；eheap_alloc 参数调整。按保守主口径合并：核心业务表 X 压缩：约 182 GiB eheap_alloc 参数调整：约 46 GiB 合计：约 228 GiB如果对核心业务表 X 压缩采用更谨慎的 50% 回收口径：核心业务表 X 压缩：约 114 GiB eheap_alloc 参数调整：约 46 GiB 合计：约 160 GiB因此，本轮两项主优化合计预计可回收：约 160 GiB - 228 GiB比较稳妥的容量收益目标，可以先按：约 200 GiB进行评估。需要注意，其他大 term 表开启 ETS compressed 的收益没有纳入这个主口径。原因是表级 compressed 需要逐表验证，不能直接按全服统一比例外推。七、容量价值估算这里计算的是“容量价值”，不是立即等同于账单下降。只有当优化结果能够带来以下变化时，才会转化为真实成本节省：降低机器规格；减少扩容需求；延后新增机器；提高单机承载密度；减少内存水位告警和 OOM 风险。按总节省：160 GiB - 228 GiB估算。按 15.75 元/GiB/月计算月度价值：约 2,520 - 3,591 元/月 年度价值：约 3.0 - 4.3 万元/年按 27.75 元/GiB/月计算月度价值：约 4,440 - 6,327 元/月 年度价值：约 5.3 - 7.6 万元/年按 200 GiB 保守主口径计算15.75 元/GiB/月： 约 3,150 元/月 约 3.78 万元/年 27.75 元/GiB/月： 约 5,550 元/月 约 6.66 万元/年八、结论这批 Erlang 节点的内存问题，主因已经比较明确：核心业务表 X 中存在超长 list 字段，导致单条记录过大，业务数据本体占用偏高；一些存放大 record、大 tuple、大 map 的 ETS 表，也存在明显压缩空间；eheap_alloc mbcs 存在明显 carrier 浪费，部分节点在进程堆分配高峰后利用率较低。从实测结果看：业务表 X： 优化前约 977.3 MiB 优化后约 10.35 MiB 节省约 967.0 MiB 降幅约 98.9%业务表 J 开启 ETS compressed 后：内存约降到原来的 26.5% 内存减少约 73.5%但 ETS compressed 并不适合所有表。对于小对象高密度表，压缩后可能不降反升。因此，本次优化可以拆成三类：对明确的大字段做字段级压缩；对大 term ETS 表逐表测试并启用 compressed；对高碎片节点调整 eheap_alloc mbcs 策略。预估收益方面，先只按两项主优化计算：核心业务表 X 压缩：约 114 GiB - 182 GiB eheap_alloc 参数调整：约 46 GiB - 50 GiB 合计：约 160 GiB - 228 GiB可以先按：约 200 GiB作为本轮优化的容量收益目标。折算容量价值：约 3,150 - 5,550 元/月 约 3.8 - 6.7 万元/年其中，核心业务表 X 字段压缩是主要收益项；ETS compressed 适合作为大 term 表的补充优化；eheap_alloc 参数调整则是低侵入、低风险的 allocator 层补充收益项。整体来看，这次优化值得做，且优先级较高。但具体落地时，不能简单地“所有 ETS 表都开启 compressed”，而应该按表的 value 大小、读写比例、压缩收益和性能代价逐表判断。
Needle：26M 参数超轻量级工具调用（Function Calling）模型Needle 是一项模型蒸馏的标志性成果——它将 Google Gemini 的工具调用能力压缩到仅 26M 参数的微型模型中。这意味着开发者可以在资源极度受限的边缘设备上部署高质量的函数调用能力，而无需依赖云端大模型。即使在 E5 + 1030 或单核 800M 内存的 VPS 上也能流畅运行。项目地址：https://github.com/cactus-compute/needle安装与快速启动1. 克隆仓库并安装git clone https://github.com/cactus-compute/needle.git cd needle source ./setup # 自动创建虚拟环境并安装依赖2. 启动 Playground（推荐）needle playground打开浏览器访问 http://127.0.0.1:7860首次运行会下载 needle.pkl（约 50MB）的模型文件到项目根目录的 checkpoints/Playground 提供 Web UI，可直接测试工具调用，也支持一键基于你的工具数据微调模型CLI 使用示例进入虚拟环境后，可直接使用 needle 命令：# 激活虚拟环境（如果未自动激活） source .venv/bin/activate单次推理（Run）needle run \ --checkpoint checkpoints/needle.pkl \ --query "I want to get a full system overview" \ --tools "[ {\"name\":\"mem\",\"description\":\"View Erlang VM memory usage. Returns byte counts for various memory types (total, processes, ETS, binary, etc.).\",\"parameters\":{}}, {\"name\":\"info\",\"description\":\"View comprehensive Erlang VM runtime information, including node name, scheduler count, process count/limit, ETS count/limit, and detailed memory usage.\",\"parameters\":{}}, {\"name\":\"top_mem\",\"description\":\"View the top 10 processes with highest memory usage. Returns PID, registered name, initial call, current function, memory, reductions, and message queue length for each.\",\"parameters\":{}}, {\"name\":\"top_queue\",\"description\":\"View the top 10 processes with longest message queues. Returns PID, registered name, initial call, current function, memory, reductions, and message queue length.\",\"parameters\":{}}, {\"name\":\"top_reds\",\"description\":\"View the top 10 processes with highest reductions (execution units). Returns PID, registered name, initial call, current function, memory, reductions, and message queue length.\",\"parameters\":{}}, {\"name\":\"top_bin\",\"description\":\"View the top 10 processes with highest binary memory usage. Returns PID, registered name, initial call, current function, memory, reductions, and message queue length.\",\"parameters\":{}}, {\"name\":\"ets\",\"description\":\"View ETS table information sorted by memory usage (top 20 tables). Returns table name, memory (in words), and size (number of entries).\",\"parameters\":{}}, {\"name\":\"mem_detail\",\"description\":\"View detailed Erlang VM memory allocation including allocator states: VM allocated memory, per-type usage, and allocator fragmentation details.\",\"parameters\":{}} ]"重要提示：--query 和 --tools 目前必须使用英文，中文输入可能会导致输出乱码。--tools 参数需传入合法的 JSON 数组字符串（每个工具包含 name、description、parameters）。示例输出（模型会自动决定调用哪个工具）：<tool_call>[{"name":"info","arguments":{}}]实际使用注意事项模型体积小：26M 参数，推理速度极快，适合边缘设备、嵌入式系统、低配服务器。专精工具调用：在单次函数调用任务上表现优秀，适合构建本地 Agent。支持微调：通过 Playground 或 needle finetune 命令，使用自己的工具数据集即可快速微调，生成专属模型。中文支持：当前版本对中文 query 会直接乱码，推荐使用英文提示词以获得最佳效果。适用场景低资源设备上的本地 Agent隐私敏感场景（完全离线运行）嵌入式系统、智能硬件的工具调用快速原型验证工具调用逻辑
游戏开发中模糊需求的应对：用「最小原型 + 反向校准」替代传统前置对齐背景：我们一直面临的沟通困境在游戏功能开发中，策划经常会给出一份相对模糊的需求文档。程序员阅读后往往无法完全把握核心意图，尤其是一些涉及服务端状态流转、边界处理、异常路径、权限控制、跨系统协作的内容，很难通过纯语言描述清楚。传统流程通常是：策划输出文档程序员（或 AI）尝试理解并实现双方花费大量时间进行语言层面的对齐即使对齐了，实际做出来也经常偏离预期这种「信息传递损耗」几乎是不可避免的。无论程序员如何仔细阅读、如何用 chain-of-thought 约束 AI，最终还是容易因为隐式假设、状态边界理解偏差而返工。我的思考：反向校准是否比纯前向对齐更有效？我提出了一种反向处理的思路：不强求在编码前把需求完全说清楚，而是先基于原始文档快速落地一个最小可运行的原型，再用这个原型作为媒介，反向和策划进行对齐。核心逻辑是：代码是可执行的、具体的，比抽象文档更能暴露理解偏差。策划看到实际运行效果后，反馈会更精准。程序员也不用在前期和策划进行大量低效的语言拉锯。这种方式把「沟通媒介」从纯语言升级为「可运行原型 + 结构化分析报告」，在需求天然模糊的游戏开发领域，性价比更高。工具选择：Superpowers + OpenSpec 的组合要让这个思路真正可落地，我选择了目前开源生态中比较成熟的两个工具组合：Superpowers：一个 agentic skills 框架，能给 AI 编码代理提供结构化的开发流程（计划、TDD、代码审查、子代理执行等），属于 execution 层。OpenSpec：一个轻量级的 Spec-Driven Development 框架，负责把需求结构化成可追溯的变更文件夹（proposal、specs、design、tasks），属于 spec 管理层。两者可以很好地协同使用。推荐的实际工作流（针对策划需求模糊场景）经过讨论，我们最终形成了一个更务实的混合流程：阶段 1：基于原始文档生成最小可运行原型策划给出初始（可能模糊）的需求文档。程序员不强行先用 OpenSpec 做完整前置 spec。直接使用 Superpowers + Code Agent（Claude Code、Codex 等），基于原始文档生成一个最小可运行的框架（能把核心状态流转、关键接口、主要业务流程跑起来即可）。这一步的目标是「可验证」，而不是「完整」。阶段 2：Agent 进行反向校准让 Agent 以「原始文档 + 已生成的原型代码」为输入，进行反向分析。输出结构化报告，包含：原型实际实现了什么（便于策划理解）与原始文档的偏差点文档未说明但代码必须决定的隐式假设边界处理、异常路径、状态一致性等存在风险的位置需要向策划澄清的具体问题清单阶段 3：用原型 + 报告与策划进行对齐把最小原型实际跑给策划看。结合反向校准报告，针对具体问题点进行讨论。这一步的核心价值是：把「文档没写清楚、状态流转不明确、边界缺失、异常情况未定义」这类问题，明确地转移给策划承担，而不是让程序员一直猜测和背锅。阶段 4：对齐后用 OpenSpec 固化需求对齐完成后，使用 OpenSpec 生成正式的 proposal.md、specs/、tasks.md 等结构化文档。后续精炼实现时，就基于这个确认过的 spec 进行。这种方式的核心价值1. 更好的职责划分程序员的主要职责是「把当前理解执行成可验证的原型 + 用工具结构化地暴露偏差」。策划的职责是「通过原型确认和补充需求」。双方都不需要在信息严重不对称的情况下做出过重承诺。2. 降低沟通成本用「可运行原型」作为媒介，比纯语言对齐效率更高、误解更少。3. 减少程序员无效精力消耗程序员不需要在前期花费大量时间去「猜」策划到底要什么，而是通过执行来暴露问题。4. 更适合游戏服务端开发的现实很多服务端需求本身就涉及复杂状态、异步流程、跨系统协作与边界控制，仅靠文档很难一次性描述完整。这种方式更尊重这个现实。适用场景与注意事项适合场景：需求具有较高不确定性（系统功能、服务端逻辑、状态流转、活动框架等）策划难以在编码前给出清晰确认团队希望减少前期无效对齐时间需要注意的点：最小原型的质量仍然重要，不能完全放飞。即使使用 Superpowers，也需要程序员对 Agent 的输出进行判断和把关。如果团队流程强行要求「所有需求必须先评审确认」，则需要先解决流程层面的阻力。反向校准不是用来逃避沟通，而是用更好的媒介进行更高效的沟通。总结在 AI 编码能力已经比较强的今天，「先快速做出最小可验证原型 → 用结构化方式反向校准 → 再固化需求」这种模式，比传统「强求前期完全确认」在很多游戏服务端开发场景下更加务实。它不是在回避沟通，而是在用执行 + 工具把原本模糊的沟通，变成了更具体、可讨论、可追溯的过程。这或许是当前阶段，结合 Superpowers 和 OpenSpec 能做到的、比较高明的职责划分方式。参考工具：Superpowers: https://github.com/obra/superpowersOpenSpec: https://github.com/Fission-AI/OpenSpec
这一章节只是简单教一下内存子系统相关的知识，如果想更深入学习内存子系统可以读一下 "Understanding the Linux Kernel" or "Linux System Programming"这一章节主要是教两种动态可分配内存：堆、内存映射段本章节把堆叫做C-heap，以此区分erlang进程里面的堆；本章节把栈叫做C-stack；本章节把内存映射段（memory mapped segment）叫做segmentC-heap：通过malloc来分配C-heap的内存segment：通过mmap来分配segment的内存11.1 内存子系统有关erts_alloc的概述，可以看文档：erts\_alloc — erts v16.2目前有11种不同的分配器、6种不同的分配策略和18种以上的其他不同设置，其中一些设置采用任意数值。为了能够以任何有意义的方式使用这些设置，我们必须了解这些分配器是如何工作的，以及每个设置如何影响分配器的性能。erts_alloc手册警告：只有绝对明白你在干嘛的时候再调整这些参数，不合适的设置会导致严重的性能衰减甚至系统崩溃。11.2 不同类型的内存分配器详细了解内存是如何分配的,以及不同的分配器是如何工作的，关于系统可能遇到的问题以及如何分析和纠正行为的精彩故事,请阅读 Fred Hébert 的文章 "Troubleshooting Down the Logplex Rabbit Hole" https://blog.heroku.com/archives/2013/11/7/logplex-down-the-rabbit-hole内存分配器：管理特定类型内存的分配和释放。每个分配器都针对特定类型的数据,并且通常针对一种大小的数据进行专门化。每个内存分配器都实现了分配器接口,该接口可以使用不同的算法和设置来进行实际的内存分配。这么多不同的内存分配器的目的：是通过将相同大小的分配分组来减少碎片,并通过使频繁的分配成本降低来提高性能。有两种特殊的、基础的或通用的内存分配器类型：sys_alloc 和 mseg_alloc,以及通过 alloc_util 框架实现的九个特定分配器。表 1. 内存分配器列表名称描述C-nameType-nameFlag基础分配器malloc 接口sys_allocSYSTEMY内存段分配器mmap 接口mseg_alloc-M临时分配器临时分配temp_allocTEMPORARYT堆分配器Erlang 堆数据eheap_allocEHEAPH二进制分配器二进制数据binary_allocBINARYBETS 分配器ETS 数据ets_allocETSE驱动分配器驱动数据driver_allocDRIVERR短生命周期分配器短生命周期内存sl_allocSHORT_LIVEDS长生命周期分配器长生命周期内存ll_allocLONG_LIVEDL固定分配器固定大小数据fix_allocFIXED_SIZEF标准分配器大多数其他数据std_allocSTANDARDD字面量分配器模块常量literal_allocLITERAL(none)11.2.1. 基础分配器:sys_alloc不能被禁用，如果没指定用什么allocator，默认使用这个，基本上是对底层操作系统 libc 中 malloc 实现的直接映射。所有特定分器都使用 sys_alloc 或 mseg_alloc 根据需要从操作系统分配内存。当从操作系统分配内存时,sys_alloc 可以向请求的数量添加(填充)固定数量的千字节。这可以通过过度分配内存来减少系统调用的次数。默认填充为零。当释放内存时,sys_alloc 会在进程中保留一些空闲内存。这个空闲内存的大小称为修剪阈值(trim threshold),默认值为 128 kb。这也减少了系统调用的次数,但代价是更高的内存占用。这意味着如果您使用默认设置运行系统,您可能会遇到 Beam 进程不会在释放内存时立即将内存返回给操作系统的情况。sys_alloc 分配的内存 存储在 beam 进程的 C 堆中,该堆会根据需要通过 brk 系统调用增长。11.2.2. 内存段分配器:mseg_alloc如果底层操作系统支持 mmap,特定的内存分配器可以使用 mseg_alloc 而不是 sys_alloc 从操作系统分配内存。通过 mseg_alloc 分配的内存区域称为段(segments)。当释放一个段时,它不会立即返回给操作系统,而是保存在段缓存中。当分配一个新段时,如果可能的话会重用缓存的segment,即如果它的大小相同或大于请求的大小,但又不太大。absolute_max_cache_bad_fit 的值决定了被认为不太大的额外大小的千字节数。默认值为 4096 千字节。为了不对非常小的分配重用 4096 千字节的段,还有一个 relative_max_cache_bad_fit 值,它声明如果缓存的段大于百分比就不能使用。默认值为 20%。也就是说,当请求 10 KB 的段时,可以使用 12 KB 的段。缓存中的条目数默认为 10,但可以设置为从零到三十的任何值。11.2.3. 内存分配器框架:alloc_util建立在 sys_alloc 和 mseg_alloc 之上的就是这个框架 alloc_util用于为不同类型的使用和数据实现特定的内存分配器。该框架在 erl_alloc_util.[ch] 中实现,ERTS 使用的不同分配器在目录 "erts/emulator/beam/" 中的 erl_alloc.types 中定义。在 SMP 系统中,通常每个调度器线程（Scheduler thread）有一个每种类型的分配器。分配器可以使用的最小内存单元称为块(block)。当您调用分配器来分配一定量的内存时,您得到的是一个块。当您想要释放内存时,您作为参数提供给分配器的也是块。分配器并不直接从操作系统要到block。相反,分配器从操作系统分配一个载体(carrier),可以通过 sys_alloc 或通过 mseg_alloc,后者又使用 malloc 或 mmap。如果使用 sys_alloc,载体被放置在 C 堆上;如果使用 mseg_alloc,载体被放置在段中。多块载体（multi block carrier） ：用来放小块的，可以放多个块；大多数分配器还有一个"主多块载体",它永远不会被释放；适合频繁的、较小的分配，默认情况下,分配器通常以大约 8 MB 的块请求多块载体(可通过系统标志调整),有效地处理常见的 Erlang 内存模式。单块载体（single block carrier）：较大的块放在这里，只能放一个块；超过单块载体阈值的分配被放置在直接从操作系统分配的单块载体中。单块载体阈值(singleblock carrier threshold, sbct)：一个用来决定什么块是大块或是小块的边界，大于 sbct 值的分配使用单块载体,而较小的分配使用多块载体；默认情况下,sbct 阈值设置为较大的分配,那些明显大于典型 Erlang 术语的分配,被隔离在单块载体中。较小的对象,构成大多数分配,有效地共享多块载体中的空间；何时调整 sbct：程序如果有不太正常的内存分配，这个sbct是可以调整的，调整它会影响运行时系统如何平衡碎片(由多块载体中的大分配引起)和开销(由于许多单块载体)之间的平衡；增加 sbct 会将更多分配引导到多块载体中,改善内存重用并减少碎片。如果您的应用程序频繁分配中等大小的数据结构,导致碎片或频繁的操作系统级内存请求,这尤其有益。然而,降低 sbct 会强制更多分配进入单块载体,当您的应用程序偶尔分配大内存块时,这是有意义的。单独管理这些大分配简化了它们的回收,并防止与较小分配的干扰。如果单独调整 sbct 不能解决频繁的次要垃圾收集——通常由于大量短期分配——考虑增加进程的初始堆大小(min_heap_size)以减少分配流失。在实践中,默认的 sbct 设置适合大多数 Erlang 应用程序。只有在性能分析指出碎片、内存开销或不寻常的分配模式的特定问题时才微调此参数。大多数分配如果都是小块存储在多块载体里面是更好的：多块载体因其对典型 Erlang 工作负载的效率而受到青睐——许多小的、短暂的分配。HiPE 团队的研究表明,大多数 Erlang term都很小(少于八个字),很好地适合这些多块载体。典型的 ERTS 分配器通常维护:• 每个分配器一个主多块载体,用于处理频繁的、小尺寸的分配。它很少释放这个载体,在内部重用释放的块以减少碎片。• 多个单块载体,每个为大对象或二进制文件单独分配。一旦这些块被释放,单块载体会立即返回给操作系统。块、载体和分配策略：为了在多块载体中找到空闲的内存块,会使用分配策略。每种类型的分配器都有默认的分配策略,但您也可以使用 as 标志设置分配策略。Erlang 运行时系统应用程序参考手册列出了以下分配策略:• 最佳拟合(Best fit):找到满足请求块大小的最小块。(bf)• 地址顺序最佳拟合(Address order best fit):找到满足请求块大小的最小块。如果找到多个块,选择地址最低的那个。(aobf)• 地址顺序首次拟合(Address order first fit):找到满足请求块大小的地址最低的块。(aoff)• 地址顺序首次拟合载体最佳拟合(Address order first fit carrier best fit):找到可以满足请求块大小的地址最低的载体,然后使用"最佳拟合"策略在该载体内找到一个块。(aoffcbf)• 地址顺序首次拟合载体地址顺序最佳拟合(Address order first fit carrier address order best fit):找到可以满足请求块大小的地址最低的载体,然后使用"地址顺序最佳拟合"策略在该载体内找到一个块。(aoffcaobf)• 良好拟合(Good fit):尝试找到最佳拟合,但在有限的搜索中满足于找到的最佳拟合。(gf)• A 拟合(A fit):不搜索拟合,只检查一个空闲块以查看它是否满足请求。此策略仅用于临时分配。(af)分配策略的选择影响空闲内存的重用效率,并直接影响碎片和性能。大多数分配器的默认策略通常是最佳拟合(bf)或地址顺序最佳拟合(aobf),它在内存利用率和分配速度之间取得平衡。可以为每个分配器配置替代策略(使用 +M \<S\> as 系统标志)。选择不同的策略可以缓解内存碎片,但代价是在分配搜索期间更高的 CPU 开销。11.2.4. 临时分配器:temp_alloc您可以使用 temp_alloc 作为一个小的暂存或工作区,在函数中执行某些工作时使用。将其视为 C 栈的扩展,并以相同的方式释放它。也就是说,为了安全起见,在进行分配的函数返回之前释放由 temp_alloc 分配的内存。erl_alloc.types 中有一条注释说,您应该在模拟器开始执行 Erlang 代码之前释放 temp_alloc 块。请注意,在与分配器相同的调度器上运行的任何 Erlang 进程在块被释放之前都不能开始执行 Erlang 代码。这意味着您不能在 BIF 或 NIF 陷阱(yield)上使用临时分配。在默认的 R16 SMP 系统中,有 N+1 个 temp_alloc 分配器,其中 N 是调度器的数量。temp_alloc 使用"A 拟合"(af)策略。由于 temp_alloc 的分配模式基本上是栈的模式(大小大多为 0 或 1),这个策略运行良好。11.2.5. 堆分配器:eheap_alloc堆分配器管理进程私有数据的内存块:存储在进程堆(新生代和老生代)上的 Erlang term,以及相关结构如堆片段和 beam 寄存器。进程创建的几乎每个 Erlang term最终都在通过 eheap_alloc 获得的内存。默认情况下,每个调度器都有一个 eheap_alloc 实例,因此在该调度器上运行的进程的内存保持大部分本地化,减少了争用。分配器的频繁任务包括:• 进程堆:每个 Erlang 进程都有自己的堆,在其中存储数据,如元组、列表、映射、整数和任何小二进制文件(≤ 64 字节)。• 堆片段:如果进程短暂需要更多内存但不能立即进行 GC(例如,构建大消息),VM 可能会从 eheap_alloc 分配"堆片段"。在下一次垃圾收集时,这些片段被合并或释放。• 寄存器数组:一些运行时实现细节(例如,"beam_registers"数据结构)也使用 eheap_alloc。作为 Erlang 开发人员,您通常通过控制进程堆大小或了解进程何时生成大量短暂数据来优化 eheap_alloc 的使用。良好的实践包括如果进程频繁处理大数据,则仔细调整进程堆的大小,而不是允许许多次要 GC。11.2.6. 二进制分配器:binary_alloc二进制分配器用来处理二进制文件的内存。具体来说,它管理大于 64 字节的二进制文件(称为 refc 二进制文件),将它们存储在堆外并使用引用计数来跟踪它们的使用。每个进程堆只保存一个小包装器(ProcBin)指向实际的二进制文件。这些二进制文件差异很大,从适度大小的二进制文件(数百字节)到以兆字节为单位的巨大二进制文件(如整个文件内容或来自网络的外部数据)。分配器有几个有趣的特性:• 最佳拟合策略:它选择最小的合适空闲块来存储新的二进制文件,这减少了碎片,可以将其视为一个细心的俄罗斯方块玩家,将二进制文件整齐地放入内存中。• 引用计数:堆外二进制文件会一直存在,直到最后一个进程停止引用它们。如果只有一个被忘掉的进程还引用了一个巨大的二进制文件,它会一直存活,有时就很烦。• 子二进制(切片):当您匹配二进制文件如 <<Rest/binary>> 时,较小的二进制文件(Rest)仍然引用原始的更大二进制文件,避免复制是很好的,直到您意识到您不小心让一个巨大的二进制文件存活只是为了引用一小部分。在这些情况下,调用 binary:copy/1 是比较好用。如果内存神秘地增长,检查由于引用在进程中徘徊而挂起的大二进制文件。偶尔,您可能需要温柔地提醒垃圾收集器(通过 erlang:garbage_collect/1)或让进程休眠以更快地回收内存。11.2.7. ETS 分配器:ets_alloc分配器 ets_alloc 管理您心爱的 ETS(Erlang term Store) Table的内存。默认情况下,ETS 表不绑定到单个进程,这意味着即使您的进程清理垃圾,它们也会存在。请记住几个关键细节:• 长期存在的数据:一旦您将数据放入 ETS 表中,它就会从进程堆中移出并进入自己合适的分配器。标准进程垃圾收集不会清理此空间。如果您想要内存返回,您必须显式删除数据或删除表。• 多样化的用例:此分配器处理与 ETS 相关的所有内容——从经典哈希表到 ordered_set 结构和内部元数据。常用的或大型表很容易成为猛吃内存的地方,所以要小心。• 短期存在的 ETS 数据:有时 ETS 可能会暂时借用其他分配器,用于快速任务如匹配或中间结果,但主要数据存在于 ets_alloc 中。因为 ETS 表可能会增长到令人惊讶的大,偶尔使用诸如 ets:info(Tab, memory) 之类的函数检查它们的大小,或通过系统指标关注整体 ets_alloc 使用情况。11.2.8. 驱动分配器:driver_alloc驱动分配器处理端口、链入驱动程序和 NIF 资源的内存。简单来说,如果您超出了 Erlang 的安全网（提供的自动内存安全机制）——与 I/O 驱动程序、外部库或文件描述符交互——您最终会到这里。要记住的关键点:• 端口和驱动数据:这里的分配包括网络套接字、打开的文件描述符（fd）和特定于您的链入驱动程序的缓冲区的结构。• NIF 分配的数据:当 NIF 使用 enif_alloc 时,内存最终来自此分配器。VM 礼貌地等待,直到您调用 enif_free,或您的 NIF 对象优雅地退出舞台,以回收此空间。• 外部泄漏的可能性:因为 NIF 和驱动程序绕过 Erlang 通常的内存安全规则,行为不当的驱动程序可能会无意中保留内存,造成数字等价物的漏水龙头。密切关注您的 driver_alloc 使用情况有助于捕获这些滴漏。虽然开发人员很少直接操作 driver_alloc,但在生产环境中监控此分配器是明智的,尤其是如果您使用 NIF**。%% 在 Erlang 的安全网内 - 自动管理List = [1, 2, 3], % 内存自动分配Binary = <<1, 2, 3>>. % 会被GC自动回收%% 超出 Erlang 的安全网 - 需要手动管理%% 使用 NIF（Native Implemented Function）my_nif:external_function() % 调用C代码因为使用 driver_alloc（驱动分配器）意味着：风险更高：Erlang 的 GC 无法帮你清理这些内存责任转移：内存管理的责任从 Erlang VM 转移到了你（程序员）可能出错：如果 NIF/驱动程序有 bug，忘记释放内存，就会造成内存泄漏11.2.9. 短生命周期分配器:sl_alloc短生命周期分配器(sl_alloc)处理数据结构的内存,其生命周期超过了临时分配的一下就没了（blink-and-you-miss-it）的性质,但也不会差太多。可以将其视为存在时间足够长以打个招呼并快速喝杯咖啡然后离开的内存。典型示例包括:• 中间缓冲区:短操作所需的小缓冲区,在调度点之间短暂徘徊——但绝对不打算过夜。• 短暂列表:临时运行时结构——如快速系统消息缓冲区或短暂的调度元数据——几乎在您注意到它们时就消失了。Erlang/OTP 利用 sl_alloc 进行瞬态操作,如匹配状态对象、短暂的 I/O 缓冲区和其他短暂的实体。虽然这些分配可以比真正的临时内存存活更久,但它们仍然会迅速消失。如果您的应用程序特别健谈（chatty）——生成大量短期分配(如频繁的小驱动程序调用或简短计算),sl_alloc 可能会显著升温。使用诸如 recon_alloc:usage() 之类的工具检查使用情况可以告诉您 sl_alloc 是否被过度使用。11.2.10. 长生命周期分配器:ll_alloc长生命周期分配器处理打算长期存活的数据,通常与 Erlang 节点本身一样长。典型示例包括:• 原子:一旦创建,原子将无限期地持续存在,使它们成为 ll_alloc 的永久居民。• 加载的模块和代码:编译的模块、导出的函数和与匿名函数(funs)相关的元数据存储在这里。例如,当您通过 code:load_file(my_module) 加载模块时,其元数据会进入 ll_alloc。• 调度器和系统结构:内部运行时结构——如调度器运行队列(run_queue)、pollset 信息(pollset)和进程注册表(proc_tab)——存在于这里,因为它们是 VM 操作的基础。因为 ll_alloc 中的对象倾向于存在,所以此分配器通常在节点的生命周期内缓慢但持续地增长。如果您的系统频繁加载和卸载模块,您可能会看到波动。例如,重复执行热代码加载而不正确卸载旧版本可能会逐渐增加 ll_alloc 中的内存使用。在实践中,大多数系统不会从这里回收太多内存,除非您显式卸载模块或执行节点重启。因此,偶尔浏览 recon_alloc:usage(ll_alloc) 以检测意外峰值或碎片是明智的——尤其是如果您的应用程序动态管理模块或大量长期数据。11.2.11. 固定大小分配器:fix_alloc固定分配器 fix_alloc 专门用于分配固定大小的对象——通常是大小永远不会改变的小 C 结构(例如,消息引用、驱动事件数据、监视器)。由于这些对象具有统一的大小,分配器可以有效地处理它们:默认情况下,fix_alloc 使用"地址顺序最佳拟合",将释放的对象整齐地返回到相同大小块的列表中,有效地最小化碎片。可以将其想象为完美地堆叠相同的乐高积木,而不是随机地将不同大小的碎片扔进盒子里。示例包括内部 VM 结构,如 ErlMessage、监视器引用和调度器簿记数据。虽然开发人员通常不会直接与 fix_alloc 交互,但它在系统级别至关重要。这里的高效分配确保运行时不会因管理微小的碎片分配而陷入困境,帮助 Erlang 保持其优雅处理并发的名声。11.2.12. 标准分配器:std_alloc当内存不能整齐地适合 Erlang 的专用分配器桶（Erlang’s specialized allocator buckets）时,它会在 std_alloc 中找到一个家——运行时的多功能万能分配器。可以将其视为 Erlang 的内存"杂项抽屉"。此分配器处理各种各样的分配。这可以包括对模糊短期或长期存在的短暂数据的引用、动态大小的结构、没有明确分类的 VM 子系统数据,或者只是太独特而无法整齐地分类到其他地方的内存分配。就像其他分配器一样,您可以使用启动标志(如 +Ms 或 +Msbct)调整 std_alloc 行为。通常,不用去动他,但知道如果要改的话这里是可以调的也挺好。在故障排除中,std_alloc 可能成为无法解释的内存峰值的主要嫌疑。诸如 erlang:system_info({allocator, std_alloc}) 或 recon_alloc 之类的工具可以快速揭示它是否囤积了比您预期更多的内存。由于 std_alloc 收集那些无法整齐分类的内存请求,它在繁忙的节点上积累大量使用是正常的。如果您看到此分配器持续增长,这通常是一个信号,需要仔细检查应用程序行为或重新审视您对"正常"的假设。11.2.13. 字面量分配器:literal_allocliteral_alloc 存储编译时常量,通常称为字面量池,在加载的 Erlang 模块中。可以将其视为 VM 的"只读内存架",Erlang 在那里安全地存储常量,如在编译时定义的大静态二进制文件、元组或列表。与典型的分配器不同,literal_alloc 是全局管理的,而不是每个调度器管理的,因为字面量在运行时不经常修改或回收。一旦加载,这些字面量会一直存在,直到它们相应的模块被显式清除或重新加载。监控 literal_alloc 通常是平淡无奇的,但频繁的动态模块更新或重复加载包含大量字面量的模块可能值得一看。11.11 建议• 除非出现问题,否则从默认设置开始。• 在调优之前监控分配器使用情况(erlang:system_info/1, recon_alloc)。• 在受控环境中逐步测试更改。• 没有基准测试和性能分析,避免激进的调优。上述大多数内存分配器标志高度依赖于实现。它们的行为、可用性和默认值可能会在没有事先通知的情况下更改或完全删除。此外,运行时(erts_alloc)可能会根据内部启发式或系统约束忽略或调整提供的设置。始终使用实际系统指标和测试来验证设置。11.12 进程内存正如我们在第3章中看到的,进程实际上只是一些内存区域,在本章中我们将更仔细地研究栈、堆和邮箱是如何管理的。栈和堆的默认大小是233个字(words)。可以在启动Erlang时通过+h标志全局更改此默认大小。您还可以在使用spawn_opt启动进程时通过设置min_heap_size来设置最小堆大小。正如我们在第4章中看到的,Erlang term是带标签的,当它们存储在堆上时,它们要么是cons单元,要么是装箱对象(boxed objects)。11.12.1 Term共享堆上的对象在一个进程的上下文context中通过引用传递。如果您使用元组作为参数调用一个函数,那么只有对该元组的带标签引用会传递给被调用的函数。当您构建新term时,您也只会使用对子术语的引用。这很好,因为它成本低且使用的空间很少。但是,如果您将元组发送到另一个进程或执行任何其他类型的IO,或任何导致所谓深拷贝的操作,那么数据结构将被展开。因此,如果我们将元组T发送到另一个进程P2(P2 ! T),那么T2的堆将获得一个元组,其中第一个元素指向字符串的一个副本,第二个元素指向另一个副本,使用的空间翻倍。您可以在下面的消息传递部分中看到这个结果。如果您有嵌套的共享元组,这种深拷贝时的复制将随着嵌套级别呈指数增长。您可以通过展开高度共享的术语快速使Erlang节点崩溃,参见share.erl。11.12.2 消息传递当进程P1向另一个(本地)进程P2发送消息M时,进程P1首先计算M的扁平大小(flat size)。然后它通过在本地调度器上下文中对heap_frag执行heap_alloc来分配该大小的新消息缓冲区。然后P1开始向P2发送消息M。erl_message.c中的代码首先计算M的扁平大小(在我们的示例中为23个字)。然后(在SMP系统中)如果它可以对P2加锁并且P2的堆上有足够的空间,它将把消息复制到P2的堆上。如果P2正在运行(或退出)或堆上没有足够的空间,则分配一个新的堆片段(大小为 ErlHeapFragment的大小 - Eterm的大小 + 23*Eterm的大小),初始化后将如下所示:erl_heap_fragment:ErlHeapFragment* next; NULLErlOffHeap off_heap:erl_off_heap_header* first; NULLUint64 overhead; 0unsigned alloc_size; 23unsigned used_size; 23Eterm mem[1]; ?... 22 free words然后消息被复制到堆片段的mem部分,并更新first指针。在任何一种情况下,都会分配一个新的邮箱(ErlMessage),对接收者加锁(ERTS_PROC_LOCK_MSGQ),并将堆上或新堆片段中的消息链接到邮箱中。然后邮箱被链接到接收者的输入消息队列(msg_inq)中,并释放锁。11.12.3 二进制内部有四种二进制类型，三种是：堆二进制、子二进制（Sub Binaries）、匹配上下文，这些是存储在本地堆上的，由垃圾回收器和消息传递处理对待其他对象一样处理，并且根据需求进行复制。引用计数（reference counting）第四种就是：特别存储在进程堆外部的大二进制或引用二进制（large binaries或refc binaries），这些是被引用计数的（用来管理大型二进制数据的生命周期）。存储方式：refc binary的有效载荷（payload）存储在进程堆之外，由binary allocator分配的内存中。进程堆上只存储一个小的引用结构，称为ProcBin。引用计数机制：每当通过ProcBin引用一个refc binary时，该binary的引用计数就会增加1所有进程堆上的ProcBin对象都链接在一个链表中垃圾回收后，会遍历这个链表，对于每个已死亡的ProcBin，将binary的引用计数减1当引用计数降到0时，该binary就会被释放优势：这使得发送大型二进制数据到其他进程时非常高效，因为不需要复制整个binary，只需要复制小的ProcBin引用。潜在问题：由于引用只有在垃圾回收时才会被检测为"死亡"，大型binary可能会在所有引用实际死亡后仍然存活很长时间，直到所有见过该binary的进程都进行了垃圾回收。不幸的是，作为开发者，并不是很容易看出哪些进程在垃圾回收意义上"见过"一个二进制。例子：在这段代码中，有一个不需要进行垃圾回收的循环示例（完整示例见lb列表）：loop(Workers, N) ->receiveWorkItem ->Worker = lists:nth(N+1, Workers),Worker ! WorkItem,loop(Workers, (N+1) rem length(Workers))end.这个服务器会不断抓取对二进制的引用而永远不释放它们，最终会耗尽所有系统内存。当意识到这个问题后，修复起来很容易。可以在循环的每次迭代中执行garbage_collect，或者每隔五秒左右执行一次，方法是在receive中添加一个after子句：after 5000 → garbage_collect(), loop(Workers, N)子二进制和模式匹配（Sub Binaries and Matching）当你匹配出二进制的一部分时，你会得到一个子二进制。这个子二进制是一个小结构，只包含指向真实二进制的指针。这会增加原来的大二进制的引用计数，但只使用很少的额外空间。如果匹配操作要创建被匹配部分的新副本，那将会消耗空间和时间。因此，在大多数情况下，只对二进制进行模式匹配并获得一个子二进制来处理，正是你想要的。但存在一些退化情况。例如，想象你将一个巨大的文件（比如一本书）加载到内存中，然后匹配出一小部分（比如一个章节）来处理。问题是，在你处理完这个章节之前，整本书的其余部分仍然保留在内存中。如果你对许多书都这样做，也许你想获取文件系统中每本书的引言，那么你会将每本书的全部内容保留在内存中，而不仅仅是引言章节。这可能导致巨大的内存使用。解决方案是，当你知道只想要大型二进制的一小部分，并且希望这个小部分保留一段时间时，使用binary:copy/1。这个函数只用于其副作用，即实际将子二进制从真实二进制中复制出来，移除对较大二进制的引用，从而有望让它被垃圾回收。在Erlang文档中有关于二进制构造和匹配如何完成的相当详尽的解释：http://www.erlang.org/doc/efficiency_guide/binaryhandling.html11.13 其他有趣的内存区域11.13.1 原子表（The atom table）Erlang中的原子（Atoms）在内部实际上是用整数来表示的。所有原子都存储在一个称为原子表的全局结构中。原子表是一个固定大小的结构，这意味着运行中的Erlang系统中可以存在的原子数量有上限（默认为1,048,576个原子）。虽然这听起来是个很大的数字，但粗心的使用（特别是从外部数据动态创建原子）可能导致原子耗尽，进而导致整个BEAM虚拟机崩溃。原子表中的每个条目都包含关于原子的元数据，包括：字符串表示（原子本身的文本）运行时系统使用的唯一内部标识符（internal identifier）附加信息，如引用计数和在模块或函数中的使用详情Erlang通过三种关键的内存分配器类型来维护原子：atom_text：包含原子的字符串表示，存储原子的实际文本atom_tab：存储原子表本身——一个用于快速查找的哈希表结构atom_entry：为每个原子的元数据分配内存（内部表示、使用计数等）重要特性：原子永远不会被垃圾回收。 一旦创建了原子，它会一直存在直到虚拟机关闭。这个设计决策简化了实现（并提高了查找性能），但也带来了风险：不受控制的原子创建（通常通过list_to_atom/1等动态原子生成）可能导致原子表耗尽。一旦达到原子限制，尝试创建新原子会导致运行时错误，可能导致节点崩溃。常见问题场景：直接将用户提供的数据转换为原子解析大量不可信的外部输入在循环或递归函数中重复生成原子避免问题的方法：验证输入：在转换为原子之前，始终验证或白名单（whitelist）用户输入优先使用其他类型：尽可能使用现有原子，或者如果需要动态标识符，优先使用二进制或字符串监控使用情况：定期使用Observer工具或内置函数监控原子表使用情况：erlang:system_info(atom_count) - 当前加载的唯一原子数量erlang:memory(atom) - 原子使用的总字节数（包括开销）erlang:memory(atom_used) - 仅实际原子字符串使用的字节数如果达到原子限制，有两个实用解决方案：增加原子表大小（这通常是短期权宜之计，不应替代良好的原子使用习惯）：erl +t <new_max_atoms>重新设计应用程序以避免无限制创建原子——通常使用二进制、字符串或整数标识符代替安全转换方法：使用list_to_existing_atom/1函数可以安全地将字符串转换为原子，而不会有原子耗尽的风险。此函数仅在原子已存在时才会成功。11.13.2. 代码区（Code）另一个重要的内存区域是代码区，编译后的Erlang模块会加载到这里。Erlang模块一旦编译，就会加载到Erlang运行时系统的这个代码内存区域中，该区域在所有进程之间共享。代码区通常是静态（static）和持久的（persistent），因为模块会一直保持加载状态，除非显式卸载或替换（通过热代码加载）。版本管理：当使用l(Module)或code:load_file(Module)等函数加载或重新加载模块时，旧代码不会立即删除，而是作为"旧"版本保留，直到没有进程引用它。Erlang同时维护每个模块的两个版本。这允许安全升级而不会中断运行中的进程。常量池：在Erlang代码中定义的常量（如数字、原子和二进制）存储在模块代码段内的常量池中。这些常量在单个模块内的内存使用效率很高，因为它们只存储一次。但是，当常量在模块外部使用时（如消息传递或插入ETS表），它们会被复制到接收进程的堆上，可能会显著增加总体内存使用量。监控代码内存：监控和管理代码内存使用至关重要，特别是在频繁执行热代码升级的长期运行系统中。可以使用以下内置函数检查：code:all_loaded/0 - 查看已加载的模块erlang:memory(code) - 监控已加载模块及其常量使用的总内存补充：ERTS 核心底层优化：MBC 内存池与跨核迁移机制 (Carrier Migration)在多核（SMP）环境下，Erlang 为了避免多个调度器频繁向操作系统申请内存产生锁竞争，并解决各核之间内存负载不均的问题，设计了一套复杂的Carrier 迁移机制。核心概念：MBC (Multi-Block Carrier)•Carrier (载体)：ERTS 向 OS 申请的一整块大内存（通常为 2MB）。•Block (块)：代码中实际申请的小内存单元（如 32 字节的元组）。•MBC：专门存放小对象的载体，内部会切分成多个 Block。每个调度器都有自己私有的 MBC 链表。核心痛点：多核负载不均 (Imbalance)•现象：调度器 A 忙于处理业务，分配了大量 MBC 导致内存占用高；而调度器 B 此时很闲，其持有的 MBC 大量空闲却不释放。•后果：系统整体内存碎片率升高，且调度器 A 必须不断执行昂贵的系统调用（mmap）去申请新内存。终极方案：mbcs_pool(Multi-Block Carriers Pool)为了实现内存的“二次利用”，ERTS 引入了MBC 迁移池逻辑：•抛弃 (Abandonment)：•当一个调度器在 GC 后发现某个 MBC 的利用率低于阈值（即大部分 Block 已释放），它不会将其归还 OS。•该调度器将该 MBC 标记为 "Abandoned"，并将其存入全局的mbcs_pool。夺取 (In-migration / Adoption)：•当另一个调度器（内存紧张）需要新的 MBC 时，优先不去向 OS 申请。•它会先扫描mbcs_pool，如果发现别人抛弃的 MBC，直接“过户”到自己名下，重用其中的空闲 Block。无锁化设计：•为了防止这个全局池子变成新的锁瓶颈，mbcs_pool采用了基于原子操作的无锁链表（Lock-free List）或细粒度锁实现。机制触发逻辑1.分配请求：进程需要申请内存。2.局部查找：在当前调度器的私有 MBC 链表中查找空闲空间。3.全局捞取：若私有空间不足，检查mbcs_pool是否有现成的“弃件”。4.系统申请：若池子也空，才调用mmap向 OS 申请新 Carrier。5.空闲检测：当释放 Block 导致整个 MBC 利用率极低时，根据策略决定：保留/抛弃到池子/归还给 OS。开发者视角•性能提升：极大地减少了多核服务器在超高频内存申请时的系统调用次数。•自省工具：通过erlang:system_info({allocator, binary_alloc})可以看到mbc_pool的具体统计（如abandoned_carriers数量），用于分析是否存在内存碎片或分配不均的问题。
调度器是用来让erlang系统决定哪些代码要跑和什么时候跑的，10.1 并发、并行、抢占式多任务（Preemptive Multitasking）SMP：Symmetric Multi-Processing（对称多处理）在Erlang/ERTS中的含义SMP是Erlang虚拟机的一个特性，使其能够：在多核处理器上真正地并行执行使用多个OS线程，每个线程运行一个独立的调度器充分利用多核CPU的计算能力并发 vs 并行并发(Concurrency)：多个进程在逻辑上同时执行，从外部观察者看起来是同时进行。在单核系统中通过抢占式多任务处理实现——一个进程运行一段时间后被中断，另一个进程运行并行(Parallelism)：多个进程在物理上真正同时执行，仅在多核或分布式系统中可实现10.2 ERTS中的抢占式多任务 与C代码的协作抢占式多任务是通过C级别的协作多任务实现的规约计数(Reduction Counting)：Erlang语言、编译器和虚拟机三者共同协作，以确保Erlang进程的执行在有限的时间内让出CPU，允许下一个进程运行。这是一种用于测量和限制允许执行时间的技术。10.3 ReductionsBEAM中的调度是一种协作式调度之上的抢占式调度。 一个进程只能在执行的特定点被中断，例如在receive或函数调用处。从这个意义上讲，调度是协作式的——进程必须执行允许被暂停的代码。Erlang代码的特性使得进程几乎不可能在不做函数调用的情况下运行很长时间。还有一些内置函数(BIF)仍然可能在不让出的情况下花费太长时间。另外，如果在实现不当的本地实现函数(NIF)中调用C代码，你可能会阻塞一个调度器很长时间。我们将何编在第16章讨论如写表现良好的NIF。协作式调度：进程/线程主动让出CPU执行权，而不是被操作系统或虚拟机强制中断。协作式调度之上的抢占式调度：erlang牛的地方，对内是一种协作式的调度，有自己的规矩决定进程什么时候需要让出cpu去执行其他进程的任务；对外看起来是抢占式调度，开发人员不需要自己手动去yield()，不需要自己去决定进程什么时候需要阻塞去让出cpu由于唯一的循环构造是递归和列表推导式，不做函数调用就不可能无限循环。每个函数调用都被计为一个reduction；当进程的reduction限制达到时，它就被暂停。规约(Reductions)术语来源于Erlang的Prolog血统。 在Prolog中，每个执行步骤都是一个目标化简(goal-reduction)，其中每个步骤将逻辑问题化简为其构成部分，然后尝试求解每个部分。BEAM的聪明设计： Erlang的语言特性（强制递归、频繁函数调用）天然地为规约计数提供了暂停点，使得系统既能实现看似抢占式的调度，又避免了真正抢占式调度的复杂性和性能开销。10.3.1 你能得到多少reductions这是定义在erl_vm.h文件里面的有一个CONTEXT_REDS字段，大约是4000左右。用完了reductions或者receive阻塞等待消息中，进程就会暂停，一个新的进程将被调度。如果虚拟机已执行由INPUT_REDUCTIONS定义的规约数量（目前为2*CONTEXT_REDS，也在erl_vm.h中定义），或者没有就绪进程可运行，调度器将进行系统级活动。 即基本上检查IO。reduction的双重作用：进程公平性：每个进程都获得固定的4000规约系统响应性：每8000规约检查一次IO，保证系统能及时响应外部事件这确保了Erlang系统既能实现进程间的公平调度，又不会因为CPU密集型计算而忽视IO事件。10.3.2 Reduction到底是是个啥？目前来说reduction没有一个完全的定义，但是至少每个函数调用算一个reduction。如果函数调用涉及到BIFs或者NIFs的话就有一些复杂了，首先用C写的函数不能中途yield，C函数无法自动暂停和恢复，所以很难支持中途让出，如果非要可重入（一个函数可以在自己还没执行完的时候被重新调用），需要保存一些执行状态和函数重新回到运行状态的时候要恢复状态，很麻烦。核心问题： C函数如果要支持中途让出，编程复杂度很高，所以通常的做法是：函数要么完整执行不让出，要么分解成多个小函数在调用点让出。比如说：在R16之前的Erlang版本中，BIF binary_to_term/1和term_to_binary/1是不让出的，只算作一个reduction。 这意味着对大型term调用这些函数的进程可能会使其他进程饥饿。而且这在SMP系统下也可能发生，因为进程在调度器之间是平衡的。这就是为什么Erlang在C中编写NIF时，官方建议如果任务太长，要么：✓ 分解成多个较小的NIF调用✓ 或使用可让出的NIF API（保存执行状态）当进程运行时，虚拟机在（寄存器映射的）变量FCALLS中保持要执行的剩余规约数。 reds字段记录进程已执行的总reduction数，直到被暂停。通过监控这个数字，你可以看到哪些进程做最多的工作。用erlang:process_info/2 传入reductions作为第二个参数可以查看进程的总reductions（reds字段）你也可以在observer的process标签中看到这个数字，或在Erlang shell中使用i/0命令看到。每次进程启动时，字段fcalls被设置为CONTEXT_REDS的值，对于进程执行的每个函数调用，fcalls减1。 当进程被暂停时，reds字段增加执行的规约数。在某些类似C的代码中，类似：p→reds += (CONTEXT_REDS - p→fcalls)。通常一个进程会做完它分配的所有规约，此时fcalls为0，但如果进程在receive中暂停等待消息，它将有一些规约剩余。当一个进程用完所有规约时，它将让出让另一个进程运行，它将从进程状态running转到状态runnable，如果它在receive中让出它将进入状态waiting（等待消息）。 在下一节中我们将看到进程可以处于的所有不同状态。10.4 进程状态PCB里面有几个进程的状态：free、runnable、waiting、running、exiting、garbing和suspended进程的常规状态是runnable、waiting和runningfree：当进程退出时，它被标记为free——你永远不应该能看到处于此状态的进程，这是一个短暂的状态，进程就系统的其余部分而言不再存在，但仍有一些清理工作要做（释放内存和其他资源）running：进程当前在其中一个调度器中执行代码，进入receive并且消息队列中没有匹配的消息时，进程将变为waiting状态，直到消息到达或发生超时。如果进程用完所有reductions，它将变为runnable并等待调度器再次拾取它。waiting：进程接收消息或超时将变为runnablegarbing：当进程需要进行垃圾回收时，它将进入garbing状态直到GC完成。 进行GC时，它在gcstatus字段中保存旧状态，完成后使用gcstatus将状态设置回旧状态。suspended（有啥用呢？）：仅用于调试目的。 你可以在另一个进程上调用erlang:suspend_process/2来强制它进入suspended状态。每次进程在另一个进程上调用suspend_process时，suspend count就会增加。这记录在rcount字段中。暂停进程调用erlang:resume_process/1将减少suspend count。进程在suspend状态中不会离开，除非suspend count达到零。rstatus（恢复状态）字段用于跟踪进程暂停前的状态。 如果它是running或runnable，它将以runnable开始，如果是waiting，它将返回等待队列。如果一个suspended的waiting进程接收到超时，rstatus被设置为runnable，所以它将以runnable状态恢复。10.5 进程队列维护进程队列是调度器主要干的活，调度器必须处理两种进程状态：runnable和waiting。等待接收消息的进程处于waiting状态。当waiting进程接收到消息时，发送操作触发接收进程移动到runnable状态。如果receive语句有超时子句，调度器必须在超时触发时触发状态转换到runnable。10.5.1 就绪队列(The Ready Queue)处于runnable状态的进程被放置在调度器处理的FIFO（先进先出）队列中，称为就绪队列。 该队列由first指针和last指针实现，以及每个参与进程的PCB中的next指针。当新进程添加到队列时，跟随last指针，进程以O(1)操作添加到队列末尾。当新进程被调度时，它只是从队列的头部（first指针）弹出。SMP系统中的队列：在具有多个调度器线程的SMP系统中，每个调度器有一个队列。优先级队列：Erlang进程有优先级，每个调度器实际上有三个队列：一个用于最高优先级任务的队列，一个用于高优先级任务的队列，以及一个包含普通和低优先级任务的队列。调度规则：✓ 如果max队列中有进程，调度器选择这些进程执行✓ 如果max队列为空但high队列有进程，调度器选择high进程✓ 只有当max和high队列都为空时，调度器才从normal和low队列中选择第一个进程优先级与调度计数：普通(Normal)进程：schedule count = 1（立即执行）低优先级(Low)进程：schedule count = 8（执行前要排队7次）当从队列前面弹出进程时，其schedule count减1。如果计数达到零，进程被调度；否则它被插入到队列末尾。10.5.2 等待、超时和时间轮(Waiting, Timeouts and the Timing Wheel)等待状态：进程尝试receive，但是邮箱是空的或没有匹配消息，进程将让出yield并进入waiting状态。当消息被传递到收件箱时，发送进程将检查接收者是否在waiting状态下休眠，如果是，它将唤醒进程，将其状态更改为runnable，并将其放在适当就绪队列的末尾。超时机制：如果receive语句有超时子句，将为进程创建一个计时器，该计时器将在指定的超时时间后触发。 运行时系统对超时的唯一保证是它不会在设定时间之前触发，在进程被调度并获得执行机会之前可能需要一些时间。时间轮(Timing Wheel)：计时器由VM中的时间轮处理。 即一个时间槽数组，不断循环。在Erlang 18之前，时间轮是全局资源，如果有许多进程将计时器插入轮中，写锁可能会有一些竞争。如果使用许多计时器，请确保使用Erlang的较新版本。Erlang18之后的时间轮：时间轮配置：Erlang/OTP源代码 (beam/erl_time.c)、Erlang 18 Release Notes (关于timer改进)默认大小(TIW_SIZE)：65536个槽位（小内存系统为8192槽位）当前时间：通过索引(tiw_pos)指示插入公式：timer插入到 (tiw_pos+T)%TIW_SIZE 槽位相同槽位的多个计时器： 如果多个计时器被插入同一槽位，它们通过prev和next字段链接在一起形成链表。count字段设为T/TIW_SIZE。计时器结构体typedef struct erl_timer {struct erl_timer next; / 时间轮槽位或链中的下一项 */struct erl_timer prev; / 时间轮槽位或链中的前一项 */Uint slot; / 时间轮中的槽位 /Uint count; / 剩余循环次数 /int active; / 1=激活, 0=已取消 /void (timeout)(void); / 超时时调用 /void (cancel)(void); / 取消时调用(可能为NULL) /void arg; / timeout/cancel函数的参数 */} ErlTimer;时间轮运转逻辑：OTP高精度定时器(HLT)文档10.6 端口(Ports)端口：对Erlang虚拟机外部通信点的抽象。在Erlang中，与套接字、管道和文件I/O的所有通信都通过端口进行。端口与进程一样，是在与创建进程相同的调度器上创建的。像进程一样，端口也使用归约（reduction）来决定何时让出，它们也可以运行4000个归约。但由于端口不运行Erlang代码，没有Erlang函数调用来计算为归约，而是每个端口任务都计算为一定数量的归约。目前，每个任务使用略多于200个归约，还有相对于传输数据大小的1/1000的归约数。端口的任务：如读取、写入，打开文件等（按上面的说法一个操作大概200个reductions或者相对于传输数据大小的1/1000的reduction）为了执行端口任务，执行线程（threads）必须对端口进行加锁。端口大概运行几个reduction让出端口：我猜应该是根据调度器上面运行的进程来的为什么需要让出？：公平性：防止一个端口独占调度器，影响其他端口和进程的执行响应性：确保系统能及时响应其他任务并发执行：多个端口和进程能轮流获得执行机会10.7. 调度器循环概念上，调度器像是erlang虚拟机里面程序运行的驱动，实际上，从C代码的结构上来看，是模拟器（beam_emu.c中的process_main）用来驱动执行的，它将调度器当作一个subroutine子程序，用来找到下一个需要运行的进程。不过，我们将假装它是另一种方式，因为这样可以形成调度器循环的一个好的概念模型。也就是说，我们将其视为：调度器选择一个进程来执行，然后将执行权交给模拟器。调度器循环就像这样：更新reduction计数器 - 每个进程被分配一定数量的归约（执行的小单位）。当归约计数超过限制时，该进程被抢占，另一个进程被调度。检查定时器 - 定时器（如receive...after）通过定时轮进行检查。如果超时已过期，相关进程被移动到就绪队列。必要时检查平衡 - 系统偶尔检查某些调度器线程是否超负荷。如果是，则进行负载平衡决策。必要时迁移进程和端口 - 进程和端口可以在调度器线程之间移动，以在多核环境中重新平衡CPU负载。执行辅助调度器工作 - 这包括处理脏NIF、进程退出、跟踪钩子和其他延迟的运行时维护任务。必要时检查IO并更新时间 - 轮询文件描述符或端口的I/O就绪情况，可能会更新系统时间或墙钟。在需要时选择端口任务执行 - 在调度进程之前，先执行端口任务，如TCP读写或调用驱动程序回调。选择一个进程执行 - 从运行队列中选择一个可运行的进程（考虑优先级和公平性），并将其交给模拟器执行。10.9 负载均衡目标：使用尽可能少的调度器，同时避免任何CPU过载。这样做是为了通过进程共享同一CPU来获得更好的内存局部性，从而提高性能。在哪执行：负载均衡是在调度器线程之间进行的，而不一定是在CPU或核心之间BEAM假设每个核心上运行一个调度器。如果改变调度器与核心的绑定方式，或操作系统分配不当，负载均衡可能反而会降低性能主要有两个技术：Task Stealing和Migration10.9.1 任务窃取 (Task Stealing)工作流程：当调度器的运行队列为空时，它会尝试从其他调度器窃取任务先对自己加锁，防止其他调度器来窃取首先检查是否有不活跃的调度器有可窃取的任务如果没有，则查看活跃调度器（从较高ID的开始），寻找可窃取的任务每次只看一个调度器，尝试窃取该调度器的最高优先级任务特点：速度快，调度器每次迭代都可以执行将任务向较低编号的调度器移动确保进程分散在所有活跃调度器上10.9.2 迁移 (Migration)目标：将负载压缩到尽可能少的调度器，同时确保没有调度器过载实现机制：通过erl_process.c中的check_balance函数完成每经过2000*CONTEXT_REDS次reductions，调度器会根据所有调度器的工作负载计算一个迁移计划迁移路径的三种值：cleared - 清空状态migrate to scheduler # - 迁移到指定调度器immigrate from scheduler # - 从指定调度器接收进程运作流程：进程准备就绪时（如收到消息或触发超时），通常被调度回最后运行的调度器（S1）如果S1的迁移路径被清空，进程就保留在S1如果S1的路径设置为"迁移到S2"，且两个调度器运行队列不平衡，进程会被转移到S2平衡计算方法：比较每个调度器的最大运行队列长度计算平均最大队列长度（AMQL）队列长度大于平均值的调度器标记为迁移源队列长度小于平均值的调度器标记为迁移目标特殊情况：调度器离线时，设置撤空标志确保没有新进程被调度到离线调度器检测到某个优先级没有进展时（如高优先级进程独占CPU），也会设置撤空标志，强制迁移总结Erlang的负载均衡是一个两层策略系统：任务窃取用于快速应对不平衡，迁移用于长期优化资源分配。这种设计确保了系统既能高效利用多核，又不会过度优化而造成反效果。补充：📝 BEAM 调度底层进阶：JIT 与脏调度 (OTP 24+)#### 1. JIT 时代的 Reduction 检查* 实现机制：JIT 编译器在每个 Native 函数的开头插入汇编指令检查 fcalls 寄存器。高效跳转：通过"Out-of-line code"处理进程挂起。只有当 `fcallsDirty I/O：处理阻塞挂起型 NIF（如同步 IO）。特点：无 CPU 亲和性，允许大量线程并存。触发阈值：官方建议如果一个 NIF 执行时间超过 1毫秒，必须移交给脏调度器，否则会造成调度器阻塞（Scheduler Collapse）。#### 3. NIF 让出机制 (Trapping)* enif_consume_timeslice：在 C 代码内部手动扣除进度，告知 VM 消耗了多少百分比的 Reductions。enif_schedule_nif：现代化的 NIF 分片执行方案。VM 会自动处理上下文切换，无需开发者手动在 C 里维护复杂的重入状态机。
《The BEAM Book》第十二章：垃圾回收器 (Garbage Collection) 深度详析本章的核心在于解释 BEAM 如何在不停止整个系统的情况下，高效地管理内存。12.1 复制式垃圾回收 (Copying Garbage Collection)这一节介绍了 BEAM GC 的基本算法基础：分代（Generational） 和 复制（Copying）。基本原理触发时机：GC 发生时，系统分配一块新的内存区域（To-Space）。扫描过程：从根集合（Roots）（主要是栈 Stack、寄存器）开始扫描。复制操作：将所有“活”的对象从旧堆（From-Space）复制到新堆。清理：复制完成后，旧堆被整体废弃。详细步骤（Cheney 算法变体）根扫描：找到栈上引用的堆对象。复制与转发：当一个对象（如 Cons Cell）被复制到新地址后，BEAM 会在旧对象的内存位置写入一个转发指针（Forwarding Pointer），指向新地址。目的：如果堆中有多个指针指向同一个对象（共享数据），第一个指针触发复制后，后续指针看到“转发标记”，直接更新指向新地址，而不会重复复制对象。这保证了共享性（Sharing）在 GC 后依然存在。广度优先遍历 (BFS)：BEAM 的 GC 是按照广度优先的顺序进行的。书中 GDB 调试实验（有趣细节）书中通过一个 gc_example 模块演示了 GC 前后的内存变化。指针反转现象：Erlang 构建数据通常是自底向上（Bottom-up，先构建尾部再构建头部），而 GC 复制是自顶向下（Top-down，从根开始）。结果：GC 后，内存中对象的物理排列顺序往往与创建时相反。这虽然不改变逻辑结构，但会改变内存引用的方向。12.2 Erlang GC 中的分代机制 (Generations in Erlang's GC)这一节解释了 BEAM 如何区分“年轻数据”和“年老数据”。PCB 中的关键字段high_water（高水位线）：这是堆中的一个指针。地址低于此线的对象是“老年代”，高于此线的是“新生代”。old_heap：指向老年代堆的指针。gen_gcs：该进程经历过的次级 GC 次数。max_gen_gcs：在强制进行全量回收（Full Sweep）前的最大次级 GC 次数（默认 65535）。回收类型Minor GC (次级回收)：只回收 high_water 之上的新生代区域。存活下来的对象会被移动到老年代（Old Heap）。Major GC / Full Sweep (全量回收)：回收整个堆（包括老年代和新生代）。堆外内存管理Off-heap 数据：如大二进制（Refc Binaries）。它们不占堆空间，但占内存。虚拟堆 (Virtual Heap)：BEAM 维护 bin_vheap_sz 等字段，统计进程持有的二进制数据大小。如果虚拟堆太大，即使进程主堆还很空，也会强制触发 GC，以便减少二进制数据的引用计数。12.3 实际影响与考量 (Practical Implications and Considerations)这一节讨论了 GC 行为如何映射到我们在 process_info 或 Observer 中看到的指标。观察 high_water 的移动如果 high_water 线不断上移，说明该进程正在不断积累长期存活的数据。这可能是正常的（如 ETS 表的所有者进程），也可能是内存泄漏（无用的数据被意外保留引用）。消息缓冲区 (mbuf)当进程接收消息过快，来不及处理，或者发送方无法获取接收方的堆锁时，消息会被放在堆外的碎片区（Heap Fragments / mbufs）。警示：如果看到 mbuf_sz 很大，说明消息处理有瓶颈，或者发生了严重的锁竞争。12.4 优化建议 (Suggestions for Optimization)基于 GC 原理，作者给出了一些具体的优化建议。数据局部性 (Locality)由于 GC 采用广度优先复制，可能会破坏数据在内存中的局部性（原本相邻的数据被分散），但在现代大缓存 CPU 上，这通常不是主要瓶颈。预分配堆大小问题：如果一个进程启动后立刻处理大量数据，会触发多次扩容和 GC，浪费 CPU。方案：使用 spawn_opt(Fun, [{min_heap_size, Words}])。直接分配一个大堆，减少初期的 GC 抖动。减少临时垃圾虽然 Minor GC 很快，但大量临时数据依然会消耗内存带宽和 CPU 周期。12.5 栈与堆的增长 (Stack and Heap Growth)这一节通过物理布局解释了进程何时会“内存不足”。单一连续内存块Erlang 进程的栈和堆共用一块内存。相向生长Stack：从高地址向低地址增长（向下）。Heap：从低地址向高地址增长（向上）。碰撞检测每次函数调用或分配数据时，虚拟机只需检查 StackPtr > HeapPtr + Needed。如果检查失败（两者相遇），说明内存不足，触发 GC。扩容如果 GC 后空间依然不足，VM 会申请一块更大的内存，将栈和堆都搬过去。12.6 关键内存特征与优化机会 (Key Memory Characteristics)这一节从理论层面解释了为什么 BEAM 选择复制式 GC 而不是引用计数。不可变性 = 无循环引用 (No Cycles)这是 Erlang 最重要的内存特征。由于数据不可变，你无法修改一个老对象让它指向一个新对象（只能创建新对象包含老对象）。因此，Erlang 堆中永远不会出现环状引用。知识点：理论上这让引用计数（Reference Counting）变得可行且简单。为什么不用引用计数？（HiPE 团队的研究数据）对象极小：统计显示，75% 的 Erlang 对象是 Cons Cell（列表节点，2个字大小），99% 的对象小于 8 个字。代价分析：引用计数：对海量微小对象频繁更新计数，开销巨大，且无法解决内存碎片。复制 GC：对于微小对象，复制成本极低（就是拷贝几个字节）。更重要的是，复制 GC 顺便完成了内存整理（Compaction），使得空闲内存永远是连续的，内存分配变成了最快的“指针递增”。结论BEAM 的 GC 策略是针对“大量、微小、短命、不可变”的数据特征进行极致优化的结果。📝 总结出的“隐藏”知识点 (Takeaways)fullsweep_after 标志：你可以通过设置进程标志 {fullsweep_after, N} 来控制全量回收的频率。如果设为 0，则该进程每次 GC 都是全量回收（以 CPU 换内存，适合对延迟敏感但不产生大量垃圾的进程）。binary:copy/1 的作用：如果你有一个巨大的二进制数据（比如 10MB），然后匹配出了其中的一小段（比如 10字节）。这一小段是 Sub-binary，它在底层依然引用着那 10MB 的大二进制，导致 GC 无法回收那 10MB。优化：调用 binary:copy(SubBin) 会创建一个新的深拷贝，断开对大数据的引用，从而允许 GC 回收那 10MB 空间。GC 是“借”来的时间：Erlang 是软实时的，GC 的开销是分摊在每个进程的时间片里的。如果一个进程 GC 时间太长，调度器会强制挂起它，下次再继续（对于 Major GC）。这保证了单个进程的垃圾回收不会阻塞整个系统的响应。
通用的BEAM指令集通用指令集（Generic）：是官方标准，编译器和虚拟机都用它，相对稳定，只在大版本间变化；特定指令集（Specific）：是内部优化实现，可能随时改变，不应该基于它开发工具。要想开发编译器、虚拟机或分析工具，必须基于通用指令集，这样才能保证跨版本兼容性，特定指令集只是BEAM的内部实现细节。7.1 指令定义通用指令集的名称和操作码都定义在 lib/compiler/src/genop.tab.genop.tab 文件被 beam_makeops（一个Perl脚本）用作输入，从中生成各种代码。这个生成器（beam_makeops）会为编译器生成Erlang代码（beam_opcodes.hrl 和 beam_opcodes.erl），为模拟器生成C代码（beam_opcodes.c 和 beam_opcodes.h；对于传统BEAM解释器还会生成 beam_hot.h、beam_warm.h、beam_cold.h；对于BeamAsm会生成 beamasm_emit.h 和 beamasm_protos.h）。文件中以"#"开头的行是注释会被忽略，文件还可以包含定义来转换成Perl脚本中的绑定。genop.tab 文件的主要内容是操作码定义，格式是 OPNUM: [-]NAME/ARITY，其中OPNUM和ARITY是整数，NAME是以小写字母开头的标识符，冒号、减号和斜杠是字面符号。比如 1: label/1 就表示操作码1是label函数，参数数量为1。减号表示该函数已弃用，弃用函数会保留其操作码以便向后兼容，加载器会识别这些弃用指令但拒绝加载包含它们的代码。7.2 BEAM码列表给个 S 参数给编译器编译就可以得到 人可读和机器可读格式的 .S文件-module(beamexample1). -export([id/1]). id(I) when is_integer(I) -> I.When compiled with erlc -S beamexample1.erl we get the following beamexmaple1.S file:%% Module Declaration {module, beamexample1}. %% version = 0 %% Exported Functions {exports, [{id,1}, {module_info,0}, {module_info,1}]}. %% Attributes {attributes, []}. %% Labels Count {labels, 7}. %% ============================================================================ %% Function: id/1 %% ============================================================================ {function, id, 1, 2}. {label, 1}. % Error handling label {line, [{location, "beamexample1.erl", 5}]}. {func_info, {atom,beamexample1}, {atom,id}, 1}. {label, 2}. % Function entry point {test, is_integer, {f,1}, [{x,0}]}. % Test if x0 is integer return. % Return x0 %% ============================================================================ %% Function: module_info/0 (auto-generated) %% ============================================================================ {function, module_info, 0, 4}. {label, 3}. % Error handling label {line, []}. {func_info, {atom,beamexample1}, {atom,module_info}, 0}. {label, 4}. % Function entry point {move, {atom,beamexample1}, {x,0}}. % Move module name to x0 {line, []}. {call_ext_only, 1, {extfunc, erlang, get_module_info, 1}}. %% ============================================================================ %% Function: module_info/1 (auto-generated) %% ============================================================================ {function, module_info, 1, 6}. {label, 5}. % Error handling label {line, []}. {func_info, {atom,beamexample1}, {atom,module_info}, 1}. {label, 6}. % Function entry point {move, {x,0}, {x,1}}. % Move arg0 to arg1 {move, {atom,beamexample1}, {x,0}}. % Move module name to x0 {line, []}. {call_ext_only, 2, {extfunc, erlang, get_module_info, 2}}.第一行 {module, beamexample1}. %% version = 0 告诉我们模块名是beamexample1，指令集版本号是0。接着是导出函数列表 id/1, module_info/0, module_info/1，可以看出编译器自动添加了两个生成的函数，这两个函数只是对通用模块信息BIFs（erlang:module_info/1 和 erlang:module_info/2）的分发器，并将模块名作为第一个参数添加。{attributes, []} 列出所有编译属性，我们的案例中没有。{labels, 7} 说明模块中标签数量少于7个，这样做便于一次性加载代码。最后一种元指令是函数指令，格式为 {function, Name, Arity, StartLabel}，可以看到id函数的开始标签实际上是函数代码中的第二个标签。{label, N} 指令并不是真正的指令，加载到内存时不占用空间，它只是为代码中的某个位置起个本地名称或编号。每个标签可能标记一个基本块的开始，因为它是跳转的潜在目标。第一个标签后的前两条指令实际上是错误生成代码，添加行号、模块、函数和参数数量信息并抛出异常，这些是 line 和 func_info 指令。函数的核心在 {label,2} 后，指令 {test,is_integer,{f,1},[{x,0}]} 测试其参数（列表末尾的 {x,0} 变量）是否满足测试条件（在本例中是is_integer）。如果测试成功就执行下一条指令 return，否则函数失败跳转到标签1（{f,1}），即执行到标签1处抛出函数子句异常。文件中的另外两个函数是自动生成的。看第二个函数，指令 {move,{x,0},{x,1}} 将x0寄存器中的参数移到第二个参数寄存器x1，然后 {move,{atom,beamexample1},{x,0}} 将模块名原子移到第一个参数寄存器x0，最后对 erlang:get_module_info/2 进行尾调用（{call_ext_only,2,{extfunc,erlang,get_module_info,2}}）。如后续章节所示，有多种不同的调用指令。7.3 Calls第八章又说道erlang的几种调用类型，为了区分指令集的本地calls和远程calls，远程calls有 _ext 在指令集名称里面，本地calls只是在模块的代码上有一个标签，同时远程calls还是这种格式的{extfunc, Module, Function, Arity}.为了区分普通调用（构建栈）和尾递归调用，后者在名称中包含 _only 或 _last。带 _last 的变体还会释放由最后一个参数指定的栈槽数量。另外还有 call_fun Arity 指令，用于调用存储在寄存器 {x, Arity} 中的闭包。参数存储在 x0 到 {x, Arity-1} 中。7.4 堆栈管理两类函数：叶函数（Leaf Function） - 不调用其他函数无需保存返回地址无需管理栈帧直接执行后return返回非叶函数（Non-leaf Function） - 会调用其他函数需要保存返回地址(CP)到栈用 allocate 指令创建栈帧用 deallocate 指令销毁栈帧关键指令：allocate StackNeed Live - 分配栈空间StackNeed：分配多少个字的额外栈空间给局部变量Live：垃圾回收时需要保存多少个X寄存器（比如Live=2就保存x0和x1）deallocate StackNeed - 释放栈空间恢复栈指针从栈读取返回地址必须与allocate成对使用简单说： allocate和deallocate就像C语言的函数栈帧管理，push返回地址和局部变量，pop释放它们。Live参数是个优化，告诉垃圾回收器哪些寄存器有活跃数据，不用扫描其他的。7.5 消息推送核心是 loop_rec（检查）、remove_message（移除）、loop_rec_end（推进指针）、wait（挂起）这几个指令配合，Ref技巧是为了避免在邮箱很满时进行低效的线性扫描发送消息很简单：使用 send 指令，无需参数。假设x0是目标进程，x1是消息内容，执行后消息也会被放在x0中。接收消息比较复杂，因为涉及选择性接收和模式匹配。7.5.1 最小接收循环 - 接收任意消息{loop_rec, {f,2}, {x,0}}. % 检查消息队列，无消息跳到L2remove_message. % 移除消息{jump, {f,3}}. % 继续处理{wait, {f,1}}. % 无消息时进程挂起流程：检查→有消息就处理→没消息就挂起等待。7.5.2 选择性接收循环 - 只接收特定类型的消息{loop_rec, {f,3}, {x,0}}. % 检查消息{test, is_nil, {f,2}, [{x,0}]}. % 模式匹配remove_message. % 匹配则移除{loop_rec_end, {f,1}}. % 不匹配则推进指针到下一条消息{wait, {f,1}}. % 无新消息则挂起流程：逐条扫描消息队列→匹配就处理→不匹配就推进指针继续扫描。7.5.3 带超时的接收循环 - 接收消息或超时{wait_timeout, {f,1}, {integer,1000}}. % 设置1000ms超时timeout. % 超时被触发时执行流程：设置定时器→进程挂起→要么新消息到达→要么超时触发。7.5.4 Ref技巧 - 优化深邮箱的扫描问题：如果邮箱有100条旧消息，选择性接收需要扫描所有100条才能找到1条新响应，太慢。解决方案：使用新创建的ref标记RPC消息Ref = make_ref(),Counter ! {self(), inc, Ref},receive {Ref, Count} -> Count end.BEAM代码用 recv_mark 和 recv_set 指令：recv_mark - 标记make_ref前的消息队列末尾recv_set - 接收时只扫描mark后的新消息性能提升：从O(N)（扫描所有旧消息）变成O(1)（只扫描新消息）。这对于高吞吐量的RPC模式很重要。注意：如果Ref不是发送给自己的进程的情况下，这种优化是不行的，编译的时候不会用recv_mark和recv_set，这里的问题是：你创建的ref被发给了别的进程别的进程处理后才会发消息回来在发送和接收之间，你的邮箱可能积累了其他消息recv_set时无法确定"哪些消息是ref创建之前的"所以优化无法应用，还是得线性扫描ref技巧的前提：Ref必须是本进程创建的Ref必须在make_ref之后立即（或很快）被发送出去receive必须紧跟在send之后期间没有其他receive操作简单说：ref技巧是为了优化本进程发起的"同步RPC"模式（发送请求→等待响应），如果Ref的流向更复杂，优化就失效了。补充：Erlang 核心底层优化：同步消息的“指针对跳” (Recv Marker Optimization)核心原理当 Erlang 编译器识别出一种特殊的“请求-响应”模式时，它会生成两根特殊的 BEAM 指令，将信箱扫描从O(N)O(N)优化为O(1)O(1)：•recv_mark：在发送请求前，记录当前信箱的末尾指针（Save Pointer）。•recv_set：在执行receive时，直接跳过recv_mark之前的所有消息，从记录的位置开始扫描。标准优化模板这种优化通常由make_ref/0或monitor/2触发：codeErlangfoo(Pid) -> Ref = make_ref(), %% [指令: recv_mark] 记录当前信箱末尾 Pid ! {self(), Ref, query}, receive {Ref, Reply} -> Reply %% [指令: recv_set] 直接从记录点开始看，无视旧消息 end.优化失效的“三大杀手”（精准触发条件）编译器非常保守，只要它不能在静态编译阶段“证明”这个 Ref 是绝对安全的，它就会放弃生成recv_mark。失效场景：跨函数调用 (Scope) 编译器只做函数内分析。如果make_ref在另一个函数内执行，编译器无法保证调用该函数前信箱里没有同名 Ref。 R = get_ref(), gen_server:call(P, R)变量逃逸 (Escape) 如果 Ref 被传给了非内置(BIF)函数，编译器认为变量“逃逸”了，因为该函数可能产生副作用（如递归接收）。 Ref = make_ref(), logger:info("~p", [Ref]), receive...中间干扰 (Intervening) 在make_ref和目标receive之间如果存在另一个receive块，会重置 PCB 中的扫描位移。 Ref = make_ref(), receive timeout -> ok after 0 -> ok end, receive...非直接构造 Ref 必须是刚生成的。如果 Ref 是从函数参数传进来的，优化绝对不会触发。 handle_msg(Ref) -> receive {Ref, _} -> ok end.高阶补充：不仅仅是make_ref在现代 OTP 版本中，以下操作同样会触发recv_mark：•monitor(process, Pid)：监控进程并等待'DOWN'消息。•spawn_monitor/1/3：派生并监控。•编译器知道：在这些指令执行之前，信箱里绝不可能已经存在对应的Ref或'DOWN'消息。性能影响•生效时：即使进程信箱有 100,000 条消息，匹配Ref的延迟也是0 毫秒。•失效时：每收到一个回复，都要线性扫描一遍 100,000 条消息，CPU 会瞬间因为Selective Receive Cleanup飙升。