How to run a task on a timer
Short answer: A loop that does its work and then calls thread::sleep(interval) schedules the next tick relative to when the last iteration finished — so every iteration adds its own runtime to the schedule, and the drift compounds: at 200 ms of interval and 120 ms of work, the fifth tick lands 1.3 s in instead of 1.0 s. The fix is to anchor ticks to multiples of the interval — start + n * interval — and sleep until the next one. The crossbeam-channel crate wraps that in tick(): while you keep up, ticks land on the original grid; when a task overruns, the schedule re-anchors to the moment you recovered, and missed ticks are skipped rather than queued.
Everything below was compiled and run with rustc 1.99.0, edition 2024, on macOS, and behaves the same on Linux. The millisecond values will land close to these numbers on another machine, but not exactly — scheduling granularity is never zero.
The naive loop drifts
Section titled “The naive loop drifts”A periodic task usually starts life looking like this — work, then sleep, forever:
use std::time::{Duration, Instant};
fn main() { let interval = Duration::from_millis(200); let work = Duration::from_millis(120); // the task itself takes time too let t0 = Instant::now();
for n in 0..5 { println!("tick {n} at {}ms", t0.elapsed().as_millis()); std::thread::sleep(work); std::thread::sleep(interval); }}tick 0 at 0mstick 1 at 327mstick 2 at 654mstick 3 at 983mstick 4 at 1318msThe interval is 200 ms, but the ticks are ~330 ms apart. The loop sleeps after the work, so each cycle is work + interval plus whatever the OS scheduler added on top of the two sleeps. And the error compounds: it is not “each tick is a bit late”, it is “each tick is late relative to a schedule that was already late”. Five ticks later the program is 318 ms behind where a real 200 ms ticker would be — and in a monitor or a rate limiter, that gap is the bug.
Anchor the ticks to the schedule, not to the last iteration
Section titled “Anchor the ticks to the schedule, not to the last iteration”The sleep should not measure from “now” — it should measure from the schedule. Compute the n-th tick as start + n * interval, and sleep until that instant:
use std::time::{Duration, Instant};
fn main() { let interval = Duration::from_millis(200); let work = Duration::from_millis(120); let t0 = Instant::now();
for n in 1..=5 { let next = t0 + interval * n; let now = Instant::now(); if next > now { std::thread::sleep(next - now); } println!("tick {n} at {}ms", t0.elapsed().as_millis()); std::thread::sleep(work); }}tick 1 at 210mstick 2 at 402mstick 3 at 607mstick 4 at 806mstick 5 at 1009msFive ticks in one second, work included. The work time no longer moves the schedule — it only eats the slack inside each 200 ms window. Two details worth noticing in the code:
interval * nisDuration * u32, andt0 + ...isInstant + Duration— both are in the standard library, no crate needed.- The
if next > nowguard matters: when a task overruns its interval,next - nowwould be a negative duration, and subtracting oneInstantfrom an earlier one panics. The guard turns “we are already late” into “start the next window immediately” — but note the debt is never repaid: after an overrun, every later tick stays late by the same amount.
crossbeam-channel’s tick() does the anchoring for you
Section titled “crossbeam-channel’s tick() does the anchoring for you”This is common enough that it has a library shape. The crossbeam-channel crate ships a tick constructor that returns a channel delivering an Instant every interval:
[dependencies]crossbeam-channel = "0.5"use crossbeam_channel::tick;use std::time::{Duration, Instant};
fn main() { let interval = Duration::from_millis(200); let work = Duration::from_millis(120); let t0 = Instant::now(); let ticker = tick(interval);
for n in 1..=5 { let scheduled = ticker.recv().expect("ticker never disconnects"); println!( "tick {n}: scheduled {}ms, handled {}ms", (scheduled - t0).as_millis(), t0.elapsed().as_millis() ); std::thread::sleep(work); }}tick 1: scheduled 200ms, handled 204mstick 2: scheduled 400ms, handled 406mstick 3: scheduled 600ms, handled 603mstick 4: scheduled 800ms, handled 810mstick 5: scheduled 1000ms, handled 1008msSame grid as the anchored loop, and two things come free. First, the message is an Instant — the moment the tick was scheduled — so measuring lateness is a subtraction, as the two numbers above show. Second, recv blocks until the next tick, so there is no sleep arithmetic left to get wrong.
One detail that surprises people who assume a timer thread: there is none. The channel’s state is a single “next delivery time”, and your own recv call does the waiting and the bookkeeping. That is also why tick never panics and never leaks threads — drop the receiver and the ticker is gone, because it never existed.
When a task overruns the interval
Section titled “When a task overruns the interval”What happens when the work takes longer than the interval? Set the work to 350 ms against a 200 ms tick:
use crossbeam_channel::tick;use std::time::{Duration, Instant};
fn main() { let interval = Duration::from_millis(200); let work = Duration::from_millis(350); // longer than the interval let t0 = Instant::now(); let ticker = tick(interval);
for n in 1..=4 { let scheduled = ticker.recv().expect("ticker never disconnects"); println!( "tick {n}: scheduled {}ms, handled {}ms", (scheduled - t0).as_millis(), t0.elapsed().as_millis() ); std::thread::sleep(work); }}tick 1: scheduled 200ms, handled 202mstick 2: scheduled 400ms, handled 560mstick 3: scheduled 760ms, handled 920mstick 4: scheduled 1120ms, handled 1280msRead tick 2 and 3 together: the tick scheduled for 400 ms was handled at 560 ms, and the next one was scheduled for 760 ms — not 600, not 800. The channel’s rule, straight from its source, is that each delivery is scheduled at max(scheduled_time, now) + interval. While you keep up, now is earlier than the scheduled time and the original grid survives. After an overrun, the grid re-anchors to the moment you recovered.
That policy is a deliberate middle ground:
- No backlog. The channel holds at most one tick. A naive ticker built on a
Vecof deadlines would hand you three overdue ticks after one slow task and have you re-run the work three times in a row to “catch up” —tickskips them. - No forced catch-up either. The re-anchored grid (760, 1120, …) never gets back to the original one. If catching up matters — a scraper that must not skip a page, a writer that must not skip a sample — the anchored loop from the previous section, with the
next > nowguard removed so overruns run back-to-back, is the honest shape. If lateness merely needs to be visible, thescheduledinstant tells you, and the loop above is enough.
The same shape exists in async land: tokio::time::interval is the runtime’s version of the ticker, with its own policy knobs for missed ticks. That is a different runtime’s article.
Related
Section titled “Related”- How to run a command with a timeout — the other side of waiting: bounding someone else’s work instead of pacing your own
- How to handle Ctrl+C gracefully — stopping a loop like this one in order, instead of at signal speed
- Processes, environment and signals — the rest of the chapter