Reference Counting — Hands-On Tasks¶
Topic: Reference Counting
A progression of exercises that take you from watching a reference count change to building a counted pointer, diagnosing real cycle leaks, and measuring the atomic-count tax. Do them in any language you're comfortable with unless a task names one; Python, Rust, C++, and Swift are all good fits.
Work top-to-bottom: Warm-Up builds intuition, Core builds the mechanism, Advanced builds diagnosis and measurement skills, and the Capstone integrates everything.
Table of Contents¶
Warm-Up¶
Task 1 — Watch a count move¶
Use a language that exposes the count (Python's sys.getrefcount, or Rust's Rc::strong_count). Create an object, then make and drop additional references, printing the count at each step. Predict each value before you print it.
Self-check:
- I can predict the count after each assignment and
del/drop. - I understand why Python's
getrefcountreads one higher than expected (the argument temporary). - I observed the object being freed (e.g. a
__del__/Dropprint) at the exact step the count hit zero.
Hint
In Python: `a = []`, then `b = a`, then `del b`. In Rust: `let a = Rc::new(0); let b = a.clone();` and print `Rc::strong_count(&a)` between operations. Add a struct with a `Drop`/`__del__` that prints, to see *when* it dies.Task 2 — Build a cycle and confirm it leaks¶
Create two objects that reference each other, then drop all external references. Confirm (with a destructor print, a count check, or a memory tool) that they are not freed by reference counting alone.
Self-check:
- The two objects are not reclaimed after external references are gone.
- I can explain, in terms of counts, exactly why each stays at 1.
- In Python,
gc.collect()does reclaim them — and I understand why that's a separate mechanism from refcounting.
Hint
Give each object an `other`/`ref` field. Set `a.other = b; b.other = a`. Put a print in the destructor. Drop `a` and `b`. No print = leak. In Python, call `gc.collect()` afterward and watch the prints finally fire.Task 3 — Break the cycle with a weak reference¶
Repeat Task 2, but make one of the two edges a weak reference (Python weakref.ref, Rust Weak, C++ weak_ptr, Swift weak). Confirm both objects are now freed promptly by refcounting alone, with no cycle collector.
Self-check:
- Both destructors run when external references drop, with no
gc.collect()needed. - I made exactly one edge weak and can justify which one (the non-owning back-edge).
- Accessing the object through the weak reference correctly reports "gone" after it's freed.
Hint
Decide which object *owns* the other; the back-link from the owned object is the weak one. To use a weak ref you must "upgrade": `weakref()` call returns `None`, `Weak::upgrade()` returns `Option`, `weak_ptr::lock()` returns a possibly-empty `shared_ptr`.Core¶
Task 4 — Implement a reference-counted smart pointer¶
In a language with manual memory management (C, C++ without shared_ptr, or Rust with unsafe), implement your own counted box: a retain that increments, a release that decrements and frees at zero, and copy semantics that retain. Store the count inline with the data.
Self-check:
- Copying the handle increments the count; dropping it decrements.
- The payload is freed exactly once, exactly when the count hits zero.
- I tested an unbalanced case and saw it leak (too few releases) or double-free (too many).
Hint
Layout: a struct `{ size_t count; T value; }` allocated together. `retain(p)` does `p->count++`. `release(p)` does `if (--p->count == 0) free(p);`. The hard part is copy semantics — every place a handle is duplicated must retain, every place one dies must release.Task 5 — Add weak references to your smart pointer¶
Extend Task 4 with a separate weak count and a weak_ptr-style handle that does not keep the object alive but can be upgraded to a strong handle if the object still exists. You'll need a control block that outlives the payload.
Self-check:
- A weak handle does not prevent the payload from being freed at strong-zero.
-
upgrade()succeeds while a strong reference exists and fails after the payload is freed. - The control block itself is freed only when both strong and weak counts reach zero.
Hint
Split into a control block `{ strong; weak; }` and a separately-tracked payload. Strong-zero runs the destructor and frees the payload but keeps the control block alive until weak-zero — exactly the C++ `shared_ptr`/`weak_ptr` split. `upgrade()` must check `strong > 0` and increment atomically-or-carefully.Task 6 — Make it thread-safe and feel the cost¶
Convert your Task 4/5 counts to atomic operations so the pointer can be shared across threads. Then write a benchmark: clone-and-drop the pointer in a tight loop, first single-threaded with non-atomic counts, then with atomic counts, then with several threads hammering the same pointer.
Self-check:
- My atomic version is correct under concurrent clone/drop (no double-free, no leak).
- I measured a clear slowdown from non-atomic → atomic even single-threaded.
- Multiple threads on the same count are dramatically slower than threads on separate counts — and I can name the cause (cache-line contention).
Hint
Use `std::atomicAdvanced¶
Task 7 — Diagnose a real cycle leak with tools¶
Write a small Python program that leaks via a cycle (e.g. a cache of objects that hold back-references, or a parent/child graph). Then find the leak as you would in production: confirm it's a cycle (not a strong-ref leak) with gc.collect(), attribute the allocation site with tracemalloc, and draw the retainer graph with objgraph.show_backrefs.
Self-check:
- I confirmed it's a cycle by showing
gc.collect()reclaims the memory. -
tracemallocpointed me at the source line creating the leaking objects. -
objgraph(or manualgc.get_referrers) showed me the exact retainer chain.
Hint
`gc.set_debug(gc.DEBUG_SAVEALL)` then `gc.collect()` puts cyclic garbage in `gc.garbage`. `tracemalloc.start()` + snapshot-compare ranks growth by line. `objgraph.show_growth()` shows which types accumulate; `show_backrefs([obj], max_depth=5)` renders who points at `obj`.Task 8 — Profile and kill atomic-count contention¶
Take a multithreaded program (Rust Arc, C++ shared_ptr, or your Task 6 pointer) where many threads clone one hot shared pointer in a loop. Profile it, identify the contention on the count's cache line, then refactor to remove the hot clone (pass by borrow/reference, or clone once per task) and re-measure.
Self-check:
- I located time spent in atomic count ops via a CPU profiler / flame graph.
- I observed cache-coherence contention (e.g.
perf c2cHITM events, or high cache-miss counts on the count's address). - My refactor measurably reduced both wall-clock time and the contention signal.
Hint
`perf stat -e cache-misses,cache-references` for a quick signal; `perf c2c record/report` to pinpoint cache-to-cache transfers (HITM) on the shared line. The fix: in the inner loop pass `&ArcTask 9 — Find and fix a closure retain cycle (Swift) or its analogue¶
In Swift, build a small object that owns a closure capturing self (a timer callback, a network completion handler), confirm with Instruments / the Xcode Memory Graph that it leaks, then fix it with [weak self]. If you don't have Swift, reproduce the equivalent in Rust/C++: an object whose stored callback Arc/shared_ptr captures a strong reference back to the object.
Self-check:
- I reproduced the leak and saw the retain cycle in a tool (Memory Graph / leak check).
-
[weak self](or aWeak/weak_ptrcapture) broke the cycle and the object now deallocates. - I can explain why
unowned/non-zeroing weak would be faster but riskier here.
Hint
Swift: `self.onDone = { self.cleanup() }` leaks; `self.onDone = { [weak self] in self?.cleanup() }` doesn't. Verify with the Memory Graph Debugger's purple leak badges or Instruments → Leaks → Cycles & Roots. In Rust, store the callback as a `Weak` upgrade-on-call.Capstone¶
Task 10 — A hybrid allocator: refcounting + cycle detection¶
Build a miniature managed-object system in a language of your choice that (a) reference-counts objects with inline counts, (b) supports weak references, and (c) includes a simple cycle detector that periodically finds and reclaims cyclic garbage the counts can't — i.e. a tiny version of CPython's design. Then write a test suite proving: acyclic objects free promptly via counting, weakly-broken cycles free promptly, and unbroken cycles are reclaimed only by the cycle pass.
Self-check:
- Acyclic graphs are freed immediately at count-zero, without invoking the cycle pass.
- My cycle detector reclaims unreachable cycles and leaves reachable objects alone (it correctly distinguishes "referenced only from within the cycle" from "referenced from outside").
- Weak references neither keep objects alive nor confuse the cycle detector, and dangling weak refs report "gone."
- I can articulate, with my own implementation as evidence, why refcounting needs a tracing companion to be complete.
Hint
For the cycle detector, implement the Bacon–Rajan / CPython trick on a set of candidate container objects: copy each candidate's count, **subtract** the references that come from *within* the candidate set, and anything left at zero is reachable *only* through the cycle and is collectable; anything still positive is externally reachable, so mark it and everything it reaches as alive. Restore counts, then free the truly-dead set. Keep the object set small and explicit so the algorithm is easy to inspect.Self-Assessment¶
You've internalized reference counting when you can:
- Trace a count through a sequence of assignments and predict the exact free point.
- Explain in terms of counts why a cycle leaks, and break it with a weak reference on the correct edge.
- Implement a counted smart pointer with strong/weak counts and a correct control-block lifetime.
- State why atomic counting is far costlier than non-atomic, and demonstrate cache-line contention with a benchmark.
- Diagnose a production cycle leak: distinguish it from a strong-ref leak, attribute the allocation site, and find the retainer chain.
- Find and kill atomic-count contention by removing hot clones from inner loops.
- Argue when reference counting beats tracing GC (latency, promptness, footprint, single-owner) and when it loses (throughput, cycles, heavy shared mutation).
- Describe how CPython, Swift/ARC, Rust, and C++ each implement counting and cycle handling, and why their choices differ.
If every box is checked, you understand reference counting at the depth this topic is meant to convey.
In this topic
- interview
- tasks