summaryrefslogtreecommitdiff
path: root/notes/260925-satb.md
diff options
context:
space:
mode:
authorTaylan Kammer <taylan.kammer@gmail.com>2026-09-25 16:13:07 +0200
committerTaylan Kammer <taylan.kammer@gmail.com>2026-09-25 17:40:14 +0200
commit0545c6c65fe922af26c2343093a5ca1fca9d3a2b (patch)
treed8b842d98ca048a992dbc3875ca3f7ea5dbc1e0e /notes/260925-satb.md
parent9c9d62c6f2f549914806232a2b06c6d6782131b5 (diff)
Add note.
Diffstat (limited to 'notes/260925-satb.md')
-rw-r--r--notes/260925-satb.md342
1 files changed, 342 insertions, 0 deletions
diff --git a/notes/260925-satb.md b/notes/260925-satb.md
new file mode 100644
index 0000000..2d72eb8
--- /dev/null
+++ b/notes/260925-satb.md
@@ -0,0 +1,342 @@
+# No-STW SATB
+
+<!--TOC-->
+
+_2026 September_
+
+Snapshot at the Beginning (SATB) seems like an elegant strategy for a
+concurrent mark-sweep GC. Implementations of it out in the wild seem
+to generally use a global STW for root scanning. I.e., they need all
+threads to be paused simultaneously, at which point some work is done,
+and then the threads can continue. That's sub-optimal because it may
+take a while for all threads to jointly come to a halt.
+
+But the upside is you need no load barriers. Given that reading a
+pointer from the heap is typically a lot more common than writing a
+pointer into the heap, a no-load-barriers design like SATB is quite
+attractive.
+
+Here's an attempt at a GC design using SATB without a global STW.
+
+I'm still by no means a GC expert, and will be glossing over some
+details. There's a chance I'll miss something crucial, making some
+aspect of the design unfeasible. We'll see.
+
+By the way, some familiarity with GC terminology is assumed, such as
+tricolor object sets, and what a barrier is at a basic level.
+
+
+## Data Structure Recap
+
+First, let's recap some of the design choices related to the mark and
+sweep phases of the GC, which I've already covered in previous notes.
+
+
+### 'Allocated' Bitmaps
+
+A massive block of virtual memory containing bitmaps that cover every
+slot of memory that our GC can give out.
+
+The location of a bit is trivially calculated from the object address
+in few CPU instructions. The kernel's demand paging ensures that the
+physical backing memory is proportional to all-time peak heap usage
+including uncollected garbage, and it's only a tiny fraction of that,
+since one bit here represents an object of 64 bits at minimum, up to
+many KiB at the high end.
+
+A bit is set atomically whenever any thread allocates an object.
+
+The GC will swap out the entire Allocated bitmap array for a zeroed
+out bitmap when it begins the mark phase; the original one is stored
+away to be used for the sweep phase, and then recycled.
+
+Basically, this becomes the representation of the White object set at
+the start of marking; AKA the 'condemned' set.
+
+
+### 'Marked' Bitmaps
+
+Same shape and characteristics as the Allocated Bitmaps.
+
+Starts out all zero, and replaces the Allocated Bitmaps when marking
+begins, so new allocations are automatically marked.
+
+Basically, the Black object set during marking. After marking, the
+sweep phase subtracts this set from the White set, to determine the
+set of objects to be freed.
+
+
+### Trace Work Set
+
+A radix tree-like structure storing an ordered map of object address
+to type, so the trace loop can scan memory in order, and objects need
+no in-heap type headers (since we use NaN-packed tagged pointers that
+obviate the need for that).
+
+Basically the Grey set during marking.
+
+
+## State Terminology
+
+Let's establish some common terminology with regards to different
+states the GC and application threads can be in.
+
+
+### GC States
+
+* `IDLE`: Not doing any GC work (that other threads need to know of).
+
+* `SATB`: Waiting for application threads to reach SATB status.
+
+* `MARK`: Marking has begun after SATB has been established.
+
+* `FREE`: Wants to sweep; will wait for application threads to flush
+ any pending Work Set writes from SATB barrier action.
+
+We may need one more state to synchronize across multiple GC threads;
+that's outside the scope of this article.
+
+
+### Mutator States
+
+*Mutator means application thread.*
+
+* `IDLE`: Assumes the GC is idle; barriers disabled.
+
+* `SATB`: Acknowledged the GC's SATB request; store barriers enabled.
+
+* `MARK`: Acknowledged the GC's start of the mark phase.
+
+* `FREE`: Acknowledged the GC's desire to begin sweeping, so has
+ disabled barriers and flushed pending writes into the Work Set.
+
+
+## GC Strategies
+
+Before we get to our actual desired design, let's look at simpler GC
+strategies as a baseline, and look at problems that arise as we add
+Concurrent and No-STW capabilities.
+
+
+### STW Serial M/S
+
+This one is extremely simple. Stop all threads, scan their roots,
+trace the object graph, sweep, continue all threads.
+
+The only interesting problem is that stopping the threads arbitrarily
+could lead to pointers being invisible due to being in the middle of
+some pointer arithmetic shenanigans. The typical solution is to wait
+for all threads to hit a safe-point in which they have a well-defined
+layout with regards to register contents.
+
+(Once again, I'm glossing over details, but I believe the design of
+safe-points with regards to ensuring a well-defined view of thread
+roots is largely an orthogonal issue to what I'm focusing on here.)
+
+We need no barriers of any kind, and it doesn't matter whether the
+Allocated and Marked bitmaps are swapped at the start or end, since
+allocations are paused anyway.
+
+If language semantics don't permit threads directly writing into the
+roots of other threads, then I believe threads could scan their own
+roots as they hit the pause, without waiting for the global pause.
+However, in the context of STW Serial M/S this would probably be a
+micro-optimization at best. Anyhow.
+
+
+### STW Concurrent M/S
+
+This isn't too complicated. You perform the same STW to scan roots,
+but then mark and sweep concurrently.
+
+Similar to the serial method, you either wait for all threads to hit
+the pause, and then scan their roots; or, if cross-thread writes to
+roots isn't possible by language semantics, you can have them scan
+their own roots before hitting pause. Either way, what happens next
+is that the GC enters the mark phase, but then wakes up application
+threads, allowing them to run concurrently with M/S.
+
+Note that this means the Mutators don't need to be set to the MARK
+state; they can just stay in SATB which tells the GC that they have
+been, or can be, scanned. Then, they're just woken up and continue
+execution while the GC marks and sweeps.
+
+Here we need barriers for the first time to satisfy SATB invariants.
+
+What if, after the GC begins marking, an application thread acquires
+some pointer from deep within the object graph, that hasn't yet been
+traced, deletes all occurrences of it within the heap, but stores it
+locally? It could stay in a register or on the stack for any amount
+of time and used later. Meanwhile, the GC finds no trace of it, and
+it gets freed during sweep.
+
+This is called "hiding" a White object. Another way of doing it is
+acquiring a pointer to a White object and writing it into a Black
+object, then removing any other pointers to it. Since the Black
+object won't be scanned again, we successfully hid the White one.
+
+A solution is to use a write barrier that takes the *old* value of a
+pointer being overwritten within the heap, and greys it. I.e., it's
+inserted into the Trace Work Set. Hence "Snapshot at the Beginning":
+Any object that *was* reachable when marking began will be considered
+reachable by this GC cycle, just in case. Hiding becomes impossible.
+This is the traditional *Yuasa barrier* or *Yuasa-style deletion
+barrier* that's used to implement regular SATB.
+
+And what about newly allocated objects? The GC swaps the Allocated
+and Marked bitmaps while entering the MARK state, which application
+threads are forced to observe, in terms of memory ordering, by the
+time they're woken up. As such, new objects become Black; the GC
+allocator thinks it's setting bits in the Allocated bitmap but the
+pointer goes to the Marked bitmap which will effectively become the
+Allocated bitmap by the end of the mark phase.
+
+In tricolor terms, this puts new objects into the Black set after the
+mark phase has begun. This is correct because new objects can't have
+pointers to existing objects.
+
+To summarize, the sequence here would be:
+
+1. GC enters SATB state and begins waiting.
+
+2. Mutators gradually halt (enter SATB state), signaling the GC to
+ continue if all mutators have reached SATB state. They can scan
+ their roots before pausing, or let the GC do it once all are in
+ pause (if cross-thread root access is possible).
+
+3. GC swaps out Allocated & Marked bitmaps, enters MARK state, and
+ wakes up mutators. The Yuasa barrier upholds the SATB invariant
+ during concurrent GC work.
+
+
+### No-STW Concurrent M/S
+
+Now we're finally getting to our actual desired design, where threads
+don't all need to reach a simultaneous pause. Let me simply explain
+the order of events:
+
+0. Everything is IDLE.
+
+1. The GC is triggered: It enters the SATB state and begins waiting.
+
+2. Mutator threads gradually all enter SATB: From this point on, no
+ object reachable *from the heap* can get lost, thanks to write
+ barriers intercepting the *deletion* of a pointer *in* the heap.
+
+ (Threads can drop pointers in locals, but global variables count
+ as box objects on the heap, so to speak; write barrier applies.)
+
+ (I realize now that I may be abusing the term "Snapshot," the S in
+ SATB, because there's no single instant in which we capture a view
+ of *all* live objects.)
+
+3. The GC finds that all Mutators have acknowledged SATB. It enters
+ the MARK state, which will tell Mutators that they can scan their
+ roots now. The GC's trace loop begins, which continues until all
+ Mutators are in MARK, and the Trace Work Set is empty / all Black.
+
+ (Yes, Black objects can end up in the set; explained later.)
+
+ This is a good time to scan global roots; the GC can begin their
+ tracing, while Mutator threads are busy scanning their roots.
+
+ (Global roots are global/static variables; Mutator roots are the
+ stack, registers, and thread-local variables of Mutator threads.)
+
+ Also, this is when the Allocated and Marked bitmaps are swapped.
+
+4. Mutators gradually acknowledge the scan request, performing it and
+ updating themselves to the MARK state. This forces them, in terms
+ of memory ordering, to observe the swap of the Allocated bitmap.
+ That means new allocations are Black rather than White.
+
+ Some threads may have "coincidentally" observed the bitmap swap
+ earlier, while still in SATB; this is benign. A Mutator in SATB
+ state (pre root scan) can safely interact with Black objects, and
+ can see some in the heap, since concurrent mark may have already
+ started. Read on for how this is made safe.
+
+ After the stack scan, new allocations are guaranteed Black, which
+ is typical Concurrent M/S behavior. However, while this works in
+ STW SATB due to any objects written into a Black object being part
+ of the existing snapshot; we have the problem that some Mutator,
+ which didn't scan its roots yet, could have the sole copy of some
+ White object's pointer, which it writes into the new Black object,
+ then drops the local pointer before its root scan.
+
+ The more general version of this problem is that *any* Black heap
+ object could have a White pointer written into it by a thread that
+ hasn't yet performed its root scan. (It wouldn't be a problem if
+ the White object came from the heap, since the Yuasa barrier would
+ make sure it can't be hidden, but pre-scan Mutator roots may still
+ contain White pointers that can be written into Black objects and
+ then dropped.)
+
+ The solution is a *Dijkstra-style insertion barrier* AKA *Dijkstra
+ barrier* which is another kind of write barrier: It intercepts the
+ *new* pointer being written into any heap object (includes global
+ variables, which are seen as box objects on the heap) and greys
+ that pointer (puts it in the Work Set).
+
+ Though this only needs to happen if the pointer is written into a
+ Black object, it's probably not worth adding this conditional, for
+ multiple reasons: 1. It requires a "remote" memory load from the
+ Marked bitmap. 2. We can't really mark global pointers as Black,
+ since they're not really heap objects.
+
+ The combination of the Yuasa and Dijkstra barriers is also what Go
+ uses, because although it performs a very brief STW, it then scans
+ thread roots incrementally.
+
+ Oh, and this is how Black objects can end up in the Work Set: The
+ Dijkstra barrier won't be checking whether the new value of the
+ pointer it's writing is White, just like it won't be checking if
+ the object that's being written into is Black. Similar reason as
+ before: It may end up doing a "remote" memory load for a mark bit
+ that's not in this thread's L1. Greying, on the other hand, is
+ very cheap with local buffers that get flushed to the Work Set in
+ batches.
+
+ The Dijkstra barrier is disabled again for a Mutator thread once
+ it's scanned its roots and entered the MARK state.
+
+ Wow, this was a lot of text for a list point...
+
+ One last note: I believe the Dijkstra barrier can allow for safe
+ cross-thread root writing. After all, the object whose pointer
+ you're "sending off" is greyed, so it can't possibly be hidden.
+ Just make sure cross-thread root references are seen as "heap"
+ objects, so the barrier is added to write operations to them.
+
+5. The GC's trace loop eventually terminates as it finds the Work Set
+ empty while all Mutators are in the MARK state, meaning there's no
+ more marking work to be done, except that some Mutators may still
+ be holding on to unmarked objects that haven't yet been flushed
+ into the Work Set. So, the GC enters the FREE state and begins
+ waiting.
+
+ There's one important nuance to keep in mind here: Mutators may
+ continue to put the same Black objects into the Work Set over and
+ over again due to barrier behavior (explained below), so tracing
+ could get stuck in a loop if we're not careful. The trace loop
+ must be able to deal with this edge case, noticing that all new
+ additions to the Work Set that it found during an iteration were
+ Black, *and* all Mutators were in the MARK state already at the
+ start of that iteration. This condition must terminate the loop.
+
+6. Mutators acknowledge the GC's transition into the FREE state and
+ flush any pending writes into the Work Set, then enter the FREE
+ state themselves.
+
+7. The GC observes that all Mutators have acknowledged the desire to
+ end marking. It restarts the trace loop to finish off stragglers
+ and then performs the sweep. It returns to IDLE.
+
+I'm glossing over some details here, and I'm not sure yet how some of
+them would best be implemented, such as the GC "waiting" for Mutator
+threads to do something. But that should be easy to figure out when
+compared to all else we've discussed.
+
+I won't be discussing the spawning of new threads during GC, or the
+synchronization of multiple GC threads. That's for another time,
+because this is complicated enough already.