সরাসরি পর্বে যান
পর্ব ৪৯ · ৫৬-এর মধ্যে · মডিউল ৭

মাল্টিথ্রেডিং — একসাথে অনেক কাজ, কিন্তু নিয়ম মেনে

Multithreading in C — Threads, Race Conditions, Mutexes, Deadlock & Condition Variables

Taught in Bangla, this lesson covers multithreading in C with pthreads: concurrency versus parallelism, creating and joining threads, race conditions, mutexes and atomics, deadlock, condition variables, thread pools, and Amdahl's law.

এই পর্বে যা শিখবেন

  • Concurrency আর parallelism-এর পার্থক্য, আর thread ও process কোথায় আলাদা
  • pthread_create ও pthread_join দিয়ে thread চালানো, আর প্রতিটি thread-কে নিরাপদে তথ্য পাঠানো
  • Race condition কেন হয়, আর mutex ও atomic দিয়ে কীভাবে থামাবেন
  • Deadlock-এর চারটি শর্ত, lock-এর ক্রম মেনে সমাধান, আর producer-consumer নকশা
  • বেশি thread কেন সবসময় বেশি গতি দেয় না (Amdahl's law), আর AI-এর লেখা thread কোড যাচাইয়ের পদ্ধতি

১ · একসাথে অনেক কাজ — concurrency আর parallelism

একটি চায়ের দোকানের কথা ভাবুন। সকালবেলা ভিড়। দোকানি একাই চা বানাচ্ছেন, ফাঁকে ফাঁকে পরোটা উল্টাচ্ছেন, আবার টাকাও নিচ্ছেন। কোনো এক মুহূর্তে তিনি একটি কাজই করছেন। কিন্তু দ্রুত কাজ বদলান বলে মনে হয় সব একসাথে চলছে। এটাই concurrency — অনেক কাজ একসাথে সামলানো।

এবার দুপুরে দোকানে তিনজন কর্মী এলেন। একজন শুধু চা বানান, একজন পরোটা ভাজেন, একজন টাকা নেন। এখন তিনটি কাজ সত্যিই একই মুহূর্তে হচ্ছে। এটাই parallelism — অনেক কাজ একসাথে করা।

🍵 Concurrency

একজন রাঁধুনি, অনেকগুলো চুলা। কাজের মধ্যে দ্রুত ঘোরাঘুরি। একটি CPU core-ও এভাবে কয়েক মিলিসেকেন্ড পরপর এক কাজ থেকে আরেক কাজে যায়।

গঠনের প্রশ্ন: কাজগুলো কীভাবে সাজাবেন

👩‍🍳 Parallelism

অনেক রাঁধুনি, প্রত্যেকে নিজের চুলায়। কয়েকটি core থাকলে কয়েকটি কাজ আক্ষরিক অর্থেই একই সময়ে চলে।

হার্ডওয়্যারের প্রশ্ন: কয়টি core আছে

আজকের প্রায় সব ফোন আর ল্যাপটপের CPU multi-core — একটি চিপের ভেতরে ৪, ৮ বা আরও বেশি আলাদা core. কিন্তু সাধারণ একটি C প্রোগ্রাম শুধু একটি core ব্যবহার করে, বাকিগুলো বসে থাকে। সব core কাজে লাগাতে হলে প্রোগ্রামকে কয়েকটি স্বাধীন ধারায় ভাগ করতে হয়। প্রতিটি ধারার নাম thread, আর এই কৌশলের নাম multithreading.

মূল ধারণা

Concurrency হলো প্রোগ্রামের নকশা — কাজগুলোকে আলাদা ভাগে লেখা, যাতে একটি আটকে থাকলে অন্যটি এগোয়। Parallelism হলো চালানোর ধরন — সেই ভাগগুলো একাধিক core-এ একই সময়ে চলা। ভালো concurrent নকশা বেশি core পেলে নিজে থেকেই দ্রুত হয়।

বাস্তবে thread কোথায় লাগে? কয়েকটি পরিচিত উদাহরণ:

  • Web server: হাজারো মানুষ একসাথে একটি ওয়েবসাইট খুলছেন। Server অনুরোধগুলো কয়েকটি thread-এ ভাগ করে সামলায়, যাতে কেউ অন্যের জন্য আটকে না থাকেন।
  • গেম: একটি thread ছবি আঁকে (render), আরেকটি পদার্থবিদ্যার হিসাব করে — বল কোথায় পড়বে, গাড়ি কোথায় ধাক্কা খাবে।
  • মোবাইল অ্যাপ: Android অ্যাপে একটি মূল UI thread বোতাম, স্ক্রল আর অ্যানিমেশন সামলায়। ধীর কাজ (যেমন ইন্টারনেট থেকে ডেটা আনা) সেখানে করলে অ্যাপ জমে যায়, তাই সেগুলো পাঠানো হয় আলাদা thread-এ।

২ · Thread কী — একই ঘরে কয়েকজন কর্মী

আগের পর্বে fork() দিয়ে বানানো নতুন process পেয়েছিল নিজের আলাদা মেমরি — যেন পাশে আরেকটি আলাদা দোকান খোলা হলো। এক দোকানের খাতা অন্য দোকান দেখতে পায় না; কথা বলতে pipe-এর মতো আলাদা পথ লাগে।

Thread হলো একই দোকানে আরেকজন কর্মী নিয়োগ দেওয়া। সবাই একই তাক, একই ক্যাশবাক্স, একই হিসাবের খাতা ব্যবহার করেন। কথা বলা খুব সহজ — খাতায় লিখে দিলেই অন্যজন দেখতে পান। কিন্তু ঠিক এখানেই বিপদ: দুজন একসাথে একই খাতার একই লাইনে লিখতে গেলে গোলমাল অবধারিত।

একটি process-এর ভেতরে তিনটি thread

What threads share, and what each one owns

  1. সবার ভাগেcode, globals, heapএকই ঠিকানা, একই ডেটা
  2. Thread 1own stack + registersনিজের local ভ্যারিয়েবল
  3. Thread 2own stack + registersনিজের local ভ্যারিয়েবল
  4. Thread 3own stack + registersনিজের local ভ্যারিয়েবল
ছবি ৪৯.১ — global ভ্যারিয়েবল ও heap সবার, কিন্তু stack ও register প্রত্যেকের নিজের।
বিষয়Process (পর্ব ৪৮)Thread (এই পর্ব)
মেমরিপ্রত্যেকের আলাদাএকই মেমরি সবার
তৈরি করার খরচবেশি (নতুন ঠিকানা-জগৎ বানাতে হয়)কম
একে অপরের সাথে কথা বলাpipe, socket, shared memory — আলাদা ব্যবস্থা লাগেসরাসরি একই ভ্যারিয়েবল পড়া-লেখা
একজন crash করলেসাধারণত শুধু সেটিই মরেপুরো process মরে, সব thread সহ
সবচেয়ে বড় ঝুঁকিযোগাযোগ ধীর ও জটিলভাগ করা ডেটায় একসাথে হাত দেওয়া

একটি core-এ কয়েকটি thread থাকলে operating system-এর scheduler তাদের পালা করে চালায়। একটি thread থামিয়ে তার register-গুলো সংরক্ষণ করে আরেকটি thread চালু করার কাজকে বলে context switch. এটি খুব দ্রুত হয়, কিন্তু বিনা খরচে নয় — আর সবচেয়ে গুরুত্বপূর্ণ কথা, কখন থামানো হবে তা আপনি ঠিক করেন না। আপনার কোডের যেকোনো দুটি machine instruction-এর মাঝখানে thread থেমে যেতে পারে। এই একটি সত্য থেকেই এই পর্বের সব সমস্যার জন্ম।

৩ · pthread দিয়ে thread তৈরি — create, join আর তথ্য পাঠানো

Linux, macOS আর অন্য Unix-ধাঁচের সিস্টেমে C-তে thread চালানোর প্রচলিত উপায় হলো pthread (POSIX threads) library. এর জন্য #include <pthread.h> লাগে, আর অনেক সিস্টেমে compile করার সময় -pthread flag দিতে হয়। মূল কাজ দুটি:

ফাংশনকী করেদোকানের ভাষায়
pthread_create(&t, NULL, fn, arg)নতুন thread চালু করে, সেটি fn(arg) চালাতে শুরু করেনতুন কর্মীকে একটি কাজের চিরকুট দিয়ে পাঠানো
pthread_join(t, NULL)t শেষ না হওয়া পর্যন্ত অপেক্ষা করেকর্মী কাজ শেষ করে ফেরা পর্যন্ত দোকান বন্ধ না করা

Thread-এর ফাংশনের চেহারা সবসময় একই: void *fn(void *arg). void * (পর্ব ১৭) দিয়ে যেকোনো ধরনের তথ্যের ঠিকানা পাঠানো যায়। Join না করলে main শেষ হওয়ার সাথে সাথে পুরো process বন্ধ হয়ে যায় — অন্য thread-গুলো কাজের মাঝপথে থাকলেও।

সবচেয়ে প্রচলিত শুরুর ভুল — লুপের ভ্যারিয়েবলের ঠিকানা পাঠানো। নিচের কোডে চারটি thread-কে ০, ১, ২, ৩ নম্বর দেওয়ার চেষ্টা করা হয়েছে:

for (int i = 0; i < 4; i++)
    pthread_create(&t[i], NULL, worker, &i);   /* BUG */

চারটি thread-ই একই i-এর ঠিকানা পায়। কোনো thread যখন সেই ঠিকানা থেকে পড়ে, ততক্ষণে লুপ হয়তো i-কে ২ বা ৪ বানিয়ে ফেলেছে। ফলে দুটি thread একই নম্বর পেতে পারে, আর লুপ শেষে i-এর জীবনই শেষ — তখন পড়া মানে মৃত ভ্যারিয়েবল পড়া। সমাধান: প্রতিটি thread-এর জন্য আলাদা ঘর রাখুন, যেমন int id[4] অ্যারে বানিয়ে &id[i] পাঠান। নিচে অংশ ৫-এর কোডে ঠিক এটাই করা হয়েছে।

একসময় Unix-এর নানা কোম্পানি নিজেদের মতো thread library বানাত, ফলে এক সিস্টেমের কোড আরেকটিতে চলত না। ১৯৯৫ সালে POSIX মানদণ্ডে (POSIX.1c) একটি সাধারণ thread interface যুক্ত হয় — সেটিই আজকের pthread. পরে ২০১১ সালের C11 মানদণ্ডে C ভাষা নিজেই <threads.h> আর <stdatomic.h> যোগ করে।

৪ · Race condition — counter++ আসলে তিনটি কাজ

C-তে counter++ দেখতে একটি ছোট্ট কাজ। কিন্তু CPU-র কাছে এটি তিনটি আলাদা ধাপ: (১) মেমরি থেকে counter-এর মান register-এ পড়া, (২) register-এ ১ যোগ করা, (৩) নতুন মান মেমরিতে লেখা। এই ধরনকে বলে read-modify-write. একটি thread-এ কোনো সমস্যা নেই। কিন্তু দুটি thread একই counter বাড়ালে কী হয়? নিচে দুজনেই একবার করে বাড়াচ্ছে — শুরুতে মান ৫, শেষে হওয়ার কথা ৭. “পরে” চাপুন:

1counter = 5;            /* shared */2T1: r1 = counter;       /* read  */3T2: r2 = counter;       /* read  */4T1: r1 = r1 + 1;        /* add   */5T2: r2 = r2 + 1;        /* add   */6T1: counter = r1;       /* write */7T2: counter = r2;       /* write */
মেমরি ও register
counter (ভাগ করা)
r1 (T1-এর register)
r2 (T2-এর register)
  1. শুরু। counter একটি global ভ্যারিয়েবল, মান ৫. দুটি thread — T1 আর T2 — প্রত্যেকে একবার counter++ চালাবে। প্রত্যেকের নিজের register আছে: r1 আর r2.
  2. T1 পড়ল। T1 মেমরি থেকে ৫ এনে r1-এ রাখল। ঠিক এই মুহূর্তে scheduler T1-কে থামিয়ে T2-কে চালাল — এমনটি যেকোনো সময় হতে পারে।
  3. T2-ও পড়ল। মেমরিতে এখনো ৫, কারণ T1 নতুন মান লেখার সুযোগই পায়নি। T2-ও ৫ নিয়ে গেল। গোলমালের বীজ এখানেই বোনা হলো।
  4. T1 যোগ করল। r1 = ৫ + ১ = ৬. এটি শুধু T1-এর register-এ, মেমরিতে নয়।
  5. T2-ও যোগ করল। r2 = ৫ + ১ = ৬. দুজনেই আলাদাভাবে ভাবছে, “পরের মান ৬”।
  6. T1 লিখল। মেমরিতে counter = ৬. এ পর্যন্ত সব ঠিক মনে হচ্ছে।
  7. T2-ও লিখল — আবার ৬! T2 তার পুরোনো হিসাবের ৬ বসিয়ে দিল, T1-এর কাজ মুছে গেল। দুবার বাড়ানো হলো, অথচ ফল ৬. একে বলে lost update. ধাপগুলোর ক্রম একটু অন্যরকম হলে (T1 পুরো শেষ করে তারপর T2) ফল ঠিক ৭ আসত। ফলাফল নির্ভর করছে ভাগ্যের ওপর — এটাই race condition.

সংখ্যা বাড়ানো শুনতে নিরীহ। কিন্তু একই ঘটনা টাকার হিসাবে ঘটলে? ধরুন একটি মোবাইল ব্যাংকিং অ্যাকাউন্টে আছে ১,০০০ টাকা। একই মুহূর্তে দুটি ডিভাইস থেকে টাকা তোলার অনুরোধ এলো — একটি ৭০০, আরেকটি ৫০০. Server দুটি অনুরোধ দুটি thread-এ সামলাচ্ছে, আর প্রত্যেকে “ব্যালান্স দেখো → যথেষ্ট থাকলে কাটো” নিয়ম মানছে:

সময়Thread A (৭০০ তুলবে)Thread B (৫০০ তুলবে)মেমরিতে ব্যালান্স
১ব্যালান্স পড়ল: ১,০০০—১,০০০
২—ব্যালান্স পড়ল: ১,০০০১,০০০
৩১,০০০ ≥ ৭০০ ✓ — টাকা দিল১,০০০ ≥ ৫০০ ✓ — টাকা দিল১,০০০
৪লিখল: ১,০০০ − ৭০০ = ৩০০—৩০০
৫—লিখল: ১,০০০ − ৫০০ = ৫০০৫০০

গ্রাহক মোট ১,২০০ টাকা পেলেন, অথচ অ্যাকাউন্টে ছিল ১,০০০ — আর শেষে ব্যালান্স দেখাচ্ছে ৫০০! সঠিক আচরণ হতো দ্বিতীয় অনুরোধটি বাতিল করা। এই কারণেই আসল লেনদেন-ব্যবস্থায় “পড়া, যাচাই আর লেখা” পুরোটা একটিমাত্র অবিভাজ্য কাজ হিসেবে করা হয়।

এবার আসল কোডে দেখুন। দুটি thread দশ লাখ বার করে একটি সাধারণ long বাড়াচ্ছে, আর পাশে আরও দুটি thread একই কাজ করছে একটি atomic ভ্যারিয়েবলে (atomic কী, তা পরের অংশে বিস্তারিত)। কয়েকবার Run চাপুন:

#include <stdio.h>
#include <pthread.h>
#include <stdatomic.h>
#define N 1000000

long plain = 0;            /* shared, unprotected */
atomic_long safe = 0;      /* shared, atomic */

void *racy(void *p) { (void)p; for (int i = 0; i < N; i++) plain++; return NULL; }
void *atom(void *p) { (void)p; for (int i = 0; i < N; i++) atomic_fetch_add(&safe, 1); return NULL; }

int main(void) {
    pthread_t t[4];
    pthread_create(&t[0], NULL, racy, NULL); pthread_create(&t[1], NULL, racy, NULL);
    pthread_create(&t[2], NULL, atom, NULL); pthread_create(&t[3], NULL, atom, NULL);
    for (int i = 0; i < 4; i++) pthread_join(t[i], NULL);
    printf("expected: %d\n", 2 * N);
    printf("plain++ : got %ld, matched: %s\n", plain, plain == 2L * N ? "yes" : "no");
    printf("atomic  : got %ld, matched: %s\n", (long)safe, safe == 2L * N ? "yes" : "no");
    return 0;
}

plain++-এর লাইনে আপনি সাধারণত ২০,০০,০০০-এর চেয়ে কম একটি সংখ্যা দেখবেন, আর matched: no. সংখ্যাটি প্রতিবার আলাদা হয় — কখনো কম হারায়, কখনো বেশি। কোন সংখ্যা আসবে তা নির্ভর করে কয়টি core পাওয়া গেল আর scheduler কখন কাকে থামাল তার ওপর; সার্ভার খুব কম ব্যস্ত থাকলে কদাচিৎ মিলেও যেতে পারে। কিন্তু atomic-এর লাইন প্রতিবার ঠিক ২০,০০,০০০ দেখাবে, কারণ atomic_fetch_add পড়া-যোগ-লেখা তিনটিকে একটি অবিভাজ্য কাজ হিসেবে করে। দুটি নিয়ম মনে রাখুন: “একবার মিলে গেছে” মানে কোড ঠিক নয়; আর ভুল উত্তর দেয় এমন কোড মাঝে মাঝে ঠিক উত্তরও দিতে পারে।

মূল ধারণা

Race condition হলো যেকোনো পরিস্থিতি, যেখানে ফল নির্ভর করে কাজগুলো কোন ক্রমে ঘটল তার ওপর। এর একটি বিশেষ, কঠোর রূপ হলো data race: দুটি thread কোনো সুরক্ষা ছাড়া একই মেমরিতে একসাথে হাত দিচ্ছে, আর অন্তত একজন লিখছে। C11 মানদণ্ড অনুযায়ী data race হলো undefined behavior — শুধু “ভুল সংখ্যা” নয়, compiler তখন যেকোনো কিছু করার অধিকার পায়।

volatile এই সমস্যার সমাধান নয়। পর্ব ৪৫-এ দেখেছেন, volatile compiler-কে বলে “প্রতিবার মেমরি থেকে আসল মান পড়ো” — এটি hardware register বা signal handler-এর জন্য তৈরি। কিন্তু এটি counter++-এর তিন ধাপকে অবিভাজ্য করে না, আর অন্য core কখন নতুন মান দেখবে সে ব্যাপারেও কোনো নিশ্চয়তা দেয় না। পুরোনো অনেক টিউটোরিয়াল আর AI এখনো volatile-কে thread-এর সমাধান বলে — এটি ভুল। Thread-এর জন্য লাগে mutex বা atomic.

Race condition-এর দাম মানুষের জীবন দিয়েও দিতে হয়েছে। ১৯৮৫ থেকে ১৯৮৭ সালের মধ্যে Therac-25 নামের একটি রেডিয়েশন থেরাপি যন্ত্রে কয়েকজন রোগী ভয়াবহ মাত্রার অতিরিক্ত রেডিয়েশন পান, যাঁদের কয়েকজন মারা যান। পরে Nancy Leveson-এর বিখ্যাত তদন্তে উঠে আসে, অনেকগুলো কারণের একটি ছিল সফটওয়্যারের race condition — অপারেটর খুব দ্রুত তথ্য সংশোধন করলে যন্ত্রের একাংশ পুরোনো সেটিং ধরে রাখত। আবার ২০০৩ সালের আগস্টে যুক্তরাষ্ট্র ও কানাডার বিশাল এলাকার বিদ্যুৎ বিপর্যয়ের (Northeast blackout) তদন্তে দেখা যায়, একটি বিদ্যুৎ কোম্পানির alarm software-এ race condition-এর কারণে alarm চুপচাপ থেমে গিয়েছিল, ফলে অপারেটররা সময়মতো সতর্কবার্তা পাননি। বিপর্যয়ের আরও কারণ ছিল, কিন্তু এই বাগ সমস্যাটিকে বড় হতে দিয়েছিল।

৫ · Mutex ও atomic — এক সময়ে একজন

ভাগ করা ডেটায় হাত দেওয়া কোডের অংশটুকুকে বলে critical section. সমাধানের মূল কথা: critical section-এ এক সময়ে শুধু একজন ঢুকবে। এর জন্য সবচেয়ে প্রচলিত হাতিয়ার mutex (mutual exclusion — পারস্পরিক বর্জন)।

অফিসের একটিমাত্র ওয়াশরুমের কথা ভাবুন, দরজায় একটি ছিটকিনি। যিনি ঢোকেন, ভেতর থেকে ছিটকিনি দেন (lock). অন্য কেউ এলে দরজা বন্ধ দেখে বাইরে অপেক্ষা করেন। ভেতরের জন বেরোনোর সময় ছিটকিনি খোলেন (unlock), তখন অপেক্ষমাণ একজন ঢোকেন। ছিটকিনি না খুলে কেউ জানালা দিয়ে বেরিয়ে গেলে? বাইরের সবাই চিরকাল দাঁড়িয়ে থাকবেন। Mutex ঠিক এমনই — আর unlock ভুলে যাওয়া সবচেয়ে প্রচলিত বাগগুলোর একটি।

অংশ ৪-এর একই দুটি counter++, এবার mutex দিয়ে ঘেরা। খেয়াল করুন T2 কীভাবে অপেক্ষা করছে:

1T1: lock(&m);           /* free: T1 gets it  */2T2: lock(&m);           /* busy: T2 sleeps   */3T1: r1 = counter;4T1: counter = r1 + 1;5T1: unlock(&m);         /* wakes T2          */6T2: r2 = counter;7T2: counter = r2 + 1; unlock(&m);
অবস্থা
mutex m
counter
r1
r2
T2
  1. T1 lock নিল। Mutex খোলা ছিল, তাই T1 সেটি দখল করল। এখন থেকে critical section-এ শুধু T1.
  2. T2 lock চাইল — পেল না। Mutex T1-এর হাতে, তাই T2 ঘুমিয়ে পড়ল। এ সময় সে CPU নষ্ট করে না; operating system তাকে একটি অপেক্ষার তালিকায় রেখে দেয়।
  3. T1 পড়ল। r1 = ৫. মাঝখানে scheduler T1-কে থামালেও এবার ক্ষতি নেই — T2 তো দরজার বাইরে।
  4. T1 লিখল। counter = ৬. পড়া-যোগ-লেখা তিনটিই T1 একটানা শেষ করল, কেউ মাঝে ঢুকতে পারেনি।
  5. T1 unlock করল। Mutex খুলতেই অপেক্ষায় থাকা T2 জেগে উঠে lock পেল।
  6. T2 পড়ল — এবার নতুন মান। r2 = ৬, কারণ T1-এর লেখা আগেই শেষ। Lost update-এর রাস্তা বন্ধ।
  7. T2 লিখে unlock করল। counter = ৭ — ঠিক যা হওয়ার কথা। দাম কী দিলাম? T2-কে কিছুক্ষণ অপেক্ষা করতে হলো। এজন্যই critical section যত ছোট, তত ভালো।

এবার একটি বাস্তবসম্মত উদাহরণ। ১ থেকে ১০ লাখ পর্যন্ত সব সংখ্যার যোগফল চারটি thread ভাগ করে বের করবে। প্রত্যেকে নিজের ভাগ নিজের local ভ্যারিয়েবলে যোগ করবে, আর শেষে মাত্র একবার lock নিয়ে মোট যোগফলে নিজের অংশ যোগ করবে:

#include <stdio.h>
#include <pthread.h>
typedef struct { long from, to; } Range;
long total = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *sum_part(void *arg) {
    Range *r = arg;  long local = 0;          /* own range, own local sum */
    for (long k = r->from; k <= r->to; k++) local += k;   /* no lock needed */
    pthread_mutex_lock(&lock);                /* critical section: one line */
    total += local;
    pthread_mutex_unlock(&lock);  return NULL;
}
int main(void) {
    pthread_t t[4];  Range r[4];              /* one struct per thread, not &i */
    for (int i = 0; i < 4; i++) {
        r[i] = (Range){ i * 250000L + 1, (i + 1) * 250000L };
        pthread_create(&t[i], NULL, sum_part, &r[i]);
    }
    for (int i = 0; i < 4; i++) pthread_join(t[i], NULL);
    printf("total = %ld, formula = %ld\n", total, 1000000L * 1000001L / 2);
    return 0;
}

এখানে তিনটি ভালো অভ্যাস একসাথে আছে। এক: প্রতিটি thread পায় নিজের Range struct-এর ঠিকানা (&r[i]), লুপের i-এর নয় — তাই কেউ কারও ভাগ নিয়ে টানাটানি করে না। দুই: ভারী কাজ (আড়াই লাখ যোগ) হয় local-এ, যা প্রত্যেকের নিজের stack-এ থাকে, তাই lock লাগে না। তিন: lock নেওয়া হয় মাত্র একবার, একটি লাইনের জন্য। প্রতিটি যোগের সময় lock নিলে উত্তর ঠিকই আসত, কিন্তু চারটি thread বেশিরভাগ সময় একে অপরের জন্য দাঁড়িয়ে থাকত — একটি thread-এর চেয়েও ধীর হয়ে যেত। ফলাফল প্রতিবার একই: 500000500000, যা n(n+1)/2 সূত্রের সাথে মেলে।

Atomic — একটি সংখ্যার জন্য হালকা হাতিয়ার

শুধু একটি সংখ্যা বাড়ানো বা কমানোর জন্য পুরো mutex ব্যবহার করা অনেক সময় বাড়াবাড়ি। আধুনিক CPU-তে বিশেষ instruction আছে, যা পড়া-বদলানো-লেখাকে এক ধাক্কায় করে — মাঝখানে অন্য কোনো core ঢুকতে পারে না। একে বলে atomic operation. C11-এ _Atomic বা atomic_long-এর মতো টাইপ আর atomic_fetch_add-এর মতো ফাংশন দিয়ে এটি ব্যবহার করা যায়, যা আপনি অংশ ৪-এর কোডে দেখেছেন।

বিষয়MutexAtomic
কী রক্ষা করেযত বড় খুশি কোডের অংশ — কয়েকটি ভ্যারিয়েবল একসাথেএকটিমাত্র ভ্যারিয়েবলের একটি কাজ
অপেক্ষার সময়thread ঘুমিয়ে পড়েঘুমায় না, hardware নিজেই সামলায়
খরচতুলনামূলক বেশিকম
ভালো উদাহরণব্যালান্স যাচাই করে কাটা, queue-তে জিনিস রাখাভিজিটর গোনা, “কাজ শেষ” flag

মোবাইল ব্যাংকিংয়ের উদাহরণে atomic যথেষ্ট নয়: সেখানে “যাচাই” আর “কাটা” দুটি কাজ মিলে অবিভাজ্য হতে হবে, আর দুটি আলাদা atomic কাজের মাঝখানেও আরেকজন ঢুকে পড়তে পারে। কয়েকটি ধাপ মিলে একটি নিয়ম রক্ষা করতে হলে mutex লাগবে।

৬ · Deadlock — দুজনেই অপেক্ষায়, কেউ এগোয় না

Lock সমস্যা মেটায়, আবার নতুন সমস্যাও আনে। একটি সরকারি অফিসের দৃশ্য ভাবুন। একটি ফাইল অনুমোদন করতে দুটি নথি লাগে — ক আর খ. কর্মকর্তা রহিম নথি ক টেবিলে নিয়ে বসে আছেন, খ-এর জন্য অপেক্ষা করছেন। কর্মকর্তা করিম নথি খ নিয়ে বসে আছেন, ক-এর জন্য অপেক্ষা করছেন। কেউ নিজের নথি ছাড়বেন না। ফাইল চিরকাল আটকে থাকবে। এটাই deadlock.

প্রোগ্রামে এটি ঘটে যখন দুটি thread দুটি mutex উল্টো ক্রমে নেয়। নিচে প্রথমে সমস্যাটি দেখুন, তারপর সমাধান:

1/* version 1: opposite order */2T1: lock(A);3T2: lock(B);4T1: lock(B);   /* waits for T2 */5T2: lock(A);   /* waits for T1 */6/* version 2: always A, then B */7T1: lock(A);8T2: lock(A);   /* waits, holds nothing */9T1: lock(B); work(); unlock(B); unlock(A);10T2: lock(A); lock(B); work(); ...
কে কোন lock ধরে আছে
lock A
lock B
T1
T2
  1. T1 নিল A. কোনো সমস্যা নেই, A খোলা ছিল।
  2. T2 নিল B. এটিও খোলা ছিল। এখন দুজনের হাতে একটি করে lock.
  3. T1 চাইল B. B আছে T2-এর হাতে, তাই T1 ঘুমিয়ে অপেক্ষা শুরু করল — হাতে A ধরে রেখেই।
  4. T2 চাইল A — deadlock! A আছে ঘুমন্ত T1-এর হাতে। T1 জাগবে B খুললে, আর B খুলবে T2 জাগলে। এই চক্র কখনো ভাঙবে না। প্রোগ্রাম crash করে না, কোনো error-ও দেখায় না — শুধু চিরকাল থেমে থাকে। এজন্যই deadlock খুঁজে পাওয়া এত কঠিন।
  5. সমাধান: সবাই আগে A, তারপর B. নতুন নিয়মে T1 আগের মতোই A নিল।
  6. T2-ও আগে A চাইল। A পাওয়া গেল না, তাই T2 অপেক্ষা করছে — কিন্তু এবার তার হাতে কিছুই নেই। কাউকে আটকে রাখছে না।
  7. T1 নির্বিঘ্নে B নিল, কাজ শেষ করল, আর দুটি lock-ই ছেড়ে দিল। অপেক্ষার চক্র তৈরিই হতে পারেনি।
  8. T2 এবার A, তারপর B নিয়ে কাজ করছে। দুজনেই শেষ করবে। নিয়মটি সহজ: সব lock-কে একটি নির্দিষ্ট ক্রমে সাজান, আর প্রতিটি thread সেই ক্রমেই নেবে। একে বলে lock ordering.

Deadlock-এর চারটি শর্ত

১৯৭১ সালে Edward Coffman ও তাঁর সহকর্মীরা দেখান, deadlock হতে হলে চারটি শর্ত একসাথে সত্য হতে হয়। এগুলো পরিচিত Coffman conditions নামে:

শর্তমানেঅফিসের উদাহরণে
১. Mutual exclusionএকটি জিনিস এক সময়ে একজনই ধরতে পারেএকটি নথি একজনের টেবিলেই থাকে
২. Hold and waitএকটি ধরে রেখে আরেকটির জন্য অপেক্ষাক হাতে রেখে খ-এর জন্য বসে থাকা
৩. No preemptionজোর করে কারও কাছ থেকে কেড়ে নেওয়া যায় নাকেউ অন্যের টেবিল থেকে নথি তুলে নিতে পারেন না
৪. Circular waitঅপেক্ষার একটি বৃত্ত তৈরি হয়রহিম করিমের জন্য, করিম রহিমের জন্য

যেকোনো একটি শর্ত ভাঙলেই deadlock অসম্ভব। Lock ordering ভাঙে চতুর্থ শর্তটি — সবাই একই দিকে এগোলে বৃত্ত তৈরি হতে পারে না। দ্বিতীয় শর্ত ভাঙার উপায় হলো pthread_mutex_trylock: lock না পেলে অপেক্ষা না করে হাতের সবকিছু ছেড়ে দিয়ে পরে আবার চেষ্টা করা।

Deadlock-এর দুই আত্মীয় — livelock আর starvation

🚶 Livelock

সরু গলিতে দুজন মুখোমুখি। দুজনেই ভদ্রতা করে বাঁ দিকে সরলেন, আবার মুখোমুখি। তারপর দুজনেই ডানে — আবার মুখোমুখি। দুজনেই নড়ছেন, কিন্তু কেউ এগোচ্ছেন না। Livelock-এ thread-গুলো ব্যস্ত, CPU খরচ হচ্ছে, কিন্তু কাজ হচ্ছে না। trylock করে সবাই একই সময় পরপর ছেড়ে দিলে এটি ঘটে; সমাধান হলো প্রতিবার অল্প এলোমেলো সময় অপেক্ষা করা।

⏳ Starvation

ব্যাংকের লাইনে কেউ বারবার পেছনে পড়ে যাচ্ছেন, কারণ “জরুরি” গ্রাহকরা সবসময় আগে ঢুকছেন। সিস্টেম চলছে, কিন্তু একজন কখনো সেবা পাচ্ছেন না। Starvation-এ একটি thread চিরকাল lock বা CPU-র পালা পায় না। ন্যায্য (fair) lock আর priority-র সতর্ক ব্যবহার এর প্রতিকার।

১৯৯৭ সালে NASA-র Mars Pathfinder মঙ্গলে নামার কয়েক দিন পর বারবার নিজে থেকে restart হতে শুরু করে। কারণ ছিল priority inversion: কম গুরুত্বের একটি কাজ একটি mutex ধরে ছিল, বেশি গুরুত্বের কাজ তার অপেক্ষায়, আর মাঝারি কাজগুলো কম গুরুত্বেরটিকে চলতেই দিচ্ছিল না। পৃথিবী থেকে একটি সেটিং বদলে (priority inheritance চালু করে) সমস্যাটি সমাধান করা হয়।

৭ · Condition variable — অপেক্ষা করার সঠিক উপায়

পর্ব ৩৪-এ আপনি circular buffer বানিয়েছিলেন — নির্দিষ্ট আকারের একটি queue, যার head আর tail ঘুরে আসে। এখন ভাবুন একটি গার্মেন্টস কারখানার সেলাই লাইন: একজন কর্মী শার্ট সেলাই করে টেবিলে রাখেন (producer), আরেকজন টেবিল থেকে নিয়ে ভাঁজ করেন (consumer). টেবিলে জায়গা মাত্র ৩টি শার্টের। একে বলে producer-consumer সমস্যা, আর এটি web server, ভিডিও প্লেয়ার, লগ লেখার সিস্টেম — সবখানে আছে।

নিয়ম দুটি: টেবিল ভরা থাকলে producer অপেক্ষা করবেন, খালি থাকলে consumer. কিন্তু বারবার “খালি হলো?” জিজ্ঞেস করলে (busy waiting) CPU অযথা পুড়ে যায়। সঠিক হাতিয়ার condition variable: একটি thread ঘুমিয়ে পড়ে, আর অন্য thread অবস্থা বদলালে তাকে ডেকে তোলে (signal). pthread_cond_wait ঘুমানোর ঠিক আগে mutex ছেড়ে দেয়, আর জাগার পর আবার mutex নিয়ে তবেই ফেরে।

1/* producer */2lock(&m);3while (count == 3) wait(&not_full, &m);4put(item); count++;5signal(&not_empty); unlock(&m);6/* consumer */7lock(&m);8while (count == 0) wait(&not_empty, &m);9item = take(); count--;10signal(&not_full); unlock(&m);
Buffer (৩ ঘর, circular)
ঘর ০ · ১ · ২
count
producer
consumer
  1. Consumer আগে এলো, buffer খালি। লাইন ৮: count == 0, তাই wait. Consumer mutex ছেড়ে ঘুমিয়ে পড়ল — CPU খরচ শূন্য।
  2. Producer A রাখল। লাইন ৪-এ ঘর ০-তে A, count = ১. লাইন ৫-এ signal(&not_empty) — “খালি আর নেই” — এতে ঘুমন্ত consumer জেগে উঠছে।
  3. Consumer আবার যাচাই করে নিল। জেগেই সে সরাসরি নেয় না — while লুপ আবার count দেখে। ১ পেয়ে নিশ্চিত হয়ে A নিল, count = ০, আর signal(&not_full) দিল।
  4. Producer দ্রুত তিনটি রাখল। Circular buffer-এর tail ঘুরে চলে: B গেল ঘর ১-এ, C ঘর ২-এ, আর D ঘুরে ঘর ০-তে। count = ৩ — buffer ভরা।
  5. Producer E রাখতে চাইল — ভরা। লাইন ৩: count == 3, তাই wait(&not_full). Producer ঘুমিয়ে পড়ল। এটাই backpressure — দ্রুত producer-কে ধীর consumer-এর গতিতে নামিয়ে আনা।
  6. Consumer B নিল। Head ছিল ঘর ১-এ, তাই প্রথমে ঢোকা B-ই আগে বেরোল (FIFO). count = ২, আর signal(&not_full).
  7. Producer জেগে আবার যাচাই করল, তারপর E রাখল। ফাঁকা ঘর ১-এ E গেল, count = ৩. পুরো সময়ে কেউ অযথা ঘোরেনি, আর কেউ ভরা buffer-এ লেখেনি বা খালি buffer থেকে পড়েনি।

if নয়, সবসময় while. জেগে ওঠা মানেই শর্ত পূরণ হয়েছে, এমন নিশ্চয়তা নেই। দুটি কারণ: (১) দুজন consumer অপেক্ষায় থাকলে প্রথমজন জিনিসটি নিয়ে নিতে পারে, দ্বিতীয়জন জেগে দেখবে আবার খালি। (২) POSIX নিয়ম অনুযায়ী pthread_cond_wait কোনো signal ছাড়াও মাঝে মাঝে ফিরে আসতে পারে — একে বলে spurious wakeup. while লুপ জাগার পর শর্তটি আবার যাচাই করে, তাই দুটি সমস্যাই নিজে থেকে সামলে যায়।

ভাগ করা ডেটা → mutex
একটি সংখ্যা → atomic
“অবস্থা বদলানো পর্যন্ত অপেক্ষা” → condition variable + while

৮ · বেশি thread মানেই বেশি গতি নয় — thread pool ও Amdahl's law

প্রতিটি কাজের জন্য নতুন thread বানানো আর শেষে ধ্বংস করাও খরচের ব্যাপার। তাই বড় সিস্টেম ব্যবহার করে thread pool: শুরুতেই কয়েকটি thread বানিয়ে রাখা হয়, আর তারা একটি কাজের queue থেকে একটির পর একটি কাজ তুলে নেয়। এটি আসলে অংশ ৭-এর producer-consumer-ই।

রাইড-শেয়ারিং অ্যাপে প্রতিটি যাত্রীর জন্য নতুন চালক নিয়োগ দেওয়া হয় না। একদল চালক আগে থেকেই রাস্তায় থাকেন, অনুরোধ এলে যিনি ফাঁকা তিনি নেন। Thread pool ঠিক এই নকশা, আর এর আকার সাধারণত core-এর সংখ্যার কাছাকাছি রাখা হয়।

Amdahl's law — ধারাবাহিক অংশই গতির সীমা টানে

১৯৬৭ সালে IBM-এর কম্পিউটার স্থপতি Gene Amdahl একটি সরল কিন্তু গভীর হিসাব দেখান। প্রায় প্রতিটি প্রোগ্রামের কিছু অংশ ভাগ করা যায় না — যেমন ফাইল পড়া, শুরুর প্রস্তুতি, শেষে ফল একত্র করা। ধরুন সেই অংশ পুরো সময়ের ভগ্নাংশ s, আর core আছে Nটি। তাহলে সর্বোচ্চ গতি-বৃদ্ধি:

গতি-বৃদ্ধি = 1 ÷ ( s + (1 − s) ÷ N )

এটিই Amdahl's law. একটি উদাহরণ নিই: প্রোগ্রামের ১০% ধারাবাহিক (s = 0.1), বাকি ৯০% পুরোপুরি ভাগ করা যায়।

Core সংখ্যা (N)হিসাবসর্বোচ্চ গতি-বৃদ্ধি
১1 ÷ (0.1 + 0.9)১ গুণ
২1 ÷ (0.1 + 0.45)প্রায় ১.৮২ গুণ
৪1 ÷ (0.1 + 0.225)প্রায় ৩.০৮ গুণ
৮1 ÷ (0.1 + 0.1125)প্রায় ৪.৭১ গুণ
১৬1 ÷ (0.1 + 0.05625)৬.৪ গুণ
অসীম1 ÷ 0.1সর্বোচ্চ ১০ গুণ

লক্ষ করুন: core দ্বিগুণ করলে গতি দ্বিগুণ হয় না, আর অসীম core দিলেও ১০ গুণের বেশি নয় — ঐ ১০% ধারাবাহিক অংশ কেউ ভাগ করে নিতে পারছে না। বাস্তবে অবস্থা আরও খারাপ, কারণ thread বাড়লে lock-এর জন্য কাড়াকাড়ি (contention)-ও বাড়ে।

এর একটি সূক্ষ্ম রূপ হলো false sharing: দুটি thread দুটি আলাদা ভ্যারিয়েবলে লিখছে, কিন্তু সেগুলো পাশাপাশি থেকে একই ৬৪ বাইটের cache line-এ পড়েছে। তখন দুটি core বারবার লাইনটি কাড়াকাড়ি করে — ফল ঠিক আসে, কিন্তু প্রোগ্রাম রহস্যজনকভাবে ধীর হয়।

৯ · AI-এর যুগে multithreading — যে বাগ একবার চালিয়ে ধরা যায় না

Thread-এর বাগ হাজারবারে একবার আসে, আপনার ল্যাপটপে না এসে ৬৪ core-এর server-এ আসে, আর printf যোগ করলে সময়ের হিসাব বদলে লুকিয়ে পড়ে। তাই AI-এর লেখা thread কোড প্রথমবার ঠিক চললেও সেটি কোনো প্রমাণ নয়।

Thread কোডে “চলছে” মানে ঠিক নয়।
প্রমাণ আসে নিয়ম থেকে, আর যাচাইয়ের টুল থেকে।

যাচাইয়ের টুল

সবচেয়ে শক্তিশালী হাতিয়ার ThreadSanitizer (TSan) — GCC আর Clang দুটিতেই আছে। এটি প্রোগ্রাম চলার সময় প্রতিটি মেমরি-পড়া ও লেখা নজরে রাখে, আর দুটি thread সুরক্ষা ছাড়া একই জায়গায় হাত দিলে কোন দুটি লাইনে ঘটেছে তা জানিয়ে দেয় — এমনকি সেই চালানোয় ফল ঠিক এলেও। পর্ব ৩১-এর AddressSanitizer-এর মতোই এটি একটি sanitizer. Valgrind-এর Helgrind টুলও একই ধরনের ভুল আর lock-এর ক্রমভঙ্গ খোঁজে।

gcc -g -fsanitize=thread race.c -o race -pthread
./race
# WARNING: ThreadSanitizer: data race ... (line numbers of both accesses)

এর সাথে লাগে stress test: প্রোগ্রামটি একটি লুপে শত শত বার চালানো, thread-এর সংখ্যা বাড়িয়ে-কমিয়ে, যাতে বিরল interleaving-ও কোনো এক সময় ঘটে।

AI সাধারণত যেসব thread-ভুল করে

AI-এর ভুলফলকীভাবে ধরবেন
ভাগ করা global বা heap ডেটায় lock ছাড়া লেখাData race, মাঝে মাঝে ভুল ফলTSan
Error-এর পথে return করার আগে unlock ভুলে যাওয়াপরের সবাই চিরকাল অপেক্ষায়প্রতিটি return-এর আগে খুঁজে দেখা; পর্ব ৩০-এর goto-cleanup
দুই জায়গায় দুটি lock উল্টো ক্রমে নেওয়াবিরল deadlockHelgrind, TSan-এর lock-order সতর্কবার্তা
“সমাধান” হিসেবে volatile বসিয়ে দেওয়াবাগ থেকেই যায়, শুধু লুকায়TSan এখনো race দেখাবে
লুপের &i thread-কে পাঠানোদুটি thread একই নম্বর পায়প্রতিটি thread-এর id ছাপিয়ে দেখা
pthread_cond_wait-কে if-এর ভেতরে রাখাখালি queue থেকে পড়াকোড পড়েই ধরা যায়: while আছে কি?

AI-কে thread কোড লিখতে বললে নিয়মগুলো আগেই বলে দিন, আর তাকে নিজের কোডের ঝুঁকি ব্যাখ্যা করতে বাধ্য করুন:

C আর pthread দিয়ে একটি producer-consumer প্রোগ্রাম লেখো: ৫ ঘরের circular buffer, ২টি producer thread, ২টি consumer thread. শর্তগুলো: ১. প্রতিটি ভাগ করা ভ্যারিয়েবলের পাশে comment-এ লেখো কোন mutex সেটিকে রক্ষা করছে। ২. pthread_cond_wait সবসময় while লুপে রাখবে; volatile ব্যবহার করবে না। ৩. একাধিক lock লাগলে সেগুলোর ক্রম কোডের শুরুতে comment-এ লিখে দাও। ৪. প্রতিটি error-এর পথে lock ছাড়া হচ্ছে কি না দেখাও। ৫. শেষে বলো gcc -fsanitize=thread দিয়ে কীভাবে চালাব, আর কোন অংশে race বা deadlock-এর ঝুঁকি সবচেয়ে বেশি, সহজ বাংলায় ব্যাখ্যা করো।

AI-এর লেখা thread কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১৪ core-এর মেশিনে ৪টির জায়গায় ৬৪টি thread দিলে প্রোগ্রাম অনেক সময় আরও ধীর হয়ে যায় কেন?

কারণ thread নিজে কোনো কাজ করে না — core কাজ করে। ৪টি core থাকলে এক মুহূর্তে বড়জোর ৪টি thread সত্যিই চলতে পারে। বাকি ৬০টি পালার অপেক্ষায় থাকে, আর তাদের বাড়তি উপস্থিতি কয়েকভাবে খরচ বাড়ায়:

  • Context switch: প্রতিবার thread বদলাতে register সংরক্ষণ ও ফিরিয়ে আনতে হয়, আর নতুন thread-এর ডেটা cache-এ না থাকায় শুরুতে অনেক cache miss হয়। কাজের চেয়ে বদলানোয় সময় বেশি যেতে পারে।
  • Lock contention: সবাই একই mutex চাইলে যতই thread দিন, critical section-এ এক সময়ে একজনই। বাকিরা ঘুমায়-জাগে, আর প্রতিটি ঘুমানো-জাগানোর জন্য operating system-কে ডাকতে হয়।
  • False sharing: আলাদা thread-এর আলাদা counter একটি অ্যারেতে পাশাপাশি রাখলে সেগুলো একই ৬৪ বাইটের cache line-এ পড়ে। প্রতিটি লেখায় সেই লাইন এক core থেকে আরেক core-এ দৌড়ায়। সমাধান হিসেবে প্রতিটি counter-কে আলাদা cache line-এ রাখতে padding দেওয়া হয়, অথবা প্রত্যেকে local-এ হিসাব রেখে শেষে একবার যোগ করে।
  • মেমরি: প্রতিটি thread-এর নিজের stack লাগে — অনেক সিস্টেমে ডিফল্ট কয়েক মেগাবাইট। হাজারো thread মানে বিপুল মেমরি সংরক্ষণ।

এ কারণেই হিসাব-নির্ভর কাজে (CPU-bound) thread-এর সংখ্যা সাধারণত core-এর সংখ্যার সমান রাখা হয়। আবার যে কাজে thread বেশিরভাগ সময় নেটওয়ার্ক বা ডিস্কের অপেক্ষায় থাকে (I/O-bound), সেখানে core-এর চেয়ে বেশি thread লাভজনক হতে পারে, কারণ অপেক্ষার সময় অন্যজন core ব্যবহার করে। কতটি thread সবচেয়ে ভালো, তা অনুমান না করে মেপে দেখাই পেশাদার পদ্ধতি।

মূল উপলব্ধি: Parallelism-এর একটি দাম আছে — সমন্বয়ের খরচ। লাভ তখনই আসে যখন প্রতিটি thread-এর হাতে স্বাধীনভাবে করার মতো যথেষ্ট কাজ থাকে আর ভাগ করা জিনিস কম থাকে।

প্র ০২Python-এ বহু বছর ধরে GIL নামের একটি বড় lock ছিল, যাতে এক সময়ে একটিই thread Python কোড চালায়। এমন নকশা কেন, আর এখন কেন বদলানোর চেষ্টা?

Python-এর মূল interpreter CPython নিজেই C-তে লেখা। এর ভেতরে প্রতিটি object-এর একটি reference count থাকে — কতজন object-টি ব্যবহার করছে তার হিসাব। এই সংখ্যা সারাক্ষণ বাড়ে-কমে। একাধিক thread একসাথে বাড়ালে-কমালে ঠিক এই পর্বের lost update ঘটত: count ভুল হয়ে object আগেভাগে মুছে যেত, বা কখনোই মুছত না।

প্রতিটি object-এ আলাদা lock দেওয়া ধীর আর জটিল। তাই নব্বইয়ের দশকে একটি সরল সমাধান নেওয়া হয়: পুরো interpreter-এর জন্য একটিই lock — Global Interpreter Lock (GIL). এর সুবিধা অনেক: interpreter সহজ, একটি thread-এর প্রোগ্রাম দ্রুত, আর C-তে লেখা extension লাইব্রেরিগুলো thread নিয়ে কম চিন্তা করে লেখা যেত। সে সময় বেশিরভাগ কম্পিউটারে একটিই core থাকায় ক্ষতিও কম ছিল।

কিন্তু multi-core যুগে এটি বড় সীমা হয়ে দাঁড়ায়: ১৬ core-এর মেশিনে Python-এর হিসাব-নির্ভর thread কোড মূলত একটি core-এই চলে। মানুষ তাই multiprocessing (আলাদা process, পর্ব ৪৮) ব্যবহার করতেন, অথবা ভারী কাজ C-তে লেখা NumPy-র মতো লাইব্রেরিতে পাঠাতেন, যারা কাজের সময় GIL ছেড়ে দেয়।

২০২৩ সালে PEP 703 নামের প্রস্তাব গৃহীত হয়, যার লক্ষ্য GIL ছাড়া CPython. Python 3.13-এ এটি পরীক্ষামূলক, আলাদা একটি build হিসেবে আসে, আর পরের version-গুলোতে ধাপে ধাপে পরিণত করা হচ্ছে। এর জন্য reference counting-এর নতুন কৌশল, thread-নিরাপদ memory allocator, আর হাজারো extension লাইব্রেরি নতুন করে যাচাই করতে হচ্ছে — কারণ এত বছর তারা GIL-এর সুরক্ষার ওপর ভরসা করে লেখা।

মূল উপলব্ধি: একটি বড় lock হলো সরলতা বনাম parallelism-এর ক্লাসিক trade-off. শুরুতে যে সিদ্ধান্ত বুদ্ধিমানের, হার্ডওয়্যার বদলালে সেটিই বোঝা হয়ে যায় — আর একবার সারা ইকোসিস্টেম সেটির ওপর দাঁড়িয়ে গেলে বদলাতে বহু বছর লাগে।

প্র ০৩Lock ছাড়াই, শুধু atomic দিয়ে নিরাপদ ডেটা স্ট্রাকচার বানানো যায় — একে lock-free programming বলে। তাহলে সবাই কেন lock-free লেখে না?

Lock-free কাঠামোর মূল হাতিয়ার compare-and-swap (CAS): “যদি মেমরিতে এখনো পুরোনো মান থাকে, তবেই নতুন মান বসাও — নইলে জানাও যে কেউ আগে বদলে দিয়েছে।” C11-এ এটি atomic_compare_exchange_strong. কোনো thread এখানে ঘুমায় না; ব্যর্থ হলে আবার পড়ে আবার চেষ্টা করে।

সুবিধা বাস্তব:

  • কোনো thread lock ধরে থাকা অবস্থায় থেমে গেলে বা ধীর হলে অন্যরা আটকে যায় না — deadlock আর priority inversion-এর ভয় নেই।
  • খুব বেশি contention-এ অনেক সময় দ্রুততর, কারণ operating system-কে ডেকে ঘুমানো-জাগানোর খরচ নেই।
  • Signal handler বা real-time সিস্টেমের মতো জায়গায় যেখানে lock নেওয়াই নিষেধ, সেখানে এটিই উপায়।

কিন্তু দাম অনেক:

  • ABA সমস্যা: আপনি মান A পড়লেন; মাঝে আরেক thread সেটি B করে আবার A করল। CAS দেখে “এখনো A” — সফল হয়, অথচ ভেতরের অবস্থা বদলে গেছে। লিংকড লিস্টে এতে মুছে ফেলা node-এর ঠিকানা আবার ব্যবহার হয়ে যেতে পারে।
  • মেমরি ফেরত দেওয়া: কোনো node কখন free করা নিরাপদ, যখন অন্য thread হয়তো এখনো তার ঠিকানা ধরে আছে? এর জন্য hazard pointer বা epoch-এর মতো জটিল কৌশল লাগে।
  • Memory ordering: আধুনিক CPU আর compiler মেমরির কাজের ক্রম বদলাতে পারে। কোন atomic-এ কোন ordering লাগবে, তা ভুল হলে বাগ শুধু নির্দিষ্ট CPU-তে দেখা দেয়।
  • প্রমাণ কঠিন: lock-free queue-র মতো প্রকাশিত অ্যালগরিদমেও বছরের পর বছর পরে বাগ ধরা পড়েছে।

এজন্য পেশাদার পরামর্শ হলো: আগে সাধারণ mutex দিয়ে লিখুন, মেপে দেখুন। শুধু যেখানে মাপা তথ্য বলছে lock-ই আসল বাধা, সেখানে পরীক্ষিত লাইব্রেরির lock-free কাঠামো ব্যবহার করুন — নিজে বানাবেন না।

মূল উপলব্ধি: জটিলতা কোথাও না কোথাও থাকে। Lock সেটি একটি সহজ নিয়মে আটকে রাখে; lock-free সেটি অ্যালগরিদমের ভেতরে ছড়িয়ে দেয়। বেশি গতির দাম প্রায়ই বেশি জটিলতা।

প্র ০৪একটি ব্যাংকের ডেটাবেসে একই সেকেন্ডে হাজারো লেনদেন হয়। অংশ ৪-এর মতো দ্বিগুণ টাকা তোলার সমস্যা সেখানে কীভাবে ঠেকানো হয়?

ডেটাবেস আসলে এই পর্বের ধারণাগুলোরই এক বিশাল, পরিণত প্রয়োগ। মূল হাতিয়ার হলো transaction — কয়েকটি কাজকে এক প্যাকেটে বেঁধে বলা: “হয় সবগুলো হবে, নয় একটিও না।” এর নিশ্চয়তাগুলো একসাথে ACID নামে পরিচিত:

  • Atomicity: টাকা কাটা আর অন্য অ্যাকাউন্টে যোগ — দুটি একসাথে হবে, মাঝপথে বিদ্যুৎ গেলেও আধা কাজ থাকবে না।
  • Consistency: “ব্যালান্স ঋণাত্মক হবে না” — এমন নিয়ম কোনো transaction ভাঙতে পারবে না।
  • Isolation: একসাথে চলা transaction-গুলো একে অপরের আধা-কাজ দেখবে না — ঠিক mutex-এর উদ্দেশ্য।
  • Durability: “সফল” বলার পর তথ্য আর হারাবে না।

Isolation পেতে ডেটাবেস নানা কৌশল ব্যবহার করে। একটি হলো row-level lock: অ্যাকাউন্টের সারিটি পড়ার সময়ই “লেখার জন্য” lock করা (SQL-এ SELECT ... FOR UPDATE), তাই দ্বিতীয় লেনদেন প্রথমটি শেষ হওয়া পর্যন্ত অপেক্ষা করে, তারপর নতুন ব্যালান্স দেখে বাতিল হয়। আরেকটি হলো MVCC (multi-version concurrency control): প্রতিটি পরিবর্তনে সারির নতুন সংস্করণ তৈরি হয়, ফলে পাঠকেরা লেখকদের আটকায় না। PostgreSQL ও MySQL-এর InnoDB এই ধারণা ব্যবহার করে।

আর deadlock? হাজারো transaction যেকোনো ক্রমে সারি lock করে, তাই lock ordering সবসময় চাপিয়ে দেওয়া যায় না। ডেটাবেস তাই একটি “কে কার জন্য অপেক্ষা করছে” গ্রাফ রাখে। সেখানে cycle (পর্ব ৩৮-এর গ্রাফ!) পেলে একটি transaction বাতিল করে বাকিদের এগোতে দেয়, আর বাতিল হওয়াটি আবার চেষ্টা করে। অর্থাৎ ডেটাবেস deadlock এড়ায় না, ধরে ফেলে ভেঙে দেয়।

মূল উপলব্ধি: Mutex, deadlock, atomicity — এই পর্বের প্রতিটি শব্দ ডেটাবেসের ভেতরে আরও বড় আকারে বেঁচে আছে। যিনি thread বোঝেন, তিনি ডেটাবেসের isolation level আর deadlock error-এর মানেও বোঝেন।

প্র ০৫Node.js প্রায় একটি thread-এ চলে, nginx অল্প কয়েকটি worker দিয়ে হাজারো সংযোগ সামলায়। Thread ছাড়াই এত কাজ কীভাবে? Event loop কি thread-এর চেয়ে ভালো?

একটি web server-এর সময় কোথায় যায় ভাবুন। একটি অনুরোধের ৯৯% সময় হয়তো কাটে অপেক্ষায় — নেটওয়ার্ক থেকে ডেটা আসার, ডেটাবেসের উত্তরের, ডিস্ক পড়ার। প্রতিটি সংযোগের জন্য একটি thread দিলে বেশিরভাগ thread শুধু ঘুমিয়ে থাকে, অথচ প্রত্যেকে নিজের stack-এর মেমরি দখল করে রাখে। নব্বইয়ের দশকের শেষে Dan Kegel এই সমস্যার নাম দেন “C10K problem” — একটি মেশিনে একসাথে দশ হাজার সংযোগ কীভাবে সামলানো যায়।

উত্তর ছিল event loop: একটি thread operating system-কে বলে, “এই হাজার সংযোগের যেকোনোটিতে নতুন ডেটা এলে আমাকে জানাও” (Linux-এ epoll). তারপর যেটি প্রস্তুত, সেটির ছোট্ট কাজ সেরে পরেরটিতে যায়। কেউ কারও জন্য বসে থাকে না। nginx এভাবে কয়েকটি worker process দিয়ে বিপুল সংখ্যক সংযোগ সামলায়, আর Node.js-এর JavaScript কোড একটি event loop-এ চলে (ভেতরে ফাইল পড়ার মতো কাজের জন্য libuv লাইব্রেরি একটি ছোট thread pool রাখে)।

সুবিধা: একটি thread-এ data race হয় না — আপনার কোডের দুটি অংশ কখনো একই মুহূর্তে চলে না, তাই mutex লাগে না। মেমরি কম লাগে।

অসুবিধা: কোনো একটি কাজ যদি দীর্ঘ হিসাব করে (যেমন বড় ছবি প্রক্রিয়াকরণ), পুরো loop আটকে যায় — বাকি হাজার ব্যবহারকারী অপেক্ষায়। আর একটি thread মানে একটি core; বাকি core কাজে লাগাতে আবার কয়েকটি process বা worker লাগে। তাছাড়া race পুরোপুরি যায় না: দুটি async কাজের মাঝখানে অন্য কাজ ঢুকে ভাগ করা অবস্থা বদলে দিতে পারে — শুধু বিন্দুগুলো কম ও চোখে পড়ার মতো।

বাস্তবের বড় সিস্টেম তাই দুটো মেশায়: প্রতিটি core-এ একটি event loop, আর ভারী হিসাবের জন্য আলাদা thread pool. Go ভাষার goroutine বা Rust-এর async-ও এই দুই জগৎকে জোড়ার চেষ্টা।

মূল উপলব্ধি: “অপেক্ষা-নির্ভর” কাজে event loop, “হিসাব-নির্ভর” কাজে thread — প্রশ্নটি কোনটি ভালো নয়, আপনার প্রোগ্রামের সময় কোথায় যাচ্ছে।

অনুশীলন

বেশিরভাগ কাজে কোড লিখতে হবে না — দেখা, অনুমান করা আর হিসাব করাই আসল কাজ।

  1. Race-কে লুকিয়ে যেতে দেখুন: অংশ ৪-এর race.c পাঁচবার চালিয়ে plain++-এর সংখ্যাগুলো লিখে রাখুন। এবার N-কে 1000 করে আবার পাঁচবার চালান। কী দেখলেন, আর এর থেকে টেস্টিং নিয়ে কী শিক্ষা?

    ১০ লাখে প্রতিবার আলাদা, ২০ লাখের কম একটি সংখ্যা আসার সম্ভাবনা বেশি। ১,০০০-এ প্রায় সবসময় ঠিক ২,০০০ আসবে, matched: yes! কারণ এত ছোট লুপ একটি thread এক পালার মধ্যেই শেষ করে ফেলে — দ্বিতীয় thread চালু হওয়ার আগেই প্রথমটি শেষ, তাই interleaving-ই ঘটে না।

    শিক্ষা: বাগ আছে, কিন্তু ছোট টেস্টে ধরা পড়ছে না। আসল ব্যবহারে (বেশি ডেটা, বেশি core, বেশি ব্যবহারকারী) গিয়ে একদিন হঠাৎ দেখা দেবে। এজন্যই thread কোড ছোট উদাহরণে “পাস” করলেই বিশ্বাস করা যায় না — TSan দিয়ে যাচাই আর বড় মাপের stress test লাগে।

  2. Output অনুমান করুন: একটি প্রোগ্রাম লুপে pthread_create(&t[i], NULL, worker, &i) দিয়ে ৪টি thread চালায়, আর worker শুধু printf("I am %d\n", *(int *)arg) ছাপে। কী কী ছাপা হতে পারে? কীভাবে ঠিক করবেন?

    প্রায় যেকোনো কিছু: I am 1, I am 2, I am 2, I am 4, অথবা চারবারই I am 4. সবাই একই ঠিকানা থেকে পড়ছে, আর পড়ার মুহূর্তে লুপ i-কে কতদূর নিয়ে গেছে তার ওপর মান নির্ভর করে। ৪ ছাপা হলে বুঝবেন thread লুপ শেষ হওয়ার পর পড়েছে — তখন i আসলে তার scope-এর বাইরে, তাই এটি undefined behavior-ও।

    সমাধান: int id[4]; রেখে লুপে id[i] = i; তারপর &id[i] পাঠান — প্রত্যেকের আলাদা ঘর, যা join পর্যন্ত বেঁচে থাকে। অংশ ৫-এর Range r[4] ঠিক এই কৌশল।

  3. টাকা পাঠানোর deadlock: একটি ফাংশন transfer(from, to, amount) আগে from-এর mutex নেয়, তারপর to-এর। একই সময়ে রহিম করিমকে আর করিম রহিমকে টাকা পাঠালেন। কী হতে পারে? কোনো অ্যাকাউন্টকে আলাদা গুরুত্ব না দিয়ে কীভাবে ঠিক করবেন?

    প্রথম thread রহিমের lock নিল, দ্বিতীয়টি করিমের। এখন প্রথমটি করিমের জন্য, দ্বিতীয়টি রহিমের জন্য অপেক্ষা করছে — অংশ ৬-এর হুবহু deadlock. Lock-এর ক্রম এখানে নির্ভর করছে কে কাকে পাঠাচ্ছেন তার ওপর, তাই দুই দিকের লেনদেনে ক্রম উল্টে যায়।

    সমাধান: ক্রম ঠিক করুন এমন কিছু দিয়ে যা লেনদেনের দিকের ওপর নির্ভর করে না — যেমন অ্যাকাউন্ট নম্বর। নিয়ম: “যে অ্যাকাউন্টের নম্বর ছোট, তার lock আগে।” তাহলে রহিম→করিম আর করিম→রহিম দুটোই একই অ্যাকাউন্টের lock আগে চাইবে, আর circular wait অসম্ভব।

  4. Amdahl দিয়ে হিসাব: একটি ছবি-প্রক্রিয়াকরণ প্রোগ্রামের ২৫% সময় যায় ফাইল পড়া আর লেখায় (ভাগ করা যায় না), বাকি ৭৫% প্রতিটি pixel-এ আলাদা হিসাব। ৮ core-এ সর্বোচ্চ কত গুণ দ্রুত হবে? অসীম core-এ?

    s = 0.25, N = 8. গতি-বৃদ্ধি = 1 ÷ (0.25 + 0.75 ÷ 8) = 1 ÷ (0.25 + 0.09375) = 1 ÷ 0.34375 ≈ ২.৯১ গুণ. ৮টি core দিয়েও তিন গুণও নয়!

    অসীম core-এ: 1 ÷ 0.25 = ৪ গুণ — এর বেশি কখনো নয়। তাই এখানে আরও core কেনার চেয়ে ফাইল পড়া-লেখার অংশটি দ্রুত করা (যেমন দ্রুততর SSD, বা পড়ার সাথে সাথে হিসাব শুরু করা) বেশি লাভজনক।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমProducer thread একটি জিনিস buffer-এ রাখবে — ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🔒 pthread_mutex_lock — mutex নেওয়া
  2. 🔁 while buffer ভরা: pthread_cond_wait(&not_full, &m)
  3. 📦 জিনিসটি buffer-এ রাখা, count++
  4. 🔔 pthread_cond_signal(&not_empty) — consumer-কে জানানো
  5. 🔓 pthread_mutex_unlock — mutex ছেড়ে দেওয়া

একই process-এর দুটি thread-এর মধ্যে কোনটি ভাগ করা থাকে না?

  • Global ভ্যারিয়েবল
  • Stack ও তার local ভ্যারিয়েবল
  • Heap মেমরি
  • প্রোগ্রামের কোড

দুটি thread একই counter-এ দশ লাখ বার করে counter++ করল, কোনো lock ছাড়া। ফল ২০ লাখের কম এলো কেন?

  • Overflow হয়েছে
  • কিছু thread লুপ শেষ করার আগেই থেমে গেছে
  • counter++ আসলে পড়া-যোগ-লেখা তিনটি ধাপ, মাঝে আরেক thread ঢুকে যোগ মুছে দেয়
  • Compiler ভুল কোড তৈরি করেছে

একজন বললেন, “counter-কে volatile long করে দিলেই race মিটে যাবে।” আপনার উত্তর?

  • ঠিক — volatile মানে সব thread একই মান দেখবে
  • ঠিক, তবে কোড একটু ধীর হবে
  • volatile আসলে একটি mutex-এর মতো lock নেয়
  • ভুল — volatile পড়া-যোগ-লেখাকে অবিভাজ্য করে না; লাগবে mutex বা atomic

Thread X আগে lock A তারপর B নেয়; Thread Y আগে B তারপর A. সবচেয়ে সহজ নিশ্চিত সমাধান কোনটি?

  • দুজনেই সবসময় একই ক্রমে নেবে — আগে A, তারপর B
  • মাঝখানে একটু sleep দেওয়া
  • আরও বেশি thread চালানো
  • দুটি lock-ই সরিয়ে দেওয়া

pthread_cond_wait সবসময় while লুপে রাখতে হয় কেন, if-এ নয়?

  • if দিলে compile error হয়
  • জেগে ওঠার পর শর্ত আবার যাচাই করতে হয় — অন্যজন আগে নিয়ে নিতে পারে, বা কারণ ছাড়াই জাগতে পারে
  • while লুপ দ্রুত চলে
  • while না দিলে mutex ছাড়া হয় না

একটি প্রোগ্রামের ২০% অংশ ভাগ করা যায় না। যত core-ই দিন, সর্বোচ্চ কত গুণ দ্রুত হতে পারে?

  • ২০ গুণ
  • যত core, তত গুণ — সীমা নেই
  • ৫ গুণ
  • ১.২৫ গুণ

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Thread একই process-এর মেমরি ভাগ করে; নিজের শুধু stack আর register. যেকোনো দুটি instruction-এর মাঝে thread থেমে যেতে পারে।
  2. counter++ = পড়া → যোগ → লেখা; সুরক্ষা ছাড়া ভাগ করা ডেটায় লেখা মানে data race, যা C11-এ undefined behavior. volatile সমাধান নয়।
  3. Mutex দিয়ে critical section ঘিরুন, ছোট রাখুন, প্রতিটি পথে unlock করুন; একটি সংখ্যার জন্য atomic.
  4. Deadlock-এর চার শর্তের একটি ভাঙুন — সবচেয়ে সহজ সব lock একই ক্রমে. অপেক্ষা = condition variable + while.
  5. বেশি thread ≠ বেশি গতি (Amdahl's law, contention, false sharing); thread কোড যাচাই করুন TSan আর stress test দিয়ে।

পরের পর্বে: এতক্ষণ সব thread ছিল একই কম্পিউটারে। এবার দুটি আলাদা কম্পিউটার কথা বলবে — TCP socket দিয়ে নিজের ছোট্ট server আর client বানাব, আর দেখব অনেক client একসাথে এলে এই পর্বের thread কীভাবে কাজে লাগে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান