Foundationপ্রথম নীতি থেকে
LEVEL 5লেসন ৮/১৫কঠিন৪৫ মিনিট

Reference Counting — গোনা রাখা, cycle-এর ফাঁদ, আর weak reference

Reference Counting and Cycles

প্রতিটা assignment-এ গুনে রাখা — deterministic আর simple, কিন্তু cycle তাকে চিরতরে ফাঁসে ফেলে; এই লেসনে RC-র গণিত, ফাঁদ, আর weak reference-এর মুক্তিপথ।

এই লেসন শেষে আপনি পারবেন

  • Reference counting-এর mechanics ব্যাখ্যা করতে পারবেন — কোন operation-এ count বাড়ে কমে, শূন্যে পৌঁছালে ঠিক কোন মুহূর্তে dealloc হয় — আর এই determinism-টা tracing GC-র latency model-এর সাথে তুলনা করতে পারবেন
  • প্রতিটা assignment-এ hidden memory-traffic tax আর multithreaded program-এ atomic RC-র cache-line contention cost — দুটোই quantitatively বর্ণনা করতে পারবেন
  • Parent↔child cycle-এ refcount কেন কখনোই শূন্যে নামে না তা diagram-এ trace করে দেখাতে পারবেন, আর CPython-এর gc module cycle-collection fallback কীভাবে এই ফাঁদ থেকে বাঁচায় বলতে পারবেন
  • Weak reference-এর semantics (non-owning borrow, upgrade-to-strong) বলতে পারবেন আর Rust Rc/Weak আর Python weakref-এ সেটা কীভাবে প্রকাশ পায় দেখাতে পারবেন
  • C-তে নিজ হাতে retain/release implement করে deliberate cycle leak বানাতে, ধরতে, আর একটা back-pointer দিয়ে ঠিক করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

গত লেসনের collector ছিল জরিপকর্তা — মাঝে মাঝে এসে পুরো heap traverse করে যায়। এই লেসনের mechanism সম্পূর্ণ ভিন্ন দর্শনের: জরিপ নেই, শুধু হিসাব। প্রতিটা object নিজের মাথায় একটা counter বহন করে — “আমার দিকে এখন কয়টা reference আছে”। কেউ নতুন alias বানালে counter বাড়ে, কেউ reference ছাড়লে কমে, আর শূন্যে পৌঁছালেই object মৃত — সাথে সাথে, deterministic ভাবে।

দর্শনগত পার্থক্যটা এক লাইনে:

Tracing:   "মরণ ঘোষণা করব জরিপে — periodic, batch, deferred।"
Counting:  "মরণ ঘোষণা করব হিসাবে — incremental, per-event, immediate।"

CPython-এর প্রতিটা object, Swift-এর প্রতিটা class instance, PHP-র প্রতিটা zval, Windows COM-এর প্রতিটা interface — এই সবাই আজও এভাবেই বাঁচে-মরে। অর্থাৎ RC কোনো historical foot-note না, production-এর সবচেয়ে বেশি ব্যবহৃত automatic memory management পরিবারগুলোর একটা।

কিন্তু এই সরলতার একটা ভয়ংকর গর্ত আছে — cycle। দুইটা object একে অপরকে ধরে রাখলে কেউই কখনো শূন্যে পৌঁছায় না, যতক্ষণ না বাইরে কোনো root থেকে তাদের দিকে পৌঁছানো যায় — tracing-এ এরাই প্রথম শিকার, counting-এ এরা চিরকালের বন্দি। আজকের লেসনের দ্বিতীয়ার্ধ এই ফাঁদ, তিনটা মুক্তিপথ (weak reference, cycle-collecting fallback, acyclic-by-construction ownership) আর নিজের হাতে leak বানিয়ে-ঠিক করার উপর।

মূল ধারণা

Mechanics — তিনটা নিয়ম

প্রতিটা heap object-এর header-এ একটা integer field (refcnt)। তারপর:

১. ALIAS (copy a pointer):        refcnt++        — "retain", "inc_ref", Py_INCREF
২. DESTROY a reference (a dies):  refcnt--
৩. refcnt == 0:                    object-এর destructor চলে,
                                   তারপর সে নিজের সব field-এর reference
                                   ছেড়ে দেয় (প্রতিটার refcnt--) —
                                   chain reaction হতে পারে — তারপর memory free

Chain reaction-টা খেয়াল করুন — এখানেই RC-র সবচেয়ে সুন্দর property:

