ArrayLists 的指针稳定性

Lobsters Hottest 新闻

摘要

Zig 的开发日志介绍了 ArrayLists 的指针稳定性,以防止在重新分配过程中因指针失效而导致的内存安全错误。

<p><a href="https://lobste.rs/s/yqqsgk/pointer_stability_for_arraylists">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/28 21:41

# 开发日志 ⚡ Zig 编程语言 来源:https://ziglang.org/devlog/2026/ 本页面包含主分支 Zig 近期变更的精选列表。 本页面包含 2026 年的条目。其他年份可在开发日志存档页面 (https://ziglang.org/devlog/) 查阅。 **2026年8月27日** ## ArrayList 的指针稳定性 (https://ziglang.org/devlog/2026/#2026-08-27) 作者:Robbie Lyman 指针稳定性锁 (Pointer Stability Locks) 于 2024 年被添加到 `std` 的哈希映射容器中 (https://github.com/ziglang/zig/pull/17719)。Leo Emar-Kar (https://github.com/emar-kar) 在 2025 年最初提交的一个拉取请求,现在将这种确保内存安全的技术引入到了 `std.ArrayList` 中。 要在代码中使用此功能,请在您首次存储指向由 `ArrayList` 支撑的元素或元素切片的指针时,调用 `lockPointers()`,并在这些指针不再需要时调用 `unlockPointers()`。 下面是一个有些刻意构造的例子。假设我们管理两个 `ArrayList`,其中一个在内存中存储某些输入的内容,而另一个存储感兴趣的片段;可能是每一行。以下是这个过程的一个存在 bug 的版本;看看您是否能发现它。 ```zig const std = @import("std"); const Context = struct { history: std.ArrayList(u8), lines: std.ArrayList([]const u8), fn parse(ctx: *Context, allocator: std.mem.Allocator, input: []const u8) !void { const slice = try ctx.history.addManyAsSlice(allocator, input.len); @memcpy(slice, input); var it = std.mem.tokenizeScalar(u8, slice, '\n'); while (it.next()) |line| { try ctx.lines.append(allocator, line); } } }; ``` 您发现 bug 了吗?问题在于 `Context.lines.items` 的元素依赖于 `Context.history.items` 的*位置*,但如果 `Context.history` 需要增长到超过其当前容量,这个位置可能会*改变*。以下是该 bug 的复现: ```zig test "Context.parse" { const input = "I'm first!\n"; const input_two = \\But this text \\is juuuuuuuuuuuuuuuuuuuuuuuuust long enough that it \\causes a problem! \\And the problem could be that we segfault! \\Which is no fun to run into. ; var ctx: Context = .{ .history = .empty, .lines = .empty, }; const gpa = std.testing.allocator; defer ctx.history.deinit(gpa); defer ctx.lines.deinit(gpa); try ctx.parse(gpa, input); try ctx.parse(gpa, input_two); try std.testing.expectEqualStrings("I'm first!", ctx.lines.items[0]); } ``` 如果我使用 `zig test` 运行此代码,会得到如下输出(以及更多内容)。 ```text ====== expected this output: ========= I'm first! ======== instead found this: ========= UUUUUUUUUU ====================================== First difference occurs on line 1: expected: I'm first! ^ ('\x49') found: UUUUUUUUUU ^ ('\x55') 1/1 blah.test.Context.parse...FAIL (TestExpectedEqual) ``` 这可不太好,对吧?这确实告诉我们*存在*一个 bug,但根据您调试内存问题的经验(以及您选择的分配器,这会影响 bug 的表现方式!),您可能会在相当长一段时间内感到困惑,才找到修复方法。 由于我们在第一次调用 `parse` 后存储了指针,如果我们进行以下更改会发生什么? ```zig try ctx.parse(gpa, input); + ctx.history.lockPointers(); + defer ctx.history.unlockPointers(); try ctx.parse(gpa, input_two); try std.testing.expectEqualStrings("I'm first!", ctx.lines.items[0]); } ``` 我们会得到一个带堆栈跟踪的 panic,它向我们展示了关于指针稳定性的假设在何处被违反了! ```text thread 3023222 panic: reached unreachable code /Users/robbie/bin/lib/std/debug.zig:442:14: 0x102d2506f in assert (test) if (!ok) unreachable; // assertion failure ^ /Users/robbie/bin/lib/std/debug.zig:1880:15: 0x102d31ef7 in assertUnlocked (test) assert(l.state == .unlocked); ^ /Users/robbie/bin/lib/std/array_list.zig:1348:50: 0x102e3ced7 in ensureTotalCapacityPrecise (test) self.pointer_stability.assertUnlocked(); ^ /Users/robbie/bin/lib/std/array_list.zig:1341:51: 0x102e3cdff in ensureTotalCapacity (test) return self.ensureTotalCapacityPrecise(gpa, growCapacity(new_capacity)); ^ /Users/robbie/bin/lib/std/array_list.zig:1237:41: 0x102e4e5c3 in resize (test) try self.ensureTotalCapacity(gpa, new_len); ^ /Users/robbie/bin/lib/std/array_list.zig:1461:28: 0x102e4e40f in addManyAsSlice (test) try self.resize(gpa, try addOrOom(self.items.len, n)); ^ /Users/robbie/src/advent-of-code/2024/blah.zig:8:51: 0x102e4dc1f in parse (test) const ptr = try ctx.history.addManyAsSlice(allocator, input.len); ^ /Users/robbie/src/advent-of-code/2024/blah.zig:35:18: 0x102e4e167 in test.Context.parse (test) try ctx.parse(gpa, input_two); ``` 很好,这已经很有帮助了:现在我明白,除了逻辑问题之外,或者而不是逻辑问题,我应该考虑内存安全问题是导致测试失败的可能原因。显然这个例子有些刻意构造,但我确实在实际代码中经常将 `std.ArrayList` 用作此类后备存储,所以我希望您也能看到它在真实世界中的用例。 在结束之前,我想指出一点微妙之处:与 `HashMap` 及其类似物不同,`ArrayList` 是*有序的*,这意味着对列表的操作可能会移动元素,即使没有移动、调整大小或释放整个列表的后备内存。例如,如果您调用 `orderedRemove()` 或 `pop()`,由 `addManyAsSlice(gpa, n)` 返回的指针(嗯,切片)可能不会指向列表的最后 `n` 个元素。因此,尽管 `orderedRemove()` 和 `pop()` 永远不会分配内存,但它们在调用 `lockPointers()` *之后会触发上述相同的断言。 **2026年6月30日** ## 所有包管理功能从编译器移至构建系统 (https://ziglang.org/devlog/2026/#2026-06-30) 作者:Andrew Kelley 既然用户的 `build.zig` 脚本和构建系统本身现在有单独的进程,那么将包管理逻辑放在这里是合理的。 我将这些子命令移到了 maker 进程: - `zig build` - `zig fetch` - `zig init` - `zig libc` 这意味着以前包含在编译器可执行文件中的大部分内容现在以源代码形式分发,包括: - 包获取逻辑 - HTTP 客户端和网络 - TLS(传输层安全)和相关加密 - Git 协议 - xz、gzip、zstd、flate、zip - 解析、验证以及处理 `build.zig.zon` 文件 因此,现在可以在不重新构建编译器的情况下修补此功能,这使得用户和贡献者更容易进行调整。 此外,这意味着在进行网络操作时,zig 中的包管理现在启用了安全检查,因为 maker 可执行文件是以 `ReleaseSafe` 模式编译的。而且,用于网络和文件哈希的所有加密现在都可以利用主机上可用的特殊 CPU 指令,即使是那些通常在分发软件时过于罕见而无法依赖的指令。*我们可以同时拥有 AOT 蛋糕并吃掉 JIT!* 我最初这样做的动机是为了暴露构建服务器协议 (https://codeberg.org/ziglang/zig/issues/35538),以便在 maker/configurer 进程分离 (https://ziglang.org/devlog/2026/#2026-05-26) 对 `--build-runner` 覆盖标志进行破坏性更改后,解除对 ZLS (https://zigtools.org/zls/) 的阻塞。 最初,进程树如下所示: ```text zig build (the zig compiler + package manager) └─ builder (the user's build.zig logic + build system implementation) ``` 进程分离变更集使其变成这样: ```text zig build (the zig compiler + package manager) ├─ configurer (the user's build.zig logic) └─ maker (build system) ``` 此时,考虑一个长时间运行的 `zig build --watch` 进程,监视文件并在源代码更改时重建。如果检测到对 `build.zig` 的任何更改,或在该逻辑执行期间观察到任何文件,这意味着 `configurer` 需要重新运行,意味着 `maker` 进程必须退出,以便 `zig build` 有机会重复包管理逻辑。 现在,经过本开发日志条目中描述的更改后,它看起来像这样: ```text zig build (the zig compiler) └─ maker (build system + package manager) └─ configurer (the user's build.zig logic) ``` 因此,当配置需要重新运行时,`maker` 进程可以继续存活,因为它是父进程而不是兄弟进程。对于即将推出的构建服务器而言,这意味着避免了服务器必须退出而客户端必须重新连接的尴尬情况,而不是简单地通知客户端配置更改。 这几乎完全是一个无破坏性的更改,但也有一些可观察到的差异: - Zig 可执行二进制文件大小:从 14.1 MiB 缩小 4% 至 13.5 MiB(无 LLVM,ReleaseSmall) - `--maker-opt` 标志被 `ZIG_DEBUG_MAKER` 环境变量替代 - `--zig-lib-dir` 标志被 `ZIG_LIB_DIR` 环境变量替代 此变更集的后续问题是我们标记 Zig 0.17.0 之前的主要障碍: - 构建服务器协议 MVP (https://codeberg.org/ziglang/zig/issues/35538)(解除 ZLS 阻塞所需) - 引入添加构建脚本本身路径依赖的概念 (https://codeberg.org/ziglang/zig/issues/35473) - 使 `zig build --watch` 检测对构建脚本的修改并重新运行自身 (https://github.com/ziglang/zig/issues/20602) - 不同的 cwd 导致构建脚本缓存未命中 (https://codeberg.org/ziglang/zig/issues/35500) 我七月份有两个会议,我需要准备我的演讲,所以现实地说,我认为我直到八月初才有时间完成这些。当然,欢迎贡献。 非常感谢 ZLS 团队的 Techatrix 与我联系并合作构建服务器协议。顺便说一句,他们正在寻求赞助。 **2026年6月26日** ## SPIR-V 后端进展 (https://ziglang.org/devlog/2026/#2026-06-26) 作者:Ali Cheraghi 内容相当多。SPIR-V 后端在最近的编译器更改后,在许多地方出现了过时,因此我花了过去几周时间将其拖入更好的状态。 ## @SpirvType SPIR-V 有一些类型无法在 Zig 的类型系统中表达。新的 `@SpirvType` 内置函数被引入以解决编写着色器的最长遗留障碍。请参阅 #20550 (https://github.com/ziglang/zig/issues/20550)、#23326 (https://github.com/ziglang/zig/pull/23326) 和 #35461 (https://codeberg.org/ziglang/zig/pulls/35461) 以追溯背景。 ```zig const Sampler = @SpirvType(.sampler); const Image = @SpirvType(.{ .image = .{ .usage = .{ .sampled = u32 }, .format = .unknown, .dim = .@"2d", .depth = .unknown, .arrayed = false, .multisampled = false, .access = .unknown, } }); const SampledImage = @SpirvType(.{ .sampled_image = Image }); const RuntimeArray = @SpirvType(.{ .runtime_array = u32 }); const sampled_image = @extern(*addrspace(.constant) const SampledImage, .{ .name = "sampled_image", .decoration = .{ .descriptor = .{ .set = 0, .binding = 1 } }, }); ``` ## 调用约定上的执行模式 执行模式信息(工作组大小、片段原点等)现在由调用约定携带,而不是通过内联汇编 `OpExecutionMode` 发出。旧的 `std.gpu.executionMode()` 辅助函数已消失,SPIR-V 汇编器现在会拒绝手动的 `OpExecutionMode` 指令。还添加了两个新的调用约定 `spirv_task` 和 `spirv_mesh`,用于网格着色管线。 ```zig export fn vert() callconv(.spirv_vertex) void {} export fn frag() callconv(.{ .spirv_fragment = .{ .depth_assumption = .greater } }) void {} export fn comp() callconv(.{ .spirv_kernel = .{ .x = 8, .y = 8, .z = 1 } }) void {} export fn task() callconv(.{ .spirv_task = .{ .x = 1, .y = 1, .z = 1 } }) void {} export fn mesh() callconv(.{ .spirv_mesh = .{ .stage_output = .output_lines, .max_primitives = 1, .max_vertices = 2 } }) void {} ``` ## 从 CPU 特性派生能力和扩展 能力和扩展以前由代码生成或内联汇编临时发出。它们现在完全由 CPU 特性集驱动,就像其他目标一样,依赖链从 SPIRV-Headers (https://github.com/KhronosGroup/SPIRV-Headers) 中提取(目前排除外部供应商),汇编器现在拒绝任何直接发出 `OpCapability` 或 `OpExtension` 的尝试。 ## 多线程代码生成 从第一天起,SPIR-V 后端在链接器线程内单线程运行代码生成。现在每个代码生成作业都会产生一个 `Mir` 值,就像其他所有自托管后端一样,并在编译器的线程池上调度。 同样的更改恢复了两个在早期重构中被移除的 ISel 通道:`dedup_types`(合并等效类型指令)和 `prune_unused`(从最终模块中剥离死代码)。这些最初是在代码生成单线程时被删除的。 ## 目标文件链接 `.spv` 文件现在被识别为目标文件。您可以编译多个 `.zig` 文件(或外部 `.spv` 对象),并让 SPIR-V 链接器将它们缝合成一个模块。 在此过程中还修复了数十个 bug,`spirv64-vulkan` 目标上的通过行为测试总数增加了近 10%(现在为 49%),`std.gpu` 被重命名为 `std.spirv`,并且 SPIR-V 后端比一个月前有了实质性的用处提升,但仍有很长的路要走。许多行为测试在 SPIR-V 上仍然被跳过。也就是说,如果您一直犹豫是否尝试使用 Zig 进行着色器或计算内核编程,现在是尝试的好时机。欢迎在 Codeberg (https://codeberg.org/ziglang/zig/issues) 上提交错误报告。祝编程愉快! **2026年6月25日** ## 新的 @bitCast 语义和 LLVM 后端改进 (https://ziglang.org/devlog/2026/#2026-06-25) 作者:Matthew Lugg (接下来是一篇相当长的开发日志,抱歉——我这次有点写得太多了!) 几周前,我开始在一个分支上实现 LLVM 后端的一项改进,该改进计划已久。这最终演变成了一个更大的变更,实现了一些您可能感兴趣的关于语言特性的提案。 ## LLVM 后端整数降低 Zig 一直将任意位宽整数类型(例如 `u4`、`i13`、`u40`)直接降低为 LLVM IR 的位整数类型(`i4`、`i13`、`i40`)。然而,我们早就知道这种降低不是最优的,因为 LLVM 记录的在内存中表示这些类型的语义对优化器施加了不必要的限制。也许更重要的是,因为 Clang 从不发出这样的 LLVM IR,所以 LLVM 中的这些代码路径从未经过适当测试,因此在实践中支持不佳——在过去的几年中,我们观察到许多平凡优化被遗漏 (https://github.com/ziglang/zig/issues/17768) 甚至直接误编译 (https://codeberg.org/ziglang/zig/issues/35560) 的实例。 因此,该 PR 的最初目标是仅在操作 SSA 形式中的值时使用这些位整数类型,并在将它们存储到内存中时将它们零扩展或符号扩展到 ABI 大小的类型(`i8`、`i16`、`i32` 等)。这应该能很好地支持,不仅因为它与 Clang 降低 C 的 `_BitInt(N)` 的方式相匹配! 这个更改实际上相当直接,但我遇到了一个让我钻进兔子洞的问题。 ## `@bitCast` 的问题 `@bitCast` 是一个有趣的内置函数。在 p

