Hệ điều hành · 03/10/2026

Linux signalfd thực chiến: Đưa signal vào epoll event loop

Signal handler truyền thống buộc chương trình phải tuân thủ danh sách hàm async-signal-safe rất hẹp, dễ tạo race khi chạm vào logger, allocator hay state của event loop. Trên Linux, signalfd() cho phép biến các signal bất đồng bộ như SIGTERM, SIGHUP và SIGCHLD thành dữ liệu có thể read() từ file descriptor, rồi đưa vào cùng vòng lặp epoll với socket, timer và eventfd.

Linux signalfd thực chiến: Đưa signal vào epoll event loop

Signal handler truyền thống buộc chương trình phải tuân thủ danh sách hàm async-signal-safe rất hẹp, dễ tạo race khi chạm vào logger, allocator hay state của event loop. Trên Linux, signalfd() cho phép biến các signal bất đồng bộ như SIGTERM, SIGHUP và SIGCHLD thành dữ liệu có thể read() từ file descriptor, rồi đưa vào cùng vòng lặp epoll với socket, timer và eventfd.

Bài viết trình bày thứ tự setup đúng, cách dùng trong chương trình đa luồng, xử lý SIGCHLD không để zombie, drain ở chế độ edge-triggered và các giới hạn không thể bỏ qua. Nội dung dựa trên Linux man-pages về signalfd(2), signal(7), pthread_sigmask(3) và waitpid(2).

1. signalfd thay đổi mô hình xử lý signal

Trong mô hình handler, kernel ngắt luồng thực thi để gọi một callback bất đồng bộ. Callback chỉ được gọi một tập API an toàn rất nhỏ; việc khóa mutex, cấp phát bộ nhớ, ghi log qua thư viện hoặc cập nhật object phức tạp có thể deadlock hay gây hành vi không xác định.

Với signalfd, ứng dụng chặn các signal đã chọn trong signal mask, tạo một FD gắn với mask đó rồi đọc các record struct signalfd_siginfo. Logic shutdown, reload và thu hồi child chạy trong context bình thường của event loop, nơi có thể dùng logger, mutex và state machine theo quy tắc thông thường.

signalfd không làm signal biến thành message queue bền vững. Nó chỉ cung cấp giao diện FD cho các signal đang pending theo semantics vốn có của Linux.

2. Thứ tự quan trọng: block trước, tạo thread sau

sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGHUP);
sigaddset(&mask, SIGCHLD);

int rc = pthread_sigmask(SIG_BLOCK, &mask, NULL);
if (rc != 0) {
    /* pthread API trả error number trực tiếp, không nhất thiết qua errno */
    fail_pthread(rc);
}

/* Chỉ tạo worker threads sau bước block này. */
start_worker_threads();

Mỗi thread có signal mask riêng, nhưng thread mới kế thừa bản sao mask của thread tạo ra nó. Vì vậy chương trình đa luồng nên block tập signal điều khiển ở main thread trước mọi pthread_create(). Nếu một worker không block signal, kernel có thể giao signal cho thread đó theo disposition mặc định thay vì để signalfd nhận.

Trong chương trình đơn luồng có thể dùng sigprocmask(); với pthreads, pthread_sigmask() là API được POSIX quy định rõ.

3. Tạo signalfd an toàn

#include <sys/signalfd.h>

int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
if (sfd == -1)
    fail_errno("signalfd");
  • SFD_NONBLOCK giúp vòng lặp drain đến EAGAIN mà không bị treo.
  • SFD_CLOEXEC ngăn descriptor vô tình sống qua execve().
  • Nếu truyền một signalfd hiện có thay cho -1, lời gọi sẽ thay mask gắn với FD đó.

SIGKILL và SIGSTOP không thể bị chặn hay nhận qua signalfd; kernel lặng lẽ bỏ qua chúng nếu được thêm vào mask.

4. Thêm vào epoll

int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd == -1)
    fail_errno("epoll_create1");

struct epoll_event ev = {
    .events = EPOLLIN | EPOLLET,
    .data.fd = sfd
};

if (epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev) == -1)
    fail_errno("epoll_ctl");

Level-triggered cũng hợp lệ và đơn giản hơn cho nhiều ứng dụng. Nếu dùng EPOLLET, mọi FD nonblocking phải được đọc cho đến khi trả EAGAIN; đọc đúng một record rồi quay lại epoll_wait() có thể làm các record còn lại không kích hoạt edge mới.

5. Drain nhiều record trong mỗi lần thức dậy

static void drain_signals(int sfd, bool *stopping)
{
    struct signalfd_siginfo info[16];

    for (;;) {
        ssize_t n = read(sfd, info, sizeof(info));

        if (n == -1 && errno == EINTR)
            continue;
        if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK))
            return;
        if (n == -1)
            fail_errno("read signalfd");
        if (n == 0 || n % sizeof(info[0]) != 0)
            fail_protocol("short signalfd read");

        size_t count = (size_t)n / sizeof(info[0]);
        for (size_t i = 0; i < count; i++) {
            switch (info[i].ssi_signo) {
            case SIGTERM:
            case SIGINT:
                *stopping = true;
                begin_graceful_shutdown();
                break;
            case SIGHUP:
                request_config_reload();
                break;
            case SIGCHLD:
                reap_children();
                break;
            default:
                log_unexpected_signal(info[i].ssi_signo);
            }
        }
    }
}

Một lần read() có thể trả nhiều record. Việc đọc tiêu thụ các signal tương ứng: chúng không còn pending để một handler hoặc sigwaitinfo() nhận lại.

6. Signal chuẩn có thể coalesce