root ──► A(3) ──► B(2) ──► C(1)

B-র reference ছাড়লে:
  A-- → A(2); B-- → B(1)          (শুধু direct effect)
A-ও ছাড়লে:
  A-- → A(1)... A-- → A(0) → A মরে!
    A মরার সময় B-র reference ছাড়ে → B-- → B(0) → B মরে!
      B মরার সময় C-র reference ছাড়ে → C-- → C(0) → C মরে!

পুরো subtree cascade-এ মরে গেল, ঠিক সেই microsecond-এ যখন শেষ external reference গেল। Java-তে এই subtree-র মৃত্যুর জন্য collector-এর রান পর্যন্ত অপেক্ষা; RC-তে timing-টা আপনার কোডের structure-এই লেখা। Destructor-এ side-effect (file close, log flush) লিখলে সেটা predictable সময়ে ঘটে — RAII-র deterministic সুবিধাটা automatic management-এর সাথেও পাওয়া গেল।

দাম ১ — প্রতিটা assignment-এ traffic

Counter update “ফ্রি” না। x = y একটা pointer-copy মাত্র হলে ভালো হতো, কিন্তু RC-জগতে সেটা:

x = y  ⇒   INCREF(y->target); old = x->target; x = y; DECREF(old);
           ↑ read+write counter     ↑ আরেকটা read+write (old target-এ)

চারটা extra memory access প্রতি pointer-assignment-এ — আর counter আর object ভিন্ন cache line-এ থাকলে প্রতিটাই potential cache miss। Hot loop-এ pointer reassignment থাকলে এই tax throughput-এ সরাসরি দেখা যায়। এজন্যই optimization জন্ম নিয়েছে: deferred RC (stack-local reference-এর count ধরে না — stack নিজেই root), coalescing (একই object-এর inc/dec জোড়া merge)।

দাম ২ — multithreading-এ atomicity

দুই Thread একসাথে একই counter-এ ++ চালালে lost update — মানে premature free, use-after-free। তাই shared object-এর counter atomic হতে হয় (std::atomic<int>, Rust-এর Arc = atomic Rc):

single-threaded INCREF:  load; add; store            (~1–2 ns)
atomic INCREF:           lock cmpxchg / fetch_add     (~5–20 ns uncontended)
                         + contended হলে cache-line
                           দুই core-এর মধ্যে ping-pong (~40–100+ ns)

Worst case সুন্দর ফাঁদে: একটা hot object যার counter সব thread বারবার ধরে-ছাড়ে (যেমন shared config node) — counter-টার cache line পুরো system-এ ঘুরতে থাকে, আসল data কেউ স্পর্শই করে না। Swift-এর এই কারণে value types (struct) প্রচার করে — class instance-ই শুধু RC-tax দেয়।

Trade-off টেবিল — RC বনাম tracing

Reference CountingTracing GC
Reclaim timingImmediate, deterministicDeferred, collector-dependent
Worst-case pauseছোট, তবে deep cascade-এ destructor-chain লম্বা হতে পারেবড় STW pause সম্ভব (modern concurrent-এ sub-ms)
Throughput overheadপ্রতি assignment-এ (constant, spread out)প্রতি collection-এ (batched)
Cycle handlingব্যর্থ — বাড়তি machinery লাগেNative
Root set দরকার?না — counter-ই সত্যের উৎসহ্যাঁ — scan শুরুর ঠিকানা
Real-time friendlinessভালো (no surprise batch)concurrent হলে ভালো
Implementation জটিলতাসহজ (per-op rule)কঠিন (barrier, pacer, safepoint)

ভেতরে কী ঘটছে

Cycle — ফাঁদটা ঠিক কোথায় পড়ে

Concrete scenario: parent-child doubly-linked structure। Parent child-কে চেনে, child উল্টো parent-কে চেনে (back-pointer — doubly-linked list, DOM tree, observer registration — সবখানে এটা):

শুরুর অবস্থা (দুটোই root-reachable):

root ──► PARENT(2) ◄──────────┐
              │               │ back-pointer
              ▼               │
           CHILD(2) ──────────┘

এখন root-এর reference মুছে গেল (del parent_var):
  PARENT-- → PARENT(1)     ... আর কিছুই না!

  CHILD-এর কাছে এখনো PARENT-এর reference আছে → count ১
  PARENT-এর কাছে এখনো CHILD-এর reference আছে → count ১
       ∞ ────────────────────────
  কেউই কখনো ০-তে পৌঁছাবে না। দুটোই unreachable —
  কিন্তু counter-দের কাছে দুটোই "live"।

Tracing-এ এই জুটির মৃত্যু নিশ্চিত — roots থেকে traversal তাদের ছোঁয়ই না, sweep মুছে দেয়। Counting-এর কাছে graph-এর global view নেই; প্রতিটা object শুধু নিজের local হিসাব জানে। এই ফাঁদ থেকে মুক্তির তিনটা পথ, production তিনটাই ব্যবহার করে:

পথ ১ — Weak reference (manual, explicit)। Non-owning pointer: যে reference count বাড়ায় না। Child-এর back-pointer-টা weak করে দিন — ফাঁদই তৈরি হবে না:

root ──► PARENT(2) ◄┄┄┄┄┄┄┄┄┄┐  ┄┄ = weak (count-এ ভাগ না)
              │              ┊
              ▼              ┊
           CHILD(1) ─────────┘
root গেলে: PARENT(0) → মরে → ছাড়ে CHILD → CHILD(0) → মরে ✓

Weak reference-এর শর্ত: ব্যবহারের আগে upgrade করতে হয় — “owner এখনো বেঁচে আছে?” Python-এ weakref.ref(), Rust-এ Weak::upgrade() -> Option<Rc<T>>। Option ফেরত পাঠানোর কারণই এই: owner মধ্যপথে মরে যেতে পারে।

পথ ২ — Cycle-collecting fallback (automatic, batched)। CPython-এর মূল বুদ্ধি: RC-কে fast path রেখে, কালেভদ্রে একটা generational tracing pass চালিয়ে শুধু container-object-দের মধ্যে cycle খোঁজা। এই hybrid-এর বিস্তারিত নিচে hood-এ।

পথ ৩ — Acyclic by construction (type-system)। Rust-এ Rc<T> clone করলে count বাড়ে, কিন্তু ভাষাটা আপনাকে বলে দেয় কোনটা owning (Rc<T>) কোনটা non-owning (Weak<T>) — type signature-ই documentation। Cycle এখনো লেখা যায় (Rc↔Rc রাখলেই), তাই এটা prevention না, visibility: ownership graph আপনার types-এ লেখা থাকে, code review-তে ধরা পড়ে।

CPython-এর hybrid — production-এর সবচেয়ে interesting compromise

CPython object lifecycle:
  প্রতিটা PyObject: [refcnt][type ptr][data...]
  INCREF/DECREF inline macro — প্রায় প্রতিটা op-এ ঘটে (fast path)

  BUT: container objects (list/dict/set/class instance) আরেকটা
  আলাদা linked list-এ নিবন্ধিত ("tracked")।

  threshold cross হলে → gc.collect():
    generation-wise traversal শুধু tracked containers-এর মধ্যে —
    reachability হিসাব করে "external refs ছাড়া শুধু ভেতরেই
    একে অপরকে ধরে আছে" এমন cluster খোঁজে (tentatively unreachable),
    finalizer-safe ভাবে মুছে দেয়।

  ফল: scalar/int/str — কখনো cycle হয় না — শুধু RC-তেই চলে;
       cycle-prone containers — fallback পাহারায়।

Experiment section-এ আপনি ঠিক এই দুই mechanism-কে আলাদা করে দেখবেন: sys.getrefcount দিয়ে RC-র pulse, gc.collect() দিয়ে fallback-এর intervention।

`x = obj.attr` — একটা 'সোজা' assignment-এর ভেতরে RC-র জীবন
  1. Read attrobj-এর dict lookup → target pointer পাওয়া গেল (target->refcnt এখনো অপরিবর্তিত)
  2. INCREF targettarget->refcnt += 1 — single-threaded-এ plain increment, shared-এ atomic RMW
  3. Store to xx-এর পুরনো value save করে নতুন pointer বসানো
  4. DECREF oldপুরনো target->refcnt -= 1; শূন্য হলে deallocate path — যা আবার তার field-দের DECREF করে (cascade!)

চার ধাপ, প্রতিটা potential cache miss, প্রতিটা store atomic হলে আরও ভারী — একটা নিরীহ assignment-এর hidden price, আর এটাই RC-র পুরো throughput-story এক ছোট্ট sequence-এ।

উদাহরণ

Python-এ দুইটা graph — একটা ফাঁসে, একটা মুক্ত

import sys, weakref, gc

class Node:
    def __init__(self, name):
        self.name = name
        self.other = None
    def __repr__(self):
        return f"<Node {self.name}>"

