fork, exec, wait — যে function দুইবার return করে
fork, exec, and wait
গত লেসনে প্রশ্ন রেখে গিয়েছিলাম — একটা নতুন process কোথা থেকে আসে? উত্তর একটা অদ্ভুত function-এ, যেটা একবার ডাকা হয় কিন্তু দুইবার return করে। এই লেসনে আমরা দেখব fork() কীভাবে একটা প্রায়-অভিন্ন কপি তৈরি করে অথচ পুরো address space কপি করে না (copy-on-write), exec() কীভাবে সেই কপির ভেতরে সম্পূর্ণ নতুন একটা প্রোগ্রাম বসিয়ে দেয়, আর wait() কীভাবে parent-কে child-এর মৃত্যুর খবর পৌঁছে দেয় — আর কী হয় যখন সেই খবর কখনো নেওয়া হয় না।
আগে এটা বুঝি
C-তে প্রায় প্রতিটা function একবার ডাকা হয়, একবার return করে।
fork() নিয়ম ভাঙে:
pid_t pid = fork();
printf("এই লাইনটা কতবার চলবে?\n");উত্তর: দুইবার। একবার সেই process-এ যেটা fork() ডেকেছিল
(parent), আরেকবার একটা নতুন process-এ যেটা হঠাৎ অস্তিত্বে
এসেছে (child) — ঠিক fork() কল করার পরের instruction থেকে।
দুইটা process-ই এখন printf চালাবে। একই কোড, দুইবার, দুইটা
আলাদা task_struct-এ (গত লেসনের ধারণা)।
কীভাবে জানবেন কোনটায় আছেন? fork()-এর return value দিয়ে —
কিন্তু সেই মানটাও দুই process-এ আলাদা:
pid_t pid = fork();
if (pid == 0) {
/* এখানে শুধু child আসে */
} else if (pid > 0) {
/* এখানে শুধু parent আসে, pid = child-এর PID */
} else {
/* fork ব্যর্থ হলে */
}এই একটা design decision-এর উপর পুরো Unix process model দাঁড়িয়ে — shell pipeline, background job, সার্ভারের worker process। আজ আমরা দেখব এটা কীভাবে কাজ করে, কেন এটা এত সস্তা হতে পারে, আর কোথায় এটা বিপজ্জনক হয়ে ওঠে।
মূল ধারণা
fork() — একটা প্রায়-নিখুঁত ক্লোন
fork() ডাকলে kernel বর্তমান process-এর একটা কপি বানায়:
| যা কপি হয় | যা কপি হয় না |
|---|---|
| Address space-এর বিষয়বস্তু (logically) | PID |
| খোলা file descriptor | PPID (নতুন child-এর parent = caller) |
| Signal handler, mask | Pending signal |
| Environment variable | File lock |
| CPU register-এর বর্তমান মান | Timer |
Address space “কপি হয়” বলা হলেও, বাস্তবে byte-by-byte কপি হয় না — এটাই পরের বিভাগের বিষয়।
Child শুরু হয় ঠিক সেই instruction থেকে যেখানে fork() return
করছে — parent-এর মাঝপথ থেকে, শুরু থেকে নয়। যদি parent একটা
loop-এর মাঝে থাকে, child-ও সেই loop-এর মাঝেই “জন্মায়”।
Copy-on-write — কীভাবে এত সস্তা হয়
একটা প্রশ্ন স্বাভাবিকভাবেই আসে: parent-এর যদি ২ GB heap থাকে,
fork() কি ২ GB কপি করে? উত্তর: না।
গত কয়েক লেসনে আমরা দেখেছি প্রতিটা process-এর virtual address
আসলে page table দিয়ে physical frame-এ translate হয় (লেসন
১১-১৩)। fork() এই layer-টাই কাজে লাগায়:
- fork() syscallkernel-এ ঢোকা
- নতুন task_structনতুন PID, নতুন mm_struct
- Page table কপি (entry-level)physical frame কপি নয়, শুধু mapping
- সব writable page → read-onlyparent ও child উভয়ের জন্য
- Physical frame-এর refcount বাড়ানোএকই frame, দুইটা owner
- return — দুইবারএখানেই আসল কাজ শেষ
fork() শেষ হওয়ার মুহূর্তে parent আর child-এর virtual address
একই physical memory নির্দেশ করে। শুধু পার্থক্য: প্রতিটা
writable page এখন read-only হিসেবে চিহ্নিত, দুই দিকেই।
যখন কেউ (parent বা child) সেই memory-তে লিখতে চেষ্টা করে, hardware একটা page fault তোলে (লেসন ১৩-এর ঠিক এই প্রক্রিয়া)। Kernel সেই fault ধরে, বোঝে এটা একটা COW page, তখনই প্রকৃত কপি করে — শুধু সেই একটা 4KB page, পুরো heap নয়।
fork()-এর ঠিক পরে: একজন লিখলে:
parent ──┐ parent ──→ [নতুন copy]
├──→ [একই page]
child ──┘ child ──→ [আসল page]exec() — একই process, নতুন পরিচয়
fork() একটা কপি বানায়। exec() সেই কপির ভেতরের সবকিছু
প্রতিস্থাপন করে — নতুন কোড, নতুন data, নতুন stack, নতুন
heap — কিন্তু PID একই থাকে।
execve("/bin/ls", argv, envp);সফল হলে এই কলের পরের কোনো লাইন কখনো চলে না — কারণ পুরনো প্রোগ্রামের কোড-ই আর নেই।
| অক্ষত থাকে | প্রতিস্থাপিত হয় |
|---|---|
| PID, PPID | .text, .data, .bss, heap |
খোলা file descriptor (যদি না O_CLOEXEC) | stack |
| Working directory | signal handler → default-এ ফিরে যায় |
| uid/gid | argv, envp (নতুন করে দেওয়া) |
Fork + exec = নতুন প্রোগ্রাম চালানোর Unix পদ্ধতি:
pid_t pid = fork();
if (pid == 0) {
execve("/bin/ls", (char *[]){"ls", "-l", NULL}, environ);
perror("execve"); /* শুধু ব্যর্থ হলে এখানে পৌঁছাবে */
_exit(127);
}wait() — সন্তানের মৃত্যুর খবর নেওয়া
Child শেষ হলে সে সাথে সাথে অদৃশ্য হয় না — গত লেসনে দেখেছি সে zombie (Z state) হয়ে থাকে, parent তার exit status না পড়া পর্যন্ত।
int status;
pid_t child = wait(&status); /* যেকোনো child-এর জন্য অপেক্ষা */
pid_t specific = waitpid(pid, &status, 0); /* নির্দিষ্ট child */
pid_t nonblock = waitpid(-1, &status, WNOHANG); /* block না করে চেক */status-এর ভেতরে encode করা থাকে child কীভাবে শেষ হলো:
if (WIFEXITED(status))
printf("normal exit, code %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status))
printf("signal-এ মারা গেছে: %d\n", WTERMSIG(status));Zombie reaping-এর দায়িত্ব parent-এর। Parent যদি কখনো
wait() না ডাকে, zombie জমতে থাকে (গত লেসনে দেখেছি)। Parent
নিজে মারা গেলে তার সন্তানরা orphan — kernel তাদের PID 1
(init/systemd)-এর কাছে reparent করে দেয়, যার নির্দিষ্ট
দায়িত্বই হলো periodic-ভাবে wait() ডেকে সব orphan zombie
পরিষ্কার করা।
# orphan তৈরি করে দেখুন
(sleep 2 &) # subshell exit হয়ে যায়, sleep orphan হয়
ps -o pid,ppid,comm -C sleepsleep-এর PPID দেখাবে 1 (বা systemd --user-এর PID) —
তার আসল parent (subshell) আর নেই।
ভেতরে কী ঘটছে
থ্রেডেড প্রোগ্রামে fork() — একটা লুকানো ফাঁদ
গত লেসন থেকে (থ্রেড) মনে করুন: একটা multithreaded process-এ বহু thread একই address space শেয়ার করে।
fork() ডাকলে child-এ শুধু যে thread fork() ডেকেছিল,
সেটাই বেঁচে থাকে। বাকি সব thread-এর memory (তাদের যা কিছু
lock ধরে ছিল, তাদের যা কিছু অসম্পূর্ণ কাজ ছিল) সেই অবস্থাতেই
জমে থাকে, কিন্তু সেই thread গুলো নিজেরা child-এ নেই তাদের
চালিয়ে নেওয়ার জন্য।
এটা কেন বিপজ্জনক — একটা concrete উদাহরণ:
Thread A: malloc()-এর ভেতরে, arena lock ধরে আছে
Thread B: fork() ডাকছেChild-এ Thread A নেই — কিন্তু arena lock locked অবস্থায়
জমে আছে, কারণ lock-এর অবস্থাও memory-র অংশ, copy হয়ে
গেছে। Child যদি এখন malloc() ডাকে, সেটা একটা lock-এর জন্য
অপেক্ষা করবে যেটা কখনো release হবে না — কারণ যে thread
release করত সে অস্তিত্বেই নেই।
vfork() আর posix_spawn() — বিকল্প পথ
COW সাধারণত যথেষ্ট সস্তা, কিন্তু কিছু ক্ষেত্রে (বিশাল address space, memory-সীমিত embedded system) এমনকি page table কপি করাও ব্যয়বহুল মনে হতে পারে।
vfork() — parent-এর address space শেয়ার করে
(কপিই করে না), আর parent-কে সাসপেন্ড রাখে যতক্ষণ না
child exec() বা _exit() ডাকে। বিপজ্জনক — child যদি কোনো
memory পরিবর্তন করে (যেমন একটা local variable-এ লেখে), সেটা
আসলে parent-এর memory বদলে দেয়। আধুনিক কোডে প্রায় কখনো
সরাসরি ব্যবহার করা উচিত নয়।
posix_spawn() — fork+exec-এর একটা সংকলিত, নিরাপদ সংস্করণ।
Implementation ভেদে এটা vfork-এর মতো দ্রুততর কৌশল ব্যবহার
করতে পারে, কিন্তু নিরাপত্তার নিয়ম মেনে। যেখানে শুধু “একটা
প্রোগ্রাম চালাও” দরকার (fork-এর মাঝে কাস্টম কোড লাগবে না),
সেখানে এটাই আধুনিক সুপারিশ — glibc, musl দুটোতেই সমর্থিত।
উদাহরণ
একটা সম্পূর্ণ ট্রেস — shell-এ ls চালানো
Shell process (PID 1000) 'ls' টাইপ পড়ল
│
├─ fork() ডাকল
│ │
│ ├─→ Parent (PID 1000) return পেল pid=1001
│ │ waitpid(1001, ...) ডেকে block করল
│ │
│ └─→ Child (নতুন PID 1001) return পেল pid=0
│ (এখনো shell-এরই কোড চলছে, কিন্তু নতুন process-এ)
│
├─ Child: execve("/bin/ls", ...) ডাকল
│ │
│ └─→ PID 1001-এর address space সম্পূর্ণ বদলে গেল
│ এখন এটা 'ls'-এর কোড, 'ls'-এর data — কিন্তু PID এখনো 1001
│
├─ 'ls' চলল, output লিখল, exit(0) ডাকল
│ │
│ └─→ PID 1001 এখন Zombie (Z state)
│
└─ Parent-এর waitpid() ফিরে এল
status বলছে exit code 0
Zombie সম্পূর্ণ পরিষ্কার — PID 1001 এখন সম্পূর্ণ মুক্তএকটা ls কমান্ডের পেছনে একটা fork, একটা exec, একটা wait —
তিনটা syscall পরিবার, আর তাদের মাঝে PID 1001 তিনটা সম্পূর্ণ
ভিন্ন অবস্থার মধ্য দিয়ে গেছে: shell-এর কপি → ls প্রোগ্রাম →
zombie → মুক্ত।
নিজে চালিয়ে দেখুন
Copy-on-write নিজের চোখে দেখুন
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#define SIZE (200UL * 1024 * 1024) /* 200 MB */
static long get_rss_kb(void) {
FILE *f = fopen("/proc/self/status", "r");
char line[256];
long kb = 0;
while (fgets(line, sizeof line, f))
if (sscanf(line, "VmRSS: %ld kB", &kb) == 1) break;
fclose(f);
return kb;
}
int main(void) {
char *buf = malloc(SIZE);
memset(buf, 'A', SIZE); /* সব page touch করে বাধ্য করে allocate */
printf("fork()-এর আগে, parent RSS: %ld MB\n", get_rss_kb() / 1024);
pid_t pid = fork();
if (pid == 0) {
printf("child fork()-এর ঠিক পরে RSS: %ld MB (এখনো কম হওয়া উচিত)\n",
get_rss_kb() / 1024);
/* এখন অর্ধেক memory-তে লিখি — COW ট্রিগার করি */
memset(buf, 'B', SIZE / 2);
printf("child অর্ধেক লেখার পরে RSS: %ld MB\n", get_rss_kb() / 1024);
_exit(0);
}
waitpid(pid, NULL, 0);
printf("parent-এর RSS অপরিবর্তিত: %ld MB\n", get_rss_kb() / 1024);
free(buf);
return 0;
}gcc -O0 -o cow cow.c && ./cowপ্রত্যাশিত ফলাফল:
fork()-এর আগে, parent RSS: 200 MB
child fork()-এর ঠিক পরে RSS: 200 MB (এখনো কম হওয়া উচিত)
child অর্ধেক লেখার পরে RSS: 300 MB
parent-এর RSS অপরিবর্তিত: 200 MBসূক্ষ্মতা: fork()-এর ঠিক পরেই child-এর RSS প্রায় ২০০
MB দেখায় (parent-এর সমান), কারণ RSS মাপে কতগুলো physical
page ব্যবহার হচ্ছে, আর COW page শেয়ার্ড হলেও ব্যবহার হচ্ছে —
তাই দুই process-এর RSS-এ একই page দুইবার গোনা হয়। প্রকৃত
সাশ্রয়টা বোঝা যায় /proc/<pid>/smaps-এ Shared_Clean /
Private_Dirty আলাদা করে দেখলে।
./cow &
sleep 0.1
grep -A2 "^[0-9a-f].*rw" /proc/$!/smaps | grep -E "Shared|Private" | head -6
waitShared_Clean বড় সংখ্যা দেখাবে fork-এর ঠিক পরে — এটাই COW-এর
আসল প্রমাণ, কারণ “shared” মানে দুই process একই physical page
নির্দেশ করছে।
fork() সত্যিই memory copy করে না — শুধু touch করা page-ই copy হয়, আর সেটা RSS মেপে সরাসরি প্রমাণ করা যায়।
Zombie তৈরি করে দেখা, তারপর reap করা
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
printf("child (PID %d) সাথে সাথে exit করছে\n", getpid());
_exit(0);
}
printf("parent (PID %d): child %d এখন zombie হবে\n", getpid(), pid);
printf("এখানে ৫ সেকেন্ড ঘুমাচ্ছি — এই সময়ে অন্য terminal থেকে "
"'ps -o pid,ppid,stat,comm -p %d' চালান\n", pid);
sleep(5);
printf("এখন wait() ডাকছি — zombie পরিষ্কার হবে\n");
int status;
waitpid(pid, &status, 0);
printf("reaped — exit code ছিল %d\n", WEXITSTATUS(status));
return 0;
}gcc -o zombie zombie.c && ./zombieচলার সময়, অন্য একটা terminal থেকে (৫ সেকেন্ডের মধ্যে):
ps -o pid,ppid,stat,comm --ppid $(pgrep -f ./zombie | head -1)Output-এ STAT column-এ Z (বা Z+) দেখাবে — একটা প্রকৃত
zombie, ps-এ দৃশ্যমান, যদিও child-এর কোনো memory বা কোড
আর অস্তিত্বে নেই।
৫ সেকেন্ড পরে waitpid চলার পর একই কমান্ড চালালে সেই entry
আর দেখাবে না — সম্পূর্ণ মুক্ত হয়ে গেছে।
Parent যতক্ষণ wait() না ডাকে, সন্তানের মৃত্যুর খবর kernel-এ আটকে থাকে — আর সেই আটকে থাকা অবস্থাটাই zombie state, দৃশ্যমান ও পরিমাপযোগ্য।
নিজে বানান
একটা Mini Shell — fork, exec, pipe, wait একসাথে
- একটা লাইন পড়ে দুইটা কমান্ডে ভাগ করুন (একটা pipe চিহ্ন দিয়ে)
- একটা pipe() বানান
- দুইটা fork() করুন — একটা বাঁ কমান্ডের জন্য, একটা ডান কমান্ডের জন্য
- প্রতিটা child-এ সঠিক dup2 আর অব্যবহৃত fd close করুন
- Parent-এ pipe-এর দুই প্রান্তই বন্ধ করুন, তারপর দুইটা child-এর জন্যই wait করুন
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
static char **tokenize(char *line) {
char **argv = malloc(sizeof(char *) * 32);
int i = 0;
char *tok = strtok(line, " \t\n");
while (tok && i < 31) { argv[i++] = tok; tok = strtok(NULL, " \t\n"); }
argv[i] = NULL;
return argv;
}
static void run_single(char **argv) {
pid_t pid = fork();
if (pid == 0) {
execvp(argv[0], argv);
fprintf(stderr, "exec ব্যর্থ: %s\n", argv[0]);
_exit(127);
}
int status;
waitpid(pid, &status, 0);
}
static void run_pipeline(char **left, char **right) {
int fds[2];
pipe(fds);
pid_t p1 = fork();
if (p1 == 0) {
dup2(fds[1], STDOUT_FILENO);
close(fds[0]);
close(fds[1]);
execvp(left[0], left);
_exit(127);
}
pid_t p2 = fork();
if (p2 == 0) {
dup2(fds[0], STDIN_FILENO);
close(fds[0]);
close(fds[1]);
execvp(right[0], right);
_exit(127);
}
/* parent-এ pipe-এর কোনো প্রান্তেরই দরকার নেই */
close(fds[0]);
close(fds[1]);
waitpid(p1, NULL, 0);
waitpid(p2, NULL, 0);
}
int main(void) {
char line[512];
printf("mini$ ");
while (fgets(line, sizeof line, stdin)) {
char *pipe_pos = strchr(line, '|');
if (pipe_pos) {
*pipe_pos = '\0';
char **left = tokenize(line);
char **right = tokenize(pipe_pos + 1);
if (left[0] && right[0]) run_pipeline(left, right);
free(left); free(right);
} else {
char **argv = tokenize(line);
if (argv[0]) run_single(argv);
free(argv);
}
printf("mini$ ");
}
return 0;
}gcc -o mini mini.c
./mini
mini$ ls -la
mini$ ps aux | grep bash
mini$ echo hello | wc -cপরীক্ষা করুন bug-টা ইচ্ছে করে ঢুকিয়ে: run_pipeline-এ
parent-এর close(fds[0]); close(fds[1]); দুইটা লাইন মন্তব্য
করে দিন, তারপর ps aux | grep bash চালান — hang করবে, কারণ
parent-এ pipe-এর write end এখনো খোলা, তাই grep কখনো EOF
পায় না। এটাই সেই bug যা “Build a Shell” প্রজেক্টে পুরো
বিস্তারিত আলোচনা হয়েছে।
নিজে বাড়ান:
- তিন-বা-তার-বেশি কমান্ডের pipeline সমর্থন করুন
(
cmd1 | cmd2 | cmd3) >redirection যোগ করুনcdbuilt-in যোগ করুন — কেন এটাforkকরা যাবে না তা মনে রাখুন (গত লেসনের সংযোগ —chdirprocess-এর নিজের state বদলায়, child-এ করলে exec-এর পর হারিয়ে যায়)WIFSIGNALEDচেক করে child যদি signal-এ মারা যায় তা রিপোর্ট করুন- সম্পূর্ণ সংস্করণের জন্য Build a Shell প্রজেক্ট দেখুন — job control, background execution, আর signal handling সহ
বাস্তব সিস্টেমে
Fork-exec-wait যেখানে চলছে
প্রতিটা শেল আর pipeline। bash, zsh, fish — সবাই এই
প্যাটার্নে চলে। Build a Shell প্রজেক্টে আমরা এটা সম্পূর্ণভাবে
বানাব।
Apache-এর prefork MPM। পুরনো Apache একটা master process
fork() করে বহু worker child বানাত, প্রতিটা একটা independent
address space নিয়ে request সামলাত — একটা crash অন্যদের ছোঁয় না,
COW-এর কারণে memory খরচও সহনীয় ছিল।
git-এর subprocess model। git commit চালালে ভেতরে
git বহু জায়গায় fork+exec করে external tool চালায়
(less, vim, credential helper) — প্রতিটাই এই একই তিন-ধাপ।
Container runtime। docker run আসলে ভেতরে fork()
(বা clone(), লেসন ২৯-এ বিস্তারিত) করে একটা নতুন process
বানায়, namespace flag সহ, তারপর execve() দিয়ে container-এর
entrypoint চালায়।
CI/CD pipeline runner। GitHub Actions বা Jenkins-এর একটা
runner প্রতিটা step-এর জন্য fork+exec করে, wait() দিয়ে
exit code দেখে পরের step চালাবে কি না ঠিক করে — ঠিক shell-এর
&& অপারেটরের মতো ভেতরে।
system() আর popen()। libc-র এই সুবিধাজনক function
দুটো ভেতরে ঠিক এই fork+exec+wait নাচ করে — system("ls")
মানে fork, তারপর child-এ execl("/bin/sh", "sh", "-c", "ls", NULL)।
যে ভুলগুলো সবাই করে
“fork() সত্যিই পুরো memory copy করে, তাই এটা ধীর হওয়া উচিত।”
বাস্তবে fork() প্রায় সবসময় দ্রুত — কয়েক মাইক্রোসেকেন্ড,
memory-র আকার নির্বিশেষে।
Copy-on-write-এর কারণে fork()-এর প্রকৃত খরচ page table
entry কপি করা (physical frame নয়) — যেটা মূল memory-র আকারের
সমানুপাতিক নয়, বরং page table-এর আকারের সমানুপাতিক।
আজকের experiment-এ দেখেছি ২০০ MB allocate করেও fork()
মিলিসেকেন্ডের মধ্যে ফেরত এসেছে।
যেখানে সত্যিই ধীর হতে পারে: যদি child পরে অনেক page-এ লেখে (COW fault-এর ঝর্ণা), অথবা যদি process-এর হাজার হাজার mapped region থাকে (page table নিজেই বড়)। কিন্তু “large address space = slow fork” — এই সরল ধারণাটা সাধারণত ভুল।
“fork() ব্যর্থ হতে পারে না, এটা সবসময় সফল হয়।”
fork() ব্যর্থ হতে পারে, আর production কোডে এটা যাচাই না
করা একটা সাধারণ bug।
pid_t pid = fork();
if (pid < 0) {
perror("fork"); /* ⚠ এই check বাদ দেওয়া হয় প্রায়ই */
/* উদাহরণ: EAGAIN (process limit ছুঁয়েছে), ENOMEM */
}কখন ব্যর্থ হয়: RLIMIT_NPROC ছুঁয়ে গেলে (ulimit -u),
system-wide PID সীমা পূর্ণ হলে, অথবা kernel এত memory
commit করতে না চাইলে (overcommit_memory সেটিং, লেসন ১০)।
Fork bomb (:(){ :|:& };:) ঠিক এই সীমাগুলোর বিরুদ্ধে ধাক্কা
দেয় — আর একটা ভালোভাবে কনফিগার করা সিস্টেমে RLIMIT_NPROC
সেই bomb-কে নিয়ন্ত্রণে রাখে, fork()-কে ব্যর্থ করে দিয়ে
নতুন process তৈরি বন্ধ করে।
সঠিক কোড সবসময় তিনটা case handle করে: pid < 0 (ব্যর্থ),
pid == 0 (child), pid > 0 (parent)।
“exec() একটা নতুন process তৈরি করে।”
না — exec() কোনো নতুন process তৈরি করে না। এটা
বিদ্যমান process-এর ভেতরের সবকিছু প্রতিস্থাপন করে।
printf("আমার PID: %d\n", getpid());
execve("/bin/ls", ...);
/* উপরের printf-এর PID আর ls-এর PID — একই সংখ্যা */এই ভুল ধারণাটা বিশেষভাবে বিপজ্জনক যখন কেউ ভাবে “নতুন process
মানে নতুন isolation” — কিন্তু exec() একা কোনো isolation
তৈরি করে না, শুধু কোড বদলায়। Isolation আসে fork() থেকে
(নতুন task_struct, নতুন address space) অথবা namespace থেকে
(লেসন ২৯)।
মনে রাখার নিয়ম: fork() = নতুন পরিচয় তৈরি করে, একই কোড
নিয়ে। exec() = একই পরিচয় রেখে, নতুন কোড বসায়। দুটো
সম্পূর্ণ বিপরীত কাজ, প্রায়ই একসাথে ব্যবহৃত।
“parent process শেষ হয়ে গেলে তার সব child-ও শেষ হয়ে যায়।”
স্বয়ংক্রিয়ভাবে না — child orphan হয়ে বেঁচে থাকে, শুধু তার PPID বদলে যায় (গত অংশে দেখেছি — PID 1-এর কাছে reparent)।
sleep 300 &
disown # shell-এর job table থেকে সরান
exit # terminal বন্ধ করুনকয়েক মিনিট পর অন্য terminal থেকে ps aux | grep sleep
চালালে সেই sleep এখনো চলছে দেখবেন, PPID এখন 1।
যেখানে এটা bug তৈরি করে: একটা parent process crash
করলে বা kill-এ মারা গেলে, তার সব child (worker thread pool,
subprocess) এতিম হয়ে চলতে থাকে, resource ধরে রাখে,
কেউ তাদের পরিষ্কার করে না।
সমাধান — process group ব্যবহার:
kill -- -$PGID # negative PGID মানে পুরো groupঅথবা Linux-এ PR_SET_PDEATHSIG (prctl) দিয়ে child-কে বলে
দেওয়া যায় “parent মারা গেলে আমাকেও একটা signal পাঠাও” —
এটাই সেই primitive যা দিয়ে সঠিক cleanup নিশ্চিত করা যায়।
Level 4-এর জব কন্ট্রোল-সংক্রান্ত অংশে (Build a Shell
প্রজেক্ট) আমরা এই ধরনের group-ভিত্তিক নিয়ন্ত্রণ দেখব।
বুঝেছেন কি না দেখুন
1এই কোডটা কী ছাপাবে, আর কেন?
int x = 10;
pid_t pid = fork();
x = x + 1;
if (pid == 0)
printf("child: x = %d\n", x);
else
printf("parent: x = %d\n", x);
প্রয়োগ
int x = 10;
pid_t pid = fork();
x = x + 1;
if (pid == 0)
printf("child: x = %d\n", x);
else
printf("parent: x = %d\n", x);দুটোই ছাপাবে: x = 11।
child: x = 11
parent: x = 11(ক্রম নির্ধারিত নয় — scheduler ঠিক করে কে আগে চলবে।)
কারণ: fork()-এর মুহূর্তে x-এর মান (10) child-এ
কপি হয়ে যায় (logically — বাস্তবে COW-এর মাধ্যমে)। এরপর দুই
process সম্পূর্ণ স্বাধীন, একে অপরের memory-কে প্রভাবিত
করে না।
x = x + 1 লাইনটা দুইবার চলে — একবার parent-এ, একবার
child-এ, প্রতিটা তার নিজস্ব কপির উপর। কোনো race condition
নেই, কারণ দুইটা আলাদা physical memory (COW fault-এর পরে)।
তুলনা করুন threading-এর সাথে (গত-গত লেসন):
int x = 10;
/* যদি এখানে fork()-এর বদলে pthread_create() হতো */
x = x + 1; /* দুইটা thread-ই একই x শেয়ার করত — race condition! */Thread-এ একই memory শেয়ার হয়, তাই x = x + 1 একটা read-modify-
write যা দুই thread একসাথে করলে একটা increment হারিয়ে যেতে
পারে (Level 4-এর synchronization লেসনে এই ক্লাসিক bug-টা
বিস্তারিত)। Process-এ সেই সমস্যা নেই, কারণ address space
আলাদা — এটাই process-based আর thread-based concurrency-র
একটা মৌলিক নিরাপত্তা পার্থক্য।
2কেন fork() কে “child-এর জন্য 0 রিটার্ন করে” এমনভাবে ডিজাইন
করা হলো, PID না দিয়ে? Child তার নিজের PID কীভাবে জানবে?
যুক্তি
fork() কে “child-এর জন্য 0 রিটার্ন করে” এমনভাবে ডিজাইন
করা হলো, PID না দিয়ে? Child তার নিজের PID কীভাবে জানবে?ডিজাইনের যুক্তি — child-এর নিজের PID জানার জন্য 0-এর
দরকার নেই, কারণ সেটা getpid() দিয়েই পাওয়া যায়:
pid_t pid = fork();
if (pid == 0) {
printf("আমার PID: %d\n", getpid()); /* এখানে জানা যায় */
}fork()-এর return value-র আসল কাজ ভিন্ন — কোন branch-এ
আছি সেটা বলা, PID জানানো নয়। আর 0 এই কাজে বিশেষভাবে
উপযুক্ত, কারণ:
১. 0 কখনো একটা বৈধ PID নয়। PID শুরু হয় 1 থেকে
(init/systemd)। তাই 0 একটা sentinel value হিসেবে
নিরাপদ — কোনো বিভ্রান্তির সুযোগ নেই যে “child-এর PID
আসলেই 0 কি না” ভাবতে হবে।
২. দুইটা ভিন্ন তথ্য, দুইটা ভিন্ন query।
| আমি কে | আমি কার সন্তান |
|---|---|
getpid() | (fork করার আগেই জানা, বা getppid()) |
| — | fork()-এর return value parent-কে বলে child কে |
Parent-এর fork()-এর return value (child-এর PID) parent-এর
জন্য দরকারি — সে পরে waitpid(child_pid, ...) ডাকবে, বা
সেই child-কে signal পাঠাবে। Child-এর নিজের এই তথ্যটার দরকারই
নেই, কারণ সে নিজেই সেই PID — getpid() তাকে সরাসরি দেয়।
৩. এক function, দুই ভূমিকা, দুই ধরনের প্রয়োজনীয় তথ্য —
আর fork() সেই দুই তথ্যই ঠিক ঠিক জায়গায় দিয়ে দেয়:
pid_t pid = fork();
/* parent পায়: child-এর PID (তাকে নিয়ন্ত্রণ করতে) */
/* child পায়: 0 (branch চেনার জন্য, নিজের PID না — সেটা getpid()-এ) */এটা একটা elegant নকশা — একটামাত্র return value দিয়ে দুইটা ভিন্ন প্রশ্নের ঠিক ততটুকু উত্তর দেওয়া যতটা প্রতিটা branch-এর আসলেই দরকার।
3আপনি একটা web server লিখছেন যা প্রতিটা incoming connection-এর
জন্য একটা নতুন process fork() করে সামলায় (Apache prefork
মডেলের মতো)। এই নকশার সুবিধা আর অসুবিধা কী, আর thread-based
মডেলের সাথে তুলনায় কখন এটা যুক্তিসঙ্গত?
ডিজাইন
fork() করে সামলায় (Apache prefork
মডেলের মতো)। এই নকশার সুবিধা আর অসুবিধা কী, আর thread-based
মডেলের সাথে তুলনায় কখন এটা যুক্তিসঙ্গত?সুবিধা — isolation বিনামূল্যে (প্রায়):
প্রতিটা connection সম্পূর্ণ আলাদা address space-এ চলে। একটা connection handler crash করলে (segfault, out-of-memory) — শুধু সেই একটা process মরে, বাকি সব connection অক্ষত। এই নকশায় memory corruption bug (buffer overflow, use-after-free) অন্য connection-এর ডেটা দূষিত করতে পারে না — কারণ physically আলাদা memory।
Thread-based মডেলে একই process-এর সব thread memory শেয়ার করে (লেসন ৬), তাই একটা corruption bug potentially সব connection-কে প্রভাবিত করতে পারে।
অসুবিধা — খরচ ও জটিলতা:
| Process-per-connection | Thread-per-connection | |
|---|---|---|
| তৈরির খরচ | বেশি (নতুন task_struct, address space) | কম (শুধু stack + TLS) |
| Context switch খরচ | বেশি (page table বদল, TLB flush) | কম (একই address space) |
| Memory শেয়ার করা | কঠিন (IPC লাগে — shared memory, socket) | সহজ (স্বাভাবিকভাবেই শেয়ার্ড) |
| Connection সংখ্যার সীমা | কয়েক হাজার (process limit, memory) | অনেক বেশি (হালকা) |
COW কীভাবে সাহায্য করে: fork()-এর পরপরই child সাধারণত
শুধু পড়ে (request পার্স, response পাঠানো) — তাই অনেক
page কখনো লেখা হয় না, COW-এর কারণে সেগুলো parent-এর সাথে
শেয়ার্ড থেকে যায়। এটাই কারণ prefork model বাস্তবে যতটা
ব্যয়বহুল মনে হয় ততটা নয়।
কখন এটা যুক্তিসঙ্গত:
- নিরাপত্তা-সংবেদনশীল কাজ — একটা connection-এর buffer overflow যেন অন্য user-এর session/memory ছুঁতে না পারে (privilege separation-এর একটা রূপ)
- তৃতীয়-পক্ষের/অবিশ্বস্ত কোড চালানো — CGI script, plugin
- মাঝারি concurrency (কয়েকশো-কয়েক হাজার connection, লক্ষ নয়)
- Crash isolation অগ্রাধিকার, throughput নয়
কখন যুক্তিসঙ্গত নয়:
- খুব বেশি concurrent connection (C10K+ সমস্যা — লেসন ২০-এ বিস্তারিত, যেখানে thread-per-connection-ও ভেঙে পড়ে আর event-driven মডেল লাগে)
- Connection-গুলোর মধ্যে ঘন ঘন শেয়ার্ড state দরকার (cache, connection pool) — তখন IPC-র overhead জেতার চেয়ে thread-এর স্বাভাবিক শেয়ারিং ভালো
বাস্তব বিশ্বে: আধুনিক high-throughput সার্ভার (nginx, node.js) এই দুইটার কোনোটাই ব্যবহার করে না প্রধান মডেল হিসেবে — তারা event-driven, single-threaded per worker (epoll-ভিত্তিক, লেসন ২০)। কয়েকটা worker process (fork-ভিত্তিক isolation-এর জন্য), প্রতিটা হাজার হাজার connection সামলায় non-blocking I/O দিয়ে — দুইটা জগতের ভালো দিক মেলানো।
4নিচের কোডে একটা bug আছে। এটা কী, আর কীভাবে ঠিক করবেন?
for (int i = 0; i < 5; i++) {
pid_t pid = fork();
if (pid == 0) {
printf("worker %d, PID %d\n", i, getpid());
_exit(0);
}
}
sleep(10);
প্রয়োগ
for (int i = 0; i < 5; i++) {
pid_t pid = fork();
if (pid == 0) {
printf("worker %d, PID %d\n", i, getpid());
_exit(0);
}
}
sleep(10);Bug — এটা ৫টা worker তৈরি করে না, বরং একটা exponential বিস্ফোরণ ঘটায়।
সমস্যা: fork() parent আর child দুটোতেই loop চালিয়ে
যায়, কারণ শুধু child-এ _exit(0) করা হচ্ছে — কিন্তু parent
কোনো break বা early-exit ছাড়াই লুপ চালিয়ে যাচ্ছে, আর সেই
parent-ও পরের iteration-এ আবার fork() ডাকছে।
ট্রেস করে দেখি:
iteration 0: মূল process fork করে → 2 process (1 parent + 1 child, child exit)
বাকি থাকে: 1 (মূল process)
iteration 1: মূল process আবার fork করে → child exit করে,
কিন্তু parent থেকে যায়
...আসলে সমস্যাটা আরো গভীর — প্রতিটা "বেঁচে থাকা" process-ই
পরের iteration-এ আবার fork করে, তাই:
i=0: 1 process → fork → 2 (1 exits) → 1 বেঁচে থাকে
i=1: সেই 1 → fork → 2 (1 exits) → 1 বেঁচে থাকেআসলে এই নির্দিষ্ট কোডে exponential বিস্ফোরণ ঘটে না —
কারণ প্রতিবার child সাথে সাথে _exit করছে, আর মূল process-ই
(যেটা pid != 0 পেয়েছে) একাই পরের iteration-এ যাচ্ছে।
প্রকৃত bug ভিন্ন, আর সূক্ষ্ম: প্রতিটা fork()-এ parent
process child-এর জন্য কখনো wait() ডাকে না। তাই প্রতিটা
child শেষ হয়ে zombie হয়ে যায়, আর সেগুলো sleep(10)-এর
পুরোটা সময় ধরে জমা থাকে — ৫টা zombie।
gcc -o buggy buggy.c && ./buggy &
sleep 1
ps -o pid,ppid,stat,comm --ppid $!৫টা Z state entry দেখা যাবে।
ঠিক করা:
pid_t children[5];
for (int i = 0; i < 5; i++) {
pid_t pid = fork();
if (pid == 0) {
printf("worker %d, PID %d\n", i, getpid());
_exit(0);
}
children[i] = pid; /* শুধু parent-এ এটা রাখা হয়, child-এ কখনো পৌঁছায় না */
}
for (int i = 0; i < 5; i++)
waitpid(children[i], NULL, 0);একটা দ্বিতীয় সূক্ষ্ম বিপদ যা এখানে নেই কিন্তু সহজেই ঘটতে
পারত: যদি if (pid == 0)-এর ভেতরে _exit(0) না থেকে
return 0 বা কিছুই না থাকত, তাহলে child-ও loop-এর বাকি
অংশ চালিয়ে যেত — অর্থাৎ child নিজেও আবার fork() ডাকত!
সেটাই প্রকৃত exponential বিস্ফোরণ ঘটাত: i=0-এ ২টা process
(1 fork করে), i=1-এ প্রতিটা আবার fork করে ৪টা, i=2-এ ৮টা…
i=4-এ ৩২টা process। এটাই একটা ছোট fork bomb-এর গঠন,
আর _exit(0) (বা loop থেকে break) ভুলে যাওয়াই এর কারণ।
সাধারণ নিয়ম: fork()-এর child branch-এ সবসময় একটা
স্পষ্ট _exit() বা exec() দিয়ে শেষ করুন, যাতে child কখনো
ভুল করে বাকি কোড চালিয়ে না যায়।
5fork()-এর পরে child-এ open file descriptor “কপি” হয় বলা
হয়। এর মানে কি child আর parent-এর জন্য আলাদা file offset,
নাকি শেয়ার্ড?
যুক্তি
fork()-এর পরে child-এ open file descriptor “কপি” হয় বলা
হয়। এর মানে কি child আর parent-এর জন্য আলাদা file offset,
নাকি শেয়ার্ড?শেয়ার্ড — এটা একটা গুরুত্বপূর্ণ সূক্ষ্মতা যা file-descriptor লেসনে (লেসন ১৬) বিস্তারিত আসবে, কিন্তু এখানেই প্রথম দেখা দরকার।
মনে করুন file descriptor-এর তিন-স্তরের indirection: fd table → open file description (offset + flag এখানে) → inode।
fork()-এ কপি হয় শুধু fd table-এর entry — অর্থাৎ
parent আর child-এর fd number (যেমন 3) একই open file
description-কে নির্দেশ করে।
int fd = open("log.txt", O_WRONLY | O_APPEND);
pid_t pid = fork();
if (pid == 0) {
write(fd, "child\n", 6); /* offset বাড়ায় */
_exit(0);
}
waitpid(pid, NULL, 0);
write(fd, "parent\n", 7); /* child-এর offset বৃদ্ধির পরে থেকে লেখে */ফলাফল: ফাইলে child\nparent\n — দুইটা write সংঘর্ষ না
করে পরপর বসেছে, কারণ offset শেয়ার্ড ছিল। যদি এটা দুইটা
স্বতন্ত্র open() কল হতো (দুই আলাদা open file description),
দুটোই offset 0-এ শুরু করত আর একে অপরকে overwrite করতে
পারত।
এটা কেন গুরুত্বপূর্ণ — একটা বাস্তব ব্যবহার:
Web server-এর log file-এ multiple worker process লেখে।
যদি প্রতিটা worker আলাদাভাবে open(..., O_APPEND) করে, O_APPEND
প্রতিটা write-কে atomically ফাইলের শেষে বসায় (kernel-স্তরে
guaranteed) — কিন্তু যদি একটা মাস্টার process file খুলে
fork করে সব worker-এ fd শেয়ার করে, তাহলে offset-ও শেয়ার্ড
থাকে, যা কিছু ব্যবহারে আরো predictable আচরণ দেয়।
তুলনা করুন dup()-এর সাথে:
int fd1 = open("f", O_RDONLY);
int fd2 = dup(fd1); /* একই process-এ — offset শেয়ার্ড */
/* বনাম */
int fd3 = open("f", O_RDONLY);
int fd4 = open("f", O_RDONLY); /* দুইটা আলাদা open — offset আলাদা */fork()-এর fd কপি ঠিক dup()-এর মতো আচরণ করে — নতুন fd
number, কিন্তু একই underlying open file description। এই
সংযোগটা মনে রাখলে লেসন ১৬-এর তিন-স্তরের diagram সহজেই
বোধগম্য হবে।
এরপর কী
পরের লেসন — Thread
এই লেসন পর্যন্ত আমরা concurrency-র জন্য শুধু process
ব্যবহার করেছি — প্রতিটা fork() একটা সম্পূর্ণ নতুন address
space তৈরি করে, isolation দেয় কিন্তু শেয়ারিং কঠিন করে তোলে।
কিন্তু প্রশ্ন থেকে যায়: যদি আমি isolation না চাই, শুধু একসাথে অনেক কাজ করতে চাই — একটা web server যেখানে হাজার হাজার connection একই cache, একই connection pool শেয়ার করবে — তাহলে প্রতিবার পুরো address space আলাদা করার কী দরকার?
উত্তর thread — যেখানে একাধিক execution context একই
address space-এর ভেতরে সহাবস্থান করে। এটাই এই মডিউলের পরবর্তী
প্রধান বিষয়, আর যেহেতু threading-এর প্রায় সবটাই ইতিমধ্যে
লেখা হয়ে গেছে (threads.mdx), সেখানে আমরা দেখেছি Linux-এ
thread আর process আসলে কতটা কাছাকাছি — দুটোই একটা
task_struct, পার্থক্য শুধু clone()-এর flag-এ।
আর তারপর আসবে সবচেয়ে ব্যবহারিক প্রশ্ন: যখন একাধিক execution
context একই memory শেয়ার করে, তখন x = x + 1-এর মতো সরল
একটা লাইনও কীভাবে race condition-এ পরিণত হতে পারে — আর সেই
সমস্যা সমাধানের হাতিয়ারগুলো (mutex, semaphore, condition
variable) কীভাবে কাজ করে।
আরও পড়ুন
- The Linux Programming Interface, Chapter 24-27 — Process Creation, Termination, Monitoring — Michael Kerrisk · fork/exec/wait-এর সবচেয়ে নির্ভুল ও বিস্তারিত বর্ণনা, প্রতিটা edge case সহ
- A fork() in the road — Andrew Baumann, Jonathan Appavoo, Orran Krieger, Timothy Roscoe (HotOS 2019) · fork()-কে কেন একটা ঐতিহাসিক ভুল বলা হয় — আধুনিক system-এ এর সমস্যাগুলোর একটা প্রভাবশালী সমালোচনা
- fork(2), execve(2), wait(2), vfork(2) man pages · প্রতিটা syscall-এর প্রামাণ্য semantics