CHƯƠNG 02 · PROCESS · ~110 phút

Process
& Process Control Block

Process = chương trình đang chạy. Đây là khái niệm cốt lõi nhất của OS. Học chương này, bạn nắm: memory layout 1 process, PCB là gì, vòng đời 5 trạng thái, fork/exec, zombie/orphan, và cách dùng ps/top//proc để quan sát process thật.

2.1 Program vs Process — đừng nhầm!

Program

  • File tĩnh trên disk (vd /usr/bin/ls)
  • Chứa instructions + initial data
  • Không "chạy", không có state
  • Là một entity passive

Process

  • Program đang chạy trong RAM
  • Có PID, state, registers, stack, heap riêng
  • Được kernel quản lý qua PCB
  • Là một entity active

1 program → nhiều process: mở 5 cửa sổ Chrome = 1 program nhưng nhiều process. 1 process → 1 program tại 1 thời điểm: nhưng có thể "thay" program khác qua execve (xem 2.6).

2.2 Memory Layout của 1 Process

Khi process chạy, kernel cấp cho nó một virtual address space, chia làm các vùng:

High address ┌─────────────────────┐ 0x7fff... │ Stack │ ← function call frames, local vars │ ↓ grows down │ (LIFO) │ │ │ │ │ ↑ grows up │ │ Heap │ ← malloc, new — dynamic alloc ├─────────────────────┤ │ BSS │ ← uninit'd globals (set 0) ├─────────────────────┤ │ Data │ ← init'd globals/static ├─────────────────────┤ │ Text (Code) │ ← machine instructions └─────────────────────┘ 0x0000... Low address

Chi tiết từng vùng

  • Text (Code): instructions của program. Read-only — bảo vệ khỏi modify.
  • Data: biến global đã khởi tạo (vd int x = 5;).
  • BSS: biến global chưa khởi tạo (vd int y;) — kernel zero-fill khi load.
  • Heap: cấp phát động bằng malloc/new. Tăng lên (low → high) qua syscall brk/sbrk.
  • Stack: function call. Mỗi call push 1 frame (return address, params, local vars). Tăng xuống (high → low).

Phân biệt 3 lỗi memory phổ biến

LỗiNguyên nhânVùng liên quan
Stack OverflowĐệ quy quá sâu, mảng stack quá lớnStack đụng heap
Heap Overflow / Buffer OverflowGhi quá kích thước buffer cấp phátHeap
Memory LeakCấp phát mà quên freeHeap (lớn dần không thu hồi)
Use-after-freeDùng pointer sau khi freeHeap (UB)
Segmentation FaultTruy cập memory không thuộc processBất kỳ — kernel block
# Xem memory layout của process running trên Linux
cat /proc/$$/maps | head
# 555555554000-555555558000 r--p 00000000 fd:01 ...   /usr/bin/bash  (text)
# 555555558000-555555637000 r-xp 00004000 fd:01 ...   /usr/bin/bash  (text exec)
# ...
# 7fffffde0000-7fffffe01000 rw-p 00000000 00:00 0     [stack]
# ...

2.3 Process Control Block (PCB) — "thẻ căn cước" của process

Kernel quản lý mỗi process bằng cấu trúc PCB (trong Linux gọi là task_struct, ~10KB). Mỗi process có 1 PCB lưu mọi thứ cần để khôi phục trạng thái khi switch CPU.

PCB chứa gì?

┌─────────────────────────────────────────┐ │ PROCESS CONTROL BLOCK (task_struct) │ ├─────────────────────────────────────────┤ │ PID (Process ID) │ │ PPID (Parent PID) │ │ UID, GID (owner) │ │ State (Ready / Running / Blocked / ...)│ │ Priority & nice value │ ├─────────────────────────────────────────┤ │ CPU registers snapshot (khi switch) │ │ Program counter │ │ Stack pointer │ ├─────────────────────────────────────────┤ │ Memory info (page table pointer, │ │ mm_struct, vm areas) │ ├─────────────────────────────────────────┤ │ File descriptor table (mảng fd) │ │ Working directory │ ├─────────────────────────────────────────┤ │ Signal handlers │ │ Pending signals │ ├─────────────────────────────────────────┤ │ Statistics: CPU time used, │ │ start time, page faults, ... │ ├─────────────────────────────────────────┤ │ Pointers: parent, children, siblings │ └─────────────────────────────────────────┘

Khi context switch xảy ra, kernel:

  1. Lưu CPU state hiện tại vào PCB của process đang chạy
  2. Load CPU state từ PCB của process kế tiếp
  3. Nhảy đến program counter của process mới → process tiếp tục

Tất cả diễn ra trong <1 microsecond. Hàng nghìn lần mỗi giây trên máy bận.

