Hệ điều hành & Tiến trình/Signal — SIGTERM, SIGKILL và Ctrl+C thật sự làm gì
12/28
Bài 12 / 28~12 phútTiến trình — sinh ra, sống, chếtMiễn phí lượt xem

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ửitiế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à SIGINT từ 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 SIGKILLSIGSTOP mà tiến trình không thể bắt hay chặn.

Văn phòngSignal
"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ựcSIGINT — Ctrl+C tới nhóm foreground
Nút đóng băng đứng imSIGSTOP — tạm dừng, không kết thúc
Bảo vệ và nút đóng băng: không cãi đượcSIGKILL/SIGSTOP không bắt/chặn được
💡 Cách nhớ

SIGTERMgõ cửa ("làm ơn dừng"); SIGKILLphá 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:

  1. 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.
  2. 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).
  3. 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), "SIGKILLSIGSTOP 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

SignalSốDefaultBắt được?Ý nghĩa / khi nào
SIGINT2TermCtrl+C tới nhóm tiến trình foreground — "ngắt, tôi muốn dừng"
SIGTERM15TermYêu cầu kết thúc lịch sự; kill <pid> mặc định gửi cái này
SIGKILL9TermKhôngGiết ngay, không cleanup; kill -9; van cuối cùng
SIGSTOP19StopKhô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ửi SIGTERM, không phải SIGKILL. Nhiều người tưởng kill là "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ả pipeline a | 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.
  • SIGKILL là lựa chọn cuối: dùng khi tiến trình không phản hồi SIGTERM. Vì không cleanup, nó có thể để lại file tạm, lock, dữ liệu ghi dở.
Vì sao SIGKILL và SIGSTOP phải không bắt được

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ự:

  1. Ngừng nhận yêu cầu/việc mới.
  2. Hoàn tất các việc đang dở (request đang xử lý).
  3. Đóng tài nguyên: flush log, đóng kết nối DB, nhả lock, xoá file tạm.
  4. 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;
}
Tự điền trước khi xem đáp án

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);while (!stop) { ... }.

Thử đoán

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.

docker stop = SIGTERM, rồi SIGKILL sau 10 giây

Đâ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

SIGKILLSIGSTOP 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

📚 Đào sâu (tuỳ chọn)

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 "SIGKILLSIGSTOP khô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 kill củ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: SIGCHLD là signal kernel gửi cho cha khi con chết — cơ sở để reap không block (bắt SIGCHLD, gọi waitpid với WNOHANG).
  • fork, exec, wait: disposition signal được thừa kế qua fork và (một phần) reset qua exec — vòng đời signal gắn với vòng đời tiến trình.
  • System call là gì: kill, sigaction là system call; và việc SIGKILL khô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 SIGTERM vs SIGKILL lê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)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 stop gửi SIGTERM, chờ 10 giây (mặc định Linux), rồi SIGKILL — dùng đúng tên hằng thay vì số trần trong code.

9. Tự kiểm tra

Tự kiểm tra
Q1
Vì 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.
Ctrl+C gửi 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.
Q2
SIGKILL 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?
Chúng là hai signal duy nhất không thể catch, block, hay ignore — kernel thực thi trực tiếp, bỏ qua mọi handler tiến trình đăng ký (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.
Q3
Vì 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 đó?
Signal handler chạy bất đồng bộ: kernel có thể chen nó vào giữa bất kỳ hai 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, 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.
Q4
Mô 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.
Q5
Vì 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ắtsignal(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

Đặt 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