Zombie & orphan — khi tiến trình chết mà không ai thu xác
Zombie sinh ra khi cha không wait, orphan khi cha chết trước con — vì sao zombie chiếm PID, init/systemd nhận nuôi thế nào, và cách xử lý.
TL;DR: Một zombie là tiến trình đã chết nhưng cha chưa wait nó — kernel còn giữ lại một ô nhỏ trong bảng tiến trình (PID, exit status, thống kê tài nguyên) để cha còn đọc kết quả. Zombie không chạy, không tốn RAM/CPU, nhưng chiếm một PID; tích nhiều có thể làm đầy bảng tiến trình. Xử lý: cha phải wait (hoặc xử lý SIGCHLD). Một orphan thì ngược lại: cha chết trước con, để con "mồ côi". Con orphan lập tức được init/systemd (PID 1) nhận nuôi — nó trở thành cha mới và tự wait để dọn xác khi con chết. Nhận diện zombie: ps hiện trạng thái Z và nhãn <defunct>.
Bạn deploy một service, vài giờ sau ps aux trả về hàng loạt dòng lạ: trạng thái Z, tên process kèm <defunct>, không ăn CPU, không ăn RAM — nhưng cứ nhiều dần. kill chúng không chết. Đây không phải virus; đây là zombie process — và thủ phạm là chính code của bạn fork ra con mà quên wait.
Xong bài, bạn explain được vì sao hai trạng thái "hậu sự" — zombie (con chết, cha chưa thu xác) và orphan (cha chết trước con) — sinh ra từ chính cơ chế fork/wait bài trước, và cách xử lý mỗi loại.
1. Analogy — giấy chứng tử chưa ai ký nhận
Khi một người qua đời, cơ quan hộ tịch giữ lại giấy chứng tử cho tới khi thân nhân tới ký nhận và làm thủ tục. Người đã mất không còn hoạt động gì, nhưng hồ sơ của họ vẫn nằm trong sổ cho tới khi có người chính thức "đóng" nó. Nếu thân nhân không bao giờ tới, tờ giấy cứ nằm đó chiếm chỗ trong tủ hồ sơ.
Zombie process y hệt: tiến trình đã "chết" (không chạy nữa), nhưng kernel giữ lại tấm "giấy chứng tử" (PID + exit status) trong bảng tiến trình cho tới khi cha tới wait — thao tác "ký nhận". Cha không bao giờ wait → tấm giấy nằm mãi, chiếm một ô.
Còn orphan: người thân nhân (cha) mất trước, đứa trẻ (con) còn sống nhưng không còn ai giám hộ. Nhà nước (init/systemd, PID 1) lập tức đứng ra nhận nuôi — và khi đứa trẻ đó qua đời, chính nhà nước ký giấy chứng tử. Nhờ vậy con orphan không bao giờ thành zombie kẹt lại.
| Đời thường | Tiến trình |
|---|---|
| Người đã mất, giấy chứng tử chờ ký | Zombie: con chết, exit status chờ cha wait |
| Thân nhân tới ký nhận | Cha gọi wait() để reap |
| Giấy nằm mãi vì không ai ký | Zombie kẹt vì cha không wait |
| Cha mẹ mất, con còn sống | Orphan: cha chết trước con |
| Nhà nước nhận nuôi trẻ mồ côi | init/systemd (PID 1) nhận nuôi orphan |
Zombie là xác chưa được thu (con chết trước, cha chưa wait). Orphan là con mất cha (cha chết trước, con còn sống). Hai chuyện ngược chiều nhau về "ai chết trước".
2. Zombie sinh ra thế nào?
Nhớ lại bài trước: khi con exit, nó chưa biến mất hoàn toàn. Theo wait(2), "một con kết thúc nhưng chưa được wait sẽ trở thành zombie". Kernel giữ lại tối thiểu thông tin về nó — PID, termination status, thống kê tài nguyên — để cha còn wait mà đọc.
Vậy zombie là trạng thái bình thường và ngắn ngủi trong vòng đời: mọi con đều thành zombie trong khoảnh khắc giữa lúc exit và lúc cha wait. Vấn đề chỉ nảy sinh khi cha không bao giờ wait — zombie kẹt lại vô hạn.
flowchart TB R["Con dang chay"] -->|"con exit()"| Z["Zombie: da chet, giu PID + exit status"] Z -->|"cha goi wait()"| G["Reaped: bien mat hoan toan"] Z -.->|"cha khong bao gio wait"| K["Zombie ket: chiem PID mai"] R2["Con dang chay"] -->|"cha chet truoc con"| O["Orphan: PPID doi thanh 1"] O -->|"con exit -> init tu wait"| G
// make-zombie.c -- co tinh tao zombie de quan sat
#include <stdio.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
// Con: thoat ngay lap tuc
_exit(0);
}
// Cha: KHONG wait, ngu 60 giay
// -> trong 60s nay, con la ZOMBIE (da chet, cha chua thu xac)
printf("Cha PID=%d, con zombie PID=%d. Chay 'ps' o terminal khac.\n",
getpid(), pid);
sleep(60);
return 0;
}
Biên dịch và chạy chương trình trên. Ở một terminal khác, chạy ps -el | grep defunct trong lúc chương trình còn sleep. Bạn nghĩ trạng thái (cột STAT/S) của tiến trình con sẽ là gì? Nó có ăn CPU không?
Trong 60 giây đó, con hiện là zombie: ps cho trạng thái Z, cột lệnh có <defunct>. Nó không ăn CPU (đã chết, không chạy code) và không giữ RAM của không gian địa chỉ (đã được giải phóng lúc exit) — thứ duy nhất còn lại là ô bảng tiến trình. Khi cha sleep xong và exit, cha cũng chết → con zombie được init nhận và reap ngay.
3. Nhận diện zombie bằng ps
ps phơi bày trạng thái tiến trình. Zombie lộ ở hai chỗ:
$ ps -el | grep Z
F S UID PID PPID ... CMD
0 Z 1000 4823 4820 ... make-zombie <defunct>
^ ^
STAT = Z (zombie) nhan <defunct>
- Cột S/STAT =
Z: theo ps(1),Znghĩa là "defunct (zombie) process, terminated but not reaped by its parent". - Cột lệnh có
<defunct>: nhãnpsgắn cho tiến trình đã chết chưa reap.
Quan trọng: không kill được zombie. kill gửi signal để yêu cầu một tiến trình đang sống dừng lại — nhưng zombie đã chết rồi, không còn gì để dừng. Muốn dọn zombie, bạn phải tác động vào cha:
- Nếu cha còn sống và chỉ quên
wait: gửiSIGCHLDđể nhắc, hoặc sửa code cha đểwait. - Nếu cha treo/lỗi:
killcha. Khi cha chết, con zombie trở thành orphan củainit—initreap nó ngay.
4. Orphan — con mất cha, init nhận nuôi
Chiều ngược lại: nếu cha chết trước khi con kết thúc, con thành orphan — vẫn đang chạy nhưng không còn cha để wait nó sau này. OS không để con "vô thừa nhận": theo wait(2), khi cha kết thúc, các con của nó được init(1) nhận nuôi (hoặc subreaper gần nhất — tiến trình đăng ký "nhận nuôi thay init" cho cây con của nó, ví dụ systemd --user), và init tự động wait để dọn khi chúng chết.
init (trên hệ hiện đại là systemd) là tiến trình PID 1 — tiến trình đầu tiên kernel tạo lúc boot, tổ tiên của mọi tiến trình user-space. Một nhiệm vụ nền của nó là reap mọi orphan mà nó nhận nuôi. Nhờ vậy, orphan không trở thành zombie kẹt: luôn có PID 1 đứng ra wait.
Trong chương trình dưới, con sleep(2) còn cha thoát ngay. Bạn nghĩ con sẽ in ra PPID là bao nhiêu sau khi cha đã chết? So với PPID lúc con vừa được tạo, nó đổi thế nào?
// make-orphan.c -- cha chet truoc, con thanh orphan
#include <stdio.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
sleep(2); // con song lau hon cha
// Luc nay cha da chet -> PPID doi thanh 1 (init/systemd)
printf("Con PID=%d gio co PPID=%d (init nhan nuoi)\n",
getpid(), getppid());
_exit(0);
}
// Cha thoat ngay, khong wait
printf("Cha PID=%d thoat truoc, bo lai con orphan\n", getpid());
return 0;
}
Cả hai đều là "trục trặc quan hệ cha-con", nhưng hệ quả khác hẳn. Orphan tự lành: ngay khi cha chết, PPID của con được đổi sang 1 (hoặc PID của subreaper gần nhất trên hệ systemd), và init cam kết reap con khi nó chết — không ai bị kẹt. Zombie có thể kẹt: nó cần cha của nó wait, mà nếu cha còn sống nhưng viết sai (không wait, không xử lý SIGCHLD), không ai thay cha làm việc đó. Nghịch lý hữu ích: giết cha của một đàn zombie lại là cách dọn chúng — vì khi cha chết, lũ zombie thành orphan của init và được reap ngay.
5. Xử lý đúng trong code thật
Cách phòng zombie là luôn reap con. Ba hướng:
wait/waitpidtường minh sau khi tạo con (như bài 02) — đơn giản nhưng block cha.- Xử lý
SIGCHLD: đăng ký handler cho signalSIGCHLD(kernel gửi khi một con chết), trong handler gọiwaitpidvới cờWNOHANGđể reap không block. Đây là pattern của server fork nhiều worker. (Cơ chế signal + handler: bài kế.) - Bảo kernel tự reap: đặt disposition của
SIGCHLDthànhSIG_IGN, hoặc cờSA_NOCLDWAIT— kernel tự dọn con, cha không cần wait (khi cha không cần exit status).
Hai mẫu trên (make-zombie.c, make-orphan.c) cho thấy zombie/orphan sinh ra thế nào; giờ tới lượt bạn viết phần dọn. Đây là khung reap-không-block (hướng 2) còn hai chỗ trống — đối chiếu "hướng 2" ở trên để điền trước khi xem đáp án.
// Reap khong block: reap moi con da chet, khong dung cha lai
// (Goi trong handler SIGCHLD -- xem bai 04 ve dang ky handler)
while (waitpid(/* TODO 1: chi dinh con nao? */, NULL, /* TODO 2: co gi de KHONG block? */) > 0) {
// vong lap vi mot SIGCHLD co the gop nhieu con chet cung luc
}
TODO 1: điền tham số nào để waitpid chờ bất kỳ con nào, thay vì một PID cụ thể? TODO 2: điền cờ nào để waitpid trả về ngay khi chưa con nào chết (reap không block)? Gợi ý: TODO 2 (cờ) đã nhắc ở hướng 2 phía trên; còn TODO 1 — đối số pid -1 nghĩa là "chờ bất kỳ con nào" (xem man 2 wait).
Đáp án: TODO 1 = -1 (chờ bất kỳ con nào, không chỉ định PID cụ thể); TODO 2 = WNOHANG (trả về ngay nếu chưa con nào chết, không giữ cha lại). Khớp lại:
while (waitpid(-1, NULL, WNOHANG) > 0) {
// -1: bat ky con nao; WNOHANG: khong block neu chua con nao chet
// vong lap vi mot SIGCHLD co the gop nhieu con chet cung luc
}
Với vòng lặp WNOHANG, cha reap sạch mọi con đã chết mà không phải đứng chờ — đúng cho service chạy dài fork nhiều con.
6. Pitfall tổng hợp
❌ Nhầm 1 — kill -9 một zombie để dọn nó:
kill -9 4823 # 4823 la zombie -> LENH NAY VO TAC DUNG
✅ Zombie đã chết; signal (kể cả SIGKILL) không tác động lên tiến trình đã chết. Muốn dọn, tác động vào cha: sửa cha để wait, hoặc kill cha để init reap con. kill zombie chỉ phí công.
❌ Nhầm 2 — tưởng zombie ngốn RAM/CPU nên hoảng:
✅ Zombie không chạy code (không CPU) và không giữ không gian địa chỉ (RAM đã trả lúc exit). Nó chỉ chiếm một ô bảng tiến trình (một PID). Nguy hiểm thật là cạn PID khi tích hàng nghìn zombie, không phải cạn RAM. Đừng nhầm zombie với memory leak.
❌ Nhầm 3 — lo lắng về orphan như một lỗi:
✅ Orphan là trạng thái tự lành: init/systemd nhận nuôi và reap. Chạy tiến trình nền cố tình "mồ côi" (double-fork) là kỹ thuật tạo daemon hợp lệ. Orphan chỉ đáng lo nếu init/subreaper bị cấu hình sai — hiếm.
7. 📚 Deep Dive
Nguồn chuẩn:
- wait(2) — man7.org — định nghĩa zombie ("terminated but not waited for"), cảnh báo zombie chiếm ô bảng tiến trình, và cơ chế init nhận nuôi + reap orphan (kèm khái niệm subreaper qua
prctlPR_SET_CHILD_SUBREAPER). - ps(1) — man7.org — bảng mã PROCESS STATE CODES:
Z= defunct (zombie), cùngR,S,D,T... để đọc cột STAT. - OSTEP — chương "Process API" (file
cpu-api.pdf) — mục vềwait()và quan hệ cha-con, nền cho vòng đời đầy đủ.
Ghi chú: "Subreaper" (đặt qua prctl(PR_SET_CHILD_SUBREAPER)) là cơ chế cho một tiến trình không phải PID 1 đứng ra nhận orphan trong nhánh của nó — ví dụ systemd --user hoặc container init nhỏ (tini, dumb-init). Trong container, PID 1 thường là app của bạn; nếu app không reap con, zombie tích trong container — đó là lý do nhiều image dùng tini làm PID 1 để reap thay.
8. Liên hệ các bài khác
- fork, exec, wait: zombie là hệ quả trực tiếp của việc thiếu
waitở bài đó; hiểu vòng đời fork/wait là điều kiện để hiểu bài này. - Signal: reap không block dựa trên
SIGCHLD— kernel gửi signal này cho cha khi con chết; bài kế dạy cách đăng ký handler. - Mini-challenge — mổ xẻ cây tiến trình: bạn sẽ tự tạo một zombie rồi dọn nó, và quan sát init nhận nuôi orphan bằng
ps/pstree. - Tiến trình & PCB: ô bảng tiến trình mà zombie chiếm chính là phần định danh (PID, exit status) của PCB được giữ lại sau khi phần còn lại đã giải phóng.
9. Tóm tắt
- Zombie = con đã
exitnhưng cha chưawait; kernel giữ PID + exit status trong bảng tiến trình để cha đọc. Mọi con đều thoáng qua trạng thái này — chỉ kẹt khi cha không bao giờwait. - Zombie không tốn CPU/RAM, chỉ chiếm một PID; nguy cơ thật là cạn bảng tiến trình khi tích nhiều.
- Không kill được zombie (đã chết); dọn bằng cách cho cha
wait, hoặckillcha để init reap. - Orphan = con còn sống nhưng cha đã chết; init/systemd (PID 1) nhận nuôi và tự reap khi con chết → orphan tự lành.
- Nhận diện zombie:
pstrạng tháiZ+ nhãn<defunct>. PPID của orphan đổi thành1(hoặc PID của subreaper gần nhất trên hệ systemd). - Phòng zombie trong code:
wait/waitpid, hoặc xử lýSIGCHLDvớiwaitpid(-1, ..., WNOHANG), hoặcSIG_IGNchoSIGCHLD.
10. Tự kiểm tra
Q1Một zombie process là gì, và vì sao nó tồn tại thay vì biến mất ngay khi tiến trình chết?▸
exit (chết) nhưng cha chưa wait nó. Nó tồn tại vì kernel cần giữ lại kết quả của con — PID, exit status, thống kê tài nguyên — để cha có cơ hội wait mà đọc "con chạy thành công hay lỗi". Nếu con biến mất ngay lập tức, cha sẽ không bao giờ biết con kết thúc thế nào. Vì vậy zombie là một trạng thái bình thường và thường rất ngắn trong vòng đời: mọi con đều thoáng qua nó giữa lúc exit và lúc cha wait. Nó chỉ thành vấn đề khi cha không bao giờ wait, khiến ô bảng tiến trình bị giữ mãi.Q2Bạn thấy 500 tiến trình trạng thái Z (<defunct>) trên server. Chúng có ngốn RAM/CPU không? Đâu là rủi ro thật, và bạn dọn thế nào?▸
Z (<defunct>) trên server. Chúng có ngốn RAM/CPU không? Đâu là rủi ro thật, và bạn dọn thế nào?exit). Mỗi zombie chỉ chiếm một ô bảng tiến trình / một PID. Rủi ro thật là cạn PID: bảng tiến trình có giới hạn, đầy thì hệ thống không tạo được tiến trình mới (fork lỗi). Cách dọn: không kill zombie được (chúng đã chết); phải xử lý tiến trình cha — sửa code cha để wait/xử lý SIGCHLD, hoặc kill chính cha. Khi cha chết, 500 zombie thành orphan của init và được reap ngay lập tức.Q3Vì sao gửi SIGKILL cho một zombie không dọn được nó, trong khi kill tiến trình cha lại dọn được?▸
SIGKILL) là yêu cầu kernel tác động lên một tiến trình đang sống — dừng nó, ngắt nó. Zombie đã chết rồi: không còn code chạy, không còn gì để "giết". Nên SIGKILL lên zombie vô tác dụng — thứ giữ zombie lại là *sự thiếu wait từ cha*, không phải bản thân zombie còn hoạt động. Kill cha lại hiệu quả vì: khi cha chết, mọi con (kể cả zombie) của nó được init/systemd nhận nuôi, và init tự động wait để reap — thao tác thu xác mà cha lẽ ra phải làm. Vậy nghịch lý "giết cha để dọn con đã chết" thực ra là chuyển quyền reap sang init.Q4Phân biệt zombie và orphan theo trục 'ai chết trước'. Vì sao orphan tự lành còn zombie thì có thể kẹt?▸
wait — xác con kẹt chờ cha thu. Orphan: cha chết trước, con còn sống mà mất cha. Orphan tự lành vì ngay khi cha chết, con được init/systemd (PID 1) nhận nuôi — PPID của con đổi thành 1 (hoặc PID của subreaper gần nhất trên hệ systemd) — và init cam kết wait reap con khi nó chết. Luôn có một tiến trình đáng tin (PID 1) đứng ra dọn. Zombie có thể kẹt vì nó cần chính cha của nó wait; nếu cha còn sống nhưng viết sai (không wait, không xử lý SIGCHLD), không ai thay cha làm việc đó — init chỉ nhận việc khi cha đã chết.Q5Trong một container Docker, PID 1 thường là chính app của bạn (không phải systemd). Điều này liên quan gì tới zombie, và vì sao nhiều image dùng tini/dumb-init?▸
fork ra tiến trình con (ví dụ chạy subprocess) mà không reap, những con chết sẽ thành zombie — và vì không có init tử tế làm PID 1 để nhận nuôi/reap, zombie tích lại trong container. Đây là lý do nhiều image đặt một init tối giản như tini hoặc dumb-init làm PID 1: chúng làm đúng việc của init — nhận nuôi orphan và reap zombie — trong khi app chạy như con của chúng. Bài học: PID 1 mang trách nhiệm reap; nếu app đóng vai PID 1 mà không reap, hãy chèn một init nhỏ.Bài tiếp theo: Signal — SIGTERM, SIGKILL và Ctrl+C thật sự làm gì
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