skip to content

questions

19

In a proxy, every request thread copies and drops a handle to one shared read-only routing table. Why does that cost throughput?

level: middleimportance: must knowfreq 62%

answer

  1. reading it still writes something
  2. the write is not the table
  3. one word, every core, exclusively owned
  4. atomic read-modify-write per share and per drop
  5. scales with handle traffic, not object size

basics

~10 s

Each copy and drop of a counted handle is an atomic read-modify-write of one shared count, so read-only sharing is still a write workload: cores must take exclusive turns owning that counter's cache line.

solid answer

~40 s

The table's data is read-only, but the bookkeeping beside it is not. Reference counting maintains a count of the owning handles that refer to an object; taking a handle adds one, dropping it subtracts one. Because handles here are taken by many threads, both updates must be atomic read-modify-writes rather than plain increments, and an atomic update of a word requires the core to hold that word's cache line exclusively. So every request performs two exclusive writes to the *same* address as every other request. Uncontended, that is a modest constant; under load it turns one shared word into a serialisation point, and throughput stops improving — or falls — as threads are added. The cost tracks the number of ownership changes, not the size of the table or the amount of data read.

code

pseudocode · 16 lines
pseudocode
// what a counted handle does on copy and on drop
function copy_handle(h):
    atomic_add(h.object.count, +1)      // exclusive ownership of the count line
    return handle(h.object)

function drop_handle(h):
    old = atomic_add(h.object.count, -1)
    if old == 1:                        // this thread removed the last handle
        destroy(h.object)

// one request, two exclusive writes to the same word
function handle_request(req):
    t = copy_handle(shared_table)
    route = lookup(t.object, req.host)  // pure reads, freely shared
    drop_handle(t)
    return route

go deeper

for a junior

Remember the one fact under all of this: a reference count is data that lives with the object, and copying a handle writes it. Read-only sharing of a counted object still touches memory.

for a middle

Explain the mechanics: an ownership change is an atomic read-modify-write, an atomic update needs exclusive ownership of the word's cache line, and so many threads sharing one object serialise on one address regardless of what the object contains.

for a senior

Show that you can size the tax before you profile: ownership changes per request times request rate against one word, then judge whether that is the ceiling. Then name the fix that removes ownership changes rather than making them faster.

for a principal

Frame it as a cost that scales with handle traffic while the benefit — prompt, deterministic release — scales with what the object holds. That framing is what lets you decide per object rather than adopting one reclamation discipline for everything.