# ---- কেস ১: acyclic chain — RC একাই যথেষ্ট ----
a = Node("a")
b = Node("b")
a.other = b                      # b: refcnt 2 (b var + a.other)
print(sys.getrefcount(b))        # 3 — getrefcount নিজে একটা temp ref যোগ করে!

del a                            # a.other গেল → b: refcnt 1
print(sys.getrefcount(b))        # 2

# ---- কেস ২: cycle — ফাঁদ ----
p = Node("parent")
c = Node("child")
p.other = c                      # c: 2
c.other = p                      # p: 2 — cycle complete

probe = weakref.ref(p)           # weak নজরদার — count বাড়ায় না
del p, c                         # দুই strong reference-ই গেল!
print(probe())                   # <Node parent> — এখনো বেঁচে!!! ফাঁদে
print(gc.get_count())

gc.collect()                     # fallback intervene করল
print(probe())                   # None — এবার সত্যিই মরেছে

Output (counts environment-ভেদে সামান্য বদলাতে পারে):

3
2
<Node parent>
(489, 4, 12)
None

লক্ষ্য করুন probe() প্রথমবার object-টাই ফেরত দিল — দুইটা variable del করা সত্ত্বেও। এই এক লাইনই cycle-এর প্রমাণ: strong reference শূন্যে নামেনি, কারণ দুই object একে অপরকে জীবন দিয়ে যাচ্ছে। gc.collect()-ই শুধুমাত্র fallback tracing pass — এবং সেটা চলার পর probe() None। আর কেস ১-এ কোনো gc.collect() লাগেনি — pure RC কাজ করেছে।

ছবিতে

Refcount lifecycle — তিনটা অবস্থার মানচিত্র

                 allocate (rc=1)
                       │
        ┌──────────────▼──────────────┐
        │                             │
   INCREF (alias)                DECREF (alias মরল)
        │                             │
        ▼                             ▼
  rc=2,3,4... ◄────INCREF──── rc=n−1 ────DECREF──► ...
        │                             │
        │                        rc == 0?
        │                        │
        │                        ▼
        │              destructor চলে (__del__, drop)
        │                        │
        │              fields-দের DECREF (cascade!)
        │                        │
        │                  memory freed
        │                        │
        └── cycle থাকলে: rc কখনো ০-এ আসে না ──► zombie pair
                                 (fallback gc / weak ref ছাড়া মুক্তি নেই)

Weak reference upgrade-এর race-টাও মানচিত্রে রাখুন:

weak ──upgrade──► [owner বেঁচে?]
                      │yes→ strong ref পাওয়া গেল (rc++, নিশ্চিত ব্যবহারযোগ্য)
                      └no ─→ None/Nil — caller-কে মোকাবিলা করতে হবে

মজা: upgrade-এর মুহূর্তে rc==0 হলেও একটা সূক্ষ্ম জায়গা আছে —
proper implementation (Rust Weak::upgrade) check-and-inc এক
atomic ধাপে করে, নাহলে "check করলাম বেঁচে আছে → ঠিক তখনই অন্য
thread-এ মরে গেল" race।

নিজে চালিয়ে দেখুন

EXPERIMENT

Refcount-এর stethoscope — sys.getrefcount, gc.get_referrers, আর RSS growth

Linux/macOS, Python 3 (CPython)· ১৫ মিনিট
# cycles.py
import gc, sys, weakref

class Node:
    def __init__(self, name, cyclic_peer=None):
        self.name = name
        self.peer = cyclic_peer
    def __repr__(self):
        return f"<{self.name}>"

def show(tag, ref):
    print(f"{tag:<28} weak→ {ref()}")

# ---------- ১. Acyclic: pure RC ----------
n = Node("solo")
w = weakref.ref(n)
del n
show("acyclic after del:", w)          # None — RC-ই মেরেছে, gc লাগেনি

# ---------- ২. Cyclic: ফাঁদ ----------
a = Node("A")
b = Node("B")
a.peer, b.peer = b, a                  # cycle!
wa, wb = weakref.ref(a), weakref.ref(b)
print("refcounts:", sys.getrefcount(a), sys.getrefcount(b))  # 2, 2
del a, b                               # দুই external ref গেল
show("cyclic after del:", wa)          # এখনো <A>! RC ব্যর্থ
show("peer's referrer:", wb)

