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 data.

The problem: TLS isolation, global heap

D’s language model is closer to actors than shared-memory C threads:

Diagram: Thread A and Thread B have private TLS and a copy/immutable mailbox, over one shared GC heap that still pause-stops both

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.

Timeline: default GC pauses both threads; tgc pauses only the collecting thread

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 @nogc function may not allocate via the GC (new, ~= on GC slices, throwing GC-backed exceptions, and so on).

  • You can mix @nogc and 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 call GC.collect() in a safe window (for example a loading screen).

Useful tools already exist:

  • dmd -profile=gc for 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:

Mock terminal: .\\app --DRT-gcopt=gc:tgc with status that the default conservative GC remains registered
./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)

  1. Per-thread heap arenas. Thread A allocates from Heap A; Thread B from Heap B.

  2. Local collection. When Heap A collects, only Thread A’s stack, TLS, and blocks are scanned and swept. No thread_suspendAll for that cycle.

  3. Detach still wins. @nogc threads that call thread_detachThis() are never registered for pauses.

  4. 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.

  5. Isolation goal. Threads with no shared region in common must not pause each other. That is the point: realtime / @nogc shells 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.

Diagram comparing a shared global GC heap that pauses all threads against per-thread tgc heaps that collect locally and use a remote free queue for ownership transfer

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 @nogc

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:

Mock desktop studio window with a 60 fps preview, GUI and audio threads running, and a decode worker collecting locally
  • GUI / shell thread: @nogc or isolated tgc, 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:

Diagram of partitioned GC regions where Worker 1 and Worker 3 attach to Regions Alpha and Beta while Worker 2 uses only a local heap and is not paused by a Beta collection
  • 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 select tgc.

  • Pluggable GC API. druntime already supports --DRT-gcopt=gc:… and registerGCFactory. tgc is another factory name beside conservative, precise, and manual.

  • Phobos / @nogc. Separate track: less hidden allocation in the standard library helps @nogc code; it does not replace heap isolation for GC threads.

  • Upstream discussion. Historically, built-in thread-local heaps as the default were controversial (shared/immutable casting complexity). Framing tgc as opt-in matches that history and keeps the blast radius small.

This problem space is not empty. SymGC (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 1 (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 @nogc workers, 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.org.

Trying the prototype

The implementation lives in the feature/tgc branch of the dlang-supplemental DMD fork (path druntime/src/core/internal/gc/impl/tgc/).

  1. Build DMD and druntime from that branch.

  2. Run with --DRT-gcopt=gc:tgc.

  3. See druntime/test/gc/tgc.d for 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.