Khi một tiến trình con kết thúc, nó không lập tức biến mất khỏi hệ thống. Nó để lại một cái "xác" — trạng thái zombie — nằm chờ cho tới khi tiến trình cha đến nhặt (reap) bằng lời gọi wait. Nghe kỳ quái, nhưng cơ chế này là nền của quản lý tiến trình trên Unix, và hiểu sai nó là gốc của một loại lỗi rò rỉ tài nguyên rất khó chẩn đoán — nhất là trong container. Bài này đo một tiến trình zombie thực sự chiếm gì và rò rỉ gì, và phát hiện thứ nó làm cạn kiệt không phải cái tôi tưởng.
Zombie và reaping là gì
Khi tiến trình con gọi exit, nhân giải phóng gần hết tài nguyên của nó — bộ nhớ, file đang mở, ngăn xếp — nhưng giữ lại một mẩu: mã thoát (exit status) và một mục trong bảng tiến trình. Con lúc này ở trạng thái zombie (ký hiệu Z, hay <defunct> trong ps). Lý do giữ lại: tiến trình cha có thể muốn biết con thoát ra sao (thành công hay lỗi, mã bao nhiêu), nên nhân giữ thông tin đó tới khi cha hỏi.
Cha hỏi bằng wait() hoặc waitpid() — thao tác này gọi là reap (nhặt xác): nó đọc mã thoát của con rồi bảo nhân xóa hẳn mục bảng tiến trình đó. Sau khi reap, con biến mất hoàn toàn. Nếu cha không bao giờ reap, zombie nằm lại mãi. Và có một luật cứu vãn: nếu cha chết trước con, con trở thành mồ côi và được PID 1 (tiến trình init) nhận nuôi; init có nhiệm vụ reap các con mồ côi này. Tôi muốn đo: một zombie thực sự chiếm bao nhiêu, và điều gì xảy ra khi không ai reap.
Đo: zombie chiếm RSS ~0, nhưng vẫn giữ một slot
Tôi fork một con cho nó thoát ngay, rồi (chưa wait) đọc trạng thái con từ /proc/<pid>/stat và bộ nhớ từ /proc/<pid>/statm:
Con (pid 16) đã thoát, cha chưa wait:
trạng thái = Z (ZOMBIE, defunct) | RSS ~0 KB
Sau khi cha waitpid() -> reap (mã thoát 42):
pid 16: KHÔNG CÒN trong bảng tiến trình
Con số cốt lõi: một zombie chiếm RSS ~0 KB — nó thật sự không giữ byte bộ nhớ nào (đã bị giải phóng lúc exit). Sau khi cha waitpid, nhặt được mã thoát 42, con biến mất khỏi bảng tiến trình. Đo tiếp trên quy mô: tôi fork 300 con cho thoát mà không reap, rồi đếm — có 300 zombie tồn đọng, tổng RSS vẫn ~0. Còn khi tôi reap từng con (waitpid sau mỗi fork), số zombie là 0. Rõ ràng zombie tích tụ khi không ai nhặt, dù chúng chẳng tốn bộ nhớ.
Một lần tôi đo hớ: đo bộ nhớ để phán "vô hại"
Nhìn con số RSS ~0 KB, phản xạ của tôi rất tự nhiên: "zombie chẳng giữ byte bộ nhớ nào, vậy nó vô hại thôi". Tôi đã đo bộ nhớ để đánh giá mức nguy hiểm — và kết luận sai hoàn toàn, vì tôi đo nhầm tài nguyên.
Zombie không tốn bộ nhớ, nhưng nó giữ một slot trong bảng tiến trình, tức một PID. Và PID là tài nguyên hữu hạn: /proc/sys/kernel/pid_max trên máy tôi là 4.194.304, còn mặc định trên nhiều hệ thống chỉ 32.768. Mỗi zombie chiếm một PID, và khi zombie tích đủ nhiều, PID cạn kiệt — lúc đó fork() thất bại khắp máy, không tiến trình nào tạo được tiến trình mới, không script nào chạy được lệnh mới, dù RAM còn trống mênh mông. Cái làm sập hệ thống không phải bộ nhớ (thứ tôi đo), mà là PID (thứ tôi bỏ qua).
Bài học đo lường: luôn đo tài nguyên thật sự bị ràng buộc, đừng đo cái dễ nhìn nhất rồi tưởng đã đo đúng chỗ. Đây là gương lật của bài cgroup giới hạn bộ nhớ: ở đó con số bộ nhớ trông đầy mà vô hại (toàn page cache tái chế được); ở đây con số bộ nhớ trông rỗng mà đang rò rỉ (PID). Cả hai cùng một bài học: con số bộ nhớ, nhìn một mình, nói dối về sức khỏe hệ thống — phải hỏi "tài nguyên nào mới là giới hạn thật". Với zombie, giới hạn là số mục bảng tiến trình, đo bằng đếm tiến trình (ps, /proc), không phải bằng RAM.
Có một tầng nữa của cái bẫy này, đặc biệt trong container. Tôi cho một chương trình chạy làm PID 1 của container, tạo một tiến trình cháu mồ côi (cha của cháu thoát trước), rồi không reap:
KHÔNG --init (chương trình là PID 1): 1 zombie kẹt lại mãi
CÓ docker --init (tini là PID 1): 0 zombie
Trong container, tiến trình chính của bạn thường là PID 1 — và PID 1 gánh trách nhiệm reap mọi con mồ côi được nhận nuôi. Nhưng phần lớn ứng dụng (server web, script) không được viết để làm init, không reap gì cả, nên các cháu mồ côi biến thành zombie kẹt vĩnh viễn dưới PID 1. Chạy docker run --init chèn tini — một init tí hon — làm PID 1, và nó tự reap giúp, nên số zombie về 0.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là luôn reap con bạn tạo ra. Nếu chương trình của bạn fork/spawn các tiến trình con (chạy lệnh ngoài, worker, script), bạn phải wait chúng khi chúng xong — nếu không, mỗi con để lại một zombie, và một dịch vụ chạy lâu tạo con liên tục sẽ rò rỉ PID cho tới khi không fork được nữa. Nhiều thư viện bậc cao lo hộ (Python subprocess reap khi bạn gọi .wait()/.poll(), hay dùng context manager; Go os/exec reap khi bạn gọi cmd.Wait()), nhưng quên gọi phần "chờ" đó là gieo rò rỉ. Hoặc đăng ký bỏ qua SIGCHLD (signal(SIGCHLD, SIG_IGN)) để nhân tự reap con giúp.
Hệ quả thứ hai là trong container, dùng một init đúng nghĩa cho PID 1. Nếu ứng dụng của bạn spawn tiến trình con (đặc biệt là các tiến trình có thể mồ côi), hãy chạy docker run --init, hoặc dùng tini/dumb-init làm entrypoint, hoặc đảm bảo tiến trình chính của bạn tự reap. Bỏ qua điều này là lý do nhiều container "chạy vài ngày rồi không spawn được process mới" — một sự cố khó lần vì log ứng dụng sạch trơn, chỉ có ps mới lộ ra một rừng <defunct>.
Hệ quả thứ ba, về đo lường: chẩn đoán một rò rỉ tài nguyên phải đo đúng tài nguyên đang rò. Con số mang theo: một zombie chiếm RSS ~0 KB nên đo bộ nhớ sẽ tưởng nó vô hại — nhưng nó giữ một slot PID (pid_max hữu hạn), đủ zombie thì cạn PID và fork() thất bại khắp máy dù RAM còn đầy; con được reap bằng wait()/waitpid(), và trong container phải để một init (docker --init/tini) làm PID 1 để reap con mồ côi. Đừng để một con số bộ nhớ đẹp đẽ đánh lừa rằng mọi thứ ổn; hỏi cho ra tài nguyên nào mới là giới hạn.
Thử ba mươi giây
Tìm zombie trên máy bạn: ps -eo pid,ppid,stat,comm | grep -w Z (hay grep defunct) — mỗi dòng Z hay <defunct> là một tiến trình đã chết mà cha chưa nhặt. Cột PPID chỉ ra ai là cha lười reap; nếu PPID là 1, chúng là con mồ côi mà init không dọn (dấu hiệu container thiếu init đúng). Xem giới hạn PID: cat /proc/sys/kernel/pid_max — đó là trần số tiến trình cùng lúc, và mỗi zombie ăn một chỗ. Muốn thấy tận mắt, trong shell chạy một hàm fork mà không wait (ví dụ một chương trình C fork rồi để cha ngủ) và quan sát con thành <defunct> trong ps — rồi nhớ rằng con số RSS 0 của nó không hề nói lên cái slot PID nó đang giữ, đúng cái bẫy bài này đo.