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

Linux eventfd thực chiến: Đánh thức epoll an toàn giữa các thread

Một thread worker vừa đẩy công việc vào queue, nhưng event-loop thread đang ngủ trong epoll_wait(). Polling queue liên tục tốn CPU; condition variable lại không thể đưa trực tiếp vào epoll. eventfd() giải quyết đúng khoảng trống này bằng một counter 64 bit trong kernel, có thể được ghi từ producer và đọc như một file descriptor ở consumer.

Linux eventfd thực chiến: Đánh thức epoll an toàn giữa các thread

Một thread worker vừa đẩy công việc vào queue, nhưng event-loop thread đang ngủ trong epoll_wait(). Polling queue liên tục tốn CPU; condition variable lại không thể đưa trực tiếp vào epoll. eventfd() giải quyết đúng khoảng trống này bằng một counter 64 bit trong kernel, có thể được ghi từ producer và đọc như một file descriptor ở consumer.

Bài viết xây dựng mẫu queue + eventfd an toàn, giải thích counter mode và semaphore mode, quy tắc đọc/ghi 8 byte, xử lý edge-triggered epoll, overflow và shutdown. Nội dung dựa trên Linux man-pages về eventfd(2), epoll(7), read(2) và write(2).

1. eventfd là counter thông báo, không phải queue dữ liệu

eventfd() tạo một object chứa counter unsigned 64 bit và trả một file descriptor. write() cộng giá trị vào counter; khi counter lớn hơn 0, FD readable với poll/epoll. read() tiêu thụ counter theo mode đã chọn.

Counter chỉ nói “có sự kiện/công việc”. Payload thật nên nằm trong một queue được đồng bộ bằng mutex hoặc atomics. Tách hai vai trò giúp event loop được đánh thức hiệu quả mà không ép mọi dữ liệu đi qua pipe.

eventfd không thay thế tính đúng đắn của queue. Mutex, memory ordering, ownership và lifetime của work item vẫn phải được thiết kế độc lập.

2. Tạo eventfd cho epoll

#include <sys/eventfd.h>

int wake_fd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
if (wake_fd == -1)
    fail_errno("eventfd");
  • Giá trị đầu 0 nghĩa counter chưa có notification.
  • EFD_NONBLOCK làm read/write trả EAGAIN thay vì chặn.
  • EFD_CLOEXEC ngăn descriptor vô tình sống qua execve().

Không cần EFD_SEMAPHORE cho mẫu “đánh thức loop rồi drain queue”. Counter mode mặc định coalesce nhiều lần wake thành một số.

3. Semantics đọc và ghi chính xác

Thao tácCounter modeEFD_SEMAPHORE
write(uint64_t n)Cộng n vào counter; n phải khác UINT64_MAX.
read() khi counter > 0Trả toàn bộ counter rồi reset về 0.Trả 1 và giảm counter đi 1.
Counter = 0Block hoặc trả EAGAIN nếu nonblocking.

Cả read và write phải truyền buffer ít nhất 8 byte; nhỏ hơn sẽ nhận EINVAL. Giá trị dùng native byte order vì đây là kernel ABI trên cùng host, không phải giao thức mạng.

4. Producer: enqueue trước, wake sau

bool submit(struct work_item *item)
{
    pthread_mutex_lock(&queue_mutex);
    bool ok = queue_push(&work_queue, item);
    pthread_mutex_unlock(&queue_mutex);

    if (!ok)
        return false;

    uint64_t one = 1;
    for (;;) {
        ssize_t n = write(wake_fd, &one, sizeof(one));
        if (n == (ssize_t)sizeof(one))
            return true;
        if (n == -1 && errno == EINTR)
            continue;
        if (n == -1 && errno == EAGAIN) {
            /* Counter đã gần trần: FD vốn đã readable. */
            record_wakeup_saturation();
            return true;
        }
        return handle_wakeup_failure(item, errno);
    }
}

Thứ tự bắt buộc là publish item vào queue trước rồi mới ghi wake. Nếu đảo lại, consumer có thể thức dậy, thấy queue rỗng, quay lại ngủ trước khi item xuất hiện.

Ví dụ dùng mutex tạo quan hệ đồng bộ rõ ràng. Với lock-free queue, phải dùng memory ordering đúng; syscall eventfd không tự sửa một thuật toán queue sai.

5. Consumer: đọc counter rồi drain queue

static void handle_wakeup(int wake_fd)
{
    uint64_t notifications = 0;

    for (;;) {
        uint64_t value;
        ssize_t n = read(wake_fd, &value, sizeof(value));

        if (n == (ssize_t)sizeof(value)) {
            notifications += value;
            continue;
        }
        if (n == -1 && errno == EINTR)
            continue;
        if (n == -1 && errno == EAGAIN)
            break;
        fail_errno("read eventfd");
    }

    size_t processed = 0;
    while (processed < WORK_BUDGET) {
        struct work_item *item = queue_pop_synchronized(&work_queue);
        if (item == NULL)
            break;
        process_item(item);
        processed++;
    }

    record_wakeup_batch(notifications, processed);
    if (!queue_is_empty_synchronized(&work_queue))
        schedule_local_continuation();
}

notifications không nhất thiết bằng số item: producer có thể batch nhiều item cho một wake, item có thể bị hủy, hoặc nhiều write được coalesce. Queue mới là source of truth; counter là tín hiệu và metric batching.

6. Kết hợp EPOLLET mà không mất wakeup

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

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

Với edge-triggered epoll, đọc đến EAGAIN. Nếu chỉ đọc một phần trong semaphore mode, counter vẫn dương nhưng có thể không xuất hiện edge mới, khiến consumer ngủ dù token còn lại.

Counter mode thường chỉ cần một read thành công để về 0, nhưng loop đến EAGAIN vẫn là mẫu chắc chắn khi producer có thể ghi đồng thời. Level-triggered đơn giản hơn và cũng hoàn toàn hợp lệ nếu workload không cần EPOLLET.

7. EFD_SEMAPHORE dùng khi nào?

Với EFD_SEMAPHORE, mỗi read trả 1 và giảm counter một đơn vị. Mode này hữu ích khi counter đại diện token tài nguyên và mỗi consumer phải claim đúng một token. Nó không tự cung cấp fairness, payload, priority hay cancellation.

int token_fd = eventfd(initial_tokens,
                       EFD_SEMAPHORE | EFD_NONBLOCK | EFD_CLOEXEC);

Đừng bật semaphore mode chỉ vì có nhiều item trong queue. Việc phải đọc một lần trên mỗi item làm mất lợi ích batching và có thể gây starvation cho FD khác nếu drain không có budget.

8. Overflow và backpressure

Counter lớn nhất có thể lưu từ write thông thường là UINT64_MAX - 1. Ghi một giá trị khiến vượt trần sẽ block hoặc trả EAGAIN ở nonblocking mode; ghi chính UINT64_MAX trả EINVAL.

Với write 1 và consumer khỏe, chạm trần gần như là dấu hiệu lỗi lifecycle nghiêm trọng. Tuy vậy queue có thể đầy rất lâu trước counter. Backpressure phải đặt trên số item/bytes của queue:

  • queue bounded với kết quả submit rõ ràng;
  • quota theo producer/tenant;
  • drop/coalesce cho công việc thay thế được;
  • deadline và cancellation cho item cũ;
  • metric queue depth, oldest age và rejection.

9. Wake coalescing để giảm syscall

Mỗi producer ghi eventfd cho mỗi item là đúng nhưng có thể nhiều syscall. Một tối ưu phổ biến là chỉ wake khi queue chuyển từ empty sang non-empty:

  1. producer push item dưới cùng cơ chế đồng bộ;
  2. nếu trước đó queue rỗng, write 1;
  3. consumer drain theo budget;
  4. nếu còn item, tự lên lịch continuation thay vì ngủ.

Protocol này dễ có lost wake nếu kiểm tra “rỗng” và chuyển trạng thái ngủ không nguyên tử theo cùng state machine. Chỉ áp dụng sau khi có stress test race; mẫu wake mỗi batch đơn giản thường đáng tin hơn tối ưu sớm.

10. Shutdown và lifetime

Không để producer ghi vào FD đã đóng hoặc bị hệ điều hành tái sử dụng cho tài nguyên khác. Quy trình shutdown nên:

  1. đánh dấu không nhận submit mới;
  2. chờ producer đang submit rời critical section;
  3. wake loop để nó quan sát trạng thái shutdown;
  4. drain hoặc cancel queue theo policy;
  5. gỡ FD khỏi epoll, đóng trong owner thread;
  6. join threads và giải phóng queue.

Sau fork, descriptor ở parent và child trỏ cùng eventfd object. Dùng EFD_CLOEXEC và đóng bản không cần thiết; nếu hai process cần kênh độc lập, tạo eventfd riêng.

11. eventfd, pipe hay condition variable?

Công cụĐiểm mạnhGiới hạn
eventfdMột FD, counter, tích hợp epoll, overhead thấp.Không mang payload tùy ý.
pipe/socketpairMang byte/message nhỏ, portable hơn.Hai FD, buffer và framing phức tạp hơn.
condition variableTự nhiên cho thread pool thuần túy.Không trực tiếp nằm trong epoll.
futexPrimitive thấp, fast path user-space.Khó dùng đúng, không phải FD readiness thông thường.

Chọn eventfd khi nhu cầu chính là đánh thức một FD-based event loop; chọn pipe/socketpair khi notification cần mang dữ liệu nhỏ trực tiếp hoặc cần portability ngoài Linux.

Checklist production

  • Tạo với EFD_NONBLOCK | EFD_CLOEXEC.
  • Queue là source of truth; eventfd chỉ là wake hint/counter.
  • Producer publish item trước rồi mới write wake.
  • Read/write dùng đúng 8 byte và xử lý EINTR, EAGAIN.
  • EPOLLET được drain đến EAGAIN.
  • Không giả định counter bằng queue length.
  • Queue có bound và policy backpressure độc lập.
  • Consumer có work budget để không làm đói socket/timer khác.
  • Shutdown ngăn submit trước khi đóng FD.
  • Stress test producer đồng thời, queue transition và shutdown race.

Kết luận

eventfd() là primitive nhỏ nhưng rất hợp với kiến trúc Linux hướng sự kiện: nhiều producer có thể đánh thức một epoll owner bằng một FD và kernel tự coalesce notification qua counter. Mẫu bền vững là queue giữ payload, eventfd chỉ báo có việc, producer enqueue trước khi wake, consumer drain có budget và lifecycle được sở hữu rõ ràng. Khi tách đúng các trách nhiệm này, hệ thống vừa giảm polling vừa tránh biến counter thành một queue giả dễ race.

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.