Hệ điều hành & Tiến trình/Zombie & orphan — khi tiến trình chết mà không ai thu xác
11/28
Bài 11 / 28~11 phútTiến trình — sinh ra, sống, chếtMiễn phí lượt xem

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ườngTiế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ậnCha 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ốngOrphan: cha chết trước con
Nhà nước nhận nuôi trẻ mồ côiinit/systemd (PID 1) nhận nuôi orphan
💡 Cách nhớ

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;
}
Thử đoán

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), Z nghĩa là "defunct (zombie) process, terminated but not reaped by its parent".
  • Cột lệnh có <defunct>: nhãn ps gắ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ửi SIGCHLD để nhắc, hoặc sửa code cha để wait.
  • Nếu cha treo/lỗi: kill cha. Khi cha chết, con zombie trở thành orphan của initinit reap 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.

Thử đoán trước khi chạy

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;
}
Vì sao orphan an toàn còn zombie thì không

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:

  1. wait/waitpid tường minh sau khi tạo con (như bài 02) — đơn giản nhưng block cha.
  2. Xử lý SIGCHLD: đăng ký handler cho signal SIGCHLD (kernel gửi khi một con chết), trong handler gọi waitpid với cờ WNOHANG để reap không block. Đây là pattern của server fork nhiều worker. (Cơ chế signal + handler: bài kế.)
  3. Bảo kernel tự reap: đặt disposition của SIGCHLD thành SIG_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
}
Tự điền trước khi xem đáp án

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

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

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 prctl PR_SET_CHILD_SUBREAPER).
  • ps(1) — man7.org — bảng mã PROCESS STATE CODES: Z = defunct (zombie), cùng R, 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 đã exit nhưng cha chưa wait; 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ặc kill cha để 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: ps trạng thái Z + nhãn <defunct>. PPID của orphan đổi thành 1 (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ý SIGCHLD với waitpid(-1, ..., WNOHANG), hoặc SIG_IGN cho SIGCHLD.

10. Tự kiểm tra

Tự kiểm tra
Q1
Mộ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?
Zombie là tiến trình đã 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.
Q2
Bạ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?
Chúng không ngốn CPU (đã chết, không chạy code) và không giữ RAM của không gian địa chỉ (đã giải phóng lúc 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.
Q3
Vì 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?
Signal (kể 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.
Q4
Phâ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?
Zombie: con chết trước, cha còn sống nhưng chưa (hoặc không) 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.
Q5
Trong 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?
Trên máy thường, PID 1 là init/systemd — nó luôn reap orphan. Trong container, PID 1 thường là chính app của bạn. Nếu app đó 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

Đặ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