সরাসরি পর্বে যান
পর্ব ৬৪ · ৬৪-এর মধ্যে · মডিউল ১০

Systems প্রোগ্রামিং ইন্টারভিউ — concurrency আর C কোডিং সমস্যা

Systems Programming Interview — 14 classic concurrency and C/POSIX coding problems, solved, run and stress-tested

Taught in Bangla, this page solves fourteen classic systems programming interview problems in runnable, stress-tested C with POSIX threads: odd-even printing and H2O, a blocking producer-consumer queue, reader-writer locks and spinlocks, a thread-safe LRU cache and token bucket, a memory pool and deadlock-free wallet transfer, cp and a pipeline in system calls, a timer scheduler and finding a race, plus a primitive cheat sheet and AI mock interviews.

এই পর্বে যা শিখবেন

  • Systems আর concurrency-র কোডিং ইন্টারভিউতে কী দেখা হয়, আর invariant বলে, race মাথায় রেখে কোড লেখার পদ্ধতি
  • Mutex, condition variable, semaphore, barrier আর C11 atomic — কোন সমস্যায় কোনটি, আর কেন
  • Blocking queue, reader–writer lock, spinlock, LRU cache, token bucket, memory pool আর timer নিজে বানানো
  • System call-এর কোডে partial write, EINTR, fork/pipe/dup2 সঠিকভাবে সামলানো
  • Deadlock এড়াতে lock-এর ক্রম, আর racy কোডে lost update, if-এ wait, double-checked locking-এর বাগ খুঁজে বের করা

১ · Systems কোডিং ইন্টারভিউ — invariant আগে, race মাথায় রেখে

পর্ব ৬৩-এ আপনি OS-এর ধারণাভিত্তিক ইন্টারভিউ প্রশ্নের অনুশীলন করেছেন। এই পর্ব তার হাতে-কলমে রূপ। Systems, backend, embedded, DevOps বা SRE পদের অনেক ইন্টারভিউতে সাধারণ অ্যালগরিদমের পাশাপাশি একটি ভিন্ন ধরনের সমস্যা আসে: “দুটি থ্রেড দিয়ে পালা করে সংখ্যা ছাপান”, “একটি thread-safe queue লিখুন”, “এই কোডে কী ভুল?”। এখানে Big-O-র চেয়ে বেশি দেখা হয় আপনি race condition চিনতে পারেন কি না, সঠিক primitive বাছতে পারেন কি না, আর ত্রুটি (error) ঠিকমতো সামলান কি না। প্রতিষ্ঠানভেদে ধরন আলাদা, তবে এ ধরনের প্রশ্ন প্রায়ই আসে।

ধাপসাধারণত কী হয়এই পাতায় প্রস্তুতি
Concurrency codingশেয়ার করা editor-এ ৩০–৪৫ মিনিটে একটি সমন্বয়ের সমস্যা: queue, পালা, lock বানানোসমস্যা ১–৬, ১০
Data structure + thread-safety“LRU cache লিখুন, তারপর বহু থ্রেডে নিরাপদ করুন”সমস্যা ৭–৯
System call / Unix codingফাইল কপি, pipe, fork-exec, signal — ত্রুটি সামলানোসহসমস্যা ১১–১২
Design আলোচনা“একটি timer service বা rate limiter কীভাবে বানাবেন?” — কোড কম, যুক্তি বেশিসমস্যা ৮, ১৩
Code review / debuggingএকটি racy কোড দিয়ে বলা হয়, “বাগ কোথায়, আর কীভাবে প্রমাণ করবেন?”সমস্যা ১৪, quiz

পাঁচ ধাপের পদ্ধতি

সাধারণ কোডিং ইন্টারভিউর “বুঝুন → উদাহরণ → brute force → উন্নতি” রুটিন এখানেও খাটে, তবে concurrency-তে কয়েকটি বাড়তি ধাপ লাগে। একজন অভিজ্ঞ বিদ্যুৎমিস্ত্রি যেমন তার জোড়ার আগে মূল সুইচ বন্ধ করেন, তেমনি আপনিও কোড লেখার আগে ঠিক করে নিন — কোন ডেটা ভাগ হচ্ছে, আর কে তাকে রক্ষা করবে।

প্রশ্ন করুন → shared state আর invariant লিখুন → primitive বাছুন → কোড (wait সবসময় while-এ) → race মাথায় রেখে পরীক্ষা
  1. প্রশ্ন করুন (clarifying question): কতজন producer আর consumer? Queue-র আকার সীমিত? Block করবে, নাকি সঙ্গে সঙ্গে ব্যর্থতা ফেরত দেবে? Timeout লাগবে? Shutdown কীভাবে হবে? কোন OS — POSIX না Windows?
  2. Shared state আর invariant লিখুন: “count সবসময় ০ থেকে CAP-এর মধ্যে”, “একসাথে writer থাকলে কোনো reader নেই”, “দুই ওয়ালেটের মোট টাকা বদলায় না”। প্রতিটি shared ভ্যারিয়েবলের পাশে লিখুন কোন mutex তাকে রক্ষা করে।
  3. Primitive বাছুন: শুধু পারস্পরিক বর্জন হলে mutex; “শর্ত সত্য না হওয়া পর্যন্ত ঘুমিয়ে থাকা” হলে mutex + condition variable; সম্পদ গোনা হলে semaphore; “সবাই পৌঁছালে একসাথে এগোনো” হলে barrier; একটি সংখ্যা বা flag হলে atomic.
  4. কোড লিখুন — কয়েকটি অটল নিয়মে: প্রতিটি wait একটি while-এ; lock ধরে কোনো ধীর কাজ (I/O, callback) নয়; একাধিক lock সবসময় একই ক্রমে; প্রতিটি system call-এর ফেরত মান যাচাই।
  5. Race মাথায় রেখে পরীক্ষা: “দুই থ্রেড ঠিক এই লাইনের মাঝে বদলালে কী হয়?” — প্রতিটি lock-ছাড়া জায়গায় এই প্রশ্ন করুন। তারপর পরীক্ষা: অনেক থ্রেড, বড় লুপ, ফল যাচাই, আর ThreadSanitizer.
ইন্টারভিউর কৌশল
  • Invariant জোরে বলুন। “এই mutex head, count আর buf রক্ষা করে; invariant হলো ০ ≤ count ≤ CAP” — এই এক বাক্যই দেখায় আপনি ভেবে কোড লিখছেন।
  • “এটা কাজ করছে” নয়, “এটা কেন ঠিক” বলুন। Concurrent কোড হাজারবার ঠিক চলে একবার ভাঙতে পারে। যুক্তি দিন: কোন lock কোন interleaving বন্ধ করছে।
  • Race মাথায় রেখে test বলুন। “আমি ৮টি থ্রেডে ১০ লাখবার চালাব, শেষে মোট যোগফল মেলাব, invariant ভাঙলে একটি counter বাড়বে, আর -fsanitize=thread দিয়ে compile করব।” Valgrind-এর Helgrind-এর নামও নিতে পারেন।
  • OS-এর পার্থক্য স্বীকার করুন। “এটা POSIX; Windows-এ SRWLOCK আর CONDITION_VARIABLE লাগবে; macOS-এ unnamed semaphore আর pthread_barrier নেই।”
  • আটকে গেলে সরল সংস্করণ: “আগে একটি বড় lock দিয়ে সঠিক সমাধান দিই, তারপর সূক্ষ্ম lock-এ যাই।” সঠিক ধীর সমাধান ভুল দ্রুত সমাধানের চেয়ে অনেক ভালো।

Concurrent কোডের পরীক্ষা কেন আলাদা

একক থ্রেডের কোডে একই ইনপুটে একই ফল আসে। থ্রেডে তা নয় — scheduler প্রতিবার আলাদা interleaving বাছে। তাই এই পাতার প্রতিটি প্রোগ্রাম তিনটি কৌশল মানে: (১) থ্রেড নিজে ছাপে না, বা ছাপে কঠোর পালায় — ফল ছাপে main, join-এর পরে; (২) “কী ঘটল” নয়, “নিয়ম ভেঙেছে কি না” গোনে — যেমন violations = 0; (৩) ফলের এমন রূপ ছাপে যা interleaving-এর ওপর নির্ভর করে না — মোট যোগফল, সংখ্যা, “yes/no”. ইন্টারভিউতেও এভাবেই test-এর কথা বলুন। মনে রাখবেন, data race থাকলে C-তে আচরণ undefined; একবার ঠিক ফল আসা কিছুই প্রমাণ করে না (পর্ব ২২)।

চালানোর পরিবেশ: এই পাতার প্রোগ্রামগুলো একটি অনলাইন রানারে (Linux x86-64, gcc, glibc, একটি container-এর ভেতরে, -Wall -Wextra চালু) পরীক্ষা করা হয়েছে, আর কোনো warning আসেনি। নিজের মেশিনে চালাতে gcc -Wall -Wextra -pthread prog.c দিন; race খুঁজতে -fsanitize=thread -g যোগ করুন। Windows-এ সরাসরি চলবে না — WSL বা একটি Linux VM ব্যবহার করুন।

নিচের টেবিলে ১৪টি সমস্যার মানচিত্র। প্রতিটির শুরুতে কোন পর্ব রিভিশন করবেন, তা-ও দেওয়া আছে।

২ · পালা আর দল বাঁধার ধাঁধা — বিজোড়-জোড় আর H2O

সমস্যা ১ · দুই থ্রেডে পালা করে ১ থেকে ১০ (সহজ)

রিভিশন: পর্ব ২৫ · Condition variable আর monitor, পর্ব ১৫ · POSIX thread হাতে-কলমে

সমস্যা: “দুটি থ্রেড আছে — একটি শুধু বিজোড় সংখ্যা ছাপে, অন্যটি শুধু জোড়। মিলে তারা ঠিক ক্রমে ১, ২, ৩ … ১০ ছাপবে। কোনো থ্রেড busy-wait করবে না।”

যে প্রশ্নগুলো আগে করবেন: কোন থ্রেড আগে চালু হবে, তা কি নিশ্চিত? (না — ধরে নেওয়া যাবে না।) ছাপার ক্রমই কি একমাত্র শর্ত, নাকি প্রতিটি থ্রেডের কাজও লাগবে? শেষে দুই থ্রেডকেই কি পরিষ্কারভাবে শেষ হতে হবে? থ্রেড N-টি হলে (১, ২, ৩, ১, ২, ৩ … পালা) কী হবে?

পদ্ধতি: একটি shared ভ্যারিয়েবল next — পরের যে সংখ্যা ছাপা হবে। Invariant: “next-এর parity যার, পালা তার”। প্রতিটি থ্রেড mutex ধরে while-এ অপেক্ষা করে যতক্ষণ পালা তার নয়; পালা এলে ছাপে, next++ করে, অন্যজনকে জাগায়। পালা হস্তান্তরের ঘটনাটা হলো “next বদলানো + signal” — দুটোই mutex ধরে।

  • ফাঁদ ১ — busy waiting: while (next % 2 != me) ; লুপে ঘোরা CPU পোড়ায়, আর lock ছাড়া পড়া একটি data race. তাই condition variable.
  • ফাঁদ ২ — if-এ wait: spurious wakeup-এ থ্রেড পালা ছাড়াই জেগে উঠতে পারে; while শর্ত আবার দেখে নেয়।
  • ফাঁদ ৩ — শেষের সমস্যা: ১০ ছাপার পর অন্য থ্রেড চিরকাল ঘুমিয়ে থাকতে পারে। তাই wait-এর শর্তে next <= MAX, আর বের হওয়ার আগে শেষ signal.
  • ফাঁদ ৪ — signal হারানো: শর্তের ভ্যারিয়েবল mutex ছাড়া বদলালে বা wait-এর আগে শর্ত না দেখলে lost wakeup হয় (পর্ব ২৫)।
#include <pthread.h>
#include <stdio.h>
enum { MAX = 10 };
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t turn = PTHREAD_COND_INITIALIZER;
static int next = 1;                 /* invariant: next = the number to print now */

static void *run(void *arg) {
    int parity = *(int *)arg;        /* 1 = odd thread, 0 = even thread */
    pthread_mutex_lock(&mu);
    for (;;) {
        while (next <= MAX && next % 2 != parity)
            pthread_cond_wait(&turn, &mu);    /* not my turn: sleep, mutex released */
        if (next > MAX) break;
        printf("%s thread: %d\n", parity ? "odd " : "even", next);
        next++;                               /* hand the turn over ... */
        pthread_cond_signal(&turn);           /* ... and wake the other thread */
    }
    pthread_mutex_unlock(&mu);
    return NULL;
}

int main(void) {
    pthread_t t[2];
    int parity[2] = {0, 1};          /* start the even thread first on purpose */
    for (int i = 0; i < 2; i++) pthread_create(&t[i], NULL, run, &parity[i]);
    for (int i = 0; i < 2; i++) pthread_join(t[i], NULL);
    printf("both threads finished, next = %d\n", next);
    return 0;
}

একটি আসল রানের আউটপুট (একাধিকবার চালিয়ে প্রতিবার একই):

odd  thread: 1
even thread: 2
odd  thread: 3
even thread: 4
odd  thread: 5
even thread: 6
odd  thread: 7
even thread: 8
odd  thread: 9
even thread: 10
both threads finished, next = 11

main ইচ্ছা করে জোড় থ্রেডটি আগে তৈরি করেছে, তবু প্রথম লাইন “odd thread: 1” — কারণ জোড় থ্রেড আগে mutex পেলেও শর্ত দেখে ঘুমিয়ে যায়। কে আগে চলবে তার ওপর ফল নির্ভর করে না; নির্ভর করে শুধু next-এর ওপর। প্রতিটি printf mutex ধরে হয়, আর পালা এক থ্রেড থেকে অন্য থ্রেডে যায় শুধু next++-এর মাধ্যমে — তাই ক্রম নির্ধারিত। শেষ লাইনের next = 11 দেখায় দুই থ্রেডই loop থেকে বেরিয়ে join হয়েছে; কেউ আটকে থাকলে প্রোগ্রাম শেষই হতো না।

পালা হস্তান্তরের পুরো নাচটি ধাপে ধাপে দেখুন — কে mutex ধরে আছে, কে ঘুমিয়ে, কে জেগে mutex-এর অপেক্ষায়:

1lock(&mu);2while (next <= MAX && next % 2 != parity)3    cond_wait(&turn, &mu);  // sleep, unlock4if (next > MAX) break;5printf("%d", next);6next++;7cond_signal(&turn);   // wake the other8unlock(&mu);  // after the loop
shared অবস্থা
next
mutex ধরে আছে
odd থ্রেড
even থ্রেড
  1. জোড় থ্রেড আগে mutex পেল। ধরুন scheduler জোড় থ্রেডকে আগে চালাল। সে lock নিয়ে শর্ত দেখে: next = 1, ১ বিজোড়, তাই পালা তার নয়। pthread_cond_wait একসাথে দুটি কাজ করে — mutex ছেড়ে দেয় আর থ্রেডকে ঘুম পাড়ায়। mutex এখন খালি।
  2. বিজোড় থ্রেড ছাপল 1. বিজোড় থ্রেড lock পেল; শর্ত মিথ্যা (১ বিজোড়, পালা তারই), তাই while থেকে বেরিয়ে ছাপে “odd thread: 1”. পুরো সময় mutex তার হাতে, তাই অন্য কেউ next ছুঁতে পারে না।
  3. next++ আর signal. next হলো 2 — পালা এখন জোড়ের। signal ঘুমন্ত জোড় থ্রেডকে জাগায়, কিন্তু সে সঙ্গে সঙ্গে চলতে পারে না: wait থেকে ফেরার আগে তাকে mutex আবার নিতে হবে, আর mutex এখনো বিজোড়ের হাতে।
  4. বিজোড় ঘুমাল, জোড় চলল। বিজোড় থ্রেড লুপের শুরুতে ফিরে শর্ত দেখে: ২ জোড়, পালা তার নয় — wait, mutex ছাড়ল। জোড় থ্রেড mutex পেয়ে আবার শর্ত যাচাই করে (এই কারণেই while) — পালা তার, তাই ছাপে “even thread: 2”.
  5. জোড় থ্রেড পালা ফেরত দিল। next = 3, signal দিয়ে বিজোড়কে জাগাল, নিজে পরের ঘুরে wait-এ যাবে। এই চারটি ধাপের নাচই ১০ পর্যন্ত চলতে থাকে — প্রতিবার ঠিক একজন mutex ধরে ছাপে।
  6. 10 ছাপা শেষ, next = 11. জোড় থ্রেড 10 ছেপে next-কে 11 করল আর signal দিল। এখন শর্তের প্রথম অংশ next <= MAX মিথ্যা, তাই জোড় থ্রেড wait না করে while থেকে বেরিয়ে if (next > MAX) break;-এ লুপ ছাড়ে, তারপর unlock করে ফিরে যায়।
  7. শেষ signal-টি কাজে লাগল। বিজোড় থ্রেড জেগে mutex পেল, while-এর শর্ত মিথ্যা দেখে বেরোল, break করে unlock. শেষ signal না থাকলে সে চিরকাল ঘুমিয়ে থাকত আর main-এর join কখনো ফিরত না — এই ফাঁদটিই ইন্টারভিউতে সবচেয়ে বেশি ধরা পড়ে।
  8. main দুজনকেই join করল। দুই থ্রেড শেষ, mutex খালি, আর main ছাপে next = 11. ফলটি নির্ধারিত, কারণ ক্রম ঠিক করে next-এর invariant, scheduler-এর ভাগ্য নয়।

সঠিকতা ও জটিলতা: invariant — “next শুধু mutex ধরে বদলায়, আর যে থ্রেড ছাপে তার parity next-এর parity-র সমান”। তাই কোনো সংখ্যা দুবার বা ভুল থ্রেড থেকে ছাপা হয় না। প্রতিটি সংখ্যায় একটি হস্তান্তর, মোট O(MAX) কাজ; কিন্তু প্রতিটি হস্তান্তরে একটি থ্রেড ঘুমায় আর আরেকটি জাগে — সাধারণত একটি context switch-সহ — তাই এটি আসলে ধীর। ইন্টারভিউয়ারকে বলুন: এই সমস্যা সমন্বয় দেখানোর জন্য, গতির জন্য নয়।

ফলো-আপ ১তিনটি থ্রেড পালা করে ১, ২, ৩, ১, ২, ৩ … ছাপলে কী বদলাবেন? signal না broadcast? (মাঝারি)

উত্তর: শর্ত হবে next % N != id (id ০ থেকে N−1, আর ছাপবে next % N-এর মালিক)। এক condition variable-এ N−1 জন ঘুমালে pthread_cond_signal যেকোনো একজনকে জাগায় — হয়তো ভুল জনকে; সে শর্ত দেখে আবার ঘুমায়, আর যার পালা সে কখনো জাগে না: অনন্ত অপেক্ষা। দুটি সঠিক সমাধান: (ক) pthread_cond_broadcast — সবাই জাগে, শুধু যার পালা সে এগোয় (সরল, তবে N বড় হলে thundering herd); (খ) প্রতিটি থ্রেডের নিজের condition variable, আর হস্তান্তরের সময় শুধু পরের জনের cv-তে signal — দক্ষ। দুই থ্রেডের ক্ষেত্রে signal যথেষ্ট, কারণ ঘুমন্ত মানুষ একজনই।

