Foundationপ্রথম নীতি থেকে
LEVEL 5লেসন ১২/১৫অ্যাডভান্সড৫০ মিনিট

Exception Internals — throw থেকে catch পর্যন্ত সিলিকনের রাস্তা

Exception Handling Internals

try/catch দেখতে if-এর মতো, কিন্তু throw চলে metadata-driven stack-walk-এ — happy path শূন্য instruction, throw-এ মাইক্রোসেকেন্ড; এই অসম খরচের রূপরেখাই C++/Rust/Go-র error strategy-র ভিত্তি।

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

  • setjmp/longjmp model আর table-based zero-cost EH — দুই মডেলেই try entry, normal exit আর throw-এর খরচ আলাদা করে ব্যাখ্যা করে cost asymmetry টেবিলে বসাতে পারবেন
  • Itanium/x86-64 ABI-র exception path দুই phase-এ বিভক্ত করে trace করতে পারবেন — phase 1 handler search (.eh_frame + LSDA + personality routine), phase 2 unwind (destructor/cleanup)
  • RAII আর stack unwinding-এর সম্পর্ক দেখিয়ে একটা multi-frame C++ program-এ destructor-দের ঠিক ক্রম predict করতে পারবেন
  • কেন exception কখনো FFI boundary পার হওয়া উচিত না, আর Rust-এর panic=unwind বনাম panic=abort কোন অবস্থায় কোনটা — justify করতে পারবেন
  • -fno-exceptions দিয়ে আর exceptions দিয়ে compile করা binary-র section/size/perf তুলনা করে প্রমাণ করতে পারবেন try-block-এর happy-path overhead শূন্য, কিন্তু throw নিজেই মাইক্রোসেকেন্ড-স্তরের ঘটনা

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

আগে এটা বুঝি

আপনি হাজারবার লিখেছেন:

try {
    result = parse(input);
} catch (const ParseError &e) {
    log(e.what());
}

পড়তে দেখতে if/else-এর মতোই — condition ভুল হলে ওপাশের branch-এ যাও। কিন্তু assembly module-এর stack-frames-and-function-calls লেসন মনে করুন: machine-এ control flow মানে মাত্র তিনটা জিনিস — sequential fetch, conditional branch, আর call/ret-এর PC manipulation। “পাঁচ frame নিচে থেকে সোজা main-এর catch-এ লাফ” — machine language-এ এমন কোনো instruction-ই নেই।

তাহলে throw আসলে কী? উত্তরটা ভাষার runtime-এর সবচেয়ে সূক্ষ্ম কাজগুলোর একটা: compiler আর runtime মিলে এমন একটা machinery বানায় যেখানে সাধারণ execution-এ exception-এর কোনো অস্তিত্বই দেখা যায় না, কিন্তু throw-এর মুহূর্তে পুরো call stack উল্টে গিয়ে পথের সব cleanup ঠিকঠাক চালিয়ে দেয়। আজকের experiment-এ দেখবেন try-block আসলে একটা machine instruction-ও যোগ করে না — তবু throw একবারে মাইক্রোসেকেন্ড খায়।

আজকের প্রশ্ন তিনটা: (১) try-তে ঢোকাই মাত্র কী খরচ, আর কেন কোনো ভাষাই প্রতি-try register-snapshot আর নেয় না? (২) throw-এর মুহূর্তে runtime কোন metadata পড়ে, কোন দুই phase-এ কী করে? (৩) cleanup কে চালায় — ভাষা, runtime, নাকি programmer?

মূল ধারণা

দুইটা মডেল — snapshot বনাম মানচিত্র

Machine-এ non-local control transfer-এর দুইটা মৌলিক রাস্তা আছে, আর সব ভাষা এই দুটোর কোনো একটার variant:

মডেলtry-তে ঢোকার খরচNormal exitThrow-এর খরচকোথায়
State snapshot (setjmp/longjmp)Register+SP+PC save — প্রতি try-এ ~১০–১০০ ns~শূন্যJump নিজে সস্তা, ~০.১–১ µsC setjmp, legacy x86-32 C++, Lua pcall-সদৃশ
Table-based (“zero-cost”)শূন্য instructionশূন্য~১–১০ µs — table walk + দুই-phase unwindC++ (Itanium ABI), Java, Rust, Python

সংখ্যাগুলো ballpark — CPU ভেদে বদলাবে। কিন্তু অনুপাতটা বদলাবে না, এবং সেটাই design-এর মূল: প্রতিটা প্রোগ্রামে try হাজারবার, throw বছরে ক’বার। Snapshot মডেল common case-কে (try entry) দামি করে rare case-কে (throw) সস্তা করে; table-based মডেল ঠিক উল্টো। Error বিরল — তাই industry এক ভোটে table-based-এ গেছে।

Snapshot মডেল — setjmp/longjmp

jmp_buf saved;                       // register snapshot-এর পাত্র
if (setjmp(saved) == 0) {
    risky();                         // normal path — ভেতরের কেউ
                                     // longjmp(saved, 1) ডাকতে পারে
} else {
    // এখানে পৌঁছায় longjmp থেকে — "exception handler"
}

setjmp দুইবার ফেরত আসে: direct call-এ (0), আর longjmp দিয়ে (nonzero)। jmp_buf-এ থাকে callee-saved registers, stack pointer, mangled return address — সম্পূর্ণ machine-state photo; longjmp photo restore করে PC-কে setjmp-এর পরের লাইনে ফেলে দেয়। আজকের build-এ এটা নিজের হাতে বানাবেন।

দাম: প্রতিটা try-তে ঢোকাই মানে সম্পূর্ণ snapshot — hot loop-এ try ঘুরিয়ে দিলে performance ডোবে। আর intermediate frame-গুলো কিছুই জানে না — automatic destructor/cleanup এই মডেলে নেই।

Table-based মডেল — happy path শূন্য

আধুনিক C++/Rust (Linux/x86-64, Itanium C++ ABI) এমন চুক্তি করে:

Normal execution-এ exception machinery-র জন্য চলে ঠিক শূন্য instruction।
Try block একটা plain region — কোনো setup, কোনো flag, কিছুই না।

বিনিময়ে compiler প্রতিটা function-এর জন্য দুইটা নীরব metadata table রাখে:
  .eh_frame         — প্রতিটা frame কীভাবে ভাঙা যায়: caller-এর return
                      address কোথায়, callee-saved register-রা stack-এর
                      কোন offset-এ বসে (unwinder-এর ভাঙার-নকশা)
  .gcc_except_table — এই function-এর কোন PC-range-এ exception উঠলে কোন
                      handler/cleanup চলবে (LSDA — Language Specific
                      Data Area)

Throw হলে runtime এই টেবিল পড়ে stack নামে — বিস্তারিত hood section-এ। Happy path-এ কিছুই হয় না, তাই নাম zero-cost। মনে রাখুন: zero-cost-এর অর্থ “normal path free”, কখনোই “throw free” না।

RAII — cleanup কে চালায় কে?

C++-এ cleanup-এর দায়িত্ব ভাষার, programmer-এর না — এটাই RAII (Resource Acquisition Is Initialization): resource-এর lifetime = object-এর lifetime, আর object-এর lifetime শেষ হয় scope-এ — exception-এর ভয়েও না। Stack unwinding-এর phase 2 পথে পড়ে এমন প্রতিটা object-এর destructor চালিয়ে যায়। memory-models লেসনে দেখেছেন stack allocation কত সস্তা — RAII সেই সস্তাকে deterministic cleanup-এ রূপান্তর করে, GC-র finalizer-এর মতো “কবে হবে কে জানে” নয়।

Go-র উত্তর ভিন্ন: destructor নেই, তাই cleanup manual — defer। Panic হলে deferred function-রা LIFO চলে, recover শুধু panicking goroutine-এর deferred ফাংশনের ভেতরেই কাজ করে। Rust-এ destructor আছে (Drop), panic= unwind হলে সেগুলোও unwinding-এ চলে — C++-এর RAII-র সমকক্ষ।

ভেতরে কী ঘটছে

Throw-এর সম্পূর্ণ যাত্রা — ধাপে ধাপে

throw std::runtime_error("boom") চাপলে ঘটে এই ক্রম (Itanium ABI, Linux):

1. __cxa_allocate_exception(size)   — libstdc++: heap-এ emergency buffer
                                      থেকে exception object allocate
2. copy-initialize                  — আপনার expression ওই memory-তে construct
3. __cxa_throw(obj, &type_info, dtor)
   └─► _Unwind_RaiseException(context)   — libgcc: আসল unwinder

PHASE 1 — SEARCH (handler কোথায়?)
4. বর্তমান PC থেকে শুরু করে প্রতিটা frame-এ:
   a. .eh_frame entry decode → এই PC-র CFA (canonical frame address),
      return address, saved-register location
   b. ঐ function-এর LSDA (.gcc_except_table) আছে? না থাকলে frame-টা
      exception-transparent — পরের frame
   c. personality routine (__gxx_personality_v0) ডাকো — LSDA-র call-site
      table-এ দেখো: এই PC-range-এ catch-clause আছে, type match করে?
5. match পেলে → সেই frame-এর landing pad নোট করে phase 2
   root frame পর্যন্ত না পেলে → std::terminate (catch ছাড়া throw-এর ভাগ্য)

PHASE 2 — UNWIND (ফেরত নামো, পথে সাফ করো)
6. আবার নিচ থেকে handler-frame পর্যন্ত, প্রতিটা frame-এ:
   a. LSDA-য় cleanup action আছে? (catch না থাকলেও destructor-guard
      থাকতে পারে!) → landing pad-এ jump করে destructor চালিয়ে resume-unwind
   b. handler frame-এ পৌঁছে: catch parameter-টা exception object-এর সাথে
      type-match করে bind করো
7. landing pad-এ jump — এখন থেকে আবার ordinary code
8. catch scope শেষ হলে __cxa_end_catch → exception object free

লক্ষ্য করুন stack দুইবার traverse হয় — একবার খুঁজতে, একবার ভাঙতে। Phase separation-এর কারণ: search-এর সময় stack এখনো intact, তাই কোনো state নষ্ট না করে “handler আদৌ আছে কি না” আগে জেনে নেওয়া যায়।

একটা throw-এর স্তরগুলো
  1. Your codethrow expr; — দেখতে statement, আসলে runtime-library call-এর sugar
  2. libstdc++ (__cxa_*)exception object allocate, type-info attach, unwinder-কে hand-off
  3. libgcc (_Unwind_*)দুই-phase engine: .eh_frame binary-search per frame, personality callback
  4. Metadata tables.eh_frame (ভাঙার নকশা) + .gcc_except_table/LSDA (handler/cleanup map) — executable-এর নীরব অংশ
  5. Your destructorsphase 2 পথে প্রতিটা frame-এর landing pad সংক্ষেপে active হয়ে RAII cleanup চালায়

কেন exception FFI boundary পার হয় না

C function-এর জগতে “exception” বলে কোনো contract নেই — সে LSDA লেখে না, personality routine-ও জানে না। C++ exception C callback-এর ভেতর দিয়ে propagate করার চেষ্টায়: C frame unwind হবে কি না undefined (C-র cleanup concept-ই নেই), আর non-C++ toolchain-এ compile হওয়া frame-এ মানচিত্রই থাকে না। তাই নিয়ম কঠোর: boundary-তে exception convert — error code-এ (C++), Result-এ (Rust), নয়তো boundary-তেই abort। Rust 1.81 থেকে extern "C" fn-এর ভেতরে panic হলে সরাসরি abort — language-ই crossing panic-কে UB থেকে crash-এ নামিয়ে এনেছে।

panic=unwind বনাম panic=abort — Rust-এর দুই মোড

Unwind মোডে (default) panic ঠিক আজকের দুই-phase machinery-ই চালায় — Drop চলে, thread মরে, process বাঁচে; catch_unwind দিয়ে ধরাও যায়। Abort মোডে (panic = "abort" Cargo profile-এ) panic মানেই process death — unwind table দরকার নেই, binary ছোট, embedded/firmware-friendly, fail-fast দর্শনে মানানসই। Trade-off এক লাইনে: unwind recovery দেয়, abort determinism আর আকার দেয়।

Go আবার নিজের রাস্তা: panic কোনো shared unwinder ব্যবহার করে না — goroutine নিজের deferred-function chain ধরে নামে (runtime-systems লেসনের G-M-P জগতের ভেতরেই সব); unrecovered panic পুরো process মারে, আর recover শুধু panicking goroutine-এর সরাসরি deferred ফাংশনের ভেতরেই কাজ করে।

উদাহরণ

Destructor-রা ঠিক কোন ক্রমে চলে — একটা সম্পূর্ণ trace

// unwind.cpp — RAII meets unwinding
#include <cstdio>
#include <stdexcept>

struct Guard {
    const char *name;
    explicit Guard(const char *n) : name(n) { printf("  open  %s\n", n); }
    ~Guard() { printf("  close %s\n", n); }   // unwind phase 2 এটাই ডাকে
};

void level3() {
    Guard conn("level3/connection");
    printf("  level3: throw!\n");
    throw std::runtime_error("boom");
}

void level2() {
    Guard txn("level2/transaction");
    level3();
    printf("  level2: never runs\n");     // throw লাফ দিয়ে পার হয়ে গেছে
}

void level1() {
    Guard session("level1/session");
    level2();
}

int main() {
    try {
        level1();
    } catch (const std::runtime_error &e) {
        printf("  main: caught '%s'\n", e.what());
    }
}

