A Linux event loop often waits for sockets, signals, internal work, and deadlines at the same time. A separate timer thread or signal callback quickly complicates synchronization. timerfd_create() turns a kernel timer into a file descriptor that goes directly into epoll; each read also reports how many expirations occurred while the consumer was delayed.
This guide covers clock selection, nonblocking setup, absolute deadlines that avoid drift, missed-expiration policy, wall-clock changes, and scheduling many deadlines with one timerfd. It follows Linux man-pages for timerfd_create(2), clock_gettime(2), time(7), and epoll(7).
1. What timerfd provides
timerfd_create() creates a timer and returns a descriptor. Applications arm or disarm it with timerfd_settime(), inspect it with timerfd_gettime(), wait through poll or epoll, and consume expirations with read().
Each successful read returns one host-byte-order 64-bit integer: the number of expirations since the timer was armed or changed, or since the previous successful read. This provides overrun accounting without an asynchronous callback.
Expiration does not mean application code executes at that exact nanosecond. The kernel marks the FD readable; scheduling latency still depends on CPU load and the event loop.
2. Choose a clock by semantics
| Clock | Counts suspend | Wall-clock jumps | Typical use |
|---|---|---|---|
CLOCK_MONOTONIC | No | Unaffected by discontinuous clock setting | Timeouts, retries, and heartbeats when suspend should pause elapsed time. |
CLOCK_BOOTTIME | Yes | No wall-clock jumps | Leases and elapsed deadlines that continue while suspended. |
CLOCK_REALTIME | Calendar time | Can jump forward or backward | Run at a particular civil or UTC time. |
*_ALARM | Can wake the system | Follows the corresponding clock | Intentional wake alarms requiring CAP_WAKE_ALARM. |
CLOCK_BOOTTIME includes suspend, but an ordinary timer does not necessarily wake the machine. Its expiration is observed after resume. Waking requires the matching alarm clock and privileges.
3. Create an event-loop-ready timer
#include <sys/timerfd.h>
int tfd = timerfd_create(CLOCK_MONOTONIC,
TFD_NONBLOCK | TFD_CLOEXEC);
if (tfd == -1)
fail_errno("timerfd_create");
TFD_NONBLOCK permits draining to EAGAIN; TFD_CLOEXEC prevents accidental inheritance through execve(). After fork(), parent and child descriptors refer to the same timer and consume the same counter. Create separate timers after fork when schedules must be independent.
4. Simple relative one-shot
struct itimerspec spec = {
.it_value = { .tv_sec = 2, .tv_nsec = 500000000 },
.it_interval = { .tv_sec = 0, .tv_nsec = 0 }
};
if (timerfd_settime(tfd, 0, &spec, NULL) == -1)
fail_errno("timerfd_settime");
Without TFD_TIMER_ABSTIME, it_value is relative to the call. A zero interval makes it one-shot, and an all-zero it_value disarms it. Relative one-shots fit individual timeouts, but repeatedly arming “one second from now” after work adds callback duration to every cycle and drifts.
5. Absolute deadlines avoid drift
static void add_ns(struct timespec *t, uint64_t ns)
{
t->tv_sec += (time_t)(ns / 1000000000ULL);
t->tv_nsec += (long)(ns % 1000000000ULL);
if (t->tv_nsec >= 1000000000L) {
t->tv_sec++;
t->tv_nsec -= 1000000000L;
}
}
struct timespec next;
if (clock_gettime(CLOCK_MONOTONIC, &next) == -1)
fail_errno("clock_gettime");
add_ns(&next, 1000000000ULL);
struct itimerspec spec = {
.it_value = next,
.it_interval = {0, 0}
};
if (timerfd_settime(tfd, TFD_TIMER_ABSTIME, &spec, NULL) == -1)
fail_errno("timerfd_settime absolute");
After each run, increment next from the previous deadline rather than calculating now plus period. The schedule stays anchored. If it falls several periods behind, policy must skip, perform bounded catch-up, or declare overload.
A kernel interval in it_interval also stays rhythmic and reports missed expirations. Absolute one-shots are especially useful for changing deadlines or a job heap.
6. Add to epoll and read correctly
struct epoll_event ev = {
.events = EPOLLIN | EPOLLET,
.data.fd = tfd
};
if (epoll_ctl(epfd, EPOLL_CTL_ADD, tfd, &ev) == -1)
fail_errno("epoll_ctl");
static uint64_t read_expirations(int tfd)
{
uint64_t total = 0;
for (;;) {
uint64_t count;
ssize_t n = read(tfd, &count, sizeof(count));
if (n == (ssize_t)sizeof(count)) {
total += count;
continue;
}
if (n == -1 && errno == EINTR)
continue;
if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK))
return total;
fail_errno("read timerfd");
}
}
A buffer smaller than eight bytes fails with EINVAL. With EPOLLET, drain to EAGAIN. One successful read normally consumes the current counter, but the loop remains safe when readiness and rearming overlap.
7. Missed expirations without a catch-up storm
If a 100 ms timer returns count = 37, the loop did not consume it for about 3.7 seconds. Running a heavy callback 37 times can deepen the delay. Choose an explicit policy:
- Coalesce: run once using current state, suitable for cache refresh and metric snapshots.
- Bounded catch-up: process at most N ticks and record the remainder as dropped.
- Exact accounting: add the count to domain state but execute in batches.
- Fail or degrade: for safety-critical timing, enter a controlled degraded mode.
Monitor counts above one, event-loop lag, callback duration, and queue depth. Missed ticks are capacity signals.
8. Wall-clock timers and CANCEL_ON_SET
Schedules tied to civil time need absolute CLOCK_REALTIME, but NTP steps, administrator changes, and VM restore can move that clock discontinuously:
int flags = TFD_TIMER_ABSTIME | TFD_TIMER_CANCEL_ON_SET;
if (timerfd_settime(tfd, flags, &spec, NULL) == -1)
fail_errno("timerfd_settime realtime");
TFD_TIMER_CANCEL_ON_SET applies only to absolute timers on CLOCK_REALTIME or CLOCK_REALTIME_ALARM. A discontinuous change makes read() fail with ECANCELED. Reload the schedule source, recompute the deadline, and rearm instead of blindly retrying I/O.
9. One timerfd for many deadlines
One FD per request timeout increases descriptor and epoll overhead. A large scheduler commonly keeps deadlines in a min-heap or timing wheel, arms one timerfd to the earliest deadline, pops every due job when it wakes, executes under a budget, then rearms to the next deadline or disarms when empty.
Adding a job earlier than the current heap head requires immediate rearming. Use one clock consistently for all comparisons; never compare realtime and monotonic timestamps.
10. Common failures
- Relative rearming after callbacks accumulates execution-time drift.
- Ignoring the count hides overload and loses elapsed-tick information.
- Unbounded catch-up creates a CPU spiral.
- REALTIME timeouts change when the system clock jumps.
- MONOTONIC leases unintentionally pause across suspend.
- Failing to rearm after the heap head changes delays a new early job.
- Multiple threads closing and rearming without ownership race with stale epoll events.
- Missing CLOEXEC leaks a live timer into an executed program.
Prefer one event-loop owner for timerfd. Other threads submit scheduler changes through a queue and eventfd; the owner updates the heap and timer sequentially.
Production checklist
- Use MONOTONIC for timeouts, BOOTTIME when suspend counts, and REALTIME for calendar schedules.
- Create with
TFD_NONBLOCK | TFD_CLOEXEC. - Use a kernel interval or absolute deadlines for periodic work.
- Read exactly eight bytes and observe the expiration count.
- Drain EPOLLET to
EAGAIN. - Define coalescing or bounded catch-up for counts above one.
- Handle
ECANCELEDand recompute REALTIME schedules. - Use a single timerfd plus heap or timing wheel for many jobs when appropriate.
- Serialize ownership, rearming, closing, and shutdown.
- Measure event-loop lag, missed ticks, and callback duration.
Conclusion
timerfd() is more than a replacement for sleep: it brings time into the same descriptor model as the rest of a Linux event loop. A robust design chooses the correct clock, stays aligned with absolute deadlines, interprets expiration counts, bounds catch-up, and manages many deadlines through one clearly owned scheduler. Done well, timing becomes observable, testable, and operationally stable.




No comments yet. Be the first to share your thoughts.