# কে ধরে আছে? gc.get_referrers — fallback-এর সাথে একই traversal knowledge
refs = gc.get_referrers(wa())
print("referrer types:", [type(r).__name__ for r in refs])

# ---------- ৩. Fallback intervene ----------
collected = gc.collect()
print("gc.collect() returned:", collected)   # কতটা object মরল
show("after gc.collect():", wa)              # None

# ---------- ৪. gc.disable() + churn = leak দেখুন ----------
import tracemalloc
class Big:
    def __init__(self):
        self.data = bytearray(1024)

tracemalloc.start()
gc.disable()
for _ in range(50_000):
    x = Big(); y = Big()
    x.peer, y.peer = y, x               # প্রতি iteration-এ একটা cycle
cur, peak = tracemalloc.get_traced_memory()
print(f"gc OFF : current={cur/1e6:.1f} MB")

gc.enable(); gc.collect()
cur2, _ = tracemalloc.get_traced_memory()
print(f"collect: current={cur2/1e6:.1f} MB  ← সব ফেরত এল")
python3 cycles.py

Typical output:

acyclic after del:           weak→ None
refcounts: 2 2
cyclic after del:            weak→ <A>
peer's referrer:             weak→ <B>
referrer types: ['Node']
gc.collect() returned: 2
after gc.collect():          weak→ None
gc OFF : current=151.8 MB
collect: current=0.0 MB  ← সব ফেরত এল

পড়ার নিয়ম: লাইন ১-এ acyclic object del-এই মরেছে (pure RC)। লাইন ৪-৫-এ cycle-এর জুটি বেঁচে আছে — আর get_referrers বলছে কার হাতে আটকে (Node peer)। শেষ দুই লাইন সবচেয়ে dramatic: gc.disable() অবস্থায় ৫০ হাজার cycle ~150 MB জমিয়ে ফেলেছে — leak, RC-র জগতের permanent ফাঁদ — আর একটা gc.collect()-এ শূন্যে নেমেছে — fallback tracing-এর সরাসরি intervention।

এটা কী প্রমাণ করে

Cycle-এ থাকা object-দের refcount del-এর পরেও শূন্যে নামে না আর gc.disable() করলে memory বাড়তেই থাকে — অর্থাৎ CPython-এর reclaim আসলে দুই mechanism-এর কাজ, আর cycle-অংশটা pure RC-তে অসম্পূর্ণ।

নিজে বানান

BUILD IT

Retain/release নিজ হাতে — আর একটা deliberate cycle leak

C · ●●○○○
  1. RefCounted struct: refcnt + payload, retain()/release() helper
  2. release()-এ refcnt==0 হলে free — cascade নিজে থেকেই ঘটবে
  3. Doubly-linked pair বানাও: next strong, prev strong (ভুল ভার্সন!)
  4. Root release করে দেখাও কেউই মরে না — ASan/valgrind-এ leak confirm
  5. prev-কে weak বানাও (retain ছাড়া raw pointer + validity flag) — leak শেষ
/* rc.c — refcounted nodes + deliberate cycle leak + weak fix */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct Node {
    int refcnt;
    char name;
    struct Node *next;         /* strong (retained) */
    struct Node *prev;         /* WEAK: retained না — non-owning back-ptr */
    int prev_alive;            /* weak pointer-এর validity flag */
} Node;

static Node *node_new(char name) {
    Node *n = calloc(1, sizeof(Node));
    n->refcnt = 1; n->name = name;
    return n;
}
static void retain(Node *n) { if (n) n->refcnt++; }

static void release(Node *n) {
    if (!n || --n->refcnt > 0) return;
    /* cascade: strong references ছাড়ি — প্রতিটা নিজের release ডাকবে */
    printf("free %c\n", n->name);
    if (n->next) {
        n->next->prev_alive = 0;      /* weak দিককে জানাই — owner মরলে */
        release(n->next);
    }
    free(n);
}

int main(int argc, char **argv) {
    int weak_fix = argc > 1 && strcmp(argv[1], "fix") == 0;

    /* A ⇄ B: দুই দিকেই strong = CYCLE (buggy version) */
    Node *a = node_new('A');          /* refcnt 1 — main-এর ref */
    Node *b = node_new('B');          /* refcnt 1 — creation ref এখনই transfer */
    a->next = b;                      /* A owns B (ref transfer, retain লাগে না) */
    b->next = a; retain(a);           /* B owns A ← ফাঁদ! a: refcnt 2 */

    if (weak_fix) {                   /* fix mode: B→A edge-টা weak করি */
        release(a);                   /* undo B-র retain — a: refcnt ফিরে 1 */
        b->next = NULL;               /* B আর A-কে own করে না */
        b->prev = a; b->prev_alive = 1;   /* non-owning জানা মাত্র */
    }

    printf("releasing root...\n");
    release(a);                       /* বাইরের শেষ reference */
    printf("done\n");
    return 0;
}
gcc -g -fsanitize=address -o rc rc.c
./rc          # buggy: cycle
./rc fix      # fixed: weak back-pointer
valgrind --leak-check=full ./rc
$ ./rc
releasing root...
done            ← কোনো "free" লাইনই নেই! (A-র refcnt 2→1, B-কে ধরে আছে;
                  B আবার A-কে — mutual pinning)
                  valgrind: definitely lost: 32 bytes in 2 blocks

$ ./rc fix
releasing root...
free A
free B
done            ← cascade complete — valgrind: no leaks are possible

Fix-mode-এর জাদুটা খেয়াল করুন: B→A retain-টা সরালেই release(a) এ A-র count শূন্যে নামে → A free → A নিজের strong next (B) ছাড়ে → B free → B তার weak prev-কে ছোঁয়ই না (own করে না, শুধু prev_alive=0 করতে পারত)। Cycle ভাঙার মানে extra code না — ownership এক দিকে রাখা।

নিজে বাড়ান:

  1. Node_get_prev(n) লিখুন — weak pointer safe-access: alive না হলে NULL। এটাই manual Weak::upgrade()।
  2. Atomic করুন: __atomic_add_fetch(&n->refcnt, 1, __ATOMIC_RELAXED) দিয়ে retain/release বানিয়ে দুই-thread stress test চালান — তারপর plain ++-এর ভুল সংস্করণে race ধরুন (TSan: -fsanitize=thread)।
  3. একটা তৃতীয় node C যোগ করে A→B→C→A triangle cycle বানান — fix করতে কোন edge-টা weak করবেন এবং কেন (tree-shaped ownership রাখার নিয়মটা মনে করুন)?

বাস্তব সিস্টেমে

Production RC — চারটা রাজ্য

CPython: সবকিছুই refcounted। PyObject header-এ counter, Py_INCREF inline macro। পুরো ভাষার semantic — variable assignment, function call argument passing — এই counters-এর নাচে। Cycle fallback (generational, container-only) 2001 থেকে। Recent CPython version-গুলোতে immortal objects (interned strings, small ints-এর কিছু অংশ) — counter আর নড়ে না, multi-interpreter sharing-এর জন্য।

Swift: ARC ভাষার নিজস্ব স্তরে। Compiler প্রতিটা class-reference-এ retain/release call insert করে — programmer দেখে না। SIL optimizer অনেক জোড়া elide করে (একই scope-এ retain+release = শুরুতেই cancel)। Value types (struct/enum) RC-free — performance-sensitive API-তে struct প্রচারের মূল কারণ। weak/unowned reference delegate patterns-এর মানসিক অভ্যাস (delegate সবসময় weak — retain cycle-এর ক্লাসিক উৎস)।

PHP আর Windows COM: PHP-র zval refcounted (copy-on-write এর উপরে), COM-এর AddRef/Release — API-তে explicitly exposed, কারণ COM binary boundary পার করা object-দের lifetime contract এটাই।

Rust-এর Rc/Arc: opt-in RC। Default ownership zero-cost; shared mutable ownership দরকার হলে Rc<RefCell<T>> (single-thread) বা Arc<Mutex<T>> (shared) — কোন জায়গায় RC-tax দিচ্ছেন তা type signature-এ লেখা। Linux kernel-ও kref দিয়ে একই দর্শন: hot path-এ tracing অসম্ভব, কিন্তু সুশৃঙ্খল counting সম্ভব।

Performance folklore-এর সত্য অংশ: Objective-C/Swift iOS apps-এ autorelease pool drain, Python-এ large cyclic datastructure teardown-এর spike — সব RC-র জীবন্ত বাণিজ্য-ঘাটগুলো। আপনি এখন ওই folklore-এর পেছনের গণিতটা জানেন।

যে ভুলগুলো সবাই করে

“Inc/dec জোড়া ঠিকমতো মিলিয়ে লিখলে RC-তে কোনো leak হয় না।”

Perfect pairing-ও cycle আটকাতে পারে না — ফাঁদটা pairing-bug না, model-bug: দুই object একে অপরকে own করলে কারো count-ই শূন্যে নামার পথ নেই, কোনো ভুল ছাড়াই। এজন্যই solution গঠনগত: weak/non-owning দিক, বা cycle collector। Pairing discipline (RAII guard, defer) অন্য শ্রেণির bug (premature free, double release) ঠেকায়।

“Python-এ object scope থেকে বেরোলেই সাথে সাথে মুক্ত হয় — always।”

CPython-এ সাধারণত হ্যাঁ — refcount শূন্য হলেই instant dealloc, এটা ভাষার প্রায়-guarantee (CPython-specific behavior, language spec না)। কিন্তু exception: cycle-এ থাকা object fallback collector-এর turn-এর অপেক্ষায় থাকে, আর __del__-সহ legacy cases-এ deferral আরও জটিল। Determinism-টা default, absolute না।

“ARC মানে Swift-এ cycle সমস্যা নেই — Apple-এরা সমাধান করে দিয়েছে।”

ARC = Automatic Reference Counting — automatic অংশটা শুধু retain/release insertion। Cycle-এর গণিত একই: mutually-strong references কখনো মরে না। তাই Swift-এ weak var delegate এত standard idiom — ভাষা সমাধান দেয়নি, সরঞ্জাম দিয়েছে (weak/unowned)।

“Atomic refcounting-এর খরচ নগণ্য — modern CPU তো fast।”

Uncontended atomic op ~5–20 ns — একা নিলে সস্তা। কিন্তু RC-তে op-টা প্রতি assignment-এ, আর shared-hot-object-এ cache-line contention-এ 10× খরচ। Deep call-stack জুড়ে প্রতি frame-এ retain/release হলে compound effect মাপা যায় (Swift team নিজেরাই value-type migration এর জন্য প্রচার করে)। “নগণ্য” বলার আগে profile — এটা constant-spread tax, ব্যাচ-এর না।

বুঝেছেন কি না দেখুন

1

RC-তে একটা object-এর মৃত্যু ঠিক করে কোন তিনটা ঘটনা-ক্রম? আর tracing GC-তে সেই মৃত্যু কীসের উপর নির্ভর করে?

স্মরণ

RC: (১) শেষ strong reference-এর DECREF → refcnt==0, (২) destructor/finalizer চলে, (৩) object নিজের held references ছেড়ে (cascade) তারপর memory free — সব সাথে সাথে, deterministic।

Tracing: মৃত্যুর সময় ঠিক হয় collector-এর schedule-এ — object dead হওয়া আর reclaim হওয়ার মধ্যে arbitrary gap (next GC cycle)।

2

Doubly-linked list: প্রতিটা node next আর prev দুটোকেই strongly retain করলে কী হয় পুরো list delete করার সময়? Fix-টা বলুন আর justify করুন কেন ঠিক ওই দিকটা weak হবে।

যুক্তি

প্রথম node release করলে সে তার next ছাড়ে, কিন্তু next-এর prev তাকে ধরে আছে — পুরো list একটা giant cycle, একটাও free হবে না (head-এর external ref গেলেও)।

Fix: prev weak (non-owning)। Justify — ownership tree-এর নিয়ম: list-এর canonical ownership হেড থেকে tail-এর দিকে (next দিক), prev শুধু navigation convenience। Tree-direction রাখলে every object-এর ঠিক একজন owner — cascade সবসময় terminate করে। উল্টোটা করলেও technically চলে, কিন্তু নিয়ম ভাঙলে পরের developer-এর জন্য ফাঁদ।

3

sys.getrefcount(x) কেন actual count-এর চেয়ে এক বেশি দেখায়? আর একটা object-এর count যদি 1 থেকে বাড়েই না কোনো প্রোগ্রামে, সে object কি cycle-এ আছে?

প্রয়োগ

getrefcount(x) call-এ x একটা temporary argument-reference পায় — সেই temp ref-ই +1; ফেরার সময় ছেড়ে যায়।

Count 1-এ আটকে থাকা মানে cycle না — cycle-এ থাকলে minimum 2 হতো (cycle-partner-এর reference + possibly self)। Count 1 মানে ঠিক একজন holder — সাধারণ একক ownership, healthy state। Cycle detection-এর signal হলো: external refs শেষ হওয়ার পরেও count > 0।

4

আপনি একটা document editor লিখছেন: Document → Paragraph → Run hierarchy, plus একটা global StyleCache যেখানে paragraph-রা style lookup করে, আর Style object-রা paragraph-দের জানে কোথায় ব্যবহৃত (usage tracking)। Strong/weak কোথায় বসাবেন — প্রতিটা edge ধরে বলুন, আর কোন edge-ই cycle বানাত?

