Biased Reference Counting

Biased reference counting (BRC) is the scheme that makes reference counting fast again after the Global Interpreter Lock (GIL) is removed. Without a GIL, every Py_INCREF/Py_DECREF would otherwise have to be an atomic read-modify-write so two threads cannot corrupt a shared counter — and atomic instructions are several times slower than ordinary ones and scale badly under contention. BRC sidesteps this by exploiting an empirical fact: most objects are only ever touched by one thread. It splits the reference count into two fields — a ob_ref_local count that the object’s single owning thread mutates with plain non-atomic instructions, and a ob_ref_shared count that every other thread mutates atomically — and remembers the owner in ob_tid. The owning thread, which does the overwhelming majority of refcount traffic, pays no atomic cost at all; only cross-thread references pay. The technique is taken directly from the 2018 academic paper “Biased Reference Counting: Minimizing Atomic Operations in Garbage Collection” by Jiho Choi, Thomas Shull, and Josep Torrellas (PACT ‘18, iacoma.cs.uiuc.edu/iacoma-papers/pact18.pdf) and was adopted by PEP 703 for the free-threaded build of CPython (PEP 703). This note describes the build as of CPython 3.14.5 (the v3.14.5 tag), and applies only to the free-threaded build (compiled with Py_GIL_DISABLED); the default GIL build keeps the single classic ob_refcnt field documented in Reference Counting Mechanics.

It is the natural companion of Deferred Reference Counting, and the two are easy to confuse. They solve different problems. Biased reference counting answers who pays for the atomic — it never skips a count, it just routes the cheap path to the owner. Deferred reference counting answers whether to count at all — it elides counting entirely for a handful of hot, long-lived objects and lets the cyclic GC reconstruct their true count later. Keep them mentally distinct: BRC is about the cost of each refcount operation; deferral is about omitting refcount operations.

Why Atomic Refcounting Is the Problem

Under the GIL, Py_INCREF is just op->ob_refcnt++ — one non-atomic increment, safe because only one thread runs Python bytecode at a time. The GIL is what makes that cheap increment correct. Remove the GIL and two threads can race on the same ob_refcnt: the classic lost-update bug, where both read the same value, both add one, and both write back, leaving the count one short — eventually the object is freed while still referenced, a use-after-free.

The obvious fix is to make every increment and decrement an atomic operation — atomic_fetch_add(&op->ob_refcnt, 1) — so the read-modify-write is indivisible. This is correct but expensive. An atomic add carries a memory fence that prevents the processor from overlapping it with other work, and when several cores write the same cache line they fight over it (cache-line ping-pong), so the cost grows with contention. The BRC paper measured the impact directly in Swift, another language that uses non-deferred reference counting. Refcounting overall consumed on average 42% of client-program execution time. Removing the atomic safeguards entirely — re-running the subset of programs that did not crash without the compare-and-swap (CAS) instructions — reduced average client-program execution time by 25%, and the paper attributes “the large majority of the RC overhead” to the CAS operations themselves: the 25% is the atomicity portion of that 42%, not an additional cost (Choi et al. 2018, §3.1).

The escape hatch is the sharing pattern. The paper instrumented Swift programs and classified each object as private (every refcount operation over its lifetime came from one thread) or shared. The result: over 99% of objects in client programs, and over 93% in server programs, were private, and about 93% (client) / 87% (server) of all refcount operations targeted private objects (Choi et al. 2018, Table 1, §3.2). In other words, the atomic is paid almost all the time to protect against contention that almost never happens. BRC’s whole design is to make the common (private) case non-atomic and accept a slightly more expensive path for the rare shared case.

Mental Model

Think of each object as having a “home thread” stamped on it (ob_tid) and a private ledger only the home thread writes to (ob_ref_local), plus a public ledger anyone may write to but which requires a lock-free atomic to touch (ob_ref_shared). The object’s true refcount is ob_ref_local + (ob_ref_shared >> 2) (the shift drops two flag bits, explained below). When the home thread does an incref or decref it just edits its private ledger — no atomic, no fence. When a stranger thread does one, it atomically edits the public ledger. The object dies only when both ledgers net to zero. Because the home thread does most of the traffic, most traffic is non-atomic.

