use std::marker::PhantomData; use std::sync::Arc; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError}; use std::thread::{self, JoinHandle, Scope}; use std::time::Duration; /// Executor shared by every worker of a pool. The `'static` bound required to /// move the executor into detached threads is expressed on the pool types /// instead of this trait so scoped pools can borrow their environment. pub trait ObjectTaskExecutor: Send + Sync { fn execute(&self, worker_index: usize, task: T) -> R; } enum ObjectWorkerMessage { Task(T), Shutdown, } #[derive(Debug)] pub enum ObjectWorkerSubmitError { QueueFull { worker_index: usize, task: T }, Disconnected { worker_index: usize, task: T }, } pub struct ObjectWorkerPool where T: Send + 'static, R: Send + 'static, E: ObjectTaskExecutor + 'static, { task_txs: Vec>>, result_rx: Receiver, workers: Vec>, next_worker_idx: usize, _executor: Arc, } impl ObjectWorkerPool where T: Send + 'static, R: Send + 'static, E: ObjectTaskExecutor + 'static, { pub fn new(worker_count: usize, queue_capacity: usize, executor: E) -> Result { if worker_count == 0 { return Err("ObjectWorkerPool requires at least one worker".to_string()); } if queue_capacity == 0 { return Err("ObjectWorkerPool requires queue_capacity > 0".to_string()); } let executor = Arc::new(executor); let (result_tx, result_rx) = mpsc::channel::(); let mut task_txs = Vec::with_capacity(worker_count); let mut workers = Vec::with_capacity(worker_count); for worker_index in 0..worker_count { let (task_tx, task_rx) = mpsc::sync_channel::>(queue_capacity); let result_tx = result_tx.clone(); let executor = Arc::clone(&executor); let handle = thread::Builder::new() .name(format!("object-validation-worker-{worker_index}")) .spawn(move || object_worker_loop(worker_index, task_rx, result_tx, executor)) .map_err(|e| format!("spawn object worker failed: {e}"))?; task_txs.push(task_tx); workers.push(handle); } Ok(Self { task_txs, result_rx, workers, next_worker_idx: 0, _executor: executor, }) } pub fn worker_count(&self) -> usize { self.task_txs.len() } pub fn next_worker_index(&self) -> usize { self.next_worker_idx } pub fn try_submit_round_robin(&mut self, task: T) -> Result> { let worker_index = self.next_worker_idx % self.task_txs.len(); match self.task_txs[worker_index].try_send(ObjectWorkerMessage::Task(task)) { Ok(()) => { self.next_worker_idx = (worker_index + 1) % self.task_txs.len(); Ok(worker_index) } Err(TrySendError::Full(ObjectWorkerMessage::Task(task))) => { Err(ObjectWorkerSubmitError::QueueFull { worker_index, task }) } Err(TrySendError::Disconnected(ObjectWorkerMessage::Task(task))) => { Err(ObjectWorkerSubmitError::Disconnected { worker_index, task }) } Err(TrySendError::Full(ObjectWorkerMessage::Shutdown)) | Err(TrySendError::Disconnected(ObjectWorkerMessage::Shutdown)) => { unreachable!("shutdown is never submitted via try_submit_round_robin") } } } pub fn recv_result_timeout(&self, timeout: Duration) -> Result, String> { match self.result_rx.recv_timeout(timeout) { Ok(result) => Ok(Some(result)), Err(RecvTimeoutError::Timeout) => Ok(None), Err(RecvTimeoutError::Disconnected) => { Err("object worker result channel disconnected".to_string()) } } } pub fn shutdown(mut self) -> Result<(), String> { self.shutdown_inner() } fn shutdown_inner(&mut self) -> Result<(), String> { if self.workers.is_empty() { return Ok(()); } for tx in &self.task_txs { tx.send(ObjectWorkerMessage::Shutdown) .map_err(|e| format!("send shutdown to object worker failed: {e}"))?; } let mut first_err = None; for handle in self.workers.drain(..) { if let Err(e) = handle.join() { if first_err.is_none() { first_err = Some(format!("join object worker failed: {e:?}")); } } } if let Some(err) = first_err { return Err(err); } Ok(()) } } impl Drop for ObjectWorkerPool where T: Send + 'static, R: Send + 'static, E: ObjectTaskExecutor + 'static, { fn drop(&mut self) { let _ = self.shutdown_inner(); } } fn object_worker_loop( worker_index: usize, task_rx: Receiver>, result_tx: mpsc::Sender, executor: Arc, ) where T: Send, R: Send, E: ObjectTaskExecutor, { loop { match task_rx.recv() { Ok(ObjectWorkerMessage::Task(task)) => { let result = executor.execute(worker_index, task); if result_tx.send(result).is_err() { break; } } Ok(ObjectWorkerMessage::Shutdown) | Err(_) => break, } } } /// Scoped variant of `ObjectWorkerPool`: workers are spawned on a /// `std::thread::Scope`, so tasks, results and the executor may borrow their /// environment (`'env`) instead of being `'static`. The pool never sends /// `Shutdown`; workers exit when every task sender is dropped, which happens /// when the pool itself is dropped ahead of the scope join. pub struct ScopedObjectWorkerPool<'scope, 'env, T, R, E> where T: Send + 'env, R: Send + 'env, E: ObjectTaskExecutor + 'env, { task_txs: Vec>>, result_rx: Receiver, next_worker_idx: usize, _executor: Arc, // Join handles are intentionally not stored: dropping a `ScopedJoinHandle` // detaches the worker and the enclosing scope joins it on exit, after the // dropped task senders have made every worker return. _marker: PhantomData<(&'scope (), &'env ())>, } impl<'scope, 'env, T, R, E> ScopedObjectWorkerPool<'scope, 'env, T, R, E> where T: Send + 'env, R: Send + 'env, E: ObjectTaskExecutor + 'env, { pub fn new( scope: &'scope Scope<'scope, 'env>, worker_count: usize, queue_capacity: usize, executor: E, ) -> Result { if worker_count == 0 { return Err("ScopedObjectWorkerPool requires at least one worker".to_string()); } if queue_capacity == 0 { return Err("ScopedObjectWorkerPool requires queue_capacity > 0".to_string()); } let executor = Arc::new(executor); let (result_tx, result_rx) = mpsc::channel::(); let mut task_txs = Vec::with_capacity(worker_count); for worker_index in 0..worker_count { let (task_tx, task_rx) = mpsc::sync_channel::>(queue_capacity); let result_tx = result_tx.clone(); let executor = Arc::clone(&executor); thread::Builder::new() .name(format!("object-validation-worker-{worker_index}")) .spawn_scoped(scope, move || { object_worker_loop(worker_index, task_rx, result_tx, executor) }) .map_err(|e| format!("spawn scoped object worker failed: {e}"))?; task_txs.push(task_tx); } Ok(Self { task_txs, result_rx, next_worker_idx: 0, _executor: executor, _marker: PhantomData, }) } pub fn worker_count(&self) -> usize { self.task_txs.len() } pub fn try_submit_round_robin(&mut self, task: T) -> Result> { let worker_index = self.next_worker_idx % self.task_txs.len(); match self.task_txs[worker_index].try_send(ObjectWorkerMessage::Task(task)) { Ok(()) => { self.next_worker_idx = (worker_index + 1) % self.task_txs.len(); Ok(worker_index) } Err(TrySendError::Full(ObjectWorkerMessage::Task(task))) => { Err(ObjectWorkerSubmitError::QueueFull { worker_index, task }) } Err(TrySendError::Disconnected(ObjectWorkerMessage::Task(task))) => { Err(ObjectWorkerSubmitError::Disconnected { worker_index, task }) } Err(TrySendError::Full(ObjectWorkerMessage::Shutdown)) | Err(TrySendError::Disconnected(ObjectWorkerMessage::Shutdown)) => { unreachable!("shutdown is never submitted via try_submit_round_robin") } } } pub fn recv_result_timeout(&self, timeout: Duration) -> Result, String> { match self.result_rx.recv_timeout(timeout) { Ok(result) => Ok(Some(result)), Err(RecvTimeoutError::Timeout) => Ok(None), Err(RecvTimeoutError::Disconnected) => { Err("scoped object worker result channel disconnected".to_string()) } } } } impl<'scope, 'env, T, R, E> Drop for ScopedObjectWorkerPool<'scope, 'env, T, R, E> where T: Send + 'env, R: Send + 'env, E: ObjectTaskExecutor + 'env, { fn drop(&mut self) { // Close every worker input queue so blocked `recv` calls return and // the scoped workers exit before the enclosing scope joins them. self.task_txs.clear(); } } #[cfg(test)] mod tests { use super::{ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Barrier}; use std::time::Duration; #[derive(Clone)] struct EchoExecutor; impl ObjectTaskExecutor for EchoExecutor { fn execute(&self, worker_index: usize, task: u32) -> (usize, u32) { (worker_index, task) } } #[test] fn object_worker_pool_rejects_invalid_config_and_shutdowns_explicitly() { let err = match ObjectWorkerPool::new(0, 1, EchoExecutor) { Ok(_) => panic!("zero workers should be rejected"), Err(err) => err, }; assert!(err.contains("at least one worker")); let err = match ObjectWorkerPool::new(1, 0, EchoExecutor) { Ok(_) => panic!("zero queue should be rejected"), Err(err) => err, }; assert!(err.contains("queue_capacity > 0")); let pool = ObjectWorkerPool::new(2, 1, EchoExecutor).expect("pool"); assert_eq!(pool.worker_count(), 2); assert_eq!(pool.next_worker_index(), 0); pool.shutdown().expect("shutdown"); } #[test] fn object_worker_pool_round_robin_submits_to_worker_queues() { let mut pool = ObjectWorkerPool::new(3, 4, EchoExecutor).expect("pool"); assert_eq!(pool.try_submit_round_robin(10).expect("submit 10"), 0); assert_eq!(pool.try_submit_round_robin(11).expect("submit 11"), 1); assert_eq!(pool.try_submit_round_robin(12).expect("submit 12"), 2); assert_eq!(pool.try_submit_round_robin(13).expect("submit 13"), 0); let mut results = Vec::new(); for _ in 0..4 { results.push( pool.recv_result_timeout(Duration::from_secs(1)) .expect("result channel") .expect("result"), ); } results.sort_by_key(|(_, task)| *task); assert_eq!(results, vec![(0, 10), (1, 11), (2, 12), (0, 13)]); } struct BlockingExecutor { barrier: Arc, started: Arc, } impl ObjectTaskExecutor for BlockingExecutor { fn execute(&self, _worker_index: usize, task: u32) -> u32 { self.started.store(true, Ordering::SeqCst); self.barrier.wait(); task } } #[test] fn object_worker_pool_reports_full_worker_queue_without_advancing_round_robin() { let barrier = Arc::new(Barrier::new(2)); let started = Arc::new(AtomicBool::new(false)); let mut pool = ObjectWorkerPool::new( 1, 1, BlockingExecutor { barrier: Arc::clone(&barrier), started: Arc::clone(&started), }, ) .expect("pool"); assert_eq!(pool.try_submit_round_robin(1).expect("first task"), 0); let deadline = std::time::Instant::now() + Duration::from_secs(1); while !started.load(Ordering::SeqCst) { assert!( std::time::Instant::now() < deadline, "worker did not start first task" ); std::thread::sleep(Duration::from_millis(1)); } assert_eq!(pool.try_submit_round_robin(2).expect("queued task"), 0); match pool.try_submit_round_robin(3) { Err(ObjectWorkerSubmitError::QueueFull { worker_index, task }) => { assert_eq!(worker_index, 0); assert_eq!(task, 3); } other => panic!("expected queue full, got {other:?}"), } assert_eq!(pool.next_worker_index(), 0); barrier.wait(); assert_eq!( pool.recv_result_timeout(Duration::from_secs(1)) .expect("result channel"), Some(1) ); barrier.wait(); assert_eq!( pool.recv_result_timeout(Duration::from_secs(1)) .expect("result channel"), Some(2) ); } struct BorrowingEchoExecutor<'a> { base: &'a u32, } impl<'a> ObjectTaskExecutor for BorrowingEchoExecutor<'a> { fn execute(&self, _worker_index: usize, task: u32) -> u32 { task + *self.base } } #[test] fn scoped_object_worker_pool_borrows_environment_and_processes_tasks() { let base = 100u32; std::thread::scope(|scope| { let mut pool = super::ScopedObjectWorkerPool::new( scope, 2, 2, BorrowingEchoExecutor { base: &base }, ) .expect("scoped pool"); assert_eq!(pool.worker_count(), 2); pool.try_submit_round_robin(1).expect("submit 1"); pool.try_submit_round_robin(2).expect("submit 2"); let mut results = Vec::new(); for _ in 0..2 { results.push( pool.recv_result_timeout(Duration::from_secs(1)) .expect("result channel") .expect("result"), ); } results.sort(); assert_eq!(results, vec![101, 102]); // Dropping the pool inside the scope closes the task queues; the // workers exit on their own and the scope join below must not hang. drop(pool); }); } #[test] fn scoped_object_worker_pool_reports_full_queue() { std::thread::scope(|scope| { let barrier = Arc::new(Barrier::new(2)); let started = Arc::new(AtomicBool::new(false)); let mut pool = super::ScopedObjectWorkerPool::new( scope, 1, 1, BlockingExecutor { barrier: Arc::clone(&barrier), started: Arc::clone(&started), }, ) .expect("scoped pool"); pool.try_submit_round_robin(1).expect("first task"); let deadline = std::time::Instant::now() + Duration::from_secs(1); while !started.load(Ordering::SeqCst) { assert!( std::time::Instant::now() < deadline, "scoped worker did not start first task" ); std::thread::sleep(Duration::from_millis(1)); } pool.try_submit_round_robin(2).expect("queued task"); match pool.try_submit_round_robin(3) { Err(ObjectWorkerSubmitError::QueueFull { worker_index, task }) => { assert_eq!(worker_index, 0); assert_eq!(task, 3); } other => panic!("expected queue full, got {other:?}"), } // Dropping the pool closes the task queues; releasing the barrier // afterwards lets the blocked worker finish task 1, observe the // closed result channel, and exit before the scope join. drop(pool); barrier.wait(); }); } }