এই পর্বে যা শিখবেন
- আধুনিক হার্ডওয়্যারে Peterson-এর মতো শুধু-সফটওয়্যার সমাধান কেন ভাঙে, আর memory barrier কী করে
- Test-and-set, compare-and-swap (x86 আর ARM-এর আসল instruction) আর fetch-and-add দিয়ে lock বানানো, আর তাদের সঠিকতার প্রমাণ
- TAS lock-এ bounded waiting কেন নেই, বইয়ের bounded-waiting TAS অ্যালগরিদম, আর ticket lock-এর FIFO ন্যায্যতা
- C11, C++ আর Java-র atomic variable; spinlock কখন ভালো, TTAS, backoff,
pause, আর Linux kernel-এর spinlock - Sequential consistency বনাম x86-TSO, store-buffering litmus test নিজে চালানো, acquire/release, আর
volatile-এর ভুল ধারণা
১ · শুধু সফটওয়্যার কেন যথেষ্ট নয় — reordering আর memory barrier
পর্ব ২২-এ দেখেছেন, একটি ভালো critical section সমাধানের তিনটি শর্ত: mutual exclusion (একসাথে একজনই ভেতরে), progress (ভেতরে কেউ না থাকলে ঢুকতে চাওয়া কাউকে অনির্দিষ্টকাল আটকে রাখা যাবে না) আর bounded waiting (ঢুকতে চাওয়ার পর অন্যরা সীমিত সংখ্যকবারই আগে ঢুকতে পারবে)। সেখানে Peterson-এর সমাধান শুধু সাধারণ load আর store দিয়ে দুটি থ্রেডের জন্য তিনটি শর্তই পূরণ করে। মনে করিয়ে দিই, থ্রেড i-এর প্রবেশ-অংশ:
flag[i] = true; // "I want to enter"
turn = j; // "but you go first"
while (flag[j] && turn == j)
; // busy wait
/* critical section */
flag[i] = false;এর প্রমাণে একটি নীরব অনুমান আছে: প্রতিটি থ্রেডের load আর store মেমরিতে ঠিক কোডের ক্রমে ঘটে, আর সব থ্রেড একই ক্রম দেখে। আধুনিক কম্পিউটারে এই অনুমান তিন জায়গায় ভাঙে:
- Compiler ক্রম বদলায়: একক থ্রেডের চোখে
flag[i] = trueআরflag[j]পড়া স্বাধীন — আলাদা ঠিকানা। তাই optimizer পড়াটিকে আগে আনতে পারে, বা লুপের ভেতরেরflag[j]একবার পড়ে register-এ রেখে দিতে পারে। - CPU ক্রম বদলায়: x86-এর প্রতিটি core নিজের লেখা প্রথমে একটি ছোট সারিতে রাখে — store buffer. লেখাটি অন্য core-এর কাছে পৌঁছানোর আগেই পরের load মেমরি থেকে পড়ে ফেলে। ফলে T0 পড়ে
flag[1] == false, T1 পড়েflag[0] == false— দুজনেই critical section-এ! অংশ ৮-এ এটি নিজের চোখে দেখবেন। - একাধিক core: একটি core-এর যুগে সবচেয়ে পুরোনো kernel কৌশল ছিল interrupt বন্ধ করা (x86-এ
cli): interrupt না এলে preemption নেই, তাই critical section নিরাপদ। কিন্তু multicore-এ অন্য core তো চলছেই; তা ছাড়াcliএকটি privileged instruction, user program পারে না, আর বেশিক্ষণ interrupt বন্ধ রাখলে timer আর ডিভাইসের সংকেত দেরিতে পৌঁছায়।
রেস্তোরাঁর রান্নাঘরে একটিই চুলা, আর দেয়ালে একটি বোর্ড — “চুলা ব্যস্ত?” দুই বাবুর্চি একই মুহূর্তে বোর্ডের দিকে তাকালেন: “খালি।” দুজনেই চক তুলে “ব্যস্ত” লিখতে গেলেন, আর দুজনেই হাঁড়ি চাপালেন। সমস্যা হলো “দেখা” আর “লেখা” দুটি আলাদা কাজ, মাঝে ফাঁক। আরও খারাপ: প্রত্যেকে নিজের লেখা প্রথমে নিজের হাতের চিরকুটে লেখেন (store buffer), বোর্ডে তোলেন পরে। হার্ডওয়্যারের সমাধান হলো এমন একটি তালা, যা ঘোরালে একই সাথে দেখা আর দখল হয় — মাঝে কোনো ফাঁক নেই।
Memory barrier — ক্রম বদলানোর দেয়াল
Memory barrier (বা fence) এমন একটি instruction, যা বলে: “এর আগের মেমরির কাজ শেষ না হওয়া পর্যন্ত পরেরগুলো শুরু হবে না” — অন্তত নির্দিষ্ট কিছু ধরনের ক্ষেত্রে। x86-এ পূর্ণ fence হলো mfence (আর যেকোনো lock-যুক্ত instruction-ও পূর্ণ fence-এর কাজ করে); ARMv8-এ dmb ish. Compiler-কেও আলাদা করে থামাতে হয় — GCC-তে asm volatile("" ::: "memory") একটি compiler barrier, যা কোনো CPU instruction তৈরি করে না, শুধু compiler-কে ক্রম বদলাতে নিষেধ করে। C11-এ দুটো একসাথে পাওয়া যায় atomic_thread_fence(memory_order_seq_cst) দিয়ে।
Peterson-এর turn = j-এর পরে একটি পূর্ণ fence বসালে x86-এ সে আবার সঠিক হয়। তবু বাস্তবে কেউ Peterson ব্যবহার করে না: এটি দুই থ্রেডের জন্য (n থ্রেডে অনেক জটিল), প্রতিটি থ্রেডের নম্বর জানা থাকতে হয়, আর fence-এর খরচ দিতেই হয়। তার চেয়ে সরল আর দ্রুত পথ — হার্ডওয়্যারকে দিয়েই একটি ঠিকানায় পড়া আর লেখা একটি অবিভাজ্য ধাপে করানো। এমন instruction-কে বলে read-modify-write (RMW) atomic instruction. পরের তিনটি অংশ তাদের নিয়ে।
২ · Test-and-set — দেখা আর দখল এক ধাপে
Test-and-set (TAS) একটি ঠিকানার পুরোনো মান ফেরত দেয় আর সেখানে true লিখে দেয় — দুটোই একটি অবিভাজ্য ধাপে। অর্থাৎ অন্য কোনো core মাঝখানে ওই ঠিকানা পড়তে বা লিখতে পারে না। C-র ভাষায় এর অর্থ (হার্ডওয়্যার এটি পুরোটা atomically করে):
bool test_and_set(bool *target) { /* executed atomically */
bool old = *target;
*target = true;
return old;
}
/* a TAS lock */
void lock(bool *L) { while (test_and_set(L)) ; /* spin */ }
void unlock(bool *L) { *L = false; }x86-এ এটি সাধারণত xchg — মেমরির সাথে register অদলবদল, যার lock নিজে থেকেই লাগানো থাকে। C11-এ portable রূপ হলো atomic_flag: atomic_flag_test_and_set(&f) আর atomic_flag_clear(&f). মানদণ্ড অনুযায়ী atomic_flag-ই একমাত্র ধরন, যা সব প্ল্যাটফর্মে lock ছাড়া (lock-free) হওয়ার নিশ্চয়তা পায়।
সঠিকতার প্রমাণ — mutual exclusion আর progress
- Mutual exclusion: কোনো থ্রেড ভেতরে ঢোকে কেবল তখন, যখন তার TAS 0 ফেরত দেয়। TAS 0 ফেরত দিলে সেই একই ধাপে L = 1 হয়ে যায়। এরপর আরেকটি TAS 0 পেতে হলে মাঝে কাউকে L = 0 লিখতে হবে — আর সেটি লেখে শুধু ভেতরের থ্রেড, বেরোনোর সময়। তাই যেকোনো মুহূর্তে ভেতরে সর্বোচ্চ একজন। ∎
- Progress: L = 0 অবস্থায় এক বা একাধিক থ্রেড চেষ্টা করলে, তাদের মধ্যে প্রথম যে TAS ঘটে সেটি 0 পায়, তাই কেউ না কেউ ঢোকে। ∎
নিচে দুটি থ্রেড একটি TAS lock নিয়ে প্রতিযোগিতা করছে। খেয়াল করুন T2 কীভাবে ঘুরতে থাকে, আর শেষের দিকে কী অন্যায় ঘটে:
1lock: while (test_and_set(&L)) ; // spin2 /* critical section */3unlock: L = 0;
| L (মেমরি) | |
| T1 | |
| T2 | |
| T2-এর মোট TAS |
- শুরু: L = 0. দুটি থ্রেড প্রায় একসাথে
lock()ডাকল। Peterson-এর মতো কোনোflagবাturnনেই — একটিমাত্র শব্দই পুরো lock. - T1-এর TAS আগে পৌঁছাল। হার্ডওয়্যার একটি ধাপে পুরোনো মান 0 পড়ল আর 1 লিখল। T1 পেল 0, তাই লুপ শেষ — T1 ভেতরে। দুটি TAS একসাথে এলে হার্ডওয়্যার (cache coherence) তাদের একটি ক্রমে সাজায়; কোনটি আগে, তা আমরা ঠিক করি না।
- T2-এর TAS: পুরোনো মান 1. T2 পেল 1, আর L-এ আবার 1 লেখা হলো (মান বদলায়নি)। T2 লুপের ভেতরে থেকে আবার চেষ্টা করবে — একে বলে busy waiting, বা spin.
- T2 ঘুরছে। আরও দুটি TAS, দুটিতেই 1 — মোট ৩টি। প্রতিটি চেষ্টায় CPU-র পুরো সময় খরচ হচ্ছে, অথচ কোনো কাজ হচ্ছে না। T1 ভেতরে নিজের কাজ করছে।
- T1 unlock করল: L = 0. T1 সাধারণ একটি store-এ 0 লিখল, আর লুপের পরের ঘুরে আবার
lock()ডাকল। T2 এই মুহূর্তে তার পরের TAS-এর পথে। - অন্যায়: T1 আবার জিতল। T1-এর নতুন TAS T2-এর TAS-এর আগে পৌঁছাল (বাস্তবে এটি খুব সম্ভব: line-টি সবে T1-এর core-এর cache-এ লেখা হয়েছে)। T1 পেল 0, আবার ভেতরে; T2 পেল 1, মোট ৪টি TAS. T2 আগে থেকে অপেক্ষা করছিল, তবু ঢুকল না।
- শেষে T2. T1 আবার unlock করল, এবার T2-এর TAS আগে পৌঁছাল আর 0 পেল (মোট ৫টি)। এই ফলটি ভাগ্যের। ধাপ ৫–৬-এর মতো ঘটনা অনির্দিষ্টকাল চলতে পারে — নিচের প্রমাণ দেখুন।
প্রমাণ: TAS lock-এ bounded waiting নেই
Bounded waiting মানে একটি নির্দিষ্ট সীমা k আছে: একটি থ্রেড ঢুকতে চাওয়ার পর অন্যরা সর্বোচ্চ k বার আগে ঢুকতে পারবে। TAS lock-এর জন্য এমন কোনো k নেই — একটি বৈধ execution দেখালেই যথেষ্ট:
- T2 প্রবেশ-লুপে আছে। T1 ভেতরে, L = 1.
- চিরকাল পুনরাবৃত্তি: T2-এর একটি TAS (পায় 1) → T1 unlock করে (L = 0) → T1-এর TAS (পায় 0, ঢোকে) → আবার T2-এর TAS (পায় 1)…
- এই execution-এ T2 অসীমবার ধাপ নেয়, তাই scheduler তাকে বঞ্চিত করছে না; প্রতিটি ধাপ অ্যালগরিদমের নিয়ম মেনে চলে। তবু T1 অসীমবার ঢোকে, আর T2 একবারও না।
যেকোনো k-এর জন্য এই execution-এ T1 k + ১ বার ঢোকে T2-এর আগে, তাই কোনো সীমা টেকে না। ∎ মূল কারণ: lock-টি শুধু “খালি না ভরা” মনে রাখে, কে কতক্ষণ অপেক্ষা করছে তা নয়। এটি শুধু তাত্ত্বিক নয় — যে core সবে lock ছেড়েছে, তার cache-এ line-টি সবচেয়ে কাছে, তাই সে প্রায়ই আবার জেতে।
বইয়ের সমাধান — bounded-waiting TAS অ্যালগরিদম
Silberschatz-দের পাঠ্যবইয়ে n থ্রেডের জন্য একটি সংস্করণ আছে, যেখানে প্রতিটি থ্রেডের একটি waiting[i] ঘর থাকে, আর বেরোনোর সময় থ্রেডটি lock ছেড়ে না দিয়ে সরাসরি পরের অপেক্ষারত থ্রেডকে হাতে তুলে দেয়:
waiting[i] = true;
key = true;
while (waiting[i] && key)
key = test_and_set(&lock);
waiting[i] = false;
/* critical section */
j = (i + 1) % n;
while (j != i && !waiting[j]) // look for the next waiter, in circle
j = (j + 1) % n;
if (j == i) lock = false; // nobody waiting: open the lock
else waiting[j] = false; // hand over directly to thread j- Mutual exclusion: থ্রেড i লুপ থেকে বেরোয় কেবল
key == false(TAS 0 পেয়েছে) বাwaiting[i] == false(কেউ হাতে তুলে দিয়েছে) হলে। প্রথম প্রবেশে শুধু একজনই TAS-এ 0 পায়। এরপর প্রতিটি বেরোনো থ্রেড ঠিক একটি কাজ করে — হয়lock = false, নয় একজনেরwaiting[j] = false— অর্থাৎ ঢোকার “অনুমতিপত্র” একটিই, হাতবদল হয়। - Progress: বেরোনো থ্রেড হয় lock খুলে দেয়, নয় একজন অপেক্ষারতকে সরাসরি ঢুকিয়ে দেয়; তাই কেউ অপেক্ষা করলে সিস্টেম আটকে থাকে না।
- Bounded waiting: বেরোনো থ্রেড i + ১, i + ২, … এই বৃত্তাকার ক্রমে খোঁজে। তাই অপেক্ষারত একটি থ্রেডের আগে সর্বোচ্চ n − ১ জন ঢুকতে পারে। ∎
৩ · Compare-and-swap আর LL/SC — মান না বদলালে তবেই লেখা
TAS শুধু 1 লিখতে জানে। Compare-and-swap (CAS) বেশি সাধারণ: ঠিকানাটিতে যদি এখনো expected থাকে, তবেই new লেখা হয়, আর ফেরত মান জানায় লেখা সফল হলো কি না — সবটা একটি অবিভাজ্য ধাপে।
bool CAS(int *addr, int expected, int new) { /* executed atomically */
if (*addr != expected) return false; /* someone changed it */
*addr = new;
return true;
}x86 — lock cmpxchg
x86-এ CAS এসেছে ১৯৮৯ সালের Intel 80486-এর cmpxchg দিয়ে। এর নিয়ম: তুলনার মান আগে থেকে eax (বা rax) register-এ রাখতে হয়। মেমরির মান eax-এর সমান হলে নতুন মান লেখা হয় আর ZF flag 1 হয়; সমান না হলে মেমরির আসল মান eax-এ এনে দেওয়া হয় আর ZF 0 হয়। একাধিক core-এ অবিভাজ্য করতে সামনে lock prefix লাগে। দুই শব্দ একসাথে বদলানোর জন্য আছে cmpxchg8b আর x86-64-এ cmpxchg16b.
mov eax, 0 ; expected = 0 (lock free)
mov ecx, 1 ; new = 1
lock cmpxchg [rdi], ecx ; if [rdi] == eax: [rdi] = ecx, ZF = 1
; else: eax = [rdi], ZF = 0
jnz busy ; ZF == 0 -> someone holds itARM — load-linked / store-conditional (ldxr/stxr)
ARM, RISC-V আর POWER-এর মতো RISC নকশায় একটি instruction-এ “পড়া + তুলনা + লেখা” রাখা হয়নি। তার জায়গায় একটি জোড়া: load-linked/store-conditional. ARMv8-এ ldxr (load exclusive) মান পড়ে, আর সঙ্গে সঙ্গে ওই ঠিকানার ওপর একটি exclusive monitor চালু করে — “এই ঠিকানায় নজর রাখছি”। পরে stxr (store exclusive) লিখতে চেষ্টা করে, কিন্তু সফল হয় কেবল যদি মাঝখানে নজরদারি ভাঙেনি — অর্থাৎ অন্য কেউ ওই অংশে লেখেনি (মাঝে context switch-এর মতো ঘটনাও নজর ভাঙতে পারে)। stxr একটি status register-এ 0 (সফল) বা 1 (ব্যর্থ) লেখে; ব্যর্থ হলে প্রোগ্রাম আবার শুরু থেকে চেষ্টা করে।
retry:
ldaxr w1, [x0] // w1 = *x0 (acquire), start watching x0
cmp w1, w2 // equal to expected (w2)?
b.ne fail // no: give up, w1 holds the current value
stlxr w4, w3, [x0] // try *x0 = w3 (release); w4 = 0 ok, 1 failed
cbnz w4, retry // watch was broken -> try againদুটি পার্থক্য মনে রাখুন। প্রথমত, stxr “বিনা কারণে” ব্যর্থ হতে পারে, এমনকি মান না বদলালেও; তাই C11/C++-এ আছে compare_exchange_weak (মাঝে মাঝে মিথ্যা ব্যর্থতা মেনে নেয়, লুপের ভেতরে দ্রুত) আর compare_exchange_strong (মিথ্যা ব্যর্থতা নেই)। দ্বিতীয়ত, ARMv8.1-এর LSE (Large System Extensions) থেকে ARM-এও একক instruction আছে — cas, ldadd, swp — কারণ অনেক core-এর সার্ভারে LL/SC-এর লুপ বারবার ব্যর্থ হতে পারে।
CAS দিয়ে lock আর যেকোনো atomic হালনাগাদ
#include <stdatomic.h>
static atomic_int L = 0; /* 0 = free, 1 = held */
void lock(void) {
int expected = 0;
while (!atomic_compare_exchange_weak(&L, &expected, 1))
expected = 0; /* on failure, expected was overwritten with L */
}
void unlock(void) { atomic_store(&L, 0); }
/* CAS loop: atomically keep the maximum ever seen */
void update_max(atomic_int *m, int v) {
int cur = atomic_load(m);
while (cur < v && !atomic_compare_exchange_weak(m, &cur, v))
; /* cur now holds the fresh value; re-check */
}দ্বিতীয় ফাংশনটি CAS-এর আসল শক্তি দেখায়: পড়া → নতুন মান হিসাব → CAS দিয়ে লেখার চেষ্টা → মাঝে কেউ বদলে থাকলে আবার শুরু — এই ধাঁচে যেকোনো হালনাগাদ অবিভাজ্য করা যায়। Lock-free stack আর queue এভাবেই বানানো হয়। তবে একটি ফাঁদ আছে: মান A থেকে B হয়ে আবার A হলে CAS টের পায় না (ABA সমস্যা)। এটি আর lock-free নকশা পর্ব ২৯-এ।
| Instruction | কী করে | x86-64 | ARMv8 | C11 |
|---|---|---|---|---|
| Test-and-set / exchange | নতুন মান লেখে, পুরোনোটি ফেরত দেয় | xchg | ldxr/stxr লুপ, বা swp | atomic_flag_test_and_set, atomic_exchange |
| Compare-and-swap | মান expected হলে তবেই new লেখে | lock cmpxchg | ldxr/stxr লুপ, বা cas | atomic_compare_exchange_* |
| Fetch-and-add | যোগ করে, পুরোনো মান ফেরত দেয় | lock xadd | ldxr/stxr লুপ, বা ldadd | atomic_fetch_add |
৪ · Fetch-and-add আর ticket lock — টোকেন নিয়ে লাইনে দাঁড়ানো
Fetch-and-add (FAA) একটি ঠিকানায় একটি সংখ্যা যোগ করে আর যোগের আগের মান ফেরত দেয় — অবিভাজ্যভাবে। x86-এ এটি lock xadd. পর্ব ১৫-এর lost update-এর সবচেয়ে সরাসরি সমাধান এটিই: counter++-এর load-add-store তিন ধাপ হার্ডওয়্যার একটি ধাপে করে দেয়।
ব্যাংকের টোকেন-কাউন্টার ভাবুন। দরজায় একটি টোকেন মেশিন: চাপলে পরের নম্বর বেরোয় (fetch-and-add — একসাথে দুজন চাপলেও দুটি আলাদা নম্বর)। দেয়ালের পর্দায় লেখা “এখন সেবা চলছে: ৪৭”। আপনার টোকেন ৪৯ হলে আপনি জানেন, আপনার আগে ঠিক দুজন। কেউ লাইন ভাঙতে পারে না, আর দেরিতে এলে পেছনেই দাঁড়াতে হয় — TAS lock-এর মতো “যে আগে ধাক্কা দেয় সে জেতে” নয়।
static atomic_uint next_ticket = 0; /* the token machine */
static atomic_uint now_serving = 0; /* the display board */
void lock(void) {
unsigned my = atomic_fetch_add(&next_ticket, 1); /* take a token */
while (atomic_load(&now_serving) != my)
; /* wait for my number */
}
void unlock(void) {
atomic_fetch_add(&now_serving, 1); /* call the next token */
}তিনটি থ্রেড A, B, C একটি ticket lock নিয়ে কাজ করছে — নম্বর আর পর্দা ধাপে ধাপে দেখুন:
1lock: my = fetch_add(&next_ticket, 1);2 while (now_serving != my) pause;3 /* critical section */4unlock: now_serving++; // only the holder
| next_ticket | |
| now_serving | |
| A | |
| B | |
| C | |
| অপেক্ষার সারি |
- শুরু। next_ticket = 0, now_serving = 0. কেউ lock চায়নি। দুটি সংখ্যা সমান মানে lock খালি।
- A টোকেন নিল: my = 0.
fetch_addপুরোনো মান 0 ফেরত দিল আর next_ticket হলো 1. now_serving = 0 = my, তাই A এক মুহূর্তও না ঘুরে ভেতরে। - B টোকেন নিল: my = 1. next_ticket = 2. now_serving এখনো 0, তাই B ঘোরে — পর্দার দিকে তাকিয়ে থাকে। সে কখনো lock-এ লেখে না, শুধু পড়ে।
- C টোকেন নিল: my = 2. next_ticket = 3. সারিতে এখন B, তারপর C — fetch_add-এর ক্রমেই। দুজন একই নম্বর পেতে পারে না, কারণ fetch_add অবিভাজ্য।
- A unlock করল: now_serving = 1. পর্দায় 1 — শুধু B-এর নম্বর মেলে, তাই B ঢোকে। C এখনো অপেক্ষায়। মুক্তি দিল শুধু ভেতরের থ্রেড, তাই now_serving-এ একসাথে দুজন লেখে না।
- A আবার এল: my = 3. TAS lock-এ A সঙ্গে সঙ্গে আবার জিততে পারত। এখানে A টোকেন 3 নিয়ে C-এর পেছনে দাঁড়াল (next_ticket = 4) — আগে এলে আগে সেবা।
- B বেরোল: now_serving = 2. এবার C-এর পালা। লক্ষ করুন, C ঢুকতে চাওয়ার পর থেকে তার আগে ঢুকেছে শুধু B — একজন।
- C বেরোল: now_serving = 3 → A. ঢোকার ক্রম A, B, C, A — হুবহু টোকেনের ক্রম। দ্বিতীয়বার আসা A অপেক্ষা করেছে C-এর জন্য, মোট n − ১ = ২ জনের বেশি কখনো নয়।
প্রমাণ: mutual exclusion, FIFO আর bounded waiting
- অনন্য টোকেন: fetch_add অবিভাজ্য, তাই প্রতিটি কল আলাদা মান পায়, আর মানগুলো কল হওয়ার ক্রমে বাড়ে।
- Mutual exclusion: ভেতরে থাকার শর্ত
now_serving == my. যেকোনো মুহূর্তে now_serving একটিই সংখ্যা, আর সেই নম্বরের টোকেন একজনেরই। now_serving বদলায় শুধু ভেতরের থ্রেডের unlock-এ। ∎ - FIFO আর bounded waiting: now_serving একবারে ১ করে বাড়ে, তাই টোকেন t-ধারী ঢোকে ঠিক t − (তার আসার সময়ের now_serving) জন বেরোনোর পরে। একসাথে n থ্রেড থাকলে এই সংখ্যা সর্বোচ্চ n − ১. ∎
- সংখ্যা ঘুরে গেলে?
unsigned232-এ পৌঁছে 0-তে ফেরে, কিন্তু তুলনা শুধু সমতার, তাই একসাথে 232-এর কম থ্রেড অপেক্ষা করলে কোনো সমস্যা নেই।
ticket lock-এর দুটি দুর্বলতাও আছে। সব অপেক্ষারত core একই now_serving পড়ে, তাই প্রতিটি unlock সবার cache-এর কপি বাতিল করে (অংশ ৬)। আর ন্যায্যতার উল্টো পিঠ: পরের টোকেনধারী থ্রেডটি যদি সেই মুহূর্তে CPU-তে না থাকে (scheduler সরিয়ে দিয়েছে), তবে lock খালি থাকলেও কেউ ঢুকতে পারে না — পেছনের সবাই অপেক্ষা করে। অংশ ৬-এর পরীক্ষায় এটি সত্যিই দেখা যাবে।
৫ · Atomic variable — C11, C++ আর Java-তে
প্রতিদিনের কোডে assembly লিখতে হয় না। ভাষাগুলো atomic variable দেয়: একটি সাধারণ ভ্যারিয়েবলের মতো দেখতে, কিন্তু তার প্রতিটি পড়া, লেখা আর হালনাগাদ অবিভাজ্য, আর compiler সেগুলো সরিয়ে বা মিশিয়ে ফেলে না। C11-এ <stdatomic.h> আর _Atomic কীওয়ার্ড (atomic_int হলো _Atomic int), C++11-এ std::atomic<T>, Java 5 (২০০৪) থেকে java.util.concurrent.atomic.
পর্ব ১৫-এর lost update আবার দেখি, এবার ৪টি থ্রেডে। প্রত্যেকে ১০ লাখবার যোগ করে — একবার সাধারণ int-এ, একবার atomic_fetch_add দিয়ে:
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#define T 4 /* threads */
#define N 1000000 /* increments per thread */
static int plain = 0; /* ordinary int: shared, unprotected */
static atomic_int counter = 0; /* C11 atomic int */
static void *inc_plain(void *arg) {
(void)arg;
for (int i = 0; i < N; i++) plain++; /* load, add, store */
return NULL;
}
static void *inc_atomic(void *arg) {
(void)arg;
for (int i = 0; i < N; i++) atomic_fetch_add(&counter, 1); /* one RMW */
return NULL;
}
static void run(void *(*fn)(void *)) {
pthread_t t[T];
for (int i = 0; i < T; i++) pthread_create(&t[i], NULL, fn, NULL);
for (int i = 0; i < T; i++) pthread_join(t[i], NULL);
}
int main(void) {
run(inc_plain);
run(inc_atomic);
printf("expected : %d\n", T * N);
printf("plain int : %d\n", plain);
printf("atomic_fetch_add: %d\n", atomic_load(&counter));
return 0;
}একটি আসল রানের আউটপুট (দ্বিতীয় সারির সংখ্যাটি প্রতিবার বদলায়):
expected : 4000000
plain int : 1346132
atomic_fetch_add: 4000000
সাধারণ int-এ ৪০ লাখের জায়গায় এসেছে মাত্র ১৩,৪৬,১৩২ — ২৬ লাখের বেশি যোগ হারিয়েছে (আরেকটি রানে এসেছিল প্রায় ২৬ লাখ ৮৬ হাজার)। Atomic সংস্করণ প্রতিবার ঠিক ৪০,০০,০০০. কোডে পার্থক্য একটি শব্দের, কিন্তু machine code-এ বড়: plain++ হলো আলাদা load, add, store; আর atomic_fetch_add x86-64-এ একটি lock xadd (ফেরত মান ব্যবহার না হলে gcc প্রায়ই lock add দেয়)। lock চলার সময় core-টি cache line-এর একচ্ছত্র মালিকানা ধরে রাখে, তাই অন্য কেউ মাঝখানে ঢুকতে পারে না। দুটি থ্রেড-দল আলাদা সময়ে চলেছে (প্রথমটির join শেষে দ্বিতীয়টি), তাই একটি আরেকটিকে প্রভাবিত করেনি। মনে রাখুন, সাধারণ int-এর অংশটি একটি data race, যা C-তে undefined behavior — শেখানোর জন্যই রাখা।
#include <atomic>
std::atomic<int> hits{0};
hits.fetch_add(1, std::memory_order_relaxed); // a counter needs no ordering
int seen = hits.load(); // default: seq_cst
std::atomic<bool> busy{false};
bool expected = false;
if (busy.compare_exchange_strong(expected, true)) { /* we got it */ }
static_assert(std::atomic<long>::is_always_lock_free); // C++17C++-এ std::atomic<T> যেকোনো trivially-copyable ধরনের জন্য চলে, কিন্তু বড় struct হলে ভেতরে আসলে একটি lock ব্যবহার হতে পারে — is_lock_free() দিয়ে দেখে নিন। C++20 এনেছে atomic::wait/notify_one (Linux-এ সাধারণত futex-এর ওপর) আর std::atomic_ref.
import java.util.concurrent.atomic.*;
AtomicInteger hits = new AtomicInteger();
hits.incrementAndGet(); // atomic ++
hits.compareAndSet(5, 0); // CAS: if 5 then 0
LongAdder fast = new LongAdder(); // many writers: spread over cells
fast.increment();
long total = fast.sum();Java-র AtomicInteger CAS-ভিত্তিক; JVM এটিকে x86-এ lock xadd/lock cmpxchg-এ নামিয়ে আনে। খুব বেশি থ্রেড একটি counter-এ লিখলে Java 8-এর LongAdder ভালো: প্রতিটি থ্রেড আলাদা ঘরে যোগ করে, পড়ার সময় সব যোগ হয় — একই cache line নিয়ে টানাটানি কম।
#include <windows.h>
volatile LONG hits = 0;
InterlockedIncrement(&hits); /* lock xadd on x86 */
LONG old = InterlockedCompareExchange(&hits, 0, 5); /* if hits == 5 then 0 */Win32-এ atomic কাজ হয় Interlocked* পরিবারের ফাংশনে। এখানে volatile শুধু ফাংশনের ঘোষণার সাথে মেলানোর জন্য; অবিভাজ্যতা দেয় Interlocked ফাংশন নিজে। MSVC সাম্প্রতিক সংস্করণে C11-এর <stdatomic.h>-এর সমর্থন আনছে (শুরুতে পরীক্ষামূলক switch দিয়ে); নিজের compiler-এর ডকুমেন্টেশন দেখে নিন।
Atomic জাদু নয়, খরচ আছে। একই cache line-এ অনেক core একসাথে atomic যোগ করলে line-টি core থেকে core-এ ঘুরে বেড়ায়, আর প্রতিটি যোগ শত শত cycle নিতে পারে। তাই Linux kernel-এর পরিসংখ্যান-গণনা প্রায়ই per-CPU counter: প্রতিটি CPU নিজের ঘরে যোগ করে, পড়ার সময় সব ঘর যোগ হয়।
৬ · Spinlock — কখন ঘোরা ভালো, কখন ঘুমানো
এতক্ষণের TAS, CAS আর ticket lock — সবাই spinlock: lock না পেলে থ্রেড ঘুমায় না, লুপে ঘুরে বারবার দেখে। বিকল্প হলো ঘুমানো: kernel-কে জানিয়ে রাখা, lock খুললে যেন জাগিয়ে দেয় (পর্ব ২৪-এর mutex আর semaphore). কোনটি ভালো, তা নির্ভর করে অপেক্ষা কতক্ষণ, তার ওপর।
| পরিস্থিতি | Spin করা | কারণ |
|---|---|---|
| Critical section খুব ছোট (কয়েকশো ns), একাধিক core | ভালো | Holder অন্য core-এ চলছে, শিগগির ছাড়বে; ঘুমিয়ে-জেগে দুটি context switch-এর খরচ (কয়েক µs) তার চেয়ে বেশি |
| Interrupt handler বা যেখানে ঘুমানো নিষেধ | একমাত্র পথ | Interrupt context-এর কোনো “থ্রেড” নেই যাকে ঘুম পাড়িয়ে পরে জাগানো যায় |
| Uniprocessor (একটি core) | খারাপ | Holder চলতে পারে না যতক্ষণ spinner CPU ধরে আছে — পুরো time slice নষ্ট |
| লম্বা critical section, বা ভেতরে I/O | খারাপ | অপেক্ষার পুরো সময় CPU পুড়ে, অথচ অন্য কাজ চলতে পারত |
| Core-এর চেয়ে বেশি থ্রেড (oversubscription) | খুব খারাপ | Holder বা পরের টোকেনধারী CPU-তে না থাকলে সবাই ঘোরে |
একটি সহজ হিসাব: ঘুমিয়ে জাগার খরচ মোটামুটি দুটি context switch (ধরা যাক C). অপেক্ষা যদি C-এর চেয়ে কম হয়, ঘোরাই সস্তা। অজানা হলে একটি প্রচলিত কৌশল — প্রায় C সময় পর্যন্ত ঘুরে তারপর ঘুমানো। প্রমাণ করা যায়, এতে মোট খরচ সবচেয়ে ভালো সিদ্ধান্তের দ্বিগুণের বেশি কখনো হয় না। অনেক আধুনিক lock — যেমন Linux kernel-এর mutex, বা glibc-র adaptive mutex (PTHREAD_MUTEX_ADAPTIVE_NP) — ঠিক এভাবেই একটু ঘুরে তারপর ঘুমায়।
এবার TAS lock আর ticket lock দুটিই আসল কোডে, ২টি থ্রেড, প্রত্যেকে ১০ লাখবার lock নিয়ে একটি counter বাড়ায়। pause হলো x86-এর একটি ইঙ্গিত-instruction (নিচে ব্যাখ্যা); __builtin_ia32_pause GCC/Clang-এ শুধু x86-এ চলে:
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#include <time.h>
#define T 2
#define N 1000000
static atomic_flag flag = ATOMIC_FLAG_INIT; /* TAS lock */
static atomic_uint next_ticket = 0, now_serving = 0; /* ticket lock */
static long counter; /* protected by the lock */
static int ticket_mode;
static void relax(void) { __builtin_ia32_pause(); } /* x86 "pause" hint */
static void lock(void) {
if (!ticket_mode) { while (atomic_flag_test_and_set(&flag)) relax(); return; }
unsigned me = atomic_fetch_add(&next_ticket, 1); /* take a token */
while (atomic_load(&now_serving) != me) relax(); /* wait for my number */
}
static void unlock(void) {
if (!ticket_mode) atomic_flag_clear(&flag);
else atomic_fetch_add(&now_serving, 1); /* call the next token */
}
static void *work(void *a) {
(void)a;
for (int i = 0; i < N; i++) { lock(); counter++; unlock(); }
return NULL;
}
static void run(const char *name) {
pthread_t t[T]; struct timespec s, e; counter = 0;
clock_gettime(CLOCK_MONOTONIC, &s);
for (int i = 0; i < T; i++) pthread_create(&t[i], NULL, work, NULL);
for (int i = 0; i < T; i++) pthread_join(t[i], NULL);
clock_gettime(CLOCK_MONOTONIC, &e);
double ms = (e.tv_sec - s.tv_sec) * 1e3 + (e.tv_nsec - s.tv_nsec) / 1e6;
printf("%-6s lock: counter = %ld (expected %d), %.0f ms\n", name, counter, T * N, ms);
}
int main(void) { run("TAS"); ticket_mode = 1; run("ticket"); return 0; }একটি আসল রানের আউটপুট (সময় প্রতিবার আর মেশিনভেদে বদলায়):
TAS lock: counter = 2000000 (expected 2000000), 24 ms
ticket lock: counter = 2000000 (expected 2000000), 34 ms
দুটি lock-ই ঠিক ২০,০০,০০০ দিয়েছে — counter নিজে atomic নয়, তবু নিরাপদ, কারণ সেটি শুধু lock ধরে ছোঁয়া হয়। এই রানে TAS একটু দ্রুত, কিন্তু এক রানের সময় দেখে সিদ্ধান্ত নেবেন না: আমাদের আরেকটি রানে দুটিই লেগেছিল প্রায় ১২০–১৩০ ms (TAS ১২২, ticket ১২৮) — একই কোড, সময় কয়েক গুণ আলাদা, সম্ভবত রানারের মেশিনে একই সময়ে অন্য কাজও চলছিল। এই রানারের container-এ ২টি CPU, আর থ্রেডও ২টি, তাই দুজনেই সত্যিই একসাথে চলেছে। পরীক্ষা হিসেবে আমরা একই কোড ৪টি থ্রেড দিয়ে, প্রত্যেকে ১ লাখবার করে চালিয়েছিলাম: TAS lock শেষ হয়েছে ১৯ ms-এ, কিন্তু ticket lock-সহ প্রোগ্রামটি রানারের সময়সীমা পেরিয়ে যাওয়ায় বন্ধ করে দেওয়া হয়। কারণ: ২টি CPU-তে ৪টি থ্রেড, তাই পরের টোকেনধারী প্রায়ই CPU-তে নেই — অথচ শুধু সে-ই ঢুকতে পারে, আর বাকিরা তার time slice শেষ হওয়া পর্যন্ত ঘোরে। ন্যায্যতার এই দাম user-space spinlock-এর সবচেয়ে বড় বিপদ।
Cache line-এর টানাটানি — MESI আর test-and-test-and-set
পর্ব ৪-এ দেখেছেন, multicore-এ cache coherence নিশ্চিত করে যে একই ঠিকানার কপিগুলো পরস্পরবিরোধী নয়। প্রচলিত protocol MESI: প্রতিটি cache line-এর অবস্থা Modified (শুধু আমার কাছে, বদলানো), Exclusive (শুধু আমার কাছে, অপরিবর্তিত), Shared (কয়েকজনের কাছে, শুধু পড়ার জন্য) বা Invalid (বাতিল)। লেখার আগে core-কে line-টির একচ্ছত্র মালিক হতে হয় — বাকিদের কপি বাতিল করিয়ে। আর TAS তো সবসময় লেখে, ফল 1 হলেও। তাই TAS-এ ঘোরা প্রতিটি core প্রতিটি চেষ্টায় line-টি কেড়ে নেয়: line একবার এই core-এ, একবার ওই core-এ — একে বলে cache-line bouncing. সমাধান test-and-test-and-set (TTAS): প্রথমে শুধু পড়ে ঘোরা, আর মান 0 দেখলে তবেই TAS.
1TAS: while (test_and_set(&L)) ;2TTAS: for (;;) {3 while (load(L) == 1) pause; // read-only4 if (!test_and_set(&L)) break; }5unlock: L = 0;
| Core 0 (holder) | |
| Core 1 | |
| Core 2 | |
| L-এর মান | |
| lock কার | |
| coherence বার্তা (এই কৌশলে) |
- শুরু। Core 0 lock ধরে আছে (L = 1 লিখেছে), তাই line-টি তার cache-এ M (Modified). Core 1 আর Core 2-এর কাছে কোনো কপি নেই (I). বার্তা 0.
- TAS: Core 1 চেষ্টা করল। TAS লেখে, তাই Core 1 “মালিকানা চাই” বার্তা পাঠায়; Core 0-এর কপি বাতিল (I), Core 1-এ M. ফল 1 — lock পায়নি, অথচ line কেড়ে নিয়েছে। বার্তা 1.
- TAS: Core 2 চেষ্টা করল। এবার Core 2 line কেড়ে নেয় (M), Core 1-এর কপি বাতিল। বার্তা 2. কেউ lock পায়নি।
- TAS: আবার Core 1. line আবার Core 1-এ। প্রতিটি চেষ্টায় একটি বার্তা আর line-এর স্থানান্তর — interconnect ব্যস্ত, আর Core 0 যখন unlock করতে চাইবে, তাকেও লাইনে দাঁড়িয়ে line ফেরত আনতে হবে। ঘোরা core যত বেশি, holder-এর মুক্তি তত ধীর। (একই কারণে আলাদা ভ্যারিয়েবলেও false sharing হয়।)
- TTAS: শুধু পড়ে ঘোরা। একই অবস্থা থেকে শুরু (Core 0 ধরে আছে)। Core 1 পড়ে — Core 0-এর M কপি S হয়, Core 1-এ S; তারপর Core 2 পড়ে — S. মোট ২টি বার্তা। তিন core-এর কাছে একই line-এর শুধু-পড়ার কপি।
- TTAS: স্থানীয় ঘোরা। Core 1 আর Core 2 এখন শতবার পড়লেও প্রতিটি পড়া নিজের cache-এ hit — কোনো নতুন বার্তা নেই (এখনো 2). Interconnect শান্ত, Core 0 নির্বিঘ্নে কাজ করছে।
- Core 0 unlock করল: L = 0. লেখার জন্য Core 0 সবার কপি বাতিল করায়: Core 0 M, বাকিরা I. বার্তা 3.
- হুড়োহুড়ি। Core 1 আর Core 2-এর পড়া miss হয় (২টি বার্তা), দুজনেই দেখে L = 0, আর দুজনেই TAS করে (আরও ২টি): Core 1-এর TAS আগে, সে 0 পায় — lock তার। Core 2-এর TAS line কেড়ে নেয় (M) কিন্তু 1 পায়, তাই আবার পড়ে ঘোরায় ফেরে। মোট 7. প্রতিটি unlock-এ এই হুড়োহুড়ি অপেক্ষারত core-এর সংখ্যার সাথে বাড়ে — এর ওষুধ backoff আর MCS lock.
static atomic_int L = 0;
void lock(void) {
unsigned delay = 1;
for (;;) {
while (atomic_load_explicit(&L, memory_order_relaxed))
__builtin_ia32_pause(); /* local, read-only spin */
if (!atomic_exchange_explicit(&L, 1, memory_order_acquire))
return; /* got it */
for (unsigned i = 0; i < delay; i++) /* lost the race: back off */
__builtin_ia32_pause();
if (delay < 1024) delay *= 2; /* exponential, with a cap */
}
}
void unlock(void) { atomic_store_explicit(&L, 0, memory_order_release); }Exponential backoff: হেরে গেলে কিছুক্ষণ অপেক্ষা, আর প্রতিবার হারলে অপেক্ষা দ্বিগুণ (একটি সীমা পর্যন্ত) — Ethernet-এর সংঘর্ষ সামলানোর একই বুদ্ধি। এতে একসাথে TAS করা core কমে। আর pause (Pentium 4-এর SSE2-এর সময় এসেছে; encoding rep nop, তাই পুরোনো CPU-তে এটি সাধারণ nop) CPU-কে বলে “এটি একটি spin-loop”: CPU লুপটি ধীরে চালায়, বিদ্যুৎ কম খরচ করে, hyper-threading-এর সঙ্গী থ্রেডকে বেশি সুযোগ দেয়, আর lock খোলার মুহূর্তে লুপ থেকে বেরোনোর সময় memory-order-এর জটিলতায় যে বড় খরচ হয়, তা এড়ায়। একটি pause কত cycle নেয়, তা প্রজন্মভেদে অনেক আলাদা (Intel-এর Skylake-এর সার্ভার সংস্করণ থেকে আগের চেয়ে বহু গুণ বেশি), তাই backoff-এর সময় “কতবার pause” দিয়ে মাপা portable নয়। ARM-এ সমতুল্য ইঙ্গিত yield, আর অপেক্ষার জন্য wfe.
৭ · Linux kernel-এর spinlock — spin_lock_irqsave থেকে qspinlock
User-space-এ spinlock সাবধানে ব্যবহার্য, কিন্তু kernel-এ এটি প্রধান হাতিয়ার — কারণ kernel জানে কে কোন CPU-তে চলছে, আর preemption নিজেই বন্ধ করতে পারে। Linux-এ spin_lock(&l) প্রথমে নিজের CPU-তে preemption বন্ধ করে (যাতে holder-কে scheduler সরিয়ে না দেয়), তারপর lock নেয়। Interrupt handler-ও একই ডেটা ছুঁলে আরেকটি বিপদ: process-এর কোড lock ধরে আছে, এমন সময় সেই CPU-তেই interrupt এল, handler-ও একই lock চাইল — handler ঘোরে, অথচ holder ওই CPU-তেই আটকে, interrupt শেষ না হলে চলতে পারবে না। Deadlock, একটিমাত্র CPU-তে। তাই আছে spin_lock_irqsave: নিজের CPU-র interrupt বন্ধ করে, আগের অবস্থা flags-এ রেখে, তারপর lock নেয়।
static DEFINE_SPINLOCK(stats_lock); /* shared with the IRQ handler */
void update_stats(struct my_dev *dev, unsigned int len)
{
unsigned long flags;
spin_lock_irqsave(&stats_lock, flags); /* local IRQs off + take lock */
dev->rx_packets++;
dev->rx_bytes += len; /* short, never sleeps */
spin_unlock_irqrestore(&stats_lock, flags); /* lock off, IRQ state back */
}Kernel-এর নিয়ম কঠোর: spinlock ধরে থাকা অবস্থায় ঘুমানো যাবে না — kmalloc(GFP_KERNEL), copy_from_user বা mutex নেওয়ার মতো কাজ, যা ঘুমাতে পারে, নিষেধ (debug kernel-এ “BUG: sleeping function called from invalid context” দেখায়)। Critical section হবে ছোট।
Ticket থেকে qspinlock — MCS-এর ধারণা
Linux ২০০৮ সালে (2.6.25) পুরোনো TAS-ধাঁচের spinlock বদলে ticket spinlock আনে — ন্যায্যতার জন্য। কিন্তু অনেক core-এর মেশিনে সব অপেক্ষারত CPU একই শব্দে ঘোরায় প্রতিটি unlock-এ সবার cache-এ বার্তা যায়। এর তাত্ত্বিক সমাধান আগেই ছিল: ১৯৯১ সালে Mellor-Crummey আর Scott-এর MCS lock. প্রতিটি অপেক্ষারত থ্রেড নিজের একটি ছোট node নিয়ে সারিতে দাঁড়ায় (lock-এর tail-এ একটি atomic exchange দিয়ে), আর ঘোরে নিজের node-এর flag-এ। Holder বেরোনোর সময় শুধু পরের জনের flag বদলায় — একটি বার্তা, একটি core:
lock(L, me):
me->next = NULL; me->locked = 1;
prev = atomic_exchange(&L->tail, me); // join the queue at the end
if (prev != NULL) {
prev->next = me; // tell the one ahead of me
while (me->locked) pause; // spin on MY OWN flag
}
unlock(L, me):
if (me->next == NULL) {
if (CAS(&L->tail, me, NULL)) return; // nobody behind me
while (me->next == NULL) pause; // someone is joining right now
}
me->next->locked = 0; // wake exactly one waiterসমস্যা ছিল আকার: kernel-এর অগণিত struct-এ spinlock মাত্র ৪ বাইট, আর MCS-এর node-এর জন্য পয়েন্টার লাগে। ২০১৫ সালে Linux 4.2-এ x86-এ এল qspinlock (মূল কাজ Waiman Long-এর), যা MCS-এর ধারণা ৪ বাইটে ঢুকিয়েছে: একটি “locked” বাইট, একটি “pending” bit, আর একটি tail অংশ, যেখানে পয়েন্টারের বদলে CPU নম্বর লেখা থাকে (প্রতিটি CPU-র জন্য আগে থেকে বানানো কয়েকটি MCS node আছে)। কেউ অপেক্ষা না করলে lock নেওয়া একটি CAS মাত্র; দ্বিতীয়জন pending bit দিয়ে কাছেই ঘোরে; তৃতীয়জন থেকে সবাই MCS সারিতে, নিজের node-এ।
আরও গভীরে: Virtual machine-এ spinlock-এর নতুন বিপদ — hypervisor হয়তো lock-ধারী vCPU-কে থামিয়ে রেখেছে, আর অন্য vCPU-রা অকারণে ঘুরছে (lock-holder preemption); ticket বা সারি-ভিত্তিক lock-এ পরের অপেক্ষারত vCPU থেমে থাকলেও একই দশা। তাই Linux-এর paravirt qspinlock কিছুক্ষণ ঘুরে hypercall দিয়ে vCPU-কে ঘুম পাড়ায়, আর Intel-এর PAUSE-loop exiting বা AMD-এর pause filter-এর মতো হার্ডওয়্যার বৈশিষ্ট্য লম্বা spin ধরে hypervisor-কে জানায়। PREEMPT_RT kernel-এ (Linux 6.12-এ মূল ধারায় যোগ হয়েছে) সাধারণ spinlock_t আসলে একটি ঘুমাতে-পারা rt_mutex হয়ে যায়; সত্যিকারের ঘোরা lock লাগলে raw_spinlock_t.
অন্য OS-এ একই ধারণা: Windows kernel-এ KeAcquireSpinLock CPU-র IRQL বাড়িয়ে DISPATCH_LEVEL-এ নেয় (ফলে ওই CPU-তে thread scheduling থামে), আর অনেক core-এর জন্য আছে in-stack queued spinlock — MCS-এর মতো। macOS/iOS-এ user-space-এর OSSpinLock ২০১৬ সালে বাতিল ঘোষিত হয়ে os_unfair_lock এসেছে (কারণ ভাবনার প্রশ্ন ৪-এ)।
৮ · Memory ordering — sequential consistency, x86-TSO আর store buffer
১৯৭৯ সালে Leslie Lamport sequential consistency (SC) সংজ্ঞায়িত করেন: একটি multiprocessor-এর যেকোনো রানের ফল এমন হবে যেন সব প্রসেসরের সব কাজ একটি ক্রমে একের পর এক ঘটেছে, আর সেই ক্রমে প্রতিটি প্রসেসরের নিজের কাজগুলো তার প্রোগ্রামের ক্রমেই আছে। এটি আমাদের স্বাভাবিক ধারণা — “থ্রেডগুলো পালা করে মেশানো”। কোন ক্রম ভাঙা অনুমোদিত, সেই নিয়মকে বলে একটি memory ordering মডেল (memory model).
মডেল যাচাইয়ের ছোট পরীক্ষাকে বলে litmus test. সবচেয়ে বিখ্যাতটি store buffering (SB): শুরুতে x = y = 0.
Thread 1 Thread 2
x = 1; y = 1;
r1 = y; r2 = x;
/* Is r1 == 0 && r2 == 0 possible? */SC-তে অসম্ভব — প্রমাণ: চারটি কাজের একটি একক ক্রম ধরুন, যা দুই থ্রেডের প্রোগ্রাম-ক্রম মানে। সেই ক্রমের শেষ কাজটি অবশ্যই একটি load, কারণ প্রতিটি থ্রেডে store আগে, load পরে। ধরা যাক শেষ কাজটি r1 = y. তাহলে Thread 2-এর y = 1 তার আগেই ঘটেছে (সেটি Thread 2-এর load-এর আগে, যা শেষ কাজের আগে), তাই r1 = 1. শেষ কাজটি r2 = x হলে একইভাবে r2 = 1. অর্থাৎ অন্তত একটি 1. ∎ SC-তে সম্ভাব্য ফল শুধু (0, 1), (1, 0) আর (1, 1).
x86-TSO — store buffer
x86 SC নয়। এর মডেলকে বলে x86-TSO (Total Store Order), যা ২০০৯–১০ সালে Peter Sewell, Scott Owens ও সহকর্মীরা আনুষ্ঠানিকভাবে লিখেছিলেন। মূল কথা: প্রতিটি core-এর একটি নিজস্ব FIFO store buffer. Store প্রথমে সেখানে যায়, পরে কোনো এক সময় মেমরিতে (cache-এ) নামে। Load প্রথমে নিজের store buffer দেখে (নিজের লেখা নিজে সঙ্গে সঙ্গে দেখে), না পেলে মেমরি থেকে পড়ে। ফল: একটি পরের load আগের store-কে (ভিন্ন ঠিকানায়) ছাড়িয়ে যেতে পারে — x86-এ অনুমোদিত reordering কার্যত এই একটিই। Store-store, load-load আর load-store-এর ক্রম রক্ষা হয়।
অফিসের দুই সহকর্মী, আলাদা টেবিল, মাঝখানে একটি নোটিশ বোর্ড। আপনি একটি নোট লিখে নিজের “বাইরে যাবে” ট্রেতে রাখলেন (store buffer); পিয়ন পরে বোর্ডে লাগাবে। তারপর সঙ্গে সঙ্গে বোর্ডে তাকালেন — সহকর্মীর নোট নেই। ঠিক একই সময় সহকর্মীও একই কাজ করলেন। দুজনেই বোর্ডে অন্যের নোট দেখেননি, অথচ দুজনেই নোট লিখেছেন। নিজের ট্রেতে নিজের নোট অবশ্য আপনি দেখতে পান।
1CPU 0: x = 1;2CPU 0: r1 = y;3CPU 1: y = 1;4CPU 1: r2 = x;5fix: store; mfence; load (both CPUs)
| CPU 0-এর store buffer | |
| CPU 1-এর store buffer | |
| মেমরিতে x | |
| মেমরিতে y | |
| r1 | |
| r2 |
- শুরু: x = y = 0. দুটি CPU-র store buffer খালি। মেমরি বলতে এখানে cache-coherent মেমরি ব্যবস্থা, যা সব core একইভাবে দেখে।
- CPU 0: x = 1 — buffer-এ গেল। লেখাটি CPU 0-এর store buffer-এ বসেছে। CPU 0 অপেক্ষা না করে পরের instruction-এ চলে যায় — এটিই store buffer-এর উদ্দেশ্য, লেখার ধীর গতি লুকানো। মেমরিতে x এখনো 0.
- CPU 1: y = 1 — buffer-এ গেল। একইভাবে y = 1 বসল CPU 1-এর buffer-এ; মেমরিতে y এখনো 0.
- CPU 0: r1 = y → 0. CPU 0 নিজের buffer-এ y খোঁজে — নেই। মেমরি থেকে পড়ে: 0. CPU 1-এর y = 1 তার buffer-এ আটকে, CPU 0 সেটি দেখতে পায় না। r1 = 0.
- CPU 1: r2 = x → 0. একই ঘটনা উল্টো দিকে: CPU 1-এর buffer-এ x নেই, মেমরিতে x = 0. r2 = 0. দুটি load-ই নিজের আগের store-কে “ছাড়িয়ে” মেমরিতে পৌঁছেছে।
- CPU 0-এর buffer খালি হলো। x = 1 এবার মেমরিতে নামল। কিন্তু দেরি হয়ে গেছে — CPU 1 আগেই x পড়ে ফেলেছে।
- CPU 1-এর buffer-ও খালি। মেমরিতে x = 1, y = 1 — দুটি লেখাই হয়েছে। অথচ r1 = 0, r2 = 0: এমন ফল, যা কোনো “পালা করে মেশানো” ক্রমে সম্ভব নয়। Peterson ঠিক এখানেই ভাঙে।
- Fence দিলে। প্রতিটি store-এর পরে
mfence: CPU নিজের store buffer মেমরিতে নামিয়ে তবেই পরের load করে। এই উদাহরণে CPU 0-এর পুরো ক্রম আগে শেষ হলো (r1 = 0), তারপর CPU 1-এর y = 1 মেমরিতে নেমে x পড়ল: r2 = 1. সম্ভাব্য ফল এখন শুধু (0, 1), (1, 0), (1, 1) — SC-র মতোই।
এবার নিজে চালিয়ে দেখুন। দুটি থ্রেড একসাথে শুরু করে প্রতি পরীক্ষায় একজোড়া নতুন x, y (দুটিই 0) ব্যবহার করে — মোট এক লাখ পরীক্ষা, প্রথমবার relaxed atomic দিয়ে (কোনো ordering নেই), দ্বিতীয়বার store আর load-এর মাঝে একটি seq_cst fence দিয়ে:
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#define N 100000 /* trials per mode */
static atomic_int x[2][N], y[2][N]; /* fresh zeros for every trial */
static int r1[2][N], r2[2][N];
static atomic_int arrive; /* tiny start barrier */
static int m; /* mode 0 = relaxed, 1 = seq_cst fence */
static void *side(void *arg) { /* side 0: x=1; r1=y side 1: y=1; r2=x */
int me = (int)(intptr_t)arg;
atomic_int *mine = me ? y[m] : x[m], *other = me ? x[m] : y[m];
int *r = me ? r2[m] : r1[m];
for (int i = 0; i < N; i++) {
atomic_fetch_add(&arrive, 1); /* both start together */
while (atomic_load(&arrive) < 2 * (i + 1)) __builtin_ia32_pause();
atomic_store_explicit(&mine[i], 1, memory_order_relaxed); /* store */
if (m) atomic_thread_fence(memory_order_seq_cst); /* fence */
r[i] = atomic_load_explicit(&other[i], memory_order_relaxed); /* load */
}
return NULL;
}
int main(void) {
for (m = 0; m < 2; m++) {
pthread_t a, b; int both0 = 0;
atomic_store(&arrive, 0);
pthread_create(&a, NULL, side, (void *)0); pthread_create(&b, NULL, side, (void *)1);
pthread_join(a, NULL); pthread_join(b, NULL);
for (int i = 0; i < N; i++) both0 += (r1[m][i] == 0 && r2[m][i] == 0);
printf("%s: r1 == 0 && r2 == 0 in %d of %d trials\n", m ? "seq_cst fence" : "relaxed ", both0, N);
}
return 0;
}একটি আসল রানের আউটপুট (প্রথম সংখ্যাটি প্রতিবার বদলায়):
relaxed : r1 == 0 && r2 == 0 in 3079 of 100000 trials
seq_cst fence: r1 == 0 && r2 == 0 in 0 of 100000 trials
এক লাখ পরীক্ষার ৩,০৭৯টিতে — প্রায় ৩%-এ — দুটি থ্রেডই 0 পড়েছে, যা SC-তে অসম্ভব। আমাদের আরও কয়েকটি রানে সংখ্যাটি ছিল প্রায় ৩,৯০০ থেকে ৫,২০০-এর মধ্যে। Fence দিলে প্রতিবার 0 (gcc x86-64-এ এই fence-এর জন্য সাধারণত mfence বসায়)। সংখ্যাটি কত আসবে, তা নির্ভর করে দুটি থ্রেড কতটা একসাথে শুরু করে তার ওপর: এখানে container-এ ২টি CPU, তাই দুটি থ্রেড সত্যিই পাশাপাশি চলে। মাত্র একটি CPU থাকলে relaxed অংশেও 0 আসতে পারে — কারণ তখন দুই থ্রেড একই core-এ পালা করে চলে: store buffer দুজনের একটিই (নিজের buffer-এর লেখা load দেখতে পায়), আর context switch-এর পথে kernel-এর কাজে buffer খালি হয়ে যায়। “0 এসেছে” তাই কখনো “reordering হয় না”-র প্রমাণ নয়। আর relaxed atomic-এ compiler-ও ক্রম বদলাতে পারত; ফল ভাঙার দায় শুধু CPU-র নয়।
অন্য প্রসেসর আরও ঢিলা
| প্রসেসর | মডেল | কোন কাজ আগে-পরে হতে পারে |
|---|---|---|
| x86, x86-64 | TSO | শুধু store → পরের load (ভিন্ন ঠিকানা) |
| ARMv8 (ফোন, Apple M-series, AWS Graviton) | দুর্বল (weak) | প্রায় সব জোড়া — load-load, load-store, store-store, store-load — যদি ঠিকানা বা নির্ভরতা না আটকায় |
| IBM POWER | দুর্বল | ARM-এর মতো, আর একেক core একেক ক্রমে লেখা দেখতে পারে |
| RISC-V | RVWMO (দুর্বল) | ARM-এর কাছাকাছি; ঐচ্ছিক Ztso এক্সটেনশনে TSO |
তাই x86 ল্যাপটপে “চলছে” এমন lock-ছাড়া কোড ARM ফোনে বা ক্লাউড সার্ভারে ভাঙতে পারে। সমাধান কখনো প্রসেসর-নির্দিষ্ট fence হাতে বসানো নয় — ভাষার memory model অনুযায়ী লেখা, পরের অংশের মতো।
৯ · Acquire, release আর volatile-এর ভুল ধারণা
পূর্ণ fence দামি, আর সবসময় দরকারও নেই। সবচেয়ে প্রচলিত দরকার হলো message passing: একটি থ্রেড ডেটা তৈরি করে একটি flag তোলে, আরেকটি flag দেখে ডেটা পড়ে। এর জন্য যথেষ্ট দুটি একমুখী বাধা:
- Release store: এর আগের কোনো পড়া-লেখা এর পরে সরতে পারবে না। “সব গুছিয়ে তারপর flag তুলি।”
- Acquire load: এর পরের কোনো পড়া-লেখা এর আগে সরতে পারবে না। “flag দেখার পরেই ডেটা ছুঁই।”
int data; /* ordinary, non-atomic */
atomic_int ready = 0;
/* producer thread */
data = 42;
atomic_store_explicit(&ready, 1, memory_order_release); /* publish */
/* consumer thread */
while (atomic_load_explicit(&ready, memory_order_acquire) == 0)
; /* wait */
printf("%d\n", data); /* guaranteed to print 42 */Consumer-এর acquire load যদি producer-এর release store-এর লেখা মান (1) পড়ে, তবে দুটির মধ্যে একটি happens-before সম্পর্ক তৈরি হয় — তাই data = 42 নিশ্চিতভাবে দেখা যাবে, আর data-তে কোনো data race নেই। Lock-ও আসলে এটিই: lock নেওয়া একটি acquire, ছাড়া একটি release, তাই একজনের critical section-এর সব লেখা পরের জন দেখে। x86-এ acquire load আর release store সাধারণ mov-ই (TSO এমনিতেই এগুলো রক্ষা করে — বাধা দিতে হয় শুধু compiler-কে); ARMv8-এ এদের জন্য আলাদা instruction ldar আর stlr. relaxed রাখুন শুধু সেখানে, যেখানে ক্রম লাগে না — যেমন একটি পরিসংখ্যান counter, যা শেষে join-এর পরে পড়া হবে।
| C11/C++ কাজ | x86-64 (সাধারণত) | ARMv8 (সাধারণত) |
|---|---|---|
| relaxed load / store | mov / mov | ldr / str |
| acquire load | mov | ldar (বা ldapr) |
| release store | mov | stlr |
| seq_cst store | xchg (বা mov + mfence) | stlr |
| seq_cst fence | mfence | dmb ish |
| fetch_add | lock xadd | ldaddal (LSE) বা ldaxr/stlxr লুপ |
C/C++-এ volatile synchronization নয়। volatile শুধু compiler-কে বাধ্য করে এই ভ্যারিয়েবলের প্রতিটি পড়া-লেখা সত্যিই মেমরিতে করতে, আর সেগুলো বাদ দেওয়া, সরানো বা মেশানো থেকে বিরত থাকতে — এটি বানানো হয়েছিল ডিভাইসের register (memory-mapped I/O) আর signal handler-এর জন্য। এটি (১) x++-কে অবিভাজ্য করে না, (২) অন্য সাধারণ ভ্যারিয়েবলের সাপেক্ষে ক্রম রক্ষা করে না, আর (৩) কোনো CPU fence বসায় না — তাই ARM-এ store-load তো বটেই, অন্য reordering-ও থেকে যায়। মানদণ্ডের চোখে volatile ভ্যারিয়েবলে দুই থ্রেডের সংঘাতপূর্ণ ব্যবহার এখনো data race. সঠিক হাতিয়ার _Atomic/std::atomic. Java-র volatile আলাদা জিনিস: JSR-133 (Java 5) থেকে সেটি দৃশ্যমানতা আর ক্রমের নিশ্চয়তা দেয় (C++-এর seq_cst atomic-এর কাছাকাছি), কিন্তু Java-তেও volatile counter-এর count++ অবিভাজ্য নয়। Linux kernel নিজের memory model (LKMM) মেনে READ_ONCE/WRITE_ONCE (ভেতরে volatile cast) আর smp_mb(), smp_load_acquire()-এর মতো আলাদা barrier একসাথে ব্যবহার করে — একা volatile নয়।
১০ · AI-এর যুগে — atomic, lock আর memory order-এর কোডে AI
Lock-ছাড়া কোড, নিজের spinlock বা memory order নিয়ে AI খুব আত্মবিশ্বাসী উত্তর দেয়। সমস্যা হলো, এই কোডের বাগ x86 ল্যাপটপে প্রায় কখনো দেখা দেয় না — দেখা দেয় ARM সার্ভারে, বেশি load-এ, বা মাসে একবার। AI-এর সাধারণ ভুল:
| AI-এর ভুল | ফল | কীভাবে ধরবেন |
|---|---|---|
Flag বা counter-এ volatile দিয়ে “thread-safe” | lost update, ARM-এ পুরোনো ডেটা পড়া | _Atomic/std::atomic চান; TSan চালান |
সব জায়গায় memory_order_relaxed “দ্রুত বলে” | message passing ভাঙে, শুধু দুর্বল CPU-তে | প্রতিটি order-এর পক্ষে যুক্তি চান: কোন release কোন acquire-এর সাথে জোড়া |
User-space-এ নিজের spinlock, pause বা backoff ছাড়া | core-এর চেয়ে বেশি থ্রেডে সময় নষ্ট, এমনকি আটকে থাকা | core-এর চেয়ে বেশি থ্রেড দিয়ে load test; সাধারণত pthread_mutex-ই ভালো |
| “x86-এ reordering হয় না” | Peterson বা SB-ধাঁচের কোড ভাঙে | store-buffering পরীক্ষাটি নিজে চালান (অংশ ৮) |
| পুরোনো বা ভুল তথ্য: kernel-এর spinlock-এর নাম, Linux সংস্করণ, ARM instruction | ভুল ধারণা, ভুল উত্তর পরীক্ষায় | kernel-এর Documentation (locking, memory-barriers.txt) আর CPU-র manual দেখুন |
| Ticket lock-এর অপেক্ষার সংখ্যা, TAS-এর প্রমাণে ভুল হিসাব | পরীক্ষায় নম্বর হারানো | next_ticket আর now_serving হাতে ট্রেস করুন |
AI-কে concurrent কোড চাইলে প্ল্যাটফর্ম, শর্ত আর প্রমাণ — তিনটিই চান:
AI-এর লেখা atomic বা lock-এর কোড ব্যবহারের আগে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- TAS lock কোন শর্ত পূরণ করে না? — bounded waiting; lock শুধু খালি-ভরা মনে রাখে, অপেক্ষার ক্রম নয়, তাই একজন অনির্দিষ্টকাল হারতে পারে।
- Uniprocessor-এ spinlock কেন অর্থহীন? — spinner যতক্ষণ CPU ধরে আছে, holder চলতে পারে না; তাই পুরো time slice নষ্ট। সেখানে kernel interrupt বা preemption বন্ধ করে।
- CAS আর LL/SC-এর পার্থক্য? — CAS (x86
lock cmpxchg) একটি instruction-এ মান মিলিয়ে লেখে; LL/SC (ARMldxr/stxr) একটি জোড়া, মাঝে ওই ঠিকানায় কেউ লিখলে store ব্যর্থ হয় আর আবার চেষ্টা করতে হয়। - Store-buffering পরীক্ষায় x86-এ r1 = r2 = 0 কীভাবে সম্ভব? — প্রতিটি core-এর store buffer-এ লেখা আটকে থাকতেই পরের load মেমরি থেকে পুরোনো মান পড়ে;
mfenceএটি আটকায়। - C-তে
volatileকি থ্রেডের জন্য যথেষ্ট? — না; এটি অবিভাজ্যতা বা CPU-স্তরের ক্রম দেয় না। লাগবে_Atomicবা lock.
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১CAS কেন TAS-এর চেয়ে “শক্তিশালী”? Herlihy-র consensus number কী বলে, আর প্রসেসর নির্মাতারা কেন সবাই CAS বা LL/SC বেছে নিয়েছেন?
দুটি দিয়েই lock বানানো যায়, তাহলে পার্থক্য কোথায়? প্রশ্নটি আনুষ্ঠানিকভাবে তোলেন Maurice Herlihy, ১৯৯১ সালের “Wait-free synchronization” প্রবন্ধে। তিনি জিজ্ঞেস করেন: lock ছাড়া, এমনভাবে যে কোনো থ্রেড থেমে গেলেও বাকিরা এগোতে পারে (wait-free), কয়টি থ্রেড একটি instruction দিয়ে একটি সাধারণ সিদ্ধান্তে পৌঁছাতে পারে? এই “consensus” সমস্যায় প্রতিটি থ্রেড একটি প্রস্তাব দেয়, আর সবাইকে একই প্রস্তাব বেছে নিতে হয়। যত বেশি থ্রেডের জন্য একটি instruction এটি পারে, সেটিই তার consensus number.
ফল চমকপ্রদ: সাধারণ read/write-এর consensus number ১ — দুটি থ্রেডের জন্যও wait-free সিদ্ধান্ত অসম্ভব। Test-and-set, swap আর fetch-and-add-এর consensus number ২: দুটি থ্রেড পারে, তিনটি পারে না। আর compare-and-swap বা LL/SC-এর consensus number অসীম — যেকোনো সংখ্যক থ্রেডের জন্য। Herlihy আরও দেখান, অসীম consensus number-এর instruction দিয়ে যেকোনো data structure-এর wait-free সংস্করণ বানানো যায় (universal construction). অর্থাৎ শুধু TAS দিয়ে সব lock-free অ্যালগরিদম বানানো তাত্ত্বিকভাবেই অসম্ভব, CAS দিয়ে সম্ভব।
বাস্তবের ইতিহাসও এই পথে গেছে। IBM ১৯৭০-এর দশকে System/370-এ Compare and Swap instruction যোগ করে, মূলত multiprocessor-এ সফটওয়্যারকে lock ছাড়াই তালিকা হালনাগাদ করতে দিতে। Intel ১৯৮৯ সালের 80486-এ cmpxchg আনে। RISC নকশাকারীরা (MIPS, Alpha, POWER, পরে ARM আর RISC-V) একটি instruction-এ পড়া-তুলনা-লেখা রাখার বদলে LL/SC বেছে নেন, কারণ তা pipeline-এ সহজে বসে, আর একই জোড়া দিয়ে CAS, fetch-and-add, swap সবই বানানো যায়। পরে অনেক core-এর সার্ভারে LL/SC লুপের বারবার ব্যর্থতা দেখা দিলে ARMv8.1 আবার একক instruction (cas, ldadd) যোগ করে — তত্ত্ব আর বাস্তব প্রয়োজনের মাঝে টানাপোড়েনের ভালো উদাহরণ।
মূল উপলব্ধি: হার্ডওয়্যারের সব atomic instruction সমান নয়। TAS দিয়ে lock বানানো যায়, কিন্তু lock-ছাড়া সাধারণ সমাধানের জন্য CAS বা LL/SC লাগে — আর এ কারণেই প্রতিটি আধুনিক প্রসেসরে এর কোনো একটি আছে।
প্র ০২Linux কেন TAS থেকে ticket lock, আর ticket lock থেকে qspinlock-এ গেল? প্রতিটি ধাপে কোন সমস্যা দেখা দিয়েছিল?
Linux-এর প্রথম দিকের SMP spinlock ছিল একটি শব্দ, যেখানে x86-এ একটি lock dec দিয়ে lock নেওয়া হতো আর না পেলে পড়ে ঘোরা হতো — মূলত TTAS. অল্প core-এ এটি দ্রুত, কিন্তু অন্যায্য: যে CPU সবে lock ছেড়েছে, তার cache-এ line-টি সবচেয়ে কাছে, তাই সে বারবার জেতে। অনেক core-এর মেশিনে দেখা গেল, কিছু CPU দীর্ঘ সময় lock পাচ্ছে না — অংশ ২-এর bounded waiting প্রমাণের বাস্তব রূপ, যার ফলে latency অনির্দেশ্য হয়ে যায়।
২০০৮ সালে Nick Piggin-এর ticket spinlock (Linux 2.6.25) এই সমস্যা মেটায়: next আর owner দুটি ছোট সংখ্যা একটি শব্দে, lock নেওয়া একটি lock xadd, আর সেবা কঠোরভাবে FIFO. কিন্তু দুটি নতুন সমস্যা সামনে এল। প্রথমত, সব অপেক্ষারত CPU একই cache line-এ ঘোরে, তাই প্রতিটি unlock সবার কপি বাতিল করে, আর সবাই আবার পড়ে — বহু core-এ এই ট্রাফিক বড় বোঝা। দ্বিতীয়ত, virtual machine-এ: পরের টোকেনের vCPU-কে hypervisor থামিয়ে রাখলে lock খালি থাকলেও কেউ ঢুকতে পারে না, আর বাকি সব vCPU ঘুরে সময় নষ্ট করে। ঠিক এটিই আমাদের ২-CPU-র container-এ ৪ থ্রেডের ticket lock-এ দেখা গেছে।
সমাধানের তত্ত্ব ছিল ১৯৯১ সালের MCS lock — প্রত্যেকে নিজের node-এ ঘোরে, unlock একজনকেই জাগায়। বাধা ছিল আকার, কারণ kernel-এর অগণিত struct-এ spinlock ৪ বাইটের, আর তা বাড়ালে মেমরি আর cache-এর খরচ বাড়ে। Waiman Long-সহ কয়েকজন ডেভেলপারের কয়েক বছরের কাজের পর ২০১৫ সালে Linux 4.2-এ x86-এর জন্য qspinlock আসে: পয়েন্টারের বদলে CPU নম্বর, প্রতিটি CPU-র জন্য আগে থেকে রাখা কয়েকটি node (সাধারণ কাজ, softirq, hardirq আর NMI — চার রকম পরিস্থিতির জন্য), আর কেউ অপেক্ষা না করলে একটি মাত্র CAS. পরে অন্য কিছু architecture-ও এটি গ্রহণ করে। Paravirt সংস্করণ VM-এ অপেক্ষারত vCPU-কে ঘুম পাড়াতেও পারে।
মূল উপলব্ধি: lock-এর নকশায় তিনটি লক্ষ্য একসাথে টানে — অল্প প্রতিযোগিতায় গতি, ন্যায্যতা, আর বহু core-এ cache ট্রাফিক। প্রতিটি নতুন হার্ডওয়্যার (বহু core, virtualization) পুরোনো ভারসাম্য ভেঙে নতুন নকশা দাবি করে।
প্র ০৩প্রসেসর নির্মাতারা কেন sequential consistency দেন না? Store buffer না রাখলে কী হারাতাম, আর ভাষাগুলো কীভাবে এই বিশৃঙ্খলা সামলাল?
Lamport ১৯৭৯ সালে SC সংজ্ঞায়িত করেছিলেন সঠিকতার একটি পরিষ্কার মাপকাঠি হিসেবে। কিন্তু SC কঠোরভাবে মানতে হলে প্রতিটি store শেষ না হওয়া পর্যন্ত — অর্থাৎ অন্য সব core-এর কপি বাতিল হয়ে নিশ্চিতকরণ না আসা পর্যন্ত — পরের load আটকে রাখতে হয়। একটি cache miss-এর store শত cycle নিতে পারে। Store buffer সেই অপেক্ষা লুকায়: core লেখাটি সারিতে রেখে এগিয়ে যায়। একক থ্রেডের কোডে এর কোনো দৃশ্যমান প্রভাব নেই (নিজের লেখা নিজের buffer থেকেই দেখে), আর লাভ বিশাল। তাই প্রায় সব প্রসেসর store buffer রাখে, আর SC ছেড়ে দেয়। আরও দুর্বল মডেল (ARM, POWER) হার্ডওয়্যারকে আরও স্বাধীনতা দেয় — load আগে চালানো, store-গুলো আলাদা ক্রমে নামানো — আর ক্রম দরকার হলে প্রোগ্রামারকে barrier দিতে হয়।
সমস্যা ছিল, বহু বছর এই নিয়মগুলো অস্পষ্ট ছিল। Intel আর AMD-এর manual-এ x86-এর আচরণ এমন ভাষায় লেখা ছিল, যা থেকে নিশ্চিত উত্তর পাওয়া যেত না, আর কিছু উদাহরণ পরস্পরবিরোধী। ২০০৭ সালে Intel একটি আলাদা memory-ordering white paper প্রকাশ করে কিছু litmus test দিয়ে; তারপর Cambridge-এর Peter Sewell, Scott Owens, Susmit Sarkar-দের দল x86-TSO নামে একটি আনুষ্ঠানিক মডেল লেখে (২০০৯–১০), যা store buffer-ওয়ালা একটি সহজ বিমূর্ত যন্ত্র দিয়ে x86-এর আচরণ ব্যাখ্যা করে আর আসল চিপে পরীক্ষা দিয়ে মিলিয়ে দেখা হয়। ARM আর POWER-এর মডেল আরও জটিল, এবং সেগুলো নিয়েও একই ধরনের গবেষণা হয়েছে।
ভাষার দিকে Java প্রথম, ২০০৪ সালে JSR-133-এ নতুন memory model দিয়ে। C আর C++ ২০১১ সালে নিজেদের model আনে — Hans Boehm, Sarita Adve-সহ অনেকের কাজের ভিত্তিতে — যার মূল চুক্তি: data race না থাকলে প্রোগ্রাম SC-এর মতো আচরণ করবে (“SC for DRF”), আর প্রয়োজনে দুর্বল order দিয়ে গতি নেওয়া যাবে। Compiler তারপর প্রতিটি CPU-র জন্য ঠিক barrier বসায়। একটি আকর্ষণীয় হার্ডওয়্যার পাল্টা উদাহরণ: Apple-এর M-series চিপে একটি TSO মোড আছে বলে বহুলভাবে জানা যায়, যা Rosetta 2-কে x86-এর অনুবাদ করা কোড ARM-এ ঠিক ক্রমে চালাতে সাহায্য করে।
মূল উপলব্ধি: SC হারানো গতির দাম। হার্ডওয়্যার দ্রুত হওয়ার স্বাধীনতা নেয়, ভাষা সেই স্বাধীনতা লুকিয়ে একটি সহজ চুক্তি দেয় — আর সেই চুক্তি টেকে যদি আপনি data race না রাখেন।
প্র ০৪Apple কেন OSSpinLock বাতিল করল? User-space-এ spinlock কেন প্রায়ই খারাপ ধারণা — এমনকি দ্রুত মনে হলেও?
বহু বছর macOS আর iOS-এ OSSpinLock ছিল জনপ্রিয় — খুব দ্রুত, খুব হালকা। ২০১৫–১৬ সালের দিকে Apple-এর ডেভেলপার ও বাইরের প্রোগ্রামাররা একটি গুরুতর সমস্যা প্রকাশ্যে আলোচনা করেন। iOS-এর scheduler থ্রেডের QoS (quality of service) শ্রেণি অনুযায়ী অগ্রাধিকার দেয়। এখন ধরুন একটি কম-অগ্রাধিকারের থ্রেড lock ধরে আছে, আর একটি বেশি-অগ্রাধিকারের থ্রেড সেই lock-এ ঘুরছে। Scheduler বেশি-অগ্রাধিকারের থ্রেডকেই CPU দেয়, কারণ সে “প্রস্তুত”; আর কম-অগ্রাধিকারের holder CPU না পেয়ে lock ছাড়তে পারে না। এটি priority inversion-এর একটি চরম রূপ — spinner যত জোরে ঘোরে, holder তত কম সুযোগ পায়। ফল ছিল অ্যাপ লম্বা সময় আটকে থাকা।
Spinlock-এর সমস্যা হলো, সে scheduler-কে কিছুই জানায় না। ঘুমানো lock (mutex) অপেক্ষার সময় kernel-কে বলে “আমি অমুকের জন্য অপেক্ষা করছি”, তাই kernel holder-কে অগ্রাধিকার ধার দিতে পারে (priority inheritance, পর্ব ১৮ আর ২৯)। Apple তাই iOS 10 আর macOS 10.12 (২০১৬)-এ OSSpinLock বাতিল ঘোষণা করে os_unfair_lock আনে — একটি lock, যা অপেক্ষার সময় kernel-এ ঘুমায় আর holder কে তা kernel-কে জানায়। নামের “unfair” অংশটিও সচেতন সিদ্ধান্ত: FIFO ন্যায্যতা দিলে অংশ ৬-এর ticket lock-এর মতো দশা হতে পারে।
Linux-এর জগতেও একই বিতর্ক হয়েছে। ২০২০ সালে একটি গেম ইঞ্জিনের ডেভেলপার user-space spinlock-এর benchmark নিয়ে লেখার পর একটি আলোচিত অনলাইন আলোচনায় Linus Torvalds জোর দিয়ে বলেন, user-space-এ spinlock প্রায় কখনোই ব্যবহার করা উচিত নয়, কারণ user program জানে না holder CPU-তে আছে কি না; kernel-এ spinlock চলে, কারণ সেখানে preemption বন্ধ থাকে। ব্যবহারিক পরামর্শ: user-space-এ pthread_mutex (glibc-তে futex-ভিত্তিক, অল্প অপেক্ষায় user mode-এই নেওয়া হয়) — বা খুব ছোট critical section-এ অল্প ঘুরে তারপর ঘুমানো, যা Linux kernel-এর mutex বা glibc-র adaptive mutex-এর মতো lock নিজেই করে।
মূল উপলব্ধি: Spinlock ধরে নেয় holder এই মুহূর্তে চলছে। Kernel সেই নিশ্চয়তা দিতে পারে, user-space পারে না — তাই user-space-এ ঘুমাতে-পারা lock প্রায় সবসময় বেশি নিরাপদ, আর প্রায়ই বাস্তবে দ্রুতও।
প্র ০৫একটি atomic counter-এ ৬৪টি core একসাথে যোগ করলে গতি কেন ভেঙে পড়ে? “নির্ভুল” counter-এর বিকল্প কী?
atomic_fetch_add সঠিক, কিন্তু সস্তা নয়। প্রতিটি lock xadd-এর সময় core-টিকে cache line-এর একচ্ছত্র মালিক (M বা E অবস্থা) হতে হয়। একটি core একা যোগ করলে line তার cache-এই থাকে, আর প্রতিটি যোগ মোটামুটি কয়েক থেকে কয়েক ডজন cycle. কিন্তু ৬৪টি core একই counter-এ যোগ করলে প্রতিটি যোগের আগে line অন্য core থেকে আনতে হয় — interconnect দিয়ে, বড় সার্ভারে হয়তো অন্য socket থেকে, আর প্রতিটি স্থানান্তর শত শত cycle পর্যন্ত নিতে পারে। যোগগুলো তখন কার্যত একটি একটি করে চলে, একটি সারিতে: core বাড়ালে মোট গতি বাড়ে না, বরং অনেক সময় কমে। একে প্রায়ই বলা হয় contention-এর কারণে scalability ভেঙে পড়া।
সমাধানের মূল ধারণা: shared লেখা এড়ানো। Linux kernel-এ per-CPU counter: প্রতিটি CPU নিজের ঘরে (যা অন্য কেউ লেখে না, তাই line নিজের cache-এই থাকে) যোগ করে, আর পড়ার সময় সব ঘরের যোগফল নেওয়া হয়। percpu_counter আরেক ধাপ এগোয়: প্রতিটি CPU একটি সীমা পর্যন্ত নিজের ঘরে জমায়, সীমা পেরোলে তবেই কেন্দ্রীয় counter-এ যোগ করে — তাই কেন্দ্রীয় মান মোটামুটি সঠিক, আর নির্ভুল মান চাইলে সব ঘর যোগ করতে হয়। OSTEP বইয়ে এর নাম “approximate (sloppy) counter”, যেখানে সীমা বড় করলে গতি বাড়ে আর সঠিকতা কমে। Java-র LongAdder-ও একই ধারণায় চলে: প্রতিযোগিতা দেখলে আলাদা cell বানিয়ে যোগ ছড়িয়ে দেয়।
এই সমঝোতা সব জায়গায়: ডেটাবেসে একটি row-এ সবার “like” যোগ করলে সেই row-ই গলার কাঁটা হয়ে দাঁড়ায় (DBMS কোর্সের concurrency control পর্বে একই সমস্যা lock-এর ভাষায় দেখবেন)। আর পর্ব ২৯-এর RCU-ও মূলত একই নীতিতে দাঁড়িয়ে — পড়ার পথে কোনো shared লেখা নয়।
মূল উপলব্ধি: সঠিকতা দেয় atomic instruction, কিন্তু গতি দেয় নকশা — যত কম core একই cache line-এ লেখে, তত ভালো। “সবসময় নির্ভুল” আর “সবসময় দ্রুত” — অনেক সময় দুটির একটি বেছে নিতে হয়।
অনুশীলন
হাতে ট্রেস, ফল অনুমান আর কোড লেখা — আগে নিজে চেষ্টা করুন, তারপর উত্তর মেলান।
-
হাতে ট্রেস (ticket lock): একটি ticket lock-এ এখন next_ticket = 7, now_serving = 5. (ক) এই মুহূর্তে কয়টি থ্রেড lock ধরে আছে বা অপেক্ষা করছে? (খ) এরপর P, Q, R এই ক্রমে
lock()ডাকলে কে কোন টোকেন পাবে, আর তিনটি unlock-এর পর next_ticket আর now_serving কত হবে? (গ) R ঢোকার আগে মোট কয়জন critical section-এ ঢুকবে (এখনকার holder-সহ)?(ক) টোকেন 5 আর 6 দেওয়া হয়েছে কিন্তু সেবা শেষ হয়নি: 5-এর থ্রেড ভেতরে, 6-এর থ্রেড অপেক্ষায় — মোট ৭ − ৫ = ২টি.
(খ) P পায় 7, Q পায় 8, R পায় 9; next_ticket = 10. তিনটি unlock (5, 6 আর 7-এর ধারক বেরোয়) শেষে now_serving = 5 + 3 = 8 — এখন Q ভেতরে।
(গ) R-এর টোকেন 9, now_serving এখন 5, তাই তার আগে ঢুকবে 5, 6, 7, 8-এর ধারক — ৪ জন (৯ − ৫). তখন একসাথে ৫টি থ্রেড প্রতিযোগিতায় (৫ থেকে ৯), তাই R-এর অপেক্ষা n − ১ = ৪-এর সীমার মধ্যেই।
-
ফল অনুমান: শুরুতে x = y = 0. Message passing: Thread 1 লেখে
x = 1; y = 1;, Thread 2 পড়েr1 = y; r2 = x;. চারটি কাজই সাধারণ (relaxed) store/load, আর compiler কিছুই সরায় না ধরে নিন। (ক) SC-তে, (খ) x86-TSO-তে, (গ) ARMv8-এ —r1 == 1 && r2 == 0সম্ভব কি?(ক) SC-তে না। r1 = 1 মানে
y = 1আগে ঘটেছে, তার আগেx = 1, আরr2 = xঘটেছেr1 = y-এর পরে — তাই r2 = 1.(খ) x86-TSO-তেও না। Store buffer FIFO, তাই x = 1 মেমরিতে নামে y = 1-এর আগে (store-store ক্রম রক্ষা), আর Thread 2-এর দুটি load-এর ক্রমও রক্ষা হয় (load-load). x86-এ ভাঙে শুধু store → load.
(গ) ARMv8-এ হ্যাঁ। সেখানে দুটি store আলাদা ক্রমে দৃশ্যমান হতে পারে, আর দুটি load-ও। প্রতিকার:
y-এর store release আরy-এর load acquire (অংশ ৯), তখন r1 = 1 হলে r2 = 1 নিশ্চিত। শিক্ষা: x86-এ এই কোড “কাজ করে”, ARM ফোনে বা সার্ভারে নয়। -
হাতে ট্রেস (bounded-waiting TAS): n = 5 থ্রেড (0–4), অংশ ২-এর bounded-waiting TAS অ্যালগরিদম। থ্রেড 3 critical section থেকে বেরোচ্ছে, তখন
waiting = [true, false, false, false, true](সূচক 0–4) আর lock = true. থ্রেড 3 কী করবে, আর কে পরের জন ঢুকবে? যদি সবwaitingfalse হতো?থ্রেড 3 খোঁজা শুরু করে j = (৩ + ১) mod ৫ = 4 থেকে।
waiting[4] == true, তাই লুপ সেখানেই থামে। j ≠ 3, তাই সেwaiting[4] = falseকরে — lock খোলে না (lock তখনো true). থ্রেড 4-এর প্রবেশ-লুপের শর্তwaiting[4] && keyএখন মিথ্যা, তাই সে TAS ছাড়াই ভেতরে ঢোকে। থ্রেড 0 অপেক্ষা করছিল, কিন্তু বৃত্তাকার ক্রমে 4 আগে।সব
waitingfalse হলে j ঘুরে 4, 0, 1, 2 পেরিয়ে আবার 3-এ ফেরে, তাইj == i— তখন থ্রেড 3lock = falseকরে, পরের আগন্তুক TAS-এ 0 পেয়ে ঢুকবে। কোনো ক্ষেত্রেই দুজন একসাথে ঢোকে না: “অনুমতিপত্র” একটিই, হয় lock-এ নয় কারও waiting-এ। -
কোড লিখুন: C11 atomic দিয়ে একটি ফাংশন লিখুন,
int fetch_and_multiply(atomic_int *p, int k), যা*p-কে অবিভাজ্যভাবে k দিয়ে গুণ করে আর গুণের আগের মান ফেরত দেয়। হার্ডওয়্যারে fetch-and-multiply নেই — কীভাবে করবেন?অংশ ৩-এর CAS লুপ: পড়ুন, নতুন মান হিসাব করুন, CAS দিয়ে লেখার চেষ্টা করুন; মাঝে কেউ বদলে থাকলে CAS ব্যর্থ হয়ে
old-এ নতুন মান এনে দেয়, তখন আবার হিসাব।#include <stdatomic.h> int fetch_and_multiply(atomic_int *p, int k) { int old = atomic_load(p); while (!atomic_compare_exchange_weak(p, &old, old * k)) ; /* old was refreshed with the current value */ return old; }লুপের প্রতিটি ঘুরে
old * kনতুন করে হিসাব হয় (CAS-এর পরেoldবদলায়), তাই কোনো গুণ হারায় না।weakসংস্করণ এখানে ঠিক, কারণ মিথ্যা ব্যর্থতাও শুধু আরেকবার ঘোরায়। এটি lock-free (কেউ না কেউ সবসময় এগোয়), তবে wait-free নয় — খুব প্রতিযোগিতায় একটি থ্রেড বারবার হারতে পারে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমTicket lock-এ একটি থ্রেডের lock নেওয়া থেকে ছাড়া পর্যন্ত ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
fetch_add(&next_ticket, 1)দিয়ে নিজের টোকেন নেয়now_servingপড়ে দেখে, নিজের টোকেনের সমান নয় — ঘুরতে থাকে- আগের holder-এর unlock-এ
now_servingবেড়ে তার টোকেনের সমান হয় - লুপ থেকে বেরিয়ে critical section-এ shared ডেটা হালনাগাদ করে
- Unlock:
now_servingএক বাড়ায় (release) - পরের টোকেনধারী থ্রেড ঢোকে
একটি test-and-set (TAS) spinlock critical section সমস্যার কোন শর্তটি নিশ্চিত করে না?
- Mutual exclusion
- Progress
- Bounded waiting
- Critical section-এর ভেতরে কাজ শেষ করার স্বাধীনতা
x86-TSO memory model-এ কোন ধরনের reordering অন্য core-এর চোখে দেখা যেতে পারে?
- একটি store তার আগের store-কে ছাড়িয়ে যাওয়া
- একটি load তার আগের (ভিন্ন ঠিকানার) store-কে ছাড়িয়ে যাওয়া
- একটি load তার আগের load-কে ছাড়িয়ে যাওয়া
- কোনো reordering-ই হয় না
দুটি থ্রেড একটি shared C ভ্যারিয়েবলে count++ করে। ভ্যারিয়েবলটি volatile int ঘোষণা করলে কী হয়?
- count++ অবিভাজ্য হয়ে যায়, তাই ফল সবসময় ঠিক
- CPU-স্তরের সব reordering বন্ধ হয়
- Compiler error দেয়
- প্রতিটি পড়া-লেখা মেমরিতে হয়, কিন্তু lost update আর data race থেকে যায়
ARMv8-এ ldxr-এর পর stxr কোন ক্ষেত্রে ব্যর্থ হয় (status register-এ 1)?
- ldxr-এর পর exclusive monitor-এর নজর ভেঙেছে, যেমন অন্য core ওই ঠিকানায় লিখেছে
- লেখার মান পুরোনো মানের সমান হলে
- ldxr শূন্য পড়লে
- মাঝে কেউ ওই ঠিকানায় না লিখলে
একটি ticket lock-এ next_ticket = 12 আর now_serving = 9. এখন নতুন একটি থ্রেড lock() ডাকলে, সে ঢোকার আগে আর কয়টি থ্রেড critical section-এ ঢুকবে বা শেষ করবে (এখনকার holder-সহ)?
- ১টি
- ২টি
- ৩টি
- ৪টি
Producer লেখে data = 42; তারপর ready flag-এ 1 লেখে; consumer flag-এ 1 দেখলে data পড়ে। C11-এ ন্যূনতম কোন memory order দিলে consumer নিশ্চিতভাবে 42 দেখবে?
- দুই দিকেই
memory_order_relaxed - flag-এর store-এ
release, load-এacquire - শুধু store-এ
release, load relaxed - flag-কে
volatile intকরা
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- শুধু load/store যথেষ্ট নয় — compiler আর CPU (x86-এর store buffer) ক্রম বদলায়; Peterson বাঁচাতে fence লাগে, আর বাস্তবে লাগে হার্ডওয়্যারের atomic RMW instruction.
- TAS, CAS, FAA: x86-এ
xchg,lock cmpxchg,lock xadd; ARM-এldxr/stxr(বা LSE-রcas,ldadd). CAS লুপ দিয়ে যেকোনো হালনাগাদ অবিভাজ্য করা যায়। - TAS lock-এ bounded waiting নেই; bounded-waiting TAS বা ticket lock (FIFO, সর্বোচ্চ n − ১ জনের অপেক্ষা) তা দেয় — তবে oversubscription-এ ticket lock বিপজ্জনক।
- Spin শুধু ছোট আর multicore-এ, বা যেখানে ঘুমানো নিষেধ; TTAS + backoff +
pause; kernel-এspin_lock_irqsave, ভেতরে ঘুম নিষেধ; Linux-এর qspinlock হলো ৪ বাইটে MCS. - SC নয়, TSO বা আরও দুর্বল: store-buffering পরীক্ষায় x86-এ r1 = r2 = 0 সত্যিই আসে; message passing-এ release/acquire;
volatileকখনো synchronization নয়।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ২৪ · Mutex আর semaphoreপরবর্তী পর্বঘুমাতে-পারা lock, semaphore-এর wait/signal আর futex.
- অপারেটিং সিস্টেম · কোর্স হোম৬৪টি পর্বপুরো কোর্সের রোডম্যাপ — race condition থেকে RCU পর্যন্ত মডিউল ৪-এ।
- C++ · async আর atomicsহাতে-কলমে
std::atomicআর memory order — C++-এর দিক থেকে আরও অনুশীলন। - Java · থ্রেড আর synchronizationপাশের ট্র্যাক
synchronized,volatileআর atomic class — Java-র memory model-এ। - DBMS · Concurrency controlএকই সমস্যা, অন্য জগৎডেটাবেসে lock আর ন্যায্যতা — transaction-এর ভাষায়।