Foundationপ্রথম নীতি থেকে
LEVEL 4লেসন ৬/২৯অ্যাডভান্সড১ ঘণ্টা ২০ মিনিট

Threads — একই address space-এ একাধিক execution context

Threads

Thread হলো একই address space-এর ভেতরে একাধিক স্বাধীন execution context — নিজের stack আর register আছে, কিন্তু heap, global আর file descriptor সবার সাথে ভাগাভাগি। Linux-এ এই ধারণাটা আলাদা কোনো object না: thread আর process দুটোই একই `task_struct`, শুধু `clone()`-এ কোন flag পাস হয়েছে তাতেই পার্থক্য। এই লেসনে শেয়ার-বনাম-প্রাইভেট বিভাজন, clone flag-এর প্রকৃত অর্থ, TLS-এর জন্য `fs` register-এর ব্যবহার, আর 1:1 বনাম M:N model-এর ইতিহাস — সবকিছু চালানো-যায় এমন কোড দিয়ে প্রমাণ করে দেখা হবে।

এই লেসন শেষে আপনি পারবেন

  • একটা thread আর একটা process-এর মধ্যে ঠিক কী কী resource শেয়ার হয় (text, heap, global, file descriptor table, cwd, signal handler) আর কী কী প্রাইভেট থাকে (stack, register set, TLS, errno, signal mask) — এই বিভাজনটা তালিকা করে বলতে ও কোড দিয়ে প্রমাণ করতে পারবেন
  • Linux-এর একীভূত দৃষ্টিভঙ্গি ব্যাখ্যা করতে পারবেন — thread আর process দুটোই একটা `task_struct`, আর পার্থক্যটা শুধু `clone()`-এ কোন flag দেওয়া হয়েছে তাতে; বিশেষত CLONE_VM, CLONE_FS, CLONE_FILES, CLONE_SIGHAND, CLONE_THREAD-এর প্রতিটার আলাদা প্রভাব বলতে পারবেন
  • 1:1, N:1 (green thread) আর M:N threading model-এর পার্থক্য বর্ণনা করতে পারবেন, আর LinuxThreads থেকে NPTL-এ যাওয়ার ঐতিহাসিক কারণ ব্যাখ্যা করতে পারবেন — কেন Linux, Solaris, FreeBSD সবাই শেষমেশ 1:1-এ থিতু হলো
  • TID বনাম PID বনাম thread group id-র সম্পর্ক নির্ভুলভাবে ব্যাখ্যা করতে পারবেন — কেন `getpid()` সব thread-এ একই মান দেয় অথচ `gettid()` আলাদা, আর kernel-এ `task_struct.pid` আসলে TID
  • Thread-local storage কীভাবে বাস্তবায়িত হয় বুঝতে পারবেন — x86-64-এ `fs` segment register, `%fs:` prefix সহ instruction, আর CLONE_SETTLS/arch_prctl-এর ভূমিকা
  • `/proc/PID/task/`, `ps -eLf`, `strace -f` আর একটা raw `clone()` প্রোগ্রাম দিয়ে thread-এর অস্তিত্ব ও শেয়ারিং-আচরণ নিজে পরিমাপ ও যাচাই করতে পারবেন, এবং thread বনাম process creation-এর খরচ সংখ্যায় তুলনা করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

গত লেসনে fork() দিয়ে নতুন process বানানো শিখেছি — একটা সম্পূর্ণ নতুন address space, নিজস্ব heap, নিজস্ব stack, সবকিছু আলাদা। এই বিচ্ছিন্নতাটা দুর্দান্ত একটা নিরাপত্তা-বৈশিষ্ট্য: একটা process ক্র্যাশ করলে অন্যটা বেঁচে থাকে।

কিন্তু ধরুন আপনি একটা web server লিখছেন যেটা একসাথে ১,০০০টা connection সামলায়, আর প্রতিটা connection-কে একটা বড় in-memory cache পড়তে হবে (ধরা যাক ২ GB-র একটা product catalog)। fork() দিয়ে করলে দুইটা সমস্যা:

  1. শেয়ার করার কোনো সহজ উপায় নেই। প্রতিটা child-এর copy-on-write কপি আলাদা — একজন cache আপডেট করলে বাকিরা দেখে না। শেয়ার করতে হলে shm_open/mmap(MAP_SHARED) দিয়ে আলাদা করে shared memory region বানাতে হবে, আর সেখানে pointer-ভিত্তিক ডেটা স্ট্রাকচার রাখা রীতিমতো যন্ত্রণা (প্রতিটা process-এ mapping ভিন্ন ঠিকানায় পড়তে পারে)।
  2. তৈরির খরচ বেশি। fork()-কে পুরো page table কপি করতে হয় (COW হলেও entry-গুলো তো কপি করতে হবেই) — একটা ২ GB RSS-এর process-এ সেটা কয়েক লক্ষ page table entry।

এই দুইটার সরাসরি সমাধানই thread: একই address space-এর ভেতরে একাধিক স্বাধীন execution stream। প্রতিটা thread-এর নিজের stack আর register set আছে (তাই তারা স্বাধীনভাবে ফাংশন কল করতে, লুপ চালাতে পারে), কিন্তু heap, global variable আর open file descriptor সবাই একই — কোনো বাড়তি ব্যবস্থা ছাড়াই।

একটা দ্রুত সংখ্যা, যেটা এই লেসনের experiment অংশে নিজে মাপবেন — একটা সাধারণ x86-64 Linux মেশিনে (kernel 6.x, glibc 2.39):

অপারেশনসাধারণ খরচ
pthread_create() + pthread_join()~১৫–২৫ μs
fork() + wait() (ছোট process, ~5 MB RSS)~৫০–৮০ μs
fork() + wait() (বড় process, ~2 GB RSS)~২–১০ ms

লক্ষ করুন শেষ সারিটা — process তৈরির খরচ address space-এর আকারের সাথে বাড়ে, thread তৈরির খরচ বাড়ে না। কারণটা এই লেসনের কেন্দ্রীয় বিষয়: thread তৈরি করার সময় address space কপিই করা হয় না, শুধু একই mm_struct-এর একটা reference বাড়ানো হয়।

আর Linux-এ এর সবচেয়ে সুন্দর দিকটা হলো — thread কোনো আলাদা kernel object না। এটা একই task_struct, শুধু একটা ভিন্ন flag-সেট নিয়ে জন্ম নিয়েছে। এই লেসন শেষে আপনি pthread ছাড়াই, সরাসরি clone() দিয়ে thread বানাবেন — আর দেখবেন কোন একটামাত্র flag বদলালে সেটা “thread” থেকে “process” হয়ে যায়।

মূল ধারণা

Thread কী — একটা execution context

একটা running program-এর অবস্থাকে দুই ভাগে ভাগ করা যায়:

  • Resource container — address space (কোন virtual address কোথায় map করা), open file descriptor-এর টেবিল, current working directory, signal handler-এর তালিকা, user/group id, resource limit। এগুলো “প্রোগ্রামটার কী কী আছে” বর্ণনা করে।
  • Execution context — program counter (পরের কোন instruction চলবে), stack pointer, বাকি সব register, আর একটা call stack। এগুলো “প্রোগ্রামটা এখন ঠিক কোথায় আছে” বর্ণনা করে।

ক্লাসিক Unix process এই দুইটাকে একসাথে বেঁধে রাখে — এক process, এক execution context। Thread হলো সেই বাঁধন খুলে দেওয়া: এক resource container, একাধিক execution context।

        Virtual address space (একটাই, সব thread-এর জন্য অভিন্ন)
        0x7fff_ffff_ffff  ┌──────────────────────────┐
                          │  main thread-এর stack    │ ← thread 0 এর %rsp
                          │        ↓ বাড়ে            │
                          ├──────────────────────────┤
                          │        (ফাঁকা)            │
                          ├──────────────────────────┤
                          │  thread 2 stack (8 MB)   │ ← thread 2 এর %rsp
                          │  thread 2 TCB + TLS      │ ← thread 2 এর %fs base
                          ├──────────────────────────┤
        mmap region       │  thread 1 stack (8 MB)   │ ← thread 1 এর %rsp
                          │  thread 1 TCB + TLS      │ ← thread 1 এর %fs base
                          ├──────────────────────────┤
                          │  libc.so, ld.so ...      │  শেয়ার্ড
                          ├──────────────────────────┤
                          │        ↑ বাড়ে            │
                          │        heap (malloc)     │  শেয়ার্ড -- একজনের
                          ├──────────────────────────┤  malloc আরেকজন free করতে পারে
                          │   .bss  (শূন্য global)   │  শেয়ার্ড
                          │   .data (init global)    │  শেয়ার্ড
                          │   .text (কোড)            │  শেয়ার্ড, read-only
        0x0000_0000_0000  └──────────────────────────┘
একটা তিন-thread process-এর address space। text, data, heap একবারই আছে -- সবাই শেয়ার করে; কিন্তু stack তিনটা আলাদা region, আর register set তিনটা আলাদা (CPU-তে অথবা kernel-এ সংরক্ষিত)।

ঠিক কী শেয়ার হয়, কী হয় না — সম্পূর্ণ তালিকা

এই টেবিলটাই এই লেসনের সবচেয়ে বেশি কাজে লাগা রেফারেন্স। বাঁ কলামের সবকিছুতে একজন thread-এর পরিবর্তন বাকি সবাই সাথে সাথে দেখে; ডান কলামের সবকিছু প্রতিটা thread-এর সম্পূর্ণ নিজস্ব।

শেয়ার্ড (পুরো thread group-এ একটাই)প্রাইভেট (প্রতি thread-এ আলাদা)
.text — প্রোগ্রামের কোডStack (ডিফল্ট ৮ MB, ulimit -s)
.data / .bss — global আর static variableসম্পূর্ণ register set (PC, SP, সব GPR, SIMD)
Heap — malloc()-এর সবকিছুKernel-side stack (x86-64-এ ১৬ KB)
File descriptor table (fd নম্বর)TID (gettid())
Current working directory, root dir, umaskerrno (আসলে একটা TLS variable)
Signal handler (কোন signal-এ কোন ফাংশন)Signal mask (pthread_sigmask)
PID / thread group id (getpid())__thread বা thread_local ঘোষিত সব variable
Memory mapping (mmap করা সব region)Alternate signal stack (sigaltstack)
User/group id, resource limitScheduling policy ও priority (Linux-এ per-thread)
Timer, IPC namespace, cgroup সদস্যপদCPU affinity mask (sched_setaffinity)

কয়েকটা সূক্ষ্মতা যা প্রায়ই ভুল বোঝা হয়:

errno প্রাইভেট, কিন্তু জাদু দিয়ে না। POSIX বলে errno per-thread হতে হবে, নাহলে দুইটা thread একসাথে syscall করলে একজন আরেকজনের error code মুছে দিত। glibc-তে errno আসলে একটা ম্যাক্রো — #define errno (*__errno_location()) — যে ফাংশনটা বর্তমান thread-এর TLS ব্লকের ভেতরের একটা int-এর ঠিকানা ফেরত দেয়। অর্থাৎ প্রাইভেট হওয়াটা TLS মেকানিজমেরই একটা প্রয়োগ, আলাদা কোনো kernel সুবিধা না।

Signal handler শেয়ার্ড, signal mask প্রাইভেট। এটা প্রথমে অদ্ভুত লাগে কিন্তু যুক্তিসঙ্গত: signal()/sigaction() দিয়ে “SIGINT এলে এই ফাংশন চালাও” — এই তথ্যটা পুরো process-এর একটা বৈশিষ্ট্য (CLONE_SIGHAND এটাই শেয়ার করায়)। কিন্তু “আমি এই মুহূর্তে SIGINT ব্লক করে রেখেছি” — এটা প্রতিটা thread নিজের মতো ঠিক করতে পারে। এই বিভাজনটা একটা খুব ব্যবহারিক প্যাটার্নের ভিত্তি: একটা ডেডিকেটেড signal-handling thread বানানো, আর বাকি সব thread-এ সব signal ব্লক করে রাখা।

Stack প্রাইভেট, কিন্তু গোপন না। একটা thread অন্য thread-এর stack-এর ঠিকানা পেলে সেখানে অনায়াসে পড়তে-লিখতে পারে — একই address space, কোনো hardware বাধা নেই। “প্রাইভেট” মানে এখানে “আলাদা region, স্বাভাবিক ব্যবহারে সংঘর্ষ হয় না”, “সুরক্ষিত” না। এই কারণেই একটা thread-এর stack-এ থাকা local variable-এর pointer অন্য thread-কে দেওয়া বৈধ (আর প্রায়ই করা হয়, যেমন একটা worker-কে কাজ পাঠানোর সময়) — যতক্ষণ সেই ফাংশনটা return না করে।

Threading model — কে thread-কে schedule করে

একটা মৌলিক প্রশ্ন: user-space-এর “thread” আর kernel-এর “schedulable entity” — এই দুইয়ের মধ্যে সংখ্যাগত সম্পর্ক কী?

Modelমানেসুবিধাঅসুবিধা
N:1 (user-level / green thread)N user thread, ১টা kernel entity। Scheduling পুরোটাই user-space library-তেSwitch অত্যন্ত সস্তা (কোনো syscall না, ~১০০ ns)একটা thread ব্লক করলে সবাই ব্লক; multi-core-এ কোনো সমান্তরালতা নেই
1:1 (kernel-level)প্রতিটা user thread = ১টা kernel taskসত্যিকারের সমান্তরালতা; একজন ব্লক করলে বাকিরা চলে; kernel সব দেখে (debug, priority)প্রতিটা thread-এ kernel resource খরচ; switch-এ syscall/interrupt লাগে
M:N (hybrid)M user thread, N kernel entity-র উপর multiplexedতাত্ত্বিকভাবে দুটোরই সেরাটাবাস্তবে ভয়াবহ জটিল — দুই স্তরের scheduler একে অপরের সিদ্ধান্ত না জেনে লড়াই করে

তাহলে goroutine কী — M:N কি ফিরে এসেছে?

আংশিকভাবে, হ্যাঁ — কিন্তু kernel-এর ভেতরে না, language runtime-এর ভেতরে। এটাই গুরুত্বপূর্ণ পার্থক্য।

Go-র goroutine, Java-র virtual thread (JDK 21, Project Loom), Rust-এর async/tokio task, Python-এর asyncio coroutine — সবাই একই কৌশল: runtime নিজে কিছু OS thread (1:1 thread, kernel-এর দৃষ্টিতে সাধারণ thread) নিয়ে রাখে, আর তাদের উপর হাজার-হাজার হালকা user-space “task” multiplex করে।

pthread (1:1)goroutineJava virtual thread
প্রাথমিক stack৮ MB reserved (VA)২ KB, প্রয়োজনে বাড়েHeap-এ, প্রয়োজনমতো
তৈরির খরচ~১৫ μs~০.৩ μs~১ μs
Switch খরচ~১–৩ μs (kernel হয়ে)~০.১ μs (user-space)~০.১ μs
বাস্তবসম্মত সংখ্যাহাজার দশেকলক্ষ-কোটিলক্ষ
Kernel দেখে?হ্যাঁ, প্রতিটাকেনা, শুধু OS thread-গুলোকেনা

এই মডেল কাজ করার একমাত্র কারণ — runtime প্রতিটা blocking operation-কে নিজে intercept করে। Go-তে আপনি conn.Read() লিখলে runtime সেটাকে একটা non-blocking read + epoll registration-এ পরিণত করে, আর goroutine-টাকে পার্ক করে অন্য goroutine চালায়। LinuxThreads-যুগের M:N যেখানে হেরেছিল, Go সেখানে জিতেছে কারণ তার পুরো standard library-টাই এই runtime-এর নিয়ন্ত্রণে — সেটা kernel-এর পক্ষে কখনোই সম্ভব ছিল না, কারণ kernel-কে যেকোনো C কোডের যেকোনো blocking syscall সামলাতে হতো।

TID, PID আর thread group

এখানে একটা নামকরণের গোলমাল আছে যেটা না বুঝলে /proc পড়া বা kernel সোর্স পড়া অসম্ভব।

নামUserspace-এKernel-এ (task_struct-এ)সব thread-এ একই?
Thread IDgettid()task->pidনা — প্রতিটা thread-এ আলাদা
Process IDgetpid()task->tgidহ্যাঁ — পুরো thread group-এ একই

হ্যাঁ, আপনি ঠিকই পড়েছেন — kernel-এর pid ফিল্ডটা আসলে userspace-এর TID, আর userspace-এর PID হলো kernel-এর tgid (thread group id)। এই বিভ্রান্তিকর নামকরণটা ঐতিহাসিক: kernel-এ pid ফিল্ডটা thread ধারণা আসার আগে থেকেই ছিল (তখন এক process = এক task, কোনো দ্বিধা ছিল না), আর thread যোগ করার সময় backward compatibility রাখতে নামটা বদলানো হয়নি; শুধু পাশে tgid যোগ করা হয়েছে।

একটা thread group হলো এমন সব task-এর সমষ্টি যাদের tgid একই। Group-এর যে task-এর pid == tgid, সেটা thread group leader — সাধারণত main() চালানো thread-টা। /proc/1234/ আসলে thread group leader-এর ডিরেক্টরি, আর প্রতিটা সদস্য thread-এর নিজস্ব ডিরেক্টরি আছে /proc/1234/task/TID/-এ।

$ ./three_threads &
[1] 48213
$ ls /proc/48213/task/
48213  48214  48215  48216

চারটা এন্ট্রি — main thread (48213, যার TID == PID) আর তিনটা spawn করা thread। এই ডিরেক্টরিগুলোর প্রতিটার ভেতরে সেই নির্দিষ্ট thread-এর stat, status, stack, sched ফাইল আছে।

ভেতরে কী ঘটছে

clone() — একটাই syscall, সব ধরনের task তৈরির জন্য

Linux-এ fork(), vfork() আর pthread_create() — তিনটাই শেষপর্যন্ত একই kernel ফাংশনে পৌঁছায়। পার্থক্য শুধু flag-এ।

/* glibc-র wrapper signature (man clone(2)) */
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
          /* pid_t *parent_tid, void *tls, pid_t *child_tid */ );

মূল flag-গুলোর প্রত্যেকটা ঠিক কী শেয়ার করায়:

Flagসেট করলে যা শেয়ার হয়Kernel-এ প্রকৃত প্রভাব
CLONE_VMAddress spacetsk->mm = current->mm — নতুন mm_struct তৈরি না করে refcount বাড়ানো হয়। কোনো page table কপি হয় না
CLONE_FScwd, root dir, umaskfs_struct শেয়ার — একজন chdir() করলে সবাই সরে যায়
CLONE_FILESFile descriptor tablefiles_struct শেয়ার — একজনের open() করা fd নম্বর সবাই ব্যবহার করতে পারে
CLONE_SIGHANDSignal handler টেবিলsighand_struct শেয়ার — sigaction() সবার জন্য। CLONE_VM ছাড়া দেওয়া যায় না
CLONE_THREADThread group সদস্যপদtsk->tgid = current->tgid — একই PID; wait()-এ অদৃশ্য; process-level signal group-এ যায়। CLONE_SIGHAND ছাড়া দেওয়া যায় না
CLONE_SYSVSEMSystem V semaphore adjustmentsysvsem.undo_list শেয়ার
CLONE_SETTLS(শেয়ার না) TLS সেট করেtls আর্গুমেন্ট থেকে নতুন task-এর fs base সেট করা হয়
CLONE_PARENT_SETTID(শেয়ার না)নতুন TID-টা parent-এর দেওয়া ঠিকানায় লেখা হয়
CLONE_CHILD_CLEARTID(শেয়ার না)Task মরার সময় ওই ঠিকানায় 0 লিখে সেখানে FUTEX_WAKE করা হয় — এটাই pthread_join()-এর ভিত্তি

এই flag-গুলো দিয়ে তিনটা পরিচিত অপারেশন কীভাবে গঠিত:

fork()            = clone(SIGCHLD)
                    কিছুই শেয়ার হয় না; SIGCHLD মানে child মরলে parent-কে জানাও

vfork()           = clone(CLONE_VM | CLONE_VFORK | SIGCHLD)
                    address space শেয়ার, আর parent থেমে থাকে child exec/exit না করা পর্যন্ত

pthread_create()  = clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND
                        | CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS
                        | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID, 0)
                    ↑ exit signal 0 -- কারণ thread মরলে parent-কে SIGCHLD পাঠানো হয় না

pthread_create() থেকে runqueue — পুরো পথ

pthread_create(&t, NULL, worker, arg) -- একটা কল, আটটা স্তর
  1. pthread_create() [glibc, nptl/pthread_create.c]প্রথমে allocate_stack() -- একটা stack cache থেকে পুনর্ব্যবহার, নাহলে নতুন mmap
  2. mmap(NULL, 8 MB, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK)৮ MB virtual address সংরক্ষিত হয় (RLIMIT_STACK), কিন্তু কোনো physical page এখনো বরাদ্দ হয় না -- lazy allocation
  3. struct pthread (TCB) স্থাপননতুন stack region-এর উপরের প্রান্তে thread control block বসানো হয়; TLS ব্লক তার ঠিক আগে; এই TCB-র ঠিকানাই হবে নতুন thread-এর fs base
  4. clone3() syscall [আগে clone()]CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID
  5. kernel: copy_process() [kernel/fork.c]dup_task_struct() নতুন task_struct + kernel stack বানায়; copy_mm() CLONE_VM দেখে mmget(current->mm) করে -- কোনো page table কপি হয় না
  6. kernel: copy_thread() [arch/x86/kernel/process_64.c]নতুন task-এর CPU context ভরাট; CLONE_SETTLS দেখে fsbase সেট করা হয়
  7. kernel: wake_up_new_task()scheduler-এর কাছে হস্তান্তর -- একটা CPU-র runqueue-তে ঢোকানো হয় (কোন CPU, সেটা load balancer ঠিক করে)
  8. নতুন thread চালু -- start_thread() → worker(arg)worker() return করলে বা pthread_exit() ডাকলে __exit_thread, তারপর exit syscall; CLONE_CHILD_CLEARTID-এর কারণে ctid ঠিকানায় 0 লিখে FUTEX_WAKE -- এভাবেই pthread_join() জেগে ওঠে

লক্ষ করার মতো দুইটা জিনিস। প্রথমত, সবচেয়ে দামি ধাপটা clone() না, mmap() — এবং সেটাও শুধু প্রথমবার। glibc একটা stack cache রাখে (ডিফল্ট ৪০ MB পর্যন্ত), তাই একটা thread শেষ হলে তার stack ফেরত না দিয়ে জমিয়ে রাখা হয়, পরের pthread_create() সেটা পুনর্ব্যবহার করে। এই কারণেই একটা লুপে বারবার thread তৈরি-ধ্বংস করলে প্রথম কয়েকটার চেয়ে পরেরগুলো লক্ষণীয়ভাবে দ্রুত হয় — experiment-এ এটা নিজে দেখবেন।

দ্বিতীয়ত, pthread_join() কোনো syscall না — সাধারণ অবস্থায়। এটা শুধু ctid ঠিকানাটা পড়ে; যদি সেখানে ইতিমধ্যে 0 থাকে (thread আগেই শেষ), কোনো syscall ছাড়াই সাথে সাথে ফেরত আসে। শুধু যদি thread এখনো চলমান থাকে, তখন FUTEX_WAIT করে ঘুমায়। এটাই futex-এর মূল ডিজাইন-দর্শন — “fast userspace mutex”: প্রতিযোগিতা না থাকলে kernel-এ যাওয়াই লাগে না।

Thread-local storage আর fs register

Assembly module-এ segment register (cs, ds, ss, es, fs, gs) দেখেছিলেন, আর তখন বলা হয়েছিল x86-64-এ এদের বেশিরভাগই অকেজো হয়ে গেছে (flat memory model, base সবসময় ০)। দুইটা ব্যতিক্রম — fs আর gs — এখনো একটা আসল base address ধরে রাখে, আর ঠিক এই কাজেই সেটা ব্যবহৃত হয়।

Linux-এ userspace-এ fs base প্রতিটা thread-এর thread control block-কে দেখায়; TLS variable-গুলো সেই TCB-র ঠিক আগে (নিম্ন ঠিকানায়) বসে। ফলে একটা TLS variable অ্যাক্সেস করা মানে শুধু %fs: prefix সহ একটা মামুলি memory access:

__thread int counter = 0;    /* অথবা C11-এ: _Thread_local int counter = 0; */

void bump(void) { counter++; }
bump:
    mov    eax, DWORD PTR fs:0xfffffffffffffffc   ; fs_base - 4 থেকে পড়ো
    add    eax, 1
    mov    DWORD PTR fs:0xfffffffffffffffc, eax   ; সেখানেই লেখো
    ret

কোনো ফাংশন কল নেই, কোনো লুকআপ টেবিল নেই, কোনো লক নেই — একটা সাধারণ global variable অ্যাক্সেসের চেয়ে ঠিক শূন্য cycle বেশি। প্রতিটা thread-এর fs base আলাদা, তাই একই instruction প্রতিটা thread-এ ভিন্ন memory location স্পর্শ করে। এটাই TLS-এর পুরো জাদু: কোনো জাদু নেই, শুধু একটা per-thread base register

Kernel thread — যাদের কোনো address space নেই

ps aux চালালে বর্গবন্ধনীতে মোড়ানো কিছু নাম দেখবেন: [kthreadd], [ksoftirqd/0], [kworker/2:1], [kswapd0], [rcu_sched]। এরা kernel thread — kernel-এর নিজের ব্যাকগ্রাউন্ড কাজের জন্য তৈরি task, যাদের কোনো userspace কোড নেই।

এদের সবচেয়ে বড় বৈশিষ্ট্য — task->mm == NULL। তাদের কোনো নিজস্ব address space নেই, কারণ দরকার নেই (তারা শুধু kernel address space-এ কাজ করে, যেটা সব task-এর page table-এ একইভাবে map করা থাকে)। যখন scheduler একটা kernel thread চালায়, তখন সে page table বদলায়ই না — আগের task-এর mm-টা “ধার” করে রাখে (task->active_mm)। একে বলে lazy TLB mode, আর এটা একটা সরাসরি performance optimization: kernel thread-এ switch করার সময় CR3 লিখতে হয় না, তাই TLB flush-ও হয় না। পরের লেসনে দেখব সেই সাশ্রয়টা কত বড়।

ps-এ বর্গবন্ধনীর কারণটাও এখান থেকেই — ps একটা process-এর কমান্ড লাইন পড়ে /proc/PID/cmdline থেকে, কিন্তু সেটা userspace stack-এ থাকে; kernel thread-এর তো userspace stack নেই, তাই ফাইলটা খালি। ps তখন /proc/PID/stat-এর ভেতরের নামটা নিয়ে বর্গবন্ধনীতে দেখায়।

উদাহরণ

একটা তিন-thread প্রোগ্রাম, সম্পূর্ণ পর্যবেক্ষণ সহ

#define _GNU_SOURCE
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>

int   g_shared   = 0;         /* .bss -- শেয়ার্ড */
__thread int t_private = 0;   /* TLS  -- প্রাইভেট */

void *worker(void *arg) {
    long id = (long)arg;
    int  local = 0;           /* stack -- প্রাইভেট */

    for (int i = 0; i < 1000; i++) { g_shared++; t_private++; local++; }

    printf("thread %ld: tid=%ld pid=%d | &g_shared=%p &t_private=%p &local=%p\n",
           id, (long)syscall(SYS_gettid), getpid(),
           (void *)&g_shared, (void *)&t_private, (void *)&local);
    sleep(30);                /* যাতে ps দিয়ে দেখা যায় */
    return NULL;
}

int main(void) {
    pthread_t t[3];
    printf("main   : tid=%ld pid=%d\n", (long)syscall(SYS_gettid), getpid());
    for (long i = 0; i < 3; i++) pthread_create(&t[i], NULL, worker, (void *)i);
    for (int i = 0; i < 3; i++) pthread_join(t[i], NULL);
    printf("শেষে g_shared = %d  (প্রত্যাশা 3000 হলে race, বাস্তবে কম হবে)\n", g_shared);
    return 0;
}

কম্পাইল আর চালানো:

$ gcc -O0 -pthread three.c -o three && ./three
main   : tid=48213 pid=48213
thread 0: tid=48214 pid=48213 | &g_shared=0x55e3f2c04014 &t_private=0x7f4a8b3ff6fc &local=0x7f4a8b3fee5c
thread 1: tid=48215 pid=48213 | &g_shared=0x55e3f2c04014 &t_private=0x7f4a8abfe6fc &local=0x7f4a8abfde5c
thread 2: tid=48216 pid=48213 | &g_shared=0x55e3f2c04014 &t_private=0x7f4a8a3fd6fc &local=0x7f4a8a3fce5c
শেষে g_shared = 2847  (প্রত্যাশা 3000 হলে race, বাস্তবে কম হবে)

এই আউটপুটের প্রতিটা কলাম কিছু প্রমাণ করছে — একে একে দেখি।

pid তিনটা thread-এই ৪৮২১৩ — অভিন্ন। এটাই thread group। getpid() kernel-এর tgid ফেরত দেয়, যা group leader-এর TID (৪৮২১৩)। POSIX-এর দাবি ঠিক এটাই, আর LinuxThreads এই জায়গাটাতেই ব্যর্থ হতো।

tid চারটা আলাদা — ৪৮২১৩, ৪৮২১৪, ৪৮২১৫, ৪৮২১৬। পরপর বরাদ্দ হয়েছে, কারণ kernel PID allocator ক্রমবর্ধমান।

&g_shared তিনটা thread-এই 0x55e3f2c04014 — হুবহু এক ঠিকানা। একই address space, একই global। এটাই thread-এর সংজ্ঞা।

&t_private তিনটা আলাদা, আর তাদের মধ্যে ব্যবধান ঠিক ০x৮০০০০০ = ৮ MB:

0x7f4a8b3ff6fc0x7f4a8abfe6fc=0x8010008 MB\text{0x7f4a8b3ff6fc} - \text{0x7f4a8abfe6fc} = \text{0x801000} \approx 8\ \text{MB}

এটা কাকতালীয় না — প্রতিটা thread-এর stack region ৮ MB (ডিফল্ট RLIMIT_STACK), আর TCB/TLS ব্লকটা প্রতিটা stack region-এর উপরের প্রান্তে বসে। তাই পরপর তৈরি হওয়া thread-এর TLS ঠিকানাগুলো ঠিক ৮ MB (+ কিছু guard page) ব্যবধানে বসেছে। &local (stack variable)-এর ব্যবধানও একই — এবং লক্ষ করুন &t_private আর &local-এর মধ্যে ব্যবধান প্রতিটা thread-এ অভিন্ন (0x6fc - 0xe5c মানে ২,২০৮ বাইট), কারণ প্রতিটা thread-এর লেআউট অভিন্ন।

g_shared = 2847, ৩০০০ না। এটাই lost update — তিনটা thread একসাথে g_shared++ করছে, যেটা assembly-তে তিনটা আলাদা instruction (load, add, store)। দুইজন একই সময়ে load করলে একজনের বৃদ্ধি হারিয়ে যায়। এই সমস্যাটাই পরের কয়েকটা লেসনের (mutex, atomic, memory ordering) মূল বিষয় — আপাতত শুধু লক্ষ করুন যে শেয়ারিং বিনামূল্যে আসে না; ঠিক যে সুবিধাটার জন্য thread ব্যবহার করছি (শেয়ার্ড memory), সেটাই একটা নতুন শ্রেণির bug নিয়ে আসে।

ps -eLf দিয়ে বাইরে থেকে দেখা

প্রোগ্রামটা sleep(30)-এ থাকা অবস্থায় আরেকটা টার্মিনালে:

$ ps -eLf | head -1; ps -eLf | grep '[t]hree'
UID     PID   PPID    LWP  NLWP  C  STIME TTY   TIME     CMD
user   48213  47990  48213     4  0  14:02 pts/1 00:00:00 ./three
user   48213  47990  48214     4  0  14:02 pts/1 00:00:00 ./three
user   48213  47990  48215     4  0  14:02 pts/1 00:00:00 ./three
user   48213  47990  48216     4  0  14:02 pts/1 00:00:00 ./three

চারটা সারি, কিন্তু PID কলামে চারবারই ৪৮২১৩। LWP কলামটাই TID (LWP = Light Weight Process, একটা পুরনো Solaris-যুগের নাম যা ps-এ টিকে আছে), আর NLWP = এই thread group-এ মোট কয়টা thread (৪)।

top-এও একই — ডিফল্টে thread লুকানো থাকে, H চাপলে thread-ভিত্তিক দৃশ্যে যায়। htop-এ Shift+H

নিজে চালিয়ে দেখুন

EXPERIMENT

Thread-এর অস্তিত্ব ও শেয়ারিং নিজে যাচাই করুন

Linux (যেকোনো distro, kernel 4.x+)· ২০ মিনিট

উপরের three.c প্রোগ্রামটা কম্পাইল করে ব্যাকগ্রাউন্ডে চালান, তারপর /proc থেকে সরাসরি পড়ুন।

gcc -O0 -pthread three.c -o three
./three &
PID=$!

# ধাপ ১ -- কয়টা thread আছে
ls /proc/$PID/task/
# 48213  48214  48215  48216

grep Threads /proc/$PID/status
# Threads:        4

# ধাপ ২ -- প্রতিটা thread-এর নিজস্ব status
for t in /proc/$PID/task/*; do
  printf "%s  " "$(basename $t)"
  grep -H '' /dev/null; awk '/^Name:|^State:/ {printf "%s ", $2}' $t/status
  echo
done
# 48213  three S
# 48214  three S
# 48215  three S
# 48216  three S

# ধাপ ৩ -- সবার address space কি সত্যিই একটাই? maps ফাইল তুলনা করুন
diff /proc/$PID/task/48214/maps /proc/$PID/task/48215/maps && echo "অভিন্ন -- একই address space"
# অভিন্ন -- একই address space

# ধাপ ৪ -- stack region-গুলো mmap-এ খুঁজে বের করুন (৮ MB-র anonymous region)
grep -c 'rw-p' /proc/$PID/maps
awk '$2=="rw-p" && $6=="" {split($1,a,"-"); sz=(strtonum("0x"a[2])-strtonum("0x"a[1]))/1024/1024;
     if (sz > 7) printf "%s  %.1f MB\n", $1, sz}' /proc/$PID/maps
# 7f4a8a3fd000-7f4a8abfd000  8.0 MB
# 7f4a8abfe000-7f4a8b3fe000  8.0 MB
# 7f4a8b3ff000-7f4a8bbff000  8.0 MB

ধাপ ৩-এর ফলাফলটাই সবচেয়ে জোরালো প্রমাণ — /proc/PID/task/TID/maps প্রতিটা thread-এর জন্য আলাদাভাবে পড়া যায়, কিন্তু বিষয়বস্তু হুবহু অভিন্ন, কারণ নিচে একটাই mm_struct আছে।

ধাপ ৪-এ ঠিক তিনটা ৮ MB region দেখা যাচ্ছে — তিনটা spawn করা thread-এর stack (main thread-এর stack আলাদা, [stack] লেবেলযুক্ত, আর সেটা mmap region-এ না, address space-এর উপরের প্রান্তে)। প্রতিটার মধ্যে ৪ KB ফাঁক — সেটাই guard page, একটা PROT_NONE mapping যাতে একটা thread-এর stack overflow পাশের thread-এর stack-এ গিয়ে না পড়ে বরং সাথে সাথে SIGSEGV হয়।

kill $PID
এটা কী প্রমাণ করে

একই process-এর সব thread একই PID ভাগ করে কিন্তু আলাদা TID পায়; একটা global variable সব thread-এ হুবহু একই ঠিকানায় থাকে (শেয়ার্ড), অথচ stack local আর __thread variable প্রতিটা thread-এ ভিন্ন ঠিকানায় (প্রাইভেট)।

EXPERIMENT

Thread বনাম process তৈরির খরচ মাপুন, আর strace দিয়ে flag দেখুন

Linux (gcc, strace, /usr/bin/time)· ২৫ মিনিট
/* bench.c -- 10000 বার thread বা process তৈরি ও অপেক্ষা */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <time.h>
#include <sys/wait.h>

#define N 10000
static void *noop_t(void *a) { (void)a; return NULL; }

static double now_ms(void) {
    struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec * 1e3 + ts.tv_nsec / 1e6;
}

int main(int argc, char **argv) {
    int use_thread = (argc > 1 && strcmp(argv[1], "thread") == 0);
    /* ঐচ্ছিক: address space ফুলিয়ে দিন, fork-এর খরচে প্রভাব দেখতে */
    size_t bloat_mb = (argc > 2) ? (size_t)atoi(argv[2]) : 0;
    if (bloat_mb) { char *p = malloc(bloat_mb << 20); memset(p, 1, bloat_mb << 20); }

    double t0 = now_ms();
    for (int i = 0; i < N; i++) {
        if (use_thread) {
            pthread_t t; pthread_create(&t, NULL, noop_t, NULL); pthread_join(t, NULL);
        } else {
            pid_t p = fork();
            if (p == 0) _exit(0);
            waitpid(p, NULL, 0);
        }
    }
    printf("%-7s  %d বার  মোট %.1f ms  প্রতিটি %.2f us\n",
           use_thread ? "thread" : "fork", N, now_ms() - t0, (now_ms() - t0) * 1000.0 / N);
    return 0;
}
gcc -O2 -pthread bench.c -o bench

./bench thread          # ছোট address space
# thread   10000 বার  মোট 186.4 ms  প্রতিটি 18.64 us

./bench fork            # ছোট address space
# fork     10000 বার  মোট 612.9 ms  প্রতিটি 61.29 us

./bench thread 512      # 512 MB touched memory
# thread   10000 বার  মোট 191.2 ms  প্রতিটি 19.12 us   ← প্রায় অপরিবর্তিত

./bench fork 512        # 512 MB touched memory
# fork     10000 বার  মোট 3184.7 ms  প্রতিটি 318.47 us  ← ৫ গুণ বেড়ে গেছে

শেষ দুইটা সারিই আসল গল্প। ৫১২ MB touched memory মানে প্রায় ১,৩১,০০০ page table entry (৫১২ MB ÷ ৪ KB); fork()-কে COW সেট করতে হলেও সেই এন্ট্রিগুলোর প্রত্যেকটা ঘুরে দেখতে হয় আর read-only চিহ্নিত করতে হয়। pthread_create()-এর খরচ একই থাকল — কারণ সে page table স্পর্শই করে না, শুধু mmget() দিয়ে একটা refcount বাড়ায়।

এখন প্রমাণ করুন দুটোই একই syscall ব্যবহার করে:

strace -f -e trace=clone,clone3 -o /tmp/tr.txt ./three
head -3 /tmp/tr.txt
48213 clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD
       |CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID,
       child_tid=0x7f4a8b3ff990, parent_tid=0x7f4a8b3ff990, exit_signal=0,
       stack=0x7f4a8abff000, stack_size=0x7fff00, tls=0x7f4a8b3ff6c0}
       => {parent_tid=[48214]}, 88) = 48214

এই এক লাইনেই এই লেসনের hood অংশের পুরো টেবিলটা বাস্তবে দেখা যাচ্ছে — নয়টা flag, exit_signal=0 (thread মরলে SIGCHLD পাঠানো হবে না), stack_size=0x7fff00 (৮ MB থেকে সামান্য কম, guard page বাদে), আর tls= ঠিকানাটাই হবে নতুন thread-এর fs base।

একই কাজ fork()-এর জন্য:

strace -f -e trace=clone,clone3 ./bench fork 2>&1 | head -2
# clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD,
#       child_tidptr=0x7f2c9a1f0a10) = 48301

মাত্র SIGCHLD (+ দুটো tid হাউসকিপিং flag) — কোনো CLONE_VM, কোনো CLONE_THREAD। একই syscall, সম্পূর্ণ ভিন্ন ফলাফল।

এটা কী প্রমাণ করে

pthread_create সাধারণত fork-এর চেয়ে ৩-৫ গুণ সস্তা ছোট process-এ, আর ব্যবধানটা address space বড় হলে নাটকীয়ভাবে বাড়ে -- কারণ fork-কে page table কপি করতে হয়, thread-কে হয় না। আর strace প্রমাণ করে দুটোই একই clone/clone3 syscall ডাকছে, শুধু flag আলাদা।

নিজে বানান

BUILD IT

pthread ছাড়াই thread বানান -- raw clone() দিয়ে

C (Linux, glibc) · ●●●●○
  1. তিনটা মোডসহ প্রোগ্রামটা লিখুন -- process (শুধু SIGCHLD), vm (CLONE_VM কিন্তু CLONE_THREAD ছাড়া), thread (পূর্ণ thread flag-সেট)
  2. প্রতিটা মোডে child-এর নিজের stack mmap দিয়ে বরাদ্দ করুন, MAP_STACK আর একটা guard page সহ
  3. তিনটা মোডেই চালিয়ে দেখুন getpid() মেলে কি না, আর shared_global-এর পরিবর্তন parent দেখে কি না
  4. thread মোডে wait() কেন কাজ করে না বুঝুন, আর CLONE_CHILD_CLEARTID + FUTEX_WAIT দিয়ে নিজের join বানান
  5. strace -f দিয়ে যাচাই করুন আপনার clone() ঠিক সেই flag-গুলোই পাঠাচ্ছে যা আপনি চেয়েছেন

এটাই এই লেসনের কেন্দ্রীয় নির্মাণ — pthread লাইব্রেরির কোনো সাহায্য ছাড়া, শুধু clone() দিয়ে একটা thread তৈরি করা, আর দেখানো যে কোন flag-গুলো ঠিক কোন আচরণ তৈরি করে

/* rawclone.c -- gcc -O2 rawclone.c -o rawclone
 *   ./rawclone process   ->  fork()-এর সমতুল্য
 *   ./rawclone vm        ->  memory শেয়ার করা, কিন্তু আলাদা process
 *   ./rawclone thread    ->  আসল thread
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <sys/syscall.h>
#include <linux/futex.h>

#define STACK_SZ (256 * 1024)

/* --- child যা লিখবে, parent যা পড়বে ------------------------------ */
/* CLONE_VM থাকলে এই তিনটা variable parent-child উভয়ে একই memory;
   না থাকলে child-এর লেখা COW কপিতে যাবে, parent কিছুই দেখবে না।   */
static volatile int   shared_counter = 100;
static volatile pid_t child_saw_pid  = -1;
static volatile pid_t child_saw_tid  = -1;

/* CLONE_CHILD_CLEARTID এই ঠিকানায় 0 লিখে FUTEX_WAKE করবে -- আমাদের join */
static volatile int   child_tid_slot = 0;

/* --- child-এ চলবে ------------------------------------------------- */
/* সাবধান: glibc-র clone() wrapper TLS সেট করে না (CLONE_SETTLS আমরা
   দিচ্ছি না), তাই child-এর %fs এখনো parent-এর TCB দেখাচ্ছে। ফলে
   errno বা stdio-র মতো TLS-নির্ভর জিনিস child-এ ব্যবহার করা অনিরাপদ।
   তাই child শুধু raw syscall করে আর শেয়ার্ড variable-এ লিখে রাখে;
   সব ছাপাছাপি parent করবে।                                          */
static int child_fn(void *arg)
{
    (void)arg;
    child_saw_pid = (pid_t)syscall(SYS_getpid);
    child_saw_tid = (pid_t)syscall(SYS_gettid);
    shared_counter += 1;              /* CLONE_VM থাকলে parent দেখবে */
    return 0;                         /* return মানে exit(0) */
}

/* --- futex-ভিত্তিক join, ঠিক যেভাবে pthread_join কাজ করে ---------- */
static void futex_join(volatile int *slot, int expect)
{
    while (__atomic_load_n(slot, __ATOMIC_ACQUIRE) != 0) {
        long r = syscall(SYS_futex, (int *)slot, FUTEX_WAIT, expect, NULL, NULL, 0);
        if (r == -1 && errno != EAGAIN && errno != EINTR) { perror("futex"); exit(1); }
    }
}

int main(int argc, char **argv)
{
    const char *mode = (argc > 1) ? argv[1] : "thread";

    int flags;
    int is_thread = 0;
    if (!strcmp(mode, "process")) {
        flags = SIGCHLD;                                   /* fork()-এর সমতুল্য */
    } else if (!strcmp(mode, "vm")) {
        flags = CLONE_VM | CLONE_FS | CLONE_FILES | SIGCHLD;
    } else if (!strcmp(mode, "thread")) {
        flags = CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND
              | CLONE_THREAD | CLONE_SYSVSEM | CLONE_CHILD_CLEARTID;
        is_thread = 1;                                     /* exit_signal = 0 */
    } else {
        fprintf(stderr, "ব্যবহার: %s process|vm|thread\n", argv[0]);
        return 2;
    }

    /* child-এর stack: নিচে একটা guard page, উপরে ব্যবহারযোগ্য অংশ */
    char *region = mmap(NULL, STACK_SZ + 4096, PROT_NONE,
                        MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
    if (region == MAP_FAILED) { perror("mmap"); return 1; }
    if (mprotect(region + 4096, STACK_SZ, PROT_READ | PROT_WRITE)) {
        perror("mprotect"); return 1;
    }
    void *stack_top = region + 4096 + STACK_SZ;   /* x86-64-এ stack নিচের দিকে বাড়ে */

    printf("parent : pid=%d tid=%ld  shared_counter=%d  (mode=%s)\n",
           getpid(), (long)syscall(SYS_gettid), shared_counter, mode);

    /* glibc wrapper: clone(fn, stack, flags, arg, parent_tid, tls, child_tid) */
    pid_t kid = clone(child_fn, stack_top, flags, NULL,
                      NULL, NULL, (int *)&child_tid_slot);
    if (kid == -1) { perror("clone"); return 1; }

    if (is_thread) {
        /* CLONE_THREAD child-কে wait() করা যায় না -- errno হবে ECHILD।
           নিজে প্রমাণ করে দেখুন:                                        */
        if (wait(NULL) == -1)
            printf("         wait() ব্যর্থ: %s  (প্রত্যাশিত -- thread wait-যোগ্য নয়)\n",
                   strerror(errno));
        futex_join(&child_tid_slot, kid);
    } else {
        if (waitpid(kid, NULL, 0) == -1) perror("waitpid");
    }

    printf("child  : clone() ফেরত দিল %d\n", kid);
    printf("child   নিজেকে দেখল  pid=%d tid=%d\n", child_saw_pid, child_saw_tid);
    printf("parent-এর দৃষ্টিতে shared_counter = %d  →  memory %s\n",
           shared_counter,
           shared_counter == 101 ? "শেয়ার্ড" : "শেয়ার্ড নয় (COW কপি)");

    munmap(region, STACK_SZ + 4096);
    return 0;
}

তিনটা মোডের প্রকৃত আউটপুট:

$ ./rawclone process
parent : pid=51002 tid=51002  shared_counter=100  (mode=process)
child  : clone() ফেরত দিল 51003
child   নিজেকে দেখল  pid=-1 tid=-1              ← parent কিছুই দেখেনি!
parent-এর দৃষ্টিতে shared_counter = 100  →  memory শেয়ার্ড নয় (COW কপি)

$ ./rawclone vm
parent : pid=51010 tid=51010  shared_counter=100  (mode=vm)
child  : clone() ফেরত দিল 51011
child   নিজেকে দেখল  pid=51011 tid=51011        ← আলাদা PID
parent-এর দৃষ্টিতে shared_counter = 101  →  memory শেয়ার্ড

$ ./rawclone thread
parent : pid=51020 tid=51020  shared_counter=100  (mode=thread)
         wait() ব্যর্থ: No child processes  (প্রত্যাশিত -- thread wait-যোগ্য নয়)
child  : clone() ফেরত দিল 51021
child   নিজেকে দেখল  pid=51020 tid=51021        ← একই PID, আলাদা TID
parent-এর দৃষ্টিতে shared_counter = 101  →  memory শেয়ার্ড

এই তিনটা আউটপুট একসাথে পড়লে পুরো লেসনটা এক পৃষ্ঠায় ধরা পড়ে:

processvmthread
FlagSIGCHLD+CLONE_VM,FS,FILES+CLONE_SIGHAND,THREAD
Memory শেয়ার্ড?না (COW)হ্যাঁহ্যাঁ
getpid() একই?নানাহ্যাঁ
wait() কাজ করে?হ্যাঁহ্যাঁনা (ECHILD)
এটা কি “thread”?নাপ্রায়, কিন্তু নাহ্যাঁ

মাঝের কলামটাই সবচেয়ে শিক্ষণীয় — vm মোডে memory পুরোপুরি শেয়ার্ড, কিন্তু getpid() আলাদা আর wait() কাজ করে। অর্থাৎ CLONE_VM memory শেয়ার করায়, কিন্তু CLONE_THREAD-ই সেটাকে “একই process-এর অংশ” বানায়। POSIX যাকে thread বলে, সেটা এই দুইটার সংমিশ্রণ — Linux দুটোকে আলাদা করে রেখেছে, আর সেই বিচ্ছিন্নতাই container, sandbox আর vfork-এর মতো জিনিস সম্ভব করেছে।

নিজে বাড়ান

  1. CLONE_FILES সরিয়ে দিন thread মোড থেকে (kernel এটা অনুমতি দেবে, CLONE_THREAD-এর সাথে CLONE_FILES বাধ্যতামূলক নয়)। তারপর child-এ একটা ফাইল open() করে fd নম্বরটা শেয়ার্ড variable-এ লিখুন, আর parent থেকে সেই fd-তে write() করার চেষ্টা করুন। কী error পান, আর কেন?
  2. CLONE_SIGHAND ছাড়া CLONE_THREAD দেওয়ার চেষ্টা করুন। clone() কী error দেয়? man clone(2)-এ কারণটা খুঁজে বের করুন, আর ভাবুন kernel কেন এই নির্ভরতা চাপিয়ে দিয়েছে (ইঙ্গিত: একটা thread group-কে একটা signal পাঠালে কী হওয়া উচিত?)।
  3. futex_join()-টা ভেঙে ফেলুনCLONE_CHILD_CLEARTID flag-টা সরিয়ে দিন কিন্তু futex_join() কল রেখে দিন। প্রোগ্রামটা কী করে? কেন? এটা থেকে বুঝুন pthread_join() কেন kernel-এর সহযোগিতা ছাড়া বাস্তবায়ন করা যায় না।
  4. দশটা thread একসাথে তৈরি করুন, প্রত্যেকে shared_counter-এ ১০,০০০ বার += 1 করবে। চূড়ান্ত মান ১,০০,১০০ হওয়া উচিত — কতবার হয়? -O0 আর -O2-তে ফলাফল কেন আলাদা? (__atomic_fetch_add(&shared_counter, 1, __ATOMIC_RELAXED) দিয়ে ঠিক করুন, তারপর দুটোর গতি তুলনা করুন।)
  5. CLONE_NEWUTS যোগ করুন (root বা unshare -Ur লাগবে)। Child-এ sethostname() ডাকুন, তারপর parent-এ gethostname()। দুইজন ভিন্ন hostname দেখবে — এটাই container isolation-এর সবচেয়ে ছোট সম্ভাব্য প্রদর্শনী, যা Level 12-এ পুরোপুরি খোলা হবে।
  6. perf stat -e page-faults,minor-faults ./rawclone process বনাম thread চালিয়ে page fault সংখ্যা তুলনা করুন। process মোডে বেশি কেন?

বাস্তব সিস্টেমে

nginx — ইচ্ছাকৃতভাবে process, thread নয়। nginx-এর ডিফল্ট আর্কিটেকচার হলো এক master process + CPU-core-সংখ্যক worker process (worker_processes auto;), প্রতিটা worker একটা event loop চালায় (epoll)। Thread ব্যবহার না করার সিদ্ধান্তটা সচেতন — একটা worker ক্র্যাশ করলে বাকিরা অক্ষত থাকে, আর কোনো শেয়ার্ড mutable state না থাকায় লক লাগে না। তবে nginx 1.7.11 থেকে একটা thread pool যোগ করা হয়েছে (aio threads;) শুধুমাত্র blocking disk read-এর জন্য, কারণ read() একটা cold file-এ ব্লক করলে পুরো event loop আটকে যায়।

Apache httpd — তিনটা MPM, তিনটা দর্শন। prefork (process-প্রতি-connection, সবচেয়ে নিরাপদ, সবচেয়ে ভারী), worker (hybrid: কিছু process, প্রতিটায় অনেক thread), event (worker + keep-alive connection-এর জন্য আলাদা listener thread)। একই সার্ভার সফটওয়্যারের তিনটা concurrency model পাশাপাশি — এই লেসনের বিভাজনটার সবচেয়ে সরাসরি বাস্তব উদাহরণ।

PostgreSQL বনাম MySQL — একই সমস্যা, উল্টো উত্তর। PostgreSQL প্রতিটা client connection-এর জন্য একটা আলাদা process fork করে (২০২৪-এও, যদিও একটা thread-ভিত্তিক পুনর্লিখনের প্রস্তাব সক্রিয়ভাবে আলোচিত হচ্ছে); MySQL/InnoDB প্রতিটা connection-এ একটা thread ব্যবহার করে। ফলাফল: PostgreSQL-এ ১০,০০০ connection মানে ১০,০০০ process — তাই PgBouncer-এর মতো connection pooler প্রায় বাধ্যতামূলক; MySQL-এ সেই সংখ্যা অনেক বেশি সহনীয়, কিন্তু একটা thread-এ memory corruption পুরো server নামিয়ে দিতে পারে। Level 11-এ database performance আলোচনায় এই ট্রেড-অফ ফিরে আসবে।

Chrome — দুই স্তরেই। Chrome প্রতিটা site-এর জন্য আলাদা renderer process চালায় (site isolation, Spectre-পরবর্তী যুগে একটা নিরাপত্তা-বাধ্যবাধকতা), কিন্তু প্রতিটা renderer-এর ভেতরে অনেক thread (main/compositor/raster/IO)। “Thread না process” একটা ভুল প্রশ্ন — বাস্তব সিস্টেম দুটোই ব্যবহার করে, ভিন্ন স্তরে ভিন্ন কারণে।

JVM — Project Loom, দুই দশকের সিদ্ধান্ত উল্টে দেওয়া। Java-র Thread শুরু থেকেই ছিল 1:1 (আসলে Java 1.1-এ green thread ছিল, ১.৩-এ 1:1-এ সরে আসে)। JDK 21 (২০২৩) virtual thread এনেছে (JEP 444) — লক্ষ লক্ষ হালকা thread, কিছু “carrier” platform thread-এর উপর multiplexed। অর্থাৎ M:N ফিরে এসেছে, কিন্তু JVM-এর ভেতরে, kernel-এ না। খেয়াল রাখার মতো ফাঁদ — একটা virtual thread যদি synchronized ব্লকের ভেতরে ব্লক করে, সে তার carrier thread “পিন” করে ফেলে (JDK 21-24-এ; JDK 24-এর JEP 491 এই সীমাবদ্ধতা দূর করেছে)।

fork() আর thread একসাথে — একটা ক্লাসিক ফাঁদ। POSIX বলে, একটা multithreaded process-এ fork() করলে child-এ শুধু ডাককারী thread-টাই বেঁচে থাকে। কিন্তু বাকি thread-দের ধরে রাখা lock-গুলো child-এর memory-তে “locked” অবস্থাতেই কপি হয়ে যায় — আর সেগুলো ছাড়ানোর কেউ নেই। ফলে child-এ malloc() ডাকলেই deadlock, কারণ glibc-র malloc arena lock ধরা আছে এমন একটা thread-এর দ্বারা যে child-এ অস্তিত্বহীন। এই কারণেই POSIX-এর নিয়ম: fork() আর exec()-এর মধ্যে শুধু async-signal-safe ফাংশন ডাকা যাবে। pthread_atfork() একটা আংশিক প্রশমন, কিন্তু ব্যবহারিকভাবে অকার্যকর (আপনি প্রতিটা লাইব্রেরির প্রতিটা lock জানেন না)। এই সমস্যার আধুনিক সমাধান হলো posix_spawn() বা vfork()+exec — যা ঠিক এই কারণেই Java-র ProcessBuilder, Python-এর subprocess (৩.৮ থেকে macOS/Linux-এ posix_spawn) আর Go-র os/exec ব্যবহার করে।

Thread-per-core আর্কিটেকচার — 1:1-কে চরমে নেওয়া। ScyllaDB (Seastar framework-এর উপর), DPDK-ভিত্তিক নেটওয়ার্ক অ্যাপ, আর Redis-এর io-thread — এরা ঠিক core-সংখ্যক thread চালায়, প্রতিটাকে একটা core-এ পিন করে (sched_setaffinity), আর thread-এর মধ্যে কোনো শেয়ার্ড mutable state রাখে না (message passing)। যুক্তি: context switch, cache bouncing আর lock contention — তিনটাই শূন্যে নামিয়ে আনা। ScyllaDB-র দাবি এতে Cassandra-র তুলনায় ১০ গুণ throughput। পরের লেসনে ঠিক এই দাবির পেছনের সংখ্যাগুলো নিজে মাপব।

সিস্টেম-সীমা — কয়টা thread আসলে বানানো যায়। cat /proc/sys/kernel/threads-max (সাধারণত RAM-নির্ভর, ৩২ GB মেশিনে ~২৫,০০,০০০), cat /proc/sys/kernel/pid_max (আধুনিক kernel-এ ডিফল্ট ৪১,৯৪,৩০৪, ঐতিহাসিকভাবে ৩২,৭৬৮), ulimit -u (per-user process limit), আর cgroup v2-র pids.max। বাস্তবে বাধাটা প্রায়ই VA বা kernel stack-এ: প্রতিটা thread ১৬ KB kernel stack নেয়, তাই ১,০০,০০০ thread মানে ১.৬ GB kernel memory — যা swap করা যায় না।

যে ভুলগুলো সবাই করে

“Thread হালকা কারণ kernel-এর একটা আলাদা, সস্তা 'thread scheduler' আছে যা process scheduler-এর চেয়ে দ্রুত।”

Linux-এ কোনো আলাদা thread scheduler নেই। Scheduler-এর কাছে একটা thread আর একটা process — দুটোই struct task_struct, একই runqueue-তে, একই pick_next_task() লজিক দিয়ে বাছাই হয়, একই CFS/EEVDF নিয়ম মেনে। sched.h-এ “thread” নামে কোনো বিশেষ শ্রেণি নেই।

তাহলে thread সস্তা কেন? সাশ্রয়টা তৈরির সময়ে, scheduling-এ না — clone(CLONE_VM) কোনো mm_struct বানায় না, কোনো page table কপি করে না, শুধু একটা refcount বাড়ায়। আর switch করার সময়ে — একই thread group-এর দুই thread-এর মধ্যে switch-এ CR3 (page table root) বদলাতে হয় না, তাই TLB flush হয় না। এই দ্বিতীয় সাশ্রয়টা কতটা তা পরের লেসনে সংখ্যায় মাপব।

পার্থক্যটা গুরুত্বপূর্ণ, কারণ এটা ঠিক কোথায় সাশ্রয় হচ্ছে তা বলে দেয়: যদি আপনার প্রোগ্রাম দশটা task একবার তৈরি করে দিনভর চালায়, thread বনাম process-এর তৈরির-খরচের পার্থক্য কার্যত শূন্য — সিদ্ধান্তটা তখন শুধু শেয়ারিং আর isolation-এর প্রশ্ন।

“প্রতিটা thread-এর নিজস্ব heap আছে, তাই একটা thread-এ malloc করা memory অন্য thread ব্যবহার করতে পারে না।”

Heap সম্পূর্ণভাবে শেয়ার্ড — thread A-তে malloc() করা pointer thread B-তে সম্পূর্ণ বৈধ, পড়া যায়, লেখা যায়, এমনকি free()-ও করা যায়। এটা একটা অত্যন্ত সাধারণ প্যাটার্ন (producer thread একটা buffer বানায়, consumer thread সেটা ব্যবহার করে মুছে দেয়)।

যে জিনিসটা এই ভুল ধারণার জন্ম দেয় সেটা হলো glibc-র per-thread arena — malloc lock contention কমাতে glibc প্রতিটা thread-কে (একটা সীমা পর্যন্ত, ডিফল্টে 8 × ncores arena) একটা আলাদা heap region থেকে বরাদ্দ দেয়, যাতে দুইটা thread একসাথে malloc করলে একই lock-এর জন্য লড়তে না হয়। কিন্তু এটা নিছক একটা allocator optimization, কোনো isolation না — সব arena একই address space-এ, সব pointer সবার কাছে বৈধ। mallinfo2() বা malloc_stats() দিয়ে arena-গুলো দেখা যায়, আর MALLOC_ARENA_MAX env variable দিয়ে সংখ্যা সীমিত করা যায় (container-এ memory ফুটপ্রিন্ট কমানোর একটা পরিচিত কৌশল)।

“বেশি thread মানে বেশি সমান্তরালতা -- তাই বেশি thread মানে দ্রুত প্রোগ্রাম।”

CPU-bound কাজে সমান্তরালতার ঊর্ধ্বসীমা হলো physical core সংখ্যা (SMT/hyperthreading থাকলে logical core, যা সাধারণত ২০-৩০% বাড়তি দেয়, ১০০% না)। ৮ core-এর মেশিনে ৮০টা CPU-bound thread চালালে প্রতিটা core-কে ১০টা thread-এর মধ্যে ঘুরিয়ে-ফিরিয়ে চালাতে হবে — মোট কাজ একই থাকবে, কিন্তু যোগ হবে (ক) context switch-এর সরাসরি খরচ, (খ) প্রতিটা switch-এ cache-এ অন্য thread-এর ডেটা ঢুকে যাওয়ার পরোক্ষ খরচ, (গ) ৮০টা stack-এর memory footprint। বাস্তবে প্রোগ্রামটা ধীর হয়।

I/O-bound কাজে গল্প ভিন্ন — সেখানে thread-রা বেশিরভাগ সময় ব্লকড থাকে, তাই core-সংখ্যার চেয়ে অনেক বেশি thread যুক্তিসঙ্গত (এটাই thread pool-এর আকার নির্ধারণের সূত্রের ভিত্তি: Nthreads=Ncores×(1+W/C)N_{threads} = N_{cores} \times (1 + W/C), যেখানে WW = wait time, CC = compute time)।

আর মনে রাখুন Amdahl-এর সূত্র — যদি প্রোগ্রামের ৫% অংশ ক্রমিক (serial) হয়, অসীম core দিয়েও সর্বোচ্চ ২০ গুণ speedup সম্ভব। Level 11-এর performance module এই মাপজোক পদ্ধতিগতভাবে করবে; আপাতত নিয়মটা হলো — thread সংখ্যা অনুমান করবেন না, মাপবেন

“একটা thread-এর ভেতরে getpid() ডাকলে সেই thread-এর নিজস্ব id পাওয়া যায়।”

না — getpid() thread group id (kernel-এর tgid) ফেরত দেয়, যা group-এর সব thread-এ অভিন্ন। এই লেসনের example অংশের আউটপুটে তিনটা thread-এই pid=48213 দেখা গেছে, ঠিক এই কারণে।

Thread-নির্দিষ্ট kernel id পেতে হলে gettid() (glibc 2.30+) বা syscall(SYS_gettid) লাগবে। আর pthread_self() সম্পূর্ণ ভিন্ন জিনিস — সেটা kernel-এর কোনো id না, বরং glibc-র struct pthread-এর ঠিকানা (একটা unsigned long, সাধারণত 0x7f...-এর মতো একটা pointer)। এটা userspace-এর একটা হ্যান্ডল মাত্র; ps, top, /proc, gdb বা perf-এ এই সংখ্যাটা কোথাও দেখা যাবে না।

ব্যবহারিক নিয়ম: log ফাইলে thread শনাক্ত করতে gettid() ব্যবহার করুন, pthread_self() না — কারণ শুধু gettid()-এর মানটাই বাইরের টুল দিয়ে মেলানো যায়। (LinuxThreads-যুগে getpid() thread-প্রতি আলাদা মান দিত, আর তখনকার কিছু কোড এই আচরণের উপর নির্ভর করত — NPTL-এ যাওয়ার সময় সেই কোডগুলো নীরবে ভেঙে গিয়েছিল, এই ভুল ধারণার একটা ঐতিহাসিক উৎস।)

বুঝেছেন কি না দেখুন

1

একটা প্রোগ্রাম clone() ডাকছে CLONE_VM | CLONE_FILES | SIGCHLD flag দিয়ে (CLONE_THREAD বা CLONE_SIGHAND ছাড়া)। এই child-টা কি একটা “thread”? নিচের চারটার প্রতিটার উত্তর দিন: (ক) getpid() parent আর child-এ কি একই? (খ) child-এর malloc() করা pointer parent কি ব্যবহার করতে পারবে? (গ) parent কি wait() দিয়ে child-এর জন্য অপেক্ষা করতে পারবে? (ঘ) child-এ sigaction(SIGINT, ...) ডাকলে parent-এর handler কি বদলাবে?

যুক্তি

এটা POSIX অর্থে thread নয়, কিন্তু memory-র দিক থেকে thread-এর মতোই আচরণ করে — Linux-এর flag-ধারাবাহিকতার একটা মধ্যবর্তী বিন্দু।

প্রশ্নউত্তরকোন flag দায়ী
(ক) getpid() একই?না — আলাদা PIDCLONE_THREAD নেই, তাই নতুন tgid বরাদ্দ হয়েছে
(খ) pointer বৈধ?হ্যাঁ — সম্পূর্ণ বৈধCLONE_VM আছে, একই mm_struct, একই heap
(গ) wait() কাজ করে?হ্যাঁCLONE_THREAD নেই মানে এটা একটা সাধারণ child process, আর SIGCHLD exit signal দেওয়া আছে
(ঘ) handler শেয়ার্ড?নাCLONE_SIGHAND নেই, তাই sighand_struct কপি হয়েছে

মূল শিক্ষা: “thread” একটা POSIX-স্তরের abstraction, kernel-স্তরের শ্রেণি না। Kernel শুধু জানে কোন কোন resource শেয়ার্ড। CLONE_VM ঠিক করে memory, CLONE_THREAD ঠিক করে পরিচয় (PID, wait-যোগ্যতা, signal group), CLONE_SIGHAND ঠিক করে signal handler — তিনটা স্বাধীন সিদ্ধান্ত।

এই মধ্যবর্তী সংমিশ্রণটা বাস্তবে ব্যবহৃত হয়: vfork() (CLONE_VM|CLONE_VFORK|SIGCHLD) ঠিক এই শ্রেণির, আর posix_spawn() তার উপর নির্মিত — memory শেয়ার করে বলে page table কপি করতে হয় না (তাই দ্রুত), কিন্তু একটা আলাদা process বলে exec() করা যায় আর wait() করা যায়। Level 12-এর container আলোচনায় এই একই ধারাবাহিকতা namespace flag (CLONE_NEWPID, CLONE_NEWNET) দিয়ে আরও প্রসারিত হবে।

2

আপনার সার্ভারে ৩২ GB RAM, ১৬টা core। আপনি ৫০,০০০ thread তৈরি করতে চান, প্রতিটা একটা network connection সামলাবে (বেশিরভাগ সময় recv()-এ ব্লকড)। ডিফল্ট ulimit -s হলো ৮১৯২ (৮ MB)। এটা কি কাজ করবে? সংখ্যা দিয়ে হিসাব করুন, আর দুইটা সমাধান দিন।

প্রয়োগ

তিনটা আলাদা resource আলাদাভাবে হিসাব করতে হবে।

১. Virtual address space:

50,000×8 MB=400 GB VA50{,}000 \times 8\ \text{MB} = 400\ \text{GB VA}

৬৪-বিট মেশিনে user address space ১২৮ TB (৪৭ বিট), তাই ৪০০ GB সমস্যা না — VA বিনামূল্যে। (৩২-বিট মেশিনে ৩ GB সীমা থাকায় এটা ~৩৭০টা thread-এই মারা যেত; এই কারণেই ৩২-বিট যুগে thread-per-connection অচল ছিল।)

২. Physical memory (RSS): Stack lazy-allocated — শুধু সত্যিই স্পর্শ করা page-ই physical memory পায়। একটা সাধারণ handler ফাংশন হয়তো ৮–১৬ KB stack ব্যবহার করে:

50,000×12 KB600 MB50{,}000 \times 12\ \text{KB} \approx 600\ \text{MB}

৩২ GB-তে গ্রহণযোগ্য।

৩. Kernel memory — এখানেই আসল বাধা। প্রতিটা task-এর জন্য একটা task_struct (~২–৪ KB) আর একটা kernel stack (x86-64-এ ১৬ KB, THREAD_SIZE):

50,000×(16+4) KB=1,000 MB1 GB50{,}000 \times (16 + 4)\ \text{KB} = 1{,}000\ \text{MB} \approx 1\ \text{GB}

এই ১ GB swap করা যায় না (kernel memory unswappable) — সরাসরি হারিয়ে যাওয়া RAM। এর সাথে যোগ হবে scheduler-এর প্রতিটা runqueue অপারেশনের খরচ, আর ৫০,০০০ task-এর red-black tree।

রায়: কাজ করবে, কিন্তু ১ GB kernel memory অপচয় করে, আর pid_max/threads-max-এর সীমা যাচাই করতে হবে।

সমাধান ১ — stack ছোট করুন। pthread_attr_setstacksize(&attr, 64*1024) দিয়ে প্রতি thread ৬৪ KB। VA নামে ৩.২ GB-তে, আর guard page-এর সংখ্যা কমে। Trade-off: গভীর recursion বা বড় stack array থাকলে overflow — এবং thread stack overflow প্রায়ই নীরব corruption হয়ে দেখা দেয় (guard page থাকলে অন্তত SIGSEGV পাবেন)।

সমাধান ২ — thread-per-connection ছেড়ে event-driven মডেলে যান। ১৬–৩২টা thread, প্রতিটা epoll_wait() দিয়ে হাজার হাজার socket তদারকি করবে। Kernel memory ৫০,০০০ × ২০ KB থেকে নেমে ৩২ × ২০ KB = ৬৪০ KB-তে। Trade-off: কোড লেখা কঠিন (callback/state machine), আর একটা handler ভুল করে ব্লক করলে হাজারটা connection আটকে যায়। এটাই nginx, Redis আর Node.js-এর পথ।

সমাধান ৩ (আধুনিক আপস) — user-space task। Go goroutine, Java virtual thread বা Rust async — সিনট্যাক্সে thread-per-connection-এর সরলতা রেখে নিচে event loop। ৫০,০০০ goroutine মানে ~১০০ MB (২ KB প্রাথমিক stack), আর kernel দেখে মাত্র ১৬টা thread।

Level 7-এর networking module-এ epoll আর io_uring-এর মেকানিজম, আর Level 11-এ এই তিনটা মডেলের latency distribution পাশাপাশি মাপা হবে।

3

নিচের কোডটা কম্পাইল করে চালালে দুইটা thread একই __thread variable-এ লিখছে। কোনো mutex নেই। এতে কি data race হবে? আর নিচের assembly-তে %fs: prefix-টা এই উত্তরের সাথে কীভাবে সম্পর্কিত?

__thread long tls_counter = 0;
void *w(void *a) { for (long i = 0; i < 1e8; i++) tls_counter++; return NULL; }
যুক্তি

না, কোনো race হবে না — এবং কারণটা সরাসরি %fs: prefix-এ লেখা আছে।

tls_counter++ কম্পাইল হয় এরকম কিছুতে:

mov    rax, QWORD PTR fs:0xfffffffffffffff8
add    rax, 1
mov    QWORD PTR fs:0xfffffffffffffff8, rax

Instruction তিনটা উভয় thread-এ হুবহু অভিন্ন — একই offset (fs_base - 8), একই opcode বাইট। কিন্তু কার্যকর ঠিকানা হিসাব হয় fs_base+offset\text{fs\_base} + \text{offset} হিসেবে, আর fs_base প্রতিটা thread-এ আলাদা (thread ১-এর জন্য হয়তো 0x7f4a8b3ff6c0, thread ২-এর জন্য 0x7f4a8abfe6c0)। ফলে দুইটা thread সম্পূর্ণ ভিন্ন দুইটা memory location স্পর্শ করছে — race-এর পূর্বশর্ত (একই location, অন্তত একটা write, কোনো synchronization নেই) কখনো পূরণই হচ্ছে না।

প্রতিটা thread স্বাধীনভাবে ১০⁸-এ পৌঁছাবে, আর মোট “হারানো” কোনো বৃদ্ধি থাকবে না — কারণ যোগ করার মতো কোনো সাধারণ যোগফলই নেই।

তুলনামূলক পর্যবেক্ষণ, যেটা মাপা উচিত: একই লুপ যদি একটা সাধারণ long g_counter (শেয়ার্ড global) নিয়ে চালানো হয়, দুইটা ফল একসাথে পাবেন — (ক) চূড়ান্ত মান ২×১০⁸-এর অনেক কম (lost update), আর (খ) প্রোগ্রামটা অনেক ধীর, কারণ দুইটা core একই cache line নিয়ে টানাটানি করছে (MESI প্রোটোকলে line-টা বারবার এক core থেকে আরেক core-এ যাচ্ছে)। TLS সংস্করণে সেই cache bouncing নেই — দুইটা variable ভিন্ন cache line-এ, প্রতিটা নিজের core-এ Modified অবস্থায় বসে থাকে।

এই দ্বিতীয় প্রভাবটাই false sharing-এর যমজ ভাই, আর এটাই ব্যাখ্যা করে কেন performance-সচেতন কোড per-thread counter রেখে শেষে যোগ করে (sharded counter প্যাটার্ন)। Level 11-এর performance module-এ perf c2c দিয়ে এই cache line bouncing সরাসরি দেখা হবে, আর Level 9-এর distributed systems module-এ একই ধারণা বড় স্কেলে (per-node counter, CRDT) ফিরে আসবে।

4

ps -eLf চালিয়ে দেখলেন একটা প্রোগ্রামের NLWP কলামে ১, কিন্তু top-এ সেই প্রোগ্রামটা ধারাবাহিকভাবে ৭৫০% CPU ব্যবহার করছে (৮-core মেশিনে)। এটা কি সম্ভব? কীভাবে ব্যাখ্যা করবেন?

প্রয়োগ

একটা মাত্র thread কখনোই ১০০%-এর বেশি CPU ব্যবহার করতে পারে না — একটা thread এক সময়ে একটা core-এই চলে। তাই NLWP=1 আর ৭৫০% CPU একসাথে অসম্ভব। কোথাও পর্যবেক্ষণে ভুল আছে, আর সম্ভাব্য কারণ তিনটা:

ব্যাখ্যা ১ (সবচেয়ে সম্ভাব্য) — top process-সমষ্টি দেখাচ্ছে, ps ভুল প্রোগ্রামের। top-এর ডিফল্ট দৃশ্যে প্রতিটা সারি একটা process (thread group), আর CPU% হলো তার সব thread-এর যোগফল — তাই ৮ core-এ ৮০০% পর্যন্ত সম্ভব। ps -eLf-এর গ্রেপে হয়তো ভুল PID ধরা পড়েছে, বা একটা supervisor/wrapper process দেখা যাচ্ছে যার আসল কাজ করা child-গুলো আলাদা।

যাচাই: top-এ H চাপুন (thread mode) — তখন প্রতিটা thread আলাদা সারিতে আসবে, প্রত্যেকে ১০০%-এর নিচে, আর সারির সংখ্যা NLWP-র সাথে মিলবে। অথবা সরাসরি cat /proc/PID/status | grep Threads

ব্যাখ্যা ২ — thread-গুলো ক্ষণস্থায়ী। প্রোগ্রামটা যদি অনবরত thread তৈরি করে আর শেষ করে (একটা naive thread-per-request সার্ভার), তাহলে ps চালানোর ঠিক সেই মুহূর্তে হয়তো একটাই thread জীবিত ছিল। top-এর CPU% একটা সময়-ব্যাপী গড়, তাই সে সব ক্ষণস্থায়ী thread-এর কাজ যোগ করে দেখায়।

যাচাই: watch -n 0.2 'grep Threads /proc/PID/status' চালিয়ে সংখ্যাটা ওঠানামা করছে কি না দেখুন, অথবা perf stat -e sched:sched_process_fork -p PID sleep 5 দিয়ে ৫ সেকেন্ডে কতগুলো task তৈরি হলো গুনুন।

ব্যাখ্যা ৩ — এটা আসলে অনেকগুলো process, thread না। PostgreSQL বা nginx-এর মতো process-ভিত্তিক আর্কিটেকচারে প্রতিটা worker আলাদা PID, প্রত্যেকের NLWP=1top-এ যদি সেগুলো collapse করে দেখানো হয় (অথবা আপনি যদি htop-এ tree mode-এ parent-এর সারিটা পড়ে থাকেন), যোগফলটা ৭৫০% দেখাতে পারে।

যাচাই: ps -eo pid,ppid,nlwp,pcpu,comm --sort=-pcpu | head -20 — একই comm নামে একাধিক PID দেখলেই উত্তর পেয়ে গেছেন।

Level 11-এর performance module-এ এই ধরনের পর্যবেক্ষণগত অসঙ্গতি পদ্ধতিগতভাবে সমাধান করার কৌশল (pidstat -t, perf record -a --call-graph, flame graph) শেখানো হবে — মূল নীতিটা এখানেই: একটা সংখ্যা বিশ্বাস করার আগে দ্বিতীয় স্বাধীন উৎস দিয়ে মিলিয়ে নিন।

5

আপনি একটা নতুন OS ডিজাইন করছেন এবং সিদ্ধান্ত নিতে হবে thread কীভাবে বাস্তবায়ন করবেন। আপনার লক্ষ্য একটা IoT gateway — ২৫৬ MB RAM, ২টা core, ~৫,০০০ concurrent MQTT connection, আর কঠোর latency বাজেট (৯৯তম পার্সেন্টাইলে ১০ ms)। 1:1, N:1 আর M:N-এর মধ্যে কোনটা বাছবেন, আর কেন? অন্তত দুইটা ট্রেড-অফ স্পষ্ট করুন।

ডিজাইন

সুপারিশ: kernel-এ 1:1, তার উপরে runtime-স্তরে N:1 (একটা hybrid — কিন্তু M:N kernel-এ নয়)। অর্থাৎ ২–৪টা OS thread (core-সংখ্যার সমান বা সামান্য বেশি), প্রতিটা একটা event loop চালাবে, আর ৫,০০০ connection সেই loop-গুলোর উপর user-space task হিসেবে multiplexed।

কেন খাঁটি 1:1 (thread-per-connection) নয় — সংখ্যা দিয়ে:

Resource৫,০০০ thread-এবাজেট
Kernel stack (১৬ KB/thread)৮০ MB২৫৬ MB-র ৩১% — unswappable
task_struct (~৩ KB)১৫ MBআরও ৬%
User stack, ৬৪ KB-তে কমিয়েও (touched ~৮ KB)~৪০ MBআরও ১৬%
মোট~১৩৫ MBRAM-এর অর্ধেকের বেশি, কোনো কাজ করার আগেই

এর সাথে scheduler-এর খরচ: ৫,০০০ runnable task-এর মধ্যে ২টা core ঘোরালে প্রতিটা task তার পালা পেতে অনেকক্ষণ অপেক্ষা করবে — ১০ ms p99 বাজেট রাখা কঠিন।

কেন খাঁটি N:1 নয়: ২টা core আছে, কিন্তু N:1-এ একটামাত্র kernel thread — তাই দ্বিতীয় core সম্পূর্ণ অব্যবহৃত থাকবে, অর্ধেক hardware অপচয়। আর একটামাত্র thread-এ যেকোনো একটা অসাবধান blocking কল (একটা DNS লুকআপ, একটা flash write) পুরো gateway-কে থামিয়ে দেবে — p99 latency-র জন্য মারাত্মক।

কেন kernel-স্তরের M:N নয়: এই লেসনের ইতিহাস অংশের যুক্তিগুলো এখানে দ্বিগুণ প্রযোজ্য — একটা ছোট embedded OS-এ scheduler activation, upcall আর দুই-স্তরের priority সমন্বয় বাস্তবায়ন করা মানে জটিলতার একটা পাহাড়, যা NPTL/NGPT-র মতো একটা সুসম্পন্ন দলও এড়িয়ে গেছে। বাগ-প্রতি-সুবিধা অনুপাত ভয়াবহ।

ট্রেড-অফ ১ — প্রোগ্রামিং মডেলের জটিলতা। Event loop মানে callback বা state machine; একটা connection-এর যুক্তি অনেকগুলো ফাংশনে ছড়িয়ে পড়ে, ডিবাগ করা কঠিন (stack trace আর গল্প বলে না)। প্রশমন: ভাষা-স্তরের async/await (Rust-এ tokio, বা C-তে একটা coroutine লাইব্রেরি) — সিনট্যাক্সে ক্রমিক কোড, নিচে event loop।

ট্রেড-অফ ২ — একটা লম্বা কম্পিউটেশন সব কিছু আটকে দেয়। একটা task যদি ৫০ ms ধরে TLS handshake-এর RSA গণনা করে, ওই event loop-এর বাকি ২,৫০০ connection ততক্ষণ অপেক্ষায় — p99 সরাসরি ভেঙে যায়। প্রশমন: ভারী কাজের জন্য একটা ছোট, আলাদা worker thread pool (২–৩টা 1:1 thread) রাখা, আর event loop থেকে সেখানে কাজ পাঠানো। এটাই nginx-এর aio threads আর Node.js-এর libuv thread pool-এর ঠিক একই যুক্তি।

ট্রেড-অফ ৩ — hardware-এর সাথে বাঁধা। Event loop-ভিত্তিক ডিজাইন epoll/kqueue-এর মতো একটা দক্ষ readiness API ধরে নেয়। কিছু ছোট RTOS-এ সেটা নেই (শুধু select(), যা O(n)) — তখন ৫,০০০ fd-তে প্রতিটা লুপ-চক্রে ৫,০০০ bit স্ক্যান করতে হবে, আর সুবিধাটা উবে যাবে। যাচাই আগে, ডিজাইন পরে।

Level 7 (networking) এই readiness API-গুলোর মেকানিজম খুলবে, Level 11 p99 latency মাপার সঠিক পদ্ধতি (coordinated omission এড়ানো সহ) শেখাবে, আর Level 12 একই সিদ্ধান্তটা cloud-স্কেলে (thread pool বনাম serverless) পুনর্বিবেচনা করবে।

এরপর কী

পরের লেসন — Context Switching

এই লেসনে বারবার একটা সংখ্যা এসেছে যেটার পেছনে যাওয়া হয়নি: thread switch করতে “~১–৩ μs” লাগে, আর একই thread group-এর ভেতরে switch সস্তা কারণ “CR3 বদলাতে হয় না, তাই TLB flush হয় না”। দুটো দাবিই এই লেসনে ব্যবহার করা হয়েছে, কিন্তু প্রমাণ করা হয়নি।

পরের লেসনে সেই কাজটাই করব — একটা context switch-এ ঠিক কোন কোন বাইট সংরক্ষিত ও পুনরুদ্ধার হয় (register set, stack pointer, program counter, আর process switch হলে page table root CR3), সেটার সরাসরি খরচ কত (register save/restore), আর তার চেয়েও বড় পরোক্ষ খরচ কত (ঠান্ডা হয়ে যাওয়া cache, flush হওয়া TLB, দূষিত branch predictor)। দেখব কেন দ্বিতীয়টা প্রায় সবসময় প্রথমটার চেয়ে বড়, আর PCID/ASID নামের hardware ফিচারটা কীভাবে TLB flush-এর খরচ কমায়।

আর নিজে মাপব — দুইটা process একটা pipe দিয়ে ping-pong খেলবে, আর সেখান থেকে μs-প্রতি-switch বের করব; /proc/PID/status-এর voluntary_ctxt_switches বনাম nonvoluntary_ctxt_switches পড়ে বুঝব কে স্বেচ্ছায় CPU ছাড়ছে আর কাকে জোর করে সরানো হচ্ছে; আর দুইটা thread-কে একই core-এ বনাম ভিন্ন core-এ পিন করে (sched_setaffinity) দেখব cache-এর প্রভাব কতটা নাটকীয় — এই লেসনের build-এ যে clone() flag-গুলো শিখলাম, তার সরাসরি পরবর্তী ধাপ।

আরও পড়ুন

  • The Native POSIX Thread Library for Linux — Ulrich Drepper, Ingo Molnar · NPTL-এর মূল ডিজাইন পেপার (২০০৩) — কেন 1:1 model বেছে নেওয়া হলো, LinuxThreads-এর কোন সমস্যাগুলো এটা সমাধান করল, তার প্রামাণ্য বিবরণ
  • clone(2) — Linux manual page — Michael Kerrisk · প্রতিটা CLONE_* flag-এর সঠিক শব্দার্থ, তাদের পারস্পরিক নির্ভরতা আর glibc wrapper-এর argument order — এই লেসনের build অংশের প্রামাণ্য উৎস
  • ELF Handling For Thread-Local Storage — Ulrich Drepper · TLS-এর চারটা access model, আর x86-64-এ fs segment register কীভাবে thread control block-এ পৌঁছায় তার পূর্ণ বিবরণ
  • Operating Systems: Three Easy Pieces — Concurrency: An Introduction — Remzi H. Arpaci-Dusseau, Andrea C. Arpaci-Dusseau · Thread-এর ধারণাগত পরিচয় আর stack-বিভাজনের চিত্র — এই লেসনের concept অংশের পরিপূরক পাঠ
  • Linux kernel source — kernel/fork.c · copy_process() ফাংশন — যেখানে CLONE_VM, CLONE_THREAD ইত্যাদি flag আসলে প্রয়োগ হয়; এই লেসনের hood অংশের দাবিগুলো এখানে যাচাই করা যায়