এই পর্বে যা শিখবেন
- Mutex-এর acquire/release, মালিকানার নিয়ম, আর spin করা বনাম ঘুমানো — kernel অপেক্ষারত থ্রেডকে কীভাবে ঘুম পাড়ায় ও জাগায়
- Linux-এ futex দিয়ে mutex কীভাবে বানানো হয় (fast path আর slow path), adaptive mutex, আর pthread mutex-এর normal, recursive, errorcheck ধরন ও trylock/timedlock
- Dijkstra-র semaphore: P/V, counting আর binary, অপেক্ষার সারিসহ সংজ্ঞা, আর মানের দুই প্রচলিত রীতি
- পরীক্ষার প্রিয় হিসাব — wait/signal-এর পরে মান আর সারি ট্রেস করা; semaphore দিয়ে পাহারা, ক্রম আর সম্পদ গণনা
- POSIX (
sem_init,sem_open), System V আর Windows-এর সমতুল্য ব্যবস্থা, আর সাধারণ ভুল: signal ভোলা, উল্টো ক্রমে wait, হারানো জাগরণ
১ · Mutex — একটি চাবি, একজন মালিক
পর্ব ২২-এ দেখেছেন, একাধিক থ্রেড একই ডেটা একসাথে বদলালে race condition হয়, আর সমাধান হলো কোডের সেই অংশটিকে — critical section — এমনভাবে পাহারা দেওয়া, যাতে যেকোনো মুহূর্তে একজনই ভেতরে থাকে। এই নিশ্চয়তার নাম mutual exclusion. পর্ব ২৩-এ দেখেছেন hardware কীভাবে সাহায্য করে: atomic instruction আর তার ওপর বানানো spinlock. এই পর্বে আমরা এক ধাপ ওপরে উঠব — এমন দুটি হাতিয়ারে, যা প্রায় প্রতিটি OS আর প্রোগ্রামিং ভাষায় আছে: mutex আর semaphore.
একটি অফিসে একটিমাত্র রেকর্ড-রুম, আর তার একটিই চাবি থাকে দারোয়ানের টেবিলে। যিনি ভেতরে যাবেন, তিনি চাবি তুলে নেন (acquire), কাজ শেষে নিজেই ফেরত রাখেন (release). চাবি টেবিলে না থাকলে পরের জন ভেতরে যেতে পারেন না — অপেক্ষা করেন। আর একটি নিয়ম কড়া: যিনি চাবি নিয়েছেন, শুধু তিনিই ফেরত দেবেন; অন্য কেউ “ওনার হয়ে” চাবি রেখে দিলে ভেতরে দুজন ঢুকে পড়তে পারেন। এই চাবিটিই mutex, আর “যিনি নিয়েছেন তিনিই ফেরত দেবেন” নিয়মটির নাম মালিকানা.
Mutex-এর দুটি মূল অপারেশন:
- acquire (lock): তালা খোলা থাকলে অবিভাজ্যভাবে সেটি বন্ধ করে নিজেকে মালিক বানানো; বন্ধ থাকলে খোলা না হওয়া পর্যন্ত অপেক্ষা।
- release (unlock): তালা খুলে দেওয়া, আর কেউ অপেক্ষায় থাকলে তাদের একজনকে সুযোগ দেওয়া। এটি ডাকতে পারে শুধু মালিক।
যেকোনো ভালো lock-এর কাছে তিনটি জিনিস চাওয়া হয় (পর্ব ২২-এর তিন শর্তের সাথে মিলিয়ে দেখুন): সঠিকতা — একসাথে একজনই ভেতরে; ন্যায্যতা — কেউ চিরকাল অপেক্ষায় পড়ে থাকবে না (starvation নেই); আর কম খরচ — কেউ প্রতিযোগিতা না করলে lock নেওয়া-ছাড়া প্রায় বিনামূল্যে হওয়া উচিত। শেষেরটি কীভাবে সম্ভব, তা অংশ ৩-এ।
হাতে-কলমে দেখা যাক। একটি ব্যাংক হিসাবে শুরুতে ১০ লাখ টাকা। চারজন টেলার (চারটি থ্রেড) একসাথে প্রত্যেকে ১ লাখবার লেনদেন করেন: দুজন জমা দেন (৫ আর ৩ টাকা করে), দুজন তোলেন (২ আর ৪ টাকা করে)। তোলার আগে দেখা হয় টাকা আছে কি না — “দেখা আর বদলানো” একসাথে, mutex-এর ভেতরে:
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#define OPS 100000 /* operations per thread */
static long balance = 1000000; /* shared account: 10 lakh taka */
static long refused = 0; /* withdrawals refused (no money) */
static pthread_mutex_t acct = PTHREAD_MUTEX_INITIALIZER;
static void *teller(void *arg) {
long amount = (long)(intptr_t)arg; /* > 0 deposit, < 0 withdraw */
for (int i = 0; i < OPS; i++) {
pthread_mutex_lock(&acct); /* acquire: now WE own the lock */
if (balance + amount >= 0) balance += amount; /* check + update ... */
else refused++; /* ... as ONE indivisible step */
pthread_mutex_unlock(&acct); /* release: only the owner may */
}
return NULL;
}
int main(void) {
long amounts[4] = { +5, +3, -2, -4 }; /* taka per operation */
pthread_t t[4];
for (int i = 0; i < 4; i++)
pthread_create(&t[i], NULL, teller, (void *)(intptr_t)amounts[i]);
for (int i = 0; i < 4; i++) pthread_join(t[i], NULL);
long expect = 1000000 + (long)OPS * (5 + 3 - 2 - 4);
printf("final balance : %ld\n", balance);
printf("expected : %ld\n", expect);
printf("refused : %ld\n", refused);
printf("match : %s\n", balance == expect ? "yes" : "no");
return 0;
}একটি আসল রানের আউটপুট:
final balance : 1200000
expected : 1200000
refused : 0
match : yes
হিসাব: প্রতি রাউন্ডে ৫ + ৩ − ২ − ৪ = ২ টাকা বাড়ে, ১ লাখ রাউন্ডে ২ লাখ, তাই ১০ লাখ + ২ লাখ = ১২,০০,০০০. থ্রেডগুলো যে ক্রমেই চলুক, ফল প্রতিবার এক। refused সবসময় ০ হওয়ার কারণও হিসাব করা যায়: সবচেয়ে খারাপ ক্রমেও (সব তোলা আগে, সব জমা পরে) মোট তোলা ১ লাখ × (২ + ৪) = ৬ লাখ, যা শুরুর ১০ লাখের কম। এখানে mutex দুটি কাজ করছে। প্রথমত lost update ঠেকাচ্ছে (পর্ব ১৫-এ দেখেছেন lock ছাড়া counter++-এর লাখ লাখ যোগ হারায়)। দ্বিতীয়ত, আরও সূক্ষ্ম: “টাকা আছে কি?” যাচাই আর “টাকা কাটা” একই lock-এর ভেতরে, তাই যাচাইয়ের পরে কিন্তু কাটার আগে অন্য কেউ টাকা তুলে নিতে পারে না। একে বলে check-then-act race; lock শুধু একটি লাইনকে নয়, একটি পুরো সিদ্ধান্তকে অবিভাজ্য করে।
প্রতিটি shared ডেটার জন্য বলতে পারতে হবে “এটি কোন lock দিয়ে রক্ষিত”। ডেটা পড়া আর লেখা — দুটোই সেই lock ধরে। Lock-এর ভেতরের অংশ যত ছোট রাখা যায় তত ভালো (ভেতরে ফাইল পড়া, নেটওয়ার্ক বা printf-এর মতো ধীর কাজ নয়), আর যে পথেই ফাংশন থেকে বের হোন, প্রতিটি lock-এর জন্য ঠিক একটি unlock.
২ · অপেক্ষার দুই পথ — spin না ঘুম
Lock বন্ধ পেলে থ্রেড কী করবে? দুটি পথ আছে, আর এই পছন্দই mutex আর spinlock-কে আলাদা করে।
Spin করা (busy waiting)
থ্রেড একটি লুপে বারবার দেখে — “খুলেছে? খুলেছে?” — CPU ছাড়ে না। একে বলে busy waiting. Lock অল্প সময়ের জন্য (কয়েকশো ন্যানোসেকেন্ড) ধরা থাকলে এটিই দ্রুত, কারণ ঘুমানো আর জাগার খরচ লাগে না। কিন্তু অপেক্ষা লম্বা হলে CPU শুধু পুড়ে যায়।
ঘুমানো (blocking)
থ্রেড kernel-কে জানায় যে lock খুললে তাকে জাগাতে হবে, তারপর CPU ছেড়ে দেয়। Kernel তাকে একটি wait queue-এ রেখে অন্য কাউকে চালায়। এমন lock-কে বলে sleeping lock, আর সাধারণ অর্থে mutex মানে এটিই। খরচ: ঘুমানো আর জাগাতে দুটি context switch আর system call.
Kernel অপেক্ষারত থ্রেডকে কীভাবে ঘুম পাড়ায়
পর্ব ৮ আর ৯-এর ভাষায়: ঘুমানো মানে থ্রেডের অবস্থা running থেকে waiting/blocked-এ যাওয়া। ধাপগুলো মোটামুটি সব OS-এ একই — (১) থ্রেডের TCB সেই lock-এর wait queue-তে যোগ হয়, (২) অবস্থা “ঘুমন্ত” চিহ্নিত হয়, তাই সে আর ready queue-তে থাকে না, (৩) scheduler অন্য কাউকে CPU দেয়। Lock ছাড়ার সময় মালিক wait queue থেকে একজনকে তুলে আবার ready queue-তে রাখে — সে তখন CPU পাওয়ার যোগ্য, আর পরে চলতে শুরু করে lock আবার নেওয়ার চেষ্টা করে। Linux kernel-এর ভেতরের কোড এই ছাঁচের:
DEFINE_WAIT(wait);
for (;;) {
prepare_to_wait(&q, &wait, TASK_UNINTERRUPTIBLE); /* join q, mark "sleeping" */
if (condition) /* re-check AFTER joining the queue */
break;
schedule(); /* give the CPU away; resume here when woken */
}
finish_wait(&q, &wait); /* state = TASK_RUNNING, leave the queue */
/* the waker, e.g. at unlock time: */
condition = 1;
wake_up(&q); /* move a sleeper back to the run queue */লক্ষ করুন ক্রমটি: আগে সারিতে নাম লেখানো আর “ঘুমন্ত” চিহ্ন, তারপর শর্ত আবার যাচাই, তারপর ঘুম। উল্টো ক্রমে করলে (আগে যাচাই, তারপর সারিতে যোগ) মাঝের মুহূর্তে জাগানোর সংকেত এসে কাউকে না পেয়ে হারিয়ে যেতে পারে — lost wakeup, অংশ ৮-এ বিস্তারিত।
কোনটি কখন — আর adaptive mutex
এক-CPU-র মেশিনে spin প্রায় সবসময় অর্থহীন: আপনি যতক্ষণ ঘুরছেন, মালিক CPU পাচ্ছে না, তাই lock খোলার সম্ভাবনাই নেই — পুরো time slice নষ্ট। Multicore-এ মালিক অন্য core-এ চলছে, শিগগির ছাড়বে — তখন অল্প spin ভালো। এই বুদ্ধি থেকে এসেছে adaptive mutex: মালিক যদি এই মুহূর্তে অন্য CPU-তে চলমান থাকে তবে কিছুক্ষণ spin, আর মালিক নিজেই ঘুমন্ত বা preempted হলে সঙ্গে সঙ্গে ঘুম। Solaris-এর adaptive mutex এর পরিচিত উদাহরণ, Linux kernel-এর নিজস্ব struct mutex-ও মালিক চলমান থাকলে কিছুক্ষণ spin করে (একে বলে optimistic spinning), আর glibc-তে PTHREAD_MUTEX_ADAPTIVE_NP নামের একটি অ-মানক ধরন আছে যা সীমিত কয়েকবার spin করে তারপর ঘুমায়। Windows-এর CRITICAL_SECTION-এও একটি spin count দেওয়া যায় (অংশ ৭)।
কতক্ষণ spin? — একটি ছোট প্রমাণ
ধরা যাক ঘুমিয়ে আবার জাগার মোট খরচ C (context switch, syscall ইত্যাদি), আর lock খুলতে কত সময় লাগবে (t) তা আগে থেকে জানা নেই। কৌশল: “C সময় পর্যন্ত spin, তখনও না খুললে ঘুম”। দাবি: এই কৌশলের খরচ কখনো সর্বোত্তম খরচের ২ গুণের বেশি নয় — এমনকি সেই সর্বোত্তম কৌশলের সাথে তুলনাতেও, যে আগে থেকেই t জানে।
- t ≤ C হলে: আমরা t সময় spin করে lock পাই, খরচ t. যে t জানে, সে-ও এর কম খরচ করতে পারে না (spin-এর খরচ t, ঘুমের খরচ C ≥ t). অনুপাত ১.
- t > C হলে: আমরা C spin করে তারপর ঘুমাই, মোট খরচ C + C = ২C. সর্বোত্তম কৌশল সঙ্গে সঙ্গে ঘুমাত, খরচ C. অনুপাত ২. ∎
অর্থাৎ ভবিষ্যৎ না জেনেও আমরা সর্বোত্তমের দ্বিগুণের মধ্যে থাকি। এ ধরনের নিশ্চয়তাকে বলে competitive ratio; Karlin, Li, Manasse আর Owicki-এর ১৯৯১ সালের একটি পরিচিত গবেষণা এই spin-then-block কৌশল বাস্তব কাজের চাপে মেপে দেখেছিল। Adaptive mutex একই ধারণার একটি ব্যবহারিক রূপ।
৩ · Linux-এ futex — user space-এ দ্রুত পথ, kernel-এ ধীর পথ
সমস্যাটি ভাবুন: ঘুমাতে kernel লাগে, অথচ প্রতিটি pthread_mutex_lock-এ system call করলে প্রতিযোগিতা ছাড়া lock-ও কয়েকশো ন্যানোসেকেন্ড বা তার বেশি সময় নেবে। বাস্তবের বেশিরভাগ lock নেওয়ার সময় কেউ প্রতিযোগিতা করে না। তাহলে kernel-এ যাব শুধু তখনই, যখন সত্যিই অপেক্ষা লাগবে — এটিই Linux-এর futex (fast userspace mutex)-এর মূল ধারণা। ২০০২ সালে Hubertus Franke, Rusty Russell আর Matthew Kirkwood-এর একটি প্রবন্ধে এর নকশা আসে, আর Linux 2.6 থেকে এটি glibc-র সব থ্রেড-সমন্বয়ের ভিত্তি।
ব্যবস্থাটি দুই ভাগে:
- User space-এ একটি পূর্ণসংখ্যা (lock word), যা থ্রেডগুলো atomic instruction (পর্ব ২৩-এর compare-and-swap, exchange) দিয়ে বদলায়। প্রতিযোগিতা না থাকলে lock নেওয়া আর ছাড়া পুরোটাই এখানে — এটি fast path.
- একটি system call
futex(addr, op, val, …), যার দুটি মূল কাজ:FUTEX_WAIT—*addr-এ এখনোvalথাকলে ডাকা থ্রেডকে ঘুম পাড়ানো, আরFUTEX_WAKE— এই ঠিকানায় যারা ঘুমিয়ে আছে তাদের n জনকে জাগানো। এটি slow path, শুধু প্রতিযোগিতার সময়।
FUTEX_WAIT-এর “এখনো val থাকলে” অংশটিই আসল কৌশল। Kernel মান যাচাই আর ঘুমানো একসাথে, অবিভাজ্যভাবে করে। ফলে আপনি “lock বন্ধ, ঘুমাব” ঠিক করার পর কিন্তু ঘুমানোর আগে মালিক lock ছেড়ে দিলে kernel দেখবে মান বদলে গেছে, আর আপনাকে না ঘুমিয়ে সঙ্গে সঙ্গে ফিরিয়ে দেবে — জাগরণ হারায় না। নিচের mutex-টি Ulrich Drepper-এর বিখ্যাত প্রবন্ধ “Futexes Are Tricky”-র তৃতীয় নকশার সরলীকৃত রূপ; lock word-এর তিনটি মান: 0 = খোলা, 1 = বন্ধ, কেউ অপেক্ষায় নেই, 2 = বন্ধ, অপেক্ষায় কেউ থাকতে পারে। ধাপে ধাপে দেখুন:
1lock():2 c = cmpxchg(&word, 0, 1); // 0 → 1 if free3 if (c == 0) return; // fast path: no syscall4 if (c != 2) c = xchg(&word, 2);5 while (c != 0) {6 futex_wait(&word, 2); // sleep if word is still 27 c = xchg(&word, 2); }8unlock():9 if (atomic_dec(&word) != 1) { // old value 2?10 word = 0; futex_wake(&word, 1); }
| word | |
| T1 | |
| T2 | |
| T3 | |
| kernel wait queue | |
| মোট syscall |
- T1-এর fast path. word = 0 (খোলা)। T1 একটি atomic
cmpxchgকরে, যার অর্থ: word 0 হলে সেখানে 1 লেখা আর পুরোনো মান ফেরত দেওয়া। পুরোনো মান 0, তাই T1 মালিক। কোনো system call নেই — পুরো কাজটি একটি instruction, কয়েক ন্যানোসেকেন্ড। - T2 এসে দেখে বন্ধ। T2-এর
cmpxchgব্যর্থ, পুরোনো মান c = 1. T2 এবারxchgদিয়ে word-এ 2 লেখে (“অপেক্ষায় কেউ আছে”) আর পুরোনো মান পায় 1 — অর্থাৎ lock এখনো বন্ধ। তাই সে লুপে ঢোকে। - T2 kernel-এ ঘুমায়।
futex_wait(&word, 2): kernel যাচাই করে word এখনো 2 কি না — হ্যাঁ, তাই T2-কে সেই ঠিকানার wait queue-তে রেখে ঘুম পাড়ায়, আর scheduler অন্য কাউকে চালায়। প্রথম system call. T2 এখন CPU খরচ করছে না। - T1 ছাড়ে আর জাগায়।
atomic_decword-কে 2 থেকে 1 করে পুরোনো মান 2 ফেরত দেয় — মানে কেউ অপেক্ষায় থাকতে পারে। তাই T1 word = 0 লেখে আরfutex_wake(&word, 1)ডাকে: kernel T2-কে wait queue থেকে ready queue-তে সরায়। দ্বিতীয় system call. - T2 মালিক হয়। জেগে T2 আবার
xchg(&word, 2)করে; পুরোনো মান 0, তাই লুপ শেষ, T2 মালিক। লক্ষ করুন, word এখন 2 — যদিও আর কেউ অপেক্ষায় নেই। T2 জানে না তার পেছনে কেউ আছে কি না, তাই সাবধানে “থাকতে পারে” ধরে নেয়। - সাবধানতার দাম। T2 ছাড়ার সময়
atomic_decপুরোনো মান 2 পায়, তাইfutex_wakeডাকে — কিন্তু kernel-এর সারিতে কেউ নেই। তৃতীয় system call, বৃথা। তবে ভুল নয়: বাড়তি একটি syscall-এর খরচে নিশ্চিত হলো যে কোনো ঘুমন্ত থ্রেড কখনো ভুলে পড়ে থাকবে না। - আবার শান্ত সময়। পরে T3 একা lock নেয় (0 → 1) আর ছাড়ে (
atomic_decপুরোনো মান 1 দেয়, তাই wake লাগে না)। শূন্য system call. তিন থ্রেড মিলে ৩বার lock আর ৩বার unlock — মোট ৬টি অপারেশনে syscall মাত্র ৩টি, আর সবগুলোই প্রতিযোগিতার মুহূর্তে। প্রতিযোগিতা কম এমন প্রোগ্রামে প্রায় সব lock-ই fast path-এ শেষ হয়।
বাস্তবের glibc-র pthread_mutex_lock আরও অনেক কিছু সামলায় (mutex-এর ধরন, মালিকের TID রাখা, robust আর priority-inheritance রূপ), কিন্তু normal mutex-এর মূল ছাঁচ এটিই। sem_wait/sem_post, condition variable আর pthread_join-ও futex-এর ওপর দাঁড়ানো (পর্ব ১৫-এ join-এর futex দেখেছেন)।
আরও গভীরে: Kernel ঘুমন্ত থ্রেডদের রাখে একটি hash table-এ, যার চাবি futex-এর ঠিকানা (shared memory-র futex হলে ভেতরের পেজ আর offset, যাতে দুই প্রসেসের ভিন্ন ভার্চুয়াল ঠিকানাও এক ঘরে মেলে) — তাই lock word-এর জন্য kernel-এ আগে থেকে কোনো অবজেক্ট বানাতে হয় না। Priority inheritance-ওয়ালা mutex-এর জন্য আছে আলাদা FUTEX_LOCK_PI/FUTEX_UNLOCK_PI, যেখানে lock word-এ মালিকের TID লেখা থাকে আর kernel একটি rt_mutex দিয়ে মালিকের অগ্রাধিকার সাময়িক বাড়ায় (পর্ব ২৯)। Linux 5.16-এ futex_waitv যোগ হয়, যা একসাথে কয়েকটি futex-এ অপেক্ষা করতে দেয় — Windows-এর WaitForMultipleObjects-ধাঁচের কাজ Linux-এ দ্রুত চালানোর (যেমন গেম-এমুলেশনে) প্রয়োজনে। Windows 8 থেকে WaitOnAddress/WakeByAddressSingle futex-এর মতোই একটি ব্যবস্থা দেয়।
৪ · pthread mutex-এর ধরন, trylock আর timedlock
একই থ্রেড যদি নিজের ধরা mutex আবার lock করতে চায়, বা মালিক নয় এমন কেউ unlock করে — কী হবে? POSIX উত্তর ঠিক করে দিয়েছে mutex-এর ধরন দিয়ে, যা pthread_mutexattr_settype দিয়ে বাছা হয়:
| ধরন | মালিক আবার lock করলে | মালিক নয় এমন কেউ unlock করলে | কখন |
|---|---|---|---|
PTHREAD_MUTEX_NORMAL | চিরকাল আটকে থাকে (নিজের সাথেই deadlock) | undefined behavior | সবচেয়ে দ্রুত; কোড সঠিক জানা থাকলে |
PTHREAD_MUTEX_ERRORCHECK | EDEADLK ফেরত দেয় | EPERM ফেরত দেয় | ডিবাগ আর টেস্টের সময় |
PTHREAD_MUTEX_RECURSIVE | একটি গণনা বাড়ে; ততবার unlock লাগে | EPERM ফেরত দেয় | যে ফাংশন lock ধরে নিজেকেই আবার ডাকতে পারে |
PTHREAD_MUTEX_DEFAULT | undefined behavior | undefined behavior | ডিফল্ট; Linux/glibc-তে আচরণ normal-এর মতো |
সঙ্গে আছে অপেক্ষা না করার আর সীমিত অপেক্ষার দুটি উপায়। pthread_mutex_trylock lock খোলা থাকলে নেয়, বন্ধ থাকলে না ঘুমিয়ে সঙ্গে সঙ্গে EBUSY ফেরত দেয় — “পরে চেষ্টা করব, এখন অন্য কাজ”। pthread_mutex_timedlock একটি পরম সময় (CLOCK_REALTIME-এর একটি মুহূর্ত, “এখন থেকে ১০০ ms” নয়) পর্যন্ত অপেক্ষা করে, না পেলে ETIMEDOUT. নিচের ফলগুলো একটি পরীক্ষামূলক প্রোগ্রাম Wandbox-এ (glibc, Linux) চালিয়ে পাওয়া:
pthread_mutexattr_t a;
pthread_mutexattr_init(&a);
pthread_mutexattr_settype(&a, PTHREAD_MUTEX_ERRORCHECK);
pthread_mutex_init(&ec, &a);
pthread_mutex_lock(&ec); /* 0 */
pthread_mutex_lock(&ec); /* EDEADLK "Resource deadlock avoided" */
pthread_mutexattr_settype(&a, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init(&rec, &a);
/* lock x3 -> 0 0 0, unlock x3 -> 0 0 0, 4th unlock -> EPERM */
pthread_mutex_lock(&nm); /* a normal mutex, now held */
pthread_mutex_trylock(&nm); /* EBUSY "Device or resource busy" */
/* from ANOTHER thread: */
pthread_mutex_unlock(&ec); /* EPERM "Operation not permitted" */
pthread_mutex_timedlock(&nm, &deadline_100ms); /* ETIMEDOUT */আরও দুটি বিশেষ রূপ জেনে রাখুন। Robust mutex (pthread_mutexattr_setrobust): মালিক থ্রেড বা প্রসেস lock ধরা অবস্থায় মারা গেলে পরের জন lock পায় সাথে EOWNERDEAD সতর্কতা — ডেটা ঠিক করে pthread_mutex_consistent ডাকার সুযোগ। Process-shared mutex (PTHREAD_PROCESS_SHARED): shared memory-তে রাখা mutex দিয়ে দুটি প্রসেসের মধ্যে পাহারা (পর্ব ১২-এর shared memory). আর priority inheritance (PTHREAD_PRIO_INHERIT) পর্ব ২৯-এ।
Recursive mutex একটি গন্ধ, সমাধান নয়। “আমার ফাংশন lock ধরে নিজেকে আবার ডাকে, তাই recursive দিলাম” শুনতে সহজ। কিন্তু ভেতরের ডাকটি যখন চলে, তখন ডেটা হয়তো অর্ধেক-বদলানো অবস্থায় — বাইরের ডাক তার কাজ শেষ করেনি। আর condition variable-এর সাথে recursive mutex একসাথে চলে না (wait শুধু একটি স্তর ছাড়ে)। সাধারণত ভালো নকশা: lock ছাড়া একটি ভেতরের ফাংশন (_locked নাম দিয়ে), আর lock নিয়ে সেটি ডাকে এমন একটি বাইরের ফাংশন।
৫ · Semaphore — Dijkstra-র সংকেত-খুঁটি
Mutex বলে “একজন”। কিন্তু অনেক সমস্যায় দরকার “n জন”, বা “ও শেষ করলে তবে আমি”। এর জন্য Edsger W. Dijkstra ১৯৬০-এর দশকের প্রথম দিকে একটি সাধারণ হাতিয়ার প্রস্তাব করেন — semaphore. ১৯৬৫ সালের তাঁর বিখ্যাত লেখা “Cooperating Sequential Processes” (EWD123)-এ এটি বিস্তারিত আসে, আর পরে নেদারল্যান্ডসের Eindhoven-এ তাঁর দলের তৈরি THE multiprogramming system-এ ব্যবহৃত হয়। নামটি রেলের সিগন্যাল-খুঁটি (semaphore) থেকে — যা বলে ট্রেন এগোবে, না থামবে।
একটি ব্যাংকের শাখায় ৩টি ক্যাশ কাউন্টার। দরজায় দারোয়ানের হাতে একটি গণনা: “এখন খালি কাউন্টার ৩”। গ্রাহক এলে দারোয়ান সংখ্যাটি ১ কমান; সংখ্যাটি শূন্যের নিচে গেলে গ্রাহককে টোকেন দিয়ে বেঞ্চে বসান (অপেক্ষার সারি)। কেউ কাজ শেষে বেরোলে দারোয়ান সংখ্যাটি ১ বাড়ান, আর বেঞ্চে কেউ থাকলে সারির প্রথমজনকে ডাকেন। দারোয়ানের সেই সংখ্যা আর বেঞ্চটিই semaphore. দারোয়ান জানেন না কোন কাউন্টারে কে বসেছে, শুধু গোনেন — মালিকানার কোনো ধারণা নেই।
P আর V — নাম কোথা থেকে
Semaphore একটি পূর্ণসংখ্যা S, যাকে শুরুতে একটি মান দেওয়া হয়, আর তারপর শুধু দুটি অবিভাজ্য অপারেশন দিয়ে ছোঁয়া যায়:
- P(S) — বিভিন্ন বইয়ে
wait,downবাacquire: S থেকে ১ নেওয়ার চেষ্টা; না পারলে অপেক্ষা। - V(S) —
signal,up,postবাrelease: S-এ ১ ফেরত দেওয়া, আর অপেক্ষারত কাউকে জাগানো।
অক্ষর দুটি ডাচ শব্দ থেকে, তবে কোন শব্দ — তা নিয়ে বইভেদে ভিন্ন ব্যাখ্যা পাবেন। প্রচলিত একটি ব্যাখ্যা: V এসেছে verhogen (বাড়ানো) থেকে, আর P এসেছে Dijkstra-র বানানো শব্দ prolaag থেকে, যা probeer te verlagen (“কমানোর চেষ্টা”)-এর সংক্ষেপ। কিছু পুরোনো বইয়ে P = passeren (পার হওয়া) আর V = vrijgeven (ছেড়ে দেওয়া) লেখা আছে। পরীক্ষায় অর্থটি মনে রাখাই যথেষ্ট: P কমায়/অপেক্ষা করায়, V বাড়ায়/জাগায়।
সংজ্ঞা ১ — busy waiting দিয়ে (প্রাথমিক রূপ)
wait(S) {
while (S <= 0)
; /* busy wait */
S--;
}
signal(S) {
S++;
}
/* the test S <= 0 and S-- must happen as ONE atomic step */সহজ, কিন্তু অপেক্ষারত প্রসেস CPU পুড়িয়ে ঘোরে — এক অর্থে এটি একটি spinlock. এখানে S কখনো ঋণাত্মক হয় না।
সংজ্ঞা ২ — অপেক্ষার সারিসহ (busy waiting ছাড়া)
typedef struct { int value; struct process *list; } semaphore;
wait(semaphore *S) {
S->value--;
if (S->value < 0) {
add this process to S->list;
sleep(); /* block: running -> waiting */
}
}
signal(semaphore *S) {
S->value++;
if (S->value <= 0) { /* someone was waiting */
remove a process P from S->list;
wakeup(P); /* waiting -> ready */
}
}এই রূপে wait আগে মান কমায়, তারপর দেখে। তাই মান ঋণাত্মক হতে পারে, আর তখন |মান| = অপেক্ষারত প্রসেসের সংখ্যা: মান −২ মানে দুজন সারিতে। signal-এর পর মান ≤ ০ মানে কেউ অপেক্ষায় ছিল, তাই একজনকে জাগাতে হয়। একটি কথা খেয়াল করুন: wait আর signal নিজেরাও shared ডেটা (value আর list) বদলায়, তাই এদের অবিভাজ্য হতে হয় — kernel ভেতরে একটি ছোট spinlock বা (এক-CPU-তে) interrupt বন্ধ করে তা নিশ্চিত করে (পর্ব ২৩)। অর্থাৎ busy waiting পুরোপুরি যায়নি, সরে গেছে wait/signal-এর কয়েক লাইনের ভেতরে, যেখানে অপেক্ষা খুব ছোট।
পাঠ্যবই-রীতি (ঋণাত্মক মান): মান = শুরুর মান − শুরু হওয়া wait-এর সংখ্যা + signal-এর সংখ্যা; ঋণাত্মক হলে |মান| জন অপেক্ষায়। অ-ঋণাত্মক রীতি: মান কখনো ০-এর নিচে নামে না; অপেক্ষারতদের সংখ্যা আলাদা রাখা হয়, আর কেউ অপেক্ষায় থাকলে signal মান না বাড়িয়ে সরাসরি একজনকে জাগায়। POSIX দুটোই অনুমতি দেয়: অপেক্ষারত থাকলে sem_getvalue ০ দিতে পারে, অথবা ঋণাত্মক সংখ্যা যার পরম মান অপেক্ষারতদের সংখ্যা। Linux প্রথমটি বেছেছে — সেখানে আপনি কখনো ঋণাত্মক দেখবেন না। পরীক্ষায় প্রশ্ন সাধারণত পাঠ্যবই-রীতিতে; উত্তরে কোন রীতি ধরছেন তা এক লাইনে লিখে দিলে ঝুঁকি থাকে না।
এখন পরীক্ষার সবচেয়ে প্রিয় কাজ — মান আর সারি ট্রেস করা। S = ২ (যেমন ২টি কাউন্টার), চারটি প্রসেস A, B, C, D. প্রতিটি ধাপের পর দুই রীতিতে মান, সারি আর কে ভেতরে আছে দেখুন:
1S = 2 // initial value2A: wait(S)3B: wait(S)4C: wait(S)5D: wait(S)6A: signal(S)7B: signal(S)8C: signal(S)9D: signal(S)
| মান (পাঠ্যবই) | |
| মান (POSIX/Linux) | |
| অপেক্ষার সারি | |
| ভেতরে (সম্পদ ধরে) | |
| ঘটনা |
- শুরু: S = 2. দুটি একই রকম সম্পদ খালি — যেমন দুটি কাউন্টার। সারি খালি, কেউ ভেতরে নেই। দুই রীতিতেই মান 2.
- A: wait. 2 − 1 = 1, যা ঋণাত্মক নয়, তাই A না থেমে ভেতরে ঢোকে। মান 1 মানে এখনো একটি সম্পদ খালি।
- B: wait. 1 − 1 = 0, ঋণাত্মক নয়, B-ও ঢোকে। এখন দুটি সম্পদই ব্যস্ত, তবে কেউ অপেক্ষায় নেই — মান ঠিক 0.
- C: wait — ঘুম। পাঠ্যবই-রীতিতে 0 − 1 = −1 < 0, তাই C সারিতে যায় আর ঘুমায় (running থেকে waiting). মান −1 মানে একজন অপেক্ষায়। Linux-এ মান 0-তেই থাকে, অপেক্ষারত গোনা হয় আলাদাভাবে।
- D: wait — ঘুম। −1 − 1 = −2, D-ও সারিতে C-এর পেছনে। |−2| = 2 জন অপেক্ষায় — সারির দৈর্ঘ্যের সাথে মেলে। এটিই ঋণাত্মক মানের অর্থ।
- A: signal — C জাগে। −2 + 1 = −1. ফল ≤ 0, অর্থাৎ কেউ অপেক্ষায় ছিল, তাই সারির প্রথমজন C-কে জাগানো হয় (waiting থেকে ready). A-র ছেড়ে দেওয়া সম্পদ সরাসরি C পেল; C আর wait আবার ডাকে না, তার wait-এর বাকি অংশ শেষ হলো।
- B: signal — D জাগে। −1 + 1 = 0, এখনো ≤ 0, তাই D জাগে। সারি খালি। ভেতরে এখন C আর D — আবার ঠিক ২ জন, কখনো ২-এর বেশি নয়।
- C: signal. 0 + 1 = 1 > 0 — কেউ অপেক্ষায় ছিল না, জাগানোর কেউ নেই, সম্পদটি শুধু ফেরত গেল। এখান থেকে দুই রীতির মান আবার এক।
- D: signal — শেষ। 1 + 1 = 2, শুরুর মতো। যাচাই: শেষ মান = শুরুর মান − wait-এর সংখ্যা + signal-এর সংখ্যা = 2 − 4 + 4 = 2. পুরো ট্রেসে কখনোই ২-এর বেশি প্রসেস একসাথে ভেতরে ছিল না।
কেন semaphore কাজ করে — invariant আর একটি প্রমাণ
অ-ঋণাত্মক রীতিতে ভাবুন। ধরুন শুরুর মান k, সম্পূর্ণ হওয়া wait-এর সংখ্যা #P, আর signal-এর সংখ্যা #V. প্রতিটি সম্পূর্ণ wait মান ১ কমায়, প্রতিটি signal ১ বাড়ায় (বা সরাসরি একজনের wait সম্পূর্ণ করায় — হিসাবে একই), তাই সবসময় সত্য থাকে এই invariant:
এখন ধরুন প্রতিটি প্রসেস wait(S); critical section; signal(S); মেনে চলে। যে প্রসেস ভেতরে আছে, সে wait সম্পূর্ণ করেছে কিন্তু signal দেয়নি; তাই ভেতরের সংখ্যা = #P − #V = k − S ≤ k, কারণ S ≥ ০. অর্থাৎ কখনোই k-এর বেশি প্রসেস একসাথে ভেতরে থাকতে পারে না; k = ১ হলে এটিই mutual exclusion-এর প্রমাণ। ∎ আর সারি যদি FIFO হয় (একে বলে strong semaphore), প্রতিটি অপেক্ষারত প্রসেস সীমিত সংখ্যক signal-এর পর জাগবেই — starvation নেই। সারি থেকে যেকোনো একজনকে জাগানো weak semaphore-এ এই নিশ্চয়তা থাকে না। POSIX FIFO নিশ্চিত করে না; priority scheduling-এ সবচেয়ে উচ্চ অগ্রাধিকারের অপেক্ষারত থ্রেড জাগে।
নিজে চালিয়ে দেখুন — semaphore simulator
নিচের simulator পাঠ্যবই-রীতিতে প্রতিটি অপারেশনের পর মান, সারি আর POSIX-রীতির মান ছাপে। এখানে S = ৩, পাঁচটি প্রসেস; মাঝে একটি ইচ্ছাকৃত ভুল অপারেশনও আছে — একটি ঘুমন্ত প্রসেস signal দিতে চাইছে। value আর ops বদলে নিজের প্রশ্ন বানিয়ে উত্তর মিলিয়ে নিন:
// Counting semaphore with a FIFO waiting queue (textbook style, value may go negative).
// wait: value--; if value < 0 the caller blocks. signal: value++; if value <= 0 wake the first waiter.
let value = 3; // initial value of S: 3 identical resources
const ops = ["P1 wait", "P2 wait", "P3 wait", "P4 wait", "P5 wait", "P5 signal",
"P2 signal", "P1 signal", "P3 signal", "P4 signal", "P5 signal"];
const queue = [], blocked = new Set(), holds = new Set();
const row = (op, note) => console.log(op.padEnd(10) + String(value).padStart(5) + " " +
("[" + queue.join(", ") + "]").padEnd(10) + String(Math.max(value, 0)).padStart(6) + " " + note);
console.log("op value queue POSIX-value what happened");
row("start", "");
for (const op of ops) {
const [p, kind] = op.split(" ");
if (blocked.has(p)) { console.log(op.padEnd(10) + " ERROR: " + p + " is blocked, it cannot run"); continue; }
if (kind === "wait") {
value--;
if (value < 0) { queue.push(p); blocked.add(p); row(op, p + " blocks"); }
else { holds.add(p); row(op, p + " passes"); }
} else {
holds.delete(p); value++;
if (value <= 0) { // someone was waiting: hand the unit over
const w = queue.shift(); blocked.delete(w); holds.add(w); row(op, "wakes " + w);
} else row(op, "unit returned");
}
}
console.log("final value = " + value + ", waiting = " + queue.length);
console.log("check: initial 3 - 5 waits + 5 signals = " + (3 - 5 + 5));আসল রানের আউটপুট:
op value queue POSIX-value what happened
start 3 [] 3
P1 wait 2 [] 2 P1 passes
P2 wait 1 [] 1 P2 passes
P3 wait 0 [] 0 P3 passes
P4 wait -1 [P4] 0 P4 blocks
P5 wait -2 [P4, P5] 0 P5 blocks
P5 signal ERROR: P5 is blocked, it cannot run
P2 signal -1 [P5] 0 wakes P4
P1 signal 0 [] 0 wakes P5
P3 signal 1 [] 1 unit returned
P4 signal 2 [] 2 unit returned
P5 signal 3 [] 3 unit returned
final value = 3, waiting = 0
check: initial 3 - 5 waits + 5 signals = 3
তিনটি জিনিস লক্ষ করুন। (১) মান −২ হওয়ার মুহূর্তে সারিতে ঠিক দুজন — P4 আর P5. (২) “P5 signal” বাতিল: ঘুমন্ত প্রসেস কোনো কোডই চালাতে পারে না, তাই পরীক্ষার প্রশ্নে এমন ক্রম দিলে সেটি অসম্ভব ক্রম — খাতায় তা উল্লেখ করুন। (৩) জাগার ক্রম FIFO: আগে ঘুমানো P4 আগে জাগে। শেষ লাইনের সূত্র — শেষ মান = শুরুর মান − wait + signal — পাঠ্যবই-রীতিতে যেকোনো ক্রমেই খাটে, কারণ প্রতিটি অপারেশন মানকে ঠিক ১ বদলায়।
৬ · Semaphore-এর তিন কাজ — পাহারা, ক্রম আর গণনা
একই হাতিয়ার, শুধু শুরুর মান বদলে তিনটি ভিন্ন সমস্যার সমাধান। পরীক্ষা আর ইন্টারভিউতে এই তিনটি ছাঁচ চিনতে পারা জরুরি।
(ক) Mutual exclusion — মান ১
semaphore mutex = 1;
/* every process: */
wait(mutex);
/* critical section */
signal(mutex);
/* remainder section */অংশ ৫-এর প্রমাণে k = ১: একসাথে একজনের বেশি ভেতরে নয়। মান কেবল ০ আর ১-এর মধ্যে ঘোরে (অপেক্ষারত ছাড়া), তাই এমন semaphore-কে বলে binary semaphore. আর যার মান যেকোনো অ-ঋণাত্মক পূর্ণসংখ্যা হতে পারে, সেটি counting semaphore.
(খ) ক্রম বা সংকেত — মান ০
প্রসেস P1-এর statement S1 অবশ্যই P2-এর S2-এর আগে চলতে হবে — যেমন P1 একটি config ফাইল লিখবে, P2 সেটি পড়বে। একটি semaphore sync, শুরুর মান ০:
1semaphore sync = 0;2// P13S1; // e.g. write config4signal(sync);5// P26wait(sync);7S2; // e.g. read config
| পরিস্থিতি | |
| sync (পাঠ্যবই) | |
| P1 | |
| P2 | |
| সারি |
- sync = 0. শূন্য মানে “এখনো কোনো সংকেত আসেনি”। দুই প্রসেসই প্রস্তুত, scheduler যাকে খুশি আগে চালাতে পারে। আমরা দুটি ক্রমই দেখব।
- ক্রম ১: P2 আগে CPU পায়। P2
wait(sync)ডাকে: 0 − 1 = −1 < 0, তাই P2 সারিতে গিয়ে ঘুমায়। S2 চালানোর আগেই সে থেমে গেল — ঠিক যা চাই, কারণ S1 এখনো হয়নি। - P1 S1 চালায়। P2 ঘুমন্ত, তাই config লেখার সময় কেউ পড়তে আসছে না। sync এখনো −1.
- P1: signal. −1 + 1 = 0 ≤ 0, তাই সারির P2-কে জাগানো হয়। P2 এখন ready — CPU পেলে তার wait-এর পরের লাইন থেকে চলবে।
- P2 S2 চালায়। আউটপুটে S1-এর পরে S2. শর্ত পূরণ।
- ক্রম ২: এবার P1 আগে। নতুন করে শুরু, sync = 0. P1 S1 চালিয়ে
signal(sync)দেয়: 0 + 1 = 1. কেউ অপেক্ষায় নেই, তবু সংকেতটি হারায় না — মান 1 হয়ে জমা থাকে। এটিই semaphore-এর “স্মৃতি”। - P2 পরে আসে।
wait(sync): 1 − 1 = 0, ঋণাত্মক নয়, তাই P2 না থেমে S2 চালায়। দুই ক্রমেই ফল এক: S1, তারপর S2. Condition variable-এর signal কিন্তু এভাবে জমা থাকে না — কেউ অপেক্ষায় না থাকলে হারিয়ে যায়; তাই সেখানে একটি আলাদা শর্ত-ভ্যারিয়েবল লাগে (পর্ব ২৫)।
এবার আসল POSIX semaphore দিয়ে। থ্রেড B-কে ইচ্ছে করে আগে তৈরি করা হয়েছে, আর A ২০০ ms ধীরে কাজ করে — তবু B ছাপে শুধু A-এর পরে:
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>
static sem_t done_a; /* "A has finished S1"; starts at 0 */
static int config = 0; /* written by A, read by B */
static struct timespec t0; /* start time, set by main */
static void *thread_a(void *arg) {
(void)arg;
usleep(200000); /* A is slow: 200 ms of work */
config = 42; /* S1 */
printf("A: S1 done, config = %d\n", config);
sem_post(&done_a); /* signal (V): wake B, or 0 -> 1 */
return NULL;
}
static void *thread_b(void *arg) {
(void)arg;
sem_wait(&done_a); /* wait (P): sleeps while value is 0 */
struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t);
double ms = (t.tv_sec - t0.tv_sec) * 1e3 + (t.tv_nsec - t0.tv_nsec) / 1e6;
printf("B: S2 sees config = %d, waited >= 200 ms: %s\n", config, ms >= 200 ? "yes" : "no");
return NULL;
}
int main(void) {
sem_init(&done_a, 0, 0); /* pshared 0 = between threads; value 0 */
clock_gettime(CLOCK_MONOTONIC, &t0);
pthread_t a, b;
pthread_create(&b, NULL, thread_b, NULL); /* B is started FIRST */
pthread_create(&a, NULL, thread_a, NULL);
pthread_join(a, NULL); pthread_join(b, NULL);
int v; sem_getvalue(&done_a, &v);
printf("main: semaphore value at the end = %d\n", v);
return sem_destroy(&done_a);
}একটি আসল রানের আউটপুট:
A: S1 done, config = 42
B: S2 sees config = 42, waited >= 200 ms: yes
main: semaphore value at the end = 0
B আগে চালু হয়েও sem_wait-এ ঘুমিয়ে ছিল প্রায় ২০০ ms, আর জেগে দেখেছে config = 42 — A-এর লেখা। দুটি নিশ্চয়তা একসাথে: (১) ক্রম — A-এর printf হয় sem_post-এর আগে, আর B-এর printf sem_wait-এর পরে, তাই আউটপুটের ক্রম প্রতিবার এক; (২) দৃশ্যমানতা — POSIX বলে sem_post-এর আগে যা লেখা হয়েছে, sem_wait পেরোনো থ্রেড তা দেখবে, তাই config lock ছাড়াই নিরাপদে পড়া গেছে। শেষে মান 0: একটি post, একটি wait, ০ + ১ − ১ = ০. sem_init-এর দ্বিতীয় argument 0 মানে semaphore একই প্রসেসের থ্রেডদের মধ্যে; অশূন্য দিলে shared memory-তে রেখে প্রসেসদের মধ্যেও ব্যবহার করা যায়।
(গ) সম্পদ গণনা — মান n
একটি অফিসে ২টি প্রিন্টার, কিন্তু ৫ জন একসাথে ছাপাতে চান। Semaphore-এর শুরুর মান = প্রিন্টারের সংখ্যা। প্রত্যেকে ছাপার আগে wait, শেষে signal. অংশ ৫-এর প্রমাণ বলে একসাথে কখনো ২ জনের বেশি নয়। নিচের প্রোগ্রাম সেটি মেপে দেখে — একটি mutex দিয়ে রক্ষিত গণনায় সর্বোচ্চ কতজন একসাথে “ছাপাচ্ছিল”:
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <unistd.h>
#define WORKERS 5
#define SLOTS 2 /* e.g. 2 printers */
static sem_t slots; /* counts free printers */
static pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
static int active = 0, max_active = 0, done = 0;
static void *job(void *arg) {
(void)arg;
sem_wait(&slots); /* take a printer, or sleep */
pthread_mutex_lock(&m);
if (++active > max_active) max_active = active; /* record the peak */
pthread_mutex_unlock(&m);
usleep(50000); /* "print" for 50 ms */
pthread_mutex_lock(&m);
active--; done++;
pthread_mutex_unlock(&m);
sem_post(&slots); /* give the printer back */
return NULL;
}
int main(void) {
sem_init(&slots, 0, SLOTS); /* initial value = free printers */
pthread_t t[WORKERS];
for (int i = 0; i < WORKERS; i++) pthread_create(&t[i], NULL, job, NULL);
for (int i = 0; i < WORKERS; i++) pthread_join(t[i], NULL);
int v; sem_getvalue(&slots, &v);
printf("workers finished: %d, printers: %d\n", done, SLOTS);
printf("max active at a time: %d\n", max_active);
printf("semaphore value now : %d\n", v);
return sem_destroy(&slots);
}একটি আসল রানের আউটপুট:
workers finished: 5, printers: 2
max active at a time: 2
semaphore value now : 2
পাঁচজনই কাজ শেষ করেছে, কিন্তু একসাথে কখনো ২ জনের বেশি নয় — semaphore-এর নিশ্চয়তা। সর্বোচ্চ ঠিক ২ (১ নয়) কারণ প্রতিটি কাজ ৫০ ms ধরে চলে, তাই প্রথম দুজন প্রায় সঙ্গে সঙ্গে একসাথে ঢোকে। বাকি তিনজন ঘুমিয়ে অপেক্ষা করে, CPU খরচ করে না; মোট সময় মোটামুটি ৩ দফা × ৫০ ms. শেষে মান আবার ২ — দুটি প্রিন্টারই খালি। দুটি আলাদা হাতিয়ারের আলাদা কাজও লক্ষ করুন: semaphore কতজন ঢুকবে তা সীমিত করে, আর mutex রক্ষা করে active গণনাটিকে, যা ভেতরের দুজন একসাথে বদলাতে পারে। Semaphore-এর মান ২ হলে ভেতরের shared ডেটা নিজে থেকে রক্ষিত নয়। ডেটাবেস connection pool বা “একসাথে সর্বোচ্চ ১০টি download” — বাস্তবের এমন সীমা এভাবেই বানানো হয়।
Producer-consumer-এর মতো সমস্যায় এই তিন ছাঁচ একসাথে লাগে — একটি mutex (মান ১), খালি ঘরের গণনা (মান n), ভরা ঘরের গণনা (মান ০)। পূর্ণ সমাধান আর readers-writers ও dining philosophers পর্ব ২৬-এ।
৭ · Binary semaphore বনাম mutex, আর বাস্তবের API
“Binary semaphore আর mutex কি একই?” — এটি সম্ভবত OS ইন্টারভিউর সবচেয়ে পরিচিত প্রশ্ন। দুটিই একজনকে ঢুকতে দেয়, কিন্তু নকশার উদ্দেশ্য আলাদা:
| বিষয় | Mutex | Binary semaphore |
|---|---|---|
| মালিকানা | আছে — যে lock করেছে, শুধু সে-ই unlock করবে | নেই — যেকোনো থ্রেড signal দিতে পারে |
| অন্য থ্রেড থেকে ছাড়া | ভুল (errorcheck-এ EPERM, normal-এ undefined) | স্বাভাবিক ব্যবহার — এটিই সংকেত পাঠানোর উপায় (অংশ ৬-এর খ) |
| Priority inheritance | সম্ভব, কারণ kernel জানে কার অগ্রাধিকার বাড়াতে হবে | সাধারণত নয় — মালিক নেই, কাকে বাড়াবে? |
| নিজে আবার নেওয়া | recursive ধরনে সম্ভব; errorcheck ধরে ফেলে | নিজের সাথেই deadlock, কেউ ধরেও না |
| দুবার ছাড়লে | error বা undefined | POSIX-এ মান ২ হয়ে যায় — “binary” আর থাকে না! |
| মূল উদ্দেশ্য | পাহারা (mutual exclusion) | সংকেত আর গণনা; পাহারাও করা যায় |
শেষ সারিটি গুরুত্বপূর্ণ: POSIX-এ “binary semaphore” বলে আলাদা কিছু নেই — এটি মান ১ দিয়ে শুরু করা একটি সাধারণ semaphore, আর ভুল করে দুবার sem_post দিলে মান ২, ফলে দুজন একসাথে ভেতরে। এজন্যই পাহারার কাজে mutex নিরাপদ পছন্দ, আর semaphore রাখা হয় সংকেত আর গণনার জন্য। Linux kernel-ও এই পথে গেছে: ২০০৬ সালে Linux 2.6.16-এ আলাদা struct mutex যোগ হওয়ার পর kernel-এর ভেতরে পাহারার কাজে semaphore-এর জায়গা ধীরে ধীরে mutex নিয়েছে।
POSIX semaphore — unnamed আর named
| ফাংশন | কাজ |
|---|---|
sem_init(&s, pshared, value) | Unnamed semaphore তৈরি — মেমরির একটি sem_t; pshared ০ = থ্রেডদের মধ্যে, অশূন্য = shared memory-তে রেখে প্রসেসদের মধ্যে |
sem_open("/name", O_CREAT, 0644, value) | Named semaphore — নাম ধরে সম্পর্কহীন প্রসেসও খুলতে পারে; Linux-এ এটি /dev/shm/sem.name ফাইল হিসেবে দেখা যায় |
sem_wait / sem_trywait / sem_timedwait | P: অপেক্ষা / না পেলে সঙ্গে সঙ্গে EAGAIN / সময়সীমা পর্যন্ত, তারপর ETIMEDOUT |
sem_post | V: মান বাড়ানো বা একজনকে জাগানো; async-signal-safe, তাই signal handler থেকেও ডাকা যায় |
sem_getvalue | বর্তমান মান (ডিবাগের জন্য; পড়ার সাথে সাথেই পুরোনো হয়ে যেতে পারে) |
sem_destroy / sem_close + sem_unlink | Unnamed মুছে ফেলা / named বন্ধ করা আর নামটি সিস্টেম থেকে সরানো |
এই ফাংশনগুলো, pthread-এর উল্টো, ব্যর্থ হলে −১ দেয় আর কারণ রাখে errno-তে। একটি ব্যবহারিক ফাঁদ: sem_wait ঘুমের মধ্যে signal এলে −১ আর EINTR দিয়ে ফিরতে পারে, তাই নির্ভরযোগ্য কোডে লেখা হয় while (sem_wait(&s) == -1 && errno == EINTR) ;. আর OS-ভেদে পার্থক্য: macOS-এ unnamed semaphore (sem_init) কার্যত সমর্থিত নয় — ডাকলে ব্যর্থ হয়; সেখানে named semaphore বা Apple-এর dispatch_semaphore ব্যবহার করা হয়।
System V semaphore
পুরোনো কিন্তু এখনো সব Unix-এ থাকা আরেকটি API হলো System V semaphore (পর্ব ১২-এর System V IPC পরিবারের সদস্য)। এর বৈশিষ্ট্য তিনটি: semget একসাথে semaphore-এর একটি সেট বানায়; semop সেটের কয়েকটির ওপর একাধিক অপারেশন একসাথে, অবিভাজ্যভাবে করতে পারে (হয় সবগুলো, নয় একটিও না); আর SEM_UNDO flag দিলে প্রসেস হঠাৎ মারা গেলে kernel তার করা বদলগুলো উল্টে দেয় — semaphore চিরকাল আটকে থাকে না। বিনিময়ে API জটিল, আর প্রতিটি অপারেশন system call. ipcs -s দিয়ে সিস্টেমের System V semaphore তালিকা দেখা যায়। নতুন কোডে সাধারণত POSIX semaphore বেছে নেওয়া হয়।
Windows-এ সমতুল্য
Windows-এর নিজস্ব হাতিয়ারগুলো মোটামুটি এভাবে মেলে (বিস্তারিত আচরণ Microsoft-এর ডকুমেন্টেশনে মিলিয়ে নিন):
CRITICAL_SECTION— একই প্রসেসের থ্রেডদের জন্য দ্রুত mutex; প্রতিযোগিতা না থাকলে user mode-এই কাজ সারে, প্রয়োজনে kernel-এ অপেক্ষা করে; একই থ্রেড একাধিকবার ঢুকতে পারে (recursive);InitializeCriticalSectionAndSpinCountদিয়ে spin count দেওয়া যায়।SRWLOCK(Windows Vista থেকে) — মাত্র একটি pointer-এর সমান আকারের হালকা lock, exclusive আর shared (reader-writer) দুই রূপে; recursive নয়। নতুন কোডে প্রায়ই প্রথম পছন্দ।- Mutex object (
CreateMutex,WaitForSingleObject,ReleaseMutex) — kernel-এর অবজেক্ট; নাম দিলে একাধিক প্রসেসের মধ্যে কাজ করে; মালিকানা আছে, আর মালিক থ্রেড না ছেড়ে মারা গেলে পরের অপেক্ষাকারীWAIT_ABANDONEDপায়। প্রতিটি অপারেশন kernel-এ যায় বলে ধীর। - Semaphore object (
CreateSemaphore,WaitForSingleObject,ReleaseSemaphore) — counting semaphore, তবে একটি সর্বোচ্চ মানসহ: সর্বোচ্চের বেশি ছাড়তে চাইলে ব্যর্থ হয় — POSIX-এর “দুবার post” ভুলটি এখানে ধরা পড়ে।
৮ · সাধারণ ভুল — signal ভোলা, উল্টো ক্রম আর হারানো জাগরণ
Semaphore শক্তিশালী, কিন্তু কাঠামোহীন: wait আর signal কোডের যেকোনো জায়গায় বসানো যায়, আর compiler কিছুই যাচাই করে না। একটি ভুল জায়গার লাইন পুরো সিস্টেম আটকে দিতে পারে। পরিচিত ভুলগুলো:
| ভুল কোড (mutex = 1) | কী ঘটে |
|---|---|
signal(mutex); CS; wait(mutex); — উল্টো | কয়েকজন একসাথে critical section-এ: mutual exclusion ভাঙে |
wait(mutex); CS; wait(mutex); — signal-এর জায়গায় wait | প্রসেস নিজের সাথেই আটকে যায়; পরে সবাই আটকে যায় — deadlock |
wait(mutex); CS; — signal ভুলে যাওয়া (প্রায়ই error-এর return পথে) | পরের সবাই চিরকাল ঘুমায়; ধীরে ধীরে পুরো সার্ভিস থেমে যায় |
CS; — wait-ই নেই | race condition; ফল মাঝে মাঝে ভুল |
উল্টো ক্রমে wait — deadlock
দুটি semaphore S আর Q, দুটিরই শুরুর মান ১. দুই প্রসেস দুটিই চায়, কিন্তু উল্টো ক্রমে:
P0: wait(S); P1: wait(Q);
wait(Q); wait(S);
... ...
signal(S); signal(Q);
signal(Q); signal(S);যদি P0 wait(S) করে আর ঠিক তখনই P1 wait(Q) করে, তাহলে P0 Q-এর জন্য ঘুমায় আর P1 S-এর জন্য — কেউ কখনো signal দেবে না, কারণ যে দেবে সে নিজেই ঘুমিয়ে। এটিই deadlock. বেশিরভাগ রানে এমন সময়-মিল হয় না, তাই টেস্টে ধরা পড়ে না; তারপর একদিন চাপের মধ্যে সিস্টেম জমে যায়। প্রচলিত প্রতিকার: সব থ্রেড সব lock একই নির্দিষ্ট ক্রমে নেবে। Deadlock-এর চার শর্ত আর প্রতিরোধ পর্ব ২৭-এ।
হারানো জাগরণ — কেন semaphore-এর “স্মৃতি” দরকার
ধরুন semaphore ছাড়া, শুধু একটি সাধারণ count আর দুটি ধারণাগত কল sleep()/wakeup(p) দিয়ে লেখা হলো:
/* consumer */ /* producer */
if (count == 0) count = count + 1;
/* <-- preempted HERE */ if (count == 1)
sleep(); wakeup(consumer); /* not asleep yet: lost! */Consumer দেখল count = ০, ঘুমাতে যাবে — ঠিক তখনই preempt হলো। Producer একটি জিনিস রেখে wakeup পাঠাল, কিন্তু consumer তখনো ঘুমায়নি, তাই সংকেতটি কেউ পেল না। Consumer আবার CPU পেয়ে ঘুমিয়ে পড়ল — হয়তো চিরকালের জন্য। Andrew Tanenbaum-এর OS বইয়ে producer-consumer প্রসঙ্গে এটি একটি পরিচিত উদাহরণ। তিনটি সমাধান এই কোর্সে দেখছেন: semaphore-এর মান সংকেতটি মনে রাখে (অংশ ৬-এর ক্রম ২); futex-এর kernel মান যাচাই আর ঘুম একসাথে করে (অংশ ৩); আর condition variable mutex ধরে শর্ত যাচাই করে, আর wait mutex ছাড়া ও ঘুমানো একসাথে করে (পর্ব ২৫)। মূল নীতি একটিই: “যাচাই” আর “ঘুম”-এর মাঝে কোনো ফাঁক রাখা যাবে না।
আরও কয়েকটি ফাঁদ। (১) Semaphore-এর ভেতরে দীর্ঘ কাজ রেখে signal-এর আগে কোনো error-পথে return — প্রতিটি পথে signal আছে কি না দেখুন। (২) Signal handler থেকে pthread_mutex_lock ডাকা — এটি async-signal-safe নয়; সেখানে শুধু sem_post-এর মতো নিরাপদ ফাংশন (পর্ব ১৩)। (৩) মালিক নয় এমন থ্রেড থেকে mutex ছাড়া — normal mutex-এ এটি undefined behavior, প্রায়ই নীরবে চলে, পরে অদ্ভুত বাগ হয়ে ফেরে। ডিবাগের সময় errorcheck ধরন চালু রাখুন।
৯ · AI-এর যুগে mutex আর semaphore
AI আপনাকে সেকেন্ডে একটি “thread-safe” queue বা connection limiter লিখে দেবে। কিন্তু সমন্বয়ের কোডে ভুল সাধারণত কয়েক হাজার রানে একবার ধরা দেয়, আর AI-এর লেখা কোড প্রায়ই সেই একবারের কথা ভাবে না। সাধারণ ভুলগুলো:
| AI-এর ভুল | ফল | কীভাবে ধরবেন |
|---|---|---|
| পাহারার কাজে binary semaphore, আর অন্য থ্রেড থেকে post | মালিকানা নেই; ভুল post-এ দুজন ভেতরে | পাহারা হলে mutex চান; semaphore শুধু সংকেত/গণনায় |
error-পথে unlock/sem_post নেই | পরে সবাই চিরকাল আটকে | প্রতিটি return আর goto পথ হাতে মিলিয়ে দেখুন |
| দুটি lock ভিন্ন ফাংশনে ভিন্ন ক্রমে | মাঝে মাঝে deadlock | lock-এর একটি ক্রম লিখে রাখুন; Helgrind lock-order সতর্কতা দেয় |
Linux-এর sem_init কোড macOS-এর জন্য, বা Win32-এর বদলে POSIX | compile হয় না বা চালালে ব্যর্থ | প্রম্পটে OS আর compiler লিখুন; man page মিলিয়ে নিন |
| পরীক্ষার trace-এ মানের রীতি গুলিয়ে ফেলা (কখনো −২, কখনো ০) | ভুল উত্তর | রীতি আগে ঠিক করে হাতে ট্রেস করুন, simulator দিয়ে মিলিয়ে নিন |
sem_wait-এর EINTR উপেক্ষা | signal এলে wait না পেরিয়েই ভেতরে ঢুকে পড়া | ফেরত মান আর errno দেখা হচ্ছে কি না দেখুন |
সমন্বয়ের কোড চাইলে উদ্দেশ্য, পরিবেশ আর যাচাইয়ের দাবি তিনটিই স্পষ্ট লিখুন:
AI-এর লেখা সমন্বয়ের কোড ব্যবহারের আগে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- Mutex আর binary semaphore-এর পার্থক্য? — mutex-এর মালিক আছে (শুধু সে-ই ছাড়ে, তাই priority inheritance আর ভুল-ধরা সম্ভব); semaphore-এর মালিক নেই, যেকোনো থ্রেড signal দিতে পারে — সংকেতের জন্য উপযুক্ত।
- Counting semaphore-এর শুরুর মান ১০; ৬টি P আর ৪টি V-এর পর মান কত? — ১০ − ৬ + ৪ = ৮.
- Semaphore-এর মান −৩ মানে কী? — পাঠ্যবই-রীতিতে ৩টি প্রসেস সেই semaphore-এ অপেক্ষায় (Linux-এর
sem_getvalueসেখানে ০ দেখায়)। - Spinlock কখন mutex-এর চেয়ে ভালো? — multicore-এ, যখন lock খুব অল্প সময় ধরা থাকে আর অপেক্ষাকারী ঘুমাতে পারে না (যেমন interrupt handler); এক-CPU-তে প্রায় কখনো না।
- Futex কেন দ্রুত? — প্রতিযোগিতা না থাকলে lock নেওয়া-ছাড়া একটি atomic instruction-এ user space-এ শেষ; system call শুধু অপেক্ষা আর জাগানোর প্রয়োজনে।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Dijkstra কেন semaphore-এর মতো একটি নতুন ধারণা আনলেন, আর পরে কেন এটিকে “সমন্বয়ের goto” বলে সমালোচনা করা হলো?
১৯৬০-এর দশকের শুরুতে multiprogramming সবে আসছে: একই কম্পিউটারে কয়েকটি প্রোগ্রাম পালা করে চলে, আর তারা card reader, printer, মেমরির মতো সম্পদ ভাগ করে। তখনকার সমাধানগুলো ছিল প্রতিটি সমস্যার জন্য আলাদা কৌশল — কখনো interrupt বন্ধ করা, কখনো hardware-এর বিশেষ সুবিধা, কখনো জটিল flag-এর খেলা। Dekker-এর অ্যালগরিদম (যা Dijkstra প্রকাশ করেন) দেখিয়েছিল শুধু সাধারণ read/write দিয়েও দুই প্রসেসের mutual exclusion সম্ভব, কিন্তু তা জটিল আর busy waiting-নির্ভর (পর্ব ২২-এর Peterson-এর সমাধান পরে এটিকে সরল করে)। Dijkstra চেয়েছিলেন একটি সাধারণ ভিত্তি, যা দিয়ে যেকোনো সংখ্যক প্রসেসের পাহারা, ক্রম আর সম্পদ-গণনা — সব একই ভাষায় লেখা যায়, আর যার সঠিকতা যুক্তি দিয়ে প্রমাণ করা যায়।
Semaphore ঠিক তা-ই দিল: একটি সংখ্যা আর দুটি অবিভাজ্য অপারেশন। “Cooperating Sequential Processes” (১৯৬৫)-এ তিনি এটি দিয়ে producer-consumer-এর মতো সমস্যা সমাধান করেন, আর THE multiprogramming system (১৯৬৮ সালে প্রকাশিত বর্ণনা) পুরোটাই স্তরে স্তরে সাজানো প্রসেস আর semaphore দিয়ে গড়া হয়েছিল — OS নকশাকে প্রমাণযোগ্য করার প্রথম দিকের একটি বড় চেষ্টা। অপেক্ষারত প্রসেসকে ঘুম পাড়ানোর ধারণা (busy waiting নয়) সেই সময়ের সীমিত CPU-তে বিশাল সাশ্রয় ছিল।
কিন্তু সমস্যা দেখা দিল বড় প্রোগ্রামে। Semaphore কাঠামোহীন: wait আর signal যেকোনো জায়গায় বসে, কোন semaphore কোন ডেটা রক্ষা করে তা কোথাও লেখা থাকে না, আর একটি ভুলে যাওয়া signal বা উল্টো ক্রম দূরের কোনো জায়গায় deadlock ঘটায়। এটি অনেকটা goto-র মতো — শক্তিশালী, কিন্তু ভুল করা সহজ, আর ভুল খুঁজে পাওয়া কঠিন (Dijkstra নিজেই ১৯৬৮ সালে goto-র বিরুদ্ধে বিখ্যাত চিঠি লেখেন)। এই অভিজ্ঞতা থেকে ১৯৭০-এর দশকের প্রথম দিকে Per Brinch Hansen আর C. A. R. Hoare monitor-এর ধারণা আনেন: ডেটা আর তার পাহারা একসাথে একটি কাঠামোয়, যেখানে compiler নিজেই mutual exclusion নিশ্চিত করে (পর্ব ২৫)।
মূল উপলব্ধি: semaphore সমন্বয়ের “assembly language” — সব কিছু বানানো যায়, কিন্তু নিরাপদে বানাতে হলে ওপরে কাঠামো লাগে। আজকের mutex, condition variable, monitor আর channel সেই কাঠামোর বিভিন্ন রূপ।
প্র ০২এক-CPU-র মেশিনে spinlock কেন প্রায় অর্থহীন, অথচ OS kernel-এর ভেতরে spinlock-এর এত ব্যবহার কেন?
এক-CPU-তে একটি থ্রেড spin করছে মানে CPU তার হাতে। Lock-এর মালিক তখন চলতে পারছে না — সে ready queue-তে বসে। তাই spin যতক্ষণই চলুক, lock খোলার কোনো সম্ভাবনা নেই, যতক্ষণ না timer interrupt এসে spin-কারীকে সরিয়ে মালিককে চালায়। পুরো time slice নষ্ট; আর preemption বন্ধ থাকলে (যেমন kernel-এর কিছু অংশে) সিস্টেম চিরকালের জন্য জমে যেত। তাই এক-CPU-র kernel-এ “spinlock” আসলে প্রায়ই শুধু preemption বা interrupt বন্ধ করা — Linux-এ uniprocessor build-এ spinlock কার্যত সেটুকুতে নেমে আসে।
Multicore-এ ছবিটা উল্টে যায়। মালিক অন্য core-এ চলছে, কয়েকশো ন্যানোসেকেন্ডে ছেড়ে দেবে। ঘুমিয়ে আবার জাগতে লাগে দুটি context switch, scheduler-এর কাজ, cache-এর গরম ডেটা হারানো — প্রায়ই কয়েক মাইক্রোসেকেন্ড। ছোট critical section-এ ঘুমানো তাই spin-এর চেয়ে দশ গুণ বেশি খরচ হতে পারে। তা ছাড়া kernel-এর কিছু জায়গায় ঘুমানো সম্ভবই নয়: interrupt handler কোনো থ্রেডের প্রসঙ্গে চলে না, তাকে “ঘুম পাড়িয়ে পরে জাগানো” যায় না। সেখানে spinlock একমাত্র পথ, আর নিয়ম কড়া — spinlock ধরে কখনো ঘুমানো যাবে না, ভেতরের কাজ খুব ছোট রাখতে হবে।
মাঝামাঝি পথের তত্ত্ব আমরা অংশ ২-এ দেখেছি: ঘুমের খরচ C-এর সমান সময় spin, তারপর ঘুম — এতে খরচ কখনো সর্বোত্তমের দ্বিগুণের বেশি হয় না। Solaris-এর adaptive mutex আরও একধাপ চালাক: মালিক এই মুহূর্তে কোনো CPU-তে চলছে কি না দেখে — চলছে হলে শিগগির ছাড়বে, তাই spin; না চললে (মালিক নিজেই ঘুমন্ত বা preempted) spin বৃথা, তাই সঙ্গে সঙ্গে ঘুম। Linux kernel-এর mutex-এর optimistic spinning একই যুক্তি মানে।
মূল উপলব্ধি: spin বনাম ঘুম কোনো নৈতিক প্রশ্ন নয়, হিসাবের প্রশ্ন — অপেক্ষার প্রত্যাশিত সময় বনাম ঘুমের খরচ, আর মালিক এই মুহূর্তে চলছে কি না। ভালো lock এই হিসাব নিজেই করে।
প্র ০৩Futex-এর FUTEX_WAIT কেন “মান এখনো X হলে তবেই ঘুম” — এই শর্তটি না থাকলে কী ভাঙত? আর futex নিয়ে প্রবন্ধের নাম কেন “Futexes Are Tricky”?
ধরুন futex শুধু বলত “ঘুম পাড়ানো হোক”। Thread T2 user space-এ দেখল lock বন্ধ, ঠিক করল ঘুমাবে। কিন্তু syscall-এ ঢোকার আগের মুহূর্তে T1 lock ছেড়ে দিল আর FUTEX_WAKE ডাকল। Kernel-এর সারিতে তখনো কেউ নেই, তাই wake কাউকে জাগাল না। এরপর T2 ঘুমিয়ে পড়ল — খোলা lock-এর জন্য, যার কোনো মালিক নেই, তাই কেউ আর জাগাবেও না। ঠিক অংশ ৮-এর lost wakeup. সমাধান হলো যাচাই আর ঘুমকে kernel-এর ভেতরে অবিভাজ্য করা: kernel futex-এর hash bucket-এর lock ধরে মানটি পড়ে, মান এখনো প্রত্যাশিত হলে তবেই থ্রেডকে সারিতে রাখে। মান বদলে গেলে সঙ্গে সঙ্গে EAGAIN দিয়ে ফেরত পাঠায়, আর user space-এর লুপ আবার চেষ্টা করে। Wake-কারীও একই bucket-lock নেয়, তাই দুজনের মাঝে ফাঁক থাকে না।
Futex-এর প্রথম নকশা আসে ২০০২ সালের Ottawa Linux Symposium-এ Franke, Russell আর Kirkwood-এর প্রবন্ধে, আর Linux 2.6-এর সাথে NPTL থ্রেড library এর ওপর দাঁড়ায় (পর্ব ১৫)। তবে ব্যবস্থাটি এত নিচু স্তরের যে সঠিক mutex লেখা চমকে দেওয়ার মতো কঠিন। Ulrich Drepper-এর “Futexes Are Tricky” প্রবন্ধ (২০০০-এর দশকের মাঝামাঝি প্রথম প্রকাশ, পরে কয়েকবার হালনাগাদ) প্রথমে একটি সহজ-দেখতে mutex দেখায়, তারপর প্রমাণ করে কেন সেটি ভুল বা ধীর, তারপর ধাপে ধাপে অংশ ৩-এর তিন-অবস্থার নকশায় পৌঁছায়। একটি সূক্ষ্মতা: দুই-অবস্থার (০/১) নকশায় unlock জানতে পারে না কেউ অপেক্ষায় আছে কি না, তাই হয় প্রতিবার বৃথা wake syscall, নয় জাগরণ হারানোর ঝুঁকি। তৃতীয় অবস্থা “২ = অপেক্ষাকারী থাকতে পারে” এই দ্বিধা মেটায়, বিনিময়ে মাঝে মাঝে একটি বাড়তি wake.
অন্য OS-ও একই ধারণায় পৌঁছেছে। Windows 8 থেকে WaitOnAddress আর WakeByAddressSingle/WakeByAddressAll futex-এর মতোই “এই ঠিকানার মান এটি হলে ঘুম” কাজ করে, আর SRWLOCK-এর মতো হালকা lock-ও user space-এ fast path রাখে।
মূল উপলব্ধি: সমন্বয়ের যেকোনো ব্যবস্থার সবচেয়ে বিপজ্জনক মুহূর্ত হলো “দেখলাম” আর “ঘুমালাম”-এর মাঝখান। Futex সেই ফাঁকটি kernel-এ বন্ধ করে, আর বাকি সব কাজ user space-এ রেখে দেয়।
প্র ০৪১৯৯৭ সালে মঙ্গলগ্রহে Mars Pathfinder বারবার নিজেকে reset করছিল। এর সাথে mutex, semaphore আর মালিকানার সম্পর্ক কী?
NASA-র Mars Pathfinder ১৯৯৭ সালের ৪ জুলাই মঙ্গলে নামে। কয়েক দিন পর এর কম্পিউটার মাঝে মাঝে পুরো সিস্টেম reset করতে শুরু করে, আর প্রতিবার কিছু ডেটা হারায়। সফটওয়্যার চলত Wind River-এর VxWorks নামের একটি real-time OS-এ (RTOS). JPL-এর প্রকৌশলীরা পৃথিবীতে রাখা হুবহু একটি কপিতে সমস্যাটি পুনরায় ঘটিয়ে কারণ বের করেন; Glenn Reeves-এর লেখা বিবরণটি এখন একটি ক্লাসিক কেস-স্টাডি।
কারণটি ছিল priority inversion. একটি shared “information bus” একটি mutex দিয়ে রক্ষিত ছিল (VxWorks-এ এটি একটি “mutex semaphore”). কম অগ্রাধিকারের একটি আবহাওয়া-ডেটার task সেই mutex ধরে কাজ করছিল। উচ্চ অগ্রাধিকারের bus-management task-এর একই mutex দরকার হলো, তাই সে ঘুমাল — এটুকু স্বাভাবিক, কম task শিগগির ছেড়ে দেবে। কিন্তু তখন মাঝারি অগ্রাধিকারের একটি যোগাযোগ task জেগে উঠে দীর্ঘ সময় চলল, আর কম task-কে CPU-ই পেতে দিল না। ফলে উচ্চ task কার্যত মাঝারির জন্য অপেক্ষা করল। একটি watchdog timer দেখল উচ্চ task সময়মতো কাজ শেষ করেনি, আর ধরে নিল সিস্টেম বিকল — তাই reset.
সমাধান আগে থেকেই OS-এ ছিল: mutex-এর priority inheritance বিকল্প, যা বন্ধ রাখা হয়েছিল। চালু থাকলে, উচ্চ task অপেক্ষা শুরু করামাত্র mutex-এর মালিক (কম task) সাময়িকভাবে উচ্চ অগ্রাধিকার পেত, মাঝারি task তাকে সরাতে পারত না, সে দ্রুত কাজ শেষ করে mutex ছাড়ত। প্রকৌশলীরা পৃথিবী থেকে একটি ছোট হালনাগাদ পাঠিয়ে সেই বিকল্প চালু করেন, আর reset থেমে যায়।
এই গল্পে মালিকানার গুরুত্ব স্পষ্ট। Priority inheritance কাজ করে কারণ mutex জানে তার মালিক কে — কার অগ্রাধিকার বাড়াতে হবে। একটি সাধারণ counting বা binary semaphore-এ মালিক নেই; যেকোনো কেউ signal দিতে পারে, তাই kernel জানে না কাকে সাহায্য করতে হবে। এজন্যই real-time আর kernel কোডে পাহারার কাজে mutex পছন্দ করা হয়। বিস্তারিত — priority ceiling-সহ — পর্ব ২৯-এ।
মূল উপলব্ধি: mutex-এর “মালিকানা” কোনো আনুষ্ঠানিক খুঁটিনাটি নয়; এটিই OS-কে সমস্যা ধরার আর সারানোর তথ্য দেয়। ভুল জায়গায় semaphore বা বন্ধ রাখা একটি বিকল্প কোটি ডলারের মিশনকেও ঝুঁকিতে ফেলতে পারে।
প্র ০৫আজকের সার্ভার আর ভাষায় semaphore কোথায় টিকে আছে — connection pool, rate limit, Go-র channel? আর “bounded semaphore” কেন দরকার হলো?
পাহারার কাজে mutex জায়গা নিলেও semaphore-এর গণনার কাজটি আজও সবখানে। একটি ওয়েব সার্ভারের পেছনে ডেটাবেস হয়তো একসাথে ১০০টি সংযোগ সামলাতে পারে, অথচ সার্ভারে হাজার হাজার অনুরোধ একসাথে আসে। Connection pool আসলে একটি counting semaphore (মান = সংযোগের সংখ্যা) আর খালি সংযোগের একটি তালিকা। বাইরের কোনো API “এক সেকেন্ডে সর্বোচ্চ ২০টি অনুরোধ” বললে, বা একটি ব্যাকআপ টুলে “একসাথে সর্বোচ্চ ৪টি ফাইল আপলোড” রাখতে হলে, একই ছাঁচ। Java 5 (২০০৪) থেকে java.util.concurrent.Semaphore-এ “permit” নেওয়া-ফেরত দেওয়া যায়, সঙ্গে একটি fairness বিকল্প — চালু করলে অপেক্ষাকারীরা FIFO ক্রমে permit পায় (অংশ ৫-এর strong semaphore), বন্ধ রাখলে গতি বেশি কিন্তু starvation-এর নিশ্চয়তা কম।
Python-এর threading module-এ দুটি রূপ আছে: Semaphore আর BoundedSemaphore. দ্বিতীয়টি শুরুর মানের বেশি release করতে চাইলে ValueError দেয়। কারণ অংশ ৭-এর সেই ফাঁদ: ভুল করে একবার বেশি release করলে সাধারণ semaphore-এর মান বেড়ে যায়, আর “সর্বোচ্চ ৩” নীরবে “সর্বোচ্চ ৪” হয়ে যায় — কোনো error ছাড়াই। Windows-এর semaphore object-এর সর্বোচ্চ মানও একই সুরক্ষা দেয়। POSIX semaphore-এ এমন সীমা নেই (শুধু SEM_VALUE_MAX), তাই সেখানে নিজেকেই সাবধান থাকতে হয়।
Go ভাষা আরও মজার পথ নিয়েছে। Go-র দর্শন “shared memory দিয়ে যোগাযোগ নয়, যোগাযোগ দিয়ে memory ভাগ” — Tony Hoare-এর ১৯৭৮ সালের CSP (Communicating Sequential Processes) ধারণা থেকে অনুপ্রাণিত। একটি n আকারের buffered channel কার্যত একটি counting semaphore: পাঠানো (send) = wait, কারণ channel ভর্তি থাকলে পাঠানো থেমে থাকে; আর গ্রহণ (receive) = signal. Go-র প্রচলিত কোডে “একসাথে সর্বোচ্চ n goroutine” এভাবেই লেখা হয়।
আর OS-এর ভেতরে? Linux kernel-এ struct semaphore এখনো আছে, তবে নতুন কোডে কম ব্যবহৃত; ঘটনা জানানোর কাজে kernel completion নামের একটি বিশেষ ব্যবস্থা পছন্দ করে — যা মূলত অংশ ৬-এর “sync = 0” ছাঁচের নিরাপদ, নামযুক্ত রূপ।
মূল উপলব্ধি: semaphore-এর গণনা ধারণাটি অমর, কিন্তু আধুনিক সিস্টেম তাকে নিরাপত্তার রেলিং দিয়ে ঘিরে দেয় — সর্বোচ্চ সীমা, fairness, নামযুক্ত উদ্দেশ্য। হাতিয়ার যত শক্তিশালী, ভুল ব্যবহারের বিরুদ্ধে সুরক্ষা তত জরুরি।
অনুশীলন
হাতে trace, হিসাব, আউটপুট অনুমান আর কোড লেখা — আগে নিজে চেষ্টা করুন, তারপর উত্তর মেলান।
-
হাতে trace: একটি semaphore S-এর শুরুর মান ১. ক্রমে এই অপারেশনগুলো ঘটে: P1 wait, P2 wait, P3 wait, P1 signal, P2 signal, P3 signal. প্রতিটির পর (ক) পাঠ্যবই-রীতিতে মান, (খ) অপেক্ষার সারি, (গ) Linux-রীতিতে মান লিখুন। কোন মুহূর্তে কে critical section-এ?
অপারেশন মান (পাঠ্যবই) সারি মান (Linux) ভেতরে শুরু ১ [ ] ১ কেউ নেই P1 wait ০ [ ] ০ P1 P2 wait −১ [P2] ০ P1 P3 wait −২ [P2, P3] ০ P1 P1 signal (P2 জাগে) −১ [P3] ০ P2 P2 signal (P3 জাগে) ০ [ ] ০ P3 P3 signal ১ [ ] ১ কেউ নেই প্রতিটি মুহূর্তে ভেতরে ঠিক একজন বা কেউ না — binary semaphore-এর mutual exclusion. যাচাই: ১ − ৩ + ৩ = ১. Simulator-এ
value = 1আর এই ছয়টি অপারেশন দিয়ে মিলিয়ে নিন। -
হিসাব: (ক) একটি counting semaphore-এর শুরুর মান ৭. এর ওপর ২০টি P আর ১৫টি V অপারেশন হলো। শেষ মান কত? (খ) আরেকটি semaphore-এর শুরুর মান ০; তিনটি আলাদা প্রসেস একবার করে wait করে, তারপর অন্য একটি প্রসেস একবার signal দেয়। পাঠ্যবই-রীতিতে শেষ মান কত, তার অর্থ কী, আর Linux-এর
sem_getvalueকী দেখাবে? (গ) একটি অফিসে ৪টি প্রিন্টার; প্রিন্টার-semaphore-এর মান এখন −৩. এই মুহূর্তে কতজন ছাপাচ্ছে আর কতজন অপেক্ষায়?(ক) ৭ − ২০ + ১৫ = ২. পাঠ্যবই-রীতিতে প্রতিটি P মান ১ কমায় আর প্রতিটি V ১ বাড়ায়, ক্রম যা-ই হোক। শেষ মান ধনাত্মক, তাই শেষে কেউ অপেক্ষায় নেই — Linux-রীতিতেও ২.
(খ) ০ − ৩ + ১ = −২: দুটি প্রসেস এখনো অপেক্ষায় (একজন signal-এ জেগেছে)। Linux-এ
sem_getvalueদেখাবে ০, আর দুজন ঘুমন্ত — সংখ্যাটি আলাদাভাবে রাখা হয়।(গ) মান ঋণাত্মক, তাই কোনো প্রিন্টার খালি নেই: ৪ জনই ছাপাচ্ছেন, আর |−৩| = ৩ জন অপেক্ষায়। যাচাই: শুরু ৪, মোট ৭টি wait, এখনো কোনো signal নেই — ৪ − ৭ = −৩.
-
আউটপুট অনুমান: দুটি semaphore
s1আরs2, দুটির শুরুর মান ০. তিনটি থ্রেড যেকোনো ক্রমে চালু হয়। T1:wait(s2); print("C");— T2:print("A"); signal(s1);— T3:wait(s1); print("B"); signal(s2);. (ক) কী কী আউটপুট সম্ভব? (খ) ভুল করেs1-এর শুরুর মান ১ দিলে কোন কোন আউটপুট সম্ভব হয়ে যায়?(ক) শুধু
A B C. T3 B ছাপতে পারে কেবল s1-এর signal পেলে, যা আসে A ছাপার পরে; T1 C ছাপতে পারে কেবল s2-এর signal পেলে, যা আসে B-এর পরে। শিকলটি A → B → C, তাই scheduler যা-ই করুক, ক্রম স্থির।(খ) s1 = ১ হলে T3 প্রথম wait-এ থামে না — A-এর আগেই B ছাপতে পারে। তবে C তখনো B-এর পরে (s2 এখনো ০ থেকে শুরু)। তাই সম্ভাব্য আউটপুট:
A B C,B A C,B C A— B-এর পরে C আছে এমন সব ক্রম। একটি ভুল শুরুর মান পুরো ক্রমের নিশ্চয়তা ভেঙে দিল। (আর T2-এর signal-এ s1 শেষে ১ থেকে যায় — একটি অব্যবহৃত সংকেত।) -
কোড লিখুন: তিনটি থ্রেড: P1 statement S1 চালায়, P2 চালায় S2, আর P3 চালায় S3. শর্ত: S3 চলবে কেবল S1 এবং S2 দুটিই শেষ হওয়ার পরে; S1 আর S2-এর মধ্যে কোনো ক্রম নেই। Semaphore দিয়ে pseudocode লিখুন, তারপর POSIX-এ মূল লাইনগুলো দেখান।
একটি semaphore
done, শুরুর মান ০, যা গোনে “কয়টি পূর্বশর্ত শেষ”। P1 আর P2 নিজের কাজ শেষে একবার করে signal দেয়; P3 দুবার wait করে।semaphore done = 0; P1: S1; signal(done); P2: S2; signal(done); P3: wait(done); wait(done); S3; /* POSIX */ sem_t done; sem_init(&done, 0, 0); /* P1 */ s1(); sem_post(&done); /* P2 */ s2(); sem_post(&done); /* P3 */ sem_wait(&done); sem_wait(&done); s3();কেন কাজ করে: P3-এর দ্বিতীয় wait পেরোতে মোট দুটি signal লাগে, আর signal আসে কেবল S1 বা S2 শেষে — তাই দুটিই শেষ না হলে S3 চলে না। P1 আর P2 যে ক্রমেই শেষ হোক, এমনকি P3 আসার আগেই, signal জমা থাকে (মান ১ বা ২)। বিকল্প সমাধান: দুটি আলাদা semaphore
a = 0,b = 0; P1 signal(a), P2 signal(b), P3 wait(a); wait(b). প্রথম সমাধান বেশি সাধারণ — n টি পূর্বশর্ত হলে n বার wait.
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমমান ০ থাকা একটি semaphore-এ প্রসেস P wait ডাকল। পাঠ্যবই-রীতিতে এরপরের ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- wait মান ১ কমিয়ে −১ করে
- মান ঋণাত্মক, তাই P-কে semaphore-এর অপেক্ষার সারিতে যোগ করা হয়
- P-এর অবস্থা waiting হয়, আর scheduler অন্য প্রসেসকে CPU দেয়
- অন্য একটি প্রসেস signal দিয়ে মান −১ থেকে ০ করে
- মান ≤ ০ বলে P-কে সারি থেকে সরিয়ে ready queue-তে রাখা হয়
- P CPU পেয়ে wait-এর পরের লাইন থেকে চলতে থাকে
একটি counting semaphore-এর শুরুর মান ১০. এর ওপর ৬টি P (wait) আর ৪টি V (signal) অপারেশন হলো। শেষ মান কত?
- ৪
- ৮
- ১২
- ০
অপেক্ষার সারিসহ পাঠ্যবই-সংজ্ঞায় (wait আগে মান কমায়, তারপর ঋণাত্মক হলে ঘুমায়) একটি semaphore-এর মান −৩. এর অর্থ কী?
- semaphore-টি নষ্ট হয়ে গেছে, পুনরায় শুরু করতে হবে
- ৩টি প্রসেস এখন critical section-এ আছে
- ৩টি প্রসেস এই semaphore-এর সারিতে অপেক্ষা করছে
- পরের একটি signal-এই মান ধনাত্মক হবে
প্রসেস P1 চালায় S1; signal(sync); আর P2 চালায় wait(sync); S2;. S1 যেন সবসময় S2-এর আগে চলে, তার জন্য sync-এর শুরুর মান কত হবে?
- ০
- ১
- ২
- −১
নিচের কোনটি binary semaphore-এর ক্ষেত্রে স্বাভাবিক, কিন্তু (errorcheck ধরনের) pthread mutex-এর ক্ষেত্রে ভুল?
- একসাথে একটি থ্রেডকে ভেতরে ঢুকতে দেওয়া
- অপেক্ষারত থ্রেডকে kernel-এ ঘুম পাড়ানো
- priority inheritance দিয়ে মালিকের অগ্রাধিকার বাড়ানো
- যে থ্রেড wait/lock করেনি, তার কাছ থেকে signal/unlock আসা
Linux-এ futex-ভিত্তিক একটি mutex কোনো প্রতিযোগিতা ছাড়া (অন্য কোনো থ্রেড চাইছে না) একবার lock আর একবার unlock করা হলো। মোট কয়টি system call হয়?
- ২টি — একটি lock, একটি unlock
- ০টি
- ১টি — শুধু unlock-এ
- ১টি — শুধু lock-এ FUTEX_WAIT
দুটি semaphore S আর Q-এর শুরুর মান ১. প্রসেস P0 চালায় wait(S); wait(Q); আর P1 চালায় wait(Q); wait(S); (তারপর দুজনই দুটি signal দেয়)। কোনটি সঠিক?
- কিছু interleaving-এ deadlock হতে পারে, সবসময় নয়
- প্রতিবার deadlock হয়
- semaphore থাকায় deadlock কখনো হয় না
- দুজন একসাথে critical section-এ ঢুকে পড়তে পারে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Mutex = মালিকানাসহ তালা: lock করে যে, unlock করে সে-ই। অপেক্ষাকারী kernel-এর wait queue-তে ঘুমায়; ছোট অপেক্ষায় spin, লম্বায় ঘুম — adaptive mutex দুটো মেলায়।
- Futex: প্রতিযোগিতা ছাড়া lock = একটি atomic instruction, ০ syscall; অপেক্ষা লাগলে
FUTEX_WAIT(মান যাচাই করে ঘুম) আরFUTEX_WAKE. - pthread mutex-এর ধরন: normal (দ্রুত, ভুলে আটকে যায়), errorcheck (
EDEADLK/EPERM), recursive (গণনা); অপেক্ষা ছাড়াtrylock(EBUSY), সময়সীমাসহtimedlock(ETIMEDOUT). - Semaphore: P/wait কমায়, V/signal বাড়ায়; পাঠ্যবই-রীতিতে ঋণাত্মক মান = অপেক্ষারত সংখ্যা, শেষ মান = শুরু − wait + signal. মান ১ = পাহারা, ০ = ক্রম, n = গণনা।
- ভুলগুলো চিনুন: signal ভোলা, wait/signal উল্টানো, ভিন্ন ক্রমে দুটি wait (deadlock, পর্ব ২৭), আর “যাচাই” ও “ঘুম”-এর মাঝে ফাঁক (lost wakeup). পাহারায় mutex, সংকেত আর গণনায় semaphore.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ২৫ · Condition variable আর monitorপরবর্তী পর্বশর্তের জন্য অপেক্ষা, signal বনাম broadcast, আর ভাষার ভেতরে পাহারা।
- অপারেটিং সিস্টেম · কোর্স হোম৬৪টি পর্বপুরো কোর্সের রোডম্যাপ — ক্লাসিক সমস্যা আর deadlock মডিউল ৪-এর পরের পর্বগুলোয়।
- C প্রোগ্রামিং · Multithreadingহাতে-কলমেC-র দিক থেকে থ্রেড, mutex আর আরও অনুশীলন।
- Java · থ্রেড আর synchronizationপাশের ট্র্যাকsynchronized, lock আর Semaphore — Java-র দিক থেকে একই ধারণা।
- DBMS · Concurrency controlএকই সমস্যা, অন্য জগৎডেটাবেসের lock, two-phase locking আর deadlock — transaction-এর ভাষায়।