Phần 44 nhắc rằng --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.
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 |
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. 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: triệu chứng là "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
Gọi waitpid trong vòng lặp — đúng nhất, và nên dùng WNOHANG để không bị chặn:
while (waitpid(-1, NULL, WNOHANG) > 0) ;
Bỏ qua SIGCHLD — bảo nhân tự dọn, không cần đọc mã thoát:
signal(SIGCHLD, SIG_IGN);
Cách này 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ếu bạn cần biết tiến trình con thành công hay thất bại, đừng dùng.
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.
Thử 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.