Foundationপ্রথম নীতি থেকে
LEVEL 4লেসন ২৬/২৯অ্যাডভান্সড১ ঘণ্টা ২০ মিনিট

Race Condition ও Deadlock — যখন সমান্তরালতা ভুল হয়ে যায়

Race Conditions and Deadlock

গত লেসনে দুইটা প্রশ্ন ইচ্ছাকৃতভাবে খোলা রাখা হয়েছিল — counter++ ঠিক কীভাবে হারায়, আর কেন একাধিক lock বিপজ্জনক। এই লেসনে দুইটাই সমাধান করব। প্রথমে দেখব data race কেন শুধু 'ভুল উত্তর' নয়, বরং compiler-কে অদ্ভুত আচরণ করার অনুমতি দেওয়া একটা undefined behaviour। তারপর deadlock-কে দেখব ঠিক যেভাবে Level 0-তে শিখেছি — একটা graph-এর cycle — আর দেখব কেন lock-এর উপর একটা total order চাপালেই সেই cycle কাঠামোগতভাবে অসম্ভব হয়ে যায়।

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

  • Data race আর race condition-এর মধ্যে নির্ভুল পার্থক্য করতে পারবেন — একটা TOCTOU bug data race ছাড়াই ঘটতে পারে তা উদাহরণ দিয়ে দেখাতে পারবেন
  • কেন `counter++` একটা data race হলে C11/C++11-এ undefined behaviour, শুধু 'ভুল মান' নয় — তা ব্যাখ্যা করতে পারবেন
  • একটা TOCTOU vulnerability চিনতে ও ঠিক করতে পারবেন (openat, O_NOFOLLOW ব্যবহার করে)
  • Coffman-এর চারটা শর্ত তালিকাভুক্ত করতে পারবেন এবং যেকোনো একটা ভাঙলে deadlock কেন অসম্ভব হয় তা যুক্তি দিতে পারবেন
  • Deadlock-কে resource allocation graph-এর একটা cycle হিসেবে দেখতে পারবেন, Level 0-এর relations লেসনের সাথে সরাসরি সংযোগ করে
  • Livelock, starvation, আর deadlock-কে আলাদা করতে পারবেন, আর ThreadSanitizer দিয়ে একটা real race ধরতে পারবেন

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

আগে এটা বুঝি

গত লেসনের শেষে দুইটা প্রশ্ন রেখে এসেছিলাম।

প্রথমটা: কেন counter++ -এর মতো এক লাইনের কোড হারিয়ে যায় যখন দুইটা thread একসাথে সেটা চালায়? “তিনটা instruction” বলে পাশ কাটিয়ে গিয়েছিলাম — আসল গল্প আরো গভীর।

দ্বিতীয়টা, আরো বড়: বারবার বলা হয়েছে “একাধিক lock নিলে বিপদ”, “সবসময় একই ক্রমে নিন” — কিন্তু কেন, আর কীভাবে সেটা নিশ্চিত করবেন?

আজ দুইটাই সমাধান করব। প্রথমটা নিয়ে যাব হার্ডওয়্যার আর compiler-এর সীমানায়। দ্বিতীয়টা নিয়ে ফিরে যাব Level 0-এর mathematics/relations লেসনে — কারণ deadlock আসলে নতুন কিছু না, এটা একটা গ্রাফের cycle, আর আপনি সেই গণিত ইতিমধ্যে শিখে ফেলেছেন।

মূল ধারণা

Data race বনাম race condition — এক নয়

এই দুইটা শব্দ প্রায়ই সমার্থক হিসেবে ব্যবহৃত হয়, কিন্তু তারা নয়

Data race: দুইটা thread একই memory location-এ একসাথে access করছে, অন্তত একটা write, আর কোনো synchronization নেই তাদের মধ্যে।

Race condition: ফলাফল timing-এর উপর নির্ভর করছে — কোন thread আগে চলল তার উপর প্রোগ্রামের সঠিকতা নির্ভরশীল।

একটা race condition data race ছাড়াই ঘটতে পারে:

if (access("secret.txt", R_OK) == 0) {   /* চেক ১ */
    FILE *f = fopen("secret.txt", "r");  /* চেক ২ */
    /* ... */
}

এখানে কোনো shared memory নেই, কোনো দুইটা thread একই variable ছুঁচ্ছে না — তাই কোনো data race নেই। কিন্তু access() আর fopen()-এর মাঝখানে যদি কেউ secret.txt-কে একটা symlink দিয়ে বদলে দেয় (/etc/shadow-এর দিকে নির্দেশ করা), আপনি এমন একটা ফাইল খুলবেন যা check করা ফাইল থেকে আলাদা

এটাই TOCTOU (Time-Of-Check to Time-Of-Use) — একটা race condition, কিন্তু single-threaded প্রোগ্রামেও ঘটতে পারে (attacker একটা আলাদা process থেকে filesystem বদলে দিচ্ছে)।

counter++ কেন হারায় — তিনটা instruction, আসল গল্প

counter++;

Assembly-তে (সরলীকৃত):

mov eax, [counter]    ; পড়ো
add eax, 1             ; বাড়াও
mov [counter], eax    ; লেখো

দুইটা thread একসাথে চালালে:

Thread A: mov eax, [counter]    ; eax = 5
Thread B: mov eax, [counter]    ; eax = 5 (এখনো লেখা হয়নি!)
Thread A: add eax, 1             ; eax = 6
Thread B: add eax, 1             ; eax = 6
Thread A: mov [counter], eax    ; counter = 6
Thread B: mov [counter], eax    ; counter = 6

দুইটা increment হলো, কিন্তু counter 5 থেকে 6-এ গেল — একটা হারিয়ে গেল

Undefined behaviour — এটা শুধু ‘ভুল উত্তর’ নয়

এখানেই বেশিরভাগ ব্যাখ্যা থেমে যায়, কিন্তু C11/C++11-এ গল্পটা আরো গভীর।

Data race থাকা মানে শুধু “একটা increment হারাতে পারে” নয় — মানে আপনার প্রোগ্রামের সম্পূর্ণ আচরণ সংজ্ঞায়িত নয়।

Standard আক্ষরিক অর্থেই বলে: যদি একটা data race ঘটে, প্রোগ্রামের আচরণ undefined। এর মানে compiler এমন optimization করতে পারে যা দেখলে মনে হবে hardware-এর নিয়ম ভাঙছে:

int flag = 0;

void worker(void) {
    while (!flag) { /* অপেক্ষা */ }
    printf("flag সত্য হলো!\n");
}

void setter(void) {
    sleep(1);
    flag = 1;
}

flag যদি volatile বা atomic না হয়, compiler দেখতে পারে worker()-এর ভেতরে flag কখনো এই thread-এর মধ্যে বদলায় না (কোনো write নেই এই function-এ) — তাই সে ধরে নিতে পারে flag সবসময়ই false, আর পুরো loop-টাকে একটা infinite loop-এ optimize করে ফেলতে পারে, flag-কে register-এ একবার পড়ে memory আর কখনো না দেখেই।

সঠিক সমাধান counter++-এর জন্য:

#include <stdatomic.h>
atomic_int counter = 0;
atomic_fetch_add(&counter, 1);   /* একটা atomic instruction, race নেই */

অথবা mutex দিয়ে (গত লেসনের হাতিয়ার):

pthread_mutex_lock(&m);
counter++;
pthread_mutex_unlock(&m);

দুইটাই কাজ করে, কিন্তু কারণ ভিন্ন — atomic hardware-স্তরে একটা অবিভাজ্য operation নিশ্চিত করে (লেসন ২৮-এ বিস্তারিত); mutex পুরো critical section-কে serialize করে।

Deadlock — Coffman-এর চারটা শর্ত

Deadlock ঘটে যখন দুই বা ততোধিক thread একে অপরের ধরে রাখা resource-এর জন্য চিরকাল অপেক্ষা করে।

/* Thread 1 */                    /* Thread 2 */
lock(&A);                          lock(&B);
lock(&B);  /* B-এর জন্য অপেক্ষা */  lock(&A);  /* A-এর জন্য অপেক্ষা */

Thread 1 ধরে আছে A, চায় B। Thread 2 ধরে আছে B, চায় A। কেউ কখনো এগোতে পারবে না — এটাই ABBA deadlock, নাম পড়ার ক্রম অনুযায়ী।

Coffman-এর ১৯৭১-এর পেপার প্রমাণ করে: deadlock ঘটতে হলে এই চারটা শর্তই একসাথে সত্য হতে হবে:

শর্তমানে
Mutual exclusionএকটা resource একবারে শুধু একজন ধরে রাখতে পারে
Hold and waitএকটা thread resource ধরে রেখেই আরেকটার জন্য অপেক্ষা করে
No preemptionকারো কাছ থেকে জোর করে resource কেড়ে নেওয়া যায় না
Circular waitthread-দের একটা চক্র, প্রতিটা পরেরটার resource-এর জন্য অপেক্ষা করছে

সবচেয়ে গুরুত্বপূর্ণ পরিণতি: যেকোনো একটা শর্ত ভাঙলেই deadlock অসম্ভব। চারটার মধ্যে circular wait ভাঙা সবচেয়ে ব্যবহারিক — আর সেটাই পরের বিভাগের বিষয়।

ভেতরে কী ঘটছে

Deadlock একটা graph-এর cycle — Level 0-এ ফিরে যাওয়া

Level 0-এর mathematics/relations লেসনে আমরা শিখেছি একটা directed graph-এ topological sort সম্ভব যদি এবং কেবল যদি সেটা acyclic। আর একটা partial order-এ antisymmetry ভাঙলে (A → B আর B → A দুটোই) সেটা আর সাজানো যায় না।

Deadlock ঠিক এই একই গাণিতিক বস্তু, শুধু ভিন্ন নাম দিয়ে:

Thread-দের আর lock-দের একটা directed graph বানান:

  T1 ──holds──→ A       T1 ──waits for──→ B
  T2 ──holds──→ B       T2 ──waits for──→ A

মিলিয়ে আঁকলে:

       holds            holds
  A ◄──────── T1        B ◄──────── T2
       waits             waits
  A ────────► T2        B ────────► T1

চক্র: T1 → B → T2 → A → T1
Resource allocation graph — thread আর lock দুটোই node, দুই ধরনের edge। Deadlock মানে এই গ্রাফে একটা cycle।

Deadlock detection মানে এই গ্রাফে cycle খোঁজা — ঠিক Level 0 আর Level 6-এ যে DFS-ভিত্তিক cycle detection শেখা হয়েছে (বা হবে), সেই একই অ্যালগরিদম।

Deadlock prevention-এর সবচেয়ে ব্যবহারিক কৌশল — lock-এর উপর একটা total order চাপানো:

/* নিয়ম: সবসময় নিচু address-এর lock আগে নাও */
void safe_transfer(mutex_t *a, mutex_t *b) {
    mutex_t *first  = (a < b) ? a : b;
    mutex_t *second = (a < b) ? b : a;
    lock(first);
    lock(second);
    /* ... */
    unlock(second);
    unlock(first);
}

Total order মানে প্রতিটা lock-এর একটা অনন্য rank আছে (এখানে, তাদের memory address), আর নিয়ম হলো সবসময় কম rank-এর lock আগে। এখন Thread 1 আর Thread 2 — দুজনেই একই ক্রমে lock নেবে (A তারপর B, address যাই হোক), তাই circular wait গঠনগতভাবে অসম্ভব

Livelock আর starvation — deadlock নয়, কিন্তু কাছাকাছি

Livelock: থ্রেড ব্লক নয়, কিন্তু কোনো অগ্রগতি নেই — উভয়েই সক্রিয়ভাবে কিছু করছে, শুধু একে অপরকে বাধা দিয়ে যাচ্ছে।

দুইজন মানুষ একটা সরু করিডোরে মুখোমুখি — দুজনেই ভদ্রতা করে
একই দিকে সরে যায়, বারবার, কখনো পার হতে পারে না

Starvation: একটা thread কখনো resource পায় না, যদিও deadlock নেই — অন্য thread-রা ক্রমাগত সেটা আগে দখল করে ফেলছে (scheduler-এর অন্যায্য priority, বা lock নেওয়ার fairness-হীন নীতি)।

তিনটাই আলাদা রোগ, ভিন্ন লক্ষণ:

অগ্রগতি হচ্ছে?Thread-এর অবস্থা
Deadlockনা, কখনোই নাBlocked, চিরকাল
Livelockনা, কিন্তু কাজ হচ্ছেRunning, কিন্তু কার্যকর অগ্রগতি নেই
Starvationএকটা thread-এর জন্য নাRunnable, কিন্তু বারবার পিছিয়ে যাচ্ছে

Detection বনাম prevention বনাম avoidance

Prevention — Coffman-এর একটা শর্ত গঠনগতভাবে ভেঙে দেওয়া (যেমন lock ordering)। সবচেয়ে ব্যবহারিক, এই লেসনের মূল কৌশল।

Avoidance — runtime-এ প্রতিটা resource request পরীক্ষা করে দেখা সেটা একটা “নিরাপদ অবস্থায়” নিয়ে যাবে কি না। Banker’s algorithm এর ক্লাসিক উদাহরণ — প্রতিটা thread-এর সর্বোচ্চ resource প্রয়োজন আগে থেকে জানা থাকতে হয়।

উদাহরণ

একটা সম্পূর্ণ ABBA deadlock — ধরে ধরে দেখা

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

pthread_mutex_t A = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t B = PTHREAD_MUTEX_INITIALIZER;

void *thread1(void *arg) {
    pthread_mutex_lock(&A);
    printf("T1: A ধরলাম\n");
    sleep(1);                       /* T2-কেও B ধরার সময় দিচ্ছে */
    printf("T1: B-র জন্য অপেক্ষা করছি\n");
    pthread_mutex_lock(&B);
    printf("T1: B-ও পেলাম\n");      /* এখানে কখনো পৌঁছাবে না */
    pthread_mutex_unlock(&B);
    pthread_mutex_unlock(&A);
    return NULL;
}

void *thread2(void *arg) {
    pthread_mutex_lock(&B);
    printf("T2: B ধরলাম\n");
    sleep(1);
    printf("T2: A-র জন্য অপেক্ষা করছি\n");
    pthread_mutex_lock(&A);
    printf("T2: A-ও পেলাম\n");      /* এখানেও না */
    pthread_mutex_unlock(&A);
    pthread_mutex_unlock(&B);
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, thread1, NULL);
    pthread_create(&t2, NULL, thread2, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}
gcc -pthread -g -o deadlock deadlock.c && ./deadlock

Output-এ দেখবেন T1: A ধরলাম, T2: B ধরলাম, তারপর দুইটা “অপেক্ষা করছি” বার্তা — আর তারপর কিছুই না। প্রোগ্রাম চিরকাল ঝুলে থাকে। Ctrl-C দিয়ে থামান।

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

EXPERIMENT

gdb দিয়ে একটা live deadlock ধরা

Linux, gdb· ১৫ মিনিট
gcc -pthread -g -o deadlock deadlock.c
./deadlock &
sleep 2          # নিশ্চিত হতে দিন যে deadlock ঘটে গেছে
gdb -p $!

gdb-এর ভেতরে:

(gdb) info threads
  Id   Target Id                    Frame
  3    Thread 0x... (LWP ...)       __lll_lock_wait ()
  2    Thread 0x... (LWP ...)       __lll_lock_wait ()

(gdb) thread 2
(gdb) bt
#0  __lll_lock_wait ()
#1  pthread_mutex_lock ()
#2  thread1 (arg=...) at deadlock.c:14

(gdb) thread 3
(gdb) bt
#0  __lll_lock_wait ()
#1  pthread_mutex_lock ()
#2  thread2 (arg=...) at deadlock.c:24

__lll_lock_wait — দুইটা thread-ই সেখানে আটকে, আর backtrace স্পষ্ট বলছে thread1 line 14-এ (lock(&B)) আটকে, thread2 line 24-এ (lock(&A))। এখান থেকে সরাসরি কোড দেখে বোঝা যায় কোন lock কোনটার জন্য অপেক্ষা করছে — ঠিক আজকের figure-এর diagram-টাই বাস্তবে দেখা।

Kernel-স্তরেও একই তথ্য পাওয়া যায়, root ছাড়া:

for tid in /proc/$!/task/*; do
    echo "=== $tid ==="
    cat $tid/stack 2>/dev/null || cat $tid/wchan
done

kill $! (বা Ctrl-C) দিয়ে পরিষ্কার করুন।

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

Deadlock গায়েবি কিছু নয় — প্রতিটা আটকে থাকা thread ঠিক কোন lock-এর জন্য অপেক্ষা করছে তা debugger দিয়ে সরাসরি দেখা যায়।

EXPERIMENT

ThreadSanitizer দিয়ে একটা race একটামাত্র রান থেকে ধরা

Linux/macOS, clang বা gcc· ১৫ মিনিট
#include <pthread.h>
#include <stdio.h>

int counter = 0;   /* ইচ্ছাকৃতভাবে atomic নয় */

void *incrementer(void *arg) {
    for (int i = 0; i < 100000; i++)
        counter++;      /* data race — কোনো lock নেই */
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, incrementer, NULL);
    pthread_create(&t2, NULL, incrementer, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("counter = %d (আশা করেছিলাম 200000)\n", counter);
    return 0;
}
gcc -pthread -fsanitize=thread -g -o race_tsan race.c
./race_tsan

Output-এর একটা অংশ:

==================
WARNING: ThreadSanitizer: data race (pid=12345)
  Write of size 4 at 0x... by thread T2:
    #0 incrementer race.c:8

  Previous write of size 4 at 0x... by thread T1:
    #0 incrementer race.c:8

  Location is global 'counter' of size 4 at 0x...
==================
counter = 143829 (আশা করেছিলাম 200000)

ThreadSanitizer ঠিক কোন লাইনে, কোন দুইটা thread-এর মধ্যে race ঘটেছে তা সরাসরি বলে দেয় — সাধারণ ভাবে চালালে (gcc -pthread -o race race.c) হয়তো ২০-৩০% সময় সঠিক উত্তর আসতেও পারে (timing-নির্ভর), কিন্তু TSan প্রতিবারই race-টা চিহ্নিত করবে, সেই run-এ ভুল উত্তর এসেছে কি না তার উপর নির্ভর না করে।

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

একটা data race প্রতিবার ভুল ফল নাও দিতে পারে (timing-নির্ভর) — কিন্তু ThreadSanitizer একটামাত্র execution দেখেই race-টা নিশ্চিতভাবে ধরতে পারে, কারণ এটা happens-before সম্পর্ক ট্র্যাক করে, শুধু ফলাফল দেখে না।

নিজে বানান

BUILD IT

একটা Deadlock Detector — lockdep-এর একটা ছোট সংস্করণ

C · ●●●●●
  1. একটা lock wrapper বানান যা আসল mutex-এর সাথে metadata রাখে
  2. প্রতিটা thread-এ "বর্তমানে কোন lock ধরে আছি" তার একটা তালিকা রাখুন
  3. যখনই lock B নেওয়া হয় আর ইতিমধ্যে A ধরা আছে, "A → B" একটা edge রেকর্ড করুন
  4. নতুন edge যোগ করার আগে DFS দিয়ে cycle আছে কি না যাচাই করুন
  5. Cycle পেলে deadlock ঘটার আগেই abort করে দিন
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_LOCKS 32
#define MAX_EDGES 256

typedef struct { const char *name; } tracked_lock_t;

/* গ্রাফ: edges[i][j] সত্য মানে lock i, lock j-এর আগে নেওয়া হয়েছে
   কোনো না কোনো thread-এ কখনো */
static int n_locks = 0;
static tracked_lock_t locks[MAX_LOCKS];
static int edges[MAX_LOCKS][MAX_LOCKS];
static pthread_mutex_t graph_lock = PTHREAD_MUTEX_INITIALIZER;

/* প্রতিটা thread-এর "বর্তমানে ধরে থাকা lock"-এর স্ট্যাক */
static __thread int held_stack[MAX_LOCKS];
static __thread int held_count = 0;

static int register_lock(const char *name) {
    pthread_mutex_lock(&graph_lock);
    int id = n_locks++;
    locks[id].name = name;
    pthread_mutex_unlock(&graph_lock);
    return id;
}

/* DFS — 'to' থেকে 'from'-এ ফিরে আসার পথ আছে কি না (cycle detection) */
static int path_exists(int from, int to, int visited[MAX_LOCKS]) {
    if (from == to) return 1;
    visited[from] = 1;
    for (int i = 0; i < n_locks; i++)
        if (edges[from][i] && !visited[i])
            if (path_exists(i, to, visited)) return 1;
    return 0;
}

static void tracked_lock(pthread_mutex_t *mu, int lock_id) {
    pthread_mutex_lock(&graph_lock);

    /* বর্তমানে ধরে থাকা প্রতিটা lock থেকে এই নতুনটায় edge যোগ করার
       আগে যাচাই করি — এই নতুন edge কি একটা cycle তৈরি করবে? */
    for (int i = 0; i < held_count; i++) {
        int held = held_stack[i];
        if (held == lock_id) continue;

        int visited[MAX_LOCKS] = {0};
        if (path_exists(lock_id, held, visited)) {
            fprintf(stderr,
                "\n*** DEADLOCK ঝুঁকি ধরা পড়েছে ***\n"
                "  '%s' ধরে '%s' নেওয়ার চেষ্টা — কিন্তু '%s' → ... → '%s' "
                "পথ ইতিমধ্যে গ্রাফে আছে!\n"
                "  এই ক্রম মেনে চললে ভবিষ্যতে ABBA deadlock অনিবার্য।\n\n",
                locks[held].name, locks[lock_id].name,
                locks[lock_id].name, locks[held].name);
            pthread_mutex_unlock(&graph_lock);
            abort();
        }
        edges[held][lock_id] = 1;   /* নতুন নির্ভরতা রেকর্ড */
    }

    pthread_mutex_unlock(&graph_lock);

    pthread_mutex_lock(mu);
    held_stack[held_count++] = lock_id;
}

static void tracked_unlock(pthread_mutex_t *mu, int lock_id) {
    pthread_mutex_unlock(mu);
    for (int i = 0; i < held_count; i++)
        if (held_stack[i] == lock_id) {
            memmove(&held_stack[i], &held_stack[i+1],
                    (held_count - i - 1) * sizeof(int));
            held_count--;
            break;
        }
}

/* ── পরীক্ষা ────────────────────────────────────────────────── */
static pthread_mutex_t A_mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t B_mu = PTHREAD_MUTEX_INITIALIZER;
static int A_id, B_id;

void *safe_order(void *arg) {
    tracked_lock(&A_mu, A_id);
    tracked_lock(&B_mu, B_id);
    printf("thread %ld: A তারপর B — নিরাপদ\n", (long)arg);
    tracked_unlock(&B_mu, B_id);
    tracked_unlock(&A_mu, A_id);
    return NULL;
}

void *dangerous_order(void *arg) {
    tracked_lock(&B_mu, B_id);
    printf("thread %ld: B ধরলাম, এখন A নেব — এটাই ধরা পড়বে\n", (long)arg);
    tracked_lock(&A_mu, A_id);   /* এখানে abort হবে */
    tracked_unlock(&A_mu, A_id);
    tracked_unlock(&B_mu, B_id);
    return NULL;
}

int main(void) {
    A_id = register_lock("A");
    B_id = register_lock("B");

    pthread_t t1;
    pthread_create(&t1, NULL, safe_order, (void *)1);
    pthread_join(t1, NULL);

    pthread_t t2;
    pthread_create(&t2, NULL, dangerous_order, (void *)2);
    pthread_join(t2, NULL);

    return 0;
}
gcc -pthread -o detector detector.c && ./detector

Output:

thread 1: A তারপর B — নিরাপদ

*** DEADLOCK ঝুঁকি ধরা পড়েছে ***
  'B' ধরে 'A' নেওয়ার চেষ্টা — কিন্তু 'A' → ... → 'B' পথ ইতিমধ্যে গ্রাফে আছে!
  এই ক্রম মেনে চললে ভবিষ্যতে ABBA deadlock অনিবার্য।

Aborted (core dumped)

লক্ষ্য করুন — কোনো প্রকৃত deadlock ঘটেনি। প্রথম thread নিরাপদ ক্রমে A → B নিয়েছিল, সেই তথ্য গ্রাফে রয়ে গেছে। দ্বিতীয় thread যখন B → A নিতে গেল, detector দেখল এটা একটা বিপরীত-দিকের edge — যোগ করলে cycle তৈরি হবে — আর deadlock ঘটার আগেই থামিয়ে দিল।

এটাই ঠিক Linux kernel-এর lockdep-এর মূল নীতি — যদিও lockdep অনেক বেশি সংস্করণ-সংবেদনশীল ও optimized। প্রতিটা kernel lock acquisition এভাবে ট্র্যাক করা হয়, আর একটা সম্ভাব্য ক্রম-বিরোধ পাওয়া গেলে kernel log-এ একটা warning আসে — bug production-এ পৌঁছানোর আগেই।

নিজে বাড়ান:

  1. Cycle-এর সম্পূর্ণ পথ রিপোর্ট করুন (শুধু “cycle আছে” না, ঠিক কোন lock-গুলো কোন ক্রমে)
  2. abort()-এর বদলে একটা global flag সেট করে চালিয়ে যান (production-এ crash না করে শুধু log করা)
  3. Read-write lock-এর জন্য আলাদা ট্র্যাকিং যোগ করুন — দুইটা reader একে অপরকে block করে না, তাই তাদের মধ্যে edge দরকার নেই
  4. pthread_mutex_t-কে সরাসরি wrap না করে, LD_PRELOAD দিয়ে pthread_mutex_lock/unlock intercept করুন — তাহলে বিদ্যমান কোনো প্রোগ্রাম, কোনো কোড পরিবর্তন ছাড়াই, এই detector দিয়ে পরীক্ষা করা যাবে

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

Race আর deadlock যেখানে সরাসরি বিপর্যয় ঘটিয়েছে

Therac-25 (১৯৮৫-৮৭)। একটা radiation therapy machine-এ একটা race condition-এর কারণে কমপক্ষে ৬ জন রোগী মারাত্মক overdose পেয়েছিলেন — একটা নির্দিষ্ট দ্রুত key-sequence UI state-কে এমন একটা অবস্থায় নিয়ে যেত যা safety check এড়িয়ে যেত। এটা software race condition-এর সবচেয়ে trag ঘটনাগুলোর একটা, আর আজও software safety engineering-এ পড়ানো হয়।

Linux kernel-এর lockdep ঠিক আজকের build-এর সম্পূর্ণ production সংস্করণ — প্রতিটা kernel lock acquisition ট্র্যাক করে, সম্ভাব্য deadlock pattern ধরে (এমনকি যদি বাস্তবে কখনো সেই নির্দিষ্ট timing না ঘটে), আর kernel developer-দের সবচেয়ে বেশি ব্যবহৃত debugging tool-গুলোর একটা।

Database-এর deadlock detection। PostgreSQL, MySQL — দুটোই runtime-এ transaction-দের মধ্যে wait-for graph বানায়, cycle পেলে একটা transaction-কে জোর করে rollback করে দেয় (deadlock detected error) — এটাই Coffman-এর “no preemption” শর্ত ভাঙার একটা বাস্তব প্রয়োগ, কারণ database নিজেই একটা resource জোর করে কেড়ে নিচ্ছে।

Android-এর ANR (Application Not Responding)। UI thread যদি একটা lock-এর জন্য অনেকক্ষণ block হয়ে থাকে (প্রায়ই একটা background thread-এর সাথে lock-order সমস্যা), OS একটা dialog দেখায় — এটা deadlock না হলেও, একই মূল কারণের পরিবারভুক্ত সমস্যা।

Git-এর file locking। .git/index.lock একটা advisory lock — যদি Git crash করে বা জোর করে kill হয়, এই lock file থেকে যায়, আর পরের git command “Another git process seems to be running” বলে আটকে যায়। এটা deadlock না হলেও একই পরিবারের সমস্যা — একটা resource যেটা properly release হয়নি।

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

“আমার প্রোগ্রাম শত শত বার সঠিক ফলাফল দিয়েছে, তাই এতে কোনো race condition নেই।”

এটা Level 0-এর counterexample-সংক্রান্ত পাঠের সরাসরি প্রয়োগ — testing দেখাতে পারে bug আছে, দেখাতে পারে না bug নেই।

একটা race condition-এর প্রকাশ পাওয়া নির্ভর করে খুব সূক্ষ্ম timing-এর উপর — কোন thread কখন scheduled হবে, cache-এর অবস্থা কী, memory ordering hardware কীভাবে reorder করছে। একটা মেশিনে, একটা load-এ, ১০০০ বার চালিয়ে কখনো প্রকাশ না পাওয়া মানে এই নয় যে সেটা নেই — মানে এই নির্দিষ্ট পরিস্থিতিতে timing window টা যথেষ্ট সরু ছিল।

Production-এ ভিন্ন hardware (বেশি core, ভিন্ন cache latency), ভিন্ন load (বেশি contention), বা এমনকি একটা কম্পাইলার আপগ্রেড (ভিন্ন optimization, ভিন্ন instruction timing) সেই সরু জানালাটা খুলে দিতে পারে।

এই কারণেই ThreadSanitizer এত মূল্যবান — এটা টাইমিং-নির্ভর নয়, এটা happens-before সম্পর্ক বিশ্লেষণ করে গাণিতিকভাবে প্রমাণ করে একটা race সম্ভব কি না, একটামাত্র execution দেখেই। Production deploy করার আগে TSan-এর অধীনে test suite চালানো একটা industry-standard practice, শুধু ঐচ্ছিক সতর্কতা নয়।

“Deadlock এড়াতে যথেষ্ট সতর্ক থাকাই যথেষ্ট — কোড রিভিউ দিয়ে ধরা যায়।”

ছোট কোডবেসে সম্ভব, কিন্তু বাস্তব সিস্টেমে lock ordering violation সাধারণত দুইটা আলাদা, দূরবর্তী function-এর মধ্যে ঘটে যারা কেউই জানে না অন্যটার অস্তিত্ব — একটা module A তারপর B নেয় একটা code path-এ, আরেকটা সম্পূর্ণ ভিন্ন module B তারপর A নেয় আরেকটা path-এ, আর কোনো একক ডেভেলপার পুরো ছবিটা দেখেন না।

এটাই ঠিক কারণ যে Linux kernel-এর মতো বিশাল codebase-এ lockdep-এর মতো runtime tool আছে — কোনো মানুষ কয়েক কোটি লাইন কোড জুড়ে সব lock-ordering সম্পর্ক মনে রাখতে পারে না। Tool গ্রাফটা runtime-এ বানায়, আর প্রতিটা নতুন edge যোগ করার সময় cycle-check করে — আজকের build ঠিক এই ধারণারই একটা ছোট সংস্করণ।

ব্যবহারিক পরামর্শ: যেকোনো সিস্টেমে যেখানে একাধিক lock আছে আর একাধিক ডেভেলপার কাজ করছেন, একটা lock-ordering discipline ডকুমেন্ট করুন (যেমন “সবসময় UserLock তারপর SessionLock”), আর যেখানে সম্ভব একটা runtime checker (Rust-এর borrow checker যা compile-time-এ কিছু race prevent করে, বা TSan/lockdep-জাতীয় tool) ব্যবহার করুন — শুধু discipline-এর উপর নির্ভর করবেন না।

“একটা mutex নিলেই race condition এড়ানো যায়।”

একটা mutex শুধু সেই critical section-কে race-মুক্ত করে যা সেই নির্দিষ্ট mutex দিয়ে সুরক্ষিত। যদি একই ডেটা দুইটা ভিন্ন জায়গায় দুইটা ভিন্ন mutex দিয়ে (বা কোথাও কোনো mutex ছাড়াই) access হয়, race তখনো ঘটতে পারে।

pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER;
int shared_value = 0;

void writer(void) {
    pthread_mutex_lock(&lock_a);
    shared_value = 42;
    pthread_mutex_unlock(&lock_a);
}

void reader(void) {
    printf("%d\n", shared_value);   /* ⚠ কোনো lock ছাড়াই পড়ছে! */
}

writer() সঠিকভাবে lock ব্যবহার করছে, কিন্তু reader() করছে না — তাদের মধ্যে এখনো একটা data race আছে, কারণ উভয় পক্ষকেই একই lock discipline মানতে হয়।

নিয়ম: একটা shared variable-এর প্রতিটা access (read হোক বা write) একই lock দিয়ে সুরক্ষিত হতে হবে — একটামাত্র unprotected access পুরো নিরাপত্তা ভেঙে দেয়। এই কারণেই বড় codebase-এ প্রতিটা shared variable-এর সাথে একটা স্পষ্ট মন্তব্য রাখা ভালো অভ্যাস: “shared_value কে lock_a সুরক্ষা দেয়” — যাতে পরের কেউ এই নিয়ম ভুলে না যায়।

“Lock ordering মানলে সব ধরনের deadlock এড়ানো যায়।”

Lock ordering circular wait শর্তটা ভাঙে, যা lock-ভিত্তিক deadlock-এর সবচেয়ে সাধারণ কারণ — কিন্তু deadlock অন্য উপায়েও ঘটতে পারে যেখানে lock ordering প্রযোজ্য নয়।

উদাহরণ — thread pool deadlock: একটা fixed-size thread pool (বলুন ৪টা thread) যেখানে প্রতিটা task আরেকটা task submit করে আর তার ফলাফলের জন্য অপেক্ষা করে। যদি ৪টা task-ই একসাথে নতুন task submit করে আর সেই নতুন task-গুলো চালানোর জন্য কোনো thread খালি না থাকে (সবাই অপেক্ষায়), পুরো pool আটকে যায় — এখানে কোনো “lock” জড়িত নেই, তবু এটা কার্যকরভাবে একটা deadlock (resource = thread pool slot, hold-and-wait = task নিজের thread ধরে রেখে নতুন thread-এর জন্য অপেক্ষা করছে)।

সমাধান এখানে ভিন্ন: lock ordering নয়, বরং unbounded thread pool ব্যবহার করা, বা task submission-কে non-blocking করা (async callback), বা আলাদা pool রাখা “producer” আর “worker” task-এর জন্য।

শিক্ষা: Coffman-এর চারটা শর্ত শুধু lock-এর জন্য নয় — যেকোনো resource (lock, thread, connection pool slot, file descriptor) hold-and-wait প্যাটার্নে আটকাতে পারে। প্রতিটা ক্ষেত্রে সঠিক শর্ত ভাঙার কৌশল ভিন্ন হতে পারে।

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

1

প্রমাণ করুন — Coffman-এর চারটা শর্তের যেকোনো একটা ভাঙলেই deadlock অসম্ভব হয়ে যায়, প্রতিটা শর্তের জন্য আলাদাভাবে যুক্তি দিয়ে।

যুক্তি

Mutual exclusion ভাঙলে: যদি একটা resource একসাথে একাধিক thread ব্যবহার করতে পারে (যেমন read-only ডেটা, বা একটা semaphore যার count > 1), তাহলে কোনো thread-কে সেই resource-এর জন্য চিরকাল অপেক্ষা করতে হয় না — সে যখনই চায় (capacity থাকা পর্যন্ত) resource পেতে পারে। Hold-and-wait ঘটতেই পারে না যদি wait-এর কোনো কারণই না থাকে।

Hold-and-wait ভাঙলে: যদি একটা thread একবারে সব প্রয়োজনীয় resource চায় (একসাথে, atomically, “সব পাবো নাহলে কিছুই না”), তাহলে সে কখনো একটা resource ধরে রেখে আরেকটার জন্য অপেক্ষা করে না। হয় সে সাথে সাথে সব পায় (progress), নয় কিছুই পায় না (কিন্তু তখন সে কিছুই ধরেও নেই, তাই কারো পথে বাধা দিচ্ছে না)।

/* একবারে সব lock নেওয়া — trylock দিয়ে */
if (pthread_mutex_trylock(&A) == 0) {
    if (pthread_mutex_trylock(&B) == 0) {
        /* কাজ করুন */
        pthread_mutex_unlock(&B);
    }
    pthread_mutex_unlock(&A);
}
/* B না পেলে A-ও ছেড়ে দিন, retry করুন — কখনো ধরে রেখে অপেক্ষা নয় */

No preemption ভাঙলে: যদি একটা thread-এর কাছ থেকে জোর করে resource কেড়ে নেওয়া যায় (একটা timeout-এর পর, বা একটা higher- priority thread-এর জন্য), তাহলে একটা “চিরকাল অপেক্ষা” অবস্থা টিকতে পারে না — এক পর্যায়ে সিস্টেম হস্তক্ষেপ করে অবস্থা ভেঙে দেবে। Database-এর deadlock detector ঠিক এটাই করে — একটা transaction-কে জোর করে rollback করে তার lock ছেড়ে দিতে বাধ্য করে।

Circular wait ভাঙলে (এই লেসনের মূল কৌশল): যদি সব resource request একটা total order মেনে চলে (সবসময় নিচু-rank আগে), তাহলে wait-for graph-এ প্রতিটা edge শুধু “কম rank → বেশি rank” দিকে যায়। একটা directed graph যেখানে প্রতিটা edge একটা strictly increasing quantity-র দিকে যায়, সেখানে cycle থাকতে পারে না — কারণ cycle-এ ফিরে আসতে হলে কোনো এক পর্যায়ে “বেশি → কম” যেতে হবে, যা নিয়ম ভাঙে। এটাই একটা formal প্রমাণ, শুধু অন্তর্জ্ঞান নয় (Level 0-এর well-ordering principle-এর সরাসরি প্রয়োগ)।

সাধারণ প্যাটার্ন: চারটা প্রমাণই একই কাঠামো অনুসরণ করে — প্রতিটাতে দেখানো হয় “শর্তটা ভাঙলে, deadlock-এর সংজ্ঞার জন্য প্রয়োজনীয় একটা উপাদান অনুপস্থিত থাকে, তাই deadlock-এর সংজ্ঞাই পূরণ হয় না”। এটাই কেন চারটা শর্ত প্রয়োজনীয় (necessary) — deadlock ঘটতে চারটাই লাগে, একটা কম হলেও চলবে না।

2

নিচের কোডে race condition আছে কি না বিশ্লেষণ করুন। থাকলে সেটা data race, নাকি শুধু race condition (data race ছাড়া)?

int shared_flag = 0;   /* atomic_int নয় */

void writer(void) {
    write_important_data();
    shared_flag = 1;    /* সিগন্যাল: ডেটা রেডি */
}

void reader(void) {
    while (shared_flag == 0) { /* busy wait */ }
    use_important_data();       /* এখানে কি নিরাপদ? */
}
প্রয়োগ

এটা একটা data race, আর এটা একটা race condition-ও — দুইটাই, এবং একটা সূক্ষ্ম কারণে দ্বিতীয় সমস্যাও আছে।

Data race: shared_flag-এ একটা thread write করছে (writer), আরেকটা read করছে (reader), কোনো synchronization primitive (mutex, atomic) ছাড়া। C11-এর সংজ্ঞা অনুযায়ী এটা সরাসরি data race — এবং তাই undefined behaviour

Compiler কী করতে পারে (বাস্তবিক পরিণতি):

reader()-এ compiler দেখতে পারে shared_flag এই function-এর মধ্যে কখনো বদলায় না (কোনো write নেই এই function-এর ভেতরে), তাই সে অনুমান করে নিতে পারে এটা loop-এর বাইরে একবার পড়লেই যথেষ্ট, বারবার memory থেকে পড়ার দরকার নেই — আর পুরো while loop-টাকে হয় একটা infinite loop-এ পরিণত করতে পারে (যদি প্রথম read 0 দেয়), অথবা সম্পূর্ণ বাদ দিতে পারে (যদি অন্য কোনো প্রমাণে মনে করে এটা সবসময় true)। এটা কাল্পনিক নয় — বাস্তব compiler optimization-এ এই ধরনের ঘটনা রিপোর্ট হয়েছে।

দ্বিতীয় সমস্যা, এমনকি যদি atomic_int ব্যবহার করা হতো:

ধরুন shared_flag-কে atomic_int করা হলো। তাহলে data race-টা সমাধান হয়ে যায় — compiler আর এই optimization করতে পারবে না। কিন্তু একটা memory ordering প্রশ্ন থেকে যায়: write_important_data()-এর লেখা কি নিশ্চিতভাবে reader()-এ use_important_data() চালানোর আগে দৃশ্যমান?

Default atomic operation (C11-এ memory_order_seq_cst) এই নিশ্চয়তা দেয় — shared_flag = 1 লেখার আগের সব write “happens-before” সম্পর্কে shared_flag == 1 পড়ার পরের কোডের আগে দৃশ্যমান হবে। কিন্তু যদি কেউ কম কঠোর memory order (memory_order_relaxed) ব্যবহার করত, শুধু flag নিজে সঠিকভাবে দেখা যেত, কিন্তু write_important_data()-এর প্রভাব এখনো পুরনো (stale) দেখা যেতে পারত — যদিও flag বলছে “রেডি”।

সঠিক সমাধান:

#include <stdatomic.h>
atomic_int shared_flag = 0;

void writer(void) {
    write_important_data();
    atomic_store_explicit(&shared_flag, 1, memory_order_release);
}

void reader(void) {
    while (atomic_load_explicit(&shared_flag, memory_order_acquire) == 0) {}
    use_important_data();
}

release/acquire জোড়া নিশ্চিত করে: writer-এর store-এর আগের সব write, reader-এর load-এর পরের সব read-এর কাছে দৃশ্যমান — ঠিক যা দরকার, seq_cst-এর চেয়ে সস্তা।

Level 11-এ আমরা memory ordering আর MESI cache coherence protocol বিস্তারিত দেখব — এই প্রশ্নটার সম্পূর্ণ hardware-স্তরের ব্যাখ্যা সেখানে।

3

আপনার একটা graph database আছে যেখানে একটা transaction একাধিক node lock করতে পারে, যেকোনো ক্রমে (graph traversal-এর প্যাটার্ন অনুযায়ী)। Node-দের উপর একটা fixed total order চাপানো অবাস্তব (millions of node, ক্রমাগত যোগ হচ্ছে)। কীভাবে deadlock এড়াবেন?

ডিজাইন

এখানে “সবসময় lower-address আগে” পদ্ধতিটা প্রযোজ্য (node-এর memory address বা একটা persistent ID দিয়ে rank করা যায়, node সংখ্যা যত বড়ই হোক), কিন্তু আরো তিনটা বাস্তবসম্মত বিকল্প আছে, আর সবচেয়ে ভালো সমাধান সাধারণত একাধিক কৌশলের মিশ্রণ।

বিকল্প ১ — Node ID দিয়ে total order (সহজ, কিন্তু সীমাবদ্ধ)।

নিয়ম: যদি traversal-এ node A আর B দুটোই lock করতে হয়,
সবসময় ছোট node-ID আগে lock করুন — traversal-এর প্রকৃত ক্রম যাই হোক

সমস্যা: এতে graph traversal-এর স্বাভাবিক ক্রম ভাঙতে হয় (আগে জানতে হবে কোন node-গুলো লাগবে, তারপর সাজিয়ে lock নিতে হবে) — যা অনেক algorithm-এর জন্য অস্বাভাবিক (traversal প্রায়ই adaptive, আগে থেকে জানা যায় না কোন node দরকার হবে)।

বিকল্প ২ — Try-lock + backoff (হার্ডওয়্যার lock-free অ্যালগরিদমেও ব্যবহৃত)।

Ordering মানার বদলে, কখনো ব্লক করবেন না — শুধু চেষ্টা করুন, ব্যর্থ হলে সব ছেড়ে দিয়ে পরে আবার চেষ্টা করুন:

while (1) {
    if (try_lock(node_a)) {
        if (try_lock(node_b)) {
            /* উভয়ই পেয়েছি — কাজ করুন, তারপর release */
            break;
        }
        unlock(node_a);   /* B না পেলে A-ও ছেড়ে দিন */
    }
    random_backoff();      /* একটু অপেক্ষা, retry storm এড়াতে */
}

এটা Coffman-এর hold-and-wait শর্ত ভাঙে — কখনো একটা lock ধরে রেখে অন্যটার জন্য ব্লক করা হচ্ছে না। Livelock-এর সামান্য ঝুঁকি থাকে (দুই thread বারবার একে অপরকে ব্যর্থ করে দিতে পারে), তাই random exponential backoff জরুরি — নেটওয়ার্কের collision avoidance-এর মতোই একই নীতি (Level 7-এ Ethernet-এ এই একই কৌশল দেখব)।

বিকল্প ৩ — Timeout-সহ lock, আর transaction abort/retry।

if (pthread_mutex_timedlock(&node_lock, &timeout) == ETIMEDOUT) {
    rollback_transaction();
    retry_with_backoff();
}

এটা Coffman-এর no preemption শর্ত ভাঙে — timeout মানে কার্যকরভাবে একটা resource জোর করে ছেড়ে দিতে বাধ্য করা। এটাই বেশিরভাগ প্রকৃত graph database (Neo4j-এর মতো) ব্যবহার করে — deadlock detection-এর বদলে timeout-ভিত্তিক abort, কারণ detection-এর জটিলতা (একটা distributed graph-এ প্রতিটা node-এর lock-order ট্র্যাক করা) ব্যবহারিকভাবে costly।

বিকল্প ৪ — Optimistic concurrency control, লক-ই এড়ানো।

সবচেয়ে র‍্যাডিক্যাল সমাধান: lock না নিয়ে, প্রতিটা node-এ একটা version number রাখা। Transaction পড়ে, কাজ করে, তারপর commit করার সময় যাচাই করে কোনো version বদলেছে কি না — বদলালে পুরো transaction retry। এটা read-heavy workload-এ চমৎকার কাজ করে, কিন্তু write-heavy-তে অনেক retry হতে পারে। Level 8-এ MVCC (Multi-Version Concurrency Control) হিসেবে এটা database-এর প্রধান কৌশল হিসেবে বিস্তারিত দেখব।

বাস্তব সুপারিশ: ছোট, predictable lock set-এ (২-৩টা node) বিকল্প ১ বা ২ ব্যবহার করুন। বড়, unpredictable traversal-এ বিকল্প ৩ বা ৪ — deadlock prevent করার চেষ্টার বদলে, deadlock হলে দ্রুত সনাক্ত করে সহজে recover করার নকশা করাই বাস্তবসম্মত এই স্কেলে।

4

TOCTOU-এর ঝুঁকিতে থাকা এই কোডটা ঠিক করুন:

if (file_exists(path) && is_owned_by_user(path)) {
    delete_file(path);
}
প্রয়োগ

সমস্যা: file_exists, is_owned_by_user, delete_file — তিনটা আলাদা syscall, তিনটা আলাদা মুহূর্ত। এই মুহূর্তগুলোর মাঝখানে attacker path-কে অন্য কোনো ফাইলে (symlink দিয়ে) redirect করতে পারে — check হয়েছিল একটা ফাইলে, delete হবে অন্য একটায়।

/* বিপজ্জনক ধারণা: */
/* t=0: check হচ্ছে "myfile.txt" — ব্যবহারকারীর নিজের ফাইল */
/* t=1: attacker path-কে symlink দিয়ে /etc/shadow-এ পরিবর্তন করে */
/* t=2: delete_file(path) আসলে /etc/shadow মুছে ফেলে */

সঠিক সমাধান — fd-স্তরে কাজ করা, path-স্তরে বারবার নয়:

int fd = open(path, O_RDONLY | O_NOFOLLOW);
if (fd < 0) {
    /* symlink হলে বা না পেলে এখানেই থেমে যায় */
    return -1;
}

struct stat st;
fstat(fd, &st);              /* fd-র উপর stat — এখন race-মুক্ত,
                                 কারণ fd একটা নির্দিষ্ট inode-কে
                                 আটকে রেখেছে, path আর প্রাসঙ্গিক নয় */

if (st.st_uid != getuid()) {
    close(fd);
    return -1;                /* মালিক নয় — প্রত্যাখ্যান */
}

/* এখন delete-ও fd-নির্ভর করা উচিত, কিন্তু POSIX-এ unlink()
   সরাসরি fd নেয় না — তাই unlinkat() ব্যবহার করা হয় directory
   fd-র সাথে, যা directory-স্তরের race-ও কমায় */
close(fd);
unlink(path);   /* এখনো path-based, কিন্তু আমরা আগেই যাচাই করে
                    ফেলেছি — সম্পূর্ণ atomic নয়, কিন্তু windows
                    অনেক ছোট */

আরো শক্তিশালী — openat() + unlinkat() একই directory fd দিয়ে:

int dirfd = open(dirname, O_RDONLY | O_DIRECTORY);
int fd = openat(dirfd, basename, O_RDONLY | O_NOFOLLOW);
fstat(fd, &st);
if (st.st_uid == getuid()) {
    unlinkat(dirfd, basename, 0);   /* একই directory fd প্রসঙ্গে */
}
close(fd);
close(dirfd);

মূল নীতি: যেকোনো “check তারপর act” প্যাটার্ন-কে যতটা সম্ভব একটা atomic operation-এ (বা একটা fd-বাঁধা reference-এ) নামিয়ে আনা, যাতে check-এর মুহূর্ত আর act-এর মুহূর্তের মাঝখানে কোনো ফাঁক না থাকে যেখানে filesystem বদলে যেতে পারে।

O_NOFOLLOW নিশ্চিত করে final path component symlink হলে open ব্যর্থ হবে (attacker সেই সহজ পথটা ব্যবহার করতে পারবে না)। fstat() (path-based stat() নয়) fd-র উপর কাজ করে, তাই একবার open হওয়ার পর সেটা আর বদলাতে পারে না, path যাই ঘটুক।

5

একটা thread pool-এ প্রতিটা task আরেকটা task submit করে এবং সেই sub-task-এর ফলাফলের জন্য .get()-এ block করে। Pool-এর আকার 4। এই নকশায় কীভাবে deadlock ঘটতে পারে, যদিও কোনো mutex-ই ব্যবহৃত হয়নি? Coffman-এর চারটা শর্তের ভাষায় ব্যাখ্যা করুন।

যুক্তি

এই সমস্যাটা misconception-এর অংশে সংক্ষেপে উল্লেখ করা হয়েছিল — এখানে পূর্ণ বিশ্লেষণ।

Resource এখানে কী: থ্রেড pool-এর একটা slot (একটা free worker thread)।

চারটা শর্ত এখানে কীভাবে প্রযোজ্য:

Mutual exclusion: একটা thread slot একবারে শুধু একটা task চালাতে পারে — স্পষ্টতই সত্য, কারণ এটাই thread-এর সংজ্ঞা।

Hold and wait: একটা task যখন .get() ডেকে sub-task-এর ফলাফলের জন্য block করে, সে নিজের thread slot ধরে রেখেই অপেক্ষা করছে — সে সেই thread ছেড়ে দিচ্ছে না, শুধু ব্লক করছে।

No preemption: কোনো mechanism নেই যা একটা blocked task-এর কাছ থেকে জোর করে তার thread slot কেড়ে নেবে অন্য কাউকে চালানোর জন্য।

Circular wait: যদি ৪টা thread-ই একসাথে নতুন sub-task submit করে আর তাদের ফলাফলের জন্য অপেক্ষা করে, আর সেই sub-task গুলো চালানোর জন্য কোনো thread free নেই (সবাই parent task-এ ব্লক), তাহলে:

T1 (busy, .get()-এ অপেক্ষা) → চায় একটা free thread sub-task-এর জন্য
T2 (busy, .get()-এ অপেক্ষা) → চায় একটা free thread sub-task-এর জন্য
T3, T4 — একই অবস্থা

কিন্তু কোনো free thread নেই কারণ সবাই ব্যস্ত (অপেক্ষা করছে)

এটা একটা circular wait — যদিও প্রযুক্তিগতভাবে কোনো একটা নির্দিষ্ট thread অন্য একটা নির্দিষ্ট thread-এর জন্য অপেক্ষা করছে না (mutex-এর ABBA প্যাটার্নের মতো সরাসরি নয়), বরং সবাই “pool-এর কোনো একটা free slot”-এর জন্য অপেক্ষা করছে, যা কখনো আসবে না কারণ তৈরির শর্তই পূরণ হচ্ছে না।

সমাধান, তিনটা ভিন্ন কোণ থেকে:

১. Hold-and-wait ভাঙা — কাজটাকে non-blocking করা:

// ব্লক করার বদলে একটা callback/future রিটার্ন করুন
CompletableFuture<Result> future = pool.submit(subTask);
future.thenAccept(result -> continueWork(result));
// এই thread এখনই free হয়ে যায়, sub-task-এর অপেক্ষায় ব্লক করছে না

২. No preemption ভাঙা — একটা unbounded বা কাজ-চুরি করা (work-stealing) pool ব্যবহার:

ForkJoinPool-এর মতো implementation এই সমস্যা সমাধান করে একটা work-stealing algorithm দিয়ে — একটা thread যখন .get()-এ ব্লক করে, সে আসলে সেই সময়টায় অন্য pending task নিজেই চালাতে পারে (নিজের thread ছেড়ে না দিয়েও), কার্যকরভাবে নিজেকে recursive করে তোলে।

৩. আলাদা pool রাখা — “producer” আর “worker” task-এর জন্য:

Task যেগুলো sub-task submit করে (এবং block করতে পারে) তাদের একটা আলাদা, বড় pool-এ রাখা, আর যেগুলো কখনো sub-task submit করে না (leaf task) তাদের অন্য pool-এ — যাতে “সবাই ব্লক” অবস্থা গঠনগতভাবে অসম্ভব হয়ে যায়।

মূল শিক্ষা: Coffman-এর চারটা শর্ত শুধু mutex_lock()-এর জন্য নয় — এটা যেকোনো সীমিত resource আর blocking wait-এর সাধারণ তত্ত্ব। Thread pool slot, database connection pool, file descriptor, semaphore count — সবকিছুতেই একই বিশ্লেষণ প্রযোজ্য, আর একই চারটা প্রশ্ন জিজ্ঞেস করে সমাধান খুঁজতে হয়।

এরপর কী

পরের লেসন — Futex ও Lock-এর বাস্তবায়ন

এই লেসন পর্যন্ত আমরা pthread_mutex_lock()-কে একটা black box হিসেবে ব্যবহার করেছি — ডাকলাম, block হলাম, unlock হলে জাগলাম। কিন্তু কখনো প্রশ্ন করিনি: এই “block হওয়া” ঠিক কীভাবে বাস্তবায়িত হয়?

গত-গত লেসনে (synchronization primitives) আমরা spinlock দেখেছি — একটা busy-wait loop যা CPU পোড়ায় কিন্তু কখনো kernel-এ যায় না। আর mutex দেখেছি, যেটা “ঘুমিয়ে পড়ে” — কিন্তু ঠিক কোথায় ঘুমায়? Kernel কীভাবে জানে কাকে জাগাতে হবে?

পরের লেসনে আমরা এই প্রশ্নের গভীরে যাব — hardware atomic instruction (lock cmpxchg) থেকে শুরু করে, একটা pure spinlock হাতে লিখে দেখব কেন সেটা userspace-এ প্রায় কখনোই সঠিক পছন্দ নয়, আর তারপর futex (fast userspace mutex) — Linux-এর সেই চতুর design যা নিশ্চিত করে uncontended path-এ কখনো kernel-এ ঢুকতেই হয় না। এই লেসনের মূল কেন্দ্রবিন্দু হবে একটা সম্পূর্ণ, তিন-অবস্থার (unlocked/locked/locked-with-waiters) mutex implementation, একেবারে শূন্য থেকে — যা আপনি নিজে বেঞ্চমার্ক করে pthread_mutex_t-এর সাথে তুলনা করবেন।

আরও পড়ুন

  • C11 Standard, §5.1.2.4 — Multi-threaded executions and data races · Data race-কে undefined behaviour হিসেবে সংজ্ঞায়িত করা প্রামাণ্য উৎস
  • System Deadlocks — E. G. Coffman, M. J. Elphick, A. Shoshani (ACM Computing Surveys, 1971) · চারটা প্রয়োজনীয় শর্ত-এর মূল পেপার
  • ThreadSanitizer documentation · একটা execution থেকেই race ধরার হ্যাপেন্স-বিফোর অ্যালগরিদমের ব্যাখ্যা