এই পর্বে যা শিখবেন
- Concurrency আর parallelism-এর পার্থক্য, thread কখন লাভজনক আর কখন শুধু ঝামেলা বাড়ায়
std::threadআরstd::jthreadচালানো, join বনাম detach, argument পাঠানো আরstd::ref-এর ফাঁদ- Data race কেন হয়, ThreadSanitizer কী বলে, আর mutex ও তিন রকম lock দিয়ে কীভাবে ঠেকাবেন
- Deadlock-এর গল্প আর সমাধান,
condition_variableদিয়ে producer–consumer queue,stop_tokenদিয়ে বাতিল করা - নিরাপদ নকশার চারটি কৌশল, latch আর barrier, কতগুলো thread বানাবেন — আর AI-এর লেখা thread-কোডের ভুল ধরা
১ · কেন thread — এক রান্নাঘরে কয়েকজন রাঁধুনি
এ পর্যন্ত আপনার লেখা প্রতিটি প্রোগ্রাম একটি সরল রেখায় চলেছে: একটি লাইন শেষ, তারপর পরেরটি। আজকের কম্পিউটারে এটা অনেকটা এমন, যেন একটি বড় রান্নাঘরে চারটি চুলা আছে, অথচ রাঁধছেন একজনই। একটি প্রোগ্রামের ভেতরে স্বাধীনভাবে চলা প্রতিটি কাজের ধারাকে বলে thread. এক প্রোগ্রামে কয়েকটি thread মানে এক রান্নাঘরে কয়েকজন রাঁধুনি।
পুরান ঢাকার একটি বিরিয়ানির দোকান ভাবুন। ঈদের আগের দিন অর্ডারের পাহাড়। একজন বাবুর্চি একা সব করলে কাজ ধীর। চারজন বাবুর্চি হলে চারটি ডেকচি একসাথে চড়ে — কাজ প্রায় চার গুণ দ্রুত। কিন্তু রান্নাঘর একটাই। মসলার তাক, লবণের বয়াম, অর্ডারের খাতা — সবাই ভাগ করে ব্যবহার করেন। এবার কল্পনা করুন, দুজন বাবুর্চি একই ডেকচিতে একসাথে লবণ দিলেন, কেউ জানলেন না অন্যজন দিয়েছেন। বিরিয়ানি নষ্ট। হাত বাড়লে গতি বাড়ে, কিন্তু ভাগের জিনিসে সমন্বয় না থাকলে বিশৃঙ্খলা। এই পুরো পর্ব আসলে এই একটি বাক্যের ব্যাখ্যা।
Process আর thread
অপারেটিং সিস্টেমে চলমান প্রতিটি প্রোগ্রাম একটি process. প্রতিটি process-এর মেমরি আলাদা — যেন আলাদা আলাদা দোকান, যার রান্নাঘর অন্যজন ছুঁতে পারে না। একটি process-এর ভেতরের সব thread কিন্তু একই মেমরি ভাগ করে: global ভ্যারিয়েবল, heap-এর object, খোলা ফাইল — সব। শুধু প্রত্যেকের নিজের stack আলাদা, যেন প্রত্যেক বাবুর্চির নিজের কাটার বোর্ড। তাই thread-দের মধ্যে তথ্য আদান-প্রদান খুব সস্তা (একই মেমরি), আবার ঠিক সেই কারণেই বিপজ্জনক।
Concurrency আর parallelism — এক নয়
Concurrency — একসাথে সামলানো
একজন বাবুর্চি ভাত বসিয়ে, ফোটার ফাঁকে মাংস কাটছেন। দুটি কাজই “চলছে”, কিন্তু যেকোনো মুহূর্তে হাত একটিতেই। এক CPU core-এ অপারেটিং সিস্টেম thread-দের পালা করে চালায় — একে বলে concurrency.
dealing with manyParallelism — একসাথে করা
চারজন বাবুর্চি চারটি চুলায় একই মুহূর্তে রাঁধছেন। একাধিক core থাকলে thread-রা সত্যিই একই সময়ে চলে — একে বলে parallelism. গতি বাড়ে এখান থেকেই।
doing manyএক core-এ এক thread থেকে আরেক thread-এ যেতে অপারেটিং সিস্টেম আগের thread-এর অবস্থা সেভ করে, নতুনটির অবস্থা ফিরিয়ে আনে — একে বলে context switch. এটি দ্রুত, কিন্তু বিনা খরচে নয়। আপনার কোড এক core-এ চলছে নাকি আট core-এ, তা সাধারণত আপনি জানেন না, আর জানার দরকারও নেই — সঠিক প্রোগ্রাম দুই অবস্থাতেই সঠিক থাকবে।
কখন thread লাভজনক, আর কখন নয়
| thread লাগান | thread এড়ান |
|---|---|
| বড় হিসাব, যা স্বাধীন টুকরোয় ভাগ করা যায় — ছবি প্রসেসিং, লাখো ভোট গোনা, বড় ফাইল কম্প্রেস | কাজটা ছোট — একটি thread বানানো আর শেষ করার খরচ কাজটার চেয়েই বেশি |
| প্রোগ্রাম যেন সাড়া দিতে থাকে — ডাউনলোড চলার সময়ও অ্যাপের বাটন কাজ করবে | প্রতিটি ধাপ আগেরটির ফলের ওপর নির্ভর করে — ভাগ করার মতো স্বাধীন অংশই নেই |
| একসাথে অনেক অপেক্ষা — নেটওয়ার্ক বা ডিস্ক থেকে উত্তর আসার সময় অন্য কাজ | সবাইকে একই ডেটায় বারবার তালা লাগাতে হয় — তখন thread-রা লাইনে দাঁড়িয়ে থাকে, গতি বাড়ে না |
খরচ শুধু সময়ের নয়। প্রতিটি thread-এর নিজের stack-এর জন্য মেমরি সংরক্ষিত থাকে, আর সবচেয়ে বড় খরচ জটিলতা: thread-এর বাগ প্রতিবার চালালে দেখা দেয় না, আজ ঠিক চলে, কাল গ্রাহকের মেশিনে ভেঙে পড়ে। মেশিন একসাথে আনুমানিক কতগুলো thread সত্যিই চালাতে পারে, তা জানায় hardware_concurrency() — কিন্তু এটি শুধু ইঙ্গিত, মেশিনভেদে আলাদা, আর জানা না গেলে 0 ফেরত দিতে পারে।
সমস্যা তৈরি হয় তিনটি জিনিস একসাথে থাকলে: ডেটা ভাগ করা (একাধিক thread দেখে), বদলানো যায় (কেউ লেখে), আর একই সময়ে হাত পড়ে (মাঝে কোনো সমন্বয় নেই)। এই তিনটির যেকোনো একটি সরিয়ে দিলেই প্রোগ্রাম নিরাপদ। এই পর্বের প্রতিটি কৌশল আসলে এর একটিকে সরানোর উপায়।
২ · std::thread আর std::jthread — শুরু, join আর detach
C++11 থেকে standard library-তে আছে std::thread (<thread> header). একটি thread object তৈরি করা মানেই নতুন thread চলা শুরু — আলাদা কোনো “start” ডাকতে হয় না। কী চালাবে, তা constructor-এ দেন: একটি সাধারণ ফাংশন, একটি lambda (পর্ব ৩৪), বা যেকোনো ডাকা যায় এমন জিনিস।
#include <thread>
void cookRice() { /* ভাত রান্না */ }
int main() {
std::thread t1(cookRice); // এই মুহূর্তে নতুন thread চলা শুরু
std::thread t2([] { /* মাংস রান্না */ }); // lambda-ও চলে
t1.join(); // t1 শেষ হওয়া পর্যন্ত main অপেক্ষা করে
t2.join();
}এখন তিনটি thread একসাথে চলছে: main নিজে, t1 আর t2. কোনটি আগে শেষ হবে, কোনটির লাইন আগে চলবে — কোনো নিশ্চয়তা নেই। সেটা অপারেটিং সিস্টেমের সময়-বণ্টনের ওপর নির্ভর করে, আর প্রতিবার আলাদা হতে পারে।
join না detach — রাঁধুনিকে বিদায় দেবেন কীভাবে?
প্রতিটি std::thread-এর জীবনে আপনাকে একটি সিদ্ধান্ত নিতেই হবে। join() মানে “এই thread-এর কাজ শেষ হওয়া পর্যন্ত আমি অপেক্ষা করব” — দোকান বন্ধের আগে মালিক সব বাবুর্চির রান্না শেষ হওয়ার জন্য দাঁড়িয়ে থাকেন। join-এর পর thread-এর সব লেখা নিরাপদে পড়া যায়। detach() মানে “এই thread নিজের মতো চলুক, আমি আর খোঁজ নেব না।” detach করা thread-এর জন্য আর অপেক্ষা করা যায় না, আর main শেষ হলে process বন্ধ হয় — তখন সে কাজের মাঝপথেই থেমে যায়।
যদি দুটির কোনোটিই না করেন? যে std::thread এখনো একটি thread ধরে আছে, তাকে বলে joinable. joinable অবস্থায় তার destructor চললে C++ সোজা std::terminate ডাকে — প্রোগ্রাম সঙ্গে সঙ্গে বন্ধ। এটা ইচ্ছাকৃত কঠোরতা (কেন, তা ভাবনার প্রশ্ন ৩-এ)। সমস্যা হলো, মাঝে একটি exception উড়লে আপনার t.join() লাইনে পৌঁছানোই হয় না।
void serve() {
std::thread t(cookRice);
prepareTable(); // এখানে exception হলে...
t.join(); // ...এই লাইনে পৌঁছায় না → t joinable অবস্থায় ধ্বংস → terminate
}চেনা সমস্যা, তাই না? পর্ব ১৭-এ ঠিক এটাই দেখেছেন: “মনে রেখে ফেরত দেওয়া” মানুষ নির্ভরযোগ্যভাবে পারে না। সমাধানও একই — RAII. C++20 এনেছে std::jthread (“joining thread”): এর destructor নিজেই থামার অনুরোধ পাঠায় আর join করে। exception উড়ুক বা আগেভাগে return হোক, thread-এর জন্য অপেক্ষা নিশ্চিত।
void serve() {
std::jthread t(cookRice); // C++20
prepareTable(); // exception হলেও t-এর destructor join করবে
} // এখানে নিজে থেকেই join| বিষয় | std::thread (C++11) | std::jthread (C++20) |
|---|---|---|
| joinable অবস্থায় destructor | std::terminate — প্রোগ্রাম মরে | থামার অনুরোধ, তারপর join |
| থামতে বলার উপায় | নেই — নিজে flag বানাতে হয় | request_stop() আর std::stop_token |
| কপি / move | কপি নয়, শুধু move | কপি নয়, শুধু move |
| কখন ব্যবহার | পুরোনো কোড, C++20 না থাকলে | নতুন কোডে ডিফল্ট |
দুটিই কপি করা যায় না, কারণ একটি চলমান thread-এর মালিক একজনই হতে পারে — ঠিক পর্ব ১৭-এর unique_ptr-এর মতো। হাতবদল করতে std::move লাগে (পর্ব ৩৬)। তাই std::vector<std::jthread>-এ অনেকগুলো thread রাখা যায়, আর vector ধ্বংস হলে সবগুলো join হয়।
ধারণাগুলো C-এর মতোই: C-তে POSIX pthread_create/pthread_join (বা C11-এর thrd_create) দিয়ে thread চালান, pthread_mutex_t দিয়ে তালা দেন। পার্থক্য হলো, C-তে thread-এর ফাংশন একটি void* নেয়, তাই ডেটা পাঠাতে cast করতে হয়; C++-এ যেকোনো টাইপের argument সরাসরি যায়। আর C-তে unlock বা join ভুললে কেউ মনে করায় না; C++-এ jthread আর lock_guard RAII দিয়ে সেটা নিশ্চিত করে। race condition, deadlock আর condition variable-এর গভীর আলোচনা পাবেন C কোর্সের মাল্টিথ্রেডিং পর্বে।
৩ · Thread-এ argument পাঠানো — কপি, std::ref আর dangling-এর ফাঁদ
thread-এর ফাংশনে argument দিতে চাইলে সেগুলো constructor-এ ফাংশনের পরে লিখে দেন: std::jthread t(f, a, b);. এখানে একটি নিয়ম অনেককে চমকে দেয়: thread সব argument ডিফল্টভাবে কপি করে (বা move করে), নিজের কাছে রেখে দেয়। কারণ নতুন thread কখন চলবে আর কতক্ষণ চলবে, তা অজানা। তাই আসল ভ্যারিয়েবলের ওপর ভরসা না রেখে নিজের কপি রাখাই নিরাপদ ডিফল্ট।
void addBonus(int& salary, int taka) { salary += taka; }
int salary = 30000;
std::jthread t1(addBonus, salary, 500); // compile error: কপি int&-এ বাঁধে না
std::jthread t2(addBonus, std::ref(salary), 500); // ঠিক: আসল salary-র reference যায়ফাংশনটি int& চাইলেও thread একটি কপি বানিয়ে পাঠাতে চায়, তাই compiler আটকে দেয় — এটা ভালো খবর, ভুলটা চলার আগেই ধরা পড়ল। সত্যিই আসল ভ্যারিয়েবল পাঠাতে চাইলে std::ref দিয়ে মুড়ে দিন (const reference-এর জন্য std::cref). কিন্তু মনে রাখুন, std::ref লেখা মানে আপনি একটি প্রতিশ্রুতি দিলেন: thread শেষ হওয়া পর্যন্ত ভ্যারিয়েবলটি বেঁচে থাকবে, আর এর মধ্যে অন্য কেউ একে ছোঁবে না, অথবা ছুঁলে তালা দিয়ে।
সবচেয়ে বিপজ্জনক ফাঁদ — মরে যাওয়া ভ্যারিয়েবলের reference
lambda-তে [&] দিয়ে capture করলে (capture by reference) একই প্রতিশ্রুতি লুকিয়ে দেওয়া হয়। পর্ব ১৮-এ দেখেছেন, ফাংশন শেষ হলে তার local ভ্যারিয়েবল মরে যায়। thread যদি তখনো চলতে থাকে, তার হাতে থাকে একটি dangling reference:
void startReport() {
std::string file = "sales.csv";
std::thread t([&file] { process(file); }); // file-এর reference
t.detach(); // thread নিজের মতো চলল
} // file এখানেই ধ্বংস — অথচ thread হয়তো এখনো file পড়ছে → undefined behavior
// সমাধান: thread-কে নিজের কপি দিন
std::jthread t([file] { process(file); }); // file-এর কপি thread-এর ভেতরেএই বাগটা কপট: আপনার মেশিনে thread হয়তো খুব দ্রুত file পড়ে ফেলে, তাই সব ঠিক চলে। ভারী লোডের সার্ভারে thread একটু দেরিতে চালু হলো, আর সে পড়ল এমন মেমরি, যেখানে এখন অন্য কিছু আছে। detach এই বাগকে সবচেয়ে বেশি সাহায্য করে, কারণ detach করা thread কতক্ষণ বাঁচবে তা কেউ জানে না।
thread-এ যা পাঠাবেন, তা হয় কপি বা move করে দিন, নয়তো নিশ্চিত হোন যে সেটি thread-এর চেয়ে বেশি দিন বাঁচবে। jthread দুইয়ের মাঝে সেতু: সে scope শেষে join করে, তাই একই scope-এর local ভ্যারিয়েবলের reference তার জন্য নিরাপদ — যতক্ষণ jthread-টি ভ্যারিয়েবলগুলোর পরে declare করা হয় (তাহলে সে আগে ধ্বংস হয়, অর্থাৎ আগে join করে)।
৪ · Data race — একই হাঁড়িতে দুজনের হাত
এবার সেই লবণের গল্প কোডে। দুটি thread একটি ভাগ করা counter-কে এক লাখ বার করে বাড়ায়। মোট হওয়ার কথা দুই লাখ। নিচের কোডটি ভুল — এটি চালানোর জন্য নয়, দেখার জন্য:
int counter = 0; // ভাগ করা, বদলানো যায়
void add() { for (int i = 0; i < 100000; ++i) ++counter; } // কোনো তালা নেই!
int main() {
{ std::jthread a(add), b(add); } // দুজন একসাথে
std::println("{}", counter); // 200000? কোনো নিশ্চয়তা নেই
}বাস্তবে এটি কখনো 200000 দেখায়, কখনো 137452, কখনো অন্য কিছু — প্রতিবার আলাদা। কেন? কারণ ++counter দেখতে একটি কাজ, কিন্তু CPU-র কাছে তিনটি: মেমরি থেকে মান পড়া, এক যোগ করা, মেমরিতে ফেরত লেখা। একে বলে read-modify-write. দুই thread-এর এই তিন ধাপ মিশে গেলে কী হয়, ধাপে ধাপে দেখুন:
1T1: r1 = counter; // পড়া2T2: r2 = counter; // পড়া3T1: r1 = r1 + 1; // যোগ4T2: r2 = r2 + 1; // যোগ5T1: counter = r1; // লেখা6T2: counter = r2; // লেখা7// mutex থাকলে: T1-এর তিন ধাপ শেষ, তারপর T2
| counter (মেমরি) | |
| T1-এর r1 | |
| T2-এর r2 |
- শুরু। counter-এর মান 5. দুটি thread আলাদা core-এ একই মুহূর্তে
++counterচালাতে যাচ্ছে। প্রতিটি core হিসাব করে নিজের register-এ, মেমরিতে সরাসরি নয়। - T1 পড়ল। T1 মেমরি থেকে 5 এনে নিজের register r1-এ রাখল। এখনো কিছু বদলায়নি।
- T2-ও পড়ল — এখানেই বিপদের বীজ। T1 এখনো নতুন মান লেখেনি, তাই T2-ও দেখল 5. দুজনেই এখন একটি পুরোনো ছবি নিয়ে কাজ করছে।
- T1 যোগ করল। r1 এখন 6 — কিন্তু শুধু T1-এর register-এ, মেমরিতে নয়।
- T2-ও যোগ করল। 5 থেকে শুরু করেছিল, তাই r2-ও 6. দুজনেই ভাবছে “আমি 5 থেকে 6 করলাম” — কেউ অন্যজনের কাজ জানে না।
- T1 লিখল। মেমরিতে counter এখন 6. এ পর্যন্ত ঠিক।
- T2 লিখল — বৃদ্ধি হারাল। T2 তার 6 লিখে T1-এর লেখার ওপর চাপিয়ে দিল। দুবার
++হলো, counter বাড়ল একবার। এক লাখ বারে এমন ঘটনা হাজারবার ঘটতে পারে — তাই ফল দুই লাখের কম, আর প্রতিবার আলাদা। একে বলে lost update. - তালা থাকলে। mutex দিলে T1-এর তিনটি ধাপ শেষ না হওয়া পর্যন্ত T2 অপেক্ষা করে। T2 পড়ে 6, লেখে 7. তিন ধাপ এখন একটি অবিভাজ্য কাজের মতো আচরণ করে, যার মাঝখানে কেউ ঢুকতে পারে না।
শুধু ভুল সংখ্যা নয় — undefined behavior
C++ standard এই পরিস্থিতির একটি নাম দিয়েছে: data race — দুটি thread একই মেমরি-স্থানে একসাথে হাত দেয়, অন্তত একটি লেখে, আর মাঝে কোনো সমন্বয় (mutex, atomic) নেই। standard বলে, data race থাকা প্রোগ্রামের আচরণ undefined behavior (পর্ব ৪৩)। অর্থাৎ “সংখ্যাটা একটু কম আসবে” — এটুকুও নিশ্চিত নয়। compiler ধরে নেয় আপনার প্রোগ্রামে data race নেই, আর সেই ভরসায় কোড সাজায়। যেমন, এক thread while (!done) {} লিখে অপেক্ষা করলে compiler done একবার পড়ে register-এ রেখে দিতে পারে — অন্য thread done = true করলেও লুপ আর কখনো শেষ হয় না।
volatile এর সমাধান নয়। C আর পুরোনো কোডে অনেকে ভাগ করা flag-কে volatile করে দেন। C++-এ volatile হার্ডওয়্যার রেজিস্টারের মতো বিশেষ মেমরির জন্য; এটি thread-দের মধ্যে কোনো সমন্বয় দেয় না, ++-কে অবিভাজ্যও করে না। volatile ভ্যারিয়েবলেও data race undefined behavior.
ThreadSanitizer — race ধরার যন্ত্র
data race প্রায়ই পরীক্ষায় ধরা পড়ে না, কারণ বেশিরভাগ চালানোয় সময়ের হিসাব “ভাগ্যক্রমে” মিলে যায়। তাই চোখের ওপর ভরসা না করে যন্ত্র ব্যবহার করুন: ThreadSanitizer (পর্ব ৪৩-এ এর পরিচয় পেয়েছেন)। g++ -fsanitize=thread -g race.cpp দিয়ে বানিয়ে চালালে race হওয়ামাত্র এমন রিপোর্ট আসে (ঠিকানা আর সংখ্যা মেশিনভেদে আলাদা):
WARNING: ThreadSanitizer: data race (pid=4821)
Write of size 4 at 0x55d0c2a4c014 by thread T2:
#0 add() race.cpp:3
Previous write of size 4 at 0x55d0c2a4c014 by thread T1:
#0 add() race.cpp:3
Location is global 'counter' of size 4 at 0x55d0c2a4c014
SUMMARY: ThreadSanitizer: data race race.cpp:3 in add()
রিপোর্টটি ঠিক বলে দেয় কোন ভ্যারিয়েবল (counter), কোন লাইন, কোন দুই thread. TSan শুধু সেই race ধরে, যা ওই চালানোয় সত্যিই ঘটেছে, তাই পরীক্ষার ইনপুট বাস্তবসম্মত হওয়া চাই। আর একটি জিনিস মনে রাখুন, data race একটি বিশেষ ধরনের race condition. race condition আরও বড় ধারণা: ফল নির্ভর করে কে আগে চলল তার ওপর। প্রতিটি কাজ আলাদা তালায় সুরক্ষিত হলেও যুক্তিতে race থাকতে পারে — যেমন “টিকিট বাকি আছে কি না দেখা” আর “টিকিট কাটা” দুটি আলাদা তালায় করলে, মাঝের ফাঁকে অন্যজন শেষ টিকিটটা নিয়ে নিতে পারে।
৫ · std::mutex — এক সময়ে একজন
সমাধান হলো একটি তালা: mutex (mutual exclusion). কোডের যে অংশ ভাগ করা ডেটায় হাত দেয় — যাকে বলে critical section — তার আগে তালা লাগান, পরে খোলেন। একজন তালা ধরে থাকলে অন্যরা অপেক্ষা করে।
পাড়ার ATM বুথে একসাথে একজনই ঢোকেন — দরজা ভেতর থেকে বন্ধ। বাকিরা বাইরে লাইনে। ভদ্রতার নিয়ম হলো, ভেতরে যত কম সময় থাকা যায় — কার্ড আগেই বের করে রাখুন, PIN মনে রাখুন, বুথে ঢুকে ফোনে কথা বলবেন না। mutex-এর সাথে critical section ঠিক তেমনই: ভেতরে শুধু ভাগ করা ডেটার কাজ, বাকি সব বাইরে।
হাতে m.lock() আর m.unlock() ডাকা যায়, কিন্তু তাতে আবার সেই পুরোনো ফাঁদ: মাঝে exception বা return হলে তালা চিরকাল লাগানো থাকে, আর বাকি সব thread চিরকাল লাইনে। তাই তালাও RAII দিয়ে নিন। standard library-তে তিনটি lock আছে:
| lock | কী করে | কখন |
|---|---|---|
| std::lock_guard (C++11) | তৈরিতে lock, scope শেষে unlock. এর বেশি কিছু না | একটি mutex, সরল ক্ষেত্র |
| std::scoped_lock (C++17) | একসাথে এক বা একাধিক mutex নেয়, deadlock এড়ানোর নিয়ম মেনে | নতুন কোডে ডিফল্ট; দুই বা তার বেশি mutex হলে অবশ্যই |
| std::unique_lock (C++11) | মাঝপথে unlock()/lock() করা যায়, move করা যায়, পরে lock নেওয়া যায় | condition_variable-এর সাথে, বা তালা আগে ছাড়তে হলে |
একটি কাজের নিয়ম: mutex পাহারা দেয় ডেটা, কোড নয়। তাই mutex-কে তার ডেটার পাশেই রাখুন — একই class-এর সদস্য হিসেবে — আর কোন mutex কোন ভ্যারিয়েবল রক্ষা করে, তা মন্তব্যে লিখুন। ডেটা অনেক thread পড়ে কিন্তু কদাচিৎ লেখে (যেমন একটি দামের তালিকা), এমন ক্ষেত্রে C++17-এর std::shared_mutex অনেক পাঠককে একসাথে ঢুকতে দেয়, আর লেখককে একা।
বাস্তব উদাহরণ — কয়েকটি ভোটকেন্দ্রের ভোট একসাথে গোনা
একটি নির্বাচনী এলাকায় তিনটি ভোটকেন্দ্র। প্রতিটি কেন্দ্রের বুথগুলোর ভোট আলাদা thread গুনবে, তারপর এলাকার মোট হবে। এখানে দুটি কৌশল একসাথে দেখুন: প্রতিটি thread নিজের কেন্দ্রের ফল লেখে নিজের আলাদা ঘরে (কোনো ভাগাভাগি নেই, তাই তালাও লাগে না), আর এলাকার মোট — যা সবাই বদলায় — বাড়ানো হয় তালা দিয়ে। প্রিন্ট হয় শুধু সব thread শেষ হওয়ার পরে:
#include <mutex>
#include <numeric>
#include <print>
#include <thread>
#include <vector>
int main() {
std::vector<std::vector<int>> centers{{120, 85, 40}, {200, 15}, {75, 75, 75, 30}};
std::vector<long> part(centers.size()); // one slot per thread: no sharing
long total = 0; // shared by all: guarded by m
std::mutex m;
{
std::vector<std::jthread> workers;
for (std::size_t i = 0; i < centers.size(); ++i)
workers.emplace_back([&, i] {
part[i] = std::accumulate(centers[i].begin(), centers[i].end(), 0L);
std::scoped_lock lock(m); // short critical section
total += part[i];
});
} // every jthread joins here
for (std::size_t i = 0; i < part.size(); ++i) std::println("center {}: {}", i + 1, part[i]);
std::println("total: {}", total);
}আউটপুট সবসময় একই: center 1: 245, center 2: 215, center 3: 255, total: 715. thread-গুলো যে ক্রমেই চলুক, ফল বদলায় না — কারণ (১) প্রতিটি thread লেখে শুধু part[i], নিজের ঘর; vector-এর আলাদা উপাদান আলাদা মেমরি-স্থান, তাই data race নেই। (২) total-এ সবাই লেখে, তাই scoped_lock; তালার ভেতরে শুধু একটি যোগ, আসল ভারী কাজ (accumulate) তালার বাইরে। (৩) ভেতরের {} শেষে workers ধ্বংস হয়, প্রতিটি jthread join করে — তাই প্রিন্টের সময় সব হিসাব শেষ, আর join নিশ্চিত করে যে thread-দের লেখা main দেখতে পাবে। lambda-র [&, i] মানে বাকি সব reference-এ, কিন্তু i কপিতে — না হলে লুপ এগিয়ে গেলে সব thread একই বদলে-যাওয়া i দেখত।
খেয়াল করুন, আলাদা ঘরের কৌশলটিই আসল জয়। thread যত বেশি তালার জন্য অপেক্ষা করে, তত বেশি তারা আসলে একজন একজন করে চলে। তালা কম লাগলে ভালো; একেবারে না লাগলে সবচেয়ে ভালো। (আলাদা ঘর হলেও ঘরগুলো একই cache line-এ পড়লে core-রা লাইনটি নিয়ে টানাটানি করে — একে বলে false sharing; ফল ভুল হয় না, শুধু গতি কমে। পর্ব ৪৯-এ মাপবেন।)
৬ · Deadlock — দুজনেই অপেক্ষায়, কেউ এগোয় না
তালা সমস্যার সমাধান করে, কিন্তু নতুন একটি সমস্যা আনে। একটি মোবাইল ব্যাংকিং সার্ভারে টাকা পাঠানোর ফাংশন ভাবুন। দুটি অ্যাকাউন্টের ব্যালান্সই বদলাবে, তাই দুটিরই তালা লাগে। একই মুহূর্তে রহিম করিমকে ৫০০ টাকা পাঠালেন, আর করিম রহিমকে ৩০০. দুটি thread, দুটি তালা, উল্টো ক্রমে:
1void transfer(Account& from, Account& to, int taka) {2 std::lock_guard a(from.m); // প্রথম তালা3 std::lock_guard b(to.m); // দ্বিতীয় তালা4 from.balance -= taka; to.balance += taka;5}6// T1: transfer(rahim, karim, 500) T2: transfer(karim, rahim, 300)7std::scoped_lock both(from.m, to.m); // লাইন ২-৩-এর বদলে
| rahim.m | |
| karim.m | |
| T1 | |
| T2 |
- দুটি transfer একসাথে। T1 চালাচ্ছে
transfer(rahim, karim, 500), T2 চালাচ্ছেtransfer(karim, rahim, 300). একই ফাংশন, কিন্তু from আর to উল্টো। - T1 প্রথম তালা নিল। T1-এর from হলো rahim, তাই সে
rahim.mলক করল। এখন পর্যন্ত সব স্বাভাবিক। - T2 প্রথম তালা নিল। T2-এর from হলো karim, তাই সে
karim.mলক করল। দুজনেই একটি করে তালা ধরে আছে। - T1 দ্বিতীয়টি চায়। T1-এর এখন
karim.mলাগবে — কিন্তু সেটা T2-এর হাতে। T1 অপেক্ষা শুরু করল, নিজেরrahim.mছাড়ল না। - T2-ও দ্বিতীয়টি চায় — চক্র পূর্ণ। T2-এর লাগবে
rahim.m, যা T1-এর হাতে। দুজনেই অন্যজনের জন্য অপেক্ষা করছে, কেউ নিজেরটা ছাড়বে না। এটাই deadlock. প্রোগ্রাম crash করে না, error দেয় না — শুধু ঝুলে থাকে, CPU-ও খরচ হয় না। গ্রাহকের টাকা মাঝপথে আটকে। - সমাধান — একসাথে দুটি। লাইন ২-৩-এর বদলে
std::scoped_lock both(from.m, to.m);. scoped_lock দুটি তালা এমন কৌশলে নেয় (standard-এর deadlock-এড়ানো algorithm,std::lock-এর মতো), যাতে কেউ একটি ধরে অন্যটির জন্য বসে না থাকে — পুরো সেট না পেলে হাতেরটাও ছেড়ে দিয়ে আবার চেষ্টা করে। T1 দুটি পেল; T2 কিছু না ধরে অপেক্ষা করছে। - সবাই এগোয়। T1-এর কাজ শেষে scoped_lock-এর destructor দুটি তালাই খোলে। T2 এবার দুটিই পায়। argument-এর ক্রম যা-ই হোক, চক্র তৈরি হতে পারে না।
দার্শনিকদের খাবার টেবিল
এই সমস্যার সবচেয়ে বিখ্যাত রূপ dining philosophers. গোল টেবিলে পাঁচজন দার্শনিক, প্রতি দুজনের মাঝে একটি করে কাঁটাচামচ — মোট পাঁচটি। খেতে দুটি লাগে। সবাই একসাথে বাঁ পাশের কাঁটা তুলে নিলে, ডান পাশেরটা কারও হাতেই খালি থাকে না — সবাই ক্ষুধার্ত বসে থাকেন। Edsger Dijkstra ১৯৬৫ সালের দিকে এমন একটি সমস্যা ছাত্রদের পরীক্ষায় দিয়েছিলেন, আর পরে Tony Hoare একে দার্শনিকদের গল্পে রূপ দেন। ১৯৭১ সালে Coffman ও সহকর্মীরা দেখান, deadlock-এর জন্য চারটি শর্ত একসাথে লাগে: (১) জিনিসটি একজনই ধরতে পারে, (২) একটি ধরে অন্যটির অপেক্ষা, (৩) কেউ জোর করে কেড়ে নিতে পারে না, (৪) অপেক্ষার একটি চক্র। যেকোনো একটি ভাঙলেই deadlock অসম্ভব।
একসাথে নিন
একাধিক mutex দরকার হলে std::scoped_lock(a, b, ...). “একটি ধরে অন্যটির অপেক্ষা” শর্ত ভাঙে।
সবসময় একই ক্রম
নিয়ম বানান — যেমন ছোট অ্যাকাউন্ট নম্বরের তালা আগে। সবাই মানলে চক্র হয় না। একে বলে lock ordering.
break the cycleতালা ধরে অজানা কোড নয়
তালা ধরা অবস্থায় callback, অন্য module-এর ফাংশন বা নেটওয়ার্ক ডাকবেন না — সে কোন তালা নেবে, আপনি জানেন না।
no surprisesএক সময়ে এক তালা
নকশা এমন করুন যাতে বেশিরভাগ কাজে একটিই তালা লাগে। দুটি না ধরলে deadlock-এর প্রশ্নই নেই।
fewer locksএকটি সতর্কতা: একই thread একই std::mutex দুবার লক করলে (যেমন তালা ধরা ফাংশন থেকে আরেকটি ফাংশন ডাকল, যে একই তালা নেয়) তা-ও undefined behavior, বাস্তবে সাধারণত নিজের সাথেই deadlock. প্রোগ্রাম ঝুলে গেলে debugger দিয়ে সব thread-এর অবস্থা দেখুন — দুটি thread lock-এর ভেতরে আটকে থাকলেই বুঝবেন (পর্ব ৪৮-এ debugger).
৭ · condition_variable — অপেক্ষার সঠিক উপায়
রেস্টুরেন্টে ওয়েটার অর্ডার নিয়ে রান্নাঘরের অর্ডার-বোর্ডে লাগান, আর বাবুর্চিরা সেখান থেকে একটি করে নিয়ে রাঁধেন। এই নকশার নাম producer–consumer: কেউ কাজ বানায় (producer), কেউ করে (consumer), মাঝে একটি ভাগ করা std::queue. queue-তে push আর pop দুটিই mutex দিয়ে পাহারা দেবেন, সেটা এখন জানা। প্রশ্ন হলো: বোর্ড খালি থাকলে বাবুর্চি কী করবেন?
| উপায় | যা হয় |
|---|---|
| বারবার দেখা (busy-wait): তালা নেওয়া, খালি দেখে ছাড়া, আবার নেওয়া… | একটি core পুরো সময় শুধু দেখতে দেখতে গরম হয়, অর্থহীন বিদ্যুৎ খরচ; তালা নিয়ে টানাটানিতে ওয়েটারও ধীর হন |
দেখা, না পেলে sleep_for(100ms) | CPU বাঁচে, কিন্তু অর্ডার এলেও গড়ে ৫০ ms দেরি; sleep কম দিলে আবার busy-wait. আর sleep কখনো সমন্বয়ের নিশ্চয়তা দেয় না |
std::condition_variable | বাবুর্চি ঘুমান, CPU খরচ শূন্য; অর্ডার এলেই ওয়েটার ডেকে তোলেন — দেরি প্রায় নেই |
std::condition_variable (<condition_variable>) তিনটি জিনিস একসাথে চালায়: একটি mutex, একটি শর্ত (“queue খালি নয়”), আর একটি ঘণ্টা। consumer লেখে cv.wait(lock, শর্ত); producer ডেটা বদলে বাজায় notify_one() (একজনকে জাগাও) বা notify_all() (সবাইকে)। কেন wait-এর সাথে unique_lock লাগে? কারণ ঘুমানোর মুহূর্তে wait-কে তালা ছেড়ে দিতে হয় (নইলে producer কখনো push করতে পারত না), আর জেগে উঠে আবার নিতে হয়। lock_guard মাঝপথে ছাড়তে পারে না; unique_lock পারে। হাতবদলটা ধাপে ধাপে দেখুন:
1// বাবুর্চি (consumer)2std::unique_lock lock(m);3cv.wait(lock, [] { return !orders.empty(); });4int o = orders.front(); orders.pop();5// ওয়েটার (producer)6{ std::lock_guard g(m); orders.push(42); }7cv.notify_one();
| orders | |
| m-এর মালিক | |
| বাবুর্চি |
- তালা নিয়ে শুরু। বাবুর্চি
unique_lockদিয়ে m লক করলেন — queue দেখার আগে তালা লাগবেই, কারণ queue ভাগ করা। - শর্ত মিথ্যা — ঘুম। wait শর্তটি দেখল: queue খালি। তাই সে তালা ছেড়ে দিয়ে thread-কে ঘুম পাড়াল। “ছাড়া” আর “ঘুমানো” একটি অবিভাজ্য কাজ — মাঝখানে notify এসে হারিয়ে যেতে পারে না।
- Spurious wakeup. standard অনুমতি দেয়, কেউ notify না করলেও অপেক্ষারত thread হঠাৎ জেগে উঠতে পারে — একে বলে spurious wakeup. শর্তসহ wait এতে বিভ্রান্ত হয় না: তালা ফিরে নিয়ে শর্ত আবার দেখে, মিথ্যা পেয়ে আবার ঘুমায়। শর্ত না দিলে বাবুর্চি খালি queue থেকে pop করে বসতেন — undefined behavior.
- Producer কাজ আনল। ওয়েটার তালা নিয়ে অর্ডার 42 push করলেন, তারপর তালা ছাড়লেন। শর্ত বদলানো — অর্থাৎ queue বদলানো — অবশ্যই তালার ভেতরে।
- ঘণ্টা বাজল।
notify_one()একজন ঘুমন্ত বাবুর্চিকে জাগাল। তালার বাইরে notify করা বৈধ, আর এতে জাগা thread-কে তালার জন্য কম অপেক্ষা করতে হয়। - জেগে তালা, তারপর শর্ত। wait ফেরার আগে তালা আবার নেয়, তারপর শর্ত দেখে — এবার সত্য। এখন বাবুর্চি তালা ধরে আছেন, আর queue-তে অন্তত একটি অর্ডার নিশ্চিত।
- কাজ নেওয়া। pop করে অর্ডার 42 হাতে। বাস্তব কোডে এখানে
lock.unlock()করে তারপর রাঁধতে যাওয়া উচিত — রান্না ভাগ করা ডেটার কাজ নয়, তাই critical section-এর বাইরে।
শর্তের দ্বিতীয় কাজও আছে: notify যদি wait-এর আগেই বেজে যায় (ওয়েটার আগে এলেন, বাবুর্চি পরে), শর্ত-ছাড়া wait সেই ঘণ্টা আর কখনো শোনে না — চিরকাল ঘুম, একে বলে lost wakeup. শর্তসহ wait প্রথমেই দেখে queue খালি কি না, তাই ঘুমাতেই যায় না। নিয়ম তাই সরল: wait সবসময় শর্তসহ।
বাস্তব উদাহরণ — রেস্টুরেন্টের অর্ডার queue আর দুজন বাবুর্চি
একজন ওয়েটার পাঁচটি অর্ডার (দাম টাকায়) আনেন, দুজন বাবুর্চি নেন। রান্নাঘর বন্ধের সংকেত হলো দাম 0-এর একটি “বন্ধ” চিরকুট — প্রতিটি বাবুর্চির জন্য একটি। শেষে শুধু সারাংশ প্রিন্ট হয়, thread-রা নিজেরা কিছু প্রিন্ট করে না, তাই আউটপুট নির্দিষ্ট:
#include <condition_variable>
#include <mutex>
#include <print>
#include <queue>
#include <thread>
std::queue<int> orders; std::mutex m; std::condition_variable cv;
int cooked = 0, bill = 0; // guarded by m
void cook() {
while (true) {
std::unique_lock lock(m);
cv.wait(lock, [] { return !orders.empty(); }); // always with a predicate
int price = orders.front(); orders.pop();
if (price == 0) return; // 0 = "kitchen closed" slip
++cooked; bill += price;
}
}
int main() {
{ std::jthread a(cook), b(cook); // two cooks start waiting
for (int p : {120, 80, 250, 60, 90, 0, 0}) { // one closing slip per cook
{ std::lock_guard g(m); orders.push(p); }
cv.notify_one();
}
} // a and b join here
std::println("cooked {} orders, bill {} taka", cooked, bill);
}আউটপুট সবসময়: cooked 5 orders, bill 600 taka (120 + 80 + 250 + 60 + 90 = 600). কোন বাবুর্চি কয়টি অর্ডার রাঁধলেন, তা প্রতিবার আলাদা হতে পারে — তাই আমরা সেটা প্রিন্টই করিনি; মোট সংখ্যা আর মোট বিল কিন্তু নির্দিষ্ট। queue FIFO, তাই দুটি 0 আসে সব আসল অর্ডারের পরে, আর প্রতিটি বাবুর্চি একটি করে 0 নিয়ে ফিরে যান। একটি 0 কম দিলে কী হতো? একজন বাবুর্চি চিরকাল অপেক্ষা করতেন, আর jthread-এর destructor তাঁর join-এর জন্য চিরকাল আটকে থাকত — প্রোগ্রাম ঝুলে যেত। cooked আর bill বদলানো হয়েছে তালার ভেতরে, আর main এগুলো পড়েছে join-এর পরে, তাই কোনো data race নেই।
৮ · stop_token — ভদ্রভাবে থামতে বলা
একটি অ্যাপ বড় একটি ফাইল ডাউনলোড করছে, আর ব্যবহারকারী “Cancel” চাপলেন। thread-টিকে এখনই মেরে ফেলা যায় না কেন? কারণ সে হয়তো এই মুহূর্তে একটি mutex ধরে আছে (মরলে তালা চিরকাল লাগানো), বা ফাইলে অর্ধেক লিখেছে, বা মেমরি নিয়েছে যা ফেরত যাবে না। C++-এ তাই অন্য thread-কে জোর করে মারার কোনো standard উপায়ই নেই। পথ একটাই — cooperative cancellation: থামার অনুরোধ পাঠানো, আর thread নিজে সুবিধাজনক জায়গায় সেটা দেখে, পরিষ্কার করে, ফিরে যায়।
C++20-এর আগে এর জন্য নিজে একটি std::atomic<bool> flag বানাতে হতো। jthread এটা তৈরি অবস্থায় দেয়: thread-এর ফাংশনের প্রথম প্যারামিটার যদি std::stop_token হয়, jthread নিজে থেকে সেটি পাঠায়। বাইরে থেকে request_stop() ডাকলে ভেতরে stop_requested() সত্য হয়। আর jthread-এর destructor join-এর আগে নিজেই request_stop() ডাকে।
1std::jthread dl([](std::stop_token st) {2 for (int part = 1; part <= 10; ++part) {3 if (st.stop_requested()) { removePartial(); return; }4 download(part);5 }6});7// ব্যবহারকারী Cancel চাপলেন:8dl.request_stop();9// scope শেষ → dl-এর destructor: request_stop() + join()
| stop_requested() | |
| নামানো অংশ | |
| thread |
- Thread চালু। lambda-র প্রথম প্যারামিটার
std::stop_token, তাই jthread নিজে একটি token বানিয়ে পাঠাল। এখনো কেউ থামতে বলেনি। - কাজ চলছে। প্রতিটি অংশ নামানোর আগে thread দেখে নেয়, থামার অনুরোধ এসেছে কি না। দেখা খুব সস্তা, তাই প্রতিটি ধাপে দেখা যায়।
- Cancel — শুধু অনুরোধ। UI thread
request_stop()ডাকল। এতে ডাউনলোড thread হঠাৎ থামে না; শুধু token-এর অবস্থা সত্য হয়। সে এখন যে অংশ নামাচ্ছে, সেটা শেষ করবে — এটাই “cooperative”. - নিজে দেখে, নিজে পরিষ্কার করে। পরের বার পরীক্ষায় thread দেখল অনুরোধ এসেছে। অর্ধেক নামানো ফাইল মুছে সে ফিরে গেল। কোনো রিসোর্স মাঝপথে আটকে রইল না, কারণ থামার জায়গাটা thread নিজে বেছেছে।
- Destructor join করে। scope শেষে jthread-এর destructor join করল, আর thread তো আগেই ফিরেছে, তাই অপেক্ষা প্রায় শূন্য। কেউ Cancel না চাপলেও scope শেষে destructor নিজে অনুরোধ পাঠাত — তাই এমন লুপ কখনো “join-এ চিরকাল আটকে” থাকে না।
অনুরোধ আসামাত্র কিছু করতে চাইলে std::stop_callback দিয়ে একটি ফাংশন নিবন্ধন করা যায়। আর thread যদি condition_variable-এ ঘুমিয়ে থাকে? সাধারণ condition_variable stop_token চেনে না; C++20-এর std::condition_variable_any-এর wait-এ token দেওয়া যায়, তখন থামার অনুরোধেও ঘুম ভাঙে।
৯ · নিরাপদ নকশা — কম ভাগাভাগি, latch, barrier আর কতগুলো thread
অভিজ্ঞ প্রোগ্রামারদের আসল দক্ষতা তালা লাগানোয় নয় — তালার দরকারই না রাখায়। অংশ ১-এর তিন শর্ত মনে করুন: ভাগ করা, বদলানো যায়, একই সময়ে। ভালো নকশা শুরু থেকেই এর একটি সরিয়ে দেয়:
ভাগই করবেন না
প্রতিটি thread নিজের ডেটায় কাজ করে, শেষে ফল জোড়া লাগে — ভোট গোনার উদাহরণের মতো। সবচেয়ে দ্রুত আর সবচেয়ে নিরাপদ।
no sharingভাগ করুন, কিন্তু বদলাবেন না
thread শুরুর আগে কনফিগারেশন বা দামের তালিকা তৈরি করে const করে দিন। সবাই একসাথে শুধু পড়লে race নেই — immutable data.
ডেটা নয়, বার্তা পাঠান
রেস্টুরেন্টের queue-এর মতো: thread-রা একে অপরের ডেটা ছোঁয় না, শুধু queue দিয়ে কাজ হাতবদল করে। তালা থাকে একটি জায়গায়, queue-এর ভেতরে।
message passingThread pool
প্রতিটি ছোট কাজের জন্য নতুন thread নয়। আগে থেকে কয়েকটি worker বানিয়ে রাখুন, তারা একটি queue থেকে কাজ নেবে — যেমন গার্মেন্টসের লাইনে নির্দিষ্ট কয়েকজন অপারেটর একের পর এক বান্ডেল সেলাই করেন। একেই বলে thread pool.
reuse workersশেষ দুটি আসলে একই ধারণার দুই রূপ: thread pool মানে producer–consumer queue, যার consumer-রা স্থায়ী কর্মী। C++23 পর্যন্ত standard library-তে তৈরি thread pool নেই, তাই প্রজেক্টে সাধারণত একটি পরীক্ষিত লাইব্রেরি ব্যবহার করা হয়, বা পর্ব ৪৬-এর std::async-এর মতো উঁচু স্তরের যন্ত্র।
কতগুলো thread বানাবেন?
কাজ যদি CPU-নির্ভর হয় (হিসাব, ছবি প্রসেসিং), তাহলে core-এর সংখ্যার চেয়ে বেশি thread বানালে লাভ নেই — বাড়তি thread শুধু পালার জন্য অপেক্ষা করে আর context switch বাড়ায়। শুরুর অনুমান হিসেবে std::thread::hardware_concurrency() নিন, কিন্তু 0 ফেরত এলে একটি যুক্তিসংগত ডিফল্ট রাখুন। কাজ যদি অপেক্ষা-নির্ভর হয় (নেটওয়ার্ক, ডিস্ক), core-এর চেয়ে বেশি thread চলতে পারে, কারণ বেশিরভাগ সময় তারা ঘুমিয়ে থাকে। আর গতি-বৃদ্ধির একটি কঠিন সীমা আছে, যাকে বলে Amdahl's law: প্রোগ্রামের যে অংশ ক্রমিক (তালার ভেতরের অংশও!), সেটাই শেষ কথা বলে (ভাবনার প্রশ্ন ২)। সবশেষে মেপে দেখুন — অনুমানে নয় (পর্ব ৪৯)।
Latch আর barrier — সবাই পৌঁছানো পর্যন্ত অপেক্ষা (C++20)
কখনো শুধু এটুকু দরকার: “সবাই কাজ শেষ করলে তবেই এগোব।” এর জন্য condition_variable দিয়ে পুরো ব্যবস্থা না বানিয়ে C++20-এর দুটি সরল যন্ত্র ব্যবহার করুন। std::latch একবারের গণক: তিনটি কেন্দ্রের ফল আসা পর্যন্ত রিটার্নিং অফিসার অপেক্ষা করবেন, তারপর ঘোষণা।
#include <latch>
std::latch reported(3); // তিনটি কেন্দ্র
// প্রতিটি কেন্দ্রের thread, গোনা শেষে:
reported.count_down(); // গণক এক কমল
// রিটার্নিং অফিসার (main):
reported.wait(); // গণক 0 না হওয়া পর্যন্ত অপেক্ষা, তারপর ঘোষণাlatch শূন্যে পৌঁছালে আর ফিরিয়ে দেওয়া যায় না। বারবার ধাপে ধাপে মিলতে হলে std::barrier: ক্রিকেট দলের অনুশীলনে সবাই প্রথম ড্রিল শেষ না করা পর্যন্ত কেউ দ্বিতীয়টি শুরু করেন না, আর প্রতি ধাপের শেষে গণক নিজে থেকে আবার শুরু হয়। চাইলে প্রতি ধাপ শেষে একবার চলা একটি ফাংশনও দেওয়া যায় — যেমন সিমুলেশনের প্রতিটি ধাপের ফল একসাথে করা।
আর একটি ছোট কথা: শুধু একটি সংখ্যা বাড়ানোর জন্য পুরো mutex অনেক সময় বেশি ভারী — তার জন্য আছে std::atomic, যার ++ নিজেই অবিভাজ্য। এর পুরো গল্প, সাথে memory model, পরের পর্বে।
বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় এই প্রশ্নগুলো বারবার আসে:
- process আর thread-এর পার্থক্য? — process-এর মেমরি আলাদা; একই process-এর thread-রা মেমরি ভাগ করে, শুধু stack আলাদা।
- join আর detach? — join শেষ হওয়া পর্যন্ত অপেক্ষা করে; detach ছেড়ে দেয়, আর অপেক্ষা করা যায় না। joinable std::thread ধ্বংস হলে std::terminate.
- data race আর race condition-এর পার্থক্য? — data race: সমন্বয় ছাড়া একই মেমরিতে একসাথে, অন্তত একজন লেখে (C++-এ UB). race condition: ফল সময়ের ক্রমের ওপর নির্ভরশীল — বড় ধারণা।
- deadlock-এর চারটি শর্ত? — mutual exclusion, hold and wait, no preemption, circular wait (Coffman, ১৯৭১)।
- condition_variable-এর wait-এ predicate কেন? — spurious wakeup আর lost wakeup থেকে বাঁচতে; জেগে উঠে শর্ত আবার যাচাই হয়।
১০ · AI-এর যুগে — thread-কোডের ভুল ধরা
thread-এর বাগ AI-এর জন্য সবচেয়ে কঠিন জায়গাগুলোর একটি, কারণ ভুল কোডও প্রায়ই compile হয় আর দশবারের নয়বার ঠিক উত্তর দেয়। অনলাইনের অসংখ্য উদাহরণে sleep দিয়ে “সমন্বয়”, শর্ত-ছাড়া wait, বা পুরোনো std::thread আর detach — AI সেগুলোই শিখেছে। আপনার কাজ হলো কোড চালিয়ে নয়, পড়ে যাচাই করা:
| AI যা করে | সমস্যা | যা হওয়া উচিত |
|---|---|---|
std::thread বানিয়ে join ভুলে যায়, বা exception-এর পথে join বাদ পড়ে | joinable অবস্থায় ধ্বংস → std::terminate | std::jthread |
local ভ্যারিয়েবলের reference নিয়ে detach() | ফাংশন শেষে reference dangling — undefined behavior | কপি/move করে পাঠান; detach প্রায় কখনোই নয় |
| পুরো ফাংশন জুড়ে একটি তালা, বা তালার ভেতরে ফাইল/নেটওয়ার্ক | সবাই লাইনে দাঁড়ায় — thread থেকেও গতি নেই | critical section শুধু ভাগ করা ডেটার কাজ |
| কোনো একটি পড়া বা লেখায় তালা নেই (“শুধু পড়ছি তো”) | অন্য কেউ লিখলে পড়াটাও data race | একই ডেটার সব পড়া-লেখা একই mutex-এ |
sleep_for দিয়ে “অপেক্ষা করো যতক্ষণ না অন্যজন শেষ করে” | সময় কোনো নিশ্চয়তা নয়; ধীর মেশিনে ভাঙে | join, condition_variable বা latch |
cv.wait(lock) — শর্ত ছাড়া | spurious আর lost wakeup | cv.wait(lock, শর্ত) |
দুটি তালা দুটি lock_guard দিয়ে, বিভিন্ন জায়গায় বিভিন্ন ক্রমে | deadlock | std::scoped_lock(a, b) |
ভাগ করা flag-কে volatile | কোনো সমন্বয় নেই, data race থেকেই যায় | std::atomic<bool> বা stop_token |
AI-কে thread-কোড লিখতে বলার সময় নিয়মগুলো আগেই দিন, আর প্রতিটি ভাগ করা ভ্যারিয়েবলের পাহারাদার কে, তা লিখিয়ে নিন:
AI-এর thread-কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১C++11-এর আগেও মানুষ C++-এ thread চালাত (pthreads, Windows thread দিয়ে)। তাহলে C++11-এ ভাষার ভেতরে memory model আর std::thread আনার দরকার পড়ল কেন? Hans Boehm-এর “Threads cannot be implemented as a library” কী দেখিয়েছিল?
C++98 আর C++03 standard-এ “thread” শব্দটাই ছিল না। ভাষার নিয়ম লেখা হয়েছিল এমনভাবে, যেন প্রোগ্রামে কেবল একটি কাজের ধারা আছে। বাস্তবে সবাই thread চালাত POSIX pthreads বা Windows API দিয়ে — লাইব্রেরি হিসেবে। ধারণা ছিল, লাইব্রেরির mutex ফাংশনগুলো ঠিকমতো ডাকলেই চলবে, compiler-কে কিছু জানার দরকার নেই।
২০০৫ সালে PLDI সম্মেলনে Hans Boehm-এর গবেষণাপত্র “Threads Cannot Be Implemented as a Library” এই ধারণা ভেঙে দেয়। তাঁর যুক্তি: compiler যদি thread-এর অস্তিত্বই না জানে, তাহলে সে এমন অপ্টিমাইজেশন করতে পারে, যা single-thread প্রোগ্রামে সম্পূর্ণ বৈধ কিন্তু multi-thread প্রোগ্রামে নতুন race তৈরি করে — আর লাইব্রেরি তা ঠেকাতে পারে না। একটি উদাহরণ: পাশাপাশি দুটি ছোট ভ্যারিয়েবল (যেমন struct-এর দুটি bit-field বা char) বদলাতে compiler পুরো word পড়ে, বদলে, পুরোটা ফেরত লিখতে পারে। এক thread একটি অংশ লিখছে, আরেক thread অন্যটি — প্রোগ্রামার ভাবছেন তাঁরা আলাদা ভ্যারিয়েবল ছুঁচ্ছেন, অথচ একজনের লেখা অন্যজনের লেখা মুছে দিচ্ছে। আরেক উদাহরণ: compiler একটি শর্তসাপেক্ষ লেখাকে “আগে লিখে পরে দরকার হলে ফিরিয়ে দাও” রূপে বদলাতে পারে, ফলে তালার বাইরে একটি লেখা তৈরি হয়, যা প্রোগ্রামার লেখেননি।
সমাধান হিসেবে C++11 প্রথমবার একটি আনুষ্ঠানিক memory model দিল, যার কাজে Boehm নিজে নেতৃত্ব দেন (Sarita Adve-সহ অনেকের সাথে)। মূল ধারণাগুলো: “memory location” কী (পাশাপাশি দুটি আলাদা ভ্যারিয়েবল আলাদা location, তাই compiler আর অন্যটির ওপর লিখতে পারবে না), “happens-before” সম্পর্ক কী (mutex ছেড়ে দেওয়া আর পরে আবার নেওয়া, thread শুরু আর join — এগুলো ক্রম তৈরি করে), আর data race হলো undefined behavior. বিনিময়ে একটি সুন্দর নিশ্চয়তা: প্রোগ্রামে data race না থাকলে (আর শুধু ডিফল্ট atomic ব্যবহার করলে) প্রোগ্রাম এমনভাবে আচরণ করে, যেন সব thread-এর কাজ কোনো এক ক্রমে একটির পর একটি চলেছে। একে বলে “sequential consistency for data-race-free programs”. Java-ও প্রায় একই সময়ে (২০০৪ সালে) নিজের memory model নতুন করে লিখেছিল, আর দুই কাজের মধ্যে ধারণার আদান-প্রদান হয়েছিল।
std::thread, std::mutex, std::atomic এই ভিত্তির ওপর দাঁড়ানো। এরা শুধু pthreads-এর মোড়ক নয় — compiler এদের অর্থ জানে, আর তাদের চারপাশে অপ্টিমাইজেশন সেই নিয়ম মেনে করে।
মূল উপলব্ধি: concurrency একটি লাইব্রেরি-সমস্যা নয়, ভাষার সমস্যা। compiler, CPU আর প্রোগ্রামারের মধ্যে একটি স্পষ্ট চুক্তি না থাকলে “সঠিক” কোডও ভুল চলতে পারে। C++11-এর memory model সেই চুক্তি — আর data race-কে UB বলাটা তার কেন্দ্রীয় শর্ত।
প্র ০২আট core-এর মেশিনে আট thread দিলে কাজ আট গুণ দ্রুত হয় না কেন? Amdahl's law কী বলে, আর এর থেকে নকশার কী শিক্ষা পাই?
১৯৬৭ সালে IBM-এর কম্পিউটার স্থপতি Gene Amdahl একটি সম্মেলনে একটি সহজ যুক্তি দেন, যা পরে তাঁর নামে পরিচিত হয়। ধরুন একটি প্রোগ্রামের মোট সময়ের p অংশ একসাথে চালানো যায় (parallel), আর বাকি (1 − p) অংশ শুধু একজনই করতে পারে (serial). n টি core দিলে parallel অংশ n গুণ দ্রুত হয়, কিন্তু serial অংশ একটুও না। তাই মোট গতি-বৃদ্ধি = 1 / ((1 − p) + p / n).
সংখ্যায় দেখুন। p = 0.9 (৯০% কাজ ভাগ করা যায়) আর n = 8 হলে গতি-বৃদ্ধি 1 / (0.1 + 0.1125) ≈ 4.7 গুণ — আট গুণ নয়। n অসীম হলেও 1 / 0.1 = 10 গুণের বেশি কখনো নয়। মাত্র ১০% ক্রমিক অংশ হাজার core-কেও দশ গুণে বেঁধে দেয়। p = 0.5 হলে সর্বোচ্চ মাত্র দুই গুণ।
বাস্তবে আরও কিছু খরচ যোগ হয়, যা সূত্রে নেই: thread বানানো আর join-এর সময়, ফল জোড়া লাগানোর সময়, আর সবচেয়ে গুরুত্বপূর্ণ — তালার জন্য অপেক্ষা। critical section আসলে প্রোগ্রামের serial অংশ: এক সময়ে একজনই সেখানে থাকে। তাই একটি “বড় তালা” পুরো প্রোগ্রামকে কার্যত ক্রমিক বানিয়ে দেয়। এজন্যই এই পর্বে বারবার বলা হয়েছে: তালার ভেতরে শুধু ভাগ করা ডেটার কাজ। false sharing আর মেমরি-bandwidth-এর মতো হার্ডওয়্যার সীমাও আছে — আট core একই মেমরি থেকে ডেটা টানলে রাস্তা ভরে যায়, অনেকটা ঢাকার এক রাস্তায় বেশি গাড়ি নামানোর মতো।
অবশ্য গল্পের আরেক দিক আছে। ১৯৮৮ সালে John Gustafson দেখান, বাস্তবে core বাড়লে মানুষ সাধারণত একই কাজ দ্রুত করতে চায় না, বরং বড় কাজ করতে চায় — বেশি রেজোলিউশনের সিমুলেশন, বেশি ডেটা। তখন parallel অংশ সমস্যার আকারের সাথে বাড়ে, আর serial অংশের অনুপাত কমে যায়। একে বলে Gustafson's law. দুটি সূত্র পরস্পরবিরোধী নয় — এরা দুটি ভিন্ন প্রশ্নের উত্তর দেয়।
নকশার শিক্ষা: (১) thread যোগ করার আগে মাপুন কতটা অংশ সত্যিই ভাগ করা যায়। (২) ক্রমিক অংশ কমানোই আসল কাজ — ভাগাভাগি কমান, তালা ছোট করুন, প্রতিটি thread-কে নিজের ডেটা দিন। (৩) প্রত্যাশা বাস্তবসম্মত রাখুন — চার গুণ দ্রুত হওয়াও অনেক সময় দারুণ সাফল্য।
মূল উপলব্ধি: গতি-বৃদ্ধির সীমা ঠিক করে প্রোগ্রামের সেই অংশ, যা ভাগ করা যায় না। আর প্রতিটি তালা সেই অংশে নতুন করে যোগ হয়।
প্র ০৩std::thread-এর destructor join না করে সোজা std::terminate ডাকে কেন — এটা কি নকশার ভুল? C++20-এর std::jthread কীভাবে সমস্যাটা সমাধান করল?
C++11 নকশার সময় কমিটির সামনে প্রশ্ন ছিল: একটি std::thread object ধ্বংস হচ্ছে, অথচ তার thread এখনো joinable — তখন কী করা উচিত? তিনটি বিকল্প ছিল, আর প্রতিটির বিপদ ছিল।
প্রথম বিকল্প, নিজে থেকে join. শুনতে নিরাপদ, কিন্তু ভাবুন একটি exception উড়েছে, আর stack unwinding-এর সময় destructor join করছে। thread-টি হয়তো এমন একটি শর্তের জন্য অপেক্ষা করছে, যা পূরণ করার কথা ছিল ঠিক সেই কোডের, যা exception-এর কারণে আর চলবে না। ফল: প্রোগ্রাম নিঃশব্দে চিরকাল ঝুলে থাকে — crash-এর চেয়েও খারাপ, কারণ কিছুই বলে না। দ্বিতীয় বিকল্প, নিজে থেকে detach. পুরোনো Boost.Thread লাইব্রেরি এমনই করত। কিন্তু thread যদি সেই ফাংশনের local ভ্যারিয়েবলের reference ধরে থাকে (খুবই সাধারণ), তাহলে ফাংশন শেষে সেগুলো মরে যায়, আর detach করা thread মৃত মেমরি পড়তে থাকে — নিঃশব্দ undefined behavior.
দুটিই নিঃশব্দ বিপদ। তাই কমিটি তৃতীয় পথ নিল: std::terminate — জোরে, স্পষ্টভাবে ব্যর্থ হও, যাতে প্রোগ্রামার সিদ্ধান্তটা নিজে নিতে বাধ্য হন। যুক্তিটি বোঝা যায়, কিন্তু এর ফল হলো, std::thread কার্যত RAII-বিরোধী: exception-safe কোড লিখতে হলে প্রতিবার নিজে একটি guard class লিখতে হতো।
এখানেই Nicolai Josuttis-সহ কয়েকজনের প্রস্তাব (P0660) আসে, যা C++20-এ std::jthread হিসেবে গৃহীত হয়। মূল অন্তর্দৃষ্টি: join-এর আসল বিপদ ছিল “চিরকাল অপেক্ষা”, আর সেটা এড়ানো যায় যদি destructor join-এর আগে thread-কে থামতে বলতে পারে। তাই jthread একটি stop_source ধরে রাখে; destructor প্রথমে request_stop() ডাকে, তারপর join. যে thread stop_token নিয়মিত দেখে, সে দ্রুত ফিরে আসে, আর join আটকে থাকে না। একই সাথে stop_token, stop_source আর stop_callback একটি সাধারণ বাতিল-ব্যবস্থা হিসেবে standard-এ এল, যা condition_variable_any-র সাথেও কাজ করে।
তবু jthread জাদু নয়। thread যদি token কখনো না দেখে, বা এমন জায়গায় আটকে থাকে যেখানে token-এর খবর পৌঁছায় না (যেমন সাধারণ condition_variable-এ, বা নেটওয়ার্ক থেকে পড়ার মাঝখানে), তাহলে destructor-এর join তবুও অপেক্ষা করবে। জোরে ব্যর্থ হওয়ার বদলে এখন ঝুলে থাকার ঝুঁকি — তাই নকশায় নিশ্চিত করতে হয় যে প্রতিটি অপেক্ষা ভাঙানো যায়।
মূল উপলব্ধি: RAII-এর destructor “ভালো ডিফল্ট” দিতে পারে তখনই, যখন সেই ডিফল্টটা নিরাপদ। join-কে নিরাপদ করতে একটি বাতিল-ব্যবস্থা লাগত; সেটা আসার পরেই thread সত্যিকারের RAII হলো।
প্র ০৪Rust দাবি করে, নিরাপদ Rust কোডে data race compile-ই হয় না (“fearless concurrency”). Send আর Sync কী, আর C++ কেন একই নিশ্চয়তা দিতে পারে না?
Rust-এর টাইপ সিস্টেমে দুটি বিশেষ বৈশিষ্ট্য (trait) আছে, যা compiler নিজে থেকেই প্রতিটি টাইপের জন্য হিসাব করে। Send মানে: এই টাইপের একটি মানের মালিকানা অন্য thread-এ পাঠানো নিরাপদ। Sync মানে: এই টাইপের একটি মান একাধিক thread থেকে একসাথে শেয়ার্ড reference দিয়ে দেখা নিরাপদ। thread চালানোর ফাংশন (std::thread::spawn) শুধু Send টাইপ নেয়। যেমন Rc<T> (atomic নয় এমন reference count) Send নয়, তাই ভুল করে সেটা অন্য thread-এ পাঠালে compile error. একই কাজের জন্য আছে Arc<T>, যার গোনা atomic — ঠিক যেমন C++-এর shared_ptr.
দ্বিতীয় বড় পার্থক্য mutex-এ। Rust-এর Mutex<T> ডেটাকে নিজের ভেতরে রাখে। ডেটা ছোঁয়ার একমাত্র পথ lock(), যা একটি guard ফেরত দেয়; guard-এর মাধ্যমেই ডেটা পাওয়া যায়, আর guard মরলে তালা খোলে। ফলে “তালা না নিয়ে ডেটা ছোঁয়া” টাইপ-স্তরেই অসম্ভব। C++-এ std::mutex আর তার ডেটা দুটি আলাদা জিনিস — কোন mutex কোন ভ্যারিয়েবল রক্ষা করে, তা থাকে শুধু প্রোগ্রামারের মাথায় বা মন্তব্যে। কেউ একটি জায়গায় তালা নিতে ভুলে গেলে compiler কিছু বলে না। borrow checker-ও যোগ দেয়: একটি মানের হয় অনেকগুলো শুধু-পড়ার reference থাকে, নয়তো একটিমাত্র বদলানোর reference — এই নিয়মটাই data race-এর সংজ্ঞার উল্টো।
C++ কেন পারে না? মূল কারণ পেছনের সামঞ্জস্য। কয়েক দশকের কোটি কোটি লাইন C++ কোড, C লাইব্রেরি, raw pointer — এদের ওপর নতুন কঠোর নিয়ম চাপালে প্রায় সব কোড ভাঙবে। C++ তাই অন্য পথে চেষ্টা করে: ThreadSanitizer (চলার সময় ধরা), Clang-এর Thread Safety Analysis (-Wthread-safety, যেখানে ভ্যারিয়েবলে GUARDED_BY(m)-এর মতো চিহ্ন দিলে compiler তালা ছাড়া ছোঁয়া ধরে), আর নকশার রীতি — যেমন ডেটা আর mutex-কে একই class-এ লুকিয়ে শুধু তালা-ধরা ফাংশন দিয়ে ব্যবহার করতে দেওয়া। আপনি চাইলে C++-এও Rust-এর মতো একটি Synchronized<T> মোড়ক বানাতে পারেন — কিছু বড় কোম্পানির লাইব্রেরিতে এমন আছে।
তবে Rust-ও সব সমস্যা দূর করে না: deadlock, ভুল ক্রমের যুক্তি (race condition) বা অগ্রগতি না হওয়া — এগুলো Rust-এও সম্ভব। Rust শুধু data race-কে, অর্থাৎ undefined behavior-এর এই উৎসটিকে, নিরাপদ কোড থেকে নির্মূল করেছে।
মূল উপলব্ধি: “কোন তালা কোন ডেটা রক্ষা করে” — এই তথ্য Rust টাইপের মধ্যে লিখে রাখে, C++ রাখে প্রোগ্রামারের শৃঙ্খলায়। C++-এ নিরাপদ থাকার উপায় তাই সেই শৃঙ্খলাকে নকশায় বেঁধে ফেলা: ডেটা আর তালা একসাথে, আর যন্ত্র দিয়ে নিয়মিত যাচাই।
প্র ০৫Herb Sutter ২০০৫ সালে লিখেছিলেন “The Free Lunch Is Over”. কোন “বিনামূল্যের খাবার” শেষ হয়েছিল, কেন, আর এর প্রভাব আজকের সফটওয়্যারে কোথায় দেখা যায়?
১৯৮০ আর ১৯৯০-এর দশক জুড়ে প্রোগ্রামাররা একটি আরামের জীবন কাটিয়েছেন। প্রতি বছর-দুবছরে নতুন CPU আসত আগের চেয়ে অনেক বেশি clock speed নিয়ে — কয়েক MHz থেকে কয়েকশো MHz, তারপর GHz-এর ঘরে। আপনার পুরোনো প্রোগ্রাম কোনো পরিবর্তন ছাড়াই নতুন মেশিনে দ্রুত চলত। ধীর সফটওয়্যারের সমাধান ছিল “পরের বছরের কম্পিউটারের জন্য অপেক্ষা করো।” এটাই সেই free lunch.
২০০০-এর দশকের শুরুতে এই ধারা থেমে গেল। clock speed প্রায় ৩–৪ GHz-এর কাছে এসে আটকে যায়। কারণ মূলত পদার্থবিদ্যা: clock বাড়ালে বিদ্যুৎ খরচ আর তাপ খুব দ্রুত বাড়ে, আর একটা সময় পর চিপ ঠান্ডা রাখাই অসম্ভব হয়ে পড়ে (একে বলা হয় power wall). Intel তখন তাদের পরিকল্পিত আরও উচ্চ-গতির একটি Pentium 4 উত্তরসূরি বাতিল করে বহু-core নকশার দিকে মোড় নেয়। তবে Moore-এর নিয়ম — চিপে transistor-এর সংখ্যা বাড়তে থাকা — তখনো চলছিল। সেই বাড়তি transistor দিয়ে নির্মাতারা একটি দ্রুততর core নয়, একাধিক core বানাতে শুরু করলেন। ২০০৫ সালের দিকে সাধারণ ডেস্কটপে dual-core চিপ আসে।
ঠিক এই সময়ে, ২০০৫ সালের মার্চে Dr. Dobb's Journal-এ Herb Sutter-এর প্রবন্ধ “The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software” প্রকাশিত হয়। তাঁর বক্তব্য: এখন থেকে নতুন হার্ডওয়্যার আপনার একটি-thread প্রোগ্রামকে খুব একটা দ্রুত করবে না। বাড়তি শক্তি আছে অন্য core-গুলোতে, আর তা কাজে লাগাতে হলে সফটওয়্যারকেই concurrent হতে হবে। concurrency আর বিশেষজ্ঞদের বিষয় থাকবে না, সাধারণ প্রোগ্রামারদেরও শিখতে হবে।
ভবিষ্যদ্বাণী মিলে গেছে। আজ বাজেট স্মার্টফোনেও সাধারণত আট core, আর ল্যাপটপে আরও বেশি। ভাষাগুলো সাড়া দিয়েছে: C++11-এর memory model আর std::thread, পরে parallel algorithm (C++17), jthread, latch, barrier (C++20); Java-র concurrency লাইব্রেরি, Go-র goroutine, Rust-এর Send/Sync. GPU আর SIMD-এর মতো আরও বিশাল parallelism-ও মূলধারায় এসেছে — আজকের AI মডেল প্রশিক্ষণ পুরোটাই হাজার হাজার core-এর একসাথে কাজের ওপর দাঁড়ানো। অবশ্য প্রতি core-এর গতিও এরপর থেমে থাকেনি — cache, নির্দেশ চালানোর কৌশল আর নকশার উন্নতিতে ধীরে বেড়েছে — কিন্তু আগের সেই দ্রুত, “বিনামূল্যের” বৃদ্ধি আর ফেরেনি।
মূল উপলব্ধি: হার্ডওয়্যার যখন “দ্রুত” থেকে “চওড়া”-র দিকে গেল, তখন গতির দায়িত্ব সরে এল সফটওয়্যার নকশায়। thread, তালা আর ভাগাভাগি-কমানোর কৌশল তাই আজ প্রত্যেক গুরুতর প্রোগ্রামারের মৌলিক দক্ষতা।
অনুশীলন
বেশিরভাগ কাজে কোড লিখতে হবে না — আগে অনুমান করুন, তারপর উত্তর মিলিয়ে নিন।
-
আউটপুট অনুমান: দুটি jthread প্রত্যেকে ১০০০ বার
{ std::lock_guard g(m); ++counter; }চালায়, শুরুতে counter = 0, আর দুটি join হওয়ার পর main counter প্রিন্ট করে। কী আসবে? আর lock_guard লাইনটি সরিয়ে দিলে?তালাসহ সবসময়
2000— প্রতিটি read-modify-write অবিভাজ্য, কোনো বৃদ্ধি হারায় না। তালা সরালে data race, অর্থাৎ undefined behavior. বাস্তবে প্রায়ই 2000-এর কম কোনো সংখ্যা আসে, কখনো ঠিক 2000-ও আসতে পারে (thread ছোট বলে হয়তো একটি শেষ হওয়ার আগে অন্যটি শুরুই হয়নি)। “ঠিক এসেছে” মানে “ঠিক আছে” নয় — ThreadSanitizer দিয়ে চালালে race রিপোর্ট আসবেই। -
Deadlock খুঁজুন: একটি লাইব্রেরি-ব্যবস্থাপনা প্রোগ্রামে
issueBook()আগেbooksMutex, তারপরmembersMutexনেয়;removeMember()আগেmembersMutex, তারপরbooksMutexনেয়। দুটি থাকলে কখন প্রোগ্রাম ঝুলে যায়, আর দুটি সমাধান বলুন।দুটি ফাংশন দুই thread-এ একই সময়ে চললে: প্রথমটি booksMutex ধরে membersMutex চায়, দ্বিতীয়টি membersMutex ধরে booksMutex চায় — চক্রাকার অপেক্ষা, deadlock. বেশিরভাগ সময় সমস্যা হয় না, কারণ দুটি ঠিক একই মুহূর্তে চলে কদাচিৎ — তাই বাগটি পরীক্ষায় ধরা পড়ে না, ভিড়ের দিনে পড়ে। সমাধান (১): দুই জায়গাতেই
std::scoped_lock lk(booksMutex, membersMutex);— ক্রম যা-ই লিখুন, scoped_lock নিরাপদে নেয়। সমাধান (২): একটি নিয়ম ঠিক করুন, যেমন “সবসময় আগে books, তারপর members”, আর পুরো কোডে মেনে চলুন। -
বাগ শিকার: একটি AI এই কোড দিয়েছে —
void start() { int total = 0; std::thread t([&] { total = sumAll(); }); t.detach(); std::this_thread::sleep_for(100ms); std::println("{}", total); }. কয়টি সমস্যা খুঁজে পান?তিনটি বড় সমস্যা। (১)
[&]দিয়ে localtotal-এর reference, তারপর detach — sumAll ১০০ ms-এর বেশি নিলে start() শেষ হয়ে যায়, total মরে যায়, আর thread মৃত মেমরিতে লেখে: undefined behavior. (২)sleep_forদিয়ে “সমন্বয়” — সময় কোনো নিশ্চয়তা দেয় না; ধীর মেশিনে total তখনো 0, বা আধা-লেখা। (৩) main thread total পড়ছে আর অন্য thread লিখছে, মাঝে join বা তালা নেই — data race. সমাধান সবচেয়ে সরল:std::jthread t([&] { total = sumAll(); });তারপরt.join();আর তারপর প্রিন্ট — অথবা পর্ব ৪৬-এরstd::asyncদিয়ে সরাসরি ফল ফেরত নেওয়া। -
নিজে লিখুন (কোড): উপরের
kitchen_queue.cpp-এ একজন তৃতীয় বাবুর্চি যোগ করুন। কোন কোন লাইন বদলাতে হবে, আউটপুট কী হবে — আর একটি জিনিস বদলাতে ভুলে গেলে কী ঘটবে?দুটি পরিবর্তন:
std::jthread a(cook), b(cook), c(cook);আর তালিকার শেষে আরও একটি 0 —{120, 80, 250, 60, 90, 0, 0, 0}. আউটপুট একই থাকবে:cooked 5 orders, bill 600 taka, কারণ মোট হিসাব বাবুর্চির সংখ্যার ওপর নির্ভর করে না। তৃতীয় 0 দিতে ভুলে গেলে একজন বাবুর্চি খালি queue-তে চিরকালwait-এ ঘুমিয়ে থাকবেন, আর scope শেষে jthread-এর destructor তাঁর join-এর জন্য চিরকাল অপেক্ষা করবে — প্রোগ্রাম কোনো error ছাড়াই ঝুলে যাবে। শিক্ষা: প্রতিটি অপেক্ষারত thread-এর জন্য “শেষ” হওয়ার একটি নিশ্চিত পথ রাখুন (আরও শক্ত নকশা: একটিclosedflag আরnotify_all(), যাতে শর্ত হয় “queue খালি নয় অথবা বন্ধ”)।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি consumer thread খালি queue-তে cv.wait(lock, শর্ত) দিয়ে অপেক্ষা করে, পরে একটি producer একটি কাজ যোগ করে। ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- consumer
unique_lockদিয়ে mutex লক করে - wait শর্ত দেখে মিথ্যা পায়, তালা ছেড়ে ঘুমায়
- producer তালা নিয়ে queue-তে কাজ push করে, তালা ছাড়ে
- producer
notify_one()ডাকে - consumer জাগে, তালা ফিরে নেয়, শর্ত সত্য পায়
- consumer queue থেকে কাজটি pop করে
C++-এ একটি std::thread object-এর destructor চলল, অথচ তার thread-কে join বা detach কোনোটিই করা হয়নি। কী ঘটে?
- destructor নিজে থেকে join করে
- std::terminate ডাকা হয়, প্রোগ্রাম বন্ধ হয়
- destructor নিজে থেকে detach করে
- compile error হয়
C++20-এর std::jthread-এর destructor কী করে, যদি thread তখনো চলতে থাকে?
- std::terminate ডাকে
- thread-কে detach করে দেয়
- thread-কে সঙ্গে সঙ্গে জোর করে থামায়
- থামার অনুরোধ পাঠায়, তারপর join করে
C++-এ void f(int& x); ফাংশনটিকে একটি thread-এ চালিয়ে main-এর ভ্যারিয়েবল n বদলাতে চান। কোনটি ঠিক?
- std::jthread t(f, std::ref(n));
- std::jthread t(f, n);
- std::jthread t(f, &n);
- std::jthread t(f, std::move(n));
C++-এ condition_variable::wait-এ শর্ত (predicate) দেওয়ার মূল কারণ কী?
- শর্ত দিলে wait দ্রুত চলে
- শর্ত না দিলে wait তালা ছাড়ে না
- spurious wakeup আর আগেভাগে বেজে যাওয়া notify থেকে বাঁচতে
- শর্ত ছাড়া unique_lock ব্যবহার করা যায় না
একটি C++ প্রোগ্রামে thread A আগে m1 তারপর m2 লক করে, আর thread B আগে m2 তারপর m1. দুটি একসাথে চললে কী ঘটতে পারে, আর সবচেয়ে সরল সমাধান কী?
- কিছুই না — mutex নিজে deadlock ঠেকায়
- deadlock; দুই জায়গায় std::scoped_lock(m1, m2)
- data race; volatile দিয়ে সমাধান
- deadlock; lock_guard-কে unique_lock দিয়ে বদলালেই সমাধান
C++-এ নিচের কোনটি data race?
- চারটি thread একসাথে একটি const vector পড়ছে
- প্রতিটি thread vector-এর নিজের আলাদা উপাদানে লিখছে
- একটি thread একটি int লিখছে, আরেকটি তালা ছাড়া একই int পড়ছে
- দুটি thread একই mutex ধরে একই map বদলাচ্ছে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- সমস্যার মূল ভাগ করা, বদলানো যায় এমন ডেটা। ভাগ না করা, শুধু পড়া, বা বার্তা পাঠানো — যেকোনো একটি দিয়ে তিন শর্তের একটি ভাঙলেই নিরাপদ।
- নতুন কোডে
std::jthread: destructor-এ থামার অনুরোধ আর join. argument কপি হয়; reference দিলেstd::ref, আর নিশ্চিত হোন সেটি thread-এর চেয়ে বেশি বাঁচে। detach প্রায় কখনোই নয়। - Data race = undefined behavior. ভাগ করা ডেটার সব পড়া-লেখা একই mutex-এ, RAII lock দিয়ে; critical section ছোট। সন্দেহ হলে
-fsanitize=thread. - একাধিক তালা লাগলে
std::scoped_lock(a, b), নয়তো সবসময় একই ক্রম — উল্টো ক্রম মানে deadlock. - অপেক্ষা
condition_variableদিয়ে, সবসময় শর্তসহ; sleep দিয়ে সমন্বয় নয়। থামাতেstop_token, সবার জন্য অপেক্ষায় latch/barrier.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৬ · async, future আর atomicsপরবর্তী পর্বthread-এর ওপরে উঁচু স্তরের যন্ত্র, আর তালা ছাড়া নিরাপদ counter.
- C++ প্রোগ্রামিং · কোর্স হোম৫৫টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- C প্রোগ্রামিং · মাল্টিথ্রেডিংগভীরে যানpthread দিয়ে একই ধারণা, আরও কাছ থেকে — race, mutex, deadlock আর condition variable.