⭐timerfd
timerfd 是Linux一个定时器接口,它基于文件描述符工作,并通过该文件描述符的可读事件进行超时通知。可以方便地与select、poll和epoll等I/O多路复用机制集成,从而在没有处理事件时阻塞程序执行,实现高效的零轮询编程模型。
🟠timerfd_create
创建一个新的定时器对象,并返回一个与其关联的文件描述符。
#include <sys/timerfd.h>
int timerfd_create(int clockid,int flags);
clockid:定时器所依据的时间基准。
CLOCK_REALTIME/CLOCK_MONOTONIC(含义见下文)。
flags:控制定时器文件描述符的行为,可以是0或多个以下标志通过位或(|)组合而成:
TFD_NONBLOCK: 设置为非阻塞模式,使得读取操作立即返回而不是等待直到有数据可读。
TFD_CLOEXEC: 设置执行新程序时自动关闭文件描述符的标志,这可以防止子进程中继承不必要的文件描述符(子进程不继承父进程的定时器文件描述符)。
系统实时时间 (CLOCK_REALTIME)
系统实时时间指的是从一个固定的时间点(通常是1970年1月1日UTC,也称为Unix纪元)到现在的总时间。这个时间是可以通过系统设置或网络时间协议(NTP)进行调整。
使用 CLOCK_REALTIME 获取的时间可以被操作系统或其他软件手动更改,例如当系统管理员手动调整系统时钟或自动同步时间时。如果应用程序依赖于 CLOCK_REALTIME 来计算事件之间的时间差,那么这些计算可能会因为系统时间的突然跳跃变得不准确。
单调递增的时间 (CLOCK_MONOTONIC)
单调递增的时间通常是从系统启动时开始计数,并且会持续增加直到系统关闭。与CLOCK_REALTIME 不同的是,CLOCK_MONOTONIC 不受系统时间的手动调整或自动同步的影响。
使用 CLOCK_MONOTONIC 可以确保获得的时间值总是向前移动,不会出现向后跳跃的情况。因此,它非常适合用来测量时间段。
🟠timerfd_settime
启动或停止由timerfd_create创建的定时器,并可以设置其初始时间和间隔时间。
#include <sys/timerfd.h>
int timerfd_settime(int ufd, int flags,
const struct itimerspec *new_value,
struct itimerspec *old_value);
ufd: 由timerfd_create返回的文件描述符。
flags: 设置为0表示相对定时器,即从当前时间开始计时;设置为TFD_TIMER_ABSTIME则表示绝对定时器,即按照指定的时间点来触发。
new_value:指向包含初始到期时间和后续间隔时间的结构体指针。
old_value: 如果不为NULL,则指向一个用于接收旧的定时器值的结构体。
返回值:成功时返回0;失败时返回-1并设置相应的错误号。
struct timespec{
time_t tv_sec; /* Seconds */
long tv_nsec; /* Nanoseconds */
};
struct itimerspec {
struct timespec it_interval; /* Interval for periodic timer */
struct timespec it_value; /* Initial expiration */
};
it_value是首次超时时间,需要填写从clock_gettime获取的时间,并加上要超时的时间。 it_interval是后续周期性超时时间,是多少时间就填写多少。注意一个容易犯错的地方:tv_nsec加上去后一定要判断是否超出1000000000(如果超过要秒加一),否则会设置失败。
🟠clock_gettime
#include <time.h>
int clock_gettime(clockid_t clk_id, struct timespec *tp);
clockid_t clk_id 是时钟 ID,常用的选项包括 CLOCK_REALTIME 和 CLOCK_MONOTONIC。
CLOCK_REALTIME 提供的是系统实时时间,可能会因为系统时间调整而发生跳跃。
CLOCK_MONOTONIC 提供单调递增的时间,适合用于测量时间间隔。
struct timespec *tp 是一个指向 timespec 结构体的指针,用于存储获取到的时间信息。
第三个参数设置超时时间,如果为0则表示停止定时器。定时器设置超时方法:
设置超时时间是需要调用clock_gettime获取当前时间,如果是绝对定时器,那么需要获取CLOCK_REALTIME,在加上要超时的时间。如果是相对定时器,要获取CLOCK_MONOTONIC时间。
定时器代码实例:
#define _GNU_SOURCE
#include<sys/timerfd.h>
#include<unistd.h>
#include<stdlib.h>
#include<stdio.h>
#include<time.h>
void print_itimerspec(struct itimerspec *new_value) {
printf("Initial expiration: sec: %ld nsec: %ld\n", new_value->it_value.tv_sec, new_value->it_value.tv_nsec);
printf("Interval: sec: %ld nsec: %ld\n", new_value->it_value.it_interval.tv_sec, new_value->it_value.it_interval.tv_nsec);
}
int main() {
struct itimerspec new_value;
int tfd;
//创建一个新的定时器对象
tfd = timerfd_create(CLOCK_MONOTONIC, 0);
if (tfd == -1) {
perror("timerfd_create");
exit(EXIT_FAILURE);
}
//设置定时器参数
//首次超时时间为3秒后
new_value.it_value.tv_sec = 3;
new_value.it_value.tv_nsec = 0;
// 后续每隔2秒触发一次
new_value.it_interval.tv_sec = 2;
new_value.it_interval.tv_nsec = 0;
print_itimerspec(&new_value);
// 启动定时器
if (timerfd_settime(tfd, 0, &new_value, NULL) == -1) {
perror("timerfd_settime");
close(tfd);
exit(EXIT_FAILURE);
}
// 循环读取定时器事件
uint64_t exp;
ssize_t s;
while((s = read(tfd, &exp, sizeof(uint64_t))) != sizeof(uint64_t)) {
if (s != -1) {
fprintf(stderr, "Error reading timerfd\n");
break;
}
if (errno == EINTR)
continue;
perror("read");
break;
}
printf("Timer expired %llu times\n", exp);
close(tfd);
return 0;
}
read函数可以读timerfd,读的内容为uint_64,表示超时次数。
❓补充:什么是零轮询编程模型?
零轮询编程模型是一种高效处理I/O操作的方法,旨在避免传统轮询(polling)带来的CPU资源浪费。
传统的轮询会周期性地检查I/O设备是否准备好进行数据传输,可能导致大量的CPU时间被消耗在无意义的检查上。
相比之下,零轮询编程模型利用了操作系统提供的机制(select/poll/epoll等),允许程序在等待I/O事件时进入阻塞状态,即不占用CPU资源,直到有实际的I/O事件发生才会唤醒程序进行处理。这种模型通过减少或消除不必要的检查循环。
❓补充:timerfd、eventfd、signalfd分别有什么用?
timerfd、eventfd、signalfd配合epoll使用的场景,共同工作以实现一个不需要主动轮询的环境。
timerfd 提供了一个基于文件描述符的定时器接口,可以通过文件描述符的可读事件来通知超时。
eventfd 是一种用于进程间或线程间事件通知的机制,它提供了一个文件描述符,可以用来执行简单的事件计数。
signalfd 允许信号的接收通过文件描述符进行,这样就可以将信号处理集成到文件描述符的多路复用中。
epoll 则是一个I/O多路复用的接口,能够监控大量文件描述符的集合,当某个文件描述符准备好进行I/O操作时,就返回通知给应用程序。
❓补充:把定时器文件描述符设置为非阻塞模式和阻塞模式有什么区别,举例说明?和select/poll/epoll集成时,应该设置为阻塞还是非阻塞?为什么?
(1)非阻塞模式与阻塞模式的区别
非阻塞模式(通过设置 TFD_NONBLOCK 标志):当尝试从一个非阻塞的定时器文件描述符读取数据时,如果当前没有定时器到期事件可供读取,read 调用会立即返回。程序可以在不等待I/O操作完成的情况下继续执行其他任务。
阻塞模式:在默认情况下(即未设置 TFD_NONBLOCK),对定时器文件描述符进行读操作时,如果当前没有定时器到期事件可供读取,调用线程会被挂起,直到有数据可读为止。这允许程序在等待I/O操作完成期间节省CPU资源,但同时也会导致线程暂时不可用于处理其他任务。
❓和 select/poll/epoll 集成时的选择
在使用 select、poll 或 epoll 等机制管理多个文件描述符时,推荐将定时器文件描述符设置为 非阻塞模式。
因为这些机制本身已经提供了等待I/O就绪的功能。当将文件描述符设置为非阻塞模式时,可以避免在轮询中出现不必要的阻塞。例如使用 epoll 监控定时器文件描述符,当定时器到期时,epoll_wait 返回,由于定时器文件描述符处于非阻塞模式,可以立即尝试读取而不担心阻塞问题,然后根据需要执行相应的处理逻辑。这样确保应用能够高效地响应各种I/O事件,不会因为某个特定的操作被阻塞而导致整体性能下降(具体解释看补充问题)
❓补充:如果定时器文件描述符设置为阻塞模式会发生什么情况?
当定时器文件描述符使用阻塞模式,并使用epoll
监听时,可能会导致应用程序在处理定时器事件时被阻塞,进而影响整体性能,使其他I/O
事件无法及时得到处理。
#include <stdio.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <time.h>
#include <unistd.h>
#include <fcntl.h>
#define MAX_EVENTS 10
int main() {
int epoll_fd = epoll_create1(0);
if (epoll_fd == -1) {
perror("epoll_create1");
return 1;
}
// 创建定时器文件描述符
int timer_fd = timerfd_create(CLOCK_MONOTONIC, 0);
if (timer_fd == -1) {
perror("timerfd_create");
return 1;
}
// 设置定时器
struct itimerspec new_value;
new_value.it_interval.tv_sec = 5;
new_value.it_interval.tv_nsec = 0;
new_value.it_value.tv_sec = 5;
new_value.it_value.tv_nsec = 0;
if (timerfd_settime(timer_fd, 0, &new_value, NULL) == -1) {
perror("timerfd_settime");
return 1;
}
// 将定时器文件描述符添加到epoll实例中
struct epoll_event ev, events[MAX_EVENTS];
ev.events = EPOLLIN;
ev.data.fd = timer_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, timer_fd, &ev) == -1) {
perror("epoll_ctl: timer_fd");
return 1;
}
while (1) {
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
return 1;
}
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == timer_fd) {
// 由于定时器文件描述符是阻塞模式,这里可能会阻塞
uint64_t expirations;
ssize_t s = read(timer_fd, &expirations, sizeof(uint64_t));
if (s!= sizeof(uint64_t)) {
perror("read");
return 1;
}
printf("Timer expired %lu times\n", expirations);
}
}
}
close(timer_fd);
close(epoll_fd);
return 0;
}
阻塞模式下,当对定时器文件描述符执行read
或write
等操作时,如果操作不能立即完成,进程会进入睡眠状态,等待操作条件满足。这就导致应用程序在这个操作上被阻塞,无法继续执行后续代码,包括处理其他I/O
事件。
在Linux内核中,每个文件描述符都有一个对应的文件对象,文件对象中包含了与该文件描述符相关的操作函数集合。对于定时器文件描述符,当执行read
操作时,内核会检查定时器的状态和相关的缓冲区。如果缓冲区没有数据,内核会将当前进程加入到等待队列中,并将进程状态设置为睡眠状态,直到定时器到期并产生数据,或者发生其他可以满足read
操作的条件。这种机制是为了确保read
操作能够正确完成,但在多I/O
事件处理的场景下,会导致其他 I/O 事件延迟处理:主线程或事件循环被挂起,网络套接字、文件操作等事件无法及时响应。
❓上一个问题的补充:为什么要使用read读取定时器的内核缓冲区?为什么数据会存在定时器的内核缓冲区?
定时器文件描述符为何需要 read
操作?
内核缓冲区的数据来源
定时器文件描述符(如 Linux 的 timerfd
)通过 timerfd_create
创建时,内核会为其维护一个计数器缓冲区。当定时器到期时,内核会向该缓冲区写入一个 8 字节的无符号整数,表示自上次读取后定时器触发的次数。(这就是定时器可读事件的本质)。
uint64_t expirations;
read(timer_fd, &expirations, sizeof(expirations));
若不读取,缓冲区会持续累积到期次数,导致后续 epoll_wait
误判为"持续就绪"。
为什么检测到定时器文件描述符就绪时,需要通过read来读取定时器文件描述符?
- 清除就绪状态:读取后重置内核缓冲区,避免
epoll_wait
重复触发。 - 获取触发次数:通过读取的整数值,可统计定时器到期次数 (适用于周期性定时器)。
- 避免数据堆积:长期不读取可能导致缓冲区溢出或逻辑错误。
❓上一个问题的补充:什么时候read定时器文件描述符会阻塞?
定时器文件描述符的缓冲区设计为“有数据时触发读就绪”,因此在正常逻辑中,epoll_wait
返回定时器就绪时,缓冲区应已有数据,此时 read
操作应立刻成功。但以下情况可能导致阻塞:
假设定时器到期时,内核触发超时事件并准备向文件描述符的缓冲区写入超时次数(uint64_t
类型数据).
内核检测到定时器到期,将事件标记为就绪并唤醒epoll_wait
。
在写入缓冲区的过程中(如正在更新计数器),发生线程/进程上下文切换。
用户线程从epoll_wait
返回后,立即调用read
,但此时内核尚未完成缓冲区数据的写入。
read
操作因缓冲区无数据而阻塞(若文件描述符未设置为非阻塞模式),或返回EAGAIN
(非阻塞模式)。
类比: 多线程环境下“先通知后执行”的竞态,例如生产者-消费者模型中,消费者收到通知但数据尚未生产完毕。
解决方案:设置为非阻塞模式,通过fcntl(fd, F_SETFL, O_NONBLOCK)
避免 read
阻塞。
最佳实践
- 非阻塞读取:所有通过
epoll
监听的文件描述符均设置为非阻塞模式。 - 事件处理原子化:在单次
epoll_wait
返回后,批量处理所有就绪事件,避免穿插阻塞调用。
定时器文件描述符的阻塞模式会破坏事件驱动架构的异步性,内核缓冲区的数据读取机制是定时触发的核心逻辑。通过非阻塞模式 + 严格的数据读取,可确保系统的高效性和可靠性。理解这一机制对设计高并发服务(如 Web 服务器、实时交易系统)至关重要。
❓上一个问题的补充:如果不使用timerfd实现定时器,应该怎么实现定时器?
定时器的替代方案
若需避免 read
操作,可结合信号(如 SIGEV_THREAD
)或用户态定时器队列(如 libevent
的定时器堆),但需权衡精度和性能。