1use std::collections::VecDeque;
18use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
19use std::sync::{Arc, Condvar, Mutex, PoisonError};
20use std::time::{Duration, Instant};
21
22pub type Job = Box<dyn FnOnce() + Send + 'static>;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum Rejected {
28 Full,
30 Overloaded,
32 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#[derive(Clone, Debug)]
50pub struct OffloadConfig {
51 pub max_threads: usize,
54 pub max_queue: usize,
56 pub idle_timeout: Duration,
58 pub target: Duration,
61 pub interval: Duration,
63 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82pub struct OffloadStats {
83 pub submitted: u64,
85 pub completed: u64,
87 pub rejected_full: u64,
89 pub rejected_overload: u64,
91 pub queued: usize,
93 pub threads: usize,
95 pub last_sojourn_us: u64,
97 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 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 run_ns: AtomicU64,
125}
126
127pub fn default_threads() -> usize {
132 (simoom_sys::cpu::cpu_count() * 32).clamp(512, 1024)
133}
134
135#[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 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 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 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 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 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 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 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 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 #[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 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 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 #[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 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}