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.
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ột lần tôi đo hớ: signal không phải kênh báo hiệu rẻ
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. Signal không rẻ, và không thiết kế cho tần suất cao.
Bài học đo lường: một cơ chế "trông nhẹ" (một số nguyên nhỏ) có thể nặng về chi phí thực thi — đo trước khi dùng nó như một kênh nóng. Signal đắt vì mỗi lần giao là một chuỗi thao tác nhân (ngắt, lưu/khôi phục ngữ cảnh, dựng khung), không phải một phép ghi bộ nhớ. Nếu tôi tin "signal rẻ" và dùng nó để, ví dụ, báo mỗi khi có một mục dữ liệu mới trong một hàng đợi tần suất cao, tôi đã 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. Signal đúng cho sự kiện hiếm và bất thường: Ctrl-C, tiến trình con chết, hết giờ, lỗi nghiêm trọng — những thứ xảy ra vài lần, không vài triệu lần mỗi giây. Cho báo hiệu thường xuyên, các 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 vòng lặp sự kiện epoll và không có ràng buộc async-signal-safe.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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.
Hệ quả thứ ba là tinh thần đo lường: "trông nhẹ" không bằng "chi phí thấp" — đo cơ chế trước khi chọn nó cho đường nóng. Con số mang theo: một signal tốn ~489 ns (gần 5× một syscall 100ns, 1.646× một cờ atomic 0,3ns) vì mỗi lần giao là kill-syscall + nhân ngắt luồng + lưu ngữ cảnh + dựng khung handler + sigreturn (nhiều lần vào/ra nhân); và handler bị ràng async-signal-safe (không printf/malloc). Signal là để báo sự kiện HIẾM/bất thường (Ctrl-C, SIGCHLD, hẹn giờ), KHÔNG phải kênh báo hiệu tần suất cao — báo hiệu thường dùng cờ atomic (cùng tiến trình) hay pipe/eventfd (giữa tiến trình). Một cái tên nhỏ không có nghĩa một chi phí nhỏ.
Thử ba mươi giây
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? Ba mươi giây 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ể.