flowchart TD
    A["Py_DECREF(op) on thread T"] --> B{"op->ob_tid == T's thread id?<br/>(_Py_IsOwnedByCurrentThread)"}
    B -- "Yes (owner, common)" --> C["ob_ref_local--<br/>NON-ATOMIC store"]
    C --> D{"ob_ref_local == 0?"}
    D -- "No" --> E["done — object still alive"]
    D -- "Yes" --> F["_Py_MergeZeroLocalRefcount(op)"]
    F --> G{"ob_ref_shared == 0?"}
    G -- "Yes" --> H["_Py_Dealloc(op) — free it"]
    G -- "No" --> I["atomic CAS: set flags = _Py_REF_MERGED,<br/>ob_tid = 0; free iff shared now 0"]
    B -- "No (non-owner, rare)" --> J["_Py_DecRefShared(op):<br/>ATOMIC update of ob_ref_shared"]
    J --> K{"shared was 0 / MAYBE_WEAKREF<br/>(would go negative)?"}
    K -- "No" --> L["atomic subtract 1<<2 from shared"]
    K -- "Yes" --> M["atomic set flags = _Py_REF_QUEUED;<br/>_Py_brc_queue_object(op):<br/>hand object to owner thread's merge queue"]

Figure: the decrement decision in the free-threaded build. The single branch on ob_tid is the heart of BRC — the owner takes the left, non-atomic path; everyone else takes the right, atomic path. The insight to extract is that a non-owner can never safely free the object on its own (the owner’s local count may still be non-zero), so when a non-owner’s decrement would drive the shared count negative it does not free — it queues the object back to the owner to perform the final merge.

The Object Header in the Free-Threaded Build

In the default GIL build, the object header is small: an ob_refcnt, an ob_type, and (since 3.12) some flag bits. The free-threaded build replaces the single count with several fields. From Include/object.h at v3.14.5:

struct _object {
    uintptr_t ob_tid;           // owning thread id (or 0 = unowned)
    uint16_t ob_flags;
    PyMutex ob_mutex;           // per-object lock
    uint8_t ob_gc_bits;         // gc-related state
    uint32_t ob_ref_local;      // local reference count
    Py_ssize_t ob_ref_shared;   // shared (atomic) reference count
    PyTypeObject *ob_type;
};

Reading the fields that matter for BRC:

  • ob_tid — the owning thread’s id. It is set when the object is created (the creating thread becomes the owner) and is compared against the current thread on every refcount operation. A value of 0 (the macro _Py_UNOWNED_TID) means the object is unowned — either immortal or “merged” (no thread holds the fast path any more). ob_tid is overloaded: the comment in object.h notes it is also reused by the cyclic GC to store the computed gc_refs and by the trashcan mechanism as a linked-list pointer, because a live owner-id is meaningless once the GC has stopped the world.
  • ob_ref_local — a 32-bit count owned exclusively by the owning thread. Only the owner reads or writes it on the fast path, so it never needs an atomic. The special value UINT32_MAX (_Py_IMMORTAL_REFCNT_LOCAL) marks an immortal object — the free-threaded build encodes immortality in ob_ref_local rather than in ob_refcnt’s sign bit as the GIL build does.
  • ob_ref_shared — a signed Py_ssize_t written atomically by non-owner threads. Its two least-significant bits are flags, not count; the real shared count is ob_ref_shared >> 2. Crucially this field can legitimately go negative. This happens when a non-owner removes a reference whose matching increment was recorded in the owner’s ob_ref_local rather than in the shared field. The paper’s own example (§4.4, Figure 6(c)): thread T1 creates an object and stores it in a global pointer, recording that reference in its local count (ob_ref_local = 1, ob_ref_shared = 0); thread T2 then overwrites the global pointer, dropping that reference — but T2 is a non-owner, so it atomically subtracts from the shared count, driving it to -1. The true total is still consistent (local + shared = 1 + (-1) = 0), but T2 cannot see the owner’s local count and so cannot conclude the object is dead. The negative shared count is precisely the signal that the owner must eventually be told to merge.
  • ob_mutex — an unrelated per-object lock used for per-object locking of mutable containers; it is not part of BRC but shares the header.

The shared-count flag bits

The two flag bits in ob_ref_shared form a small set of states, defined in Include/refcount.h:

#  define _Py_REF_SHARED_SHIFT        2
#  define _Py_REF_SHARED_FLAG_MASK    0x3
#  define _Py_REF_SHARED_INIT         0x0   // default
#  define _Py_REF_MAYBE_WEAKREF       0x1   // object may have a weakref / been shared
#  define _Py_REF_QUEUED              0x2   // queued to owner for merging
#  define _Py_REF_MERGED              0x3   // counts merged; object unowned
#  define _Py_REF_SHARED(refcnt, flags) (((refcnt) << _Py_REF_SHARED_SHIFT) + (flags))
  • _Py_REF_SHARED_INIT (0x0) is the default. An object whose shared field is still exactly 0 (flags and count both zero) is in the cheapest state — when its owner’s local count hits zero it can be freed immediately with no atomic merge.
  • _Py_REF_MAYBE_WEAKREF (0x1) marks an object that has been shared to another thread or had a weak reference taken; it tells the runtime that lock-free weakref dereferencing and certain container fast paths must treat the object carefully.
  • _Py_REF_QUEUED (0x2) means a non-owner thread has placed the object on the owner’s merge queue (below).
  • _Py_REF_MERGED (0x3) means the local and shared counts have been combined into the shared field and the object has no owner (ob_tid == 0); from here on all refcount operations go through the atomic shared path.

It is worth being precise about how an object moves between these states, because two distinct properties are easy to conflate. Tracing every site that writes the low two flag bits — _Py_DecRefSharedDebug, _Py_MergeZeroLocalRefcount, _Py_ExplicitMergeRefcount, _PyObject_SetMaybeWeakref, and _Py_NewRefWithLock in Objects/object.c and Include/internal/pycore_object.h at v3.14.5, plus the owner-side merge_queued_objects_Py_ExplicitMergeRefcount path in Python/brc.c — establishes:

  • The flag value never decreases. The observed transitions are INIT (0x0) → MAYBE_WEAKREF (0x1) (set optimistically by _Py_NewRefWithLock/_PyObject_SetMaybeWeakref only when the flag bits are currently zero), INIT/MAYBE_WEAKREF → QUEUED (0x2) (the decref slow path queues when shared == 0 || shared == _Py_REF_MAYBE_WEAKREF), and * → MERGED (0x3) (the merge paths OR in _Py_REF_MERGED). No site ever writes a lower flag value over a higher one. So the note’s “climb, never descend” intuition is correct as a statement about the numeric value of the flag field.
  • The bits are not purely additive, however. When an object in MAYBE_WEAKREF (0x1) is queued, the decref path sets new_shared = _Py_REF_QUEUED (0x2) outright, clearing bit 0 — so the value still rises (1 → 2) but the weakref bit is dropped rather than retained. Likewise _Py_MergeZeroLocalRefcount recomputes the flags as (shared & ~_Py_REF_SHARED_FLAG_MASK) | _Py_REF_MERGED, replacing whatever the low bits were. Thus the states are not a fixed linear pipeline where each step preserves prior bits; they are a small lattice in which the encoded value is non-decreasing while individual bits may turn off.

Resolved (2026-06-01)

Verified by tracing every flag-bit write site in Objects/object.c, Include/internal/pycore_object.h, and Python/brc.c at the v3.14.5 tag. The flag value is monotonically non-decreasing (no transition lowers it); it is not bit-monotonic (the MAYBE_WEAKREF → QUEUED transition clears bit 0 while raising the value 1 → 2). The owner-side brc.c merge path only ever transitions to MERGED (the highest value), so it introduces no descent.

Mechanical Walk-through

Increment

The free-threaded Py_INCREF, from Include/refcount.h, branches on ownership after an immortality check:

uint32_t local = _Py_atomic_load_uint32_relaxed(&op->ob_ref_local);
uint32_t new_local = local + 1;
if (new_local == 0) {                 // local was UINT32_MAX => immortal
    return;                           // do nothing; immortal never changes
}
if (_Py_IsOwnedByCurrentThread(op)) {
    _Py_atomic_store_uint32_relaxed(&op->ob_ref_local, new_local);  // owner: relaxed store
}
else {
    _Py_atomic_add_ssize(&op->ob_ref_shared, (1 << _Py_REF_SHARED_SHIFT));  // non-owner: atomic add
}

