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

fork, exec আর wait — প্রসেস তৈরি আর শেষ

The Process API — fork, the exec Family, wait/waitpid, Zombies, Orphans, posix_spawn and CreateProcess

Taught in Bangla, this lesson covers the Unix process API with runnable C: what fork copies, copy-on-write, solving 'how many processes / how many prints' puzzles with fork trees and short-circuit && and ||, why printf appears twice after fork, the exec family naming rules, wait, waitpid and exit-status macros, exit vs _exit, zombie and orphan processes, and alternatives such as vfork, posix_spawn, clone and Windows CreateProcess.

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

  • fork কী কপি করে আর কী করে না — address space, file descriptor-এর ভাগ করা offset, PID, pending signal, lock
  • “কতবার ছাপা হবে / কতটি প্রসেস হবে” ধরনের পরীক্ষার ধাঁধা নিশ্চিতভাবে সমাধান করা — fork tree আঁকা, && / ||-এর short-circuit ধরে ধরে
  • exec পরিবারের নাম পড়ার নিয়ম (l, v, p, e), exec-এর পরে কী থাকে আর কী হারায়
  • wait / waitpid আর exit status-এর macro, exit বনাম _exit, আর fork-এর পরে printf দুবার আসার আসল কারণ
  • Zombie আর orphan চোখে দেখা, প্রসেস শেষ হওয়ার কারণগুলো, আর posix_spawn, vfork, clone ও Windows-এর CreateProcess-এর সাথে তুলনা

১ · প্রসেস আসে কোথা থেকে — প্রসেসের বংশবৃক্ষ

পর্ব ৮-এ দেখেছেন, একটি process হলো চলমান একটি program — তার কোড, নিজস্ব মেমরি, খোলা ফাইল আর CPU-র অবস্থা মিলিয়ে। প্রতিটির একটি পরিচয় নম্বর থাকে: PID (process ID). কিন্তু প্রশ্ন হলো, নতুন প্রসেস জন্মায় কীভাবে? আপনি টার্মিনালে ls লিখে Enter চাপলেন — ls নামের প্রসেসটি কে বানাল?

উত্তর: আরেকটি প্রসেস। Unix-ধাঁচের সিস্টেমে (Linux, macOS, BSD) প্রথম user প্রসেস init (PID 1) ছাড়া প্রতিটি প্রসেসকে অন্য কোনো প্রসেস তৈরি করেছে। যে তৈরি করে সে parent, যাকে তৈরি করা হলো সে child. প্রতিটি প্রসেস তার parent-এর PID মনে রাখে — একে বলে PPID. ফলে পুরো সিস্টেম একটি বংশবৃক্ষ, যাকে বলে process tree. Linux-এ pstree কমান্ড এটি দেখায় (উদাহরণ আউটপুট, আপনার মেশিনে ভিন্ন হবে):

$ pstree -p | head -n 6
systemd(1)─┬─sshd(812)───sshd(4090)───bash(4100)───pstree(4188)
           ├─cron(640)
           ├─nginx(901)─┬─nginx(902)
           │            └─nginx(903)
           └─systemd-journal(301)

এখানে PID 1 হলো systemd (পর্ব ৭). তার child sshd, sshd-এর child আপনার login shell bash, আর bash-এর child এইমাত্র চালানো pstree. প্রতিটি কমান্ড আপনার shell-এর একটি child হয়ে জন্মায় আর কাজ শেষে বিদায় নেয়।

তিনটি call, তিনটি কাজ

Unix এই জন্ম-মৃত্যুর কাজটি তিনটি আলাদা system call-এ ভাগ করেছে। প্রথমে একটি উপমা দিয়ে বুঝে নিই।

একটি সরকারি অফিসের কথা ভাবুন। বড় কর্মকর্তার টেবিলে একটি ফাইল আছে — ফাইল নম্বর, ভেতরের কাগজপত্র, কোন কোন আলমারির চাবি তাঁর কাছে আছে তার তালিকা। নতুন একটি কাজের জন্য তিনি প্রথমে ফটোকপি মেশিনে পুরো ফাইলটির হুবহু কপি করান, কপিতে নতুন একটি ফাইল নম্বর পড়ে (fork). সহকারী সেই কপি হাতে নিয়ে ভেতরের পুরোনো কাগজ ফেলে দেন, আর নতুন কাজের ফর্ম ঢোকান — ফাইল নম্বর আর আলমারির চাবিগুলো কিন্তু একই থাকে (exec). কাজ শেষে সহকারী একটি ছোট রিপোর্ট রেখে যান — “কাজ সফল” বা “ব্যর্থ, কারণ এই”। কর্মকর্তা রিপোর্ট না নেওয়া পর্যন্ত ফাইলটি রেজিস্টার খাতা থেকে কাটা যায় না (wait).

🧬 fork()

চলমান প্রসেসের প্রায় হুবহু একটি কপি বানায়। কপিটি একই প্রোগ্রাম, একই জায়গা থেকে চলতে শুরু করে — fork থেকে ফেরার মুহূর্ত থেকে।

নতুন PID, একই প্রোগ্রাম

🔁 exec…()

প্রসেসের ভেতরের প্রোগ্রামটি ফেলে দিয়ে ডিস্ক থেকে নতুন একটি প্রোগ্রাম বসায়। নতুন প্রসেস তৈরি হয় না।

একই PID, নতুন প্রোগ্রাম

⏳ wait() / waitpid()

Parent অপেক্ষা করে, child শেষ হলে তার exit status নেয়, আর kernel child-এর শেষ চিহ্নটুকু মুছে দেয়।

ফলাফল সংগ্রহ

এই ভাগাভাগির সবচেয়ে বড় সুবিধা দেখা যায় shell-এ। fork আর exec-এর মাঝখানে child-এর হাতে কিছু সময় থাকে, যখন সে এখনো shell-এর কোড চালাচ্ছে। তখন সে নিজের stdout একটি ফাইলে ঘুরিয়ে দিতে পারে (ls > out.txt), একটি pipe-এর সাথে জুড়তে পারে (ls | wc -l), directory বা environment বদলাতে পারে — তারপর exec. নতুন প্রোগ্রামটি এসব কিছুই জানে না, শুধু দেখে তার fd 1 আগে থেকেই ঠিক জায়গায় জোড়া। একটি “সব-একসাথে” call-এ এগুলোর প্রতিটির জন্য আলাদা parameter লাগত।

fork-এর ধারণা Unix-এর চেয়েও পুরোনো। Dennis Ritchie তাঁর ১৯৭৯ সালের প্রবন্ধ “The Evolution of the Unix Time-sharing System”-এ লিখেছেন, fork আর exec-এর এই ভাগ তাঁরা পেয়েছিলেন Berkeley-র GENIE time-sharing সিস্টেম থেকে। শুরুর PDP-7 Unix-এ fork ছিল না; ১৯৭০ সালের দিকে PDP-7-এই যোগ হয়, আর Ritchie-র হিসাবে লাগাতে মাত্র ২৭ লাইনের মতো assembly লেগেছিল — কারণ প্রসেসের পুরো মেমরি সোয়াপ ডিস্কে লিখে রাখার ব্যবস্থা আগেই ছিল, শুধু পুরোনোটি না মুছে নতুন নম্বরে চালু করলেই হলো।

২ · fork() — একবার ডাকলে দুবার ফেরে

fork-এর সবচেয়ে অদ্ভুত দিকটি দিয়েই শুরু করি। সাধারণ ফাংশন একবার ডাকলে একবার ফেরে। fork একবার ডাকা হয়, কিন্তু ফেরে দুটি প্রসেসে — parent-এ আর নতুন child-এ। দুজনেই fork-এর ঠিক পরের লাইন থেকে চলে। তাহলে কোড কীভাবে বুঝবে সে কে? ফেরত মান দেখে:

কোথায় ফেরেফেরত মানকেন এমন নকশা
Child-এ0Child চাইলে getpid() দিয়ে নিজের PID আর getppid() দিয়ে parent-এর PID জানতে পারে; তাই 0-ই যথেষ্ট
Parent-এchild-এর PID (> 0)Parent-এর একাধিক child থাকতে পারে, আর পরে কাকে wait করবে বা signal পাঠাবে তা জানতে child-এর PID দরকার — অন্য কোনো উপায়ে তা পাওয়া যায় না
শুধু parent-এ (ব্যর্থ হলে)-1, সঙ্গে errnoকোনো child তৈরি হয়নি। কারণ সাধারণত EAGAIN (প্রসেসের সংখ্যার সীমা ছুঁয়েছে) বা ENOMEM (মেমরি নেই)

তাই fork-এর পরের কোড প্রায় সবসময় এরকম দেখায়: if (pid == 0) { child-এর কাজ } else if (pid > 0) { parent-এর কাজ } else { ত্রুটি }. একটি আসল উদাহরণ দেখি। এখানে child নিজের কাজ করে exit(3) দিয়ে শেষ হয়, আর parent আগে waitpid দিয়ে অপেক্ষা করে, তারপর ছাপে — ফলে আউটপুটের ক্রম প্রতিবার একই থাকে।

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
    int x = 100;
    pid_t me = getpid();
    printf("before fork: one process, x = %d\n", x);
    fflush(stdout);                   /* empty the buffer before fork */

    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return 1; }

    if (pid == 0) {                   /* ---- child ---- */
        x += 1;
        printf("[child ] fork() returned 0\n");
        printf("[child ] my parent is the original? %s\n",
               getppid() == me ? "yes" : "no");
        printf("[child ] x = %d (my own copy)\n", x);
        exit(3);
    }
    /* ---- parent ---- */
    int status;
    pid_t done = waitpid(pid, &status, 0);   /* child finishes first */
    printf("[parent] fork() returned the child's pid (> 0)? %s\n",
           pid > 0 ? "yes" : "no");
    printf("[parent] waitpid returned that same pid? %s\n",
           done == pid ? "yes" : "no");
    printf("[parent] x = %d (unchanged)\n", x);
    printf("[parent] child exit status = %d\n", WEXITSTATUS(status));
    return 0;
}

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

before fork: one process, x = 100
[child ] fork() returned 0
[child ] my parent is the original? yes
[child ] x = 101 (my own copy)
[parent] fork() returned the child's pid (> 0)? yes
[parent] waitpid returned that same pid? yes
[parent] x = 100 (unchanged)
[parent] child exit status = 3

প্রথম লাইন একবারই ছাপা হয়েছে, কারণ তখন প্রসেস একটিই, আর fork-এর আগে fflush করা হয়েছে (কেন জরুরি, অংশ ৪-এ দেখবেন)। এরপর একই কোড দুই প্রসেসে চলেছে, কিন্তু ফেরত মান আলাদা বলে ভিন্ন শাখায় ঢুকেছে। Child-এ x বেড়ে ১০১ হয়েছে, অথচ parent-এ ১০০-ই রয়ে গেছে — দুজনের মেমরি আলাদা, একজনের লেখা অন্যজন দেখে না। Parent-এর waitpid ঠিক সেই PID-ই ফেরত দিয়েছে যা fork দিয়েছিল, আর child-এর exit(3)-এর 3 parent পেয়েছে WEXITSTATUS দিয়ে। আসল PID সংখ্যাগুলো প্রতিবার বদলায়, তাই এখানে সম্পর্কগুলোই ছাপা হয়েছে। waitpid না থাকলে child আর parent-এর লাইন যেকোনো ক্রমে মিশে আসতে পারত — কে আগে CPU পাবে, তা ঠিক করে scheduler, আপনার কোড নয়।

কী কপি হয়, কী হয় না

“হুবহু কপি” কথাটি পুরোপুরি সত্য নয়। POSIX মানদণ্ড আর Linux-এর man page নির্দিষ্ট করে দেয় কোনটি child পায় আর কোনটি পায় না। পরীক্ষায় এই তালিকা প্রায়ই আসে:

