Tín hiệu (signal) là cách hệ điều hành gõ vai một tiến trình giữa chừng: Ctrl-C gửi SIGINT, một tiến trình con thoát gửi SIGCHLD cho cha, đổi cỡ cửa sổ terminal gửi SIGWINCH, một bộ hẹn giờ gửi SIGALRM. Chúng đến bất đồng bộ — bất cứ lúc nào, kể cả giữa lúc chương trình đang kẹt trong một lời gọi hệ thống. Câu hỏi thực tế có hai vế: giao một tín hiệu tốn bao lâu, và chuyện gì xảy ra với một syscall đang chạy khi tín hiệu ập tới? Bài này đo cả hai — và vế thứ hai phơi ra một trong những con bug âm thầm phổ biến nhất trên Unix.
Tín hiệu được giao thế nào
Khi một tín hiệu được gửi tới tiến trình (bằng kill, raise, hay do nhân sinh ra), nhân đánh dấu nó là "đang chờ" (pending) trên tiến trình đó. Tín hiệu không được giao ngay lập tức giữa chừng một lệnh máy; nhân đợi tới thời điểm an toàn tiếp theo — khi tiến trình quay về không gian người dùng (từ một syscall, một ngắt, hay khi được lập lịch chạy lại — rất giống thời điểm giao ở bài lát thời gian). Lúc đó nhân dựng một khung ngăn xếp đặc biệt và nhảy vào handler (hàm xử lý tín hiệu) mà tiến trình đã đăng ký.
Điểm quan trọng thứ hai: nếu tín hiệu tới đúng lúc tiến trình đang kẹt trong một syscall chậm (đang read chờ dữ liệu, đang sleep, đang wait...), nhân phải đánh thức syscall đó dậy để giao tín hiệu. Theo mặc định, syscall bị đánh thức sẽ trả về -1 với errno = EINTR ("Interrupted system call") — nó không hoàn thành việc của mình. Đây là chỗ tôi muốn đo, vì nó là nguồn của vô số lỗi khó lần.
Đo: giao mất 500 ns, nhưng cú EINTR mới đáng sợ
Trước hết là độ trễ giao. Tôi đăng ký handler cho SIGUSR1, rồi raise tín hiệu cho chính mình một trăm nghìn lần, đo khoảng từ lúc gửi tới lúc handler bắt đầu chạy:
Độ trễ giao (raise -> handler chạy): trung vị 500 ns | p99 834 ns
Nửa micro giây để giao một tín hiệu và nhảy vào handler — rẻ, cỡ một lời gọi hệ thống, vì đó chính là bộ máy nhân dựng khung và chuyển quyền điều khiển. Nhưng con số này không phải điều nguy hiểm. Điều nguy hiểm nằm ở phép đo thứ hai.
Tôi hẹn một SIGALRM tới sau 50 mili giây, rồi gọi nanosleep xin ngủ 10 giây, và xem chuyện gì xảy ra:
nanosleep trả về = -1, errno = EINTR
NGỦ THẬT = 0,055 giây (KHÔNG phải 10!)
còn 9,944 giây chưa ngủ
Đây là cú lật. Ở bài độ phân giải timer tôi đo được sleep luôn vượt giờ một chút (sàn ~80 µs). Nhưng ở đây một tín hiệu làm điều ngược lại và tệ hơn nhiều: nó khiến nanosleep xin 10 giây mà trả về sau 0,055 giây — hụt tới 180 lần. Syscall bị cắt ngang, trả -1 với errno = EINTR, và tham số "còn lại" cho biết 9,944 giây chưa được ngủ.
Một lần tôi đo hớ: "gọi xong" không phải "làm xong"
Đây là chỗ tôi phải thú nhận thói quen sai của chính mình. Cách hầu hết chúng ta viết một lệnh ngủ là nanosleep(&req, NULL) — truyền NULL cho tham số "còn lại" và không thèm nhìn giá trị trả về, vì "ngủ thì có gì mà lỗi". Nếu tôi viết như vậy, tôi sẽ hoàn toàn không biết giấc ngủ vừa bị cắt: hàm trả về, chương trình chạy tiếp, tôi đinh ninh đã ngủ đủ 10 giây trong khi thực tế chỉ ngủ 55 mili giây và âm thầm mất 9,944 giây dự tính. Không có lỗi nào ném ra, không có cảnh báo — chỉ có một hành vi sai lặng lẽ.
Phép đo buộc tôi nhận ra bài học: một lời gọi đang chặn không đảm bảo nó chạy tới cùng. Trên Unix, bất kỳ syscall chậm nào cũng có thể bị một tín hiệu cắt ngang bất cứ lúc nào — và tín hiệu đó thường đến từ những chuyện ngoài ý muốn: một tiến trình con thoát (SIGCHLD), người dùng đổi cỡ cửa sổ terminal (SIGWINCH), một bộ hẹn giờ của thư viện, hay chỉ một cú Ctrl-C. Cách duy nhất để biết lời gọi có hoàn thành hay không là kiểm giá trị trả về và errno. Bỏ qua chúng là gieo một con bug âm thầm và cực kỳ phổ biến. Nói theo tinh thần cả sê-ri: "gọi xong" chưa chắc là "làm xong" — phải hỏi lại "đủ chưa, đúng chưa", đừng tin rằng hàm trả về nghĩa là nó đã làm đủ việc.
Có một chiếc van cho chuyện này, và tôi cũng đo luôn: cờ SA_RESTART. Khi đăng ký handler với cờ này, nhân sẽ tự động chạy lại nhiều syscall bị ngắt thay vì trả EINTR. Tôi cho một read chặn trên pipe rỗng bị SIGALRM ngắt: không có SA_RESTART, read trả -1 EINTR; có SA_RESTART, handler vẫn chạy nhưng read tự nối lại và đọc bình thường khi dữ liệu tới. Cùng một tín hiệu, cùng một handler, nhưng một cờ quyết định syscall bị ngắt sẽ ném EINTR cho bạn hay được nhân giấu đi và chạy tiếp. (Lưu ý: nanosleep là ngoại lệ — nó luôn trả EINTR kể cả khi có SA_RESTART, vì nó cần báo cho bạn thời gian còn lại.)
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là luôn xử lý EINTR trên mọi lời gọi đang chặn. Với các syscall có thể bị ngắt (read, write, recv, accept, poll, wait, nanosleep...), hãy hoặc bọc chúng trong vòng lặp thử lại khi gặp EINTR (while ((r = read(...)) < 0 && errno == EINTR);), hoặc đăng ký handler với SA_RESTART để nhân tự lo. Nhiều thư viện chuẩn của ngôn ngữ bậc cao đã tự làm điều này cho bạn (Python từ bản 3.5 tự thử lại khi EINTR, Go ẩn hoàn toàn signal khỏi goroutine), nhưng khi viết C hay gọi syscall trực tiếp, bạn phải tự lo — và bỏ sót là bug.
Hệ quả thứ hai là hiểu vì sao chương trình "thỉnh thoảng" hành xử lạ dưới tải hay khi có sự kiện ngoài. Một dịch vụ chạy tốt trong lúc yên tĩnh nhưng lỗi lạ mỗi khi một tiến trình con thoát, hay khi terminal bị đổi cỡ, rất thường là do một read/sleep bị EINTR mà không được xử lý. Đây là kiểu bug phụ thuộc thời điểm tín hiệu tới, nên hiếm và khó tái hiện — đúng loại bug cần biết cơ chế mới lần ra, chứ đọc code suông không thấy.
Hệ quả thứ ba, về đo lường và về tư duy: đừng bao giờ bỏ qua giá trị trả về của một thao tác có thể thất bại hay bị cắt ngang. Con số mang theo: giao một tín hiệu tới handler tốn ~500 ns; nhưng một tín hiệu tới giữa một syscall đang chặn sẽ ngắt nó, trả -1 với errno=EINTR — nanosleep(10 giây) bị ngắt lúc 50ms trả về sau 0,055 giây, hụt 180 lần — và giá trị trả về là dấu hiệu DUY NHẤT báo điều đó; SA_RESTART khiến nhân tự chạy lại nhiều syscall (trừ nanosleep) thay vì ném EINTR. Một hàm trả về không có nghĩa nó đã làm xong việc; chỉ kiểm kết quả mới biết.
Thử ba mươi giây
Tự thấy EINTR tận mắt bằng shell: chạy sleep 10 rồi trong khi nó đang chạy, ở terminal khác gửi một tín hiệu vô hại như kill -WINCH <pid_cua_sleep> (SIGWINCH mặc định bị bỏ qua nên sleep của GNU sẽ tự ngủ tiếp) — hoặc quan sát: nhiều lệnh sẽ kết thúc sớm nếu bạn gửi một tín hiệu chúng không bắt. Trong code, tìm mọi chỗ gọi read, write, sleep, recv, poll, wait mà không kiểm errno == EINTR — mỗi chỗ đó là một quả bom âm thầm khi có tín hiệu. Nếu dùng C, thử strace -e trace=nanosleep ./chuong-trinh và gửi cho nó một tín hiệu: bạn sẽ thấy dòng nanosleep(...) = ? ERESTART_RESTARTBLOCK (Interrupted by signal) — đúng cú ngắt bài này đo, hiện ra đen trắng.