Tín hiệu (signal) là cách hệ điều hành gõ vai một tiến trình để báo "có chuyện xảy ra": Ctrl+C gửi SIGINT, một tiến trình con chết gửi SIGCHLD, kill gửi bất cứ tín hiệu nào bạn chọn. Khác các cách IPC ở những bài trước, tín hiệu không mang dữ liệu — nó chỉ báo "có việc loại X" và làm tiến trình đang chạy nhảy vào một hàm xử lý. Câu hỏi thực tế: giao một tín hiệu tốn bao lâu, và handler đáng tin đến đâu? Tôi đo trong container, và suýt rút ra một kết luận sai vì quên kiểm một biến ẩn.
Tín hiệu hoạt động thế nào
Gửi một tín hiệu bằng kill(pid, signo) (hoặc raise cho chính mình). Nhân đánh dấu tín hiệu đó là "đang chờ" cho tiến trình đích; lần tới tiến trình đó được lập lịch chạy, nhân ngắt luồng thực thi bình thường của nó và gọi hàm xử lý (handler) mà nó đã đăng ký qua sigaction. Handler chạy xong, tiến trình quay lại chỗ đang dở bằng một syscall đặc biệt sigreturn. Vì handler cắt ngang bất cứ đâu, code trong nó phải an toàn với tín hiệu bất đồng bộ (async-signal-safe) — chỉ được gọi một tập hàm hẹp, không printf, không malloc.
Tôi đo hai thứ: độ trễ khứ hồi khi giao một tín hiệu (cha gửi SIGUSR1 cho con, con gửi trả, lặp nhiều vòng), và độ tin cậy của việc giao khi bị dồn dập. Ghim tiến trình vào lõi cụ thể bằng affinity để kiểm soát biến động.
Đo: và một con số suýt đánh lừa tôi
Kết quả đầu tiên cho độ trễ khứ hồi một tín hiệu, khi tôi ghim cha ở lõi 0 và con ở lõi 1:
SIGUSR1 khứ hồi (cha lõi 0, con lõi 1): 17215 ns (~17 micro giây)
Tôi nhớ ở bài Unix socket đo pipe khứ hồi chỉ khoảng 1,2 micro giây. Đặt cạnh nhau, 17 so với 1,2 — tôi suýt viết ngay: "tín hiệu là cơ chế báo chậm, tốn gấp 14 lần một byte qua pipe, nên đừng dùng cho báo hiệu tần suất cao". Con số gọn, kết luận nghe hợp lý. Nhưng có gì đó cấn: 17 micro giây là quá nhiều cho một thao tác trong nhân, mà pipe với tín hiệu về bản chất đều chỉ là "đánh thức tiến trình kia". Tôi dừng lại kiểm.
Một lần tôi đo hớ: biến ẩn là ghim lõi, không phải tín hiệu
Chỗ sai lộ ra ngay khi tôi hỏi: con số pipe 1,2 micro giây kia đo trong điều kiện nào? Ở bài cũ, tôi đo pipe khi cả hai tiến trình cùng một lõi. Còn tín hiệu lần này tôi đo khi ghim hai lõi khác nhau. Hai phép đo ở hai điều kiện khác hẳn — so chúng với nhau là so táo với cam.
Tôi đo lại cả hai trong đúng cùng điều kiện, trong cùng một chương trình:
| Kênh khứ hồi | cùng lõi (0,0) | khác lõi (0,1) |
|---|---|---|
tín hiệu SIGUSR1 |
1778 ns | 17215 ns |
| pipe (1 byte) | 1122 ns | 16406 ns |
Câu chuyện lật hẳn. Khi so cùng điều kiện, tín hiệu và pipe gần như ngang nhau: cùng lõi thì 1778 so với 1122 nano giây; khác lõi thì 17215 so với 16406 nano giây. Tín hiệu chỉ nhỉnh hơn pipe một chút (phần thêm là chạy handler rồi sigreturn), tuyệt nhiên không phải 14 lần.
Cái khác biệt 10 lần thật sự nằm ở ghim lõi, chứ không phải ở tín hiệu: đánh thức một tiến trình đang nằm ở lõi khác tốn ~17 micro giây (phải gửi tín hiệu liên xử lý giữa hai lõi, kéo tiến trình kia dậy), còn đánh thức một tiến trình trên cùng lõi chỉ tốn ~1-2 micro giây (một cú chuyển ngữ cảnh). Và chi phí đắt đó thì cả tín hiệu lẫn pipe đều phải trả như nhau — nó là thuộc tính của "đánh thức liên lõi", không phải của cơ chế báo hiệu.
Bài học đo lường: một biến ẩn duy nhất (ghim lõi) đủ dựng lên một khác biệt "14 lần" hoàn toàn bịa đặt. Nếu tôi không kiểm mà cứ so con số mới với con số cũ đo ở điều kiện khác, tôi đã kết luận sai về bản chất của tín hiệu. Khi so hai thứ, phải chắc chúng được đo trong cùng một điều kiện; nếu không, cái mình tưởng là khác biệt giữa A và B có thể chỉ là khác biệt giữa hai môi trường đo.
Bẫy thứ hai: tín hiệu chuẩn bị gộp
Có một đặc tính của tín hiệu khiến nhiều người mất dữ liệu mà không hiểu vì sao, và tôi đo nó riêng. Tôi chặn SIGUSR1, gửi nó 100 lần liên tiếp trong khi đang chặn, rồi mở chặn và đếm handler chạy bao nhiêu lần:
SIGUSR1 (chuẩn) : handler chạy 1 lần
SIGRTMIN (thời gian thực): handler chạy 100 lần
Với tín hiệu chuẩn như SIGUSR1, gửi 100 lần mà handler chỉ chạy một lần. Lý do: một tín hiệu chuẩn đang chờ chỉ là một bit trong tiến trình — hoặc "có đang chờ" hoặc "không". Gửi thêm khi bit đã bật thì chẳng thêm gì; các lần gửi thừa bị gộp và mất. Đây là kiểu lỗi kinh điển: dùng tín hiệu làm bộ đếm sự kiện (đếm số lần một việc xảy ra), rồi dưới tải cao thì đếm hụt trầm trọng mà không có lỗi nào báo.
Tín hiệu thời gian thực (SIGRTMIN trở lên) thì khác: chúng xếp hàng, nên gửi 100 lần thì handler chạy đủ 100. Nếu bạn thật sự cần đếm hoặc không được mất sự kiện nào, đó là loại phải dùng — hoặc tốt hơn, đừng đếm bằng tín hiệu mà chỉ dùng tín hiệu để "đánh thức" rồi đọc số liệu thật từ một nguồn khác (như signalfd hay self-pipe).
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: tín hiệu là để báo sự kiện hiếm, không phải để truyền tin tần suất cao. Không phải vì nó chậm (ta vừa thấy nó ngang pipe), mà vì nó gộp và không mang dữ liệu. Dùng tín hiệu cho "hãy dừng lại" (SIGTERM), "nạp lại cấu hình" (SIGHUP), "con vừa chết" (SIGCHLD) là đúng chỗ; dùng nó làm hàng đợi thông điệp là sai công cụ — bạn sẽ mất sự kiện vì gộp.
Hệ quả thứ hai: handler chạy trong một thế giới hạn chế. Vì nó cắt ngang tiến trình ở chỗ bất kỳ (có thể ngay giữa một malloc), gọi hàm không async-signal-safe trong handler có thể làm treo hoặc hỏng ngầm. Mẫu an toàn phổ biến là handler chỉ đặt một cờ volatile sig_atomic_t rồi thoát, để vòng lặp chính xử lý; hoặc dùng signalfd/self-pipe để biến tín hiệu thành một sự kiện đọc được trong epoll, tránh hẳn việc chạy code trong handler.
Hệ quả thứ ba là bài học đo lường bao trùm cả sê-ri: kiểm soát biến ẩn trước khi kết luận, và chỉ so những con số đo cùng điều kiện. Con số mang theo: giao một tín hiệu tốn ~1,8 micro giây khi cùng lõi và ~17 micro giây khi khác lõi — gần y hệt pipe, vì phần đắt là đánh thức liên lõi mà cả hai đều trả, không phải bản thân tín hiệu; và tín hiệu chuẩn bị gộp (gửi 100 nhận 1) trong khi tín hiệu thời gian thực xếp hàng (nhận đủ 100). "Tín hiệu chậm gấp 14 lần" là một kết luận bịa mà một biến ẩn suýt bán cho tôi.
Thử ba mươi giây
Mở hai cửa sổ terminal. Ở cửa sổ một, chạy sleep 1000 & echo $! để có một tiến trình và PID của nó. Ở cửa sổ hai, gửi tín hiệu bằng kill -USR1 <pid> — sleep không bắt SIGUSR1 nên nó chết ngay, minh họa tín hiệu mặc định làm gì khi không có handler. Rồi thử kill -STOP <pid> (đóng băng tiến trình) và kill -CONT <pid> (chạy tiếp) — hai tín hiệu mà tiến trình không thể bắt hay chặn, do nhân xử lý thẳng. Gõ kill -l để xem toàn bộ danh sách tín hiệu, và để ý các số cao (SIGRTMIN+n) — đó là những tín hiệu thời gian thực xếp hàng mà bài này đo, khác hẳn các tín hiệu chuẩn hay bị gộp ở phía trên danh sách.