Output — প্রতিটা লাইনের পাশে কোন মেকানিজম লিখল:

  open  level1/session            normal construction
  open  level2/transaction        normal construction
  open  level3/connection         normal construction
  level3: throw!                  ── PHASE 1: search ──
                                  level3/2/1: কোনো catch নেই (শুধু cleanup)
                                  main: catch (runtime_error) match!
                                  ── PHASE 2: unwind ──
  close level3/connection         landing pad → destructor (LIFO!)
  close level2/transaction        landing pad → destructor
  close level1/session            landing pad → destructor
  main: caught 'boom'             handler frame-এ landing pad-এ jump

তিনটা observation:

  1. Destructor-রা construction-এর ঠিক উল্টো ক্রমে (LIFO) — কারণ তারা stack-এ বসে, আর unwind stack-ই ভাঙে। Nested transaction rollback এভাবেই নিজে নিজে ঠিক হয়।
  2. printf("never runs") কখনো চলে না — throw একটা frame-কে পুরোপুরি স্কিপ করে; শুধু destructor চলে, বাকি কোড না। এটাই “invisible goto”-র ভয়।
  3. level1/level2-তে catch clause না থাকলেও তাদের destructor চলেছে — phase 2 প্রতিটা frame-এর cleanup map পড়ে, শুধু handler map না।

ছবিতে

দুই-phase stack walk

উঁচু address (main-এর দিক)

┌────────────────────────────────┐
│ main                           │ ◄── phase 1: STOP — catch found!
│   try { level1(); }            │     phase 2: শেষ landing pad এখানে
│   catch (runtime_error&) {...} │
├────────────────────────────────┤ ▲
│ level1 frame                   │ │ phase 2 — UNWIND
│   Guard session   [cleanup!]   │ │   প্রতি frame: LSDA পড়ো,
├────────────────────────────────┤ │   cleanup থাকলে landing pad-এ
│ level2 frame                   │ │   jump → destructor চালাও →
│   Guard txn       [cleanup!]   │ │   resume unwinding
├────────────────────────────────┤ │
│ level3 frame   throw!          │ │
│   Guard conn      [cleanup!]   │ │
└────────────────────────────────┘ │
নিচু address                       │

phase 1 — SEARCH: throw বিন্দু থেকে উপরে ──► ──►
   প্রতি frame: .eh_frame decode → LSDA? → personality routine:
   "এই PC-range-এ এই type-এর handler আছে?" — না → next frame
phase 1:  throw ●──search──►●──search──►●──search──►★ handler
          (stack intact — শুধু টেবিল পড়ছে)

phase 2:  throw ●◄──cleanup─●◄──cleanup─●◄──────────★ jump into catch
          (পথে প্রতিটা frame destructed, SP/PC restore হয়ে যায়)
একই stack, দুই পথ: phase 1 কোনো state না ভেঙে শুধু পড়ে (read-only walk), phase 2 আসলে ভাঙে। Search-এ fail হলে terminate — কোনো frame অর্ধেক ভাঙা অবস্থায় ঝুলে থাকে না।

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

EXPERIMENT

Zero-cost যাচাই — try ফ্রি, throw দামি

Linux (g++, readelf; macOS-এ objdump/otool দিয়ে একই ভাবনা)· ২০ মিনিট

ধাপ ১ — দুই build, একই সোর্স:

// eh_size.cpp
#include <cstdio>
#ifdef USE_THROW
#include <stdexcept>
#endif

static long work(long x) {
#ifdef USE_THROW
    try {
        if (x == 42) throw std::runtime_error("answer");
#endif
        return x * 3 + 1;
#ifdef USE_THROW
    } catch (const std::runtime_error &) {
        return -1;
    }
#endif
}

int main() {
    long acc = 0;
    for (long i = 0; i < 100000000; ++i) acc += work(i);
    printf("%ld\n", acc);
    return 0;
}
g++ -O2 -o eh_plain eh_size.cpp
g++ -O2 -DUSE_THROW -o eh_throw eh_size.cpp
g++ -O2 -DUSE_THROW -fno-exceptions -o eh_noex eh_size.cpp 2>&1 || \
    echo "(throw ব্যবহার করা হয়েছে, তাই -fno-exceptions compile fail — এটাও একটা ডেটা)"

ls -la eh_plain eh_throw | awk '{print $5, $9}'
readelf -S eh_throw | grep -E "eh_frame|gcc_except"
readelf -S eh_plain | grep -E "eh_frame" | head -2

time ./eh_plain
time ./eh_throw

Typical pattern:

eh_plain: ~16 KB,   .eh_frame ছোট, .gcc_except_table নেই
eh_throw: ~17 KB,   .eh_frame + .gcc_except_table দৃশ্যমান
time eh_plain ≈ time eh_throw  (±noise — happy-path overhead: শূন্য)

Size difference-টাই exceptions-এর দাম: নীরব metadata। Speed difference absent — এটাই “zero-cost” শব্দটার empirical অর্থ।

ধাপ ২ — throw-rate benchmark:

// throw_rate.cpp — কত ঘন ঘন throw করলে কী হয়
#include <cstdio>
#include <cstdlib>
#include <stdexcept>

static void may_throw(long i, long rate) {
    if (rate > 0 && i % rate == 0) throw std::runtime_error("boom");
}

int main(int argc, char **argv) {
    long rate = argc > 1 ? atol(argv[1]) : 0;    // 0 = কখনো throw না
    long acc = 0;
    for (long i = 0; i < 10000000; ++i) {
        try {
            may_throw(i, rate);
            acc += 1;
        } catch (const std::runtime_error &) {
            acc -= 1;
        }
    }
    printf("%ld\n", acc);
    return 0;
}
g++ -O2 -o throw_rate throw_rate.cpp
./throw_rate 0          && time ./throw_rate 0
./throw_rate 1000000    && time ./throw_rate 1000000
./throw_rate 10000      && time ./throw_rate 10000

Typical pattern (১ কোটি iteration):

rate=0        : ~30 ms     — try/catch নিজে ফ্রি
rate=1M       : ~35 ms     — ১০টা throw: প্রায় অলেখযোগ্য
rate=10K      : ~120 ms    — ১,০০০ throw: দৃশ্যমান
rate=100      : ~2 s       — ১ লাখ throw: throw-প্রতি ~২০ µs স্তরে

throw-প্রতি খরচ বের করুন: (t_rate − t_0) / throws — আপনার machine-এর unwind-এর unit price। এই সংখ্যাটাই “exceptions দিয়ে control flow বানানো যাবে না” নীতির quantitative ভিত্তি।

যা প্রমাণিত: happy path-এ exception machinery অদৃশ্য (speed identical), কিন্তু throw একটা দামি runtime protocol — ভাষাগত সিদ্ধান্তগুলো (error codes বনাম exceptions) এই অসমতার উপরেই দাঁড়িয়ে।

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

exceptions enable করলে binary-তে .eh_frame/.gcc_except_table section জন্মায় কিন্তু try-containing hot loop-এর speed-এ পার্থক্য হয় না (happy path শূন্য instruction); throw-এর rate বাড়ালে সময় শত-গুণ বেড়ে যায় — cost asymmetry-র সরাসরি মাপ।

নিজে বানান

BUILD IT

Mini exception system — jmp_buf stack + TRY/CATCH macros

C · ●●●○○
  1. Frame struct বানান — jmp_buf + prev pointer দিয়ে try-frame-দের linked stack
  2. TRY macro: নিজের frame push করে setjmp করে; 0 ফেরত এলে protected region
  3. THROW macro: top frame pop করে longjmp — message গ্লোবাল slot-এ রেখে
  4. CATCH macro: nonzero return-এর পথ — এখানে cleanup আর rethrow-এর জায়গা
  5. দুই frame গভীর থেকে throw করে unwind path আর cleanup order যাচাই করুন

লক্ষ্য: আজকের concept-এর snapshot মডেলটা নিজের হাতে — আর দেখা — এই মডেলে intermediate frame কেন নীরব (কোনো automatic cleanup নেই), তাই cleanup programmer-কেই লিখতে হয়।

/* mini_exc.c — longjmp-ভিত্তিক mini exception system */
/* compile: gcc -O0 -o mini_exc mini_exc.c */

#include <stdio.h>
#include <stdlib.h>
#include <setjmp.h>

struct Frame {
    jmp_buf       jb;      /* এই try-এর machine-state snapshot */
    struct Frame *prev;    /* বাইরের try — unwind-এর পরের স্টপ */
};

static struct Frame *g_top = NULL;
static const char   *g_msg = NULL;

#define TRY                                       \
    do {                                          \
        struct Frame _frame;                      \
        _frame.prev = g_top;                      \
        g_top = &_frame;                          \
        int _exc = setjmp(_frame.jb);             \
        if (_exc == 0) {

#define CATCH                                     \
        } else {                                  \
            /* THROW frame pop করে গেছে; এখানে */  \
            /* cleanup, log, rethrow — যা খুশি */ \

#define END_TRY                                   \
        }                                         \
        if (_exc == 0) g_top = _frame.prev;       \
    } while (0)

#define THROW(msg)                                \
    do {                                          \
        if (g_top == NULL) {                      \
            fprintf(stderr, "FATAL unhandled: %s\n", msg); \
            exit(1);                              \
        }                                         \
        g_msg = (msg);                            \
        struct Frame *_f = g_top;                 \
        g_top = _f->prev;        /* pop FIRST */  \
        longjmp(_f->jb, 1);      /* then jump */  \
    } while (0)


/* ── তিন স্তরের কল-চেইন ──────────────────────────────────────── */

static void level2(void) {
    printf("  level2: enter\n");
    THROW("disk full");
    printf("  level2: never runs\n");
}

static void level1(void) {
    printf("  level1: enter, resource খোলা\n");
    TRY {
        level2();
        printf("  level1: never runs\n");
    } CATCH {
        /* unwind এই frame-এ এসে থামেছে — cleanup এখানে manual */
        printf("  level1: [cleanup] resource বন্ধ\n");
        THROW("db error: disk full");     /* rethrow → main পর্যন্ত */
    } END_TRY;
}

int main(void) {
    printf("== scenario 1: দুই frame পার হয়ে unwind ==\n");
    TRY {
        level1();
    } CATCH {
        printf("  main: caught '%s'\n", g_msg);
        printf("  main: [cleanup] buffer flush\n");
    } END_TRY;

    printf("== scenario 2: nested try, swallow ==\n");
    TRY {
        TRY {
            THROW("inner panic");
        } CATCH {
            printf("  inner caught: '%s'\n", g_msg);   /* rethrow না */
        } END_TRY;
        printf("  outer continues normally\n");
    } CATCH {
        printf("  outer caught (never runs)\n");
    } END_TRY;

    printf("== execution resumes ==\n");
    return 0;
}

প্রত্যাশিত output:

== scenario 1: দুই frame পার হয়ে unwind ==
  level1: enter, resource খোলা
  level2: enter
  level1: [cleanup] resource বন্ধ
  main: caught 'db error: disk full'
  main: [cleanup] buffer flush
== scenario 2: nested try, swallow ==
  inner caught: 'inner panic'
  outer continues normally
== execution resumes ==

যা ঘটল: level2-এর THROW নিজের frame pop করে এক লাফে level1-এর setjmp-এ — level2-এর পরের কোড চলেই না, এবং level2-তে কোনো cleanup হয়ও না (snapshot মডেলে সে কিছুই জানে না — C++-এর RAII-র এই absence-ই মডেল দুটোর সবচেয়ে বড় পার্থক্য)। level1-এর CATCH cleanup চালিয়ে rethrow করে — THROW তখন main-এর frame pop করে। Pop-before-jump ordering ঠিক না রাখলে infinite loop বা stack corruption হতো — নিজে ভেঙে দেখুন।

নিজে বাড়ান:

  1. -O2 দিয়ে compile করে ভাঙুন: setjmp-longjmp-এর মাঝে modify হওয়া local variable volatile না হলে UB — documentation-এর কোন বাক্যটা কাজ করছে?
  2. Exception type system যোগ করুন: THROW(code) আর CATCH_IF(code_eq) — personality routine-এর type-matching-এর হাতে-লেখা রূপ।
  3. strace ./mini_exc চালান — পুরো machinery-তে একটা syscall-ও নেই; সব user-space registers আর memory-তে।

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

Exception machinery production-এ যেখানে দেখা যায়

Game engines আর HFT। Unreal Engine আর বেশিরভাগ console engine -fno-exceptions-এ build হয়: frame budget deterministic রাখতে throw-এর µs-scale spike অগ্রহ্য; binary size আর compile-time বাড়তি কারণ।

Chrome/Chromium। বহু বছর exceptions disabled — Result-স্টাইল error handling; বিশাল codebase-এ exception-safety audit অসম্ভব, আর unwinding-এর hidden control flow প্রতিটা লাইনের অর্থ বদলে দেয়।

Embedded/ARM। ARM EABI-র নিজস্ব unwind format (EHABI) — একই দর্শন, ভিন্ন encoding; flash-size-sensitive target-এ -fno-exceptions + -fno-unwind-tables KB-র হাজার বাঁচায়।

JVM আর Python — উল্টো দর্শন। Java-তে exception যথেষ্ট সস্তা (metadata + JIT), control-flow-ish ব্যবহারও চলে। Python এক ধাপ এগিয়ে: StopIteration — generator-এর termination signal নিজেই raised exception; প্রতিটা next() call ভেতরে raise/handle-ই করে। “Error বিরল” assumption-টাই ভাষা ভেদে বদলায়।

Rust ecosystem। Default unwind রেখেও panic = "abort" embedded/WASM target-এ popular; catch_unwind মূলত FFI boundary-তে (C callback-এর ভেতরের Rust panic বাইরে ছিটকে না যাক) আর test harness-এ।

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

“'Zero-cost exceptions' মানে exception ফ্রি।”

Free শুধু happy path-এ। Throw-এ ঘটে: exception object allocation, দুইবার stack walk, প্রতি frame-এ metadata decode, personality callback — একসাথে মাইক্রোসেকেন্ড-স্তর, ordinary function call-এর হাজার গুণ। নামটার পূর্ণ বাক্য: “zero-cost when not thrown”। এই অসমতাই বলে দেয় exception শুধু truly exceptional ঘটনার জন্য।

“try block লিখলেই ওই কোড ধীর হয়ে যায়।”

Table-based মডেলে try কোনো instruction generate করে না — আজকের experiment দেখিয়েছে identical timing। Legacy 32-bit x86 আর setjmp-style implementations-এ সত্যিই per-try cost ছিল, আর সেই যুগের পরামর্শ এখনো folklore হয়ে ঘোরে। Hot loop-এ try রাখা নিয়ে আজকে একমাত্র বাস্তব concern হলো optimizer-এর code-layout সিদ্ধান্ত, মেকানিজম নিজে না।

“Error codes সবসময় exceptions-এর চেয়ে দ্রুত।”

Happy path-এ error-code checking প্রতি call-এ compare+branch যোগ করে — branch predictor well-predicted হলে খরচ ছোট কিন্তু শূন্য না; আর code size বাড়ে, I-cache pressure বাড়ে। Zero-cost exceptions-এ happy path-এ ঠিক শূন্য। Rare-error workload-এ exceptions আসলে দ্রুতই হতে পারে। সিদ্ধান্তের metric performance একা না — propagation ergonomics, type safety, ABI constraint সব ঢোকে।

“Go-র panic/recover C++ exception-এর মতোই — শুধু নাম আলাদা।”

Mechanism আলাদা, philosophy আলাদা। Panic প্রতি-goroutine deferred chain ধরে নামে (shared unwinder/table না), recover শুধু সরাসরি deferred ফাংশনে কাজ করে, আর Go-র API culture panic-কে programmer-error (nil deref, index out of range)-এর জন্য রাখে — expected failure-এ error value। C++-এ বিপরীতে exception হলো expected-failure-র primary channel। তাই Go-তে if err != nil হাজারবার, C++-এ try হাজারবার — দুই community-র cost-model বিশ্বাসের ফল।

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

1

Unwind-এর দুই phase-এর নাম, ক্রম আর কাজ লিখুন। Phase 1-এ stack-এর state বদলায় কি?

স্মরণ

Phase 1 — SEARCH: throw বিন্দু থেকে root frame পর্যন্ত প্রতিটা frame-এর .eh_frame decode করে LSDA পড়া, personality routine দিয়ে handler type-match check; পেলে stop-target নোট। Phase 2 — UNWIND: নিচ থেকে handler frame পর্যন্ত আবার নেমে পথের সব cleanup/destructor চালানো, শেষে landing pad-এ jump। Phase 1-এ state বদলায় না — pure read-only walk; এজন্যই “handler আদৌ আছে?” আগে নিশ্চিত হওয়া যায়, না থাকলে terminate (অর্ধভাঙা stack ছাড়াই)।

2

তিনটা nested function A (catch আছে), B (Guard object), C (Guard object + throw) — C থেকে throw হলে destructor আর catch-এর চূড়ান্ত ক্রম লিখুন। B-তে catch না থাকলেও কি B-র destructor চলে?

প্রয়োগ

ক্রম: C-র Guard destructor (unwind phase 2, সবার নিচের frame আগে) → B-র Guard destructor → A-র catch clause execute। হ্যাঁ, B-র destructor চলবেই — phase 2 প্রতিটা frame-এর cleanup entries দেখে, handler-entry না। Catch clause শুধু handler-frame-এ চলে, আর সেটা সবার শেষে।

3

setjmp/longjmp মডেলে throw তুলনামূলক সস্তা, তবু সব modern ভাষা table-based-এ গেছে। Frequency-asymmetry argument-টা পূর্ণ বাক্যে লিখুন।

যুক্তি

Try-এ ঢোকা আর throw করা — দুই event-এর frequency প্রায় সব প্রোগ্রামে হাজার-গুণ ভিন্ন: প্রতিটা risky operation একটা try দাবি করে (প্রতি request, প্রতি parse — millions/sec), কিন্তু throw বিরল (failures/sec অনেক কম)। Snapshot মডেল দাম বসায় high-frequency event-এ (প্রতি try-এ register-save), table-based মডেল low-frequency event-এ (প্রতি throw-এ table-walk)। খরচ যেখানে rare সেখানে সরানোই optimal — এই amortized reasoning-ই migration-এর কারণ। Counter-case: কিছু Lisp/Scheme runtime যেখানে “exceptional” ঘটনা আসলে frequent (continuation-heavy) — সেখানে snapshot-style আবার যুক্তিসঙ্গত।

4

আপনি MCU firmware লিখছেন (256 KB flash, hard real-time ISR deadline)। Exceptions enable রাখবেন না -fno-exceptions? চারটা স্বাধীন কারণ দিয়ে decision justify করুন।

ডিজাইন

-fno-exceptions: (১) Determinism — throw-এর µs-scale, data-dependent latency ISR deadline ভাঙতে পারে; error path-ও bounded হতে হয়। (২) Size — unwind tables + personality routines + landing pads বাদ দিয়ে কয়েক-KB flash বাঁচে, 256 KB-তে প্রাসঙ্গিক। (৩) Heap — Itanium ABI exception object heap চায়; firmware-এ heap প্রায়ই নিষিদ্ধ (fragmentation, non-determinism)। (৪) Toolchain surface — পুরো call-graph জুড়ে exception-safety guarantee audit করা embedded codebase-এ অবাস্তব; নিষেধাজ্ঞা সরাসরি সম্পত্তিটা দেয়। Compensating pattern: error-code + assert + watchdog, আর critical cleanup-এ scope-guard macro।

5

C++ program একটা C callback ডাকে; callback-এর ভেতর থেকে একটা C++ function call হয় যেটা throw করে, আর কেউ ধরে না — throw-টা C callback-কে পার হয়ে উপরে যেতে চায়। ঠিক কোথায়, কেন সমস্যা?

যুক্তি

C callback-এর frame পার হওয়ার মুহূর্তেই। Phase 1 সেই frame-এ পৌঁছে .eh_frame decode করবে (C-ও সাধারণত টেবিল রাখে — debugger-দের জন্য), কিন্তু LSDA থাকবে না — C compiler কোনো language-specific handler map জানে না। Frame-টা “transparent” ধরে উপরে ওঠা technically possible, কিন্তু semantics undefined: C-র নিজস্ব cleanup expectation নেই, আর ওপাশের C++ caller-এর দৃষ্টিতে মাঝখানে একটা foreign frame “অলসভাবে পার হয়ে গেল” — কোনো standard এই composition define করে না। বাস্তবে ভিন্ন toolchain/linker combination-এ ভিন্ন ফল। তাই ABI নিয়ম: boundary-তে convert (error code) বা abort; Rust-ও 1.81 থেকে extern “C”-এর ভেতরের panic-কে forced-abort করে ঠিক এই গর্ত বন্ধ করেছে।

এরপর কী

এরপর কী

Control flow-র দুইটা চরম আমরা দেখলাম: গণনা কখন হবে (evaluation strategy), আর control কীভাবে উল্টোপথে পালায় (unwinding)। কিন্তু এতক্ষণের সব কাহিনি একটা মাত্র execution thread-এর। এখন সেই প্রশ্ন: একই সমস্যা — শত শত কাজ একসাথে — ভাষারা চার রকম উত্তর দিয়েছে: OS threads + locks, async/await state machines, M:N green threads, আর channels/actors। কোনটা কোথায় জেতে, deadlock-এর class কীভাবে বদলায়, আর একটা cooperative scheduler নিজের হাতে ৮০ লাইনে বানিয়ে interleaving determinism প্রমাণ করা যায় কি না — পরের লেসনে।

পরের লেসন: Concurrency Model — threads, async/await, actors, CSP: একই সমস্যার চার উত্তর।

আরও পড়ুন

  • Itanium C++ ABI — Exception Handling sections — Christophe de Dinechin (editor), Intel et al. · Zero-cost EH, two-phase unwind, LSDA আর personality routine-এর primary specification
  • Programming Language Pragmatics, Exception Handling chapters — Michael L. Scott · setjmp/longjmp বনাম table-based model-এর তুলনামূলক survey
  • The DWARF Debugging Standard — Call Frame Information — DWARF Standards Committee · .eh_frame যে DWARF CFI format-এর ভিত্তিতে দাঁড়িয়ে তার spec
  • Go Blog — Defer, Panic, and Recover — Andrew Gerrand, The Go Blog · Go-র panic/recover semantics-এর canonical বর্ণনা