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ớ.
Đo bộ nhớ để phán "vô hại" — sai chỗ
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).
Điều rút ra: 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ề đến code hằng ngày
Điều đầ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.
Điều 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>.
Điều cuối, 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ò. Gói lại bằng số: 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, mặc định thường 32.768), đủ zombie (đo được 300 con vẫn ~0 RSS) 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.
Cái bẫy "đo tài nguyên dễ nhìn nhất rồi tưởng đã chẩn đoán xong, trong khi thứ thật sự cạn là một quota vô hình khác" này lặp lại nguyên vẹn ở tầng ứng dụng, và "rò rỉ mà bộ nhớ không tăng" là một trong những sự cố production khó lần nhất. Trong Java, một connection pool hay thread pool bị rò — quên close() connection, quên shutdown() executor — có thể làm ứng dụng "treo, không nhận request mới" trong khi heap vẫn phẳng lì và GC log sạch trơn; thứ cạn là số connection/thread (một quota), không phải RAM, y hệt zombie cạn PID ở đây. Go có đúng cặp bẫy: goroutine leak (goroutine chờ mãi trên channel không ai gửi) làm số goroutine phình dần mà mỗi cái tốn rất ít bộ nhớ, phải đo bằng runtime.NumGoroutine() chứ không phải bằng RSS. Cơ sở dữ liệu cũng vậy: PostgreSQL "hết connection slot" khi ứng dụng rò connection — cạn max_connections, không phải cạn RAM của server. Mẫu số chung, đúng như bài này: khi một hệ thống "chạy một thời gian rồi ngừng phục vụ" mà bộ nhớ vẫn ổn, đừng dừng ở biểu đồ RAM; liệt kê mọi tài nguyên đếm được (PID, fd, connection, thread, goroutine, slot pool) và đo từng cái, vì thủ phạm gần như luôn là một quota vô hình mà không dashboard bộ nhớ nào chỉ ra.
Muốn tự 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, chạy một chương trình fork rồi để cha ngủ mà không wait, 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.