Child পায় (কপি বা ভাগ)Child পায় না (নতুন বা খালি)
পুরো address space-এর কপি — কোড, global, heap, stack (copy-on-write দিয়ে)নিজের নতুন PID; PPID = parent-এর PID
খোলা file descriptor-গুলোর কপি — একই ফাইলের দিকে, একই offset ভাগ করেParent-এর pending signal — child-এর pending signal-এর তালিকা খালি শুরু হয়
Signal handler আর signal mask, environment, current directory, umask, user/group IDfcntl দিয়ে নেওয়া record lock, mlock করা মেমরির lock
stdio buffer — কারণ এটিও শুধু মেমরি (এখান থেকেই অংশ ৪-এর গোলমাল)CPU সময়ের হিসাব (শূন্য থেকে শুরু), alarm ও timer
mmap করা অঞ্চল — MAP_SHARED হলে দুজনে সত্যিই ভাগ করেParent-এর অন্য thread-গুলো — child-এ শুধু fork ডাকা থ্রেডটি থাকে

File descriptor কপি হয়, কিন্তু offset ভাগ হয়

এই সূক্ষ্ম বিষয়টি ভালো করে বুঝুন। Kernel-এ একটি খোলা ফাইলের তিনটি স্তর থাকে: প্রসেসের fd টেবিল (ছোট সংখ্যা 0, 1, 2, 3…), তার পেছনে open file description (ফাইলটি কোন মোডে খোলা আর এখন কোন বাইটে আছি — offset), আর তার পেছনে আসল ফাইল। fork fd টেবিল কপি করে, কিন্তু দুই টেবিলের এন্ট্রি একই open file description-এর দিকে নির্দেশ করে। তাই parent ১০ বাইট পড়লে child-এর জন্যও offset ১০ বাইট এগিয়ে যায়। দুজনে একই লগ ফাইলে লিখলে একে অপরের লেখা মুছে ফেলে না, বরং পরপর যোগ হয় — এজন্যই parent আর child-এর আউটপুট একই টার্মিনাল বা pipe-এ সুন্দরভাবে আসে। আর আলাদা আলাদা open() করলে প্রত্যেকে নিজস্ব offset পেত।

Copy-on-write — কপি না করেই কপি

একটি প্রসেস ২ GB মেমরি ব্যবহার করলে fork কি ২ GB কপি করে? করলে প্রতিটি কমান্ড চালাতে কয়েক সেকেন্ড লাগত। আধুনিক OS একটি চালাকি করে: fork-এর সময় kernel মেমরির পাতাগুলো (page) কপি করে না, শুধু দুই প্রসেসের page table-এ একই physical পাতা বসিয়ে দেয়, আর সেগুলোকে “শুধু পড়া যাবে” চিহ্নিত করে। যতক্ষণ দুজনে শুধু পড়ে, একটি পাতাই দুজনের কাজে লাগে। কেউ লিখতে গেলেই হার্ডওয়্যার একটি page fault তোলে, kernel তখন শুধু সেই একটি পাতা কপি করে লেখকের নিজের করে দেয়। একেই বলে copy-on-write (COW). উপরের কোডে child x += 1 লিখতেই x-এর পাতাটি কপি হয়েছে; বাকি পাতাগুলো হয়তো কখনো কপিই হয়নি। এর পেছনের page fault আর virtual memory-র কলকবজা পর্ব ৩৫-এ বিস্তারিত দেখবেন।

দুই ভাইবোন একই পাঠ্যবই থেকে পড়ছে। নতুন বই কেনার দরকার নেই, যতক্ষণ কেউ বইয়ে দাগ না দেয়। যেদিন একজন একটি পাতায় নোট লিখতে চাইবে, শুধু সেই পাতাটির ফটোকপি করে নিজের কাছে রাখবে। বাকি বই দুজনের ভাগেই থাকবে।

৩ · fork গোনার ধাঁধা — কতটি প্রসেস, কতবার ছাপা

বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় fork নিয়ে সবচেয়ে বেশি আসে এই ধাঁধা: “নিচের কোড চালালে কতবার hi ছাপা হবে?” মূল নিয়ম একটাই — fork চলার মুহূর্তে যতগুলো প্রসেস ওই লাইনে পৌঁছায়, প্রত্যেকে একটি করে নতুন প্রসেস বানায়. তাই প্রতিটি fork লাইন প্রসেসের সংখ্যা দ্বিগুণ করে। পরপর তিনটি fork ধাপে ধাপে দেখি। নাম রাখার নিয়ম: k-তম fork-এ প্রতিটি Pi নতুন child বানায় P(i + 2k−1).

