এই পর্বে যা শিখবেন
- তিন semaphore-এর bounded buffer-এর পাঠ্যবইয়ের কোড, প্রতিটি semaphore-এর মানে, আর কেন
wait-এর ক্রম উল্টালে সিস্টেম চিরতরে আটকে যায়; একই সমাধান condition variable আর monitor দিয়ে - Readers–writers-এর তিনটি রূপ — পাঠক-অগ্রাধিকার, লেখক-অগ্রাধিকার আর ন্যায্য সমাধান; বাস্তবের
pthread_rwlockআর Linux-এর rwsem - Dining philosophers-এ deadlock কীভাবে জন্মায়, চারটি প্রতিকার, প্রতিকারগুলোর ছোট প্রমাণ, Chandy–Misra-র ধারণা, আর deadlock বনাম starvation
- Sleeping barber আর cigarette smokers — সমস্যার বিবরণ আর সমাধানের কাঠামো
- C-তে চালিয়ে যাচাই: semaphore-এর buffer, rwlock-এর নিয়ম, ক্রম মেনে fork তোলা; আর একটি simulator-এ deadlock নিজের চোখে ধরা
১ · ক্লাসিক সমস্যা কেন — তিনটি ছাঁচ
পর্ব ২২ থেকে ২৫-এ আপনি হাতিয়ারগুলো চিনেছেন: race condition কেন হয়, critical section কী, mutex, semaphore, condition variable আর monitor. এবার প্রশ্ন — এসব হাতিয়ার দিয়ে আসল সমস্যা কীভাবে সমাধান করবেন? এখানেই আসে কয়েকটি “ক্লাসিক” সমস্যা। ১৯৬০ আর ১৯৭০-এর দশকে Edsger Dijkstra, Tony Hoare, David Parnas-এর মতো গবেষকেরা এগুলো প্রস্তাব করেছিলেন। তারপর থেকে প্রায় প্রতিটি OS বই আর পরীক্ষায় এগুলো ঘুরে ফিরে আসে।
কারণটি সহজ। প্রতিটি সমস্যা একটি ছাঁচ — বাস্তবের হাজার রকম সমস্যা আসলে এদেরই কোনো একটির রূপ। একটি নতুন সমন্বয়-সমস্যা দেখলে আগে জিজ্ঞেস করুন, “এটি কোন ছাঁচের?” — উত্তর পেলে সমাধানের অর্ধেক হয়ে যায়।
Producer–consumer
কেউ জিনিস বানায়, কেউ নেয়, মাঝে সীমিত জায়গা। প্রশ্ন: ভর্তি হলে বানানো থামবে, খালি হলে নেওয়া থামবে — কীভাবে? উদাহরণ: সার্ভারের অনুরোধের সারি, pipe, প্রিন্টারের কাজের সারি।
Readers–writers
অনেকে পড়ে, কেউ কেউ বদলায়। প্রশ্ন: পড়ার সময় ভিড় চলবে, কিন্তু লেখার সময় একা — আর কেউ যেন অনন্তকাল বসে না থাকে। উদাহরণ: ডেটাবেসের টেবিল, cache, কনফিগারেশন।
Dining philosophers
প্রত্যেকের একাধিক সম্পদ লাগে, আর সম্পদ পাশের জনের সাথে ভাগ করা। প্রশ্ন: সবাই একটি করে ধরে বসে থাকলে? উদাহরণ: দুটি ব্যাংক হিসাবের মধ্যে টাকা পাঠানো, দুটি ফাইল একসাথে lock করা।
তিনটি ছাঁচ তিনটি পরিচিত দৃশ্যে: (১) বেকারির কাউন্টারে ৪টি তাক। রুটিওয়ালা তাক ভর্তি করেন, ক্রেতারা নেন। তাক ভর্তি থাকলে রুটিওয়ালাকে দাঁড়াতে হয়, খালি থাকলে ক্রেতাকে। (২) অফিসের নোটিশবোর্ড। দশজন একসাথে পড়তে পারেন, কিন্তু নতুন নোটিশ লাগানোর সময় বোর্ডের সামনে একজনই থাকবেন — নইলে কেউ আধা-লাগানো নোটিশ পড়বেন। (৩) গোল টেবিলে পাঁচজন, প্রতি দুজনের মাঝে একটি চামচ। খেতে দুটো চামচ লাগে — পাশের দুজনের সাথে ভাগ করা।
এই পর্বে লেখার নিয়ম: semaphore-এর দুই অপারেশন লিখব wait(S) আর signal(S). Dijkstra এদের নাম দিয়েছিলেন P আর V (ডাচ শব্দ থেকে), আর POSIX-এ এরা sem_wait ও sem_post. wait(S) মানে: S-এর মান ০-এর বেশি হলে এক কমানো আর এগিয়ে যাওয়া, নইলে ঘুমানো। signal(S) মানে: কেউ ঘুমিয়ে থাকলে একজনকে জাগানো, নইলে S এক বাড়ানো। প্রতিটি wait আর signal নিজে অবিভাজ্য (atomic) — এর ভেতরের বাস্তবায়ন পর্ব ২৪-এ দেখেছেন।
২ · Producer–consumer — তিনটি semaphore দিয়ে bounded buffer
Producer–consumer সমস্যায় এক বা একাধিক producer (উৎপাদক) থ্রেড জিনিস বানিয়ে একটি bounded buffer-এ রাখে, আর এক বা একাধিক consumer (ভোক্তা) থ্রেড সেখান থেকে তোলে। Buffer-টি n ঘরের একটি বৃত্তাকার সারি: in দেখায় পরের জিনিস কোন ঘরে রাখা হবে, out দেখায় পরের জিনিস কোন ঘর থেকে তোলা হবে, আর দুটিই শেষ ঘরের পর আবার ০-তে ফেরে ((in + 1) % n). পর্ব ১২-এর ৩ ঘরের bounded mailbox-এ kernel এই কাজটি আমাদের হয়ে করেছিল; এবার নিজেরা করব।
তিনটি শর্ত মানতে হবে: (ক) buffer-এর ভেতরের ডেটা (buf, in, out) একবারে একজনই বদলাবে; (খ) buffer ভর্তি থাকলে producer অপেক্ষা করবে; (গ) খালি থাকলে consumer অপেক্ষা করবে। প্রতিটি শর্তের জন্য একটি করে semaphore:
| Semaphore | শুরুর মান | কী গোনে | কে wait, কে signal |
|---|---|---|---|
mutex | 1 | buffer-এ ঢোকার অনুমতি — একটি binary semaphore | দুজনেই wait করে ঢোকে, signal করে বের হয় |
empty | n | খালি ঘরের সংখ্যা — একটি counting semaphore | producer wait, consumer signal |
full | 0 | ভরা ঘরের সংখ্যা | consumer wait, producer signal |
semaphore mutex = 1, empty = n, full = 0;
item buf[n]; int in = 0, out = 0;
producer: consumer:
while (true) { while (true) {
item x = produce(); wait(full); // a filled slot?
wait(empty); // a free slot? wait(mutex); // then enter
wait(mutex); // then enter x = buf[out];
buf[in] = x; out = (out + 1) % n;
in = (in + 1) % n; signal(mutex);
signal(mutex); signal(empty); // one more free
signal(full); // one more item consume(x);
} }লক্ষ করুন, produce() আর consume(x) critical section-এর বাইরে। ভারী কাজ lock ধরে করলে অন্যরা অকারণে অপেক্ষা করে — critical section যত ছোট, তত ভালো। নিচে n = 3 নিয়ে একজন producer (P) আর একজন consumer (C) ধাপে ধাপে চলুক। জিনিসগুলোর নাম a, b, c, d, e.
1P: wait(empty);2P: wait(mutex);3P: buf[in] = x; in = (in+1) % 3;4P: signal(mutex); signal(full);5C: wait(full);6C: wait(mutex);7C: x = buf[out]; out = (out+1) % 3;8C: signal(mutex); signal(empty);
| buf[0..2] | |
| in / out | |
| empty | |
| full | |
| mutex | |
| কে কী করছে |
- শুরু। Buffer খালি,
in = out = 0.empty = 3(তিনটি ঘরই খালি),full = 0(কোনো জিনিস নেই),mutex = 1(buffer-এ কেউ নেই)। লক্ষ করুন,empty + full = 3— এই সম্পর্কটি পরে কাজে লাগবে। - Consumer আগে এল। C
wait(full)ডাকল, কিন্তুfull = 0. তাই C ঘুমাল, full-এর অপেক্ষার সারিতে। কোনো busy-loop নেই — C কোনো CPU সময় নষ্ট করছে না। খালি buffer থেকে তোলার চেষ্টাই হয়নি, তাই শর্ত (গ) রক্ষা পেল। - Producer a বানাল। P
wait(empty): ৩ থেকে ২, একটি খালি ঘর তার নামে সংরক্ষিত। তারপরwait(mutex): ১ থেকে ০, P এখন buffer-এর ভেতরে একা।buf[0] = a, আরinহলো ১. এই মুহূর্তে C জেগে গেলেও mutex-এ আটকাত। - P বের হলো, C জাগল।
signal(mutex): mutex আবার ১.signal(full): full-এর সারিতে C ঘুমাচ্ছিল, তাই মান না বাড়িয়ে C-কে জাগানো হলো (POSIX-এ মান ১ হয়, আর জেগে ওঠা C-এর wait সঙ্গে সঙ্গে সেটি ০ করে দেয় — ফল একই)। - C a তুলল। C
wait(mutex)নিয়েbuf[0]থেকে a পড়ল,out = 1, তারপরsignal(mutex)আরsignal(empty): empty ২ থেকে ৩. ঘরটি আবার খালি। C এখন lock ছাড়াই a নিয়ে কাজ করছে। - P দ্রুত, C ব্যস্ত। P পরপর b, c, d রাখল: ঘর ১, ২, তারপর ঘুরে ০ — বৃত্তাকার সারির মজা এখানেই। empty ৩ → ২ → ১ → ০, full ০ → ১ → ২ → ৩.
in১ → ২ → ০ → ১. Buffer এখন ভর্তি। - ভর্তি buffer-এ P. P e রাখতে
wait(empty)ডাকল, কিন্তু empty = ০ — P ঘুমাল। লক্ষ করুন,in = out = 1, ঠিক যেমন শুরুতে খালি অবস্থায়in = outছিল। শুধু in/out দেখে ভর্তি আর খালি আলাদা করা যায় না; semaphore দুটি (বা একটি count) সেই তথ্য রাখে। - C b তুলল, P জাগল। C
wait(full): ৩ থেকে ২. সবচেয়ে পুরোনো জিনিস b (ঘর ১) তুলল,out = 2.signal(empty)ঘুমন্ত P-কে জাগাল, তাই empty ০-ই থাকল। এখনempty + full = 2, ৩ নয় — কারণ P একটি ঘর “সংরক্ষণ” করেছে কিন্তু এখনো ভরেনি। - P e রাখল। P mutex নিয়ে
buf[1] = e,in = 2, তারপরsignal(full): ২ থেকে ৩. আবারempty + full = 3. পরের তোলা হবে ঘর ২ থেকে c, তারপর d, তারপর e — ঠিক যে ক্রমে বানানো হয়েছিল: a, b, c, d, e. FIFO ক্রম অটুট।
কেন এটি সঠিক — একটি ছোট প্রমাণ
ধরুন এ পর্যন্ত মোট pটি জিনিস রাখা শেষ (P signal(full) পর্যন্ত গেছে) আর মোট cটি তোলা শেষ। কোনো থ্রেড wait আর signal-এর মাঝখানে নেই এমন মুহূর্তে full = p − c আর empty = n − (p − c), কারণ প্রতিটি রাখায় full এক বাড়ে আর empty এক কমে, প্রতিটি তোলায় উল্টো। Semaphore-এর মান কখনো ঋণাত্মক হয় না, তাই p − c ≥ 0 (খালি থেকে তোলা অসম্ভব) আর p − c ≤ n (n-এর বেশি জমা অসম্ভব)। এটিই শর্ত (খ) আর (গ)। শর্ত (ক) আসে mutex থেকে: buf, in, out শুধু wait(mutex) আর signal(mutex)-এর মাঝে ছোঁয়া হয়। এ ধরনের সবসময়-সত্য সম্পর্ককে বলে invariant — concurrent কোডের সঠিকতা প্রমাণের সবচেয়ে কাজের হাতিয়ার।
একাধিক producer, একাধিক consumer হলে? উপরের কোড বদলাতে হয় না। দুজন producer একসাথে empty পেরোতে পারে (দুটি আলাদা খালি ঘর আছে বলেই), কিন্তু mutex তাদের একে একে buf[in]-এ লেখায়, তাই দুজন একই ঘরে লেখে না। শুধু একজন producer আর একজন consumer হলে একটি চতুর কৌশলে mutex বাদ দেওয়া যায় — producer শুধু in, consumer শুধু out বদলায় — কিন্তু তখন memory ordering-এর সূক্ষ্মতা (পর্ব ২৩) সামলাতে atomic লাগে। এটিই lock-free “single-producer single-consumer ring buffer”-এর ভিত্তি।
৩ · wait-এর ক্রম উল্টালে deadlock — তারপর C-তে চালিয়ে দেখা
Producer-এর দুটি লাইন — wait(empty) আর wait(mutex) — জায়গা বদল করলে কী হয়? দেখতে নির্দোষ: “আগে buffer-এ ঢুকি, তারপর দেখি জায়গা আছে কি না।” কিন্তু buffer ভর্তি থাকলে (n = 3, empty = 0, full = 3) এমন ঘটে:
| ধাপ | ঘটনা | mutex | empty | full | অবস্থা |
|---|---|---|---|---|---|
| ১ | P: wait(mutex) | 0 | 0 | 3 | P buffer-এর ভেতরে |
| ২ | P: wait(empty) | 0 | 0 | 3 | P ঘুমাল — mutex হাতে রেখেই |
| ৩ | C: wait(full) | 0 | 0 | 2 | C একটি জিনিস তুলতে পারবে, এগোল |
| ৪ | C: wait(mutex) | 0 | 0 | 2 | C ঘুমাল — mutex P-এর হাতে |
| ৫ | — | 0 | 0 | 2 | P জাগবে শুধু C-এর signal(empty)-এ, C জাগবে শুধু P-এর signal(mutex)-এ — চিরতরে আটকে গেল |
এটিই deadlock: দুজনেই এমন একটি ঘটনার জন্য অপেক্ষা করছে, যা কেবল অপরজনই ঘটাতে পারে, আর অপরজনও অপেক্ষায়। Consumer-এর দিকেও একই বিপদ: সে আগে wait(mutex) নিলে খালি buffer-এ wait(full)-এ ঘুমাবে mutex হাতে রেখে, আর producer কখনো ঢুকতে পারবে না। নিয়মটি মনে রাখুন: যে wait-এ দীর্ঘক্ষণ ঘুমাতে হতে পারে (empty/full), সেটি আগে; ছোট্ট সময়ের তালা (mutex) পরে। তালা হাতে রেখে কখনো এমন কিছুর জন্য ঘুমাবেন না, যা ঘটাতে অন্যকে সেই তালাই লাগবে। তবে signal দুটির ক্রম বদলালে (signal(full) আগে) কোনো deadlock হয় না — signal কখনো ঘুমায় না; শুধু জেগে ওঠা থ্রেড একটু বেশি অপেক্ষা করতে পারে।
এমন বাগ টেস্টে প্রায়ই ধরা পড়ে না। Buffer কখনো ভর্তি না হলে উল্টো ক্রমের কোডও নিখুঁত চলে। হঠাৎ কোনো দিন consumer ধীর হলো (ডিস্ক ধীর, নেটওয়ার্ক জ্যাম) — buffer ভরে গেল, আর পুরো সার্ভার নিঃশব্দে থেমে গেল, CPU ব্যবহার ০%. তাই এ ধরনের কোডে টেস্টের সময় buffer ছোট (n = 1 বা 2) রেখে consumer-কে ইচ্ছা করে ধীর করুন।
এবার আসল POSIX semaphore দিয়ে — n = 4 ঘরের buffer, দুজন producer (প্রত্যেকে ৫টি জিনিস: 11..15 আর 21..25), দুজন consumer. কোন consumer কোন জিনিস পাবে, তা রান-ভেদে বদলায়; তাই প্রোগ্রামটি শুধু এমন জিনিস ছাপে, যা প্রতিবার একই হওয়ার কথা — মোট সংখ্যা, যোগফল, আর buffer কখনো n ছাড়িয়েছে কি না:
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#define N 4 /* buffer slots */
#define ITEMS 5 /* items per producer */
static int buf[N], in, out, count, max_count, consumed; static long sum;
static sem_t empty, full, mutex; /* start: N, 0, 1 */
static void *producer(void *arg) {
int base = *(int *)arg;
for (int i = 1; i <= ITEMS; i++) {
sem_wait(&empty); /* 1. wait for a free slot */
sem_wait(&mutex); /* 2. then lock the buffer */
buf[in] = base + i; in = (in + 1) % N;
if (++count > max_count) max_count = count;
sem_post(&mutex); sem_post(&full); /* one more item */
}
return NULL;
}
static void *consumer(void *arg) {
(void)arg;
for (int i = 0; i < ITEMS; i++) {
sem_wait(&full); sem_wait(&mutex);
sum += buf[out]; out = (out + 1) % N; count--; consumed++;
sem_post(&mutex); sem_post(&empty);/* one more free slot */
}
return NULL;
}
int main(void) {
sem_init(&empty, 0, N); sem_init(&full, 0, 0); sem_init(&mutex, 0, 1);
pthread_t p[2], c[2]; int base[2] = {10, 20};
for (int k = 0; k < 2; k++) {
pthread_create(&p[k], NULL, producer, &base[k]); pthread_create(&c[k], NULL, consumer, NULL); }
for (int k = 0; k < 2; k++) { pthread_join(p[k], NULL); pthread_join(c[k], NULL); }
int e, f; sem_getvalue(&empty, &e); sem_getvalue(&full, &f);
printf("consumed %d items, sum = %ld (expected 180)\n", consumed, sum);
printf("max occupancy <= N=%d: %s\n", N, max_count <= N ? "yes" : "no");
printf("end: empty=%d full=%d count=%d in==out: %s\n", e, f, count, in == out ? "yes" : "no");
return 0;
}একটি আসল রানের আউটপুট:
consumed 10 items, sum = 180 (expected 180)
max occupancy <= N=4: yes
end: empty=4 full=0 count=0 in==out: yes
হিসাব মেলে: ১১ + ১২ + … + ১৫ = ৬৫, ২১ + … + ২৫ = ১১৫, মোট ১৮০ — কোনো জিনিস হারায়নি, কোনোটি দুবার তোলা হয়নি। Buffer-এ একসাথে কখনো ৪টির বেশি জিনিস জমেনি (max occupancy <= N: yes). শেষে empty = 4, full = 0 — শুরুর অবস্থায় ফেরত, আর ১০টি রাখা ও ১০টি তোলার পর in আর out দুটিই ১০ mod ৪ = ২. কোন consumer কয়টি নিল বা কোন ক্রমে, তা প্রতি রানে বদলাতে পারে — তাই সেটি ছাপা হয়নি। sem_init-এর দ্বিতীয় argument ০ মানে semaphore একই প্রসেসের থ্রেডদের মধ্যে; প্রসেসে-প্রসেসে ভাগ করতে ১ দিয়ে shared memory-তে রাখতে হয়। macOS-এ এই unnamed semaphore (sem_init) সমর্থিত নয় — সেখানে sem_open-এর named semaphore বা GCD-র dispatch_semaphore; Windows-এ CreateSemaphore, WaitForSingleObject আর ReleaseSemaphore.
একই সমাধান condition variable আর monitor দিয়ে
Semaphore একসাথে দুটি কাজ করে — গোনা আর ঘুম পাড়ানো — তাই কোড ছোট, কিন্তু ভুল ক্রমে লেখা সহজ। পর্ব ২৫-এর condition variable-এ শর্তটি সরাসরি লেখা থাকে (“count == N হলে অপেক্ষা”), তাই পড়তে বেশি স্পষ্ট। একটি mutex আর দুটি condition variable:
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t not_full = PTHREAD_COND_INITIALIZER, not_empty = PTHREAD_COND_INITIALIZER;
void put(int x) {
pthread_mutex_lock(&m);
while (count == N) /* while, never if */
pthread_cond_wait(¬_full, &m); /* releases m while asleep */
buf[in] = x; in = (in + 1) % N; count++;
pthread_cond_signal(¬_empty);
pthread_mutex_unlock(&m);
}
int get(void) {
pthread_mutex_lock(&m);
while (count == 0)
pthread_cond_wait(¬_empty, &m);
int x = buf[out]; out = (out + 1) % N; count--;
pthread_cond_signal(¬_full);
pthread_mutex_unlock(&m);
return x;
}এখানে ক্রমের ফাঁদটি নেই, কারণ pthread_cond_wait ঘুমানোর সময় নিজেই mutex ছেড়ে দেয় আর জাগার পর আবার নেয়। while জরুরি: spurious wakeup ছাড়াও, দুজন consumer থাকলে একজন জেগে ওঠার আগেই অন্যজন জিনিসটি তুলে নিতে পারে। এই put/get জোড়া, mutex আর condition variable সব একটি “বাক্সে” ভরলে সেটিই একটি monitor — Hoare আর Brinch Hansen ১৯৭০-এর দশকে যে কাঠামো প্রস্তাব করেন: বাক্সের ভেতরের ডেটা শুধু বাক্সের ফাংশনই ছোঁয় আর একবারে একটিই ফাংশন চলে। Java-র synchronized মেথড আর wait()/notifyAll() ঠিক এই ধারণা, আর Java-র ArrayBlockingQueue বা Go-র buffered channel (make(chan int, 4)) তৈরি bounded buffer.
৪ · Readers–writers — প্রথম সমাধান আর লেখকের starvation
Readers–writers সমস্যায় একটি shared ডেটা (একটি ফাইল, টেবিল বা কনফিগারেশন) কিছু থ্রেড শুধু পড়ে (reader), কিছু থ্রেড বদলায় (writer). একটি সাধারণ mutex দিলেও কাজ চলে, কিন্তু অপচয় হয়: দুজন reader একসাথে পড়লে কোনো ক্ষতি নেই, অথচ mutex তাদেরও এক এক করে পড়ায়। তাই নিয়ম: অনেক reader একসাথে থাকতে পারে; writer থাকলে আর কেউ নয়। সমস্যাটির আনুষ্ঠানিক রূপ দেন P. J. Courtois, F. Heymans আর D. L. Parnas, ১৯৭১ সালে Communications of the ACM-এ; “প্রথম” আর “দ্বিতীয়” readers–writers সমস্যার নাম সেখান থেকেই।
প্রথম সমাধানে তিনটি জিনিস: read_count (এখন কতজন পড়ছে), mutex (read_count রক্ষা করে, শুরু ১) আর rw_mutex (ডেটায় লেখার অধিকার, শুরু ১)। কৌশলটি হলো — প্রথম reader পুরো পাঠক-দলের হয়ে rw_mutex নেয়, আর শেষ reader সেটি ছাড়ে:
semaphore rw_mutex = 1, mutex = 1; int read_count = 0;
writer: reader:
wait(rw_mutex); wait(mutex);
... write ... read_count++;
signal(rw_mutex); if (read_count == 1) wait(rw_mutex); // first in
signal(mutex);
... read ...
wait(mutex);
read_count--;
if (read_count == 0) signal(rw_mutex); // last out
signal(mutex);Writer লিখছে আর কয়েকজন reader এল? প্রথম reader wait(rw_mutex)-এ ঘুমায় — mutex হাতে রেখে — তাই বাকি reader-রা wait(mutex)-এ সারি বাঁধে। এখানে তালা ধরে ঘুমানো নিরাপদ, কারণ writer-এর mutex লাগে না। কিন্তু বড় দুর্বলতাটি আরেক জায়গায়। একজন reader পড়ছে, এমন অবস্থায় writer এলে সে অপেক্ষা করে — ঠিক আছে। কিন্তু নতুন reader এলে সে writer-কে ডিঙিয়ে সরাসরি ঢুকে পড়ে, কারণ read_count তখন ০ নয়। পাঠকের স্রোত না থামলে writer কখনো সুযোগ পায় না:
1R: wait(mutex); read_count++;2R: if (read_count == 1) wait(rw_mutex);3R: signal(mutex); ... read ...4R: wait(mutex); read_count--;5R: if (read_count == 0) signal(rw_mutex);6R: signal(mutex);7W: wait(rw_mutex); ... write ...; signal(rw_mutex);
| সময় | |
| read_count | |
| rw_mutex | |
| পড়ছে | |
| W |
- t = 0, R1 এল। R1 mutex নিয়ে
read_count১ করল। সে প্রথম reader, তাই পুরো দলের হয়েwait(rw_mutex): ১ থেকে ০. mutex ছেড়ে R1 পড়া শুরু করল (প্রতিটি পড়ায় ৪ একক সময় লাগে ধরুন)। - t = 1, W এল। W
wait(rw_mutex)ডাকল, কিন্তু মান ০ — পাঠক-দল ধরে রেখেছে। W ঘুমাল। এ পর্যন্ত সব ঠিক: কেউ পড়ার সময় লেখা চলবে না। - t = 2, R2 এল। R2-এর
read_count++হলো ২. সে প্রথম নয়, তাই rw_mutex-এর দিকে তাকায়ই না — সোজা ঢুকে পড়ল, যদিও W তার আগে থেকে অপেক্ষা করছে। এখানেই অন্যায্যতার শুরু। - t = 4, R1 বের হলো।
read_count২ থেকে ১. শেষ reader নয়, তাই rw_mutex ছাড়া হলো না। W যে কারণে অপেক্ষা করছিল (R1), সে চলে গেছে — অথচ W এখনো বাইরে, কারণ R2 আছে। - t = 5, R3 এল। R2 এখনো পড়ছে, তাই
read_count১ থেকে ২, R3 ঢুকে গেল। W-এর অপেক্ষা ৪ একক হলো। - t = 6, R2 বের হলো। read_count ১ — R3 এখনো ভেতরে। rw_mutex পাঠক-দলের হাতেই। পাঠকদের পড়া একে অপরের ওপর পড়ছে, তাই
read_countএকবারও ০ হয়নি। - t = 7, R4 এল। read_count ২. প্রতিটি নতুন reader আগের জন চলে যাওয়ার আগেই আসছে। এই স্রোত যতক্ষণ চলবে, W ততক্ষণ বাইরে — সিস্টেম চলছে, শুধু একজন বারবার বঞ্চিত। এটিই writer starvation; deadlock নয়, কারণ reader-রা ঠিকই এগোচ্ছে।
- প্রতিকার: সারির একটি দরজা। একই ঘটনা, কিন্তু সবাইকে আগে একটি FIFO
queuesemaphore (turnstile) পেরোতে হলে t = 2-তে R2 দাঁড়ায় W-এর পেছনে। t = 4-এ R1 বের হতেই read_count ০,signal(rw_mutex)— W লেখে t = 4 থেকে ৬, তারপর R2 (আর পরে আসা R3) একসাথে পড়ে। W-এর অপেক্ষা মাত্র ৩ একক।
৫ · লেখক-অগ্রাধিকার, ন্যায্য সমাধান আর বাস্তবের rwlock
দ্বিতীয় readers–writers সমস্যা উল্টো দাবি করে: একজন writer অপেক্ষা শুরু করলে আর কোনো নতুন reader ঢুকতে পারবে না। এর কাঠামো: writer-দের জন্যও একটি write_count, আর একটি read_try semaphore, যেটি প্রথম অপেক্ষমাণ writer বন্ধ করে দেয় আর শেষ writer খোলে। Reader-কে ঢোকার আগে read_try পেরোতে হয়, তাই writer অপেক্ষায় থাকলে নতুন reader বাইরে আটকায়:
writer: reader:
wait(wmutex); write_count++; wait(read_try); // blocked if a writer waits
if (write_count == 1) wait(read_try); wait(mutex); read_count++;
signal(wmutex); if (read_count == 1) wait(rw_mutex);
wait(rw_mutex); ... write ... signal(mutex);
signal(rw_mutex); signal(read_try);
wait(wmutex); write_count--; ... read ...
if (write_count == 0) signal(read_try); (leave exactly as in solution 1)
signal(wmutex);সমস্যা উল্টে গেল: writer-দের স্রোত না থামলে এবার reader-রা starve করে। প্রথম আর দ্বিতীয় — দুই সমাধানেই কেউ না কেউ অনন্তকাল বসে থাকতে পারে। তৃতীয়, ন্যায্য সমাধান একটি বাড়তি queue semaphore (শুরু ১) যোগ করে, যা সবাইকে আসার ক্রমে এক দরজা দিয়ে ঢোকায় — ব্যাংকের টোকেন-কাউন্টারের মতো, যেখানে “শুধু দেখব” আর “টাকা জমা দেব” দুই ধরনের গ্রাহকই একই টোকেন-লাইনে দাঁড়ান:
semaphore queue = 1, rw_mutex = 1, mutex = 1; int read_count = 0;
writer: reader:
wait(queue); wait(queue); // stand in line
wait(rw_mutex); wait(mutex);
signal(queue); read_count++;
... write ... if (read_count == 1) wait(rw_mutex);
signal(rw_mutex); signal(queue); // let the next one in
signal(mutex);
... read ...
(leave exactly as in solution 1)Writer queue ধরে রেখে rw_mutex-এর অপেক্ষা করে, তাই তার পরে আসা reader-রা দরজায় আটকে যায় — আগের ডায়াগ্রামের শেষ ধাপ ঠিক এটিই দেখিয়েছে। পরপর আসা reader-রা তবু একসাথে পড়ে, কারণ প্রত্যেকে দরজা পেরিয়েই সেটি পরের জনের জন্য খুলে দেয়। একটি সতর্কতা: এই ন্যায্যতা নির্ভর করে queue-এর সারি সত্যিই FIFO হওয়ার ওপর; POSIX কোনো semaphore-এর জাগানোর ক্রম কঠোরভাবে নিশ্চিত করে না, তাই বাস্তবের ন্যায্য lock-গুলো ভেতরে নিজস্ব সারি রাখে।
বাস্তবের reader–writer lock
রোজকার কোডে কেউ এই semaphore-গুলো হাতে লেখেন না — প্রতিটি প্ল্যাটফর্মে তৈরি reader–writer lock আছে। আর প্রতিটির অগ্রাধিকার-নীতি আলাদা, যা জানা জরুরি:
| প্ল্যাটফর্ম | API | অগ্রাধিকার |
|---|---|---|
| POSIX (Linux, macOS, BSD) | pthread_rwlock_rdlock, _wrlock, _unlock | মানদণ্ডে বাস্তবায়নের ওপর ছাড়া। glibc-র ডিফল্ট পাঠক-অগ্রাধিকার (PTHREAD_RWLOCK_PREFER_READER_NP); লেখক-অগ্রাধিকারের জন্য pthread_rwlockattr_setkind_np-এ PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP |
| Linux kernel | rwsem: down_read, up_read, down_write, up_write | অপেক্ষমাণ writer থাকলে নতুন reader সাধারণত সারিতে দাঁড়ায়; ঘুমাতে পারে এমন lock |
| Windows (Vista থেকে) | SRWLOCK: AcquireSRWLockShared, AcquireSRWLockExclusive | ন্যায্যতার কোনো প্রতিশ্রুতি নেই; খুব হালকা, পুনরাবৃত্ত (recursive) নেওয়া যায় না |
| Java | ReentrantReadWriteLock | ডিফল্ট অন্যায্য (বেশি throughput); new ReentrantReadWriteLock(true) দিলে ন্যায্য |
| Go | sync.RWMutex | একটি Lock() অপেক্ষায় থাকলে নতুন RLock() আটকে যায় — লেখক যাতে starve না করে |
নিচের প্রোগ্রামে ৪টি reader থ্রেড প্রত্যেকে ২,০০০ বার পড়ে, আর ২টি writer প্রত্যেকে ৫০০ বার লেখে। Writer দুটি ভ্যারিয়েবল a আর b দুই ধাপে বাড়ায় — কোনো reader কখনো a != b দেখলে বুঝব আধা-লেখা অবস্থা পড়া হয়েছে। সাথে atomic counter দিয়ে গোনা হয়, ভেতরে কতজন reader আর writer — নিয়ম ভাঙলেই violations বাড়ে:
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
static pthread_rwlock_t rw = PTHREAD_RWLOCK_INITIALIZER;
static long a, b; /* a writer keeps a == b */
static atomic_int readers_in, writers_in, violations;
static atomic_long reads;
static void *reader(void *arg) {
(void)arg; /* unused */
for (int i = 0; i < 2000; i++) {
pthread_rwlock_rdlock(&rw); /* shared: many readers at once */
atomic_fetch_add(&readers_in, 1);
if (atomic_load(&writers_in) != 0 || a != b) atomic_fetch_add(&violations, 1);
atomic_fetch_add(&reads, 1);
atomic_fetch_sub(&readers_in, 1);
pthread_rwlock_unlock(&rw);
}
return NULL;
}
static void *writer(void *arg) { (void)arg;
for (int i = 0; i < 500; i++) {
pthread_rwlock_wrlock(&rw); /* exclusive: nobody else inside */
if (atomic_fetch_add(&writers_in, 1) != 0 || atomic_load(&readers_in) != 0)
atomic_fetch_add(&violations, 1);
a++; b++; /* two steps: never visible half-done */
atomic_fetch_sub(&writers_in, 1);
pthread_rwlock_unlock(&rw);
}
return NULL;
}
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("reads = %ld, a = %ld, b = %ld\n", (long)atomic_load(&reads), a, b);
int v = atomic_load(&violations);
printf("violations = %d\nwriters never overlapped readers: %s\n", v, v == 0 ? "yes" : "no");
return 0;
}একটি আসল রানের আউটপুট:
reads = 8000, a = 1000, b = 1000
violations = 0
writers never overlapped readers: yes
৪ × ২,০০০ = ৮,০০০টি পড়া, ২ × ৫০০ = ১,০০০টি লেখা — a আর b দুটিই ঠিক ১০০০, অর্থাৎ দুই writer-এর কোনো লেখা হারায়নি (writer-রা একে অপরকেও বাদ রাখে)। একবারও কোনো reader writer-এর সাথে ভেতরে ছিল না, বা আধা-লেখা অবস্থা দেখেনি। এই প্রোগ্রাম কিন্তু দেখায় না rwlock দ্রুত — শুধু দেখায় নিয়ম মানে। Critical section এখানে এত ছোট যে একটি সাধারণ mutex সম্ভবত সমান বা বেশি দ্রুত হতো; কারণ “ভাবনার প্রশ্ন ২”-এ।
আরও গভীরে: Linux kernel-এর প্রতিটি প্রসেসের address space একটি rwsem দিয়ে রক্ষিত — আগে নাম ছিল mmap_sem, Linux 5.8 থেকে mmap_lock. Page fault সামলানো reader হিসেবে নেয়, আর mmap/munmap writer হিসেবে; বহু থ্রেডের বড় প্রোগ্রামে এটি বহু বছর ধরে পরিচিত বাধা, তাই নতুন kernel-এ প্রতি-VMA lock-এর মতো সূক্ষ্মতর কৌশল এসেছে। Reader-দের একটি shared counter বাড়াতে-কমাতে হয় বলে অনেক core-এ সেই cache line নিজেই যুদ্ধক্ষেত্র হয়; তাই খুব বেশি পড়া আর কদাচিৎ লেখার জন্য kernel seqlock আর RCU ব্যবহার করে, যেখানে reader কোনো shared মেমরিতে লেখেই না (পর্ব ২৯)।
৬ · Dining philosophers — deadlock কীভাবে জন্মায়
Dining philosophers-এর গল্প: গোল টেবিলে পাঁচজন দার্শনিক (P0…P4), প্রতি দুজনের মাঝে একটি কাঁটাচামচ (fork F0…F4). প্রত্যেকে পালা করে ভাবেন আর খান; খেতে দুপাশের দুটো fork লাগে। Pi-এর বাঁ দিকে Fi, ডান দিকে F((i+1) mod 5) — তাই F1 ভাগ করেন P0 আর P1. প্রচলিত বর্ণনা অনুযায়ী Dijkstra ১৯৬৫ সালে সমস্যাটি দিয়েছিলেন পাঁচটি কম্পিউটার আর ভাগাভাগি করা tape drive-এর অনুশীলনী হিসেবে; Hoare পরে এটিকে দার্শনিক আর স্প্যাগেটির গল্পে রূপ দেন। প্রতিটি fork-কে একটি mutex ধরলে সবচেয়ে সহজ কোড:
philosopher(i):
while (true) {
think();
lock(fork[i]); // left fork
lock(fork[(i + 1) % 5]); // right fork
eat();
unlock(fork[(i + 1) % 5]); unlock(fork[i]);
}বেশিরভাগ সময় এটি চলে। কিন্তু একটি দুর্ভাগা মুহূর্তে পাঁচজনই প্রায় একসাথে ক্ষুধার্ত হলে:
1philosopher(i): while (true) {2 think();3 lock(fork[i]); // left4 lock(fork[(i+1) % 5]); // right5 eat();6 unlock both; }7// 4 conditions: ME, H&W, NP, CW8// fix: lock lower-numbered fork first
| F0 F1 F2 F3 F4 | |
| P0 | |
| P1, P2 | |
| P3 | |
| P4 | |
| অপেক্ষার শিকল |
- সবাই ভাবছেন। পাঁচটি fork-ই টেবিলে, কারও হাতে কিছু নেই। পাঁচ fork, পাঁচজন — একসাথে সর্বোচ্চ দুজন খেতে পারেন (একজন খেলে দুপাশের দুজন পারেন না)।
- P0 ক্ষুধার্ত। P0 নিজের বাঁ দিকের F0 তুললেন —
lock(fork[0])সফল। এখন তিনি ডান দিকের F1 তুলতে যাবেন, কিন্তু ঠিক এই মুহূর্তে scheduler তাঁকে সরিয়ে P1-কে CPU দিল। - P1-ও বাঁ fork তুললেন। P1 তুললেন F1 — যেটি P0-এর ডান fork-ও। প্রত্যেকে নিজের দৃষ্টিতে কোনো ভুল করছেন না: একটি খালি fork দেখে তুলেছেন।
- P2 আর P3. একই ঘটনা: P2 তুললেন F2, P3 তুললেন F3. এখনো একটি fork, F4, খালি — এখনো রক্ষা পাওয়া সম্ভব।
- P4 তুললেন F4. শেষ খালি fork-টিও গেল। প্রত্যেকের হাতে ঠিক একটি fork, টেবিলে একটিও নেই। কেউ এখনো অপেক্ষা শুরু করেননি, কিন্তু ভাগ্য এর মধ্যেই ঠিক হয়ে গেছে।
- P0 ডান fork চাইলেন।
lock(fork[1])— F1 P1-এর হাতে, তাই P0 ঘুমালেন, F0 হাতে রেখেই। অপেক্ষার শিকলের প্রথম কড়া: P0 → P1 (P0 অপেক্ষা করছেন P1-এর জন্য)। - সবাই ডান fork চাইলেন। P1 চান F2 (P2-এর হাতে), P2 চান F3, P3 চান F4, আর P4 চান F0 — যেটি P0-এর হাতে। শিকলটি ঘুরে শুরুতে ফিরে এল: P0 → P1 → P2 → P3 → P4 → P0.
- Deadlock — চারটি শর্তই সত্য। (১) Mutual exclusion: একটি fork একবারে একজনের। (২) Hold and wait: প্রত্যেকে একটি ধরে আরেকটির অপেক্ষায়। (৩) No preemption: কেউ কারও হাত থেকে fork কেড়ে নেয় না। (৪) Circular wait: বৃত্তাকার শিকল। এই চার শর্তের পূর্ণ তত্ত্ব পর্ব ২৭-এ; যেকোনো একটি ভাঙলেই deadlock অসম্ভব।
- প্রতিকার: ছোট নম্বর আগে। নিয়ম “দুটোর মধ্যে ছোট নম্বরের fork আগে” হলে শুধু P4-এর ক্রম বদলায় — তাঁর fork F4 আর F0, তাই তিনি আগে F0 চান। ধাপ ৫-এ F0 P0-এর হাতে, তাই P4 খালি হাতে অপেক্ষা করেন, আর F4 খালি থাকে। P3 F4 তুলে খেতে শুরু করেন — বৃত্ত ভেঙে গেল, শিকল একটি সরল রেখা।
বাস্তবে এই deadlock হাজারে একবার ঘটতে পারে — কারণ ধাপ ২–৫-এর মতো “সবাই প্রথম fork তুলে থেমে যাওয়া” ঘটতে scheduler-এর ঠিক ওই ক্রমে থ্রেড বদলাতে হয়। নিচের simulator ভাগ্যের ওপর নির্ভর করে না: প্রতিটি tick-এ সব ক্ষুধার্ত দার্শনিক একসাথে পরের fork-টি চান (lock-step), একই fork দুজন চাইলে ছোট id-এর জন পায়, আর খাওয়া চলে এক tick. কোনো tick-এ কেউ এগোতে না পারলে আর কেউ না খেলে সেটিই deadlock. প্রথমে সাধারণ “বাঁ fork আগে”, তারপর “ছোট নম্বরের fork আগে”:
// Dining philosophers in lock-step: every tick, all hungry philosophers try their next fork at once
const N = 5, MEALS = 2;
function run(name, firstFork) {
const owner = Array(N).fill(-1); // owner[f] = philosopher holding fork f
const ph = [...Array(N).keys()].map(i => {
const L = i, R = (i + 1) % N, a = firstFork(L, R);
return { want: [a, a === L ? R : L], step: 0, meals: 0 }; // step 0/1 = need fork, 2 = eating
});
console.log(`--- ${name} ---`);
for (let tick = 1; tick <= 50; tick++) {
let changed = false;
const req = new Map(); // fork -> lowest id asking for it this tick
ph.forEach((p, i) => {
if (p.step === 2) { // finished eating: put both forks down
p.meals++; p.step = 0; owner[p.want[0]] = owner[p.want[1]] = -1; changed = true;
} else if (p.meals < MEALS) {
const f = p.want[p.step];
if (owner[f] === -1 && !req.has(f)) req.set(f, i);
}
});
for (const [f, i] of req) { owner[f] = i; ph[i].step++; changed = true; }
const row = ph.map((p, i) => `P${i}:` + (p.meals >= MEALS ? "done" :
["needs " + p.want[0], "has " + p.want[0], "EATS"][p.step])).join(" ");
console.log(`t${String(tick).padStart(2)} ${row}`);
if (ph.every(p => p.meals >= MEALS)) { console.log(`all ate ${MEALS} times by tick ${tick}`); return; }
if (!changed && !ph.some(p => p.step === 2)) {
const cyc = ph.map((p, i) => `P${i}->fork${p.want[p.step]}(P${owner[p.want[p.step]]})`);
console.log("DEADLOCK: nobody can move. waits-for: " + cyc.join(" "));
return;
}
}
}
run("naive: left fork first", (L, R) => L);
run("ordered: lower-numbered fork first", (L, R) => Math.min(L, R));আউটপুট:
--- naive: left fork first ---
t 1 P0:has 0 P1:has 1 P2:has 2 P3:has 3 P4:has 4
t 2 P0:has 0 P1:has 1 P2:has 2 P3:has 3 P4:has 4
DEADLOCK: nobody can move. waits-for: P0->fork1(P1) P1->fork2(P2) P2->fork3(P3) P3->fork4(P4) P4->fork0(P0)
--- ordered: lower-numbered fork first ---
t 1 P0:has 0 P1:has 1 P2:has 2 P3:has 3 P4:needs 0
t 2 P0:has 0 P1:has 1 P2:has 2 P3:EATS P4:needs 0
t 3 P0:has 0 P1:has 1 P2:has 2 P3:needs 3 P4:needs 0
t 4 P0:has 0 P1:has 1 P2:EATS P3:needs 3 P4:needs 0
t 5 P0:has 0 P1:has 1 P2:needs 2 P3:has 3 P4:needs 0
t 6 P0:has 0 P1:EATS P2:needs 2 P3:EATS P4:needs 0
t 7 P0:has 0 P1:needs 1 P2:has 2 P3:done P4:needs 0
t 8 P0:EATS P1:needs 1 P2:EATS P3:done P4:needs 0
t 9 P0:needs 0 P1:has 1 P2:done P3:done P4:has 0
t10 P0:needs 0 P1:EATS P2:done P3:done P4:EATS
t11 P0:needs 0 P1:done P2:done P3:done P4:needs 0
t12 P0:has 0 P1:done P2:done P3:done P4:needs 0
t13 P0:EATS P1:done P2:done P3:done P4:needs 0
t14 P0:done P1:done P2:done P3:done P4:has 0
t15 P0:done P1:done P2:done P3:done P4:EATS
t16 P0:done P1:done P2:done P3:done P4:done
all ate 2 times by tick 16
সাধারণ সংস্করণ দ্বিতীয় tick-এই আটকে গেছে: t1-এ পাঁচজন পাঁচটি fork তুলেছেন (প্রত্যেকে আলাদা fork চেয়েছেন, তাই কোনো সংঘাত নেই), আর t2-তে প্রত্যেকে যে fork চান, সেটি পাশের জনের হাতে। Simulator যে শিকলটি ছেপেছে — P0->fork1(P1) … P4->fork0(P0) — সেটিই ডায়াগ্রামের ধাপ ৭-এর বৃত্ত। ক্রম মানা সংস্করণে t1-এ P0 আর P4 দুজনেই F0 চেয়েছেন; P0 পেয়েছেন, P4 খালি হাতে অপেক্ষায় — ফলে F4 খালি, আর t2-তেই P3 খাচ্ছেন। ১৬ tick-এ সবাই দুবার করে খেয়েছেন। তবে লক্ষ করুন, P4 t1 থেকে t8 পর্যন্ত একটানা “needs 0” — deadlock নেই, কিন্তু একজন বারবার পিছিয়ে পড়ছেন। এই simulator-এর “ছোট id আগে পায়” নিয়ম নিজেই অন্যায্য; নিয়মটি বদলালে কী হয়, অনুশীলনে চেষ্টা করুন।
৭ · Deadlock-এর প্রতিকার — চারটি কৌশল আর তাদের প্রমাণ
ডায়াগ্রামের চার শর্তের যেকোনো একটি ভাঙলেই সমস্যা মেটে। পাঠ্যবই আর পরীক্ষায় সবচেয়ে বেশি আসে এই চারটি প্রতিকার:
semaphore room = 4; // at most 4 philosophers at the table
philosopher(i):
wait(room);
lock(fork[i]); lock(fork[(i + 1) % 5]);
eat();
unlock(fork[(i + 1) % 5]); unlock(fork[i]);
signal(room);যুক্তি: সবাই বাঁ fork আগে তোলেন, তাই কেউ আটকে থাকলে দুই অবস্থার একটিতে: (ক) বাঁ fork Fi হাতে, ডান fork-এর অপেক্ষায় — যেটি ডানের প্রতিবেশী P(i+1)-এর হাতে; বা (খ) খালি হাতে বাঁ fork Fi-এর অপেক্ষায় — যেটি বাঁয়ের প্রতিবেশী P(i−1) নিয়েছেন তাঁর দ্বিতীয় fork হিসেবে, অর্থাৎ তিনি খাচ্ছেন এবং শেষে ছাড়বেন। তাই চিরস্থায়ী অপেক্ষার প্রতিটি তীর যায় Pi থেকে P(i+1)-এর দিকে, আর এমন তীরের একটি বৃত্ত পেতে টেবিলের চারপাশের পাঁচজনকেই লাগে। room = 4 একসাথে চারজনের বেশি বসতে দেয় না, তাই বৃত্তটি কখনো সম্পূর্ণ হয় না — circular wait ভাঙল। সহজ ভাষায় (pigeonhole): চারজন একটি করে fork ধরলেও পাঁচটির একটি বাড়তি থাকে, আর সেটি কোনো বসে থাকা দার্শনিকের দ্বিতীয় fork.
philosopher(i):
if (i % 2 == 0) { lock(fork[i]); lock(fork[(i + 1) % 5]); } // even: left first
else { lock(fork[(i + 1) % 5]); lock(fork[i]); } // odd: right first
eat();
unlock both;জোড় নম্বরের দার্শনিক আগে বাঁ, বিজোড় নম্বরের আগে ডান fork তোলেন। পাঁচজনের টেবিলে মিলিয়ে দেখুন: P1 (বিজোড়) আর P2 (জোড়) দুজনেরই প্রথম fork F2; P3 আর P4-এর প্রথম fork F4. এই দুই জোড়ায় দুজন একই fork-এর জন্য লড়েন হাতে কিছু না নিয়ে, আর যিনি হারেন, তিনি খালি হাতে অপেক্ষা করেন। Deadlock-এর বৃত্তে পাঁচজনকেই একটি করে fork ধরে থাকতে হয় — কিন্তু অন্তত একজন সবসময় খালি হাতে, তাই বৃত্ত সম্পূর্ণ হয় না। (P4 আর P0 দুজনেই জোড়, পাশাপাশি বসে একই নিয়ম মানেন; বিজোড় সংখ্যক দার্শনিকে এটুকু অসমতা থাকবেই, কিন্তু তাতে ক্ষতি নেই।)
monitor DiningPhilosophers {
enum { THINKING, HUNGRY, EATING } state[5];
condition self[5];
void pickup(int i) {
state[i] = HUNGRY;
test(i); // can I eat right now?
if (state[i] != EATING) self[i].wait(); // no: sleep until a neighbour wakes me
}
void putdown(int i) {
state[i] = THINKING;
test((i + 4) % 5); // maybe my left neighbour can eat now
test((i + 1) % 5); // maybe my right neighbour can eat now
}
void test(int i) {
if (state[(i + 4) % 5] != EATING && state[i] == HUNGRY && state[(i + 1) % 5] != EATING) {
state[i] = EATING;
self[i].signal();
}
}
init: for i in 0..4: state[i] = THINKING;
}এখানে কেউ কখনো একটি fork ধরে অপেক্ষা করেন না: দুপাশের কেউ না খেলে তবেই একসাথে “দুটোই” পান, নইলে খালি হাতে ঘুমান — hold and wait ভাঙল। test(i)-এর শর্ত (বাঁয়ের জন খাচ্ছেন না, আমি ক্ষুধার্ত, ডানের জন খাচ্ছেন না) monitor-এর ভেতরে চলে, তাই একসাথে একজনই state বদলায়। কেউ খাওয়া শেষ করলে দুই প্রতিবেশীর জন্য test ডাকেন — অপেক্ষারত প্রতিবেশী তখন জাগেন। pthread দিয়ে লিখলে if-এর বদলে while (state[i] != EATING) pthread_cond_wait(...). সমাধানটি deadlock-মুক্ত, কিন্তু starvation-মুক্ত নয়: P1 আর P3 পালা করে এমনভাবে খেতে পারেন যে মাঝের P2-এর দুপাশ কখনো একসাথে খালি হয় না।
philosopher(i):
a = min(i, (i + 1) % 5); b = max(i, (i + 1) % 5);
lock(fork[a]); lock(fork[b]); // always the lower-numbered fork first
eat();
unlock(fork[b]); unlock(fork[a]);প্রমাণ: ধরুন তবু একটি বৃত্তাকার অপেক্ষা আছে: T1 অপেক্ষা করছে T2-এর হাতের fork-এর জন্য, T2 T3-এর, … , Tk আবার T1-এর। নিয়ম অনুযায়ী প্রত্যেকে যে fork-এর অপেক্ষায়, তার নম্বর তার হাতের সব fork-এর চেয়ে বড়। Ti-এর হাতে আছে fi, সে চায় fi+1 (যেটি Ti+1-এর হাতে), তাই f1 < f2 < … < fk < f1 — একটি সংখ্যা নিজের চেয়ে ছোট, যা অসম্ভব। তাই circular wait কখনো হয় না। এই প্রমাণ পাঁচজন বা fork-এ সীমাবদ্ধ নয় — যেকোনো সংখ্যক lock আর থ্রেডে খাটে। এজন্যই বাস্তবের সবচেয়ে প্রচলিত কৌশল lock ordering: প্রতিটি lock-এর একটি নির্দিষ্ট ক্রম, আর সবাই সেই ক্রমেই নেয়।
এবার resource ordering আসল থ্রেড আর mutex দিয়ে — পাঁচজন দার্শনিক প্রত্যেকে ১,০০০ বার খান। খাওয়ার সময় প্রত্যেকে একটি atomic পতাকা তোলেন, আর দেখেন কোনো প্রতিবেশীর পতাকা ওঠানো কি না (তাহলে দুজন একই fork-এ — নিয়মভঙ্গ):
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#define P 5
#define K 1000 /* meals per philosopher */
static pthread_mutex_t forks[P];
static atomic_int eating[P], clashes;
static int meals[P], uses[P];
static void *philosopher(void *arg) {
int i = (int)(intptr_t)arg, left = i, right = (i + 1) % P;
int first = left < right ? left : right; /* lower-numbered fork first */
int second = left < right ? right : left;
for (int k = 0; k < K; k++) {
pthread_mutex_lock(&forks[first]);
pthread_mutex_lock(&forks[second]);
atomic_store(&eating[i], 1); /* eating: neighbours must not be */
if (atomic_load(&eating[(i + P - 1) % P]) || atomic_load(&eating[right]))
atomic_fetch_add(&clashes, 1);
meals[i]++; uses[left]++; uses[right]++;
atomic_store(&eating[i], 0);
pthread_mutex_unlock(&forks[second]);
pthread_mutex_unlock(&forks[first]);
}
return NULL;
}
int main(void) {
pthread_t t[P];
for (int i = 0; i < P; i++) pthread_mutex_init(&forks[i], NULL);
for (int i = 0; i < P; i++) pthread_create(&t[i], NULL, philosopher, (void *)(intptr_t)i);
for (int i = 0; i < P; i++) pthread_join(t[i], NULL);
int total = 0, ok = 1;
for (int i = 0; i < P; i++) {
int r = (i + 1) % P;
printf("P%d: ate %d times, takes fork %d then %d\n", i, meals[i], i < r ? i : r, i < r ? r : i);
total += meals[i]; if (uses[i] != 2 * K) ok = 0;
}
printf("total meals = %d, every fork used %d times: %s\n", total, 2 * K, ok ? "yes" : "no");
printf("neighbours eating together: %d | all threads finished: no deadlock\n", atomic_load(&clashes));
return 0;
}একটি আসল রানের আউটপুট:
P0: ate 1000 times, takes fork 0 then 1
P1: ate 1000 times, takes fork 1 then 2
P2: ate 1000 times, takes fork 2 then 3
P3: ate 1000 times, takes fork 3 then 4
P4: ate 1000 times, takes fork 0 then 4
total meals = 5000, every fork used 2000 times: yes
neighbours eating together: 0 | all threads finished: no deadlock
প্রথম চারজন নেন বাঁ fork আগে, শুধু P4 নেন F0 আগে, তারপর F4 — ক্রমের নিয়মের পুরো প্রভাব একটিমাত্র দার্শনিকে। মোট ৫ × ১,০০০ = ৫,০০০ বার খাওয়া, আর প্রতিটি fork ঠিক ২,০০০ বার ব্যবহৃত (দুপাশের দুজন × ১,০০০) — অর্থাৎ uses[]-এ কোনো lost update নেই, কারণ প্রতিটি ঘর তার fork-এর mutex দিয়েই রক্ষিত। কোনো প্রতিবেশী-জোড় একসাথে খায়নি। আর “শেষ হয়েছে” কথাটিই প্রমাণ: প্রোগ্রাম deadlock-এ পড়লে কোনো লাইনই ছাপা হতো না, রানার শুধু সময়সীমা পেরিয়ে থেমে যেত। প্রথম চার লাইনের first আর second বদলে সবাইকে “বাঁ fork আগে” করলে প্রোগ্রামটি কখনো কখনো চিরতরে আটকে যেতে পারে — তবে প্রতি রানে নয়, যা এই বাগকে আরও বিপজ্জনক করে।
Chandy–Misra — পরিষ্কার আর ময়লা fork
উপরের সমাধানগুলোতে হয় একটি কেন্দ্রীয় monitor লাগে, নয়তো সবাইকে একটি সাধারণ নম্বর-ক্রম মানতে হয়। K. Mani Chandy আর Jayadev Misra ১৯৮৪ সালে (“The Drinking Philosophers Problem”, ACM TOPLAS) একটি বিতরণকৃত (distributed) সমাধান দেন, যেখানে দার্শনিকেরা শুধু প্রতিবেশীর সাথে বার্তা আদান-প্রদান করেন। মূল নিয়ম: প্রতিটি fork “পরিষ্কার” বা “ময়লা”. শুরুতে প্রতিটি fork ছোট id-এর প্রতিবেশীর হাতে, ময়লা অবস্থায়। ক্ষুধার্ত হলে দার্শনিক প্রতিবেশীর কাছে অনুরোধ পাঠান। যার হাতে ময়লা fork আছে আর যিনি খাচ্ছেন না, তিনি অনুরোধ পেলে সেটি পরিষ্কার করে দিয়ে দেন; পরিষ্কার fork খাওয়ার আগে কেউ ছাড়েন না। খাওয়ার পর সব fork ময়লা হয়ে যায়। ফলে যিনি সদ্য খেয়েছেন, তিনি অগ্রাধিকার হারান, আর অগ্রাধিকারের একটি চক্রহীন ক্রম সবসময় বজায় থাকে — deadlock আর starvation দুটোই নেই।
Deadlock বনাম starvation — পার্থক্যটি স্পষ্ট করুন
| বিষয় | Deadlock | Starvation |
|---|---|---|
| কারা আটকে | একটি দলের সবাই, একে অপরের অপেক্ষায় | একজন (বা কয়েকজন); বাকিরা এগোয় |
| নিজে থেকে মেটে? | কখনো না — বাইরে থেকে কাউকে থামাতে বা সম্পদ কাড়তে হয় | পারে, ভাগ্য বা load বদলালে; কিন্তু কোনো সময়সীমার নিশ্চয়তা নেই |
| এই পর্বের উদাহরণ | সবাই বাঁ fork আগে; উল্টো ক্রমের wait(mutex)/wait(empty) | পাঠক-অগ্রাধিকারে writer; monitor সমাধানে মাঝের দার্শনিক |
| প্রতিকার | চার শর্তের একটি ভাঙা (ordering, সব-বা-কিছু-না, সীমিত আসন) | ন্যায্য সারি (FIFO turnstile), aging, Chandy–Misra |
তৃতীয় একটি অবস্থাও আছে — livelock. ধরুন প্রত্যেক দার্শনিক বাঁ fork তুলে ডানটি না পেলে বাঁটি নামিয়ে রেখে আবার চেষ্টা করেন। সবাই একই তালে চললে সবাই একসাথে তোলেন, একসাথে নামান, আবার তোলেন — কেউ ঘুমাচ্ছেন না, CPU ব্যস্ত, অথচ কেউ খাচ্ছেন না। ঠিক যেন সরু গলিতে মুখোমুখি দুজন, দুজনেই একসাথে একই দিকে সরে যাচ্ছেন। সমাধান সাধারণত এলোমেলো অপেক্ষা (random backoff) — Ethernet-এর সংঘর্ষ সামলানোর কৌশলের মতো।
৮ · Sleeping barber, cigarette smokers আর বাস্তব সিস্টেম
Sleeping barber — ঘুমন্ত নাপিত
Sleeping barber সমস্যাটিও Dijkstra-র ১৯৬০-এর দশকের লেখা থেকে। একটি সেলুনে একজন নাপিত, একটি কাটার চেয়ার আর অপেক্ষার n-টি চেয়ার। কোনো খদ্দের না থাকলে নাপিত ঘুমান। খদ্দের এসে নাপিতকে ঘুমন্ত দেখলে জাগান; নাপিত ব্যস্ত থাকলে খালি চেয়ারে বসেন; কোনো চেয়ার খালি না থাকলে চলে যান। এটি আসলে একটি bounded buffer, যেখানে buffer ভর্তি হলে producer অপেক্ষা না করে চলে যায় — ঠিক যেমন ভিড় হলে web server নতুন অনুরোধ ফিরিয়ে দেয় (HTTP 503). সমাধানের কাঠামো (Tanenbaum-এর বইয়ে প্রচলিত রূপ):
semaphore customers = 0, barber = 0, mutex = 1; int waiting = 0;
barber: customer:
while (true) { wait(mutex);
wait(customers); // sleep if none if (waiting < CHAIRS) {
wait(mutex); waiting++;
waiting--; signal(customers); // wake barber if asleep
signal(barber); // call next one signal(mutex);
signal(mutex); wait(barber); // wait for my turn
cut_hair(); get_haircut();
} } else {
signal(mutex); // shop full: leave
}waiting একটি সাধারণ পূর্ণসংখ্যা, কারণ খদ্দেরকে মান পড়ে সিদ্ধান্ত নিতে হয় (“চেয়ার খালি আছে?”) — semaphore-এর মান পড়ে সিদ্ধান্ত নেওয়ার নির্ভরযোগ্য উপায় নেই। তাই mutex দিয়ে রক্ষিত একটি counter, আর ঘুম-জাগানোর জন্য আলাদা দুটি semaphore.
Cigarette smokers — ধূমপায়ীদের সমস্যা
Cigarette smokers সমস্যা ১৯৭১ সালে Suhas Patil দেন। তিনজন ধূমপায়ী; সিগারেট বানাতে তামাক, কাগজ আর দেশলাই লাগে। প্রত্যেকের কাছে একটি উপকরণ অফুরন্ত। একজন “এজেন্ট” প্রতিবার এলোমেলোভাবে বাকি দুটি উপকরণ টেবিলে রাখে; যার কাছে তৃতীয়টি আছে, তাকেই নিতে হবে, আর সে শেষ করলে এজেন্ট আবার রাখবে। কঠিন শর্ত: এজেন্টের কোড বদলানো যাবে না — সে শুধু প্রতিটি উপকরণের জন্য একটি করে semaphore-এ signal দেয়। কাগজের ধূমপায়ী সরল মনে wait(tobacco); wait(match); লিখলে বিপদ: এজেন্ট রাখল তামাক আর কাগজ, কাগজের ধূমপায়ী তামাক তুলে নিল, দেশলাইয়ের ধূমপায়ী কাগজ পেল কিন্তু তামাক আর নেই — দুজনেই একটি করে ধরে বসে আছে।
প্রচলিত সমাধান (Allen Downey-র “The Little Book of Semaphores”-এ সুন্দরভাবে আছে) তিনটি সহকারী “pusher” থ্রেড যোগ করে। প্রতিটি pusher একটি উপকরণের semaphore-এ অপেক্ষা করে; জাগলে একটি mutex ধরে বুলিয়ান পতাকা দেখে — অন্য উপকরণটি আগেই এসেছে কি না। এসে থাকলে ঠিক সেই ধূমপায়ীকে signal দেয়, যার কাছে তৃতীয়টি আছে; নইলে নিজের উপকরণের পতাকা তুলে রাখে। শিক্ষা: যখন সিদ্ধান্ত নির্ভর করে কোন কোন ঘটনা ঘটেছে তার সমন্বয়ের ওপর, তখন semaphore-এর সাথে mutex-রক্ষিত অবস্থা (state) লাগে — ঠিক monitor-এর ধারণা।
বাস্তব সিস্টেমে এই ছাঁচগুলো
| বাস্তব সিস্টেম | ছাঁচ | কী মিলে যায় |
|---|---|---|
| Web server-এর thread pool আর অনুরোধের সারি | Producer–consumer / sleeping barber | সংযোগ-গ্রহণকারী থ্রেড producer, worker-রা consumer; সারি ভর্তি হলে নতুন অনুরোধ ফেরত (503) বা kernel-এর listen backlog-এ অপেক্ষা |
| Linux pipe, Go-র buffered channel | Bounded buffer | ভর্তি হলে লেখক ঘুমায়, খালি হলে পাঠক — kernel বা runtime semaphore-এর কাজটি করে |
| Kafka-র মতো message broker-এর producer client | Bounded buffer আর backpressure | Client-এর পাঠানোর buffer সীমিত (Kafka-তে buffer.memory); ভর্তি হলে send কিছুক্ষণ আটকে থাকে (max.block.ms), তারপর ত্রুটি |
| ডেটাবেসের shared (S) আর exclusive (X) lock | Readers–writers | অনেক transaction একসাথে পড়ে (S), লেখার জন্য X একা; অনেক DBMS অপেক্ষার সারিতে ন্যায্যতা রাখে |
| দুটি ব্যাংক হিসাবে একসাথে টাকা স্থানান্তর | Dining philosophers | A→B আর B→A একসাথে চললে দুই lock উল্টো ক্রমে — সমাধান হিসাব নম্বরের ক্রমে lock; ডেটাবেস deadlock ধরে একটি transaction বাতিল করে |
ডেটাবেস কীভাবে এগুলো সামলায়, দেখুন DBMS কোর্সের concurrency control পর্বে। আর Linux kernel lock-এর ক্রম রক্ষার জন্য নিজেই পাহারা দেয়: lockdep নামের একটি debug ব্যবস্থা প্রতিটি lock নেওয়ার ক্রম মনে রাখে, আর কোথাও উল্টো ক্রম দেখা গেলেই — deadlock আসলে না ঘটলেও — সতর্কবার্তা দেয়।
৯ · AI-এর যুগে ক্লাসিক সমস্যা
“Producer-consumer-এর C কোড দাও” — AI এক সেকেন্ডে দেবে, আর বেশিরভাগ সময় কোডটি দেখতে পাঠ্যবইয়ের মতো। কিন্তু এই সমস্যাগুলোর বাগ লুকিয়ে থাকে এক লাইনের ক্রমে, আর সাধারণ টেস্টে সেটি প্রায় কখনো ধরা পড়ে না। AI-এর সাধারণ ভুল:
| AI-এর ভুল | ফল | কীভাবে ধরবেন |
|---|---|---|
wait(mutex) আগে, wait(empty) পরে | Buffer ভর্তি হলেই deadlock | buffer-এর আকার ১ রেখে consumer-কে ধীর করে চালান |
Condition variable-এ if, while নয়; বা একটিমাত্র condition variable-এ signal | খালি buffer থেকে তোলা, বা ভুল থ্রেড জেগে বাকিরা ঘুমিয়ে থাকা | একাধিক producer ও consumer দিয়ে হাজারবার চালান, যোগফল মেলান |
| “Readers–writers সমাধান” বলে পাঠক-অগ্রাধিকারের কোড, starvation-এর কথা না বলে | ভারী পড়ার load-এ লেখা কখনো হয় না | জিজ্ঞেস করুন: অপেক্ষমাণ writer থাকলে নতুন reader কি ঢোকে? |
Dining philosophers-এ sleep() বা trylock-লুপ দিয়ে “সমাধান” | Deadlock বিরল হয়, যায় না; বা livelock | যুক্তি চান: চার শর্তের কোনটি ভাঙা হলো? |
macOS-এ sem_init, Windows-এ pthread — ভুল প্ল্যাটফর্মের API | compile হয় না বা চুপচাপ ব্যর্থ | প্রম্পটে OS লিখুন; প্রতিটি ফেরত মান যাচাই করুন |
| Semaphore-এর মান বা অপেক্ষার সময়ের হিসাবে ভুল সংখ্যা | পরীক্ষার উত্তর ভুল | প্রতিটি wait/signal হাতে ট্রেস করে মান মেলান |
কোড চাওয়ার সময় পরিবেশ, নীতি আর প্রমাণ — তিনটিই চান:
AI-এর লেখা সমন্বয়-কোড ব্যবহারের আগে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- Bounded buffer-এ তিনটি semaphore-এর শুরুর মান কত, আর কেন? — mutex = 1 (একবারে একজন), empty = n (সব ঘর খালি), full = 0 (কোনো জিনিস নেই)।
- Producer-এ wait(mutex) আর wait(empty)-এর ক্রম উল্টালে কী হয়? — ভর্তি buffer-এ producer mutex ধরে ঘুমায়, consumer mutex পায় না — deadlock.
- প্রথম readers–writers সমাধানের দুর্বলতা কী? — পাঠক-অগ্রাধিকার; reader-দের স্রোত চললে writer starve করে।
- Dining philosophers-এ deadlock এড়ানোর দুটি উপায়? — ছোট নম্বরের fork আগে (circular wait ভাঙে), বা সর্বোচ্চ ৪ জনকে বসতে দেওয়া; monitor-এ দুটো একসাথে নেওয়া।
- Deadlock আর starvation-এর পার্থক্য? — deadlock-এ দলের কেউই এগোয় না, চিরতরে; starvation-এ সিস্টেম চলে, একজন অনির্দিষ্টকাল বঞ্চিত।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Dijkstra semaphore এনেছিলেন, তবু কয়েক বছরের মধ্যেই monitor এল কেন? এই পর্বের কোড থেকে উত্তর খুঁজুন।
১৯৬০-এর দশকের মাঝামাঝি নেদারল্যান্ডসের Eindhoven-এ Dijkstra ও তাঁর দল THE নামের একটি multiprogramming OS বানাচ্ছিলেন। সেখানে একাধিক প্রসেসকে একে অপরের জন্য অপেক্ষা আর জাগানোর একটি পরিষ্কার উপায় দরকার ছিল, আর সেই প্রয়োজন থেকেই আসে semaphore — একটি পূর্ণসংখ্যা আর দুটি অবিভাজ্য অপারেশন, P আর V. ১৯৬৫ সালের দিকের তাঁর দীর্ঘ লেখা “Cooperating Sequential Processes”-এ semaphore-এর সাথে bounded buffer, sleeping barber-এর মতো সমস্যাও ছিল। ধারণাটি এত সরল আর শক্তিশালী যে আজও প্রায় প্রতিটি OS-এ এটি আছে।
কিন্তু এই পর্বেই দেখলেন এর দুর্বলতা। Bounded buffer-এ দুটি wait-এর ক্রম উল্টালে deadlock; একটি signal ভুলে গেলে কেউ চিরকাল ঘুমায়; ভুল semaphore-এ signal দিলে অন্য কেউ জাগে। আর এসব ভুল কোডের সব জায়গায় ছড়িয়ে থাকতে পারে — প্রতিটি producer, প্রতিটি consumer নিজে নিজে নিয়ম মানবে, এটিই ভরসা। Semaphore দুটি ভিন্ন কাজে ব্যবহৃত হয় — পারস্পরিক বর্জন (mutex) আর শর্তের অপেক্ষা (empty/full) — অথচ কোডে দুটি দেখতে হুবহু এক। পড়ে বোঝা কঠিন কোনটি কী।
Per Brinch Hansen (১৯৭৩ সালের বই “Operating System Principles”, পরে Concurrent Pascal ভাষা) আর C. A. R. Hoare (১৯৭৪ সালের প্রবন্ধ “Monitors: An Operating System Structuring Concept”) প্রস্তাব করেন: shared ডেটা আর তার ওপরের সব অপারেশন একটি মডিউলে বন্দি থাকুক, আর compiler নিজেই নিশ্চিত করুক যে একবারে একটিই অপারেশন চলে। Mutual exclusion তখন আর প্রোগ্রামারের মনে রাখার বিষয় নয়, ভাষার গঠন। শর্তের অপেক্ষার জন্য আলাদা জিনিস — condition variable — যাতে দুটি কাজ আর গুলিয়ে না যায়। Dining philosophers-এর monitor সমাধান দেখুন: সব state এক জায়গায়, আর test() ফাংশনটি পড়লেই নিয়ম বোঝা যায়।
আজকের Java-র synchronized, C#-এর lock আর Monitor ক্লাস, Go-র “memory ভাগ করে কথা নয়, কথা বলে memory ভাগ” (channel) — সবই এই ধারার উত্তরসূরি। তবে semaphore হারিয়ে যায়নি; “সর্বোচ্চ k জন একসাথে” (যেমন connection pool বা টেবিলে ৪ জন) প্রকাশে এটিই সবচেয়ে স্বাভাবিক।
মূল উপলব্ধি: একটি primitive শক্তিশালী হলেই যথেষ্ট নয়; ভুল করা কঠিন হওয়াও জরুরি। Monitor একই ক্ষমতাকে এমন কাঠামোয় বাঁধে, যেখানে ভুলগুলো কম জায়গায় লুকাতে পারে।
প্র ০২Reader–writer lock কি সবসময় সাধারণ mutex-এর চেয়ে দ্রুত? কখন এটি উল্টো ক্ষতি করে?
সহজ ধারণা বলে, পড়া বেশি হলে rwlock জিতবে — reader-রা একসাথে ঢোকে, mutex তাদের এক এক করে ঢোকায়। কিন্তু বাস্তবে উত্তর প্রায়ই “না”, আর কারণটি হার্ডওয়্যারে। প্রতিটি reader ঢোকার সময় আর বের হওয়ার সময় একটি shared counter (আমাদের read_count) বদলায় — অর্থাৎ lock-এর মেমরিতে লেখে। পর্ব ৪-এর cache coherence মনে করুন: একটি core কোনো cache line-এ লিখলে বাকি core-গুলোর কপি বাতিল হয়, আর line-টি core থেকে core-এ ছোটাছুটি করে। ১৬ বা ৬৪ core-এ সবাই একই counter বাড়াতে-কমাতে থাকলে এই ছোটাছুটিই মূল খরচ হয়ে দাঁড়ায় — পড়ার কাজ নিজে যত ছোটই হোক।
তাই critical section যদি খুব ছোট হয় (একটি hash table-এ একটি খোঁজ), তাহলে rwlock-এর বাড়তি হিসাব (reader গোনা, writer অপেক্ষায় আছে কি না দেখা) mutex-এর চেয়ে বেশি পড়তে পারে, আর একসাথে পড়ার সুবিধা চোখেই পড়ে না। Rwlock সত্যিই কাজে আসে যখন পড়ার কাজ দীর্ঘ (একটি বড় কাঠামো হেঁটে দেখা, ফাইল পড়া) আর লেখা কদাচিৎ। দ্বিতীয় ঝুঁকি ন্যায্যতা: glibc-র ডিফল্ট পাঠক-অগ্রাধিকার, তাই ব্যস্ত সার্ভারে কনফিগারেশন হালনাগাদের মতো writer অনেকক্ষণ আটকে থাকতে পারে — ঠিক এই পর্বের stepper-এর মতো।
এই সমস্যার জবাবে kernel আর উচ্চ-গতির সিস্টেম অন্য পথে গেছে। Seqlock-এ reader কিছুই লেখে না: writer একটি sequence নম্বর বাড়িয়ে লেখে, আর reader পড়ার আগে ও পরে নম্বর মিলিয়ে দেখে — মাঝে বদলালে আবার পড়ে। Linux-এর সময় রাখার কোড এভাবে চলে। RCU (Read-Copy-Update)-এ reader একেবারে বিনা খরচে পড়ে; writer নতুন কপি বানিয়ে একটি pointer বদলায়, আর পুরোনো কপি মুছে ফেলে সব পুরোনো reader বের হওয়ার পর। Paul McKenney-রা ২০০২ সালের দিকে Linux-এ RCU আনেন, আর এখন kernel-এর হাজারো জায়গায় এটি ব্যবহৃত। Per-CPU counter-ও একই ধারণার আরেক রূপ — প্রতিটি core নিজের ঘরে গোনে।
মূল উপলব্ধি: “একসাথে পড়া যায়” তত্ত্বে সত্য, কিন্তু গতি ঠিক করে হার্ডওয়্যার — shared মেমরিতে যেকোনো লেখা ব্যয়বহুল। Lock বাছাইয়ের আগে মেপে দেখুন; অনুমানে নয়।
প্র ০৩পাঁচজন দার্শনিক আর স্প্যাগেটি — একটি খেলনা সমস্যা বড় সফটওয়্যারে কেন এত গুরুত্বপূর্ণ? বাস্তবে lock-এর ক্রম কীভাবে রক্ষা করা হয়?
Dining philosophers আসলে যেকোনো সিস্টেমের সবচেয়ে সাধারণ deadlock-এর ছবি: দুটি থ্রেড, দুটি lock, উল্টো ক্রম। একটি ব্যাংকের কোডে transfer(A, B) আগে A-এর হিসাব lock করে, তারপর B-এর। একই সময়ে আরেকজন transfer(B, A) ডাকলে সে আগে B lock করে। দুজনেই একটি ধরে অন্যটির অপেক্ষায় — দুই দার্শনিকের টেবিল। একে প্রায়ই “ABBA deadlock” বলা হয়। বড় কোডবেসে এই দুটি lock নেওয়া হয়তো দুটি ভিন্ন মডিউলে, ভিন্ন সময়ে লেখা — কোনো একজন প্রোগ্রামার পুরো ছবিটা দেখেন না।
সমাধান এই পর্বের resource ordering: সব lock-এর একটি সর্বজনীন ক্রম ঠিক করা — হিসাব নম্বরের ক্রমে, মেমরি ঠিকানার ক্রমে, বা স্তর অনুযায়ী (আগে ফাইলের lock, তারপর inode-এর)। Linux kernel-এর অনেক উৎস ফাইলের শুরুতে মন্তব্যে লেখা থাকে কোন lock কোনটির আগে নিতে হবে। কিন্তু মন্তব্য কেউ না পড়লে? তাই Ingo Molnar-রা ২০০৬ সালের দিকে (Linux 2.6.18) lockdep আনেন: debug build-এ kernel প্রতিটি lock-শ্রেণি নেওয়ার ক্রম একটি graph-এ লিখে রাখে। কোনো দিন একটি পথে A তারপর B, আর অন্য দিন অন্য পথে B তারপর A নেওয়া হলে — দুটি একসাথে না ঘটলেও — lockdep সতর্ক করে দেয়, কারণ graph-এ বৃত্ত তৈরি হয়েছে। অর্থাৎ বিরল deadlock ঘটার আগেই ধরা পড়ে।
ডেটাবেস ভিন্ন পথ নেয়: transaction-গুলো কোন ক্রমে row lock করবে, তা আগে থেকে জানা যায় না। তাই অনেক DBMS অপেক্ষার একটি wait-for graph রাখে, বৃত্ত দেখলে একটি transaction-কে “বলি” দিয়ে বাতিল করে, আর অ্যাপ্লিকেশনকে আবার চেষ্টা করতে বলে — পর্ব ২৮-এর detection আর recovery-র বাস্তব রূপ।
মূল উপলব্ধি: deadlock প্রতিরোধ মূলত একটি শৃঙ্খলা — সবাই একই ক্রম মানবে — আর বড় সিস্টেমে সেই শৃঙ্খলা মানুষের স্মৃতিতে না রেখে টুল দিয়ে যাচাই করা হয়।
প্র ০৪Cigarette smokers সমস্যা কেন একসময় semaphore-এর “সীমাবদ্ধতার প্রমাণ” বলে দাবি করা হয়েছিল? এ থেকে primitive-এর ক্ষমতা সম্পর্কে কী শিখি?
১৯৭১ সালে MIT-র Suhas Patil সমস্যাটি দেন একটি নির্দিষ্ট দাবি নিয়ে: Dijkstra-র semaphore দিয়ে এটি সমাধান করা যায় না। কিন্তু দাবিটি দাঁড়িয়ে ছিল দুটি কঠিন শর্তে — এজেন্টের কোড বদলানো যাবে না, আর সমাধানে কোনো শর্তযুক্ত বিবৃতি (if) বা semaphore-এর array ব্যবহার করা যাবে না। অর্থাৎ শুধু “wait আর signal-এর একটি সরল ক্রম” দিয়ে। এই শর্তে প্রতিটি ধূমপায়ী জানে না, টেবিলের দুটি উপকরণ কি তার জন্য, নাকি অন্য কারও জন্য — আর একটি তুলে নিলেই অন্যজনকে আটকে দেয়, যেমন এই পর্বে দেখেছেন।
পরে কয়েকজন গবেষক দেখান, শর্তগুলো একটু শিথিল করলে সমস্যাটি সহজ; জানা যায় David Parnas ১৯৭৫ সালের দিকে দেখিয়েছিলেন যে semaphore-এর array ব্যবহার করলে শর্তযুক্ত বিবৃতি ছাড়াই সমাধান সম্ভব। আজকের পাঠ্যবইয়ের সমাধান আরও সরল: সহকারী pusher থ্রেড আর mutex দিয়ে রক্ষিত কয়েকটি বুলিয়ান — অর্থাৎ অবস্থা মনে রাখা। Semaphore একা শুধু “কতটি” গোনে, “কোনটি” নয়; কোন উপকরণ এসেছে তা মনে রাখতে বাড়তি state লাগে।
এই বিতর্কের বড় শিক্ষা হলো, একটি primitive-এর “ক্ষমতা” প্রশ্নটির শর্তের ওপর নির্ভর করে। পরে তত্ত্বে দেখা গেছে, যুক্তিসংগত ব্যবহারে semaphore, monitor আর message passing একে অপরকে দিয়ে বানানো যায় — ক্ষমতায় সমান। পার্থক্য সুবিধায়: কোন সমস্যা কোন primitive-এ স্বাভাবিকভাবে লেখা যায়। “একাধিক ঘটনার যেকোনো একটির অপেক্ষা” আধুনিক সিস্টেমে এজন্য আলাদা ব্যবস্থা পেয়েছে — Unix-এর select/poll/epoll, Windows-এর WaitForMultipleObjects, Go-র select — যা ধূমপায়ীদের সমস্যার মূল জটিলতাকেই সরাসরি সামলায়।
মূল উপলব্ধি: “এটি দিয়ে করা অসম্ভব” দাবি প্রায়ই আসলে “এই নিয়মে, এই সরঞ্জামে করা কঠিন” — আর সেই কঠিনতাই নতুন, আরও উপযুক্ত primitive-এর জন্ম দেয়।
প্র ০৫Buffer “bounded” রাখার দরকার কী? মেমরি তো অনেক — একটি অসীম সারি দিলে producer কখনো থামবে না, সেটিই কি ভালো নয়?
অসীম সারি শুনতে আকর্ষণীয় — producer কখনো অপেক্ষা করে না, কোনো wait(empty) নেই। কিন্তু producer যদি গড়ে consumer-এর চেয়ে সামান্যও দ্রুত হয়, সারি প্রতি সেকেন্ডে বাড়তে থাকে। Little's law (পর্ব ১৬) বলে, সারিতে গড় জিনিস = আগমনের হার × গড় অপেক্ষার সময়; আগমন প্রক্রিয়াকরণের চেয়ে বেশি হলে অপেক্ষার সময় আর সারির দৈর্ঘ্য দুটিই সীমাহীনভাবে বাড়ে। ফল দুটি: প্রথমত, প্রতিটি অনুরোধ এত দেরিতে প্রক্রিয়া হয় যে ব্যবহারকারী ততক্ষণে চলে গেছেন — কাজটিই অর্থহীন। দ্বিতীয়ত, একসময় মেমরি ফুরিয়ে যায়, আর Linux-এর OOM killer (পর্ব ৩৮) প্রসেসটি বন্ধ করে দেয় — সারির সব কাজ একসাথে হারায়।
Bounded buffer ভর্তি হলে producer-কে থামায় — একে বলে backpressure: ধীর অংশের চাপ উল্টো দিকে উৎস পর্যন্ত পৌঁছায়। ঢাকার কোনো মোড়ে সামনের রাস্তা জ্যাম হলে সিগন্যাল লাল রেখে নতুন গাড়ি ঢুকতে না দেওয়ার মতো — মোড়টি অন্তত অচল হয় না। বাস্তব সিস্টেম এই নীতিতে ভরা। TCP-র receive window প্রাপকের খালি জায়গা প্রেরককে জানায়, ভর্তি হলে প্রেরক থামে। Linux pipe-এর buffer সীমিত, তাই yes | head চিরকাল মেমরি খায় না। Kafka-র producer client-এর পাঠানোর buffer সীমিত, ভর্তি হলে send আটকে যায়। Go-র buffered channel, Java-র ArrayBlockingQueue — সবই n ঘরের bounded buffer.
ভর্তি হলে কী করবেন, সেটিও একটি নকশার সিদ্ধান্ত: অপেক্ষা (bounded buffer), নতুনটি ফেরত দেওয়া (sleeping barber, HTTP 503), না পুরোনোটি ফেলে দেওয়া (লাইভ ভিডিওর ফ্রেম, যেখানে পুরোনো তথ্যের দাম নেই)। আর n কত হবে? খুব ছোট হলে গতির সামান্য ওঠানামাতেই producer থামে; খুব বড় হলে সমস্যা দেরিতে ধরা পড়ে আর প্রতিটি জিনিসের অপেক্ষা বাড়ে।
মূল উপলব্ধি: সীমা ছাড়া সারি সমস্যা লুকায়, মেটায় না। Bounded buffer-এর wait(empty) আসলে একটি সুরক্ষা-ব্যবস্থা — সিস্টেম ডুবে যাওয়ার আগেই সে “থামুন” বলে।
অনুশীলন
হাতে হিসাব, সময়রেখা, আউটপুট অনুমান আর কোড লেখা — আগে নিজে চেষ্টা করুন, তারপর উত্তর মেলান।
-
হাতে ট্রেস: n = 5 ঘরের bounded buffer, তিন semaphore-এর পাঠ্যবইয়ের সমাধান, শুরুতে খালি। একজন producer পরপর ৪টি জিনিস রাখল, তারপর consumer ২টি তুলল, তারপর producer আরও ৩টি রাখল। এখন
empty,full,mutex,inআরout-এর মান কত? producer এখন আরেকটি রাখতে চাইলে কী হবে?৪টি রাখার পর: in = ৪, full = ৪, empty = ১. ২টি তোলার পর: out = ২, full = ২, empty = ৩. আরও ৩টি রাখার পর: in = (৪ + ৩) mod ৫ = ২, full = ৫, empty = ০. কেউ critical section-এ নেই, তাই mutex = ১. উত্তর: empty = 0, full = 5, mutex = 1, in = 2, out = 2. যাচাই: রাখা ৭, তোলা ২, buffer-এ ৫ = n; আর empty + full = ৫. in = out হলেও buffer ভর্তি — খালি নয়, যা semaphore-ই বলে দেয়। এখন producer
wait(empty)-এ ঘুমাবে (mutex না নিয়েই), যতক্ষণ না consumer একটি তোলে। -
সময়রেখা — কে কখন ঢোকে: R1 আসে t = 0-তে (পড়তে লাগে ৪ একক), W আসে t = 1-এ (লিখতে লাগে ২), R2 আসে t = 2-তে (পড়া ৪), R3 আসে t = 5-এ (পড়া ৪)। (ক) প্রথম (পাঠক-অগ্রাধিকার) সমাধানে প্রত্যেকে কখন শুরু ও শেষ করে, আর W কতক্ষণ অপেক্ষা করে? (খ) FIFO turnstile-এর ন্যায্য সমাধানে?
(ক) R1: ০–৪. W t = 1-এ আসে, rw_mutex পাঠক-দলের হাতে, অপেক্ষা। R2 t = 2-তে সরাসরি ঢোকে: ২–৬. R3 t = 5-এ, তখন R2 ভেতরে, তাই সেও ঢোকে: ৫–৯. read_count প্রথমবার ০ হয় t = 9-এ। W: ৯–১১, অপেক্ষা ৯ − ১ = ৮ একক. R2 আর R3-এর অপেক্ষা ০.
(খ) W t = 1-এ queue নিয়ে rw_mutex-এর অপেক্ষা করে। R2 (t = 2) queue-তে আটকায়, R3 (t = 5)-ও। t = 4-এ R1 বের হয়, W লেখে ৪–৬ (অপেক্ষা ৩)। W লেখা শুরুর সময় queue ছেড়ে দেয়; R2 queue পেরিয়ে rw_mutex-এর অপেক্ষা করে। t = 6-এ W শেষ, R2 আর তারপর R3 দুজনেই ঢোকে: ৬–১০. অপেক্ষা: R2 = ৪, R3 = ১. মোট অপেক্ষা ৮ বনাম ৮ — সমান! কিন্তু ন্যায্য সমাধানে সর্বোচ্চ অপেক্ষা ৪, পাঠক-অগ্রাধিকারে ৮ — আর পাঠকের স্রোত চলতে থাকলে সেখানে সীমাহীন।
-
আউটপুট অনুমান: অংশ ৩-এর
bounded_buffer.c-তে consumer-এর লুপi < ITEMS-এর বদলেi < ITEMS + 1করা হলো (প্রত্যেক consumer ৬টি জিনিস চায়, producer-রা মোট ১০টি বানায়)। প্রোগ্রাম কী ছাপবে? এটি কি deadlock?কিছুই ছাপবে না। দুই consumer মোট ১২টি চায়, অথচ জিনিস মাত্র ১০টি। ১০টি তোলার পর বাকি দুটি চেষ্টা
sem_wait(&full)-এ ঘুমায়, কারণ full = ০ আর producer-রা শেষ। mainpthread_join-এ চিরকাল অপেক্ষা করে, তাই কোনোprintf-এ পৌঁছায় না; অনলাইন রানার সময়সীমা পেরিয়ে প্রোগ্রাম থামাবে। এটি ক্লাসিক অর্থে deadlock নয় — কোনো বৃত্তাকার অপেক্ষা নেই, কেউ কোনো সম্পদ ধরে বসে নেই; consumer এমন একটি ঘটনার অপেক্ষায়, যা ঘটানোর মতো কেউ আর বেঁচে নেই। ফল অবশ্য একই রকম খারাপ: চিরকাল আটকে থাকা। বাস্তবে এর সমাধান একটি “শেষ” সংকেত — producer-রা শেষে বিশেষ “poison pill” জিনিস রাখে, বা একটিdoneপতাকা আর condition variable-এর broadcast. -
কোড লিখুন: “টেবিলে সর্বোচ্চ ৪ জন” প্রতিকারটি POSIX semaphore আর pthread mutex দিয়ে C-তে লিখুন — শুধু দার্শনিকের থ্রেড-ফাংশন আর দরকারি global ঘোষণা।
#include <pthread.h> #include <semaphore.h> #include <stdint.h> #define P 5 static pthread_mutex_t forks[P]; /* init each with pthread_mutex_init */ static sem_t room; /* in main: sem_init(&room, 0, P - 1); */ static void *philosopher(void *arg) { int i = (int)(intptr_t)arg; for (int k = 0; k < 1000; k++) { sem_wait(&room); /* at most 4 at the table */ pthread_mutex_lock(&forks[i]); /* left */ pthread_mutex_lock(&forks[(i + 1) % P]); /* right */ /* eat */ pthread_mutex_unlock(&forks[(i + 1) % P]); pthread_mutex_unlock(&forks[i]); sem_post(&room); /* leave the table */ } return NULL; }মূল কথা:
room-এর শুরুর মান P − 1 = ৪, আরsem_wait(&room)fork তোলার আগে,sem_post(&room)দুটো ছাড়ার পরে। ক্রম উল্টালে (আগে fork, পরে room) পাঁচজনই fork ধরে ফেলতে পারেন, আর সুরক্ষাটি অর্থহীন হয়ে যায়। Fork-গুলো এখানে নির্দিষ্ট ক্রমে না নিলেও চলে — বৃত্ত ভাঙছে আসন-সংখ্যা।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমতিন semaphore-এর bounded buffer-এ producer-এর একবারের কাজ সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- নতুন জিনিসটি বানানো (critical section-এর বাইরে)
wait(empty)— একটি খালি ঘরের অপেক্ষাwait(mutex)— buffer-এ একা ঢোকাbuf[in]-এ রাখা আরin = (in + 1) % nsignal(mutex)— buffer ছেড়ে দেওয়াsignal(full)— একটি নতুন জিনিসের খবর দেওয়া
৮ ঘরের একটি bounded buffer-এর পাঠ্যবইয়ের সমাধানে তিনটি semaphore mutex, empty আর full-এর শুরুর মান কত?
- 1, 0, 8
- 1, 8, 0
- 0, 8, 0
- 8, 8, 0
Bounded buffer-এর producer যদি wait(empty)-এর আগে wait(mutex) ডাকে, তাহলে কোন অবস্থায় সমস্যা হয়?
- Buffer খালি থাকলে — producer আর consumer দুজনেই আটকে যায়
- কখনো কখনো দুজন একই ঘরে লিখে ফেলে
- Buffer ভর্তি থাকলে — producer mutex ধরে ঘুমায়, consumer ঢুকতে না পেরে deadlock হয়
- কোনো সমস্যা হয় না, শুধু একটু ধীর হয়
Readers–writers-এর প্রথম সমাধানে (read_count, mutex, rw_mutex; প্রথম reader rw_mutex নেয়, শেষ reader ছাড়ে) কোন সমস্যা থেকে যায়?
- Reader-রা একটানা আসতে থাকলে writer starve করতে পারে
- Reader কখনো কখনো writer-এর আধা-লেখা ডেটা পড়ে
- Reader আর writer একসাথে থাকলে deadlock অবশ্যম্ভাবী
- দুজন writer একসাথে লিখতে পারে
Dining philosophers-এ “প্রত্যেকে দুটো fork-এর মধ্যে ছোট নম্বরেরটি আগে নেবেন” নিয়মটি deadlock-এর চার শর্তের কোনটি ভাঙে?
- Mutual exclusion
- Hold and wait
- No preemption
- Circular wait
Dining philosophers-এর monitor সমাধানে (state THINKING/HUNGRY/EATING, দুই প্রতিবেশী না খেলে তবেই EATING) কোনটি সত্য?
- Deadlock আর starvation দুটোই অসম্ভব
- Deadlock হয় না, কিন্তু একজন দার্শনিক starve করতে পারেন
- পাশাপাশি দুজন একসাথে খেতে পারেন
- সবাই একসাথে ক্ষুধার্ত হলে deadlock হয়
একটি counting semaphore S-এর শুরুর মান 7. বিভিন্ন থ্রেড মোট 20টি wait (P) আর 15টি signal (V) অপারেশন করল, আর শেষে কোনো থ্রেড অপেক্ষায় নেই। S-এর শেষ মান কত?
- −13
- 5
- 2
- 12
Deadlock আর starvation-এর পার্থক্য কোন বাক্যে সঠিকভাবে বলা হয়েছে?
- Deadlock-এ একটি দলের সবাই চিরতরে আটকে যায়; starvation-এ সিস্টেম চলে, কিন্তু কেউ একজন অনির্দিষ্টকাল সুযোগ পায় না
- দুটি একই জিনিসের দুই নাম
- Starvation-এ সবাই আটকে যায়, deadlock-এ শুধু একজন
- Starvation শুধু CPU scheduling-এ হয়, lock-এ কখনো নয়
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Bounded buffer: mutex = 1, empty = n, full = 0; producer
wait(empty)তারপরwait(mutex), consumerwait(full)তারপরwait(mutex). Invariant: empty + full = n. - তালা ধরে ঘুমাবেন না এমন কিছুর জন্য, যা ঘটাতে অন্যকে সেই তালাই লাগবে — এর ক্লাসিক উদাহরণ উল্টো ক্রমের wait. Condition variable-এ wait সবসময়
while-এ। - Readers–writers: প্রথম সমাধানে writer starve করে, দ্বিতীয়তে reader; FIFO turnstile ন্যায্য। বাস্তবের rwlock-এর অগ্রাধিকার-নীতি প্ল্যাটফর্মভেদে আলাদা — জেনে নিন।
- Dining philosophers: সবাই বাঁ fork আগে = বৃত্তাকার অপেক্ষা। প্রতিকার: সর্বোচ্চ ৪ জন, বিজোড়-জোড়, monitor-এ দুটো একসাথে, আর সবচেয়ে সাধারণ — lock ordering.
- Deadlock ≠ starvation ≠ livelock: কেউ এগোয় না / একজন বঞ্চিত / সবাই ব্যস্ত কিন্তু কাজ হয় না। আর buffer সবসময় bounded — backpressure সিস্টেমকে বাঁচায়।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ২৭ · Deadlock — চারটি শর্ত, resource graph আর প্রতিরোধপরবর্তী পর্বDining philosophers-এর বৃত্তের পেছনের তত্ত্ব।
- অপারেটিং সিস্টেম · কোর্স হোম৬৪টি পর্বপুরো কোর্সের রোডম্যাপ — মডিউল ৪-এ কনকারেন্সি আর সিনক্রোনাইজেশন।
- পর্ব ১২ · Inter-process communicationপেছনে ফিরে দেখাKernel-এর bounded mailbox আর pipe — একই producer-consumer, kernel-এর হাতে।
- DBMS · Concurrency controlএকই সমস্যা, অন্য জগৎShared/exclusive lock, two-phase locking আর ডেটাবেসের deadlock.
- C প্রোগ্রামিং · Multithreadingহাতে-কলমেC-র দিক থেকে থ্রেড, mutex আর condition variable-এর আরও অনুশীলন।