Memory Consistency Models — coherence যথেষ্ট নয়, ক্রমও লাগে
Memory Consistency Models
গত দুই লেসনে আমরা দেখেছি ডেটা কোথায় বসে (NUMA) আর কীভাবে একই address সব core-এ সামঞ্জস্যপূর্ণ থাকে (coherence, আগের লেসনে)। কিন্তু একটা সম্পূর্ণ ভিন্ন প্রশ্ন বাকি: যখন একটা core দুইটা ভিন্ন address-এ লেখে, অন্য core কি সেই দুইটা লেখা একই ক্রমে দেখে? উত্তর — বাস্তব হার্ডওয়্যারে, প্রায়ই না। এই লেসনে sequential consistency, x86-এর TSO, আর ARM-এর weak memory model — আর কীভাবে C++11-এর memory_order দিয়ে এই সবকিছু portable ভাষায় প্রকাশ করা যায়।
আগে এটা বুঝি
গত দুই লেসনে multi-core hardware-এর দুইটা বাস্তবতা দেখা হয়েছে — memory কোথায় বসে (NUMA), আর memory line-এর গ্রানুলারিটি কীভাবে অপ্রত্যাশিত খরচ তৈরি করে (false sharing)। তার আগের লেসনে (৩) দেখা হয়েছে MESI protocol কীভাবে নিশ্চিত করে — একটা নির্দিষ্ট memory address-এর সব কপি সবসময় সামঞ্জস্যপূর্ণ থাকে।
এখন একটা প্রশ্ন করা যাক যেটা coherence উত্তর দেয় না, দেওয়ার কথাও না:
/* Thread 1 (core A-তে) */
data = 42;
flag = 1;
/* Thread 2 (core B-তে) */
while (flag == 0) { } /* অপেক্ষা */
print(data);MESI নিশ্চিত করে flag-এর সব কপি একদিন 1 দেখাবে, আর
data-এর সব কপি একদিন 42 দেখাবে — এইটুকু coherence-এর
প্রতিশ্রুতি, আর এটা রাখা হবে। কিন্তু coherence এটা বলে
না: যখন Thread 2 flag == 1 দেখে, তখন কি সে নিশ্চিতভাবে
data == 42-ও দেখবে?
সরল উত্তর মনে হয় “অবশ্যই, কারণ Thread 1 আগে data লিখেছে,
পরে flag” — কিন্তু এই যুক্তিটা একটা অনুমান যা coherence
থেকে আসে না। Coherence শুধু বলে প্রতিটা একক address
নিজের ভেতরে সামঞ্জস্যপূর্ণ। দুইটা ভিন্ন address-এর লেখার
মধ্যে আপেক্ষিক ক্রম — Thread 2 কি সেই একই ক্রমে দেখবে
যেভাবে Thread 1 লিখেছে — এটা একটা সম্পূর্ণ ভিন্ন প্রশ্ন,
আর এই প্রশ্নের উত্তর দেয় একটা ভিন্ন চুক্তি: memory
consistency model।
এই পার্থক্যটা এতটাই গুরুত্বপূর্ণ, আর এতটাই প্রায়ই গুলিয়ে ফেলা হয়, যে এই পুরো লেসনের কেন্দ্রীয় বার্তা এক বাক্যে বলে রাখা ভালো:
Coherence একটা address-এর গল্প। Consistency একাধিক address-এর মধ্যেকার order-এর গল্প। প্রথমটা প্রতিটা আধুনিক multi-core CPU দেয়, বিনামূল্যে, সবসময়। দ্বিতীয়টা — না, কখনোই “বিনামূল্যে” না, আর কোন হার্ডওয়্যার কতটা দেয় তা ব্যাপকভাবে ভিন্ন।
মূল ধারণা
Sequential consistency — সবচেয়ে স্বজ্ঞাত মডেল
কল্পনা করা সবচেয়ে সহজ মডেলটা প্রথমে সংজ্ঞায়িত করা যাক — এটাই baseline, যার সাপেক্ষে বাস্তব হার্ডওয়্যারকে “দুর্বল” (weaker) বলা হবে।
Sequential Consistency (SC), Leslie Lamport-এর ১৯৭৯-এর সংজ্ঞা:
একটা multiprocessor execution-এর ফলাফল এমন হতে হবে যেন সব processor-এর সব operation একটা একক ক্রমিক ক্রমে ঘটেছে (কোনো interleaving), আর প্রতিটা processor-এর নিজস্ব operation-গুলো সেই ক্রমে তার program order অনুযায়ী আসে।
সহজ ভাষায় — কল্পনা করুন সব core-এর সব instruction একটা একক সারিতে সাজানো হয়েছে (কীভাবে সাজানো হয়েছে তা যেকোনো বৈধ interleaving হতে পারে), কিন্তু প্রতিটা core-এর নিজের instruction-গুলো তার কোড-এ যে ক্রমে লেখা সেই ক্রমেই সারিতে থাকতে হবে। কোনো reordering নেই, দৃশ্যমান কোনো “ভেতরের বিশৃঙ্খলা” নেই — মনে হবে যেন সবকিছু একটাই CPU একে একে চালাচ্ছে, শুধু কোন core-এর turn কখন আসবে তা অনিশ্চিত।
উপরের data/flag উদাহরণে, SC-তে Thread 2 flag==1 দেখলে
data==42 নিশ্চিতভাবে দেখবে — কারণ program order অনুযায়ী
data=42 অবশ্যই flag=1-এর আগে সারিতে বসবে, আর flag-এর
read সেই flag=1-এর পরে বসবে (তাই দেখেছে ১), তাই transitivity-তে
data=42 অবশ্যই সেই read-এর আগেই সারিতে বসে গেছে।
x86 — Total Store Order (TSO)
x86 (Intel/AMD উভয়ই) একটা মডেল অনুসরণ করে যার নাম TSO — Total Store Order। এটা SC-এর তুলনায় প্রায় পূর্ণ, কিন্তু একটা নির্দিষ্ট, নামকরা ব্যতিক্রম রাখে।
TSO-র নিয়ম:
| Reordering ধরন | x86 TSO-তে অনুমোদিত? |
|---|---|
| Load → Load (দুইটা read একে অপরের সাপেক্ষে reorder) | না — program order বজায় থাকে |
| Load → Store | না |
| Store → Store (দুইটা write একে অপরের সাপেক্ষে reorder) | না — writes সবসময় program-order-এ visible হয় |
| Store → Load (একটা write-এর পরের read আগে চলে যাওয়া) | হ্যাঁ — এটাই একমাত্র ব্যতিক্রম |
এই একমাত্র exception-টার নাম store-load reordering, আর এটা আসে হার্ডওয়্যারের একটা খুবই বাস্তব optimization থেকে — store buffer। যখন একটা core একটা memory address-এ লেখে, সেই লেখাটা সাথে সাথে cache/memory-তে যায় না — প্রথমে একটা ছোট, local store buffer-এ যায় (যাতে core পরের instruction-এ এগিয়ে যেতে পারে, write সম্পূর্ণ হওয়ার জন্য অপেক্ষা না করে)। এই buffer পরে asynchronously প্রকৃত cache-এ flush হয়।
সমস্যাটা এখানে: যদি সেই core-ই সাথে সাথে পরের instruction-এ একটা ভিন্ন address পড়ে, সে সেই read-টা store buffer flush হওয়ার অপেক্ষা না করেই চালাতে পারে — অন্য core-দের দৃষ্টিতে মনে হবে যেন read-টা write-এর আগে ঘটেছে, যদিও প্রোগ্রামে write আগে লেখা ছিল।
ARM/RISC-V — Weak memory model
ARM (v7/v8), RISC-V (RVWMO), আর POWER আরও অনেক বেশি স্বাধীনতা নেয়। ডিফল্টভাবে প্রায় সব চার ধরনের reordering-ই অনুমোদিত — Load→Load, Load→Store, Store→Store, Store→Load — যদি না প্রোগ্রামার explicit ভাবে একটা memory barrier/fence বা acquire/release-semantics-সহ instruction ব্যবহার করে বলে দেয় “এখানে অর্ডার বজায় রাখো”।
এই নকশা-দর্শনের যুক্তি — এই CPU-গুলো (বিশেষত ARM, মোবাইল আর এখন সার্ভার উভয় বাজারেই power-efficiency-কেন্দ্রিক) হার্ডওয়্যার-স্তরে কম কড়া গ্যারান্টি রেখে বেশি aggressive reordering আর pipeline optimization করতে চায়, আর সেই দায়িত্বটা compiler/প্রোগ্রামারের হাতে তুলে দেয় — শুধু যেখানে সত্যিই দরকার, সেখানেই explicit fence-এর খরচ দিতে হবে, বাকি সব জায়গায় হার্ডওয়্যার সম্পূর্ণ স্বাধীন।
| x86 (TSO) | ARM/RISC-V (weak) | |
|---|---|---|
| ডিফল্ট reordering | শুধু Store→Load | প্রায় সবকিছু |
| Explicit fence কতবার লাগে | কম (বেশিরভাগ code স্বাভাবিকভাবেই “যথেষ্ট order” পায়) | বেশি (portable correctness-এর জন্য প্রায়ই দরকার) |
| দর্শন | “strong by default, ব্যতিক্রম explicit করো” | “weak by default, order explicit করো” |
ভেতরে কী ঘটছে
Litmus test ১ — Store Buffering (SB), x86-এর নির্দিষ্ট ব্যতিক্রম
এই test-টা ক্লাসিক — অনেকটা Dekker/Peterson mutual-exclusion
algorithm-এর মূল কাঠামো। দুইটা independent flag, X আর Y,
শুরুতে 0:
/* শুরুতে: X = 0, Y = 0 */
/* Thread 1 */ /* Thread 2 */
X = 1; Y = 1;
r1 = Y; r2 = X;প্রশ্ন: r1 == 0 এবং r2 == 0 — দুটোই একসাথে সম্ভব কি?
Sequential consistency-তে: অসম্ভব। প্রমাণ সংক্ষেপে: SC-তে
সব instruction-এর একটা একক total order আছে যা প্রতিটা
thread-এর program order মানে। যদি r1==0 হয়, তার মানে
Thread 1-এর r1=Y read সেই total order-এ Thread 2-এর Y=1
write-এর আগে ঘটেছে। একইভাবে r2==0 মানে Thread 2-এর
r2=X read Thread 1-এর X=1-এর আগে ঘটেছে। কিন্তু program
order দাবি করে X=1 তার নিজের thread-এ r1=Y-এর আগে, আর
Y=1 তার নিজের thread-এ r2=X-এর আগে। এই চারটা সম্পর্ক
মিলিয়ে একটা চক্র (cycle) তৈরি হয় — X=1 < r1=Y < Y=1 < r2=X < X=1 — যা একটা total order-এ অসম্ভব (একটা জিনিস নিজের
আগে হতে পারে না)। তাই SC-তে অন্তত একজনের read 1 দেখতেই হবে।
x86 TSO-তে: সম্ভব — এবং বাস্তবে মাপা গেছে। কারণ ঠিক
উপরের “store buffer” ব্যাখ্যা। উভয় thread-এর X=1/Y=1
write প্রথমে নিজ নিজ core-এর store buffer-এ বসে থাকে, cache-এ
এখনো flush হয়নি। তারপরই সেই একই core Y/X পড়ে — এই
read সরাসরি cache থেকে যায় (নিজের pending store বাদে, যেটা
store buffer থেকেই forward হয়), অন্য core-এর store buffer-এ
কী বসে আছে তা সে দেখতে পায় না। ফলাফল: উভয় read-ই পুরনো
মান 0 দেখতে পারে, যদিও উভয় write “আগেই ঘটেছে” প্রোগ্রামের
ক্রমে।
- Thread 1, core A: X = 1write X=1 store buffer A-তে বসে, এখনো cache/memory-তে visible না
- Thread 2, core B: Y = 1সমান্তরালে, write Y=1 store buffer B-তে বসে, এখনো visible না
- Thread 1, core A: r1 = Ycore A cache থেকে Y পড়ে -- core B-র store buffer এখনো flush হয়নি, তাই পুরনো মান 0 দেখে
- Thread 2, core B: r2 = Xএকই কারণে, core B পুরনো X=0 দেখে
- কিছুক্ষণ পরেউভয় store buffer flush হয়ে যায়, cache coherence protocol এখন X=1, Y=1 সব core-এ সামঞ্জস্যপূর্ণ দেখায় -- কিন্তু read দুটো ততক্ষণে হয়ে গেছে
সমাধান — একটা memory fence:
/* Thread 1 */ /* Thread 2 */
X = 1; Y = 1;
mfence; mfence;
r1 = Y; r2 = X;mfence (x86) core-কে বাধ্য করে তার store buffer সম্পূর্ণ
flush করতে, পরের কোনো load চালানোর আগে। উভয় thread-এ
এই fence থাকলে, r1==0 && r2==0 আর সম্ভব থাকে না — ঠিক
SC-র মতো আচরণ ফিরে আসে, কিন্তু একটা দাম দিয়ে (fence নিজেই
কয়েক দশ cycle খরচ করে, কারণ এটা pipeline-কে সেই মুহূর্তে
থামিয়ে সব pending store flush হওয়ার জন্য অপেক্ষা করায়)।
Litmus test ২ — Message Passing (MP), ARM/RISC-V-এর ব্যাপক reordering
এবার লেসনের শুরুর উদাহরণে ফিরে আসি, এখন একটা litmus test হিসেবে formal:
/* শুরুতে: data = 0, flag = 0 */
/* Thread 1 (writer) */ /* Thread 2 (reader) */
data = 42; while (flag == 0) { } /* r1 = flag, ধরে নেওয়া হচ্ছে দেখেছে 1 */
flag = 1; r2 = data;প্রশ্ন: যদি Thread 2 flag == 1 দেখে (loop থেকে বের
হয়), তাহলে কি r2 == 42 নিশ্চিত?
Sequential consistency-তে: হ্যাঁ, নিশ্চিত — একই transitivity
যুক্তি: data=42 প্রোগ্রাম-অর্ডারে flag=1-এর আগে, আর
Thread 2-এর flag-read (যা 1 দেখেছে) তার নিজের data-read-এর
আগে (program order)। SC total order-এ data=42 < flag=1,
আর দেখা গেছে flag=1-এর write সেই read-এর “আগে বা একই সময়ে”
দৃশ্যমান হয়েছে বলেই read 1 দেখেছে — transitivity দিয়ে
data=42 অবশ্যই Thread 2-এর data read-এর আগে ঘটে গেছে।
x86 TSO-তে: হ্যাঁ, এখানেও নিশ্চিত — এবং এটা গুরুত্বপূর্ণ
লক্ষ করার মতো একটা সূক্ষ্ম বিষয়। এই প্যাটার্নে Thread 1-এর
দুইটা write (store-store), TSO-তে এই ক্রম সংরক্ষিত
থাকে (উপরের টেবিলে “Store → Store: না [reorder হয় না]”)। তাই
data=42 সবসময় flag=1-এর আগেই অন্য core-দের কাছে visible
হবে x86-এ, কোনো fence ছাড়াই। এই নির্দিষ্ট bug-টা x86-এ
কখনো ঘটে না — এটাই কেন প্রোগ্রামাররা প্রায়ই ভুলভাবে বিশ্বাস
করেন “flag দিয়ে signal করা নিরাপদ, কোনো barrier ছাড়াই” —
তারা শুধু x86-এ পরীক্ষা করেছেন।
ARM/RISC-V weak model-এ: না, নিশ্চিত না — এবং এটাই বাস্তবে
মাপা গেছে। কারণ এই স্থাপত্যগুলো ডিফল্টভাবে store-store
reordering-ও অনুমোদন করে। Thread 1-এর flag=1 write বিভিন্ন
কারণে (ভিন্ন cache line, ভিন্ন memory bank-এর জন্য ভিন্ন
সময়ে সম্পন্ন হওয়া, out-of-order write-back বাফারিং) data=42-এর
আগেই অন্য core-দের কাছে visible হয়ে যেতে পারে। ফলাফল:
Thread 2 flag==1 দেখে ফেলতে পারে, অথচ তখনও data-এর পুরনো
মান 0 দেখছে — একটা সরাসরি, বাস্তব bug।
- Thread 1: data = 42write ইস্যু হলো, কিন্তু ARM-এ পরবর্তী write-এর সাপেক্ষে এর সম্পন্ন হওয়ার ক্রম নিশ্চিত না
- Thread 1: flag = 1weak model-এ এই write data-এর write-কে "ওভারটেক" করে আগে অন্য core-এ পৌঁছাতে পারে
- Thread 2: flag পড়ে, দেখে 1loop থেকে বের হয় -- মনে করে data-ও নিশ্চয়ই লেখা হয়ে গেছে
- Thread 2: data পড়েকিন্তু data=42-এর write তখনো এই core-এ পৌঁছায়নি -- পুরনো মান 0 দেখে যায়
- x86-এ এই একই কোডstore-store reordering নিষিদ্ধ বলে এই bug কখনো ঘটে না -- একই কোড, ভিন্ন hardware, ভিন্ন ফলাফল
সমাধান — explicit barrier (ARM assembly-স্তরে):
// Thread 1 (ARM64)
str w1, [data_ptr] // data = 42
dmb ish // store-store barrier: data-এর write নিশ্চিতভাবে flag-এর write-এর আগে visible হবে
str w2, [flag_ptr] // flag = 1
// Thread 2 (ARM64)
loop: ldr w0, [flag_ptr]
cbz w0, loop // flag == 0 হলে আবার চেষ্টা
dmb ish // load-load barrier: flag-read-এর পরের data-read নিশ্চিতভাবে এই flag-read-এর পরে ঘটবে
ldr w3, [data_ptr]dmb ish (Data Memory Barrier, Inner Shareable domain) দুই
পাশেই বসানো — writer-এ store-store ordering নিশ্চিত করছে,
reader-এ load-load ordering নিশ্চিত করছে। এই দুইটা মিলিয়ে
পুরো chain-টা restore হয়।
উদাহরণ
C++11 memory_order — portable সমাধান, architecture না জেনেই
Raw fence hand-লেখা architecture-নির্দিষ্ট, ভুল-প্রবণ, আর
পোর্টেবল না। C++11 std::atomic আর memory_order enum
দিয়ে একই semantics প্রকাশ করে, compiler-কে target-নির্দিষ্ট
সঠিক instruction (বা x86-এর ক্ষেত্রে, প্রায়ই কিছুই না,
কারণ TSO ইতিমধ্যেই যথেষ্ট) generate করার দায়িত্ব দিয়ে।
memory_order | অর্থ | কোথায় ব্যবহার |
|---|---|---|
relaxed | শুধু atomicity, কোনো ordering গ্যারান্টি না — অন্য কোনো memory operation-এর সাপেক্ষে reorder হতে পারে | pure counter (যেমন statistics), যেখানে শুধু final valueটা লাগে, ক্রম না |
release | এই write-এর আগের সব write (এই থ্রেডের প্রোগ্রাম-অর্ডারে) এই release-write-এর সাথেসাথেই বা তার আগে visible হবে, অন্য থ্রেডের matching acquire-read-এর কাছে | producer-এর “রেডি” সংকেত (flag, lock unlock) |
acquire | এই read-এর পরের সব read/write অন্য থ্রেডের matching release-write-এর পরেই ঘটবে বলে দেখা যাবে | consumer-এর “রেডি কিনা” চেক (flag, lock lock) |
seq_cst (ডিফল্ট) | সম্পূর্ণ sequential consistency — সবচেয়ে কড়া, সবচেয়ে নিরাপদ, সবচেয়ে বেশি খরচ | যখন সন্দেহ থাকে, বা SB-জাতীয় multi-directional ordering দরকার |
MP উদাহরণ, release/acquire দিয়ে সঠিক
#include <atomic>
int data = 0;
std::atomic<int> flag{0};
/* Thread 1 (writer) */
void producer() {
data = 42; /* সাধারণ, non-atomic write */
flag.store(1, std::memory_order_release); /* release -- সব আগের write "সিল" করে দেয় */
}
/* Thread 2 (reader) */
void consumer() {
while (flag.load(std::memory_order_acquire) == 0) { } /* acquire */
int r = data; /* গ্যারান্টিড 42 দেখবে, যেকোনো architecture-এ */
}release/acquire জোড়া একটা happens-before edge তৈরি
করে — C++11 standard-এর ভাষায়, flag.store(release) আর
flag.load(acquire) (যেটা সেই মানই দেখে) একে অপরের সাথে
synchronizes-with সম্পর্কে থাকে, আর তার মানে producer-এর
release-এর আগের সব effect (এখানে data=42) consumer-এর
acquire-এর পরের সব code-এর কাছে দৃশ্যমান হওয়ার নিশ্চয়তা
পায়। Compiler এই কোড কম্পাইল করার সময় x86-এ কার্যত কোনো
extra instruction যোগ করবে না (কারণ TSO ইতিমধ্যেই store-store
আর load-load ordering দেয়, শুধু flag নিজেই atomic হতে হবে
compiler-optimization এড়াতে), কিন্তু ARM64-এ ঠিক উপরের মতো
dmb/load-acquire (ldar)/store-release (stlr) instruction
বসিয়ে দেবে — প্রোগ্রামারকে architecture জানতে হয়নি।
SB উদাহরণ, seq_cst দিয়ে সঠিক
std::atomic<int> X{0}, Y{0};
/* Thread 1 */ /* Thread 2 */
X.store(1); Y.store(1);
int r1 = Y.load(); int r2 = X.load();
/* ডিফল্ট memory_order = seq_cst */std::atomic-এর সব অপারেশনের ডিফল্ট memory_order_seq_cst
— তাই কোনো explicit annotation ছাড়াই এই কোড সব architecture-এ
sequential-consistency আচরণ দেয় (r1==0 && r2==0 অসম্ভব)।
seq_cst সবচেয়ে ব্যয়বহুল মোড কারণ এটাই একমাত্র মোড যা SB-জাতীয়
multi-directional guarantee দেয় — শুধু release/acquire
(যা একমুখী, producer→consumer) এখানে যথেষ্ট না, কারণ এখানে
দুই দিকেই সমান order দরকার।
নিজে চালিয়ে দেখুন
SB litmus test নিজে চালিয়ে x86-এ reordering ধরা
/* sb_litmus.c -- gcc -O2 -pthread -o sb_litmus sb_litmus.c */
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdatomic.h>
#include <sched.h>
#define ITERS 2000000
static volatile int X, Y;
static int r1_arr[ITERS], r2_arr[ITERS];
static pthread_barrier_t bar;
#ifndef USE_FENCE
#define USE_FENCE 0
#endif
void *t1(void *arg) {
for (int i = 0; i \< ITERS; i++) {
pthread_barrier_wait(&bar); /* প্রতিটা iteration একসাথে শুরু, race window বড় করতে */
X = 1;
#if USE_FENCE
atomic_thread_fence(memory_order_seq_cst);
#endif
r1_arr[i] = Y;
}
return NULL;
}
void *t2(void *arg) {
for (int i = 0; i \< ITERS; i++) {
pthread_barrier_wait(&bar);
Y = 1;
#if USE_FENCE
atomic_thread_fence(memory_order_seq_cst);
#endif
r2_arr[i] = X;
X = 0; Y = 0; /* পরের iteration-এর জন্য রিসেট (শুধু t2 করছে, race আছে কিন্তু litmus test-এর জন্য যথেষ্ট) */
}
return NULL;
}
int main(void) {
pthread_barrier_init(&bar, NULL, 2);
pthread_t a, b;
pthread_create(&a, NULL, t1, NULL);
pthread_create(&b, NULL, t2, NULL);
pthread_join(a, NULL);
pthread_join(b, NULL);
long both_zero = 0;
for (int i = 0; i \< ITERS; i++)
if (r1_arr[i] == 0 && r2_arr[i] == 0) both_zero++;
printf("USE_FENCE=%d: %ld / %d iteration-এ r1==0 && r2==0 (SC-তে এটা 0 হওয়া উচিত)\n",
USE_FENCE, both_zero, ITERS);
return 0;
}gcc -O2 -pthread -o sb_no_fence sb_litmus.c
gcc -O2 -pthread -DUSE_FENCE=1 -o sb_fence sb_litmus.c
./sb_no_fence
./sb_fenceপ্রত্যাশিত ধরনের output (সংখ্যা মেশিন/লোডভেদে ভিন্ন হবে, কিন্তু দিকটা ধারাবাহিক):
USE_FENCE=0: 148302 / 2000000 iteration-এ r1==0 && r2==0
USE_FENCE=1: 0 / 2000000 iteration-এ r1==0 && r2==0fence ছাড়া, লক্ষ লক্ষ iteration-এর প্রায় ৭%-এ SC-বিরোধী
ফলাফল দেখা যায় — x86-এর store buffer সত্যিই কাজ করছে,
প্রত্যক্ষভাবে পরিমাপযোগ্য। mfence/atomic_thread_fence
যোগ করলে সংখ্যা ঠিক শূন্যে নেমে আসে — কোনো ব্যতিক্রম
ছাড়াই।
x86-এর store buffer সত্যিই একটা পরিমাপযোগ্য reordering ঘটায় -- raw variable দিয়ে r1==0 && r2==0 দুইটাই মাঝে মাঝে ঘটে, আর mfence/atomic seq_cst যোগ করলে তা সম্পূর্ণ বন্ধ হয়ে যায়।
নিজে বানান
একটা mini memory-model checker -- MP litmus test x86-এ পরিমাপ
- MP litmus test লিখুন -- non-atomic data + non-atomic flag, spin loop দিয়ে flag চেক
- বহু iteration চালিয়ে গণনা করুন কতবার flag==1 দেখেও data-র পুরনো মান দেখা গেছে
- x86-এ এই সংখ্যা কেন প্রায় সবসময় 0 আসবে তা লেসনের TSO আলোচনা থেকে ব্যাখ্যা করুন
- flag-কে std::atomic<int> করে release/acquire semantics যোগ করুন, নিশ্চিত করুন এখনো সঠিক
- flag-কে relaxed atomic করে (release/acquire ছাড়া) আবার চালান -- x86-এ এখনো কেন সঠিক ফলাফল আসতে পারে তা যুক্তি দিন, আর কেন এটা তবু ভুল কোড তা ব্যাখ্যা করুন
/* mp_litmus.c -- gcc -O2 -pthread -o mp_litmus mp_litmus.c */
#include <pthread.h>
#include <stdio.h>
#include <stdatomic.h>
#define ITERS 500000
static int data;
static atomic_int flag;
static long stale_reads = 0;
void *writer(void *arg) {
for (int i = 0; i \< ITERS; i++) {
data = i + 1; /* কখনো 0 না, তাই stale detect করা যায় */
atomic_store_explicit(&flag, 1, memory_order_release);
while (atomic_load_explicit(&flag, memory_order_acquire) != 0) { } /* পরের iteration-এর জন্য reset-এর অপেক্ষা */
}
return NULL;
}
void *reader(void *arg) {
for (int i = 0; i \< ITERS; i++) {
while (atomic_load_explicit(&flag, memory_order_acquire) == 0) { }
if (data == 0) stale_reads++; /* release/acquire সঠিক হলে এটা কখনো ঘটবে না */
atomic_store_explicit(&flag, 0, memory_order_release);
}
return NULL;
}
int main(void) {
pthread_t w, r;
pthread_create(&w, NULL, writer, NULL);
pthread_create(&r, NULL, reader, NULL);
pthread_join(w, NULL);
pthread_join(r, NULL);
printf("stale reads: %ld / %d (release/acquire দিয়ে 0 হওয়া উচিত)\n", stale_reads, ITERS);
return 0;
}gcc -O2 -pthread -o mp_litmus mp_litmus.c && ./mp_litmusপ্রত্যাশিত output: stale reads: 0 / 500000 — কারণ
memory_order_release/memory_order_acquire সঠিকভাবে ব্যবহৃত
হয়েছে। memory_order_relaxed-এ পাল্টে চালালে x86-এ এখনো
সাধারণত 0 পাবেন (কারণ x86 TSO নিজেই store-store/load-load
ordering দেয়, relaxed শুধু compiler-optimization বন্ধ
করে) — কিন্তু এই কোড তবু ভুল, কারণ এটা ARM/RISC-V-এ চালালে
stale read দেখা যাবে। এই BuildIt-এর সবচেয়ে গুরুত্বপূর্ণ শিক্ষা:
x86-এ “কাজ করা” কখনো সঠিকতার প্রমাণ না যদি কোড অন্য
architecture-এ চলার সম্ভাবনা থাকে (ARM সার্ভার — AWS Graviton,
Ampere — এখন প্রোডাকশনে সাধারণ)।
বাস্তব সিস্টেমে
Memory consistency model যেখানে সরাসরি bug বা redesign ঘটায়
Linux kernel-এর smp_wmb()/smp_rmb()/smp_mb()। Kernel
নিজেই architecture-independent কোড লেখে যা x86, ARM, RISC-V,
PowerPC সব জায়গায় চলে — তাই raw mfence/dmb লেখার বদলে
এই macro-গুলো ব্যবহার করে, যা প্রতিটা architecture-এ সঠিক
instruction-এ compile হয় (x86-এ প্রায়ই no-op, ARM-এ প্রকৃত
barrier)।
RCU (Read-Copy-Update), Linux kernel-এর lock-free data structure কৌশল। RCU-র সঠিকতা সরাসরি নির্ভর করে publish (নতুন ডেটা তৈরি করে pointer আপডেট করা) আর subscribe (pointer পড়ে ডেটা ব্যবহার করা) — এই দুইটার মধ্যে সঠিক release/acquire-জাতীয় ordering-এর উপর, ঠিক এই লেসনের MP litmus test-এর মতো কাঠামোতে, কিন্তু বিশাল স্কেলে ব্যবহৃত।
JVM Memory Model (JSR-133, ২০০৪)। Java-র volatile
keyword-এর semantics ২০০৪ সালে সম্পূর্ণ পুনর্লিখিত হয়েছিল
ঠিক এই কারণে — আগের সংজ্ঞা যথেষ্ট স্পষ্ট ছিল না multi-processor
reordering সম্পর্কে। এখনকার volatile read/write ঠিক
C++11-এর acquire/release-এর সমতুল্য semantics দেয়।
ARM সার্ভার প্রসেসরের উত্থান (AWS Graviton, Ampere Altra)। বছরের পর বছর ধরে x86-এ লেখা, ভালোভাবে টেস্ট করা multi-threaded সফটওয়্যার যখন প্রথমবার ARM সার্ভারে deploy হয়, memory-ordering bug (যা x86-এ কখনো manifestation হয়নি) মাঝেমধ্যে প্রথমবার প্রকাশ পায় — এই লেসনের MP litmus test ঠিক এই বাস্তব ঘটনার একটা ক্ষুদ্র সংস্করণ।
Compiler reordering, hardware reordering থেকে আলাদা কিন্তু
সমান বিপজ্জনক। volatile (C/C++, Java-র থেকে ভিন্ন অর্থে)
শুধু compiler-কে reorder/eliminate করা থেকে আটকায়, hardware-কে
না — তাই raw volatile দিয়ে MP প্যাটার্ন লেখা compiler-এর
দিক থেকে নিরাপদ মনে হলেও hardware-এর দিক থেকে এখনো ভাঙা,
std::atomic ছাড়া।
Database write-ahead log (WAL) ordering guarantee। অনেক ডাটাবেস engine নিশ্চিত করে log entry প্রথমে disk-এ flush হবে, তারপর “commit সফল” flag সেট হবে — এটা কনসেপ্টগতভাবে ঠিক এই লেসনের release-semantics-এর মতোই একটা ordering guarantee, যদিও memory-র বদলে disk I/O-তে প্রয়োগ করা।
Distributed system-এর “linearizability”। Level ১৩-এর distributed systems মডিউলে দেখা “linearizability” ধারণাটা আসলে sequential consistency-রই একটা সম্প্রসারণ — এবার single machine-এর core-দের বদলে network-এর মধ্য দিয়ে যোগাযোগরত node-দের মধ্যে। একই মৌলিক প্রশ্ন (“সবাই কি একই ক্রমে ঘটনা দেখে”), সম্পূর্ণ ভিন্ন স্কেলে।
যে ভুলগুলো সবাই করে
“Cache coherence (MESI) থাকলে memory consistency এমনিতেই থাকে -- দুটো একই জিনিস।”
এই লেসনের কেন্দ্রীয় ভুল ধারণা, আর সবচেয়ে সাধারণ। MESI নিশ্চিত করে প্রতিটা একক address-এর সব কপি সবসময় সামঞ্জস্যপূর্ণ — কেউ কখনো একটা address-এর “পুরনো” আর “নতুন” মান একসাথে স্থায়ীভাবে দেখবে না। কিন্তু consistency প্রশ্ন করে একাধিক ভিন্ন address-এর লেখার মধ্যে আপেক্ষিক দৃশ্যমানতার ক্রম নিয়ে — আর MESI protocol এই প্রশ্নের কোনো উত্তর দেয় না, দেওয়ার কথাও না, কারণ MESI protocol একটা সময়ে একটা cache line নিয়ে কাজ করে, একাধিক line-এর মধ্যে সম্পর্ক নিয়ে না। এই লেসনের দুইটা litmus test-ই একটা সম্পূর্ণভাবে coherent সিস্টেমে ঘটে (প্রতিটা individual variable-এর নিজস্ব সামঞ্জস্যতা অক্ষুণ্ণ) — সমস্যাটা তাদের মধ্যেকার order নিয়ে, কোনো একটার নিজের ভেতরের অসামঞ্জস্য নিয়ে না।
“মেমরি ব্যারিয়ার/fence মানে 'সবকিছু থামিয়ে দাও' -- সবসময় সবচেয়ে ব্যয়বহুল অপারেশন।”
সব fence সমান খরচের না। একটা full mfence/seq_cst fence
সত্যিই ব্যয়বহুল (পুরো store buffer flush, কয়েক দশ cycle)।
কিন্তু acquire/release fence অনেক সস্তা হতে পারে — বিশেষত
x86-এ, যেখানে TSO ইতিমধ্যেই বেশিরভাগ ordering guarantee দেয়,
তাই release/acquire কম্পাইল হতে পারে কোনো অতিরিক্ত
instruction ছাড়াই (শুধু compiler-level reordering আটকানো,
যা প্রায় বিনামূল্যে)। ARM64-তেও release/acquire একটা
হালকা instruction variant (stlr/ldar) ব্যবহার করতে পারে,
পূর্ণ dmb barrier-এর চেয়ে সস্তা। fence-এর খরচ নির্ভর করে
কতটা ordering দরকার তার উপর, “কোনো fence আছে কি না” তার
উপর না — এই কারণেই সবচেয়ে দুর্বল (relaxed) থেকে শুরু করে
প্রয়োজনমতো শক্তিশালী করার (acquire/release, তারপর seq_cst)
নীতি যুক্তিসঙ্গত, সবসময় সবচেয়ে কড়া দিয়ে শুরু করার বদলে।
“যেহেতু x86 প্রায় সবসময় TSO দিয়ে 'সঠিক' ফলাফল দেয়, x86-only সফটওয়্যারে memory_order নিয়ে চিন্তার দরকার নেই -- raw variable যথেষ্ট।”
দুইটা কারণে বিপজ্জনক ধারণা। প্রথমত, এই লেসনের প্রথম experiment-ই
দেখিয়েছে x86-এও SB-জাতীয় প্যাটার্নে (store-load reordering)
raw variable ব্যবহার করলে পরিমাপযোগ্য ভুল ফলাফল আসে —
“x86-এ TSO আছে” মানে “x86-এ সব multi-threaded কোড automatically
সঠিক” না। দ্বিতীয়ত, raw (non-atomic) variable-এ data race
থাকলে C/C++ standard অনুযায়ী এটা undefined behavior —
compiler-এর optimizer (memory model নির্বিশেষে) সেই কোডকে
এমনভাবে পুনর্বিন্যাস বা eliminate করতে পারে যা প্রোগ্রামারের
উদ্দেশ্যের সম্পূর্ণ বিপরীত (যেমন একটা spin-loop-কে infinite
loop ধরে নিয়ে অপ্রত্যাশিতভাবে optimize করা, কারণ optimizer
জানে না অন্য thread সেই variable বদলাতে পারে)। তাই raw
variable দিয়ে thread-এর মধ্যে communication করা x86-এও
নিরাপদ না — hardware-এর memory model আর compiler-এর
optimization guarantee — দুটোই std::atomic দিয়ে সঠিকভাবে
address করতে হয়।
“Sequential consistency-ই একমাত্র 'সঠিক' মডেল -- অন্য সবগুলো একটা compromise, তাই সবসময় seq_cst ব্যবহার করাই নিরাপদ পছন্দ।”
“নিরাপদ” অর্থে এটা সত্য (seq_cst প্রায় সবসময় সঠিক ফলাফল দেয়,
ভুল বোঝার সুযোগ কম), কিন্তু “সঠিক মডেল” ধারণাটা ভুল ফ্রেমিং।
Consistency model-গুলো একে অপরের “খারাপ সংস্করণ” না — তারা
ভিন্ন trade-off বেছে নেয় safety আর performance-এর মধ্যে, ঠিক
যেমন সিঙ্ক্রোনাইজেশন প্রিমিটিভ-দের মধ্যে (mutex বনাম lock-free
atomic) trade-off আছে। একটা pure statistics counter-এ (যেখানে
শুধু final total-টা লাগে, কোনো ক্রম-নির্ভর logic নেই)
memory_order_relaxed সঠিক এবং যথেষ্ট — সেখানে seq_cst
ব্যবহার করা শুধু অপ্রয়োজনীয় খরচ, কোনো বাড়তি সঠিকতা দেয় না
কারণ কোনো ordering guarantee-ই সেখানে দরকার নেই। প্রশ্নটা
সবসময় “এই নির্দিষ্ট algorithm-এর জন্য ঠিক কতটুকু ordering
guarantee প্রয়োজন” — তার চেয়ে বেশি guarantee নেওয়া অহেতুক
খরচ, তার চেয়ে কম নেওয়া bug।
বুঝেছেন কি না দেখুন
1এক বাক্যে, কোনো উদাহরণ ছাড়া — cache coherence আর memory
consistency-র মধ্যে পার্থক্যটা লিখুন। তারপর বলুন: গত লেসনের
(NUMA) local/remote latency পার্থক্য কি consistency-র সমস্যা,
নাকি সম্পূর্ণ orthogonal একটা তৃতীয় বিষয়?
যুক্তি
এক বাক্যে: Coherence নিশ্চিত করে একটা একক address-এর সব কপি সবসময় সামঞ্জস্যপূর্ণ থাকে; consistency নিয়ন্ত্রণ করে একাধিক ভিন্ন address-এ করা লেখাগুলো অন্য observer-রা কোন ক্রমে দেখে।
NUMA সম্পূর্ণ orthogonal, তৃতীয় একটা বিষয় — এবং এই প্রশ্নটাই সেই বিভ্রান্তি এড়ানোর একটা ভালো পরীক্ষা। NUMA কোনো correctness প্রশ্ন না — এটা শুধু latency নিয়ে: একটা read/write কতক্ষণে সম্পন্ন হয়, ডেটা কতদূরে বসে আছে তার উপর ভিত্তি করে। NUMA কখনো বলে না “কোন ক্রমে দেখা যাবে” (consistency-র প্রশ্ন) বা “একটা address-এর কপি সামঞ্জস্যপূর্ণ কি না” (coherence-এর প্রশ্ন) — এটা শুধু বলে “কতক্ষণ অপেক্ষা করতে হবে”। একটা সিস্টেম পুরোপুরি coherent, পুরোপুরি sequentially consistent হতে পারে, তবু NUMA-র কারণে কিছু access অন্যদের চেয়ে ধীর হতে পারে — তিনটা প্রশ্নই স্বাধীন, যদিও তিনটাই একই hardware layer (multi-core memory subsystem) থেকে উদ্ভূত।
একটা দরকারী মানসিক মডেল: coherence = “কী দেখা যায়” (একটা address-এর জন্য), consistency = “কোন ক্রমে দেখা যায়” (একাধিক address-এর মধ্যে), NUMA/latency = “কত দ্রুত দেখা যায়” (সময়ের প্রশ্ন, correctness-এর প্রশ্ন না)।
2std::atomic<int> ready{0};
int config_value = 0;
/* Thread A (একবার, startup-এ) */
config_value = load_config_from_disk();
ready.store(1, std::memory_order_relaxed); /* লক্ষ করুন: relaxed, release না */
/* Thread B */
while (ready.load(std::memory_order_relaxed) == 0) { }
use(config_value);
এই কোডে memory_order_relaxed ব্যবহার করা হয়েছে release/acquire-এর
বদলে। এটা কি সঠিক? যদি না হয়, ঠিক কোন গ্যারান্টিটা অনুপস্থিত,
আর কোন hardware-এ এটা বাস্তবে ভুল ফলাফল দিতে পারে?
প্রয়োগ
std::atomic<int> ready{0};
int config_value = 0;
/* Thread A (একবার, startup-এ) */
config_value = load_config_from_disk();
ready.store(1, std::memory_order_relaxed); /* লক্ষ করুন: relaxed, release না */
/* Thread B */
while (ready.load(std::memory_order_relaxed) == 0) { }
use(config_value);memory_order_relaxed ব্যবহার করা হয়েছে release/acquire-এর
বদলে। এটা কি সঠিক? যদি না হয়, ঠিক কোন গ্যারান্টিটা অনুপস্থিত,
আর কোন hardware-এ এটা বাস্তবে ভুল ফলাফল দিতে পারে?না, এটা ভুল — এবং এটা ঠিক এই লেসনের MP litmus test, শুধু
raw variable-এর বদলে relaxed atomic দিয়ে লেখা।
memory_order_relaxed শুধু atomicity গ্যারান্টি দেয় —
ready-এর read/write নিজে একটা অবিভাজ্য অপারেশন হবে (torn
read/write হবে না), আর সব thread শেষমেশ একই চূড়ান্ত মান
দেখবে (coherence)। কিন্তু এটা কোনো ordering গ্যারান্টি
দেয় না অন্য কোনো variable-এর (এখানে config_value) সাপেক্ষে
— ঠিক raw non-atomic variable-এর মতোই এই ক্ষেত্রে দুর্বল।
যে গ্যারান্টি অনুপস্থিত: release/acquire যে
happens-before edge তৈরি করত (Thread A-র ready-write-এর
আগের সব effect Thread B-র ready-read-এর পরের সব code-এর
কাছে visible হওয়া), relaxed সেই edge তৈরি করে না। Thread
B ready == 1 দেখতে পারে অথচ config_value তখনো পুরনো
(0) দেখতে পারে — কারণ compiler এবং/অথবা hardware config_value-এর
write আর ready-এর write-এর মধ্যে কোনো ordering বজায় রাখতে
বাধ্য না।
কোথায় এটা বাস্তবে ভাঙবে: x86-এ, TSO-র store-store ordering-এর
কারণে, এই কোড সম্ভবত সঠিক ফলাফল দেবে বেশিরভাগ সময় (হুবহু
এই লেসনের মধ্যেকার একটা misconception-এর মতো ফাঁদ)। কিন্তু
এখানে দুইটা ঝুঁকি থেকে যায়: (ক) compiler নিজে reorder করতে
পারে — relaxed atomic-এর মানে এই না compiler-এর optimizer
config_value = ... কে ready.store()-এর পরে সরিয়ে দিতে
পারবে না, কারণ কোনো ordering constraint নেই যা compiler-কে
আটকায়; (খ) ARM/RISC-V/POWER-এ hardware নিজেই store-store
reorder করতে পারে, ঠিক এই লেসনের MP litmus test-এর মতো —
সরাসরি stale config_value পড়ার ঝুঁকি।
সঠিক সংস্করণ — শুধু memory_order বদলে দিন, বাকি কোড
অভিন্ন:
config_value = load_config_from_disk();
ready.store(1, std::memory_order_release); /* release */
...
while (ready.load(std::memory_order_acquire) == 0) { } /* acquire */
use(config_value);সাধারণ শিক্ষা: relaxed শুধু ব্যবহার করা উচিত যখন
variable-টার মান নিজেই একমাত্র জিনিস যা গুরুত্বপূর্ণ (pure
counter, statistics) — যেই মুহূর্তে সেই variable-এর মান
অন্য কোনো variable-এর দৃশ্যমানতাকে সংকেত দিচ্ছে (এখানে
ready সংকেত দিচ্ছে config_value প্রস্তুত), release/acquire
(বা seq_cst) বাধ্যতামূলক।
3একটা lock-free single-producer single-consumer (SPSC) ring
buffer ডিজাইন করছেন — producer write_index বাড়ায়, consumer
read_index বাড়ায়, আর প্রতিটা নিজেরটা পড়ে অন্যেরটা লেখে না
(শুধু পড়ে)। কোন কোন জায়গায় কোন memory_order দরকার, আর
কেন relaxed সব জায়গায় যথেষ্ট নয়?
ডিজাইন
write_index বাড়ায়, consumer
read_index বাড়ায়, আর প্রতিটা নিজেরটা পড়ে অন্যেরটা লেখে না
(শুধু পড়ে)। কোন কোন জায়গায় কোন memory_order দরকার, আর
কেন relaxed সব জায়গায় যথেষ্ট নয়?এই ডিজাইনের মূল challenge হলো — buffer-এর ডেটা (actual
payload, একটা array-তে) আর index (write_index,
read_index) দুইটা ভিন্ন জিনিস, আর তাদের মধ্যে একটা
happens-before সম্পর্ক নিশ্চিত করতে হবে, ঠিক এই লেসনের MP
litmus test-এর মতো কাঠামোতে (data/flag ↔ payload/write_index)।
Producer-এর দিক:
buffer[write_index % N] = item; /* ডেটা লেখা, non-atomic */
write_index.store(write_index_local + 1, std::memory_order_release); /* সূচক প্রকাশ -- release */release বাধ্যতামূলক এখানে — এটাই নিশ্চিত করে buffer[...]-এ
লেখা ডেটা write_index-এর নতুন মান দেখা consumer-এর কাছে
আগেই visible হয়ে গেছে। যদি relaxed ব্যবহার করা হতো,
consumer নতুন write_index দেখেও পুরনো/আংশিক buffer content
পড়তে পারত — ঠিক ARM-এ ঘটা MP bug-এর মতো।
Consumer-এর দিক:
while (write_index.load(std::memory_order_acquire) == read_index_local) { } /* খালি কিনা চেক -- acquire */
item = buffer[read_index_local % N]; /* এখন নিরাপদে পড়া যায় */
read_index.store(read_index_local + 1, std::memory_order_release); /* consumed জানানো -- release */acquire বাধ্যতামূলক এখানে ঠিক পূর্ববর্তী প্রশ্নের কারণে
— এটাই producer-এর release-এর সাথে জোড়া মিলিয়ে happens-before
তৈরি করে। consumer-এর নিজের read_index-write আবার release
হওয়া দরকার, কারণ producer-কেও (buffer-slot পুনর্ব্যবহারের
আগে) জানতে হবে সেই slot সত্যিই খালি হয়ে গেছে — এখানে ভূমিকা
উল্টে যায়, consumer এখন producer-এর কাছে একটা signal পাঠাচ্ছে।
কেন relaxed সব জায়গায় যথেষ্ট না, সংক্ষেপে: write_index/read_index
নিজেরাই শুধু সংখ্যা না — তারা একটা সংকেত যে “এই index-এর
আগে পর্যন্ত buffer content নিরাপদে পড়া/লেখা যায়”। যে মুহূর্তে
একটা atomic variable-এর মান অন্য (non-atomic বা ভিন্ন atomic)
ডেটার দৃশ্যমানতা নিয়ন্ত্রণ করে, সেই variable-এর অপারেশনে
release/acquire (বা তার বেশি) লাগবে — এই নীতিটাই এই
প্রশ্ন এবং আগের প্রশ্নের অভিন্ন কেন্দ্রীয় শিক্ষা।
কোথায় relaxed-ই যথেষ্ট হতো: যদি শুধু একটা diagnostic
counter থাকত (যেমন “মোট কতবার buffer full হয়ে producer
block হয়েছে”) যেটা শুধু পরিসংখ্যানের জন্য পড়া হয়, কোনো
অন্য ডেটার দৃশ্যমানতা নিয়ন্ত্রণ করে না — সেখানে relaxed
সম্পূর্ণ সঠিক এবং দ্রুততর হতো।
4এই লেসনের প্রথম experiment (SB litmus test) x86-এ চালিয়ে
r1==0 && r2==0 কয়েক শতাংশ সময় দেখা গেছে। প্রশ্ন — কেন এই
percentage কখনো ১০০% না, বরং একটা ছোট ভগ্নাংশ? এই সংখ্যাটা
কী প্রকাশ করছে হার্ডওয়্যার সম্পর্কে?
যুক্তি
r1==0 && r2==0 কয়েক শতাংশ সময় দেখা গেছে। প্রশ্ন — কেন এই
percentage কখনো ১০০% না, বরং একটা ছোট ভগ্নাংশ? এই সংখ্যাটা
কী প্রকাশ করছে হার্ডওয়্যার সম্পর্কে?এই সংখ্যাটা আসলে একটা race window-এর প্রশস্ততা-র পরিমাপ — store buffer কতক্ষণ ধরে “pending, অন্য core-দের কাছে অদৃশ্য” থাকে, প্রতিটা iteration-এর মোট সময়ের তুলনায়।
Store buffer entry সাধারণত খুব দ্রুত flush হয় — কয়েক
cycle-এর মধ্যেই, যদি না CPU-র pipeline সেই মুহূর্তে ব্যস্ত
অন্য কিছুতে বা memory subsystem-এ চাপ বেশি থাকে (অনেক
outstanding store/cache miss)। তাই X=1 লেখার পরপরই r1=Y
পড়া হলে, বেশিরভাগ সময় ততক্ষণে store buffer flush হয়েই গেছে
(অথবা আগের flush-এর সাথে overlap করে চলে গেছে), তাই SC-এর
মতো ফলাফল দেখা যায় — reordering-এর window-টা মিস হয়ে যায়।
যে percentage দেখা যায় তা নির্ভর করে:
- CPU-র store buffer-এর গভীরতা (কতগুলো pending store এক সাথে ধরে রাখতে পারে) — গভীর buffer মানে বেশি সুযোগ reordering-এর
- Core-দের মধ্যে interconnect latency — flush যত ধীর, window তত চওড়া
- মুহূর্তের system load — অন্য প্রোগ্রাম/thread একই core-গুলোতে চাপ দিলে timing অনির্দেশ্য হয়ে window চওড়া হতে পারে
- Experiment কোডের নিজস্ব
pthread_barrier_wait— ইচ্ছাকৃতভাবে দুই thread-কে প্রতিটা iteration একসাথে শুরু করাচ্ছে, যা এই race window-কে যতটা সম্ভব চওড়া করে তোলে (barrier ছাড়া দুই thread স্বাভাবিকভাবেই কিছুটা alignment হারিয়ে ফেলত, আর percentage আরও কম হতো, হয়তো পরিমাপযোগ্যই না হতো)।
গুরুত্বপূর্ণ সাধারণ শিক্ষা: এই ছোট percentage-ই আসল বিপদ — একটা bug যেটা ১০০% সময় ঘটে সেটা সহজেই code review-তে বা প্রথম টেস্ট রানেই ধরা পড়ে। একটা bug যেটা ৭% সময় ঘটে (এখানে) বা তার চেয়েও কম (production-এর কম-artificial পরিস্থিতিতে) হাজার হাজার সফল রান দেখিয়ে “নিরাপদ” মনে করানোর পরে হঠাৎ, প্রায়ই উচ্চ-লোডের মুহূর্তে (যখন race window আরও predictable ভাবে align হয়) আঘাত করে। এটাই মূল কারণ কেন memory-ordering bug-কে “ধরা পড়া না পর্যন্ত সঠিক ধরে নেওয়া” এত বিপজ্জনক একটা অভ্যাস।
এরপর কী
এরপর — নিয়ম জানার পর, হাতিয়ার বানানো
এই লেসনে আমরা দেখলাম কেন raw shared variable দিয়ে thread-দের
মধ্যে যোগাযোগ করা বিপজ্জনক (memory-ordering bug), আর কীভাবে
std::atomic + সঠিক memory_order দিয়ে সেই যোগাযোগ portable-ভাবে
নিরাপদ করা যায়। কিন্তু এখনো একটা গুরুত্বপূর্ণ প্রশ্ন বাকি:
এই atomic অপারেশনগুলো (store, load, compare_exchange)
নিজেরা hardware-এ কীভাবে কাজ করে?
futex-and-lock-implementation লেসনে আমরা দেখেছিলাম কীভাবে
একটা mutex একটা futex-এর উপর বানানো হয়। পরের লেসনে আমরা
আরেক ধাপ নিচে নামব — কীভাবে সেই futex/spinlock-এর একদম
ভিত্তি, lock cmpxchg instruction-টা নিজেই একটা সম্পূর্ণ
lock-free data structure বানানোর জন্য যথেষ্ট শক্তিশালী,
আর কোথায় সেই শক্তির একটা বিখ্যাত, subtle সীমাবদ্ধতা লুকিয়ে
আছে — ABA problem।
আরও পড়ুন
- A Primer on Memory Consistency and Cache Coherence — Daniel J. Sorin, Mark D. Hill, David A. Wood · এই লেসনের title-টাই এই বইয়ের central thesis থেকে ধার করা — consistency ও coherence আলাদা সমস্যা, এই বই-ই প্রথম সেটা স্পষ্টভাবে আলাদা করে
- Intel 64 and IA-32 Architectures SDM, Volume 3A, §8.2 — Memory Ordering — Intel Corporation · x86 TSO-র প্রামাণ্য সংজ্ঞা, store-buffering exception সহ
- cppreference — std::memory_order · C++11 memory_order enum-এর প্রামাণ্য semantics
- Herd7 / diy — memory model litmus tests — Jade Alglave et al. · SB (store buffering) আর MP (message passing) litmus test-এর আনুষ্ঠানিক সংজ্ঞা ও বিভিন্ন hardware-এ পরীক্ষিত ফলাফল