dtact_util/timer/native.rs
1//! Native timer backend: a hashed timing wheel (Varghese & Lauck), the
2//! standard O(1)-amortized-insert/fire structure for high-churn timer
3//! workloads — single global heap-based timers do not belong in this
4//! codebase.
5//!
6//! Layout: `WHEEL_SIZE` (256) buckets, each covering one `TICK` (1ms) of
7//! wall-clock time, so one full rotation spans `WHEEL_SIZE * TICK` = 256ms.
8//! A timer whose deadline falls further out than one rotation is placed in
9//! its target slot immediately with a `rounds` counter set to how many full
10//! rotations must elapse before it's live; each time the wheel revisits that
11//! slot it decrements `rounds` until it reaches zero, at which point the
12//! timer actually fires. This is the classic single-level hashed wheel with
13//! implicit "cascading via rounds" instead of a multi-tier hierarchy —
14//! O(1) insert, O(1) amortized fire, bounded per-tick work.
15//!
16//! **Zero-lock hot path.** Each bucket is a [`crate::lockfree::MpmcStack`]
17//! (lock-free Treiber stack) instead of a `Mutex<Vec<_>>` — 256-way
18//! sharding *and* no OS mutex/futex on either the insert or the per-tick
19//! drain. Per-timer completion state is plain atomics
20//! (`AtomicBool`/`AtomicI8` for the rounds counter) plus an
21//! [`crate::lockfree::AtomicWakerSlot`] instead of `Mutex<Option<Waker>>`.
22//! The worker thread idles via `thread::park()`/`Thread::unpark()`
23//! (a futex under the hood, not a `Condvar`) rather than blocking on a
24//! `Mutex`-guarded `Condvar::wait`.
25//!
26//! Cancellation is lazy: a dropped [`DtactSleep`] simply lets its
27//! `Arc<Node>` refcount hit zero without being scrubbed out of its bucket;
28//! the wheel drops the last reference silently when its round comes up
29//! (waking nobody, since nothing is listening), so no O(n) linear removal
30//! is ever needed on the hot cancel path.
31
32use crate::lockfree::{AtomicWakerSlot, MpmcStack};
33use std::future::Future;
34use std::pin::Pin;
35use std::sync::atomic::{AtomicI32, AtomicU64, AtomicUsize, Ordering};
36use std::sync::{Arc, OnceLock};
37use std::task::{Context, Poll};
38use std::thread::Thread;
39use std::time::{Duration, Instant};
40
41const WHEEL_BITS: u32 = 8;
42const WHEEL_SIZE: usize = 1 << WHEEL_BITS; // 256 slots
43const WHEEL_MASK: u64 = (WHEEL_SIZE as u64) - 1;
44const TICK: Duration = Duration::from_millis(1);
45/// Sentinel meaning "not yet fired" for `SleepState::done`, stored as a
46/// plain atomic rather than a bool so the fire path is a single
47/// store+wake with no separate flag/result pair to keep in sync.
48const PENDING: i32 = 0;
49const DONE: i32 = 1;
50
51#[repr(align(64))]
52struct SleepState {
53 /// PENDING (0) or DONE (1) — see constants above.
54 status: AtomicI32,
55 waker: AtomicWakerSlot,
56}
57
58#[repr(align(64))]
59struct Node {
60 state: Arc<SleepState>,
61 /// Remaining full rotations before this node is eligible to fire.
62 /// Decremented with `Relaxed` `fetch_sub` — only ever touched by the
63 /// single wheel worker thread, so no synchronization is needed beyond
64 /// what already orders bucket drains (each tick fully drains and
65 /// rebuilds its bucket via `MpmcStack::drain_all`/`push`).
66 rounds: AtomicUsize,
67}
68
69#[repr(align(64))]
70struct Wheel {
71 buckets: Box<[MpmcStack<Arc<Node>>]>,
72 /// Absolute tick counter, advanced only by the single worker thread.
73 current_tick: AtomicU64,
74 pending: AtomicUsize,
75 start: Instant,
76 worker: OnceLock<Thread>,
77}
78
79static WHEEL: OnceLock<Arc<Wheel>> = OnceLock::new();
80
81fn wheel() -> &'static Arc<Wheel> {
82 WHEEL.get_or_init(|| {
83 let mut buckets = Vec::with_capacity(WHEEL_SIZE);
84 for _ in 0..WHEEL_SIZE {
85 buckets.push(MpmcStack::new());
86 }
87 let w = Arc::new(Wheel {
88 buckets: buckets.into_boxed_slice(),
89 current_tick: AtomicU64::new(0),
90 pending: AtomicUsize::new(0),
91 start: Instant::now(),
92 worker: OnceLock::new(),
93 });
94 let worker_wheel = Arc::clone(&w);
95 let handle = std::thread::Builder::new()
96 .name("dtact-timer-wheel".into())
97 .spawn(move || worker_loop(&worker_wheel))
98 .expect("failed to spawn dtact-timer-wheel worker thread");
99 let _ = w.worker.set(handle.thread().clone());
100 w
101 })
102}
103
104fn worker_loop(w: &Arc<Wheel>) {
105 loop {
106 // Idle-park: no pending timers anywhere, avoid ticking for nothing.
107 // `park_timeout` also guards against a lost wakeup race between
108 // the emptiness check and the park call — worst case we wake up
109 // to nothing pending and loop back around within 50ms.
110 if w.pending.load(Ordering::Acquire) == 0 {
111 std::thread::park_timeout(Duration::from_millis(50));
112 continue;
113 }
114
115 let tick = w.current_tick.load(Ordering::Relaxed);
116 let tick_deadline = w.start + TICK * (tick as u32 + 1);
117 let now = Instant::now();
118 if tick_deadline > now {
119 std::thread::park_timeout(tick_deadline - now);
120 // Re-loop regardless of whether we were woken early by a new
121 // registration or by the timeout — the top-of-loop deadline
122 // check below re-derives whether it's actually time to tick.
123 if Instant::now() < tick_deadline {
124 continue;
125 }
126 }
127
128 let tick = w.current_tick.fetch_add(1, Ordering::Relaxed) + 1;
129 let slot = (tick & WHEEL_MASK) as usize;
130
131 let bucket = &w.buckets[slot];
132 if bucket.is_empty() {
133 continue;
134 }
135 let batch = bucket.drain_all();
136 let mut fired = 0usize;
137 for node in batch {
138 let rounds = node.rounds.load(Ordering::Relaxed);
139 if rounds == 0 {
140 node.state.status.store(DONE, Ordering::Release);
141 node.state.waker.take_and_wake();
142 fired += 1;
143 } else {
144 node.rounds.fetch_sub(1, Ordering::Relaxed);
145 bucket.push(node);
146 }
147 }
148 if fired > 0 {
149 w.pending.fetch_sub(fired, Ordering::AcqRel);
150 }
151 }
152}
153
154fn register(deadline: Instant) -> Arc<SleepState> {
155 let now = Instant::now();
156
157 // A deadline that's already passed (including `Duration::ZERO`
158 // sleeps) must fire immediately rather than going through the wheel:
159 // inserting into the *current* tick's bucket would place it in a slot
160 // the worker just finished draining, which then wouldn't be revisited
161 // for a full rotation (`WHEEL_SIZE` ticks, ~256ms) — a "zero-duration"
162 // sleep would then take up to a quarter-second instead of resolving
163 // on the very next poll. Short-circuiting here also avoids waking the
164 // wheel worker thread at all for a no-op sleep.
165 if deadline <= now {
166 return Arc::new(SleepState {
167 status: AtomicI32::new(DONE),
168 waker: AtomicWakerSlot::new(),
169 });
170 }
171
172 let w = wheel();
173 let state = Arc::new(SleepState {
174 status: AtomicI32::new(PENDING),
175 waker: AtomicWakerSlot::new(),
176 });
177
178 let remaining = deadline - now;
179 let ticks_from_now = remaining.as_nanos().div_ceil(TICK.as_nanos()) as u64;
180
181 let current_tick = w.current_tick.load(Ordering::Relaxed);
182 let target_tick = current_tick + ticks_from_now;
183 let slot = (target_tick & WHEEL_MASK) as usize;
184 let rounds = (ticks_from_now >> WHEEL_BITS) as usize;
185
186 let node = Arc::new(Node {
187 state: Arc::clone(&state),
188 rounds: AtomicUsize::new(rounds),
189 });
190
191 w.buckets[slot].push(node);
192 w.pending.fetch_add(1, Ordering::AcqRel);
193 if let Some(t) = w.worker.get() {
194 t.unpark();
195 }
196
197 state
198}
199
200/// A future that completes once, after the given [`Duration`] has elapsed.
201pub struct DtactSleep {
202 state: Arc<SleepState>,
203}
204
205impl DtactSleep {
206 /// Sleep for `duration` from now.
207 #[must_use]
208 pub fn new(duration: Duration) -> Self {
209 Self::until(Instant::now() + duration)
210 }
211
212 /// Sleep until the given `deadline` (already-elapsed deadlines
213 /// resolve on the very next wheel tick).
214 #[must_use]
215 pub fn until(deadline: Instant) -> Self {
216 Self {
217 state: register(deadline),
218 }
219 }
220}
221
222impl Future for DtactSleep {
223 type Output = ();
224
225 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
226 if self.state.status.load(Ordering::Acquire) == DONE {
227 return Poll::Ready(());
228 }
229 self.state.waker.register(cx.waker());
230 // Re-check after installing the waker to close the race where the
231 // wheel fired between the initial load and the waker install.
232 if self.state.status.load(Ordering::Acquire) == DONE {
233 return Poll::Ready(());
234 }
235 Poll::Pending
236 }
237}
238
239/// Convenience free function mirroring `tokio::time::sleep`.
240#[must_use]
241pub fn sleep(duration: Duration) -> DtactSleep {
242 DtactSleep::new(duration)
243}
244
245/// Configures how [`DtactInterval::tick`] catches up after missing one
246/// or more ticks.
247///
248/// A tick is "missed" when the interval isn't polled promptly enough —
249/// e.g. the caller was busy doing other work. Mirrors
250/// `tokio::time::MissedTickBehavior`.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
252pub enum MissedTickBehavior {
253 /// Fire every missed tick back-to-back with no delay between them
254 /// until caught up, then resume the original cadence. `next` always
255 /// advances by exactly one `period` per `tick()` call, so a caller
256 /// that fell behind sees a burst of immediately-ready `tick()`
257 /// calls rather than any ticks being silently dropped. The default,
258 /// matching `tokio::time::MissedTickBehavior`'s own default.
259 #[default]
260 Burst,
261 /// Skip every missed tick: the next scheduled time jumps straight to
262 /// the next period boundary that's still in the future, rather than
263 /// firing once for each one that was missed.
264 Skip,
265 /// Reset the schedule relative to when the late tick actually fired,
266 /// not the original cadence — the next tick is `period` after *now*,
267 /// not after the missed tick's originally-scheduled time.
268 Delay,
269}
270
271/// A repeating timer. `tick()` is a plain async method mirroring
272/// `tokio::time::Interval::tick`.
273pub struct DtactInterval {
274 period: Duration,
275 next: Instant,
276 missed_tick_behavior: MissedTickBehavior,
277}
278
279impl DtactInterval {
280 /// Build an interval firing every `period`, starting one `period`
281 /// from now, with the default [`MissedTickBehavior::Burst`] catch-up
282 /// policy (change it via [`Self::set_missed_tick_behavior`]).
283 ///
284 /// # Panics
285 ///
286 /// Panics if `period` is zero — a zero-length period has no sensible
287 /// tick rate and almost always indicates a caller bug (e.g. an
288 /// unintended default `Duration::ZERO`).
289 #[must_use]
290 pub fn new(period: Duration) -> Self {
291 assert!(
292 period > Duration::ZERO,
293 "dtact-timer: interval period must be > 0"
294 );
295 Self {
296 period,
297 next: Instant::now() + period,
298 missed_tick_behavior: MissedTickBehavior::default(),
299 }
300 }
301
302 /// The current missed-tick catch-up policy.
303 #[must_use]
304 pub const fn missed_tick_behavior(&self) -> MissedTickBehavior {
305 self.missed_tick_behavior
306 }
307
308 /// Change the missed-tick catch-up policy. Takes effect starting
309 /// from the next [`Self::tick`] call — doesn't retroactively change
310 /// how the interval caught up (or didn't) from any prior lateness.
311 pub const fn set_missed_tick_behavior(&mut self, behavior: MissedTickBehavior) {
312 self.missed_tick_behavior = behavior;
313 }
314
315 /// Wait for the next tick, returning the `Instant` it fired at.
316 pub async fn tick(&mut self) -> Instant {
317 DtactSleep::until(self.next).await;
318 let fired_at = self.next;
319 match self.missed_tick_behavior {
320 MissedTickBehavior::Burst => {
321 self.next += self.period;
322 }
323 MissedTickBehavior::Skip => {
324 // Advance by whole periods to the next boundary still in
325 // the future, rather than firing once per missed tick.
326 let now = Instant::now();
327 let mut next = self.next + self.period;
328 while next <= now {
329 next += self.period;
330 }
331 self.next = next;
332 }
333 MissedTickBehavior::Delay => {
334 self.next = Instant::now() + self.period;
335 }
336 }
337 fired_at
338 }
339}
340
341/// Convenience free function mirroring `tokio::time::interval`.
342#[must_use]
343pub fn interval(period: Duration) -> DtactInterval {
344 DtactInterval::new(period)
345}
346
347/// Error returned by [`DtactTimeout`] when the wrapped future does not
348/// complete before the deadline.
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350pub struct TimeoutError;
351
352impl std::fmt::Display for TimeoutError {
353 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354 write!(
355 f,
356 "dtact-timer: deadline elapsed before the future completed"
357 )
358 }
359}
360
361impl std::error::Error for TimeoutError {}
362
363/// Wraps a future with a deadline: resolves to `Ok(F::Output)` if the inner
364/// future completes first, or `Err(TimeoutError)` if the deadline elapses
365/// first.
366pub struct DtactTimeout<F> {
367 inner: Pin<Box<F>>,
368 sleep: DtactSleep,
369}
370
371impl<F> DtactTimeout<F> {
372 /// Wrap `inner`, racing it against a `duration`-long deadline.
373 pub fn new(duration: Duration, inner: F) -> Self {
374 Self {
375 inner: Box::pin(inner),
376 sleep: DtactSleep::new(duration),
377 }
378 }
379}
380
381impl<F: Future> Future for DtactTimeout<F> {
382 type Output = Result<F::Output, TimeoutError>;
383
384 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
385 let this = self.get_mut();
386 if let Poll::Ready(v) = this.inner.as_mut().poll(cx) {
387 return Poll::Ready(Ok(v));
388 }
389 match Pin::new(&mut this.sleep).poll(cx) {
390 Poll::Ready(()) => Poll::Ready(Err(TimeoutError)),
391 Poll::Pending => Poll::Pending,
392 }
393 }
394}
395
396/// Convenience free function mirroring `tokio::time::timeout`.
397pub fn timeout<F: Future>(duration: Duration, fut: F) -> DtactTimeout<F> {
398 DtactTimeout::new(duration, fut)
399}