Nhiều lần phát cùng một standard signal trong lúc nó đang pending không bắt buộc tạo cùng số record. Ví dụ, 20 lần SIGHUP có thể chỉ biểu thị “cần reload” một lần. Thiết kế action theo trạng thái sẽ phù hợp hơn đếm số lần:

  • SIGHUP: đặt cờ reload và đọc lại nguồn cấu hình chuẩn;
  • SIGTERM: chuyển state machine sang draining, thao tác lặp lại phải idempotent;
  • SIGCHLD: xem là tín hiệu “có thể có child cần reap”, rồi quét toàn bộ bằng waitpid().

Real-time signals có semantics xếp hàng khác, nhưng vẫn cần giới hạn queue và không nên dùng như broker nghiệp vụ.

7. Xử lý SIGCHLD không để zombie

static void reap_children(void)
{
    for (;;) {
        int status;
        pid_t pid = waitpid(-1, &status, WNOHANG);

        if (pid > 0) {
            record_child_exit(pid, status);
            continue;
        }
        if (pid == 0)
            return;
        if (errno == EINTR)
            continue;
        if (errno == ECHILD)
            return;

        fail_errno("waitpid");
    }
}

Không gọi waitpid() đúng một lần cho mỗi record SIGCHLD. Nhiều child có thể đổi trạng thái trước khi event loop chạy và standard signal có thể coalesce. Vòng lặp phải reap cho đến 0 hoặc ECHILD.

Nếu service cần theo dõi chính xác từng process trong kiến trúc mới, có thể kết hợp pidfd; signalfd vẫn hữu ích như kênh điều khiển chung nhưng không thay thế định danh process chống PID reuse.

8. Shutdown và reload nên là state machine

Khi nhận SIGTERM, đừng đóng mọi thứ ngay trong nhánh switch. Hãy chuyển trạng thái:

  1. dừng accept kết nối mới;
  2. đánh dấu request đang chạy cần hoàn tất trong deadline;
  3. gửi yêu cầu dừng tới worker;
  4. tiếp tục phục vụ event cần thiết để drain;
  5. đóng FD theo thứ tự và thoát khi sạch hoặc hết deadline.

Với reload, parse và validate cấu hình mới vào object tạm, sau đó swap nguyên tử/state-locked. Nếu reload thất bại, giữ cấu hình cũ. Việc signalfd đưa xử lý vào context bình thường giúp mô hình này dễ kiểm thử hơn.

9. Giới hạn và bẫy quan trọng

  • Signal đồng bộ: lỗi như SIGSEGV do truy cập địa chỉ sai hoặc SIGFPE do lỗi số học không thể xử lý đúng bằng signalfd; cần handler phù hợp.
  • Thread-directed signal: thread đọc signalfd nhận signal hướng tới chính nó và signal hướng tới process, không nhận signal được gửi riêng cho thread khác.
  • Fork cùng epoll: child kế thừa signalfd có thể đọc signal của nó, nhưng epoll instance kế thừa có caveat readiness. Thực tế an toàn là tạo signalfd/epoll riêng sau fork hoặc tái đăng ký theo tài liệu.
  • Exec helper: child kế thừa signal mask. Trước execve() một chương trình không dùng signalfd, thường phải unblock các signal mà helper kỳ vọng.
  • Thư viện tự spawn process: nếu không kiểm soát khoảng fork/exec để sửa mask, mô hình signalfd có thể không phù hợp; self-pipe từ handler tối giản là fallback phổ biến.
  • Hai consumer: đừng đồng thời để handler, sigwaitinfo() và signalfd cạnh tranh cùng signal nếu không có thiết kế rõ ràng.

10. Quan sát và kiểm thử

Linux hiển thị mask của signalfd trong /proc/PID/fdinfo/FD. Production nên ghi metric cho số lần shutdown/reload, thời gian drain, child exit theo reason, lỗi waitpid() và số event bất ngờ.

Bộ test tối thiểu:

  • gửi liên tiếp nhiều SIGHUP, xác nhận reload idempotent;
  • gửi SIGTERM khi có request đang chạy và kiểm tra deadline;
  • cho nhiều child thoát gần đồng thời, xác nhận không còn zombie;
  • stress edge-triggered để chứng minh vòng đọc drain đến EAGAIN;
  • fork/exec helper và xác nhận mask, CLOEXEC đúng mong đợi;
  • khởi tạo nhiều worker rồi kiểm tra mọi thread đã block tập signal.

Checklist production

  • Block signal điều khiển trước khi tạo bất kỳ worker thread nào.
  • Dùng SFD_NONBLOCK | SFD_CLOEXEC.
  • Với EPOLLET, đọc theo batch đến EAGAIN.
  • Action của standard signal mang tính idempotent, không dựa vào số lần phát chính xác.
  • SIGCHLD luôn kích hoạt vòng waitpid(-1, ..., WNOHANG).
  • Shutdown/reload được triển khai bằng state machine có deadline.
  • Child trước exec được sửa signal mask theo contract của chương trình đích.
  • Không dùng signalfd để “bắt” SIGKILL, SIGSTOP hoặc synchronous fault.
  • Đã test fork, worker threads, signal burst và zombie cleanup.

Kết luận

signalfd() giúp ứng dụng Linux hướng sự kiện gom signal, socket, timer và IPC vào một cơ chế chờ thống nhất. Giá trị lớn nhất không chỉ là ít code handler hơn, mà là đưa shutdown, reload và child lifecycle trở về context đồng bộ có thể quan sát và kiểm thử. Đổi lại, ứng dụng phải block signal đúng trước khi tạo thread, tôn trọng semantics coalescing, drain FD đúng cách và xử lý cẩn thận các ranh giới fork/exec.

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.