Skip to main content

simoom_rt/
offload.rs

1//! A bounded, shared worker pool for blocking work (the M/M/c side of the hybrid design).
2//!
3//! - **Bounded queue.** [`OffloadPool::try_submit`] fails at once with [`Rejected::Full`]
4//!   when `max_queue` jobs are waiting; nothing grows without bound.
5//! - **Workers on demand.** A worker thread is started only when a job arrives and no worker is
6//!   idle, up to `max_threads`; idle workers exit after `idle_timeout`.
7//! - **CoDel admission.** Each job's sojourn time (from submission until a worker takes it) is
8//!   measured. When it stays above `target` for a whole `interval`, the pool reports overload
9//!   and refuses new jobs with [`Rejected::Overloaded`] until a job is taken faster than `target`
10//!   again (Nichols and Jacobson, "Controlling Queue Delay", 2012). A server answers those with a
11//!   fast 503 instead of queueing. The effective target is the larger of `target` and four times
12//!   the jobs' recent mean run time, so a pool of slow blocking jobs (100 ms calls to a database)
13//!   does not shed because a job waits a few run times behind a burst: only a queue that keeps
14//!   growing past what the workers can absorb is overload (K10: at the old one-run-time target,
15//!   500 clients of 100 ms jobs on 192 workers had nearly every request shed).
16
17use std::collections::VecDeque;
18use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
19use std::sync::{Arc, Condvar, Mutex, PoisonError};
20use std::time::{Duration, Instant};
21
22/// A unit of offloaded work.
23pub type Job = Box<dyn FnOnce() + Send + 'static>;
24
25/// Why [`OffloadPool::try_submit`] refused a job.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum Rejected {
28    /// `max_queue` jobs are already waiting.
29    Full,
30    /// Jobs have been waiting longer than the CoDel target for a whole interval.
31    Overloaded,
32    /// The pool is shutting down.
33    Stopped,
34}
35
36impl std::fmt::Display for Rejected {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.write_str(match self {
39            Rejected::Full => "offload queue full",
40            Rejected::Overloaded => "offload queue overloaded",
41            Rejected::Stopped => "offload pool stopped",
42        })
43    }
44}
45
46impl std::error::Error for Rejected {}
47
48/// Settings of an [`OffloadPool`].
49#[derive(Clone, Debug)]
50pub struct OffloadConfig {
51    /// Most worker threads (0 = [`default_threads`]: 32 per CPU, between 512 and 1024). Threads
52    /// start on demand and exit when idle, so a high cap costs nothing until jobs block.
53    pub max_threads: usize,
54    /// Most jobs waiting.
55    pub max_queue: usize,
56    /// How long an idle worker waits before it exits.
57    pub idle_timeout: Duration,
58    /// CoDel sojourn target (raised to four times the jobs' recent mean run time when that is
59    /// longer).
60    pub target: Duration,
61    /// CoDel interval.
62    pub interval: Duration,
63    /// Worker thread name.
64    pub name: String,
65}
66
67impl Default for OffloadConfig {
68    fn default() -> Self {
69        OffloadConfig {
70            max_threads: 0,
71            max_queue: 4096,
72            idle_timeout: Duration::from_secs(10),
73            target: Duration::from_millis(5),
74            interval: Duration::from_millis(100),
75            name: "simoom-offload".into(),
76        }
77    }
78}
79
80/// Counters of an [`OffloadPool`].
81#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82pub struct OffloadStats {
83    /// Jobs accepted.
84    pub submitted: u64,
85    /// Jobs finished.
86    pub completed: u64,
87    /// Jobs refused because the queue was full.
88    pub rejected_full: u64,
89    /// Jobs refused by CoDel.
90    pub rejected_overload: u64,
91    /// Jobs waiting now.
92    pub queued: usize,
93    /// Worker threads alive now.
94    pub threads: usize,
95    /// Sojourn time of the last job taken, in microseconds.
96    pub last_sojourn_us: u64,
97    /// Whether CoDel currently reports overload.
98    pub overloaded: bool,
99}
100
101struct State {
102    queue: VecDeque<(Instant, Job)>,
103    idle: usize,
104    threads: usize,
105    stop: bool,
106    first_above: Option<Instant>,
107}
108
109struct Inner {
110    cfg: OffloadConfig,
111    /// `cfg.max_threads`, or twice the CPU count resolved on the first job (reading the
112    /// cgroup quota costs file reads that start-up need not pay).
113    max_threads: std::sync::OnceLock<usize>,
114    state: Mutex<State>,
115    cv: Condvar,
116    overloaded: AtomicBool,
117    submitted: AtomicU64,
118    completed: AtomicU64,
119    rejected_full: AtomicU64,
120    rejected_overload: AtomicU64,
121    last_sojourn_us: AtomicU64,
122    alive: AtomicUsize,
123    /// Mean run time of recent jobs, ns (exponentially weighted, 1/8).
124    run_ns: AtomicU64,
125}
126
127/// The worker cap for `max_threads: 0`: 32 per CPU, at least 512 and at most 1024. Sized for
128/// blocking integration calls like a thread-per-request server's pool (a 100 ms handler needs
129/// about 10 threads per 100 requests per second; Cameleer's pool is 1024); workers start on
130/// demand and exit after `idle_timeout`, so the cap costs nothing until jobs block (K10).
131pub fn default_threads() -> usize {
132    (simoom_sys::cpu::cpu_count() * 32).clamp(512, 1024)
133}
134
135/// A bounded shared worker pool. Cheap to clone. See the [module docs](self).
136#[derive(Clone)]
137pub struct OffloadPool {
138    inner: Arc<Inner>,
139}
140
141impl std::fmt::Debug for OffloadPool {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.debug_struct("OffloadPool").field("stats", &self.stats()).finish()
144    }
145}
146
147impl OffloadPool {
148    /// Creates a pool. No thread starts until the first job.
149    pub fn new(cfg: OffloadConfig) -> OffloadPool {
150        let max_threads = std::sync::OnceLock::new();
151        if cfg.max_threads != 0 {
152            let _ = max_threads.set(cfg.max_threads);
153        }
154        OffloadPool {
155            inner: Arc::new(Inner {
156                max_threads,
157                state: Mutex::new(State { queue: VecDeque::new(), idle: 0, threads: 0, stop: false, first_above: None }),
158                cv: Condvar::new(),
159                overloaded: AtomicBool::new(false),
160                submitted: AtomicU64::new(0),
161                completed: AtomicU64::new(0),
162                rejected_full: AtomicU64::new(0),
163                rejected_overload: AtomicU64::new(0),
164                last_sojourn_us: AtomicU64::new(0),
165                alive: AtomicUsize::new(0),
166                run_ns: AtomicU64::new(0),
167                cfg,
168            }),
169        }
170    }
171
172    /// Queues a job, or refuses it at once.
173    pub fn try_submit(&self, job: Job) -> Result<(), Rejected> {
174        let inner = &self.inner;
175        if inner.overloaded.load(Ordering::Relaxed) {
176            inner.rejected_overload.fetch_add(1, Ordering::Relaxed);
177            return Err(Rejected::Overloaded);
178        }
179        let mut st = inner.state.lock().unwrap_or_else(PoisonError::into_inner);
180        if st.stop {
181            return Err(Rejected::Stopped);
182        }
183        if st.queue.len() >= inner.cfg.max_queue {
184            inner.rejected_full.fetch_add(1, Ordering::Relaxed);
185            return Err(Rejected::Full);
186        }
187        st.queue.push_back((Instant::now(), job));
188        inner.submitted.fetch_add(1, Ordering::Relaxed);
189        if st.idle > 0 {
190            drop(st);
191            inner.cv.notify_one();
192        } else if st.threads < *inner.max_threads.get_or_init(default_threads) {
193            st.threads += 1;
194            drop(st);
195            let inner2 = self.inner.clone();
196            let spawned = crate::spawn(std::thread::Builder::new().name(inner.cfg.name.clone()), Box::new(move || worker(inner2)));
197            if spawned.is_err() {
198                self.inner.state.lock().unwrap_or_else(PoisonError::into_inner).threads -= 1;
199            }
200        }
201        Ok(())
202    }
203
204    /// Counters.
205    pub fn stats(&self) -> OffloadStats {
206        let st = self.inner.state.lock().unwrap_or_else(PoisonError::into_inner);
207        OffloadStats {
208            submitted: self.inner.submitted.load(Ordering::Relaxed),
209            completed: self.inner.completed.load(Ordering::Relaxed),
210            rejected_full: self.inner.rejected_full.load(Ordering::Relaxed),
211            rejected_overload: self.inner.rejected_overload.load(Ordering::Relaxed),
212            queued: st.queue.len(),
213            threads: self.inner.alive.load(Ordering::Relaxed),
214            last_sojourn_us: self.inner.last_sojourn_us.load(Ordering::Relaxed),
215            overloaded: self.inner.overloaded.load(Ordering::Relaxed),
216        }
217    }
218
219    /// Stops taking jobs; workers finish what is queued and exit. Waits up to `deadline` for
220    /// them and returns whether all exited.
221    pub fn shutdown(&self, deadline: Duration) -> bool {
222        self.inner.state.lock().unwrap_or_else(PoisonError::into_inner).stop = true;
223        self.inner.cv.notify_all();
224        let end = simoom_sys::time::now_ns().saturating_add(deadline.as_nanos().min(u64::MAX as u128) as u64);
225        while self.inner.alive.load(Ordering::Acquire) > 0 {
226            if simoom_sys::time::now_ns() >= end {
227                return false;
228            }
229            std::thread::sleep(Duration::from_millis(1));
230        }
231        true
232    }
233}
234
235fn worker(inner: Arc<Inner>) {
236    inner.alive.fetch_add(1, Ordering::AcqRel);
237    let mut st = inner.state.lock().unwrap_or_else(PoisonError::into_inner);
238    loop {
239        if let Some((at, job)) = st.queue.pop_front() {
240            let now = Instant::now();
241            let sojourn = now - at;
242            inner.last_sojourn_us.store(sojourn.as_micros() as u64, Ordering::Relaxed);
243            // CoDel: overloaded once the sojourn stays above target for a whole interval. The
244            // target is at least four times the jobs' mean run time: waiting a few jobs' length
245            // is normal for a pool of slow jobs, not overload.
246            let target = inner.cfg.target.max(Duration::from_nanos(inner.run_ns.load(Ordering::Relaxed).saturating_mul(4)));
247            if sojourn > target {
248                match st.first_above {
249                    None => st.first_above = Some(now + inner.cfg.interval),
250                    Some(t) if now >= t => inner.overloaded.store(true, Ordering::Relaxed),
251                    _ => {}
252                }
253            } else {
254                st.first_above = None;
255                inner.overloaded.store(false, Ordering::Relaxed);
256            }
257            if st.queue.is_empty() {
258                st.first_above = None;
259                inner.overloaded.store(false, Ordering::Relaxed);
260            }
261            drop(st);
262            job();
263            let ran = now.elapsed().as_nanos().min(u64::MAX as u128) as u64;
264            let m = inner.run_ns.load(Ordering::Relaxed);
265            // The first job sets the mean (from zero, the first slow jobs looked like a queue
266            // far above target).
267            inner.run_ns.store(if m == 0 { ran.max(1) } else { m - m / 8 + ran / 8 }, Ordering::Relaxed);
268            inner.completed.fetch_add(1, Ordering::Relaxed);
269            st = inner.state.lock().unwrap_or_else(PoisonError::into_inner);
270            continue;
271        }
272        if st.stop {
273            break;
274        }
275        st.idle += 1;
276        let (g, to) = inner.cv.wait_timeout(st, inner.cfg.idle_timeout).unwrap_or_else(PoisonError::into_inner);
277        st = g;
278        st.idle -= 1;
279        if to.timed_out() && st.queue.is_empty() {
280            break;
281        }
282    }
283    st.threads -= 1;
284    drop(st);
285    inner.alive.fetch_sub(1, Ordering::AcqRel);
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use std::sync::mpsc;
292
293    #[test]
294    fn runs_jobs_and_starts_threads_on_demand() {
295        let p = OffloadPool::new(OffloadConfig { max_threads: 3, ..Default::default() });
296        assert_eq!(p.stats().threads, 0, "no thread before the first job");
297        let (tx, rx) = mpsc::channel();
298        for i in 0..50 {
299            let tx = tx.clone();
300            p.try_submit(Box::new(move || tx.send(i).unwrap())).unwrap();
301        }
302        let mut got: Vec<i32> = (0..50).map(|_| rx.recv_timeout(Duration::from_secs(5)).unwrap()).collect();
303        got.sort();
304        assert_eq!(got, (0..50).collect::<Vec<_>>());
305        assert!(p.stats().threads <= 3);
306        assert!(p.shutdown(Duration::from_secs(5)));
307        assert_eq!(p.try_submit(Box::new(|| {})), Err(Rejected::Stopped));
308    }
309
310    #[test]
311    fn bounded_queue_rejects_when_full() {
312        let p = OffloadPool::new(OffloadConfig { max_threads: 1, max_queue: 2, ..Default::default() });
313        let (gate_tx, gate_rx) = mpsc::channel::<()>();
314        let (started_tx, started_rx) = mpsc::channel();
315        p.try_submit(Box::new(move || {
316            started_tx.send(()).unwrap();
317            gate_rx.recv().unwrap();
318        }))
319        .unwrap();
320        started_rx.recv_timeout(Duration::from_secs(5)).unwrap();
321        p.try_submit(Box::new(|| {})).unwrap();
322        p.try_submit(Box::new(|| {})).unwrap();
323        assert_eq!(p.try_submit(Box::new(|| {})), Err(Rejected::Full));
324        assert_eq!(p.stats().rejected_full, 1);
325        gate_tx.send(()).unwrap();
326        assert!(p.shutdown(Duration::from_secs(5)));
327        assert_eq!(p.stats().completed, 3);
328    }
329
330    #[test]
331    fn codel_sheds_when_sojourn_stays_high() {
332        let p = OffloadPool::new(OffloadConfig {
333            max_threads: 1,
334            max_queue: 1000,
335            target: Duration::from_millis(1),
336            interval: Duration::from_millis(20),
337            ..Default::default()
338        });
339        // Slow jobs keep the queue's sojourn time above target.
340        for _ in 0..40 {
341            if p.try_submit(Box::new(|| std::thread::sleep(Duration::from_millis(5)))).is_err() {
342                break;
343            }
344        }
345        let start = Instant::now();
346        let mut shed = false;
347        while start.elapsed() < Duration::from_secs(5) {
348            if p.try_submit(Box::new(|| std::thread::sleep(Duration::from_millis(5)))) == Err(Rejected::Overloaded) {
349                shed = true;
350                break;
351            }
352            std::thread::sleep(Duration::from_millis(2));
353        }
354        assert!(shed, "CoDel should report overload: {:?}", p.stats());
355        // Once the queue drains, admission resumes.
356        let start = Instant::now();
357        while p.stats().overloaded && start.elapsed() < Duration::from_secs(10) {
358            std::thread::sleep(Duration::from_millis(5));
359        }
360        assert!(!p.stats().overloaded);
361        p.try_submit(Box::new(|| {})).unwrap();
362        p.shutdown(Duration::from_secs(10));
363    }
364
365    // U10: slow blocking jobs whose waits stay near their own run time are not overload.
366    #[test]
367    fn slow_jobs_waiting_one_job_length_are_not_shed() {
368        let p = OffloadPool::new(OffloadConfig { max_threads: 2, target: Duration::from_millis(1), interval: Duration::from_millis(10), ..Default::default() });
369        for _ in 0..6 {
370            for _ in 0..4 {
371                // two run at once, two wait about one run time (30 ms, far above the 1 ms target)
372                assert_eq!(p.try_submit(Box::new(|| std::thread::sleep(Duration::from_millis(30)))), Ok(()), "{:?}", p.stats());
373            }
374            std::thread::sleep(Duration::from_millis(80));
375        }
376        assert_eq!(p.stats().rejected_overload, 0);
377        assert!(p.shutdown(Duration::from_secs(5)));
378    }
379
380    #[test]
381    fn default_thread_cap_suits_blocking_calls() {
382        let n = default_threads();
383        assert!((512..=1024).contains(&n), "{n}");
384        let p = OffloadPool::new(OffloadConfig::default());
385        let (tx, rx) = mpsc::channel();
386        // 64 concurrent blocking calls all run at once with the default cap
387        let gate = Arc::new((Mutex::new(0usize), Condvar::new()));
388        for _ in 0..64 {
389            let (tx, gate) = (tx.clone(), gate.clone());
390            p.try_submit(Box::new(move || {
391                let (m, cv) = &*gate;
392                let mut g = m.lock().unwrap();
393                *g += 1;
394                cv.notify_all();
395                let _ = cv.wait_timeout_while(g, Duration::from_secs(10), |c| *c < 64).unwrap();
396                tx.send(()).unwrap();
397            }))
398            .unwrap();
399        }
400        for _ in 0..64 {
401            rx.recv_timeout(Duration::from_secs(10)).expect("all 64 run concurrently");
402        }
403        assert!(p.shutdown(Duration::from_secs(5)));
404    }
405
406    // K10: closed-loop clients of slow jobs, more of them than workers: a standing queue of a
407    // couple of run times, which used to be shed almost entirely, is served.
408    #[test]
409    fn more_clients_than_workers_of_slow_jobs_are_queued_not_shed() {
410        let p = OffloadPool::new(OffloadConfig { max_threads: 4, target: Duration::from_millis(1), interval: Duration::from_millis(10), ..Default::default() });
411        let (tx, rx) = mpsc::channel::<()>();
412        // 10 clients, each submitting its next 20 ms job when the last one finished.
413        let submit = |p: &OffloadPool, tx: &mpsc::Sender<()>| {
414            let tx = tx.clone();
415            p.try_submit(Box::new(move || {
416                std::thread::sleep(Duration::from_millis(20));
417                let _ = tx.send(());
418            }))
419        };
420        for _ in 0..10 {
421            submit(&p, &tx).unwrap();
422        }
423        for _ in 0..100 {
424            rx.recv_timeout(Duration::from_secs(5)).unwrap();
425            assert_eq!(submit(&p, &tx), Ok(()), "{:?}", p.stats());
426        }
427        assert_eq!(p.stats().rejected_overload, 0);
428        assert!(p.shutdown(Duration::from_secs(5)));
429    }
430
431    #[test]
432    fn idle_workers_exit() {
433        let p = OffloadPool::new(OffloadConfig { max_threads: 2, idle_timeout: Duration::from_millis(30), ..Default::default() });
434        let (tx, rx) = mpsc::channel();
435        p.try_submit(Box::new(move || tx.send(()).unwrap())).unwrap();
436        rx.recv_timeout(Duration::from_secs(5)).unwrap();
437        let start = Instant::now();
438        while p.stats().threads > 0 && start.elapsed() < Duration::from_secs(5) {
439            std::thread::sleep(Duration::from_millis(5));
440        }
441        assert_eq!(p.stats().threads, 0);
442    }
443}