Note that even the owner’s store uses _Py_atomic_store_uint32_relaxed rather than a bare op->ob_ref_local = new_local. This is not a contended atomic with a fence — a relaxed atomic compiles to an ordinary store on every mainstream architecture; it exists only to keep the access well-defined under the C memory model and to keep the thread sanitizer quiet, since a non-owner thread can read ob_ref_local (e.g. in Py_REFCNT). The owner path therefore has the same machine cost as the GIL build’s ob_refcnt++. The non-owner path adds 1 << 2 (i.e. 4) to ob_ref_shared so the increment lands in the count portion above the two flag bits.

_Py_IsOwnedByCurrentThread (from Include/object.h) is the cheap comparison at the center of everything: ob->ob_tid == _Py_ThreadId(). _Py_ThreadId() reads the thread id directly out of a thread-pointer register (e.g. fs:0 on x86-64 Linux, tpidr_el0 on AArch64) with a single inline-assembly instruction — no system call, no TLS indirection — so the ownership test is a register read and a compare.

Decrement and the merge protocol

The free-threaded Py_DECREF:

uint32_t local = _Py_atomic_load_uint32_relaxed(&op->ob_ref_local);
if (local == _Py_IMMORTAL_REFCNT_LOCAL) { return; }   // immortal
if (_Py_IsOwnedByCurrentThread(op)) {
    local--;
    _Py_atomic_store_uint32_relaxed(&op->ob_ref_local, local);
    if (local == 0) {
        _Py_MergeZeroLocalRefcount(op);               // owner's local hit zero
    }
}
else {
    _Py_DecRefShared(op);                             // non-owner path
}

When the owner’s local count reaches zero, the object is not necessarily dead — other threads may hold references recorded in ob_ref_shared. _Py_MergeZeroLocalRefcount (in Objects/object.c) resolves this:

void _Py_MergeZeroLocalRefcount(PyObject *op) {
    assert(op->ob_ref_local == 0);
    Py_ssize_t shared = _Py_atomic_load_ssize_acquire(&op->ob_ref_shared);
    if (shared == 0) {
        _Py_Dealloc(op);          // fast path: nobody else holds it — free now
        return;
    }
    _Py_atomic_store_uintptr_relaxed(&op->ob_tid, 0);   // give up ownership
    Py_ssize_t new_shared;
    do {                          // CAS the flags to MERGED
        new_shared = (shared & ~_Py_REF_SHARED_FLAG_MASK) | _Py_REF_MERGED;
    } while (!_Py_atomic_compare_exchange_ssize(&op->ob_ref_shared, &shared, new_shared));
    if (new_shared == _Py_REF_MERGED) {   // shared count == 0, only flags set
        _Py_Dealloc(op);
    }
}

The fast path — shared == 0 — is the common case for a private object: the owner held the only reference, no other thread ever touched it, so the moment the owner’s local count hits zero the object is freed with a single non-atomic decrement followed by one relaxed load. No atomic read-modify-write happened anywhere in the object’s life. This is exactly what BRC is for. Only when other threads did hold references (shared != 0) does the owner pay an atomic compare-and-swap to flip the object into the _Py_REF_MERGED state, hand off ownership (ob_tid = 0), and free it if the merged total is zero.

When a non-owner decrements, it calls _Py_DecRefShared_Py_DecRefSharedIsDead (in Objects/object.c). This is where the queue protocol lives:

int should_queue = (shared == 0 || shared == _Py_REF_MAYBE_WEAKREF);
if (should_queue) {
    new_shared = _Py_REF_QUEUED;          // don't subtract; the queue holds a ref
}
else {
    new_shared = shared - (1 << _Py_REF_SHARED_SHIFT);   // subtract 1 (may go negative)
}
// ... atomic CAS to install new_shared ...
if (should_queue) {
    _Py_brc_queue_object(o);              // hand the object to the owning thread
}
else if (new_shared == _Py_REF_MERGED) {
    return 1;                             // dead: free it
}

The subtlety the paper calls invariant I3 (Choi et al. 2018, Table 2): only the owner can reliably read the local count, so a non-owner that observes the shared count hitting “empty” cannot conclude the object is dead — the owner’s local count might still be positive. If the non-owner’s decrement would otherwise drop the shared count to zero-or-negative for the first time (shared == 0 or the maybe-weakref equivalent), the non-owner instead sets the _Py_REF_QUEUED flag and enqueues the object on the owning thread’s merge queue, without subtracting (the queue itself now holds the reference). The owner will later perform an explicit merge.

The owner-side queue drain

Enqueuing happens in Python/brc.c. Each thread state owns a queue (objects_to_merge) and the interpreter keeps a fixed-size hash table of thread states keyed by thread id. _Py_brc_queue_object looks up the owner by ob_tid, pushes the object onto its queue, and — critically — notifies the owner by setting an eval-breaker bit (_PY_EVAL_EXPLICIT_MERGE_BIT) on the owner’s thread state. The eval breaker is the mechanism the interpreter already uses to interrupt the bytecode loop for signals, GIL switches, and the like; here it tells the owner “you have refcounts to merge.” There are two fallback paths: if the owning thread has already exited (not found in the table), the queuing thread merges the count itself via _Py_ExplicitMergeRefcount; and if pushing onto the queue fails (allocation failure), it stops the world and merges directly.

When the owner next checks its eval breaker it calls _Py_brc_merge_refcounts, which drains the queue through merge_queued_objects_Py_ExplicitMergeRefcount(ob, -1) for each object:

Py_ssize_t _Py_ExplicitMergeRefcount(PyObject *op, Py_ssize_t extra) {
    Py_ssize_t local = (Py_ssize_t)op->ob_ref_local;
    _Py_atomic_store_uint32_relaxed(&op->ob_ref_local, 0);
    _Py_atomic_store_uintptr_relaxed(&op->ob_tid, 0);   // give up ownership
    Py_ssize_t refcnt, new_shared;
    Py_ssize_t shared = _Py_atomic_load_ssize_relaxed(&op->ob_ref_shared);
    do {
        refcnt = Py_ARITHMETIC_RIGHT_SHIFT(Py_ssize_t, shared, _Py_REF_SHARED_SHIFT);
        refcnt += local;            // fold local count in
        refcnt += extra;            // -1 because the queue held a reference
        new_shared = _Py_REF_SHARED(refcnt, _Py_REF_MERGED);
    } while (!_Py_atomic_compare_exchange_ssize(&op->ob_ref_shared, &shared, new_shared));
    return refcnt;
}

The owner adds its own ob_ref_local into the shared count, subtracts the one reference the queue was holding (extra = -1), marks the object _Py_REF_MERGED, zeroes ob_tid, and zeroes ob_ref_local. If the resulting count is zero, merge_queued_objects frees the object. This is the paper’s ExplicitMerge operation; the in-line _Py_MergeZeroLocalRefcount fast-path is its implicit merge counterpart. All queued objects are also merged during a GC pause, so a thread that never re-enters the eval loop does not leak its queue. This matches PEP 703’s description: objects begin in the default state — “the only state that allows for the quick deallocation code path” — and otherwise “the thread must merge the local and shared reference count fields, which requires an atomic compare-and-swap,” with the merged state meaning “the object is not owned by any thread,” ob_tid zero and ob_ref_local unused (PEP 703).

The Paper’s Model vs CPython’s Implementation

The PACT ‘18 paper and CPython implement the same algorithm but lay out the bits differently, and conflating them is a common error.

The paper packs everything into a single 64-bit word it calls the RCWord, split into a Biased half-word and a Shared half-word: an 18-bit thread id (TID), a 14-bit biased counter, a 14-bit shared counter, 2 flag bits (Merged, Queued), and 16 reserved bits (Choi et al. 2018, Figure 5). Counters are only 14 bits because the authors found 14 bits more than enough — Swift objects (like Java’s, which need ~7 bits) rarely have large refcounts.

CPython does not use a single packed word. It uses separate full-width fields: ob_tid is a complete uintptr_t (a real OS thread id, not 18 bits), ob_ref_local is a full 32-bit counter, and ob_ref_shared is a full Py_ssize_t (64-bit on 64-bit platforms) with the two flag bits in its low end. CPython can afford the space because Python objects already carry a fat header. So when reading the paper, take from it the concept (bias each object to its owner thread; two counters; the owner is non-atomic), the origin, the invariants (I1–I5 in its Table 2), and the QueuedObjects / ExplicitMerge protocol — but do not assume CPython’s ob_ref_shared is a 14-bit field or that ob_tid is 18 bits. The paper’s QueuedObjects list per thread corresponds to CPython’s per-thread-state objects_to_merge stack; the paper’s ExplicitMerge is CPython’s _Py_ExplicitMergeRefcount; the paper’s implicit merge (set Merged, unbias) is CPython’s _Py_MergeZeroLocalRefcount.

Worked Example: a Hand-off Between Two Threads

Consider an object created on thread A and passed to thread B:

  1. A creates the object. ob_tid = A, ob_ref_local = 1, ob_ref_shared = 0. A is the owner.
  2. A increments (e.g. stores it in a list it owns). ob_ref_local = 2, non-atomic. No atomic anywhere.
  3. A hands the object to B; B takes a reference. B is not the owner, so B does _Py_atomic_add_ssize(&ob_ref_shared, 4)ob_ref_shared = 4 (count 1, flags 0).
  4. A drops both its references. ob_ref_local goes 2 → 1 → 0, non-atomic. On hitting zero, A runs _Py_MergeZeroLocalRefcount: ob_ref_shared is 4 ≠ 0, so A does not free; it CASes the flags to _Py_REF_MERGED, sets ob_tid = 0. Now ob_ref_shared count is still 1 (B’s reference), flags MERGED.
  5. B drops its reference. The object is now unowned (merged), so B’s decrement goes through the shared path: atomic subtract 4 from ob_ref_shared, leaving only the _Py_REF_MERGED flag bits with count 0 → the object is dead and freed.

Steps 1, 2, and the local decrements in 4 were all non-atomic — the common, owner-side traffic. Only the genuine cross-thread events (3, the merge in 4, and 5) paid an atomic. That ratio is the entire point.

Failure Modes and Subtleties

  • Negative shared counts are normal, not a bug. Because increments and decrements for the same logical reference can be split across the owner (local) and a non-owner (shared), the shared field alone can be negative; only local + shared is guaranteed ≥ 0. Code that naively reads ob_ref_shared >> 2 and asserts it is non-negative is wrong. Py_REFCNT in the free-threaded build deliberately sums both fields.
  • ob_tid reuse. Because the cyclic GC and the trashcan reuse ob_tid, you cannot read ob_tid to learn the owner at arbitrary times — only on a live, non-merged object outside GC. A stale debugger inspection can be misleading.
  • Reference reads need their own protocol. Loading a shared pointer and incrementing its refcount is itself a race (the object may be freed between the load and the incref). The free-threaded build adds _Py_TryIncref/_Py_TryIncrefCompare (in pycore_object.h) for this — a “try to take a reference if it is still alive” primitive — which is related to BRC but is a separate concern.
  • Owner thread exit. If the owning thread exits while non-owners still reference the object, the queue lookup fails and the queuing thread merges the count itself (_Py_brc_queue_object’s “thread already exited” branch). Without this, a queued object would never be merged and would leak.

Alternatives and When to Choose Them

  • Plain atomic refcounting (what BRC replaces): correct and simple, but every operation is an atomic with a fence, and shared objects suffer cache-line contention. CPython rejected it as the sole mechanism on performance grounds, though the non-owner path in BRC is exactly plain atomic refcounting — BRC is plain atomic refcounting only for the rare case.
  • Deferred reference counting: orthogonal, not an alternative. Deferral removes refcount operations entirely for a few hot objects (functions, modules, classes); BRC makes the operations that do happen cheap. The free-threaded build uses both at once — a top-level function is deferred (the interpreter’s stack pushes/pops don’t count it) and, for the counts that remain, biased.
  • Tracing GC without refcounting (Java, Go): avoids the per-operation cost entirely but gives up reference counting’s prompt, deterministic destruction (__del__ at the moment the last reference drops) and its low, predictable memory overhead. CPython keeps reference counting precisely to preserve that semantics, so it had to make refcounting parallel-safe rather than abandon it.

Production Notes

Biased reference counting shipped first in the experimental free-threaded build of CPython 3.13 (October 2024) and is part of the officially supported (but still opt-in) free-threaded build in 3.14 (per PEP 779 and the free-threading HOWTO). The visible cost is in the object header: the free-threaded struct _object is larger than the GIL build’s (it adds ob_tid, ob_mutex, ob_gc_bits, and splits the count into two fields), which is one reason free-threaded builds use more memory per object. The visible benefit is that single-threaded code in the free-threaded build pays little extra for refcounting versus the GIL build, because the owner path is still a non-atomic store — the overhead that remains is mostly the slightly larger header and the relaxed-atomic wrappers, not true contended atomics. Heavily-shared workloads (many threads mutating the same objects) are where BRC’s non-owner atomic path and the merge-queue traffic show up; the design bets, on the paper’s evidence, that such sharing is rare.

See Also