Có một câu lệnh mà gần như ai cũng từng gõ khi một chương trình chạy loạn: tìm PID của nó rồi kill. Nhưng nếu chương trình đó đã đẻ ra vài tiến trình con — một server dựng worker, một script gọi lệnh con — thì giết mỗi PID cha có dừng được cả cụm không? Trực giác nói "cha chết thì con chết theo". Bài này dựng một cây tiến trình thật trong container, bắn tín hiệu theo hai cách, và đo xem ai chết ai sống — kết quả lật ngược đúng cái trực giác đó, và chỉ ra công cụ đúng: nhóm tiến trình.

Nhóm tiến trình

Nhóm tiến trình là gì

Mỗi tiến trình trên Linux, ngoài PID của riêng nó, còn thuộc về một nhóm tiến trình (process group) có mã số riêng gọi là PGID. Khi một tiến trình fork ra con, mặc định con thừa kế luôn PGID của cha — nên cả một cây tiến trình sinh ra từ cùng một gốc thường nằm chung một nhóm.

Nhóm tồn tại để làm được một việc rất cụ thể: gửi một tín hiệu tới tất cả thành viên cùng lúc. Lời gọi kill có một quy ước gọn: nếu bạn đưa cho nó một PID âm, nó hiểu đó là "gửi tới cả nhóm có PGID bằng trị tuyệt đối đó". Tức kill(-pgid, sig) phát tín hiệu cho mọi tiến trình trong nhóm, trong khi kill(pid, sig) (số dương) chỉ chạm đúng một tiến trình.

Đây chính là cơ chế đằng sau Ctrl+C. Khi bạn nhấn Ctrl+C trong terminal, bàn phím không gửi tín hiệu tới một tiến trình — nó gửi SIGINT tới cả nhóm tiền cảnh của terminal. Nhờ vậy một pipeline a | b | c (ba tiến trình chung một nhóm tiền cảnh) chết cả ba chỉ với một phím. Để kiểm chứng chuyện này thành con số, tôi dựng một cây tiến trình và tự bắn tín hiệu.

Đo: giết nhóm hạ tất, giết cha thì không

Tôi cho một tiến trình "leader" tự lập một nhóm mới (setpgid), rồi fork ra 3 worker (đều thừa kế nhóm của leader). Một tiến trình điều khiển đứng ngoài nắm PID của cả bốn, và sau khi mọi thứ chạy ổn định thì bắn tín hiệu theo từng cách. Sau đó nó kiểm tra ai còn sống bằng kill(pid, 0) (gửi "tín hiệu số 0" chỉ để dò sự tồn tại, không giết gì). Lặp lại 3 lần, kết quả y hệt:

Hành động leader 3 worker
kill(-pgid, SIGTERM) — cả nhóm chết chết cả 3
kill(leader_pid, SIGKILL) — chỉ cha chết sống cả 3

Dòng đầu đúng như mong đợi: bắn vào cả nhóm thì một lời gọi hạ luôn leader lẫn ba worker. Nhưng dòng thứ hai mới là chỗ đáng dừng lại. Khi tôi giết chỉ PID của leader bằng cả SIGKILL (tín hiệu mạnh nhất, không chặn được), leader chết ngay — nhưng cả ba worker vẫn sống nhăn. Chúng không hề nhận được tín hiệu nào cả.

Một lần tôi đo hớ: "giết cha là giết con" hoàn toàn sai

Tôi vào bài này với niềm tin phổ biến: "giết tiến trình cha thì cây con của nó chết theo". Bảng trên bác bỏ thẳng thừng. Giết leader bằng SIGKILL — cú giết dứt khoát nhất có thể — mà ba worker chẳng suy suyển. Lý do: tín hiệu trên Linux không lan theo quan hệ cha–con. Kernel gửi tín hiệu tới đúng cái PID (hoặc đúng cái nhóm) bạn chỉ định, không hơn. Khi cha chết, các con của nó không chết theo mà trở thành tiến trình mồ côi, và kernel lập tức cho chúng một cha nuôi mới — thường là pid 1 (init, hoặc tiến trình con thu gom trong container). Chúng tiếp tục chạy như chưa có gì xảy ra.

Đây không phải chuyện lý thuyết. Nó chính là lý do một script "dọn dẹp" kiểu kill $! (giết tiến trình con vừa chạy nền) thường bỏ sót cháu chắt: nếu tiến trình con ấy lại đẻ ra tiến trình của nó, giết mỗi PID con để lại một đám cháu mồ côi vẫn ngốn CPU và giữ cổng. Cũng là lý do đôi khi bạn "tắt" một server mà cổng của nó vẫn kẹt — một worker con đã sống sót qua cái chết của cha và vẫn giữ socket.

Công cụ đúng là nhắm nhóm chứ không nhắm cha: kill(-pgid, sig) phát cho mọi thành viên trong một lần, không sót con nào — đúng cái dòng đầu của bảng thể hiện, và đúng cái Ctrl+C làm với cả pipeline. Bài học đo lường: trực giác về "phạm vi ảnh hưởng" của một thao tác phải được đo, không được đoán. Tôi tưởng cái chết của cha lan xuống con; đo ra nó dừng ngay tại cha. Xác định sai đơn vị chịu tác động (một tiến trình, hay cả nhóm) là gửi tín hiệu trúng nửa cái cây và tưởng đã dọn sạch.

Nhóm là tư cách thành viên, không phải quan hệ cha–con

Để chắc rằng thứ quyết định thật sự là thành viên nhóm chứ không phải chuyện ai đẻ ra ai, tôi đo thêm một kịch bản: cho một trong ba worker tự gọi setpgid(0, 0) để rời nhóm của leader và lập nhóm riêng, rồi mới bắn kill(-pgid) vào nhóm cũ.

Gửi kill(-pgid) tới nhóm của leader:
  worker #1 -> chết
  worker #2 -> chết
  worker #3 (đã rời nhóm) -> SỐNG

Worker #3 vẫn là con của leader y như hai đứa kia, nhưng vì nó đã chuyển sang một nhóm khác, tín hiệu gửi tới nhóm cũ không chạm tới nó. Vậy thứ định đoạt ai nhận tín hiệu nhóm là PGID hiện tại của tiến trình, hoàn toàn không liên quan tới cây cha–con. Đây cũng chính là chiêu mà các daemon và lệnh nohup dùng để sống sót: chúng gọi setsid() (tạo hẳn một phiên và nhóm mới) để tách khỏi nhóm tiền cảnh của terminal, nhờ vậy khi bạn đóng terminal hay nhấn Ctrl+C, tín hiệu gửi tới nhóm cũ không với tới chúng.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là muốn dừng cả một cây tiến trình thì quản lý nó như một nhóm. Khi khởi chạy một tiến trình con có khả năng tự đẻ cháu, hãy đặt nó vào một nhóm riêng (nhiều thư viện có sẵn tùy chọn này, ví dụ setsid/start_new_session khi tạo tiến trình con), rồi khi cần dừng thì gửi tín hiệu tới cả nhóm (kill(-pgid, ...)). Chỉ giết PID cha là để lại cháu mồ côi. Đây là lỗi kinh điển của các bộ giám sát tiến trình viết vội và của các script CI "hết giờ thì kill" — chúng giết cha rồi tưởng đã sạch.

Hệ quả thứ hai là hiểu vòng đời của tiến trình mồ côi. Con mất cha không chết mà được pid 1 nhận nuôi; nếu pid 1 không chịu thu gom (reap) chúng khi chúng kết thúc, ta có tiến trình xác sống (zombie) tích tụ — đúng vấn đề mà cờ --init của Docker sinh ra để giải quyết (dựng một init tối giản làm cha nuôi biết thu gom). Biết tín hiệu không lan theo cha–con giúp bạn không ngạc nhiên khi thấy tiến trình con "sống dai" hơn cha.

Hệ quả thứ ba là một bài học tư duy chung: trước khi tác động, hãy xác định đúng đơn vị mình muốn tác động. Một tiến trình? Cả nhóm? Cả phiên? Linux cho bạn ba tầng phạm vi (PID, PGID, SID) đúng vì mỗi tầng trả lời một câu hỏi khác nhau. Con số mang theo: tín hiệu tới đúng cái bạn nhắm — kill(pid) một tiến trình, kill(-pgid) cả nhóm — và cái chết của cha KHÔNG lan xuống con; muốn hạ cả cây phải nhắm nhóm, muốn trốn tín hiệu terminal thì rời nhóm. Đó là toàn bộ cơ chế đằng sau Ctrl+C, nohup, và những con worker cứng đầu không chịu chết.

Thử ba mươi giây

Trong một terminal, chạy sleep 300 | cat rồi mở terminal khác gõ ps -o pid,pgid,cmd. Bạn sẽ thấy sleepcatcùng PGID — chúng là một nhóm tiền cảnh. Giờ quay lại terminal đầu nhấn Ctrl+C: cả hai chết cùng lúc, vì tín hiệu đi tới cả nhóm. Muốn thấy mặt trái, chạy sleep 300 & rồi kill đúng PID của sleep — nếu bạn bọc nó trong một script còn đẻ tiến trình khác, hãy thử kill PID script và xem ps còn sót tiến trình con nào không. Chỉ vài lệnh là thấy rõ ranh giới giữa "một tiến trình" và "cả nhóm" mà bài này đo.