## The count is state, and state gets written A reference-counted object carries a **count of the owning handles that currently refer to it**. That count is not part of the object's data; it is bookkeeping the reclamation scheme keeps for the object, either in a header word beside it or in a side table. The rule that makes the scheme work is unavoidable: **creating an owning handle adds one, destroying an owning handle subtracts one**, and the transition to zero destroys the object. That rule has a consequence people rarely expect. **Sharing a read-only object is a write workload.** In the proxy above, nothing about the routing table changes — no route is added, no field is touched, and every thread only reads. Yet each request performs two writes to the same memory word: one when the handler takes its handle, one when the handler drops it. The payload is immutable; the accounting is not. ## Why the update cannot be a plain increment `count = count + 1` is three steps — load, add, store. If two threads run it on the same word, both can load the same old value and both can store the same new one, so one of the two updates disappears. For an ordinary statistic that is a small inaccuracy. For a reference count it is a correctness bug in both directions: - a **lost increment** leaves the count *below* the true number of handles, so the count reaches zero while a live handle still exists and the object is freed underneath its user; - a **lost decrement** leaves the count *above* the true number, so the object is never reclaimed and the memory leaks. So in any scheme whose handles may cross threads, the update must be a single indivisible read-modify-write. That instruction is what costs: to perform it, the core must hold the line containing the count in an exclusive state, which means taking it away from whichever core held it last. ## Where the cost actually lands It helps to separate three quantities that get confused: | Cost | Scales with | Paid by | |---|---|---| | Count update | number of ownership changes | every thread, on the request path | | Destruction | the object's fields and what it owns | the thread that drops the last handle | | Reading the data | bytes touched, cache residency | every reader, but shared lines cost nothing to read | The third row is the one that surprises people. A line that many cores only **read** can sit in all of their caches at once, so wide read sharing of immutable data is close to free. A line that many cores **write** cannot: exclusive ownership moves from core to core, and the transfer is what the threads are waiting on. Reference counting quietly converts the first situation into the second, because it puts a written word next to data that nobody writes. ## Estimating the tax You can put a number on it without any profiler, and interviewers like it when you do: 1. Count the ownership changes per unit of work. In the proxy that is two per request per handler that takes its own handle — and often many more, because passing the handle into helpers, storing it in a request context, and capturing it in a callback each add a pair. 2. Multiply by the request rate to get updates per second against a single address. 3. Compare that with what one cache line can sustain when ownership has to migrate between cores. The ceiling is a property of the hardware, not of your code, and it does not rise when you add threads. When step 3 lands near your target request rate, the count is your scaling limit, and no amount of making the *table* faster will help. ## What this is not - It is **not** a fault of the table's size. A one-field object shared per request costs the same counting tax as a large one. - It is **not** the cost of freeing. Freeing happens once, at the end; the tax here is paid on every share. - It is **not** removed by making the object immutable, which is the whole point of the example. - It is **not** the same as a lock. There is no mutual exclusion in the program, no critical section, and no risk of deadlock; the serialisation happens below the program, in the hardware that owns the line. ## Why anyone pays it Counting buys **prompt, deterministic reclamation**: the object dies at the instant its last handle dies, on the thread that dropped it, with no scan of the heap and no reclamation-wide pause. For a buffer that holds an operating-system resource, or for a footprint that must fall the moment work ends, that promptness is worth real throughput. The engineering question is never 'is counting slow' but 'is this particular object shared often enough that the accounting costs more than the promptness is worth' — and for a process-lifetime read-only table touched by every request, it usually is.

  • If the table is never modified, why does the hardware treat the count differently from the table's data?
    A line that is only read can be resident in every core's cache simultaneously, so read sharing scales. An atomic update must be indivisible, which requires the writing core to hold that line exclusively, invalidating every other copy. The count sits in a written line; the routes sit in read-only lines. Same object, opposite scaling behaviour.
  • Does taking the handle only once per request, instead of in every helper, actually change anything?
    Yes, and it is the cheapest fix available. The tax is per ownership change, so hoisting the handle out of the inner call chain and passing a non-owning borrow downward removes every pair of updates below it. The object is still kept alive for the whole request by the one handle that remains.
  • Is this cost present in a single-threaded program too?
    The instruction cost mostly disappears: with no possibility of a concurrent update the scheme can use plain increments, and an uncontended update of a line the core already owns is close to a normal store. What remains is the instruction count and the extra memory traffic of touching the header on every share, which still shows up in tight loops.

A reference library where everyone may read the same book at once, but each reader must sign the one card at the desk on the way in and on the way out. The reading is parallel; the signing is a queue.

saying these in an interview costs you the question

  • Thinks sharing a read-only object costs nothing because no data is written
  • Assumes an atomic increment costs about the same as a plain one
  • Blames the table's size or its lookup cost rather than counter traffic
  • Says a lock is being taken, and looks for a critical section
  • Believes adding threads must raise throughput because the data is immutable
  • Confuses the cost of destroying the object with the cost of counting
open as a page

Two objects in a counted heap hold references to each other and nothing outside can reach them; why is neither ever freed?

level: middleimportance: must knowfreq 66%

basics

~20 s

Each object's count is held above zero by the other, so no decrement ever reaches zero. A count records whether anything points at an object, not whether anything reachable does, so an unreachable ring keeps itself alive.

open as a page

In a reference-counted system, which events change an object's count, and what happens the moment that count reaches zero?

level: middleimportance: must knowfreq 62%

basics

~20 s

Every new owning handle to an object increments its count; every handle dropped or overwritten decrements it. At zero the object's cleanup runs, every handle it holds is released in turn, and its memory is reclaimed.

open as a page

In a reference-counted object graph, what happens to a weak handle when the last owning handle to its object is dropped?

level: middleimportance: must knowfreq 68%

basics

~20 s

It reads as absent from that moment on. Only owning handles are counted, so the last one dropping destroys the object immediately; weak handles are not counted, cannot delay that, and are never allowed to yield destroyed storage.

open as a page

A million-node counted chain loses its only head handle and every node is freed at once, so why can that hurt tail latency?

level: seniorimportance: must knowfreq 55%

basics

~20 s

One decrement can destroy a whole structure. The thread that dropped the head handle runs a million cleanups, decrements and frees inline, in the middle of whatever request it was serving, so a cheap-looking assignment becomes an unbounded pause.

open as a page

In a reference-counted design, which edges of a catalogue should own: its entries, its selected-entry pointer, or its registered progress observers?

level: seniorimportance: must knowfreq 56%

basics

~20 s

Only the containment edge. The catalogue owns its entries; the selected-entry pointer and the observer registrations must not, or removing an entry frees nothing and a finished observer is kept alive by the catalogue it registered with.

