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.
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 whenenable_register_main_static_data), - thread stacks (each
pthreadis 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.
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.allocatorvtable/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
ArrayListbuffer was allocated throughGC_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 instd.startprograms) 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.
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.
Survives (no special care needed):
- C-only consumer, GC pointers on stack.
GC_MALLOCpayloads, returned to user code.- Zig
ArrayList/HashMapholding GC pointers, when the buffer itself wasGC_MALLOC'd (or routed through a bdwgc wrapper). std.Thread.spawnworkers holding GC pointers.std.Io.Threadedworkers holding GC pointers.
Does not survive (silent data loss, collected on next cycle):
- Zig
ArrayList/HashMapholding GC pointers, when the buffer ispage_allocator-backed (the default instd.startprograms). - Long-running
ArenaAllocatorwhose arena memory contains GC pointers. The arena is freed in bulk; the GC pointers inside are invisible to bdwgc.
Works with caveats:
- Globals and
constvalues 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_cppoverloads ofnew/delete(Zig cannot call them).
The IO and threading layers are fine. The standard allocator-backed
containers are the problem.
std.heap.GeneralPurposeAllocatoris gone in 0.17 (replaced byDebugAllocator). bdwgc has noAllocatorvtable; use a thin wrapper that routes toGC_MALLOCand leavesfreeas no-op.std.startinstalls its ownSIGSEGVhandler 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_threadsrequires the bdwgcwin32_threads.cpath; this works but thelinkage=.dynamictest step has known issues onmsvcABI (seebuild.zig:577-578TODO). -Doptimize=ReleaseSmallis the riskiest for the static-roots pass: aggressive constant folding can produce bit patterns that look like heap pointers, causing false retention.DebugorReleaseFastis safer.linkage=.staticis simpler than.dynamicfor consumers, because the import-library dance on Windows is fragile.
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:
- Write a thin
Allocatorvtable aroundGC_MALLOCand reserve it for payloads that hold other GC-allocated objects (trees where parents own children, etc.). Never use it forArrayListof GC pointers whose buffer itself came frompage_allocator. - Use bdwgc only from the C parts of a mixed program and pass opaque handles across the Zig/C boundary.
- 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.