ফলো-আপ ২Condition variable ছাড়া, শুধু দুটি semaphore দিয়ে একই কাজ করা যায়? (মাঝারি)

উত্তর: হ্যাঁ, আর কোড আরও ছোট। odd_turn = 1, even_turn = 0 দিয়ে শুরু। বিজোড় থ্রেড: sem_wait(&odd_turn); print; sem_post(&even_turn);, জোড় থ্রেড উল্টো। এখানে semaphore-এর মান-ই পালার টোকেন — ঠিক একটি টোকেন দুই semaphore-এর মধ্যে ঘোরে, তাই একসাথে দুজন ছাপতে পারে না। Semaphore “মনে রাখে” (post আগে হলে পরের wait আটকায় না), তাই lost wakeup-এর ভয় নেই। সাবধানতা: sem_wait signal handler-এ বাধা পেলে EINTR দিয়ে ফিরতে পারে — বাস্তব কোডে লুপে আবার চেষ্টা করুন। এটি একটি binary semaphore-এর ক্লাসিক “baton passing” ব্যবহার।

সমস্যা ২ · H2O — দুটি H আর একটি O মিলে অণু (মাঝারি)

রিভিশন: পর্ব ২৪ · Mutex আর semaphore, পর্ব ২৫ (barrier অংশ)

সমস্যা: “অনেকগুলো থ্রেড, প্রতিটি একটি পরমাণু — হাইড্রোজেন (H) বা অক্সিজেন (O). প্রতিটি থ্রেড bond() ডাকবে। শর্ত: bond() ডাকা থ্রেডগুলো তিনজনের দলে ভাগ হবে, প্রতিটি দলে ঠিক দুটি H আর একটি O, আর এক দলের তিনজন bond() শেষ না করা পর্যন্ত পরের দলের কেউ bond() ডাকবে না।” এ ধরনের ক্রম-সাজানোর ধাঁধা Allen Downey-র বিনামূল্যের বই The Little Book of Semaphores-এ “Building H2O” নামে আছে, আর ইন্টারভিউতে প্রায়ই নানা রূপে আসে।

শেয়ার্ড CNG-র কথা ভাবুন: পেছনে দুজন যাত্রী আর সামনে একজন — তিনটি আসন ভরলে তবেই গাড়ি ছাড়ে। পেছনের আসন দুটি (H-এর দুটি “seat”), সামনের একটি (O-এর একটি “seat”). তিনজন বসলে সবাই একসাথে রওনা দেন (barrier), আর গন্তব্যে নেমে আসন খালি করলে তবেই পরের যাত্রীরা উঠতে পারেন।

যে প্রশ্নগুলো আগে করবেন: H আর O-এর সংখ্যা কি সবসময় ঠিক ২:১? (না হলে কিছু থ্রেড চিরকাল অপেক্ষা করবে — সেটি কি গ্রহণযোগ্য?) একই দলের তিনজনের bond()-এর ভেতরের ক্রম কি গুরুত্বপূর্ণ? কোন primitive ব্যবহার করা যাবে — barrier কি আছে?

পদ্ধতি: দুটি counting semaphore “আসন” বোঝায় — hseat = 2, oseat = 1 — আর একটি তিনজনের pthread_barrier. প্রতিটি থ্রেড: নিজের ধরনের আসন নেয় → barrier-এ অপেক্ষা → bond() → আসন ফেরত দেয়। মূল কৌশল: আসন ফেরত দেওয়া হয় bond-এর পরে. তাই পরের দলের কোনো সদস্য তখনই আসন পায়, যখন আগের দলের কেউ bond শেষ করে আসন ছেড়েছে — আর পরের দলের তিনটি আসন ভরতে আগের দলের তিনজনকেই আসন ছাড়তে হয়।

#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
enum { MOL = 5 };
static sem_t hseat, oseat;           /* at most 2 H and 1 O inside a molecule */
static pthread_barrier_t three;      /* the 3 seated atoms wait for each other */
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static char bonded[3 * MOL]; static int n;

static void *atom(void *arg) {
    char kind = *(char *)arg;
    sem_t *seat = (kind == 'H') ? &hseat : &oseat;
    sem_wait(seat);                  /* take a seat in the current molecule */
    pthread_barrier_wait(&three);    /* wait until 2 H + 1 O are seated */
    pthread_mutex_lock(&mu); bonded[n++] = kind; pthread_mutex_unlock(&mu);
    sem_post(seat);                  /* give the seat back only after bonding */
    return NULL;
}
int main(void) {
    pthread_t t[3 * MOL]; char kind[3 * MOL];
    sem_init(&hseat, 0, 2); sem_init(&oseat, 0, 1); pthread_barrier_init(&three, NULL, 3);
    for (int i = 0; i < 3 * MOL; i++) {
        kind[i] = (i < MOL) ? 'O' : 'H';           /* all O threads are created first */
        pthread_create(&t[i], NULL, atom, &kind[i]);
    }
    for (int i = 0; i < 3 * MOL; i++) pthread_join(t[i], NULL);
    int ok = 1;
    for (int m = 0; m < MOL; m++) ok &= (bonded[3*m] == 'H') + (bonded[3*m+1] == 'H') + (bonded[3*m+2] == 'H') == 2;
    printf("atoms bonded = %d, molecules = %d, every group of 3 is 2H+1O: %s\n", n, n / 3, ok ? "yes" : "no");
}

আসল রানের আউটপুট (বারবার একই):

atoms bonded = 15, molecules = 5, every group of 3 is 2H+1O: yes

১৫টি থ্রেড (৫টি O আগে তৈরি, তারপর ১০টি H) — ইচ্ছা করে খারাপ ক্রমে, যাতে প্রথমে সব O ভিড় করে। প্রতিটি bond() একটি shared log-এ নিজের অক্ষর লেখে, আর main পরে log-কে তিনটি করে ভাগ করে দেখে প্রতিটি দলে ঠিক দুটি H আছে কি না। ফল “yes” — কারণ সমাধানটি দুটি জিনিস নিশ্চিত করে: barrier-এ একসাথে পৌঁছানো তিনজনের কাছে ঠিক দুটি H-আসন আর একটি O-আসন আছে, আর এক দলের তিনজন bond শেষ করে আসন না ছাড়া পর্যন্ত পরের দল barrier পেরোতে পারে না। তাই log-এ দলগুলো কখনো মেশে না। কোন H কোন অণুতে গেল, তা রান-ভেদে বদলায়, কিন্তু যাচাইয়ের ফল বদলায় না।

  • সঠিকতা: (১) গঠন: barrier ছাড়ে ঠিক তিনজনকে, আর তিনজনের প্রত্যেকে একটি আসন ধরে আছে; আসন মোট ২টি H আর ১টি O, তাই দল = ২H + ১O. (২) আলাদা দল: দল k+১-এর তিনটি আসন খালি পেতে দল k-এর তিনজনকেই sem_post করতে হবে, যা তারা করে bond-এর পরে — তাই দল k-এর সব bond দল k+১-এর যেকোনো bond-এর আগে।
  • ফাঁদ: আসন bond-এর আগে ফেরত দিলে দল মিশে যেতে পারে। শুধু একটি mutex আর H-এর counter দিয়ে “দুটি H জমলে O-কে ডাকি” ধরনের সমাধানে প্রায়ই ২টির জায়গায় ৩টি H ঢুকে পড়ে।
  • OS-এর পার্থক্য: macOS-এ pthread_barrier_t নেই, আর unnamed semaphore (sem_init) কাজ করে না; সেখানে mutex + condition variable দিয়ে barrier নিজে বানাতে হয়, বা Grand Central Dispatch-এর semaphore ব্যবহার করতে হয়। Windows-এ SYNCHRONIZATION_BARRIER আর CreateSemaphore আছে।
  • জটিলতা: প্রতিটি থ্রেডে ধ্রুব সংখ্যক সমন্বয়-কাজ, মোট O(n); কিন্তু প্রতিটি দল barrier-এ অন্তত একবার ঘুম-জাগরণ নেয়।
ফলো-আপ ৩pthread_barrier নেই — mutex আর condition variable দিয়ে একটি পুনঃব্যবহারযোগ্য barrier কীভাবে বানাবেন? সাধারণ ফাঁদটি কী? (কঠিন)

উত্তর: তিনটি ভ্যারিয়েবল: count (কতজন এসেছে), n (দলের আকার), আর generation (কোন রাউন্ড)। wait(): lock; নিজের রাউন্ড g = generation মনে রাখুন; count++; যদি count == n হয় — count = 0; generation++; broadcast; নইলে while (g == generation) cond_wait; unlock. ফাঁদ: generation ছাড়া শুধু while (count < n) লিখলে শেষজন count = 0 করার পর আগের ঘুমন্তরা জেগে দেখে count আবার ছোট — চিরকাল ঘুমায়; অথবা দ্রুত একটি থ্রেড পরের রাউন্ডে ঢুকে আগের রাউন্ডের broadcast “চুরি” করে। Generation counter প্রতিটি রাউন্ডকে আলাদা করে, আর spurious wakeup-ও সামলায়।

৩ · Producer–consumer — blocking queue আর semaphore

সমস্যা ৩ · Thread-safe bounded blocking queue (মাঝারি)

রিভিশন: পর্ব ২৬ · ক্লাসিক সমস্যা, পর্ব ২৫, পর্ব ১৫ (thread pool অংশ)

সমস্যা: “নির্দিষ্ট ধারণক্ষমতার (CAP) একটি queue লিখুন, যাতে put(x) আর take() আছে। Queue ভর্তি থাকলে put অপেক্ষা করবে, খালি থাকলে take অপেক্ষা করবে। একাধিক producer আর একাধিক consumer একসাথে ব্যবহার করবে।” এটিই সম্ভবত সবচেয়ে বেশি আসা concurrency প্রশ্ন — thread pool, logging, নেটওয়ার্ক সার্ভার, সবখানে এর ব্যবহার। এর আরেক নাম bounded buffer.

রেস্তোরাঁর রান্নাঘর আর পরিবেশনের কাউন্টারের মাঝে চারটি প্লেটের একটি তাক। বাবুর্চিরা (producer) রান্না করা প্লেট তাকে রাখেন, ওয়েটাররা (consumer) তুলে নেন। তাক ভর্তি হলে বাবুর্চি অপেক্ষা করেন — এটাই backpressure: ধীর ওয়েটারেরা পুরো রান্নাঘরকে ধীর করে দেয়, কিন্তু মেঝেতে প্লেট জমে না।

যে প্রশ্নগুলো আগে করবেন: কতজন producer/consumer? ভর্তি থাকলে block, নাকি try_put-এর মতো সঙ্গে সঙ্গে ব্যর্থ, নাকি timeout? বন্ধ (shutdown) করার পদ্ধতি লাগবে? ক্রম কি FIFO হতে হবে? উপাদান কি শুধু int, নাকি যেকোনো পয়েন্টার?

পদ্ধতি: একটি বৃত্তাকার array (head, count), একটি mutex যা তিনটিকেই রক্ষা করে, আর দুটি condition variable — not_full (producer এখানে ঘুমায়) আর not_empty (consumer এখানে ঘুমায়)। Invariant: ০ ≤ count ≤ CAP, আর উপাদানগুলো buf[head] থেকে পরের countটি ঘরে।

  • কেন দুটি condition variable: একটি cv-তে producer আর consumer দুজনেই ঘুমালে signal হয়তো ভুল দলের কাউকে জাগাবে (consumer জাগাল আরেক consumer-কে) — আসল অপেক্ষমাণ চিরকাল ঘুমাতে পারে। একটি cv রাখলে broadcast লাগবে, যা অপচয়।
  • কেন while: দুজন consumer অপেক্ষায়, একটি উপাদান এল; একজন জাগল, কিন্তু mutex পাওয়ার আগেই তৃতীয় একজন consumer এসে উপাদানটি নিয়ে গেল (“চুরি যাওয়া জাগরণ”)। while আবার দেখে, আবার ঘুমায়।
  • Signal lock ধরে নাকি ছেড়ে? দুটোই সঠিক। lock ছাড়ার পরে signal দিলে জাগা থ্রেড সঙ্গে সঙ্গে mutex পায় বলে কখনো একটু দ্রুত, তবে lock ধরে signal দেওয়া কোড পড়তে সহজ।
#include <pthread.h>
#include <stdio.h>
enum { CAP = 4, PER = 1000 };
static int buf[CAP], head, count, peak;      /* invariant: 0 <= count <= CAP */
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t not_full = PTHREAD_COND_INITIALIZER, not_empty = PTHREAD_COND_INITIALIZER;
static void put(int x) {
    pthread_mutex_lock(&mu);
    while (count == CAP) pthread_cond_wait(&not_full, &mu);    /* full: producer sleeps */
    buf[(head + count++) % CAP] = x; if (count > peak) peak = count;
    pthread_cond_signal(&not_empty);
    pthread_mutex_unlock(&mu);
}
static int take(void) {
    pthread_mutex_lock(&mu);
    while (count == 0) pthread_cond_wait(&not_empty, &mu);     /* empty: consumer sleeps */
    int x = buf[head]; head = (head + 1) % CAP; count--;
    pthread_cond_signal(&not_full);
    pthread_mutex_unlock(&mu);
    return x;
}
static void *producer(void *a) { int base = *(int *)a; for (int i = 1; i <= PER; i++) put(base + i); return NULL; }
static void *consumer(void *a) { long *s = a; for (int i = 0; i < PER; i++) *s += take(); return NULL; }
int main(void) {
    pthread_t p[2], c[2]; int base[2] = {0, PER}; long sum[2] = {0, 0};
    for (int i = 0; i < 2; i++) { pthread_create(&p[i], NULL, producer, &base[i]); pthread_create(&c[i], NULL, consumer, &sum[i]); }
    for (int i = 0; i < 2; i++) { pthread_join(p[i], NULL); pthread_join(c[i], NULL); }
    printf("sum taken = %ld, expected 1+..+%d = %d\n", sum[0] + sum[1], 2 * PER, PER * (2 * PER + 1));
    printf("peak <= CAP: %s, left in queue = %d\n", peak <= CAP ? "yes" : "no", count);
}

আসল রানের আউটপুট:

sum taken = 2001000, expected 1+..+2000 = 2001000
peak <= CAP: yes, left in queue = 0

দুজন producer মোট ২০০০টি আলাদা সংখ্যা (১–১০০০ আর ১০০১–২০০০) রাখে, দুজন consumer ১০০০টি করে নেয়। চারটি আসনের queue-তে হাজার হাজারবার ভর্তি আর খালি হওয়ার মধ্যে একটি সংখ্যাও হারালে বা দুবার নেওয়া হলে যোগফল ২০,০১,০০০ মিলত না। peak <= CAP: yes দেখায় invariant কখনো ভাঙেনি (peak-এর আসল মান রান-ভেদে বদলাতে পারে, তাই সেটি সরাসরি ছাপা হয়নি)। left in queue = 0 — সব উপাদান নেওয়া হয়েছে। কোন consumer কোন সংখ্যা পেল, তা প্রতিবার আলাদা; যোগফল নয়।

সঠিকতা ও জটিলতা: প্রতিটি put/take mutex ধরে count একবার বদলায়, আর while-এর পরে শর্ত (count < CAP বা count > 0) সত্য — তাই invariant রক্ষা পায়। প্রতিটি কাজ O(1). Deadlock নেই, কারণ একটিই mutex. Progress-এর যুক্তি: একজন producer ঘুমায় শুধু queue ভর্তি থাকলে; তখন অন্তত একটি উপাদান আছে, তাই কোনো consumer কখনো not_empty-এ চিরকাল ঘুমাবে না, আর প্রতিটি take একটি not_full signal দেয়।

ফলো-আপ ৪Queue-তে close() যোগ করুন: বন্ধ হলে put ব্যর্থ হবে, আর consumer-রা বাকি উপাদান শেষ করে “আর কিছু নেই” পাবে। কী বদলাবেন? (মাঝারি)

উত্তর: একটি closed flag (mutex-রক্ষিত)। close(): lock, closed = 1, দুটি cv-তেই broadcast, unlock — কারণ সব ঘুমন্ত থ্রেডকে জানাতে হবে, একজনকে নয়। take: while (count == 0 && !closed) wait; তারপর if (count == 0) return EMPTY_AND_CLOSED. put: while (count == CAP && !closed) wait; if (closed) return -1. এভাবে consumer আগে বাকি উপাদান শেষ করে, তারপর থামে — thread pool-এর graceful shutdown ঠিক এভাবেই হয় (পর্ব ১৫)। ভুলটি সাধারণত broadcast-এর জায়গায় signal — তখন অর্ধেক worker চিরকাল ঘুমায় আর join আটকে যায়।

ফলো-আপ ৫অনেক core-এ একটি mutex-ই bottleneck হলে কী করবেন? Lock ছাড়া queue কি সহজ? (কঠিন)

উত্তর: ধাপে ধাপে ভাবুন। (১) Batching: একবার lock নিয়ে কয়েকটি উপাদান রাখা/তোলা — lock-এর খরচ ভাগ হয়ে যায়। (২) দুটি lock: linked-list queue-তে head-এর জন্য একটি, tail-এর জন্য একটি lock (Michael ও Scott-এর ১৯৯৬ সালের two-lock queue) — producer আর consumer একে অপরকে আটকায় না। (৩) প্রতি-থ্রেড queue আর work stealing — বহু thread pool-এর নকশা। (৪) Lock-free: একজন producer আর একজন consumer হলে (SPSC) শুধু দুটি atomic index আর acquire/release দিয়ে একটি ring buffer বেশ সহজ। কিন্তু বহু producer-বহু consumer (MPMC) lock-free queue কঠিন — ABA সমস্যা, মেমরি ফেরত দেওয়ার সমস্যা (পর্ব ২৯)। ইন্টারভিউতে বলুন: “আগে মাপব (profile), তারপর এই ক্রমে।”

সমস্যা ৪ · Semaphore দিয়ে producer–consumer (মাঝারি)

রিভিশন: পর্ব ২৬ (তিনটি semaphore-এর সমাধান আর wait-এর ক্রম), পর্ব ২৪

সমস্যা: “একই bounded buffer, তবে condition variable ছাড়া — শুধু semaphore দিয়ে। আর ব্যাখ্যা করুন wait-এর ক্রম উল্টালে কী হয়।” পাঠ্যবইয়ের ক্লাসিক সমাধান: empty = CAP (খালি ঘরের সংখ্যা), full = 0 (ভরা ঘরের সংখ্যা), আর ঘরে লেখা-পড়ার জন্য একটি mutex.

যে প্রশ্নগুলো আগে করবেন: কোন semaphore API — POSIX sem_t, System V, নাকি Windows-এর? Unnamed semaphore (একই প্রসেসের থ্রেডে), নাকি named (আলাদা প্রসেসে)? Signal handler থাকলে EINTR সামলাতে হবে কি?

#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
enum { CAP = 3, ITEMS = 500 };
static int buf[CAP], in, out;
static sem_t empty, full;            /* empty = free slots, full = filled slots */
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static void *producer(void *unused) {
    for (int i = 1; i <= ITEMS; i++) {
        sem_wait(&empty);            /* 1. reserve a free slot (may sleep) */
        pthread_mutex_lock(&mu);     /* 2. only then take the lock */
        buf[in] = i; in = (in + 1) % CAP; pthread_mutex_unlock(&mu);
        sem_post(&full);             /* 3. announce one more item */
    }
    return unused;
}
static void *consumer(void *sum) {
    for (int i = 0; i < ITEMS; i++) {
        sem_wait(&full);             /* wait for an item */
        pthread_mutex_lock(&mu); *(long *)sum += buf[out]; out = (out + 1) % CAP; pthread_mutex_unlock(&mu);
        sem_post(&empty);            /* one slot is free again */
    }
    return NULL;
}
int main(void) {
    pthread_t p, c; long sum = 0; int e, f; sem_init(&empty, 0, CAP); sem_init(&full, 0, 0);
    pthread_create(&p, NULL, producer, NULL); pthread_create(&c, NULL, consumer, &sum);
    pthread_join(p, NULL); pthread_join(c, NULL); sem_getvalue(&empty, &e); sem_getvalue(&full, &f);
    printf("sum = %ld (expected %d), empty = %d, full = %d\n", sum, ITEMS * (ITEMS + 1) / 2, e, f);
}

আসল রানের আউটপুট:

sum = 125250 (expected 125250), empty = 3, full = 0

১ থেকে ৫০০ পর্যন্ত যোগফল ৫০০ × ৫০১ ÷ ২ = ১,২৫,২৫০ — মিলেছে, অর্থাৎ কোনো উপাদান হারায়নি বা দুবার পড়া হয়নি। শেষে empty = 3, full = 0: semaphore-এর invariant empty + full + (কাজ চলছে এমন) = CAP — সব থেমে গেলে empty + full = ৩ = CAP. এই যাচাইটি ইন্টারভিউতে বলার মতো: “semaphore-এর মান যোগ করলে ধারণক্ষমতা পাওয়া যায়।”

  • ক্রমের ফাঁদ: producer যদি আগে mutex নেয়, তারপর sem_wait(&empty) — আর buffer ভর্তি থাকে — তাহলে সে mutex ধরে ঘুমায়। Consumer sem_wait(&full) পেরিয়ে mutex চায়, পায় না; empty কখনো বাড়ে না: deadlock. নিয়ম: আগে গোনার semaphore, তারপর mutex; ছাড়ার সময় ক্রম গুরুত্বপূর্ণ নয়।
  • Mutex কেন, যদি একজন producer আর একজন consumer হয়? তখন in শুধু producer আর out শুধু consumer ছোঁয়, আর একই ঘরে একসাথে পৌঁছানো semaphore-ই আটকায় — mutex ছাড়াও চলে। একাধিক producer হলে in-এ race হবে, তাই সাধারণ সমাধানে mutex রাখুন।
  • EINTR: sem_wait signal handler-এ বাধা পেলে −১ ফেরত দেয় আর errno = EINTR — handler SA_RESTART ছাড়া বসানো হলে (Linux 2.6.22 থেকে SA_RESTART থাকলে এটি নিজে আবার শুরু হয়; অন্য OS-এ নিয়ম আলাদা হতে পারে)। বাস্তব কোডে while (sem_wait(&s) == -1 && errno == EINTR) ; লিখুন। Pthread mutex আর condition variable কখনো EINTR দেয় না।
  • Semaphore বনাম condition variable: semaphore “গোনা” মনে রাখে, তাই সম্পদ-গণনায় সরল। কিন্তু জটিল শর্তে (“queue-তে অন্তত ৫টি আর shutdown নয়”) condition variable বেশি নমনীয়। Mutex-এর একজন মালিক থাকে; semaphore যেকোনো থ্রেড post করতে পারে — তাই semaphore দিয়ে mutex-এর মালিকানা-নিয়ম বা priority inversion-এর প্রতিকার পাওয়া যায় না।
ফলো-আপ ৬Producer আর consumer আলাদা প্রসেস হলে (fork-এর পরে) এই কোড কীভাবে বদলাবেন? (কঠিন)

উত্তর: সাধারণ global ভ্যারিয়েবল fork-এর পরে আলাদা কপি হয়ে যায় (copy-on-write, পর্ব ৩৫), তাই buffer, index আর semaphore — সবকিছুকে shared memory-তে রাখতে হবে: mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0) (fork-এর আগে) বা shm_open. Semaphore হবে sem_init(&s, 1, n) — দ্বিতীয় argument ১ মানে প্রসেসের মধ্যে ভাগ করা — অথবা named semaphore (sem_open("/q_empty", O_CREAT, 0600, CAP)). Mutex হলে pthread_mutexattr_setpshared(..., PTHREAD_PROCESS_SHARED). আর একটি প্রসেস lock ধরে মারা গেলে? PTHREAD_MUTEX_ROBUST দিলে পরের জন EOWNERDEAD পায় আর ডেটা মেরামত করতে পারে। সহজ বিকল্প: pipe বা message queue (পর্ব ১২) — kernel নিজেই bounded buffer.

৪ · নিজের lock বানানো — reader–writer lock আর spinlock

সমস্যা ৫ · Reader–writer lock, লেখক-অগ্রাধিকারসহ (কঠিন)

রিভিশন: পর্ব ২৬ · Readers–writers (প্রথম সমাধান, লেখকের starvation, লেখক-অগ্রাধিকার)

সমস্যা: “একটি mutex আর condition variable দিয়ে rlock, runlock, wlock, wunlock বানান। একসাথে অনেক reader পড়তে পারবে, কিন্তু writer একা। কোনো writer অপেক্ষায় থাকলে নতুন reader ঢুকবে না।” এটি readers–writers problem-এর “দ্বিতীয়” রূপ — Courtois, Heymans আর Parnas ১৯৭১ সালে দুটি রূপই প্রকাশ করেছিলেন।

অফিসের নোটিশ বোর্ড: একসাথে বিশজন পড়তে পারেন, কেউ কাউকে বিরক্ত করেন না। কিন্তু নোটিশ বদলানোর সময় বোর্ডের সামনে কেউ থাকবেন না। আর প্রশাসনের লোক নতুন নোটিশ হাতে দাঁড়িয়ে থাকলে নতুন পাঠকদের একটু থামতে বলা হয় — নইলে পাঠকের স্রোত কখনো থামবে না, নোটিশও বদলাবে না।

যে প্রশ্নগুলো আগে করবেন: কাদের অগ্রাধিকার — reader, writer, নাকি আসার ক্রমে (fair)? একই থ্রেড কি দুবার rlock নিতে পারবে (recursive)? Read lock থেকে write lock-এ “upgrade” লাগবে? পড়া কত ঘন ঘন, লেখা কত ঘন ঘন?

পদ্ধতি: mutex-রক্ষিত তিনটি সংখ্যা — readers (এখন পড়ছে), writing (০/১), waiting_writers (অপেক্ষায়)। Invariant: writing == 1 হলে readers == 0, আর writing ≤ ১. Reader ঢোকে শুধু যখন কেউ লিখছে না এবং কোনো writer অপেক্ষায় নেই — এটিই writer preference. শেষ reader বেরোলে একজন writer-কে জাগায়; writer বেরোলে আগে অপেক্ষমাণ writer-কে, না থাকলে সব reader-কে (broadcast).

#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t can_read = PTHREAD_COND_INITIALIZER, can_write = PTHREAD_COND_INITIALIZER;
static int readers, writing, waiting_writers;  /* invariant: !(writing && readers > 0) */
static atomic_int in_r, in_w, bad;             /* only for checking the invariant */
static long value;

static void rlock(void) { pthread_mutex_lock(&mu);
    while (writing || waiting_writers > 0) pthread_cond_wait(&can_read, &mu);  /* writers first */
    readers++; pthread_mutex_unlock(&mu); }
static void runlock(void) { pthread_mutex_lock(&mu);
    if (--readers == 0) pthread_cond_signal(&can_write);       /* last reader lets a writer in */
    pthread_mutex_unlock(&mu); }
static void wlock(void) { pthread_mutex_lock(&mu); waiting_writers++;
    while (writing || readers > 0) pthread_cond_wait(&can_write, &mu);
    waiting_writers--; writing = 1; pthread_mutex_unlock(&mu); }
static void wunlock(void) { pthread_mutex_lock(&mu); writing = 0;
    if (waiting_writers > 0) pthread_cond_signal(&can_write); else pthread_cond_broadcast(&can_read);
    pthread_mutex_unlock(&mu); }

static void *reader(void *a) { for (int i = 0; i < 20000; i++) { rlock(); in_r++; if (in_w) bad++; in_r--; runlock(); } return a; }
static void *writer(void *a) { for (int i = 0; i < 5000; i++) { wlock(); in_w++; if (in_r || in_w > 1) bad++; value++; in_w--; wunlock(); } return a; }
int main(void) {
    pthread_t t[6];
    for (int i = 0; i < 6; i++) pthread_create(&t[i], NULL, i < 4 ? reader : writer, NULL);
    for (int i = 0; i < 6; i++) pthread_join(t[i], NULL);
    printf("4 readers, 2 writers: value = %ld (expected 10000), violations = %d\n", value, bad);
}

আসল রানের আউটপুট:

4 readers, 2 writers: value = 10000 (expected 10000), violations = 0

দুজন writer ৫০০০ বার করে value++ করে — lock ঠিক না হলে lost update-এ ১০,০০০ মিলত না। পরীক্ষার জন্য দুটি atomic counter রাখা হয়েছে: critical section-এ ঢুকে reader দেখে কোনো writer ভেতরে আছে কি না, আর writer দেখে কোনো reader বা আরেক writer আছে কি না। ৮০,০০০ বার পড়া আর ১০,০০০ বার লেখায় একবারও invariant ভাঙেনি (violations = 0). Atomic counter-গুলো শুধু যাচাইয়ের জন্য, lock-এর অংশ নয় — ইন্টারভিউতে এমন “invariant checker” যোগ করার প্রস্তাব দিলে ভালো ছাপ পড়ে।

  • Starvation: writer preference-এ লেখা অবিরাম চললে reader-এর starvation হয়; reader preference-এ উল্টো। ন্যায্য সমাধান: আসার ক্রমে টিকিট (ticket), বা “একজন writer শেষে অপেক্ষমাণ সব reader আগে ঢুকবে” ধরনের পালা।
  • বাস্তবে: glibc-র pthread_rwlock_t ডিফল্টে reader-দের অগ্রাধিকার দেয়; pthread_rwlockattr_setkind_np(..., PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP) দিয়ে writer preference নেওয়া যায় (glibc-নির্দিষ্ট, _np = non-portable). Windows-এর SRWLOCK আর Linux kernel-এর rwsem-এর নিজস্ব নীতি আছে। rwlock তখনই লাভজনক যখন পড়ার কাজ দীর্ঘ আর অনেক; ছোট critical section-এ এর বাড়তি হিসাব একটি সাধারণ mutex-এর চেয়ে ধীরও হতে পারে।
  • Upgrade-এর ফাঁদ: দুজন reader একসাথে “write-এ upgrade” চাইলে দুজনেই অন্যজনের বেরোনোর অপেক্ষা করে — deadlock. তাই বেশিরভাগ API upgrade দেয় না; read lock ছেড়ে write lock নিন আর শর্ত আবার যাচাই করুন।
ফলো-আপ ৭পড়া খুব বেশি, লেখা খুব কম, আর reader-দের কখনো থামানো যাবে না — rwlock-এর চেয়ে ভালো কিছু আছে? (কঠিন)

উত্তর: rwlock-এ প্রতিটি reader-ও একটি shared counter বদলায়, তাই অনেক core-এ সেই cache line টানাটানি হয়। দুটি বিকল্প (পর্ব ২৯): seqlock — writer একটি sequence counter বাড়ায়, reader lock নেয় না, পড়ার আগে-পরে counter মিলিয়ে দেখে, না মিললে আবার পড়ে (ছোট, পয়েন্টারহীন ডেটার জন্য; Linux-এ সময়ের হিসাবে ব্যবহৃত)। আর RCU — writer নতুন কপি বানিয়ে একটি পয়েন্টার atomically বদলায়, পুরোনো কপি মুছে সব reader বেরোনোর পরে। দুটিতেই reader প্রায় বিনামূল্যে, writer-এর খরচ বেশি। User-space-এ liburcu লাইব্রেরি আছে। ইন্টারভিউতে নাম আর trade-off বলাই যথেষ্ট।

সমস্যা ৬ · C11 atomic দিয়ে spinlock, আর memory ordering (কঠিন)

রিভিশন: পর্ব ২৩ · Atomic instruction, spinlock আর memory ordering

সমস্যা: “<stdatomic.h> দিয়ে একটি spinlock লিখুন। প্রতিটি atomic কাজে কোন memory order দিলেন আর কেন, ব্যাখ্যা করুন।” প্রশ্নটির আসল লক্ষ্য দ্বিতীয় অংশ — memory ordering বোঝেন কি না।

যে প্রশ্নগুলো আগে করবেন: Critical section কত ছোট? (Spinlock শুধু খুব ছোট অংশের জন্য।) থ্রেডের সংখ্যা কি core-এর চেয়ে বেশি হতে পারে? (তাহলে ঘোরা মানে অপচয়।) Kernel-এর ভেতরে, নাকি user space-এ? Fairness লাগবে?

পদ্ধতি: একটি atomic_bool held. Lock: atomic_exchange(&held, true) — পুরোনো মান false হলে lock আমাদের (এটিই test-and-set). ব্যর্থ হলে সাধারণ load-এ ঘুরি যতক্ষণ held সত্য — একে বলে test-and-test-and-set: প্রতিটি ঘোরায় লেখা হয় না, তাই cache line বারবার এক core থেকে আরেক core-এ লাফায় না। Unlock: atomic_store(&held, false).

#include <pthread.h>
#include <sched.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>
typedef struct { atomic_bool held; } spinlock_t;

static void spin_lock(spinlock_t *s) {
    for (;;) {
        /* test-and-set; acquire: nothing in the critical section moves above this */
        if (!atomic_exchange_explicit(&s->held, true, memory_order_acquire)) return;
        while (atomic_load_explicit(&s->held, memory_order_relaxed))
            sched_yield();           /* test-and-test-and-set: wait with plain reads */
    }
}
static void spin_unlock(spinlock_t *s) {
    atomic_store_explicit(&s->held, false, memory_order_release);  /* publish our writes */
}
static spinlock_t lk;                /* static storage: held starts false */
static long counter;                 /* a plain variable, protected only by lk */
static void *work(void *a) {
    for (int i = 0; i < 100000; i++) { spin_lock(&lk); counter++; spin_unlock(&lk); }
    return a;
}
int main(void) {
    pthread_t t[4];
    for (int i = 0; i < 4; i++) pthread_create(&t[i], NULL, work, NULL);
    for (int i = 0; i < 4; i++) pthread_join(t[i], NULL);
    printf("counter = %ld (expected 400000)\n", counter);
}

আসল রানের আউটপুট:

counter = 400000 (expected 400000)

counter একটি সাধারণ (non-atomic) long — তাকে রক্ষা করছে শুধু আমাদের spinlock. ৪টি থ্রেড × ১ লাখবার = ৪,০০,০০০ ঠিক মিলেছে। মূল কথা memory order-এ: lock-এর exchange-এ acquire মানে critical section-এর কোনো load/store এর আগে সরে যেতে পারবে না; unlock-এর store-এ release মানে critical section-এর সব লেখা এর আগেই দৃশ্যমান হবে। ফলে পরের যে থ্রেড acquire দিয়ে lock পায়, সে আগের থ্রেডের সব লেখা দেখে (happens-before). ঘোরার load-এ relaxed যথেষ্ট, কারণ সেটি শুধু “চেষ্টা করার সময় হলো কি” দেখে; আসল দখল হয় আবার acquire exchange-এ। কন্টেইনারে CPU কম থাকতে পারে, তাই ঘোরার মধ্যে sched_yield() দেওয়া হয়েছে; বাস্তব spinlock সাধারণত সেখানে CPU-র “pause” নির্দেশ দেয় আর কিছুক্ষণ পরে ঘুমায়।

  • Relaxed দিলে কী হতো: x86-এ হয়তো কাজ করত, কারণ x86-এর মডেল (TSO) প্রায় সব reordering আটকায় — কিন্তু ARM বা POWER-এ critical section-এর লেখা unlock-এর পরে দৃশ্যমান হতে পারে, ফলে অন্য থ্রেড পুরোনো মান পড়বে। Compiler-ও relaxed পেলে load/store সরাতে পারে। “আমার ল্যাপটপে চলে” এখানে কিছুই প্রমাণ করে না।
  • seq_cst দিলে? সবসময় সঠিক (sequential consistency), শুধু কিছু architecture-এ সামান্য ধীর। ইন্টারভিউতে ভালো উত্তর: “ডিফল্ট seq_cst দিয়ে শুরু, প্রমাণ করতে পারলে তবেই acquire/release-এ নামি।”
  • volatile কেন নয়: volatile atomicity বা ordering দেয় না — শুধু compiler-কে প্রতিবার মেমরি ছুঁতে বাধ্য করে।
  • Spinlock কখন খারাপ: lock-ধারী থ্রেড preempt হলে বাকিরা পুরো time slice ঘুরে নষ্ট করে। তাই user space-এ সাধারণত mutex (Linux-এ futex-ভিত্তিক — আগে অল্প ঘোরে, তারপর kernel-এ ঘুমায়)। Kernel-এ spinlock নেওয়ার সময় preemption বন্ধ থাকে, আর interrupt handler-ও একই lock নিলে interrupt-ও বন্ধ করতে হয় (spin_lock_irqsave).
ফলো-আপ ৮এই spinlock ন্যায্য নয় — একটি থ্রেড বারবার জিততে পারে। ন্যায্য spinlock কীভাবে বানাবেন? (কঠিন)

উত্তর: Ticket lock: দুটি atomic counter — next_ticket আর now_serving. Lock: my = atomic_fetch_add(&next_ticket, 1), তারপর while (atomic_load_explicit(&now_serving, acquire) != my) pause;. Unlock: atomic_store_explicit(&now_serving, my + 1, release) (শুধু ধারী লেখে, তাই সাধারণ store-ই যথেষ্ট)। এটি ব্যাংকের টোকেন-কাউন্টারের মতো — আসার ক্রমে সেবা, তাই starvation নেই। Linux kernel বহু বছর x86-এ ticket spinlock ব্যবহার করেছে। দুর্বলতা: সবাই একই now_serving-এ ঘোরে, তাই অনেক core-এ cache line বাউন্স করে। প্রতিকার MCS lock (Mellor-Crummey ও Scott, ১৯৯১): প্রতিটি অপেক্ষমাণ নিজের node-এ ঘোরে; Linux-এর বর্তমান qspinlock এই ধারণা থেকে এসেছে।

৫ · Data structure-কে thread-safe করা — LRU cache আর token bucket

সমস্যা ৭ · Thread-safe LRU cache (মাঝারি থেকে কঠিন)

রিভিশন: পর্ব ৩৬ · Page replacement (LRU), DSA · Hash table, DSA · Linked list

সমস্যা: “ধারণক্ষমতা CAP-এর একটি cache — get(key) আর put(key, value), দুটোই O(1). জায়গা ফুরালে সবচেয়ে কম সম্প্রতি ব্যবহৃত (LRU) উপাদান বাদ যাবে। তারপর: অনেক থ্রেড থেকে নিরাপদ করুন।”

বাড়ির ফ্রিজের দরজার তাকে তিনটি বোতলের জায়গা। যেটি এইমাত্র ব্যবহার করলেন সেটি সামনে রাখেন; নতুন বোতল ঢোকাতে হলে সবচেয়ে পেছনের, অনেকদিন না-ছোঁয়া বোতলটি ভেতরের তাকে সরান। “কোথায় আছে” দ্রুত জানতে একটি তালিকা (hash), আর “কে সবচেয়ে পুরোনো” জানতে সারির ক্রম (linked list).

যে প্রশ্নগুলো আগে করবেন: key আর value-এর ধরন ও আকার? get-এ না পেলে কী ফেরত দেব? put একই key-তে হলে update আর MRU? পড়া বেশি না লেখা? মেমরি বাঁধা (CAP-এর বাইরে একটিও নয়)? মেয়াদ (TTL) লাগবে?

পদ্ধতি: একটি hash map (key → node) আর একটি doubly linked list (MRU সামনে, LRU পেছনে)। Hit হলে node-কে তালিকা থেকে খুলে সামনে বসাই — দুই দিকের পয়েন্টার থাকায় O(1). জায়গা ফুরালে পেছনের node বাদ দিই আর map থেকেও মুছি। একটি sentinel (ডামি head) রাখলে খালি তালিকার আলাদা কেস লাগে না। নিচের ছোট মূল অংশে map-টি সরাসরি একটি array (key ০–৯৯) — আসল hash table বসালে বাকি কোড একই থাকে।

#include <pthread.h>
#include <stdio.h>
enum { CAP = 3, KEYS = 100 };
typedef struct node { int key, val; struct node *prev, *next; } node;
static node *map[KEYS];                       /* key -> node; in real code a hash table */
static node head = { 0, 0, &head, &head };    /* sentinel: head.next = MRU, head.prev = LRU */
static node slots[CAP]; static int used;
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;   /* ONE lock guards map + list */
static void unlink_node(node *n) { n->prev->next = n->next; n->next->prev = n->prev; }
static void push_front(node *n) { n->next = head.next; n->prev = &head; head.next->prev = n; head.next = n; }
static int get(int k) {
    pthread_mutex_lock(&mu);
    node *n = map[k]; int v = -1;
    if (n) { unlink_node(n); push_front(n); v = n->val; }   /* a hit becomes MRU */
    pthread_mutex_unlock(&mu); return v;
}
static void put(int k, int v) {
    pthread_mutex_lock(&mu);
    node *n = map[k];
    if (n) unlink_node(n);                                   /* update in place */
    else if (used < CAP) n = &slots[used++];
    else { n = head.prev; unlink_node(n); map[n->key] = NULL; printf("evict %d\n", n->key); }
    n->key = k; n->val = v; map[k] = n; push_front(n); pthread_mutex_unlock(&mu);
}
int main(void) {
    int ops[][2] = { {1,10}, {2,20}, {3,30}, {1,0}, {4,40}, {2,0}, {3,0}, {5,50}, {1,0}, {4,0}, {5,0} };
    for (int i = 0; i < 11; i++) if (ops[i][1]) put(ops[i][0], ops[i][1]); else printf("get %d -> %d\n", ops[i][0], get(ops[i][0]));
    printf("MRU..LRU:"); for (node *n = head.next; n != &head; n = n->next) printf(" %d", n->key);
    printf("\n");
}

আসল রানের আউটপুট:

get 1 -> 10
evict 2
get 2 -> -1
get 3 -> 30
evict 1
get 1 -> -1
get 4 -> 40
get 5 -> 50
MRU..LRU: 5 4 3

হাতে মিলিয়ে দেখুন: put 1, 2, 3 → তালিকা (MRU থেকে) 3 2 1. get 1 hit → 1 3 2. put 4-এ জায়গা নেই, পেছনে 2 — evict 2 → 4 1 3. get 2 = −1 (miss). get 3 → 3 4 1. put 5-এ পেছনে 1 — evict 1 → 5 3 4. get 1 = −1, get 4 → 4 5 3, get 5 → 5 4 3 — শেষ লাইনের সাথে হুবহু মিলেছে। লক্ষ করুন, get-ও তালিকা বদলায় — এটিই thread-safety-র মূল ফাঁদ।

  • Thread-safety-র মূল কথা: map আর list একসাথে একটি invariant — “map-এ যে key আছে, তার node তালিকায় আছে, আর উল্টোটাও” — তাই দুটোকে একই lock রক্ষা করবে। আলাদা lock দিলে (একটি map-এ, একটি list-এ) মাঝখানে অন্য থ্রেড অর্ধেক-বদলানো অবস্থা দেখবে।
  • rwlock কেন সহজে চলে না: get শুধু “পড়া” মনে হলেও node সামনে আনে — অর্থাৎ লেখে। তাই get-এও exclusive lock লাগে; read lock নিলে দুই reader একই তালিকা একসাথে বদলাবে।
  • Lock ধরে ধীর কাজ নয়: miss হলে ডেটাবেস থেকে আনার কাজ lock ছেড়ে করুন, তারপর lock নিয়ে put — নইলে একটি ধীর query পুরো cache থামিয়ে দেবে। একই key-র জন্য বহু থ্রেড একসাথে miss করলে (“cache stampede”) প্রতি key-তে একটি “লোড হচ্ছে” চিহ্ন রাখুন।
  • জটিলতা: গড়ে O(1) প্রতি কাজ (hash-এর কারণে “গড়ে”), মেমরি O(CAP). একটি lock-এ throughput সীমিত — বড় সিস্টেমে key-এর hash দিয়ে cache-কে ১৬ বা ৬৪টি আলাদা ভাগে (shard) ভাঙা হয়, প্রতিটির নিজের lock আর নিজের LRU.
ফলো-আপ ৯প্রতিটি hit-এ তালিকা বদলানো অনেক core-এ বড় খরচ। বাস্তবের cache আর OS কীভাবে এড়ায়? (কঠিন)

উত্তর: নিখুঁত LRU-র বদলে আনুমানিক LRU. (১) Clock / second chance (পর্ব ৩৬): hit-এ শুধু একটি “reference bit” ১ করা — তালিকা বদলায় না, তাই প্রায় lock ছাড়াই চলে; সরানোর সময় একটি “কাঁটা” ঘুরে bit ০ এমন কাউকে বাছে। OS-এর page replacement ঠিক এই কারণেই নিখুঁত LRU করে না — প্রতিটি মেমরি-ব্যবহারে তালিকা বদলানো অসম্ভব। (২) Sampling: কয়েকটি এলোমেলো key দেখে তাদের মধ্যে সবচেয়ে পুরোনোটি বাদ — Redis এভাবে আনুমানিক LRU করে বলে তাদের ডকুমেন্টেশনে আছে। (৩) Buffer করা hit: hit-এর খবর প্রতি-থ্রেড buffer-এ জমিয়ে মাঝে মাঝে একবারে তালিকায় প্রয়োগ। ইন্টারভিউতে বলুন: “নিখুঁততা আর throughput-এর মধ্যে বিনিময়; hit-rate মেপে সিদ্ধান্ত নেব।”

সমস্যা ৮ · Rate limiter — token bucket (মাঝারি)

রিভিশন: পর্ব ২৪ · Mutex, পর্ব ৫৩ (clock), পর্ব ৫১ (cgroup দিয়ে সম্পদ সীমা)

সমস্যা: “একটি কাল্পনিক অ্যাপের OTP-SMS API-তে প্রতি ব্যবহারকারী সেকেন্ডে গড়ে ২টি অনুরোধের বেশি পাঠাতে পারবেন না, তবে হঠাৎ একসাথে ৩টি পর্যন্ত চলবে। allow(user, now) লিখুন।” স্বাভাবিক সমাধান token bucket: একটি বালতিতে সর্বোচ্চ ৩টি টোকেন, প্রতি সেকেন্ডে ২টি করে ভরে; প্রতিটি অনুরোধ একটি টোকেন খরচ করে, টোকেন না থাকলে প্রত্যাখ্যান।

যে প্রশ্নগুলো আগে করবেন: সীমা কার — প্রতি ব্যবহারকারী, প্রতি IP, নাকি পুরো সার্ভিস? প্রত্যাখ্যান মানে সঙ্গে সঙ্গে “না” (HTTP 429), নাকি অপেক্ষা করানো? একটি সার্ভার, নাকি অনেক সার্ভার (তাহলে shared store লাগবে)? সময়ের উৎস কী?

পদ্ধতি: একটি টাইমার থ্রেড দিয়ে প্রতি মুহূর্তে টোকেন ভরার দরকার নেই — lazy refill: প্রতিটি allow-এ শেষ হিসাবের পর কত সময় গেছে দেখে টোকেন যোগ করুন (সর্বোচ্চ cap পর্যন্ত)। ভগ্নাংশ টোকেনের rounding এড়াতে হিসাব পূর্ণসংখ্যায় — “মিলি-টোকেন”-এ। পরীক্ষা নির্ধারিত রাখতে আসল ঘড়ির বদলে সময় argument হিসেবে দেওয়া হয়েছে; বাস্তবে সেটি আসবে monotonic clock থেকে।

#include <pthread.h>
#include <stdio.h>
/* token bucket in integer milli-tokens (mt), so there is no floating-point rounding */
typedef struct {
    long tokens_m, cap_m, rate_m_per_ms;   /* cap 3 tokens = 3000 mt; 2 tokens/s = 2 mt per ms */
    long last_ms;
    pthread_mutex_t mu;                    /* one bucket may be shared by many threads */
} bucket;

static int allow(bucket *b, long now_ms) {
    pthread_mutex_lock(&b->mu);
    b->tokens_m += (now_ms - b->last_ms) * b->rate_m_per_ms;   /* lazy refill */
    if (b->tokens_m > b->cap_m) b->tokens_m = b->cap_m;
    b->last_ms = now_ms;
    int ok = b->tokens_m >= 1000;
    if (ok) b->tokens_m -= 1000;                               /* one request costs 1 token */
    pthread_mutex_unlock(&b->mu);
    return ok;
}
int main(void) {
    bucket b = { 3000, 3000, 2, 0, PTHREAD_MUTEX_INITIALIZER };   /* starts full */
    long t[] = { 0, 0, 0, 0, 100, 400, 500, 900, 1000, 1000, 3000, 3000, 3000, 3000 };
    int n = sizeof t / sizeof t[0], passed = 0;
    for (int i = 0; i < n; i++) {
        int ok = allow(&b, t[i]); passed += ok;
        printf("t=%4ld ms  %s  tokens left=%ld.%03ld\n", t[i], ok ? "ALLOW" : "deny ", b.tokens_m / 1000, b.tokens_m % 1000);
    }
    printf("allowed %d of %d\n", passed, n);
}

আসল রানের আউটপুট:

t=   0 ms  ALLOW  tokens left=2.000
t=   0 ms  ALLOW  tokens left=1.000
t=   0 ms  ALLOW  tokens left=0.000
t=   0 ms  deny   tokens left=0.000
t= 100 ms  deny   tokens left=0.200
t= 400 ms  deny   tokens left=0.800
t= 500 ms  ALLOW  tokens left=0.000
t= 900 ms  deny   tokens left=0.800
t=1000 ms  ALLOW  tokens left=0.000
t=1000 ms  deny   tokens left=0.000
t=3000 ms  ALLOW  tokens left=2.000
t=3000 ms  ALLOW  tokens left=1.000
t=3000 ms  ALLOW  tokens left=0.000
t=3000 ms  deny   tokens left=0.000
allowed 8 of 14

হাতে মিলিয়ে দেখুন। শুরুতে বালতি ভর্তি (৩), তাই t = ০-তে প্রথম ৩টি অনুরোধ পাস, চতুর্থটি নয় — এটিই “burst”. হার ২ টোকেন/সেকেন্ড = প্রতি মিলিসেকেন্ডে ০.০০২: ১০০ ms-এ ০.২ জমে (প্রত্যাখ্যান), ৪০০ ms-এ আরও ০.৬ মিলে ০.৮, ৫০০ ms-এ ঠিক ১.০ — পাস, আবার ০. ৯০০ ms-এ ০.৮, ১০০০ ms-এ ১.০ — পাস। এরপর ২ সেকেন্ড চুপ: হিসাবে ৪ টোকেন জমার কথা, কিন্তু cap ৩ — তাই t = ৩০০০-তে আবার ঠিক ৩টি পাস। মোট ১৪টির মধ্যে ৮টি। লম্বা সময়ে গড় হার ২/সেকেন্ডের বেশি হতে পারে না, আর burst কখনো ৩-এর বেশি নয়।

  • Thread-safety: refill আর খরচ — পড়া, হিসাব, লেখা — একটি read-modify-write; দুই থ্রেড একসাথে করলে দুজনেই শেষ টোকেনটি খরচ করে ফেলবে। তাই প্রতিটি বালতির নিজের mutex (বহু ব্যবহারকারী হলে প্রতি বালতিতে আলাদা lock, পুরো map-এর জন্য আরেকটি)। Lock-free রূপ: পুরো অবস্থা একটি ৬৪-বিট শব্দে ভরে CAS লুপ — সম্ভব, তবে প্রথম উত্তর হিসেবে mutex-ই ভালো।
  • ঘড়ি: CLOCK_REALTIME নয় — NTP বা মানুষ ঘড়ি পিছিয়ে দিলে now − last ঋণাত্মক হবে। clock_gettime(CLOCK_MONOTONIC) ব্যবহার করুন, আর সময় কখনো পিছালে refill শূন্য ধরুন।
  • অন্য কৌশল: fixed window (“এই মিনিটে ১০০টি” — মিনিটের সীমায় দ্বিগুণ burst হয়), sliding window log (নিখুঁত, কিন্তু প্রতি অনুরোধের সময় রাখতে হয়), leaky bucket (নির্দিষ্ট হারে বের করে, queue-তে রাখে)। Linux-এর tc-তে নেটওয়ার্ক traffic আকার দেওয়ার জন্য token bucket filter (tbf) আছে।
  • জটিলতা: প্রতি অনুরোধ O(1), প্রতি বালতিতে O(1) মেমরি — এ কারণেই token bucket এত জনপ্রিয়।
ফলো-আপ ১০একই ব্যবহারকারীর অনুরোধ ১০টি আলাদা সার্ভারে যায়। সীমা কীভাবে একসাথে রাখবেন? (কঠিন)

উত্তর: প্রতিটি সার্ভারের নিজের বালতি রাখলে আসল সীমা ১০ গুণ হয়ে যায়। বিকল্প: (১) Shared store — Redis-এর মতো একটি দ্রুত store-এ বালতির অবস্থা, আর refill-ও-খরচ একটি atomic কাজ হিসেবে (store-এর server-side script বা transaction) — প্রতিটি অনুরোধে একটি network round trip বাড়ে। (২) Sticky routing — load balancer একই ব্যবহারকারীকে সবসময় একই সার্ভারে পাঠায়; সার্ভার পড়ে গেলে সীমা সাময়িক ঢিলে হয়। (৩) ভাগ করা কোটা — প্রতিটি সার্ভার মোট হারের ১/১০ পায়, মাঝে মাঝে মিলিয়ে নেয়; আনুমানিক কিন্তু দ্রুত। আর ঘড়ি: সার্ভারগুলোর ঘড়ি কখনো পুরো মেলে না (পর্ব ৫৩), তাই সময়ের হিসাব store-এর ঘড়িতে করাই নিরাপদ।

৬ · মেমরি আর deadlock — memory pool আর ওয়ালেট ট্রান্সফার

সমস্যা ৯ · নির্দিষ্ট আকারের memory pool allocator (মাঝারি)

রিভিশন: পর্ব ৩৮ · Linux-এ মেমরি (slab), পর্ব ৩১ (fragmentation), পর্ব ৫৯ (নিজের malloc)

সমস্যা: “একটি নেটওয়ার্ক সার্ভার প্রতি সেকেন্ডে হাজার হাজার একই আকারের (ধরুন ৪৮ বাইট) struct বানায় আর মুছে। malloc-এর চেয়ে দ্রুত, fragmentation-হীন একটি memory pool লিখুন — pool_alloc() আর pool_free(p), দুটোই O(1).”

রেস্তোরাঁয় নির্দিষ্ট মাপের ৪০টি ট্রে। খালি ট্রেগুলো এক কোণে একটির ওপর আরেকটি সাজানো থাকে; ওয়েটার ওপরেরটি তুলে নেন, ফেরত এলে আবার ওপরে রাখেন। কোন ট্রে খালি, তা খোঁজার দরকার নেই — স্তূপের ওপরেরটাই। আর সব ট্রে একই মাপের বলে “ছোট ফাঁক”-এর সমস্যাই নেই।

যে প্রশ্নগুলো আগে করবেন: block-এর আকার কি স্থির? মোট কতটি — শুরুতেই ঠিক, নাকি দরকারে বাড়বে? Alignment কত লাগবে? Pool শেষ হলে NULL, নাকি নতুন chunk? একাধিক থ্রেড ব্যবহার করবে? ডিবাগে double free ধরতে হবে?

পদ্ধতি: একটি বড় array-কে সমান block-এ ভাগ করুন। খালি block-গুলো একটি একমুখী free list-এ যুক্ত — আর লিংকটি রাখা হয় block-এর ভেতরেই (খালি block-এর মেমরি তো অব্যবহৃত), তাই বাড়তি মেমরি শূন্য। Alloc = তালিকার মাথা তোলা, free = মাথায় বসানো — দুটোই O(1), একটি stack-এর মতো। union-এর alignas(max_align_t) নিশ্চিত করে প্রতিটি block যেকোনো ধরনের ডেটার জন্য যথেষ্ট aligned.

#include <stdalign.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
enum { BLK = 48, NBLK = 4 };
typedef union block {                 /* a FREE block keeps the "next" link inside itself */
    union block *next;
    alignas(max_align_t) unsigned char data[BLK];
} block;
static block arena[NBLK], *free_list;
static void pool_init(void) {
    for (int i = 0; i < NBLK - 1; i++) arena[i].next = &arena[i + 1];
    arena[NBLK - 1].next = NULL; free_list = &arena[0];
}
static void *pool_alloc(void) {       /* O(1): pop the head; NULL = pool exhausted */
    block *b = free_list; if (b) free_list = b->next; return b;
}
static void pool_free(void *p) {      /* O(1): push back on the head */
    block *b = p; b->next = free_list; free_list = b;
}
static int idx(void *p) { return p ? (int)((block *)p - arena) : -1; }
int main(void) {
    pool_init(); void *a[5];
    for (int i = 0; i < 5; i++) a[i] = pool_alloc();
    printf("5 allocs -> blocks %d %d %d %d %d (-1 = NULL)\n", idx(a[0]), idx(a[1]), idx(a[2]), idx(a[3]), idx(a[4]));
    pool_free(a[1]); pool_free(a[3]);
    void *x = pool_alloc(), *y = pool_alloc();
    printf("free 1, free 3 -> next allocs %d %d (LIFO reuse)\n", idx(x), idx(y));
    printf("block size %zu bytes, 16-byte aligned: %s\n", sizeof(block), (uintptr_t)x % 16 == 0 ? "yes" : "no");
}

আসল রানের আউটপুট:

5 allocs -> blocks 0 1 2 3 -1 (-1 = NULL)
free 1, free 3 -> next allocs 3 1 (LIFO reuse)
block size 48 bytes, 16-byte aligned: yes

চারটি block-এর pool থেকে পঞ্চম অনুরোধে NULL — সীমা মানা হচ্ছে। Block 1 তারপর 3 ফেরত দিলে free list-এর মাথায় এখন 3, তার পরে 1 — তাই পরের দুটি alloc পায় 3 আর 1, উল্টো ক্রমে (LIFO). এটি ভালো: সদ্য-মুক্ত block সম্ভবত এখনো CPU cache-এ আছে। Block-এর আকার ৪৮ বাইট আর ঠিকানা ১৬-এর গুণিতক, কারণ x86-64 Linux-এ max_align_t-এর alignment ১৬. ঠিকানাগুলো নিজে না ছেপে array-এর index ছাপা হয়েছে, কারণ ঠিকানা প্রতি রানে বদলায়।

  • Thread-safety: সবচেয়ে সরল — একটি mutex দিয়ে alloc আর free ঘিরে দেওয়া। দ্রুততর: প্রতি-থ্রেড ছোট cache (glibc-র tcache বা jemalloc-এর thread cache-এর মতো) আর মাঝে মাঝে কেন্দ্রীয় pool থেকে একসাথে কয়েকটি আনা। Lock-free free list (CAS দিয়ে মাথা বদলানো) সম্ভব, কিন্তু সেখানে ABA সমস্যা ঠিক এই কাঠামোতেই দেখা দেয় — তাই tagged pointer বা hazard pointer লাগে।
  • ফাঁদ: double free হলে একই block তালিকায় দুবার ঢোকে, পরে দুজন একই মেমরি পায় — নীরব দুর্নীতি। ডিবাগ build-এ প্রতিটি block-এ একটি “used” চিহ্ন বা magic সংখ্যা রাখুন, free-র সময় যাচাই করুন; pool-এর বাইরের পয়েন্টার free হচ্ছে কি না-ও দেখুন (ঠিকানা arena-র সীমার মধ্যে আর block-আকারের গুণিতকে)।
  • বাস্তবে: এটিই slab allocator-এর মূল ধারণা — Jeff Bonwick ১৯৯৪ সালে SunOS-এর জন্য প্রকাশ করেন, আর Linux kernel-এর kmem_cache (SLUB) একই পথে চলে (পর্ব ৩৮)। Internal fragmentation শুধু তখন, যখন ব্যবহৃত struct block-এর চেয়ে ছোট।
ফলো-আপ ১১শুধু একটি আকার নয় — ১৬, ৩২, ৬৪ … ৫১২ বাইটের অনুরোধ এলে pool কীভাবে বাড়াবেন? (মাঝারি)

উত্তর: “size class”: প্রতিটি আকারের জন্য আলাদা pool, অনুরোধকে পরের বড় class-এ গোল করুন (৪০ বাইট → ৬৪-এর pool). খরচ: internal fragmentation সর্বোচ্চ প্রায় অর্ধেক; ক্লাসগুলো ঘন রাখলে (১৬, ২৪, ৩২, ৪৮, ৬৪ …) অপচয় কমে কিন্তু pool-এর সংখ্যা বাড়ে। Pool ফুরালে একটি বড় chunk (যেমন ৬৪ KiB, mmap দিয়ে) এনে block-এ ভাগ করুন। free(p)-এ কোন class, তা জানতে হয় header-এ আকার রাখুন, নয়তো chunk-এর ঠিকানা থেকে বের করুন (chunk aligned রাখলে p & ~(CHUNK-1) দিয়ে chunk-এর মাথা পাওয়া যায়, সেখানে class লেখা)। বড় অনুরোধ (ধরুন ২৫৬ KiB-এর বেশি) সরাসরি mmap-এ। আধুনিক general-purpose allocator-গুলো মোটামুটি এই নকশাই মানে।

সমস্যা ১০ · ওয়ালেট থেকে ওয়ালেটে টাকা — deadlock-মুক্ত transfer (মাঝারি)

রিভিশন: পর্ব ২৭ · Deadlock (চারটি শর্ত, lock hierarchy), DBMS · Concurrency control

সমস্যা: “একটি কাল্পনিক মোবাইল-ওয়ালেট অ্যাপ (bKash বা Nagad-ধাঁচের) — প্রতিটি ওয়ালেটে একটি mutex আর balance. transfer(from, to, amount) লিখুন, যা একসাথে হাজার থ্রেড থেকে ডাকা হবে: টাকা হারাবে না, balance ঋণাত্মক হবে না, আর কখনো deadlock হবে না।”

প্রথমে সবাই যা লেখেন — দেখতে ঠিক, অথচ বিপজ্জনক:

/* naive version -- looks right, can deadlock */
void transfer(wallet *from, wallet *to, long amount) {
    pthread_mutex_lock(&from->mu);      /* T1: lock A    T2: lock B   */
    pthread_mutex_lock(&to->mu);        /* T1: wants B   T2: wants A  -> stuck forever */
    if (from->balance >= amount) { from->balance -= amount; to->balance += amount; }
    pthread_mutex_unlock(&to->mu);
    pthread_mutex_unlock(&from->mu);
}

রহিম করিমকে টাকা পাঠাচ্ছেন (T1: A → B), আর একই মুহূর্তে করিম রহিমকে (T2: B → A). T1 A ধরে, T2 B ধরে, তারপর দুজনেই অন্যজনেরটির জন্য চিরকাল অপেক্ষা। ধাপে ধাপে দেখুন, আর সমাধানে কী বদলায়:

1// naive: lock(from); lock(to);2lock(&from->mu);3lock(&to->mu);      // may wait forever4// fixed: lower address first5first = min(from, to); second = max(from, to);6lock(&first->mu); lock(&second->mu);7check balance; move; unlock(second); unlock(first);
কে কোন lock ধরে / চায় (A-র ঠিকানা B-র চেয়ে ছোট)
A-র mutex
B-র mutex
T1 (A → B)
T2 (B → A)
wait-for চক্র?
  1. Naive: T1 A ধরল। T1 = “A থেকে B-তে”, তাই প্রথমে from = A-র mutex. এখনো কোনো সমস্যা নেই — B খালি।
  2. T2 B ধরল। T2 = “B থেকে A-তে”, তার from = B. দুজনেই একটি করে lock ধরে আছে — hold and wait-এর প্রথম অর্ধেক।
  3. T1 B চাইল। B T2-র হাতে, তাই T1 ঘুমায় — A ছেড়ে নয়, A ধরে রেখেই। Mutex জোর করে কেড়ে নেওয়া যায় না (no preemption).
  4. T2 A চাইল — deadlock. A T1-এর হাতে। এখন T1 অপেক্ষা করছে T2-র জন্য, T2 অপেক্ষা করছে T1-এর জন্য — circular wait. চারটি শর্তই পূর্ণ (পর্ব ২৭), কেউ আর এগোবে না। পরীক্ষায় হয়তো লাখে একবার ঘটে — production-এ ঈদের আগের রাতের ভিড়ে।
  5. সমাধান: সবাই আগে ছোট ঠিকানা। A-র ঠিকানা ছোট ধরে নিই। T1-এর প্রথম lock A. T2-ও — যদিও তার from B — প্রথমে A চায়। দুজনেই একই lock-এর জন্য প্রতিযোগিতা করছে।
  6. T2 অপেক্ষা করছে, কিন্তু হাতে কিছু নেই। T1 A পেয়েছে, তারপর B-ও পায় (কেউ ধরে নেই), টাকা সরিয়ে দুটি ছাড়ে। T2 A-র অপেক্ষায়, তবে কিছু না ধরে — তাই চক্র তৈরি হতে পারে না। সব থ্রেড একই ক্রমে lock নিলে wait-for graph-এর সব তীর এক দিকে যায়, চক্র অসম্ভব।
  7. T2 এগোল। T1 ছাড়ার পর T2 A, তারপর B পায়, টাকা সরায়। দুটি transfer-ই শেষ, deadlock নেই। মোট টাকা বদলায়নি, কারণ যাচাই আর সরানো দুটি lock ধরে একসাথে হয়েছে।
#include <pthread.h>
#include <stdio.h>

typedef struct { pthread_mutex_t mu; long balance; } wallet;
static wallet w[2] = { { PTHREAD_MUTEX_INITIALIZER, 5000 }, { PTHREAD_MUTEX_INITIALIZER, 5000 } };

static int transfer(wallet *from, wallet *to, long amount) {
    if (from == to) return 0;                       /* same wallet: never lock it twice */
    wallet *first  = from < to ? from : to;         /* global order: lower address first */
    wallet *second = from < to ? to : from;         /* (both are elements of one array) */
    pthread_mutex_lock(&first->mu);
    pthread_mutex_lock(&second->mu);
    int ok = from->balance >= amount;               /* check and move under BOTH locks */
    if (ok) { from->balance -= amount; to->balance += amount; }
    pthread_mutex_unlock(&second->mu);
    pthread_mutex_unlock(&first->mu);
    return ok;
}

static void *a_to_b(void *x) { (void)x; for (int i = 0; i < 200000; i++) transfer(&w[0], &w[1], 3); return NULL; }
static void *b_to_a(void *x) { (void)x; for (int i = 0; i < 200000; i++) transfer(&w[1], &w[0], 2); return NULL; }

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, a_to_b, NULL); pthread_create(&t2, NULL, b_to_a, NULL);
    pthread_join(t1, NULL); pthread_join(t2, NULL);           /* returns => no deadlock */
    printf("both threads finished; total = %ld (started with 10000)\n", w[0].balance + w[1].balance);
    printf("no wallet negative: %s\n", w[0].balance >= 0 && w[1].balance >= 0 ? "yes" : "no");
    return 0;
}

আসল রানের আউটপুট:

both threads finished; total = 10000 (started with 10000)
no wallet negative: yes

দুটি থ্রেড উল্টো দিকে ২ লাখবার করে transfer করেছে — naive সংস্করণে এমন চাপে প্রোগ্রাম সাধারণত আটকে যায় (তাই সেটি এখানে চালানো হয়নি)। এখানে দুটি join-ই ফিরেছে, অর্থাৎ deadlock হয়নি। মোট টাকা ১০,০০০ অপরিবর্তিত, আর কোনো ওয়ালেট ঋণাত্মক নয় — কারণ balance যাচাই আর সরানো দুটি lock ধরে একসাথে। শেষ balance-গুলো আলাদা আলাদা রানে আলাদা হতে পারে (কোন transfer কখন টাকা পায়নি, তার ওপর নির্ভর করে), তাই শুধু মোট আর শর্ত ছাপা হয়েছে।

  • ঠিকানা তুলনার সূক্ষ্মতা: C-তে < দিয়ে দুটি পয়েন্টার তুলনা তখনই সংজ্ঞায়িত, যখন দুটি একই array-র ঘর — এখানে তাই। আলাদা malloc করা ওয়ালেট হলে প্রতিটি ওয়ালেটের একটি অনন্য id (অ্যাকাউন্ট নম্বর) দিয়ে ক্রম ঠিক করুন — এটিই বেশি পরিষ্কার, আর আলাদা সার্ভারেও কাজ করে। (uintptr_t)-এ রূপান্তর করে তুলনাও বাস্তবে চলে।
  • from == to: একই non-recursive mutex দুবার নিলে থ্রেড নিজেই নিজেকে আটকায়। তাই শুরুতে আলাদা কেস।
  • অন্য পথ: pthread_mutex_trylock দিয়ে দ্বিতীয়টি চেষ্টা — না পেলে প্রথমটি ছেড়ে কিছুক্ষণ পরে আবার (backoff); এতে deadlock নেই, কিন্তু livelock-এর ঝুঁকি। বা একটি global lock — সঠিক, কিন্তু সব transfer এক লাইনে। ডেটাবেসে একই সমস্যা transaction-এর lock-এ হয়, আর DBMS সাধারণত deadlock ধরে একটি transaction বাতিল করে।
  • বাস্তবে: Linux kernel-এ একসাথে দুটি একই ধরনের lock নিতে হলে (যেমন দুটি runqueue) একটি নির্দিষ্ট ক্রম মানা হয়, আর lockdep টুল চালানোর সময় lock-ক্রমের লঙ্ঘন ধরে (পর্ব ২৭)। User space-এ TSan-ও “lock-order-inversion” সতর্কবার্তা দেয়।
ফলো-আপ ১২একটি transfer-এ তিনটি ওয়ালেট লাগে (A → B, সাথে C-তে ফি)। নিয়ম কীভাবে বাড়াবেন? আর lock ধরে কোন কাজ কখনো করবেন না? (মাঝারি)

উত্তর: যতগুলো lock লাগবে, আগে সবগুলোর তালিকা বানিয়ে একই ক্রমে (id বা ঠিকানা অনুযায়ী sort করে) নিন, আর ডুপ্লিকেট বাদ দিন (A আর C একই হতে পারে)। এটিই lock ordering-এর সাধারণ নিয়ম: যেকোনো দুটি lock-এর ক্ষেত্রে সব থ্রেড একই আপেক্ষিক ক্রম মানলে চক্র অসম্ভব। Lock ধরে যা করবেন না: নেটওয়ার্ক বা ডিস্ক I/O (SMS পাঠানো, লগ ফাইলে লেখা), অজানা callback ডাকা (callback হয়তো আরেকটি lock নেবে — ক্রম ভেঙে যাবে), আর অন্য থ্রেডের join. ব্যবহারিক নিয়ম: lock-এর ভেতরে শুধু shared ডেটা পড়া-লেখা; বাকিটা lock ছেড়ে।

৭ · System call-এর কোড — cp আর ls | wc -l

সমস্যা ১১ · cp লিখুন — partial write আর EINTR সামলে (মাঝারি)

রিভিশন: পর্ব ৪১ · File descriptor, open/read/write, পর্ব ১৩ · Signal

সমস্যা: “শুধু open, read, write, close দিয়ে একটি ফাইল কপি করুন। যেকোনো আকারের ফাইল, আর কোনো বাইট হারাবে না।” সহজ মনে হয় — কিন্তু ইন্টারভিউয়ার দেখতে চান আপনি জানেন যে write চাওয়ার চেয়ে কম লিখতে পারে, আর signal এসে read/write-কে বাধা দিতে পারে।

কলসি থেকে বালতিতে পানি ঢালছেন, কিন্তু বালতির মুখ সরু — একবারে পুরোটা ঢোকে না। যতটুকু ঢুকল, ততটুকু বাদ দিয়ে বাকিটা আবার ঢালেন। আর কেউ ডাক দিলে (signal) হাত থামে — কলসি নামিয়ে রাখেন না, উত্তর দিয়ে আবার ঢালতে শুরু করেন।

যে প্রশ্নগুলো আগে করবেন: উৎস কি সবসময় সাধারণ ফাইল, নাকি pipe বা socket-ও হতে পারে? গন্তব্য থাকলে overwrite? Permission আর সময় কপি করতে হবে? ডিস্ক ভরে গেলে কী আচরণ? বড় ফাইলে গতি গুরুত্বপূর্ণ (তাহলে buffer-এর আকার, বা copy_file_range/sendfile)?

পদ্ধতি: দুটি লুপ। বাইরের লুপ: read যত বাইট দেয় (r), r == 0 মানে EOF. ভেতরের write_all: যতক্ষণ বাকি থাকে, write; যা লেখা হলো ততটুকু সরে যান — একে বলে short write সামলানো। দুই জায়গাতেই −১ আর errno == EINTR হলে শুধু আবার চেষ্টা; অন্য ত্রুটিতে থামুন। শেষে close-এর ফেরত মানও দেখুন — কিছু ফাইল সিস্টেম (যেমন NFS) লেখার ত্রুটি সেখানে জানায়।

#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
static int write_all(int fd, const char *p, size_t n) {
    while (n > 0) {
        ssize_t w = write(fd, p, n);
        if (w < 0) { if (errno == EINTR) continue; return -1; }   /* interrupted: retry */
        p += w; n -= (size_t)w;                                    /* short write: go on */
    }
    return 0;
}
static long copy_fd(int in, int out) {
    char buf[4096]; long total = 0;
    for (;;) {
        ssize_t r = read(in, buf, sizeof buf);
        if (r == 0) return total;                                  /* EOF */
        if (r < 0) { if (errno == EINTR) continue; return -1; }
        if (write_all(out, buf, (size_t)r) < 0) return -1;
        total += r;
    }
}
int main(void) {
    static char big[100000]; int p[2]; pipe(p); fcntl(p[1], F_SETFL, O_NONBLOCK);  /* nobody reads p[0] */
    printf("one write() of %zu bytes into a pipe returned %zd\n", sizeof big, write(p[1], big, sizeof big));
    int src = open("src.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); write_all(src, big, sizeof big); close(src);
    int in = open("src.txt", O_RDONLY), out = open("dst.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    long n = copy_fd(in, out); int rc = close(out); close(in);      /* close() can report errors too */
    printf("copied %ld bytes, close(out) = %d\n", n, rc);
}

আসল রানের আউটপুট:

one write() of 100000 bytes into a pipe returned 65536
copied 100000 bytes, close(out) = 0

প্রথম লাইনটিই পুরো সমস্যার প্রমাণ: ১,০০,০০০ বাইট লিখতে বলা হয়েছিল, কিন্তু write ফেরত দিল 65536 — কারণ Linux-এ pipe-এর ডিফল্ট ধারণক্ষমতা ৬৫,৫৩৬ বাইট (১৬টি ৪ KiB page), আর non-blocking pipe-এ বাকিটা লেখা না গিয়েই কল ফিরে আসে। যে কোড একবার write ডেকে ধরে নেয় সব লেখা হয়ে গেছে, সে এখানে ৩৪,৪৬৪ বাইট নীরবে হারাত। সাধারণ ফাইলে short write কম ঘটে (ডিস্ক ভরে গেলে, ফাইল-আকারের সীমায়, বা signal-এ), কিন্তু pipe আর socket-এ খুবই স্বাভাবিক। দ্বিতীয় লাইন: আমাদের copy_fd পুরো ১,০০,০০০ বাইট কপি করেছে আর close সফল (0).

  • EINTR কখন আসে: একটি signal handler চলার সময় যদি থ্রেডটি ধীর কোনো system call-এ (pipe, socket, terminal থেকে read) আটকে থাকে, kernel কলটি ফিরিয়ে দেয় −১ আর EINTR দিয়ে — অথবা handler SA_RESTART দিয়ে বসানো হলে অনেক কল নিজে আবার শুরু হয় (পর্ব ১৩)। কোন কল restart হয়, তা OS আর কলভেদে আলাদা — তাই নির্ভরযোগ্য কোড দুই ক্ষেত্রেই কাজ করে।
  • Buffer-এর আকার: ৪ KiB কাজ করে; ৬৪–১২৮ KiB-এ system call কম লাগে। আরও দ্রুত: Linux-এ copy_file_range বা sendfile — ডেটা user space-এ না এনে kernel-এর ভেতরেই কপি, কিছু ফাইল সিস্টেমে (যেমন Btrfs, XFS) নতুন kernel-এ ব্লক ভাগাভাগি করেও (reflink) কপি হতে পারে।
  • সত্যিই ডিস্কে? write সফল মানে ডেটা page cache-এ পৌঁছেছে, ডিস্কে নয়। বিদ্যুৎ গেলে হারাতে পারে। নিশ্চিত হতে fsync(out), আর নিরাপদ “replace” করতে অস্থায়ী নামে লিখে fsync, তারপর rename (পর্ব ৪১, ৪৪)।
  • জটিলতা: O(n) বাইট, O(n / buffer) system call; মেমরি O(buffer).
ফলো-আপ ১৩কেউ বলল, “fopen/fread/fwrite দিলে তো এসব ঝামেলা নেই।” ঠিক কি? (মাঝারি)

উত্তর: আংশিক ঠিক। stdio নিজের buffer রাখে আর ভেতরে short write-এর লুপ নিজেই চালায়, তাই সাধারণ ফাইল-কপিতে সুবিধাজনক। কিন্তু (১) fwrite সফল মানে ডেটা stdio-র buffer-এ — ডিস্ক বা kernel-এ পৌঁছানোর ত্রুটি আসে পরে fflush বা fclose-এ; তাদের ফেরত মান না দেখলে ত্রুটি হারায়। (২) fread কম দিলে feof আর ferror দুটোই দেখতে হয়। (৩) fork-এর আগে fflush না করলে buffer-এর ডেটা দুবার ছাপা হয় (পর্ব ১০)। (৪) pipe বা socket-এ, non-blocking I/O-তে, বা poll/epoll-এর সাথে stdio মেলে না — সেখানে নিচের স্তরের read/write-ই লাগে। ইন্টারভিউতে দুই স্তরের পার্থক্য বলতে পারাই আসল পয়েন্ট।

সমস্যা ১২ · Shell-এর মতো ls | wc -l চালান — fork, pipe, dup2 (মাঝারি)

রিভিশন: পর্ব ১০ · fork, exec আর wait, পর্ব ১১ · Shell, পর্ব ১২ · Pipe

সমস্যা: “একটি C প্রোগ্রাম লিখুন, যা shell-এর মতো ls demo | wc -l চালায়: প্রথম প্রোগ্রামের stdout যাবে দ্বিতীয়টির stdin-এ, আর parent দুজনের শেষের অপেক্ষা করবে।” এটি ছোট একটি shell-এর হৃদয় (পর্ব ৫৯-এর ক্যাপস্টোন)।

যে প্রশ্নগুলো আগে করবেন: দুটি কমান্ড, নাকি যেকোনো সংখ্যার pipeline? Exit status কার — শেষ কমান্ডের (shell-এর নিয়ম), নাকি সবার? exec ব্যর্থ হলে কী ছাপবেন? PATH-এ খুঁজতে হবে (execvp/execlp)?

পদ্ধতি: (১) pipe(fd) — fd[0] পড়ার মুখ, fd[1] লেখার মুখ। (২) প্রথম child: dup2(fd[1], 1) — stdout এখন pipe; তারপর দুটি মুখই বন্ধ, তারপর exec. (৩) দ্বিতীয় child: dup2(fd[0], 0), দুটি মুখ বন্ধ, exec. (৪) Parent: দুটি মুখ বন্ধ, তারপর দুজনকে waitpid. fork-এর আগে fflush(stdout) — নইলে parent-এর buffer-এ থাকা লেখা child-এর কপিতেও থাকে আর দুবার ছাপা হতে পারে।

#include <stdio.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    mkdir("demo", 0755);                                   /* a folder with exactly 3 files */
    const char *names[] = { "demo/a.txt", "demo/b.txt", "demo/c.txt" };
    for (int i = 0; i < 3; i++) { FILE *f = fopen(names[i], "w"); if (f) fclose(f); }

    int fd[2];
    if (pipe(fd) < 0) { perror("pipe"); return 1; }
    fflush(stdout);                                        /* nothing buffered gets copied by fork */
    pid_t left = fork();
    if (left == 0) {                                       /* child 1: ls demo  -> pipe */
        dup2(fd[1], STDOUT_FILENO); close(fd[0]); close(fd[1]);
        execlp("ls", "ls", "demo", (char *)NULL); perror("exec ls"); _exit(127);
    }
    pid_t right = fork();
    if (right == 0) {                                      /* child 2: pipe -> wc -l */
        dup2(fd[0], STDIN_FILENO); close(fd[0]); close(fd[1]);
        execlp("wc", "wc", "-l", (char *)NULL); perror("exec wc"); _exit(127);
    }
    close(fd[0]); close(fd[1]);                            /* parent: else wc never sees EOF */
    int s1, s2;
    waitpid(left, &s1, 0); waitpid(right, &s2, 0);
    printf("ls exit = %d, wc exit = %d\n", WEXITSTATUS(s1), WEXITSTATUS(s2));
    return 0;
}

আসল রানের আউটপুট:

3
ls exit = 0, wc exit = 0

প্রোগ্রাম প্রথমে ঠিক ৩টি ফাইলসহ demo ফোল্ডার বানায়, যাতে ফল নির্ধারিত থাকে (রানারের নিজের ফোল্ডারে কী আছে তার ওপর নির্ভর না করে)। 3 লিখেছে wc নিজে — সে parent-এর stdout উত্তরাধিকারে পেয়েছে। ls-এর stdout terminal নয়, pipe — তাই সে প্রতি লাইনে একটি নাম লেখে, আর wc -l গোনে ৩টি লাইন। দ্বিতীয় লাইন parent-এর, দুই child-এর waitpid-এর পরে — তাই সবসময় শেষে। দুজনের exit status 0, অর্থাৎ দুটি exec-ই সফল।

  • সবচেয়ে সাধারণ বাগ — মুখ খোলা রাখা: wc থামে তখনই, যখন pipe-এর সব লেখার মুখ বন্ধ হয় (তখন সে EOF পায়)। Parent বা দ্বিতীয় child যদি fd[1] খোলা রাখে, wc চিরকাল অপেক্ষা করবে আর parent-এর waitpid-ও। নিয়ম: যে প্রসেসের যে মুখ লাগে না, সেটি সঙ্গে সঙ্গে বন্ধ।
  • উল্টো দিক: পড়ার সব মুখ বন্ধ থাকলে লেখক SIGPIPE পায় (ডিফল্টে মারা যায়) — yes | head -1 এভাবেই থামে।
  • exec-এর পরে কোড: exec সফল হলে আর ফেরে না; তাই তার পরের লাইন শুধু ব্যর্থতায় চলে — perror আর _exit(127) (127 = “কমান্ড পাওয়া যায়নি”-র প্রচলিত কোড)। Child-এ exit নয়, _exit — যাতে parent থেকে কপি হওয়া stdio buffer আবার flush না হয়।
  • N কমান্ডের pipeline: N−১টি pipe; i-তম child আগের pipe-এর পড়ার মুখ stdin-এ আর পরের pipe-এর লেখার মুখ stdout-এ নেয়; প্রতিটি প্রসেসে বাকি সব মুখ বন্ধ। File descriptor ফাঁস রোধে pipe2(fd, O_CLOEXEC) দারুণ (Linux). Windows-এ fork নেই — CreatePipe আর CreateProcess-এ handle উত্তরাধিকার দিয়ে একই কাজ হয়।
ফলো-আপ ১৪ls demo | wc -l > out.txt — output redirection যোগ করুন। আর parent যদি SIGCHLD উপেক্ষা করে, কী বদলায়? (মাঝারি)

উত্তর: দ্বিতীয় child-এ, exec-এর আগে: int o = open("out.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644); dup2(o, 1); close(o); — pipe-এর dup2(fd[0], 0)-এর পাশাপাশি। Redirection সবসময় child-এ, parent-এ নয় — নইলে shell নিজের stdout হারাবে। আর SIGCHLD: POSIX অনুযায়ী parent SIGCHLD-এর disposition SIG_IGN করলে child শেষ হয়ে zombie থাকে না, kernel নিজেই পরিষ্কার করে; তখন waitpid child-এর exit status পায় না (শেষে ECHILD দিয়ে ব্যর্থ হয়)। তাই যে shell-কে exit status জানাতে হয়, সে SIGCHLD উপেক্ষা করে না — handler বসিয়ে waitpid(-1, &st, WNOHANG) লুপে চালায় (পর্ব ১১, ১৩)।

৮ · ডিজাইন আর ডিবাগ — timer scheduler আর racy কোডের বাগ

সমস্যা ১৩ · Timer scheduler — “এত মিলিসেকেন্ড পরে এই ফাংশন চালানো” (কঠিন, ডিজাইন)

রিভিশন: DSA · Heap, পর্ব ২৫, পর্ব ৪ (timer interrupt)

সমস্যা: “schedule(delay_ms, fn, arg) লিখুন — যেকোনো থ্রেড থেকে ডাকা যাবে, আর delay পেরোলে fn(arg) চলবে। হাজার হাজার টাইমার একসাথে থাকতে পারে।” এটি সাধারণত কোড-আর-আলোচনা মিশ্র প্রশ্ন: ইন্টারভিউয়ার data structure, থ্রেড-মডেল আর ঘড়ির পছন্দ শুনতে চান।

বাস টার্মিনালের সময়সূচির বোর্ড: সব বাসের ছাড়ার সময় লেখা, কিন্তু টিকিটঘরের লোকটির শুধু একটি প্রশ্ন — “পরের বাস কখন?” তিনি সেই সময় পর্যন্ত বসে থাকেন। এর মধ্যে নতুন একটি বাস যোগ হলে, আর সেটি আগে ছাড়লে, কেউ তাঁকে ডেকে জানায় (signal) — তিনি অপেক্ষার সময় আবার হিসাব করেন।

যে প্রশ্নগুলো আগে করবেন: কতগুলো টাইমার, কত সূক্ষ্ম (মিলিসেকেন্ড না মাইক্রোসেকেন্ড)? টাইমার বাতিল (cancel) করা লাগবে? পুনরাবৃত্ত (প্রতি ৫ সেকেন্ডে) টাইমার? Callback দীর্ঘ হলে অন্য টাইমার দেরি করবে — চলবে, নাকি worker pool লাগবে? সিস্টেম ঘড়ি বদলালে কী হবে?

নকশা: (১) Data structure: deadline অনুযায়ী min-heap — সবচেয়ে কাছের টাইমার O(1)-এ দেখা, যোগ আর তোলা O(log n). (২) একটি timer thread: heap খালি হলে cond_wait; নইলে সবচেয়ে কাছের deadline পর্যন্ত pthread_cond_timedwait. (৩) নতুন টাইমার heap-এ ঢুকিয়ে signal — কারণ সেটি হয়তো এখনকার ঘুমের চেয়ে আগের। (৪) Callback চলে lock ছেড়ে — নইলে callback ভেতর থেকে schedule ডাকলে নিজেকেই আটকাবে, আর ধীর callback সবাইকে আটকাবে। (৫) ঘড়ি: condition variable-কে CLOCK_MONOTONIC-এ বাঁধা — ডিফল্ট CLOCK_REALTIME, যা NTP বা ব্যবহারকারী বদলালে লাফায়।

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

typedef void (*callback)(void *);
typedef struct { long long due_ns; callback fn; void *arg; } timer;

enum { MAXT = 64 };
static timer heap[MAXT]; static int nheap;          /* min-heap ordered by due_ns */
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cv;                            /* uses CLOCK_MONOTONIC, see init */
static int stopping;

static long long now_ns(void) {
    struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec * 1000000000LL + ts.tv_nsec;
}
static void swap(int i, int j) { timer t = heap[i]; heap[i] = heap[j]; heap[j] = t; }
static void sift_up(int i) { while (i > 0 && heap[(i - 1) / 2].due_ns > heap[i].due_ns) { swap(i, (i - 1) / 2); i = (i - 1) / 2; } }
static void sift_down(int i) {
    for (;;) {
        int l = 2 * i + 1, r = l + 1, m = i;
        if (l < nheap && heap[l].due_ns < heap[m].due_ns) m = l;
        if (r < nheap && heap[r].due_ns < heap[m].due_ns) m = r;
        if (m == i) return;
        swap(i, m); i = m;
    }
}

/* returns 0 on success, -1 if the heap is full */
int schedule(long delay_ms, callback fn, void *arg) {
    pthread_mutex_lock(&mu);
    if (nheap == MAXT) { pthread_mutex_unlock(&mu); return -1; }
    heap[nheap] = (timer){ now_ns() + delay_ms * 1000000LL, fn, arg };
    sift_up(nheap++);
    pthread_cond_signal(&cv);           /* the earliest deadline may have changed */
    pthread_mutex_unlock(&mu);
    return 0;
}

static void *timer_thread(void *unused) {
    pthread_mutex_lock(&mu);
    for (;;) {
        while (!stopping && nheap == 0) pthread_cond_wait(&cv, &mu);
        if (stopping) break;
        long long due = heap[0].due_ns;
        if (now_ns() < due) {                           /* sleep until the earliest deadline */
            struct timespec ts = { due / 1000000000LL, due % 1000000000LL };
            pthread_cond_timedwait(&cv, &mu, &ts);      /* may wake early: loop re-checks */
            continue;
        }
        timer t = heap[0]; heap[0] = heap[--nheap]; sift_down(0);
        pthread_mutex_unlock(&mu);
        t.fn(t.arg);                                    /* run the callback WITHOUT the lock */
        pthread_mutex_lock(&mu);
    }
    pthread_mutex_unlock(&mu);
    return unused;
}

static void say(void *s) { printf("fired: %s\n", (const char *)s); fflush(stdout); }

int main(void) {
    pthread_condattr_t ca; pthread_condattr_init(&ca);
    pthread_condattr_setclock(&ca, CLOCK_MONOTONIC);    /* wall-clock jumps do not matter */
    pthread_cond_init(&cv, &ca);
    pthread_t th; pthread_create(&th, NULL, timer_thread, NULL);
    schedule(300, say, "C (300 ms)");
    schedule(100, say, "A (100 ms)");
    schedule(200, say, "B (200 ms)");
    schedule(0, say, "now (0 ms)");
    struct timespec half = { 0, 450000000L }; nanosleep(&half, NULL);
    pthread_mutex_lock(&mu); stopping = 1; pthread_cond_signal(&cv); pthread_mutex_unlock(&mu);
    pthread_join(th, NULL);
    printf("pending timers left: %d\n", nheap);
    return 0;
}

এই পূর্ণ সংস্করণটি একই অনলাইন রানারে (Linux, gcc, -Wall -Wextra, কোনো warning নেই) দুবার চালিয়ে প্রতিবার একই ফল পাওয়া গেছে — চারটি টাইমার উল্টোপাল্টা ক্রমে যোগ করা হলেও deadline-এর ক্রমে চলেছে, আর shutdown-এ কিছু বাকি থাকেনি:

fired: now (0 ms)
fired: A (100 ms)
fired: B (200 ms)
fired: C (300 ms)
pending timers left: 0
  • সঠিকতার মূল: timedwait থেকে যেকোনো কারণে ফেরা (timeout, signal, spurious) — লুপ আবার heap-এর মাথা দেখে। তাই “কেন জাগলাম” জানার দরকার নেই; প্রতিবার শুধু invariant: “heap-এর মাথা সবচেয়ে কাছের deadline”. ফল নির্ধারিত রাখতে টাইমারগুলোর মধ্যে ১০০ ms ফাঁক রাখা হয়েছে — একই মুহূর্তের দুটি deadline-এর ক্রম heap নিশ্চিত করে না (ক্রম লাগলে একটি sequence নম্বর দিয়ে tie ভাঙুন)।
  • Cancel: heap-এর মাঝ থেকে মোছা কঠিন; সাধারণ কৌশল “lazy” — টাইমারে একটি cancelled চিহ্ন, তোলার সময় বাদ। নয়তো প্রতিটি টাইমার heap-এ নিজের index মনে রাখে, যাতে O(log n)-এ মোছা যায়।
  • বিপুল টাইমার: লাখ লাখ timeout (যেমন প্রতিটি TCP সংযোগের) হলে timing wheel — ঘড়ির কাঁটার মতো স্লটের বৃত্ত, প্রতি স্লটে একটি তালিকা, যোগ-মোছা O(1). Linux kernel-এর সাধারণ timer এমন চাকার কাঠামো ব্যবহার করে, আর উচ্চ-নির্ভুলতার hrtimer একটি red-black tree. libuv (Node.js-এর নিচে) টাইমার রাখে একটি min-heap-এ।
  • OS-এর বিকল্প: Linux-এ timerfd_create টাইমারকে একটি file descriptor বানায়, যা epoll-এ অন্য I/O-র সাথে অপেক্ষা করা যায়; POSIX timer_create signal বা থ্রেড দিয়ে জানায়; Windows-এ thread-pool timer (CreateThreadpoolTimer).
ফলো-আপ ১৫“প্রতি ১০০০ ms-এ একবার চালানো” — পুনরাবৃত্ত টাইমারে পরের deadline কীভাবে হিসাব করবেন, যাতে সময়ের সাথে সরে (drift) না যায়? (মাঝারি)

উত্তর: ভুল উপায়: callback শেষ হলে now + 1000 — প্রতিবার callback-এর সময় আর ঘুম ভাঙার দেরি যোগ হয়, ১ ঘণ্টায় কয়েক সেকেন্ড পিছিয়ে যায়। সঠিক: next_due += 1000 — আগের নির্ধারিত সময় থেকে হিসাব, তাই দেরি জমে না। যদি অনেক দেরি হয়ে যায় (সিস্টেম ঘুমিয়ে ছিল, next_due এখনো অতীতে), তাহলে নীতি ঠিক করুন: হারানো সব রান একসাথে চালাবেন (catch up), নাকি বাদ দিয়ে পরের ভবিষ্যৎ স্লটে যাবেন — বেশিরভাগ ক্ষেত্রে বাদ দেওয়াই নিরাপদ। clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, ...) দিয়ে absolute সময় পর্যন্ত ঘুমানো এই নকশার সহজ রূপ।

সমস্যা ১৪ · বাগ খুঁজুন — তিনটি racy কোড (মাঝারি থেকে কঠিন)

রিভিশন: পর্ব ২২ · Race condition, পর্ব ২৫, পর্ব ২৩, পর্ব ২৯

সমস্যা: ইন্টারভিউয়ার কোড দেখিয়ে বলেন, “এটি production-এ মাঝে মাঝে ভুল ফল দেয়। কোথায় বাগ, কোন interleaving-এ ঘটে, আর কীভাবে সারাবেন?” উত্তরের তিন অংশই চাই: বাগের নাম, একটি নির্দিষ্ট খারাপ ক্রম, আর সমাধান। প্রতিটি কোড দেখে আগে নিজে উত্তর ভাবুন, তারপর ভাঁজ খুলুন।

/* Bug 1: lost update -- two threads call deposit() at the same time */
static long balance;
void deposit(long amount) {
    long old = balance;          /* load              */
    balance = old + amount;      /* add + store: another deposit in between is lost */
}
বাগ ১দুটি থ্রেড একসাথে deposit(100) ডাকল, শুরুতে balance 0. ফল কত হতে পারে, আর কেন? (সহজ)

উত্তর: ২০০ অথবা ১০০ — lost update. খারাপ ক্রম: T1 পড়ল 0 → T2 পড়ল 0 → T1 লিখল 100 → T2 লিখল 100. একটি জমা হারাল। balance += amount এক লাইনে লিখলেও একই — load, add, store তিনটি আলাদা ধাপ। আর C-তে এটি data race, তাই আচরণ undefined (পর্ব ২২)। সমাধান: mutex দিয়ে ঘিরুন, অথবা _Atomic long balance আর atomic_fetch_add(&balance, amount). কিন্তু সাবধান — “balance যথেষ্ট থাকলে তবেই তোলা” ধরনের শর্তে শুধু atomic add যথেষ্ট নয়; যাচাই আর বদল একসাথে লাগবে — mutex, বা CAS লুপ। TSan এই কোডে সঙ্গে সঙ্গে “data race” রিপোর্ট দেয়।

/* Bug 2: "if" instead of "while" -- two consumers share one queue */
int take(void) {
    pthread_mutex_lock(&mu);
    if (count == 0)                           /* checked only once */
        pthread_cond_wait(&not_empty, &mu);
    int x = buf[head]; head = (head + 1) % CAP; count--;   /* count may be 0 here! */
    pthread_mutex_unlock(&mu);
    return x;
}
বাগ ২এক producer, দুই consumer. কোডটি কখন খালি queue থেকে “উপাদান” তোলে? (মাঝারি)

উত্তর: if-এর কারণে জাগার পর শর্ত আর দেখা হয় না। খারাপ ক্রম: C1 খালি দেখে wait-এ ঘুমাল। Producer একটি উপাদান রেখে signal দিল। C1 জাগল, কিন্তু mutex পাওয়ার আগে C2 এসে mutex নিল, count == 1 দেখে wait ছাড়াই উপাদানটি নিয়ে গেল। এবার C1 mutex পেল, wait থেকে ফিরল, আর if আবার দেখে না — count = 0 অবস্থায় পুরোনো ঘর পড়ে, count হয় −1: invariant ভাঙল। Spurious wakeup-এও একই ফল। সমাধান এক শব্দের: if → while. POSIX নিজেই বলে spurious wakeup হতে পারে, আর Mesa-ধাঁচের condition variable-এ (প্রায় সব বাস্তব সিস্টেম) signal মানে “শর্ত হয়তো সত্য — আবার দেখুন”, নিশ্চয়তা নয় (পর্ব ২৫)।

/* Bug 3: broken double-checked locking */
static config *instance;                      /* plain pointer, no atomics */
config *get_config(void) {
    if (instance == NULL) {                   /* 1st check without the lock: a data race */
        pthread_mutex_lock(&mu);
        if (instance == NULL) {
            config *c = malloc(sizeof *c);
            c->ready_value = 42;              /* these stores may become visible ...   */
            instance = c;                     /* ... AFTER this one on another core     */
        }
        pthread_mutex_unlock(&mu);
    }
    return instance;                          /* may see a pointer to unfinished data */
}
বাগ ৩Lock নিয়ে দ্বিতীয়বার যাচাই করা হয়েছে, তবু এই double-checked locking ভুল কেন? (কঠিন)

উত্তর: প্রথম যাচাইটি lock ছাড়া instance পড়ে — আর আরেক থ্রেড একই সময়ে lock-এর ভেতরে লিখছে: data race. বাস্তব বিপদ reordering: compiler বা CPU c->ready_value = 42-এর আগেই instance = c দৃশ্যমান করতে পারে (বিশেষত ARM-এর মতো দুর্বল memory model-এ, আর compiler optimization-এ x86-এও)। তখন দ্বিতীয় থ্রেড প্রথম যাচাইয়ে non-NULL দেখে lock ছাড়াই ফিরে যায়, আর অসম্পূর্ণ struct পড়ে। এই সমস্যা Java-তে বিখ্যাত হয়েছিল (Bill Pugh ও সহকর্মীদের “Double-Checked Locking is Broken” ঘোষণা, ২০০০ সালের দিকে); সমাধান আসে Java 5-এর নতুন memory model আর volatile-এর নতুন অর্থে। C++-এর দিক থেকে Scott Meyers আর Andrei Alexandrescu ২০০৪ সালে একই বিপদ ব্যাখ্যা করেন। C11-এ সমাধান দুটি, নিচে — দুটিই ৮টি থ্রেডে হাজারবার করে চালিয়ে পরীক্ষিত (DCL inits = 1, pthread_once inits = 1, bad reads = 0).

/* Fix A: C11 atomics with acquire / release (tested on Wandbox) */
static config *get_config(void) {
    config *c = atomic_load_explicit(&instance, memory_order_acquire);   /* fast path */
    if (c == NULL) {
        pthread_mutex_lock(&mu);
        c = atomic_load_explicit(&instance, memory_order_relaxed);       /* check again */
        if (c == NULL) {
            c = malloc(sizeof *c); c->ready_value = 42; inits++;
            atomic_store_explicit(&instance, c, memory_order_release);   /* publish */
        }
        pthread_mutex_unlock(&mu);
    }
    return c;
}

/* Fix B: usually the best answer -- let the library do it once */
static pthread_once_t once = PTHREAD_ONCE_INIT;
static config *cfg2; static atomic_int inits2;
static void init2(void) { cfg2 = malloc(sizeof *cfg2); cfg2->ready_value = 42; inits2++; }
static config *get_config2(void) { pthread_once(&once, init2); return cfg2; }

Fix A-তে acquire load আর release store একটি জোড়া: যে থ্রেড release-এ লেখা পয়েন্টার acquire-এ পড়ে, সে তার আগের সব লেখাও (ready_value = 42) দেখবে। Fix B আরও ভালো — pthread_once (বা C11-এর call_once) নিজেই এই নিশ্চয়তা দেয়, আর ভুলের জায়গা নেই। ইন্টারভিউতে প্রথমে Fix B বলুন, তারপর দেখান আপনি Fix A-ও বোঝেন।

৯ · কোন সমস্যায় কোন primitive — চিটশিট আর ভাবনার প্রশ্ন

নিচের টেবিল ইন্টারভিউর আগের রাতের রিভিশনের জন্য। বাঁ কলামের ইঙ্গিত সমস্যার বিবরণে দেখলে মাঝের primitive প্রথমে ভাবুন — তবে এটি শুরুর দিকনির্দেশ, নিশ্চিত নিয়ম নয়।

সমস্যায় যা দেখবেনprimitive / কৌশলমনে রাখার ফাঁদ
“একসাথে একজন”, shared counter, mapmutexপ্রতিটি shared ভ্যারিয়েবলের একটি নির্দিষ্ট lock
“X না হওয়া পর্যন্ত অপেক্ষা”, queue ভর্তি/খালি, পালাmutex + condition variablewait সবসময় while-এ; ভিন্ন শর্তে ভিন্ন cv
“N টি সম্পদ”, আসন, connection poolcounting semaphoreআগে semaphore, পরে mutex; EINTR
“সবাই পৌঁছালে একসাথে”, phase, দল বাঁধাbarrier (বা generation-সহ cv)পুনঃব্যবহারে generation counter
“অনেকে পড়ে, কেউ কেউ লেখে”rwlock, seqlock, RCUstarvation-এর নীতি; get-ও লিখলে rwlock নয়
একটি flag, সংখ্যা, একবারের initC11 atomic, pthread_oncememory order; DCL-এ acquire/release
খুব ছোট critical section, kernel, interruptspinlockpreemption; user space-এ সাধারণত mutex
একাধিক lock একসাথেএকটি বৈশ্বিক lock-ক্রমfrom == to; lock ধরে I/O নয়
“X ms পরে চালানো”, timeoutmin-heap + timedwait, timing wheelCLOCK_MONOTONIC; callback lock ছেড়ে
প্রতি সেকেন্ডে সর্বোচ্চ Ntoken bucket (lazy refill)পূর্ণসংখ্যায় হিসাব; ঘড়ি পিছালে
একই আকারের বহু ছোট বস্তুpool / free list / slabdouble free; alignment
read/write/pipe/socketলুপ: short count আর EINTRclose-এর ফেরত মান; fsync
দুটি প্রোগ্রাম জোড়া, redirectionpipe + fork + dup2 + execঅব্যবহৃত মুখ বন্ধ; fork-এর আগে fflush

ভাবনার প্রশ্ন

কোড লেখার পরে আলোচনায় প্রায়ই এ ধরনের গভীর প্রশ্ন আসে। প্রতিটি আগে নিজে ভাবুন, তারপর খুলুন।

প্র ০১Concurrent কোড “পরীক্ষায় পাস” করলেই কি ঠিক? ইন্টারভিউতে সঠিকতার যুক্তি কীভাবে দেবেন? (কঠিন)

না। Concurrent প্রোগ্রামের সম্ভাব্য interleaving-এর সংখ্যা থ্রেড আর ধাপের সংখ্যার সাথে বিস্ফোরকভাবে বাড়ে — দুটি থ্রেড প্রতিটি মাত্র ২০টি ধাপ নিলেও সম্ভাব্য ক্রম ১৩ হাজার কোটিরও বেশি (৪০টি ধাপ থেকে ২০টি বাছার উপায়)। একটি test রান তার একটিমাত্র দেখে, আর scheduler সাধারণত বারবার কাছাকাছি ক্রমই বাছে। এ কারণেই একটি race বছরের পর বছর লুকিয়ে থাকতে পারে, তারপর নতুন CPU, নতুন compiler বা বেশি load-এ হঠাৎ দেখা দেয়। ১৯৮৫–৮৭ সালের Therac-25 রেডিয়েশন যন্ত্রের দুর্ঘটনার তদন্তে (Nancy Leveson ও Clark Turner-এর ১৯৯৩ সালের প্রতিবেদন) একটি race condition ছিল অন্যতম কারণ — অপারেটর দ্রুত টাইপ করলেই কেবল সমস্যা ঘটত, তাই সাধারণ পরীক্ষায় ধরা পড়েনি। ২০০৩ সালে উত্তর আমেরিকার বড় ব্ল্যাকআউটের তদন্তেও একটি বিদ্যুৎ-ব্যবস্থাপনা সফটওয়্যারের race-জনিত alarm ব্যর্থতার কথা এসেছিল।

তাই ইন্টারভিউতে যুক্তি দিন তিন স্তরে। Safety (খারাপ কিছু কখনো ঘটবে না): invariant বলুন আর দেখান প্রতিটি পরিবর্তন lock ধরে হয়, তাই invariant ভাঙা অবস্থা অন্য কেউ দেখে না। Liveness (ভালো কিছু শেষে ঘটবে): কেউ চিরকাল ঘুমাবে না — প্রতিটি wait-এর জন্য দেখান কে, কখন signal দেবে; শেষের/shutdown-এর কেস আলাদা করে। Deadlock-মুক্তি: lock-এর একটি ক্রম আছে, বা একটিই lock. এই তিনটি পর্ব ২২-এর mutual exclusion, progress আর bounded waiting-এরই বাস্তব রূপ।

তারপর বলুন কীভাবে আত্মবিশ্বাস বাড়াবেন: invariant checker (এই পাতার violations counter-এর মতো), stress test — core-এর চেয়ে বেশি থ্রেড, লাখো পুনরাবৃত্তি, -O0 আর -O2 দুটোতেই — আর ThreadSanitizer, যা ফল ঠিক এলেও “এই দুটি access-এর মধ্যে happens-before নেই” ধরে। আরও গভীরে model checking: TLA+ (Leslie Lamport) দিয়ে নকশার সব ক্রম যাচাই, যা কিছু বড় cloud প্রতিষ্ঠান ব্যবহারের কথা প্রকাশ করেছে; Linux kernel-এর memory model-এর জন্য herd7-এর মতো টুল আছে।

মূল উপলব্ধি: test আত্মবিশ্বাস বাড়ায়, প্রমাণ দেয় না। Invariant, signal-এর দায় আর lock-ক্রম — এই তিনটি জোরে বলতে পারলে ইন্টারভিউয়ার বোঝেন আপনি ভাগ্যের ওপর নির্ভর করছেন না।

প্র ০২Semaphore, monitor, condition variable — এত primitive কেন এল? একটি দিয়ে কি সব হয় না? (মাঝারি)

তত্ত্বে হয় — semaphore দিয়ে mutex, condition variable, barrier সবই বানানো যায়, আর উল্টোটাও (পর্ব ২৫)। কিন্তু প্রশ্নটি ক্ষমতার নয়, ভুল করার সহজতার। Edsger Dijkstra ১৯৬০-এর দশকের মাঝামাঝি THE অপারেটিং সিস্টেমের কাজের সময় semaphore (P আর V) প্রস্তাব করেন — এক ধারণায় পারস্পরিক বর্জন আর সম্পদ-গণনা দুটোই। সমস্যা দেখা দিল বড় প্রোগ্রামে: একটি P বা V ভুল জায়গায় বা ভুল ক্রমে দিলেই deadlock (সমস্যা ৪-এর ফাঁদ), আর কোডের কোথায় কোন semaphore কী রক্ষা করে, তা পড়ে বোঝা কঠিন।

এর উত্তরে ১৯৭৩–৭৪ সালে Per Brinch Hansen আর C. A. R. Hoare monitor-এর ধারণা দেন: ডেটা আর তার ওপর কাজগুলো একটি মডিউলে, আর পারস্পরিক বর্জন ভাষা নিজেই নিশ্চিত করে — lock ভুলে যাওয়ার সুযোগই নেই। অপেক্ষার জন্য monitor-এর ভেতরে condition variable. Hoare-এর সংজ্ঞায় signal দিলে অপেক্ষমাণ থ্রেড সঙ্গে সঙ্গে চলে আর শর্ত নিশ্চিত সত্য; কিন্তু ১৯৮০ সালে Xerox PARC-এর Mesa ভাষার অভিজ্ঞতা (Lampson ও Redell) দেখাল যে “signal মানে শুধু ইঙ্গিত, জেগে আবার দেখে নেওয়া চাই” বাস্তবায়নে সহজ আর দ্রুত। আজকের pthreads, Java, C#, Go-র sync.Cond — সবাই Mesa-ধাঁচ, তাই সবসময় while.

আধুনিক ভাষাগুলো আরও এক ধাপ গেছে। Java-র synchronized আর wait/notify সরাসরি monitor. Rust-এ Mutex<T> ডেটাকে lock-এর ভেতরে রাখে — lock না নিয়ে ডেটা ছোঁয়াই যায় না, compiler নিজে ধরে। Go উৎসাহ দেয় channel-এ — “memory ভাগ করে যোগাযোগ নয়, যোগাযোগ করে memory ভাগ” — যা Hoare-এর ১৯৭৮ সালের CSP ধারণা থেকে এসেছে। প্রতিটি ধাপে লক্ষ্য একটাই: সাধারণ ভুলগুলো অসম্ভব বা অন্তত দৃশ্যমান করা।

ইন্টারভিউতে তাই primitive-এর নাম নয়, বাছাইয়ের যুক্তি বলুন: “এখানে শর্তের জন্য অপেক্ষা, তাই condition variable; সম্পদ গোনা হলে semaphore নিতাম; শুধু একটি সংখ্যা হলে atomic.”

মূল উপলব্ধি: সব primitive সমান শক্তিশালী, কিন্তু সমান নিরাপদ নয়। যে primitive আপনার সমস্যার আকারের সাথে মেলে, তাতে ভুল করার জায়গা সবচেয়ে কম।

প্র ০৩Lock বনাম lock-free — ইন্টারভিউয়ার “আরও দ্রুত করুন” বললে কি সঙ্গে সঙ্গে atomic আর CAS-এ যাবেন? (কঠিন)

সাধারণত না। আগে জানতে হবে ধীর কোথায়। একটি uncontended mutex — অর্থাৎ অন্য কেউ চাইছে না — Linux-এ futex-এর কারণে user space-এর একটি atomic কাজেই নেওয়া-ছাড়া হয়, kernel-এ যাওয়াই লাগে না; খরচ কয়েক দশ ন্যানোসেকেন্ডের ঘরে। সমস্যা শুরু হয় contention-এ: অনেক core একই lock চায়, cache line এক core থেকে আরেক core-এ লাফায়, থ্রেড ঘুমায়-জাগে। তখনও প্রথম ওষুধ lock-free নয় — কম ভাগাভাগি: lock-এর ভেতরের কাজ ছোট করা, ডেটা ভাগ করে (sharding) আলাদা lock, প্রতি-থ্রেড counter শেষে যোগ, batching. সমস্যা ৭-এর sharded LRU এর উদাহরণ।

Lock-free কাঠামো (পর্ব ২৯) একটি শক্তিশালী নিশ্চয়তা দেয় — কোনো একটি থ্রেড থেমে গেলেও (preempt, page fault) বাকিরা এগোয় — আর কিছু ক্ষেত্রে, যেমন একটি producer আর একটি consumer-এর ring buffer, সত্যিই সরল আর দ্রুত। কিন্তু সাধারণ ক্ষেত্রে দাম বড়: ABA সমস্যা, কখন মেমরি free করা নিরাপদ (hazard pointer, epoch, RCU), memory order-এর সূক্ষ্মতা, আর পরীক্ষা অনেক কঠিন। Maurice Herlihy-র ১৯৯১ সালের wait-free synchronization-এর কাজ দেখায় CAS-এর মতো primitive তাত্ত্বিকভাবে “সর্বজনীন”, কিন্তু সেই সাধারণ নির্মাণ বাস্তবে ধীর; ভালো lock-free কাঠামো প্রতিটি আলাদা করে নকশা করতে হয় আর তাদের সঠিকতার প্রমাণ নিয়েই গবেষণাপত্র লেখা হয়।

আর lock-free মানেই দ্রুত নয়: CAS লুপ বেশি contention-এ বারবার ব্যর্থ হয়ে ঘোরে, আর atomic-এর ওপর সব থ্রেডের ভিড়ও cache line টানাটানি ঘটায়। তাই ইন্টারভিউর সেরা উত্তর একটি পদ্ধতি: “আগে profile করব — perf বা lock-এর অপেক্ষার সময় মেপে (পর্ব ৫৭)। তারপর critical section ছোট করব, তারপর shard. এরপরও দরকার হলে পরিচিত, পরীক্ষিত lock-free লাইব্রেরি নেব — নিজে লিখব না, বা লিখলে TSan আর model checker দিয়ে যাচাই করব।”

মূল উপলব্ধি: lock-free একটি নিশ্চয়তা, গতির প্রতিশ্রুতি নয়। মাপুন, ভাগাভাগি কমান, তারপর ভাবুন — আর নিজে লেখা lock-free কোডকে সবচেয়ে সন্দেহের চোখে দেখুন।

প্র ০৪System call-এর ফেরত মান যাচাই কেন এত জরুরি? EINTR-এর মতো নকশা Unix-এ এল কেন? (মাঝারি)

কারণ system call হলো বাইরের জগতের সাথে কথা — ডিস্ক ভরে যায়, নেটওয়ার্ক কাটে, অনুমতি থাকে না, signal আসে। প্রতিটি ব্যর্থতা ফেরত মানেই জানানো হয়; না দেখলে প্রোগ্রাম ভুল অবস্থায় এগোয় আর ক্ষতি অনেক পরে, অন্য জায়গায় দেখা দেয়। সমস্যা ১১-এর short write তার নিখুঁত উদাহরণ: কোনো ত্রুটিই নেই, তবু ধরে নিলে ডেটা হারায়। আর close, fsync-এর ব্যর্থতা উপেক্ষা করা বহু “ফাইল নষ্ট হলো কেন” রহস্যের উৎস — PostgreSQL-এর ডেভেলপাররা ২০১৮ সালে দেখান যে Linux-এ fsync ব্যর্থ হলে আবার চেষ্টা করে সফল পাওয়া মানেই ডেটা নিরাপদ নয়, আর তারপর ডেটাবেসের ত্রুটি-সামলানো বদলাতে হয়েছিল (“fsyncgate” নামে আলোচিত)।

EINTR-এর গল্প Unix-এর নকশা-দর্শনের একটি বিখ্যাত উদাহরণ। Richard Gabriel তাঁর “Worse is Better” প্রবন্ধে (১৯৮৯–৯১) এটি তুলে ধরেন: একটি প্রসেস ধীর system call-এর মাঝে থাকলে আর signal এলে, kernel কী করবে? “সঠিক” সমাধান — kernel নিজে অবস্থা মনে রেখে handler চালিয়ে কলটি ঠিক জায়গা থেকে আবার শুরু করবে — জটিল। Unix সরল পথ নেয়: কলটি ব্যর্থ বলে ফিরিয়ে দেওয়া হয় EINTR দিয়ে, আর প্রোগ্রাম চাইলে আবার ডাকবে। kernel সরল হলো, দায় গেল প্রোগ্রামারের ঘাড়ে। পরে BSD আর POSIX-এ SA_RESTART এল, যাতে অনেক কল নিজে আবার শুরু হয় — তবে সব নয় (Linux-এ কোনগুলো, তা man 7 signal-এ তালিকা আছে)।

অন্য OS অন্য পথ নিয়েছে: Windows-এ সাধারণত ব্যর্থতা জানানো হয় BOOL ফেরত আর GetLastError() দিয়ে, আর বাধা পাওয়া অপেক্ষার জন্য আলাদা “alertable wait” ধারণা আছে। আধুনিক ভাষাগুলো (Go, Python 3.5-এর পর PEP 475) বেশিরভাগ ক্ষেত্রে EINTR-এর লুপ নিজেরাই চালায়, তাই সেখানে প্রোগ্রামার প্রায় দেখেনই না — কিন্তু C-তে আপনার দায়।

মূল উপলব্ধি: ফেরত মান হলো OS-এর কথা বলার একমাত্র উপায়। Systems ইন্টারভিউতে প্রতিটি কলের পরে “এটা ব্যর্থ হলে?” জিজ্ঞেস করার অভ্যাসই অভিজ্ঞ প্রকৌশলীকে আলাদা করে।

প্র ০৫একই সমস্যা POSIX, Windows আর বিভিন্ন ভাষায় — ইন্টারভিউতে প্ল্যাটফর্মের পার্থক্য কতটা জানা দরকার? (মাঝারি)

ধারণাগুলো সর্বজনীন, API নয়। Mutex, condition variable, semaphore, atomic — প্রতিটি OS আর ভাষায় আছে, কিন্তু নাম, আচরণ আর ফাঁদ আলাদা। POSIX-এ pthread_mutex_t আর pthread_cond_t; Windows-এ Vista থেকে SRWLOCK আর CONDITION_VARIABLE (তার আগে CRITICAL_SECTION আর event object), যেখানে SleepConditionVariableSRW-এও spurious wakeup হয় — তাই সেখানেও while. macOS POSIX মানে, তবে unnamed semaphore (sem_init) আর pthread_barrier দেয় না; Apple-এর পরামর্শ Grand Central Dispatch. Linux-এর fork Windows-এ নেই — CreateProcess নতুন প্রসেস শূন্য থেকে বানায়, তাই সমস্যা ১২ সেখানে আলাদা চেহারা নেয়।

ভাষার স্তরে পার্থক্য আরও বড়। C11 আর C++11 থেকে মানদণ্ডে নিজস্ব থ্রেড আর atomic (<threads.h>, std::thread, std::atomic) — তবে C11-এর <threads.h> ঐচ্ছিক, আর সব প্ল্যাটফর্মে দীর্ঘদিন ছিল না। Java-তে java.util.concurrent-এ BlockingQueue, ReentrantReadWriteLock, Semaphore তৈরি পাওয়া যায় — ইন্টারভিউয়ার হয়তো বলবেন “এগুলো ব্যবহার না করে বানান”। Python-এ CPython-এর GIL বহু race লুকিয়ে রাখে, কিন্তু সব নয় — x += 1 একাধিক bytecode, থ্রেড মাঝে বদলাতে পারে; আর Python 3.13-এর free-threaded build-এ GIL ছাড়াই চলে (পর্ব ১৫)। Go-তে goroutine আর channel, সাথে go run -race — একই ThreadSanitizer-এর প্রযুক্তি।

ইন্টারভিউর জন্য ব্যবহারিক নিয়ম: একটি প্ল্যাটফর্ম ভালোভাবে জানুন (Linux + pthreads সবচেয়ে প্রচলিত) আর বলুন সেটি ধরে নিচ্ছেন; বাকিগুলোর ক্ষেত্রে মূল পার্থক্যগুলো এক বাক্যে বলতে পারুন। “POSIX-এ condition variable mutex-এর সাথে বাঁধা, আর spurious wakeup সম্ভব — Windows-এও তাই” — এমন বাক্য দেখায় আপনি ধারণা বোঝেন, শুধু API মুখস্থ করেননি। আর কখনো একটি প্ল্যাটফর্মের আচরণকে সর্বজনীন বলবেন না — যেমন “pipe-এর আকার ৬৪ KiB” Linux-এর ডিফল্ট, POSIX-এর নিয়ম নয়।

মূল উপলব্ধি: ধারণা স্থানান্তরযোগ্য, বিবরণ নয়। কোন প্ল্যাটফর্ম ধরে নিচ্ছেন তা স্পষ্ট বলুন, আর পার্থক্যের জায়গাগুলো চিনে রাখুন।

১০ · AI দিয়ে প্রস্তুতি — mock interview আর race-এর পরীক্ষা

ইন্টারভিউ চলাকালীন AI-এর সাহায্য অনেক প্রতিষ্ঠানেই নিষিদ্ধ — live coding-এ, online assessment-এ, অনেক সময় take-home-এও। কেউ কেউ আবার অনুমতি দিয়ে দেখতে চান আপনি AI-এর কোড কীভাবে যাচাই করেন। নিয়ম জায়গাভেদে আলাদা আর বদলায় — আগে জিজ্ঞেস করে নিন, অনুমতি না থাকলে ব্যবহার করবেন না।

প্রস্তুতির সময় AI একটি ভালো mock-interview সঙ্গী — শর্ত একটাই: উত্তর নয়, প্রশ্ন আর মূল্যায়ন চাইবেন। আর মনে রাখবেন, concurrency-তে AI-এর ভুল প্রায়ই আত্মবিশ্বাসী শোনায়, আর তার কোড “আমার মেশিনে চলে”:

AI-এর সাধারণ ভুলএই পাতার কোথায়কীভাবে ধরবেন
if-এ wait, বা এক cv-তে signal দিয়ে তিন থ্রেডের পালাসমস্যা ১, ৩; বাগ ২প্রতিটি wait-এর while আর “কে জাগাবে” মিলিয়ে দেখুন
semaphore-এর আগে mutex, বা lock-ক্রম ঠিক না রাখাসমস্যা ৪, ১০উল্টো দিকের দুটি থ্রেড দিয়ে লাখবার চালান; TSan-এর lock-order সতর্কবার্তা
race “সারাতে” volatile বা sleepসমস্যা ৬, বাগ ৩TSan; memory order নিজে ব্যাখ্যা করতে পারেন কি না
একবার write, EINTR উপেক্ষা, close-এর ফল না দেখাসমস্যা ১১non-blocking pipe-এ বড় লেখা দিয়ে পরীক্ষা
pipe-এর মুখ খোলা রাখা, fork-এর আগে fflush নেইসমস্যা ১২প্রোগ্রাম কি থামে? আউটপুট কি দুবার?
ভুল OS-এর API (Linux-এর কোড macOS-এ), বা বানানো APIসমস্যা ২ (barrier)man page; আপনার OS-এ compile করুন
ভুল হিসাব (token bucket, LRU-র ক্রম)সমস্যা ৭, ৮হাতে ছোট উদাহরণে মেলান, তারপর চালিয়ে

নিচের prompt দিয়ে একটি systems-coding mock interview চালান — AI-কে সমাধান দিতে নিষেধ করা হয়েছে, আর race নিয়ে প্রশ্ন করতে বলা হয়েছে:

তুমি একজন অভিজ্ঞ systems ইঞ্জিনিয়ার, আমার concurrency আর C কোডিং ইন্টারভিউ নিচ্ছ। আমি Linux, gcc আর POSIX threads ব্যবহার করব। নিয়ম: ১. blocking queue, পালা করে ছাপা, barrier ধাঁচের ধাঁধা, reader-writer lock, thread-safe cache, rate limiter, spinlock, memory pool, ফাইল কপি, pipe-fork-dup2, lock ordering বা timer — এর যেকোনো একটি থেকে একটি সমস্যা দাও। কিছু শর্ত ইচ্ছা করে অস্পষ্ট রাখো, যাতে আমাকে প্রশ্ন করতে হয়। ২. আমি না চাইলে সমাধান বা কোড দেবে না। আটকে গেলে একবারে শুধু একটি ছোট hint. ৩. আমি কোড লেখার আগে জিজ্ঞেস করবে: কোন shared ডেটা, কোন lock তাকে রক্ষা করে, invariant কী। ৪. কোড দেখে অন্তত দুটি নির্দিষ্ট interleaving দিয়ে আমাকে চ্যালেঞ্জ করবে ("থ্রেড A এই লাইনে থামল, B এটা করল — কী হবে?")। wait কি while-এ, lock-এর ক্রম, shutdown আর system call-এর ব্যর্থতা নিয়ে প্রশ্ন করবে। ৫. memory order বা প্ল্যাটফর্মের আচরণ নিয়ে কিছু বললে নিশ্চিত না হলে "নিশ্চিত নই, man page দেখো" বলবে — বানিয়ে বলবে না। ৬. শেষে আমি কীভাবে test করব (stress loop, invariant counter, -fsanitize=thread) জিজ্ঞেস করবে। ৭. পাঁচটি দিকে ১ থেকে ৫ নম্বর দেবে: প্রশ্ন করা, সঠিকতা, race-এর যুক্তি, ত্রুটি সামলানো, যোগাযোগ — প্রতিটির জন্য একটি নির্দিষ্ট পরামর্শ। প্রশংসা নয়, সৎ মূল্যায়ন চাই। প্রথম সমস্যা দিয়ে শুরু করো।

নিজের বা AI-এর লেখা concurrent কোড যাচাইয়ের পদ্ধতি এই পাতার প্রোগ্রামগুলোর মতোই: ফলকে এমন রূপে আনুন যা interleaving-এর ওপর নির্ভর করে না (মোট, গণনা, invariant-ভঙ্গের সংখ্যা), থ্রেড আর পুনরাবৃত্তি বাড়ান, gcc -fsanitize=thread -g -O1 দিয়ে চালান, আর একটি বাগ ইচ্ছা করে ঢুকিয়ে (lock সরিয়ে, while-কে if করে) দেখুন আপনার পরীক্ষা সেটি ধরে কি না — না ধরলে পরীক্ষাটাই দুর্বল।

AI দিয়ে অনুশীলনের চেকলিস্ট

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমC প্রোগ্রাম থেকে ls | wc -l চালানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. pipe(fd) দিয়ে পড়া আর লেখার দুই মুখ তৈরি, তারপর fflush(stdout)
  2. প্রথম fork; child-এ dup2(fd[1], 1), দুই মুখ বন্ধ, exec ls
  3. দ্বিতীয় fork; child-এ dup2(fd[0], 0), দুই মুখ বন্ধ, exec wc -l
  4. Parent pipe-এর দুই মুখই বন্ধ করে, যাতে wc EOF পায়
  5. Parent দুই child-কে waitpid করে exit status নেয়

একটি blocking queue-র take()-এ লেখা আছে lock(&mu); if (count == 0) pthread_cond_wait(&not_empty, &mu); x = buf[head]; … count--; unlock(&mu);. একজন producer আর দুজন consumer থাকলে মাঝে মাঝে count ঋণাত্মক হয়। মূল সংশোধন কোনটি?

  • if-এর জায়গায় while দেওয়া
  • সব জায়গায় pthread_cond_signal-এর বদলে broadcast দেওয়া
  • wait-এর আগে mutex unlock করা
  • count-কে volatile করা

৮টি worker থ্রেড একটি simulation-এর প্রতিটি ধাপে নিজের অংশ হিসাব করে। ধাপ k+১ কেউ শুরু করতে পারবে না, যতক্ষণ না সব ৮ জন ধাপ k শেষ করে। সবচেয়ে মানানসই primitive কোনটি?

  • শুধু একটি mutex
  • ধারণক্ষমতা ৮-এর একটি barrier (যেমন pthread_barrier_wait)
  • একটি spinlock
  • একটি reader–writer lock

একটি LRU cache বানানো হলো hash map (key → node) আর doubly linked list (MRU থেকে LRU) দিয়ে। get আর put-এর সময়-জটিলতা কত?

  • get O(1), কিন্তু put O(n), কারণ LRU খুঁজতে হয়
  • দুটোই O(log n)
  • দুটোই গড়ে O(1)
  • দুটোই O(n)

C11 atomic দিয়ে লেখা spinlock-এর unlock-এ atomic_store_explicit(&held, false, ORDER). Critical section-এর লেখা পরের lock-ধারী যেন নিশ্চিতভাবে দেখে, তার জন্য ORDER-এ ন্যূনতম কোনটি লাগবে?

  • memory_order_relaxed
  • memory_order_consume
  • memory_order_acquire
  • memory_order_release

একটি socket-এ write(fd, buf, 100000) ফেরত দিল 65536, আর errno-তে কোনো ত্রুটি নেই। সঠিক পরের কাজ কোনটি?

  • buf + 65536 থেকে বাকি ৩৪,৪৬৪ বাইট লুপে লিখতে থাকা
  • ত্রুটি ধরে সংযোগ বন্ধ করা
  • পুরো ১,০০,০০০ বাইট আবার write করা
  • errno শূন্য কি না বারবার যাচাই করা

একটি প্রোগ্রাম pipe বানিয়ে দুটি child চালায় — একটিতে ls (stdout → pipe), অন্যটিতে wc -l (stdin ← pipe); দুই child-ই অব্যবহৃত মুখ বন্ধ করে। ls শেষ হয়, কিন্তু wc কখনো শেষ হয় না। সবচেয়ে সম্ভাব্য কারণ?

  • ls আউটপুট flush করেনি
  • Parent pipe-এর লেখার মুখ (fd[1]) বন্ধ করেনি
  • dup2 close-এর আগে ডাকা হয়েছে
  • wc-কে -l-এর বদলে -c দিতে হতো

প্রতিটি ওয়ালেটের নিজের mutex; transfer(from, to) আগে from-এর, তারপর to-এর lock নেয়। A → B আর B → A একসাথে চললে মাঝে মাঝে প্রোগ্রাম আটকে যায়। সবচেয়ে ভালো সমাধান কোনটি?

  • দুটি mutex-কেই recursive করা
  • দুই lock-এর মাঝে একটু sleep দেওয়া
  • সবসময় একটি নির্দিষ্ট ক্রমে lock নেওয়া — যেমন ছোট id-র ওয়ালেট আগে
  • balance-কে _Atomic করে lock তুলে দেওয়া

একটি token bucket-এর ধারণক্ষমতা ৫ টোকেন, প্রতি সেকেন্ডে ১টি করে ভরে, শুরুতে ভর্তি, প্রতিটি অনুরোধে ১ টোকেন। t = ০ সেকেন্ডে একসাথে ৭টি অনুরোধ আসে, তারপর t = ৩ সেকেন্ডে আরও ৪টি। মোট কতটি অনুমতি পাবে?

  • ৫টি
  • ৭টি
  • ৯টি
  • ৮টি

Semaphore-ভিত্তিক bounded buffer-এ producer লিখেছে: lock(&mu); sem_wait(&empty); buf[in] = x; …; unlock(&mu); sem_post(&full);. Buffer ভর্তি থাকলে কী ঘটে?

  • ঠিকঠাক চলে, শুধু একটু ধীর
  • Deadlock — producer mutex ধরে ঘুমায়, consumer mutex পায় না
  • একটি উপাদান হারিয়ে যায়
  • Buffer overflow হয়ে পুরোনো উপাদান মুছে যায়

তিনটি থ্রেড পালা করে (০, ১, ২, ০, ১, ২ …) ছাপে; সবাই একটি condition variable-এ while (turn != me) wait করে, আর ছাপার পর turn = (turn + 1) % 3; pthread_cond_signal(&cv);. কী সমস্যা হতে পারে?

  • কোনো সমস্যা নেই, সবসময় ঠিক চলে
  • একই সংখ্যা দুবার ছাপা হয়
  • ভুল থ্রেড জাগলে সবাই চিরকাল ঘুমিয়ে থাকতে পারে
  • Compile error হয়

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব শব্দ এখানে একসাথে:

মনে রাখুন

  1. আগে invariant আর lock-এর মানচিত্র, তারপর কোড: কোন ডেটা কোন lock রক্ষা করে, কোন শর্ত সবসময় সত্য — জোরে বলুন।
  2. Wait সবসময় while-এ; ভিন্ন শর্তে ভিন্ন condition variable; বহু অপেক্ষমাণ আর একটি cv হলে broadcast; shutdown-এ সবাইকে জাগান। গোনার কাজে semaphore — আগে semaphore, পরে mutex.
  3. একাধিক lock = একটি বৈশ্বিক ক্রম; lock ধরে I/O বা callback নয়। Atomic-এ acquire/release জোড়া, আর একবারের init-এ pthread_once.
  4. System call-এর প্রতিটি ফেরত মান দেখুন: short read/write-এ লুপ, EINTR-এ আবার, close/fsync-এর ফল; pipe-এর অব্যবহৃত মুখ বন্ধ, fork-এর আগে fflush.
  5. পরীক্ষা ভাগ্য দিয়ে নয়: interleaving-নিরপেক্ষ ফল (মোট, invariant-ভঙ্গের সংখ্যা), অনেক থ্রেড আর লুপ, আর -fsanitize=thread. প্ল্যাটফর্ম স্পষ্ট বলুন — POSIX, Windows আর macOS-এর পার্থক্য আছে।

এরপর: এটিই কোর্সের শেষ পর্ব — অভিনন্দন! কোর্স হোম-এ ফিরে পুরো রোডম্যাপ দেখুন, ড্যাশবোর্ডে মডিউল টেস্ট আর কোর্সের সমাপনী পরীক্ষা দিন, আর এই পাতার ১৪টি সমস্যা থেকে প্রতি সপ্তাহে দুটি ঘড়ি ধরে, কথা বলে বলে আবার সমাধান করুন — পর্ব ৫৯-এর ক্যাপস্টোনের shell আর allocator-এ এদের অনেকগুলো সরাসরি কাজে লাগবে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান