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

থ্রেড — কেন, কীভাবে আর threading model

Threads — Shared and Private State, Amdahl's Law, User vs Kernel Threads and Threading Models

Taught in Bangla, this lesson explains threads: shared and private state, TCB vs PCB, four benefits of threads and when separate processes are better, concurrency vs parallelism, Amdahl's law with proof and worked numericals plus Gustafson's law, user-level vs kernel-level threads, the many-to-one, one-to-one and many-to-many models, Linux clone() and /proc, threads with fork and signals, cancellation, thread-local storage, thread pools and implicit threading.

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

  • একটি থ্রেড কী নিজের রাখে আর কী ভাগ করে, TCB আর PCB-এর পার্থক্য, single- আর multi-threaded প্রসেসের ছবি
  • থ্রেডের চারটি সুবিধা, আর কখন থ্রেডের বদলে আলাদা প্রসেসই ভালো
  • Amdahl's law-এর প্রমাণ, পরীক্ষার ধাঁচের অঙ্ক, আর Gustafson's law-এর ভিন্ন দৃষ্টিভঙ্গি
  • User-level বনাম kernel-level thread, আর many-to-one, one-to-one, many-to-many model — কোন সিস্টেম কোনটি ব্যবহার করে
  • Linux-এ clone(), /proc-এ থ্রেড গোনা, আর fork, signal, cancellation, thread-local storage, thread pool ও implicit threading

১ · থ্রেড কী — এক প্রসেসে একাধিক কাজের ধারা

পর্ব ৮-এ দেখেছেন, একটি process হলো চলমান প্রোগ্রাম — নিজের মেমরি, নিজের খোলা ফাইল আর CPU-র নিজের অবস্থা নিয়ে। এতদিন আমরা ধরে নিয়েছি, প্রতিটি প্রসেসে একটিমাত্র “কাজের ধারা” আছে: CPU একটি নির্দেশ চালায়, তারপর পরেরটি, এক লাইনে। কিন্তু আপনার ফোনের ব্রাউজার একই সাথে পাতা নামাচ্ছে, ছবি ডিকোড করছে, আপনার আঙুলের স্ক্রল সামলাচ্ছে। এগুলো কি একটির পর একটি চলে?

একটি রেস্তোরাঁর রান্নাঘর ভাবুন। রান্নাঘর একটিই — একই চুলার সারি, একই ফ্রিজ, একই মশলার তাক, দেয়ালে একই অর্ডারের তালিকা। সেখানে তিনজন বাবুর্চি কাজ করছেন: একজন বিরিয়ানি, একজন ডাল, একজন সালাদ। প্রত্যেকের হাতে নিজের রেসিপির খাতা, যাতে আঙুল দিয়ে চিহ্ন রাখা আছে “এখন কোন ধাপে আছি”, আর প্রত্যেকের নিজের কাটিং বোর্ড। রান্নাঘর হলো প্রসেস; বাবুর্চিরা হলেন থ্রেড। রেসিপির খাতার চিহ্ন হলো program counter, কাটিং বোর্ড হলো stack. ফ্রিজ থেকে একজন মাংস বের করে নিলে বাকিরা সেটি সঙ্গে সঙ্গে দেখতে পান — ভাগ করা মেমরি এমনই। আবার দুজন একসাথে একই হাঁড়িতে লবণ দিলে বিপদ — সেই সমস্যা (race condition) পর্ব ২২-এ।

সংজ্ঞাটি এবার নির্দিষ্ট করি। থ্রেড হলো CPU ব্যবহারের মৌলিক একক: একটি thread ID, একটি program counter, এক সেট register আর একটি stack. একই প্রসেসের থ্রেডগুলো বাকি সবকিছু ভাগ করে নেয়। প্রচলিত প্রসেস মানে “একটি থ্রেডওয়ালা প্রসেস”; একাধিক থ্রেড থাকলে তাকে বলে multithreaded প্রসেস।

সব থ্রেড ভাগ করে (প্রসেসের সম্পদ)প্রতিটি থ্রেডের নিজের
Code (text segment) — একই ফাংশনগুলোProgram counter — এখন কোন নির্দেশে
Global ও static ভ্যারিয়েবল (data, bss)Register-এর মান, stack pointerসহ
Heap — malloc দিয়ে নেওয়া মেমরিStack — লোকাল ভ্যারিয়েবল আর ফাংশন-কলের হিসাব
খোলা ফাইল (file descriptor টেবিল), current directoryThread ID, অবস্থা (running, ready, blocked) আর priority
Signal-এর handler-এর ব্যবস্থা, user ID, পুরো address spaceSignal mask, errno আর অন্য thread-local ভ্যারিয়েবল

লক্ষ করুন, stack “নিজের” মানে আলাদা জায়গায় রাখা — কিন্তু একই address space-এর ভেতরে। তাই একটি থ্রেড চাইলে পয়েন্টার দিয়ে আরেকজনের stack-এ লিখতেও পারে; কোনো হার্ডওয়্যার তাকে থামাবে না। প্রসেসের মধ্যে যে দেয়াল (আলাদা page table) থাকে, থ্রেডের মধ্যে তা নেই। এটিই থ্রেডের শক্তি, আবার এটিই তার বিপদ।

নিচের ডায়াগ্রামে একটি প্রসেসের মেমরি ধাপে ধাপে দেখুন — একটি থ্রেড থেকে তিনটি, তারপর একই কাজ দুটি আলাদা প্রসেস দিয়ে করলে কী হয়।

Single-threaded processএকটি কাজের ধারা
+------------------------------------------+
| code   : main(), worker()                |
| data   : int g = 0                       |
| heap   : malloc'd buffers                |
| files  : fd 0, 1, 2, 3                   |
+------------------------------------------+
| thread T1 : PC, registers, stack         |
+------------------------------------------+
  kernel keeps: 1 PCB (+ state of T1)
Multithreaded processতিনটি ধারা, একটি address space
+------------------------------------------+
| code   : main(), worker()      SHARED    |
| data   : int g = 0             SHARED    |
| heap   : malloc'd buffers      SHARED    |
| files  : fd 0, 1, 2, 3         SHARED    |
+-------------+-------------+--------------+
| thread T1   | thread T2   | thread T3    |
| PC, regs    | PC, regs    | PC, regs     |
| own stack   | own stack   | own stack    |
| own errno   | own errno   | own errno    |
+-------------+-------------+--------------+
T1 writes g = 42সবাই সঙ্গে সঙ্গে দেখে
+------------------------------------------+
| data   : int g = 42   <-- written by T1  |
+-------------+-------------+--------------+
| T1: g = 42  | T2 reads g  | T3 reads g   |
|             |   -> 42     |   -> 42      |
+-------------+-------------+--------------+
  one copy of g, one address (&g) for all
Same job with fork()দুটি প্রসেস, দুটি কপি
+------------------+     +------------------+
| parent process   |     | child process    |
| code             |     | code (shared RO) |
| data: g = 0      |     | data: g = 99     |
| heap (own copy)  |     | heap (own copy)  |
| files (own table)|     | files (own table)|
| T1: PC, stack    |     | T1': PC, stack   |
+------------------+     +------------------+
  child sets g = 99 -> parent still sees 0
What the kernel storesPCB আর TCB
PCB (per process)        TCB (per thread)
-----------------        ----------------------
pid, parent, uid         tid, state, priority
page table / mm          saved PC, SP, registers
open-file table          kernel stack pointer
signal handlers          signal mask, TLS base
list of its TCBs  --->   [T1] [T2] [T3]
Linux: one task_struct per thread,
       all sharing one mm_struct
  1. একটি থ্রেডের প্রসেস। Code, global data, heap আর খোলা ফাইল — প্রসেসের সম্পদ। তার ওপর একটিমাত্র কাজের ধারা T1, যার নিজের PC, register আর stack. পর্ব ৮-এর প্রসেস আসলে এটিই।
  2. একই প্রসেসে তিনটি থ্রেড। ওপরের চারটি অংশ এখনো একটিই, তিন থ্রেড ভাগ করে নেয়। নিচে প্রতিটি থ্রেডের আলাদা PC, register, stack আর এমনকি আলাদা errno. তিনজন একই কোডের তিন জায়গায় থাকতে পারে, বা একই ফাংশন তিনবার চালাতে পারে।
  3. এক থ্রেডের লেখা সবাই দেখে। T1 global g-তে 42 লিখল। মেমরিতে g আছে একটিই, ঠিকানাও একটি; তাই T2 আর T3 পড়লেই 42 পায় — কোনো pipe বা message ছাড়াই। (দুজন একসাথে লিখলে কী হয়, সেটি পর্ব ২২-এর race condition.)
  4. একই কাজ fork() দিয়ে। পর্ব ১০-এর fork নতুন প্রসেস বানায়, যার মেমরি আলাদা কপি (বাস্তবে copy-on-write, পর্ব ৩৫). Child g-তে 99 লিখলে শুধু তার নিজের কপি বদলায়; parent আগের মতো 0 দেখে। কথা বলতে হলে IPC লাগে (পর্ব ১২).
  5. Kernel কী মনে রাখে। প্রসেস-স্তরের তথ্য থাকে PCB-তে, থ্রেড-স্তরের তথ্য প্রতিটি থ্রেডের TCB-তে। Linux-এ আলাদা দুই ধরনের struct নেই: প্রতিটি থ্রেডের একটি task_struct, আর একই প্রসেসের থ্রেডগুলোর task_struct একই memory বর্ণনা (mm_struct) দেখায়।

TCB বনাম PCB

Context switch-এর সময় (পর্ব ৯) kernel-কে চলতি কাজের register কোথাও রেখে দিতে হয়। থ্রেড আসার পর সেই “কোথাও” হলো Thread Control Block (TCB). প্রসেসের সম্পদের হিসাব থাকে PCB-তে, আর CPU-তে চলার জন্য যা লাগে তা থাকে TCB-তে। একই প্রসেসের দুই থ্রেডের মধ্যে সুইচে শুধু TCB বদলায়; page table বদলাতে হয় না, তাই TLB-র ঠিকানা-অনুবাদও (পর্ব ৩৩) অটুট থাকে — এজন্যই এই সুইচ সাধারণত সস্তা।

OSপ্রসেসের তথ্যথ্রেডের তথ্য
পাঠ্যবইয়ের মডেলPCBTCB
Linuxআলাদা struct নেই; ভাগ করা mm_struct, files_struct, sighand_structপ্রতিটি থ্রেডে একটি task_struct
Windows NTEPROCESS (kernel) + PEB (user)ETHREAD (kernel) + TEB (user)
macOS (XNU)Mach task + BSD procMach thread + BSD uthread

এই কোর্সের শব্দকোষে “TCB” আরেকটি অর্থেও আসে — নিরাপত্তার trusted computing base (পর্ব ৬). প্রসঙ্গ দেখে বুঝে নিন; এই পর্বে TCB মানে সবসময় thread control block.

নিজের চোখে দেখা — থ্রেড ভাগ করে, প্রসেস কপি করে

নিচের প্রোগ্রাম প্রথমে fork করে child-কে দিয়ে global g বদলায়, তারপর দুটি থ্রেড চালিয়ে একটিকে দিয়ে g বদলায়। প্রতিটি থ্রেড &g আর নিজের একটি লোকাল ভ্যারিয়েবলের ঠিকানা লিখে রাখে। ঠিকানা প্রতি রানে বদলায়, তাই প্রোগ্রাম ঠিকানা ছাপে না — শুধু সম্পর্ক ছাপে (“একই কি না”). থ্রেড তৈরির API (pthread_create, pthread_join) এখানে শুধু হালকাভাবে; বিস্তারিত পর্ব ১৫-এ।

#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>

int g = 0;                          /* global: one copy per process */
uintptr_t gaddr[2], laddr[2];       /* addresses seen by each thread */

static void *worker(void *arg) {
    long id = (long)arg;
    int local = 0;                  /* lives on THIS thread's own stack */
    gaddr[id] = (uintptr_t)&g;
    laddr[id] = (uintptr_t)&local;
    if (id == 0) g = 42;            /* thread 0 writes the shared global */
    return NULL;
}

int main(void) {
    fflush(stdout);                 /* nothing buffered before fork */
    pid_t pid = fork();
    if (pid == 0) { g = 99; _exit(0); }          /* child changes ITS copy */
    waitpid(pid, NULL, 0);
    printf("child process set g = 99 -> parent sees g = %d\n", g);

    pthread_t t[2];
    for (long i = 0; i < 2; i++) pthread_create(&t[i], NULL, worker, (void *)i);
    for (int i = 0; i < 2; i++) pthread_join(t[i], NULL);
    printf("thread 0 set g = 42      -> main sees g = %d\n", g);
    printf("&g same in both threads and main: %s\n",
           gaddr[0] == gaddr[1] && gaddr[0] == (uintptr_t)&g ? "yes" : "no");
    printf("&local different in each thread: %s\n", laddr[0] != laddr[1] ? "yes" : "no");
    uintptr_t d = laddr[0] > laddr[1] ? laddr[0] - laddr[1] : laddr[1] - laddr[0];
    printf("gap between the two locals: about %lu KiB\n", (unsigned long)(d / 1024));
    return 0;
}

একটি আসল রানের আউটপুট:

child process set g = 99 -> parent sees g = 0
thread 0 set g = 42      -> main sees g = 42
&g same in both threads and main: yes
&local different in each thread: yes
gap between the two locals: about 8196 KiB

প্রথম দুই লাইন পুরো পর্বের সারকথা। Child প্রসেস g-তে 99 লিখলেও parent দেখে 0 — দুটি আলাদা address space, দুটি কপি। কিন্তু থ্রেড 0 যে 42 লিখল, join-এর পর main সেটিই দেখে — মেমরি একটিই। তৃতীয় লাইন বলছে, দুই থ্রেড আর main-এর কাছে &g একই ঠিকানা; চতুর্থ লাইন বলছে, একই নামের লোকাল ভ্যারিয়েবল local দুই থ্রেডে দুই আলাদা ঠিকানায় — কারণ প্রত্যেকের stack আলাদা। শেষ লাইনের ফাঁকটি মজার: ৮১৯৬ KiB = ৮ MiB (৮১৯২ KiB) stack আর ৪ KiB-এর একটি guard page, যা stack উপচে পড়লে সঙ্গে সঙ্গে fault ঘটায়। এই রানের container-এ glibc প্রতিটি থ্রেডকে ৮ MiB stack দিয়েছে, আর stack-গুলো পাশাপাশি বসেছে; অন্য মেশিনে বা অন্য ulimit -s-এ সংখ্যাটি বদলাতে পারে। মাঝের fflush(stdout) এখানে buffer ফাঁকা থাকায় জরুরি ছিল না, কিন্তু fork-এর আগে এই অভ্যাসটি রাখুন (পর্ব ১০).

২ · থ্রেড কেন — চারটি সুবিধা

প্রসেস তো আগে থেকেই ছিল; একাধিক কাজ একসাথে করতে অনেকগুলো প্রসেস চালালেই হতো। তবু প্রায় সব আধুনিক অ্যাপ থ্রেড ব্যবহার করে। পাঠ্যবইয়ে চারটি সুবিধার কথা বলা হয়:

১. Responsiveness — সাড়া দেওয়া

একটি থ্রেড ধীর কাজে (ফাইল নামানো, বড় হিসাব) আটকে থাকলেও আরেকটি থ্রেড বোতাম-চাপ আর স্ক্রল সামলায়। Android-এ ভারী কাজ main (UI) thread থেকে সরিয়ে রাখার নিয়ম আছে; main thread কয়েক সেকেন্ড (input-এর ক্ষেত্রে সাধারণত প্রায় ৫ সেকেন্ড) আটকে থাকলে সিস্টেম “App not responding” দেখায়।

UI, সার্ভার

২. Resource sharing — সহজে ভাগাভাগি

থ্রেডরা নিজে থেকেই একই মেমরি আর ফাইল দেখে। প্রসেসে প্রসেসে ডেটা দিতে pipe, shared memory বা socket সাজাতে হয় (পর্ব ১২); থ্রেডে একটি পয়েন্টারই যথেষ্ট — তবে তখন synchronization-এর দায়িত্ব প্রোগ্রামারের।

কম কপি

৩. Economy — কম খরচ

নতুন থ্রেডের জন্য নতুন page table, নতুন file টেবিল লাগে না — শুধু একটি stack আর TCB. Context switch-এও address space বদলায় না। তাই তৈরি আর সুইচ, দুটোই সাধারণত প্রসেসের চেয়ে সস্তা।

তৈরি ও সুইচ

৪. Scalability — বেশি core, বেশি কাজ

একটি থ্রেডওয়ালা প্রসেস যত core-ই থাকুক, এক মুহূর্তে একটিতেই চলে। কাজ কয়েকটি থ্রেডে ভাগ করলে SMP মেশিনের সব core একসাথে খাটানো যায়।

multicore

খরচ কত কম — সংখ্যা নিয়ে সাবধানে

পাঠ্যবইয়ে (যেমন Silberschatz-দের Operating System Concepts) পুরোনো Solaris-এর একটি মাপ উদ্ধৃত হয়: প্রসেস তৈরি থ্রেডের চেয়ে প্রায় ৩০ গুণ ধীর, আর প্রসেস-সুইচ থ্রেড-সুইচের চেয়ে প্রায় ৫ গুণ। আজকের Linux-এ fork নিজেই copy-on-write দিয়ে অনেক দ্রুত, তাই অনুপাত মেশিন, kernel আর প্রসেসের মেমরির আকারভেদে অনেক বদলায়। সাধারণ একটি Linux মেশিনে একটি থ্রেড তৈরি আর join করতে সাধারণত কয়েক থেকে কয়েক দশ মাইক্রোসেকেন্ড লাগে; fork আর wait-এ প্রায়ই তার কয়েক গুণ, আর বড় মেমরির প্রসেসে আরও বেশি (page table কপি করতে হয়). সংখ্যা মুখস্থ না করে নিজের মেশিনে মেপে দেখাই ঠিক।

থ্রেডের মেমরি-খরচ মূলত তার stack. Linux-এ glibc সাধারণত নতুন থ্রেডকে ulimit -s-এর সমান (প্রায়ই ৮ MiB) virtual stack দেয়, Windows-এ ডিফল্ট সাধারণত ১ MB. এগুলো virtual জায়গা — যতটুকু সত্যিই ছোঁয়া হয়, ততটুকুই আসল RAM নেয়। তবু হাজার হাজার থ্রেডে address space, kernel stack আর scheduler-এর কাজ বাড়ে। অংশ ৫-এ দেখবেন, এ কারণেই Go আর Java লাখো “হালকা থ্রেড” আনতে অন্য পথ নিয়েছে।

সবসময় থ্রেড ভালো নয়। ভাগ করা মেমরি মানে একটি থ্রেডের বাগ (ভুল পয়েন্টার, crash) পুরো প্রসেস নিয়ে পড়ে। তাই নিরাপত্তা আর স্থিতির জন্য অনেকে ইচ্ছে করেই প্রসেস বেছে নেন: Chrome প্রতিটি সাইট বা ট্যাবের জন্য আলাদা renderer প্রসেস চালায়, যাতে একটি পাতা crash করলে বাকিগুলো টিকে থাকে আর দুষ্ট পাতা অন্য সাইটের মেমরি পড়তে না পারে। PostgreSQL প্রতিটি সংযোগে একটি প্রসেস চালায়, MySQL প্রতিটি সংযোগে একটি থ্রেড — দুটি পথই সফলভাবে চলছে, আলাদা দাম দিয়ে।

৩ · Concurrency, parallelism আর Amdahl's law

পর্ব ২-এর কথা মনে করুন: concurrency মানে একাধিক কাজ একই সময়কাল জুড়ে এগোনো — এক core-এ পালা করে হলেও; parallelism মানে একই মুহূর্তে সত্যিই একসাথে চলা, যার জন্য একাধিক core লাগে। এক core-এ চার থ্রেড concurrent, কিন্তু parallel নয়। চার core-এ চার থ্রেড দুটোই।

ব্যাংকের একটি টোকেন-কাউন্টারে একজন কর্মকর্তা চারজন গ্রাহককে পালা করে দু'মিনিট করে সময় দিচ্ছেন — সবার কাজ এগোচ্ছে, কিন্তু এক মুহূর্তে একজনেরই (concurrency). চারটি কাউন্টার খুললে চারজনের কাজ একসাথে হয় (parallelism). কিন্তু শেষে সবাইকে যদি ম্যানেজারের একটি সইয়ের জন্য এক লাইনে দাঁড়াতে হয়, কাউন্টার যতই বাড়ান, সেই লাইন ছোট হয় না। এই “একটিমাত্র লাইন”-ই Amdahl's law-এর মূল কথা।

কাজ ভাগ করার দুই ধরন আছে। Data parallelism: একই কাজ ডেটার আলাদা অংশে — যেমন ১ কোটি সংখ্যার যোগফল চার থ্রেডে চার ভাগে। Task parallelism: আলাদা আলাদা কাজ আলাদা থ্রেডে — যেমন একটি থ্রেড ফাইল পড়ে, একটি compress করে, একটি নেটওয়ার্কে পাঠায়। বাস্তবের প্রোগ্রামে দুটোই মেশানো থাকে।

Amdahl's law — প্রমাণসহ

১৯৬৭ সালে IBM-এর কম্পিউটার স্থপতি Gene Amdahl একটি সম্মেলনে দেখান, বহু processor দিয়ে গতি বাড়ানোর একটি কঠিন সীমা আছে। ধরা যাক একটি প্রোগ্রাম এক core-এ চলতে T(১) সময় নেয়। এর একটি অংশ শুধু ধারাবাহিকভাবেই চলতে পারে (serial — যেমন ইনপুট পড়া, ফল একত্র করা), বাকিটা পুরোপুরি ভাগ করা যায়। সমান্তরাল অংশের ভগ্নাংশ p (০ ≤ p ≤ ১).

  1. এক core-এ: T(১) = (১−p)·T(১) + p·T(১). সুবিধার জন্য T(১) = ১ ধরি।
  2. n core-এ serial অংশ এক core-এই চলে, তাই তার সময় একই থাকে: (১−p).
  3. সমান্তরাল অংশ সবচেয়ে ভালো ক্ষেত্রে n ভাগে সমান ভাগ হয়: সময় p/n. সমন্বয়, যোগাযোগ বা অসম ভাগের খরচ থাকলে সময় আরও বেশি — কখনো কম নয় (cache-এর বিশেষ প্রভাব বাদে; ভাবনার প্রশ্ন ৩ দেখুন).
  4. তাই T(n) ≥ (১−p) + p/n, আর speedup S(n) = T(১)/T(n):
S(n) ≤ 1 / ((1 − p) + p / n)

n যত বড় হয়, p/n তত শূন্যের কাছে যায়, হর যায় (১−p)-এর দিকে। তাই কোনো সংখ্যক core-এই speedup এর বেশি হয় না:

lim (n → ∞) S(n) = 1 / (1 − p)

সঙ্গে আরেকটি মাপ কাজে লাগে — efficiency E = S/n, অর্থাৎ প্রতিটি core গড়ে কতটা কাজে লাগল। নিচের ডায়াগ্রামে একটি পরীক্ষার ধাঁচের অঙ্ক ধাপে ধাপে করা হলো।

