এই পর্বে যা শিখবেন
pthread_create-এর function pointer আরvoid*argument কীভাবে কাজ করে, আর&iবাগটি কেন হয় ও কীভাবে সারাবেন- Join, return value,
pthread_exit, joinable বনাম detached থ্রেড, stack size-এর মতো attribute, আর থ্রেডের পরিচয় (pthread_self, kernel-এর TID) - একটি বড় অ্যারের যোগফল কয়েকটি থ্রেডে ভাগ করে নির্ভরযোগ্যভাবে মেলানো
- Data race নিজের চোখে দেখা, register ধরে lost update বোঝা, mutex দিয়ে সারানো (সংক্ষেপে; পূর্ণ তত্ত্ব পর্ব ২২–২৪-এ)
- ছোট একটি thread pool; C11, C++, Java, Python-এ একই ধারণা; আর ThreadSanitizer ও Helgrind দিয়ে বাগ ধরা
১ · Pthreads-এর মানচিত্র — কোন ফাংশন কী করে
পর্ব ১৪-এ দেখেছেন থ্রেড কী: একটি process-এর ভেতরে একাধিক নির্বাহ-ধারা, যারা একই মেমরি ভাগ করে কিন্তু প্রত্যেকের নিজের stack আর register থাকে। এবার হাতে-কলমে কাজ। Linux, macOS, FreeBSD-এর মতো Unix-ধাঁচের সিস্টেমে থ্রেড তৈরির প্রচলিত উপায় হলো POSIX threads, সংক্ষেপে pthreads. এটি POSIX মানদণ্ডের অংশ — IEEE Std 1003.1c, ১৯৯৫ সালে প্রকাশিত। ফলে একই C কোড এসব OS-এ প্রায় অপরিবর্তিত চলে।
একটি রেস্তোরাঁর রান্নাঘর ভাবুন। রান্নাঘর (প্রসেস) একটিই — একই চুলা, একই ফ্রিজ, একই মশলার তাক — আর সেখানে কয়েকজন বাবুর্চি (থ্রেড) একসাথে কাজ করেন। হেড শেফ (main thread) একজন সহকারীকে একটি অর্ডার স্লিপ ধরিয়ে দেন (pthread_create আর তার argument), পরে প্লেট হাতে পাওয়ার অপেক্ষা করেন (pthread_join). কারও কাজের ফল কাউকে জানাতে হয় না, যেমন রাতে ঘর পরিষ্কার (detached থ্রেড)। আর দুজন একসাথে একই হাঁড়িতে লবণ দিলে কী হয়, তা অংশ ৫-এ।
পুরো API-তে শতাধিক ফাংশন থাকলেও রোজকার কাজের জন্য অল্প কয়েকটিই যথেষ্ট:
| ফাংশন | কাজ | রান্নাঘরের ভাষায় |
|---|---|---|
pthread_create | নতুন থ্রেড চালু, একটি ফাংশন আর একটি void* argument দিয়ে | সহকারীকে স্লিপ ধরিয়ে কাজে পাঠানো |
pthread_join | থ্রেড শেষ হওয়া পর্যন্ত অপেক্ষা, তার ফেরত মান নেওয়া | প্লেট হাতে পাওয়ার অপেক্ষা |
pthread_exit | যেকোনো জায়গা থেকে নিজের থ্রেড শেষ করা | “আমার কাজ শেষ” বলে বেরিয়ে যাওয়া |
pthread_detach | থ্রেডকে “কেউ join করবে না” চিহ্ন দেওয়া; শেষ হলে নিজেই পরিষ্কার | যে কাজের ফল কাউকে জানাতে হয় না |
pthread_self, pthread_equal | নিজের পরিচয় জানা, দুটি পরিচয় তুলনা | নিজের নামের ব্যাজ দেখা |
pthread_attr_* | Stack size, detach অবস্থার মতো বৈশিষ্ট্য ঠিক করা | কাজে পাঠানোর আগে টেবিলের আকার ঠিক করা |
pthread_mutex_*, pthread_cond_* | Mutex আর condition variable — সমন্বয়ের হাতিয়ার | একটিমাত্র চামচ, আর অর্ডার এলে ডাকার ঘণ্টা |
শুরুর আগে তিনটি ব্যবহারিক কথা মনে রাখুন:
- Header আর compile:
#include <pthread.h>, আর compile করার সময়gcc -pthread prog.c. glibc 2.34 (২০২১) থেকে pthread মূল C library-তেই, তাই আধুনিক Linux-এ flag ছাড়াও প্রায়ই link হয়; তবু-pthreadদেওয়াই নিয়ম, কারণ পুরোনো সিস্টেম বা অন্য OS-এ এটি ছাড়া চলবে না। - ত্রুটি আসে ফেরত মানে, errno-তে নয়: pthread ফাংশন সফল হলে 0 দেয়, ব্যর্থ হলে সরাসরি একটি error নম্বর ফেরত দেয় (যেমন
EAGAIN— আর থ্রেড বানানোর সম্পদ নেই)। তাইperrorনয়,strerror(rc)ব্যবহার করুন। পর্ব ৫-এর system call-এর নিয়ম থেকে এটি আলাদা। - OS-ভেদে পার্থক্য: Linux আর macOS-এ pthreads আসল API. Windows-এর নিজস্ব API হলো
CreateThread/_beginthreadexআরWaitForSingleObject(অংশ ৭); সেখানে pthreads পেতে MinGW-w64-এর winpthreads-এর মতো আলাদা library লাগে।
২ · pthread_create — function pointer আর void* argument
ফাংশনটির ঘোষণা প্রথম দেখায় ভয় লাগতে পারে:
int pthread_create(pthread_t *thread, /* out: new thread's id */
const pthread_attr_t *attr, /* NULL = default settings */
void *(*start)(void *), /* function the thread runs */
void *arg); /* the one argument it gets */টুকরো করে পড়লে সহজ। প্রথমটিতে নতুন থ্রেডের পরিচয় লেখা হবে। দ্বিতীয়টি attribute — NULL দিলে সব ডিফল্ট। তৃতীয়টি একটি function pointer: এমন একটি ফাংশনের ঠিকানা, যা একটি void* নেয় আর একটি void* ফেরত দেয়। নতুন থ্রেড এই ফাংশন থেকেই জীবন শুরু করে, আর ফাংশনটি ফিরলেই থ্রেড শেষ। চতুর্থটি সেই ফাংশনকে দেওয়ার একমাত্র argument.
কেন void*? C-তে template নেই, আর library জানে না আপনার থ্রেডের কী লাগবে — একটি সংখ্যা, না দশটি জিনিসের একটি struct. void* হলো “যেকোনো কিছুর ঠিকানা”: আপনি ঠিকানা পাঠান, থ্রেডের ফাংশন সেটিকে আসল ধরনে ফিরিয়ে নেয়। বেশি তথ্য হলে সব একটি struct-এ ভরে তার ঠিকানা দিন। (Function pointer নিয়ে আরও অনুশীলন চাইলে দেখুন C কোর্সের function pointer পর্ব।)
নিচের প্রোগ্রামে তিনটি থ্রেড, প্রত্যেকের নিজের একটি struct job. থ্রেড নিজের struct পড়ে কাজ করে, ফলও সেখানেই লেখে, আর struct-এর ঠিকানাই ফেরত দেয়। সঙ্গে দেখা হবে, সব থ্রেডের getpid() এক, কিন্তু kernel-এর দেওয়া thread id (gettid(), Linux-এ glibc 2.30 থেকে) আলাদা:
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
struct job { int id, n; long sum; pid_t pid, tid; }; /* one per thread */
static void *worker(void *arg) {
struct job *j = arg; /* void* -> our own struct */
for (int k = 1; k <= j->n; k++) j->sum += k;
j->pid = getpid();
j->tid = gettid(); /* kernel thread id (Linux) */
return j; /* comes back via pthread_join */
}
int main(void) {
pthread_t t[3];
struct job jobs[3];
for (int i = 0; i < 3; i++) {
jobs[i] = (struct job){ .id = i, .n = (i + 1) * 100 };
int rc = pthread_create(&t[i], NULL, worker, &jobs[i]);
if (rc != 0) { printf("create: %s\n", strerror(rc)); return 1; }
}
for (int i = 0; i < 3; i++) {
void *ret;
pthread_join(t[i], &ret); /* wait, and collect the result */
struct job *j = ret;
printf("job %d: sum 1..%d = %ld | same pid as main: %s | tid != pid: %s\n",
j->id, j->n, j->sum, j->pid == getpid() ? "yes" : "no",
j->tid != j->pid ? "yes" : "no");
}
printf("main: tid == pid? %s\n", gettid() == getpid() ? "yes" : "no");
return 0;
}একটি আসল রানের আউটপুট:
job 0: sum 1..100 = 5050 | same pid as main: yes | tid != pid: yes
job 1: sum 1..200 = 20100 | same pid as main: yes | tid != pid: yes
job 2: sum 1..300 = 45150 | same pid as main: yes | tid != pid: yes
main: tid == pid? yes
যোগফলগুলো সূত্রের সাথে মেলে: ১০০ × ১০১ ÷ ২ = ৫০৫০, ২০০ × ২০১ ÷ ২ = ২০১০০, ৩০০ × ৩০১ ÷ ২ = ৪৫১৫০. থ্রেড যে ক্রমেই চলুক, আউটপুট সবসময় এই ক্রমে আসে — কারণ ছাপা হয় শুধু main থেকে, প্রতিটি join-এর পরে। তিনটি থ্রেডই একই প্রসেস (same pid: yes), কিন্তু kernel প্রত্যেককে আলাদা TID দিয়েছে; আর main থ্রেডের TID প্রসেসের PID-এর সমান। আসল সংখ্যাগুলো প্রতিবার বদলায় বলে শুধু সম্পর্ক ছাপা হয়েছে। jobs[] main-এর stack-এ, আর main সব join শেষ না করে ফেরে না — তাই থ্রেড চলার পুরো সময় ঠিকানাগুলো বৈধ।
সবচেয়ে পরিচিত বাগ — লুপের &i পাঠানো
নতুনরা (আর অনেক সময় AI-ও) প্রথমে এভাবে লেখেন — আলাদা struct-এর ঝামেলা না করে সরাসরি লুপের ভ্যারিয়েবলের ঠিকানা:
static void *worker(void *p) {
int id = *(int *)p; /* reads main's i -- whenever it gets the CPU */
printf("thread %d\n", id);
return NULL;
}
/* in main: */
int i;
for (i = 0; i < 3; i++)
pthread_create(&t[i], NULL, worker, &i); /* BUG: same &i for all */
for (int j = 0; j < 3; j++)
pthread_join(t[j], NULL);আশা ছিল thread 0, thread 1, thread 2. এই কোডটি একই অনলাইন রানারে তিনবার চালিয়ে আসলে পাওয়া গেছে:
run 1: thread 1, thread 3, thread 3
run 2: thread 1, thread 3, thread 3
run 3: thread 3, thread 3, thread 3
একবারও 0 বা 2 আসেনি, অথচ 3 এসেছে — যে মানে কোনো থ্রেডই তৈরি হয়নি! কারণ pthread_create থ্রেডকে শুধু “চলার জন্য প্রস্তুত” করে ফেরে; কখন CPU পাবে, ঠিক করে scheduler. ততক্ষণে main-এর লুপ এগোয়, আর সবার হাতে একই &i; থ্রেড *p পড়ার মুহূর্তে i-তে যা আছে, তা-ই পায়। নিচের ডায়াগ্রামে run 1-এর মতো একটি সময়রেখা ধাপে ধাপে দেখুন:
1void *worker(void *p) {2 int id = *(int *)p; // read later!3 printf("thread %d\n", id); }4int i;5for (i = 0; i < 3; i++)6 pthread_create(&t[i], NULL, worker, &i);7for (int j = 0; j < 3; j++) pthread_join(t[j], NULL);8// fix: ids[i] = i; pass &ids[i]
| main-এর i | |
| T0 পড়ল | |
| T1 পড়ল | |
| T2 পড়ল | |
| সবার arg |
- i = 0, T0 তৈরি। main
pthread_createডাকে, argument হিসেবে দেয়&i— মানটি নয়, ঠিকানা। T0 এখন “প্রস্তুত”, কিন্তু এখনো CPU পায়নি, তাই কিছুই পড়েনি। - লুপ এগোল: i = 1, T1 তৈরি।
pthread_createফিরে আসার জন্য নতুন থ্রেডের চলা শুরু করা জরুরি নয়। main থামেনি, তাইi++হয়ে 1, আর T1-ও পেল একই&i. T0 এখনো অপেক্ষায়। - T0 অবশেষে CPU পেল। সে
*pপড়ে — কিন্তু ঠিকানাটিতে এখন 1. তাই T0 ছাপে “thread 1”. যে থ্রেডকে 0 দেওয়ার কথা ছিল, সে কখনো 0 দেখল না। - i = 2, T2 তৈরি। main আবার এগোয়। T1 আর T2 দুজনেই চলার জন্য প্রস্তুত, কিন্তু কেউ এখনো
*pপড়েনি। - i = 3, লুপ শেষ।
i < 3মিথ্যা, তাই main join-এর লুপে গিয়ে T0-র জন্য অপেক্ষা শুরু করে।i-তে এখন 3 — যে মান কোনো থ্রেডকে দেওয়ার কথাই ছিল না। - T1 চলল, পড়ল 3. T1 ছাপে “thread 3”. একটি বাড়তি বিপদও আছে:
iযদিfor (int i …)-এর ভেতরে ঘোষিত হতো, লুপ শেষে তার আয়ু শেষ হয়ে যেত, আর থ্রেড পড়ত একটি অবৈধ ঠিকানা — যা C-তে undefined behavior. - T2-ও পড়ল 3. ফল “thread 1, thread 3, thread 3” — অনলাইন রানারের run 1-এর হুবহু ফল। অন্য রানে scheduler অন্য সিদ্ধান্ত নিলে ফল বদলায় (run 3-এ তিনটিই 3). এমন বাগ যা প্রতিবার বদলায়, তা-ই সবচেয়ে কঠিন বাগ।
- সমাধান: প্রত্যেকের নিজের ঘর।
int ids[3]নিয়েids[i] = iরাখুন, আর পাঠান&ids[i]. এখন তিনটি আলাদা ঠিকানা, কেউ কারও মান বদলায় না, তাই প্রত্যেকে নিজের 0, 1, 2 পায়। (ছাপার ক্রম তবু বদলাতে পারে; সেটি পরের অংশে সামলাব।)
থ্রেডকে যে ঠিকানা দেবেন, তার ডেটা (১) শুধু সেই থ্রেডের হবে, বা পরে কেউ বদলাবে না, আর (২) থ্রেড যতক্ষণ ব্যবহার করবে ততক্ষণ বেঁচে থাকবে। তিনটি নিরাপদ পথ: প্রতিটি থ্রেডের জন্য অ্যারের আলাদা ঘর (main join না করে না ফিরলে), malloc করা struct যা থ্রেড শেষে free করে (detached থ্রেডের জন্য এটিই ঠিক), অথবা ছোট পূর্ণসংখ্যা হলে মানটিকেই (void *)(intptr_t)i করে পাঠানো।
৩ · Join, ফেরত মান, pthread_exit আর detach
pthread_join — অপেক্ষা আর ফল নেওয়া
pthread_join(t, &ret) ডাকলে ডাকা থ্রেডটি ঘুমিয়ে পড়ে, যতক্ষণ না থ্রেড t শেষ হয়। তারপর t-এর ফেরত দেওয়া void* এসে বসে ret-এ। এটি পর্ব ১০-এর waitpid-এর মতো, তবে যেকোনো থ্রেড যেকোনো joinable থ্রেডকে join করতে পারে — শুধু “প্যারেন্ট” নয়।
একটি বিপজ্জনক ভুল: থ্রেডের ফাংশনের local ভ্যারিয়েবলের ঠিকানা ফেরত দেওয়া। থ্রেড শেষ হলেই তার stack অবৈধ। নিরাপদ উপায়: argument-এর struct-এ ফল লেখা (উপরের প্রোগ্রামের মতো), malloc করা মেমরির ঠিকানা ফেরত দেওয়া (join-কারী পরে free করবে), অথবা ছোট পূর্ণসংখ্যা হলে (void *)(intptr_t)value.
pthread_exit আর main-এর return
থ্রেড শেষ হয় তিনভাবে: start ফাংশন থেকে return, যেকোনো গভীর ফাংশন থেকে pthread_exit(value), অথবা পুরো প্রসেস শেষ হলে। শেষেরটি নিয়ে সাবধান: main থেকে return মানে exit(), আর তাতে প্রসেসের সব থ্রেড সঙ্গে সঙ্গে শেষ — কাজ শেষ হোক বা না হোক। তাই main অন্যদের join করে তারপর ফেরে। আর main নিজে pthread_exit ডাকলে শুধু main শেষ হয়; বাকিরা চলতে থাকে, শেষজন শেষ হলে প্রসেস শেষ।
Joinable বনাম detached
ডিফল্টভাবে প্রতিটি থ্রেড joinable. এমন থ্রেড শেষ হলেও তার কিছু তথ্য (ফেরত মান, পরিচয়, stack-এর মেমরি) রয়ে যায়, যতক্ষণ না কেউ join করে — অনেকটা পর্ব ১০-এর zombie প্রসেসের মতো। কেউ কখনো join না করলে সেই সম্পদ জমতেই থাকে। যে থ্রেডের ফল কারও লাগবে না, তাকে detached করুন: pthread_detach(t) ডাকলে বা attribute-এ PTHREAD_CREATE_DETACHED দিলে থ্রেড শেষ হওয়ার সঙ্গে সঙ্গে নিজের সম্পদ ফিরিয়ে দেয়।
| বিষয় | Joinable (ডিফল্ট) | Detached |
|---|---|---|
| শেষ হলে সম্পদ | join না হওয়া পর্যন্ত থেকে যায় | সঙ্গে সঙ্গে ফেরত যায় |
| ফেরত মান নেওয়া | যায় — pthread_join দিয়ে | যায় না |
| Join করা যায়? | হ্যাঁ, ঠিক একবার | না; চেষ্টা করা undefined behavior |
| কখন ব্যবহার | ফল লাগবে, বা শেষ হওয়া নিশ্চিত জানতে হবে | লগ লেখা, পটভূমির কাজ, “ছেড়ে দিন” ধরনের কাজ |
| সাধারণ ভুল | join ভুলে যাওয়া → ধীরে ধীরে সম্পদ ফুরানো | main আগে ফিরে গেলে কাজ মাঝপথে কেটে যায় |
Attribute — stack size আর অন্যান্য
প্রতিটি থ্রেডের নিজের stack লাগে, আর সেটি আসে প্রসেসের address space থেকে। কত বড় হবে, তা ঠিক হয় thread attribute দিয়ে। নিচের কোডটি ডিফল্ট stack size জানে, ২৫৬ KiB stack-এর একটি detached থ্রেড বানায়, আর pthread_exit দিয়ে একটি মান ফেরত পাঠায়। (এই পাতায় এটি চালানো যাবে না; ডান পাশের মন্তব্যে Wandbox-এর একটি পরীক্ষা-রানের মান দেওয়া হলো।)
static void *bg(void *arg) { (void)arg; return NULL; }
static void *ex(void *arg) { (void)arg; pthread_exit((void *)42); }
/* inside main: */
pthread_attr_t attr;
size_t sz;
pthread_attr_init(&attr);
pthread_attr_getstacksize(&attr, &sz); /* test run: 8192 KiB */
pthread_attr_setstacksize(&attr, 256 * 1024); /* this thread: 256 KiB */
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
pthread_create(&t, &attr, bg, NULL); /* rc = 0 */
pthread_attr_destroy(&attr); /* attr no longer needed */
/* pthread_join(t, NULL) here is a bug: glibc returned EINVAL in our test */
pthread_create(&t2, NULL, ex, NULL);
pthread_join(t2, &ret); /* (long)ret == 42 */
sysconf(_SC_THREAD_STACK_MIN); /* test run: 16384 bytes */Linux-এ glibc ডিফল্ট stack size নেয় ulimit -s থেকে — সাধারণত ৮ MiB, পরীক্ষাতেও তা-ই। এটি ভার্চুয়াল মেমরির হিসাব; যতটুকু সত্যিই ছোঁয়া হয়, ততটুকুই RAM নেয় (পর্ব ৩৫)। অন্য সিস্টেমে ডিফল্ট আলাদা: Windows-এ সাধারণত ১ MB, macOS-এ main ছাড়া অন্য থ্রেডে ৫১২ KiB, আর musl libc (Alpine Linux)-এ অনেক ছোট, সংস্করণভেদে প্রায় ১২৮ KiB. তাই বড় local অ্যারে বা গভীর recursion-ওয়ালা কোড glibc-তে ঠিক চললেও Alpine container-এ stack overflow-তে ভাঙতে পারে। প্রতিটি থ্রেডের stack-এর শেষে glibc একটি guard page রাখে, যাতে overflow হলে নীরবে অন্যের মেমরি নষ্ট না করে সঙ্গে সঙ্গে segmentation fault হয়।
থ্রেডের পরিচয় — pthread_t, TID আর ps -L
থ্রেডের দুই রকম পরিচয় আছে। pthread_self() দেয় একটি pthread_t — library-র নিজস্ব হাতল, যার ভেতরের রূপ OS-ভেদে আলাদা (glibc-তে আসলে একটি ঠিকানা)। তাই দুটি pthread_t == দিয়ে নয়, pthread_equal() দিয়ে তুলনা করতে হয়। অন্যদিকে Linux kernel প্রতিটি থ্রেডকে একটি সংখ্যা দেয় — TID, যা gettid() দেয় আর ps, top-এ দেখা যায়। Linux-এর ভাষায় প্রতিটি থ্রেড একটি “task”; প্রসেসের সব থ্রেড মিলে একটি thread group, আর সেই group-এর id-ই PID. থ্রেডের একটি নামও দেওয়া যায় (pthread_setname_np, Linux-এ ১৫ অক্ষর পর্যন্ত), যা debugging-এ খুব কাজে লাগে।
$ ps -L -o pid,lwp,nlwp,comm -p 4321
PID LWP NLWP COMMAND
4321 4321 4 server
4321 4322 4 server
4321 4323 4 server
4321 4324 4 server
$ ls /proc/4321/task
4321 4322 4323 4324LWP (light-weight process) কলামটিই TID, আর NLWP মোট থ্রেড সংখ্যা। চারটি সারির PID একই — একটি প্রসেস, চারটি থ্রেড; প্রথম সারিতে PID আর TID সমান, এটিই main থ্রেড। Windows-এ একই তথ্য দেখায় Task Manager-এর Details ট্যাবের “Threads” কলাম বা Process Explorer.
আরও গভীরে: Linux-এ glibc-র থ্রেড বাস্তবায়নের নাম NPTL (Native POSIX Thread Library). pthread_create আসলে mmap দিয়ে stack আর guard page বানিয়ে clone system call ডাকে (নতুন glibc-তে সম্ভব হলে clone3), CLONE_VM | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD-এর মতো flag দিয়ে — অর্থাৎ মেমরি, খোলা ফাইল আর signal handler ভাগ করে একই thread group-এ থাকার নির্দেশ। প্রতিটি থ্রেডের thread-local storage (যেমন প্রতিটি থ্রেডের আলাদা errno) x86-64-এ fs segment register দিয়ে খোঁজা হয়। আর pthread_join একটি futex-এ ঘুমায়: CLONE_CHILD_CLEARTID-এর কারণে থ্রেড শেষ হলে kernel একটি নির্দিষ্ট ঘরে TID-এর জায়গায় 0 লিখে সেই futex-এ অপেক্ষারতদের জাগিয়ে দেয়।
৪ · কাজ ভাগ করা — একটি অ্যারের যোগফল কয়েকটি থ্রেডে
থ্রেডের সবচেয়ে সরল ব্যবহার: একটি বড় কাজকে কয়েকটি টুকরোয় ভাগ করে আলাদা core-এ একসাথে চালানো, অর্থাৎ parallelism. উদাহরণ — ১০ লাখ সংখ্যার যোগফল। T সংখ্যক থ্রেড থাকলে থ্রেড id নেবে সূচক id·N/T থেকে (id+1)·N/T-এর ঠিক আগ পর্যন্ত। এই সূত্রে টুকরোগুলো একে অপরের ওপর পড়ে না, কোনো সূচক বাদ যায় না, আর N যদি T দিয়ে ভাগ না যায়, তবু শেষ টুকরো ঠিক N-এ থামে।
পরীক্ষার খাতা দেখার কথা ভাবুন। ১,০০০টি খাতা চারজন পরীক্ষকের মধ্যে ভাগ হলো, প্রত্যেকে ২৫০টি। প্রত্যেকে নিজের খাতার নম্বর যোগ করে নিজের নামের পাশের ঘরে মোট লেখেন। প্রধান পরীক্ষক চারজনের কাজ শেষ হওয়া পর্যন্ত অপেক্ষা করে চারটি ঘর যোগ করেন। কেউ কারও কাগজে হাত দেন না — তাই ঝগড়া নেই, আর ফল প্রতিবার একই।
এই নকশার মূল কৌশল একটি: প্রতিটি থ্রেড শুধু নিজের ঘরে লেখে, আর main সব join শেষে যোগ করে. কোনো দুই থ্রেড একই মেমরিতে লেখে না, তাই কোনো lock লাগে না। আর pthread_join শুধু অপেক্ষাই করে না, একটি নিশ্চয়তাও দেয়: join ফেরার পর থ্রেডটি যা যা লিখেছিল, সবই main দেখতে পাবে। ছোট একটি উদাহরণে ধাপে ধাপে দেখি — ১০টি সংখ্যা, ৩টি থ্রেড:
1a = {3,1,4,1,5,9,2,6,5,3}; N = 10; T = 32lo = id*N/T; hi = (id+1)*N/T;3for (i = lo; i < hi; i++) s += a[i];4partial[id] = s;5pthread_join(t[id], NULL);6total += partial[id];
| T0: a[0..3) | |
| T1: a[3..6) | |
| T2: a[6..10) | |
| partial[] | |
| main | |
| total |
- ভাগের হিসাব। N = 10, T = 3. সূত্র
id*N/T(পূর্ণসংখ্যার ভাগ) দেয় সীমা 0, 3, 6, 10. তাই T0 পায় a[0..3) = 3, 1, 4; T1 পায় a[3..6) = 1, 5, 9; T2 পায় a[6..10) = 2, 6, 5, 3. ১০ ÷ ৩ মেলে না বলে শেষ টুকরোয় একটি বেশি।partial[]global, তাই শুরুতে সব 0. - তিন থ্রেড একসাথে চলে। তিনটি core থাকলে তিনজনই একই মুহূর্তে নিজের প্রথম সংখ্যা যোগ করে: T0-এর s = 3, T1-এর s = 1, T2-এর s = 2. প্রত্যেকের
sনিজের stack-এ — কেউ কারও s দেখে না। main ততক্ষণেpthread_join(T0)-এ ঘুমিয়ে। - দ্বিতীয় সংখ্যা। T0: ৩ + ১ = ৪; T1: ১ + ৫ = ৬; T2: ২ + ৬ = ৮. এখনো কেউ shared মেমরিতে কিছু লেখেনি — সব কাজ local ভ্যারিয়েবলে। এটিই race এড়ানোর প্রথম কৌশল।
- T0 আর T1-এর কাজ শেষ। T0: ৪ + ৪ = ৮, T1: ৬ + ৯ = ১৫. দুজনেই একবারই shared মেমরিতে লেখে — নিজের নিজের ঘরে:
partial[0] = 8,partial[1] = 15. T2-এর এখনো একটি সংখ্যা বাকি (s = ৮ + ৫ = ১৩)। T0 শেষ, তাই main-এর join(T0) এখনই ফিরতে পারে। - T2-ও শেষ। ১৩ + ৩ = ১৬,
partial[2] = 16. T2-এর টুকরো বড় ছিল বলে সে সবার শেষে শেষ করল। অসমান ভাগ বা কোনো core ব্যস্ত থাকলে একটি থ্রেড পিছিয়ে পড়ে, আর মোট সময় নির্ধারণ করে সবচেয়ে ধীর থ্রেডটি — একে বলে load imbalance. - join(T0), তারপর যোগ। join ফেরার পর T0-এর লেখা
partial[0]নিশ্চিতভাবে main দেখতে পায়। total = ০ + ৮ = ৮, আর main ছাপে “thread 0: 8”. থ্রেডগুলো যে ক্রমেই শেষ হোক, main join করে আর ছাপে নির্দিষ্ট ক্রমে — 0, 1, 2. - join(T1). total = ৮ + ১৫ = ২৩. T1 অনেক আগেই শেষ হয়ে থাকলেও join তাৎক্ষণিক ফেরে; ফলে কোনো সময় নষ্ট হয় না।
- join(T2), মোট ৩৯. total = ২৩ + ১৬ = ৩৯. যাচাই: ৩+১+৪+১+৫+৯+২+৬+৫+৩ = ৩৯. Scheduler যেভাবেই থ্রেড চালাক, প্রতিবার একই ফল আর একই আউটপুট — কারণ কেউ অন্যের ঘরে লেখেনি, আর পড়া হয়েছে শুধু join-এর পরে।
এবার আসল আকারে — ১০ লাখ সংখ্যা (1, 2, …, 1000000), ৪টি থ্রেড। থ্রেডের নম্বরটি এখানে ঠিকানা হিসেবে নয়, void*-এর ভেতরে মান হিসেবে পাঠানো হয়েছে (অংশ ২-এর নিয়মের তৃতীয় পথ), আর ফল মেলানো হয়েছে সূত্র N(N+1)/2-এর সাথে:
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#define N 1000000
#define T 4
static int a[N];
static long partial[T]; /* one slot per thread */
static void *sum_part(void *arg) {
long id = (long)(intptr_t)arg; /* small integer carried in void* */
long lo = id * N / T, hi = (id + 1) * N / T;
long s = 0; /* local, private to this thread */
for (long i = lo; i < hi; i++) s += a[i];
partial[id] = s; /* each thread writes ONLY its slot */
return NULL;
}
int main(void) {
for (long i = 0; i < N; i++) a[i] = (int)(i + 1); /* 1, 2, ..., N */
pthread_t t[T];
for (long id = 0; id < T; id++)
pthread_create(&t[id], NULL, sum_part, (void *)(intptr_t)id);
long total = 0;
for (int id = 0; id < T; id++) {
pthread_join(t[id], NULL); /* after join, partial[id] is ready */
printf("thread %d: a[%ld..%ld) -> %ld\n", id,
(long)id * N / T, (long)(id + 1) * N / T, partial[id]);
total += partial[id];
}
long expect = (long)N * (N + 1) / 2;
printf("total = %ld, formula N(N+1)/2 = %ld, match: %s\n",
total, expect, total == expect ? "yes" : "no");
return 0;
}একটি আসল রানের আউটপুট:
thread 0: a[0..250000) -> 31250125000
thread 1: a[250000..500000) -> 93750125000
thread 2: a[500000..750000) -> 156250125000
thread 3: a[750000..1000000) -> 218750125000
total = 500000500000, formula N(N+1)/2 = 500000500000, match: yes
প্রতিটি টুকরো ঠিক ২,৫০,০০০টি সংখ্যা। হাতে যাচাই: থ্রেড 0-এর যোগফল ২৫০০০০ × ২৫০০০১ ÷ ২ = ৩১,২৫,০১,২৫,০০০ — আউটপুটের সাথে হুবহু মেলে, আর চার ঘরের মোট ১০৬ × (১০৬ + ১) ÷ ২. কোনো mutex নেই, তবু প্রোগ্রামটি নিরাপদ, আর আউটপুট প্রতিবার এক। প্রায় ৫০ হাজার কোটির যোগফল int-এর সীমা (প্রায় ২১৪ কোটি) ছাড়িয়ে যায়, তাই long. আর থ্রেড প্রতিটি সংখ্যা local s-এ যোগ করে শেষে একবার partial[id]-এ লিখেছে — কারণ নিচে।
False sharing — ঠিক ফল, কিন্তু ধীর। partial[0]…partial[3] পাশাপাশি ৩২ বাইটে বসে, অর্থাৎ সম্ভবত একই ৬৪ বাইটের cache line-এ। প্রতিটি থ্রেড যদি লুপের প্রতিটি ধাপে নিজের ঘরে লিখত, তাহলে ঘর আলাদা হলেও core-গুলোর cache একই line নিয়ে বারবার টানাটানি করত (পর্ব ৪-এর cache coherence), আর গতি কয়েক গুণ কমে যেতে পারত। একে বলে false sharing. সমাধান: local ভ্যারিয়েবলে জমিয়ে শেষে একবার লেখা (যা উপরে করেছি), বা প্রতিটি ঘরকে আলাদা cache line-এ বসানো (padding). এটি সঠিকতার নয়, গতির সমস্যা — তাই টেস্টে ধরা পড়ে না, শুধু মাপলে দেখা যায়।
৪টি থ্রেড মানেই ৪ গুণ দ্রুত নয়: থ্রেড তৈরি আর join-এর খরচ আছে, অ্যারে যোগে আসল বাধা প্রায়ই মেমরির bandwidth, আর core কম থাকলে থ্রেডগুলো পালা করে একই core-এ চলে — তখন concurrency আছে, parallelism নেই। এই রানারের container-এ মাত্র ২টি CPU (পরের প্রোগ্রামে দেখবেন)। তাত্ত্বিক সীমা (Amdahl-এর সূত্র) পর্ব ১৪-এ।
৫ · Data race — একই counter-এ দুই থ্রেড
এবার উল্টো নকশা: দুটি থ্রেড একই global counter-এ প্রত্যেকে ১০ লাখবার counter++ করবে। গণিত বলে ফল ২০ লাখ। একই প্রোগ্রামে দ্বিতীয়বার একই কাজ হবে mutex দিয়ে। চালিয়ে দেখুন — একাধিকবার চালালে প্রথম সংখ্যাটি প্রায় প্রতিবার আলাদা আসবে:
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#define LOOPS 1000000
static long counter = 0; /* shared by both threads */
static pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
static void *inc_racy(void *arg) {
(void)arg;
for (int i = 0; i < LOOPS; i++) counter++; /* load, add, store */
return NULL;
}
static void *inc_locked(void *arg) {
(void)arg;
for (int i = 0; i < LOOPS; i++) {
pthread_mutex_lock(&m); /* only one thread at a time */
counter++;
pthread_mutex_unlock(&m);
}
return NULL;
}
static long run(void *(*fn)(void *)) {
pthread_t x, y;
counter = 0;
pthread_create(&x, NULL, fn, NULL);
pthread_create(&y, NULL, fn, NULL);
pthread_join(x, NULL); pthread_join(y, NULL);
return counter;
}
int main(void) {
printf("online CPUs: %ld\n", sysconf(_SC_NPROCESSORS_ONLN));
long r1 = run(inc_racy), r2 = run(inc_locked);
printf("without lock: %ld (== 2000000? %s)\n", r1, r1 == 2000000 ? "yes" : "no");
printf("with mutex : %ld (== 2000000? %s)\n", r2, r2 == 2000000 ? "yes" : "no");
return 0;
}একটি আসল রানের আউটপুট (প্রথম সংখ্যাটি প্রতিবার বদলায়):
online CPUs: 2
without lock: 1407041 (== 2000000? no)
with mutex : 2000000 (== 2000000? yes)
Lock ছাড়া এসেছে ১৪,০৭,০৪১ — প্রায় ৫ লাখ ৯৩ হাজার যোগ হারিয়ে গেছে! কোনো error, crash বা compiler warning নেই — শুধু ভুল উত্তর। একে বলে data race: দুটি থ্রেড একই মেমরিতে একসাথে কাজ করছে, অন্তত একটি লিখছে, আর মাঝে কোনো সমন্বয় নেই। Mutex দিয়ে প্রতিবার ঠিক ২০,০০,০০০, তবে ধীরে — প্রতিটি যোগে lock নেওয়া-ছাড়ার খরচ। Container-এ ২টি CPU, তাই দুই থ্রেড সত্যিই একসাথে চলেছে। ১টি CPU-তে race ঘটত শুধু যখন timer interrupt ঠিক load আর store-এর মাঝে থ্রেড বদলায় — অনেক কম, কিন্তু শূন্য নয়। তাই “আমার মেশিনে তো ঠিক আসে” কোনো প্রমাণ নয়।
হারানো যোগ গেল কোথায়? counter++ C-তে এক লাইন, কিন্তু CPU-র কাছে তিন ধাপ: load, add, store. আর পর্ব ৯-এ দেখেছেন, প্রতিটি থ্রেডের register আলাদা — context switch-এ সেগুলো সংরক্ষিত হয়। নিচের ডায়াগ্রামে counter = 5 থেকে শুরু করে দুই থ্রেড একবার করে যোগ করছে; ফল হওয়ার কথা 7:
1// T1 on core 0 (counter++)2mov rax, [counter] ; load3add rax, 1 ; add4mov [counter], rax ; store5// T2 on core 1 (counter++)6mov rax, [counter] ; load7add rax, 1 ; add8mov [counter], rax ; store
| counter (মেমরি) | |
| T1-এর rax | |
| T2-এর rax | |
| প্রত্যাশিত ফল | |
| mutex কার হাতে |
- শুরু: counter = 5. দুটি থ্রেড একই কাজ করবে —
counter++, অর্থাৎ load, add, store. দুটিই শেষ হলে counter হওয়ার কথা 7. দুই থ্রেডেরraxআলাদা register (বা context switch-এ আলাদা করে সংরক্ষিত), এখনো খালি। - T1 load করে। মেমরি থেকে 5 এল T1-এর
rax-এ। মেমরিতে counter এখনো 5 — load কিছু বদলায় না। - T1 যোগ করে। T1-এর
rax= 6. কিন্তু এই 6 আছে শুধু T1-এর register-এ; মেমরিতে এখনো 5. এই মুহূর্তটিই বিপদের জানালা। - T2 load করে — পুরোনো মান। অন্য core-এ T2 (বা একই core-এ timer interrupt-এর পর context switch হয়ে T2) মেমরি পড়ে: 5. T1-এর 6 এখনো মেমরিতে পৌঁছায়নি, তাই T2 জানেই না যে কেউ আগে থেকে যোগ করছে।
- T2 যোগ করে। T2-এর
rax= 6. এখন দুই থ্রেডের হাতেই 6 — দুজনেই 5-এর সাথে 1 যোগ করেছে। - T2 store করে। মেমরিতে counter = 6. এ পর্যন্ত কিছু ভুল মনে হচ্ছে না।
- T1 store করে — হালনাগাদ হারাল। T1 নিজের 6 মেমরিতে লেখে, T2-এর লেখা 6-এর ওপর। শেষ ফল 6, যদিও দুবার যোগ হয়েছে। একটি যোগ চিরতরে হারিয়ে গেল — একে বলে lost update. আগের প্রোগ্রামে ঠিক এটিই লাখ লাখবার ঘটেছে।
- Mutex দিয়ে।
counter++-এর তিন ধাপের চারপাশে lock থাকলে T1 lock নিয়ে load 5, add 6, store 6 করে lock ছাড়ে; T2 ততক্ষণ অপেক্ষা করে, তারপর load 6, add 7, store 7. তিন ধাপের এই অংশটি এখন অবিভাজ্য — যেকোনো সময় একটিই থ্রেড এখানে থাকে। এমন অংশকে বলে critical section. ফল 7, প্রতিবার।
কত কম হতে পারে? — একটি ছোট প্রমাণ
দুটি থ্রেড প্রত্যেকে N বার (N ≥ ২) load-add-store করলে শেষ ফল সর্বোচ্চ ২N — যখন কোনো যোগই মেশে না। কিন্তু সর্বনিম্ন কত? চমকে দেওয়ার মতো উত্তর: ২, N যত বড়ই হোক। এটি বিশ্ববিদ্যালয় ও ইন্টারভিউর একটি প্রিয় প্রশ্ন।
- ২ সম্ভব (নির্মাণ): T1 load করে 0. তারপর T2 একটানা N−১ বার পুরো ++ করে (counter = N−১). T1 store করে 1 (T2-এর সব কাজ মুছে গেল)। এবার T2 load করে 1. তারপর T1 বাকি N−১ বার পুরো ++ করে শেষ করে। শেষে T2 store করে 1 + 1 = 2. মোট ফল 2.
- ২-এর কম অসম্ভব (প্রমাণ): প্রতিটি store লেখে (পড়া মান + ১), আর পড়া মান কখনো ঋণাত্মক নয় — তাই প্রতিটি store-এর মান ≥ ১. শেষ ফল হলো সবশেষ store-এর মান; ধরা যাক সেটি থ্রেড X-এর N-তম (শেষ) ধাপের store. X-এর N-তম load ঘটেছে X-এর নিজের (N−১)-তম store-এর পরে, তাই তখন মেমরিতে কোনো একটি store-এর লেখা মান ছিল (প্রাথমিক 0 আর নেই), অর্থাৎ পড়া মান ≥ ১. তাহলে শেষ store ≥ ১ + ১ = ২. ∎
অর্থাৎ ১০ লাখ করে যোগের ফল তাত্ত্বিকভাবে ২ থেকে ২০,০০,০০০-এর যেকোনো কিছু হতে পারে। (প্রমাণটি ধরে নেয় যে load আর store মেমরিতে কোনো একটি ক্রমে ঘটে; বাস্তবের CPU আর compiler আরও অদ্ভুত আচরণের সুযোগ রাখে, যা নিচে দেখবেন।)
-O2 দিলে বাগ “সেরে যায়”?
একই প্রোগ্রাম gcc -O2 দিয়ে compile করে Wandbox-এ চালানো হয়েছিল (এই পাতার রানার optimization ছাড়া compile করে)। ফল:
online CPUs: 2
without lock: 2000000 (== 2000000? yes)
with mutex : 2000000 (== 2000000? yes)
বাগ উধাও? না, লুকিয়েছে। Optimizer ধরে নেয় লুপ চলার সময় counter-কে আর কেউ বদলায় না, তাই পুরো লুপকে কার্যত একবার load, একবার ১০ লাখ যোগ, একবার store-এ নামিয়ে আনে। Race-এর জানালা প্রায় শূন্য, তাই প্রায় সবসময় ২০ লাখ — কিন্তু ওই একটি load-store মিশে গেলে ফল ঠিক ১০ লাখ। C11 আর C++11 মানদণ্ড বলে, data race থাকা প্রোগ্রামের আচরণ undefined, তাই optimization বদলালে ফলও বদলাতে পারে। শিক্ষা: সব optimization level-এ, বারবার, load দিয়ে পরীক্ষা করুন, আর ভরসা রাখুন race detector-এ (অংশ ৮), ভাগ্যে নয়।
volatile দিয়ে সারে না। অনেকে (আর অনেক AI-ও) volatile long counter লিখতে বলেন। volatile compiler-কে প্রতিবার মেমরি ছুঁতে বাধ্য করে — -O2-এর “লুকানো” বন্ধ হয়, কিন্তু load-add-store তিনটি আলাদা ধাপই থাকে, তাই lost update ঠিকই ঘটে। C/C++-এ volatile hardware register বা signal handler-এর জন্য, থ্রেডের সমন্বয়ের জন্য নয় (Java-র volatile আলাদা জিনিস)। সঠিক বিকল্প: mutex, অথবা একটি atomic operation — যেমন C11-এর atomic_fetch_add(&counter, 1), যা x86-64-এ lock-যুক্ত একটিমাত্র instruction (যেমন lock add বা lock xadd) হয়ে যায়। Atomic আর memory ordering পর্ব ২৩-এ, mutex-এর ভেতরের তত্ত্ব পর্ব ২৪-এ।
Mutex নিয়ে এই পর্বে তিনটি অভ্যাসই যথেষ্ট: lock যত ছোট অংশ ঘিরে রাখা যায় তত ভালো; ফাংশন থেকে যে পথেই বের হোন (error-এর return-সহ), প্রতিটি lock-এর একটি unlock চাই; আর একাধিক mutex হলে সব থ্রেড একই ক্রমে নেবে, নইলে deadlock (পর্ব ২৭)। Race condition, critical section আর Peterson-এর সমাধানের পূর্ণ তত্ত্ব পর্ব ২২-এ।
৬ · ছোট একটি thread pool — mutex, condition variable আর work queue
ধরুন একটি ওয়েব সার্ভার প্রতিটি অনুরোধে নতুন থ্রেড বানায় আর কাজ শেষে ফেলে দেয়। প্রতিটি থ্রেড তৈরিতে stack বরাদ্দ, kernel-এ clone — খরচ আছে; আর হঠাৎ ১০ হাজার অনুরোধ এলে ১০ হাজার থ্রেড মেমরি আর context switch-এ সার্ভারকে ডুবিয়ে দিতে পারে। সমাধান: আগে থেকে অল্প কয়েকটি থ্রেড বানিয়ে রাখা, কাজ এলে একটি সারিতে রাখা, আর খালি থ্রেড সারি থেকে কাজ তুলে নেবে। একে বলে thread pool, আর সারিটি work queue.
ব্যাংকের টোকেন-কাউন্টার ভাবুন। গ্রাহক এলে নতুন কর্মী নিয়োগ হয় না — তিনজন কর্মী আগে থেকেই বসে। গ্রাহক টোকেন নিয়ে সারিতে বসেন (queue-তে কাজ), খালি কর্মী পরের টোকেন ডাকেন (queue থেকে কাজ তোলা), সারি খালি থাকলে কর্মীরা বসে থাকেন আর নতুন গ্রাহক এলে ঘণ্টা বাজে (condition variable-এ signal). দিনের শেষে ম্যানেজার জানান “আজ আর টোকেন নেই” (shutdown); সারির বাকি কাজ শেষ করে সবাই বাড়ি যান।
এখানে নতুন হাতিয়ার condition variable. Mutex-এর কাজ “একবারে একজন” নিশ্চিত করা; condition variable-এর কাজ শর্ত পূরণ না হওয়া পর্যন্ত থ্রেডকে ঘুম পাড়িয়ে রাখা, আর কেউ সংকেত দিলে জাগানো। pthread_cond_wait(&cv, &mu) একটি অবিভাজ্য কাজে mutex ছেড়ে দিয়ে ঘুমায়, আর জাগার সময় mutex আবার নিয়ে তবেই ফেরে। নিচের pool-এ ৩টি worker আর ৮টি কাজ; কাজটি সরল (সংখ্যার বর্গ), যাতে কাঠামোটি স্পষ্ট দেখা যায়। প্রতিটি ফল নিজের ঘরে, আর ছাপা হয় সবাই join হওয়ার পরে — তাই আউটপুট প্রতিবার একই:
#include <pthread.h>
#include <stdio.h>
#define WORKERS 3
#define TASKS 8
static int queue[TASKS], head = 0, tail = 0, closed = 0; /* work queue */
static long result[TASKS];
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
static void *worker(void *arg) {
(void)arg;
for (;;) {
pthread_mutex_lock(&mu);
while (head == tail && !closed) /* empty: sleep, release mu */
pthread_cond_wait(&cv, &mu);
if (head == tail) { pthread_mutex_unlock(&mu); return NULL; }
int task = queue[head++]; /* take one job */
pthread_mutex_unlock(&mu);
result[task] = (long)task * task; /* do the work, no lock held */
}
}
int main(void) {
pthread_t w[WORKERS];
for (int i = 0; i < WORKERS; i++) pthread_create(&w[i], NULL, worker, NULL);
for (int t = 0; t < TASKS; t++) { /* submit jobs */
pthread_mutex_lock(&mu);
queue[tail++] = t;
pthread_cond_signal(&cv); /* wake one sleeping worker */
pthread_mutex_unlock(&mu);
}
pthread_mutex_lock(&mu); closed = 1; /* no more jobs */
pthread_cond_broadcast(&cv); /* wake everyone to exit */
pthread_mutex_unlock(&mu);
for (int i = 0; i < WORKERS; i++) pthread_join(w[i], NULL);
for (int t = 0; t < TASKS; t++) printf("task %d -> %ld\n", t, result[t]);
}একটি আসল রানের আউটপুট:
task 0 -> 0
task 1 -> 1
task 2 -> 4
task 3 -> 9
task 4 -> 16
task 5 -> 25
task 6 -> 36
task 7 -> 49
কোন worker কোন কাজ করেছে, তা প্রতিবার বদলাতে পারে, কিন্তু আউটপুট সবসময় এক। চারটি জায়গা খেয়াল করুন। (১) while, if নয়: জাগার পর আবার শর্ত যাচাই হয়, কারণ (ক) POSIX অনুমতি দেয় যে কোনো কারণ ছাড়াই থ্রেড জেগে উঠতে পারে — spurious wakeup, আর (খ) জাগতে জাগতে আরেক worker হয়তো কাজটি নিয়ে নিয়েছে। (২) কাজ lock-এর বাইরে: result[task] হিসাবের সময় mutex ধরা নেই, তাই তিন worker সত্যিই একসাথে কাজ করতে পারে; lock শুধু queue ছোঁয়ার মুহূর্তে। প্রতিটি task-এর ঘর আলাদা, আর main পড়ে join-এর পরে। (৩) Shutdown: closed = 1 আর broadcast সব ঘুমন্ত worker-কে জাগায়; তারা দেখে queue খালি আর বন্ধ, তাই বেরিয়ে যায় — কিন্তু তার আগে সারির বাকি কাজ শেষ করে, কারণ বের হওয়ার শর্ত “খালি এবং বন্ধ”। (৪) শর্তের ভ্যারিয়েবল mutex-এর ভেতরে বদলানো: tail++ আর closed = 1 দুটিই lock ধরে করা, নইলে একটি worker শর্ত যাচাই আর ঘুমানোর মাঝখানে signal হারিয়ে চিরকাল ঘুমিয়ে থাকতে পারত।
এটি শেখার জন্য ইচ্ছে করে সরল। বাস্তবের pool-এ queue হয় নির্দিষ্ট আকারের বৃত্তাকার buffer, “সারি ভর্তি” হলে কাজ জমা দেওয়া থ্রেডও অপেক্ষা করে (দ্বিতীয় condition variable — পর্ব ২৬-এর producer-consumer), আর কাজ হিসেবে থাকে একটি function pointer ও একটি void*. Condition variable-এর পূর্ণ তত্ত্ব পর্ব ২৫-এ।
কতগুলো থ্রেড রাখবেন? — একটি হিসাব
CPU-নির্ভর কাজে থ্রেড সংখ্যা মোটামুটি core-এর সমান রাখলেই যথেষ্ট; বেশি দিলে শুধু context switch বাড়ে। কিন্তু যে কাজে থ্রেড বেশিরভাগ সময় অপেক্ষা করে (ডেটাবেস, নেটওয়ার্ক), সেখানে বেশি লাগে। Brian Goetz-দের বই Java Concurrency in Practice-এ প্রচলিত একটি আনুমানিক সূত্র:
উদাহরণ: ৮ core, পুরো CPU ব্যবহারের লক্ষ্য (১), প্রতিটি অনুরোধে ২০ ms হিসাব আর ৮০ ms ডেটাবেসের অপেক্ষা। থ্রেড ≈ ৮ × ১ × (১ + ৮০ ÷ ২০) = ৮ × ৫ = ৪০টি. প্রতিটি থ্রেড সময়ের ১/৫ ভাগ CPU চায়, তাই ৮টি core ভরাতে ৫ গুণ থ্রেড লাগে। এটি শুরুর অনুমান মাত্র — আসল সংখ্যা ঠিক হয় load test করে।
৭ · অন্য ভাষা আর OS-এ একই ধারণা
Pthreads শিখলে বাকি সব থ্রেড-API সহজ লাগে, কারণ নিচে ধারণা একই: তৈরি, অপেক্ষা, ফল নেওয়া, আর shared ডেটা রক্ষা। পার্থক্য মূলত সুবিধায় — ভাষা কতটা নিরাপত্তা নিজে দেয়। উদাহরণগুলো এই পাতায় চালানো যায় না (Windows-এরটি ছাড়া সবগুলো আলাদাভাবে চালিয়ে যাচাই করা); মন্তব্যে প্রত্যাশিত ফল।
#include <stdio.h>
#include <threads.h>
static int work(void *arg) { /* returns int, not void* */
int id = *(int *)arg;
printf("C11 thread %d\n", id);
return id * 10;
}
int main(void) {
thrd_t t;
int id = 7, res;
if (thrd_create(&t, work, &id) != thrd_success) return 1;
thrd_join(t, &res); /* res = 70 */
printf("result %d\n", res);
return 0;
}C11 (২০১১) নিজস্ব <threads.h> আনে — pthreads-এর মতোই, তবে থ্রেড ফাংশন int ফেরত দেয়, mutex mtx_t, condition variable cnd_t. কিন্তু মানদণ্ডে এটি ঐচ্ছিক (__STDC_NO_THREADS__), আর glibc এনেছে দেরিতে, ২০১৮ সালের 2.28-এ; তাই Linux কোডে এখনো pthreads-ই বেশি চলে। Linux-এ C11 থ্রেড pthreads-এর ওপরেই বানানো।
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::vector<long> part(4, 0);
{
std::vector<std::jthread> pool; // C++20
for (int id = 0; id < 4; ++id)
pool.emplace_back([id, &part] { // lambda: no void* casts
for (long i = id; i < 1000; i += 4) part[id] += i;
});
} // every jthread joins here
long total = 0;
for (long p : part) total += p;
std::cout << "total = " << total << '\n'; // 0+1+...+999 = 499500
}C++11-এর std::thread যেকোনো ফাংশন বা lambda যেকোনো argument-সহ নেয় — void*-এর ঝামেলা নেই, আর type ভুল হলে compile-এর সময়ই ধরা পড়ে। কিন্তু একটি ফাঁদ আছে: join বা detach না করা std::thread ধ্বংস হলে প্রোগ্রাম std::terminate-এ বন্ধ হয়ে যায়। তাই C++20 আনে std::jthread, যা ধ্বংসের সময় নিজেই join করে (আর stop_token দিয়ে থামার অনুরোধও পাঠাতে পারে)। লক্ষ করুন, lambda-য় id মান হিসেবে ধরা হয়েছে — [&id] লিখলে হুবহু &i বাগটিই ফিরে আসত। Linux-এ std::thread-ও ভেতরে pthreads.
import java.util.concurrent.*;
class Main {
public static void main(String[] args) throws Exception {
try (ExecutorService pool = Executors.newFixedThreadPool(3)) { // a thread pool
Future<Integer> f = pool.submit(() -> 6 * 7); // a task
System.out.println("result " + f.get()); // waits, like join
} // close(): Java 19+
Thread v = Thread.ofVirtual().start(() -> System.out.println("hello from a virtual thread"));
v.join(); // Java 21+
}
}Java-য় থ্রেড ভাষার শুরু থেকেই আছে। বাস্তবের কোডে সরাসরি Thread কম লেখা হয়; Java 5 (২০০৪) থেকে java.util.concurrent-এর ExecutorService একটি তৈরি thread pool দেয়, আর Future হলো ফল পাওয়ার প্রতিশ্রুতি — f.get() join-এর মতো অপেক্ষা করে। Java 21 থেকে চূড়ান্ত হওয়া virtual thread খুব হালকা: হাজার হাজার virtual thread অল্প কয়েকটি OS থ্রেডের ওপর JVM নিজে চালায় (পর্ব ১৪-এর many-to-many ধাঁচের মডেল)। বিস্তারিত দেখুন Java কোর্সের virtual thread পর্বে।
import sys, threading
def work(i, n, out):
out[i] = sum(range(i, n, 4)) # each thread writes its own slot
out = [0] * 4
ts = [threading.Thread(target=work, args=(i, 1000, out)) for i in range(4)]
for t in ts: t.start()
for t in ts: t.join()
print("total =", sum(out)) # 499500
# Python 3.13+: is the GIL on in this interpreter?
print("GIL enabled:", sys._is_gil_enabled() if hasattr(sys, "_is_gil_enabled") else True)Python-এর প্রতিটি threading.Thread একটি আসল OS থ্রেড। কিন্তু প্রচলিত CPython-এ একটি GIL (Global Interpreter Lock) আছে: একসাথে একটিই থ্রেড Python bytecode চালায়। তাই ফাইল, নেটওয়ার্ক বা ডেটাবেসের অপেক্ষার কাজে থ্রেড ভালো, কিন্তু খাঁটি Python-এর হিসাব একাধিক core-এ ছড়ায় না — সেখানে multiprocessing বা NumPy-র মতো C-তে লেখা library. PEP 703 অনুযায়ী Python 3.13 (২০২৪) একটি পরীক্ষামূলক free-threaded build (python3.13t) আনে, যাতে GIL নেই; পরের সংস্করণে এটি আরও পাকাপোক্ত হচ্ছে, তবে লেখার সময় পর্যন্ত এটি আলাদা build, ডিফল্ট নয় — সর্বশেষ অবস্থা python.org-এ দেখে নিন। সতর্কতা: GIL থাকলেও count += 1 thread-safe নয়, কয়েকটি bytecode-এর মাঝে থ্রেড বদলাতে পারে। (সাধারণ CPython 3.14-এ উপরের কোডের শেষ লাইন: GIL enabled: True.)
#include <windows.h>
#include <process.h>
#include <stdio.h>
static unsigned __stdcall work(void *arg) { /* Win32 thread function */
int id = *(int *)arg;
printf("Windows thread %d\n", id);
return (unsigned)(id * 10);
}
int main(void) {
int id = 7;
HANDLE h = (HANDLE)_beginthreadex(NULL, 0, work, &id, 0, NULL); /* 0 = default stack */
WaitForSingleObject(h, INFINITE); /* like pthread_join */
DWORD code;
GetExitCodeThread(h, &code); /* the return value: 70 */
CloseHandle(h); /* release our handle */
printf("result %lu\n", (unsigned long)code);
return 0;
}Windows-এ থ্রেড kernel-এর একটি অবজেক্ট, প্রোগ্রাম পায় তার HANDLE. মূল API CreateThread, তবে C runtime ব্যবহার করলে Microsoft-এর পরামর্শ _beginthreadex. join-এর জায়গায় WaitForSingleObject, ফেরত মান GetExitCodeThread, আর CloseHandle হাতল ছেড়ে দেয়, যাতে থ্রেড শেষে অবজেক্টটি মুছে যায়। Mutex-এর জায়গায় সাধারণত SRWLOCK বা CRITICAL_SECTION, condition variable-এর জায়গায় CONDITION_VARIABLE. macOS-এ pthreads আছে (Mach থ্রেডের ওপরে), তবে Apple-এর অ্যাপে বেশি চলে Grand Central Dispatch — queue-ভিত্তিক একটি তৈরি pool.
৮ · থ্রেডের বাগ ধরা — ThreadSanitizer আর Helgrind
অংশ ৫-এর race ধরা গেছে, কারণ সঠিক উত্তর আগে থেকে জানা ছিল। বাস্তবে উত্তর জানা থাকে না, আর race হয়তো হাজারে একবার ঘটে — তাই টুল লাগে।
ThreadSanitizer (TSan)
GCC আর Clang-এ -fsanitize=thread দিয়ে compile করলে compiler প্রতিটি মেমরি-ব্যবহারের পাশে ছোট পরীক্ষার কোড বসিয়ে দেয়। প্রোগ্রাম চলার সময় ThreadSanitizer হিসাব রাখে কোন থ্রেড কোন ঠিকানা কখন ছুঁয়েছে, আর দুটি ব্যবহারের মাঝে কোনো happens-before সম্পর্ক (যেমন একই mutex, বা join) আছে কি না। না থাকলে — race. বড় সুবিধা: race-টি সেই রানে ভুল ফল না দিলেও TSan ধরে ফেলে, কারণ সে ফল নয়, সমন্বয়ের অভাব খোঁজে। অংশ ৫-এর প্রোগ্রামে TSan যা বলে, তার একটি উদাহরণ (সংক্ষেপিত; ঠিকানা আর লাইন নম্বর আপনার মেশিনে ভিন্ন হবে):
$ gcc -fsanitize=thread -g -O1 race_vs_mutex.c -o race && ./race
online CPUs: 8
==================
WARNING: ThreadSanitizer: data race (pid=31207)
Write of size 8 at 0x55c3d8a0e018 by thread T2:
#0 inc_racy race_vs_mutex.c:9 (race+0x12b4)
Previous write of size 8 at 0x55c3d8a0e018 by thread T1:
#0 inc_racy race_vs_mutex.c:9 (race+0x12b4)
Location is global 'counter' of size 8 at 0x55c3d8a0e018 (race+0x4018)
Thread T2 (tid=31210, running) created by main thread at:
#0 pthread_create ... (libtsan.so.2+0x5f1a6)
#1 run race_vs_mutex.c:25 (race+0x1386)
#2 main race_vs_mutex.c:31 (race+0x13e9)
SUMMARY: ThreadSanitizer: data race race_vs_mutex.c:9 in inc_racy
==================
without lock: 1998071 (== 2000000? no)
with mutex : 2000000 (== 2000000? yes)
ThreadSanitizer: reported 1 warningsপড়ার নিয়ম: কোন ঠিকানায় (global counter), কোন দুই থ্রেড (T1, T2), কোন লাইনে (৯, অর্থাৎ counter++), আর থ্রেডগুলো কোথায় তৈরি। Mutex-ওয়ালা অংশের জন্য কোনো সতর্কবার্তা নেই — lock একটি happens-before সম্পর্ক তৈরি করে। TSan-এ প্রোগ্রাম কয়েক গুণ ধীর হয় আর মেমরিও কয়েক গুণ বেশি লাগে, তাই এটি টেস্ট আর CI-এর জন্য। এই পাতার রানারে TSan চলে না (container-এ তার বিশাল shadow memory বরাদ্দ করা যায়নি), তাই নিজের Linux বা WSL-এ চালিয়ে দেখুন।
Helgrind (Valgrind)
Recompile না করেই race খুঁজতে আছে Valgrind-এর Helgrind. এটি প্রোগ্রামকে একটি ভার্চুয়াল CPU-তে চালিয়ে প্রতিটি মেমরি-ব্যবহার আর pthread কল দেখে, আর race ছাড়াও ধরে lock নেওয়ার ক্রমের অসংগতি (সম্ভাব্য deadlock) ও pthread API-র ভুল ব্যবহার। উদাহরণ আউটপুট (সংক্ষেপিত):
$ gcc -g race_vs_mutex.c -o race && valgrind --tool=helgrind ./race
==31452== Possible data race during read of size 8 at 0x10C018 by thread #3
==31452== Locks held: none
==31452== at 0x1091C6: inc_racy (race_vs_mutex.c:9)
==31452== by 0x485396A: ??? (in /usr/libexec/valgrind/vgpreload_helgrind-amd64-linux.so)
==31452== by 0x4909AC2: start_thread (pthread_create.c:442)
==31452==
==31452== This conflicts with a previous write of size 8 by thread #2
==31452== Locks held: none
==31452== at 0x1091D1: inc_racy (race_vs_mutex.c:9)
==31452== Address 0x10c018 is 0 bytes inside data symbol "counter"“Locks held: none” — দুই দিকেই কোনো lock ধরা ছিল না, এটিই race-এর প্রমাণ। Helgrind (আর তার সঙ্গী DRD) TSan-এর চেয়ে অনেক ধীর, তবে recompile লাগে না। দুটি টুলই শুধু পরীক্ষার রানে সত্যিই চলা কোড-পথ দেখে, তাই ভালো টেস্ট-ইনপুট লাগে। Helgrind চলে Linux-এ (আর কিছু অন্য Unix-এ), macOS-এ Clang-এর TSan চলে, কিন্তু Windows-এর নিজস্ব compiler-এ এদের সাধারণত পাওয়া যায় না — সেখানে WSL-এ Linux হিসেবে পরীক্ষা করা সহজ পথ।
৯ · AI-এর যুগে থ্রেডের কোড
AI কয়েক সেকেন্ডে একটি thread pool লিখে দেবে, আর আপনার ল্যাপটপে সম্ভবত চলবেও। কিন্তু থ্রেডের কোডে “চলছে” আর “ঠিক আছে” এক কথা নয় — race-ওয়ালা কোড -O2-এ নিখুঁত উত্তর দিয়েছে, দেখেছেন। AI-এর concurrent কোডের সাধারণ ভুল:
| AI-এর ভুল | ফল | কীভাবে ধরবেন |
|---|---|---|
লুপে &i বা একটি local-এর ঠিকানা থ্রেডকে পাঠানো | ভুল বা অবৈধ argument, রান-ভেদে আলাদা | প্রতিটি থ্রেডের argument আলাদা ঘরে কি না, আর থ্রেড বেঁচে থাকা পর্যন্ত সেটি বৈধ কি না দেখুন |
pthread_cond_wait if-এর ভেতরে, while নয় | spurious wakeup-এ খালি queue থেকে কাজ তোলা | প্রতিটি wait একটি শর্তের while লুপে কি না দেখুন |
race “সারাতে” volatile বা sleep() | বাগ লুকায়, যায় না | TSan দিয়ে চালান; সমন্বয় হবে mutex, atomic বা join দিয়ে |
pthread_create-এর ফেরত মান উপেক্ষা, বা perror দিয়ে দেখা | থ্রেড তৈরি হয়নি অথচ পরে join; ভুল error বার্তা | ফেরত মান ০ কি না দেখুন; ত্রুটি দেখান strerror(rc) দিয়ে |
| main join না করে ফিরে যায়, বা detached থ্রেড main-এর stack ব্যবহার করে | কাজ মাঝপথে কেটে যায়, বা অবৈধ মেমরি | প্রতিটি থ্রেড হয় join, নয় detach — এবং detached হলে heap-এর ডেটা |
| Linux-এর কোড Windows-এর জন্য (বা উল্টো), বা পুরোনো তথ্য (“Python-এ GIL তাই race হয় না”) | compile হয় না, বা ভুল ধারণা | প্রম্পটে OS, compiler, ভাষার সংস্করণ লিখুন; man page আর অফিসিয়াল ডকুমেন্টেশন মিলিয়ে নিন |
থ্রেডের কোড চাইলে পরিবেশ, শর্ত আর যাচাইয়ের পদ্ধতি তিনটিই দিন:
AI-এর লেখা থ্রেডের কোড ব্যবহারের আগে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- pthread_join আর pthread_detach-এর পার্থক্য? — join অপেক্ষা করে ফেরত মান নেয় আর সম্পদ ছাড়ে; detach মানে কেউ join করবে না, থ্রেড শেষ হলেই সম্পদ নিজে ফেরত যায়।
- লুপে
&iপাঠালে কী হয়? — সব থ্রেড একই ঠিকানা পায়; থ্রেড চলার আগে i বদলে যায়, তাই ভুল বা একই মান পড়ে। সমাধান প্রতিটি থ্রেডের আলাদা ঘর বা মান হিসেবে পাঠানো। - দুই থ্রেড N বার করে lock ছাড়া counter++ করলে ফল কত হতে পারে? — ২ থেকে ২N-এর যেকোনো মান; কারণ load-add-store মিশে lost update.
- Condition variable-এ wait কেন while লুপে? — spurious wakeup হতে পারে, আর জাগার আগেই অন্য থ্রেড শর্ত বদলে দিতে পারে।
- Thread pool কেন? — থ্রেড তৈরির খরচ বাঁচায়, একসাথে চলা থ্রেডের সংখ্যা সীমিত রাখে, তাই হঠাৎ বেশি load-এও সিস্টেম ডোবে না।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Linux-এ থ্রেড কি সবসময় আজকের মতো ছিল? LinuxThreads থেকে NPTL-এ যাওয়ার গল্প কী শেখায়?
না। ১৯৯০-এর দশকে Linux kernel-এ “থ্রেড” বলে আলাদা কিছু ছিল না, ছিল শুধু প্রসেস। তবে clone system call দিয়ে এমন প্রসেস বানানো যেত, যা প্যারেন্টের সাথে মেমরি ভাগ করে। এর ওপর ভিত্তি করে Xavier Leroy ১৯৯৬ সালের দিকে LinuxThreads লেখেন, যা বহু বছর glibc-র অংশ ছিল। এটি কাজ চালাত, কিন্তু POSIX-এর সাথে বেশ কিছু অমিল ছিল: প্রতিটি থ্রেডের আলাদা PID, তাই getpid() থ্রেডভেদে আলাদা ফল দিত; signal পুরো প্রসেসকে না পাঠিয়ে একটি নির্দিষ্ট থ্রেডে যেত; আর থ্রেড সামলাতে একটি বাড়তি “manager thread” লাগত, যা হাজার থ্রেডে বড় বোঝা হয়ে দাঁড়াত।
সমাধানের জন্য দুটি দল প্রতিযোগিতায় নামে। IBM-এর NGPT বেছে নিয়েছিল M:N মডেল — অনেক user-level থ্রেড অল্প কয়েকটি kernel থ্রেডের ওপর। Red Hat-এর Ulrich Drepper আর Ingo Molnar-এর NPTL বেছে নেয় সরল 1:1 মডেল — প্রতিটি থ্রেড একটি kernel task — আর তার জন্য kernel-এই পরিবর্তন আনা হয়: thread group (CLONE_THREAD, যাতে সব থ্রেড একই PID দেখায়), দ্রুত সমন্বয়ের জন্য futex, আর প্রতি-থ্রেড TLS-এর সমর্থন। দেখা গেল, kernel দ্রুত হলে সরল 1:1 মডেলই যথেষ্ট। ২০০৩ সালের দিকে Linux 2.6 আর glibc-র সাথে NPTL প্রধান হয়ে ওঠে, আর NGPT-র কাজ বন্ধ হয়ে যায়।
এই গল্পের শিক্ষা দুটি। প্রথমত, পর্ব ১৪-এর threading model-এর বিতর্ক (1:1 বনাম M:N) কেবল তত্ত্ব নয় — বাস্তবের প্রকৌশল-সিদ্ধান্ত, যেখানে সরল নকশা আর দ্রুত kernel জিতেছে। Windows, macOS আর আধুনিক FreeBSD-ও শেষ পর্যন্ত 1:1. দ্বিতীয়ত, M:N ধারণা হারিয়ে যায়নি, উঠে এসেছে ভাষার runtime-এ: Go-র goroutine আর Java-র virtual thread-এ লাখ লাখ হালকা কাজ অল্প কয়েকটি OS থ্রেডের ওপর চলে। তবে এখন সেই scheduler ভাষার runtime-এ, যে নিজের কাজের ধরন জানে।
মূল উপলব্ধি: একটি API (POSIX threads) একই থাকলেও তার নিচের বাস্তবায়ন পুরো বদলে যেতে পারে। আজ getpid() সব থ্রেডে একই দেয়, কারণ ২০ বছরেরও বেশি আগে কেউ kernel বদলে সেই প্রতিশ্রুতি রাখার ব্যবস্থা করেছিলেন।
প্র ০২Race-ওয়ালা কোড -O2-এ “ঠিক” উত্তর দিল কেন? থ্রেডকে কেন শুধু library দিয়ে নিরাপদ করা যায় না?
Compiler-এর optimization একটি অনুমানের ওপর দাঁড়িয়ে: একটি ফাংশন চলার সময় অন্য কেউ গোপনে তার ভ্যারিয়েবল বদলাচ্ছে না। একক থ্রেডের প্রোগ্রামে এটি সত্য, তাই compiler নির্ভয়ে মান register-এ রাখে, লুপ ছোট করে, load আর store-এর ক্রম বদলায়। অংশ ৫-এর লুপটি তাই counter += 1000000-এর মতো একটি কাজে পরিণত হয়েছিল। বহু বছর ধরে C আর C++ মানদণ্ড থ্রেড সম্পর্কে কিছুই বলত না; pthreads ছিল “বাইরের” একটি library. Hans Boehm ২০০৫ সালের PLDI সম্মেলনে একটি বিখ্যাত প্রবন্ধে দেখান, এভাবে চলে না — “Threads Cannot Be Implemented as a Library”. Compiler থ্রেডের কথা না জানলে এমন optimization করতে পারে, যা একক থ্রেডে নির্দোষ কিন্তু থ্রেডে কোড ভাঙে — যেমন পাশের ভ্যারিয়েবলসহ একসাথে পড়ে-লিখে অন্য থ্রেডের লেখা মুছে দেওয়া।
এর ফলে ২০১১ সালে C11 আর C++11 মানদণ্ডে একটি আনুষ্ঠানিক memory model যোগ হয়। মূল চুক্তিটি সহজ: “data race নেই এমন প্রোগ্রাম লিখুন — অর্থাৎ shared ডেটা mutex, atomic বা join-এর মতো সমন্বয় দিয়ে ছুঁয়ে দেখুন — তাহলে compiler আর CPU কথা দেয়, প্রোগ্রাম এমন আচরণ করবে যেন সব কিছু কোনো একটি ক্রমে একটির পর একটি ঘটছে” (একে বলে sequential consistency for data-race-free programs). চুক্তি ভাঙলে, অর্থাৎ data race থাকলে, আচরণ undefined — compiler-এর কোনো দায় নেই। Java-ও আগেই, ২০০৪ সালে Java 5-এ (JSR-133) নিজের memory model ঠিক করেছিল।
এর ব্যবহারিক মানে: -O0-এ race দেখানো আর -O2-এ লুকানো — দুটোই “ঠিক” আচরণ, কারণ undefined behavior-এ যেকোনো ফলই বৈধ। আর এ কারণেই volatile সমাধান নয়: সেটি compiler-কে মেমরি ছুঁতে বাধ্য করে, কিন্তু অবিভাজ্যতা বা অন্য core-এ লেখা পৌঁছানোর ক্রম নিশ্চিত করে না। CPU-র নিজস্ব reordering আর memory barrier-এর গল্প পর্ব ২৩-এ।
মূল উপলব্ধি: থ্রেডের নিরাপত্তা শুধু library-র কাজ নয়, compiler, ভাষা আর হার্ডওয়্যারের মধ্যে একটি চুক্তি। চুক্তির আপনার অংশ একটিই — কোনো data race রাখবেন না।
প্র ০৩প্রতিটি অনুরোধে নতুন থ্রেড, নাকি thread pool, নাকি একটিই থ্রেড আর event loop? সার্ভারগুলো কীভাবে বেছেছে?
সবচেয়ে সরল নকশা “প্রতি অনুরোধে একটি থ্রেড”: কোড পড়তে সহজ, প্রতিটি অনুরোধের কাজ একটি সাধারণ, ধারাবাহিক ফাংশন। সমস্যা আসে load বাড়লে। প্রতিটি থ্রেডের stack (Linux-এ ডিফল্ট ৮ MiB virtual, ব্যবহৃত অংশ RAM-এ), তৈরির খরচ, আর হাজার থ্রেডের মধ্যে context switch-এর খরচ যোগ হয়। পরীক্ষার ফল প্রকাশের দিনের মতো হঠাৎ ভিড়ে থ্রেড বানাতে বানাতেই সার্ভারের মেমরি ফুরাতে পারে। ১৯৯৯ সালে Dan Kegel “C10k problem” নামে প্রশ্ন তোলেন: একটি সার্ভার একসাথে দশ হাজার সংযোগ কীভাবে সামলাবে?
উত্তর এসেছে কয়েক দিক থেকে। Thread pool থ্রেডের সংখ্যা সীমিত রাখে; বাড়তি অনুরোধ queue-তে অপেক্ষা করে, ফলে সিস্টেম ধীর হয় কিন্তু ভেঙে পড়ে না। Apache HTTP Server-এর prefork, worker আর event — এই MPM (multi-processing module)-গুলো এই বিবর্তনের ইতিহাস: প্রতি সংযোগে প্রসেস, তারপর প্রসেসের ভেতরে থ্রেডের pool, তারপর ঘুমন্ত সংযোগ আলাদা থ্রেডে সামলানো। অন্য পথে গেছে event-driven নকশা: nginx বা Node.js অল্প কয়েকটি থ্রেডে epoll-এর মতো syscall দিয়ে হাজার হাজার সংযোগের ঘটনা দেখে, কোনো সংযোগের জন্য থ্রেডকে আটকে রাখে না। এতে মেমরি খুব কম লাগে, কিন্তু কোড callback বা async/await-এ ভাগ হয়ে যায়, আর একটি ধীর হিসাব পুরো loop আটকে দেয় — তাই Node.js-এও ফাইল আর ভারী কাজের জন্য পেছনে একটি ছোট thread pool আছে।
নতুন প্রজন্মের উত্তর দুটোর মিশ্রণ: Go-র goroutine বা Java 21-এর virtual thread-এ প্রোগ্রামার “প্রতি অনুরোধে একটি থ্রেড”-এর সহজ কোড লেখেন, আর runtime ভেতরে অল্প কয়েকটি OS থ্রেড ও event loop-এর মতো কৌশলে চালায়। ডেটাবেস সংযোগের মতো সীমিত সম্পদের জন্য তবু pool লাগে — দশ হাজার virtual thread একসাথে দশ হাজার ডেটাবেস সংযোগ চাইলে ডেটাবেস ভেঙে পড়বে।
মূল উপলব্ধি: থ্রেড বিনামূল্যের নয়। Pool, event loop বা হালকা থ্রেড — প্রতিটি নকশা আসলে একই প্রশ্নের উত্তর: একসাথে চলা কাজের সংখ্যা আর তাদের খরচ কে, কোথায় নিয়ন্ত্রণ করবে।
প্র ০৪Python-এ GIL কেন এল, এত বছর কেন টিকে থাকল, আর free-threading-এর পথে বাধা কী?
CPython-এ প্রতিটি অবজেক্টের একটি reference count থাকে — কতজন অবজেক্টটি ব্যবহার করছে। সংখ্যা শূন্য হলে মেমরি মুক্ত হয়। একাধিক থ্রেড একসাথে এই সংখ্যা বাড়ালে-কমালে ঠিক অংশ ৫-এর lost update ঘটত: অবজেক্ট আগেভাগে মুছে যেত বা কখনোই মুছত না। প্রতিটি অবজেক্টে আলাদা lock বসানো ধীর আর জটিল; তাই Python-এ থ্রেড আসার সময় (১৯৯০-এর দশকের শুরুতে) একটি সরল সমাধান নেওয়া হয় — পুরো interpreter-এর জন্য একটি lock, অর্থাৎ GIL. এতে একক থ্রেডের প্রোগ্রাম দ্রুত থাকে আর C extension লেখা সহজ হয় — যা NumPy-র মতো বিশাল ecosystem গড়তে সাহায্য করেছে।
দাম হলো multi-core-এর যুগে খাঁটি Python হিসাব এক core-এ আটকে থাকা। GIL সরানোর চেষ্টা বহুবার হয়েছে। বড় বাধা ছিল দুটি: GIL সরালে একক থ্রেডের গতি কমে যেত, আর হাজার হাজার C extension ধরে নিয়েছিল যে GIL আছে। ২০১৬ সালে Larry Hastings-এর “Gilectomy” প্রকল্প দেখায় যে কাজটি সম্ভব, কিন্তু গতি অনেক কমে যায়। পরে Meta-র Sam Gross একটি “nogil” fork-এ biased reference counting, অমর (immortal) অবজেক্ট আর নতুন memory allocator-এর মতো কৌশলে গতির ক্ষতি অনেক কমিয়ে আনেন। সেটির ভিত্তিতে PEP 703 গৃহীত হয়, আর Python 3.13-এ পরীক্ষামূলক free-threaded build আসে।
তবু বাধা শেষ হয়নি। প্রতিটি C extension-কে GIL ছাড়া নিরাপদ হতে হবে, নইলে free-threaded build সেটি লোড করার সময় GIL আবার চালু করে দিতে পারে। একক থ্রেডের কোডে এখনো কিছুটা গতি-খরচ আছে। আর সবচেয়ে বড় কথা — GIL এতদিন অনেক প্রোগ্রামের race লুকিয়ে রেখেছিল; free-threading-এ সেগুলো বেরিয়ে আসবে। সামনে কোন সংস্করণে এটি ডিফল্ট হবে, তা এখনো ঠিক হয়নি বলে জানা যায়; সর্বশেষ সিদ্ধান্ত PEP আর python.org-এর ঘোষণায় দেখুন।
মূল উপলব্ধি: একটি বড় lock হলো সবচেয়ে সরল আর নিরাপদ সমাধান — কিন্তু সেটি parallelism কেড়ে নেয়। সূক্ষ্ম lock বা lock-free নকশা গতি ফেরায়, বিনিময়ে জটিলতা আর নতুন বাগের ঝুঁকি আনে। পর্ব ২৯-এ এই টানাপোড়েন আবার দেখবেন।
প্র ০৫একটি টুল কীভাবে “জানে” যে race আছে, অথচ সেই রানে ফল ঠিক এসেছে? Race detector-এর পেছনের ধারণা কী?
ফল দেখে race ধরা প্রায় অসম্ভব, কারণ race হাজারে একবার ভুল দেখাতে পারে। তাই race detector ফল নয়, নিয়ম ভাঙা খোঁজে। প্রথম জনপ্রিয় ধারণা আসে ১৯৯৭ সালে SOSP সম্মেলনে Stefan Savage ও সহকর্মীদের Eraser টুল থেকে — lockset পদ্ধতি। নিয়মটি: প্রতিটি shared ভ্যারিয়েবল সবসময় অন্তত একটি নির্দিষ্ট lock ধরে ছোঁয়া হবে। টুলটি প্রতিটি ভ্যারিয়েবলের জন্য “সম্ভাব্য রক্ষাকারী lock-এর সেট” রাখে; প্রতিবার ছোঁয়ার সময় সেটটিকে তখন ধরা lock-গুলোর সাথে ছেদ করে। সেট খালি হয়ে গেলে মানে কোনো একটি lock সব ব্যবহারকে রক্ষা করছে না — সম্ভাব্য race. সুবিধা: race সেই রানে ঘটুক বা না ঘটুক, নিয়মভঙ্গ ধরা পড়ে। অসুবিধা: lock ছাড়া অন্যভাবে নিরাপদ কোডে (যেমন join-এর পরে পড়া) ভুল সতর্কবার্তা আসে।
দ্বিতীয় ধারণা happens-before, যা Leslie Lamport-এর ১৯৭৮ সালের ঘটনার ক্রম-সংক্রান্ত কাজ থেকে এসেছে। দুটি ঘটনার মধ্যে যদি সমন্বয়ের একটি শিকল থাকে — যেমন T1 mutex ছেড়েছে, তারপর T2 সেটি নিয়েছে; বা T1 শেষ হয়েছে আর main তাকে join করেছে — তাহলে প্রথমটি দ্বিতীয়টির “আগে ঘটেছে”। একই ঠিকানায় দুটি ব্যবহার (অন্তত একটি লেখা) যদি এমন শিকলে যুক্ত না থাকে, তাহলে তারা “একসাথে” — অর্থাৎ race. টুলটি প্রতিটি থ্রেডের জন্য vector clock নামের একটি ঘড়ি রেখে এই সম্পর্ক হিসাব করে। এতে ভুল সতর্কবার্তা অনেক কম, কারণ join বা condition variable-এর মতো সব সমন্বয় হিসাবে আসে।
Google-এর ThreadSanitizer দুটি ধারণা মিলিয়ে শুরু হয়েছিল Valgrind-ভিত্তিক টুল হিসেবে, পরে ২০১২–১৩ সাল নাগাদ Clang আর GCC-তে compiler-ভিত্তিক সংস্করণ আসে, যা অনেক দ্রুত। Helgrind-ও happens-before-ভিত্তিক। দুটির সীমাবদ্ধতা একই: যে কোড-পথ টেস্টে চলেনি, সেখানের race দেখা যায় না। তাই বড় প্রকল্পে TSan চালানো হয় CI-তে, fuzzing আর load test-এর সাথে।
মূল উপলব্ধি: concurrent কোডের সঠিকতা “এই রানে কী এল” দিয়ে মাপা যায় না, মাপতে হয় “সমন্বয়ের নিয়ম মানা হয়েছে কি না” দিয়ে। Race detector সেই নিয়মের পাহারাদার।
অনুশীলন
আউটপুট অনুমান, হাতে হিসাব, interleaving ট্রেস আর কোড লেখা — আগে নিজে চেষ্টা করুন, তারপর উত্তর মেলান।
-
আউটপুট অনুমান: নিচের কোডে লক্ষ করুন, join-এর লুপও একই
iব্যবহার করে। সম্ভাব্য আউটপুটের মধ্যে কোনগুলো ঘটতে পারে, আর কোনটি কখনোই নয়: (ক)0 1 2(খ)3 3 3(গ)0 0 0(ঘ)4 4 4? (প্রতিটি থ্রেড একটি সংখ্যা ছাপে।)int i; for (i = 0; i < 3; i++) pthread_create(&t[i], NULL, worker, &i); for (i = 0; i < 3; i++) pthread_join(t[i], NULL); /* worker: printf("%d ", *(int *)p); */(ক), (খ) আর (গ) তিনটিই সম্ভব; (ঘ) অসম্ভব। (ক): প্রতিটি থ্রেড তৈরি হওয়ার সঙ্গে সঙ্গে চলে আর পড়ে — ভাগ্য ভালো হলে। (খ): প্রথম লুপ শেষ হওয়ার পর, i = 3 থাকা অবস্থায় তিনটিই পড়ে। (গ): দ্বিতীয় লুপ i-কে আবার 0 বানায়, আর main
pthread_join(t[0])-এ অপেক্ষা করার সময় i = 0 থাকে — তখন তিন থ্রেডই চললে সবাই 0 পড়ে! (ঘ): i কখনো 3-এর বেশি হয় না (দ্বিতীয় লুপও 3-এ থামে), তাই 4 আসতে পারে না। শিক্ষা: ঠিকানা ভাগ করলে থ্রেড কী পড়বে, তা পরের লুপের ওপরও নির্ভর করে। -
হাতে হিসাব: (ক) N = 10 আর T = 4 হলে সূত্র
lo = id*N/T,hi = (id+1)*N/T(পূর্ণসংখ্যার ভাগ) অনুযায়ী প্রতিটি থ্রেডের সূচকের পরিসর আর আকার কত? (খ) একটি সার্ভারে ১৬টি core; প্রতিটি অনুরোধে ১০ ms CPU-র হিসাব আর ৩০ ms নেটওয়ার্কের অপেক্ষা। CPU-র ৭৫% ব্যবহার লক্ষ্য হলে Goetz-এর সূত্রে pool-এ কতটি থ্রেড রাখা উচিত?(ক) সীমাগুলো: ০×১০/৪ = ০, ১×১০/৪ = ২ (২.৫ থেকে নিচে), ২×১০/৪ = ৫, ৩×১০/৪ = ৭ (৭.৫ থেকে নিচে), ৪×১০/৪ = ১০. তাই T0 = [0, 2) — ২টি, T1 = [2, 5) — ৩টি, T2 = [5, 7) — ২টি, T3 = [7, 10) — ৩টি। মোট ২ + ৩ + ২ + ৩ = ১০, কোনো সূচক বাদ নেই, কোনোটি দুবার নেই। আকারের পার্থক্য সর্বোচ্চ ১.
(খ) থ্রেড ≈ ১৬ × ০.৭৫ × (১ + ৩০ ÷ ১০) = ১৬ × ০.৭৫ × ৪ = ৪৮টি. প্রতিটি থ্রেড সময়ের ১/৪ ভাগ CPU চায়, তাই ১২টি core-এর সমান কাজ (১৬-এর ৭৫%) ভরাতে ১২ × ৪ = ৪৮টি থ্রেড। এটি শুরু করার অনুমান; load test করে চূড়ান্ত করবেন।
-
Interleaving ট্রেস: দুটি থ্রেড প্রত্যেকে ৩ বার
counter++করে (প্রতিটি = load, add, store), lock ছাড়া, counter শুরুতে 0. (ক) সর্বোচ্চ আর সর্বনিম্ন সম্ভাব্য ফল কত? (খ) সর্বনিম্ন ফল দেয় এমন একটি ক্রম ধাপে ধাপে লিখুন।(ক) সর্বোচ্চ ২ × ৩ = ৬ (কোনো যোগ মেশেনি)। সর্বনিম্ন ২ (অংশ ৫-এর প্রমাণ, N = ৩).
(খ) একটি ক্রম (A আর B দুই থ্রেড, বন্ধনীতে মেমরির counter):
- A load 0 (A-এর register = 0). (0)
- B দুবার পুরো ++ করে: load 0, store 1; load 1, store 2. (2)
- A store 0 + 1 = 1 — B-এর দুটি যোগ মুছে গেল। (1)
- B তৃতীয়বারের load করে: 1 (B-এর register = 1). (1)
- A বাকি দুবার পুরো ++ করে: load 1, store 2; load 2, store 3. (3)
- B store 1 + 1 = 2. (2) — শেষ ফল ২.
ছয়টি যোগ হয়েছে, কিন্তু ফল ২. B-এর শেষ store A-এর শেষ দুটি যোগকে মুছে দিয়েছে।
-
কোড লিখুন: প্রশ্ন ১-এর কোডটি এমনভাবে ঠিক করুন যাতে প্রতিটি থ্রেড নিজের সঠিক id পায়, আর প্রতিটি থ্রেড
id * idহিসাব করে join-এর মাধ্যমে main-কে ফেরত দেয় (heap ছাড়া)। main ফলগুলো 0, 1, 2 ক্রমে ছাপবে।দুটি নিরাপদ কৌশল একসাথে: id যাবে মান হিসেবে
void*-এর ভেতরে, ফলও ফিরবে মান হিসেবে — কোনো ঠিকানা ভাগ হবে না।static void *worker(void *p) { intptr_t id = (intptr_t)p; /* value, not an address */ return (void *)(id * id); /* small result as a value */ } int main(void) { pthread_t t[3]; for (intptr_t i = 0; i < 3; i++) pthread_create(&t[i], NULL, worker, (void *)i); for (int i = 0; i < 3; i++) { void *r; pthread_join(t[i], &r); printf("thread %d -> %ld\n", i, (long)(intptr_t)r); } return 0; }আউটপুট সবসময়
thread 0 -> 0,thread 1 -> 1,thread 2 -> 4— কারণ ছাপা হয় শুধু main থেকে, join-এর ক্রমে। (<stdint.h>লাগবে।) বড় ফল বা struct ফেরত দিতে হলে অংশ ২-এর প্রোগ্রামের মতো প্রতিটি থ্রেডের আলাদা struct ব্যবহার করুন।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমMutex আর condition variable-ভিত্তিক একটি thread pool-এ একটি কাজের যাত্রা সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- Worker থ্রেড mutex নিয়ে দেখে queue খালি, তাই
pthread_cond_wait-এ mutex ছেড়ে ঘুমায় - main mutex নিয়ে queue-তে কাজ রাখে আর
pthread_cond_signalদেয় - Worker জেগে mutex আবার পায়,
while-এর শর্ত আবার যাচাই করে - Worker queue থেকে কাজ তুলে mutex ছেড়ে দেয়
- Worker lock ছাড়াই কাজটি করে ফল নিজের ঘরে লেখে
- main সব worker join করার পর ফলগুলো ক্রমে ছাপে
একটি লুপে pthread_create(&t[i], NULL, worker, &i) দিয়ে তিনটি থ্রেড তৈরি করা হলো, আর worker *(int *)arg পড়ে ছাপে। আউটপুটে বারবার একই সংখ্যা আসার মূল কারণ কী?
- pthread_create argument-এর মান ভুলভাবে কপি করে
- সব থ্রেড একই ঠিকানা পায়, আর তারা পড়ার আগেই main-এর লুপ i বদলে দেয়
- printf একাধিক থ্রেড থেকে ডাকলে সংখ্যা মিশিয়ে ফেলে
- Scheduler থ্রেডগুলোর argument অদলবদল করে দেয়
POSIX-এ একটি C প্রোগ্রামের main ফাংশন অন্য থ্রেডগুলো join না করেই return 0 করলে কী ঘটে?
- বাকি থ্রেডগুলো শেষ হওয়া পর্যন্ত প্রসেস অপেক্ষা করে
- শুধু main থ্রেড শেষ হয়, বাকিরা চলতে থাকে
- exit() ডাকা হয়, ফলে প্রসেসের সব থ্রেড সঙ্গে সঙ্গে শেষ হয়ে যায়
- বাকি থ্রেডগুলো আলাদা প্রসেস হয়ে চলতে থাকে
দুটি থ্রেড একটি shared long counter-এ lock ছাড়া প্রত্যেকে ১০ লাখবার counter++ করে (counter শুরুতে 0). শেষ ফল সম্পর্কে কোনটি সঠিক?
- ফল ২ থেকে ২০,০০,০০০-এর মধ্যে যেকোনো কিছু হতে পারে, রান-ভেদে আলাদা
- এক core-এর মেশিনে ফল সবসময় ঠিক ২০,০০,০০০
- ফল কখনো ১০,০০,০০০-এর নিচে নামতে পারে না
- counter-কে volatile করলে ফল সবসময় ঠিক হয়
Condition variable-এ অপেক্ষার সঠিক রূপ কোনটি (mu একটি mutex, cv একটি condition variable, ready একটি shared শর্ত)?
pthread_cond_wait(&cv, &mu);— lock না নিয়েইlock(&mu); if (!ready) pthread_cond_wait(&cv, &mu); …while (!ready) sleep(1);— lock ছাড়াlock(&mu); while (!ready) pthread_cond_wait(&cv, &mu); … unlock(&mu);
একটি থ্রেড pthread_detach করা হলো। এরপর কোনটি সত্য?
- থ্রেডটি সঙ্গে সঙ্গে থেমে যায়
- থ্রেড শেষ হলে সম্পদ আপনাআপনি মুক্ত হয়, তবে join করে ফেরত মান নেওয়া যায় না
- থ্রেডটি আলাদা প্রসেস হয়ে নিজের মেমরি পায়
- main শেষ হলেও detached থ্রেড চলতে থাকে
একটি pthread ফাংশন ব্যর্থ হলে ত্রুটির কারণ জানার সঠিক উপায় কোনটি?
perror()ডেকে errno দেখা- ফেরত মান −1 কি না দেখা
- ফেরত মান 0 না হলে সেটিই error নম্বর,
strerror(rc)দিয়ে পড়া - প্রসেস একটি signal পায়, সেটি ধরা
চারটি থ্রেড একটি অ্যারের চার অংশ যোগ করে partial[0]…partial[3]-এ লেখে, আর main সব join করে যোগ করে। প্রতিটি থ্রেড লুপের প্রতিটি ধাপে সরাসরি partial[id] += a[i] করলে কী সমস্যা হতে পারে?
- ফল ঠিক থাকবে, কিন্তু false sharing-এর কারণে প্রোগ্রাম ধীর হতে পারে
- একাধিক থ্রেড একই ঘরে লেখায় ফল ভুল হবে
- Deadlock হবে
- Compiler একটি warning দেবে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- create → join: থ্রেড শুরু হয় একটি
void *(*)(void *)ফাংশন আর একটিvoid*argument দিয়ে; join অপেক্ষা করে ফল দেয়। ত্রুটি আসে ফেরত মানে, errno-তে নয়। - লুপের
&iকখনো নয় — প্রতিটি থ্রেডের নিজের ঘর, heap-এর struct, বা মান হিসেবে পাঠানো; আর ডেটা থ্রেডের চেয়ে বেশিদিন বাঁচবে। - প্রতিটি থ্রেড হয় join, নয় detach; main-এর return মানে সব থ্রেড শেষ। Stack size attribute দিয়ে ঠিক হয়, ডিফল্ট OS আর libc-ভেদে আলাদা।
- নিজের ঘরে লিখুন, join-এর পরে পড়ুন — lock ছাড়াই নির্ভরযোগ্য ফল; shared ডেটায় lock ছাড়া লেখা মানে data race, ফল ২ থেকে ২N, আর
-O2তা লুকাতে পারে। - Pool = mutex + condition variable (while-এ wait) + queue + shutdown; race ধরতে TSan বা Helgrind, ভাগ্য নয়।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ১৬ · CPU scheduling-এর ভিত্তি — burst, criteria আর মাপকাঠিপরবর্তী পর্বকোন থ্রেড বা প্রসেস কখন CPU পাবে, আর সেটি কীভাবে মাপা হয়।
- অপারেটিং সিস্টেম · কোর্স হোম৬৪টি পর্বপুরো কোর্সের রোডম্যাপ — race condition, mutex আর condition variable মডিউল ৪-এ।
- C প্রোগ্রামিং · Multithreadingহাতে-কলমেC-র দিক থেকে থ্রেড, mutex আর আরও অনুশীলন।
- Python · Concurrencyপাশের ট্র্যাকthreading, multiprocessing আর GIL — Python-এর দিক থেকে।
- DBMS · Concurrency controlএকই সমস্যা, অন্য জগৎডেটাবেসে lost update আর lock — একই ধারণা transaction-এর ভাষায়।