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

fork, exec, wait — যে function দুইবার return করে

fork, exec, and wait

গত লেসনে প্রশ্ন রেখে গিয়েছিলাম — একটা নতুন process কোথা থেকে আসে? উত্তর একটা অদ্ভুত function-এ, যেটা একবার ডাকা হয় কিন্তু দুইবার return করে। এই লেসনে আমরা দেখব fork() কীভাবে একটা প্রায়-অভিন্ন কপি তৈরি করে অথচ পুরো address space কপি করে না (copy-on-write), exec() কীভাবে সেই কপির ভেতরে সম্পূর্ণ নতুন একটা প্রোগ্রাম বসিয়ে দেয়, আর wait() কীভাবে parent-কে child-এর মৃত্যুর খবর পৌঁছে দেয় — আর কী হয় যখন সেই খবর কখনো নেওয়া হয় না।

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

  • `fork()` কেন 'দুইবার return করে' এবং সেই দুইটা return value দিয়ে parent/child কোড কীভাবে আলাদা করা হয় তা নির্ভুলভাবে ব্যাখ্যা করতে পারবেন
  • Copy-on-write কীভাবে page table-এর স্তরে কাজ করে তা বর্ণনা করতে পারবেন, আর কেন সেটা `fork()`-কে 'সস্তা' করে তোলে
  • `exec()` পরিবারের function কী প্রতিস্থাপন করে আর কী অক্ষত রাখে (বিশেষত PID) তা স্পষ্টভাবে বলতে পারবেন
  • `wait()`/`waitpid()`-এর reaping contract, আর zombie/orphan কীভাবে তৈরি হয় ও সমাধান হয় তা ব্যাখ্যা করতে পারবেন
  • Threaded প্রোগ্রামে `fork()` কেন বিপজ্জনক — fork আর exec-এর মাঝে শুধু async-signal-safe function ডাকা কেন বাধ্যতামূলক — তা যুক্তিসহ বলতে পারবেন
  • একটা mini shell লিখে fork/exec/wait/pipe একসাথে প্রয়োগ করতে পারবেন

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

আগে এটা বুঝি

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 descriptorPPID (নতুন child-এর parent = caller)
Signal handler, maskPending signal
Environment variableFile 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()-এর মুহূর্তে ঠিক কী ঘটে
  1. fork() syscallkernel-এ ঢোকা
  2. নতুন task_structনতুন PID, নতুন mm_struct
  3. Page table কপি (entry-level)physical frame কপি নয়, শুধু mapping
  4. সব writable page → read-onlyparent ও child উভয়ের জন্য
  5. Physical frame-এর refcount বাড়ানোএকই frame, দুইটা owner
  6. 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 directorysignal handler → default-এ ফিরে যায়
uid/gidargv, 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 sleep

sleep-এর 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 → মুক্ত।

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

EXPERIMENT

Copy-on-write নিজের চোখে দেখুন

Linux, gcc· ১৫ মিনিট
#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
wait

Shared_Clean বড় সংখ্যা দেখাবে fork-এর ঠিক পরে — এটাই COW-এর আসল প্রমাণ, কারণ “shared” মানে দুই process একই physical page নির্দেশ করছে।

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

fork() সত্যিই memory copy করে না — শুধু touch করা page-ই copy হয়, আর সেটা RSS মেপে সরাসরি প্রমাণ করা যায়।

EXPERIMENT

Zombie তৈরি করে দেখা, তারপর reap করা

Linux, bash অথবা C· ১০ মিনিট
#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, দৃশ্যমান ও পরিমাপযোগ্য।

নিজে বানান

BUILD IT

একটা Mini Shell — fork, exec, pipe, wait একসাথে

C · ●●●●○
  1. একটা লাইন পড়ে দুইটা কমান্ডে ভাগ করুন (একটা pipe চিহ্ন দিয়ে)
  2. একটা pipe() বানান
  3. দুইটা fork() করুন — একটা বাঁ কমান্ডের জন্য, একটা ডান কমান্ডের জন্য
  4. প্রতিটা child-এ সঠিক dup2 আর অব্যবহৃত fd close করুন
  5. 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” প্রজেক্টে পুরো বিস্তারিত আলোচনা হয়েছে।

নিজে বাড়ান:

  1. তিন-বা-তার-বেশি কমান্ডের pipeline সমর্থন করুন (cmd1 | cmd2 | cmd3)
  2. > redirection যোগ করুন
  3. cd built-in যোগ করুন — কেন এটা fork করা যাবে না তা মনে রাখুন (গত লেসনের সংযোগ — chdir process-এর নিজের state বদলায়, child-এ করলে exec-এর পর হারিয়ে যায়)
  4. WIFSIGNALED চেক করে child যদি signal-এ মারা যায় তা রিপোর্ট করুন
  5. সম্পূর্ণ সংস্করণের জন্য 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);
প্রয়োগ

দুটোই ছাপাবে: 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 কীভাবে জানবে?

যুক্তি

ডিজাইনের যুক্তি — 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 মডেলের সাথে তুলনায় কখন এটা যুক্তিসঙ্গত?

ডিজাইন

সুবিধা — 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-connectionThread-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);
প্রয়োগ

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 কখনো ভুল করে বাকি কোড চালিয়ে না যায়।

5

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