1T(1) = 100 s; serial 20 s, parallel 80 s2p = 80 / 100 = 0.83n = 4: parallel part = 80 / 4 = 20 s4T(4) = 20 + 20 = 40 s; S = 100 / 405check: 1 / (0.2 + 0.8/4) = 1 / 0.46n = 16: T = 20 + 80/16 = 25 s7n -> inf: T -> 20 s; S -> 1/(1-p)8want S = 4: 0.2 + 0.8/n = 1/490.8/n = 0.05  ->  n = 16
মানগুলো
n (core)
serial সময়
parallel সময়
T(n)
S(n)
E = S/n
  1. মাপ থেকে p বের করা। প্রোগ্রাম এক core-এ ১০০ সেকেন্ড নেয়; profiling দেখায় ২০ সেকেন্ড serial (ফাইল পড়া আর ফল জোড়া), ৮০ সেকেন্ড ভাগ করা যায়। তাই p = ০.৮, serial ভগ্নাংশ ০.২.
  2. ৪ core-এ সমান্তরাল অংশ। ৮০ সেকেন্ডের কাজ চার core-এ সমান ভাগ হলে প্রতিটি পায় ২০ সেকেন্ড, আর চারটি একসাথে চলে — তাই এই অংশে লাগে ২০ সেকেন্ড। Serial ২০ সেকেন্ড যেমন ছিল তেমনই।
  3. মোট সময় আর speedup. T(৪) = ২০ + ২০ = ৪০ সেকেন্ড, S = ১০০/৪০ = ২.৫. সূত্রেও একই: ১/(০.২ + ০.৮/৪) = ১/০.৪ = ২.৫. চার core পেয়েও গতি চার গুণ নয়, আড়াই গুণ; efficiency ২.৫/৪ = ৬২.৫%.
  4. ১৬ core-এ। সমান্তরাল অংশ ৮০/১৬ = ৫ সেকেন্ড, মোট ২৫ সেকেন্ড, S = ৪. Core চার গুণ বাড়ল (৪ → ১৬), গতি বাড়ল মাত্র ২.৫ থেকে ৪. Efficiency নেমে ২৫% — বারোটি core-এর সমান কাজ কার্যত অলস।
  5. অসীম core-এ সীমা। সমান্তরাল অংশের সময় শূন্যের দিকে যায়, কিন্তু serial ২০ সেকেন্ড থেকেই যায়। তাই S কখনো ১০০/২০ = ৫ ছাড়ায় না — সূত্রে ১/(১−০.৮) = ৫. Efficiency শূন্যের দিকে।
  6. উল্টো প্রশ্ন — কত core লাগবে? S = ৪ চাইলে ১/(০.২ + ০.৮/n) = ৪, অর্থাৎ ০.২ + ০.৮/n = ০.২৫, তাই ০.৮/n = ০.০৫, n = ১৬. আর S = ৬ চাইলে? সীমা ৫, তাই কোনো n-এই সম্ভব নয় — পরীক্ষায় এটি প্রিয় ফাঁদ।

আরও কয়েকটি পরীক্ষার ধাঁচের অঙ্ক

  • সরাসরি: p = ০.৬, n = ৪ → S = ১/(০.৪ + ০.১৫) = ১/০.৫৫ ≈ ১.৮২.
  • সর্বোচ্চ speedup: ২৫% কাজ serial হলে সর্বোচ্চ S = ১/০.২৫ = ৪ — ১,০০০ core দিলেও।
  • কত core: p = ০.৯৫, লক্ষ্য S = ১০ → ০.০৫ + ০.৯৫/n = ০.১ → ০.৯৫/n = ০.০৫ → n = ১৯.
  • সম্ভব কি না: p = ০.৯ দিয়ে S = ১২? সীমা ১/০.১ = ১০, তাই অসম্ভব।
  • একই সূত্র, অন্য প্রসঙ্গ: একটি ফাংশন মোট সময়ের ৪০% নেয়; সেটিকে ৪ গুণ দ্রুত করলে পুরো প্রোগ্রামের S = ১/(০.৬ + ০.৪/৪) = ১/০.৭ ≈ ১.৪৩. Computer architecture-এর বইয়ে Amdahl's law এই রূপেই আসে — “যে অংশ উন্নত করছেন, সেটি মোট কাজের কত ভাগ?”

নিচের ছোট simulator তিনটি p-এর জন্য ১ থেকে ৬৪ core-এ speedup-এর টেবিল বানায়, সাথে প্রতিটির সীমা, ৬৪ core-এ efficiency, আর সীমার ৯০%-এ পৌঁছাতে কত core লাগে:

// Amdahl's law: S(n) = 1 / ((1 - p) + p / n)
const ps = [0.5, 0.9, 0.95];
const ns = [1, 2, 4, 8, 16, 32, 64];
const S = (p, n) => 1 / ((1 - p) + p / n);
const col = (x) => x.padStart(8);

console.log('cores |' + ps.map(p => col('p=' + p)).join(' |'));
console.log('------+' + ps.map(() => '-'.repeat(9)).join('+'));
for (const n of ns) {
  console.log(String(n).padStart(5) + ' |' + ps.map(p => col(S(p, n).toFixed(2))).join(' |'));
}
console.log('limit |' + ps.map(p => col((1 / (1 - p)).toFixed(2))).join(' |'));
console.log('');
for (const p of ps) {
  const eff = S(p, 64) / 64 * 100;
  const goal = 0.9 / (1 - p);               // 90% of the limit
  let n = 1;
  while (S(p, n) < goal - 1e-9) n++;
  console.log(`p=${p}: efficiency at 64 cores = ${eff.toFixed(1)}%, cores for 90% of limit = ${n}`);
}
console.log(`Gustafson, p=0.95, n=64: S = 0.05 + 0.95*64 = ${(0.05 + 0.95 * 64).toFixed(2)}`);

আউটপুট:

cores |   p=0.5 |   p=0.9 |  p=0.95
------+---------+---------+---------
    1 |    1.00 |    1.00 |    1.00
    2 |    1.33 |    1.82 |    1.90
    4 |    1.60 |    3.08 |    3.48
    8 |    1.78 |    4.71 |    5.93
   16 |    1.88 |    6.40 |    9.14
   32 |    1.94 |    7.80 |   12.55
   64 |    1.97 |    8.77 |   15.42
limit |    2.00 |   10.00 |   20.00

p=0.5: efficiency at 64 cores = 3.1%, cores for 90% of limit = 9
p=0.9: efficiency at 64 cores = 13.7%, cores for 90% of limit = 81
p=0.95: efficiency at 64 cores = 24.1%, cores for 90% of limit = 171
Gustafson, p=0.95, n=64: S = 0.05 + 0.95*64 = 60.85

অর্ধেক কাজ serial (p = ০.৫) হলে ৬৪ core দিয়েও গতি দ্বিগুণও হয় না (১.৯৭), আর efficiency মাত্র ৩.১% — ৬৪টি core-এর প্রায় সবই অলস। p = ০.৯৫-এর মতো খুব ভালো সমান্তরাল প্রোগ্রামেও ৬৪ core-এ speedup ১৫.৪২, সীমা ২০. শেষের সংখ্যাগুলো আরও শিক্ষণীয়: সীমার ৯০%-এ পৌঁছাতে p = ০.৯-এ লাগে ৮১ core, p = ০.৯৫-এ ১৭১ — হিসাব করলে n = ৯p/(১−p). অর্থাৎ শেষের দিকে প্রতিটি বাড়তি core খুব সামান্য লাভ আনে। তাই বাস্তবে core বাড়ানোর আগে serial অংশ (১−p) ছোট করাই বেশি লাভজনক: p ০.৯ থেকে ০.৯৫ করলে ৬৪ core-এ গতি ৮.৭৭ থেকে ১৫.৪২. শেষ লাইনে একই p আর n-এ Gustafson-এর scaled speedup ৬০.৮৫ — কারণ সেখানে প্রশ্নটিই আলাদা (নিচে দেখুন).

Gustafson's law — অন্য দিক থেকে দেখা

Amdahl ধরে নিয়েছিলেন সমস্যার আকার স্থির — একই কাজ, বেশি core. ১৯৮৮ সালে Sandia National Laboratories-এর John Gustafson লক্ষ করেন, বাস্তবে বেশি core পেলে মানুষ সাধারণত বড় সমস্যা সমাধান করেন — একই সময়ে আরও সূক্ষ্ম আবহাওয়ার মডেল, আরও বড় ছবি। n core-এর রানে serial অংশের ভগ্নাংশ (১−p) ধরে তিনি “scaled speedup” দেন:

SG(n) = (1 − p) + p · n

p = ০.৯৫, n = ৬৪ হলে SG = ০.০৫ + ৬০.৮ = ৬০.৮৫, যেখানে একই p-তে Amdahl দেয় প্রায় ১৫.৪২. দুটি সূত্র পরস্পরবিরোধী নয় — তারা আলাদা প্রশ্নের উত্তর দেয়। Amdahl: “এই নির্দিষ্ট কাজ কত দ্রুত হবে?” Gustafson: “একই সময়ে কত বড় কাজ করা যাবে?” আর এখানে p মাপা হয় আলাদাভাবে — Gustafson-এ সমান্তরাল রানে, Amdahl-এ এক core-এর রানে।

থ্রেড থাকলেই parallelism হয় না। CPython-এর ঐতিহ্যগত GIL (global interpreter lock) এক মুহূর্তে একটি থ্রেডকেই Python bytecode চালাতে দেয়, তাই CPU-নির্ভর হিসাবে থ্রেড বাড়িয়ে সাধারণত গতি বাড়ে না (I/O-তে অপেক্ষার কাজে থ্রেড তবু কাজে লাগে). Python 3.13 থেকে GIL-ছাড়া একটি পরীক্ষামূলক build পাওয়া যায়। JavaScript-এর মূল থ্রেড একটিই; সমান্তরাল হিসাবে Web Worker বা Node.js-এর worker thread লাগে। ভাষার runtime আসলে কী করে, তা না জেনে “থ্রেড = দ্রুত” ধরে নেবেন না।

৪ · User-level thread বনাম kernel-level thread

থ্রেড কে চালায় — kernel, না প্রোগ্রামের ভেতরের একটি library? এই প্রশ্নের দুই উত্তর থেকে দুই ধরনের থ্রেড।

🧵 User-level thread

পুরোটা user mode-এর একটি library বা runtime-এর হাতে: থ্রেডের টেবিল, stack, সুইচ — সব প্রসেসের ভেতরে। Kernel জানেই না যে প্রসেসের ভেতরে একাধিক থ্রেড আছে; সে একটি প্রসেস দেখে।

সুইচ: সাধারণ function call-এর মতো সস্তা

⚙️ Kernel-level thread

Kernel প্রতিটি থ্রেডকে চেনে, তার TCB রাখে, আর scheduler প্রতিটিকে আলাদাভাবে core-এ পাঠায়। তৈরি আর সুইচে system call ও kernel-এর কাজ লাগে।

Linux, Windows, macOS — সবার আছে
বিষয়User-level threadKernel-level thread
তৈরি ও সুইচের খরচখুব কম — kernel-এ যেতে হয় নাবেশি — system call আর kernel-এর data structure
একটি থ্রেড blocking system call করলেপুরো প্রসেস আটকে যায় (kernel-এর চোখে প্রসেসটিই ঘুমাল)শুধু সেই থ্রেড আটকায়, বাকিরা চলে
Multicore ব্যবহারনা — kernel একটি প্রসেস দেখে, একটি core দেয়হ্যাঁ — থ্রেডগুলো আলাদা core-এ একসাথে
Scheduling নীতিঅ্যাপ নিজের মতো ঠিক করতে পারেKernel-এর নীতি (পর্ব ১৬–২১)
Preemptionপ্রায়ই সহযোগিতামূলক — থ্রেড নিজে না ছাড়লে বাকিরা অপেক্ষা করেTimer interrupt দিয়ে kernel জোর করে সরাতে পারে
উদাহরণপুরোনো green threads, GNU Portable Threads, Windows fiberLinux NPTL-এর pthread, Windows thread

দুই দিকের সেরাটা নেওয়ার চেষ্টাই পরের অংশের threading model. মনে রাখার মতো একটি কথা: আধুনিক user-level runtime (Go, Java virtual thread) blocking সমস্যা এড়াতে চালাকি করে — থ্রেড ধীর কাজে গেলে runtime নিজেই অন্য kernel thread-এ বাকিদের সরিয়ে দেয়, বা blocking কাজটিকে non-blocking I/O-তে বদলে নেয়।

৫ · Threading model — many-to-one, one-to-one, many-to-many

User thread শেষ পর্যন্ত CPU-তে চলে কোনো না কোনো kernel thread-এর ওপর চড়ে। কয়টি user thread কয়টি kernel thread-এ বসবে, সেই সম্পর্ককে বলে threading model. তিনটি মূল নকশা:

Modelগঠনসুবিধাঅসুবিধাবাস্তব উদাহরণ
Many-to-one (M:1)সব user thread একটি kernel thread-এসুইচ খুব সস্তা; kernel-এর কোনো সমর্থন লাগে নাএকটি blocking call সবাইকে আটকায়; এক core-এর বেশি নয়Solaris-এর Green Threads (প্রথম দিকের Java এটি ব্যবহার করত), GNU Portable Threads
One-to-one (1:1)প্রতিটি user thread-এর জন্য একটি kernel threadসত্যিকারের parallelism; একজন আটকালে বাকিরা চলেপ্রতিটি থ্রেড kernel-এর সম্পদ নেয়; লাখো থ্রেডে ভারLinux (NPTL), Windows, আধুনিক macOS, Solaris 9 থেকে, FreeBSD-র বর্তমান ডিফল্ট
Many-to-many (M:N)M user thread, N kernel thread (N ≤ M) — runtime বা library মেলায়লাখো সস্তা থ্রেড, তবু সব core কাজে লাগেবাস্তবায়ন জটিল; দুই scheduler-এর তথ্য মেলাতে হয়Solaris 9-এর আগের থ্রেড library; আজ Go-র goroutine আর Java 21-এর virtual thread (runtime-এর ভেতরে)

Many-to-many-র একটি রূপ two-level model: M:N-এর পাশাপাশি কোনো কোনো থ্রেডকে একটি kernel thread-এ স্থায়ীভাবে বেঁধে (bound) রাখা যায়। পুরোনো Solaris-এ ঠিক এটি ছিল; সেখানে user thread আর kernel thread-এর মাঝের ধাপটির নাম ছিল lightweight process (LWP). LWP নামটি আজও টিকে আছে — Linux-এর ps -L-এ থ্রেড ID-র কলামের নাম LWP.

Many-to-one-এর মূল দুর্বলতা আর one-to-one কীভাবে তা এড়ায়, নিচে সময় ধরে দেখুন। ধরা যাক প্রসেসে তিনটি থ্রেড, মেশিনে দুটি core, আর T1 ডিস্ক থেকে পড়তে read() ডাকে, যাতে ১০ মিলিসেকেন্ড লাগে।

1t=0   T1, T2, T3 ready; 2 cores2t=1   T1: read(fd, buf, 4096)3t=1   kernel: disk busy, block caller4t=1..11  waiting for the disk5t=11  disk interrupt: data ready6t=12  everyone can run again
থ্রেডের অবস্থা
M:1 · kernel thread
M:1 · T1
M:1 · T2, T3
1:1 · T1
1:1 · T2
1:1 · T3
busy cores M:1 / 1:1
  1. শুরুতে। Many-to-one-এ তিন user thread একটিমাত্র kernel thread-এর ওপর; library T1-কে চালাচ্ছে, তাই দুটি core থাকলেও একটিই ব্যস্ত। One-to-one-এ প্রতিটি থ্রেড kernel-এর কাছে আলাদা — T1 আর T2 দুই core-এ, T3 অপেক্ষায়।
  2. T1 ডিস্ক থেকে পড়তে চায়। দুই model-এই T1 read() system call করে। M:1-এ এর মানে ওই একমাত্র kernel thread-টিই kernel-এ ঢুকল।
  3. Kernel caller-কে ঘুম পাড়ায়। ডিস্ক ব্যস্ত, তাই kernel যে kernel thread ডেকেছে তাকে blocked করে। M:1-এ সেটিই প্রসেসের একমাত্র kernel thread — T2 আর T3 প্রস্তুত, কিন্তু চালানোর কেউ নেই; library-ও চলতে পারছে না। 1:1-এ শুধু T1-এর kernel thread ঘুমায়, খালি core-এ T3 উঠে আসে।
  4. ১০ মিলিসেকেন্ড অপেক্ষা। M:1-এ পুরো প্রসেস জমে আছে: T2, T3-এর এক মিলিসেকেন্ড কাজও হয়নি, একটি core-ও এই প্রসেসের কাজ করছে না। 1:1-এ T2 আর T3 দুই core-এ পুরো ১০ মিলিসেকেন্ড এগিয়েছে।
  5. ডিস্কের interrupt. ডেটা এল, kernel T1-এর kernel thread-কে আবার ready করে। M:1-এ এই মুহূর্তে প্রসেসটি আবার জীবিত হলো; 1:1-এ T1 ready queue-তে যোগ দিল।
  6. আবার চলা। M:1-এ read() ফিরে আসে, T1 আবার চলে — কিন্তু আবারও এক core-এই; T2 বা T3 চলবে কেবল T1 নিজে থেকে ছাড়লে (বা library-র নিজস্ব timer থাকলে). 1:1-এ scheduler T1-কে core দেয়, T3 পালার অপেক্ষায়। শিক্ষা: user-level library blocking system call-এর খবর পায় না বলেই M:1 আটকে যায়; প্রাচীন সমাধান ছিল প্রতিটি blocking call-কে non-blocking রূপে মুড়ে দেওয়া (jacket), আধুনিক সমাধান 1:1 বা চালাক M:N runtime.

M:N আবার ফিরে এসেছে — runtime-এর ভেতরে

১৯৯০-এর দশক আর ২০০০-এর গোড়ায় অনেক Unix M:N নিয়ে পরীক্ষা করেছিল, কিন্তু kernel-স্তরে বেশিরভাগই পরে 1:1-এ ফিরে যায় (ভাবনার প্রশ্ন ১ দেখুন). অথচ আজ M:N খুব জনপ্রিয় — তবে OS-এর থ্রেড library-তে নয়, ভাষার runtime-এ:

  • Go-র goroutine: Go runtime-এর scheduler অনেক goroutine-কে অল্প কয়েকটি OS thread-এ চালায়; একসাথে কয়টি OS thread Go কোড চালাবে, তা ঠিক করে GOMAXPROCS (ডিফল্ট সাধারণত CPU-র সংখ্যা). Goroutine-এর stack ছোট (কয়েক KB) দিয়ে শুরু হয়ে দরকারমতো বাড়ে, তাই লাখো goroutine সাধারণ ব্যাপার। কোনো goroutine blocking system call-এ গেলে runtime বাকিদের অন্য OS thread-এ সরিয়ে দেয়।
  • Java-র virtual thread: Java 21 (২০২৩) থেকে স্থায়ী বৈশিষ্ট্য। অনেক virtual thread অল্প কয়েকটি সাধারণ (platform) thread-এর ওপর চলে, যাদের বলে carrier thread. Virtual thread blocking I/O-তে গেলে JVM তাকে carrier থেকে নামিয়ে রাখে, carrier অন্য কাজ নেয়। শুরুর সংস্করণগুলোতে synchronized ব্লকের ভেতরে আটকালে virtual thread carrier-কে আটকে রাখত (“pinning”); পরের সংস্করণে এর অনেকটা সমাধান হয়েছে — ব্যবহারের আগে নিজের JDK-র নথি দেখে নিন।
  • Erlang/Elixir-এর হালকা “process”-ও একই ধারণা: BEAM virtual machine প্রতি core-এ একটি scheduler চালিয়ে লাখো process ভাগ করে চালায়।

এরা কেন সফল, যেখানে পুরোনো OS-স্তরের M:N কষ্টে পড়েছিল? কারণ ভাষার runtime জানে কোন কাজ blocking, stack নিজের মতো ছোট-বড় করতে পারে, আর নিজের I/O ব্যবস্থা (Linux-এ epoll, পর্ব ৩৯) দিয়ে blocking কাজ এড়াতে পারে — C library-র পক্ষে যা সাধারণভাবে করা কঠিন।

৬ · Linux-এ থ্রেড আসলে task — clone() আর /proc

Linux-এর নকশায় একটি সুন্দর সরলতা আছে: kernel “প্রসেস” আর “থ্রেড” আলাদা করে চেনে না, চেনে শুধু task — প্রতিটির একটি task_struct. নতুন task তৈরি হয় clone() system call দিয়ে, আর কোন কোন জিনিস parent-এর সাথে ভাগ হবে তা ঠিক করে দেয় একগুচ্ছ flag. সব ভাগ করলে সেটি থ্রেড, কিছুই ভাগ না করলে fork-এর মতো নতুন প্রসেস। মাঝামাঝি সংমিশ্রণও সম্ভব — container-এর namespace (পর্ব ৫১) এই একই ব্যবস্থার আরেকটি ব্যবহার।

/* roughly what glibc's pthread_create asks the kernel for */
int flags = CLONE_VM        /* share the address space (memory)      */
          | CLONE_FS        /* share cwd, root, umask                */
          | CLONE_FILES     /* share the open-file table             */
          | CLONE_SIGHAND   /* share signal handlers                 */
          | CLONE_THREAD    /* same thread group: same PID to users  */
          | CLONE_SYSVSEM   /* share System V semaphore undo values  */
          | CLONE_SETTLS    /* give the new task its own TLS area    */
          | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID;
/* new stack (from mmap) + flags -> clone() / clone3() */

/* fork(), by contrast: no sharing flags, only SIGCHLD on exit */

এই flag-গুলোর মধ্যে মূল হলো CLONE_VM — একই মেমরি — আর CLONE_THREAD — একই “thread group”. Thread group থাকায় সব থ্রেড user-এর চোখে একই PID দেখায় (getpid() সবার জন্য একই), আর প্রতিটির আলাদা thread ID (gettid()). প্রধান থ্রেডের TID আর প্রসেসের PID সমান। নতুন glibc-তে kernel সমর্থন করলে clone3() ব্যবহার হয়, ধারণা একই।

যেহেতু প্রতিটি থ্রেড একটি task, /proc/<pid>/task/ ডিরেক্টরিতে প্রতিটি থ্রেডের একটি করে উপ-ডিরেক্টরি থাকে (নাম = TID), আর /proc/<pid>/status-এর Threads: লাইন সংখ্যা জানায়। নিচের প্রোগ্রাম তিনটি থ্রেড তৈরি করে একটি pipe-এর read()-এ আটকে রাখে, তখন /proc/self/task-এ নিজের task গোনে, তারপর pipe বন্ধ করে সবাইকে ছেড়ে দেয়:

#define _DEFAULT_SOURCE
#include <dirent.h>
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

static int pfd[2];                         /* workers wait on this pipe */

static int tasks(void) {                   /* entries in /proc/self/task */
    int n = 0;
    DIR *d = opendir("/proc/self/task");
    for (struct dirent *e; d && (e = readdir(d)) != NULL; )
        if (e->d_name[0] != '.') n++;
    if (d) closedir(d);
    return n;
}
static void *worker(void *arg) {
    char c;
    while (read(pfd[0], &c, 1) > 0) { }     /* blocks until EOF */
    return arg;
}
int main(void) {
    pthread_t t[3];
    if (pipe(pfd) != 0) return 1;
    printf("before:                     %d task(s)\n", tasks());
    for (int i = 0; i < 3; i++) pthread_create(&t[i], NULL, worker, NULL);
    printf("3 workers blocked in read:  %d task(s)\n", tasks());
    close(pfd[1]);                         /* EOF wakes every worker */
    for (int i = 0; i < 3; i++) pthread_join(t[i], NULL);
    for (int i = 0; i < 200 && tasks() > 1; i++) usleep(1000);  /* see below */
    printf("after join:                 %d task(s)\n", tasks());
    return 0;
}

একটি আসল রানের আউটপুট:

before:                     1 task(s)
3 workers blocked in read:  4 task(s)
after join:                 1 task(s)

শুরুতে প্রসেসে একটিই task — main thread. তিনটি থ্রেড তৈরির পর /proc/self/task-এ চারটি ডিরেক্টরি — kernel প্রতিটি থ্রেডকে আলাদা task হিসেবে চেনে (one-to-one); /proc/self/status-এর Threads: লাইনও তখন 4 দেখাত (নিচের bash উদাহরণে এই লাইনটি দেখবেন). থ্রেডগুলো তখন pipe-এর read()-এ ঘুমিয়ে; pthread_create ফিরে আসা মানেই task তৈরি হয়ে গেছে, থ্রেড চলতে শুরু করুক বা না করুক, তাই গোনা নির্ভরযোগ্য। write-end বন্ধ করতেই সব read() 0 (EOF) ফেরত দেয়, থ্রেডগুলো শেষ হয়, join-এর পর আবার একটি task. একটি সূক্ষ্ম বিষয়: pthread_join ফেরে থ্রেডের কাজ শেষ হওয়ার খবর পেলেই, আর kernel task-টিকে তালিকা থেকে সরায় তার এক মুহূর্ত পরে। এই প্রোগ্রামের আগের একটি সংস্করণে join-এর ঠিক পরে গুনে একবার ২টি ডিরেক্টরি পাওয়া গিয়েছিল; তাই এখন গোনার আগে অল্প কয়েক মিলিসেকেন্ড পর্যন্ত অপেক্ষা করা হয় (usleep লুপ). Concurrent প্রোগ্রামে “ঠিক এখনই” ধরে নেওয়া এমনই বিপজ্জনক।

Shell থেকেও থ্রেড দেখা যায়। ps -L প্রতিটি থ্রেডের জন্য একটি সারি দেখায় (LWP = thread ID, NLWP = থ্রেডের সংখ্যা); top -H থ্রেড ধরে CPU দেখায়। এই পাতার bash রানারে একটি সাধারণ shell-এর অবস্থা দেখুন:

echo "== this bash shell =="
echo "entries in /proc/\$\$/task: $(ls /proc/$$/task | wc -l)"
grep '^Threads' /proc/$$/status
[ -d /proc/$$/task/$$ ] && echo "main thread's TID equals the PID: yes"
echo
echo "== ps -L: one row per thread (LWP = thread id, NLWP = count) =="
ps -L -o nlwp,comm -p $$
echo
echo "== CPUs this container may use =="
nproc
echo
echo "== thread count (NLWP) of every process here =="
ps -eo nlwp,comm --no-headers | sort -rn | head -n 5

একটি আসল রানের আউটপুট:

== this bash shell ==
entries in /proc/$$/task: 1
Threads:	1
main thread's TID equals the PID: yes

== ps -L: one row per thread (LWP = thread id, NLWP = count) ==
NLWP COMMAND
   1 bash

== CPUs this container may use ==
2

== thread count (NLWP) of every process here ==
   1 sort
   1 ps
   1 podman-init
   1 head
   1 bash

bash একটি single-threaded প্রোগ্রাম: /proc/$$/task-এ একটিই ডিরেক্টরি, যার নাম shell-এর PID-ই — প্রধান থ্রেডের TID আর PID সমান। ps -L থ্রেড-প্রতি একটি সারি দেখায়; এখানে NLWP ১. এই container-কে ২টি CPU দেওয়া আছে (আপনার রানে সংখ্যাটি আলাদা হতে পারে), আর ভেতরে চলা প্রতিটি প্রসেসই — container-এর init (podman-init) থেকে ps, sort পর্যন্ত — একটি থ্রেডের। একটি সাধারণ ডেস্কটপ বা সার্ভারে একই শেষ কমান্ড চালালে ব্রাউজার, ডেটাবেস বা Java প্রোগ্রামের NLWP সহজেই কয়েক ডজন থেকে কয়েকশো দেখাবে।

# Linux, any process (here PID 1234):
ps -o nlwp= -p 1234            # e.g.  37
ls /proc/1234/task | wc -l     # same number
top -H -p 1234                 # live, one line per thread

# macOS:
ps -M 1234                     # one line per thread

# Windows PowerShell:
(Get-Process -Id 1234).Threads.Count
# Task Manager -> Details -> right-click header -> add "Threads"

আরও গভীরে: Linux ২.৬-এর আগে থ্রেড চলত LinuxThreads library দিয়ে, যেখানে প্রতিটি থ্রেডের আলাদা PID ছিল আর signal-এর আচরণ POSIX-এর সাথে মিলত না। ২০০২–২০০৩ সালে Red Hat-এর Ulrich Drepper ও Ingo Molnar-এর NPTL (Native POSIX Thread Library) kernel-এ thread group আর futex (fast userspace mutex — অপেক্ষার দরকার না হলে lock-এ kernel-এ যেতেই হয় না) ব্যবহার করে 1:1 নকশাকে দ্রুত আর POSIX-সম্মত করে; একই সময়ে IBM-এর M:N প্রকল্প NGPT পরিত্যক্ত হয়। x86-64 Linux-এ প্রতিটি থ্রেডের thread-local storage খুঁজে পাওয়া যায় FS segment register-এর base ঠিকানা দিয়ে, যা context switch-এ kernel বদলে দেয়; Windows x64 একইভাবে GS দিয়ে প্রতিটি থ্রেডের TEB খোঁজে। M:N-এর তাত্ত্বিক ভিত্তি হিসেবে ১৯৯১ সালের “scheduler activations” গবেষণা (Anderson ও সহকর্মীরা) প্রস্তাব করেছিল, kernel থ্রেড আটকালে upcall দিয়ে user-স্তরের scheduler-কে খবর দেবে — NetBSD আর FreeBSD-র KSE এর রূপ বাস্তবায়ন করেও পরে 1:1-এ ফিরে যায়।

৭ · থ্রেডের ঝামেলা — fork, signal, cancellation আর TLS

প্রসেসের জন্য বানানো পুরোনো Unix ধারণাগুলো থ্রেড আসার পর নতুন প্রশ্ন তোলে। পরীক্ষায় আর বাস্তব debugging-এ এগুলো বারবার আসে।

১. Multithreaded প্রোগ্রামে fork()

আট থ্রেডের একটি প্রসেস fork করলে child-এ কয়টি থ্রেড থাকবে? দুটি সম্ভাব্য নকশা ছিল — সব থ্রেড কপি, বা শুধু যে থ্রেড fork ডেকেছে সেটি। POSIX দ্বিতীয়টি বেছে নিয়েছে: child-এ শুধু fork-ডাকা থ্রেডের একটি কপি থাকে, যদিও পুরো মেমরি (অন্য থ্রেডদের stack আর heap-সহ) কপি হয়। (পুরোনো Solaris-এ সব থ্রেড কপি করার আলাদা একটি call-ও ছিল।) সাধারণত fork-এর পরপরই exec (পর্ব ১০) হয়, তখন সব থ্রেড কপি করা অর্থহীন হতো। কিন্তু exec না করলে একটি লুকোনো ফাঁদ আছে:

1parent T1: lock(&log_lock); writing log ...2parent T2: pid = fork();3kernel: copy memory; child gets ONLY T24parent T1: unlock(&log_lock);5child T2': lock(&log_lock);  -> waits6fix: exec() at once, or posix_spawn()
দুই প্রসেসের অবস্থা
parent-এর থ্রেড
parent-এর log_lock
child-এর থ্রেড
child-এর log_lock
child-এর অবস্থা
  1. T1 lock নিয়ে লগ লিখছে। Parent-এ দুটি থ্রেড। T1 লগ ফাইলে লেখার আগে mutex log_lock নিয়েছে (mutex পর্ব ২৪-এ বিস্তারিত) — অর্থাৎ এখন lock-টি “T1-এর দখলে”।
  2. ঠিক তখনই T2 fork করে। দুই থ্রেড স্বাধীনভাবে চলে, তাই কোনো নিয়ম নেই যে T1-এর লেখা শেষ হওয়া পর্যন্ত T2 অপেক্ষা করবে।
  3. Child পায় শুধু T2-এর কপি। Kernel পুরো মেমরি কপি করে — log_lock-এর মানও, যেখানে লেখা “দখলে”। কিন্তু child-এ T1 নেই; যে lock ছাড়বে, সেই থ্রেড কপিই হয়নি।
  4. Parent-এ সব ঠিক। T1 লেখা শেষ করে lock ছেড়ে দেয়। কিন্তু সেটি parent-এর মেমরিতে; child-এর কপি আলাদা, সেখানে lock এখনো দখলে।
  5. Child চিরকাল অপেক্ষা করে। Child-এর T2' লগ লিখতে lock চায়, কিন্তু দখলদার T1 child-এ কখনো ছিলই না — কেউ lock ছাড়বে না। এটি একটি deadlock (পর্ব ২৭). বাস্তবে এটি প্রায়ই ঘটে malloc বা printf-এর ভেতরের lock-এ, তাই বাগটি অনিয়মিত আর খুঁজে পাওয়া কঠিন।
  6. নিরাপদ পথ। POSIX-এর নিয়ম: multithreaded প্রসেসে fork-এর পর child exec পর্যন্ত শুধু async-signal-safe ফাংশন ডাকবে। তাই fork-এর পরপরই exec করুন, বা posix_spawn() ব্যবহার করুন; একান্ত দরকারে pthread_atfork() দিয়ে fork-এর আগে-পরে lock সামলানো যায়।

২. Signal কোন থ্রেডে যায়

Signal (পর্ব ১৩) ছিল প্রসেসের জন্য বানানো। থ্রেড আসার পর POSIX নিয়ম দাঁড়াল: signal-এর handler আর ব্যবস্থা প্রসেস-ব্যাপী (সব থ্রেডের এক), কিন্তু signal mask প্রতিটি থ্রেডের নিজের (pthread_sigmask). ভুল কাজের ফলে আসা signal, যেমন SIGSEGV বা SIGFPE, যায় সেই থ্রেডেই যে ভুলটি করেছে। pthread_kill() দিয়ে নির্দিষ্ট থ্রেডে পাঠানো যায়। আর পুরো প্রসেসকে পাঠানো signal (যেমন kill কমান্ডের SIGTERM) kernel দেয় এমন যেকোনো একটি থ্রেডকে, যে সেটি block করেনি। প্রচলিত নিরাপদ কৌশল: সব থ্রেডে signal block করে একটিমাত্র নির্দিষ্ট থ্রেড sigwait()-এ বসিয়ে রাখা। Windows-এ Unix-ধাঁচের signal নেই; কাছাকাছি ধারণা asynchronous procedure call (APC), যা নির্দিষ্ট থ্রেডকে লক্ষ্য করে পাঠানো হয়।

৩. Thread cancellation — অন্য থ্রেডকে থামানো

ধরুন পাঁচটি থ্রেড একটি বড় ডেটাবেসে খুঁজছে; একটি উত্তর পেয়ে গেলে বাকিদের থামানো দরকার। POSIX-এ এর নাম thread cancellation (pthread_cancel()), আর এর দুই ধরন:

Asynchronous cancellation

লক্ষ্য থ্রেড সঙ্গে সঙ্গে থামে — হয়তো একটি lock হাতে রেখে, হয়তো ডেটা অর্ধেক লেখা অবস্থায়, হয়তো malloc-এর মাঝখানে। প্রায় কখনো নিরাপদ নয়।

PTHREAD_CANCEL_ASYNCHRONOUS

Deferred cancellation (ডিফল্ট)

অনুরোধ জমা থাকে; থ্রেড থামে শুধু নির্দিষ্ট cancellation point-এ — যেমন read, sleep, pthread_join, pthread_cond_wait বা নিজে ডাকা pthread_testcancel(). থামার আগে cleanup handler চলে, lock ছাড়া যায়।

PTHREAD_CANCEL_DEFERRED

অন্য ভাষাতেও একই শিক্ষা: Java-র Thread.stop() বহু বছর আগে অপ্রচলিত (deprecated) ঘোষণা হয়, কারণ সেটি থ্রেডকে যেকোনো জায়গায় থামাত; সাম্প্রতিক Java-তে এটি আর কাজই করে না। এখন পথ হলো interrupt() — একটি অনুরোধ, যা থ্রেড নিজে যাচাই করে সাড়া দেয়। Go-তে এই কাজটি করে context — সহযোগিতামূলক বাতিলকরণ।

৪. Thread-local storage (TLS)

কিছু ডেটা প্রতিটি থ্রেডের আলাদা কপি দরকার, অথচ ফাংশন থেকে ফাংশনে আর্গুমেন্ট হিসেবে পাঠানো ঝামেলা — যেমন errno: দুই থ্রেড একসাথে system call করলে একজনের ত্রুটি আরেকজনের errno নষ্ট করবে, যদি errno একটিই global হয়। সমাধান thread-local storage: ভ্যারিয়েবলটি দেখতে global, কিন্তু প্রতিটি থ্রেড নিজের কপি পায়। C11-এ _Thread_local (C23-এ thread_local), GCC-তে __thread, C++-এ thread_local, Windows-এ TlsAlloc() বা __declspec(thread), Java-তে ThreadLocal. Stack-এর লোকাল ভ্যারিয়েবল থেকে পার্থক্য হলো, TLS ফাংশন শেষ হলেও টিকে থাকে — থ্রেড যতক্ষণ আছে।

৫. Thread pool — শুধু পরিচয়

একটি ওয়েব সার্ভার প্রতিটি অনুরোধে নতুন থ্রেড বানালে দুটি সমস্যা: প্রতিবার তৈরি আর ধ্বংসের খরচ, আর হঠাৎ ভিড়ে হাজার হাজার থ্রেড তৈরি হয়ে মেমরি আর scheduler-এর ভার। Thread pool শুরুতেই নির্দিষ্ট সংখ্যক থ্রেড তৈরি করে রাখে; কাজ আসে একটি queue-তে, খালি থ্রেড একটি কাজ তুলে নেয়, শেষ করে আবার queue দেখে। পোশাক কারখানার সেলাই লাইনের মতো — প্রতিটি অর্ডারের জন্য নতুন কর্মী নিয়োগ হয় না, একই কর্মীরা একটির পর একটি কাজ করেন। পর্ব ১৫-এ POSIX thread দিয়ে একটি thread pool নিজের হাতে বানাবেন।

৮ · Implicit threading — থ্রেড না লিখেও সমান্তরাল

হাতে থ্রেড তৈরি, join আর lock সামলানো কঠিন আর ভুলপ্রবণ। তাই আধুনিক ধারা হলো implicit threading: প্রোগ্রামার বলেন কোন কাজ সমান্তরালে চলতে পারে, আর library বা compiler ঠিক করে কয়টি থ্রেডে, কীভাবে.

ব্যবস্থাপ্রোগ্রামার যা লেখেনভেতরে যা ঘটে
OpenMP (C, C++, Fortran)#pragma omp parallel for একটি লুপের ওপরেCompiler আর runtime লুপের ঘরগুলো একদল থ্রেডে ভাগ করে; core-এর সংখ্যা দেখে থ্রেড ঠিক হয়
Grand Central Dispatch (Apple)একটি block বা closure একটি dispatch queue-তে জমাOS-পরিচালিত thread pool queue থেকে কাজ তুলে চালায়; macOS ১০.৬ (২০০৯) থেকে
Java executor, fork-joinexecutor.submit(task)Thread pool; Java 21-এ প্রতিটি কাজে একটি virtual thread-ও দেওয়া যায়
async/await আর event loop (JavaScript, Python asyncio, C#)await fetch(url)একটি থ্রেড অনেক কাজ পালা করে চালায়; অপেক্ষার সময় অন্য কাজ। Node.js ফাইলের মতো কিছু blocking কাজ একটি ছোট ভেতরের thread pool-এ পাঠায়

লক্ষ করুন, async/await নিজে parallelism দেয় না — এটি concurrency-র কৌশল: এক থ্রেড অপেক্ষার সময়গুলো কাজে লাগায়। হাজার হাজার নেটওয়ার্ক সংযোগের সার্ভারে এটি দারুণ, কারণ বেশিরভাগ সময় সবাই নেটওয়ার্কের অপেক্ষায়; কিন্তু CPU-ভারী হিসাবে এক থ্রেডের event loop আটকে গেলে সব সংযোগ থেমে যায়। তখন worker thread বা আলাদা প্রসেসে হিসাব সরাতে হয়।

কোনটি কখন: CPU-ভারী, সহজে ভাগ করা লুপ → OpenMP বা thread pool-এ data parallelism. অনেক I/O-অপেক্ষার কাজ (সার্ভার, crawler) → event loop, goroutine বা virtual thread. শক্ত আলাদাকরণ দরকার (ব্রাউজার ট্যাব, অবিশ্বস্ত প্লাগিন) → আলাদা প্রসেস। থ্রেড একটি হাতিয়ার, লক্ষ্য নয়।

৯ · AI-এর যুগে থ্রেড

“C-তে চার থ্রেড দিয়ে একটি অ্যারের যোগফল বের করার কোড দাও” — AI কয়েক সেকেন্ডে দেবে, আর আপনার ল্যাপটপে প্রায়ই ঠিক উত্তরও দেখাবে। Concurrency-র বাগের সবচেয়ে বিপজ্জনক দিক হলো, এরা “আমার মেশিনে চলে” — ভুল ফল আসে লাখে একবার, ভিন্ন core-সংখ্যায়, বা লোড বাড়লে। AI-এর সাধারণ ভুলগুলো:

AI-এর ভুলফলকীভাবে ধরবেন
ভাগ করা ভ্যারিয়েবলে lock ছাড়া sum += xRace condition — মাঝে মাঝে ভুল যোগফলঅনেকবার, বেশি থ্রেডে চালান; -fsanitize=thread দিয়ে compile করুন
Amdahl-এর হিসাবে ভুল, বা “৮ core = ৮ গুণ দ্রুত” দাবিঅবাস্তব প্রত্যাশা, পরীক্ষায় ভুল উত্তরসূত্রে নিজে বসিয়ে হাতে মেলান; আসল সময় মাপুন
Multithreaded প্রোগ্রামে fork-এর পর exec ছাড়া printf/mallocঅনিয়মিত deadlockfork-এর পর exec বা posix_spawn আছে কি না দেখুন
পুরোনো তথ্য: “Linux-এ থ্রেড আলাদা PID পায়” (LinuxThreads যুগের), “Java থ্রেড green thread”ভুল ব্যাখ্যাman page (man 7 pthreads, man 2 clone) আর ভাষার বর্তমান নথি মিলিয়ে নিন
Python-এ CPU-ভারী কাজে থ্রেড দিয়ে গতি বাড়ানোর পরামর্শGIL-এর কারণে প্রায়ই গতি বাড়ে নাPython-এর সংস্করণ আর build জেনে, সময় মেপে দেখুন
লুপ ভ্যারিয়েবলের ঠিকানা থ্রেডে পাঠানো (&i)সব থ্রেড একই বা বদলে-যাওয়া মান পড়েপ্রতিটি থ্রেড নিজের আলাদা আর্গুমেন্ট পাচ্ছে কি না দেখুন

AI-কে concurrent কোড লিখতে বললে পরিবেশ, শর্ত আর যাচাইয়ের পদ্ধতি একসাথে দিন:

আমার পরিবেশ: Ubuntu 24.04, x86-64, ৮টি core, gcc 13, glibc, POSIX threads. একটি C প্রোগ্রাম লিখে দাও যা ১ কোটি long-এর একটি অ্যারের যোগফল T টি থ্রেডে (T = ১, ২, ৪, ৮) বের করবে। শর্ত: ১. প্রতিটি থ্রেড নিজের আলাদা অংশের যোগফল নিজের ভ্যারিয়েবলে রাখবে; কোনো ভাগ করা ভ্যারিয়েবলে lock ছাড়া লিখবে না। main সব join করে শেষে যোগ করবে। ২. প্রতিটি থ্রেডের আর্গুমেন্ট আলাদা struct-এ দাও, লুপ ভ্যারিয়েবলের ঠিকানা নয়। ৩. clock_gettime(CLOCK_MONOTONIC) দিয়ে প্রতিটি T-এর সময় মেপে speedup ছাপাও, আর ফল এক থ্রেডের ফলের সাথে মিলিয়ে দেখাও। ৪. gcc -Wall -Wextra -pthread আর -fsanitize=thread দুইভাবেই warning বা report ছাড়া চলতে হবে। শেষে বলো: এই কাজে Amdahl's law অনুযায়ী কেন ৮ গুণ speedup আশা করা উচিত নয়, আর memory bandwidth কীভাবে সীমা টানতে পারে।

AI-এর লেখা concurrent কোড ব্যবহারের আগে — চেকলিস্ট

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • প্রসেস আর থ্রেডের পার্থক্য? — প্রসেসের নিজের address space ও সম্পদ; থ্রেড প্রসেসের ভেতরের কাজের ধারা, যার নিজের PC, register, stack, কিন্তু code, data, heap, ফাইল ভাগ করা।
  • Multithreading model কয়টি ও কী কী? — many-to-one, one-to-one, many-to-many (আর তার রূপ two-level); Linux ও Windows one-to-one.
  • Many-to-one-এর প্রধান সমস্যা? — একটি থ্রেডের blocking system call পুরো প্রসেস আটকায়, আর multicore কাজে লাগে না।
  • ৮০% সমান্তরাল প্রোগ্রামে ৪ core-এ speedup? — ১/(০.২ + ০.২) = ২.৫; সর্বোচ্চ ৫.
  • Multithreaded প্রসেসে fork করলে child-এ কয়টি থ্রেড? — POSIX-এ একটি, যে থ্রেড fork ডেকেছে তার কপি।

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১কাগজে many-to-many সবচেয়ে নমনীয়, তবু Linux, Solaris আর FreeBSD কেন kernel-স্তরে one-to-one-এ থিতু হলো?

১৯৯০-এর দশকে মনে করা হতো kernel thread দামি, তাই ভবিষ্যৎ M:N-এর। Solaris ২.x-এর থ্রেড library ছিল two-level: user thread, তার নিচে LWP, তার নিচে kernel thread. ১৯৯১ সালে Anderson ও সহকর্মীদের “scheduler activations” গবেষণা দেখাল, M:N ঠিকভাবে চালাতে kernel-কে user-স্তরের scheduler-কে জানাতে হবে কখন একটি kernel thread আটকাল বা ছাড়া পেল। NetBSD এই নকশা বাস্তবায়ন করেছিল, FreeBSD ৫-এ এসেছিল KSE নামে।

বাস্তবে দেখা গেল M:N-এর খরচ লুকোনো জায়গায়। দুটি scheduler — kernel-এর আর library-র — একে অপরের তথ্য পুরোপুরি জানে না: kernel জানে না কোন user thread জরুরি, library জানে না কোন kernel thread page fault-এ আটকেছে। Signal কোন user thread-এ যাবে, priority কীভাবে মিলবে, debugger কীভাবে থ্রেড দেখবে — প্রতিটি প্রশ্নে বাড়তি জটিলতা। এদিকে kernel thread সস্তা করার কাজও এগোচ্ছিল। Linux-এ ২০০২–২০০৩ সালে দুটি প্রকল্প মুখোমুখি হয়: IBM-এর M:N “NGPT” আর Red Hat-এর 1:1 “NPTL”. NPTL-এর পরীক্ষায় kernel-এর নতুন thread group, futex আর দ্রুত clone দিয়ে বিপুল সংখ্যক থ্রেড অল্প সময়ে তৈরি করে দেখানো হয়; NGPT প্রকল্প এরপর বন্ধ হয়। Sun-ও Solaris ৯ (২০০২) থেকে ডিফল্ট 1:1 করে, FreeBSD ৭ থেকে ডিফল্ট libthr (1:1), NetBSD ৫ থেকে scheduler activations বাদ দেয়।

মজার ব্যাপার, M:N আবার ফিরে এসেছে Go, Erlang আর Java virtual thread-এ। পার্থক্য হলো, এখানে runtime ভাষাটিকেই নিয়ন্ত্রণ করে: সে জানে কোথায় blocking call হচ্ছে, stack নিজের মতো সরাতে-বাড়াতে পারে, I/O-কে নিজের event loop-এ নিয়ে যেতে পারে। সাধারণ C library-র পক্ষে, যার ওপর যেকোনো কোড যেকোনো system call করতে পারে, এই নিয়ন্ত্রণ পাওয়া প্রায় অসম্ভব।

মূল উপলব্ধি: নমনীয় নকশা তখনই জেতে, যখন যে স্তর সিদ্ধান্ত নেয় তার হাতে যথেষ্ট তথ্য থাকে। OS-এর থ্রেড library-র হাতে সেই তথ্য ছিল না, ভাষার runtime-এর হাতে আছে — তাই একই ধারণা এক জায়গায় ব্যর্থ, আরেক জায়গায় সফল।

প্র ০২একটি ব্রাউজার বা ডেটাবেস সার্ভার বানাতে থ্রেড নেবেন, না আলাদা প্রসেস? বাস্তব সিস্টেম কী বেছে নিয়েছে?

প্রশ্নটি আসলে “ভাগাভাগি বনাম আলাদাকরণ”-এর। থ্রেড দেয় সস্তা ভাগাভাগি আর দ্রুত সুইচ; প্রসেস দেয় হার্ডওয়্যার-নিশ্চিত দেয়াল — একটি ভেঙে পড়লে বাকিরা টিকে থাকে, একটি আক্রান্ত হলে অন্যটির মেমরি সরাসরি পড়া যায় না।

২০০৮ সালে Google Chrome যখন আসে, তখনকার বেশিরভাগ ব্রাউজার এক প্রসেসে চলত; একটি ট্যাবের crash বা প্লাগিনের বাগ পুরো ব্রাউজার বন্ধ করত। Chrome প্রতিটি ট্যাব বা সাইটের জন্য আলাদা renderer প্রসেস চালিয়ে আর তাকে sandbox-এ রেখে (পর্ব ৪৯) এই সমস্যা কমায়। ২০১৮ সালে Spectre-ধরনের আক্রমণ প্রকাশের পর Chrome “site isolation” আরও কড়া করে, কারণ একই প্রসেসে থাকা দুটি সাইটের মেমরি hardware side-channel দিয়ে ফাঁস হওয়ার ঝুঁকি দেখা দেয়। দাম: বেশি মেমরি, আর প্রসেসে প্রসেসে IPC. Firefox-ও পরে বহু-প্রসেস নকশায় যায়।

ডেটাবেসে দুই সফল পথ পাশাপাশি দেখা যায়। PostgreSQL প্রতিটি client সংযোগের জন্য একটি প্রসেস চালায় — এক সংযোগের crash সাধারণত শুধু সেই প্রসেস নেয় (যদিও shared memory নষ্ট হওয়ার আশঙ্কায় সার্ভার অন্যদেরও restart করাতে পারে), কিন্তু হাজার হাজার সংযোগে প্রসেসের ভার বাড়ে, তাই connection pooler লাগে। MySQL প্রতিটি সংযোগে একটি থ্রেড চালায় — সংযোগ সস্তা, কিন্তু একটি থ্রেডের মেমরি-বাগ পুরো সার্ভার নামাতে পারে। Apache ওয়েব সার্ভারেও “prefork” (প্রসেস), “worker” (থ্রেড) আর “event” মোড আছে, যাতে প্রশাসক নিজের প্রয়োজনমতো বেছে নিতে পারেন।

অনেক সিস্টেম দুটো মেশায়: কয়েকটি প্রসেস, প্রতিটির ভেতরে কয়েকটি থ্রেড। Nginx কয়েকটি worker প্রসেস চালায় (সাধারণত core-প্রতি একটি), প্রতিটির ভেতরে একটি event loop.

মূল উপলব্ধি: “থ্রেড দ্রুত, প্রসেস নিরাপদ” — এই এক লাইনের পেছনে আসল প্রশ্ন হলো ব্যর্থতা আর আক্রমণ কতদূর ছড়াতে দেওয়া যায়। নকশার শুরুতেই সেটি ঠিক করলে থ্রেড-প্রসেসের সিদ্ধান্ত সহজ হয়।

প্র ০৩Amdahl's law কি অতি হতাশাবাদী? বাস্তবে speedup কখনো সূত্রের চেয়ে কম, কখনো বেশি হয় কেন?

Gene Amdahl ১৯৬৭ সালের AFIPS সম্মেলনে যুক্তিটি দেন মূলত শক্তিশালী একক processor-এর পক্ষে: serial অংশ থাকলে বহু processor-এর লাভ সীমিত। সূত্রটি গাণিতিকভাবে ঠিক, কিন্তু এর অনুমানগুলো খেয়াল করার মতো — সমস্যার আকার স্থির, সমান্তরাল অংশ নিখুঁতভাবে সমান ভাগ হয়, আর ভাগ করার কোনো খরচ নেই।

বাস্তবে speedup প্রায়ই সূত্রের চেয়েও কম: থ্রেড তৈরি আর join-এর খরচ, lock-এর জন্য অপেক্ষা, cache line নিয়ে core-দের টানাটানি (false sharing), memory bandwidth-এর সীমা, আর কাজের অসম ভাগ — সবচেয়ে ধীর থ্রেড শেষ না হলে join শেষ হয় না। কিছু ক্ষেত্রে বেশি থ্রেডে উল্টো গতি কমে যায়।

আবার কখনো speedup n-এর চেয়েও বেশি দেখা যায় — একে বলে superlinear speedup. সাধারণ কারণ cache: ১ GB ডেটা এক core-এর cache-এ আঁটে না, কিন্তু ১৬ core-এ ভাগ করলে প্রতিটির অংশ নিজের cache-এ এঁটে যায়, তাই প্রতিটি অংশ এক core-এর তুলনায় দ্রুত চলে। এটি Amdahl-এর সূত্র ভাঙে না; শুধু “সমান্তরাল অংশের সময় p/n” অনুমানটি বদলায়।

১৯৮৮ সালে John Gustafson দেখান, Sandia-র ১০২৪-processor মেশিনে কিছু বৈজ্ঞানিক কাজে খুব বড় scaled speedup পাওয়া গেছে, কারণ সমস্যার আকার processor-এর সাথে বেড়েছে, আর serial অংশ প্রায় একই থেকেছে। আজকের GPU আর ক্লাউডের বড় হিসাব (যেমন machine learning-এর প্রশিক্ষণ) এই দৃষ্টিভঙ্গির ওপরই দাঁড়িয়ে। অন্যদিকে আপনার মোবাইল অ্যাপ একটি নির্দিষ্ট কাজ দ্রুত করতে চায় — সেখানে Amdahl-ই প্রাসঙ্গিক।

মূল উপলব্ধি: Amdahl's law নিয়তি নয়, একটি সতর্কবার্তা — serial অংশ খুঁজে ছোট করাই সবচেয়ে বড় লাভ। আর সমস্যার আকার বাড়ানো গেলে Gustafson-এর আশাবাদী হিসাবও সত্য হতে পারে।

প্র ০৪fork() আর থ্রেড একসাথে এত বিপজ্জনক কেন? ভাষা আর library-গুলো কীভাবে এই সমস্যার মুখোমুখি হয়েছে?

fork-এর নকশা ১৯৭০-এর দশকের Unix-এর, যখন প্রসেসে থ্রেড ছিল একটিই; তখন “নিজের হুবহু কপি” ধারণাটি সরল আর সুন্দর। থ্রেড আসার পর কপিটি আর হুবহু থাকে না — POSIX-এ child-এ থাকে শুধু fork-ডাকা থ্রেড, অথচ মেমরিতে অন্য থ্রেডদের অর্ধেক-শেষ কাজের চিহ্ন: দখলে থাকা lock, অর্ধেক হালনাগাদ হওয়া ডেটা-কাঠামো। Child যদি সেগুলো ছোঁয়, ফল deadlock বা ভাঙা ডেটা। এজন্য POSIX বলে, multithreaded প্রসেসের child exec পর্যন্ত শুধু async-signal-safe ফাংশন (যেমন write, _exit, execve) ডাকতে পারবে — malloc বা printf নয়।

pthread_atfork() এই সমস্যা সামলাতে আনা হয়েছিল: fork-এর আগে সব lock নিয়ে নেওয়া, পরে দুই দিকেই ছেড়ে দেওয়া। কিন্তু প্রতিটি library-কে নিজের lock এভাবে নথিভুক্ত করতে হয়, যা বাস্তবে প্রায় অসম্ভব; POSIX-এর পরের সংস্করণগুলোও এর সীমাবদ্ধতা স্বীকার করে। তাই আধুনিক পরামর্শ হলো fork-এর পরপরই exec, অথবা সরাসরি posix_spawn() — যা “নতুন প্রোগ্রাম চালাও” কাজটি এক ধাপে করে, অনেক ক্ষেত্রে দ্রুতও।

বাস্তব প্রভাব বড় ভাষাতেও দেখা গেছে। Python-এর multiprocessing module ঐতিহ্যগতভাবে Linux-এ fork দিয়ে worker বানাত; থ্রেডওয়ালা প্রোগ্রামে এটি অনিয়মিত deadlock ঘটাত। Python ৩.৮-এ macOS-এ ডিফল্ট পদ্ধতি “spawn” করা হয়, ৩.১২ থেকে multithreaded প্রসেসে fork করলে সতর্কবার্তা আসে, আর পরে Linux-এও ডিফল্ট fork থেকে সরানো হয়েছে। macOS-এর অনেক system framework fork-এর পর exec ছাড়া ব্যবহার করা অসমর্থিত। Windows-এ fork নেই-ই; সেখানে CreateProcess সবসময় নতুন প্রোগ্রাম দিয়ে শুরু করে, তাই এই শ্রেণির বাগও নেই।

মূল উপলব্ধি: একটি পুরোনো সরল ধারণার ওপর নতুন ধারণা চাপালে কিনারায় অদ্ভুত আচরণ জন্মায়। fork + থ্রেডের নিরাপদ নিয়মটি ছোট — “fork-এর পর শুধু exec” — কিন্তু কেন, সেটি বোঝা থাকলেই AI বা সহকর্মীর ভুল কোড চোখে পড়ে।

প্র ০৫এক সার্ভারে দশ হাজার সংযোগ — প্রতিটির জন্য একটি থ্রেড, event loop, না virtual thread? “C10k” সমস্যার ইতিহাস কী শেখায়?

১৯৯৯ সালে Dan Kegel “The C10K problem” নামে একটি লেখায় প্রশ্ন তোলেন: একটি সার্ভার একসাথে দশ হাজার client কীভাবে সামলাবে? তখনকার সাধারণ নকশা ছিল প্রতিটি সংযোগে একটি প্রসেস বা থ্রেড। দশ হাজার থ্রেড মানে দশ হাজার stack, দশ হাজার kernel task, আর scheduler-এর ওপর বিপুল চাপ — যদিও বেশিরভাগ থ্রেড আসলে নেটওয়ার্কের অপেক্ষায় বসে।

উত্তর হিসেবে এল event-driven নকশা: কয়েকটি থ্রেড, প্রতিটি একটি event loop চালায়, আর OS-কে জিজ্ঞেস করে “কোন কোন সংযোগে এখন ডেটা এসেছে?” — Linux-এ epoll (২.৬ kernel), BSD আর macOS-এ kqueue, Windows-এ I/O completion port. Nginx (২০০৪) আর Node.js (২০০৯) এই পথে বিখ্যাত হয়। দাম: কোড ছোট ছোট callback-এ ভাঙতে হয়, আর একটি callback CPU আটকে রাখলে পুরো loop থামে। async/await ভাষাগুলোতে এই callback-কে সাধারণ ধারাবাহিক কোডের মতো দেখানোর কৌশল।

তৃতীয় পথ হলো runtime-এর M:N থ্রেড: Go-তে প্রতিটি সংযোগের জন্য একটি goroutine লেখা হয়, যেন সাধারণ blocking কোড; runtime ভেতরে epoll ব্যবহার করে আর অল্প কয়েকটি OS thread-এ লাখো goroutine চালায়। Java ২১-এর virtual thread একই সুবিধা দেয় — পুরোনো “থ্রেড-প্রতি-অনুরোধ” কোড প্রায় না বদলেই বিপুল সংখ্যক সংযোগে নেওয়া যায়। আবার kernel-এর 1:1 থ্রেডও আগের চেয়ে অনেক সস্তা হয়েছে; মাঝারি লোডে সাধারণ thread pool আজও সবচেয়ে সরল ও নির্ভরযোগ্য।

বাংলাদেশের প্রেক্ষাপটে ভাবুন: ঈদের আগে টিকিট বা কোনো ভর্তি-ফলের ওয়েবসাইটে হঠাৎ বিপুল ভিড় হয়। সেখানে সার্ভারের দুর্বলতা প্রায়ই CPU নয়, বরং প্রতিটি অপেক্ষমাণ সংযোগের জন্য আটকে থাকা থ্রেড, ডেটাবেস সংযোগের সীমা আর ধীর বাইরের সেবা।

মূল উপলব্ধি: সংযোগ-প্রতি খরচ যত কম, তত বেশি সংযোগ — event loop, M:N runtime আর সস্তা kernel thread একই লক্ষ্যে তিনটি আলাদা পথ। সঠিক পথ নির্ভর করে কাজটি অপেক্ষা-ভারী না CPU-ভারী, আর দল কোন ধরনের কোড নির্ভরযোগ্যভাবে লিখতে পারে তার ওপর।

অনুশীলন

হাতে অঙ্ক, আউটপুট অনুমান, model-এর ট্রেস আর কমান্ড — আগে নিজে চেষ্টা করুন, তারপর উত্তর মেলান।

  1. হাতে অঙ্ক (Amdahl): একটি ছবি-প্রসেসিং প্রোগ্রাম এক core-এ ৬০ সেকেন্ড নেয়, যার ৯ সেকেন্ড serial. (ক) p কত? (খ) ৮ core-এ speedup আর মোট সময়? (গ) সর্বোচ্চ সম্ভাব্য speedup? (ঘ) speedup ৫ পেতে সর্বনিম্ন কয়টি core লাগবে?

    (ক) সমান্তরাল অংশ ৬০ − ৯ = ৫১ সেকেন্ড, p = ৫১/৬০ = ০.৮৫, serial ভগ্নাংশ ০.১৫.

    (খ) T(৮) = ৯ + ৫১/৮ = ৯ + ৬.৩৭৫ = ১৫.৩৭৫ সেকেন্ড; S = ৬০/১৫.৩৭৫ ≈ ৩.৯০. সূত্রে: ১/(০.১৫ + ০.৮৫/৮) = ১/০.২৫৬২৫ ≈ ৩.৯০.

    (গ) ১/০.১৫ ≈ ৬.৬৭ — মোট সময় কখনো ৯ সেকেন্ডের নিচে নামবে না।

    (ঘ) ০.১৫ + ০.৮৫/n ≤ ০.২ → ০.৮৫/n ≤ ০.০৫ → n ≥ ১৭. তাই ১৭টি core (তখন S ঠিক ৫; ১৬ core-এ S ≈ ৪.৯২).

  2. আউটপুট অনুমান: নিচের প্রোগ্রামে worker থ্রেড হিসেবে চলে আর main তাকে join করে; তারপর fork হয়ে child counter বাড়ায়। main শেষ লাইনে কী ছাপবে? buf malloc করা মেমরি।
    int counter = 10;                     /* global */
    void *worker(void *a) { counter += 5; ((int *)a)[0] = 7; return NULL; }
    /* in main: */
    int *buf = malloc(sizeof(int)); buf[0] = 1;
    pthread_create(&t, NULL, worker, buf); pthread_join(t, NULL);
    fflush(stdout);
    if (fork() == 0) { counter += 100; buf[0] = 99; _exit(0); }
    wait(NULL);
    printf("counter=%d buf=%d\n", counter, buf[0]);
    counter=15 buf=7

    থ্রেড একই প্রসেসে চলে, তাই তার দুটি পরিবর্তন — global counter ১০ থেকে ১৫, আর heap-এর buf[0] ১ থেকে ৭ — join-এর পর main দেখতে পায়। Child প্রসেস নিজের কপিতে counter ১১৫ আর buf[0] ৯৯ করে; parent-এর মেমরি অপরিবর্তিত। heap-ও প্রসেসভেদে আলাদা কপি — একই ঠিকানা হলেও আলাদা physical পাতা (copy-on-write).

  3. Model ট্রেস: একটি প্রসেসে চারটি user thread (U1–U4), মেশিনে চারটি core. U1 একটি blocking read() করে আর ২০ মিলিসেকেন্ড আটকে থাকে। (ক) many-to-one, (খ) one-to-one, (গ) চারটি user thread দুটি kernel thread-এ many-to-many (runtime blocking-এর খবর পায় না) — প্রতিটিতে ওই ২০ মিলিসেকেন্ডে সর্বোচ্চ কয়টি user thread চলতে পারে?
    • (ক) Many-to-one: ০টি. একমাত্র kernel thread kernel-এ আটকে; U2–U4 প্রস্তুত, কিন্তু চালানোর কেউ নেই।
    • (খ) One-to-one: ৩টি. U1-এর kernel thread আটকেছে; U2, U3, U4 নিজের নিজের kernel thread-এ তিন core-এ চলে।
    • (গ) M:N (৪:২): ১টি. দুটি kernel thread-এর একটি U1-কে নিয়ে আটকে; অন্যটিতে U2–U4-এর মধ্যে এক সময়ে একটি চলে, পালা করে। চারটি core থাকলেও দুটির বেশি কখনো ব্যবহার হয় না। এজন্যই Go-র মতো runtime blocking call-এর সময় নতুন kernel thread জুড়ে দেয় — তখন উত্তর ২ বা তার বেশি হতো।
  4. কমান্ড লিখুন: Linux সার্ভারে PID 4321-এর একটি Java প্রসেস আছে। (ক) এর মোট থ্রেড সংখ্যা দুইভাবে বের করার কমান্ড লিখুন। (খ) কোন থ্রেড সবচেয়ে বেশি CPU খাচ্ছে, লাইভ দেখার কমান্ড? (গ) Windows-এ একই প্রসেসের থ্রেড সংখ্যা PowerShell-এ কীভাবে দেখবেন?
    # (a)
    ps -o nlwp= -p 4321
    grep '^Threads' /proc/4321/status        # or: ls /proc/4321/task | wc -l
    # (b)
    top -H -p 4321                            # or: ps -L -o tid,pcpu,comm -p 4321 | sort -k2 -rn | head
    # (c) PowerShell
    (Get-Process -Id 4321).Threads.Count

    অন্য ব্যবহারকারীর প্রসেসের কিছু /proc তথ্য দেখতে অনুমতি লাগতে পারে; নিজের প্রসেস হলে সমস্যা নেই। top -H-এ যে TID বেশি CPU দেখায়, সেটিকে hexadecimal-এ রূপান্তর করে Java-র thread dump (jstack)-এর nid-এর সাথে মিলিয়ে কোন Java থ্রেড দায়ী তা খোঁজা যায় — বাস্তবের একটি প্রচলিত কৌশল।

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

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

ক্রমLinux-এ pthread_create() ডাকা থেকে নতুন থ্রেড চলা পর্যন্ত ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. প্রোগ্রাম glibc-র pthread_create() ডাকে
  2. glibc নতুন থ্রেডের stack (mmap দিয়ে) আর thread-local storage-এর জায়গা ঠিক করে
  3. glibc CLONE_VM, CLONE_THREAD ইত্যাদি flag দিয়ে clone system call করে
  4. Kernel একই memory আর file টেবিল ভাগ করা একটি নতুন task_struct তৈরি করে
  5. নতুন task ready queue-তে যায়, scheduler তাকে একটি core-এ চালায়

একই প্রসেসের দুটি থ্রেডের মধ্যে নিচের কোনটি ভাগ করা হয় না?

  • Global ভ্যারিয়েবল
  • Stack আর register-এর মান
  • খোলা ফাইলের টেবিল
  • Heap মেমরি

একটি প্রোগ্রামের ৯০% কাজ সমান্তরালে চালানো যায়। Amdahl's law অনুযায়ী অসীম সংখ্যক core দিলে সর্বোচ্চ speedup কত?

  • অসীম
  • ৯০
  • ১০
  • প্রায় ১.১১

Many-to-one threading model-এ একটি user thread blocking read() system call করলে কী হয়?

  • পুরো প্রসেসের সব থ্রেড আটকে যায়
  • শুধু সেই থ্রেড আটকায়, বাকিরা অন্য core-এ চলে
  • Kernel স্বয়ংক্রিয়ভাবে বাকিদের জন্য নতুন kernel thread তৈরি করে
  • প্রসেসটি একটি signal পেয়ে বন্ধ হয়ে যায়

Linux-এ একটি multithreaded প্রসেসের সব থ্রেডে getpid() একই মান দেয়, কিন্তু gettid() আলাদা। এর কারণ কী?

  • থ্রেডগুলো আসলে একটিই task, gettid শুধু একটি কাউন্টার
  • glibc PID cache করে, kernel আসলে প্রতিটি থ্রেডকে আলাদা PID দেয়
  • থ্রেডগুলো user-level, kernel তাদের চেনে না
  • CLONE_THREAD সব থ্রেডকে একই thread group-এ রাখে; PID হলো group-এর ID, TID প্রতিটির নিজের

POSIX অনুযায়ী ৮টি থ্রেডের একটি প্রসেসের একটি থ্রেড fork() ডাকলে child প্রসেসে কয়টি থ্রেড থাকে?

  • ৮টি — সব থ্রেডের কপি
  • ১টি — যে থ্রেড fork ডেকেছে তার কপি
  • ০টি — child শুধু exec-এর জন্য অপেক্ষা করে
  • core-এর সংখ্যার সমান

Deferred thread cancellation-এ লক্ষ্য থ্রেড কখন থামে?

  • অনুরোধ আসার সঙ্গে সঙ্গে, যেখানেই থাকুক
  • শুধু পুরো প্রসেস শেষ হলে
  • পরের cancellation point-এ পৌঁছালে
  • পরের timer interrupt-এ

Go-র goroutine আর Java 21-এর virtual thread কোন threading model-এর উদাহরণ?

  • Many-to-many (M:N), runtime-এর ভেতরে
  • One-to-one (1:1)
  • Many-to-one (M:1)
  • প্রতিটি আলাদা প্রসেস

শব্দকোষ

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

মনে রাখুন

  1. থ্রেডের নিজের PC, register, stack (আর TLS); code, data, heap, খোলা ফাইল প্রসেসের সব থ্রেড ভাগ করে। প্রসেস-তথ্য PCB-তে, থ্রেড-তথ্য TCB-তে।
  2. চার সুবিধা: responsiveness, resource sharing, economy, scalability; দাম — একটি থ্রেডের বাগ পুরো প্রসেসের।
  3. Amdahl: S ≤ ১/((১−p) + p/n), সীমা ১/(১−p); Gustafson: সমস্যা বড় হলে S = (১−p) + p·n.
  4. Many-to-one-এ এক blocking call সবাইকে আটকায়; one-to-one Linux ও Windows-এর পথ; many-to-many আজ Go আর Java virtual thread-এর runtime-এ।
  5. Linux-এ থ্রেড = CLONE_VM | CLONE_THREAD দিয়ে তৈরি task; fork-এ শুধু ডাকা থ্রেড কপি হয়, signal mask থ্রেডের নিজের, cancellation সাধারণত deferred.

পরের পর্বে: ধারণা শেষ, এবার হাতে-কলমে। POSIX thread দিয়ে থ্রেড তৈরি, আর্গুমেন্ট পাঠানো আর ফল ফেরত, join আর detach, data race হাতেনাতে দেখা — আর শেষে নিজের একটি thread pool.

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

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