Hình dung một phòng khám phát số thứ tự nhưng chỉ có đúng 5.000 tấm thẻ. Bệnh nhân khám xong rời đi, nhưng tấm thẻ của họ chỉ được thu lại khi có người chủ động đến quầy nhận nó về. Nếu không ai thu, tấm thẻ vẫn bị coi là "đang dùng" dù bệnh nhân đã về từ lâu — và tới lúc cả 5.000 thẻ đều "đang dùng", phòng khám không phát nổi thẻ mới cho ai nữa, dù phòng chờ trống trơn. Zombie trong Linux đúng là những tấm thẻ chưa ai thu: tiến trình đã chết nhưng số hiệu của nó vẫn bị giữ tới khi cha đọc mã thoát. Phần 44 nhắc --init tồn tại một phần để thu hồi zombie; bài này đo xem không thu hồi thì chuyện gì xảy ra.
(Các con số là số zombie và thời gian đo trong container, không phải perf counter phần cứng.)
Zombie sinh ra như thế nào
Khi một tiến trình con thoát, nhân không xoá nó ngay. Nó giữ lại một mục nhỏ chứa mã thoát và số liệu thống kê, chờ tiến trình cha đọc bằng wait() hoặc waitpid(). Trạng thái đó là Z — zombie.
| Thí nghiệm | Số zombie |
|---|---|
Tạo 200 tiến trình con, có gọi waitpid() |
0 |
Tạo 200 tiến trình con, không gọi waitpid() |
200 |
Rồi gọi một vòng waitpid(-1, WNOHANG) |
0 |
Điểm cần khắc: zombie không phải lỗi của tiến trình con — nó là lỗi của tiến trình cha không đọc mã thoát, và một vòng waitpid dọn sạch tất cả trong một lần gọi.
Và chúng cạn PID trong 0,17 giây
Container giới hạn 5.000 PID, vòng lặp fork() không thu hồi:
2000 zombie...
4000 zombie...
dừng ở 4997 zombie sau 0,17 s: Resource temporarily unavailable
pids.max = 5000
pids.current = 5000
Zombie không tốn bộ nhớ (mục nhỏ vài trăm byte trong slab) và không tốn CPU (nó không chạy). Nó giữ đúng một thứ: số hiệu tiến trình — và số hiệu tiến trình là tài nguyên có hạn. Sau khi cạn, fork() trả về EAGAIN cho mọi tiến trình trong container: không chạy được lệnh nào nữa, không mở được shell để chẩn đoán, kiểm tra sức khoẻ dạng exec cũng hỏng. Điểm khó chịu là triệu chứng: nó hiện ra thành "không tạo được tiến trình", không phải "hết bộ nhớ" hay "hết CPU" — người trực nhìn biểu đồ thấy bộ nhớ và CPU đều bình thường, và đi tìm sai hướng. Ba nơi đọc giới hạn:
cat /sys/fs/cgroup/pids.max # gioi han cua container
cat /sys/fs/cgroup/pids.current # dang dung
cat /proc/sys/kernel/pid_max # gioi han toan he thong
ulimit -u # gioi han moi nguoi dung
Tìm zombie
ps -eo pid,ppid,stat,comm | awk '$3 ~ /^Z/'
ps -eo stat | grep -c '^Z'
Cột quan trọng là ppid — tiến trình cha. Zombie không sửa được bằng cách giết chính nó (nó đã chết rồi); phải xử lý tiến trình cha.
kill -CHLD <ppid> # nhac cha rang co con da thoat
Lệnh này chỉ có tác dụng nếu cha có trình xử lý SIGCHLD mà vì lý do nào đó chưa chạy. Nếu cha đơn giản là không bao giờ gọi waitpid, cách duy nhất là khởi động lại nó — và khi đó mọi zombie của nó được PID 1 nhận và dọn ngay.
Tiến trình mồ côi về đâu
[cha] pid=6199, thoát ngay
[con] pid=6200, cha=6199
[con] sau khi cha chết: cha mới = 1
Tiến trình con còn sống khi cha chết được nhận nuôi bởi PID 1. Đó là lý do PID 1 phải biết thu hồi: mọi tiến trình mồ côi của toàn hệ thống cuối cùng đều thành con của nó. Trong container, PID 1 là ứng dụng của bạn — nếu nó không gọi waitpid (và phần lớn ứng dụng không gọi, vì chúng không mong đợi làm init) thì mọi tiến trình mồ côi trở thành zombie vĩnh viễn. Đây chính là vấn đề mà docker run --init và tini giải quyết.
PR_SET_CHILD_SUBREAPER
Có một cách khác, tinh tế hơn: một tiến trình có thể tự nhận làm "PID 1 của cây con dưới nó".
#include <sys/prctl.h>
prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0);
[ông] pid=6202 đã đặt PR_SET_CHILD_SUBREAPER
[con] pid=6203, thoát ngay
[cháu] sau khi cha chết: cha mới = 6202
Cháu được nhận nuôi bởi ông (6202), không phải PID 1. Đây là cơ chế mà systemd dùng để giữ quyền kiểm soát toàn bộ cây tiến trình của một dịch vụ — nhờ nó, systemctl stop dừng được cả những tiến trình đã mất cha giữa chừng, và KillMode=control-group hoạt động. Với một trình quản lý tiến trình tự viết, đặt cờ này là cách đúng để không mất dấu tiến trình cháu.
Ba cách xử lý trong mã nguồn
Cách đúng nhất là gọi waitpid trong vòng lặp, nên dùng WNOHANG để không bị chặn:
while (waitpid(-1, NULL, WNOHANG) > 0) ;
Cách gọn hơn là bỏ qua SIGCHLD — bảo nhân tự dọn, không cần đọc mã thoát:
signal(SIGCHLD, SIG_IGN);
Nó gọn nhưng đánh đổi thật: sau đó waitpid không lấy được mã thoát của tiến trình con, nên nếu bạn cần biết con thành công hay thất bại thì đừng dùng. Cách thứ ba là fork hai lần — mẫu kinh điển để tạo tiến trình nền:
if (fork() == 0) { /* con */
if (fork() == 0) { /* chau - viec that lam o day */
lam_viec();
_exit(0);
}
_exit(0); /* con thoat ngay */
}
wait(NULL); /* cha thu hoi con, ngay lap tuc */
Cháu mồ côi ngay lập tức, được PID 1 nhận nuôi, và cha không phải theo dõi gì nữa. Đây là cách các daemon truyền thống tự tách khỏi terminal.
Mẫu số chung
Cái lõi ở đây — một thứ đã "xong" vẫn giữ một tài nguyên khan hiếm cho tới khi có ai đó chủ động "thu hồi" nó, và người phải thu hồi là chủ/cha chứ không phải cái xác — là hình dạng chung của mọi rò rỉ handle, chỉ đổi tên tài nguyên.
Trong thế giới Java, đây đúng là chuyện một Process từ ProcessBuilder mà bạn không gọi waitFor()/đọc hết stream: trên Linux nó để lại đúng một zombie, y như C. Rộng hơn, đó là mọi handle không đóng — Connection không trả về pool, InputStream/FileChannel quên close() — mỗi cái là một "zombie" giữ một khe khan hiếm (kết nối, file descriptor) tới khi ai đó thu; try-with-resources tồn tại chính để việc thu-hồi không phụ thuộc trí nhớ.
Trong Go, cùng khuôn ba mặt: exec.Cmd phải gọi Wait() nếu không cũng để lại zombie; một goroutine phóng ra mà không có đường kết thúc (không context huỷ, không WaitGroup) là "tiến trình mồ côi" giữ bộ nhớ và có khi cả một khe khoá; và một kết nối không trả về pool là handle rò. Lời khuyên số một của Go — "ai phóng goroutine, người đó chịu trách nhiệm cho nó kết thúc" — chính là "cha phải waitpid".
Còn ở cơ sở dữ liệu, đây là rò connection và rò cursor: một kết nối mượn từ pool mà không đóng bị coi là "đang dùng" cho tới timeout, và đủ nhiều thì pool cạn — mọi truy vấn mới nhận lỗi "không lấy được kết nối", đúng cái EAGAIN của PID, và cũng đúng cái bẫy chẩn đoán "CPU/RAM DB vẫn nhàn mà app báo hết kết nối". Cursor/prepared statement không đóng giữ handle phía server y hệt.
Mẫu số chung: một thao tác "xong" thường vẫn giữ một handle khan hiếm (PID, file descriptor, kết nối, khe pool) cho tới khi chủ sở hữu chủ động thu hồi nó — và cái handle đó, không phải bộ nhớ, mới là thứ cạn trước, với triệu chứng 'không cấp thêm được' đánh lừa người đi tìm chỗ hết RAM. Vì thế câu hỏi đúng khi một hệ báo "không tạo/mượn được nữa" mà tài nguyên thô vẫn dư không phải "hết RAM/CPU chưa" mà là "cái gì đã xong mà chưa được thu, và ai là người đáng lẽ phải thu nó" — vì cách sửa luôn nằm ở chủ/cha, không nằm ở cái xác.
Muốn tự soi trong ba mươi giây:
echo "--- zombie hien co ---"
ps -eo pid,ppid,stat,comm 2>/dev/null | awk '$3 ~ /^Z/ {print}' | head -10
echo "tong: $(ps -eo stat 2>/dev/null | grep -c '^Z')"
echo "--- gioi han PID ---"
echo "cgroup : $(cat /sys/fs/cgroup/pids.current 2>/dev/null) / $(cat /sys/fs/cgroup/pids.max 2>/dev/null)"
echo "he thong: $(cat /proc/sys/kernel/pid_max)"
echo "--- tien trinh nao co nhieu con zombie nhat ---"
ps -eo ppid,stat 2>/dev/null | awk '$2 ~ /^Z/ {print $1}' | sort | uniq -c | sort -rn | head -3
Nếu pids.current vượt 70% pids.max, hãy tìm nguyên nhân ngay — bảng ở đầu bài cho thấy khoảng cách từ 70% tới cạn sạch có thể chỉ là một phần mười giây. Và nếu lệnh cuối chỉ ra một PID cụ thể có hàng trăm con zombie, bạn đã tìm được tiến trình cần sửa: nó là tiến trình cha, không phải các zombie.
Phần sau: khởi động tiến trình — đo fork, vfork, posix_spawn.