chaowen.← Back to archive

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.

Linux deep dive · Timekeeping and event-driven I/O

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.

Questiontimerfd answerWhy 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.
Core invariant: 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.
timer expires ──▶ timerfd context: ticks = 1 ──▶ wake poll waiters timer expires ──▶ timerfd context: ticks = 2 event loop wakes, read(fd) ──▶ returns 2, resets pending ticks to 0 │ └─ periodic timer is re-armed from the current clock No read: EPOLLIN stays asserted. Short read: -EINVAL; timerfd records are exactly sizeof(uint64_t) bytes.

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.

ClockUse it forImportant behavior
CLOCK_MONOTONICOrdinary deadlines and periodic work.Not changed by wall-clock adjustments; it excludes time spent suspended.
CLOCK_BOOTTIMETasks whose elapsed time should include suspend.Like monotonic time, but advances during suspend.
CLOCK_REALTIMEWall-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_ALARMWake-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:

FieldRole
tEither an hrtimer or an alarm, selected by the clock and alarm variant.
tintvThe programmed interval, used when restarting a periodic timer.
wqhA wait queue and its lock, shared by the read and poll paths.
ticksThe pending expiration count returned by the next successful read.
expiredA small state bit telling the read path that a timer callback happened and a periodic timer may need to be advanced/restarted.
moffs, might_cancelRealtime-clock cancellation bookkeeping for TFD_TIMER_CANCEL_ON_SET.
timerfd_create(clockid, flags) │ ├─ validate clock and flags; alarm clocks require CAP_WAKE_ALARM ├─ allocate timerfd_ctx ├─ initialize hrtimer or alarm callback └─ anon_inode_getfile("[timerfd]", &timerfd_fops, ctx) │ ├─ .poll → timerfd_poll() ├─ .read_iter → timerfd_read_iter() └─ .release → cancel timer, free ctx via RCU timer callback → __timerfd_triggered() ctx->expired = 1 ctx->ticks++ wake_up_locked_poll(..., EPOLLIN) epoll_wait() sees ticks != 0 read(8 bytes) → copy ticks to userspace; clear ticks if periodic, forward/re-arm from the current time return missed-expiration count

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.

PolicyImplementation ideaGood forRisk
Catch upRun 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.
CoalesceRun once and record the count as a batch size or metric.Metrics flushes, cache refreshes, housekeeping.Intermediate states are skipped.
Drop stale workRun 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-upProcess 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.

Subtle point: a successful read consumes the pending notification. It does not make the work complete. If the callback or task later fails, the timerfd will not replay that count. Persist or metricize the count before destructive work if missed periods matter operationally.

Tradeoffs, failures, and security

Debugging method

  1. 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.
  2. Inspect /proc/<pid>/fd/<fd> to confirm the descriptor points to anon_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.
  3. Trace timerfd_create, timerfd_settime, timerfd_gettime, epoll_wait, and read with strace -tt -T. Compare the time between a timer becoming ready and the read, then compare the returned count with the elapsed interval.
  4. 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.
  5. If counts are unexpectedly large, check CPU starvation, cgroup throttling, debugger pauses, system suspend, and whether CLOCK_BOOTTIME was 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

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