1int main(void) {   // P0 starts2    fork();         // fork #13    fork();         // fork #24    fork();         // fork #35    printf("hi\n");6}
গোনা
মোট প্রসেস
এই fork-এ নতুন
fork চালাল কারা
ছাপা লাইন
process treeশুরু
P0
process treefork #1-এর পরে
P0
└─ P1
process treefork #2-এর পরে
P0
├─ P1
│  └─ P3
└─ P2
process treefork #3-এর পরে
P0
├─ P1
│  ├─ P3
│  │  └─ P7
│  └─ P5
├─ P2
│  └─ P6
└─ P4
  1. শুরুতে একটিই প্রসেস। Shell প্রোগ্রামটি চালাল, জন্ম নিল P0. এখনো কোনো fork হয়নি, process tree-তে শুধু P0.
  2. fork #1 — একজন বানায় একজন। লাইন ২-এ পৌঁছেছে শুধু P0, তাই একটি নতুন child: P1. এখন দুটি প্রসেস, আর দুজনেই পরের লাইন (fork #2) থেকে চলবে।
  3. fork #2 — দুজন বানায় দুজন। P0 আর P1 দুজনেই লাইন ৩-এ পৌঁছায়, আর প্রত্যেকে একটি করে child বানায়: P0 → P2, P1 → P3. মোট ৪. খেয়াল করুন, P1 নিজেও এখন parent.
  4. fork #3 — চারজন বানায় চারজন। P0 → P4, P1 → P5, P2 → P6, P3 → P7. মোট ৮টি প্রসেস। P0-এর সরাসরি child তিনটি (P1, P2, P4) — প্রতিটি fork-এ একটি করে; আর সবশেষে জন্মানো P7-এর কোনো child নেই।
  5. আটজনই printf চালায়। প্রতিটি প্রসেস লাইন ৫-এ পৌঁছে একবার “hi” ছাপে — মোট ৮ লাইন। কোন প্রসেস আগে ছাপবে, তা scheduler-এর ওপর নির্ভর করে, কিন্তু সংখ্যাটি নিশ্চিত।
  6. সাধারণ সূত্র। পরপর n টি fork মানে 2nটি প্রসেস, যার মধ্যে নতুন তৈরি হয়েছে 2n − 1টি (এখানে ৮ ও ৭). fork-এর পরে একটি printf থাকলে 2n লাইন। সতর্কতা: এই হিসাব ধরে নেয় printf-এর আগে buffer-এ কিছু জমে নেই; নইলে সংখ্যা বদলে যায় — অংশ ৪ দেখুন।

যখন fork একটি শর্তের ভেতরে — && আর ||

পরীক্ষকেরা ধাঁধা কঠিন করেন C-র short-circuit নিয়ম দিয়ে: A && B-এ A মিথ্যা (0) হলে B চালানোই হয় না; A || B-এ A সত্য (শূন্য নয়) হলে B চালানো হয় না। fork-এর ক্ষেত্রে parent পায় ধনাত্মক PID (সত্য), child পায় 0 (মিথ্যা)। তাই একই এক্সপ্রেশন একেক প্রসেসে একেক পথে চলে। বিখ্যাত উদাহরণ (fork() && fork()) || fork() — তিনটি fork-কে নাম দিই A, B, C. নিচের টেবিলে “+” মানে ধনাত্মক PID, “0” মানে শূন্য:

1int main(void) {2    (fork() && fork()) || fork();3    //  A           B         C4    printf("hi\n");5}
প্রতিটি প্রসেস যা দেখে
P0
P1
P2
P3
P4
মোট প্রসেস
  1. শুরু: শুধু P0. P0 এক্সপ্রেশনটি বাঁ দিক থেকে মূল্যায়ন শুরু করে, প্রথমে fork A. টেবিলের প্রতিটি ঘর দেখাবে কোন প্রসেস কোন fork-এর কী মান পেয়েছে।
  2. A চলল: P0 আর P1. P0 (parent) পায় A = + (সত্য), নতুন child P1 পায় A = 0 (মিথ্যা)। এখন দুটি প্রসেস, দুজনেই একই এক্সপ্রেশনের বাকিটা চালাবে — কিন্তু ভিন্ন পথে।
  3. P0-এর পথ: B চলে, C বাদ। A সত্য বলে &&-এর ডান দিক B চালাতে হয়; P0 বানায় P2. P0 পায় B = +, তাই (A && B) সত্য, আর ||-এর ডান দিক C আর চালাতে হয় না — P0-এর কাজ শেষ (✔). নতুন P2 A-র মান পেয়েছে কপি হিসেবে (+), কিন্তু B = 0, তাই তার কাছে (A && B) মিথ্যা — তাকে C চালাতে হবে। মোট ৩.
  4. P1-এর পথ: B বাদ, C চলে। P1-এর A = 0, তাই && short-circuit করে — B চালানোই হয় না। (A && B) মিথ্যা, তাই C চালাতে হয়: P1 বানায় P3. P1 পায় C = +, P3 পায় C = 0 — দুজনেরই এক্সপ্রেশন এখানেই শেষ (✔). মোট ৪.
  5. P2 চালায় C. P2 বানায় P4. P2 পায় C = +, আর P4-এর মান A = +, B = 0, C = 0. আর কেউ কোনো fork বাকি রাখেনি — সবার পাশে ✔. মোট প্রসেস ৫.
  6. পাঁচজনই printf চালায়: ৫টি hi. শিক্ষা: শর্তের ভেতরে fork থাকলে 2n সূত্র খাটে না। প্রতিটি প্রসেসের জন্য আলাদাভাবে লিখুন সে কোন fork-এ কী মান পেল, আর short-circuit-এর কারণে কোন fork সে আদৌ চালাল না।

এই উত্তর আমরা নিজেরা চালিয়েও যাচাই করেছি: এক্সপ্রেশনের পর printf("hi\n") আর fflush দিয়ে Linux-এ চালালে ঠিক ৫ লাইন আসে। (gcc -Wall-এ বন্ধনী ছাড়া fork() && fork() || fork() লিখলে “suggest parentheses” warning দেয়; অর্থ একই, কারণ &&-এর অগ্রাধিকার ||-এর চেয়ে বেশি।)

ধাঁধা সমাধানের পদ্ধতি

(১) প্রতিটি fork লাইনে পৌঁছানো প্রসেসের সংখ্যা গুনুন — সেই সংখ্যা দ্বিগুণ হবে। (২) লুপের ভেতরে fork থাকলে প্রতিটি পুনরাবৃত্তি আলাদা লাইন হিসেবে ধরুন; child লুপের বাকি পুনরাবৃত্তিগুলো চালায়, শুরু থেকে নয়। (৩) শর্ত বা &&/|| থাকলে প্রতিটি প্রসেসের ফেরত মান লিখে পথ ধরে এগোন। (৪) printf-এ newline আছে কি না, আর stdout টার্মিনাল না pipe — দেখে নিন; buffer কপি হলে লাইন বাড়ে। (৫) সন্দেহ থাকলে আসল মেশিনে চালিয়ে | wc -l দিয়ে গুনুন।

৪ · fork আর stdio buffer — printf দুবার আসে কেন

পর্ব ৫-এ দেখেছেন, printf সরাসরি kernel-এ লেখে না; লেখা জমায় user-space-এর একটি buffer-এ (buffering). stdout টার্মিনাল হলে প্রতিটি newline-এ buffer খালি হয়, কিন্তু pipe বা ফাইল হলে buffer ভরা পর্যন্ত বা প্রোগ্রাম শেষ হওয়া পর্যন্ত লেখা বসে থাকে। এখন fork-এর তালিকাটি আবার দেখুন: child পায় parent-এর পুরো মেমরির কপি। stdio buffer-ও মেমরি। তাই fork-এর আগে buffer-এ যা বসে ছিল, তা দুই প্রসেসেই থাকে, আর দুজনে শেষে নিজের নিজের কপি flush করলে লেখাটি দুবার আসে।

নিচের প্রোগ্রাম তিনভাবে একই কাজ করে: (০) flush ছাড়া fork, child শেষ হয় exit() দিয়ে; (১) fork-এর আগে fflush; (২) flush ছাড়া fork, কিন্তু child শেষ হয় _exit() দিয়ে। এই পাতার রানারে stdout একটি pipe — প্রথম লাইনেই তা দেখা যাবে।

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

/* mode 0: no fflush, child calls exit()
   mode 1: fflush before fork, child calls exit()
   mode 2: no fflush, child calls _exit()          */
static void demo(int mode) {
    printf("hello from mode %d\n", mode);   /* sits in the stdio buffer */
    if (mode == 1) fflush(stdout);
    pid_t pid = fork();
    if (pid == 0) {
        if (mode == 2) _exit(0);   /* no stdio flush at all */
        exit(0);                   /* flushes the child's copy */
    }
    waitpid(pid, NULL, 0);
    fflush(stdout);                /* parent flushes its own copy */
}

int main(void) {
    printf("stdout is a terminal? %s\n", isatty(STDOUT_FILENO) ? "yes" : "no");
    fflush(stdout);
    demo(0);
    demo(1);
    demo(2);
    return 0;
}

একটি আসল রানের আউটপুট (stdout একটি pipe):

stdout is a terminal? no
hello from mode 0
hello from mode 0
hello from mode 1
hello from mode 2

কোডে প্রতিটি printf একবারই আছে, তবু mode 0-এর লাইন দুবার এসেছে। প্রথম লাইন বলছে stdout টার্মিনাল নয়, তাই printf fully buffered: “hello from mode 0” fork-এর মুহূর্তে buffer-এ বসে ছিল, fork সেই buffer-সহ মেমরি কপি করল, তারপর child-এর exit(0) নিজের কপি flush করল, আর parent-এর fflush নিজেরটা। Mode 1-এ fork-এর আগেই buffer খালি, তাই কপি হওয়ার মতো কিছু ছিল না — লাইন একবার। Mode 2-এ buffer কপি হয়েছিল ঠিকই, কিন্তু child _exit(0) দিয়ে stdio-কে পাশ কাটিয়ে সরাসরি kernel-এ গিয়ে শেষ হয়েছে; তার কপি কখনো লেখা হয়নি, তাই এখানেও একবার। একই প্রোগ্রাম নিজের টার্মিনালে চালালে প্রথম লাইনে yes আসত, আর mode 0-ও একবার ছাপত — কারণ newline-এ buffer আগেই খালি হয়ে যেত। এই কারণেই “আমার মেশিনে ঠিক আছে, কিন্তু লগ ফাইলে লাইন দুবার” ধরনের বাগ এত পরিচিত।

exit() বনাম _exit()

দুটোই প্রসেস শেষ করে, কিন্তু স্তর আলাদা। exit() C library-র ফাংশন: আগে atexit() দিয়ে নিবন্ধিত ফাংশনগুলো উল্টো ক্রমে চালায়, সব stdio stream flush ও বন্ধ করে, তারপর ভেতরে _exit() ডাকে। _exit() (আর C99-এর _Exit()) সরাসরি kernel-এর কাছে যায় — Linux-এ exit_group system call. Kernel তখন সব fd বন্ধ করে, মেমরি ফেরত নেয়, parent-কে খবর দেয়। main থেকে return n করা মানে exit(n) ডাকা।

কাজexit(n) / return n_exit(n)
atexit handler চালায়হ্যাঁনা
stdio buffer flush করেহ্যাঁনা — buffer-এর লেখা হারায়
fd বন্ধ, মেমরি ফেরত, parent-কে exit statusহ্যাঁ (শেষে _exit-এর মাধ্যমে)হ্যাঁ
কোথায় ব্যবহারসাধারণ প্রোগ্রাম শেষ করতেfork-এর পর child-এ, বিশেষ করে exec ব্যর্থ হলে

নিয়ম দুটি মনে রাখুন। (১) fork-এর আগে fflush(stdout) (বা fflush(NULL), যা সব output stream flush করে)। (২) fork-এর পর child যদি exec না করে বা exec ব্যর্থ হয়, তবে child-এ _exit() — নইলে parent-এর কাছ থেকে পাওয়া buffer আর atexit handler (যেমন একটি অস্থায়ী ফাইল মুছে ফেলার কাজ) দুবার চলতে পারে।

৫ · exec পরিবার — একই প্রসেসে নতুন প্রোগ্রাম

fork দিয়ে পাওয়া কপি এখনো parent-এর প্রোগ্রামই চালাচ্ছে। অন্য প্রোগ্রাম চালাতে লাগে exec. Kernel-এ এর মূল system call একটিই — execve(path, argv, envp) (Linux-এ তার একটি নতুন রূপ execveat-ও আছে)। বাকিগুলো C library-র সুবিধাজনক রূপ, যাদের নামের শেষের অক্ষরগুলোই বলে দেয় কী কাজ করে:

অক্ষরঅর্থউদাহরণ
l (list)argument আলাদা আলাদা করে, শেষে (char *)NULLexecl("/bin/ls", "ls", "-l", (char *)NULL)
v (vector)argument একটি অ্যারেতে, শেষ ঘর NULLexecv("/bin/ls", argv)
p (path)শুধু নাম দিলেই হয়; PATH-এর directory-গুলোতে খোঁজেexecvp("ls", argv), execlp(…)
e (environment)নতুন environment নিজে দেওয়া হয়; নইলে বর্তমানটিই যায়execve(path, argv, envp), execle(…)

খেয়াল করুন, argv[0] সাধারণত প্রোগ্রামের নাম — রীতি অনুযায়ী। exec-এর প্রথম argument (যেমন "/bin/ls") লাগে প্রোগ্রামের ফাইলটি খুঁজে পেতে, আর argv[0]-এর "ls" লাগে চালু হওয়া প্রোগ্রামের নিজের নাম জানতে; দুটো আলাদা জিনিস।

exec-এর পরে কী থাকে, কী যায়

✅ থেকে যায়

PID আর PPID; খোলা file descriptor (যাদের close-on-exec চিহ্ন নেই); current directory, umask, user/group ID; signal mask আর pending signal; ignore করা signal ignore-ই থাকে; resource limit.

“ফাইল নম্বর আর চাবি”

🗑️ বদলে যায়

পুরো কোড, global, heap, stack, mmap — নতুন প্রোগ্রামের; handler বসানো signal ডিফল্টে ফেরে; atexit handler মুছে যায়; অন্য সব থ্রেড শেষ; stdio buffer-এ জমা লেখা হারায়।

“ভেতরের কাগজপত্র”

exec সফল হলে কখনো ফেরে না — যে কোডে ফিরবে, সেটিই তো আর নেই। তাই exec-এর পরের লাইন চলে শুধু ব্যর্থ হলে, আর তখন সেখানে ত্রুটি ছাপিয়ে _exit করাই রীতি। অন্যদিকে fd থেকে যাওয়া একটি দারুণ সুবিধা (shell-এর redirection এভাবেই কাজ করে), কিন্তু নিরাপত্তার ঝুঁকিও: একটি সার্ভার যদি একটি গোপন ফাইল খুলে রেখে কোনো সাহায্যকারী প্রোগ্রাম exec করে, প্রোগ্রামটি সেই fd পেয়ে যায়। সমাধান হলো fd-তে FD_CLOEXEC চিহ্ন দেওয়া — open(path, O_RDONLY | O_CLOEXEC) বা fcntl(fd, F_SETFD, FD_CLOEXEC); তখন exec-এর মুহূর্তে kernel নিজেই fd-টি বন্ধ করে দেয়। এজন্য আধুনিক সফটওয়্যার শুরু থেকেই close-on-exec ব্যবহার করে — যেমন glibc নিজের ভেতরে যে ফাইলগুলো খোলে, আর Python ৩.৪ থেকে নিজের খোলা সব fd.

fork + exec + waitpid — একটি ছোট shell-এর হৃদয়

নিচের run() ফাংশনটি আসলে একটি shell-এর মূল কাজ: fork, child-এ execvp, parent-এ waitpid, তারপর ফলাফল পড়া। চারটি পরিস্থিতি চালানো হয়েছে — সফল কমান্ড, নিজে ব্যর্থ হওয়া কমান্ড, না-থাকা প্রোগ্রাম, আর signal-এ মারা যাওয়া প্রোগ্রাম।

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

static void run(char *argv[]) {
    fflush(stdout);
    pid_t pid = fork();
    if (pid == 0) {                       /* child: become another program */
        execvp(argv[0], argv);
        perror("execvp");                 /* reached ONLY if exec failed */
        _exit(127);
    }
    int st;
    waitpid(pid, &st, 0);                 /* parent: wait and decode */
    if (WIFEXITED(st))
        printf("   -> %s: exited, status %d\n", argv[0], WEXITSTATUS(st));
    else if (WIFSIGNALED(st))
        printf("   -> %s: killed by signal %d\n", argv[0], WTERMSIG(st));
}

int main(void) {
    dup2(STDOUT_FILENO, STDERR_FILENO);   /* like 2>&1 */
    char *a[] = {"ls", "-d", "/etc", "/proc", NULL};
    char *b[] = {"ls", "/no/such/dir", NULL};
    char *c[] = {"no-such-program", NULL};
    char *d[] = {"sh", "-c", "kill -TERM $$", NULL};
    run(a);
    run(b);
    run(c);
    run(d);
    return 0;
}

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

/etc
/proc
   -> ls: exited, status 0
ls: cannot access '/no/such/dir': No such file or directory
   -> ls: exited, status 2
execvp: No such file or directory
   -> no-such-program: exited, status 127
   -> sh: killed by signal 15

প্রথম দুই লাইন লিখেছে ls নিজে — child প্রসেসটি exec-এর পর আর আমাদের প্রোগ্রাম নেই, সে এখন ls; তবু তার fd 1 আমাদের stdout-ই, কারণ exec fd রেখে দেয়। ls সফল, status 0. দ্বিতীয়বার ls নিজেই ত্রুটি দেখিয়ে status 2 দিয়েছে (GNU ls-এ 2 মানে “গুরুতর সমস্যা”, যেমন argument-এর ফাইল নেই)। তৃতীয়বার exec নিজেই ব্যর্থ: PATH-এ এমন প্রোগ্রাম নেই, তাই child আমাদের কোডেই রয়ে গেছে, perror ছেপেছে আর _exit(127) করেছে — shell-ও “command not found”-এ ঠিক 127 দেয়। শেষবার sh নিজেকে SIGTERM (signal 15) পাঠিয়েছে; সে স্বাভাবিকভাবে শেষ হয়নি, তাই WIFEXITED মিথ্যা আর WIFSIGNALED সত্য। শুরুর dup2 stderr-কে stdout-এ জুড়ে দিয়েছে, তাই ত্রুটির বার্তাগুলোও এখানে দেখা যাচ্ছে — আর সেটিও child-রা fork ও exec পেরিয়ে উত্তরাধিকার হিসেবে পেয়েছে।

এবার আপনার shell যখন ls চালায়, তখন সময়ের সাথে দুই প্রসেসে কী ঘটে, ধাপে ধাপে দেখি। PID-গুলো উদাহরণ মাত্র।

1line = read_line();       // "ls"2pid = fork();3if (pid == 0) {           // child4    execvp("ls", argv);5    _exit(127);6}7waitpid(pid, &st, 0);    // parent8last = WEXITSTATUS(st);   // $?9print_prompt();
দুই প্রসেসের অবস্থা
4100 (bash)
4101-এর প্রোগ্রাম
4101-এর অবস্থা
$?
  1. Shell কমান্ড পড়ে। আপনি “ls” লিখে Enter চাপলেন। bash (PID 4100) লাইনটি পড়ে শব্দে ভাগ করল। এখনো নতুন কোনো প্রসেস নেই; $? আগের কমান্ডের ফল ধরে রেখেছে।
  2. fork — bash-এর একটি কপি। জন্ম নিল PID 4101, যার ভেতরে এখনো bash-এর কোড। দুটোই চলছে; 4100 fork থেকে পেয়েছে 4101, আর 4101 পেয়েছে 0. Redirection থাকলে (ls > f) child ঠিক এই ফাঁকে fd 1 বদলাত।
  3. Parent অপেক্ষায় বসে। 4100 waitpid ডেকে ঘুমিয়ে পড়ল (blocked) — CPU এখন অন্য কাউকে দেওয়া যায় (পর্ব ৯-এর context switch). এই কারণেই সামনে-চলা কমান্ড শেষ না হওয়া পর্যন্ত prompt আসে না।
  4. exec — একই PID-এ ls. 4101 execvp("ls", …) চালাল। Library-র execvp PATH ধরে /usr/bin/ls খুঁজে পেয়ে execve ডাকল, আর kernel পুরোনো bash-কোড ফেলে ls লোড করল। PID বদলায়নি — 4101-ই এখন ls. Shell-এর _exit(127) লাইনটি আর কখনো চলবে না।
  5. ls কাজ করে। ls directory পড়ে নাম লেখে তার fd 1-এ — যা bash থেকে উত্তরাধিকারসূত্রে পাওয়া সেই একই টার্মিনাল। স্ক্রিনে এল ফাইলের তালিকা। bash এখনো ঘুমিয়ে।
  6. ls শেষ: zombie আর SIGCHLD. ls exit(0) করল। Kernel তার মেমরি আর fd ফেরত নিল, কিন্তু PID আর exit status 0 রেখে দিল — 4101 এখন zombie. সাথে সাথে parent-কে SIGCHLD signal পাঠিয়ে আর waitpid-এ ঘুমানো 4100-কে জাগিয়ে দিল।
  7. waitpid ফেরে — reaping. 4100-এর waitpid ফিরল 4101 আর status নিয়ে; WEXITSTATUS দিল 0, আর shell সেটি $?-এ রাখল। Status নেওয়ার মুহূর্তেই kernel 4101-এর শেষ চিহ্ন মুছে দিল — একে বলে reaping. PID 4101 এখন আবার ব্যবহারযোগ্য।
  8. নতুন prompt. bash আবার “$ ” দেখাল, পরের কমান্ডের অপেক্ষায়। প্রতিটি সাধারণ কমান্ডে এই পুরো চক্র ঘটে — দিনে হাজারবার, প্রতিবার মিলিসেকেন্ডের ভগ্নাংশে। Job control আর background কমান্ড (&) পর্ব ১১-এ।

৬ · wait, waitpid আর exit status — প্রসেস কীভাবে শেষ হলো

wait(&st) যেকোনো একটি child শেষ হওয়া পর্যন্ত অপেক্ষা করে। waitpid(pid, &st, options) আরও নিয়ন্ত্রণ দেয়: pid > 0 হলে ঠিক সেই child; −1 হলে যেকোনো child (wait-এর মতো); 0 হলে নিজের process group-এর যেকোনো child. আর options-এ WNOHANG দিলে অপেক্ষা করে না — কোনো child শেষ না হয়ে থাকলে সঙ্গে সঙ্গে 0 ফেরত দেয়; সার্ভার আর event loop এভাবে কাজ থামিয়ে না রেখে মৃত child-দের তুলে নেয়। কোনো child-ই না থাকলে ফেরত −1, errno = ECHILD.

st-এ kernel একটি পূর্ণসংখ্যায় কয়েকটি তথ্য গুঁজে দেয়। এর বিট-বিন্যাস OS-ভেদে আলাদা হতে পারে, তাই সরাসরি বিট না পড়ে POSIX-এর macro ব্যবহার করতে হয়:

Macroপ্রশ্ন বা উত্তরশুধু তখনই অর্থবহ যখন
WIFEXITED(st)স্বাভাবিকভাবে শেষ হয়েছে? (exit, _exit বা main থেকে return)সবসময়
WEXITSTATUS(st)exit-এ দেওয়া সংখ্যার নিচের ৮ বিট (০–২৫৫)WIFEXITED সত্য
WIFSIGNALED(st)কোনো signal-এ মারা গেছে?সবসময়
WTERMSIG(st)কোন signal (যেমন 9 = SIGKILL, 11 = SIGSEGV, 15 = SIGTERM)WIFSIGNALED সত্য
WIFSTOPPED(st) / WSTOPSIG(st)থেমে আছে কি, কোন signal-এ (যেমন Ctrl+Z-এর SIGTSTP)WUNTRACED দিয়ে waitpid
WCOREDUMP(st)core dump ফাইল লেখা হয়েছে? (পুরোনো POSIX সংস্করণে ছিল না, তবে Linux ও BSD-তে বহুদিন ধরে আছে)WIFSIGNALED সত্য

দুটি বাস্তব কথা। প্রথমত, exit-এর সংখ্যার শুধু নিচের ৮ বিট যায় — exit(256) parent-এর কাছে 0 হয়ে পৌঁছায়, অর্থাৎ “সফল”! তাই ত্রুটির কোড ১ থেকে ১২৫-এর মধ্যে রাখাই নিরাপদ। দ্বিতীয়ত, shell signal-এ মরা প্রসেসের $? দেখায় ১২৮ + signal নম্বর — যেমন Ctrl+C (SIGINT, 2) চাপলে echo $? দেখায় 130.

প্রসেস শেষ হওয়ার কারণগুলো

  • স্বাভাবিক, নিজের ইচ্ছায়: main থেকে return, exit(), _exit(); বা multi-thread প্রোগ্রামে শেষ থ্রেডটিও শেষ হলে। কাজ সফল হোক বা ব্যর্থ — exit status বলে দেয়।
  • নিজের ভুলে, signal-এ: অবৈধ মেমরি ছোঁয়া (SIGSEGV), শূন্য দিয়ে পূর্ণসংখ্যা ভাগ (SIGFPE), abort() (SIGABRT, প্রায়ই assert ব্যর্থ হলে)। Kernel বা library signal পাঠায়, ডিফল্ট কাজ হলো প্রসেস শেষ করা।
  • অন্যের হাতে: অন্য প্রসেস kill() দিয়ে SIGTERM বা SIGKILL পাঠালে (অনুমতি থাকলে), টার্মিনাল বন্ধ হলে SIGHUP, Ctrl+C-তে SIGINT. মেমরি একেবারে ফুরিয়ে গেলে Linux-এর OOM killer একটি প্রসেস বেছে SIGKILL পাঠায় (পর্ব ৩৮)।
  • সীমা পেরোলে: CPU সময়ের সীমা (RLIMIT_CPU) পেরোলে SIGXCPU, ফাইলের আকারের সীমায় SIGXFSZ.

Signal কীভাবে পাঠানো, ধরা বা উপেক্ষা করা যায়, তা পর্ব ১৩-এর বিষয়। এখানে শুধু মনে রাখুন: কারণ যা-ই হোক, kernel প্রতিবার parent-এর জন্য একটি status রেখে যায় — আর সেটি না নেওয়া পর্যন্ত একটি ছোট দাগ রয়ে যায়।

৭ · Zombie আর orphan — শেষ হয়েও শেষ নয়

Child শেষ হলে kernel তার প্রায় সবকিছু ফেরত নেয় — মেমরি, খোলা ফাইল, থ্রেড। কিন্তু একটি ছোট রেকর্ড রেখে দেয়: PID, exit status আর CPU সময়ের হিসাব, যাতে parent পরে জানতে পারে কী হয়েছিল। যতক্ষণ parent wait করে status না নেয়, প্রসেসটি এই আধা-মৃত অবস্থায় থাকে — একেই বলে zombie (ps-এ অবস্থা Z, নামের পাশে <defunct>). আর যে child-এর parent আগেই মারা গেছে, সে orphan.

হাসপাতালের ভর্তি-রেজিস্টারের কথা ভাবুন। রোগী ছাড়া পেয়ে বাড়ি চলে গেছেন (প্রসেস শেষ), বেডও খালি (মেমরি ফেরত)। কিন্তু রেজিস্টারে তাঁর নাম কাটা হবে কেবল যখন অভিভাবক এসে ছাড়পত্রে সই করে রিপোর্ট নিয়ে যাবেন (wait). অভিভাবক না এলে নামটি খাতায় ঝুলে থাকে — zombie. আর যদি অভিভাবক নিজেই আর না থাকেন, হাসপাতালের প্রশাসন (init) দায়িত্ব নিয়ে সই করে দেয়।

1pid = fork();                   // P=200, C=2012if (pid == 0) { work(); exit(7); }3do_something_else();            // parent busy4waitpid(pid, &st, 0);          // reaps 2015c2 = fork();                    // C2=2026exit(0);                        // P exits first!7// in 202: work(); exit(0);
Kernel-এর খাতায়
201-এর অবস্থা
201-এর রাখা status
202-এর অবস্থা
202-এর parent
200 (P)
  1. জন্ম। Parent P (PID 200, উদাহরণ) fork করে child C (201) বানাল। C নিজের কাজ করছে, অবস্থা R (running). এখন পর্যন্ত সবকিছু স্বাভাবিক।
  2. Child শেষ: zombie. C exit(7) করল। Kernel তার মেমরি আর fd ফেরত নিল, কিন্তু PID 201, status 7 আর সময়ের হিসাব রেখে দিল। C এখন zombie — চলছেও না, পুরো মুছেও যায়নি। P-কে SIGCHLD পাঠানো হলো; P সেটি ধরার ব্যবস্থা না করলে ডিফল্টে কিছুই ঘটে না।
  3. Parent ব্যস্ত — zombie ঝুলে থাকে। P অন্য কাজে ব্যস্ত, wait ডাকছে না। Zombie CPU বা উল্লেখযোগ্য মেমরি খায় না, কিন্তু একটি PID আর kernel-এর একটি ছোট কাঠামো আটকে রাখে। ps-এ দেখায় Z আর <defunct>. হাজার হাজার জমলে নতুন প্রসেসের জন্য PID ফুরিয়ে যেতে পারে।
  4. Reaping. P অবশেষে waitpid ডাকল, পেল status 7. সেই মুহূর্তেই kernel 201-এর রেকর্ড পুরো মুছে দিল — zombie আর নেই। মনে রাখুন: kill -9 দিয়ে zombie মারা যায় না, কারণ সে আগেই মৃত; পারে শুধু তার parent-এর wait, অথবা parent-এর মৃত্যু।
  5. Orphan আর দত্তক। P আরেকটি child 202 বানিয়ে wait না করেই নিজে exit(0) করল। 202 এখন orphan. Kernel সঙ্গে সঙ্গে তাকে নতুন parent দেয় — সাধারণত PID 1 (init/systemd); Linux-এ কোনো পূর্বপুরুষ নিজেকে subreaper ঘোষণা করে থাকলে সে। ps-এ 202-এর PPID এখন 1.
  6. init তুলে নেয়। 202 কাজ শেষে exit করতেই অল্প মুহূর্তের জন্য zombie হলো, কিন্তু init-এর কাজই হলো নিয়মিত wait করে দত্তক নেওয়া child-দের তুলে নেওয়া। তাই সে প্রায় সঙ্গে সঙ্গে মুছে গেল। শিক্ষা: orphan কোনো সমস্যা নয়; সমস্যা হলো বেঁচে থাকা কিন্তু wait-না-করা parent.

এবার আসল মেশিনে একটি zombie বানিয়ে দেখি। কৌশলটি হলো এমন একটি parent তৈরি করা যে কখনো wait করবে না: একটি subshell প্রথমে ছোট একটি child (sleep 0.1) চালায়, তারপর exec দিয়ে নিজেই sleep 0.6-এ পরিণত হয়। sleep প্রোগ্রাম wait করতে জানে না, তাই child শেষ হলে zombie হয়ে থাকে:

# A parent that never calls wait():
# the subshell starts a short child, then exec's into "sleep 0.6",
# so the new parent (sleep 0.6) never reaps that child.
( sleep 0.1 & exec sleep 0.6 ) &
outer=$!
sleep 0.3
echo "== 0.3 s later: the child has exited, nobody has reaped it =="
ps -o pid,ppid,stat,args -p "$outer" --ppid "$outer"
echo
wait "$outer"          # the shell reaps "sleep 0.6" (its own child)
echo "== after its parent exits: adopted and reaped =="
ps -e -o pid,ppid,stat,args | awk 'NR==1 || $3 ~ /^Z/'
echo "zombies left: $(ps -e -o stat= | grep -c '^Z')"
echo "PID 1 here is: $(ps -o comm= -p 1)"

একটি আসল রানের আউটপুট (PID-গুলো প্রতিবার আলাদা হতে পারে):

== 0.3 s later: the child has exited, nobody has reaped it ==
    PID    PPID STAT COMMAND
      3       2 SN   sleep 0.6
      4       3 ZN   [sleep] <defunct>

== after its parent exits: adopted and reaped ==
    PID    PPID STAT COMMAND
zombies left: 0
PID 1 here is: podman-init

প্রথম তালিকায় দেখুন: PID 3 হলো সেই subshell, যে exec করে এখন sleep 0.6 (অবস্থা S, ঘুমিয়ে)। তার child PID 4-এর sleep 0.1 অনেক আগেই শেষ, কিন্তু অবস্থা Z আর নাম [sleep] <defunct> — একটি zombie, কারণ তার parent (sleep 0.6) জানেই না যে wait করতে হয়। (N মানে প্রসেসটি কম priority-তে, অর্থাৎ বাড়তি nice মানে চলছে; এই রানারের নিজস্ব সেটিং, zombie-র সাথে সম্পর্ক নেই।) এরপর sleep 0.6 শেষ হলো, আর আমাদের shell wait দিয়ে তাকে তুলে নিল। তখন zombie 4 হয়ে গেল orphan; kernel তাকে দিল PID 1-এর হাতে, যা এই container-এ podman-init — container-এর জন্য বানানো একটি ছোট init, যার মূল কাজ ঠিক এটাই: দত্তক নেওয়া প্রসেস তুলে নেওয়া। তাই দ্বিতীয় তালিকা ফাঁকা, আর zombie-র সংখ্যা 0.

Zombie ঠেকানোর উপায়

  • প্রতিটি child-এর জন্য wait: সবচেয়ে সরল। অনেক child থাকলে SIGCHLD handler-এ while (waitpid(-1, &st, WNOHANG) > 0) ; — লুপ দরকার, কারণ একসাথে কয়েকটি child মরলে signal একবারই আসতে পারে।
  • SIGCHLD উপেক্ষা: POSIX অনুযায়ী parent SIGCHLD-কে স্পষ্টভাবে SIG_IGN করলে (বা SA_NOCLDWAIT) kernel child-দের zombie না বানিয়েই মুছে দেয় — তবে তখন exit status আর জানা যায় না।
  • Double fork: পুরোনো daemon-এর কৌশল — child আরেকটি child বানিয়ে নিজে সঙ্গে সঙ্গে শেষ হয়; parent সেই মাঝের child-কে wait করে, আর নাতি orphan হয়ে init-এর দায়িত্বে চলে যায়।
  • Subreaper: Linux 3.4 থেকে একটি প্রসেস prctl(PR_SET_CHILD_SUBREAPER, 1) দিয়ে নিজেকে “ছোট init” ঘোষণা করতে পারে; তার বংশের orphan-রা PID 1-এর বদলে তার কাছে আসে। systemd-এর user সেশন আর অনেক container runtime এটি ব্যবহার করে।

Container-এ এটি বাস্তব সমস্যা। Docker container-এ আপনার অ্যাপ নিজেই প্রায়ই PID 1 হয় (container-এর নিজস্ব PID namespace-এ, পর্ব ৫১)। অ্যাপটি যদি init-এর মতো wait করতে না জানে, তার বংশের orphan-রা জমতে জমতে zombie হয়ে থাকে। এজন্যই docker run --init (ভেতরে tini নামের ছোট init) বা podman-এর মতো runtime-এর নিজস্ব init ব্যবহার করা হয়। Windows-এ zombie-র ধারণা এভাবে নেই, তবে কাছাকাছি একটি জিনিস আছে: প্রসেস শেষ হলেও যতক্ষণ কারও হাতে তার handle খোলা থাকে, kernel-এর process object আর exit code রয়ে যায়; সব handle বন্ধ হলে তবেই মুছে যায়।

৮ · fork-এর বিকল্প — vfork, posix_spawn, clone আর Windows-এর CreateProcess

fork + exec সুন্দর নকশা, কিন্তু অপচয়ও আছে: যে কপি পরের মুহূর্তেই exec দিয়ে ফেলে দেওয়া হবে, তার জন্য page table কপি করা, সব পাতা read-only চিহ্নিত করা — বড় প্রসেসে এটিই মিলিসেকেন্ড খরচ করে। তাই কয়েকটি বিকল্প আছে।

vfork() ১৯৭০-এর দশকের শেষে BSD-তে আসে, যখন copy-on-write তখনো ছিল না আর fork মানে সত্যিই পুরো মেমরি কপি। vfork-এ child parent-এর মেমরিই সরাসরি ব্যবহার করে (কোনো কপি নয়), আর child exec বা _exit না করা পর্যন্ত parent থেমে থাকে। নিয়ম কঠোর: child শুধু exec বা _exit ডাকতে পারে; একটি ভ্যারিয়েবল বদলালে বা ফাংশন থেকে return করলে parent-এর মেমরি নষ্ট হয়। POSIX ২০০৮ সংস্করণে vfork বাদ দিয়েছে; নতুন কোডে এর বদলে posix_spawn ব্যবহার করুন।

posix_spawn() হলো POSIX-এর “একটি call-এ fork + exec” (মানদণ্ডে আসে ২০০১-এ)। fd বন্ধ, dup2, signal mask-এর মতো fork-আর-exec-এর-মাঝের কাজগুলো আগে থেকে একটি তালিকায় (posix_spawn_file_actions_t, posix_spawnattr_t) বলে দিতে হয়। লাভ হলো, library নিজের মতো সবচেয়ে দ্রুত পথ বেছে নিতে পারে — Linux-এর glibc সাম্প্রতিক সংস্করণে (২.২৪ থেকে) এটি clone(CLONE_VM | CLONE_VFORK) দিয়ে করে, ফলে page table কপিই করতে হয় না। macOS-এ posix_spawn kernel-এর নিজস্ব system call. বড় প্রোগ্রাম (যেমন অনেক GB মেমরির সার্ভার) থেকে ছোট কমান্ড চালাতে এটিই পছন্দের পথ।

Linux-এ প্রসেস আর থ্রেডের মধ্যে kernel-এর চোখে মূলগত পার্থক্য নেই — দুটোই একটি “task”. পার্থক্য শুধু নতুন task parent-এর কোন কোন জিনিস ভাগ করবে আর কোনগুলোর কপি নেবে। এটি ঠিক করে clone()-এর flag: CLONE_VM (একই মেমরি), CLONE_FILES (একই fd টেবিল), CLONE_FS (একই current directory), CLONE_SIGHAND (একই signal handler), CLONE_THREAD (একই thread group, অর্থাৎ একই PID দেখাবে)। কোনো ভাগ-flag না দিলে পাওয়া যায় fork; সবগুলো দিলে পাওয়া যায় একটি থ্রেড — glibc-র pthread_create ঠিক এভাবেই clone ডাকে। এমনকি glibc-র fork()-ও আজ ভেতরে clone system call দিয়েই তৈরি। থ্রেডের বিস্তারিত পর্ব ১৪ আর ১৫-এ, আর namespace-flag (container) পর্ব ৫১-এ।

Linux kernel-এর ভেতরে fork, vfork, clone আর নতুন clone3 — সবাই শেষে একই ফাংশনে পৌঁছায়। সরলীকৃত রূপে:

kernel_clone(args)                 // fork, vfork, clone, clone3 all end here
    p = copy_process(args)
        dup_task_struct()          // new task_struct (the PCB) + kernel stack
        copy_files()               // CLONE_FILES ? share : copy the fd table
        copy_fs(), copy_sighand()  // share or copy cwd, signal handlers
        copy_mm()                  // CLONE_VM ? share mm : dup_mmap() + mark COW
        alloc_pid()                // a fresh PID
    wake_up_new_task(p)            // child goes onto a run queue
    if (CLONE_VFORK) wait_for_vfork_done()
    return pid of p                // the parent's return value
// the child returns to user mode with rax = 0

Windows-এর পথ: CreateProcess

Windows (NT পরিবার) fork-exec মডেল নেয়নি। সেখানে একটি call — CreateProcess — নতুন প্রসেস তৈরি করে, প্রোগ্রাম লোড করে, প্রথম থ্রেড চালু করে, সব একসাথে। Child কী কী উত্তরাধিকার পাবে (handle, environment, working directory, window), তা প্রায় দশটি parameter আর STARTUPINFO কাঠামো দিয়ে বলে দিতে হয়। নিচের কোড শুধু Windows-এ compile হয়, এই পাতায় চালানো যাবে না:

#include <windows.h>
#include <stdio.h>

int main(void) {
    STARTUPINFOW si = { sizeof si };
    PROCESS_INFORMATION pi;
    wchar_t cmd[] = L"cmd.exe /c dir";        /* must be writable */
    if (!CreateProcessW(NULL, cmd, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi)) {
        printf("CreateProcess failed: %lu\n", GetLastError());
        return 1;
    }
    WaitForSingleObject(pi.hProcess, INFINITE);  /* like waitpid */
    DWORD code;
    GetExitCodeProcess(pi.hProcess, &code);      /* like WEXITSTATUS */
    printf("exit code = %lu\n", code);
    CloseHandle(pi.hThread);                     /* release the process object */
    CloseHandle(pi.hProcess);
    return 0;
}
বিষয়POSIX (Linux, macOS, BSD)Windows (Win32)
তৈরিfork() + exec…(), বা posix_spawn()CreateProcess() — একটি call
নিজের কপি বানানোfork-এর স্বাভাবিক কাজসাধারণ API-তে নেই
অপেক্ষাwait(), waitpid()WaitForSingleObject(hProcess, …)
ফলাফলWEXITSTATUS (৮ বিট) বা signalGetExitCodeProcess (৩২ বিট)
পরিচয়PID সংখ্যা; parent-child বৃক্ষ গুরুত্বপূর্ণPID-এর সাথে handle; parent-child সম্পর্ক দুর্বল, orphan-দত্তক ধারণা নেই
শেষ চিহ্ন মোছাparent-এর wait (নইলে zombie)সব handle বন্ধ হলে

আরও গভীরে: Linux-এ প্রতিটি task-এর PCB হলো struct task_struct; fork-এর সময় dup_mmap() parent-এর প্রতিটি লেখার-যোগ্য private পাতার page table entry-কে দুই দিকেই read-only করে দেয়, আর পরে লেখার page fault-এ do_wp_page() কপি তৈরি করে — তাই বড় প্রসেসের fork-এর খরচ মেমরির আকারের সাথে (page table-এর আকারের অনুপাতে) বাড়ে, যদিও ডেটা কপি হয় না। ২০১৯ সালে HotOS কনফারেন্সে Microsoft Research, Boston University ও ETH Zürich-এর গবেষকদের প্রবন্ধ “A fork() in the road” যুক্তি দেয় যে fork আধুনিক multi-thread প্রোগ্রাম, নিরাপত্তা আর কর্মক্ষমতার জন্য একটি পুরোনো বোঝা, আর নতুন কোডে posix_spawn-ধাঁচের API-ই ভালো। Windows-এর ভেতরে আসলে fork-এর মতো একটি ক্ষমতা আছে (NtCreateProcessEx-এ parent-এর মেমরি থেকে প্রসেস তৈরি), যা Windows-এর পুরোনো POSIX subsystem-এর মতো উপাদানের জন্য রাখা হয়েছিল, কিন্তু সাধারণ Win32 API-তে প্রকাশিত নয়।

৯ · AI-এর যুগে fork, exec আর wait

“C-তে একটি mini shell লিখে দিন” — AI কয়েক সেকেন্ডে কোড দেবে, আর একটি-দুটি কমান্ডে তা চলবেও। প্রসেস API-র বাগ লুকিয়ে থাকে কিনারায়: বেশি কমান্ড চালালে zombie জমে, আউটপুট ফাইলে redirect করলে লাইন দুবার আসে, exec ব্যর্থ হলে দুটো shell একসাথে চলে। এই পর্বের জ্ঞান দিয়েই এগুলো ধরা যায়।

AI-এর ভুলফলকীভাবে ধরবেন
exec-এর পরে perror + _exit নেইexec ব্যর্থ হলে child parent-এর কোড চালিয়ে যায় — দুটো shell, দুবার কাজনা-থাকা প্রোগ্রামের নাম দিয়ে চালিয়ে দেখুন
fork-এর আগে fflush নেই, child-এ exitফাইল বা pipe-এ লাইন দ্বিগুণ./prog | cat আর ./prog > f — দুইভাবে চালান
background কাজে wait নেইzombie জমে; দীর্ঘ চলা সার্ভারে PID ফুরায়লোড দিয়ে চালিয়ে ps -e -o stat | grep -c Z
status-কে সরাসরি exit code ধরা (if (st == 1))ভুল ফল — st-এ বিট গুঁজে রাখা থাকেWIFEXITED/WEXITSTATUS ব্যবহার হচ্ছে কি না দেখুন
multi-thread প্রোগ্রামে fork-এর পর child-এ malloc, printfমাঝে মাঝে deadlock — “আমার মেশিনে চলে”fork-এর পর exec পর্যন্ত শুধু async-signal-safe call আছে কি না দেখুন; সম্ভব হলে posix_spawn
Windows-এ চালানোর জন্য fork-ওয়ালা কোড, বা Linux-এ CreateProcesscompile-ই হয় না, বা ভুল মডেলপ্রম্পটে OS আর compiler স্পষ্ট লিখুন
“fork-এ পুরো মেমরি কপি হয়, তাই ধীর” বা উল্টো “fork একদম বিনা খরচে”ভুল ব্যাখ্যা, পরীক্ষায় নম্বর কাটাCOW আর page table-এর খরচ — দুটো মিলিয়ে বুঝুন; man 2 fork পড়ুন

AI-কে প্রসেস-সংক্রান্ত কোড লিখতে দিলে পরিবেশ আর যাচাইয়ের শর্ত দুটোই দিন:

আমার পরিবেশ: Ubuntu 24.04, x86-64, gcc 13, glibc. C-তে একটি ফাংশন লিখে দাও: int run_cmd(char *argv[]), যা fork আর execvp দিয়ে কমান্ড চালাবে আর waitpid দিয়ে অপেক্ষা করবে। শর্ত: ১. fork-এর আগে fflush(stdout) করো; fork ব্যর্থ হলে perror দিয়ে −1 ফেরত দাও। ২. child-এ exec ব্যর্থ হলে perror করে _exit(127) — কখনো exit() নয়। ৩. waitpid EINTR দিলে আবার চেষ্টা করো; ফেরত দাও WEXITSTATUS, আর signal-এ মারা গেলে 128 + signal নম্বর। ৪. gcc -Wall -Wextra-এ কোনো warning যেন না আসে; কোনো global state বা thread ব্যবহার করবে না। শেষে বলো: আউটপুট pipe-এ গেলে কোথায় লাইন দুবার আসতে পারত, আর zombie যে থাকছে না তা ps দিয়ে কীভাবে যাচাই করব।

AI-এর লেখা প্রসেস-কোড চালানোর আগে — চেকলিস্ট

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

  • fork-এর ফেরত মান কী কী? — child-এ 0, parent-এ child-এর PID, ব্যর্থ হলে parent-এ −1 (child তৈরি হয় না)।
  • n বার পরপর fork করলে কতটি প্রসেস? — 2nটি, যার 2n − 1টি নতুন।
  • Zombie আর orphan-এর পার্থক্য? — zombie: শেষ হয়েছে কিন্তু parent wait করেনি; orphan: চলছে কিন্তু parent আগে শেষ, তাকে init বা subreaper দত্তক নেয়।
  • fork আর exec-এর পার্থক্য? — fork নতুন প্রসেস (নতুন PID) বানায় একই প্রোগ্রামে; exec নতুন প্রসেস বানায় না, একই PID-এ প্রোগ্রাম বদলায়।
  • Copy-on-write কেন? — fork-এ মেমরি কপি না করে পাতা ভাগ করে, কেউ লিখলে তবেই সেই পাতা কপি; fork দ্রুত হয়, বিশেষ করে exec-এর আগে।

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

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

প্র ০১Unix কেন প্রসেস তৈরিকে fork আর exec — দুই ভাগে ভাগ করল, যেখানে Windows একটি call-এই কাজ সারে? কোন নকশা ভালো?

ইতিহাস দিয়ে শুরু করা যাক। ১৯৬০-এর দশকের শেষে Berkeley-র GENIE প্রকল্পের time-sharing সিস্টেমে fork-ধাঁচের ধারণা ছিল, আর Ken Thompson ও Dennis Ritchie সেটি Unix-এ নেন। Ritchie নিজেই লিখেছেন, PDP-7-এ fork যোগ করা সহজ ছিল, কারণ প্রসেসকে ডিস্কে সোয়াপ করার ব্যবস্থা আগেই ছিল — “পুরোনোটা না মুছে নতুন নম্বরে চালু” করলেই fork. অর্থাৎ শুরুতে এটি ছিল বাস্তবায়নের সরলতা, কোনো মহৎ দর্শন নয়।

কিন্তু এই ভাগ পরে একটি বড় সুবিধা প্রমাণ করে: মাঝখানের ফাঁক. fork আর exec-এর মাঝে child সাধারণ C কোড চালাতে পারে — fd বন্ধ, dup2 দিয়ে redirection, pipe জোড়া, chdir, setuid দিয়ে কম ক্ষমতার user হওয়া, resource limit বসানো। নতুন কোনো ক্ষমতা লাগলে নতুন system call যোগ করলেই চলে, “প্রসেস তৈরি” call-এর parameter বাড়াতে হয় না। Shell-এর cmd1 | cmd2 > out 2>&1-এর মতো জটিল জিনিস কয়েক লাইনে লেখা যায় এজন্যই। আর fork একা ব্যবহার করে “নিজের কপি” বানানো যায় — পুরোনো Apache-এর prefork সার্ভার, Android-এর Zygote (যেখানে আগে থেকে লোড করা Java runtime-এর একটি প্রসেস fork হয়ে প্রতিটি নতুন অ্যাপ দ্রুত চালু হয়), বা Redis-এর ডিস্কে snapshot লেখা — সবই fork-এর এই ক্ষমতার ওপর দাঁড়িয়ে।

Windows NT-র নকশাকারীরা (Dave Cutler-এর দল, যাঁরা আগে VMS বানিয়েছিলেন) ভিন্ন পথ নেন: CreateProcess-এ সব বিকল্প একবারে বলে দিতে হয়। এতে child-এর পরিবেশ স্পষ্ট ও নিয়ন্ত্রিত থাকে, parent-এর অপ্রয়োজনীয় জিনিস (বড় মেমরি, অজানা handle) ভুল করে child-এ চলে যায় না, আর কপির অপচয় নেই। দাম হলো দীর্ঘ parameter তালিকা, আর প্রতিটি নতুন চাহিদার জন্য নতুন flag বা “attribute list”.

আজকের বিশেষজ্ঞ মত কিছুটা Windows-এর দিকে ঝুঁকেছে। ২০১৯ সালের “A fork() in the road” প্রবন্ধ দেখিয়েছে, multi-thread প্রোগ্রামে fork বিপজ্জনক (শুধু একটি থ্রেড কপি হয়, অন্য থ্রেডের ধরা lock চিরকাল আটকে থাকে), বড় প্রসেসে ধীর, আর ভুল করে গোপন তথ্য বা fd child-এ চলে যায়। তাই নতুন কোডে posix_spawn-এর পরামর্শ বাড়ছে — যা আসলে fork-exec-এর সুবিধা (মাঝের কাজের তালিকা) আর CreateProcess-এর দক্ষতা মিলিয়ে বানানো।

মূল উপলব্ধি: fork-exec ভাগটি নমনীয়তা দেয়, কারণ “প্রসেস বানানো” আর “প্রোগ্রাম বদলানো”-কে আলাদা ছোট কাজে ভাগ করে। কিন্তু প্রতিটি সুবিধার দাম আছে; যে যুগে প্রোগ্রাম ছোট আর একক-থ্রেড ছিল, সেই যুগের সেরা নকশা আজকের বড় multi-thread সার্ভারে সবসময় সেরা নয়।

প্র ০২Copy-on-write থাকলে ২০ GB-র একটি ডেটাবেস প্রসেস fork করা কি বিনা খরচে? Redis-এর snapshot-এর উদাহরণ দিয়ে ভাবুন।

Redis একটি in-memory ডেটাবেস — সব ডেটা RAM-এ। ডিস্কে নিয়মিত একটি snapshot (RDB ফাইল) রাখার জন্য তার BGSAVE কমান্ড fork করে: child প্রসেস fork-এর মুহূর্তের মেমরির “জমাট ছবি” ধীরে ধীরে ডিস্কে লেখে, আর parent ক্লায়েন্টদের অনুরোধ সামলাতে থাকে। COW এখানে চমৎকার কাজ করে — child-এর চোখে মেমরি কখনো বদলায় না, অথচ কোনো lock বা কপি লাগে না। এটি fork-এর সবচেয়ে সুন্দর ব্যবহারগুলোর একটি।

কিন্তু “বিনা খরচে” নয়, তিন জায়গায় দাম দিতে হয়। প্রথমত, fork নিজেই: ডেটা কপি না হলেও page table কপি করতে হয়, আর প্রতিটি লেখার-যোগ্য পাতা read-only চিহ্নিত করতে হয়। 4 KB পাতা ধরলে ২০ GB মানে প্রায় ৫২ লক্ষ পাতা; প্রতিটির জন্য ৮ বাইটের একটি page table entry মানে কয়েক ডজন MB page table. এটি কপি করতে বহু মিলিসেকেন্ড লাগতে পারে, আর সেই সময় parent থেমে থাকে — Redis-এর ডকুমেন্টেশনেও fork-এর এই latency নিয়ে সতর্ক করা হয়। Huge page (2 MB) ব্যবহার করলে পাতার সংখ্যা কমে, fork দ্রুত হয়, কিন্তু তখন একটি ছোট লেখাতেও পুরো 2 MB কপি হয় — এজন্য Redis transparent huge page বন্ধ রাখার পরামর্শ দেয়।

দ্বিতীয়ত, লেখার চাপ: snapshot চলার সময় parent যত পাতায় লেখে, প্রতিটি কপি হয়। লেখা-বেশি কাজের চাপে খারাপ অবস্থায় মেমরি প্রায় দ্বিগুণ হয়ে যেতে পারে। তাই এমন সার্ভারে RAM-এর পুরোটা ডেটায় ভরে ফেলা যায় না।

তৃতীয়ত, overcommit: fork-এর মুহূর্তে kernel-কে প্রায় “প্রতিশ্রুতি” দিতে হয় যে দরকার হলে আরও ২০ GB দেওয়া যাবে। কড়া হিসাবের নীতিতে (vm.overcommit_memory = 2) fork ব্যর্থ হতে পারে; ডিফল্ট অনুমানভিত্তিক নীতিতে fork সফল হয়, কিন্তু পরে সত্যিই মেমরি ফুরালে OOM killer (পর্ব ৩৮) কাউকে মেরে ফেলতে পারে।

মূল উপলব্ধি: COW খরচ মুছে দেয় না, সরিয়ে দেয় — fork-এর মুহূর্ত থেকে পরের প্রতিটি লেখায়। বড় প্রসেসের fork-এর সিদ্ধান্তে মেমরির আকার, page table আর লেখার হার — তিনটিই হিসাব করতে হয়।

প্র ০৩Multi-thread প্রোগ্রামে fork কেন বিপজ্জনক? Python-এর মতো ভাষা এ নিয়ে কী বদলেছে?

POSIX-এর নিয়ম: multi-thread প্রোগ্রামে fork করলে child-এ শুধু fork ডাকা থ্রেডটি কপি হয়। বাকি থ্রেডগুলো child-এ অস্তিত্বহীন — কিন্তু তাদের মেমরির অবস্থা পুরোপুরি কপি হয়েছে। এখন ভাবুন, fork-এর মুহূর্তে থ্রেড ২ malloc-এর ভেতরে ছিল আর heap-এর একটি mutex ধরে রেখেছিল। Child-এ mutex-টি “ধরা” অবস্থায় কপি হলো, অথচ সেটি ছাড়বে যে থ্রেড, সে child-এ নেই। Child যেই malloc ডাকবে — হয়তো printf-এর ভেতর থেকে পরোক্ষভাবে — চিরকাল আটকে থাকবে (deadlock). আর এটি ঘটে কেবল তখন, যখন fork ঠিক ওই মুহূর্তে পড়ে — অর্থাৎ মাঝে মাঝে, লোডের সময়, আর ডেভেলপারের মেশিনে প্রায় কখনো না। এমন বাগ খুঁজে পাওয়া অত্যন্ত কঠিন।

তাই POSIX বলে, multi-thread প্রোগ্রামে fork-এর পর exec পর্যন্ত child শুধু async-signal-safe ফাংশন (যেমন dup2, close, execve, _exit) ডাকতে পারবে। pthread_atfork() দিয়ে fork-এর আগে-পরে lock সামলানোর একটি ব্যবস্থা আছে, কিন্তু সব library সেটি ঠিকভাবে ব্যবহার করে না; macOS-এর অনেক system framework fork-এর পর exec ছাড়া ব্যবহার নিরাপদ নয় বলে Apple স্পষ্টভাবে সতর্ক করে।

Python-এর multiprocessing মডিউল এর ভালো উদাহরণ। বহু বছর Linux-এ এর ডিফল্ট পদ্ধতি ছিল “fork” — দ্রুত, কারণ parent-এর সব কিছু উত্তরাধিকারে পাওয়া যায়। কিন্তু parent-এ থ্রেড চললে (অনেক library নিজেরাই গোপনে থ্রেড চালায়) মাঝে মাঝে deadlock হতো। macOS-এ Python 3.8 থেকেই ডিফল্ট “spawn” (নতুন interpreter চালু করা)। Python 3.12 থেকে থ্রেডওয়ালা প্রসেসে fork করলে সতর্কবার্তা দেখায়, আর Python 3.14-এ Linux-এর ডিফল্টও বদলে “forkserver” করা হয়েছে — একটি পরিষ্কার, একক-থ্রেড সাহায্যকারী প্রসেস থেকে fork করা, যাতে ধরা lock-এর উত্তরাধিকার না আসে। (নিজের সংস্করণের ডকুমেন্টেশনে মিলিয়ে নিন।)

সাধারণ নিয়ম দাঁড়াল: থ্রেডওয়ালা প্রোগ্রাম থেকে নতুন প্রোগ্রাম চালাতে posix_spawn, আর fork দরকার হলে প্রোগ্রামের শুরুতে, কোনো থ্রেড চালুর আগে, একটি “worker factory” প্রসেস বানিয়ে রাখা।

মূল উপলব্ধি: fork মেমরির একটি মুহূর্তের ছবি তোলে, কিন্তু চলমান কাজগুলোর নয়। ছবিতে যদি কোনো অসমাপ্ত কাজের মাঝখানের অবস্থা ধরা পড়ে, child সেই অসমাপ্ত অবস্থা উত্তরাধিকারে পায় — আর শেষ করার কেউ থাকে না।

প্র ০৪Zombie তো CPU বা মেমরি খায় না — তাহলে zombie জমা কেন গুরুতর সমস্যা? Container-এ এটি কেন বেশি দেখা যায়?

ঠিক, zombie চলে না, তার user মেমরি ও fd ফেরত দেওয়া হয়ে গেছে। কিন্তু সে দুটো জিনিস আটকে রাখে: একটি PID, আর kernel-এর ভেতরে task-এর একটি ছোট কাঠামো। PID সীমিত সম্পদ — Linux-এ সর্বোচ্চ সংখ্যা /proc/sys/kernel/pid_max-এ লেখা থাকে; ঐতিহ্যগত ডিফল্ট ৩২,৭৬৮, যদিও আধুনিক 64-bit distro-তে প্রায়ই অনেক বড় মান বসানো থাকে। তার ওপর আছে প্রতি user-এর প্রসেস-সীমা (RLIMIT_NPROC) আর cgroup-এর pids.max. একটি দীর্ঘ চলা সার্ভার যদি প্রতিটি অনুরোধে একটি child বানায় আর কখনো wait না করে, দিনে লাখ অনুরোধে কয়েক ঘণ্টাতেই সীমা ভরে যাবে। তখন fork ব্যর্থ হয় EAGAIN দিয়ে — সার্ভার নতুন কাজ নিতে পারে না, অনেক সময় অ্যাডমিন লগইন করে একটি ps চালাতেও পারেন না।

দ্বিতীয় সমস্যা পর্যবেক্ষণে: monitoring টুল প্রসেসের সংখ্যা দেখে ভুল সিদ্ধান্ত নেয়, আর অনেক zombie একটি গভীর বাগের লক্ষণ — parent হয়তো SIGCHLD হারাচ্ছে, বা একটি থ্রেড আটকে আছে।

সমাধান zombie-কে মারা নয় — সে তো মৃতই, kill -9 কাজ করে না। সমাধান হলো parent-কে ঠিক করা (wait যোগ করা), নয়তো parent-কে বন্ধ করা; তখন zombie-রা orphan হয়ে init-এর কাছে যায়, আর init তাদের তুলে নেয়।

Container-এ সমস্যাটি বেশি, কারণ সেখানে “init” প্রায়ই আপনার অ্যাপ নিজেই। Container-এর নিজস্ব PID namespace-এ প্রথম প্রসেস PID 1 পায়, আর orphan-রা তার কাছেই যায়। একটি Node.js বা Python ওয়েব অ্যাপ সাধারণত জানে না যে তাকে অচেনা orphan-দের wait করতে হবে। অ্যাপ যদি ভেতরে shell script বা অন্য টুল চালায়, তাদের নাতিরা orphan হয়ে জমতে থাকে। আরও আছে signal-এর ঝামেলা: PID 1 ডিফল্টে SIGTERM-এ মরে না, যদি নিজে handler না বসায় — তাই docker stop সময়সীমা পেরিয়ে SIGKILL পাঠায়। এজন্য Docker 1.13 থেকে --init বিকল্প আছে (ভেতরে tini), podman নিজস্ব ছোট init (catatonit বা podman-init) ব্যবহার করতে পারে — এই পাতার bash রানারেও PID 1 ছিল podman-init — আর Kubernetes-এ একই pod-এর container-রা চাইলে PID namespace ভাগ করে pause প্রসেসকে init বানাতে পারে।

মূল উপলব্ধি: Zombie নিজে ক্ষতিকর নয়, ক্ষতিকর তার সংখ্যা — আর সংখ্যা বাড়ে কেবল একটি কারণে: বেঁচে থাকা কিন্তু দায়িত্ব-ভোলা parent. প্রতিটি প্রসেস-বৃক্ষে কেউ একজনকে “অভিভাবক” হতেই হয়।

প্র ০৫একটি প্রোগ্রাম অসীম লুপে fork করতে থাকলে কী হয় (fork bomb)? OS কীভাবে নিজেকে রক্ষা করে, আর আপনি কীভাবে সেটি যাচাই ও কনফিগার করবেন?

Fork bomb হলো এমন কোড, যেখানে প্রতিটি প্রসেস বারবার fork করে, আর প্রতিটি child-ও একই কাজ করে। অংশ ৩-এর সূত্র মনে করুন — প্রতিটি “প্রজন্মে” সংখ্যা দ্বিগুণ। ৩০ প্রজন্মে ১০০ কোটির বেশি। কোনো সীমা না থাকলে মিলিসেকেন্ডের মধ্যে process table, PID আর মেমরি ফুরিয়ে যায়, scheduler-এর কাজের তালিকা বিশাল হয়, আর মেশিন সাড়া দেওয়া বন্ধ করে — এমনকি অ্যাডমিন নতুন shell খুলতে বা kill চালাতেও পারেন না, কারণ সেগুলোও নতুন প্রসেস চায়। ভুল করে লেখা একটি লুপও (যেমন fork-এর পর child-এ break বা exec ভুলে যাওয়া) একই কাজ করতে পারে — তাই এটি শুধু দুষ্টুমির বিষয় নয়, সাধারণ বাগেরও ফল।

OS-এর সুরক্ষা কয়েক স্তরে, আর প্রতিটি যাচাই করা যায়:

  • প্রতি user-এর সীমা: RLIMIT_NPROC — একজন user-এর মোট কতগুলো প্রসেস/থ্রেড। ulimit -u দিয়ে দেখা যায়; স্থায়ী মান সাধারণত /etc/security/limits.conf-এ। সীমা ছুঁলে fork EAGAIN দেয়, আর root-এর কাজ চলতে থাকে।
  • cgroup-এর pids controller (Linux 4.3 থেকে): একটি সার্ভিস, user সেশন বা container-এর মোট task-এর সংখ্যা সীমিত। cat /sys/fs/cgroup/…/pids.max দিয়ে দেখা যায়। systemd প্রতিটি সার্ভিসে TasksMax বসায়, আর Docker-এ --pids-limit. ফলে একটি container-এর ভেতরের fork bomb শুধু সেই container-কে আটকায়, পুরো সার্ভারকে নয়।
  • সিস্টেম-ব্যাপী সীমা: kernel.pid_max আর kernel.threads-max.

সুরক্ষার সঠিক পথ হলো এই সীমাগুলো চালু রাখা ও যাচাই করা — বিশেষ করে বহু-ব্যবহারকারীর সার্ভার, বিশ্ববিদ্যালয়ের ল্যাব বা অনলাইন কোড-রানারে (এই পাতার রানারও container আর সীমার ভেতরে চলে)। প্রসেস-তৈরির কোড পরীক্ষার আগে নিজের টার্মিনালে ulimit -u 200-এর মতো ছোট সীমা বসিয়ে নিলে ভুল লুপও মেশিন আটকাতে পারে না। আর সীমা ছুঁলে fork-এর −1 ফেরত সামলানো কোডেরই দায়িত্ব — ব্যর্থ fork-এর পর লুপ চালিয়ে যাওয়া নয়।

মূল উপলব্ধি: সূচকীয় বৃদ্ধি যেকোনো সীমিত সম্পদকে মুহূর্তে শেষ করে। তাই OS প্রতিটি শেয়ার করা সম্পদে — প্রসেস, মেমরি, ফাইল — কোটা বসায়; নিরাপদ সিস্টেম মানে সেই কোটাগুলো সচেতনভাবে চালু ও পরীক্ষিত।

অনুশীলন

ধাঁধা গোনা, fork tree আঁকা, আউটপুট অনুমান আর কোড লেখা — আগে নিজে চেষ্টা করুন, তারপর উত্তর মেলান।

  1. কত লাইন ছাপা হবে? নিচের প্রোগ্রাম (ক) টার্মিনালে আর (খ) ./prog | wc -l করে চালালে কত লাইন x আসবে?
    int main(void) {
        for (int i = 0; i < 3; i++) {
            fork();
            printf("x\n");
        }
        while (wait(NULL) > 0) ;
        return 0;
    }

    (ক) টার্মিনাল (line buffered) — ১৪ লাইন. প্রতি পুনরাবৃত্তিতে fork প্রসেস দ্বিগুণ করে, তারপর সবাই একটি করে x ছাপে: i = 0-তে ২টি প্রসেস → ২ লাইন, i = 1-এ ৪টি → ৪, i = 2-তে ৮টি → ৮. মোট ২ + ৪ + ৮ = ১৪. সাধারণ সূত্র: n পুনরাবৃত্তিতে 21 + … + 2n = 2n+1 − 2.

    (খ) pipe (fully buffered) — ২৪ লাইন. এখন কোনো x সঙ্গে সঙ্গে লেখা হয় না; buffer-এ জমে, আর পরের fork সেই buffer-ও কপি করে। শেষে ৮টি প্রসেসের প্রত্যেকের buffer-এ আছে ৩টি x (প্রতিটি পুনরাবৃত্তির একটি, কিছু পূর্বপুরুষের কাছ থেকে উত্তরাধিকারে)। প্রত্যেকে exit-এ নিজের buffer flush করে: ৮ × ৩ = ২৪. সাধারণ সূত্র: n · 2n.

    আসল রানে যাচাই: এই কোডটি (stdio.h, unistd.h, sys/wait.h যোগ করে) এই কোর্সের Linux রানারে চালানো হয়েছে, যেখানে stdout একটি pipe: আউটপুটে x-এর লাইন গুনে পাওয়া গেছে ২৪. আর শুরুতে setvbuf(stdout, NULL, _IOLBF, 0) যোগ করে (টার্মিনালের মতো line buffering) চালালে পাওয়া গেছে ১৪. একই কোড, দুটি সঠিক উত্তর — প্রশ্নে stdout কোথায় যাচ্ছে, তা না বললে উত্তরে দুটোই বলাই নিরাপদ।

  2. Fork tree আঁকুন: নিচের কোডে মোট কতটি প্রসেস তৈরি হয় (মূল প্রসেসসহ), আর কত লাইন P ছাপা হয়? বৃক্ষটি আঁকুন।
    pid_t pid = fork();
    if (pid == 0)
        fork();
    fork();
    printf("P\n");
    fflush(stdout);

    ধরুন মূল প্রসেস P0. প্রথম fork: P0 → P1 (P1-এ pid = 0). if-এ ঢোকে শুধু P1 (P0-এর pid ধনাত্মক): P1 → P2. P2 নিজেও P1-এর কপি, কিন্তু সে if-এর ভেতরের fork থেকে ফিরছে, তাই আর if-এ ফেরে না — এখন P0, P1, P2 তিনজন। শেষের fork-এ তিনজনই একটি করে child বানায়: P0 → P3, P1 → P4, P2 → P5.

    P0
    ├─ P1
    │  ├─ P2
    │  │  └─ P5
    │  └─ P4
    └─ P3

    মোট ৬টি প্রসেস, আর প্রত্যেকে একবার printf চালায়: ৬ লাইন. (printf-এর আগে buffer-এ কিছু নেই, আর পরেই fflush, তাই টার্মিনাল বা pipe — দুই ক্ষেত্রেই ৬. Linux রানারে চালিয়েও ৬টি P পাওয়া গেছে।)

  3. আউটপুট অনুমান: echo সাধারণ প্রোগ্রাম, যা নিজের argument ছেপে newline দেয়। নিচের কোড টার্মিনালে চালালে কী আসবে? কেন?
    printf("A");
    execlp("echo", "echo", "B", (char *)NULL);
    printf("C\n");

    শুধু:

    B

    A কোথায় গেল? printf("A")-এ newline নেই, তাই টার্মিনালেও “A” stdio buffer-এ বসে ছিল। exec সফল হতেই পুরোনো প্রোগ্রামের সব মেমরি — buffer-সহ — ফেলে দেওয়া হলো, কেউ সেটি flush করেনি। C কেন নেই? exec সফল হলে কখনো ফেরে না; printf("C\n") লাইনটি আর আছেই না। B এসেছে echo-র নিজের লেখা হিসেবে — একই PID, একই fd 1. শিক্ষা: exec-এর আগেও fflush(stdout). (exec ব্যর্থ হলে, যেমন echo না থাকলে, আসত “AC”.)

  4. কোড লিখুন — নিজের system(): C library-র system("ls -l") একটি shell দিয়ে কমান্ড চালায়। fork, execl("/bin/sh", "sh", "-c", cmd, (char *)NULL) আর waitpid দিয়ে int my_system(const char *cmd) লিখুন, যা কমান্ডের exit status ফেরত দেবে, signal-এ মারা গেলে 128 + signal নম্বর, আর fork ব্যর্থ হলে −1.
    #include <errno.h>
    #include <stdio.h>
    #include <unistd.h>
    #include <sys/wait.h>
    
    int my_system(const char *cmd) {
        fflush(NULL);                          /* no duplicated stdio output */
        pid_t pid = fork();
        if (pid < 0) return -1;                /* fork failed: no child */
        if (pid == 0) {
            execl("/bin/sh", "sh", "-c", cmd, (char *)NULL);
            _exit(127);                        /* exec failed: never exit() */
        }
        int st;
        while (waitpid(pid, &st, 0) < 0)
            if (errno != EINTR) return -1;     /* retry if a signal interrupted */
        if (WIFEXITED(st))   return WEXITSTATUS(st);
        if (WIFSIGNALED(st)) return 128 + WTERMSIG(st);
        return -1;
    }

    মূল বিষয়গুলো: fork-এর আগে fflush(NULL); child-এ exec ব্যর্থ হলে _exit(127) (shell-এর “command not found”-এর মতো); waitpid নির্দিষ্ট PID-এর জন্য, যাতে অন্য child-এর status ভুল করে না নেয়; আর status পড়তে শুধু macro. যাচাই করুন: my_system("exit 3") → 3, my_system("kill -9 $$") → 137, my_system("true") → 0. আসল system() এর বাইরেও কাজ করে — চলার সময় parent-এ SIGINT ও SIGQUIT উপেক্ষা আর SIGCHLD আটকে রাখে, আর glibc ভেতরে posix_spawn ব্যবহার করে। নিরাপত্তা: ব্যবহারকারীর দেওয়া লেখা কখনো সরাসরি sh -c-এ দেবেন না (shell injection); সেক্ষেত্রে argument অ্যারে দিয়ে execvp বা posix_spawnp ব্যবহার করুন।

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

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

ক্রমShell যখন একটি বাইরের কমান্ড চালায়, ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Shell কমান্ড লাইন পড়ে শব্দে ভাগ করে
  2. fork() — shell-এর একটি কপি child হিসেবে তৈরি হয়
  3. Child execvp() দিয়ে একই PID-এ নতুন প্রোগ্রাম লোড করে
  4. প্রোগ্রাম exit করে; kernel তাকে zombie রেখে parent-কে SIGCHLD পাঠায়
  5. Shell-এর waitpid() exit status নেয়, child মুছে যায়, নতুন prompt আসে

একটি সফল fork() call child প্রসেসে কোন মান ফেরত দেয়?

  • Child-এর নিজের PID
  • 0
  • −1
  • Parent-এর PID

একটি C প্রোগ্রামে main-এর ভেতরে পরপর চারটি fork(); লাইন, তারপর একটি printf("hi\n"); — কোনো শর্ত নেই, আর আগে কিছু ছাপা হয়নি। মোট কতটি প্রসেস “hi” ছাপবে?

  • ৫
  • ৮
  • ১৫
  • ১৬

একটি প্রসেস execvp() সফলভাবে চালানোর পর নিচের কোনটি অপরিবর্তিত থাকে?

  • প্রসেসের PID
  • Heap-এ রাখা ডেটা
  • stdio buffer-এ জমা লেখা
  • নিজের বসানো signal handler

Zombie প্রসেস কোনটি?

  • যে প্রসেস চলছে কিন্তু তার parent আগেই শেষ হয়ে গেছে
  • যে প্রসেস অসীম লুপে আটকে CPU খাচ্ছে
  • যে প্রসেস শেষ হয়েছে কিন্তু parent এখনো wait দিয়ে তার exit status নেয়নি
  • যে প্রসেস SIGSTOP পেয়ে থেমে আছে

Linux-এ একটি child exit(300) দিয়ে শেষ হলো। Parent waitpid-এর পর WEXITSTATUS(st)-এ কী পাবে?

  • 300
  • 44
  • 255
  • −1

একটি প্রোগ্রাম printf("start\n") লিখে, fflush ছাড়াই fork() করে, আর child ও parent দুজনেই স্বাভাবিকভাবে exit করে। আউটপুট ফাইলে redirect করলে “start” দুবার আসে কেন?

  • fork child-কে প্রোগ্রামের শুরু থেকে আবার চালায়
  • ফাইলে লেখার সময় kernel ভুল করে ডেটা দুবার লেখে
  • লেখাটি তখনো stdio buffer-এ ছিল, fork buffer-সহ মেমরি কপি করেছে, আর দুজনেই exit-এ flush করেছে
  • দুই প্রসেসের file descriptor আলাদা offset পায়

শব্দকোষ

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

মনে রাখুন

  1. fork কপি বানায় — child-এ 0, parent-এ child-এর PID, ব্যর্থ হলে −1; মেমরি copy-on-write, fd-এর offset ভাগ করা, pending signal আর lock নয়।
  2. পরপর n fork = 2n প্রসেস; শর্ত বা &&/|| থাকলে প্রতিটি প্রসেসের পথ আলাদা করে ধরুন।
  3. fork-এর আগে fflush, child-এ exec ব্যর্থ হলে _exit — নইলে buffer-এর লেখা দুবার আসে।
  4. exec একই PID-এ প্রোগ্রাম বদলায়, সফল হলে ফেরে না; fd থাকে, যদি FD_CLOEXEC না থাকে।
  5. wait/waitpid দিয়ে status নিন (WIFEXITED, WEXITSTATUS, WIFSIGNALED); না নিলে zombie, parent আগে গেলে orphan → init বা subreaper. Linux-এ সবকিছুর ভিত্তি clone, Windows-এ CreateProcess.

পরের পর্বে: fork-exec-wait-এর ওপর দাঁড়িয়ে থাকা সবচেয়ে পরিচিত প্রোগ্রাম — shell. Background কাজ, Ctrl+Z আর fg/bg, process group, আর ps, top ও /proc দিয়ে চলমান প্রসেসের ভেতরে উঁকি।

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

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