Foundationপ্রথম নীতি থেকে
LEVEL 11লেসন ৬/১৭অ্যাডভান্সড১ ঘণ্টা ১৫ মিনিট

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 ভাষায় প্রকাশ করা যায়।

এই লেসন শেষে আপনি পারবেন

  • Cache coherence আর memory consistency-র মধ্যে মৌলিক পার্থক্যটা নির্ভুলভাবে বলতে পারবেন — একটা single address-এর visibility বনাম একাধিক address-এর মধ্যেকার order
  • Sequential consistency-র আনুষ্ঠানিক সংজ্ঞা বলতে পারবেন, আর কেন বাস্তব হার্ডওয়্যার এটা ডিফল্টভাবে দেয় না তা ব্যাখ্যা করতে পারবেন
  • x86 TSO-র নির্দিষ্ট exception (store-load reordering) আর ARM/RISC-V-এর অনেক বেশি reordering-এর অনুমতি — এই দুইটার মধ্যে পার্থক্য একটা concrete litmus test দিয়ে দেখাতে পারবেন
  • দুইটা ক্লাসিক memory-reordering bug (store buffering আর message passing) নিজে trace করে দেখাতে পারবেন কোথায় এবং কেন তারা ভিন্ন হার্ডওয়্যারে ভিন্নভাবে আচরণ করে
  • C++11-এর memory_order enum (relaxed/acquire/release/seq_cst) ব্যবহার করে architecture-নির্দিষ্ট fence না লিখে একই সঠিকতা achieve করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

গত দুই লেসনে 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 “আগেই ঘটেছে” প্রোগ্রামের ক্রমে।

SB litmus test -- x86-এ কীভাবে r1=0, r2=0 উভয়ই ঘটতে পারে
  1. Thread 1, core A: X = 1write X=1 store buffer A-তে বসে, এখনো cache/memory-তে visible না
  2. Thread 2, core B: Y = 1সমান্তরালে, write Y=1 store buffer B-তে বসে, এখনো visible না
  3. Thread 1, core A: r1 = Ycore A cache থেকে Y পড়ে -- core B-র store buffer এখনো flush হয়নি, তাই পুরনো মান 0 দেখে
  4. Thread 2, core B: r2 = Xএকই কারণে, core B পুরনো X=0 দেখে
  5. কিছুক্ষণ পরেউভয় 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।

MP litmus test -- ARM-এ কীভাবে flag=1 দেখেও data=0 দেখা সম্ভব
  1. Thread 1: data = 42write ইস্যু হলো, কিন্তু ARM-এ পরবর্তী write-এর সাপেক্ষে এর সম্পন্ন হওয়ার ক্রম নিশ্চিত না
  2. Thread 1: flag = 1weak model-এ এই write data-এর write-কে "ওভারটেক" করে আগে অন্য core-এ পৌঁছাতে পারে
  3. Thread 2: flag পড়ে, দেখে 1loop থেকে বের হয় -- মনে করে data-ও নিশ্চয়ই লেখা হয়ে গেছে
  4. Thread 2: data পড়েকিন্তু data=42-এর write তখনো এই core-এ পৌঁছায়নি -- পুরনো মান 0 দেখে যায়
  5. 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 দরকার।

নিজে চালিয়ে দেখুন

EXPERIMENT

SB litmus test নিজে চালিয়ে x86-এ reordering ধরা

x86-64 Linux, gcc· ২০ মিনিট
/* 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==0

fence ছাড়া, লক্ষ লক্ষ iteration-এর প্রায় ৭%-এ SC-বিরোধী ফলাফল দেখা যায় — x86-এর store buffer সত্যিই কাজ করছে, প্রত্যক্ষভাবে পরিমাপযোগ্য। mfence/atomic_thread_fence যোগ করলে সংখ্যা ঠিক শূন্যে নেমে আসে — কোনো ব্যতিক্রম ছাড়াই।

এটা কী প্রমাণ করে

x86-এর store buffer সত্যিই একটা পরিমাপযোগ্য reordering ঘটায় -- raw variable দিয়ে r1==0 && r2==0 দুইটাই মাঝে মাঝে ঘটে, আর mfence/atomic seq_cst যোগ করলে তা সম্পূর্ণ বন্ধ হয়ে যায়।

নিজে বানান

BUILD IT

একটা mini memory-model checker -- MP litmus test x86-এ পরিমাপ

C · ●●●●○
  1. MP litmus test লিখুন -- non-atomic data + non-atomic flag, spin loop দিয়ে flag চেক
  2. বহু iteration চালিয়ে গণনা করুন কতবার flag==1 দেখেও data-র পুরনো মান দেখা গেছে
  3. x86-এ এই সংখ্যা কেন প্রায় সবসময় 0 আসবে তা লেসনের TSO আলোচনা থেকে ব্যাখ্যা করুন
  4. flag-কে std::atomic<int> করে release/acquire semantics যোগ করুন, নিশ্চিত করুন এখনো সঠিক
  5. 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-এর প্রশ্ন না)।

2
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 সব জায়গায় যথেষ্ট নয়?

ডিজাইন

এই ডিজাইনের মূল 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 কখনো ১০০% না, বরং একটা ছোট ভগ্নাংশ? এই সংখ্যাটা কী প্রকাশ করছে হার্ডওয়্যার সম্পর্কে?

যুক্তি

এই সংখ্যাটা আসলে একটা 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-এ পরীক্ষিত ফলাফল