Signal — SIGTERM, SIGKILL và Ctrl+C thật sự làm gì
Cơ chế signal: OS gõ cửa tiến trình thế nào, khác nhau giữa SIGTERM (xin dừng), SIGKILL (không cãi được), SIGINT (Ctrl+C), SIGSTOP — và graceful shutdown.
TL;DR: Signal là cách OS (hoặc tiến trình khác) gõ cửa một tiến trình đang chạy để báo một sự kiện: "hãy dừng", "có lỗi", "con của mày vừa chết". Khi signal tới, kernel tạm ngắt tiến trình và chạy handler — hàm phản ứng — nếu tiến trình đã đăng ký; nếu không, dùng default action (thường là kết thúc tiến trình). Điểm mấu chốt: hầu hết signal có thể bắt (catch) để xử lý gọn gàng, nhưng hai signal — SIGKILL (số 9) và SIGSTOP (số 19 trên x86/ARM) — không thể bắt, chặn, hay bỏ qua. Vì vậy SIGTERM (số 15, "xin dừng") cho phép cleanup, còn SIGKILL giết thẳng. Ctrl+C gửi SIGINT (số 2). docker stop gửi SIGTERM rồi SIGKILL sau 10 giây.
Bạn gõ Ctrl+C một script Python đang chạy — nó dừng ngay. Nhưng một service khác thì Ctrl+C hoài không chết, phải kill -9 mới xong. Bạn docker stop một container, đợi đúng 10 giây nó mới tắt hẳn. Ba tình huống này đều là signal — và khác biệt về "chết ngay hay chết từ từ" nằm ở signal nào được gửi và tiến trình có được phép phản ứng không.
Xong bài, bạn compare được SIGTERM, SIGKILL, SIGINT, SIGSTOP — dự đoán mỗi tiến trình phản ứng ra sao — và áp dụng được pattern graceful shutdown mà mọi service production nên có.
1. Analogy — các kiểu yêu cầu nhân viên rời văn phòng
Hình dung nhiều cách bảo một nhân viên rời chỗ làm:
- "Anh dọn đồ rồi về nhé" (lịch sự): nhân viên lưu file đang mở, gửi nốt email dở, tắt máy, rồi đi. Đây là
SIGTERM— yêu cầu dừng, cho phép chuẩn bị. - Bảo vệ lôi thẳng ra cửa, không cho lấy đồ: nhân viên bị đưa ra ngay, file chưa lưu mất. Đây là
SIGKILL— không thương lượng, không cleanup. - Nhấn chuông báo động chung (Ctrl+C): mọi người ở khu vực đó đứng dậy đi ra. Đây là
SIGINTtừ terminal. - Bấm nút "đóng băng": nhân viên đứng im tại chỗ, không làm gì, chờ lệnh tiếp. Đây là
SIGSTOP— tạm dừng, chưa kết thúc.
Điểm khác biệt cốt lõi: với lời nhắc lịch sự, nhân viên được quyền phản ứng (dọn dẹp). Với bảo vệ lôi ra hay nút đóng băng, nhân viên không cãi được — tương ứng SIGKILL và SIGSTOP mà tiến trình không thể bắt hay chặn.
| Văn phòng | Signal |
|---|---|
| "Dọn đồ rồi về" (được chuẩn bị) | SIGTERM — xin dừng, cleanup được |
| Bảo vệ lôi thẳng ra, không cho lấy đồ | SIGKILL — giết ngay, không cleanup |
| Chuông báo động khu vực | SIGINT — Ctrl+C tới nhóm foreground |
| Nút đóng băng đứng im | SIGSTOP — tạm dừng, không kết thúc |
| Bảo vệ và nút đóng băng: không cãi được | SIGKILL/SIGSTOP không bắt/chặn được |
SIGTERM là gõ cửa ("làm ơn dừng"); SIGKILL là phá cửa ("dừng ngay, khỏi bàn"). Luôn thử SIGTERM trước để tiến trình cleanup; chỉ dùng SIGKILL khi nó không phản hồi.
2. Cơ chế signal — kernel gõ cửa thế nào?
Signal là một thông báo bất đồng bộ (asynchronous) — nó có thể tới bất kỳ lúc nào, giữa hai lệnh bất kỳ của tiến trình. Nguồn gửi: kernel (ví dụ lỗi chia cho 0 → SIGFPE), tiến trình khác (qua system call kill), hay chính terminal (Ctrl+C → SIGINT).
Khi một signal được gửi tới tiến trình, kernel tạm ngắt luồng chạy bình thường và chọn một trong ba disposition:
- Handler tự đăng ký: nếu tiến trình đã gọi
signal()/sigaction()để đăng ký một hàm cho signal đó, kernel chạy hàm ấy (rồi thường quay lại chỗ bị ngắt). Đây là "bắt" (catch) signal. - Default action: nếu chưa đăng ký, kernel dùng hành vi mặc định của signal đó. Theo signal(7), default có thể là Term (kết thúc), Core (kết thúc + dump), Stop (tạm dừng), Cont (chạy tiếp), hay Ign (bỏ qua).
- Bỏ qua: tiến trình đặt disposition thành
SIG_IGN— signal tới cũng như không.
flowchart TB
K["Kernel gui signal toi tien trinh"] --> Q{"Tien trinh da dang ky handler?"}
Q -->|"co"| H["Chay handler roi quay lai cho bi ngat"]
Q -->|"khong"| D["Dung default action"]
D --> T["Term: ket thuc tien trinh"]
D --> S["Stop: tam dung"]
D --> I["Ign: bo qua"]
K -.->|"SIGKILL / SIGSTOP"| F["Kernel thuc thi truc tiep, KHONG qua handler"]Ngoại lệ tối quan trọng (mũi tên đứt trong sơ đồ): theo signal(7), "SIGKILL và SIGSTOP không thể bị catch, block, hay ignore". Với hai signal này, kernel bỏ qua mọi handler và thực thi trực tiếp — SIGKILL giết tiến trình ngay, SIGSTOP đóng băng ngay. Đây là "van an toàn" của OS: luôn có cách giết/dừng một tiến trình lì, kể cả khi code nó cố chống.
3. Bốn signal bạn gặp mỗi ngày
| Signal | Số | Default | Bắt được? | Ý nghĩa / khi nào |
|---|---|---|---|---|
SIGINT | 2 | Term | Có | Ctrl+C tới nhóm tiến trình foreground — "ngắt, tôi muốn dừng" |
SIGTERM | 15 | Term | Có | Yêu cầu kết thúc lịch sự; kill <pid> mặc định gửi cái này |
SIGKILL | 9 | Term | Không | Giết ngay, không cleanup; kill -9; van cuối cùng |
SIGSTOP | 19 | Stop | Không | Đóng băng tiến trình (không kết thúc); số 19 tuỳ kiến trúc; Ctrl+Z gửi SIGTSTP họ hàng (bắt được) |
Vài hệ quả thực tế:
kill <pid>mặc định gửiSIGTERM, không phảiSIGKILL. Nhiều người tưởngkilllà "giết chết" — thực ra nó chỉ gửi signal, và mặc định là lời mời dừng lịch sự.Ctrl+C=SIGINT: signal tới cả nhóm tiến trình foreground (process group — tập tiến trình shell gom lại, ví dụ cả pipelinea | b | c, để nhận signal cùng lúc). Chương trình bắt được nên có thể dừng gọn (lưu trạng thái, in "Bye"). Chương trình phớt lờSIGINT(đăng ký handler rỗng) thì Ctrl+C vô hiệu — đó là lý do đôi khi Ctrl+C không ăn.SIGKILLlà lựa chọn cuối: dùng khi tiến trình không phản hồiSIGTERM. Vì không cleanup, nó có thể để lại file tạm, lock, dữ liệu ghi dở.
Nếu mọi signal đều bắt/chặn được, một tiến trình lỗi (hoặc mã độc) có thể đăng ký handler rỗng cho tất cả và trở nên bất tử — không cách nào dừng. OS cần một cơ chế không thể bị vô hiệu hoá để giữ quyền kiểm soát cuối cùng. Vì thế đúng hai signal — SIGKILL (giết) và SIGSTOP (dừng) — được kernel thực thi trực tiếp, không đi qua code của tiến trình. Đây là ranh giới quyền lực kernel/user (đã học ở Module 1): user code chạy trong không gian của nó, nhưng kernel luôn có nút tắt cứng.
4. Graceful shutdown — bắt SIGTERM để dừng sạch
Đây là pattern quan trọng nhất của bài, dùng trong mọi service production. Ý tưởng: bắt SIGTERM, và thay vì chết ngay, làm thủ tục dừng có trật tự:
- Ngừng nhận yêu cầu/việc mới.
- Hoàn tất các việc đang dở (request đang xử lý).
- Đóng tài nguyên: flush log, đóng kết nối DB, nhả lock, xoá file tạm.
- Thoát với exit status hợp lý.
Dưới đây là khung graceful shutdown còn hai chỗ trống — dựa vào bốn bước trên, tự điền trước khi xem đáp án.
// graceful.c -- bat SIGTERM va SIGINT de dung sach
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
volatile sig_atomic_t stop = 0; // co bao "can dung"
void on_signal(int sig) {
stop = 1; // chi dat co -- KHONG lam viec nang trong handler
}
int main(void) {
signal(SIGTERM, /* TODO 1: dang ky ham nao cho SIGTERM? */);
signal(SIGINT, /* TODO 1: cung ham do cho Ctrl+C */);
while (/* TODO 2: lap chung nao co chua bat? */) {
// ... xu ly cong viec binh thuong ...
sleep(1);
}
// Toi day nghia la da nhan SIGTERM/SIGINT -> cleanup
printf("Nhan tin hieu dung, dang cleanup roi thoat...\n");
// dong DB, flush log, xoa file tam o day
return 0;
}
TODO 1: đăng ký hàm nào để khi SIGTERM (và Ctrl+C) tới thì cờ stop được bật? TODO 2: vòng lặp chính nên chạy chừng nào cờ stop còn ở giá trị nào? Nhớ: handler chỉ đặt cờ, còn main thấy cờ mới cleanup.
Đáp án: TODO 1 = on_signal (hàm handler đặt stop = 1); TODO 2 = !stop (lặp chừng nào cờ chưa bật, thoát vòng khi handler đã bật cờ). Khớp lại: signal(SIGTERM, on_signal); và while (!stop) { ... }.
Trong handler on_signal, ta chỉ đặt stop = 1 chứ không in log hay đóng DB ngay. Vì sao không làm hết việc cleanup trong handler cho gọn? (Gợi ý: handler chạy bất đồng bộ, chen vào giữa code chính.)
Lý do handler chỉ đặt cờ: signal handler chạy bất đồng bộ, chen vào giữa bất kỳ lệnh nào của chương trình chính — kể cả giữa lúc chương trình đang ở trong malloc hay printf. Một hàm async-signal-safe là hàm được phép gọi an toàn bên trong handler — nó reentrant (gọi lồng/ngắt giữa chừng vẫn đúng), không đụng tới lock, malloc hay buffer dùng chung với luồng chính (danh sách đầy đủ ở signal-safety(7)). Gọi hàm không async-signal-safe trong handler (như printf) có thể gây deadlock hoặc hỏng dữ liệu. Pattern an toàn: handler chỉ đặt một cờ kiểu volatile sig_atomic_t. Từ khoá volatile buộc compiler đọc lại biến từ bộ nhớ mỗi lần thay vì cache trong register — để vòng lặp chính thật sự thấy thay đổi mà handler vừa ghi; còn sig_atomic_t là kiểu mà đọc/ghi một lần là nguyên tử, không bị signal chen ngang giữa chừng. Vòng lặp chính thấy cờ rồi tự cleanup ở ngữ cảnh bình thường.
Đây là graceful shutdown ngoài đời. Theo tài liệu Docker, docker stop gửi SIGTERM tới tiến trình chính của container, chờ một khoảng ân hạn (mặc định 10 giây cho Linux container, chỉnh bằng cờ -t/--timeout), rồi nếu tiến trình vẫn chưa thoát mới gửi SIGKILL để giết cứng. Vì vậy nếu app của bạn bắt SIGTERM và cleanup nhanh, container tắt tức thì; nếu app phớt lờ SIGTERM, bạn phải chờ đúng 10 giây rồi nó bị SIGKILL — mất cleanup. Kubernetes dùng cùng mẫu: gửi SIGTERM, chờ terminationGracePeriodSeconds, rồi SIGKILL.
5. Pitfall tổng hợp
❌ Nhầm 1 — tưởng kill luôn là "giết chết ngay":
kill 4821 # gui SIGTERM (15) -- yeu cau dung LICH SU, khong phai giet ngay
kill -9 4821 # day moi la SIGKILL -- giet cung
✅ kill <pid> mặc định gửi SIGTERM, cho tiến trình cơ hội cleanup. Chỉ kill -9 (hoặc kill -KILL) mới là SIGKILL không cưỡng lại được. Thói quen tốt: kill (SIGTERM) trước, chờ vài giây, kill -9 chỉ khi nó lì.
❌ Nhầm 2 — làm việc nặng (I/O, malloc) ngay trong signal handler:
void handler(int sig) {
printf("Shutting down...\n"); // RUI RO: printf khong async-signal-safe
save_to_database(); // RAT RUI RO: co the deadlock/hong du lieu
}
✅ Handler chen bất đồng bộ vào giữa code chính, kể cả giữa một lời gọi thư viện. Chỉ làm thao tác async-signal-safe — điển hình là đặt một cờ volatile sig_atomic_t. Cleanup thật để vòng lặp chính làm sau khi thấy cờ.
❌ Nhầm 3 — cố bắt SIGKILL để "chống bị giết":
signal(SIGKILL, my_handler); // VO ICH: SIGKILL khong the bat
✅ SIGKILL và SIGSTOP không thể catch/block/ignore — lời gọi trên bị bỏ qua. Đừng thiết kế logic dựa trên việc "dọn dẹp khi bị SIGKILL": không có cơ hội đó. Mọi cleanup phải gắn vào SIGTERM (bắt được). SIGKILL là điểm cụt.
6. 📚 Deep Dive
Nguồn chuẩn:
- signal(7) — man7.org — bảng đầy đủ mọi signal (số, default action, giải thích), câu chốt "
SIGKILLvàSIGSTOPkhông thể catch/block/ignore", và khái niệm async-signal-safe. - kill(2) — man7.org — system call gửi signal tới tiến trình; nền cho lệnh
killcủa shell. - sigaction(2) — man7.org — cách đăng ký handler hiện đại (thay
signal()cũ), với các cờ điều khiển hành vi chính xác. - Docker
docker stop— SIGTERM, grace period mặc định 10s, rồi SIGKILL; cờ-t/--timeout. - OSTEP — chương "Process API" (file
cpu-api.pdf) — mục signals trong quan hệ tiến trình.
Ghi chú: Số của các signal cơ bản (SIGINT=2, SIGKILL=9, SIGTERM=15) ổn định trên x86/ARM Linux, nhưng vài signal (SIGSTOP là 19 trên x86/ARM) có số khác nhau theo kiến trúc — nên trong code luôn dùng tên hằng (SIGKILL) thay vì số trần. signal(7) liệt kê cả biến thể theo kiến trúc.
7. Liên hệ các bài khác
- Zombie & orphan:
SIGCHLDlà signal kernel gửi cho cha khi con chết — cơ sở để reap không block (bắtSIGCHLD, gọiwaitpidvớiWNOHANG). - fork, exec, wait: disposition signal được thừa kế qua
forkvà (một phần) reset quaexec— vòng đời signal gắn với vòng đời tiến trình. - System call là gì:
kill,sigactionlà system call; và việcSIGKILLkhông bắt được là biểu hiện của ranh giới quyền lực kernel/user. - Mini-challenge — mổ xẻ cây tiến trình: bạn sẽ tự gửi
SIGTERMvsSIGKILLlên một tiến trình thật và quan sát khác biệt.
8. Tóm tắt
- Signal là thông báo bất đồng bộ tới tiến trình; disposition có thể là handler tự đăng ký, default action, hoặc bỏ qua.
SIGTERM(15) yêu cầu dừng lịch sự — bắt được, cho cleanup;kill <pid>mặc định gửi cái này.SIGKILL(9) vàSIGSTOP(19) không thể catch/block/ignore; kernel thực thi trực tiếp — van an toàn để OS luôn kiểm soát được tiến trình.SIGINT(2) là Ctrl+C tới nhóm tiến trình foreground; bắt được nên dừng gọn.- Graceful shutdown: bắt
SIGTERM, ngừng nhận việc mới, hoàn tất việc dở, đóng tài nguyên, rồi thoát. Handler chỉ đặt cờ (async-signal-safe); vòng lặp chính cleanup. docker stopgửiSIGTERM, chờ 10 giây (mặc định Linux), rồiSIGKILL— dùng đúng tên hằng thay vì số trần trong code.
9. Tự kiểm tra
Q1Vì sao Ctrl+C dừng được script này nhưng service kia phải kill -9? Giải thích theo signal nào được gửi và tiến trình phản ứng ra sao.▸
SIGINT (số 2) tới tiến trình foreground. SIGINT bắt được: mặc định nó kết thúc tiến trình (default action Term), nên script thường dừng ngay. Nhưng một service có thể đăng ký handler cho SIGINT (ví dụ để "bỏ qua Ctrl+C" hoặc chỉ log) — khi đó Ctrl+C không giết được nó. Lúc này bạn phải leo thang: kill (gửi SIGTERM, số 15 — vẫn bắt được), và nếu vẫn lì thì kill -9 (SIGKILL, số 9). SIGKILL không thể bắt/chặn nên luôn giết được — đó là lý do nó là van cuối cùng.Q2SIGKILL và SIGSTOP khác mọi signal khác ở điểm gì, và vì sao OS cố tình thiết kế chúng như vậy?▸
SIGKILL giết ngay, SIGSTOP đóng băng ngay). OS thiết kế vậy để giữ quyền kiểm soát cuối cùng: nếu mọi signal đều chặn được, một tiến trình lỗi hoặc mã độc có thể đăng ký handler rỗng cho tất cả và trở nên bất tử — không cách nào dừng, chiếm CPU/tài nguyên mãi. Bằng cách để đúng hai signal (một để giết, một để dừng) không thể vô hiệu hoá, kernel luôn có "nút tắt cứng". Đây là biểu hiện của ranh giới quyền lực kernel/user: user code có quyền trong không gian của nó, nhưng kernel đứng trên.Q3Vì sao trong signal handler ta chỉ nên đặt một cờ (stop = 1) thay vì làm cleanup (đóng DB, ghi log) ngay tại đó?▸
malloc, printf, hay đang giữ một lock nội bộ của thư viện. Nếu handler gọi lại chính những hàm không async-signal-safe đó (phần lớn hàm chuẩn), ta có thể gây deadlock (giành lại lock chương trình chính đang giữ) hoặc hỏng cấu trúc dữ liệu đang dở. Pattern an toàn: handler chỉ làm thao tác async-signal-safe tối thiểu — đặt một cờ volatile sig_atomic_t stop = 1. Vòng lặp chính, chạy ở ngữ cảnh bình thường (không bị chen), thấy cờ rồi tự cleanup an toàn. Handler báo tin; main làm việc.Q4Mô tả trình tự docker stop thực hiện để dừng một container, và vì sao app nên bắt SIGTERM.▸
docker stop gửi SIGTERM tới tiến trình chính (PID 1) của container, rồi chờ một khoảng ân hạn (mặc định 10 giây cho Linux, chỉnh bằng -t/--timeout). Nếu tiến trình thoát trong khoảng đó, container tắt ngay. Nếu vẫn chạy sau grace period, Docker gửi SIGKILL để giết cứng. App nên bắt SIGTERM và graceful shutdown (ngừng nhận request mới, hoàn tất request dở, đóng DB/flush log, thoát) vì: (1) tắt nhanh — không phải chờ đủ 10 giây; (2) không mất dữ liệu — có cleanup thay vì bị SIGKILL cắt ngang. App phớt lờ SIGTERM sẽ luôn bị treo 10 giây rồi chết cứng, mất mọi cleanup.Q5Vì sao code không nên đăng ký handler cho SIGKILL để 'dọn dẹp khi bị giết', và cleanup nên gắn vào signal nào?▸
SIGKILL không thể bắt — signal(SIGKILL, handler) bị kernel bỏ qua, handler không bao giờ chạy. Về nguyên tắc thiết kế: SIGKILL nghĩa là "giết ngay, không thương lượng", nên OS cố ý không cho tiến trình chèn code phản ứng — nếu cho, tiến trình lì có thể trì hoãn vô hạn. Vì vậy dựa vào "cleanup khi bị SIGKILL" là sai từ gốc: cơ hội đó không tồn tại. Mọi cleanup phải gắn vào SIGTERM (bắt được) — hoặc SIGINT cho tương tác terminal. Thiết kế đúng: coi SIGKILL là điểm cụt (có thể để lại file tạm/lock), và giảm thiệt hại bằng cách làm cleanup ở SIGTERM trước khi nó có nguy cơ bị leo thang lên SIGKILL.Bài tiếp theo: Mini-challenge — mổ xẻ cây tiến trình thật
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên