Skip to main content

subms_timer_wheel/
lib.rs

1//! Single-level hashed timer wheel. O(1) schedule and cancel.
2//!
3//! The wheel has `N` buckets. A scheduled timer at `delay` ticks goes into
4//! bucket `(now + delay) % N` with a `rounds` counter of how many full
5//! revolutions it must sit out first. On `tick()`, the hand walks one bucket
6//! forward: timers with `rounds == 0` fire (their values are returned); the
7//! rest have `rounds` decremented. Cancel drops the id from the index and
8//! flags the entry; the flagged entry is reclaimed on the next visit to its
9//! bucket.
10//!
11//! Tradeoff vs the hierarchical wheel: with a single level, a long delay
12//! causes many no-op revolutions. For workloads with delays bounded by
13//! `N` ticks, the single level is optimal.
14//!
15//! Thread safety: `TimerWheel` is single-threaded. Every method takes
16//! `&mut self`, so one caller owns the wheel and there is no interior
17//! synchronisation to pay for. It is `Send` when `V: Send` and can be moved
18//! to a ticker thread; to arm timers from several threads at once, enable the
19//! `concurrent` feature or hand work to the ticker thread through a queue.
20//!
21//! ```
22//! use subms_timer_wheel::TimerWheel;
23//! let mut w: TimerWheel<&'static str> = TimerWheel::new(256);
24//! let id = w.schedule(5, "hello");
25//! for _ in 0..4 { assert!(w.tick().is_empty()); }
26//! assert_eq!(w.tick(), vec!["hello"]);
27//! let _ = id; // returned id can be used to cancel before firing
28//! ```
29//!
30//! Full writeup, design notes and measured benchmarks:
31//! <https://www.submillisecond.com/cookbook/recipes/subms-timer-wheel>
32
33use std::collections::HashMap;
34
35pub mod error;
36pub use error::TimerError;
37
38/// Ceiling on a timer's rounds counter. Held at `i32::MAX` rather than
39/// `u32::MAX` so the Java port, whose counter is a signed `int`, refuses
40/// exactly the same delays this one does.
41const MAX_ROUNDS: u64 = i32::MAX as u64;
42
43pub struct TimerWheel<V> {
44    slots: Vec<Slot<V>>,
45    mask: usize,
46    hand: usize,
47    next_id: u64,
48    /// Live timers only: an id is removed here the moment it is cancelled or
49    /// fired, so `pending()` never counts a timer the caller has retired.
50    id_to_slot: HashMap<u64, usize>,
51}
52
53struct Slot<V> {
54    entries: Vec<Entry<V>>,
55}
56
57struct Entry<V> {
58    id: u64,
59    rounds: u32,
60    value: V,
61    cancelled: bool,
62}
63
64impl<V> TimerWheel<V> {
65    /// `num_slots` rounded up to a power of two.
66    pub fn new(num_slots: usize) -> Self {
67        let n = num_slots.max(2).next_power_of_two();
68        let mut slots = Vec::with_capacity(n);
69        for _ in 0..n {
70            slots.push(Slot {
71                entries: Vec::new(),
72            });
73        }
74        Self {
75            slots,
76            mask: n - 1,
77            hand: 0,
78            next_id: 1,
79            id_to_slot: HashMap::new(),
80        }
81    }
82
83    pub fn num_slots(&self) -> usize {
84        self.slots.len()
85    }
86
87    /// Largest delay the wheel can represent: a timer can sit out at most
88    /// `i32::MAX` revolutions of `N` slots. Held at the signed bound rather
89    /// than `u32::MAX` so the Java port refuses exactly the same delays.
90    pub fn max_delay(&self) -> u64 {
91        self.slots.len() as u64 * MAX_ROUNDS
92    }
93
94    /// Number of live (scheduled, not yet fired or cancelled) timers. A
95    /// correct wheel returns this to 0 once every scheduled timer has fired;
96    /// a leak would let it climb without bound.
97    pub fn pending(&self) -> usize {
98        self.id_to_slot.len()
99    }
100
101    pub fn is_empty(&self) -> bool {
102        self.id_to_slot.is_empty()
103    }
104
105    /// Entries physically held in one bucket, including cancelled ones not
106    /// yet swept. Reading the spread across buckets is how you catch a
107    /// workload whose delays all collide on one slot.
108    pub fn slot_len(&self, slot: usize) -> usize {
109        self.slots.get(slot).map_or(0, |s| s.entries.len())
110    }
111
112    /// Schedule `value` to fire in `delay_ticks`. Returns an id for cancel.
113    ///
114    /// A delay of 0 fires on the next tick, matching Netty's treatment of a
115    /// deadline already in the past. A delay past [`Self::max_delay`] is
116    /// clamped; use [`Self::try_schedule`] to have it refused instead.
117    pub fn schedule(&mut self, delay_ticks: usize, value: V) -> u64 {
118        let d = self.clamp_delay(delay_ticks);
119        let id = self.next_id;
120        self.next_id += 1;
121        self.insert(id, d, value);
122        id
123    }
124
125    /// Schedule `value`, refusing a delay the wheel cannot represent.
126    pub fn try_schedule(&mut self, delay_ticks: usize, value: V) -> Result<u64, TimerError> {
127        let max = self.max_delay();
128        if delay_ticks as u64 > max {
129            return Err(TimerError::DelayTooLong {
130                delay: delay_ticks as u64,
131                max,
132            });
133        }
134        Ok(self.schedule(delay_ticks, value))
135    }
136
137    /// Mark a scheduled timer cancelled. Returns `true` if it was pending.
138    pub fn cancel(&mut self, id: u64) -> bool {
139        let Some(slot) = self.id_to_slot.remove(&id) else {
140            return false;
141        };
142        // The entry keeps its seat until the hand reaches this bucket; only
143        // the index is updated eagerly, which is what keeps `pending()` exact.
144        for e in &mut self.slots[slot].entries {
145            if e.id == id && !e.cancelled {
146                e.cancelled = true;
147                return true;
148            }
149        }
150        false
151    }
152
153    /// Move a pending timer to a new delay, keeping its id. Returns `false`
154    /// if the id is not pending (already fired, already cancelled, unknown).
155    ///
156    /// Unlike cancel this removes the entry eagerly - leaving a flagged
157    /// entry behind would let one id sit in two buckets at once.
158    pub fn reschedule(&mut self, id: u64, delay_ticks: usize) -> bool {
159        let Some(slot) = self.id_to_slot.remove(&id) else {
160            return false;
161        };
162        let Some(pos) = self.slots[slot]
163            .entries
164            .iter()
165            .position(|e| e.id == id && !e.cancelled)
166        else {
167            return false;
168        };
169        let entry = self.slots[slot].entries.swap_remove(pos);
170        let d = self.clamp_delay(delay_ticks);
171        self.insert(id, d, entry.value);
172        true
173    }
174
175    /// Advance the hand one tick. Returns the values of all timers that
176    /// fired (rounds was 0 and not cancelled). Cancelled timers are dropped
177    /// silently. Other timers have their `rounds` decremented.
178    pub fn tick(&mut self) -> Vec<V> {
179        self.hand = (self.hand + 1) & self.mask;
180        let slot = self.hand;
181        let mut fired = Vec::new();
182        let entries = std::mem::take(&mut self.slots[slot].entries);
183        let mut survivors = Vec::new();
184        for mut e in entries {
185            if e.cancelled {
186                continue;
187            }
188            if e.rounds == 0 {
189                self.id_to_slot.remove(&e.id);
190                fired.push(e.value);
191            } else {
192                e.rounds -= 1;
193                survivors.push(e);
194            }
195        }
196        self.slots[slot].entries = survivors;
197        fired
198    }
199
200    /// Advance `ticks` ticks and return everything that fired across them,
201    /// in tick order. A ticker thread that woke late catches up here rather
202    /// than firing a whole revolution's timers on one bucket.
203    pub fn advance(&mut self, ticks: usize) -> Vec<V> {
204        let mut fired = Vec::new();
205        for _ in 0..ticks {
206            fired.append(&mut self.tick());
207        }
208        fired
209    }
210
211    /// Remove every pending timer and return its value. The hand stays where
212    /// it is. This is the shutdown path: Netty's `HashedWheelTimer::stop`
213    /// hands back the timeouts it never got to run, and so does this.
214    pub fn drain(&mut self) -> Vec<V> {
215        let mut out = Vec::with_capacity(self.id_to_slot.len());
216        for slot in &mut self.slots {
217            for e in std::mem::take(&mut slot.entries) {
218                if !e.cancelled {
219                    out.push(e.value);
220                }
221            }
222        }
223        self.id_to_slot.clear();
224        out
225    }
226
227    /// Drop every pending timer and reset the hand. Ids already handed out
228    /// are never reused, so a late `cancel` on a cleared timer returns
229    /// `false` rather than hitting an unrelated timer.
230    pub fn clear(&mut self) {
231        for slot in &mut self.slots {
232            slot.entries.clear();
233        }
234        self.id_to_slot.clear();
235        self.hand = 0;
236    }
237
238    fn clamp_delay(&self, delay_ticks: usize) -> usize {
239        let max = self.max_delay().min(usize::MAX as u64) as usize;
240        delay_ticks.clamp(1, max)
241    }
242
243    fn insert(&mut self, id: u64, delay: usize, value: V) {
244        let n = self.slots.len();
245        let slot = self.hand.wrapping_add(delay) & self.mask;
246        // rounds = ceil(d/N) - 1, not floor(d/N). They agree everywhere except
247        // when d is an exact multiple of N, where the timer lands back on the
248        // bucket the hand has just left and waits a full revolution for the
249        // revisit. Charging a rounds counter for that revolution as well fires
250        // the timer a lap late.
251        let rounds = (delay.div_ceil(n) - 1) as u32;
252        self.slots[slot].entries.push(Entry {
253            id,
254            rounds,
255            value,
256            cancelled: false,
257        });
258        self.id_to_slot.insert(id, slot);
259    }
260}
261
262#[cfg(feature = "harness")]
263pub mod recipe;
264
265// Opt-in feature modules. Each is independent of the base wheel and
266// gated by its own Cargo feature; `cargo add subms-timer-wheel` alone
267// keeps the base zero-dep + std-only shape.
268#[cfg(any(
269    feature = "hierarchical",
270    feature = "concurrent",
271    feature = "deadline-scheduler",
272    feature = "cron",
273    feature = "metrics",
274))]
275pub mod features;
276
277#[cfg(feature = "concurrent")]
278pub use features::concurrent::ConcurrentTimerWheel;
279#[cfg(feature = "cron")]
280pub use features::cron::{CronError, CronSchedule, CronScheduler};
281#[cfg(feature = "deadline-scheduler")]
282pub use features::deadline_scheduler::{Clock, DeadlineScheduler, MonotonicClock, TestClock};
283#[cfg(feature = "hierarchical")]
284pub use features::hierarchical::HierarchicalTimerWheel;
285#[cfg(feature = "metrics")]
286pub use features::metrics::{MeteredTimerWheel, TimerMetrics};
287
288#[cfg(test)]
289#[path = "wheel_tests.rs"]
290mod wheel_tests;
291
292#[cfg(test)]
293#[path = "sample_app_tests.rs"]
294mod sample_app_tests;