Thread-local garbage collection (tgc) for D
D already isolates data by default: ordinary variables are thread-local, and std.concurrency pushes actor-style message passing with spawn, send, and receive.
What the runtime still shares is the heap.
Today’s default garbage collector is one process-wide mark-sweep engine.
When any thread triggers a collection, the collector stop-the-world pauses every registered thread—including threads that never allocate and that you carefully marked @nogc.
This article explains that gap, why per-thread heaps and partitioned shared regions fix the latency story for realtime-minded apps, the trade-offs, and an opt-in collector named tgc (thread garbage collection) that we have prototyped and proposed upstream.
Informally you may hear “realtime GC”; that is a side-name only—the registered runtime name is tgc.
Canonical design + version status: tgc design notes.
0.2.0 adds working shared-region collect, tests, and benchmarks — see tgc demo.
Related announcement: News: tgc submitted upstream.
Essay on the org site: Isolating heaps as threads isolate dataunchecked.
The problem: TLS isolation, global heap
D’s language model is closer to actors than shared-memory C threads:
Unless you type something shared or __gshared, Thread B cannot see Thread A’s variables.
std.concurrency deep-copies or requires immutable across Tid boundaries for good reason.
The GC does not yet honor that isolation.
Allocations from every thread land in one shared heap.
A collection must freeze stacks and registers across the process so the tracer does not race with mutators.
A pure @nogc audio callback thread, a game render loop, or a GUI shell that never calls new still stops when another thread’s GC runs.
That is the core friction for a GC desktop app with a few realtime threads, and for any design that wants GC workers beside deterministic @nogc workers in one process.
Mixing @nogc and GC today
GC vs @nogc is not a per-module switch.
It is enforced per function at compile time, and controlled at runtime with core.memory.GC.
-
A
@nogcfunction may not allocate via the GC (new,~=on GC slices, throwing GC-backed exceptions, and so on). -
You can mix
@nogcand GC code in one program: put GC work in init, config, or background workers; keep the hot path@nogc. -
You can suppress automatic collections with
GC.disable()/GC.enable()and callGC.collect()in a safe window (for example a loading screen).
Useful tools already exist:
-
dmd -profile=gcfor allocation profiles. -
GC.profileStats()for pause and collection counts. -
thread_detachThis()to unregister a thread from GC scans entirely—after detach, that thread must never touch GC memory.
Detaching is the only way today to guarantee a thread is never paused. It does not give you idiomatic D collections on that thread; it gives you a C-like island.
Built-in slices, associative arrays, and many Phobos APIs allocate on the GC heap, so they are rejected inside @nogc.
-betterC and the long Phobos evolution toward fewer hidden allocations help the pure-@nogc world.
They do not remove stop-the-world pauses for the threads that still use the GC.
What other runtimes do
Isolated heaps are a proven pattern:
-
Erlang / BEAM: each process has its own heap and GC; collecting one process does not pause others. Messages copy (or move large binaries).
-
Pony: capability types (
iso,val,ref) plus a concurrent GC (ORCA) so actors communicate without a global STW.
D already has the language half of the actor story (TLS + std.concurrency).
The missing piece is a GC that collects per thread (or per region) instead of per process.
Rust solves a different problem: no GC, affine ownership, and a standard library built without a collector.
That is a valid design; it is not “automatic GC-to-@nogc synthesis.”
Compilers can do escape analysis in limited cases (stack-allocate what never escapes), but Rice’s theorem blocks a general, correct, automatic rewrite of arbitrary GC programs into optimal allocation-free ones.
Programmers still need structural constraints—ownership, message passing, or regions.
Enter tgc: opt-in thread garbage collection
tgc is an opt-in GC backend for druntime, selected like other pluggable collectors:
./app --DRT-gcopt=gc:tgc
It is not a replacement for the default conservative GC.
It is for special cases: multi-threaded apps that care about p99/p999 latency, mixed GC and @nogc workers, and actor-style designs that already avoid sharing mutable GC pointers.
Design goals (target)
-
Per-thread heap arenas. Thread A allocates from Heap A; Thread B from Heap B.
-
Local collection. When Heap A collects, only Thread A’s stack, TLS, and blocks are scanned and swept. No
thread_suspendAllfor that cycle. -
Detach still wins.
@nogcthreads that callthread_detachThis()are never registered for pauses. -
Partitioned shared regions (primary cross-thread model). Threads that depend on the same data attach to the same region heap. Collecting that region pauses only attached threads, not the whole process. Many-to-many: a thread may attach to several regions; a region may have several members.
-
Isolation goal. Threads with no shared region in common must not pause each other. That is the point: realtime /
@nogcshells stay smooth while unrelated GC workers collect elsewhere.
Copying or remote-free on a private heap is an interim / escape hatch in 0.1.0 code, not the intended long-term model for shared pipelines (for example frame rings between decode workers).
What ships today (0.1.x)
0.1.0: per-thread private heaps and local collect (smoke-tested); remote free on private heaps (interim).
0.1.1: adds shared-region API scaffold (create / attach / region malloc); region collect and selective suspend not implemented.
No production claim; benchmarks vs default GC and SymGC are still outstanding. 0.2.0 (planned, breaking): working shared regions.
How local collection avoids global pauses
Because each mutator only mutates its own heap metadata during normal allocation, and because roots for a local collection are “this thread’s stack + TLS + known roots,” the collector does not need to freeze sibling threads.
Sibling GC threads keep running.
Detached @nogc threads keep running.
That is the latency win.
Performance: why per-thread heaps are worth it
Per-thread heaps are not free.
Memory footprint. Arenas do not automatically donate free pages to siblings. Thread 1 may sit on a large free list while Thread 2 asks the OS for more. RSS and fragmentation can rise with thread count unless you add arena stealing later.
Cross-thread free.
If Thread 1 allocates a buffer, transfers the pointer to Thread 2 (zero-copy ownership move), and Thread 2 is done, Thread 2 must not put that address on Thread 2’s free list—that would corrupt Thread 1’s allocator metadata.
Thread 2 pushes the pointer into Thread 1’s remote free queue; Thread 1 drains it at safe points (next alloc, collect, or mailbox loop).
That reintroduces a small amount of synchronization, but only on the ownership-transfer path—not on every local new.
Message queues and shared pipelines. Actor mailboxes for small messages can stay on private heaps. Large shared buffers (video frames, decode rings) belong in a partitioned shared region attached to the workers that actually share them—not copied every frame, and not handed off via private-heap remote free lists.
What you gain:
| Metric | Shared global heap | Per-thread (tgc) heap |
|---|---|---|
Allocation |
Contends on global locks / atomics |
Lock-free bump / local free lists on the hot path |
Cache locality |
Threads interleave on shared pages; false sharing |
Data stays on the core that uses it |
GC pause |
Global STW |
Micro-collections local to one thread; zero pause for others and for detached |
Choose the default GC when RAM is tight or concurrency is low and global pauses are fine.
Choose tgc when tail latency matters more than peak memory density.
Cross-thread memory (target vs 0.1.0 stub)
Target: shared regions
Workers that share a frame ring or parser cache attach to the same GC region. Pointers stay valid across those threads until the region collects; only region members pause for that collect. Threads outside the region keep running.
0.1.0 interim: remote free on private heaps
The current prototype includes a remote free queue for pointers allocated on one thread’s private heap and freed from another. This is a bring-up hack, not the design center—it does not replace shared regions for pipelines that intentionally share memory.
Suppose Thread 1 allocates at 0x7FFF0000 on Heap A and moves that pointer to Thread 2 (Thread 1 drops its reference):
Thread 1 (Heap A) Thread 2
Allocated 0x7FFF0000 --- move ---> Holding 0x7FFF0000
wants to free it
^ |
+---- remote free queue <----+
"put this back on Heap A"
Move path (0.1.0 stub): Thread 2 queues 0x7FFF0000 for Thread 1. Thread 1 drains the queue cooperatively.
Prefer region attach for any data that outlives a one-shot handoff.
Why Thread 2 cannot free into Heap A directly: allocator metadata would become shared memory, and every local alloc/free would need atomics—destroying the point of thread-local heaps.
Shared memory as an opt-in subset
Classical shared-memory multithreading can sit inside an actor-first design as an escape hatch, not the default.
Imagine a desktop media app:
-
GUI / shell thread:
@nogcor isolatedtgc, actor mailbox, locked frame rate. Must not hitch when workers collect. -
Decode / filter workers: share a ring of frame buffers in a partitioned shared region attached to those workers only. GC pauses there must not cascade into the shell.
A parser that occasionally pauses is fine if the shell never shares a region with the parser. A 4K frame pipeline that copies 32 MB per frame between threads will melt the memory bus; those workers need a deliberate shared region—not copy-by-default.
Many-to-many regions
Instead of one global shared heap, map workers to regions:
-
Worker 1: local heap + Region Alpha + Region Beta
-
Worker 2: local heap only
-
Worker 3: local heap + Region Alpha
Collecting Region Beta pauses only threads that hold that region. Worker 2 never notices.
Transitive pause risk: if Worker 1 shares Alpha with Worker 2, and Worker 2 shares Beta with Worker 3, a Beta collection that pauses Worker 2 can stall Worker 1 when Worker 1 needs Alpha. Preventing cascades needs a DAG of region ownership at the API or type level—future work, called out honestly in the upstream proposal.
How this relates to D’s ecosystem
-
Default GC stays (for
tgc). This proposal keeps the conservative collector as the default and adds an opt-in factory. Existing programs and Phobos assumptions remain unless you selecttgc. -
Pluggable GC API. druntime already supports
--DRT-gcopt=gc:…andregisterGCFactory.tgcis another factory name besideconservative,precise, andmanual. -
Phobos /
@nogc. Separate track: less hidden allocation in the standard library helps@nogccode; it does not replace heap isolation for GC threads. -
Upstream discussion. Historically, built-in thread-local heaps as the default were controversial (shared/
immutablecasting complexity). Framingtgcas opt-in matches that history and keeps the blast radius small.
Related work: SymGC (and “we need a new GC”)
This problem space is not empty.
SymGCunchecked (also referred to as the SDC / sdc GC), maintained by Steven Schveighoffer and sponsored by Symmetry Investments, is a clean-slate collector that you can try today as a DUB dependency or via experimental builds.
Enable it with --DRT-gcopt=gc:sdc (or sdcq to skip the startup banner) after importing symgc.gcobj.
Public talks from DConf ’24 walk through the motivation and design:
-
We Need a New GC, Episode 1unchecked (and the follow-up episode from the same conference track)
-
Forum / announce notes from the authors summarize goals such as removing the global allocation lock, more cache-friendly layout, returning memory to the OS more aggressively, and planned concurrent scanning
Community discussion has suggested SymGC may eventually become the default runtime GC. That path is still evolving; whether the classic process-wide conservative collector remains available as a selectable backend is not settled in public materials we reviewed.
How that relates to tgc (even if the projects disagree on the endgame):
-
SymGC’s headline wins are largely about allocation throughput, lock contention, and a modern heap layout while still aiming at a shared managed heap with better multi-threaded behavior.
-
tgc’s headline win is pause isolation: each thread’s collection should not stop sibling threads or detached@nogcworkers, at the cost of higher baseline memory use and stricter rules for sharing GC pointers.
Those are complementary axes at the ecosystem level.
SymGC remains the path for a better shared default heap; tgc targets pause isolation via private heaps and region-scoped STW.
Runtime note: druntime still picks one pluggable GC at process startup (--DRT-gcopt=gc:…).
You cannot today invoke SymGC on one branch and tgc on another inside the same process without a future composite GC.
Practical split: different processes, or one selected backend plus @nogc/manual islands.
Forum thread for this write-up: https://forum.dlang.org/post/eyivmktfvmdlvhbyyphd@forum.dlang.orgunchecked.
Trying the prototype
The implementation lives in the feature/tgc branchunchecked of the dlang-supplemental DMD fork (path druntime/src/core/internal/gc/impl/tgc/).
-
Build DMD and druntime from that branch.
-
Run with
--DRT-gcopt=gc:tgc. -
See
druntime/test/gc/tgc.dfor a multi-thread smoke test.
0.1.x limits to keep in mind:
-
Shared-region collect and selective thread suspend are not implemented yet (0.1.1 scaffold only).
-
Remote free on private heaps is interim, not the target cross-thread model.
-
Array append metadata helpers are minimal compared to the conservative GC.
-
Treat this as a 0.1.x prototype for design review and measurement, not a production realtime guarantee without your own benchmarks.
Summary
D’s threads are already logically isolated; the GC heap is not.
An opt-in thread-local collector—tgc—lets GC threads collect locally while @nogc and sibling threads keep running.
That unlocks the hybrid architecture many desktop and media apps want: idiomatic GC where pauses are acceptable, deterministic threads where they are not, without forcing the whole program into -betterC.
Read the relevant news announcement for the upstream PR link and status.