2.4 Process States — vòng đời 5 trạng thái

┌──────────────┐ admit │ │ exit ───────▶ │ NEW │ ──┐ │ │ │ └──────┬───────┘ │ │ scheduler │ ▼ │ ┌──────────────┐│ ││ READY │◀────┐ │ │ │ │ │ └──────┬───────┘ │ │ │ dispatch │ │ ▼ │ │ ┌──────────────┐ │ │ │ │ done│ ▼ │ RUNNING │─────┴───▶ ┌────────────┐ │ │ │ TERMINATED │ └──────┬───────┘ └────────────┘ │ I/O wait ▼ ┌──────────────┐ │ │ │ BLOCKED │ │ (waiting) │ └──────────────┘ │ I/O ready └─────────────▶ READY
  • NEW — vừa được tạo, chưa load đầy đủ
  • READY — sẵn sàng chạy, đang đợi CPU
  • RUNNING — đang sử dụng CPU
  • BLOCKED — đợi I/O hoặc event (vd đọc file, đợi network)
  • TERMINATED — đã exit, đợi parent collect

Linux state code (xem trong ps)

CodeÝ nghĩa
RRunning hoặc Runnable (ready)
SSleeping (interruptible — đợi I/O bình thường)
DUninterruptible sleep (không thể bị interrupt — vd đợi disk)
ZZombie
TStopped (bằng SIGSTOP)
ps -ax -o pid,state,comm | head
#   PID S COMMAND
#     1 S systemd
#   123 S /usr/bin/python
#   456 R top
#   789 D rsync       ← đợi disk, không thể kill được

2.5 fork() — sao chép process

fork() tạo process con bằng cách sao chép parent. Sau khi gọi:

  • Có 2 process: parent và child
  • Child có PID khác, PPID = PID của parent
  • Child có copy của memory parent (text, data, heap, stack)
  • Child có copy của file descriptors (cả 2 cùng trỏ vào file đang mở)
  • fork() trả về 2 lần: trong parent trả PID con; trong child trả 0
#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork failed");
        return 1;
    } else if (pid == 0) {
        // Child code
        printf("[child]  PID=%d, PPID=%d\n", getpid(), getppid());
    } else {
        // Parent code
        printf("[parent] PID=%d, child PID=%d\n", getpid(), pid);
    }
    return 0;
}

// Output (thứ tự có thể khác do scheduling):
// [parent] PID=1234, child PID=1235
// [child]  PID=1235, PPID=1234

Copy-on-Write — tối ưu thông minh

Tưởng fork phải copy hàng GB memory? Không! Linux dùng Copy-on-Write (CoW):

  • Sau fork, parent & child ban đầu chia sẻ page memory (đánh dấu read-only)
  • Khi một bên ghi vào page, kernel mới copy page đó cho bên kia
  • Trong nhiều trường hợp (fork rồi exec ngay), không bao giờ phải copy thật

Đây là lý do fork trong Linux nhanh đến không ngờ.

2.6 exec() — biến process thành program khác

exec() family (execve, execvp...) thay thế toàn bộ memory image của process hiện tại bằng program mới. Cùng PID, cùng PPID, nhưng code hoàn toàn khác.

#include <unistd.h>
int main() {
    char *args[] = { "/bin/ls", "-l", "/tmp", NULL };
    execv("/bin/ls", args);
    // Nếu thành công, dòng này không bao giờ được chạy
    perror("exec failed");
    return 1;
}

Pattern fork + exec — chạy program khác

// Đây là cách Unix shell chạy command:
pid_t pid = fork();
if (pid == 0) {
    // Child: replace mình thành /bin/ls
    execlp("ls", "ls", "-l", NULL);
    exit(1);  // chỉ chạy nếu exec fail
} else {
    // Parent: đợi child kết thúc
    int status;
    waitpid(pid, &status, 0);
}

Bash, zsh, sh đều làm thế này khi bạn gõ command. Đó là tại sao child process có cùng PPID = shell PID.

2.7 wait() & exit() — kết thúc process

exit(code) kết thúc process, trả về exit code (0 = success, ≠ 0 = error). wait(&status) hoặc waitpid() để parent đợi child kết thúc và lấy exit code.

pid_t pid = fork();
if (pid == 0) {
    // Child
    sleep(2);
    exit(42);  // exit code 42
} else {
    int status;
    waitpid(pid, &status, 0);
    if (WIFEXITED(status)) {
        printf("Child exit code: %d\n", WEXITSTATUS(status));
    }
}
// Output: Child exit code: 42

Trong shell

ls /no-such-dir
echo $?    # 2 — exit code của lệnh trước

true
echo $?    # 0

# Chạy chain dựa trên exit code
make && ./run    # chỉ chạy ./run nếu make success
make || echo "build failed"

2.8 Zombie & Orphan Process — bug "kinh dị" cần biết

Zombie Process 🧟

Khi child kết thúc (exit) nhưng parent chưa gọi wait(), kernel vẫn giữ PCB của child (chỉ giữ exit code, đã giải phóng memory) đợi parent collect.

Process trong trạng thái này gọi là zombie. Nó "chết" nhưng "chưa được chôn". Nếu parent là buggy không bao giờ wait, zombie tích tụ → cạn PID.

// Code tạo zombie
pid_t pid = fork();
if (pid == 0) {
    exit(0);  // child exit
} else {
    // parent KHÔNG gọi wait!
    sleep(60);  // child là zombie suốt 60s
}
# Quan sát zombie:
ps -ax -o pid,state,comm | grep Z
# 1234 Z   defunct  ← zombie!

Orphan Process

Ngược lại: parent kết thúc trước child. Child thành "mồ côi". Linux: kernel tự động re-parent orphan về PID 1 (init/systemd). init làm việc wait() giúp, nên orphan không thành zombie.

pid_t pid = fork();
if (pid == 0) {
    sleep(10);
    printf("My new parent: %d\n", getppid());  // PPID = 1 (init)
} else {
    exit(0);  // parent chết ngay
}

Cách xử lý zombie trong code thật

// Cách 1: Bắt SIGCHLD và auto-reap
#include <signal.h>
signal(SIGCHLD, SIG_IGN);  // ignore SIGCHLD → kernel auto-reap

// Cách 2: Bắt SIGCHLD handler
void handler(int sig) {
    while (waitpid(-1, NULL, WNOHANG) > 0) {}
}
signal(SIGCHLD, handler);
⚠️ Trong Docker container
Nếu bạn chạy node app.js làm PID 1 trong container, app trở thành init. Nó phải biết reap orphan. Nếu không, zombie có thể tích tụ. Cách giải quyết: dùng tini làm PID 1 hoặc --init flag của Docker.

2.9 Quan sát Process trên Linux/macOS

ps — snapshot

ps                    # process của shell hiện tại
ps aux                # tất cả process, format BSD
ps -ef                # format System V
ps -ax -o pid,ppid,state,comm,%cpu,%mem    # custom columns

# Tree view
ps auxf                # Linux
pstree -p              # tree với PID

top / htop — real-time

top                   # built-in
htop                  # đẹp hơn, interactive (cần install)
btop                  # modern alternative

/proc filesystem (Linux only) — kho báu

ls /proc/             # mỗi PID là 1 directory
ls /proc/1/           # info của init
cat /proc/1/status    # state, memory
cat /proc/1/cmdline   # command line khi start
cat /proc/1/maps      # memory regions
ls /proc/1/fd/        # file descriptors

kill / killall

kill 1234              # gửi SIGTERM (15) — process tự cleanup
kill -9 1234           # SIGKILL — force, không clean
kill -STOP 1234        # tạm dừng
kill -CONT 1234        # tiếp tục
killall node           # kill tất cả process tên "node"
pkill -f myapp.py      # kill bằng pattern command line

2.10 Node.js child_process — tạo process trong JS

const { spawn, exec, fork, execSync } = require('child_process');

// 1. spawn — chạy command, stream stdout/stderr
const ls = spawn('ls', ['-lh', '/tmp']);
ls.stdout.on('data', (data) => {
  console.log(data.toString());
});
ls.on('close', (code) => {
  console.log(`exit code: ${code}`);
});

// 2. exec — chạy + buffer toàn bộ output (cẩn thận memory!)
exec('ls /tmp', (err, stdout, stderr) => {
  if (err) console.error(err);
  console.log(stdout);
});

// 3. execSync — đồng bộ (block event loop, hạn chế dùng!)
const result = execSync('date').toString();

// 4. fork — chỉ cho Node child, có IPC channel
const child = fork('./worker.js');
child.send({ task: 'process_data' });
child.on('message', (msg) => {
  console.log('from child:', msg);
});

Trong worker.js:

process.on('message', (msg) => {
  if (msg.task === 'process_data') {
    // ... heavy work
    process.send({ result: 'done' });
  }
});

fork của Node.js KHÔNG phải fork() syscall. Nó tạo Node process mới (qua execve) và setup IPC channel để 2 process nói chuyện.

Bài tập

Bài 1 — Hello fork

Viết C program dùng fork() in "I am parent" / "I am child". Compile + chạy. Quan sát output.

Bài 2 — Tạo zombie

Viết C program tạo zombie (parent fork rồi sleep, child exit ngay). Mở terminal khác chạy ps -ax | grep Z.

Bài 3 — Orphan và init

Tương tự nhưng cho parent exit trước. Quan sát PPID của child sau đó (nên là 1).

Bài 4 — Mini shell

Viết C program đơn giản: đọc command từ stdin, fork + exec để chạy command đó, parent wait. Đây là bare-bones của bash.

Bài 5 — Memory layout

Trên Linux, viết C program in địa chỉ của: 1 biến global, 1 biến local, kết quả malloc, function pointer. Sắp xếp theo địa chỉ. Mỗi địa chỉ thuộc vùng nào (text/data/heap/stack)?

Bài 6 — child_process trong Node

Viết Node script dùng spawn chạy ping google.com -c 5, in từng line từ stdout với prefix timestamp.

Bài 7 — Đếm zombie trong system

Lệnh nào liệt kê tất cả zombie trên máy? ps aux | awk '$8 ~ /Z/' hay tương tự.

🧪 Quiz cuối chương

Câu 1. Sau fork(), parent và child có chia sẻ heap không?

  • Có, hai process dùng chung heap
  • Không — mỗi process có heap riêng (ban đầu chia sẻ qua Copy-on-Write nhưng tách nhau khi ghi)
  • Chỉ chia sẻ trên Linux, không trên macOS
  • Phụ thuộc vào kernel version

Đáp án: Không chia sẻ. Mỗi process có address space riêng. CoW giúp fork nhanh nhưng về logic process vẫn cô lập memory.

Câu 2. Khi fork() trả về 0, đó là code đang chạy ở đâu?

  • Lỗi xảy ra
  • Trong parent process
  • Trong child process
  • Trong kernel

Đáp án: Trong child. Parent nhận PID con; child nhận 0; lỗi nhận -1.

Câu 3. Zombie process là gì?

  • Process đã exit nhưng parent chưa gọi wait() để collect exit code
  • Process bị virus tấn công
  • Process chạy quá lâu
  • Process tự copy nhiều lần

Đáp án: đã exit nhưng chưa được wait. Kernel giữ PCB cho đến khi parent wait. Nếu parent buggy, zombie tích tụ.

Câu 4. Sau khi parent exit trước child, child trở thành?

  • Zombie
  • Orphan — được re-parent về PID 1 (init/systemd)
  • Tự kill mình
  • Chuyển sang chế độ kernel

Đáp án: Orphan, re-parent về init. init định kỳ wait() để dọn orphan, không tích tụ zombie.

Câu 5. Vùng nào của process tăng/giảm khi malloc()free()?

  • Stack
  • Text
  • Heap
  • Data

Đáp án: Heap. Stack cho local var; Heap cho dynamic alloc.

Câu 6. Tại sao fork() trên Linux nhanh dù process có nhiều GB memory?

  • Vì kernel chỉ copy 1 page
  • Vì child không thật sự có memory
  • Vì Copy-on-Write — chia sẻ page đến khi 1 bên ghi
  • Vì dùng RAM disk

Đáp án: Copy-on-Write. Linux đánh dấu page read-only, copy thật chỉ khi 1 bên ghi. Pattern fork+exec không bao giờ phải copy.

Câu 7. execve() làm gì?

  • Thay thế memory image của process hiện tại bằng program mới — cùng PID
  • Tạo process mới như fork
  • Kết thúc process
  • Đợi child

Đáp án: thay thế image. PID không đổi, code hoàn toàn khác. Pattern fork+exec là cách shell chạy command.

Câu 8. Process trong trạng thái D (uninterruptible sleep) trên Linux nghĩa là?

  • Đã chết
  • Đang dùng CPU
  • Đang đợi I/O kernel-level (vd disk) — không thể bị kill bằng signal
  • Bị deadlock

Đáp án: đợi I/O không thể interrupt. Vì thế kill -9 cũng vô tác dụng cho đến khi I/O xong. Hay gặp khi NFS hang.

Tổng kết chương 2

  • ✅ Program (file tĩnh) ≠ Process (đang chạy có PCB)
  • ✅ Memory layout: text, data, BSS, heap (↑), stack (↓)
  • PCB chứa PID, state, registers, page table, fd table — kernel quản lý mỗi process
  • ✅ 5 trạng thái: NEW → READY → RUNNING → BLOCKED → TERMINATED
  • fork() trả 2 lần (parent: PID con; child: 0); Copy-on-Write làm fork nhanh
  • exec() thay thế image, cùng PID. Pattern fork + exec là cách shell chạy command
  • Zombie: child exit, parent chưa wait. Orphan: parent chết trước, re-parent về init
  • ✅ Quan sát process: ps, top, htop, /proc, kill
  • ✅ Node.js: spawn (stream), exec (buffer), fork (IPC channel)
← Chương trước Chương 01: Kernel & Syscalls Chương kế tiếp Chương 03: Thread →