Synchronization Primitives — mutex, semaphore, condition variable: কোনটা ঠিক কীসের জন্য
Synchronization Primitives
একাধিক thread একই ডেটায় হাত দিলে কী ভাঙে, আর সেই ভাঙা ঠেকানোর যন্ত্রপাতিগুলো ঠিক কী কী — mutex (মালিকানা আছে), semaphore (শুধু একটা counter, মালিকানা নেই), condition variable (সবসময় mutex-এর সঙ্গে, সবসময় while loop-এ), read-write lock, barrier, আর spinlock। প্রতিটার জন্য কোন সমস্যাটা এটা সমাধান করে আর কোনটা করে না — সেটাই এই লেসনের কেন্দ্র। শেষে mutex + দুইটা condvar দিয়ে একটা সঠিক bounded blocking queue বানিয়ে লেসন ২৪-এর অসমাপ্ত ring buffer শেষ করব।
আগে এটা বুঝি
গত লেসনে signal নিয়ে কাজ করার সময় একটা নিয়ম বারবার এসেছিল — signal handler-এ শুধু async-signal-safe জিনিস করা যায়, volatile sig_atomic_t ছাড়া কোনো global-এ হাত দেওয়া যায় না। কারণটা তখন সংক্ষেপে বলা হয়েছিল: handler আপনার main flow-এর মাঝখানে, যেকোনো instruction-এর ফাঁকে ঢুকে পড়তে পারে, তাই যেকোনো আধা-সম্পন্ন আপডেট সে দেখে ফেলতে পারে।
এখন সেই একই সমস্যাটার সাধারণ রূপটা দেখব। Signal ছিল একটা special case — একটাই CPU, একটাই thread, শুধু নিয়ন্ত্রণ-প্রবাহ মাঝপথে সরে যাচ্ছে। কিন্তু আপনার মেশিনে ৮টা core আছে, আর লেসন ১০-এ দেখা pthread_create() দিয়ে আপনি ৮টা thread একসাথে চালাতে পারেন — সত্যিকারের সমান্তরালে, একই address space-এ, একই global variable-এ। সমস্যাটা তখন আর “মাঝপথে ঢুকে পড়া” থাকে না, হয়ে যায় “একই সময়ে, আক্ষরিক অর্থে”।
এই প্রোগ্রামটা লিখে দেখুন:
long counter = 0;
void *worker(void *arg) {
for (int i = 0; i < 1000000; i++) counter++;
return NULL;
}
/* main: ৪টা thread তৈরি, join, তারপর counter ছাপা */চারটা thread, প্রত্যেকে দশ লাখ বার counter++। প্রত্যাশিত উত্তর 4000000। আমার মেশিনে (Intel i7-1165G7, ৪ core, glibc 2.35) পরপর পাঁচবার চালিয়ে যা পেলাম:
1180436
1372901
1099752
1451318
1029684শুধু ভুল না — প্রতিবারই আলাদাভাবে ভুল, আর সবসময় কম। ৪০ লাখের প্রায় ৭০% আপডেট বাতাসে হারিয়ে গেছে। কোনো crash নেই, কোনো error message নেই, valgrind কিছু বলে না, compiler কোনো warning দেয় না। প্রোগ্রামটা নীরবে ভুল উত্তর দিয়ে সফলভাবে exit করে — যেটা সব ধরনের bug-এর মধ্যে সবচেয়ে বিপজ্জনক শ্রেণি।
এই লেসন সেই সমস্যার সমাধানের যন্ত্রপাতি নিয়ে। সংখ্যায় খুব বেশি না — মোটে ছয়টা primitive, আর তাদের মধ্যে সত্যিকারের মৌলিক মাত্র দুইটা। কিন্তু কোনটা কীসের জন্য, আর বিশেষ করে কোনটা কীসের জন্য নয় — সেই পার্থক্যটাই একজন engineer-কে concurrent কোড লিখতে পারা আর না-পারার মধ্যে ভাগ করে দেয়।
মূল ধারণা
Critical section problem — সমস্যাটার আনুষ্ঠানিক রূপ
উপরের counter++ কেন হারায়, সেই মেকানিজম পরের লেসনের বিষয় (সংক্ষেপে: এটা একটা instruction না, তিনটা)। এখানে আমরা সমস্যাটার আকারটা ঠিক করে নিই, কারণ সব primitive-ই এই একই আকারের সমস্যার উত্তর।
একটা critical section হলো কোডের এমন একটা অংশ যেখানে shared state পড়া-লেখা হচ্ছে এবং যেটা একবারে একটার বেশি thread চালালে invariant ভেঙে যায়। সমাধান মানে এমন একটা entry/exit protocol যা তিনটা শর্ত মানে — এই তিনটা Dijkstra-র ১৯৬৫ সালের formulation থেকে আসা, আর আজও ঠিক এগুলোই মাপকাঠি:
| শর্ত | মানে | ভাঙলে কী হয় |
|---|---|---|
| Mutual exclusion | কোনো সময়ে সর্বোচ্চ একটা thread critical section-এ থাকবে | ডেটা corrupt — উপরের হারানো counter |
| Progress | কেউ critical section-এ না থাকলে, ঢুকতে চাওয়া thread-দের মধ্য থেকে সিদ্ধান্তটা সসীম সময়ে হতে হবে; যারা ঢুকতে চায় না তারা সিদ্ধান্তে বাধা দিতে পারবে না | deadlock — কেউ ঢুকতে পারছে না অথচ ভেতরে কেউ নেই |
| Bounded waiting | একটা thread ঢোকার অনুরোধ করার পর, অন্যরা সর্বোচ্চ কতবার আগে ঢুকতে পারবে তার একটা সসীম সীমা থাকতে হবে | starvation — কেউ ঢুকছে, কিন্তু একই দুর্ভাগা thread কখনো না |
একটা অনানুষ্ঠানিক চতুর্থ শর্তও বাস্তবে সমান গুরুত্বপূর্ণ — performance। যে সমাধান তিনটা শর্তই মানে কিন্তু প্রতিবার ১০ মাইক্রোসেকেন্ড খরচ করে, সেটা কাগজে সঠিক আর বাস্তবে অকেজো। পুরো লেসনের অর্ধেকটা আসলে এই চতুর্থ শর্তের গল্প।
Mutex — মালিকানা-সহ lock
Mutex (MUTual EXclusion) সবচেয়ে সরল আর সবচেয়ে বেশি ব্যবহৃত primitive। দুইটা অপারেশন: lock আর unlock। কিন্তু mutex-এর যে বৈশিষ্ট্যটা তাকে অন্য সবার থেকে আলাদা করে, সেটা API-তে দেখা যায় না — ownership।
একটা locked mutex-এর একজন মালিক আছে: যে thread তাকে lock করেছে। আর নিয়মটা হলো — শুধু মালিকই unlock করতে পারে। POSIX-এ অন্য thread থেকে unlock করা undefined behaviour; PTHREAD_MUTEX_ERRORCHECK type ব্যবহার করলে EPERM ফেরত আসে।
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&m);
counter++; /* critical section */
pthread_mutex_unlock(&m);Ownership কোনো শৌখিন নিয়ম নয় — এর উপর তিনটা বাস্তব সুবিধা দাঁড়িয়ে আছে:
- Error checking। Runtime জানে কে ধরে আছে, তাই “নিজের ধরা lock আবার lock করার” চেষ্টা (self-deadlock) ধরা যায়, “না-ধরা lock unlock করা” ধরা যায়।
- Priority inheritance। কম-priority thread একটা mutex ধরে আছে, আর বেশি-priority thread সেটার জন্য অপেক্ষা করছে — kernel জানে মালিক কে, তাই মালিকের priority সাময়িকভাবে বাড়িয়ে দিতে পারে। মালিক না জানলে এটা অসম্ভব। (এটাই ১৯৯৭-এ Mars Pathfinder-এর বিখ্যাত reset-loop-এর সমাধান ছিল — নিচে realworld সেকশনে বিস্তারিত।)
- Deadlock detection। কে কোন lock ধরে আছে আর কার জন্য অপেক্ষা করছে — এই দুইটা জানলে wait-for graph বানানো যায়। পরের লেসনের build ঠিক এটাই করবে।
POSIX mutex-এর কয়েকটা type আছে, আর ডিফল্টটা সবচেয়ে কম সহনশীল:
| Type | নিজের ধরা lock আবার lock করলে | না-ধরা lock unlock করলে |
|---|---|---|
PTHREAD_MUTEX_NORMAL (glibc-র ডিফল্ট) | deadlock — চিরতরে ঝুলে যায় | undefined behaviour |
PTHREAD_MUTEX_ERRORCHECK | EDEADLK ফেরত | EPERM ফেরত |
PTHREAD_MUTEX_RECURSIVE | count বাড়ে, সফল হয় | EPERM ফেরত |
Semaphore — শুধু একটা counter, কোনো মালিক নেই
Semaphore এসেছে Dijkstra-র কাছ থেকে (১৯৬৫, THE operating system), আর ধারণাগতভাবে এটা mutex-এর চেয়েও সরল — এটা একটা অঋণাত্মক পূর্ণসংখ্যা যার উপর দুইটা atomic অপারেশন সংজ্ঞায়িত:
| অপারেশন | Dijkstra-র নাম | POSIX নাম | কাজ |
|---|---|---|---|
| Down / acquire | P (proberen) | sem_wait() | মান শূন্য হলে অপেক্ষা করো; নাহলে ১ কমাও |
| Up / release | V (verhogen) | sem_post() | ১ বাড়াও; কেউ অপেক্ষা করলে একজনকে জাগাও |
লক্ষ করুন কী নেই — কোনো মালিকানা নেই। যেকোনো thread sem_post() করতে পারে, তার আগে কখনো sem_wait() করেছে কি না তার কোনো সম্পর্ক নেই। এটা দুর্বলতা নয়, এটাই semaphore-এর মূল শক্তি: semaphore একটা signalling primitive, mutual-exclusion primitive না।
দুইটা সম্পূর্ণ ভিন্ন ব্যবহার এই একই যন্ত্র দিয়ে হয়:
(ক) Counting semaphore — সীমিত সম্পদের পুল। আপনার কাছে ৫টা database connection আছে, ২০টা thread সেগুলো চায়:
sem_t pool;
sem_init(&pool, 0, 5); /* ৫টা resource */
sem_wait(&pool); /* একটা নাও, না থাকলে অপেক্ষা */
use_connection();
sem_post(&pool); /* ফেরত দাও */(খ) Signalling — এক thread থেকে আরেক thread-এ ঘটনার খবর। এখানেই mutex সম্পূর্ণ অক্ষম:
sem_t ready;
sem_init(&ready, 0, 0); /* শুরুতে শূন্য */
/* thread A */ /* thread B */
prepare_data(); sem_wait(&ready); /* অপেক্ষা */
sem_post(&ready); consume_data();Thread A ঘটনার খবর দিচ্ছে, thread B খবরটা পাচ্ছে। কোনো mutual exclusion এখানে জড়িতই নয়। এটা mutex দিয়ে করা যায় না — কারণ B যদি A-র lock unlock করতে চায় তো সেটা ownership নিয়ম ভাঙে।
কেন binary semaphore মোটেও mutex নয়
সবচেয়ে বেশি ছড়ানো ভুল ধারণাটা হলো — “১ দিয়ে initialize করা semaphore = mutex”। উপর থেকে দেখতে একই: sem_wait = lock, sem_post = unlock, একবারে একজন ঢুকতে পারে। কিন্তু চারটা জায়গায় এরা আলাদা, আর প্রতিটাই বাস্তব:
| Mutex | Binary semaphore | |
|---|---|---|
| Ownership | আছে — শুধু locker unlock করতে পারে | নেই — যে কেউ post করতে পারে |
| Priority inheritance | সম্ভব (মালিক জানা) | অসম্ভব (মালিক বলে কিছু নেই) |
| Self-deadlock ধরা | সম্ভব (EDEADLK) | অসম্ভব — শুধু ঝুলে যায় |
| অতিরিক্ত release | ত্রুটি (EPERM) | নীরবে মান ২ করে ফেলে — mutual exclusion ভেঙে যায় |
শেষ সারিটা সবচেয়ে বিপজ্জনক। একটা binary semaphore-এ ভুল করে দুইবার sem_post() করলে তার মান হয়ে যায় ২, এবং তারপর দুইটা thread একসাথে critical section-এ ঢুকে পড়ে — কোনো error, কোনো warning ছাড়া। Mutex-এ একই ভুল সাথে সাথে ধরা পড়ে। এজন্যই নিয়মটা সহজ: mutual exclusion চাইলে mutex, thread-এর মধ্যে signalling চাইলে semaphore। নাম দুইটা যেন ব্যবহার ঠিক করে দেয়।
Condition variable — “একটা শর্ত সত্য হওয়া পর্যন্ত অপেক্ষা করো”
Mutex উত্তর দেয় “কে ঢুকবে” প্রশ্নের। কিন্তু বাস্তব কোডে প্রায়ই প্রশ্নটা ভিন্ন: “queue-টা খালি, আমি ঢুকতে চাই না — আমি চাই queue-তে কিছু ঢোকা পর্যন্ত ঘুমাতে।” এটাই condition variable-এর কাজ।
একটা condvar কোনো state ধরে রাখে না — এটা শুধু একটা wait queue। তিনটা অপারেশন:
pthread_cond_wait(&cv, &mutex); /* mutex ছেড়ে ঘুমাও; জাগলে mutex আবার ধরো */
pthread_cond_signal(&cv); /* অপেক্ষমাণদের মধ্যে অন্তত একজনকে জাগাও */
pthread_cond_broadcast(&cv); /* সবাইকে জাগাও */সবচেয়ে গুরুত্বপূর্ণ অংশটা pthread_cond_wait()-এর দ্বিতীয় প্যারামিটার। একটা condvar সবসময় একটা mutex-এর সঙ্গে জোড়া, কখনো একা নয়, আর wait() তিনটা কাজ করে:
- mutex টা ছেড়ে দেয়,
- নিজেকে condvar-এর wait queue-তে ঢোকায় — ধাপ ১ আর ২ atomically,
- জেগে ওঠার পর ফেরত দেওয়ার আগে mutex আবার ধরে।
ধাপ ১ আর ২-এর atomicity-ই পুরো design-এর ভিত্তি। যদি ওরা আলাদা হতো, তাহলে mutex ছাড়ার আর queue-তে ঢোকার ফাঁকে অন্য thread শর্তটা সত্য করে signal() পাঠিয়ে দিতে পারত — আর সেই signal কেউ শুনত না, কারণ আপনি তখনো queue-তে ঢোকেননি। ফলাফল: চিরকালের ঘুম। একে বলে lost wakeup।
wait সবসময় while-এ, কখনো if-এ — দুইটা স্বাধীন কারণ
এটা এই লেসনের সবচেয়ে গুরুত্বপূর্ণ একক নিয়ম:
pthread_mutex_lock(&m);
while (!predicate()) /* while — if নয়, কখনোই নয় */
pthread_cond_wait(&cv, &m);
/* এখানে predicate() নিশ্চিতভাবে সত্য, আর mutex আমার হাতে */
pthread_mutex_unlock(&m);কারণ দুইটা, এবং এরা সম্পূর্ণ স্বাধীন — একটা জানলেও অন্যটা মিস হয়ে যেতে পারে:
কারণ ১ — spurious wakeup। POSIX স্পেসিফিকেশন আক্ষরিকভাবে অনুমতি দেয় যে pthread_cond_wait() কোনো signal ছাড়াই ফেরত আসতে পারে। এটা কোনো bug না, এটা ইচ্ছাকৃত ছাড় — এতে implementation দ্রুততর হয় (Linux-এ একটা signal handler বা process migration futex wait ভেঙে দিতে পারে, আর তখন ফের ঘুমানোর চেয়ে কল ফেরত দেওয়া সস্তা)। স্পেসিফিকেশনের ভাষায়: “spurious wakeups… may occur”।
কারণ ২ — reacquire race (এটাই বেশি ঘটে, অথচ কম আলোচিত)। ধরুন signal সত্যিই এসেছে, কোনো spurious কিছু না। তবু:
Consumer C1 Producer P Consumer C2
(cond_wait-এ ঘুমন্ত)
| | |
| lock(m) |
| queue-তে ১টা item রাখল |
| signal(cv) ──────┐ |
| unlock(m) | |
| | lock(m) ← C2 আগে পেল!
| ← জেগে উঠল, কিন্তু | item নিয়ে নিল
| mutex-এর জন্য অপেক্ষা করছে | unlock(m)
| | |
lock(m) পেল ✔ | |
কিন্তু queue এখন আবার খালি! |signal() শুধু বলে “যাও, চেষ্টা করে দেখো” — এটা কোনো প্রতিশ্রুতি দেয় না যে জেগে ওঠার সময় শর্তটা এখনো সত্য থাকবে। জেগে ওঠা thread-কে mutex আবার ধরতে হয়, আর সেই ধরার প্রতিযোগিতায় সে হেরে যেতে পারে অন্য কারো কাছে, যে ইতিমধ্যে item-টা নিয়ে চলে গেছে। একে বলা হয় Mesa semantics — প্রায় সব বাস্তব সিস্টেম (POSIX, Java, C++, Go, .NET) এটাই ব্যবহার করে। এর বিকল্প Hoare semantics (signal দিলে signaler সাথে সাথে CPU ছেড়ে waiter-কে দেয়, তাই শর্ত অবশ্যই সত্য থাকে) তাত্ত্বিকভাবে সুন্দর কিন্তু বাস্তবায়ন ব্যয়বহুল — বাস্তবে কেউ ব্যবহার করে না।
তাই while লেখা শুধু “সাবধানতা” না — Mesa semantics-এ এটাই একমাত্র সঠিক লেখা। মনে রাখার সূত্র: cond_wait() মানে “predicate সত্য হয়েছে” নয়, মানে “predicate আবার চেক করার মতো কিছু একটা ঘটেছে”।
signal বনাম broadcast
signal() | broadcast() | |
|---|---|---|
| কতজন জাগে | অন্তত একজন | সবাই |
| খরচ | কম | বেশি — thundering herd |
| কখন অবশ্যই broadcast | একই condvar-এ ভিন্ন predicate-এর waiter থাকলে; একটা event একাধিক waiter-কে এগোতে দিলে (যেমন rwlock-এ writer ছাড়লে সব reader) |
নিরাপদ ডিফল্ট নিয়ম: সব waiter একই predicate-এর জন্য অপেক্ষা করলে, আর একটা event একজনকেই এগোতে দিলে — signal()। সন্দেহ হলে broadcast()। broadcast() ধীর কিন্তু কখনো ভুল না; signal() দ্রুত কিন্তু ভুল জায়গায় ব্যবহার করলে thread চিরতরে ঘুমিয়ে থাকতে পারে। এই কারণেই আমাদের bounded queue-তে দুইটা condvar থাকবে — একটা “খালি জায়গা আছে”-র জন্য, একটা “item আছে”-র জন্য — যাতে signal() নিরাপদে ব্যবহার করা যায়।
Read-write lock — অসম অ্যাক্সেসের সুযোগ নেওয়া
অনেক ডেটা স্ট্রাকচার ৯৫% পড়া হয়, ৫% লেখা (রাউটিং টেবিল, কনফিগ ক্যাশ, DNS ক্যাশ)। সব পাঠককে একে একে ঢোকানো নিছক অপচয় — পাঠকরা একে অপরের কোনো ক্ষতি করে না।
pthread_rwlock_rdlock(&rw); /* একসাথে অনেকে */
pthread_rwlock_wrlock(&rw); /* একা, exclusive */
pthread_rwlock_unlock(&rw); /* দুইটার জন্যই একই unlock */নিয়ম: N জন reader একসাথে, অথবা ১ জন writer একা — কখনো মিশ্রণ নয়।
Writer starvation এখানে আসল বিপদ। Reader-preferring implementation-এ, একজন writer অপেক্ষা করার সময় নতুন reader ঢুকতে দেওয়া হয় — উচ্চ read-লোডে reader-এর সংখ্যা কখনো শূন্য হয় না, তাই writer কখনো ঢুকতে পারে না। এটা সরাসরি bounded waiting শর্তের লঙ্ঘন।
glibc-তে এটা একটা attribute দিয়ে নিয়ন্ত্রণযোগ্য:
pthread_rwlockattr_t attr;
pthread_rwlockattr_init(&attr);
pthread_rwlockattr_setkind_np(&attr,
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP);
pthread_rwlock_init(&rw, &attr);ডিফল্ট PTHREAD_RWLOCK_PREFER_READER_NP — অর্থাৎ glibc-র ডিফল্ট আচরণে writer starve করতে পারে। এটা জানা থাকা দরকার, কারণ ডিফল্টেই সবাই ব্যবহার করে।
Barrier — সবাই এখানে পৌঁছানো পর্যন্ত
Barrier একটা সম্পূর্ণ ভিন্ন আকারের সমস্যার উত্তর — mutual exclusion না, phase synchronization। N-টা thread একটা barrier-এ wait() করে; প্রথম N−1 জন ব্লক হয়; N-তম জন এলে সবাই একসাথে ছাড়া পায়।
pthread_barrier_t b;
pthread_barrier_init(&b, NULL, 4); /* ৪টা thread */
/* প্রতিটা thread-এ */
compute_phase_1();
pthread_barrier_wait(&b); /* সবাই phase 1 শেষ করা পর্যন্ত */
compute_phase_2(); /* এখন phase 1-এর সব ফলাফল নিরাপদে পড়া যাবে */এটা iterative সংখ্যাগত হিসাবের মূল কাঠামো — matrix multiplication, stencil computation, physics simulation, আর neural network training-এর প্রতিটা layer-এর সীমানা। MPI-র MPI_Barrier আর CUDA-র __syncthreads() ঠিক একই ধারণা, ভিন্ন স্কেলে। একটা barrier আসলে mutex + condvar + একটা counter দিয়ে ১৫ লাইনে লেখা যায় — এই লেসনের build-এর একটা extension হিসেবে সেটাই করতে বলব।
Spinlock বনাম sleeping lock — অপেক্ষার দুই কৌশল
Lock না পেলে কী করবেন? দুইটা কৌশল:
| Spinlock | Sleeping lock (mutex) | |
|---|---|---|
| অপেক্ষার সময় | লুপে ঘুরে বারবার চেষ্টা | kernel-কে বলে ঘুমিয়ে পড়ে |
| CPU খরচ | পুরো core, পুরো সময় | প্রায় শূন্য |
| Lock পাওয়ার latency | খুব কম (~২০–১০০ ns) | বেশি (~১–৫ µs, দুইটা context switch) |
| Kernel-এ ঢোকে? | কখনো না | contention হলে হ্যাঁ |
| একটাই core হলে | বিপর্যয় — lock-ধারী চলতেই পারে না | ঠিকঠাক |
সিদ্ধান্ত-নিয়মটা এক লাইনে বলা যায় — critical section-এর দৈর্ঘ্য যদি দুইটা context switch-এর খরচের চেয়ে কম হয়, তবেই spin করো। লেসন ১১-এ context switch-এর খরচ মেপেছিলাম: সরাসরি খরচ ১–৩ µs, TLB আর cache pollution ধরলে কার্যকর খরচ ৫–২০ µs।
সংখ্যাগুলো বসালেই ছবিটা পরিষ্কার:
অর্থাৎ spin জেতে যখন critical section কয়েক শ ন্যানোসেকেন্ডের কম — একটা counter বাড়ানো, একটা linked-list node যোগ করা, একটা pointer বদলানো। এর বাইরে যেকোনো কিছুতে sleeping lock জেতে।
কিন্তু userspace-এ একটা মারাত্মক অতিরিক্ত সমস্যা আছে যা kernel-এ নেই: preemption। আপনার thread lock ধরে আছে, তারপর scheduler তাকে preempt করল (লেসন ১২-র CFS/EEVDF মনে করুন — একটা timeslice সাধারণত ১–১০ ms)। অন্য thread এখন সেই lock-এর জন্য spin করছে — পুরো একটা timeslice ধরে, ১ মিলিসেকেন্ড ধরে একটা core পুড়িয়ে, এমন একটা lock-এর জন্য যার মালিক এই মুহূর্তে CPU-তেই নেই। এটাকে বলে lock convoy, আর এটাই কারণ যে:
Userspace-এ pure spinlock প্রায় কখনোই সঠিক পছন্দ নয়। Kernel-এ spinlock ঠিক আছে, কারণ kernel spinlock ধরার আগে preemption বন্ধ করে দেয় (
preempt_disable()) — userspace-এ সেই সুযোগ আপনার নেই।
Adaptive mutex এই দুই জগতের সেরাটা নেওয়ার চেষ্টা: প্রথমে অল্প কিছুক্ষণ spin করো (glibc-তে PTHREAD_MUTEX_ADAPTIVE_NP, Go runtime-এ ৩০ বার, JVM-এ HotSpot-এর adaptive spinning), তারপরও না পেলে ঘুমাও। যুক্তি: বেশিরভাগ critical section ছোট, তাই সাধারণত অল্প spin-এই কাজ হয়ে যায়; আর যেগুলো ছোট না, সেগুলোতে অল্প কিছু cycle নষ্ট করে ঘুমিয়ে পড়া যায়। Linux kernel-এর mutex আরও এক ধাপ এগিয়ে MCS-based optimistic spinning ব্যবহার করে — এবং spin করার আগে চেক করে lock-এর মালিক এই মুহূর্তে সত্যিই কোনো CPU-তে চলছে কি না (owner->on_cpu); না চললে spin করাই অর্থহীন, তাই সরাসরি ঘুমিয়ে পড়ে। এটা একটা অসাধারণ সুন্দর heuristic।
Lock granularity — কতটুকু একসাথে আটকাবেন
শেষ ধারণা, আর ডিজাইনের দিক থেকে সবচেয়ে ফলপ্রসূ। ধরুন একটা hash table, ১০২৪টা bucket:
| কৌশল | Lock সংখ্যা | সুবিধা | অসুবিধা |
|---|---|---|---|
| Coarse — পুরো টেবিলে একটা lock | ১ | লেখা সহজ, deadlock প্রায় অসম্ভব, memory কম | সব thread একই lock-এ প্রতিযোগিতা করে; ১৬ core-এও ১ core-এর throughput |
| Fine — প্রতি bucket-এ একটা lock | ১০২৪ | চমৎকার scalability — ভিন্ন bucket-এ thread-রা একে অপরকে ছোঁয় না | ১০২৪ × ৪০ byte = ৪০ KB শুধু lock-এ; আর দুইটা bucket একসাথে দরকার হলে (resize, বা একটা atomic move) deadlock-এর দরজা খুলে গেল |
Striped — ১৬টা lock, bucket_index % 16 | ১৬ | দুইয়ের মধ্যবর্তী, বাস্তবে সবচেয়ে ব্যবহৃত | কিছু মিথ্যা contention (ভিন্ন bucket একই lock ভাগ করছে) |
Java-র ConcurrentHashMap জাভা ৭ পর্যন্ত ঠিক এই striped কৌশলই ব্যবহার করত (ডিফল্ট ১৬টা segment)। জাভা ৮-এ তারা per-bucket CAS + শুধু collision-এর সময় lock-এ চলে গেছে — আরও সূক্ষ্ম, আরও জটিল, আর মাপা-প্রমাণিতভাবে দ্রুততর।
গুরুত্বপূর্ণ যে সত্যটা এই টেবিলটা লুকিয়ে রাখে: fine-grained locking শুধু “দ্রুততর” নয়, এটা “বেশি বিপজ্জনক”ও। এক lock ধরে আরেকটা নিতে গেলেই lock-ordering-এর প্রশ্ন আসে, আর সেখান থেকেই deadlock। সেটাই পরের লেসনের পুরো বিষয়বস্তু।
ভেতরে কী ঘটছে
pthread_mutex_lock() ডাকলে আসলে কী কী স্তর পার হয়
Primitive-গুলোকে যাদুকরী মনে হয় যতক্ষণ না নিচে তাকান। নিচে তাকালে দেখা যায় পুরো স্তূপটা মাত্র দুইটা জিনিসের উপর দাঁড়িয়ে — একটা atomic instruction, আর kernel-এ একটা wait queue।
- pthread_mutex_lock(&m)আপনার C কোড -- একটা সাধারণ ফাংশন কল, কোনো syscall না
- glibc: __pthread_mutex_lock (nptl/pthread_mutex_lock.c)mutex type দেখে, তারপর lll_lock -- low level lock ম্যাক্রো
- atomic_compare_exchange_weak(&m->__data.__lock, 0, 1)একটাই atomic instruction। সফল হলে এখানেই শেষ -- kernel-এ ঢোকাই হলো না (uncontended path, ~১৫-২৫ ns)
- contention হলে: futex(&lock, FUTEX_WAIT, 1, NULL)শুধু তখনই syscall -- kernel-কে বলা: এই ঠিকানার মান এখনো ১ হলে আমাকে ঘুম পাড়াও
- kernel: futex_wait() -> futex hash bucket -> schedule()task-কে TASK_INTERRUPTIBLE করে wait queue-তে ঢোকায়, তারপর scheduler অন্য কাউকে চালায়
- CPU: lock cmpxchg -- cache line exclusive দখল (MESI)atomicity আসলে cache coherence protocol-এর গ্যারান্টি, কোনো global bus lock না -- Level 11-এ পুরোটা
দুইটা জিনিস এখান থেকে তুলে নেওয়ার মতো, আর দুইটাই বিস্ময়কর:
এক — uncontended lock kernel-এ ঢোকেই না। যদি lock খালি থাকে, পুরো pthread_mutex_lock() মানে একটা lock cmpxchg instruction আর কিছু bookkeeping। কোনো syscall না, কোনো mode switch না। এজন্যই uncontended mutex-এর খরচ ~২০ ন্যানোসেকেন্ড, ~২ মাইক্রোসেকেন্ড না। লেসন ২৮-এ এই design-টা (futex) পুরোপুরি খুলে দেখব আর নিজে বানাব।
দুই — নিচে গিয়ে সবকিছু একটা atomic instruction। Mutex, semaphore, condvar, rwlock, barrier — প্রতিটাই শেষমেশ lock cmpxchg (x86) বা LDAXR/STLXR (ARM64) আর একটা wait queue-র সমন্বয়। ছয়টা primitive, দুইটা উপাদান।
pthread_cond_wait()-এর ভেতরে atomic ছাড়াটা কীভাবে হয়
condvar-এর “mutex ছাড়া আর queue-তে ঢোকা atomically” — এটা বাস্তবে কীভাবে হয়? glibc-র NPTL-এ (nptl/pthread_cond_wait.c) সরলীকৃত ক্রমটা এরকম:
1. condvar-এর ভেতরের ঠিকানা থেকে বর্তমান "sequence number" পড়ো → seq
2. mutex unlock করো
3. futex(&cond->__wseq, FUTEX_WAIT, seq, NULL) -- "মান এখনো seq হলে ঘুমাও"
4. জেগে উঠলে mutex আবার lock করো
5. caller-এ ফেরত যাওচতুরত্বটা ধাপ ৩-এ। ধাপ ২ আর ৩-এর মাঝখানে অন্য thread যদি signal() করে, সে sequence number বাড়িয়ে দেয় — তখন FUTEX_WAIT দেখে মান আর seq নয়, তাই সাথে সাথে EAGAIN নিয়ে ফেরত আসে, ঘুমায়ই না। এই compare-and-sleep atomicity-ই lost wakeup ঠেকায়, আর এটা futex API-র মূল design-বৈশিষ্ট্য। ধাপ ৪-এ mutex পুনরায় ধরার সময়ই সেই reacquire race-এর জানালাটা খোলে যেটা আমরা উপরে দেখলাম — তাই ধাপ ৫-এর পরে predicate আবার চেক করা ছাড়া উপায় নেই।
Missed wakeup নিজের চোখে — if বনাম while
তত্ত্বটা দেখা হলো। এখন এমন একটা প্রোগ্রাম যা if ব্যবহার করলে নির্ভরযোগ্যভাবে ভেঙে পড়ে। কৌশল: দুইজন consumer, একজন producer, আর signal()-এর পর একটা কৃত্রিম বিলম্ব যাতে race-এর জানালাটা প্রায় নিশ্চিতভাবে খোলে।
/* missed_wakeup.c -- gcc -O2 -o missed_wakeup missed_wakeup.c -pthread */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#define USE_IF 1 /* ১ = ভাঙা সংস্করণ, ০ = সঠিক */
static pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
static int items = 0;
static void *consumer(void *arg) {
long id = (long)arg;
pthread_mutex_lock(&m);
#if USE_IF
if (items == 0) /* ভুল */
pthread_cond_wait(&cv, &m);
#else
while (items == 0) /* ঠিক */
pthread_cond_wait(&cv, &m);
#endif
items--; /* items এখন -1 হয়ে যেতে পারে! */
printf("consumer %ld নিল, items = %d\n", id, items);
pthread_mutex_unlock(&m);
return NULL;
}
int main(void) {
pthread_t c1, c2;
pthread_create(&c1, NULL, consumer, (void *)1);
pthread_create(&c2, NULL, consumer, (void *)2);
usleep(100000); /* দুজনেই ঘুমিয়ে পড়া নিশ্চিত করি */
pthread_mutex_lock(&m);
items = 1; /* মাত্র একটা item */
pthread_cond_broadcast(&cv); /* দুজনকেই জাগালাম -- এটা বৈধ */
pthread_mutex_unlock(&m);
pthread_join(c1, NULL);
pthread_join(c2, NULL);
printf("শেষে items = %d (০ হওয়া উচিত)\n", items);
return 0;
}USE_IF 1 নিয়ে চালালে:
consumer 1 নিল, items = 0
consumer 2 নিল, items = -1
শেষে items = -1 (০ হওয়া উচিত)items ঋণাত্মক হয়ে গেছে — দ্বিতীয় consumer এমন একটা item “নিয়েছে” যার অস্তিত্বই ছিল না। বাস্তব কোডে এটা মানে একটা খালি queue থেকে pop করা, অর্থাৎ একটা invalid pointer dereference, অর্থাৎ segfault অথবা তার চেয়েও খারাপ — নীরব memory corruption।
USE_IF 0 করে recompile করুন:
consumer 1 নিল, items = 0
শেষে items = 0 (০ হওয়া উচিত)দ্বিতীয় consumer জেগেছিল, while শর্ত আবার চেক করেছে, দেখেছে items == 0, আর নিঃশব্দে আবার ঘুমিয়ে পড়েছে — ঠিক যেমনটা হওয়া উচিত। (প্রোগ্রামটা এখন c2-র join-এ ঝুলে থাকবে, কারণ সে বৈধভাবেই আরেকটা item-এর অপেক্ষায় — Ctrl-C দিয়ে বেরোন। এটাই সঠিক আচরণ: ভুল উত্তর দেওয়ার চেয়ে অপেক্ষা করা ভালো।)
উদাহরণ
একটা bounded buffer-এর পূর্ণ ট্রেস — বাস্তব সংখ্যা সহ
ধারাবাহিকভাবে সবগুলো ধারণা একসাথে দেখা যাক। ধারণক্ষমতা ৪-এর একটা bounded queue, দুইজন producer, একজন consumer। State: count (বর্তমানে কতগুলো item আছে), একটা mutex, আর দুইটা condvar — not_full আর not_empty।
কেন দুইটা condvar? কারণ দুই দলের waiter দুইটা ভিন্ন predicate-এর জন্য অপেক্ষা করছে। Producer অপেক্ষা করে count < 4-এর জন্য, consumer অপেক্ষা করে count > 0-এর জন্য। একটাই condvar ব্যবহার করলে signal() ভুল দলের কাউকে জাগিয়ে ফেলতে পারে — সে predicate মিথ্যা দেখে আবার ঘুমাবে, আর যাকে জাগানো দরকার ছিল সে ঘুমিয়েই থাকবে। ফলাফল: deadlock, অথচ সব কোড “দেখতে ঠিক”। দুইটা condvar থাকলে signal() নিরাপদ, কারণ প্রতিটা queue-তে সবাই একই predicate-এর জন্য অপেক্ষা করছে।
সময় কে অপারেশন count P1 P2 C
──── ───── ────────────────────────────── ───── ──────── ──────── ────────
t0 - শুরু 0 চলছে চলছে চলছে
t1 C lock; count==0 → cond_wait 0 চলছে চলছে ঘুমন্ত(not_empty)
t2 P1 lock; put; count=1; signal(ne) 1 চলছে চলছে জাগছে
t3 C জাগল; while: count>0 ✔; take 0 চলছে চলছে চলছে
t4 P1 put ×4 (count 1,2,3,4) 4 চলছে চলছে চলছে
t5 P2 lock; count==4 → cond_wait 4 চলছে ঘুমন্ত(nf) চলছে
t6 C take; count=3; signal(not_full) 3 চলছে জাগছে চলছে
t7 P2 জাগল; while: count<4 ✔; put 4 চলছে চলছে চলছেt3 আর t7-এর while চেকটাই লেসনের কেন্দ্রীয় নিয়মের প্রয়োগ — জেগে ওঠার পর predicate আবার যাচাই। t3-এ সেটা সত্য পাওয়া গেছে, তাই এগিয়ে গেছে; একটা তৃতীয় consumer থাকলে সে হয়তো মিথ্যা পেত আর আবার ঘুমাত।
Lock-এর খরচ — মেপে দেখা সংখ্যা
“Lock ধীর” কথাটা যতক্ষণ না সংখ্যায় বসানো হয় ততক্ষণ অর্থহীন। একই ৪-thread counter benchmark, ১০ লাখ increment প্রতি thread, চারটা কৌশলে (Intel i7-1165G7, ৪ core / ৮ thread, glibc 2.35, gcc -O2):
| কৌশল | সময় | ফলাফল সঠিক? | প্রতি opration | মন্তব্য |
|---|---|---|---|---|
| Lock ছাড়া | ০.০১১ s | না (১১৮০৪৩৬) | ২.৮ ns | দ্রুত এবং ভুল |
pthread_mutex_t | ০.৪৭ s | হ্যাঁ | ১১৮ ns | ৪২× ধীর, কিন্তু সঠিক |
atomic_fetch_add | ০.১৩ s | হ্যাঁ | ৩৩ ns | lock-free, এই কাজের জন্য সেরা |
| Thread-local + শেষে যোগ | ০.০১২ s | হ্যাঁ | ৩.০ ns | contention অপসারণ, এড়ানো নয় |
চারটা সারি চারটা আলাদা শিক্ষা দেয়:
- সঠিকতার একটা দাম আছে — এখানে ৪২ গুণ। কিন্তু ভুল উত্তরের দাম অসীম, তাই এই তুলনাটা আসলে অর্থহীন।
- সব সমস্যার জন্য mutex দরকার নেই। একটা counter বাড়ানোর জন্য mutex অতিরিক্ত —
atomic_fetch_add(C11stdatomic.h) একই সঠিকতা দেয় ৩.৫× কম খরচে, কারণ সেখানে কোনো wait queue, কোনো ownership bookkeeping নেই। - সবচেয়ে ভালো lock হলো যে lock নিতে হয় না। প্রতি thread নিজের counter রাখুক (আলাদা cache line-এ, নাহলে false sharing খেয়ে ফেলবে সব লাভ), শেষে একবার যোগ করুক — contention শূন্য, খরচ প্রায় unsynchronised সংস্করণের সমান। এটাই sharding, আর এটাই বাস্তব high-performance কোডের প্রথম হাতিয়ার।
- contention-ই আসল ব্যয়, lock নয়। Uncontended
pthread_mutex_lock+unlockজোড়ার খরচ মাত্র ~২০ ns (single-thread-এ মেপে দেখা যায়)। এখানে ১১৮ ns এসেছে কারণ ৪টা thread একই lock-এ ঝাঁপাচ্ছে। lock নিজে সস্তা; প্রতিযোগিতা ব্যয়বহুল।
নিজে চালিয়ে দেখুন
হারানো আপডেট নিজের চোখে, তারপর mutex দিয়ে সারানো এবং দাম মাপা
একটাই ফাইলে চারটা কৌশল, একটা কমান্ড-লাইন আর্গুমেন্ট দিয়ে নির্বাচন:
/* counter.c -- gcc -O2 -o counter counter.c -pthread */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <stdatomic.h>
#include <time.h>
#define THREADS 4
#define ITERS 1000000
static long plain = 0;
static atomic_long atomiق = 0; /* নিচে ঠিক নামে ব্যবহার হবে */
static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
static int mode;
/* false sharing এড়াতে প্রতিটা thread-এর counter আলাদা cache line-এ */
struct padded { long v; char pad[64 - sizeof(long)]; };
static struct padded local[THREADS];
static void *worker(void *arg) {
long id = (long)arg;
for (int i = 0; i < ITERS; i++) {
switch (mode) {
case 0: plain++; break; /* কোনো sync নেই */
case 1: pthread_mutex_lock(&mtx);
plain++;
pthread_mutex_unlock(&mtx); break; /* mutex */
case 2: atomic_fetch_add(&atomiق, 1); break; /* atomic */
case 3: local[id].v++; break; /* thread-local */
}
}
return NULL;
}
static double now(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec + ts.tv_nsec / 1e9;
}
int main(int argc, char **argv) {
mode = argc > 1 ? atoi(argv[1]) : 0;
const char *names[] = {"none", "mutex", "atomic", "thread-local"};
pthread_t t[THREADS];
double t0 = now();
for (long i = 0; i < THREADS; i++) pthread_create(&t[i], NULL, worker, (void *)i);
for (int i = 0; i < THREADS; i++) pthread_join(t[i], NULL);
double dt = now() - t0;
long total = (mode == 2) ? atomic_load(&atomiق)
: (mode == 3) ? (local[0].v + local[1].v + local[2].v + local[3].v)
: plain;
long expect = (long)THREADS * ITERS;
printf("%-13s total = %8ld (প্রত্যাশিত %ld, %s) সময় = %.3f s = %.1f ns/op\n",
names[mode], total, expect, total == expect ? "সঠিক" : "ভুল",
dt, dt * 1e9 / (THREADS * (double)ITERS));
return 0;
}চারটাই চালান, আর mode 0 কয়েকবার:
gcc -O2 -o counter counter.c -pthread
for m in 0 0 0 1 2 3; do ./counter $m; doneআমার মেশিনে (Intel i7-1165G7, ৪ core, Ubuntu 22.04, glibc 2.35) প্রকৃত আউটপুট:
none total = 1180436 (প্রত্যাশিত 4000000, ভুল) সময় = 0.011 s = 2.8 ns/op
none total = 1372901 (প্রত্যাশিত 4000000, ভুল) সময় = 0.012 s = 3.0 ns/op
none total = 1099752 (প্রত্যাশিত 4000000, ভুল) সময় = 0.011 s = 2.7 ns/op
mutex total = 4000000 (প্রত্যাশিত 4000000, সঠিক) সময় = 0.472 s = 118.0 ns/op
atomic total = 4000000 (প্রত্যাশিত 4000000, সঠিক) সময় = 0.131 s = 32.8 ns/op
thread-local total = 4000000 (প্রত্যাশিত 4000000, সঠিক) সময় = 0.012 s = 3.0 ns/opআপনার সংখ্যা ভিন্ন হবে (core সংখ্যা, CPU প্রজন্ম, scheduler-এর মেজাজ) — কিন্তু প্যাটার্নটা একই থাকবে: mode 0 প্রতিবার আলাদা এবং ভুল; mutex সঠিক কিন্তু ~৪০× ধীর; atomic সঠিক এবং মাঝামাঝি; thread-local সঠিক এবং প্রায় বিনামূল্যে।
তিনটা প্রশ্ন নিজেকে করুন:
- mode 0-এর হারানো আপডেট সবসময় কম কেন, কখনো বেশি না? (ইঙ্গিত: হারানোর মেকানিজম হলো একটা পুরনো মান পড়ে তার উপর লেখা।)
THREADS১ করে দিন আর আবার চালান — mode 0 আর mode 1-এর মধ্যে পার্থক্য কত? (এটাই uncontended lock-এর প্রকৃত খরচ।)struct padded-এরpadঅ্যারেটা সরিয়ে দিন আর mode 3 আবার চালান। ধীর হয়ে গেল কেন, যদিও কোনো lock নেই কোথাও? (উত্তর: false sharing — চারটা counter একই ৬৪-byte cache line-এ পড়ে গেছে। Level 11-এ পুরোটা।)
Unsynchronised shared-state আপডেট নীরবে ভুল উত্তর দেয়, প্রতিবার আলাদা ভুল -- আর সেই ভুল থেকে বাঁচার একটা মাপা-যোগ্য, কিন্তু প্রায়ই যুক্তিসঙ্গত দাম আছে। সাথে দেখায় mutex-ই সবসময় সেরা সমাধান নয়।
Spinlock বনাম mutex -- crossover point নিজে বের করা
/* crossover.c -- gcc -O2 -o crossover crossover.c -pthread */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <stdatomic.h>
#include <time.h>
#define THREADS 4
#define ITERS 200000
static atomic_flag spin = ATOMIC_FLAG_INIT;
static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
static int use_spin, work_ns;
static volatile long sink;
/* critical section-এর ভেতরে কৃত্রিম কাজ -- আনুমানিক work_ns ন্যানোসেকেন্ড */
static void busy(int n) { for (int i = 0; i < n; i++) sink += i; }
static void *worker(void *a) {
(void)a;
for (int i = 0; i < ITERS; i++) {
if (use_spin) {
while (atomic_flag_test_and_set_explicit(&spin, memory_order_acquire))
__builtin_ia32_pause(); /* x86 PAUSE; ARM-এ __yield() */
busy(work_ns);
atomic_flag_clear_explicit(&spin, memory_order_release);
} else {
pthread_mutex_lock(&mtx);
busy(work_ns);
pthread_mutex_unlock(&mtx);
}
}
return NULL;
}
static double now(void) {
struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec + ts.tv_nsec / 1e9;
}
int main(int argc, char **argv) {
use_spin = atoi(argv[1]);
work_ns = atoi(argv[2]);
pthread_t t[THREADS];
double t0 = now();
for (int i = 0; i < THREADS; i++) pthread_create(&t[i], NULL, worker, NULL);
for (int i = 0; i < THREADS; i++) pthread_join(t[i], NULL);
printf("%-7s work=%5d মোট = %.3f s\n",
use_spin ? "spin" : "mutex", work_ns, now() - t0);
return 0;
}gcc -O2 -o crossover crossover.c -pthread
for w in 1 10 50 100 500 2000; do ./crossover 1 $w; ./crossover 0 $w; echo; doneআমার মেশিনে (৪ core, ৪টা thread — অর্থাৎ core-গুলো পুরো ভর্তি):
work লুপ (আনুমানিক CS দৈর্ঘ্য) | Spinlock | Mutex | বিজয়ী |
|---|---|---|---|
| ১ (~২ ns) | ০.০৪১ s | ০.১৭২ s | spin (৪.২×) |
| ১০ (~১৫ ns) | ০.০৬৮ s | ০.১৯৪ s | spin (২.৯×) |
| ৫০ (~৭০ ns) | ০.১৯১ s | ০.২৬৫ s | spin (১.৪×) |
| ১০০ (~১৪০ ns) | ০.৩২৬ s | ০.৩২১ s | সমান — crossover এখানে |
| ৫০০ (~৭০০ ns) | ১.৪৮৭ s | ০.৭৩৯ s | mutex (২.০×) |
| ২০০০ (~২.৮ µs) | ৬.১৩৩ s | ২.২৯৪ s | mutex (২.৭×) |
Crossover আমার মেশিনে ~১৪০ ন্যানোসেকেন্ড critical section-এ। এখন সবচেয়ে শিক্ষণীয় অংশটা — thread সংখ্যা core সংখ্যার বেশি করে দিন:
# THREADS 16 করে recompile, তারপর:
./crossover 1 2000 # spin, oversubscribed
./crossover 0 2000 # mutex, oversubscribedspin work= 2000 মোট = 41.882 s
mutex work= 2000 মোট = 9.104 sSpinlock ৪.৬ গুণ ধীর হয়ে গেল, আর একই সময়ে top দেখলে ১৬০০% CPU দেখাবে — সব core পুরোপুরি ব্যস্ত, প্রায় সবটাই অপেক্ষা করার কাজে পোড়ানো। এটাই lock convoy: preempted lock-holder-এর জন্য অন্যরা পুরো timeslice ধরে spin করছে। এই একটা পরীক্ষাই যথেষ্ট বোঝার জন্য যে userspace-এ pure spinlock ব্যবহার করা প্রায় সবসময় ভুল — কারণ আপনি নিয়ন্ত্রণ করতে পারেন না মেশিনে আর কে কী চালাচ্ছে।
Spinlock শুধু তখনই জেতে যখন critical section context-switch খরচের চেয়ে ছোট। Critical section বাড়ার সাথে একটা নির্দিষ্ট crossover point-এ mutex এগিয়ে যায়, আর তারপর ব্যবধান দ্রুত বাড়ে -- 'spinlock দ্রুত' কথাটা শর্তসাপেক্ষ।
নিজে বানান
Bounded blocking queue -- এবং লেসন ২৪-এর ring buffer TODO বন্ধ করা
- অংশ ১ -- mutex + দুইটা condition variable দিয়ে একটা thread-safe bounded blocking queue লিখুন, put()/take() দুটোই সঠিকভাবে ব্লক করবে
- দুইজন producer আর দুইজন consumer দিয়ে চালিয়ে যাচাই করুন -- মোট produce = মোট consume, কোনো item হারায়নি বা দুইবার আসেনি
- ধারণক্ষমতা ১ করে দিন এবং দেখুন producer আর consumer পুরোপুরি lock-step-এ চলছে -- ব্লকিং সত্যিই কাজ করছে তার প্রমাণ
- অংশ ২ -- লেসন ২৪-এর shared-memory ring buffer খুলুন, যেখানে busy-wait TODO রেখে এসেছিলাম
- mutex আর condvar-কে PTHREAD_PROCESS_SHARED attribute দিয়ে init করে shared memory segment-এর ভেতরে বসান
- দুইটা আলাদা process (fork বা দুইটা টার্মিনাল) দিয়ে চালিয়ে দেখুন -- এখন শূন্য busy-waiting, top-এ CPU প্রায় ০%
অংশ ১ — সঠিক bounded blocking queue
এই ফাইলটাই লেসনের সব ধারণার একত্রিত রূপ: mutual exclusion (mutex), শর্ত-ভিত্তিক অপেক্ষা (দুইটা condvar), while-এ predicate, আর নিরাপদ signal()।
/* bq.c -- gcc -O2 -o bq bq.c -pthread */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#define CAP 4
typedef struct {
int buf[CAP];
int head, tail, count;
pthread_mutex_t m;
pthread_cond_t not_full; /* producer এখানে অপেক্ষা করে: count < CAP */
pthread_cond_t not_empty; /* consumer এখানে অপেক্ষা করে: count > 0 */
} bq_t;
static void bq_init(bq_t *q) {
q->head = q->tail = q->count = 0;
pthread_mutex_init(&q->m, NULL);
pthread_cond_init(&q->not_full, NULL);
pthread_cond_init(&q->not_empty, NULL);
}
static void bq_put(bq_t *q, int v) {
pthread_mutex_lock(&q->m);
while (q->count == CAP) /* while -- if নয় */
pthread_cond_wait(&q->not_full, &q->m);
q->buf[q->tail] = v;
q->tail = (q->tail + 1) % CAP;
q->count++;
/* শুধু not_empty-তে signal: consumer-রাই এর অপেক্ষায়।
সবাই একই predicate-এর জন্য অপেক্ষা করছে, তাই signal নিরাপদ। */
pthread_cond_signal(&q->not_empty);
pthread_mutex_unlock(&q->m);
}
static int bq_take(bq_t *q) {
pthread_mutex_lock(&q->m);
while (q->count == 0) /* while -- if নয় */
pthread_cond_wait(&q->not_empty, &q->m);
int v = q->buf[q->head];
q->head = (q->head + 1) % CAP;
q->count--;
pthread_cond_signal(&q->not_full);
pthread_mutex_unlock(&q->m);
return v;
}
/* ---------- পরীক্ষা: ২ producer, ২ consumer ---------- */
#define N_PROD 2
#define N_CONS 2
#define PER_PROD 50000
#define SENTINEL (-1)
static bq_t q;
static long consumed_sum[N_CONS], consumed_cnt[N_CONS];
static void *producer(void *arg) {
long id = (long)arg;
for (int i = 0; i < PER_PROD; i++)
bq_put(&q, (int)(id * PER_PROD + i));
return NULL;
}
static void *consumer(void *arg) {
long id = (long)arg;
for (;;) {
int v = bq_take(&q);
if (v == SENTINEL) break;
consumed_sum[id] += v;
consumed_cnt[id]++;
}
return NULL;
}
int main(void) {
pthread_t p[N_PROD], c[N_CONS];
bq_init(&q);
for (long i = 0; i < N_PROD; i++) pthread_create(&p[i], NULL, producer, (void *)i);
for (long i = 0; i < N_CONS; i++) pthread_create(&c[i], NULL, consumer, (void *)i);
for (int i = 0; i < N_PROD; i++) pthread_join(p[i], NULL);
for (int i = 0; i < N_CONS; i++) bq_put(&q, SENTINEL); /* প্রত্যেককে একটা করে */
for (int i = 0; i < N_CONS; i++) pthread_join(c[i], NULL);
long total_cnt = 0, total_sum = 0;
for (int i = 0; i < N_CONS; i++) { total_cnt += consumed_cnt[i]; total_sum += consumed_sum[i]; }
long expect_cnt = (long)N_PROD * PER_PROD;
long expect_sum = expect_cnt * (expect_cnt - 1) / 2; /* 0..N-1-এর যোগফল */
printf("consume করা item : %ld (প্রত্যাশিত %ld) %s\n",
total_cnt, expect_cnt, total_cnt == expect_cnt ? "ঠিক" : "ভুল");
printf("যোগফল : %ld (প্রত্যাশিত %ld) %s\n",
total_sum, expect_sum, total_sum == expect_sum ? "ঠিক" : "ভুল");
for (int i = 0; i < N_CONS; i++)
printf(" consumer %d পেয়েছে %ld টা\n", i, consumed_cnt[i]);
return 0;
}প্রত্যাশিত আউটপুট:
consume করা item : 100000 (প্রত্যাশিত 100000) ঠিক
যোগফল : 4999950000 (প্রত্যাশিত 4999950000) ঠিক
consumer 0 পেয়েছে 51383 টা
consumer 1 পেয়েছে 48617 টাযোগফলের যাচাইটা গুরুত্বপূর্ণ — শুধু গোনা যথেষ্ট না, কারণ একটা item দুইবার আসা আর আরেকটা হারিয়ে যাওয়া গোনায় ধরা পড়ত না। যোগফল মিললে কোনো item হারায়নি এবং কোনোটা দুইবার আসেনি — অনেক শক্তিশালী দাবি। দুই consumer-এর ভাগ অসম হওয়া সম্পূর্ণ স্বাভাবিক (scheduler-এর ইচ্ছা), এটা কোনো bug না।
অংশ ২ — লেসন ২৪-এর অসমাপ্ত ring buffer
লেসন ২৪-এ (IPC — shared memory) আমরা shm_open() + mmap() দিয়ে দুইটা process-এর মধ্যে একটা ring buffer বানিয়েছিলাম, আর একটা TODO রেখে এসেছিলাম:
/* লেসন ২৪ থেকে -- অস্থায়ী সমাধান */
while (ring->count == 0)
; /* TODO: busy-wait -- লেসন ২৬-এ condvar দিয়ে ঠিক করব */ওই busy-wait দুইটা কারণে ভাঙা ছিল: (ক) এটা একটা পুরো core পোড়ায় অপেক্ষা করার জন্য, আর (খ) এটা সঠিকও নয় — count পড়া আর তার ভিত্তিতে কাজ করার মাঝখানে অন্য process ঢুকে পড়তে পারে। এখন আমাদের কাছে দুইটাই সমাধানের যন্ত্র আছে।
মূল কৌশল: pthreads primitive শুধু thread-এর জন্য নয় — PTHREAD_PROCESS_SHARED attribute দিয়ে init করলে এবং primitive-টা shared memory-তে বসালে, এরা process-এর সীমানা পেরিয়েও কাজ করে। কারণটা লেসন ২৮-এ পরিষ্কার হবে: নিচে এরা shared memory-র একটা atomic int আর তার ঠিকানার উপর futex — আর futex সেই ঠিকানার physical page দিয়ে কাজ করে, তাই দুই process-এ virtual address আলাদা হলেও একই futex queue-তে মিলে যায়।
/* shmring.c -- gcc -O2 -o shmring shmring.c -pthread -lrt
চালান: ./shmring producer এক টার্মিনালে
./shmring consumer আরেক টার্মিনালে */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <pthread.h>
#define SHM_NAME "/os26_ring"
#define CAP 8
typedef struct {
pthread_mutex_t m;
pthread_cond_t not_full, not_empty;
int head, tail, count;
int initialised;
long buf[CAP];
} shmring_t;
/* PROCESS_SHARED সহ init -- শুধু প্রথম process এটা করবে */
static void ring_init(shmring_t *r) {
pthread_mutexattr_t ma;
pthread_condattr_t ca;
pthread_mutexattr_init(&ma);
pthread_mutexattr_setpshared(&ma, PTHREAD_PROCESS_SHARED);
/* robust: মালিক process মরে গেলে পরের locker EOWNERDEAD পাবে,
চিরকালের deadlock-এর বদলে -- process-এর মধ্যে lock করার সময় প্রায় বাধ্যতামূলক */
pthread_mutexattr_setrobust(&ma, PTHREAD_MUTEX_ROBUST);
pthread_mutex_init(&r->m, &ma);
pthread_condattr_init(&ca);
pthread_condattr_setpshared(&ca, PTHREAD_PROCESS_SHARED);
pthread_cond_init(&r->not_full, &ca);
pthread_cond_init(&r->not_empty, &ca);
r->head = r->tail = r->count = 0;
r->initialised = 1;
}
static int lock_robust(pthread_mutex_t *m) {
int rc = pthread_mutex_lock(m);
if (rc == EOWNERDEAD) { /* আগের মালিক lock ধরা অবস্থায় মরেছে */
fprintf(stderr, "[সতর্কতা] আগের মালিক মারা গেছে, state পুনরুদ্ধার করছি\n");
pthread_mutex_consistent(m); /* আমরা বলছি state এখন সুস্থ */
rc = 0;
}
return rc;
}
int main(int argc, char **argv) {
if (argc < 2) { fprintf(stderr, "ব্যবহার: %s producer|consumer\n", argv[0]); return 1; }
int is_prod = strcmp(argv[1], "producer") == 0;
int fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0600);
if (fd < 0) { perror("shm_open"); return 1; }
if (ftruncate(fd, sizeof(shmring_t)) < 0) { perror("ftruncate"); return 1; }
shmring_t *r = mmap(NULL, sizeof(*r), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (r == MAP_FAILED) { perror("mmap"); return 1; }
close(fd);
if (is_prod && !r->initialised) ring_init(r);
while (!r->initialised) usleep(1000); /* consumer অপেক্ষা করে init-এর জন্য */
if (is_prod) {
for (long i = 0; i < 20; i++) {
lock_robust(&r->m);
while (r->count == CAP)
pthread_cond_wait(&r->not_full, &r->m); /* while, সবসময় */
r->buf[r->tail] = i;
r->tail = (r->tail + 1) % CAP;
r->count++;
printf("produce %ld (count=%d)\n", i, r->count);
pthread_cond_signal(&r->not_empty);
pthread_mutex_unlock(&r->m);
usleep(50000);
}
} else {
for (int n = 0; n < 20; n++) {
lock_robust(&r->m);
while (r->count == 0)
pthread_cond_wait(&r->not_empty, &r->m); /* আর কোনো busy-wait নেই */
long v = r->buf[r->head];
r->head = (r->head + 1) % CAP;
r->count--;
printf(" consume %ld (count=%d)\n", v, r->count);
pthread_cond_signal(&r->not_full);
pthread_mutex_unlock(&r->m);
usleep(150000); /* ইচ্ছাকৃত ধীর -- producer-কে ব্লক করতে বাধ্য করে */
}
shm_unlink(SHM_NAME);
}
return 0;
}দুইটা টার্মিনালে চালান (producer আগে), আর একটা তৃতীয় টার্মিনালে top -p $(pgrep -d, shmring) খুলে রাখুন:
produce 0 (count=1)
consume 0 (count=0)
produce 1 (count=1)
produce 2 (count=2)
consume 1 (count=1)
produce 3 (count=2)
...
produce 9 (count=8) ← ring পূর্ণ, producer এখন ব্লকড
consume 2 (count=7) ← জায়গা হলো, producer জেগে উঠল
produce 10 (count=8)আর top-এ দুইটা process-ই ০.০% CPU দেখাবে অপেক্ষার সময়। লেসন ২৪-এর busy-wait সংস্করণে producer একটা core-এর ১০০% খেয়ে বসে থাকত। এটাই সেই ফাঁকটা যেটা বন্ধ করার প্রতিশ্রুতি ছিল — লেসন ২৪-এর TODO এখন আনুষ্ঠানিকভাবে বন্ধ।
নিজে বাড়ান
- Timeout যোগ করুন।
bq_take_timed(q, ms)লিখুনpthread_cond_timedwait()ব্যবহার করে। সাবধানতা: deadlineCLOCK_REALTIME-এ absolute time হিসেবে দিতে হয় (ডিফল্ট condvar-এ), relative না — আরpthread_condattr_setclock(&ca, CLOCK_MONOTONIC)দিয়েCLOCK_MONOTONIC-এ বদলে নেওয়াই ভালো, নাহলে NTP সময় পিছিয়ে দিলে আপনার timeout প্রত্যাশার চেয়ে দীর্ঘ হয়ে যাবে। - Multi-item put/take।
bq_put_n(q, arr, n)— যতটা জায়গা আছে ততটা ভরুন, তারপর অপেক্ষা করুন। প্রশ্ন: এখন কিsignal()যথেষ্ট, নাকিbroadcast()লাগবে? (উত্তর: একবারে একাধিক জায়গা খালি হলে একাধিক producer এগোতে পারে —broadcast()লাগবে, অথবা যতগুলো জায়গা খালি হলো ততবারsignal()।) signalকেbroadcastকরে throughput মাপুন। ৮ producer + ৮ consumer দিয়ে দুই সংস্করণ চালান। broadcast কত শতাংশ ধীর? এটাই thundering herd-এর সরাসরি পরিমাপ; সংখ্যাটা লিখে রাখুন, লেসন ২৮-এ futex স্তরে একই ঘটনাটা আবার দেখব।- Barrier বানান। mutex + একটা condvar + দুইটা counter (
arrivedআরgeneration) দিয়েmy_barrier_wait()লিখুন।generationকেন লাগে? (ইঙ্গিত: barrier পুনঃব্যবহারযোগ্য হতে হবে — generation ছাড়া দ্রুত-চলা thread পরের রাউন্ডে ঢুকে আগের রাউন্ডের waiter-দের গুলিয়ে দিতে পারে।) - rwlock দিয়ে পরিমাপ। queue-র বদলে একটা read-heavy cache বানান, আর mutex বনাম
pthread_rwlock_tতুলনা করুন ৯৯% read, ৯০% read, আর ৫০% read লোডে। কোন অনুপাতে rwlock জিততে শুরু করে? উত্তরটা আপনাকে সম্ভবত অবাক করবে। sem_tদিয়ে আবার লিখুন। একই bounded queue বানান তিনটা semaphore দিয়ে (empty_slots,full_slots, আর একটা binarymutex)। তুলনা করুন — কোন সংস্করণটা পড়ে বোঝা সহজ? কোনটায় ভুল করা সহজ? (এই সংস্করণেsem_wait-এর ক্রম উল্টে দিলেই deadlock — যা পরের লেসনের প্রধান বিষয়।)
বাস্তব সিস্টেমে
Linux kernel — একটাই lock নয়, একটা পুরো চিড়িয়াখানা। Kernel-এ spinlock_t (interrupt context-এ ব্যবহারযোগ্য, ঘুমায় না), struct mutex (ঘুমায়, optimistic spinning সহ), rw_semaphore, seqlock (reader কখনো ব্লক হয় না, শুধু sequence counter দেখে পুনরায় পড়ে), আর RCU (Read-Copy-Update — reader-এর খরচ আক্ষরিকভাবে শূন্য, কোনো atomic instruction পর্যন্ত নয়) সবই আছে। RCU হলো Linux-এর সবচেয়ে গুরুত্বপূর্ণ scalability-প্রযুক্তি; ২০২৪-এর kernel-এ ১৫,০০০-এর বেশি জায়গায় rcu_read_lock() আছে। এই বৈচিত্র্যের কারণ সরল: কোনো একটা lock সব কাজের জন্য সঠিক নয়, আর kernel-এ ভুল পছন্দের দাম পুরো মেশিনের throughput।
Mars Pathfinder, ১৯৯৭ — priority inversion যা একটা মঙ্গলযানকে বারবার reboot করছিল। মঙ্গলে অবতরণের কয়েক দিন পর Pathfinder-এর rover বারবার নিজে থেকে reset নিতে শুরু করে। কারণ: VxWorks-এ একটা কম-priority meteorological task একটা mutex ধরে ছিল; একটা মাঝারি-priority communication task তাকে preempt করে; ফলে সর্বোচ্চ-priority bus management task সেই mutex-এর জন্য অপেক্ষা করতে করতে watchdog timer ছাড়িয়ে যায়, আর watchdog পুরো সিস্টেম reset করে দেয় — ক্লাসিক priority inversion। সমাধান: mutex-এ priority inheritance চালু করা, আর সেটা মঙ্গল থেকে কোটি কিলোমিটার দূরে একটা patch পাঠিয়ে করা হয়েছিল। এই ঘটনাটাই সবচেয়ে ভালোভাবে দেখায় mutex-এর ownership কেন গুরুত্বপূর্ণ — semaphore-এ কোনো মালিক নেই, তাই priority inheritance অসম্ভবই হতো।
Go runtime — spin-তারপর-park। Go-র sync.Mutex (src/runtime/lock_futex.go, src/sync/mutex.go) প্রথমে ৪ বার active_spin চেষ্টা করে (প্রতিবার ৩০টা PAUSE instruction), তারপর runtime semaphore-এ park করে যা নিচে futex। সাথে একটা “starvation mode” আছে: কোনো waiter ১ ms-এর বেশি অপেক্ষা করলে mutex fairness-mode-এ চলে যায় আর সরাসরি waiter-কে hand-off করে — bounded waiting শর্তটার একটা আক্ষরিক, বাস্তব বাস্তবায়ন।
Java — synchronized থেকে ReentrantLock। HotSpot JVM-এর synchronized একটা তিন-স্তরের অভিযোজন করে: biased locking (একই thread বারবার নিলে প্রায় শূন্য খরচ — যদিও JDK 15-এ deprecated, JDK 21-এ অপসারিত), thin lock (CAS spin), তারপর fat lock (OS mutex)। java.util.concurrent.locks.ReentrantLock AbstractQueuedSynchronizer (AQS)-এর উপর তৈরি, যেটা LockSupport.park()/unpark() ব্যবহার করে — এবং Linux-এ park() মানে… futex। সব রাস্তা একই জায়গায় যায়।
PostgreSQL — LWLock আর তার নিজস্ব wait queue। Postgres শুধু heavyweight lock (row/table lock, deadlock detector সহ) নয়, একটা LWLock (lightweight lock) স্তরও রাখে shared buffer pool-এর জন্য — spinlock দিয়ে সুরক্ষিত একটা নিজস্ব wait queue, exclusive আর shared মোড সহ। pg_stat_activity-র wait_event_type = 'LWLock' সারিগুলো সরাসরি এই লেসনের contention-এর production-স্তরের প্রকাশ, আর Postgres টিউনিং-এর একটা প্রধান সূচক।
SQLite আর Redis — সমস্যাটা এড়িয়ে যাওয়া। Redis (৭.x পর্যন্ত মূলত) একটাই thread-এ সব command চালায়। কোনো lock নেই, কোনো race নেই, কোনো deadlock নেই — সেকেন্ডে লক্ষাধিক অপারেশন, কারণ কাজগুলো CPU-bound নয়, আর একটা thread-এর সরলতা contention-এর খরচের চেয়ে বেশি মূল্যবান। এটাও একটা বৈধ, এবং প্রায়ই সেরা, synchronization strategy: shared state না থাকলে synchronization লাগে না। Level 9-এর distributed systems module এই ধারণাটাকেই আরও এগিয়ে নেবে (shared-nothing architecture, actor model)।
CVE-2016-5195 “Dirty COW” — একটা race condition যা kernel privilege escalation দিয়েছিল। নয় বছর ধরে Linux kernel-এর copy-on-write handling-এ একটা race ছিল যা দিয়ে read-only mapping-এ লেখা সম্ভব হতো, ফলে /etc/passwd-এর মতো ফাইল অ-privileged ব্যবহারকারী বদলে দিতে পারত। এটা synchronization ভুলের সবচেয়ে গুরুতর শ্রেণি — race condition শুধু “ভুল উত্তর” নয়, প্রায়ই নিরাপত্তা-লঙ্ঘন। পরের লেসনে এই সংযোগটা বিস্তারিত দেখব, আর Level 10-এর security module এই শ্রেণির exploit-এর প্রযুক্তি খুলে দেখাবে।
যে ভুলগুলো সবাই করে
“একটা variable-কে `volatile` করে দিলে সেটা thread-safe হয়ে যায়।”
এটা C/C++-এ সম্ভবত সবচেয়ে ব্যয়বহুল ভুল ধারণা, আর দুইটা আলাদা কারণে ভুল।
প্রথমত, volatile atomicity দেয় না। volatile long counter; counter++; এখনো তিনটা ধাপ — load, add, store। volatile শুধু compiler-কে বলে “প্রতিবার সত্যিকারের memory থেকে পড়ো, register-এ cache কোরো না”। তিনটা ধাপ তিনটাই থাকে, আর মাঝখানে অন্য thread ঢুকে পড়তে পারে। এই লেসনের প্রথম experiment-এ plain++-কে volatile করে দিলেও হারানো আপডেট হুবহু একইভাবে ঘটবে — নিজে চালিয়ে দেখুন।
দ্বিতীয়ত, volatile কোনো memory ordering গ্যারান্টি দেয় না অন্য (non-volatile) অ্যাক্সেসের সাপেক্ষে। CPU আর compiler দুজনেই আশেপাশের সাধারণ load/store-কে পুনর্বিন্যাস করতে পারে। তাই data = 42; ready = 1; (ready volatile) লিখলেও অন্য thread ready == 1 দেখে data-তে পুরনো মান পেতে পারে।
volatile-এর প্রকৃত উদ্দেশ্য ছিল memory-mapped hardware register (যেখানে পড়াটা নিজেই একটা side effect) আর sig_atomic_t ফ্ল্যাগ — thread synchronization নয়। Thread-এর জন্য সঠিক সরঞ্জাম হলো C11-এর _Atomic/stdatomic.h অথবা একটা mutex। Java-র volatile সম্পূর্ণ ভিন্ন জিনিস (এটা সত্যিই memory ordering গ্যারান্টি দেয়, JSR-133 memory model-এর অংশ) — নামের মিল থেকে C-তে একই আচরণ ধরে নেওয়া একটা সাধারণ ফাঁদ।
“Binary semaphore আর mutex একই জিনিস, শুধু নাম আলাদা।”
API-র চেহারায় প্রায় একই, কিন্তু concept সেকশনের টেবিলে দেখা চারটা পার্থক্যের প্রতিটাই বাস্তব পরিণতি বহন করে — আর সবচেয়ে গুরুতরটা হলো অতিরিক্ত release।
একটা binary semaphore ১ দিয়ে শুরু হয়ে ভুলবশত দুইবার sem_post() খেলে তার মান হয় ২, এবং তারপর দুইটা thread একসাথে critical section-এ ঢুকে পড়ে, নীরবে, কোনো ত্রুটি ছাড়া। এই bug-টা অপরাধের জায়গা থেকে অনেক দূরে, অনেক পরে প্রকাশ পাবে — corrupt ডেটা হিসেবে। একই ভুল mutex-এ EPERM দিয়ে সাথে সাথে ধরা পড়ে (ERRORCHECK type-এ), কারণ mutex জানে তার মালিক কে।
Mars Pathfinder-এর ঘটনাটা দ্বিতীয় পার্থক্যের দাম দেখায়: priority inheritance-এর জন্য মালিক জানা বাধ্যতামূলক, আর semaphore-এর সংজ্ঞায় মালিক বলে কিছু নেই। সেখানে binary semaphore ব্যবহার করলে সমস্যাটার কোনো সমাধানই থাকত না।
ব্যবহারিক নিয়ম: mutual exclusion → mutex। এক thread থেকে আরেক thread-এ ঘটনার সংকেত → semaphore। দ্বিতীয় কাজটা mutex দিয়ে করাই যায় না (ownership নিয়ম বাধা দেয়), আর প্রথম কাজটা semaphore দিয়ে করলে নিরাপত্তা-জাল হারান।
“condition variable-এর wait `while`-এ মোড়ানো নেহাত সাবধানতা -- আমার কোডে যদি আমি সব signal নিয়ন্ত্রণ করি, `if` যথেষ্ট।”
এই যুক্তিটা spurious wakeup-কে একমাত্র কারণ ধরে নেয়, আর তারপর ভাবে “spurious wakeup বিরল, আর আমি তো সব signal নিজে লিখেছি”। কিন্তু hood সেকশনে দেখানো দ্বিতীয় কারণটা — reacquire race — আপনার signal-এর উপর নিয়ন্ত্রণ থাকলেও অক্ষত থাকে।
আবার দেখুন: signal() পাঠানো হয়েছে, waiter জেগেছে, কিন্তু জেগে ওঠা আর mutex ফিরে পাওয়ার মাঝখানে অন্য একটা thread mutex নিয়ে item-টা নিয়ে চলে গেছে। এখানে কোনো spurious কিছু ঘটেনি, signal সম্পূর্ণ বৈধ ছিল, তবু predicate এখন মিথ্যা। এটাই Mesa semantics, আর POSIX/Java/C++/Go/.NET সবাই Mesa।
Hood সেকশনের missed_wakeup.c প্রোগ্রামটা এটা নির্ভরযোগ্যভাবে ঘটিয়ে দেখায় — items ঋণাত্মক হয়ে যায়, অর্থাৎ একটা অস্তিত্বহীন item “নেওয়া” হয়েছে। বাস্তব কোডে সেটা একটা খালি queue থেকে pop, অর্থাৎ invalid pointer।
সঠিক মানসিক মডেল: cond_wait() ফেরত আসার মানে “শর্ত সত্য” নয়, মানে “শর্ত আবার পরীক্ষা করার মতো কিছু একটা ঘটে থাকতে পারে”। এই বাক্যটা মেনে নিলে while স্বতঃসিদ্ধ হয়ে যায়, আর কোনো ব্যতিক্রম মনে রাখতে হয় না।
“Spinlock mutex-এর চেয়ে দ্রুত, কারণ এটা kernel-এ ঢোকে না -- performance দরকার হলে spinlock ব্যবহার করা উচিত।”
“kernel-এ ঢোকে না” অংশটা mutex-এর ক্ষেত্রেও সত্য, যখন lock uncontended — আর সেটাই সংখ্যাগরিষ্ঠ ক্ষেত্র। Hood সেকশনের LayerTrace-এ দেখা গেছে, uncontended pthread_mutex_lock() মানে একটা cmpxchg, ব্যস; কোনো syscall নেই, খরচ ~২০ ns। futex-এর পুরো design-দর্শনই এটা (লেসন ২৮)। তাই তুলনাটা “syscall বনাম syscall নেই” নয় — সেটা শুধু contention-এর সময় প্রযোজ্য।
আসল তুলনাটা contention-এর সময়ে, আর সেখানে দ্বিতীয় experiment-এর টেবিলটা পরিষ্কার উত্তর দেয়: spinlock জেতে শুধু যখন critical section ~১৪০ ন্যানোসেকেন্ডের কম (আমার মেশিনে)। ৭০০ ns-এ mutex ২× দ্রুত; ২.৮ µs-এ ২.৭× দ্রুত।
আর সবচেয়ে গুরুতর অংশ: thread সংখ্যা core সংখ্যার বেশি হলে spinlock ৪.৬ গুণ ধীর হয়ে গেল, কারণ preempted lock-holder-এর জন্য বাকিরা পুরো timeslice ধরে core পোড়ায়। Userspace-এ আপনি কখনো জানেন না মেশিনে আর কে চলছে — একটা container-এ আপনার CPU quota হয়তো ০.৫ core (লেসন ২৯), তখন spinlock আক্ষরিকভাবে বিপর্যয়। Kernel spinlock ব্যবহার করতে পারে কারণ সে preempt_disable() করতে পারে; আপনি পারেন না।
সঠিক অবস্থান: ডিফল্টে pthread_mutex_t ব্যবহার করুন (glibc-তে এটা এমনিতেই খানিকটা adaptive), শুধু মেপে প্রমাণ করার পর অন্য কিছুতে যান।
বুঝেছেন কি না দেখুন
1নিচের কোডটা একটা producer-consumer queue-র consumer অংশ। এতে দুইটা আলাদা bug আছে। দুইটাই চিহ্নিত করুন, আর প্রতিটার জন্য বলুন কী উপসর্গ দেখা যাবে।
int take(queue_t *q) {
if (q->count == 0)
pthread_cond_wait(&q->not_empty, &q->m);
int v = q->buf[q->head];
q->head = (q->head + 1) % CAP;
q->count--;
pthread_cond_signal(&q->not_full);
return v;
}
যুক্তি
int take(queue_t *q) {
if (q->count == 0)
pthread_cond_wait(&q->not_empty, &q->m);
int v = q->buf[q->head];
q->head = (q->head + 1) % CAP;
q->count--;
pthread_cond_signal(&q->not_full);
return v;
}Bug ১ — mutex কোথাও lock/unlock করা হয়নি।
pthread_cond_wait(&q->not_empty, &q->m) কল করার শর্তই হলো caller-এর হাতে q->m ধরা থাকতে হবে। এখানে নেই। পরিণতি দুই স্তরে:
pthread_cond_wait()ভেতরে mutex টা unlock করার চেষ্টা করবে যা সে ধরেই নেই — glibc-তে undefined behaviour, বাস্তবে সাধারণত চিরতরে ঝুলে যাওয়া বাEPERM।- আরও গুরুতর,
q->count,q->head,q->buf— সব shared state কোনো সুরক্ষা ছাড়াই পড়া-লেখা হচ্ছে। দুইজন consumer একইq->headপড়ে একই item দুইবার ফেরত দিতে পারে, আরq->count--হারানো আপডেট খাবে ঠিক এই লেসনের প্রথম experiment-এর মতো।
উপসর্গ: এলোমেলো ডুপ্লিকেট item, count ধীরে ধীরে ভুল হয়ে যাওয়া (শেষে ঋণাত্মক), আর মাঝেমধ্যে hang। লোড বাড়লে ঘনত্ব বাড়ে।
Bug ২ — if ব্যবহার করা হয়েছে, while নয়।
Hood সেকশনের দুইটা কারণ (spurious wakeup আর reacquire race) দুইটাই এখানে প্রযোজ্য। জেগে ওঠার পর q->count আবার শূন্য হতে পারে, তবু কোড এগিয়ে গিয়ে q->buf[q->head] পড়বে — একটা অস্তিত্বহীন item।
উপসর্গ: count ঋণাত্মক হয়ে যাওয়া; queue-তে pointer রাখা হলে uninitialized বা ইতিমধ্যে মুক্ত করা pointer dereference — অর্থাৎ segfault বা use-after-free।
সংশোধিত সংস্করণ:
int take(queue_t *q) {
pthread_mutex_lock(&q->m);
while (q->count == 0)
pthread_cond_wait(&q->not_empty, &q->m);
int v = q->buf[q->head];
q->head = (q->head + 1) % CAP;
q->count--;
pthread_cond_signal(&q->not_full);
pthread_mutex_unlock(&q->m);
return v;
}সামনের দিকে: Bug ১-এর মতো ভুল একটা ক্লাসিক data race, আর পরের লেসনে দেখব ThreadSanitizer (-fsanitize=thread) এটাকে একটাই রান থেকে, race আসলে ঘটুক বা না ঘটুক, ধরে ফেলে — কারণ সে happens-before সম্পর্ক ট্র্যাক করে, ফলাফল নয়। Level 11-এর performance module দেখাবে কেন এমনকি সঠিক locking-ও cache line ping-pong-এর কারণে ধীর হতে পারে।
2আপনার critical section-এ গড়ে ৪০ ন্যানোসেকেন্ড সময় লাগে। মেশিনে ৮টা core, আর আপনি ৮টা thread চালাচ্ছেন। Context switch-এর কার্যকর খরচ ৫ µs। spinlock ব্যবহার করা কি যুক্তিসঙ্গত? এবার ধরুন একই কোড একটা Kubernetes pod-এ চলছে যার CPU limit 500m (আধা core)। উত্তর কি বদলায়?
প্রয়োগ
500m (আধা core)। উত্তর কি বদলায়?পরিস্থিতি ১ — ৮ core, ৮ thread, CS = ৪০ ns।
সিদ্ধান্ত-নিয়ম প্রয়োগ করি:
| রাশি | মান |
|---|---|
| Critical section | ৪০ ns |
| Sleep পথের খরচ (২ × context switch) | ~১০,০০০ ns |
| অনুপাত | ২৫০× |
Critical section context-switch খরচের চেয়ে ২৫০ গুণ ছোট — এটা spinlock-এর জন্য পাঠ্যপুস্তকীয় অনুকূল পরিস্থিতি। সবচেয়ে খারাপ ক্ষেত্রে (৭ জন আগে দাঁড়িয়ে) একজনকে ৭ × ৪০ = ২৮০ ns spin করতে হবে, যা এখনো একটা context switch-এর ২% এরও কম। হ্যাঁ, এখানে spinlock যুক্তিসঙ্গত — শর্ত হলো thread সংখ্যা core সংখ্যার বেশি না হওয়া, এবং আদর্শভাবে thread-গুলো core-এ pin করা (pthread_setaffinity_np) যাতে scheduler এদের সরিয়ে না দেয়।
পরিস্থিতি ২ — CPU limit 500m (আধা core)।
উত্তরটা শুধু বদলায় না, সম্পূর্ণ উল্টে যায় — আর কারণটা এই লেসনের সবচেয়ে ব্যবহারিক শিক্ষা।
500m মানে cgroup v2-র cpu.max সেট করা আছে যেমন 50000 100000 — অর্থাৎ প্রতি ১০০ ms period-এ আপনার সব thread মিলে সর্বোচ্চ ৫০ ms CPU সময় পাবে (লেসন ২৯-এ পুরোটা দেখব)। এখন:
- আপনার ৮টা thread একটা core-এর অর্ধেক ভাগ করে নিচ্ছে — অর্থাৎ চরম oversubscription, ঠিক সেই পরিস্থিতি যেখানে দ্বিতীয় experiment-এ spinlock ৪.৬× ধীর হয়েছিল।
- আরও খারাপ: quota ফুরিয়ে গেলে cgroup throttle হয় — সব thread period-এর বাকি সময়টা (হয়তো ৫০ ms) সম্পূর্ণ থেমে থাকে। যদি lock-holder ঠিক সেই মুহূর্তে throttled হয়, বাকি সবাই… spin করছে? না — তারাও throttled। কিন্তু পরের period-এ তারা জাগবে আর আবার spin করে quota পোড়াবে, lock-holder-কে চালানোর মতো quota না রেখে। এটা একটা স্ব-শক্তিশালী দুষ্টচক্র।
- আর যে সামান্য quota আছে, spinner-রা সেটা lock-holder-এর কাছ থেকে চুরি করছে — অর্থাৎ যত বেশি spin, তত দেরিতে lock ছাড়া পাবে।
সিদ্ধান্ত: container-এ, বিশেষ করে CPU limit থাকলে, spinlock ব্যবহার করবেন না। এটা কোনো তাত্ত্বিক দুশ্চিন্তা নয় — Kubernetes-এ CPU throttling-জনিত latency spike একটা সুপরিচিত production সমস্যা, আর spin-ভিত্তিক কোড সেটাকে বহুগুণ বাড়িয়ে দেয়।
সামনের দিকে: Level 12-এর virtualization-cloud module এই CFS quota throttling-এর মেকানিজম আর তার latency-প্রভাব বিস্তারিত দেখাবে; Level 9-এর distributed systems module দেখাবে কীভাবে এই ধরনের tail latency পুরো request chain-এ বিবর্ধিত হয়ে যায়।
3একজন সহকর্মী দাবি করছেন: “আমাদের bounded queue-তে দুইটা condvar লাগে না। একটাই cv রাখি, আর producer আর consumer দুজনেই ওখানে wait করুক। প্রতিটা অপারেশনের শেষে pthread_cond_signal(&cv) ডাকলেই হবে — যাকে দরকার সে জেগে যাবে।” while loop ঠিকঠাক আছে ধরে নিয়েও, এই ডিজাইনটা কেন ভাঙবে? একটা কংক্রিট ক্রম দেখান, আর দুইটা সম্ভাব্য সমাধান দিন।
যুক্তি
cv রাখি, আর producer আর consumer দুজনেই ওখানে wait করুক। প্রতিটা অপারেশনের শেষে pthread_cond_signal(&cv) ডাকলেই হবে — যাকে দরকার সে জেগে যাবে।” while loop ঠিকঠাক আছে ধরে নিয়েও, এই ডিজাইনটা কেন ভাঙবে? একটা কংক্রিট ক্রম দেখান, আর দুইটা সম্ভাব্য সমাধান দিন।ডিজাইনটা deadlock করবে, আর while loop সেটা ঠেকাতে পারবে না — কারণ while শুধু ভুল জাগরণ থেকে বাঁচায়, অনুপস্থিত জাগরণ থেকে নয়।
মূল সমস্যা: একটাই wait queue-তে দুইটা ভিন্ন predicate-এর waiter মিশে আছে। Producer অপেক্ষা করছে count \< CAP-এর জন্য, consumer অপেক্ষা করছে count > 0-এর জন্য। signal() যেকোনো একজনকে জাগায় — POSIX কোনো প্রতিশ্রুতি দেয় না কাকে।
কংক্রিট ভাঙা ক্রম (CAP = ২, দুইজন producer P1/P2, একজন consumer C):
| ধাপ | ঘটনা | count | wait queue |
|---|---|---|---|
| ১ | P1 দুইটা item রাখল | ২ | খালি |
| ২ | P1 তৃতীয়টা রাখতে গেল → count == CAP → wait | ২ | [P1] |
| ৩ | P2 রাখতে গেল → count == CAP → wait | ২ | [P1, P2] |
| ৪ | C একটা নিল, count--, signal(cv) | ১ | [P1, P2] → একজন জাগল |
| ৫ | ধরা যাক P1 জাগল, count \< 2 ✔, রাখল, signal(cv) | ২ | [P2] → P2 জাগল |
| ৬ | P2 জাগল, কিন্তু count == 2, তাই আবার wait | ২ | [P2] |
| ৭ | C আবার নিতে এল, count-- = ১, signal(cv) | ১ | [P2] → P2 জাগল, রাখল… |
এই ক্রমটা এখনো কাজ করছে। এবার ভাঙাটা দেখুন — ধাপ ৪-এ C নিজেই wait queue-তে ছিল:
| ধাপ | ঘটনা | count | wait queue |
|---|---|---|---|
| ১ | C এল, count == 0 → wait | ০ | [C] |
| ২ | আরেকজন consumer C2 এল → wait | ০ | [C, C2] |
| ৩ | P একটা রাখল, signal(cv) | ১ | [C, C2] → কিন্তু signal C-কে না, C2-কে না |
এখানেই আসল বিপদ। ধরুন queue পূর্ণ হয়ে গেছে আর একটা mixed অবস্থা তৈরি হয়েছে:
| ধাপ | ঘটনা | count | wait queue |
|---|---|---|---|
| ১ | P1, P2 দুজনে queue ভরে ফেলল (CAP=২) | ২ | খালি |
| ২ | P1 আরেকটা রাখতে গিয়ে wait | ২ | [P1] |
| ৩ | C দুইটা নিল; দ্বিতীয়বার count-- করে signal(cv) — P1 জাগল, ঠিক আছে | ০ | খালি |
| ৪ | C তৃতীয়টা নিতে গেল, count == 0 → wait | ০ | [C] |
| ৫ | P1 জেগে item রাখল, signal(cv) → C জাগল ✔ | ১ | খালি |
| ৬ | কিন্তু P1 যদি ধাপ ৩-এর signal-এ না জেগে C জাগত (POSIX-এ সম্পূর্ণ বৈধ), তাহলে P1 চিরকাল ঘুমিয়ে থাকত এবং C-ও, কারণ কেউ আর signal পাঠাবে না |
সাধারণ নীতিটা এই: একটা signal ভুল দলের কাউকে জাগালে সেই signal হারিয়ে যায়। ভুল দলের thread predicate মিথ্যা দেখে (সঠিকভাবেই) আবার ঘুমিয়ে পড়ে, আর যাকে জাগানো দরকার ছিল সে কখনো জাগে না। যথেষ্ট waiter জমলে সিস্টেম সম্পূর্ণ থেমে যায় — সব thread ঘুমন্ত, কেউ signal পাঠানোর অবস্থায় নেই।
সমাধান ১ — signal() কে broadcast() করে দিন। এতে সঠিকতা ফিরে আসে (সবাই জাগবে, ভুল দল আবার ঘুমাবে, সঠিক দল এগোবে), কিন্তু দাম আছে: N জন waiter-এর মধ্যে হয়তো একজন এগোতে পারবে, বাকি N−1 জন জেগে, mutex-এর জন্য প্রতিযোগিতা করে, predicate চেক করে, আবার ঘুমাবে। এটাই thundering herd — খরচ যেখানে যথেষ্ট ছিল।
সমাধান ২ — দুইটা condvar (এই লেসনের build যা করে)। প্রতিটা wait queue-তে একটাই predicate, তাই signal() সবসময় “সঠিক দলের” একজনকে জাগায়। সঠিক এবং ।
সিদ্ধান্ত: প্রতি predicate-এ একটা condvar — এটাই নিয়ম। সহকর্মীর ডিজাইন একটা condvar বাঁচায় (৪৮ byte) আর বিনিময়ে হয় একটা deadlock, নয়তো thundering herd দেয়।
সামনের দিকে: পরের লেসনে দেখব এই “সবাই ঘুমন্ত, কেউ জাগানোর নেই” পরিস্থিতিটা আনুষ্ঠানিকভাবে একটা resource allocation graph-এর cycle, আর লেসন ২৮-এ দেখব FUTEX_WAKE-এর count প্যারামিটার (১ বনাম INT_MAX) ঠিক এই signal-বনাম-broadcast সিদ্ধান্তটাই kernel স্তরে প্রকাশ করে।
4আপনি একটা in-memory key-value store ডিজাইন করছেন: ১০২৪ bucket-এর hash table, ৯৫% অপারেশন get, ৫% put। ১৬ core-এর মেশিন, ৬৪টা worker thread। তিনটা locking strategy বিবেচনা করুন — (ক) একটা global mutex, (খ) একটা global rwlock, (গ) ৬৪টা lock-এর striped array — আর যুক্তি দিয়ে একটা বেছে নিন। তারপর বলুন কোন পরিমাপ আপনার সিদ্ধান্ত ভুল প্রমাণ করতে পারে।
ডিজাইন
get, ৫% put। ১৬ core-এর মেশিন, ৬৪টা worker thread। তিনটা locking strategy বিবেচনা করুন — (ক) একটা global mutex, (খ) একটা global rwlock, (গ) ৬৪টা lock-এর striped array — আর যুক্তি দিয়ে একটা বেছে নিন। তারপর বলুন কোন পরিমাপ আপনার সিদ্ধান্ত ভুল প্রমাণ করতে পারে।(ক) Global mutex।
| Scalability | ভয়াবহ — সব ৬৪ thread একটাই lock-এ serialize হবে |
| কার্যকর সমান্তরালতা | ১ core, ১৬ থাকা সত্ত্বেও |
| জটিলতা | সর্বনিম্ন, deadlock অসম্ভব |
Amdahl-এর সূত্র (Level 11-এ আনুষ্ঠানিকভাবে আসবে) সরাসরি প্রযোজ্য: কাজের যে অংশটা serialize হয়, সেটাই সর্বোচ্চ speedup ঠিক করে দেয়। এখানে প্রায় ১০০% অংশ lock-এর ভেতরে, তাই speedup ≈ ১। ৬৪ thread-এর প্রতিটার প্রতিটা অপারেশনে গড়ে ৬৩ জনের পেছনে দাঁড়াতে হবে। বাতিল — যদি না load খুবই কম হয়।
(খ) Global rwlock।
আকর্ষণীয় শোনায়: ৯৫% read, তাই বেশিরভাগ সময় ৬৪ জন একসাথে ঢুকতে পারবে। কিন্তু দুইটা সমস্যা:
- Reader counter-ই নতুন bottleneck। প্রতিটা
rdlockএকই cache line-এ একটা atomic increment করে। ১৬ core থেকে একই ৬৪-byte line-এ atomic RMW মানে ধারাবাহিক cache line transfer — Level 11-এ দেখবেন এর খরচ প্রতি transfer ~১০০ ns, অর্থাৎ কার্যত সবকিছু আবার serialize। hash lookup নিজে হয়তো ৫০ ns; আপনি critical section-এর চেয়ে বেশি খরচ করছেন lock নিতে। - Writer starvation। ৫% write মানে ৬৪ thread × উচ্চ হারে = writer প্রায় কখনোই reader-শূন্য মুহূর্ত পাবে না। glibc-র ডিফল্ট reader-preferring, তাই
putঅনির্দিষ্টকাল আটকে যেতে পারে। অন্ততPTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NPলাগবে।
(গ) ৬৪টা lock-এর striped array — এটাই আমার পছন্দ।
#define NLOCKS 64
struct { pthread_mutex_t m; char pad[64 - sizeof(pthread_mutex_t) % 64]; } locks[NLOCKS];
/* padding গুরুত্বপূর্ণ -- নাহলে একাধিক mutex একই cache line-এ পড়ে false sharing */
static inline int lock_for(uint32_t hash) { return hash & (NLOCKS - 1); }
| প্রত্যাশিত contention | দুইটা এলোমেলো অপারেশন একই lock-এ পড়ার সম্ভাবনা ১/৬৪ ≈ ১.৬% |
| কার্যকর সমান্তরালতা | ~১৬ core (হার্ডওয়্যারের সীমা), lock-এর সীমা নয় |
| Memory | ৬৪ × ৬৪ byte = ৪ KB — নগণ্য |
| Deadlock ঝুঁকি | একটা অপারেশনে একটাই lock নিলে শূন্য |
কেন ৬৪, ১০২৪ নয়? দুইটা কারণ: (১) contention ইতিমধ্যেই core সংখ্যার (১৬) তুলনায় যথেষ্ট কম, আরও lock যোগ করলে contention আর কমে না কিন্তু cache footprint বাড়ে; (২) কম lock মানে resize বা multi-key অপারেশনে কম জটিলতা। সাধারণ heuristic: lock সংখ্যা ≈ ৪× core সংখ্যা।
দুইটা design নিয়ম যা এই পছন্দকে নিরাপদ রাখে:
- একটা অপারেশনে সর্বোচ্চ একটা lock। এটা মানলে deadlock কাঠামোগতভাবে অসম্ভব (পরের লেসনে দেখব কেন: circular wait তৈরিই হতে পারে না)।
- একাধিক lock দরকার হলে (resize, atomic multi-key) — সবসময় index-এর ঊর্ধ্বক্রমে নিন। এটা একটা total order চাপিয়ে দেয়, আর সেটাই cycle প্রতিরোধের প্রামাণ্য উপায়।
কোন পরিমাপ আমার সিদ্ধান্ত ভুল প্রমাণ করবে:
| পরিমাপ | কী বোঝায় | তখন কী করব |
|---|---|---|
perf record দেখাচ্ছে সময়ের >৫% lock cmpxchg-এ | lock নিজেই bottleneck, contention নয় | lock-free পথ ভাবুন (RCU-ধাঁচের read, বা atomic pointer swap) |
| Key distribution তীব্রভাবে skewed (একটা hot key সব ট্র্যাফিকের ৪০%) | striping কাজ করছে না, একটা lock-ই সব ধরছে | সেই key-র জন্য আলাদা ব্যবস্থা, বা per-thread cache |
get critical section আসলে ৫ µs (হিসাব করা মান, শুধু lookup নয়) | rwlock-এর reader-সমান্তরালতা এখন সত্যিই মূল্যবান | (খ)-তে ফিরুন, writer-preference সহ |
| ১৬ thread আর ৬৪ thread-এ throughput একই | scalability সীমা lock-এ নয়, memory bandwidth-এ | thread কমান, লাভ নেই |
সামনের দিকে: Level 6-এর algorithms module lock-free ডেটা স্ট্রাকচার (Treiber stack, Michael-Scott queue) আর তাদের ABA সমস্যা দেখাবে; Level 11 দেখাবে কেন padding ছাড়া এই ৬৪টা mutex আসলে false sharing-এর কারণে একটা global mutex-এর চেয়েও খারাপ পারফর্ম করতে পারে; Level 13-এর cs-theory module linearizability-র আনুষ্ঠানিক সংজ্ঞা দেবে — কোন ফলাফলগুলো একটা concurrent ডেটা স্ট্রাকচারের জন্য “সঠিক” বলে গণ্য হবে সেটার প্রামাণ্য মাপকাঠি।
5আপনার একটা thread pool আছে যেখানে একটা shared task queue আছে। মূল কর্মী-লুপ এরকম:
while (running) {
task_t *t = queue_take(&q); /* খালি হলে condvar-এ ব্লক করে */
if (t) run(t);
}
এখন pool টা shutdown করতে হবে: running = 0 সেট করে সব worker-কে বেরিয়ে যেতে দিতে হবে। একজন সহকর্মী লিখলেন running = 0; pthread_cond_signal(&q.not_empty); — এতে কী কী ভুল আছে? একটা সঠিক shutdown প্রোটোকল লিখুন।
প্রয়োগ
while (running) {
task_t *t = queue_take(&q); /* খালি হলে condvar-এ ব্লক করে */
if (t) run(t);
}running = 0 সেট করে সব worker-কে বেরিয়ে যেতে দিতে হবে। একজন সহকর্মী লিখলেন running = 0; pthread_cond_signal(&q.not_empty); — এতে কী কী ভুল আছে? একটা সঠিক shutdown প্রোটোকল লিখুন।সহকর্মীর দুই লাইনে তিনটা স্বতন্ত্র bug আছে।
Bug ১ — running mutex ছাড়া লেখা হয়েছে (data race)।
running একটা shared variable যা worker-রা mutex ছাড়া পড়ছে। এটা C11-এ আক্ষরিকভাবে undefined behaviour, আর ব্যবহারিকভাবে compiler লুপটাকে এভাবে rewrite করতে পারে:
if (running) { for (;;) { task_t *t = queue_take(&q); if (t) run(t); } }কারণ compiler দেখছে লুপের ভেতরে running বদলায় না (কোনো synchronisation নেই, তাই সে ধরে নেয় অন্য কেউ বদলাচ্ছে না) — এটা একটা সম্পূর্ণ বৈধ optimization, আর ফলাফল একটা অসীম লুপ যা running = 0 কখনো লক্ষ করবে না। পরের লেসনে দেখব data race-এ compiler-এর এই “অসম্ভব দেখতে” স্বাধীনতাটা কতদূর যেতে পারে।
Bug ২ — signal() মাত্র একজনকে জাগায়, কিন্তু N জন worker ঘুমাচ্ছে।
Shutdown মানে সব worker-কে এগোতে দেওয়া — এটা signal-বনাম-broadcast টেবিলের ঠিক সেই সারি: “একটা event একাধিক waiter-কে এগোতে দেয় → broadcast”। একজন জাগবে, বেরিয়ে যাবে; বাকি N−1 জন চিরকাল cond_wait-এ ঘুমিয়ে থাকবে, আর pthread_join() কখনো ফিরবে না। প্রোগ্রাম hang করে exit করবে না।
Bug ৩ — queue_take() কখনো NULL ফেরত দেয় না।
মূল লুপে if (t) চেক আছে, কিন্তু আসল queue_take()-এর while (q->count == 0) cond_wait(...) লুপ কখনো NULL ফেরত দেওয়ার পথই রাখেনি — জেগে উঠে count এখনো শূন্য দেখে সে আবার ঘুমাবে, চিরকাল। shutdown flag টা predicate-এর অংশ হতে হবে, নাহলে জাগানোর কোনো মানে নেই।
সঠিক shutdown প্রোটোকল:
typedef struct {
/* ... buf, head, tail, count ... */
pthread_mutex_t m;
pthread_cond_t not_empty, not_full;
int shutting_down; /* mutex দ্বারা সুরক্ষিত */
} queue_t;
/* worker দিক */
task_t *queue_take(queue_t *q) {
pthread_mutex_lock(&q->m);
/* predicate-এ shutdown অন্তর্ভুক্ত -- এটাই মূল সংশোধন */
while (q->count == 0 && !q->shutting_down)
pthread_cond_wait(&q->not_empty, &q->m);
if (q->count == 0) { /* মানে shutting_down সত্য */
pthread_mutex_unlock(&q->m);
return NULL; /* worker-কে বেরোনোর সংকেত */
}
task_t *t = q->buf[q->head];
q->head = (q->head + 1) % CAP;
q->count--;
pthread_cond_signal(&q->not_full);
pthread_mutex_unlock(&q->m);
return t;
}
/* shutdown দিক */
void pool_shutdown(queue_t *q, pthread_t *workers, int n) {
pthread_mutex_lock(&q->m);
q->shutting_down = 1; /* mutex ধরে লেখা -- Bug ১ সমাধান */
pthread_cond_broadcast(&q->not_empty); /* সবাইকে -- Bug ২ সমাধান */
pthread_cond_broadcast(&q->not_full); /* ব্লকড producer-দেরও */
pthread_mutex_unlock(&q->m);
for (int i = 0; i < n; i++)
pthread_join(workers[i], NULL);
}
/* worker লুপ */
while ((t = queue_take(&q)) != NULL)
run(t);লক্ষ করুন কর্মী-লুপে আলাদা কোনো running চেক আর নেই — queue_take()-এর NULL ফেরতটাই একমাত্র প্রস্থান-সংকেত। এতে “flag চেক আর queue চেক-এর মাঝখানে” নামের গোটা একটা race-শ্রেণি অস্তিত্বহীন হয়ে যায়। এটা একটা সাধারণ নীতির উদাহরণ: একাধিক শর্তকে একটাই predicate-এ, একটাই mutex-এর নিচে একত্র করুন — একাধিক স্বাধীন ফ্ল্যাগ চেক করা মানে একাধিক race window তৈরি করা।
একটা design পছন্দ যা এখানে লুকিয়ে আছে: উপরের কোড drain semantics দেয় — shutdown-এর আগে queue-তে থাকা task-গুলো শেষ করা হয়। আপনি যদি abort semantics চান (সাথে সাথে থামো, বাকি task ফেলে দাও), তাহলে while শর্তটা হবে while (q->count == 0 && !q->shutting_down) এর বদলে প্রথমেই if (q->shutting_down) return NULL;। দুইটাই বৈধ, কিন্তু কোনটা চাই সেটা সচেতনভাবে ঠিক করতে হবে — নীরবে একটা পেয়ে যাওয়া চলবে না।
সামনের দিকে: এই shutdown-প্রোটোকলের প্যাটার্নটা (state flag + broadcast + predicate-এ অন্তর্ভুক্তি) Level 7-এর networking module-এ graceful server shutdown-এ, আর Level 9-এর distributed systems module-এ node drain / leader step-down-এ হুবহু আবার দেখবেন — শুধু scale আলাদা, কাঠামো এক।
এরপর কী
পরের লেসন — Race Conditions ও Deadlock
এই লেসনে আমরা যন্ত্রপাতিগুলো পেলাম, আর একটা কাজ-করা bounded queue বানালাম। কিন্তু দুইটা প্রশ্ন ইচ্ছাকৃতভাবে খোলা রেখে এসেছি।
প্রথমটা: counter++ আসলে কেন হারায়? “তিনটা instruction” বলে পাশ কাটিয়ে গেছি, কিন্তু আসল গল্পটা আরও গভীর — C11-এ একটা data race শুধু “অপ্রত্যাশিত মান” দেয় না, এটা undefined behaviour, যা compiler-কে এমন সব রূপান্তরের অনুমতি দেয় যা দেখে মনে হবে হার্ডওয়্যার নিয়ম ভাঙছে। প্রশ্ন ৫-এ shutdown flag-এর যে infinite-loop সমস্যাটা দেখলেন, সেটা তার একটা ছোট নমুনা মাত্র।
দ্বিতীয়টা, আর এটাই বড়: এই লেসনে বারবার বলেছি “একাধিক lock নিলে বিপদ”, “সবসময় একই ক্রমে নিন” — কিন্তু কেন, আর কীভাবে সেটা নিশ্চিত করবেন, তা বলিনি। পরের লেসনে Coffman-এর চারটা শর্ত দেখব — deadlock ঘটতে হলে চারটাই একসাথে সত্য হতে হয়, তাই যেকোনো একটা ভাঙলেই deadlock অসম্ভব। আর সবচেয়ে সুন্দর অংশটা: আমরা Level 0-এর mathematics/relations লেসনে ফিরে যাব, কারণ deadlock আসলে একটা resource allocation graph-এর cycle, আর lock-এর উপর একটা total order চাপানো মানে সেই graph-কে কাঠামোগতভাবে acyclic করে দেওয়া। গণিতটা আগেই শেখা হয়ে গেছে; শুধু প্রয়োগটা বাকি।
সাথে হাতে-কলমে: একটা নির্ভরযোগ্য ABBA deadlock বানিয়ে gdb দিয়ে thread backtrace দেখে ধরা, ThreadSanitizer দিয়ে একটা race একটামাত্র রান থেকে ধরে ফেলা, আর একটা symlink swap দিয়ে TOCTOU-র জানালা দেখানো। শেষে build হিসেবে একটা deadlock detector — একটা lock wrapper যা প্রতিটা thread-এর acquisition order রেকর্ড করে, lock-order graph বানায়, আর cycle পেলে deadlock ঘটার আগেই সতর্ক করে। Linux kernel-এর lockdep ঠিক এই কাজটাই করে, আর আপনি সেটার একটা কাজ-করা ছোট সংস্করণ নিজের হাতে বানাবেন।
আরও পড়ুন
- Operating Systems: Three Easy Pieces — Concurrency (অধ্যায় ২৬–৩২) — Remzi H. Arpaci-Dusseau, Andrea C. Arpaci-Dusseau · Locks, condition variables, semaphores — প্রতিটার জন্য আলাদা অধ্যায়, বিনামূল্যে PDF; এই লেসনের bounded buffer উদাহরণ ওখানকার আলোচনার সরাসরি সম্প্রসারণ
- The Little Book of Semaphores — Allen B. Downey · শুধু semaphore দিয়ে ডজনখানেক ক্লাসিক synchronization puzzle সমাধান — semaphore-কে signalling primitive হিসেবে দেখার সবচেয়ে ভালো উৎস
- POSIX pthread_cond_wait() স্পেসিফিকেশন (IEEE Std 1003.1) — The Open Group · spurious wakeup যে স্পেসিফিকেশন-অনুমোদিত, সেই বাক্যটা এখানে আক্ষরিকভাবে লেখা আছে — while loop-এর প্রামাণ্য যুক্তি
- Programming with POSIX Threads — David R. Butenhof · pthreads API-র ধ্রুপদী রেফারেন্স; predicate + condvar-এর সম্পর্ক, আর signal বনাম broadcast-এর নিয়ম এখানে সবচেয়ে পরিষ্কারভাবে বলা