Linux timers / epoll readiness
Linux timerfd: one readable counter for missed time
A periodic timer can expire while an event loop is busy, descheduled, or paused in a debugger. Linux timerfd turns that fact into a file descriptor: readiness says “at least one expiration is pending,” and one 8-byte read() tells you how many periods elapsed. The distinction between readiness and the expiration count is the whole design.
2026-08-18
The concrete problem
Imagine a service that runs a housekeeping task every 100 ms while also handling sockets in epoll. A signal-based timer makes the callback asynchronous: it can interrupt code holding a lock, run on an inconvenient stack, and force the program to use signal-safe operations. A thread that sleeps for 100 ms avoids signal rules but now needs another thread, another wakeup path, and a policy for what happens after a long pause.
The event loop already knows how to wait on file descriptors. timerfd_create() gives it a timer-shaped descriptor, so the loop can handle sockets, process notifications, and timeouts in one place. More importantly, a timerfd is not a queue containing one record per tick. It is a kernel-maintained count of expirations that have not yet been consumed.
| Question | timerfd answer | Why it matters |
|---|---|---|
| Has time elapsed? | epoll_wait() returns EPOLLIN while the pending count is nonzero. | Readiness is level-triggered; it remains true until a successful read consumes the count. |
| How many periods elapsed? | read(fd, &count, 8) returns a uint64_t. | The count is the timer-overrun information; there is no separate timerfd overrun syscall. |
| What if the loop was late? | One read reports all accumulated expirations. | The application can catch up, coalesce work, or deliberately skip stale periods. |
| What is the time base? | Choose CLOCK_MONOTONIC, CLOCK_BOOTTIME, or a realtime clock. | Clock choice determines behavior across wall-clock changes and suspend/resume. |
EPOLLIN means “the counter is nonzero,” not “exactly one tick happened.” Always read the full 8-byte counter and decide what that number means for the application.The API contract
The interface is three Linux-specific system calls declared by <sys/timerfd.h>:
int timerfd_create(int clockid, int flags);
int timerfd_settime(int fd, int flags,
const struct itimerspec *new_value,
struct itimerspec *old_value);
int timerfd_gettime(int fd, struct itimerspec *curr_value);
timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK) is a sensible default for a service timer. The descriptor starts disarmed. it_value is the first expiration; a zero value in both fields disarms the timer. it_interval is the period after that first expiration; if it is zero, the timer is one-shot.
| Clock | Use it for | Important behavior |
|---|---|---|
CLOCK_MONOTONIC | Ordinary deadlines and periodic work. | Not changed by wall-clock adjustments; it excludes time spent suspended. |
CLOCK_BOOTTIME | Tasks whose elapsed time should include suspend. | Like monotonic time, but advances during suspend. |
CLOCK_REALTIME | Wall-clock calendar deadlines. | Can jump when the system clock is changed; absolute timers can request cancellation on such a jump. |
CLOCK_REALTIME_ALARM / CLOCK_BOOTTIME_ALARM | Wake-from-suspend alarms. | Requires CAP_WAKE_ALARM; do not use merely because a normal timer exists. |
Relative timers are usually easier to reason about for periodic service work. With TFD_TIMER_ABSTIME, the initial it_value is interpreted on the selected clock rather than as a duration. TFD_TIMER_CANCEL_ON_SET only applies to an absolute realtime timer; after a discontinuous wall-clock change, a read returns -1 with errno == ECANCELED. That is an explicit “recompute your schedule” signal, not an ordinary expiration count.
The read ABI is intentionally strict: the destination must hold at least 8 bytes, and a successful read returns one unsigned 64-bit expiration count. With TFD_NONBLOCK, a read when the count is zero returns -1/EAGAIN. Without it, a direct read can sleep until the counter becomes nonzero. In an epoll loop, nonblocking mode prevents an unexpected blocking read after another consumer or a race has already drained the descriptor.
Kernel data and control flow
In the current Linux implementation, fs/timerfd.c stores the timer state in struct timerfd_ctx. The important fields are:
| Field | Role |
|---|---|
t | Either an hrtimer or an alarm, selected by the clock and alarm variant. |
tintv | The programmed interval, used when restarting a periodic timer. |
wqh | A wait queue and its lock, shared by the read and poll paths. |
ticks | The pending expiration count returned by the next successful read. |
expired | A small state bit telling the read path that a timer callback happened and a periodic timer may need to be advanced/restarted. |
moffs, might_cancel | Realtime-clock cancellation bookkeeping for TFD_TIMER_CANCEL_ON_SET. |
The callback deliberately does not immediately re-arm a periodic timer. timerfd_triggered() marks the context expired, increments ticks, and wakes poll waiters. The read path later calls hrtimer_forward_now() or alarm_forward_now() to account for elapsed periods and restart the timer. This keeps a very short-period timer from continuously rearming itself in callback context while userspace is not consuming it.
timerfd_poll() takes the wait-queue lock and reports EPOLLIN exactly when ctx->ticks is nonzero. The read path uses the same lock: it waits for a nonzero count, snapshots and clears it, then folds in any additional periodic expirations calculated while advancing the underlying timer. The lock makes “observe count, clear count, rearm” one kernel-side state transition.
That is why the descriptor behaves differently from a pipe. A pipe preserves a byte stream and can contain many records. A timerfd collapses repeated events into one number. If the process sleeps through 500 periods, it does not receive 500 readiness callbacks or 500 queue nodes; it receives one readable fd whose count describes the elapsed schedule.
Overruns are a policy decision
Suppose a 100 ms periodic timer is read after 750 ms. The count will be approximately seven, subject to scheduling and clock resolution. The kernel tells you what happened; it does not tell you how to apply the work.
| Policy | Implementation idea | Good for | Risk |
|---|---|---|---|
| Catch up | Run the logical task once per expiration. | Accounting where every period is semantically meaningful. | A long stall can create a work storm and extend the stall. |
| Coalesce | Run once and record the count as a batch size or metric. | Metrics flushes, cache refreshes, housekeeping. | Intermediate states are skipped. |
| Drop stale work | Run once if count > 0, then schedule from “now.” | UI refreshes and best-effort sampling. | It can hide a sustained overload unless the count is observed. |
| Bound catch-up | Process at most N ticks per loop and keep overload visible. | Schedulers with a hard latency or CPU budget. | Requires an explicit backlog policy and telemetry. |
Do not use the count as a substitute for a timestamp. A count says how many programmed intervals elapsed; it does not say exactly when the work should have run, nor does it correct for a task whose execution time exceeds its period. For a control loop, combine the count with clock_gettime() and define whether the loop follows wall time, elapsed time, or a bounded best-effort schedule.
Tradeoffs, failures, and security
- Wrong clock: using
CLOCK_REALTIMEfor elapsed-duration work makes a wall-clock correction alter the schedule. PreferCLOCK_MONOTONIC; useCLOCK_BOOTTIMEwhen suspend counts as elapsed time. - Read the wrong size: reading four bytes or a structure larger than needed fails with
EINVALor obscures the ABI. Use auint64_tand require exactlysizeof countbytes from the call. - One read per event: the kernel already aggregates expirations. A loop that assumes one tick per readiness notification loses the meaning of the count and often under-accounts after stalls.
- Forgot to drain: with level-triggered epoll, leaving the count nonzero causes immediate wakeups and a busy loop. With edge-triggered epoll, failing to read can leave the application with no new edge because the fd never transitioned back to not-ready.
- Unexpected blocking: another thread or handler can consume the timer before the event-loop branch reads it. Use
TFD_NONBLOCKand treatEAGAINas a benign race. - Overload amplification: catching up every missed period can turn one scheduling pause into a CPU storm. Bound the work and export the expiration count, maximum count, and processing latency.
- Descriptor leakage: a timerfd is a process capability. Use
TFD_CLOEXECso anexecve()does not accidentally keep the timer alive or expose it to a child with different assumptions. - Wake alarms:
CLOCK_*_ALARMcan wake a suspended machine and requiresCAP_WAKE_ALARM. Treat it as a power and availability capability, not as an ordinary timer option. - Resource exhaustion: untrusted code that can create many timerfds can consume file descriptors and kernel timer state. Apply fd limits, cgroup limits, and request quotas; close timerfds when their owner is gone.
Debugging method
- Log the clock ID, initial expiration, interval, returned count, and the monotonic timestamp at read time. A line saying “timer fired” is not enough to distinguish a late loop from a broken timer.
- Inspect
/proc/<pid>/fd/<fd>to confirm the descriptor points toanon_inode:[timerfd]. Then inspect/proc/<pid>/fdinfo/<fd>; Linux exposes the clock, pending ticks, set-time flags, next value, and interval when proc fdinfo is enabled. - Trace
timerfd_create,timerfd_settime,timerfd_gettime,epoll_wait, andreadwithstrace -tt -T. Compare the time between a timer becoming ready and the read, then compare the returned count with the elapsed interval. - If the loop spins, check whether the timerfd is level-triggered and the read is failing. If the loop stops receiving events in edge-triggered mode, check whether the previous event was drained and whether the fd was ever rearmed or returned to not-ready.
- If counts are unexpectedly large, check CPU starvation, cgroup throttling, debugger pauses, system suspend, and whether
CLOCK_BOOTTIMEwas selected intentionally.
# Linux-only inspection readlink /proc/$PID/fd/$FD cat /proc/$PID/fdinfo/$FD strace -tt -T -e trace=timerfd_create,timerfd_settime,timerfd_gettime,epoll_wait,read ./timerfd-lab # Kernel source points rg -n "timerfd_(poll|read_iter|triggered|restart)|struct timerfd_ctx" fs/timerfd.c
Compact reproducible lab
Goal
Arm a 100 ms periodic timer, deliberately leave the event loop asleep for about 550 ms, then observe one EPOLLIN wakeup and a count greater than one. The second run demonstrates that readiness remains asserted until the count is consumed.
Prerequisites
Linux 2.6.27 or newer, a C compiler, and a shell. The program uses Linux’s sys/timerfd.h; it is not portable POSIX code. The lab was prepared on a macOS host, where that header and syscall are unavailable, so it was not executed natively here. Run it in a Linux VM, container, or Linux CI runner.
Exact commands
cc -O2 -Wall -Wextra -o /tmp/timerfd-lab -x c - <<'C'
#define _GNU_SOURCE
#include <errno.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/timerfd.h>
#include <time.h>
#include <unistd.h>
static void die(const char *what) { perror(what); exit(EXIT_FAILURE); }
static long long ns_now(void) {
struct timespec ts;
if (clock_gettime(CLOCK_MONOTONIC, &ts) < 0) die("clock_gettime");
return (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
}
int main(int argc, char **argv) {
int leave_pending = argc > 1 && strcmp(argv[1], "pending") == 0;
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK);
if (tfd < 0) die("timerfd_create");
struct itimerspec spec = {
.it_value = { .tv_sec = 0, .tv_nsec = 100000000 },
.it_interval = { .tv_sec = 0, .tv_nsec = 100000000 }
};
if (timerfd_settime(tfd, 0, &spec, NULL) < 0) die("timerfd_settime");
int ep = epoll_create1(EPOLL_CLOEXEC);
if (ep < 0) die("epoll_create1");
struct epoll_event add = { .events = EPOLLIN, .data.fd = tfd };
if (epoll_ctl(ep, EPOLL_CTL_ADD, tfd, &add) < 0) die("epoll_ctl");
printf("sleeping 550 ms so several periods accumulate...\n");
usleep(550000);
struct epoll_event event;
long long before = ns_now();
int n = epoll_wait(ep, &event, 1, 1000);
long long after = ns_now();
if (n < 0) die("epoll_wait");
if (n == 0) { fprintf(stderr, "timed out\n"); return EXIT_FAILURE; }
printf("epoll: events=0x%x, wait=%lld us\n",
event.events, (after - before) / 1000);
if (leave_pending) {
n = epoll_wait(ep, &event, 1, 0);
printf("without read: immediate epoll_wait returns %d\n", n);
}
uint64_t expirations = 0;
ssize_t got = read(tfd, &expirations, sizeof expirations);
if (got < 0) die("read timerfd");
if (got != (ssize_t)sizeof expirations) {
fprintf(stderr, "short read: %zd\n", got); return EXIT_FAILURE;
}
printf("read: %" PRIu64 " expiration(s)\n", expirations);
n = epoll_wait(ep, &event, 1, 0);
printf("after read: immediate epoll_wait returns %d\n", n);
close(ep); close(tfd);
return 0;
}
C
/tmp/timerfd-lab
/tmp/timerfd-lab pending
Expected result
The first run should print one readable event, a count around 5 or 6, and after read: immediate epoll_wait returns 0. Timing is scheduler-dependent, so do not assert an exact count. The pending run should show that a second zero-timeout epoll_wait returns immediately before the read, because the timerfd is still readable; after the read it returns zero.
Revealing variations
- Change
.it_intervalto zero. The timer becomes one-shot: the read returns one, and no periodic restart occurs. - Replace
CLOCK_MONOTONICwithCLOCK_BOOTTIMEon Linux and run the program across a suspend/resume cycle. The count includes suspended time; the monotonic version does not. - Change the epoll registration to
EPOLLIN | EPOLLET. The first event still arrives, but failing to read the timerfd means no new edge can be expected—the fd never became not-ready.
Fallback when Linux is unavailable: use a Linux container or VM. On a host with Docker, the source can be saved as /tmp/timerfd-lab.c and compiled with docker run --rm -v /tmp:/lab ubuntu:24.04 bash -lc 'apt-get update && apt-get install -y build-essential && cc -O2 -Wall -Wextra -o /lab/timerfd-lab /lab/timerfd-lab.c && /lab/timerfd-lab pending'. A container shares the host kernel, so the test still exercises the host’s Linux timerfd implementation.
Primary sources
- Linux kernel:
fs/timerfd.c— context structure, poll/read paths, callbacks, rearming, and cancellation. - Linux UAPI:
include/uapi/linux/timerfd.h— public flags and ioctl definitions. - Linux man-pages:
timerfd_create(2)— clock semantics, read ABI, errors, and examples. - Linux man-pages:
epoll(7)— level-triggered and edge-triggered readiness semantics. - Kernel documentation: hrtimers — the high-resolution timer subsystem used by ordinary timerfds.