相似文章

ArenaAllocators 与 ArrayList 无法良好协作

Lobsters Hottest

一篇技术博客探讨 Zig 中 ArenaAllocator 与 ArrayList 增长机制的不兼容问题:ArrayList 的「分配-复制-释放」模式使旧内存几乎永远无法归还给 Arena,最坏情况下可能导致约 3 倍内存占用。作者建议通过预分配容量(initCapacity/ensureTotalCapacityPrecise)和避免交错分配来缓解该问题。

Zig 的数组结构体 (2024)

Hacker News Top

说明 Zig 的 comptime 和类型反射如何支持创建像 MultiArrayList 这样的数组结构体 (SoA) 数据结构,从而提升高性能应用中的缓存性能。

静态分配,恒定工作

Lobsters Hottest

本文探讨了静态分配策略,以防止释放后使用、类型混淆等内存安全问题,讨论了对象池和代际索引,并介绍了来自TigerStyle的技巧,以在初始化后避免动态内存分配。

内存安全最棘手的问题

matklad

本文讨论了一个基本的内存安全挑战,涉及带标签的联合体,其中指向一个变体的指针在联合体被覆盖为另一个变体后被使用,导致类型混淆。作者还认为,缓冲区溢出是最容易被利用的内存错误,如果采用更好的数组语法本可以缓解。