Skip to content

Instantly share code, notes, and snippets.

@iacore
Last active June 10, 2026 11:34
Show Gist options
  • Select an option

  • Save iacore/7496f6d258fa491f5b31e441248c0572 to your computer and use it in GitHub Desktop.

Select an option

Save iacore/7496f6d258fa491f5b31e441248c0572 to your computer and use it in GitHub Desktop.
How bdwgc interacts with Zig programs

Zig and bdwgc

bdwgc (the Boehm-Demers-Weiser conservative GC) ships a first-class Zig build (build.zig + build.zig.zon in-tree, CI badges in README.md for zig build test, cross-compile, and zig format). The build exposes gc as a static/dynamic library artifact with the same flag matrix as the upstream CMake build. Calling convention macros (GC_CALL) are empty on all supported platforms; GC_API is extern __attribute__((visibility ("default"))) on GCC/clang. The C ABI is therefore fully Zig-friendly.

The interesting question is not the build. It is whether the memory patterns of an idiomatic Zig program survive a conservative mark-sweep collector.

How bdwgc finds pointers

bdwgc follows usize-valued words from roots. A "root" is one of:

  • the C/Zig call stack (via getcontext() or _setjmp()),
  • statically registered ranges (GC_add_roots, or the data segment when enable_register_main_static_data),
  • thread stacks (each pthread is suspended and scanned),
  • dynamic-library globals via dl_iterate_phdr.

bdwgc does not understand struct layouts, slice lengths, or type info. Each word is treated as a candidate pointer. If a word's value lies in the plausible-heap window [GC_least_plausible_heap_addr, GC_greatest_plausible_heap_addr], it is followed as a pointer and the target object's mark bit is set. The object's mark descriptor (or the default conservative word-by-word scan) is then used to find that object's own outgoing references.

What this means for std.ArrayList

In Zig 0.17, std.ArrayList(T) = Aligned(T, null). Its in-memory representation is roughly: { items: []T, capacity: usize, allocator: Allocator }

A value of this type on the Zig stack contributes three words. bdwgc sees them as raw usize and:

  • items.ptr — if it points into a bdwgc heap region, the target buffer is recognized and bdwgc's heap mark pass scans its contents.
  • items.len — small integer, not a pointer, ignored.
  • allocator vtable/context pointers — code/static addresses, ignored.

The *T values inside the buffer are then reached through the items.ptr follow, not through the stack. So the rule is simpler than it first appears:

  • If the ArrayList buffer was allocated through GC_MALLOC (or any wrapper that routes to it), the elements are reachable.
  • If the buffer was allocated through std.heap.page_allocator (the default root allocator in std.start programs) or any other non-bdwgc allocator, the elements are unreachable and will be collected on the next cycle. The pointer values on the mmap page are never seen by bdwgc.

This is the entire mechanism. bdwgc follows pointers, not structure. Reachability is a path of usize-valued words from a root to the object. A page_allocator buffer breaks that path; a GC_MALLOC buffer keeps it.

The compiler as a stress test

Zig's own compiler uses its gpa allocator for "both temporary and long-term storage" (Compilation.zig:50) and its arena for "things requiring the same lifetime as the Compilation" (line 53-54). InternPool lives on the gpa. Compilation stores *Zcu and similar in heap-allocated structs. Running the Zig compiler on top of bdwgc is therefore unsafe: the bdwgc-managed *Zcu references inside gpa-allocated struct fields are invisible to the collector.

Compatibility by program pattern

Survives (no special care needed):

  • C-only consumer, GC pointers on stack.
  • GC_MALLOC payloads, returned to user code.
  • Zig ArrayList/HashMap holding GC pointers, when the buffer itself was GC_MALLOC'd (or routed through a bdwgc wrapper).
  • std.Thread.spawn workers holding GC pointers.
  • std.Io.Threaded workers holding GC pointers.

Does not survive (silent data loss, collected on next cycle):

  • Zig ArrayList/HashMap holding GC pointers, when the buffer is page_allocator-backed (the default in std.start programs).
  • Long-running ArenaAllocator whose arena memory contains GC pointers. The arena is freed in bulk; the GC pointers inside are invisible to bdwgc.

Works with caveats:

  • Globals and const values whose bytes happen to look like heap pointers: bdwgc treats them as live, causing retention bloat but not a crash.
  • Windows dynamic linkage: fragile because of the import-library dance; static linkage is simpler.

Not applicable from Zig:

  • C++ gc_cpp overloads of new/delete (Zig cannot call them).

The IO and threading layers are fine. The standard allocator-backed containers are the problem.

Other frictions

  • std.heap.GeneralPurposeAllocator is gone in 0.17 (replaced by DebugAllocator). bdwgc has no Allocator vtable; use a thin wrapper that routes to GC_MALLOC and leaves free as no-op.
  • std.start installs its own SIGSEGV handler by default (std.zig:122). bdwgc also installs one (os_dep.c:1007). The two will fight. Disable Zig's with -Dstd_options=.{ .enable_segfault_handler = false }.
  • On Windows, enable_threads requires the bdwgc win32_threads.c path; this works but the linkage=.dynamic test step has known issues on msvc ABI (see build.zig:577-578 TODO).
  • -Doptimize=ReleaseSmall is the riskiest for the static-roots pass: aggressive constant folding can produce bit patterns that look like heap pointers, causing false retention. Debug or ReleaseFast is safer.
  • linkage=.static is simpler than .dynamic for consumers, because the import-library dance on Windows is fragile.

Practical verdict

bdwgc works for Zig programs whose memory discipline matches bdwgc's assumptions: C-style stack discipline, GC pointers either on the stack or inside other GC-allocated objects, no mixing with page_allocator structures. That excludes the Zig compiler itself and most non-trivial Zig programs that use std.ArrayList or std.HashMap with the default allocator to hold GC pointers. For a Zig program that actually wants garbage collection, the viable paths are:

  1. Write a thin Allocator vtable around GC_MALLOC and reserve it for payloads that hold other GC-allocated objects (trees where parents own children, etc.). Never use it for ArrayList of GC pointers whose buffer itself came from page_allocator.
  2. Use bdwgc only from the C parts of a mixed program and pass opaque handles across the Zig/C boundary.
  3. Use Zig's DebugAllocator (a leak detector) and skip GC.

Option 1 is the only one that uses bdwgc in the way it was designed for. The typical "I just want a GC for my Zig data structures" case is not realistic without restructuring the data so that the page_allocator layer never holds GC pointers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment