এই পর্বে যা শিখবেন
- Producer-consumer-এর
countউদাহরণে register ধরে ধাপে ধাপে দেখানো, কীভাবে ফল 4 বা 6 হয় - Critical-section সমস্যার আনুষ্ঠানিক রূপ, তিনটি শর্তের নিখুঁত সংজ্ঞা আর অনুমানগুলো
- Kernel-এর ভেতরের race, preemptive বনাম non-preemptive kernel, আর interrupt বন্ধ করার সীমা
- ব্যর্থ সফটওয়্যার-চেষ্টা কোন interleaving-এ কোন শর্ত ভাঙে, তা দেখানো — পরীক্ষার “দেখান যে …” ধরনের প্রশ্নের উত্তর
- Dekker, Peterson (সম্পূর্ণ প্রমাণসহ) আর bakery algorithm; একটি model checker দিয়ে সব interleaving যাচাই; আর কেন আধুনিক CPU-তে memory barrier লাগে
১ · এক লাইনের কোডে লুকানো তিন ধাপ — producer, consumer আর count
পর্ব ১৫-এ দুটি থ্রেড একই counter-এ counter++ করে লাখ লাখ যোগ হারিয়েছিল, আর mutex দিয়ে তা সারানো হয়েছিল। এই পর্বে প্রশ্নটি আরও মৌলিক: mutex নিজে কীভাবে বানানো যায়? কোন নিয়ম মানলে একটি সমাধানকে “সঠিক” বলা যায়, আর সেটি প্রমাণ করা যায় কীভাবে? শুরু করি পাঠ্যবইয়ের পরিচিত উদাহরণ দিয়ে।
একটি producer-consumer ব্যবস্থা ভাবুন। Producer থ্রেড জিনিস বানিয়ে একটি shared buffer-এ রাখে, consumer সেখান থেকে তুলে নেয়। Buffer-এ এখন কয়টি জিনিস আছে, তার হিসাব রাখে একটি shared ভ্যারিয়েবল count:
/* producer */ /* consumer */
while (true) { while (true) {
item = produce(); while (count == 0) ; /* empty */
while (count == BUF) ; /* full */ item = buffer[out];
buffer[in] = item; out = (out + 1) % BUF;
in = (in + 1) % BUF; count--;
count++; consume(item);
} }আলাদাভাবে দুটিই ঠিক। কিন্তু একসাথে চললে বিপদ। count++ C-তে এক লাইন, CPU-র কাছে তিনটি machine instruction:
count++ : register1 = count count-- : register2 = count
register1 = register1 + 1 register2 = register2 - 1
count = register1 count = register2
প্রতিটি থ্রেডের নিজের register আছে (এক CPU-তে চললে context switch-এর সময় সেগুলো আলাদা করে সংরক্ষিত হয়, পর্ব ৯)। ধরুন count = 5, আর producer একটি জিনিস রাখল, consumer একটি তুলল — ফল হওয়ার কথা আবার 5. কিন্তু ছয়টি instruction যেকোনো ক্রমে মিশতে পারে। এই মেশানো ক্রমকে বলে interleaving. নিচে ধাপে ধাপে দেখুন:
1// Producer: count++2register1 = count3register1 = register1 + 14count = register15// Consumer: count--6register2 = count7register2 = register2 - 18count = register2
| count (মেমরি) | |
| register1 (producer) | |
| register2 (consumer) | |
| সঠিক ফল |
- শুরু: count = 5. Producer একটি জিনিস রেখেছে আর
count++করতে যাচ্ছে; একই সময়ে consumer একটি তুলেছে আরcount--করতে যাচ্ছে। একটি +১ আর একটি −১, তাই শেষে 5 থাকার কথা। - T0 — producer load করে।
register1 = count, অর্থাৎ register1 = 5. মেমরি বদলায়নি। - T1 — producer যোগ করে। register1 = 6. এই 6 শুধু producer-এর register-এ; মেমরিতে এখনো 5. এখানেই বিপদের জানালা খোলে।
- T2 — consumer load করে। Timer interrupt-এ CPU consumer-এর কাছে গেল (বা অন্য core-এ consumer একসাথে চলছে)। সে পড়ে মেমরির 5 — producer-এর 6 এখনো লেখা হয়নি। register2 = 5.
- T3 — consumer বিয়োগ করে। register2 = 4. এখন দুই থ্রেডই 5 থেকে হিসাব করেছে — একজন 6, আরেকজন 4.
- T4 — producer store করে।
count = register1, মেমরিতে count = 6. - T5 — consumer store করে। count = 4 — producer-এর লেখা মুছে গেল। Buffer-এ আসলে ৫টি জিনিস, অথচ হিসাব বলছে ৪টি। পরে consumer একটি জিনিস “দেখতেই পাবে না”।
- T4 আর T5 উল্টালে: count = 6. Consumer আগে 4 লেখে, producer পরে 6 লেখে — এবার consumer-এর বিয়োগ হারাল। একই ছয়টি instruction, শুধু ক্রম বদলে তিনটি ভিন্ন ফল: 4, 5 বা 6. কোনটি আসবে, তা নির্ভর করে কে কখন CPU পেল তার ওপর।
এমন অবস্থাকে বলে race condition: একাধিক প্রসেস বা থ্রেড একই ডেটা একসাথে ছোঁয় আর ব্যবহার করে, আর ফল নির্ভর করে তারা ঠিক কোন ক্রমে চলল তার ওপর. নামটি এসেছে দৌড় থেকে — কে আগে পৌঁছাবে, তার ওপর ফল। পর্ব ১৫-এ দেখা data race হলো এর সবচেয়ে সাধারণ কারণ, তবে দুটি এক জিনিস নয়:
| বিষয় | Data race | Race condition |
|---|---|---|
| সংজ্ঞা কোন স্তরে | মেমরি-স্তরে: একই ঠিকানা, অন্তত একটি লেখা, মাঝে কোনো সমন্বয় নেই | অর্থের স্তরে: ক্রমভেদে ফল ভুল হয় |
| Lock দিলে কি দূর হয়? | হ্যাঁ, প্রতিটি ব্যবহার একই lock-এ থাকলে | সবসময় না — “আগে দেখে পরে কাজ” দুটি আলাদা lock-এ থাকলে ক্রমের সমস্যা থেকেই যায় |
| উদাহরণ | দুই থ্রেড lock ছাড়া count++ | ব্যাংক অ্যাকাউন্টে “ব্যালান্স ≥ ৫০০? তাহলে তুলুন” — যাচাই আর তোলার মাঝে আরেকজন তুলে ফেলল |
| ধরার টুল | ThreadSanitizer, Helgrind (পর্ব ১৫) | নকশা পর্যালোচনা, invariant, model checking (অংশ ৫) |
সমাধানের মূল ভাবনা একটিই: count++-এর তিন ধাপ আর count---এর তিন ধাপ যেন কখনো না মেশে — একজন শুরু করলে অন্যজন অপেক্ষা করবে, যতক্ষণ না প্রথমজনের তিন ধাপ শেষ হয়। বাকি পর্বটি এই “অপেক্ষা”কে কীভাবে নির্ভুলভাবে বানানো যায়, তার গল্প।
২ · Critical-section সমস্যা — চার অংশ আর তিনটি শর্ত
কোডের যে অংশে shared ডেটা ছোঁয়া হয় (উপরের count++), তার নাম critical section. ১৯৬০-এর দশকে Edsger Dijkstra সমস্যাটিকে একটি সাধারণ কাঠামোয় বাঁধেন, যাকে আজ বলা হয় critical-section problem. n টি প্রসেস (P0 … Pn−1) প্রত্যেকে চিরকাল এই চক্রে ঘোরে:
while (true) {
entry section /* ask permission: "may I enter?" */
critical section /* touch the shared data */
exit section /* announce: "I am out" */
remainder section/* everything else; may run as long as it likes */
}Entry section অনুমতি চায়, exit section জানিয়ে দেয় কাজ শেষ, আর remainder section বাকি সব কাজ — যেখানে প্রসেস যতক্ষণ খুশি থাকতে পারে, এমনকি চিরকাল। সমস্যাটি হলো entry আর exit section এমনভাবে লেখা, যাতে নিচের তিনটি শর্ত পূরণ হয়।
গ্রামের একটি খালের ওপর এক লেনের ছোট সেতু ভাবুন — একবারে একটিই রিকশা পার হতে পারে। সেতু হলো critical section. সেতুর মুখে এসে “পার হতে পারি?” দেখা entry section, ওপারে নেমে হাত তুলে জানানো exit section, আর বাজারে কেনাকাটা বা বাড়িতে বিশ্রাম remainder section. ভালো নিয়মের তিন শর্ত: (১) সেতুতে একসাথে দুটি রিকশা নয়; (২) সেতু খালি আর কেউ অপেক্ষায় থাকলে কেউ না কেউ শিগগির উঠবেই — আর এই সিদ্ধান্তে বাড়িতে ঘুমিয়ে থাকা চালকের কোনো ভূমিকা নেই; (৩) একজন অপেক্ষা শুরু করলে তার আগে অন্যরা সীমিত সংখ্যকবারই পার হবে, চিরকাল নয়।
| শর্ত | নিখুঁত সংজ্ঞা | ভাঙলে কী হয় |
|---|---|---|
| ১. Mutual exclusion | কোনো প্রসেস Pi নিজের critical section-এ থাকলে আর কোনো প্রসেস তখন নিজের critical section-এ থাকতে পারবে না। | উপরের count = 4 — ডেটা নষ্ট |
| ২. Progress | কেউ critical section-এ নেই, আর কয়েকটি প্রসেস ঢুকতে চায় — তাহলে কে পরে ঢুকবে, সেই সিদ্ধান্তে অংশ নিতে পারবে শুধু তারা, যারা remainder section-এ নেই; আর সিদ্ধান্তটি অনির্দিষ্টকাল পিছানো যাবে না। | সেতু খালি, তবু কেউ উঠতে পারে না — deadlock, বা অনুপস্থিত কারও জন্য অপেক্ষা |
| ৩. Bounded waiting | একটি প্রসেস ঢোকার অনুরোধ করার পর থেকে অনুমতি পাওয়া পর্যন্ত, অন্য প্রসেসগুলো নিজেদের critical section-এ কতবার ঢুকতে পারবে, তার একটি নির্দিষ্ট সীমা থাকবে। | একজনকে বারবার টপকে অন্যরা ঢোকে — starvation |
তিনটি শর্তের সঙ্গে চারটি অনুমানও সংজ্ঞার অংশ — পরীক্ষায় এগুলো লিখতে ভুলবেন না:
- প্রতিটি প্রসেস শূন্য নয় এমন গতিতে চলে — অর্থাৎ যে প্রসেস entry, critical বা exit section-এ আছে, সে একসময় পরের ধাপে যাবে; critical section-এর ভেতর চিরকাল আটকে থাকবে না।
- প্রসেসগুলোর আপেক্ষিক গতি নিয়ে কোনো অনুমান নেই — একজন আরেকজনের চেয়ে হাজার গুণ ধীর হতে পারে, বা যেকোনো মুহূর্তে থেমে থাকতে পারে।
- CPU-র সংখ্যা নিয়েও কোনো অনুমান নেই — সমাধান এক core-এ পালা করে চলা আর বহু core-এ সত্যিকারের একসাথে চলা, দুই ক্ষেত্রেই সঠিক হতে হবে।
- মৌলিক machine instruction (একটি load বা একটি store) অবিভাজ্য — একটি শব্দ পড়া বা লেখা মাঝপথে ভাঙে না। (এর বাইরে কোনো বিশেষ hardware instruction ধরা হয় না; সেগুলো পর্ব ২৩-এ।)
শর্ত তিনটি দুই জাতের। Mutual exclusion একটি safety property — “খারাপ কিছু কখনো ঘটবে না”; ভাঙলে একটি নির্দিষ্ট মুহূর্ত দেখানো যায় যখন দুজনই ভেতরে। Progress আর bounded waiting liveness property — “ভালো কিছু একসময় ঘটবেই”; ভাঙা দেখাতে হলে একটি অসীম ক্রম দেখাতে হয়, যেখানে কেউ চিরকাল আটকে থাকে। লক্ষ করুন, progress থাকলেও starvation হতে পারে (সবসময় কেউ না কেউ ঢুকছে, কিন্তু একই জন বারবার বাদ পড়ছে) — তাই bounded waiting আলাদা শর্ত।
“দেখান যে এই সমাধান X শর্ত ভাঙে” প্রশ্নে উত্তর সবসময় একটি নির্দিষ্ট interleaving: প্রাথমিক মান লিখুন, তারপর সময় T0, T1, … ধরে কোন প্রসেস কোন লাইন চালাল আর shared ভ্যারিয়েবল কী হলো, তা একটি টেবিলে দেখান। Mutual exclusion ভাঙা দেখাতে শেষ সারিতে দুজনই critical section-এ; progress ভাঙা দেখাতে critical section খালি, কেউ ঢুকতে চায়, তবু কেউ পারে না। আর “সঠিক” প্রমাণ করতে হলে উদাহরণ যথেষ্ট নয় — সব interleaving-এর জন্য যুক্তি লাগে (অংশ ৫)।
৩ · Kernel-এর ভেতরেও race — preemptive আর non-preemptive kernel
Race শুধু আপনার প্রোগ্রামের সমস্যা নয়; kernel নিজেই সবচেয়ে বড় concurrent প্রোগ্রাম। খোলা ফাইলের তালিকা, মেমরি বরাদ্দের হিসাব, প্রসেসের তালিকা, interrupt-এর ডেটা — সব shared. পাঠ্যবইয়ের একটি উদাহরণ: দুটি প্রসেস প্রায় একসাথে fork() ডাকল (পর্ব ১০), আর kernel পরের খালি PID রাখে একটি ভ্যারিয়েবল next_pid-এ:
| সময় | P0 — fork() | P1 — fork() | next_pid |
|---|---|---|---|
| T0 | pid = next_pid → 2615 | 2615 | |
| T1 | pid = next_pid → 2615 | 2615 | |
| T2 | next_pid = pid + 1 | 2616 | |
| T3 | next_pid = pid + 1 | 2616 | |
| ফল | দুটি ভিন্ন child প্রসেস একই PID 2615 পেল — kill বা waitpid ভুল প্রসেসে যেতে পারে | ||
Kernel-এর নকশা এখানে দুই ভাগে পড়ে। Non-preemptive kernel-এ একটি প্রসেস kernel mode-এ চলার সময় তাকে জোর করে সরানো হয় না — যতক্ষণ না সে kernel থেকে বেরোয়, নিজে block হয়, বা স্বেচ্ছায় CPU ছাড়ে। এক CPU-র মেশিনে তাই kernel-এর ডেটায় প্রসেসে-প্রসেসে race প্রায় হয় না (interrupt handler-এর সঙ্গে race তবু থাকে)। Preemptive kernel-এ kernel mode-এ চলা প্রসেসকেও মাঝপথে সরিয়ে আরেকজনকে চালানো যায় (preemption). এটি নকশায় কঠিন — প্রতিটি shared ডেটা রক্ষা করতে হয় — কিন্তু সাড়া দ্রুত হয়, আর real-time কাজের জন্য প্রায় অপরিহার্য: একটি ধীর system call পুরো সিস্টেম আটকে রাখতে পারে না।
পুরোনো Unix আর Linux 2.4 পর্যন্ত kernel মূলত non-preemptive ছিল; Linux 2.6 (২০০৩) থেকে kernel preemption একটি কনফিগারেশন-বিকল্প, আর Windows NT-ধারার kernel শুরু থেকেই preemptive. কিন্তু খেয়াল করুন: multicore মেশিনে non-preemptive হওয়া কোনো রক্ষা নয় — দুটি core একই মুহূর্তে kernel-এর ভেতর চলতে পারে। তাই আধুনিক সব kernel-এ shared ডেটার জন্য lock লাগেই।
Interrupt বন্ধ করা — এক CPU-র সহজ সমাধান
এক CPU-তে একটি প্রসেস থেকে আরেকটিতে যাওয়া ঘটে মূলত interrupt-এর কারণে — timer interrupt-এ scheduler জাগে (পর্ব ৯)। তাহলে সবচেয়ে সরল সমাধান: critical section-এর আগে interrupt বন্ধ, পরে চালু। x86-এ এর instruction cli আর sti; Linux kernel-এ local_irq_save() আর local_irq_restore(); Windows kernel-এ একই কাজ হয় IRQL বাড়িয়ে।
unsigned long flags;
local_irq_save(flags); /* this CPU: no interrupts, so no preemption */
pid = next_pid; /* critical section: short! */
next_pid = pid + 1;
local_irq_restore(flags); /* interrupts back to how they were */এক CPU-র kernel-এ ছোট critical section-এর জন্য এটি সত্যিই কাজ করে, কারণ interrupt না এলে কেউ CPU কাড়তে পারে না। কিন্তু তিনটি কারণে এটি সাধারণ সমাধান নয়:
- Multiprocessor-এ অকেজো:
cliশুধু নিজের CPU-র interrupt বন্ধ করে। অন্য core-গুলো তখনো চলছে, আর তারাও একইnext_pidপড়তে পারে। সব CPU-কে বার্তা পাঠিয়ে সবার interrupt বন্ধ করা যায়, কিন্তু তা ধীর, আর প্রতিবার critical section-এ ঢুকতে পুরো মেশিন থামানো অসম্ভব রকম ব্যয়বহুল। - User প্রোগ্রামের জন্য নিষিদ্ধ:
cliএকটি privileged instruction. সাধারণ প্রোগ্রাম চালালে CPU trap করে, আর Linux প্রসেসটিকে সাধারণত SIGSEGV দিয়ে বন্ধ করে। অনুমতি থাকলেও বিপদ: একটি বাগওয়ালা প্রোগ্রাম interrupt বন্ধ করে অসীম লুপে পড়লে পুরো কম্পিউটার জমে যেত। - সময়ের ক্ষতি: Interrupt বন্ধ থাকলে কি-বোর্ড, নেটওয়ার্ক, ডিস্কের সংকেত দেরিতে সাড়া পায়, আর interrupt latency বাড়ে। তাই kernel এটি করে খুব ছোট অংশে, আর multicore-এ সাধারণত একটি spinlock-এর সঙ্গে মিলিয়ে (
spin_lock_irqsave, পর্ব ২৩)।
তাহলে প্রশ্ন দাঁড়াল: শুধু সাধারণ load আর store দিয়ে, কোনো বিশেষ hardware সাহায্য ছাড়া, user প্রোগ্রাম কি নিজেরাই mutual exclusion বানাতে পারে — যেকোনো সংখ্যক CPU-তে? উত্তর “হ্যাঁ”, কিন্তু পথটি ভুলে ভরা।
৪ · সফটওয়্যারের ব্যর্থ চেষ্টা — একটি turn আর দুটি flag
দুটি প্রসেস P0 আর P1 নিয়ে শুরু করি। লেখার সুবিধায় Pi নিজে, আর Pj অন্যজন (j = 1 − i). Shared ভ্যারিয়েবল ছাড়া এদের মধ্যে যোগাযোগের কোনো পথ নেই।
চেষ্টা ১ — একটি shared turn: পালা করে
int turn = 0; /* shared: whose turn is it? */
/* process i */
while (true) {
while (turn != i) ; /* entry: wait for my turn */
/* critical section */
turn = j; /* exit: give the turn to the other */
/* remainder section */
}Mutual exclusion ঠিক আছে: turn একসময়ে একটিই মান ধরে — 0 বা 1 — আর Pi ঢোকে কেবল turn == i হলে, এবং ভেতরে থাকার সময় কেউ turn বদলায় না (বদলায় শুধু exit). তাই দুজন একসাথে ভেতরে থাকতে পারে না। কিন্তু এই কোড প্রসেসগুলোকে কঠোরভাবে পালা করতে বাধ্য করে — P0, P1, P0, P1 … একে বলে strict alternation, আর এর দাম progress:
1int turn = 0; // shared2while (turn != i) ; // entry3/* critical section */4turn = j; // exit5/* remainder section */
| turn | |
| P0 | |
| P1 | |
| Critical section |
- শুরু: turn = 0. P0 ঢুকতে চায়। P1 remainder section-এ একটি লম্বা কাজে ব্যস্ত — যেমন একটি বড় ফাইল পড়ছে — critical section-এ তার আপাতত কোনো দরকার নেই।
- P0 ঢোকে।
turn != 0মিথ্যা, তাই লুপ পার হয়ে P0 critical section-এ। এ পর্যন্ত ঠিক। - P0 বেরোয়, পালা দেয় P1-কে। Exit section-এ
turn = 1. P0 এখন remainder-এ, আর তার কাজটি ছোট। - P0 আবার ঢুকতে চায়।
while (turn != 0)— turn এখন 1, তাই P0 লুপে ঘুরতে থাকে। কিন্তু critical section খালি! কেউ ভেতরে নেই। - Progress ভাঙল। শর্ত বলে, কে ঢুকবে তার সিদ্ধান্তে remainder-এ থাকা প্রসেসের কোনো ভূমিকা থাকবে না। অথচ এখানে সিদ্ধান্ত আটকে আছে P1-এর ওপর, যে remainder-এ। P1 যদি আর কখনো critical section-এ না আসে (বা প্রসেসটি শেষ হয়ে যায়), P0 চিরকাল অপেক্ষা করবে।
- শুধু P1 ঘুরে গেলে মুক্তি। P1 একসময় এসে ঢোকে, বেরিয়ে
turn = 0দেয় — তবেই P0 ঢুকতে পারে। দ্রুত প্রসেসটিকে ধীরটির গতিতে বাঁধা হলো। Mutual exclusion আছে, কিন্তু সমাধানটি অগ্রহণযোগ্য।
চেষ্টা ২ — দুটি flag: “আমি ঢুকতে চাই”
সমস্যা ছিল এই যে turn জানে না কে আসলে ঢুকতে চায়। তাই প্রতিটি প্রসেসের জন্য একটি flag: flag[i] = true মানে “Pi ঢুকতে চায় বা ভেতরে আছে”। দুটি স্বাভাবিক ক্রম আছে, আর দুটিই ভুল — দুই ভিন্ন ভাবে।
bool flag[2] = {false, false};
while (flag[j]) ; /* wait while the other wants in */
flag[i] = true; /* now I am in */
/* critical section */
flag[i] = false;| সময় | P0 | P1 | flag[0], flag[1] |
|---|---|---|---|
| T0 | flag[1] পড়ে: false → লুপ পার | false, false | |
| T1 | flag[0] পড়ে: false → লুপ পার | false, false | |
| T2 | flag[0] = true, ঢোকে | true, false | |
| T3 | flag[1] = true, ঢোকে | true, true | |
| ফল | দুজনই critical section-এ — mutual exclusion ভাঙল। যাচাই (“দেখা”) আর ঘোষণা (“flag দেওয়া”) দুটি আলাদা ধাপ, মাঝখানে অন্যজন ঢুকে পড়ে। | ||
bool flag[2] = {false, false};
flag[i] = true; /* I want in */
while (flag[j]) ; /* wait while the other wants in */
/* critical section */
flag[i] = false;| সময় | P0 | P1 | flag[0], flag[1] |
|---|---|---|---|
| T0 | flag[0] = true | true, false | |
| T1 | flag[1] = true | true, true | |
| T2 | flag[1] পড়ে: true → অপেক্ষা | true, true | |
| T3 | flag[0] পড়ে: true → অপেক্ষা | true, true | |
| ফল | দুজনই চিরকাল একে অপরের জন্য অপেক্ষায়, critical section খালি — progress ভাঙল (deadlock). সরু গলিতে মুখোমুখি দুই রিকশা, দুজনই বলছে “আপনি আগে সরুন”। | ||
তবে ২খ mutual exclusion রাখে। প্রমাণ: ধরুন দুজনই ভেতরে। প্রত্যেকে ঢোকার আগে নিজের flag true করেছে, তারপর অন্যের flag false দেখেছে। যে পরে নিজের flag দিয়েছে, ধরা যাক P1, তার পড়া ঘটেছে নিজের flag দেওয়ার পরে, অর্থাৎ P0-এর flag দেওয়ারও পরে — আর তখন flag[0] true, কারণ P0 তখনো ভেতরে বা পথে। তাহলে P1 false দেখতে পারে না — বিরোধ। ∎
দুটি চেষ্টা থেকে শিক্ষা: শুধু turn জানে “কার পালা”, কিন্তু জানে না “কে চায়”; শুধু flag জানে “কে চায়”, কিন্তু টাই ভাঙতে পারে না। সঠিক সমাধানের জন্য দুটিই লাগবে।
Dekker-এর সমাধান: দুটি প্রসেসের জন্য প্রথম পরিচিত সঠিক সফটওয়্যার-সমাধান ডাচ গণিতবিদ Th. J. Dekker-এর। ১৯৬০-এর দশকের শুরুর দিকে Dijkstra সহকর্মীদের সামনে সমস্যাটি রেখেছিলেন, আর বেশ কিছু ভুল প্রস্তাবের পর Dekker এটি সমাধান করেন। Dijkstra ১৯৬৫ সালে তাঁর বিখ্যাত লেখা “Cooperating Sequential Processes”-এ Dekker-এর নামে এটি বর্ণনা করেন, আর একই বছর Communications of the ACM-এ n প্রসেসের জন্য নিজের একটি সমাধান প্রকাশ করেন। Dekker-এর ধারণা: flag দিয়ে চাওয়া জানানো, আর দুজনই চাইলে turn দেখে যার পালা নয় সে সাময়িকভাবে flag নামিয়ে সরে দাঁড়ায়।
flag[i] = true;
while (flag[j]) { /* the other also wants in */
if (turn == j) { /* and it is the other's turn: */
flag[i] = false; /* step back ... */
while (turn == j) ; /* ... wait for my turn ... */
flag[i] = true; /* ... then try again */
}
}
/* critical section */
turn = j; /* exit: give priority to the other */
flag[i] = false;এটি সঠিক, কিন্তু নেস্টেড লুপের কারণে বোঝা আর প্রমাণ করা কঠিন। প্রায় দুই দশক পরে একটি অনেক ছোট সমাধান আসে।
৫ · Peterson-এর সমাধান (১৯৮১)
১৯৮১ সালে Gary L. Peterson Information Processing Letters-এ একটি ছোট প্রবন্ধ প্রকাশ করেন — “Myths About the Mutual Exclusion Problem”. তাঁর সমাধানে flag আর turn দুটিই আছে, কিন্তু turn-এর ভূমিকা উল্টো: যে ঢুকতে চায়, সে নিজের flag তুলে turn দিয়ে দেয় অন্যজনকে — “আমি চাই, তবে আপনি আগে যান”। দুজন একসাথে ভদ্রতা দেখালে, যার ভদ্রতা পরে লেখা হলো সে-ই অপেক্ষা করে।
bool flag[2] = {false, false}; /* shared */
int turn; /* shared: 0 or 1 */
/* process i (j = 1 - i) */
while (true) {
flag[i] = true; /* 1. I want to enter */
turn = j; /* 2. but you may go first */
while (flag[j] && turn == j) /* 3. wait only if you want */
; /* AND it is your turn */
/* critical section */
flag[i] = false; /* 4. exit: I no longer want */
/* remainder section */
}Entry section-এর লুপে প্রসেস কিছুই করে না, শুধু বারবার শর্ত দেখে — একে বলে busy waiting (spinning). এটি CPU খরচ করে; পর্ব ২৪-এ দেখবেন কীভাবে অপেক্ষারত প্রসেসকে ঘুম পাড়ানো যায়। এবার দেখি দুজন একেবারে একসাথে চাইলে কী হয়:
1flag[i] = true; // I want in2turn = j; // after you3while (flag[j] && turn == j) ;4/* critical section */5flag[i] = false; // exit6/* remainder section */
| flag[0] | |
| flag[1] | |
| turn | |
| P0 | |
| P1 | |
| Critical section |
- শুরু। দুটি flag false, turn-এর প্রাথমিক মান 0 (যেকোনো মান হলেও প্রমাণ একই)। এখন দুজনই প্রায় একই মুহূর্তে ঢুকতে চায় — সবচেয়ে কঠিন ক্ষেত্র।
- P0:
flag[0] = true. P0 ঘোষণা করল যে সে ঢুকতে চায়। - P1:
flag[1] = true. P1-ও চায়। এখন দুটি flag-ই true — চেষ্টা ২খ-এর মতো অবস্থা, যেখানে deadlock হয়েছিল। এবার turn টাই ভাঙবে। - P0:
turn = 1. P0 ভদ্রতা দেখাল: “P1, আপনি আগে।” - P1:
turn = 0. P1-ও ভদ্রতা দেখাল, আর তার লেখা P0-এর লেখার ওপর বসল। turn একটিই ভ্যারিয়েবল, তাই শেষ লেখাটিই টেকে: turn = 0. যে শেষে turn লেখে, সে-ই অপেক্ষা করে। - P0 শর্ত দেখে।
flag[1] && turn == 1→ true && false → false. লুপ শেষ, P0 critical section-এ ঢুকল। - P1 শর্ত দেখে।
flag[0] && turn == 0→ true && true → true. P1 লুপে ঘুরতে থাকে। একসাথে দুজন ভেতরে নেই — mutual exclusion রক্ষা হলো। - P0 বেরোয়:
flag[0] = false. P0 আর ঢুকতে চায় না, remainder-এ চলে যায়। - P1 ঢোকে। P1-এর পরের যাচাইয়ে
flag[0]false, তাই শর্ত মিথ্যা, P1 ঢুকল। P0 যদি সঙ্গে সঙ্গে আবার চাইত, তাহলেturn = 1লিখে সে নিজেই অপেক্ষায় পড়ত — P1-কে আর টপকাতে পারত না (bounded waiting, নিচে প্রমাণ)।
প্রমাণ — তিনটি শর্তই কেন পূরণ হয়
প্রমাণে ধরে নেওয়া হচ্ছে, প্রতিটি load আর store অবিভাজ্য আর সব প্রসেস সব লেখা একই ক্রমে দেখে (sequential consistency). অংশ ৭-এ দেখবেন, এই অনুমানটিই আধুনিক CPU-তে ভাঙে।
(১) Mutual exclusion. বিপরীতটি ধরি: কোনো মুহূর্ত t-তে P0 আর P1 দুজনই critical section-এ। তাহলে t-তে flag[0] = flag[1] = true, কারণ প্রত্যেকে ঢোকার আগে নিজের flag তুলেছে আর বেরোনোর আগে নামায় না। প্রত্যেকে ঢোকার আগে turn-এ একবার লিখেছে; ধরা যাক (নাম বদলে নেওয়া যায় বলে সাধারণতা না হারিয়ে) P1-এর লেখা turn = 0 পরে ঘটেছে, P0-এর turn = 1 আগে। P1-এর লেখার পর থেকে t পর্যন্ত আর কেউ turn-এ লেখেনি (দুজনের পরের লেখা আসবে কেবল পরের entry-তে), তাই ওই পুরো সময় turn = 0. P1 নিজের লেখার পরে লুপের শর্ত দেখে বেরিয়েছে, অর্থাৎ তখন হয় flag[0] false, নয় turn == 1. turn তখন 0, তাই সে নিশ্চয়ই flag[0] false পড়েছে। কিন্তু P0 নিজের flag তুলেছিল নিজের turn-লেখার আগে — যা P1-এর turn-লেখার আগে — আর t পর্যন্ত নামায়নি। অর্থাৎ P1-এর পড়ার সময় flag[0] true. বিরোধ। তাই দুজন একসাথে ভেতরে থাকতে পারে না। ∎
(২) Progress. Pi আটকাতে পারে কেবল এক জায়গায় — লুপে, যখন flag[j] true এবং turn == j. Pj কোথায় আছে, সেই অনুযায়ী চারটি ক্ষেত্র: (ক) Pj ঢুকতে চায় না, remainder-এ আছে — তাহলে flag[j] false, তাই Pi সঙ্গে সঙ্গে ঢোকে; remainder-এর প্রসেস কাউকে আটকায় না। (খ) দুজনই লুপে — turn-এর মান একটিই, 0 বা 1, আর লুপে থাকার সময় কেউ turn বদলায় না। তাহলে turn যার নামে নয়, তার শর্ত মিথ্যা, আর সে ঢোকে। (গ) Pj entry-র মাঝপথে — সে শিগগির turn = i লিখবে, তখন Pi-এর শর্ত মিথ্যা হয়ে যায়। (ঘ) Pj critical section-এ — অনুমান অনুযায়ী সে সসীম সময়ে বেরিয়ে flag নামাবে, তারপর (ক) বা (গ)। দুজন একসাথে চিরকাল ঘুরতে পারে না, আর সিদ্ধান্তে অংশ নিচ্ছে শুধু entry-তে থাকা প্রসেসরা (তাদের flag আর turn-লেখা)। ∎
(৩) Bounded waiting (সীমা = ১). ধরুন Pi নিজের turn = j লিখে লুপে অপেক্ষা করছে। Pj এখন ভেতরে থাকলে বেরোনোর সময় flag[j] = false করবে। এরপর Pj আবার ঢুকতে চাইলে নিজের entry-তে লিখবে turn = i — আর তখন Pj-এর নিজের শর্ত দাঁড়ায় flag[i] && turn == i = true, তাই Pj আটকে যায়, অথচ Pi-এর শর্ত (turn == j) মিথ্যা হয়ে যায়। অর্থাৎ Pi অপেক্ষা শুরুর পর Pj সর্বোচ্চ একবার ঢুকতে পারে (যে প্রবেশটি আগেই চলছিল বা শুরু হচ্ছিল), তারপর Pi-এর পালা নিশ্চিত। ∎
নিজে যাচাই — সব interleaving গুনে দেখা
হাতের প্রমাণে ভুল থাকতে পারে — concurrent অ্যালগরিদমের ইতিহাসে বহু “প্রমাণিত” সমাধান পরে ভুল প্রমাণিত হয়েছে। আরেকটি পথ: দুটি প্রসেসের ছোট অ্যালগরিদমে সম্ভাব্য অবস্থার সংখ্যা সীমিত (প্রতিটি প্রসেস কোন লাইনে, আর flag আর turn-এর মান), তাই কম্পিউটার সব অবস্থা আর সব interleaving ঘুরে দেখতে পারে। একে বলে model checking. নিচের প্রোগ্রামটি এই পর্বের চারটি অ্যালগরিদমের প্রতিটির জন্য তা-ই করে — প্রতিটি ধাপে একটিমাত্র load বা store, আর remainder-এ থাকা প্রসেস চাইলে চিরকাল সেখানে থেকে যেতে পারে (halt). তারপর তিনটি শর্ত যাচাই করে:
// pc: 0 remainder, 1..5 entry section, 9 critical section (CS). One step = one read OR one write.
const CS = 9, A = { // step(s, i, j): run one step of process i; staying on the same pc = still spinning
'strict alternation': { spin: [1], step: (s, i) => { if (s.turn === i) s.pc[i] = CS; } },
'two flags: set, then wait': { spin: [2], step: (s, i, j) => {
if (s.pc[i] === 1) { s.flag[i] = 1; s.pc[i] = 2; } else if (!s.flag[j]) s.pc[i] = CS; } },
'two flags: wait, then set': { spin: [1], step: (s, i, j) => {
if (s.pc[i] === 1) { if (!s.flag[j]) s.pc[i] = 2; } else { s.flag[i] = 1; s.pc[i] = CS; } } },
'Peterson': { spin: [3, 4], step: (s, i, j) => { const p = s.pc[i]; // while (flag[j] && turn == j);
if (p === 1) { s.flag[i] = 1; s.pc[i] = 2; } else if (p === 2) { s.turn = j; s.pc[i] = 3; }
else if (p === 3) s.pc[i] = s.flag[j] ? 4 : CS; else s.pc[i] = s.turn === j ? 3 : CS; } },
};
for (const [name, { spin, step }] of Object.entries(A)) {
const key = (s) => JSON.stringify(s), seen = new Map(), next = new Map(), todo = [];
const add = (s) => { const k = key(s); if (!seen.has(k)) { seen.set(k, s); todo.push(s); } return k; };
add({ pc: [0, 0], flag: [0, 0], turn: 0, halt: [0, 0], over: [0, 0] }); // over[j]: entries by i while j spins
while (todo.length) { const s = todo.pop(), out = [];
for (const i of [0, 1]) {
if (s.halt[i]) continue;
const j = 1 - i, t = structuredClone(s), pc = s.pc[i];
if (pc === 0) { const h = structuredClone(s); h.halt[i] = 1; out.push(add(h)); t.pc[i] = 1; } // may stay out forever
else if (pc === CS) { if (name === 'strict alternation') t.turn = j; else t.flag[i] = 0; t.pc[i] = 0; t.over[i] = 0; }
else { step(t, i, j); if (t.pc[i] === CS && spin.includes(s.pc[j])) t.over[j] = Math.min(t.over[j] + 1, 3); }
out.push(add(t));
}
next.set(key(s), out);
}
const all = [...seen.values()], good = new Set(all.filter((s) => s.pc.includes(CS)).map(key));
for (let grew = true; grew; ) { grew = false; // good = states from which someone can still reach the CS
for (const [k, out] of next) if (!good.has(k) && out.some((o) => good.has(o))) { good.add(k); grew = true; } }
const me = all.find((s) => s.pc[0] === CS && s.pc[1] === CS);
const stuck = all.find((s) => !good.has(key(s)) && s.pc.some((p) => p > 0 && p < CS));
const show = (s) => JSON.stringify({ pc: s.pc, flag: s.flag, turn: s.turn, halt: s.halt });
const bw = Math.max(...all.map((s) => Math.max(...s.over)));
console.log(`${name}: ${seen.size} states`);
console.log(' mutual exclusion :', me ? 'VIOLATED e.g. ' + show(me) : 'holds');
console.log(' progress :', !stuck ? 'holds' : (stuck.halt.includes(1) ? 'VIOLATED (other stays in remainder)'
: 'VIOLATED (deadlock)') + ' e.g. ' + show(stuck));
console.log(' bounded waiting :', bw >= 3 ? 'NO bound (3+ entries seen)' : `other enters at most ${bw} time(s)`);
}আউটপুট (প্রতিবার একই, কারণ খোঁজাটি সম্পূর্ণ, কোনো এলোমেলো রান নয়):
strict alternation: 40 states
mutual exclusion : holds
progress : VIOLATED (other stays in remainder) e.g. {"pc":[0,1],"flag":[0,0],"turn":0,"halt":[1,0]}
bounded waiting : other enters at most 1 time(s)
two flags: set, then wait: 24 states
mutual exclusion : holds
progress : VIOLATED (deadlock) e.g. {"pc":[2,2],"flag":[1,1],"turn":0,"halt":[0,0]}
bounded waiting : other enters at most 0 time(s)
two flags: wait, then set: 121 states
mutual exclusion : VIOLATED e.g. {"pc":[9,9],"flag":[1,1],"turn":0,"halt":[0,0]}
progress : holds
bounded waiting : NO bound (3+ entries seen)
Peterson: 112 states
mutual exclusion : holds
progress : holds
bounded waiting : other enters at most 1 time(s)
চারটি ফলই তত্ত্বের সঙ্গে মেলে। Strict alternation: mutual exclusion আছে, কিন্তু progress ভাঙে — উদাহরণ অবস্থায় P0 remainder-এ থেমে আছে (halt [1,0]), turn = 0, আর P1 লুপে চিরকাল ঘুরছে। ২খ (আগে flag): দুজনই লাইন ২-এ, দুটি flag 1 — অংশ ৪-এর deadlock. ২ক (আগে দেখা): pc [9,9] মানে দুজনই critical section-এ। আর এর “NO bound” মানে একজন লুপে থাকতে অন্যজন বারবার ঢুকতে পারে (৩ বার পর্যন্ত গোনা হয়েছে)। Peterson: তিনটিই ঠিক, আর অন্যজন সর্বোচ্চ ১ বার ঢোকে — ঠিক প্রমাণের সীমা। Peterson-এর শর্তের দুটি পড়া (flag[j] আর turn) এখানে আলাদা দুটি ধাপ, তাই মাঝখানে অন্যজনের লেখা ঢুকে পড়ার ক্ষেত্রও যাচাই হয়েছে। “progress” যাচাই এখানে দেখে, প্রতিটি অবস্থা থেকে critical section-এ পৌঁছানোর অন্তত একটি পথ আছে কি না — অর্থাৎ deadlock-মুক্তি। মনে রাখবেন, এটি sequential consistency ধরে নেয়; বাস্তবের CPU-র reordering এখানে নেই।
৬ · n প্রসেসের জন্য — Lamport-এর bakery algorithm
Peterson দুটি প্রসেসের জন্য। তিন বা হাজার প্রসেস হলে? ১৯৭৪ সালে Leslie Lamport Communications of the ACM-এ একটি সমাধান দেন, যার নাম bakery algorithm — বেকারির দোকানের টোকেন-ব্যবস্থার নামে।
ব্যাংকের টোকেন-কাউন্টার ভাবুন। ঢুকেই আপনি টোকেন নেন — এখন পর্যন্ত দেওয়া সবচেয়ে বড় নম্বরের চেয়ে এক বেশি। ডিসপ্লেতে যার নম্বর সবচেয়ে ছোট, সে-ই কাউন্টারে যায়। কাজ শেষে টোকেন ফেলে দেন। এখানে একটি মজার সমস্যা আছে: যন্ত্র নেই, প্রত্যেকে নিজেই বাকিদের টোকেন দেখে নিজের নম্বর লেখেন — তাই দুজন একসাথে দেখলে একই নম্বর পেতে পারেন। তখন টাই ভাঙে আইডি দিয়ে: যার রোল নম্বর ছোট, সে আগে।
bool choosing[n] = {false}; /* shared: "I am picking a ticket" */
int number[n] = {0}; /* shared: 0 = not interested */
lock(i):
choosing[i] = true;
number[i] = 1 + max(number[0], ..., number[n-1]);
choosing[i] = false;
for (j = 0; j < n; j++) {
while (choosing[j]) ; /* let j finish picking */
while (number[j] != 0 &&
(number[j], j) < (number[i], i)) ; /* j is ahead of me */
}
unlock(i):
number[i] = 0;এখানে (a, b) < (c, d) মানে a < c, অথবা a = c আর b < d — অভিধানের মতো তুলনা। একটি ছোট উদাহরণ, তিনটি প্রসেস:
| ঘটনা | number[0] | number[1] | number[2] | কে ঢুকতে পারে |
|---|---|---|---|---|
| P1 আর P2 একসাথে max দেখে (সবাই 0) | 0 | 1 | 1 | টাই: (1, 1) < (1, 2), তাই P1 |
| P0 পরে আসে: 1 + max = 2 | 2 | 1 | 1 | P1 ভেতরে; P2, P0 অপেক্ষায় |
| P1 বেরোয়: number[1] = 0 | 2 | 0 | 1 | P2 (1 < 2) |
| P2 বেরোয় | 2 | 0 | 0 | P0 |
ফল: P1, P2, P0 — যে আগে টোকেন নিয়েছে, সে আগে, অর্থাৎ first-come, first-served; তাই bounded waiting স্বাভাবিকভাবেই আসে (n − ১ জনের বেশি কেউ আপনাকে টপকাতে পারে না)। choosing কেন লাগে? ধরুন P1 আর P2 একই নম্বর 1 পাবে, কিন্তু P2 আগে লেখা শেষ করে লুপে ঢুকল, তখনো P1-এর number[1] 0 দেখাচ্ছে (P1 হিসাব করছে, লেখেনি)। choosing না থাকলে P2 ভাবত P1 আগ্রহী নয়, আর ঢুকে পড়ত; তারপর P1 লিখত 1, দেখত (1, 1) < (1, 2), আর সেও ঢুকত — দুজন ভেতরে। choosing[1] P2-কে বাধ্য করে P1-এর নম্বর লেখা শেষ হওয়া পর্যন্ত অপেক্ষা করতে।
দুটি দুর্বলতাও আছে: টোকেনের নম্বর সীমাহীনভাবে বাড়তে পারে (সবসময় কেউ না কেউ ভেতরে থাকলে নম্বর কখনো 0-তে ফেরে না), আর প্রতিটি lock-এ n টি প্রসেসের ঘর পড়তে হয় — n বড় হলে ধীর। তাই বাস্তবের সিস্টেমে bakery চলে না; কিন্তু “টোকেন নিয়ে FIFO ক্রম” ধারণাটি বেঁচে আছে ticket lock-এ, যা Linux kernel x86-এ বহু বছর spinlock হিসেবে ব্যবহার করেছে (পর্ব ২৩)।
আরও গভীরে: Burns আর Lynch দেখিয়েছেন, শুধু সাধারণ read/write ভ্যারিয়েবল দিয়ে n প্রসেসের deadlock-মুক্ত mutual exclusion বানাতে অন্তত n টি shared ভ্যারিয়েবল লাগে — তাই Peterson আর bakery-র মতো সমাধানে প্রতি প্রসেসে একটি flag বা ticket অবধারিত। এই সীমা ভাঙতে hardware থেকে আরও শক্তিশালী instruction লাগে, যেমন test-and-set বা compare-and-swap. Linux kernel-এ PID বরাদ্দ (kernel/pid.c-এর alloc_pid()) এখন একটি spinlock ধরে IDR নামের ডেটা-কাঠামো থেকে নম্বর নেয়, আর অংশ ৩-এর race এভাবেই এড়ায়।
৭ · আধুনিক CPU-তে Peterson কেন ভাঙে — store buffer আর reordering
এতক্ষণের সব প্রমাণ একটি অনুমানের ওপর দাঁড়িয়ে: প্রতিটি প্রসেস প্রোগ্রামের ক্রমেই মেমরিতে লেখে আর পড়ে, আর সবাই একই ক্রম দেখে। আধুনিক CPU আর compiler গতির জন্য এই অনুমান ভেঙে দেয়।
প্রতিটি core লেখাগুলো সঙ্গে সঙ্গে cache-এ পাঠায় না; আগে রাখে নিজের একটি ছোট সারিতে — store buffer. ফলে core পরের instruction-এ এগিয়ে যেতে পারে, ধীর মেমরির জন্য থামতে হয় না। কিন্তু এর মানে, একটি core-এর লেখা অন্য core কিছুক্ষণ দেখতে পায় না। x86-এও (যাকে “শক্ত ক্রমের” CPU বলা হয়) ঠিক এই একটি reordering অনুমোদিত: আগের store-এর আগেই পরের load ঘটে যেতে পারে (ভিন্ন ঠিকানায়)। আর Peterson-এর entry ঠিক এই ধাঁচের — আগে flag[i] লেখা, পরে flag[j] পড়া:
| সময় | Core 0 (P0) | Core 1 (P1) | সবার দেখা মেমরি |
|---|---|---|---|
| T0 | flag[0] = 1 → store buffer-এ আটকে | flag[1] = 1 → store buffer-এ আটকে | flag = 0, 0 |
| T1 | turn = 1 → store buffer-এ | turn = 0 → store buffer-এ | flag = 0, 0 |
| T2 | flag[1] পড়ে: 0 → লুপ পার | flag[0] পড়ে: 0 → লুপ পার | flag = 0, 0 |
| T3 | critical section | critical section | buffer খালি হয়: flag = 1, 1 |
প্রত্যেকে নিজের লেখা দেখে (নিজের buffer থেকে), কিন্তু অন্যেরটা দেখে না — আর দুজনই ঢুকে পড়ে। ARM বা RISC-V-এর মতো দুর্বলতর ক্রমের CPU-তে আরও বেশি ধরনের reordering হয়। Compiler-ও একই কাজ করতে পারে: সাধারণ ভ্যারিয়েবলের পড়া লুপের বাইরে তুলে নেওয়া, বা দুটি লেখার ক্রম বদলানো। সাধারণ C ভ্যারিয়েবলে Peterson লেখা তাই C11 মানদণ্ডে data race — undefined behavior. নিচের প্রোগ্রামে compiler-কে থামাতে volatile দেওয়া হয়েছে, কিন্তু CPU-কে থামানোর কিছু নেই:
#include <pthread.h>
#include <stdio.h>
#define LOOPS 1000000
static volatile int flag[2], turn; /* volatile: the COMPILER must touch memory, */
static volatile long counter; /* but the CPU may still reorder */
static void *worker(void *arg) {
int i = (int)(long)arg, j = 1 - i;
for (int k = 0; k < LOOPS; k++) {
flag[i] = 1; /* store ... */
turn = j;
while (flag[j] && turn == j) /* ... then load: x86 may let this */
; /* load pass the store above */
counter++; /* critical section */
flag[i] = 0;
}
return NULL;
}
int main(void) {
pthread_t t0, t1;
pthread_create(&t0, NULL, worker, (void *)0L);
pthread_create(&t1, NULL, worker, (void *)1L);
pthread_join(t0, NULL);
pthread_join(t1, NULL);
printf("counter = %ld, expected %d, lost updates = %ld\n",
counter, 2 * LOOPS, 2L * LOOPS - counter);
return 0;
}তিনটি আসল রানের আউটপুট (সংখ্যা প্রতিবার বদলায়):
run 1: counter = 1999167, expected 2000000, lost updates = 833
run 2: counter = 1996962, expected 2000000, lost updates = 3038
run 3: counter = 1999934, expected 2000000, lost updates = 66
কোডটি অংশ ৫-এর Peterson-এর হুবহু, আর প্রমাণ বলে ফল ঠিক ২০,০০,০০০ হতেই হবে — তবু যোগ হারিয়েছে, অর্থাৎ কখনো কখনো দুজনই একসাথে critical section-এ ছিল। পর্ব ১৫-এর lock-ছাড়া counter-এ লাখ লাখ যোগ হারিয়েছিল; এখানে কয়েক ডজন থেকে কয়েক হাজার — বাগটি তুলনায় বিরল, তাই আরও বিপজ্জনক: ছোট টেস্টে ধরা পড়ে না। এই রানারের container-এ ২টি CPU থাকায় দুই থ্রেড সত্যিই একসাথে চলে; আপনার মেশিনে, অন্য CPU-তে বা অন্য optimization-এ সংখ্যা ভিন্ন, এমনকি শূন্যও হতে পারে। শূন্য এলেও কোডটি সঠিক নয়।
সমাধান: compiler আর CPU-কে বলে দেওয়া যে এই ভ্যারিয়েবলগুলোর লেখা-পড়া প্রোগ্রামের ক্রমেই সবার কাছে দৃশ্যমান হতে হবে। C11-এর <stdatomic.h>-এ atomic_store আর atomic_load-এর ডিফল্ট ক্রম memory_order_seq_cst — sequential consistency, ঠিক যা প্রমাণে ধরা হয়েছিল। x86-64-এ GCC এমন store-কে সাধারণত xchg (অথবা mov-এর পরে mfence) হিসেবে বানায় — দুটিই একটি পূর্ণ memory barrier, যা store buffer খালি না হওয়া পর্যন্ত পরের load-কে আটকে রাখে:
#include <pthread.h>
#include <sched.h>
#include <stdatomic.h>
#include <stdio.h>
#define LOOPS 200000
static atomic_int flag[2], turn; /* atomic_* ops default to seq_cst */
static long counter; /* plain variable, guarded by the lock */
static void lock(int i) {
int j = 1 - i;
atomic_store(&flag[i], 1); /* "I want to enter" */
atomic_store(&turn, j); /* "but you may go first" */
while (atomic_load(&flag[j]) && atomic_load(&turn) == j)
sched_yield(); /* busy wait, but give the CPU away */
}
static void unlock(int i) { atomic_store(&flag[i], 0); }
static void *worker(void *arg) {
int i = (int)(long)arg; /* 0 or 1 */
for (int k = 0; k < LOOPS; k++) {
lock(i);
counter++; /* critical section */
unlock(i);
}
return NULL;
}
int main(void) {
pthread_t t0, t1;
pthread_create(&t0, NULL, worker, (void *)0L);
pthread_create(&t1, NULL, worker, (void *)1L);
pthread_join(t0, NULL);
pthread_join(t1, NULL);
printf("counter = %ld, expected %d: %s\n", counter, 2 * LOOPS,
counter == 2 * LOOPS ? "mutual exclusion held" : "BROKEN");
return 0;
}আসল রানের আউটপুট (প্রতিবার একই):
counter = 400000, expected 400000: mutual exclusion held
এবার ২ × ২,০০,০০০ = ৪,০০,০০০ — একটিও যোগ হারায়নি। তিনটি খুঁটিনাটি লক্ষ করুন। (১) শুধু flag আর turn atomic; counter সাধারণ long. Seq_cst অপারেশনগুলো lock-এর ভেতরের সাধারণ লেখা-পড়াকেও ক্রমে বেঁধে দেয়, তাই এক থ্রেডের counter++ পরের থ্রেড ঠিকঠাক দেখে। (২) লুপে sched_yield(): অপেক্ষারত থ্রেড CPU ছেড়ে দেয়, যাতে core কম থাকলে ভেতরের থ্রেড দ্রুত কাজ শেষ করতে পারে। খাঁটি busy waiting-এ এক core-এ প্রতিটি পালা-বদলে পুরো time slice নষ্ট হতো। (৩) শুধু “release” আর “acquire”-এর মতো হালকা ক্রম এখানে যথেষ্ট নয় — Peterson-এর দরকার ঠিক সেই store→load ক্রম, যা কেবল seq_cst বা পূর্ণ barrier দেয়। Memory ordering-এর পুরো ছবি পর্ব ২৩-এ।
বাস্তবে Peterson ব্যবহার করবেন না। এটি শেখার আর প্রমাণের জন্য অসাধারণ, কিন্তু কাজের কোডে নয়: শুধু দুটি থ্রেডের জন্য, busy waiting-এ CPU পোড়ায়, আর barrier-এর খরচ আছে। আধুনিক CPU-র atomic instruction (test-and-set, compare-and-swap) দিয়ে অনেক সরল ও দ্রুত lock বানানো যায় (পর্ব ২৩), আর অপেক্ষারত থ্রেডকে ঘুম পাড়ানো যায় mutex আর semaphore দিয়ে (পর্ব ২৪)। কাজের কোডে pthread_mutex_t বা আপনার ভাষার lock-ই সঠিক পছন্দ।
৮ · সারসংক্ষেপ — কোন সমাধান কোন শর্ত মানে
| সমাধান | Mutual exclusion | Progress | Bounded waiting | মন্তব্য |
|---|---|---|---|---|
| Interrupt বন্ধ | শুধু এক CPU-তে | হ্যাঁ | নির্ভর করে | শুধু kernel-এ, ছোট অংশে |
| Strict alternation (একটি turn) | হ্যাঁ | না | হ্যাঁ | ধীরের গতিতে দ্রুত বাঁধা |
| ২ক: আগে দেখা, পরে flag | না | হ্যাঁ | না | দেখা আর ঘোষণার মাঝে ফাঁক |
| ২খ: আগে flag, পরে দেখা | হ্যাঁ | না (deadlock) | — | টাই ভাঙার উপায় নেই |
| Dekker | হ্যাঁ | হ্যাঁ | হ্যাঁ | প্রথম সঠিক, কিন্তু জটিল |
| Peterson | হ্যাঁ | হ্যাঁ | হ্যাঁ (সীমা ১) | দুই প্রসেস; আধুনিক CPU-তে barrier চাই |
| Bakery | হ্যাঁ | হ্যাঁ | হ্যাঁ (FCFS) | n প্রসেস; টোকেন সীমাহীন |
প্রতিটি সফটওয়্যার-সমাধানের সাধারণ দুর্বলতা দুটি: busy waiting, আর memory ordering-এর ওপর নির্ভরতা। তাই পরের পর্বগুলোর পথ: hardware-এর atomic instruction আর spinlock (পর্ব ২৩), ঘুমিয়ে অপেক্ষার mutex আর semaphore (পর্ব ২৪), condition variable আর monitor (পর্ব ২৫), আর ক্লাসিক সমস্যাগুলোতে এগুলোর প্রয়োগ (পর্ব ২৬)।
৯ · AI-এর যুগে race condition আর lock-এর কোড
“দুই থ্রেডের জন্য একটি lock লিখে দাও” — AI মুহূর্তে Peterson বা একটি flag-ভিত্তিক lock দেবে, আর আপনার ল্যাপটপে কয়েক হাজার বার চালিয়ে হয়তো ঠিকই দেখাবে। কিন্তু উপরে দেখেছেন, ভুলটি ২০ লাখে মাত্র কয়েক ডজন বার আসতে পারে। এ বিষয়ে AI-এর সাধারণ ভুল:
| AI-এর ভুল | কেন ভুল | কীভাবে যাচাই করবেন |
|---|---|---|
সাধারণ বা volatile ভ্যারিয়েবলে Peterson, “x86 strongly ordered, তাই barrier লাগে না” | x86 store→load reordering অনুমোদন করে; C-তে এটি data race | <stdatomic.h>-এর seq_cst বা পূর্ণ barrier আছে কি না দেখুন; লাখবার লুপে চালিয়ে মিলিয়ে নিন |
| “দেখাও যে strict alternation ভুল” প্রশ্নে mutual exclusion-এর ভাঙন দেখানো | Strict alternation mutual exclusion রাখে; ভাঙে progress | নিজে interleaving-এর টেবিল বানিয়ে প্রতিটি ধাপ মেলান |
| প্রমাণে শুধু একটি সুবিধাজনক interleaving দেখানো | উদাহরণ সঠিকতার প্রমাণ নয়; সব ক্রমের জন্য যুক্তি লাগে | প্রমাণের প্রতিটি ধাপে “কেন অন্য ক্রমে হবে না” প্রশ্ন করুন; ছোট model checker চালান |
| count++ আর count-- মিশলে ফলের তালিকায় ভুল (যেমন 3 বা 7 যোগ করা) | একটি ++ আর একটি −− থেকে শুধু 4, 5, 6 সম্ভব | ছয়টি instruction-এর ক্রম হাতে গুনুন বা ছোট স্ক্রিপ্টে সব ক্রম বের করুন |
| User প্রোগ্রামে interrupt বন্ধ করার পরামর্শ, বা Linux-এর কোড Windows-এ | cli privileged; API OS-ভেদে আলাদা | প্রম্পটে OS, CPU, compiler উল্লেখ করুন; man page মিলিয়ে নিন |
AI-কে প্রমাণ আর ভাঙার উদাহরণ — দুটিই চাইতে পারেন, কিন্তু যাচাইয়ের পথ সঙ্গে দিন:
AI-এর দেওয়া lock বা প্রমাণ ব্যবহারের আগে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- Critical-section সমাধানের তিনটি শর্ত কী? — Mutual exclusion, progress, bounded waiting; প্রসেসের গতি বা CPU-সংখ্যা নিয়ে কোনো অনুমান ছাড়া।
- দেখান যে strict alternation progress ভাঙে। — turn = 1, P1 remainder-এ থেকে যায়, P0 ঢুকতে চায় — critical section খালি তবু P0 চিরকাল অপেক্ষায়।
- count = 5-এ একসাথে একটি count++ আর একটি count-- চললে ফল কত হতে পারে? — 4, 5 বা 6.
- Multiprocessor-এ interrupt বন্ধ করা কেন যথেষ্ট নয়? — শুধু নিজের CPU-র interrupt বন্ধ হয়; অন্য core তখনো একই ডেটা ছুঁতে পারে।
- Peterson-এ দুজন একসাথে চাইলে কে ঢোকে? — যে turn-এ আগে লিখেছে; যে শেষে লেখে সে অপেক্ষা করে। আধুনিক CPU-তে memory barrier ছাড়া এটি ভাঙতে পারে।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Hardware-এর কোনো সাহায্য ছাড়া শুধু load আর store দিয়ে mutual exclusion — ১৯৬০-এর দশকে এই প্রশ্ন এত গুরুত্বপূর্ণ ছিল কেন?
১৯৬০-এর দশকের শুরুতে একসাথে একাধিক প্রোগ্রাম চালানো (multiprogramming, পর্ব ২) সবে বাস্তব হচ্ছিল। কয়েক বছর পরে নেদারল্যান্ডসে Dijkstra-র দল Electrologica X8 কম্পিউটারের জন্য বিখ্যাত THE multiprogramming system বানায়। প্রশ্নটি ছিল মৌলিক: কয়েকটি স্বাধীন “sequential process” যদি একই মেমরি ভাগ করে, তাহলে তারা কি নিজেদের মধ্যেই, কোনো বিশেষ যন্ত্রাংশ ছাড়া, “একবারে একজন” নিশ্চিত করতে পারে? তখনকার যন্ত্রে আজকের মতো সর্বজনীন atomic instruction-এর ওপর ভরসা করা যেত না, আর Dijkstra চাইছিলেন এমন যুক্তি, যা কোনো নির্দিষ্ট যন্ত্রের খুঁটিনাটির ওপর নির্ভর করে না — শুধু এটুকু ধরে নিয়ে যে একটি শব্দ পড়া বা লেখা অবিভাজ্য।
গল্পটি প্রচলিত যে Dijkstra সহকর্মীদের কাছে সমস্যাটি রাখেন, আর একের পর এক প্রস্তাব আসে — কোনোটি mutual exclusion ভাঙে, কোনোটি deadlock-এ পড়ে, কোনোটি strict alternation-এ আটকে যায়, ঠিক অংশ ৪-এর মতো। শেষে Th. J. Dekker একটি সঠিক সমাধান দেন। ১৯৬৫ সালে Dijkstra “Cooperating Sequential Processes” লেখায় সমস্যা আর সমাধানটি বিশদ ব্যাখ্যা করেন, আর একই বছর CACM-এ এক পাতার প্রবন্ধে n প্রসেসের সমাধান দেন। এই লেখাগুলোতেই critical section, mutual exclusion-এর মতো ধারণা স্পষ্ট রূপ পায়। প্রায় একই সময়ে Dijkstra semaphore-এর ধারণাও দেন (পর্ব ২৪) — যা আসলে স্বীকার করে যে শুধু load/store দিয়ে কাজ চালানো সম্ভব হলেও অসুবিধাজনক।
আজ প্রতিটি CPU-তে compare-and-swap-এর মতো instruction আছে, তাহলে এই ইতিহাস কেন পড়ব? তিনটি কারণে। প্রথমত, এটি দেখায় concurrent যুক্তি কতটা প্রতারণাপূর্ণ — স্বাভাবিক মনে হওয়া প্রতিটি সমাধানই প্রথমে ভুল ছিল। দ্বিতীয়ত, তিন শর্তের কাঠামো (safety আর liveness) আজও প্রতিটি lock, ডেটাবেস transaction বা distributed protocol বিশ্লেষণে লাগে। তৃতীয়ত, কিছু ক্ষেত্রে, যেমন দুটি ভিন্ন যন্ত্রাংশ (ধরুন একটি CPU আর একটি DSP চিপ) যখন একটি shared memory ভাগ করে কিন্তু দুজনের কাছে কোনো সাধারণ atomic instruction নেই, তখনো এই ধরনের অ্যালগরিদমের ধারণা কাজে আসে।
মূল উপলব্ধি: critical-section সমস্যা আসলে একটি প্রশ্ন — “ন্যূনতম কী থাকলে সমন্বয় সম্ভব?” উত্তর: শুধু অবিভাজ্য read আর write যথেষ্ট, কিন্তু দামি। পরের সব হাতিয়ার সেই দাম কমানোর গল্প।
প্র ০২Race condition কি শুধু ভুল সংখ্যার ব্যাপার? বাস্তবের কোন দুর্ঘটনা দেখায় যে এটি জীবন-মরণের প্রশ্ন হতে পারে?
সবচেয়ে বিখ্যাত উদাহরণ Therac-25, একটি রেডিয়েশন থেরাপি যন্ত্র। ১৯৮৫ থেকে ১৯৮৭ সালের মধ্যে যুক্তরাষ্ট্র আর কানাডায় অন্তত ছয়টি দুর্ঘটনায় রোগীরা প্রচণ্ড বেশি মাত্রার বিকিরণ পান, আর কয়েকজন মারা যান। Nancy Leveson আর Clark Turner-এর বিখ্যাত তদন্ত-প্রবন্ধ (১৯৯৩) দেখায়, এর একটি বড় কারণ ছিল সফটওয়্যারের race condition: অপারেটর খুব দ্রুত — কয়েক সেকেন্ডের মধ্যে — চিকিৎসার তথ্য সম্পাদনা করলে যন্ত্রের একটি অংশ পুরোনো সেটিং নিয়েই চলত, আর আগের যন্ত্রে থাকা hardware interlock এই মডেলে সরিয়ে সফটওয়্যারের ওপর ভরসা করা হয়েছিল। পরীক্ষার সময় অপারেটররা এত দ্রুত টাইপ করতেন না, তাই বাগটি ধরা পড়েনি — ঠিক এই পর্বের “বিরল interleaving” সমস্যা।
দ্বিতীয় উদাহরণ ২০০৩ সালের আগস্টে উত্তর আমেরিকার বিশাল blackout, যাতে কয়েক কোটি মানুষ বিদ্যুৎহীন হন। তদন্তে জানা যায়, একটি বিদ্যুৎ কোম্পানির নিয়ন্ত্রণকক্ষের alarm সফটওয়্যার একটি race condition-এ আটকে নীরবে কাজ বন্ধ করে দিয়েছিল; অপারেটররা প্রায় এক ঘণ্টা জানতেই পারেননি যে সতর্কবার্তা আসছে না। ফলে ছোট একটি সমস্যা ছড়িয়ে পড়ে। (বিস্তারিত সংখ্যা আর ক্রম যৌথ টাস্ক ফোর্সের চূড়ান্ত রিপোর্টে দেখুন।)
দুটি ঘটনার মিল লক্ষ করুন। (১) বাগটি সাধারণ টেস্টে ধরা পড়েনি, কারণ ঘটে কেবল নির্দিষ্ট সময়-ক্রমে। (২) সিস্টেম “কাজ করছে” দেখাচ্ছিল — crash নয়, নীরব ভুল। (৩) সমাধান শুধু একটি lock বসানো নয়, নকশা বদল: কোন ডেটা কে কখন ছোঁয় তার স্পষ্ট নিয়ম, স্বাধীন নিরাপত্তা-ব্যবস্থা, আর যুক্তি দিয়ে সঠিকতা দেখানো। আজ চিকিৎসা-যন্ত্র, বিমান, রেল সিগন্যালিংয়ের মতো ক্ষেত্রে concurrent সফটওয়্যারের জন্য কঠোর মানদণ্ড আর formal verification-এর ব্যবহার বাড়ার পেছনে এই অভিজ্ঞতাগুলোর ভূমিকা আছে।
মূল উপলব্ধি: race condition-এর বিপদ তার বিরলতায়। যে বাগ হাজারে একবার আসে, তা টেস্টে লুকায় কিন্তু লাখো ব্যবহারকারীর হাতে নিশ্চিতভাবে ঘটে। তাই concurrent কোডে “চালিয়ে দেখা”র পাশাপাশি “প্রমাণ করে দেখা” লাগে।
প্র ০৩Peterson-এর সমাধান এত ছোট, তবু Dekker-এর প্রায় দুই দশক পরে এল কেন? Concurrent অ্যালগরিদম প্রমাণ করা এত কঠিন কেন?
সরল সমাধান খুঁজে পাওয়া প্রায়ই জটিলটির চেয়ে কঠিন। Dekker-এর পর বহু বছর ধরে অনেকে আরও সরল বা আরও সাধারণ সমাধান প্রস্তাব করেছেন, আর তাদের একটি বড় অংশ ভুল ছিল। পরিচিত একটি উদাহরণ ১৯৬৬ সালে CACM-এ প্রকাশিত Harris Hyman-এর দুই-প্রসেসের অ্যালগরিদম — দেখতে Dekker-এর চেয়ে সরল, কিন্তু পরে দেখা যায় এতে mutual exclusion ভাঙে। প্রকাশের আগে লেখক, সম্পাদক আর পর্যালোচকরা ভুলটি ধরতে পারেননি। Peterson তাঁর ১৯৮১-র প্রবন্ধের শিরোনামেই “Myths” শব্দটি রেখেছিলেন — অর্থাৎ সমস্যাটি নিয়ে প্রচলিত কিছু ধারণা, যেমন সঠিক সমাধান অবশ্যই জটিল হবে, ভুল।
প্রমাণ কঠিন কেন? দুটি প্রসেসে প্রত্যেকের k টি ধাপ থাকলে সম্ভাব্য interleaving-এর সংখ্যা (2k)! ÷ (k! × k!) — k = ১০-এ প্রায় ১,৮৫,০০০, আর লুপ থাকলে অসীম। মানুষ কয়েকটি “আকর্ষণীয়” ক্রম ভাবে, আর ভুলটি থাকে ঠিক সেই ক্রমে, যা মাথায় আসেনি। তাই সঠিক প্রমাণ সব ক্রমের ওপর খাটে এমন যুক্তি দিয়ে লেখা হয় — যেমন একটি invariant (সবসময় সত্য থাকা শর্ত), বা অংশ ৫-এর মতো “বিপরীত ধরে নিয়ে কে শেষে turn লিখল” যুক্তি।
যন্ত্রের সাহায্যও এসেছে। ১৯৮০-র দশকে Edmund Clarke, Allen Emerson আর Joseph Sifakis model checking-এর ভিত্তি গড়েন — সব সম্ভাব্য অবস্থা যন্ত্র দিয়ে ঘুরে দেখা; এ কাজের জন্য তাঁরা ২০০৭ সালের Turing Award পান। অংশ ৫-এর JS প্রোগ্রামটি এই ধারণার একটি খুদে রূপ। বাস্তবের বড় সিস্টেমে সমস্যা “state explosion” — প্রসেস আর ভ্যারিয়েবল বাড়লে অবস্থার সংখ্যা সূচকীয় হারে বাড়ে। তবু Lamport-এর তৈরি TLA+-এর মতো ভাষা দিয়ে Amazon Web Services-সহ বিভিন্ন প্রতিষ্ঠানের প্রকৌশলীরা distributed protocol-এর এমন বাগ খুঁজে পেয়েছেন বলে প্রকাশিত অভিজ্ঞতা আছে, যা টেস্টে ধরা পড়েনি।
মূল উপলব্ধি: concurrent কোডে অন্তর্দৃষ্টি যথেষ্ট নয়। ছোট অংশের জন্য কঠোর প্রমাণ বা model checking, আর বড় সিস্টেমে প্রমাণিত হাতিয়ার (mutex, semaphore) ব্যবহার — এটিই নিরাপদ পথ।
প্র ০৪Bakery algorithm-এর কোন বৈশিষ্ট্য তাকে বিশেষ করে তোলে, আর তবু সেটি বাস্তবে চলে না কেন?
Lamport ১৯৭৪ সালে bakery algorithm প্রকাশ করেন, আর পরে বিভিন্ন লেখায় বলেছেন এটি তাঁর সবচেয়ে প্রিয় কাজগুলোর একটি। এর সবচেয়ে বিস্ময়কর বৈশিষ্ট্য: এটি পড়া-লেখার অবিভাজ্যতা ছাড়াও কাজ করে. Peterson বা Dekker ধরে নেয় একটি ভ্যারিয়েবল পড়া বা লেখা অবিভাজ্য। Bakery-তে প্রতিটি প্রসেস শুধু নিজের number[i] আর choosing[i]-এ লেখে, আর অন্যেরটা পড়ে। Lamport দেখান, কেউ লিখছে এমন অবস্থায় অন্য কেউ পড়লে যদি আবর্জনা-মান (যেকোনো মান) পায়, তবুও mutual exclusion রক্ষা হয় — কারণ choosing দিয়ে অপেক্ষা নিশ্চিত করে, নম্বর লেখা শেষ হওয়ার আগে কেউ সেটির ওপর ভরসা করে সিদ্ধান্ত নেবে না। অর্থাৎ অ্যালগরিদমটি নিচের স্তরের mutual exclusion-এর ওপর নির্ভর না করেই mutual exclusion বানায় — দার্শনিকভাবে খুব পরিচ্ছন্ন।
দ্বিতীয় বৈশিষ্ট্য ন্যায্যতা: যে আগে টোকেন নেয়, সে আগে ঢোকে — first-come, first-served. তাই starvation অসম্ভব, আর bounded waiting-এর সীমা স্পষ্ট। Linux kernel-এর ticket spinlock (২০০৮ সালের দিকে x86-এ আসে) এই ধারণা থেকেই অনুপ্রাণিত: একটি “পরের টোকেন” আর একটি “এখন যার পালা” — তবে সেখানে টোকেন নেওয়া হয় একটি atomic fetch-and-add instruction দিয়ে, n টি ঘর পড়ে নয়। পরে Linux x86-এ queued spinlock-এ চলে যায়, যাতে বহু core-এ cache-এর টানাটানি কমে।
বাস্তবে খাঁটি bakery না চলার কারণ: (১) প্রতিটি lock-এ n টি প্রসেসের ঘর পড়া — শত core-এ অনেক ধীর; (২) টোকেনের মান সীমাহীন বাড়তে পারে, আর সসীম আকারের পূর্ণসংখ্যায় একসময় overflow, যা ঠিক করতে আলাদা কৌশল লাগে; (৩) আধুনিক CPU-তে এটিও Peterson-এর মতো memory barrier ছাড়া ভাঙে; (৪) busy waiting. তবু শিক্ষায় এটি অমূল্য, কারণ এটি দেখায় কীভাবে বহু প্রসেসে একটি সর্বজনীন ক্রম (টোকেন + আইডি) বানিয়ে সমন্বয় করা যায় — distributed system-এর লজিক্যাল ঘড়ির (পর্ব ৫৩) ধারণার সঙ্গে এর মিল আছে, যেটিও Lamport-এর কাজ।
মূল উপলব্ধি: bakery algorithm দেখায়, ন্যায্যতা আর সঠিকতা শুধু চতুর ক্রম-নিয়ম দিয়ে পাওয়া যায়; কিন্তু দক্ষতার জন্য hardware-এর atomic instruction লাগে। আধুনিক ticket lock হলো দুটির মিলন।
প্র ০৫CPU কেন প্রোগ্রামের লেখা ক্রম মানে না? Sequential consistency কী, আর তার দাম কে দেয়?
১৯৭৯ সালে Lamport একটি সংজ্ঞা দেন, যাকে বলা হয় sequential consistency: একটি multiprocessor-এর ফল এমন হবে যেন সব প্রসেসের অপারেশন কোনো একটি ক্রমে একটির পর একটি ঘটেছে, আর প্রতিটি প্রসেসের নিজের অপারেশনগুলো সেই ক্রমে প্রোগ্রামের ক্রমেই আছে। Peterson-এর প্রমাণ ঠিক এটিই ধরে নেয়। সমস্যা হলো, এভাবে চলা hardware ধীর। মেমরি CPU-র তুলনায় শত গুণ ধীর (পর্ব ৪), তাই CPU-নির্মাতারা store buffer, out-of-order execution, cache-এর নানা কৌশল যোগ করেছেন — যা একক থ্রেডের প্রোগ্রামের কাছে অদৃশ্য, কিন্তু অন্য core থেকে দেখলে ক্রম বদলে যায়।
প্রতিটি CPU-পরিবার তাই নিজের একটি memory model ঘোষণা করে। x86-এর আচরণ ২০১০ সালের দিকে Peter Sewell-দের দলের গবেষণায় x86-TSO (total store order) নামে আনুষ্ঠানিকভাবে বর্ণিত হয়: প্রায় সব ক্রম রক্ষা হয়, শুধু একটি store-এর পরের load আগে ঘটতে পারে — ঠিক যে ক্রম Peterson আর Dekker-এর দরকার। ARM আর POWER আরও দুর্বল: সেখানে দুটি store-এর ক্রম বা দুটি load-এর ক্রমও অন্য core-এর কাছে বদলে যেতে পারে। Programmer-এর হাতে তাই আছে fence বা barrier instruction (x86-এ mfence, ARM-এ dmb), যা CPU-কে বলে “এর আগের সব কাজ শেষ না করে পরের কাজ নয়”।
কিন্তু প্রতিটি প্রোগ্রামে CPU-ভেদে barrier লেখা অসম্ভব। তাই C11 আর C++11 (২০১১) ভাষার স্তরে একটি memory model দেয়: atomic ভ্যারিয়েবল আর memory_order (seq_cst, acquire, release, relaxed). আপনি বলেন কোন ক্রম দরকার, compiler প্রতিটি CPU-র জন্য ঠিক instruction বেছে নেয়। ডিফল্ট seq_cst সবচেয়ে নিরাপদ, কিন্তু সবচেয়ে দামি — x86-এ seq_cst store-এর জন্য পূর্ণ barrier লাগে, যা কয়েক ডজন cycle পর্যন্ত খরচ করতে পারে। অভিজ্ঞ প্রোগ্রামাররা প্রয়োজনমতো হালকা ক্রম ব্যবহার করেন, কিন্তু সেখানেই সূক্ষ্ম বাগ জন্মায়। আরও একটি সহজ উপায়: data race ছাড়া প্রোগ্রাম লিখুন, mutex ব্যবহার করুন — তাহলে ভাষা কথা দেয় প্রোগ্রাম sequentially consistent আচরণ করবে।
মূল উপলব্ধি: গতি আর সহজ যুক্তির মধ্যে একটি টানাপোড়েন আছে। Hardware গতি বেছে নিয়েছে; ভাষার memory model আর barrier হলো প্রোগ্রামারকে সহজ যুক্তি ফিরিয়ে দেওয়ার চুক্তি। পর্ব ২৩-এ এর পুরো ছবি দেখবেন।
অনুশীলন
হাতে ট্রেস, “দেখান যে ভাঙে”, আউটপুট অনুমান আর কোড লেখা — আগে নিজে চেষ্টা করুন, তারপর উত্তর মেলান।
-
হাতে ট্রেস: count = 10. Producer দুবার
count++করে (P1, P2, P3 = load, add, store), consumer একবারcount--করে (C1, C2, C3). ক্রমটি: P1, C1, P2, P3, P1, P2, C2, C3, P3. (ক) প্রতিটি ধাপের পর count আর দুই register কত? শেষ ফল কত, আর সঠিক ফল কত? (খ) এই তিনটি অপারেশনে সম্ভাব্য সব শেষ ফলের তালিকা দিন।(ক) r1 = producer-এর register, r2 = consumer-এর।
- P1: r1 = 10. (count 10)
- C1: r2 = 10. (10)
- P2: r1 = 11. (10)
- P3: count = 11.
- P1 (দ্বিতীয় ++): r1 = 11. (11)
- P2: r1 = 12. (11)
- C2: r2 = 9. (11)
- C3: count = 9.
- P3: count = 12.
শেষ ফল ১২, সঠিক ফল ১০ + ২ − ১ = ১১. Consumer-এর বিয়োগ হারিয়েছে, কারণ তার store (৯) producer-এর শেষ store (১২) মুছে দিয়েছে।
(খ) সম্ভাব্য ফল ৯, ১০, ১১, ১২. ১১ — কেউ মেশেনি। ১২ — consumer-এর বিয়োগ হারাল (উপরের মতো)। ১০ — producer-এর একটি যোগ হারাল (যেমন consumer 11 পড়ে, producer 12 লেখে, consumer 10 লেখে)। ৯ — consumer শুরুতেই 10 পড়ে আর producer-এর দুটি যোগ শেষ হওয়ার পর 9 লেখে, ফলে দুটি যোগই মুছে যায়। ১৩ বা ৮ অসম্ভব: মোট পরিবর্তন সর্বোচ্চ +২ আর সর্বনিম্ন −১.
-
দেখান যে ভাঙে: একজন শিক্ষার্থী Peterson-এর entry section-এর প্রথম দুই লাইনের ক্রম উল্টে দিলেন: আগে
turn = j;, তারপরflag[i] = true;, বাকি সব একই। দেখান যে এটি mutual exclusion ভাঙে — একটি নির্দিষ্ট interleaving টেবিল আকারে দিন (প্রাথমিক মান: দুটি flag false).সময় P0 P1 flag[0], flag[1], turn T0 turn = 1F, F, 1 T1 turn = 0F, F, 0 T2 flag[1] = trueF, T, 0 T3 শর্ত: flag[0]false → ঢোকেF, T, 0 T4 flag[0] = true(ভেতরে) T, T, 0 T5 শর্ত: flag[1] && turn == 1→ turn 0, তাই মিথ্যা → ঢোকে(ভেতরে) T, T, 0 দুজনই critical section-এ। কারণ: P1 যখন
flag[0]দেখল, P0 তখনো চাওয়া ঘোষণা করেনি, অথচ P0 turn আগেই লিখে ফেলেছিল — তাই P1-এর লেখা turn = 0 পরে P0-কেই সুবিধা দিল। সঠিক ক্রমে (আগে flag) প্রমাণের মূল ধাপ ছিল “যে শেষে turn লেখে, তার পড়ার সময় অন্যের flag ইতিমধ্যে true” — উল্টালে সেটিই ভাঙে। অংশ ৫-এর model checker-এ এই দুই লাইন উল্টে চালালে সেটিওmutual exclusion : VIOLATEDদেখায়। -
আউটপুট অনুমান: অংশ ৫-এর model checker-এ Peterson-এর
s.turn = jবদলেs.turn = iকরা হলো (অর্থাৎ “আমি আগে”), লুপের শর্ত আগের মতোইflag[j] && turn == j. Peterson-এর তিনটি সারিতে কী ছাপা হবে বলে আপনার ধারণা? কেন?বদলানো কোডটি চালিয়ে পাওয়া আসল ফল:
Peterson: 204 states mutual exclusion : VIOLATED e.g. {"pc":[9,9],"flag":[1,1],"turn":1,"halt":[0,0]} progress : holds bounded waiting : NO bound (3+ entries seen)কারণ: যে প্রসেস সবে
turn = iলিখেছে, তার শর্তেturn == jমিথ্যা — তাই সে প্রায় সবসময় সরাসরি ঢুকে যায়। উদাহরণ: P0 flag দেয়, turn = 0 লেখে, ঢোকে; তারপর P1 flag দেয়, turn = 1 লেখে, শর্তturn == 0মিথ্যা — সেও ঢোকে। “ভদ্রতা” উল্টে “স্বার্থপরতা” হলে টাই ভাঙার পুরো যুক্তিই ভেঙে যায়। আর অন্যজন বারবার নিজের turn লিখে ঢুকতে পারে বলে bounded waiting-ও নেই। -
কোড লিখুন: C11-এর
<stdatomic.h>দিয়ে n টি থ্রেডের জন্য bakery lock-এরbakery_lock(int i)আরbakery_unlock(int i)লিখুন (#define N 4). টাই ভাঙবে থ্রেডের নম্বর দিয়ে।#include <stdatomic.h> #define N 4 static atomic_int choosing[N], number[N]; /* seq_cst by default */ void bakery_lock(int i) { atomic_store(&choosing[i], 1); int max = 0; for (int k = 0; k < N; k++) { int v = atomic_load(&number[k]); if (v > max) max = v; } atomic_store(&number[i], max + 1); /* my ticket */ atomic_store(&choosing[i], 0); for (int j = 0; j < N; j++) { if (j == i) continue; while (atomic_load(&choosing[j])) ; /* j is still picking */ for (;;) { int nj = atomic_load(&number[j]), ni = atomic_load(&number[i]); if (nj == 0 || nj > ni || (nj == ni && j > i)) break; } /* wait while (nj, j) < (ni, i) */ } } void bakery_unlock(int i) { atomic_store(&number[i], 0); }যাচাই করার উপায়: পর্ব ১৫-এর মতো ৪টি থ্রেড একটি সাধারণ counter-এ lock ধরে লাখবার করে যোগ করুক, আর শেষে ফল ঠিক ৪ লাখ কি না দেখুন; core কম হলে অপেক্ষার লুপে
sched_yield()দিন। সব ভ্যারিয়েবল seq_cst atomic, তাই অংশ ৭-এর reordering সমস্যা নেই। লক্ষ করুন,number-এর মান সীমাহীন বাড়তে পারে — সব থ্রেড একসাথে কখনো বাইরে না থাকলে overflow-এর ঝুঁকি থাকে; শেখার কোডে সেটি উপেক্ষা করা হয়েছে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমPeterson-এর সমাধানে প্রসেস Pi-এর একটি চক্রের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
flag[i] = true— ঢোকার ইচ্ছা ঘোষণাturn = j— অন্যজনকে আগে যাওয়ার সুযোগflag[j] && turn == jযতক্ষণ সত্য, ততক্ষণ অপেক্ষা- Critical section-এ shared ডেটা ব্যবহার
flag[i] = false— বেরিয়ে যাওয়ার ঘোষণা- Remainder section-এ বাকি কাজ
একটি shared ভ্যারিয়েবল count = 5. একটি থ্রেড count++ আর আরেকটি থ্রেড count-- একসাথে, কোনো সমন্বয় ছাড়া চালায় (প্রতিটি = load, বদল, store). শেষে count-এর সম্ভাব্য সব মান কোনটি?
- শুধু 5
- 3, 4, 5, 6 বা 7
- 4, 5 বা 6
- শুধু 4 বা 6
দুই প্রসেসের একটি সমাধানে shared int turn; প্রসেস i ঢোকার আগে while (turn != i); করে আর বেরিয়ে turn = 1 - i দেয়। এটি critical-section-এর কোন শর্ত ভাঙে?
- Mutual exclusion
- Progress
- Bounded waiting
- কোনোটিই নয়
Peterson-এর সমাধানে P0 আর P1 দুজনই নিজের flag true করল। তারপর P0 লিখল turn = 1, এবং তার পরে P1 লিখল turn = 0. (প্রতিটি প্রসেস i অপেক্ষা করে flag[j] && turn == j সত্য থাকা পর্যন্ত।) কে আগে critical section-এ ঢুকবে?
- P0
- P1
- দুজনই একসাথে
- কেউ না — deadlock
একটি multicore মেশিনে kernel-এর কোনো shared ডেটা রক্ষা করতে শুধু interrupt বন্ধ করা (যেমন x86-এ cli) কেন যথেষ্ট নয়?
- kernel mode-এ cli চালানো যায় না
- interrupt বন্ধ করলে মেমরির ডেটা মুছে যায়
- শুধু এটি ধীর, তবে সঠিক
- এটি শুধু নিজের CPU-র interrupt বন্ধ করে; অন্য core তখনো একই ডেটা ছুঁতে পারে
Lamport-এর bakery algorithm-এ দুটি প্রসেস P2 আর P5 একই টোকেন নম্বর 7 পেল, আর আর কারও টোকেন 0. কে আগে ঢুকবে, আর কেন?
- যে আগে নম্বর লিখেছে, সে
- P2, কারণ (7, 2) < (7, 5)
- P5, কারণ তার আইডি বড়
- কেউ না, সমান নম্বরে deadlock
সাধারণ (non-atomic) ভ্যারিয়েবলে লেখা Peterson-এর সমাধান একটি x86-64 multicore মেশিনে মাঝে মাঝে দুই থ্রেডকে একসাথে critical section-এ ঢুকতে দেয়। এর মূল hardware-কারণ কী?
- x86-এ cache coherence নেই
- একটি int লেখা x86-এ অবিভাজ্য নয়
- Store buffer-এর কারণে আগের store দৃশ্যমান হওয়ার আগেই পরের load ঘটে যেতে পারে
- Scheduler থ্রেডগুলো ভুল ক্রমে চালায়
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- এক লাইন = কয়েকটি instruction:
count++load-বদল-store; মিশে গেলে ফল ক্রমের ওপর নির্ভর করে — race condition. একটি ++ আর একটি −− থেকে 4, 5 বা 6. - চার অংশ, তিন শর্ত: entry, critical, exit, remainder; mutual exclusion (safety), progress আর bounded waiting (liveness) — গতি বা CPU-সংখ্যার অনুমান ছাড়া।
- Interrupt বন্ধ শুধু এক CPU-র kernel-এ চলে। Strict alternation progress ভাঙে; দুই flag ক্রমভেদে mutual exclusion বা progress (deadlock) ভাঙে — প্রমাণে interleaving দেখান।
- Peterson = flag + turn: যে শেষে turn লেখে সে অপেক্ষা করে; তিন শর্তই প্রমাণিত, bounded waiting-এর সীমা ১. n প্রসেসে bakery: টোকেন + আইডি, FCFS.
- আধুনিক CPU: store buffer store→load ক্রম ভাঙে; seq_cst atomic বা পূর্ণ barrier ছাড়া Peterson ভুল। কাজের কোডে নিজের lock নয়, OS-এর mutex.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ২৩ · Hardware support — atomic instruction, spinlock আর memory orderingপরবর্তী পর্বTest-and-set, compare-and-swap, spinlock আর memory barrier.
- অপারেটিং সিস্টেম · কোর্স হোম৬৪টি পর্বপুরো কোর্সের রোডম্যাপ — মডিউল ৪-এ কনকারেন্সি আর সিনক্রোনাইজেশন।
- পর্ব ১৫ · POSIX thread হাতে-কলমেপেছনে ফিরেData race-এর আসল রান, mutex আর ThreadSanitizer.
- DSA · অ্যালগরিদমের সঠিকতার প্রমাণপ্রমাণের কৌশলInvariant আর বিপরীত ধরে নেওয়ার যুক্তি — একই কৌশল এখানে।
- DBMS · Concurrency controlএকই সমস্যা, অন্য জগৎTransaction-এর lost update আর lock — ডেটাবেসের ভাষায়।