1.寫在前面
對於linux高性能服務器器,前面提到了select、poll和epoll機制,以及它們與socket配合提升性能的底層原理以及相關示例代碼。可以參考:《關於select、poll和epoll的幾個問題》https://blog.51cto.com/u_17355821/14348927
signalfd、timerfd、eventfd是linux系統在2.6.xx系列版本中增加的機制並提供相應的系統調用接口,完善了linux的事件驅動編程模型,這三個能配合epoll來實現提高linux服務器的性能。這篇文章來講講其它的三個機制:signalfd、timerfd、eventfd。
2.signalfd
2.1 什麼是signalfd
signalfd 是 Linux 系統中一種非常巧妙的機制,它本質上是一個系統調用,其核心作用是將信號(Signal)這種傳統的異步通知機制,轉化為文件描述符(File Descriptor)上的 I/O 事件。 簡單來説,它可以把“處理信號”這件事,變成像“讀寫文件”或“處理網絡數據”一樣的流程。 在 Linux 傳統編程中,處理信號(如 SIGINT、SIGTERM)通常使用 sigaction 註冊一個信號處理函數(Signal Handler)。這種方式是異步的:程序正在執行某段代碼時,一旦信號到來,內核會強行中斷當前流程去執行處理函數。<br> 而 signalfd 提供了一種同步的替代方案: 創建文件描述符:調用 signalfd() 系統調用,傳入感興趣的信號集合(mask)。 阻塞信號:需要先使用 sigprocmask() 將這些信號標記為“阻塞”,防止它們觸發默認行為或傳統的處理函數。 讀取信號:當這些被監控的信號到達時,signalfd 返回的文件描述符會變為“可讀”狀態。只需要調用 read() 從這個描述符中讀取數據,就能獲取信號的詳細信息。
| 特性 | 傳統信號處理 (sigaction) |
signalfd 機制 |
|---|---|---|
| 處理模型 | 異步:隨時可能中斷主流程 | 同步:在主循環中按需處理 |
| 調用函數 | 只能調用“異步信號安全”的函數 | 可以調用任意函數,無限制 |
| 集成性 | 獨立於 I/O 流程 | 可與 select/poll/epoll 結合 |
| 信息獲取 | 信息相對有限 | 可獲取完整的 signalfd_siginfo 結構體 |
| 競態條件 | 容易產生競態條件 (Race Condition) | 避免了異步處理帶來的競態問題 |
在高性能服務器或複雜的事件驅動程序(如 Nginx、Redis 或 Android 的 init 進程)中,通常使用 epoll 來管理成千上萬個 socket 連接。通過 signalfd,可以把 SIGTERM(退出信號)或 SIGCHLD(子進程退出)也加入到這個 epoll 循環中。這樣,程序不需要為了處理信號而跳出主循環,代碼邏輯更加清晰、線性。
當從 signalfd 讀取數據時,會得到一個 struct signalfd_siginfo 結構體。它包含了比傳統方式更詳細的信息,例如:
- ssi_signo:信號編號。
- ssi_pid:發送該信號的進程 ID。
- ssi_uid:發送該信號的進程用户 ID。
- ssi_status:如果是 SIGCHLD,包含子進程的退出狀態。
- ssi_addr:如果是 SIGSEGV,包含導致段錯誤的內存地址。
2.2 signalfd結合epoll的示例代碼
#define _GNU_SOURCE // 必須定義此宏才能使用 signalfd
#include <sys/signalfd.h>
#include <sys/epoll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
int main(int argc, char *argv[]) {
sigset_t mask;
int sfd; // signalfd 的文件描述符
int epfd; // epoll 的文件描述符
struct epoll_event ev, events[1];
ssize_t s;
// 1. 定義我們想要通過 signalfd 處理的信號集
// 這裏我們關注 SIGINT (Ctrl+C) 和 SIGTERM
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
// 2. 【關鍵步驟】阻塞這些信號
// 這告訴內核:"不要調用默認處理函數,把這些信號掛起,直到我通過 signalfd 讀取它們"
if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1) {
perror("sigprocmask");
exit(EXIT_FAILURE);
}
// 3. 創建 signalfd
// 參數1: -1 表示創建一個新的 fd
// 參數2: 信號掩碼,指定監控哪些信號
// 參數3: 標誌位,這裏設為 0
sfd = signalfd(-1, &mask, 0);
if (sfd == -1) {
perror("signalfd");
exit(EXIT_FAILURE);
}
// 4. 創建 epoll 實例,用於統一管理事件
epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
// 5. 將 signalfd 添加到 epoll 的監控列表中
ev.events = EPOLLIN; // 監聽可讀事件(即信號到達)
ev.data.fd = sfd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev) == -1) {
perror("epoll_ctl: add");
exit(EXIT_FAILURE);
}
printf("程序已啓動,PID: %d\n", getpid());
printf("嘗試在終端按下 Ctrl+C 或者執行 'kill %d' 來發送信號\n", getpid());
// 6. 主事件循環
while (1) {
// 等待事件發生 (這裏會阻塞,直到有信號到達或被中斷)
int nfds = epoll_wait(epfd, events, 1, -1); // -1 表示無限期等待
if (nfds == -1) {
if (errno == EINTR) {
// 被其他信號中斷,繼續循環
continue;
} else {
perror("epoll_wait");
break;
}
}
// 遍歷發生的事件
for (int n = 0; n < nfds; ++n) {
if (events[n].data.fd == sfd) {
// 7. 從 signalfd 中讀取信號信息
struct signalfd_siginfo fdsi;
s = read(sfd, &fdsi, sizeof(struct signalfd_siginfo));
if (s != sizeof(struct signalfd_siginfo)) {
perror("read");
exit(EXIT_FAILURE);
}
// 8. 根據讀取到的信息判斷是哪個信號
switch (fdsi.ssi_signo) {
case SIGINT:
printf("\n捕獲到信號: SIGINT (Ctrl+C) 發送者 PID: %u\n", fdsi.ssi_pid);
printf("程序即將退出...\n");
goto out; // 跳出循環並退出
break;
case SIGTERM:
printf("捕獲到信號: SIGTERM (kill 命令) 發送者 PID: %u\n", fdsi.ssi_pid);
printf("程序即將退出...\n");
goto out;
break;
default:
printf("捕獲到未知信號: %d\n", fdsi.ssi_signo);
break;
}
}
}
}
out:
close(sfd);
close(epfd);
printf("程序結束。\n");
exit(EXIT_SUCCESS);
}
3.timerfd
3.1 什麼是timerfd
timerfd 是 Linux 系統中一個非常優雅的定時器接口。簡單來説,它的核心思想是將“時間”也變成一種文件描述符(File Descriptor)。 就像可以用 read() 讀取文件,或者用 epoll 監聽網絡套接字(Socket)是否有數據到來一樣,也可以用 timerfd 讓一個定時器在超時時變得“可讀”。這樣,就可以把時間事件和I/O 事件放在同一個地方(比如 epoll 循環)進行統一處理。
在 timerfd 出現之前,Linux 上常用的定時器機制(如 setitimer 或 alarm)通常是基於信號(Signal)的。這種方式存在一些痛點: 信號處理複雜:信號處理函數(Signal Handler)是異步的,容易引發競態條件(Race Condition),編寫安全的信號處理代碼非常困難。 精度問題:傳統定時器精度通常只到毫秒級。 模型割裂:在基於 epoll 的高性能服務器中,I/O 事件在主循環裏處理,而定時器卻要通過信號打斷主循環,邏輯不統一。 timerfd 解決了這些問題:它把異步的信號通知,變成了同步的文件描述符讀取。讀取到的內容是一個 uint64_t 類型的整數,表示自上次讀取以來,定時器超時的次數。如果不讀取,下次超時發生時,這個計數會累加。
| 特性 | 説明 |
|---|---|
| 統一事件模型 | 可以和 Socket 一樣被 epoll/poll 監聽,無需處理複雜的信號(Signal)。 |
| 高精度 | 基於內核高精度定時器(hrtimer),支持納秒級精度。 |
| 線程安全 | 避免了異步信號處理帶來的各種併發問題,代碼邏輯更清晰、線性。 |
| 防時間漂移 | 通過讀取 uint64_t 的計數,可以知道錯過了多少次超時,從而修正邏輯,防止長時間任務導致定時器“漂移”。 |
3.2 timerfd結合epoll的示例代碼
#define _GNU_SOURCE
#include <sys/timerfd.h>
#include <sys/epoll.h>
#include <time.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <errno.h>
// 輔助函數:創建一個定時器,返回文件描述符
int create_timer(uint64_t initial_seconds, uint64_t interval_seconds) {
int tfd;
struct itimerspec timer_spec;
// 1. 創建 timerfd
// 使用 CLOCK_MONOTONIC (不受系統時間調整影響) 並設置為非阻塞
tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK);
if (tfd == -1) {
perror("timerfd_create");
exit(EXIT_FAILURE);
}
// 2. 配置定時器參數
// 第一次觸發時間
timer_spec.it_value.tv_sec = initial_seconds;
timer_spec.it_value.tv_nsec = 0;
// 之後的週期間隔
timer_spec.it_interval.tv_sec = interval_seconds;
timer_spec.it_interval.tv_nsec = 0;
// 3. 啓動定時器
if (timerfd_settime(tfd, 0, &timer_spec, NULL) == -1) {
perror("timerfd_settime");
exit(EXIT_FAILURE);
}
printf("定時器已啓動:將在 %lu 秒後首次觸發,之後每隔 %lu 秒觸發一次。\n",
initial_seconds, interval_seconds);
return tfd;
}
int main() {
int timer_fd;
int epfd;
struct epoll_event ev, events[1];
uint64_t exp; // 用於讀取超時次數
// 1. 創建定時器 (2秒後第一次觸發,之後每2秒觸發一次)
timer_fd = create_timer(2, 2);
// 2. 創建 epoll 實例
epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
// 3. 將 timerfd 添加到 epoll 監聽列表
ev.events = EPOLLIN; // 監聽可讀事件
ev.data.fd = timer_fd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, timer_fd, &ev) == -1) {
perror("epoll_ctl: add timer");
exit(EXIT_FAILURE);
}
printf("程序已運行,等待定時器觸發... (按 Ctrl+C 退出)\n");
// 4. 主事件循環
while (1) {
int nfds = epoll_wait(epfd, events, 1, -1); // -1 表示永久阻塞等待
if (nfds == -1) {
if (errno == EINTR) {
// 如果被信號中斷(比如我們按了Ctrl+C但沒處理),繼續循環
continue;
}
perror("epoll_wait");
break;
}
// 處理髮生的事件
for (int i = 0; i < nfds; ++i) {
if (events[i].data.fd == timer_fd) {
// 5. 定時器觸發
// 必須讀取,否則定時器 fd 會一直保持可讀狀態
ssize_t s = read(timer_fd, &exp, sizeof(uint64_t));
if (s != sizeof(uint64_t)) {
perror("read timerfd");
exit(EXIT_FAILURE);
}
// 'exp' 的值表示自上次讀取以來,定時器超時的次數。
// 如果系統負載過高導致錯過了幾次觸發,exp 會大於 1。
printf("⏰ 定時器觸發! 超時次數累加值: %lu (當前時間: %ld)\n",
exp, time(NULL));
}
}
}
// 清理資源
close(timer_fd);
close(epfd);
printf("程序退出。\n");
return 0;
}
4.eventfd
4.1 什麼是eventfd
eventfd 是 Linux 內核提供的一種輕量級事件通知機制。它完美體現了 Linux 將各種機制“轉化為文件描述符(FD)”以便於統一管理的設計哲學。 簡單來説,eventfd 就是一個由內核維護的 64 位無符號整數計數器。內核把它包裝成一個文件描述符暴露給用户空間,用户空間可以通過 read/write 系統調用來操作這個計數器,從而實現進程間或線程間的通信與同步。
可以把 eventfd 想象成一個只能存放一個數字的“信箱”: 寫入(Write):往信箱裏“投遞”一個數字(uint64_t)。內核會把這個數字累加到計數器上。 讀取(Read):從信箱裏“取出”數字。這通常會把計數器清零(或者減一,取決於模式)。 狀態變化:當計數器的值大於 0 時,這個 FD 就是可讀的;當計數器為 0 時,FD 就是不可讀的。
在 eventfd 出現之前,Linux 系統中線程間的通知與同步主要依賴於互斥鎖與條件變量、管道或信號等機制。但是它們在高性能編程中顯得有些笨重。 eventfd 本質上是為了解決“如何高效地喚醒阻塞在 epoll 上的線程”這一特定問題而誕生的。
eventfd 的出現填補了“內核事件計數器”這一空白。相比於傳統方式,它的優勢主要體現在以下幾個方面: (1)完美的 epoll 兼容性(統一事件源) 這是 eventfd 最大的優勢。它返回的是一個文件描述符(fd),可以像 socket 一樣直接添加到 epoll 的監聽列表中。 效果: 線程只需要在一個 epoll_wait 循環中,既能處理網絡 I/O 事件,又能處理“線程喚醒”事件。不需要為同步機制單獨開一個線程或使用複雜的信號處理。 (2)極致的輕量級(無數據拷貝) eventfd 的內核實現僅僅是一個 64 位的計數器。 對比 Pipe: Pipe 涉及內核緩衝區的讀寫和內存拷貝。 對比 eventfd: 寫入 eventfd 只是將用户傳入的 8 字節整數累加到計數器上;讀取則是清零計數器。沒有複雜的數據流管理,也沒有內存拷貝開銷。 (3)豐富的語義(計數器 vs 信號) 可靠性: eventfd 是一個計數器,如果在非阻塞模式下連續寫入多次,計數器會累加。當線程最終去讀取時,能一次性拿到累計的次數。這避免了像信號那樣可能會丟失中間事件的問題。 靈活性: 通過設置 EFD_SEMAPHORE 標誌,它可以表現得像一個信號量(每次讀取只減 1),非常適合處理多個獨立事件。 (4)線程安全與簡潔性 eventfd 的讀寫操作本身就是線程安全的系統調用。相比於使用互斥鎖+條件變量那一套繁瑣的配合(需要手動加鎖、解鎖、等待、喚醒),eventfd 的接口極其簡單:read 和 write。
4.2 eventfd使用約束
平台不通用:它是 Linux 特有的。如果代碼需要移植到 macOS 或 Windows,eventfd 是不可用的(在其他系統上通常需要用 pipe 或 socketpair 模擬)。 無法傳輸複雜數據:它只能傳輸一個 64 位的整數,如果需要傳輸字符串、結構體或大量數據,eventfd 無能為力,必須使用管道、Socket 或共享內存。 僅限於 POSIX 線程或進程:雖然它可以用於進程間通信,但通常是在有親緣關係的進程(父子進程)或通過其他方式傳遞了 FD 的進程間使用。它不像 Socket 那樣可以通過端口號被任意進程訪問。
4.3 eventfd結合epoll的示例代碼
#define _GNU_SOURCE
#include <sys/eventfd.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
// 全局變量:eventfd 文件描述符
int efd;
// 工作線程函數
void* worker_thread(void* arg) {
printf("[工作線程] 正在執行耗時任務...\n");
// 模擬耗時操作
sleep(3);
printf("[工作線程] 任務完成,準備通知主線程。\n");
// 通知主線程:寫入一個 64 位的整數值 (通常用 1 表示事件發生)
uint64_t val = 1;
ssize_t s = write(efd, &val, sizeof(uint64_t));
if (s != sizeof(uint64_t)) {
perror("worker write");
exit(EXIT_FAILURE);
}
printf("[工作線程] 通知已發送。\n");
return NULL;
}
int main() {
int epfd;
struct epoll_event ev, events[1];
pthread_t tid;
// 1. 創建 eventfd
// 初始值設為 0
// EFD_NONBLOCK 表示非阻塞模式
efd = eventfd(0, EFD_NONBLOCK);
if (efd == -1) {
perror("eventfd");
exit(EXIT_FAILURE);
}
// 2. 創建 epoll 實例
epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
// 3. 將 eventfd 添加到 epoll 監聽列表
ev.events = EPOLLIN; // 監聽可讀事件
ev.data.fd = efd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, efd, &ev) == -1) {
perror("epoll_ctl: add");
exit(EXIT_FAILURE);
}
// 4. 創建工作線程
if (pthread_create(&tid, NULL, worker_thread, NULL) != 0) {
perror("pthread_create");
exit(EXIT_FAILURE);
}
printf("[主線程] 等待工作線程通知... (當前 eventfd 計數器為 0,阻塞中)\n");
// 5. 主事件循環
while (1) {
int nfds = epoll_wait(epfd, events, 1, -1); // 永久阻塞等待
if (nfds == -1) {
if (errno == EINTR) continue; // 被中斷則重試
perror("epoll_wait");
break;
}
// 處理事件
for (int i = 0; i < nfds; ++i) {
if (events[i].data.fd == efd) {
// 6. eventfd 就緒,説明有事件到來
uint64_t val;
ssize_t s = read(efd, &val, sizeof(uint64_t));
if (s != sizeof(uint64_t)) {
perror("main read");
exit(EXIT_FAILURE);
}
// 'val' 包含了自上次讀取以來累加的計數
printf("[主線程] 收到來自工作線程的通知!計數器值: %lu\n", val);
printf("[主線程] 開始處理後續邏輯...\n");
// 既然已經收到通知,可以退出循環(或者繼續監聽)
goto out;
}
}
}
out:
pthread_join(tid, NULL); // 等待線程結束
close(efd);
close(epfd);
printf("[主線程] 程序退出。\n");
return 0;
}
5.寫在後面
像signalfd、timerfd、eventfd、socket配合epoll來實現事件通知編程的,在linux中還有inotify機制,其實也是類似的方法,先生成一個fd,然後用epoll去監控是否有事件。有興趣也可以自行了解一下。