open as a page

What must be true of a counted handle for its count to be updated non-atomically, and what fails if that breaks?

level: middleimportance: should knowfreq 42%

basics

~20 s

A count may skip atomic instructions only when no two threads can update it at once: every handle must stay in one thread, or under one lock. Otherwise a lost increment frees the object early; a lost decrement leaks it.

open as a page

In a document tree where each child also points back at its parent, which of those two edges should be non-owning?

level: middleimportance: should knowfreq 47%

basics

~20 s

The child's back-pointer. Ownership should follow containment: the section must outlive its paragraphs, so the downward edge holds the count and the upward one does not. Exactly one edge of a cycle has to be non-owning.

open as a page

Why would you choose an unchecked non-owning handle instead of a checked weak handle in a reference-counted design?

level: middleimportance: should knowfreq 40%

basics

~20 s

For speed and for intent. An unchecked non-owning handle skips the count update and the per-use liveness check, and it declares that the target is guaranteed to outlive its holder. When that guarantee is wrong, the read dangles instead of reporting absence.

open as a page

A shared routing table's reference count is the hottest address in a service whose throughput drops as workers are added. How do you fix it?

level: seniorimportance: should knowfreq 48%

basics

~20 s

Remove ownership changes rather than making them cheaper: hold one long-lived handle per worker and borrow below it, never count a process-lifetime object, or defer the updates. Making a contended atomic update faster is not an option.

open as a page

Why is it unsafe to release an owning handle to an object before the last read of that object's fields?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Releasing may take the count to zero, which destroys the object immediately. Any pointer still aimed at the object or inside it — an interior pointer, a borrowed field address, a buffer handed to someone else — becomes a dangling pointer at that instant, and the next read is a use-after-free.

open as a page

How do you decide whether a hot shared object should keep paying the per-share counting tax, or move to another reclamation discipline?

level: principalimportance: should knowfreq 36%

basics

~20 s

Weigh two costs with different shapes: counting is priced by ownership changes per unit of work, reachability by live-set size and puts nothing on the sharing path. Pay the counting tax where prompt, deterministic release is genuinely worth something.

open as a page

A long-lived product built on reference counting leaks cycles in production; how do you choose between a cycle collector, a non-owning-edge convention, and a periodic backup trace?

level: principalimportance: should knowfreq 33%

basics

~20 s

Decide by who controls the graph's shape. Author-controlled shapes suit a non-owning-edge convention; graphs shaped by run-time data or plugins need a cycle collector; a backup reachability pass catches everything but demands exact roots and a pause budget.

open as a page

When designing a counted object representation, would you store each object's count in its header or in a side table?

level: principalimportance: should knowfreq 38%

basics

~20 s

A header count is one load and store on a line the code already touched, but every object pays the space and every update dirties the object's own page. A side table keeps objects clean and costs a lookup per update. Object size distribution and page sharing decide it.

open as a page

What policy should a technical lead set for when unchecked non-owning handles may be used in a shared codebase?

level: principalimportance: should knowfreq 31%

basics

~20 s

Make the checked weak handle the default and the unchecked form an exception that must be argued: allowed only where an enclosing-lifetime invariant can be stated in one sentence at the declaration, kept inside a module, and backed by a build that traps on use-after-destroy.

open as a page

What can a reference-counting implementation do when an object's narrow count field cannot hold another increment?

level: middleimportance: nice to knowfreq 20%

basics

~20 s

Three safe answers exist: saturate the count so the object becomes immortal, spill it into a wider side entry, or treat the overflow as a fatal error. Wrapping is not an option — a count that wraps to zero frees an object that still has owners.

open as a page

What does deferred reference counting remove from the fast path, and what does it need in exchange?

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

Deferred counting leaves the most frequently changed references, those in thread stacks and registers, out of the count, so the fast path stops updating it. In exchange a zero count no longer proves death: the roots must be scanned first.

open as a page

How does a cycle collector using trial deletion decide that a group of mutually referencing objects is garbage without tracing from roots?

level: seniorimportance: nice to knowfreq 26%

basics

~20 s

It subtracts the group's own internal references from its members' counts. Anything left above zero is referenced from outside and is restored; anything left at zero was held only from inside the group, and is freed.

open as a page

Why must a weak handle be promoted to a temporary owning handle before use rather than only tested for liveness?

level: seniorimportance: nice to knowfreq 27%

basics

~20 s

Because liveness can change between the test and the read. Promotion takes a temporary owning handle in one indivisible step when the count is still above zero, so the object cannot be destroyed mid-use; a bare is-alive test leaves a window open.

open as a page