Skip to content
Select theme

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.

A periodic task usually starts life looking like this — work, then sleep, forever:

tick 0 at 0ms
tick 1 at 327ms
tick 2 at 654ms
tick 3 at 983ms
tick 4 at 1318ms

The 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:

tick 1 at 210ms
tick 2 at 402ms
tick 3 at 607ms
tick 4 at 806ms
tick 5 at 1009ms

Five 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 * n is Duration * u32, and t0 + ... is Instant + Duration — both are in the standard library, no crate needed.
  • The if next > now guard matters: when a task overruns its interval, next - now would be a negative duration, and subtracting one Instant from 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 204ms
tick 2: scheduled 400ms, handled 406ms
tick 3: scheduled 600ms, handled 603ms
tick 4: scheduled 800ms, handled 810ms
tick 5: scheduled 1000ms, handled 1008ms

Same 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.

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 202ms
tick 2: scheduled 400ms, handled 560ms
tick 3: scheduled 760ms, handled 920ms
tick 4: scheduled 1120ms, handled 1280ms

Read 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 Vec of 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” — tick skips 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 > now guard removed so overruns run back-to-back, is the honest shape. If lateness merely needs to be visible, the scheduled instant 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.