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 đó:
- 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. - 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ụ. - 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òng | Tiến trình |
|---|---|
| Nhân bản trợ lý y hệt | fork() tạo con là bản sao của cha |
| Trợ lý biến thành thợ chuyên môn | exec() thay ruột con bằng chương trình mới |
| Quản lý chờ trợ lý báo cáo | wait() 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 |
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;
}
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ộ.
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:
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)) để:
- Chờ (block) tới khi một con kết thúc — nếu con chưa xong.
- Thu exit status của con.
- 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).
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;
}
WIFEXITED và WEXITSTATUS là macro giải mã giá trị status mà wait 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ànhls. Đâ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
lsxong 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
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/execvptrong exec(3) là wrapper của system call này. - wait(2) — man7.org —
wait/waitpid, macroWIFEXITED/WEXITSTATUS, và định nghĩa zombie. - OSTEP — chương "Process API" (file
cpu-api.pdf) — giải thích vì sao Unix táchforkvàexec, 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:
forkchép PCB + không gian địa chỉ của cha sang con;execgiữ 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:
SIGCHLDlà signal kernel gửi cho cha khi con chết — cách xử lýwaitkhô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,0cho 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
Q1Đoạn sau in ra mấy dòng "Hello" và vì sao? fork(); printf("Hello\n");▸
fork(); printf("Hello\n");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.Q2Vì 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?▸
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.Q3Vì 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 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.Q4Trace 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?▸
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 ls vì exec 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.Q5Cha 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
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