Cái chuông báo cháy trong tòa nhà chỉ là một nút bấm — một động tác duy nhất, đơn giản. Nhưng khi bạn kéo nó, cả tòa nhà dừng lại: mọi người bỏ dở việc đang làm, xếp hàng ra ngoài, chờ, rồi lục tục quay vào và cố nhớ mình đang làm tới đâu. Một hành động bé xíu, nhưng cái giá là gián đoạn khổng lồ. Nó tuyệt vời cho một đám cháy thật — chuyện hiếm và khẩn — nhưng sẽ thật lố bịch nếu bạn kéo chuông mỗi lần muốn nhắn một câu cho đồng nghiệp. Signal của hệ điều hành hành xử đúng như cái chuông đó, và nhiều người nhầm nó là một cách nhắn tin rẻ tiền.

Signal (tín hiệu) là cách hệ điều hành báo cho tiến trình một sự kiện: bạn nhấn Ctrl-C (SIGINT), một tiến trình con chết (SIGCHLD), chương trình chạm bộ nhớ cấm (SIGSEGV), một hẹn giờ nổ (SIGALRM). Vì signal có vẻ gọn — chỉ một số nguyên nhỏ báo tới handler — nhiều người dùng nó như một kênh báo hiệu tiện lợi giữa các tiến trình. Câu hỏi: gửi và nhận một signal tốn bao nhiêu? Tôi đo trong container gcc:13 (ARM), và con số cho thấy signal đắt hơn nhiều người nghĩ — đủ để định lại khi nào nên dùng nó.

Một signal đi qua những gì

Giao một signal không phải một thao tác nhẹ. Khi bạn kill(pid, SIG) (hay raise), nhân phải làm cả một chuỗi: nhận lời gọi kill (một system call); ngắt luồng đích ở bất kỳ đâu nó đang chạy; lưu ngữ cảnh hiện tại (thanh ghi, mặt nạ signal); dựng một khung stack đặc biệt cho hàm handler; nhảy vào handler chạy; và khi handler xong, gọi sigreturn (một syscall nữa) để khôi phục ngữ cảnh cũ và chạy tiếp. Nhiều lần vào/ra nhân, cộng lưu và khôi phục trạng thái — giống một chuyển ngữ cảnh thu nhỏ, nhưng có thêm khâu dựng khung handler. Đúng cái nghi thức "cả tòa nhà dừng, sơ tán, quay lại" cho một nút bấm.

Có một ràng buộc đúng đắn kèm theo: handler chỉ được gọi các hàm async-signal-safe — một danh sách nhỏ (như write, _exit), không gồm printf, malloc, hầu hết libc. Vì signal có thể ngắt chương trình giữa chừng một hàm libc (ví dụ đang trong malloc), gọi lại chính hàm đó trong handler có thể làm hỏng trạng thái nội bộ. Đây là nguồn nhiều bug tinh vi. (Thực tế nhiều chương trình vẫn lỡ gọi printf trong handler và chạy đúng gần như mọi lúc — rồi treo hoặc hỏng đúng một lần hiếm khi signal đến giữa một printf khác, y hệt các bug đồng thời chỉ lộ ra ở production mà sê-ri đã gặp.)

Đo: 489ns một signal, 1600 lần một cờ atomic

Tôi đo self-signal (kill(getpid(), SIGUSR1) tới một handler trong cùng tiến trình, chờ handler chạy xong), so với hai mốc — một cờ atomic và một syscall thường:

một tín hiệu (kill + handler + sigreturn) : 489 ns/signal
cờ atomic (store + load)                  :   0,3 ns
syscall getpid (mốc vào nhân một lần)     : 100 ns

-> signal / atomic = 1.646×  |  signal / syscall = 4,9×

Một signal tốn 489 ns — gần 5 lần một syscall đơn (100 ns), vì nó là nhiều lần vào/ra nhân (gửi, ngắt, sigreturn) cộng dựng-khung-handler, không phải một lần. So với cách báo hiệu rẻ nhất trong cùng tiến trình — đọc/ghi một cờ atomic (0,3 ns) — signal đắt hơn 1.646 lần. Nói cách khác: nếu bạn cần báo một cờ "có việc rồi" cho một luồng cùng tiến trình, dùng biến atomic gần như miễn phí; dùng signal là trả gấp hơn nghìn lần cho cùng thông điệp một bit.

(Môi trường đo: container gcc:13 trên host ARM AArch64, self-signal chờ handler xong, mỗi số lấy sau warmup. Con số tuyệt đối đổi theo máy; cái bền vững là thứ bậc: signal >> một syscall >> một cờ atomic.)

Hai niềm tin đối nghịch, cùng sai

Tôi vào đo với niềm tin phổ biến: "signal là cách nhẹ, nhanh để báo hiệu giữa tiến trình — gửi một SIGUSR1 là xong". Phép đo sửa lại: 489 ns mỗi signal, và đó là chưa tính ràng buộc async-signal-safe làm handler khó viết đúng. Một cơ chế "trông nhẹ" (một số nguyên nhỏ) có thể nặng về chi phí thực thi — signal đắt vì mỗi lần giao là một chuỗi thao tác nhân, không phải một phép ghi bộ nhớ.

Nhưng có một niềm tin ngược cũng sai: "signal đắt và khó thế thì tránh xa, dùng cái khác cho mọi việc". Không — cho đúng việc của nó, signal là công cụ không thể thay thế: không có eventfd hay cờ atomic nào bắt được Ctrl-C từ terminal, báo tiến trình con vừa chết (SIGCHLD), hay bẫy một truy cập bộ nhớ cấm (SIGSEGV). Với những sự kiện hiếm và bất thường đó, cái giá 489 ns xảy ra vài lần trong cả đời tiến trình — hoàn toàn không đáng bận tâm. Sự thật nằm ở tần suất: signal đúng cho sự kiện thưa thớt (Ctrl-C, con chết, hết giờ, lỗi nghiêm trọng — vài lần, không vài triệu lần mỗi giây); sai khi bị ép làm kênh báo hiệu thường xuyên. Nếu dùng nó để báo mỗi mục dữ liệu mới trong một hàng đợi tần suất cao, bạn trả ~489 ns mỗi mục cho riêng khâu báo hiệu — giết throughput y như đồng bộ per-item đã đo. Cho báo hiệu thường xuyên, công cụ đúng là cờ atomic (cùng tiến trình) hay pipe/eventfd (giữa tiến trình) — vốn tích hợp được vào epoll và không vướng ràng buộc async-signal-safe.

Về đến code hằng ngày

Điều đầu tiên: dùng signal cho sự kiện hiếm, không phải kênh dữ liệu. Signal hoàn hảo cho "người dùng nhấn Ctrl-C", "con tôi vừa chết", "hết 30 giây" — những sự kiện thưa thớt, bất đồng bộ. Đừng dùng signal để bàn giao dữ liệu hay đánh thức liên tục; ~489 ns mỗi lần cộng với sự khó viết handler đúng khiến nó là lựa chọn tệ cho đường nóng. Với đánh thức giữa tiến trình trong một event loop, dùng eventfd hay self-pipe.

Điều thứ hai: giữ handler tối giản và async-signal-safe. Trong một signal handler, chỉ làm việc tối thiểu tuyệt đối — thường là đặt một cờ volatile sig_atomic_t rồi để vòng lặp chính xử lý. Đừng gọi printf, malloc, hay khóa mutex trong handler: chúng không async-signal-safe và có thể deadlock hoặc hỏng trạng thái nếu signal ngắt đúng lúc chương trình đang ở trong chính hàm đó. Mẫu chuẩn "handler chỉ đặt cờ" vừa an toàn vừa nhanh.

Chỗ này cái "handler chỉ đặt cờ" được nâng lên thành kiến trúc ở tầng cao, và có vài cú dùng signal sáng tạo đáng biết. Go giải bài handler-khó-viết bằng signal.Notify(ch, ...): runtime bắt signal ở tầng thấp rồi chuyển nó thành một message trên channel, nên code của bạn xử lý nó như dữ liệu bình thường trong một goroutine, tránh sạch mọi ràng buộc async-signal-safe — đúng tinh thần "handler chỉ chuyển tiếp". Java có shutdown hook và sun.misc.Signal với cùng ý. Node.js là process.on('SIGTERM', ...) cho graceful shutdown trong Kubernetes (K8s gửi SIGTERM rồi chờ, mới SIGKILL — đúng "sự kiện hiếm"). Và một cú lật ngược thú vị: JVM dùng SIGSEGV làm đường nhanh kiểm tra null — nó không check null mỗi lần truy cập mà cứ chạy, để phần cứng bẫy SIGSEGV khi thật sự gặp null; vì null hiếm, cái signal 489 ns kia trả rất thưa, rẻ hơn nhiều so với một lệnh check ở mọi truy cập. Cùng một bài học chuông-báo-cháy: đắt mỗi lần kêu, nên chỉ để nó kêu khi thật sự hiếm.

Muốn tự thấy thì nhìn code của bạn có dùng signal không, và với mục đích gì. Nếu là bắt Ctrl-C, dọn dẹp khi thoát, hay đợi tiến trình con — đúng, signal là công cụ chuẩn cho những sự kiện hiếm đó. Nhưng nếu bạn thấy signal được dùng như một kênh báo hiệu thường xuyên (một tiến trình kill tiến trình khác mỗi khi có việc mới, nhiều lần mỗi giây), đó là dấu hiệu chọn sai công cụ — mỗi lần ~489 ns, và handler khó viết đúng. Hỏi: có thể thay bằng một eventfd hay pipe (đọc được trong epoll, không giới hạn async-signal-safe) không? Vài phút rà "signal dùng cho việc hiếm hay việc thường" đó giúp bạn tránh một cơ chế đắt và khó ở đúng chỗ nó không hợp — và nhắc rằng một API trông đơn giản (kill, một số nguyên) vẫn có thể giấu một chi phí thực thi đáng kể.