| |

Dart 93 🎯 Internal Architecture: Garbage Collector Safepoints and Mutator Pauses

⏱️ Estimated read: 14 min | |

A safepoint is the mechanism by which the Dart VM stops all mutator threads at a known, safe location so that a garbage collection operation can proceed without the heap changing underneath it. The concept is simple: before the GC can visit roots or move objects, every thread that could allocate, read, or write the heap must be paused at a point where its stack and registers are in a state the GC understands. The implementation is not simple. The VM distinguishes between different levels of safepoint operations, tracks thread execution states through RAII scopes, and coordinates all isolates within an isolate group so that a collection in one does not leave another mutating the shared heap.

Key point: A mutator thread at a safepoint is not necessarily suspended. It may be running native code or blocked on a monitor, both of which are considered safe states because they cannot touch the Dart heap. The thread is paused only when it attempts to leave the safe state and re-enter the heap.


Why safepoints exist

The heap mutation problem. The garbage collector’s job is to identify live objects, reclaim dead ones, and in some cases move objects to new addresses. All three operations require a stable view of the heap. If a mutator thread is in the middle of allocating an object or updating a pointer when the GC scans that region, the GC may see a partially initialized object or a stale pointer. Safepoints exist to guarantee that when the GC runs, no thread is in the middle of a heap operation . The VM documentation is explicit: “New-space objects and roots are only visited during a safepoint, and safepoints establish synchronization” .

The stack scanning problem. To find roots, the GC must walk the stacks of all mutator threads. An optimized Dart frame does not contain enough information for the GC to distinguish a pointer from an integer without additional metadata. The VM stores stackmaps that describe, for each instruction address, which stack slots and registers contain pointers. A thread must be at a location covered by a stackmap for the GC to scan it safely. The GC safepoint operation exists precisely to bring all threads to such locations .

The isolate group coordination problem. Since the introduction of isolate groups, isolates within a group share a heap. A GC triggered by one isolate must pause all isolates in the group, not just the one that requested it. The safepoint handler and thread registry were moved from the individual isolate to the isolate group to make this coordination possible . Without this, one isolate could continue allocating while another’s GC was trying to establish a consistent view of the shared heap.

The deoptimization problem. Some operations require not just a GC-safe point but a point where the frame can be lazily deoptimized—that is, discarded and replaced with an unoptimized frame. This requires deopt-id information in addition to stackmaps. The VM distinguishes “gc safepoint operations” from “deopt safepoint operations” because the latter has stricter requirements: a thread at a GC-safe point may not be at a deopt-safe point . The distinction allows the VM to request the minimum level of cooperation needed for a given operation.


a. Thread Execution States and Safepoint Levels

Every thread in the VM has an execution state that determines whether it is considered to be at a safepoint. The relevant states are kThreadInGenerated (running compiled Dart code), kThreadInVM (running C++ VM code), kThreadInNative (running native code), and kThreadInBlockedState (blocked on a monitor) . Threads in kThreadInNative or kThreadInBlockedState are considered at a safepoint because they cannot access the Dart heap. Threads in kThreadInGenerated or kThreadInVM are not at a safepoint and must transition to one before a GC operation can proceed.

The transitions between these states are managed through RAII scope classes defined in safepoint.h. TransitionGeneratedToNative marks a thread as entering native code and calls EnterSafepoint(). TransitionVMToBlocked does the same for a thread blocking on a monitor. The constructors and destructors of these classes assert that the transitions are valid and update the thread’s safepoint state accordingly . This design ensures that a thread cannot accidentally remain in a non-safe state while the GC is waiting for it.

The VM defines three levels of safepoint operations. GcSafepointOperationScope requires all mutators to stop at locations where GC is safe—meaning stackmaps are available for all optimized frames. DeoptSafepointOperationScope requires all mutators to stop at locations where both GC and lazy deoptimization are safe—meaning deopt-id information is also available. ReloadSafepointOperationScope additionally allows hot reload. A thread that is only at a GC-safe point can participate in a GC safepoint operation but not in a deopt or reload operation . The SafepointHandler tracks the operation owner, nesting count, and the number of threads not yet parked for each level .


b. The Rendezvous Mechanism

When a GC safepoint operation begins, the SafepointHandler sets a flag on each thread indicating that a safepoint is requested. It then waits on a monitor until the count of threads not at a safepoint reaches zero. A thread that is running compiled Dart code does not check this flag on every instruction. Instead, the flag is checked at safepoint polling locations—points in the generated code where the VM has inserted a check, such as function entry, loop back edges, and allocation sites . When the thread reaches a polling location, it calls CheckSafepoint(), enters the safepoint, and waits until the operation completes.

The rendezvous is not instantaneous. A thread running a long-running loop without a safepoint poll will delay the GC. The VM’s compiler inserts polls at loop back edges precisely to bound this delay. A thread running native code is already considered at a safepoint, so it does not need to poll; it will be blocked from re-entering the heap until the operation finishes. The same applies to a thread blocked on a monitor .

A subtlety arises when multiple threads attempt to enter safepoint operations concurrently. A thread that fails to allocate may initiate a GC safepoint operation while another thread is already organizing a deopt safepoint operation. The VM allows a thread that owns a deopt safepoint operation to still participate in another thread’s GC safepoint operation requests until all threads are at deopt-safe locations. The thread’s safepoint state includes bits for AtSafepoint, SafepointRequested, AtDeoptSafepoint, DeoptSafepointRequested, and BlockedForSafepoint to track this nested state .


c. The Allocation Safepoint Pitfall

A safepoint is not free. Entering and exiting a safepoint operation involves acquiring a monitor, updating counters, and potentially waiting for other threads. When an allocation fails and triggers a GC, the allocating thread enters a GcSafepointOperationScope. If the GC determines that a scavenge is not needed—for example, because a previous scavenge left the new space full and the scavenger set a flag to skip the next attempt—the thread still pays the cost of the safepoint operation without doing any GC work .

This became a documented performance issue in Dart 3.9. When new space is full, every allocation in every isolate in the group misses its thread-local allocation buffer and enters Heap::AllocateNew, which enters a safepoint operation, sees the flag, skips the scavenge, and exits. The safepoint operation pauses the entire isolate group for each such allocation. In a workload with multiple isolates, this can cause the group to crawl for seconds or tens of seconds until the next old-space GC clears the flag and allows normal allocation to resume . The fix for this issue is not a change to the safepoint mechanism itself but to the condition that triggers the safepoint operation—checking the skip flag before entering the scope rather than inside it.

This pitfall illustrates the central trade-off of safepoints: correctness requires that heap operations be synchronized, but the synchronization mechanism itself has a cost. The VM minimizes that cost by using polling rather than preemption, by treating native and blocked threads as already safe, and by avoiding safepoint operations for allocations that will not actually trigger a collection. When those optimizations fail, the performance impact is visible in allocation-heavy workloads with multiple isolates.


Complete Example Session

// ============================================
// PART 1: A MUTATOR THREAD RUNNING DART CODE
// ============================================
// The thread is in kThreadInGenerated state
// It is NOT at a safepoint
void mutatorLoop() {
  for (int i = 0; i < 1000000; i++) {
    // Loop back edge contains a safepoint poll
    // If a GC requests a safepoint, the poll catches it
    // and the thread enters the safepoint operation
  }
}


// ============================================
// PART 2: ALLOCATION TRIGGERS A SAFEPOINT
// ============================================
// When an allocation fails, the mutator enters
// a GcSafepointOperationScope
void allocate() {
  // Allocation attempt
  // If it fails, the VM enters a safepoint operation
  // and waits for all other mutators to park
  final list = List.generate(1000, (i) => i);
}


// ============================================
// PART 3: NATIVE CODE IS ALREADY SAFE
// ============================================
// A thread running native code is in kThreadInNative
// It is considered to be at a safepoint
// The GC does not need to wait for it to poll
// It is simply blocked from re-entering the heap
// until the operation completes


// ============================================
// PART 4: BLOCKED THREAD IS ALREADY SAFE
// ============================================
// A thread waiting on a monitor is in kThreadInBlockedState
// It is considered to be at a safepoint
// The GC can proceed without waiting for it


// ============================================
// PART 5: SAFEPOINT LEVELS
// ============================================
// GcSafepointOperationScope:
//   Requires stackmaps for optimized frames
//   Used for scavenge, root marking
//
// DeoptSafepointOperationScope:
//   Requires stackmaps + deopt-id
//   Used when frames may be lazily deoptimized
//
// ReloadSafepointOperationScope:
//   Requires stackmaps + deopt-id + reload safety
//   Used for hot reload


// ============================================
// PART 6: SAFEPOINT HANDLER STATE
// ============================================
// Each level has a handler tracking:
// - owner_ (thread that initiated the operation)
// - operation_count_ (nesting depth)
// - num_threads_not_parked_ (threads yet to reach safepoint)


// ============================================
// PART 7: THE RENDEZVOUS
// ============================================
// 1. Handler sets SafepointRequested on all threads
// 2. Handler waits for num_threads_not_parked_ == 0
// 3. Each thread polls, calls CheckSafepoint(), parks
// 4. When all parked, GC operation begins
// 5. On completion, ResumeAllThreads() unblocks all


// ============================================
// PART 8: ISOLATE GROUP COORDINATION
// ============================================
// All isolates in a group share a heap
// A GC in one isolate must safepoint all isolates
// The thread registry and safepoint handler
// are per isolate group, not per isolate


// ============================================
// PART 9: THE ALLOCATION PITFALL
// ============================================
// If new space is full and scavenge is skipped
// Every allocation enters a safepoint operation
// even though no GC work is done
// This pauses the entire isolate group
// until the next old-space GC clears the flag


// ============================================
// PART 10: SAFEPOINT SCOPE IN VM CODE
// ============================================
// VM code that needs to prevent GC
// enters a NoSafepointScope
// This increments no_safepoint_scope_depth_
// The GC waits until the depth returns to zero

The ten parts trace the thread states, the safepoint levels, the rendezvous mechanism, and the allocation pitfall. Parts 3 and 4 show why native and blocked threads do not need to poll. Part 8 shows why the handler is scoped to the isolate group.


Quick Reference

Thread Execution States

StateAt SafepointCan Touch Heap
kThreadInGeneratedNoYes
kThreadInVMNoYes
kThreadInNativeYesNo
kThreadInBlockedStateYesNo

Safepoint Operation Levels

ScopeRequirementsUse Case
GcSafepointOperationScopeStackmapsScavenge, root marking
DeoptSafepointOperationScopeStackmaps + deopt-idLazy deoptimization
ReloadSafepointOperationScopeStackmaps + deopt-id + reloadHot reload

Safepoint Handler State

FieldPurpose
owner_Thread that initiated the operation
operation_count_Nesting depth
num_threads_not_parked_Threads yet to reach safepoint

Best Practices

✅ Do This:

// Understand that long-running loops without polls
// delay GC safepoint rendezvous
// The compiler inserts polls at back edges                     // ✅
// Treat native and blocked threads as already safe
// They do not need to poll for safepoint requests              // ✅
// Be aware that allocation failures enter safepoint operations
// even when the GC is skipped                                   // ✅

❌ Don’t Do This:

// Assume a thread at a safepoint is suspended
// It may be running native code or blocked on a monitor        // ❌
// Ignore the isolate group scope
// A GC in one isolate pauses all isolates in the group         // ❌
// Assume safepoint operations are free
// They acquire monitors and wait for all mutators              // ❌

Common Pitfalls

PitfallWhy It HappensFix
GC pause longer than expectedA thread is slow to reach a safepoint pollEnsure loops have back-edge polls
Multi-isolate crawlAllocation failures trigger safepoints repeatedlyCheck Dart version; issue fixed in later releases
Reload stallsReload safepoint requires stricter conditionsAccept the pause; it is necessary for correctness
Stale pointers after safepointThread held raw pointer while at safepointUse handles; raw pointers are not allowed at safepoints

Real-World Examples

1. Thread in Generated Code

kThreadInGenerated → not at safepoint
Must poll to enter safepoint

2. Thread in Native Code

kThreadInNative → at safepoint
Cannot touch Dart heap

3. Thread Blocked on Monitor

kThreadInBlockedState → at safepoint
GC proceeds without waiting

4. GC Safepoint Operation

GcSafepointOperationScope entered by allocating thread
Waits for all mutators to park

5. Deopt Safepoint Operation

DeoptSafepointOperationScope requires deopt-id
Used before lazy deoptimization

6. Isolate Group Coordination

All isolates in group safepoint together
Handler is per isolate group

7. Safepoint Poll

Loop back edge: if safepoint_requested, call CheckSafepoint()

8. Rendezvous Completion

num_threads_not_parked_ reaches 0
GC operation begins

9. Resume All Threads

ResumeAllThreads() called after operation
Threads exit safepoint and continue

10. Allocation Skip Pitfall

Scavenge skipped but safepoint taken
Pauses group without GC work

Visual

Thread States and Safepoint Status

┌──────────────────────────────────────────────────────────────┐
│  kThreadInGenerated          NOT AT SAFEPOINT                │
│  ├── Running compiled Dart   Must poll to enter              │
│  └── Can touch heap                                         │
│                                                              │
│  kThreadInVM                 NOT AT SAFEPOINT                │
│  ├── Running C++ VM code     Must transition                │
│  └── Can touch heap                                         │
│                                                              │
│  kThreadInNative             AT SAFEPOINT                    │
│  ├── Running native code     Cannot touch heap              │
│  └── Does not need to poll                                  │
│                                                              │
│  kThreadInBlockedState       AT SAFEPOINT                    │
│  ├── Blocked on monitor      Cannot touch heap              │
│  └── Does not need to poll                                  │
│                                                              │
│  Native and blocked threads are already safe.                │
│  Generated and VM threads must reach a poll.                 │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Safepoint Rendezvous

┌──────────────────────────────────────────────────────────────┐
│  GC SAFEPOINT OPERATION BEGINS                               │
│                                                              │
│  ┌─────────────────┐                                        │
│  │ Set request     │  SafepointRequested = true             │
│  │ on all threads  │  for each mutator                      │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ Wait for        │  Thread 1: running loop, polls         │
│  │ threads to park │  Thread 2: in native code, already safe│
│  │                 │  Thread 3: blocked, already safe       │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ All parked      │  num_threads_not_parked_ == 0          │
│  │ (or already safe)│                                       │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ GC operation    │  Scavenge, root marking, etc.          │
│  │ proceeds        │                                        │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ Resume all      │  ResumeAllThreads()                    │
│  │ threads         │                                        │
│  └─────────────────┘                                        │
│                                                              │
│  The rendezvous is not a suspension of all threads.          │
│  Native and blocked threads are already safe.                │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Safepoint Levels

┌──────────────────────────────────────────────────────────────┐
│  GC SAFEPOINT                                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Requires: stackmaps for optimized frames                ││
│  │ Used for: scavenge, root marking                        ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  DEOPT SAFEPOINT                                             │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Requires: stackmaps + deopt-id                          ││
│  │ Used for: lazy deoptimization                           ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  RELOAD SAFEPOINT                                            │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Requires: stackmaps + deopt-id + reload safety          ││
│  │ Used for: hot reload                                    ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  Each level has stricter requirements.                       │
│  A thread at GC-safe may not be at deopt-safe.               │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Allocation Safepoint Pitfall

┌──────────────────────────────────────────────────────────────┐
│  NEW SPACE FULL                                              │
│  Scavenge sets assume_scavenge_will_fail_ = true             │
│                                                              │
│  ┌─────────────────┐                                        │
│  │ Allocation      │                                        │
│  │ fails           │                                        │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ Enter GcSafe    │  Pauses entire isolate group           │
│  │ point operation │                                        │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ See flag, skip  │  No GC work done                       │
│  │ scavenge        │                                        │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ Exit safepoint  │  Resume group                          │
│  │ Allocate in old │                                        │
│  └─────────────────┘                                        │
│                                                              │
│  Every allocation repeats this until old-space GC clears     │
│  the flag. Multiple isolates crawl.                          │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
SafepointPoint where mutator is safe for GC to proceed
Safe stateskThreadInNative, kThreadInBlockedState
Unsafe stateskThreadInGenerated, kThreadInVM
GC safepointRequires stackmaps
Deopt safepointRequires stackmaps + deopt-id
RendezvousSet request, wait for count zero, proceed
Isolate groupSafepoint handler scoped to group
Allocation pitfallSafepoint taken even when scavenge skipped

Key takeaways:

  • A safepoint is a location, not a suspension. A thread at a safepoint may be running native code or blocked on a monitor. It is paused only when it attempts to re-enter the Dart heap. The VM treats native and blocked threads as already safe because they cannot mutate the heap .
  • Different operations require different safepoint levels. A GC safepoint needs stackmaps so the GC can scan optimized frames. A deopt safepoint additionally needs deopt-id information so frames can be lazily discarded. A reload safepoint needs both plus reload safety. The VM requests the minimum level required .
  • The rendezvous is cooperative, not preemptive. A thread running compiled Dart code checks for safepoint requests at polling locations—loop back edges, function entries, allocation sites. The compiler inserts these polls to bound the time a GC must wait. A thread without a poll delays the collection .
  • Isolate groups share a heap and a safepoint handler. A collection triggered by one isolate must pause every isolate in the group. The thread registry and safepoint handler were moved to the isolate group to make this coordination possible .
  • The mechanism has a cost, and the cost can become a bug. The Dart 3.9 allocation issue showed that entering a safepoint operation for an allocation that will not actually trigger a collection pauses the entire group without doing GC work. The fix is to check the skip condition before entering the scope, not inside it .

Remember: Safepoints are the synchronization mechanism that makes concurrent garbage collection possible. They allow the VM to establish a consistent view of the heap by bringing all mutators to known locations, without requiring every instruction to be checked for a GC request. The mechanism is cooperative: threads poll at safe locations, native and blocked threads are already safe, and the handler waits until every thread is accounted for. The cost of this synchronization is the time spent waiting for the slowest thread to reach a poll and the overhead of entering and exiting the safepoint scope. When that cost is paid for an operation that does not actually need the heap to be stable, it becomes pure overhead. The VM’s design minimizes this overhead, but the allocation pitfall shows that it cannot eliminate it entirely.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!