ডিজাইন

Ownership tree হওয়া উচিত: Document →(strong) Paragraph →(strong) Run। StyleCache →(strong) Style (cache owns its entries — eviction তার একার সিদ্ধান্ত)।

Non-owning edges: Paragraph →(weak) Style (style মরলে paragraph জানবে, miss-এ re-resolve); Style →(weak) Paragraph usage-list (paragraph মরলে style আটকে রাখবে না — নইলে cache কখনো খালি হবে না)।

Cycle danger zone ঠিক Style↔Paragraph: দুই দিকই strong করলে paragraph মুছলেও style তাকে ধরবে, style evict-ও হবে না (paragraph ধরে আছে) — mutual pinning। এক দিক weak = সমাধান; কোন দিক তা policy-তে: cache-ই owner হওয়া natural, কারণ cache নিয়ন্ত্রণ করে lifecycle।

5

CPython কেন “pure RC” না হয়ে hybrid বেছে নিয়েছে — fallback collector-কে শুধু container-দের মধ্যে সীমাবদ্ধ রেখে? Pure-tracing-এর চেয়ে এই hybrid-এর লাভ কী?

যুক্তি

Pure RC-র ব্যর্থতা cycle; pure tracing-এর ব্যর্থতা determinism হারানো + সব allocation-এ nursery bookkeeping। Hybrid দুটোর best-of: ভাষার ৯৯% object (scalar, string, tuple-of-scalars) কখনো cycle গড়ে না — তাদের instant RC dealloc-ই যথেষ্ট, কোনো tracing overhead নেই। Cycle-সম্ভব population (container) ছোট আর identifiable — শুধু সেদের মধ্যে batched traversal চালালে fallback-এর খরচও ছোট, আর ঘন ঘন লাগে না।

লাভ tracing-over: average-case latency deterministic (instant reclaim), implementation সরল থাকে (কোনো write barrier সব pointer-store-এ না), আর small-object-heavy workload-এ footprint কম (garbage জমে না)। এজন্যই এই design ২০+ বছর ধরে টিকে আছে।

এরপর কী

এরপর কী

তিনটা লেসন ধরে আমরা দেখলাম memory কে manage করে — programmer, compiler, proof, collector, counter। কিন্তু একটা বড় প্রশ্ন এখনো ঝুলে আছে: আপনার main() চলার আগে কে malloc-এর arena-টা সাজায়? Collector-টা কোথা থেকে spawn হয়? Go-র goroutine scheduler, JVM-এর JIT, CPython-এর bytecode interpreter — এরা কি process-এর ভেতরেই থাকে? উত্তর: হ্যাঁ — runtime system, সেই অদৃশ্য স্তর যা execve থেকে _start হয়ে __libc_start_main পর্যন্ত আপনার কোডের আগে দৌড়ে মঞ্চ সাজায়, আর main return করার পরেও exit-handlers চালায়। C-এর “কোনো runtime নেই” কথাটা কতটা মিথ্যা, Go/JVM/CPython-এর runtime-রা ঠিক OS-এর কোন কাজগুলো নিজের ভেতরে নিয়ে নেয় — আর নিজের হাতে -nostdlib দিয়ে bare-metal hello world বানিয়ে syscall স্তরে নামা — সবই পরের লেসনে।

পরের লেসন: Runtime System — main() এর আগে আর পরে কী চলে।

আরও পড়ুন

  • The Garbage Collection Handbook: The Art of Automatic Memory Management, 2nd ed., chapter 4 — Richard Jones, Antony Hosking, Eliot Moss · RC family-র সম্পূর্ণ taxonomy — deferred, coalescing, cyclic RC সহ
  • A Method of Overlapping and Erasure of Lists — George E. Collins · CACM 1960 — reference counting-এর জন্মপত্র, mark-sweep-এর McCarthy paper-এর সমসাময়িক
  • Concurrent Cycle Collection in Reference Counted Systems — David F. Bacon, V. T. Rajan · ESOP/FLOPS 2001 — cycle collection without global pauses, Swift-era design-দের পূর্বসূরি
  • CPython source: Modules/gcmodule.c ও Include/refcount.h — Python Software Foundation · Production hybrid — refcount primary + generational cycle collector fallback; এই লেসনের experiment এটাই probe করে