Hệ điều hành & Tiến trình/fork, exec, wait — trace vòng đời một tiến trình Unix
10/28
Bài 10 / 28~13 phútTiến trình — sinh ra, sống, chếtMiễn phí lượt xem

fork, exec, wait — trace vòng đời một tiến trình Unix

Trace tiến trình sinh ra và kết thúc: fork nhân đôi, exec thay ruột, wait thu hoạch exit status — và vì sao shell chạy lệnh theo đúng bộ ba này.

TL;DR: Trên Unix/Linux, tạo một tiến trình chạy chương trình mới cần hai bước, không phải một. fork() nhân đôi tiến trình gọi: tạo một tiến trình con gần như giống hệt cha, chung code nhưng có PID riêng và bản sao không gian địa chỉ (Linux dùng copy-on-write nên chép rẻ). fork() trả về hai giá trị khác nhau: PID của con cho tiến trình cha, và 0 cho tiến trình con — đó là cách mỗi bên biết mình là ai. Con thường gọi exec() để thay toàn bộ ruột bằng chương trình khác (cùng PID). Cha gọi wait() để chờ con chết và thu exit status. Shell chạy mọi lệnh bằng đúng chuỗi fork → exec → wait này.

Bạn gõ ls trong terminal. Shell (bash) không tự biến thành ls — nó vẫn phải còn đó để nhận lệnh kế tiếp. Vậy làm sao một tiến trình (bash) chạy được một chương trình khác (ls) mà không mất chính mình? Và vì sao khi bạn thêm & để chạy nền, shell in ra một số PID rồi trả prompt ngay, còn không có & thì nó đứng chờ? Câu trả lời nằm ở ba system call cổ điển của Unix: fork, exec, wait.

Bài này trace từng bước vòng đời một tiến trình: sinh ra bằng fork, thay chương trình bằng exec, kết thúc bằng exit, và được cha thu hoạch bằng wait. Xong bài, bạn Trace được vòng đời một tiến trình từ fork tới khi cha wait thu hoạch, và giải thích được vì sao shell cần đúng bộ ba fork/exec/wait.

1. Analogy — nhân bản rồi giao việc

Hình dung một quản lý cần chạy một việc mới. Anh ta không tự đi làm (sẽ bỏ trống ghế quản lý). Thay vào đó:

  1. Nhân bản ra một trợ lý y hệt mình (fork) — cùng ký ức, cùng ngữ cảnh, nhưng là một người riêng.
  2. Bảo trợ lý "biến thành" thợ chuyên môn cần cho việc đó (exec) — trợ lý khoác lên toàn bộ kỹ năng của thợ, quên hết việc quản lý, chỉ làm đúng nhiệm vụ.
  3. Quản lý chờ trợ lý báo cáo xong rồi ghi nhận kết quả (wait) — biết việc thành hay bại.

Quản lý vẫn ngồi ghế của mình suốt quá trình — sẵn sàng cho việc kế tiếp. Đó chính là bash: fork ra con, cho con exec thành ls, rồi wait con xong, mà bản thân bash không bao giờ biến mất.

Văn phòngTiến trình
Nhân bản trợ lý y hệtfork() tạo con là bản sao của cha
Trợ lý biến thành thợ chuyên mônexec() thay ruột con bằng chương trình mới
Quản lý chờ trợ lý báo cáowait() cha thu exit status của con
Quản lý vẫn ngồi ghếShell vẫn sống để nhận lệnh tiếp
💡 Cách nhớ

Unix tách "tạo tiến trình" (fork) khỏi "chạy chương trình" (exec) làm hai bước. Nhờ tách, giữa fork và exec tiến trình con có một khoảng để chỉnh ngữ cảnh (đổi hướng input/output, đóng file...) — đây là cách shell làm được redirect và pipe.

2. fork() — nhân đôi một tiến trình

fork() (xem fork(2)) là system call tạo tiến trình con bằng cách nhân đôi tiến trình gọi. Con nhận bản sao của: không gian địa chỉ (code, data, heap, stack), các register, bảng file descriptor đang mở. Nhưng con là tiến trình riêng: có PID mới, PPID trỏ về cha.

Điều khó chịu-mà-thiên-tài của fork là nó trả về hai lần — một lần trong cha, một lần trong con — với hai giá trị khác nhau:

  • Trong tiến trình cha: trả về PID của con (một số dương).
  • Trong tiến trình con: trả về 0.
  • Nếu lỗi (hết tài nguyên): trả về -1, không có con nào được tạo.

Chính giá trị trả về này để mỗi bên biết mình là cha hay con và rẽ nhánh:

// fork-basic.c
#include <stdio.h>
#include <unistd.h>

int main(void) {
    printf("Truoc fork: chi mot tien trinh, PID=%d\n", getpid());

    pid_t pid = fork();   // tu day tro di: HAI tien trinh cung chay dong nay tro xuong

    if (pid < 0) {
        perror("fork failed");
        return 1;
    } else if (pid == 0) {
        // Nhanh nay chi tien trinh CON chay (fork tra 0 cho con)
        printf("Con:  PID=%d, PPID=%d\n", getpid(), getppid());
    } else {
        // Nhanh nay chi tien trinh CHA chay (fork tra PID con cho cha)
        printf("Cha:  PID=%d, PID con=%d\n", getpid(), pid);
    }
    return 0;
}
Thử đoán

Dòng printf("Truoc fork...") in ra mấy lần? Còn hai dòng "Con:" và "Cha:" — dòng nào chắc chắn in trước? Đoán trước khi chạy gcc fork-basic.c && ./a.out.

Dòng "Truoc fork" in một lần (lúc đó chỉ có một tiến trình). Sau fork(), có hai tiến trình cùng chạy tiếp từ ngay sau lời gọi — nên cả nhánh con và nhánh cha đều chạy, in ra hai dòng. Thứ tự giữa "Con:" và "Cha:" không xác định: sau fork, cả hai là tiến trình độc lập, scheduler quyết ai chạy trước — chạy nhiều lần có thể thấy thứ tự đảo. Đây là bài học đầu tiên về đồng thời: đừng giả định thứ tự khi chưa đồng bộ.

Vì sao fork rẻ dù chép cả không gian địa chỉ

Nghe "chép toàn bộ bộ nhớ của cha" tưởng rất đắt. Thực ra Linux dùng copy-on-write (COW): ngay sau fork, cha và con chung các trang bộ nhớ vật lý, chỉ đánh dấu chúng read-only. Chừng nào cả hai chỉ đọc, không có gì bị chép. Chỉ khi một bên ghi vào một trang, kernel mới chép riêng trang đó ra cho bên ghi. Theo fork(2), chi phí thật của fork chỉ là "thời gian và bộ nhớ để nhân đôi page table của cha và tạo task structure cho con" — không phải chép hết RAM. Đây chính là bộ nhớ ảo + page table bạn đã học ở khoá Bộ nhớ phát huy tác dụng.

3. exec() — thay toàn bộ ruột tiến trình

Sau fork, con là bản sao của cha — vẫn chạy cùng chương trình. Để con chạy một chương trình khác (ví dụ ls), nó gọi exec() (họ hàm execvp, execlp... — xem execve(2)).

exec thay toàn bộ: text (code), data khởi tạo, bss (vùng biến toàn cục chưa khởi tạo), và stack của tiến trình hiện tại bị ghi đè bằng nội dung chương trình mới. Nhưng PID không đổi — theo execve(2), "không có tiến trình mới nào; nhiều thuộc tính của tiến trình gọi giữ nguyên, đặc biệt là PID". Nó là cùng một tiến trình khoác lên chương trình khác.

Hệ quả cực quan trọng: nếu exec thành công, nó không bao giờ trả về. Vì code cũ (kể cả dòng lệnh ngay sau exec) đã bị ghi đè mất. Chỉ khi exec thất bại (không tìm thấy file, không có quyền) nó mới trả về -1 và code cũ chạy tiếp:

Thử đoán

Nếu ls tồn tại và exec thành công, dòng perror("exec failed") có in ra không? Còn nếu đổi "ls" thành một lệnh không tồn tại thì điều gì xảy ra? Đoán trước khi đọc tiếp.

// fork-exec.c -- con bien thanh chuong trinh "ls -l"
#include <stdio.h>
#include <unistd.h>

int main(void) {
    pid_t pid = fork();
    if (pid == 0) {
        // Tien trinh CON: thay ruot bang /bin/ls
        execlp("ls", "ls", "-l", (char *) NULL);
        // Chi chay toi day NEU exec THAT BAI:
        perror("exec failed");
        _exit(127);
    }
    // Tien trinh CHA chay tiep binh thuong o day
    printf("Cha da fork con PID=%d, gio ket thuc (chua wait -- xem muc 4)\n", pid);
    return 0;
}

Trong nhánh con, mọi dòng sau execlp chỉ chạy nếu exec lỗi. Đây là lý do bạn luôn thấy perror + _exit ngay sau một lời gọi exec.

4. exit() và wait() — kết thúc và thu hoạch

Một tiến trình kết thúc bằng cách gọi exit(status) (hoặc return từ main), trả về một exit status — số nguyên nhỏ báo thành/bại (quy ước: 0 = thành công, khác 0 = lỗi).

Nhưng con chết chưa phải xong chuyện. Cha thường muốn biết con chạy có thành công không. Cha gọi wait() (hoặc waitpid() — xem wait(2)) để:

  1. Chờ (block) tới khi một con kết thúc — nếu con chưa xong.
  2. Thu exit status của con.
  3. Cho OS giải phóng ô cuối cùng của con trong bảng tiến trình.

Bước 3 là mấu chốt: theo wait(2), một con đã chết nhưng chưa được cha wait vẫn tồn tại dưới dạng zombie — kernel giữ lại tối thiểu thông tin (PID, exit status, thống kê tài nguyên) để cha còn wait được. wait chính là thao tác dọn xác đó (chi tiết ở bài Zombie & orphan).

Thử đoán

Khi ls chạy xong bình thường, WIFEXITED(status) trả về đúng hay sai, và dòng exit code in ra là số mấy? Đoán trước khi đọc code.

// fork-exec-wait.c -- vong doi day du
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
    pid_t pid = fork();
    if (pid == 0) {
        execlp("ls", "ls", "-l", (char *) NULL);
        perror("exec failed");
        _exit(127);
    }

    int status;
    waitpid(pid, &status, 0);   // cha CHO con PID nay ket thuc

    if (WIFEXITED(status)) {
        printf("Con thoat binh thuong, exit code=%d\n", WEXITSTATUS(status));
    } else {
        printf("Con ket thuc bat thuong\n");
    }
    return 0;
}

WIFEXITEDWEXITSTATUS là macro giải mã giá trị statuswait trả về (nó đóng gói nhiều thông tin trong một int, không phải trực tiếp exit code). Đây là pattern chuẩn để đọc kết quả của con.

5. Vì sao shell chạy lệnh theo bộ ba này?

Giờ ghép lại. Khi bạn gõ ls -l rồi Enter trong bash:

sequenceDiagram
    participant U as Ban go "ls -l"
    participant S as Shell (bash)
    participant C as Tien trinh con
    U->>S: nhan Enter
    S->>C: fork() tao con (ban sao bash)
    Note over C: con goi exec("ls")
    C->>C: exec thay ruot -> gio la chuong trinh ls
    S->>S: wait() -- block cho con xong
    C-->>S: con exit(0), tra exit status
    S->>U: in prompt moi, san sang lenh tiep
  • fork: bash nhân đôi chính nó ra tiến trình con. Cần con riêng vì bash phải sống tiếp để nhận lệnh sau.
  • (giữa fork và exec): nếu lệnh có redirect (ls > out.txt) hay pipe (ls | grep x), bash sửa file descriptor của con ở đây — trước khi con biến thành ls. Đây là lý do Unix tách fork/exec: có một khoảng để chỉnh ngữ cảnh của con.
  • exec: con thay ruột thành ls, giữ nguyên PID và các file descriptor đã chỉnh.
  • wait: bash chờ con ls xong rồi mới in prompt. Nếu bạn thêm & (chạy nền), bash bỏ qua wait ngay, in PID của con và trả prompt liền — đó là lý do lệnh nền không giữ terminal.

Hiểu bộ ba này là hiểu cách mọi trình khởi chạy tiến trình hoạt động: shell, trình quản lý dịch vụ (systemd), server fork worker (nginx, PostgreSQL) — tất cả đều fork rồi exec.

6. Pitfall tổng hợp

Nhầm 1 — tưởng code sau exec (khi thành công) sẽ chạy:

execlp("ls", "ls", (char *) NULL);
printf("Da chay xong ls\n");   // SAI: dong nay KHONG BAO GIO chay neu exec thanh cong

exec thành công thì ghi đè toàn bộ code — dòng printf đã biến mất khỏi bộ nhớ. Code sau exec chỉ chạy khi exec thất bại. Muốn in "đã xong" thì đó là việc của tiến trình cha sau khi wait, không phải của con.

Nhầm 2 — quên rằng cả cha và con đều chạy tiếp sau fork:

fork();
printf("Hello\n");   // in ra HAI lan (ca cha va con deu chay dong nay)

✅ Sau fork, cả hai tiến trình chạy tiếp từ ngay sau lời gọi. Không rẽ nhánh theo giá trị trả về thì mọi dòng phía sau chạy đôi. Luôn kiểm tra pid == 0 (con) vs pid > 0 (cha) để mỗi bên làm đúng việc.

Nhầm 3 — cha không wait, để con thành zombie:

// Cha fork roi thoat ngay, khong wait
fork();
return 0;   // con co the thanh zombie (neu con chet sau) -- xem bai 03

✅ Cha nên wait/waitpid mọi con nó tạo (hoặc xử lý SIGCHLD) để thu exit status và dọn xác. Con chết mà cha không wait → zombie chiếm ô bảng tiến trình. Bài kế bóc kỹ.

7. 📚 Deep Dive

📚 Đào sâu (tuỳ chọn)

Nguồn chuẩn:

  • fork(2) — man7.org — giá trị trả về hai lần, copy-on-write, và danh sách chính xác những gì con không thừa kế từ cha.
  • execve(2) — man7.org — cơ chế thay process image; phần NOTES nói rõ "không có tiến trình mới, PID giữ nguyên". Các hàm execlp/execvp trong exec(3) là wrapper của system call này.
  • wait(2) — man7.orgwait/waitpid, macro WIFEXITED/WEXITSTATUS, và định nghĩa zombie.
  • OSTEP — chương "Process API" (file cpu-api.pdf) — giải thích vì sao Unix tách forkexec, với chính ví dụ shell redirect.

Ghi chú: Cặp fork + exec tưởng dư thừa (Windows dùng một lời gọi CreateProcess gộp cả hai) nhưng khoảng trống giữa chúng chính là nơi shell cài redirect/pipe — một quyết định thiết kế của Unix mà OSTEP chương Process API phân tích kỹ. Đọc để hiểu vì sao "hai bước" lại linh hoạt hơn "một bước".

8. Liên hệ các bài khác

  • Tiến trình & PCB: fork chép PCB + không gian địa chỉ của cha sang con; exec giữ PID (một field trong PCB) nhưng thay vùng code/data. Bài này đứng trên khái niệm PCB đó.
  • Zombie & orphan: hệ quả của việc thiếu wait (zombie) và của việc cha chết trước con (orphan) — mặt trái của vòng đời bài này dựng.
  • Signal: SIGCHLD là signal kernel gửi cho cha khi con chết — cách xử lý wait không đồng bộ thay vì block.
  • System call là gì: fork, execve, wait đều là system call — chuyển từ user mode vào kernel mode để nhờ OS thao tác tiến trình.

9. Tóm tắt

  • Unix tách tạo-tiến-trình (fork) khỏi chạy-chương-trình (exec) thành hai bước; khoảng giữa cho phép chỉnh ngữ cảnh con (redirect, pipe).
  • fork() nhân đôi tiến trình gọi; con dùng chung code, có PID riêng, và bản sao không gian địa chỉ (copy-on-write nên rẻ).
  • fork() trả hai giá trị: PID con cho cha, 0 cho con — dùng để rẽ nhánh. Sau fork, cả hai tiến trình chạy tiếp; thứ tự không xác định.
  • exec() thay toàn bộ code/data của tiến trình hiện tại bằng chương trình mới, giữ PID; thành công thì không trả về, code sau nó chỉ chạy khi exec lỗi.
  • exit(status) kết thúc con với exit status; wait()/waitpid() cho cha chờ con và thu exit status, đồng thời dọn xác.
  • Shell chạy mọi lệnh theo fork → exec → wait; thêm & thì bỏ wait, trả prompt ngay.

10. Tự kiểm tra

Tự kiểm tra
Q1
Đoạn sau in ra mấy dòng "Hello" và vì sao? fork(); printf("Hello\n");
In ra hai dòng "Hello". fork() nhân đôi tiến trình gọi; sau lời gọi có hai tiến trình (cha và con), cả hai đều chạy tiếp từ ngay sau fork(), nên cả hai đều thực thi printf. Vì không có nhánh if (pid == 0) để tách vai, cả cha lẫn con làm cùng một việc. Nếu fork được gọi hai lần liên tiếp không rẽ nhánh, số tiến trình nhân đôi mỗi lần và "Hello" sẽ in ra 4 dòng.
Q2
Vì sao fork() cần trả về hai giá trị khác nhau (PID con cho cha, 0 cho con)? Nếu nó trả cùng một giá trị cho cả hai thì sao?
Sau fork, cả cha và con chạy cùng một đoạn code từ ngay sau lời gọi — chúng cần một cách để biết mình là ai và rẽ nhánh làm việc khác nhau. Giá trị trả về chính là dấu hiệu đó: con nhận 0 (nhánh pid == 0 chạy code của con), cha nhận PID của con (số dương — nhánh cha chạy, đồng thời cha cần PID này để sau đó waitpid đúng con). Nếu trả cùng một giá trị, hai bên không phân biệt được vai, và cha mất tham chiếu tới con nên không wait đúng nó được. Thiết kế "một lời gọi trả hai giá trị theo ngữ cảnh" chính là điểm khéo của fork.
Q3
Vì sao code ngay sau một lời gọi exec thành công không bao giờ chạy? Và khi nào thì nó chạy?
exec ghi đè toàn bộ process image — text (code), data, bss, stack của tiến trình hiện tại bị thay bằng chương trình mới. Chính đoạn code chứa dòng lệnh sau exec cũng nằm trong vùng bị ghi đè, nên nó biến mất khỏi bộ nhớ; CPU giờ chạy code của chương trình mới từ đầu. Vì vậy nếu exec thành công, nó "không trả về" — không còn code cũ để quay lại. Dòng sau exec chỉ chạy khi exec thất bại (không tìm thấy file, thiếu quyền): lúc đó process image chưa bị thay, exec trả -1, và code cũ tiếp tục — nên ta luôn đặt perror + _exit ngay sau exec để xử lý lỗi.
Q4
Trace từng bước điều xảy ra khi bạn gõ `ls` trong bash. Vì sao bash phải fork thay vì tự exec thành ls?
Bash fork tạo một tiến trình con là bản sao của chính nó. Con exec thành ls — thay ruột bằng chương trình ls, giữ PID con. Bash (cha) gọi wait, block tới khi ls exit, thu exit status, rồi in prompt mới. Bash không tự exec thành lsexec ghi đè toàn bộ bash — nếu bash tự biến thành ls, khi ls xong thì không còn bash để nhận lệnh kế tiếp, terminal sẽ đóng. Bằng cách fork một con riêng để exec, bash bảo toàn chính mình. Đây cũng là chỗ bash cài redirect/pipe: giữa fork và exec, nó sửa file descriptor của con.
Q5
Cha gọi wait() để làm gì ngoài việc chờ con? Điều gì xảy ra với con nếu cha không bao giờ wait?
wait() làm ba việc: (1) chờ con kết thúc nếu con chưa xong; (2) thu exit status của con để cha biết con thành công hay lỗi; (3) yêu cầu OS giải phóng ô cuối cùng của con trong bảng tiến trình. Khi một con kết thúc nhưng cha chưa wait, kernel giữ lại tối thiểu thông tin về con (PID, exit status, thống kê tài nguyên) — đây là trạng thái zombie. Zombie tồn tại để cha còn cơ hội wait đọc kết quả. Nếu cha không bao giờ wait, zombie tiếp tục chiếm một ô trong bảng tiến trình; tạo nhiều zombie có thể làm đầy bảng và chặn việc tạo tiến trình mới. Bài kế (Zombie & orphan) bóc kỹ và chỉ cách dọn.

Bài tiếp theo: Zombie & orphan — khi tiến trình chết mà không ai thu xác

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên