Skip to main content

ntex_rt/
pool.rs

1//! A thread pool for blocking operations.
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering, fence};
4use std::task::{Context, Poll};
5use std::time::{Duration, Instant};
6use std::{any::Any, fmt, future::Future, panic, pin::Pin, thread};
7
8use crossbeam_channel::{Receiver, Select, Sender, TrySendError, bounded, unbounded};
9
10/// Submits blocking work and returns a future for its result.
11///
12/// If a system is running, work is submitted to its blocking thread pool.
13/// Otherwise, the closure runs immediately on the current thread.
14/// Panics from the closure are captured and reported as [`BlockingError`]
15/// through the returned future in either case.
16///
17/// Dropping the returned future prevents queued work from starting, but cannot
18/// interrupt work that is already running. Call [`BlockingResult::detach`] to
19/// let queued work continue even if its result is no longer needed.
20pub fn spawn_blocking<F, R>(f: F) -> BlockingResult<R>
21where
22    F: FnOnce() -> R + Send + 'static,
23    R: Send + 'static,
24{
25    if let Some(sys) = crate::System::try_current() {
26        sys.spawn_blocking(f)
27    } else {
28        ThreadPool::execute_inplace(f)
29    }
30}
31
32/// Error returned when blocking work cannot produce a result.
33///
34/// This can occur if the task is canceled, panics, or no worker thread
35/// can be started.
36#[derive(Copy, Clone, Debug, PartialEq, Eq)]
37pub struct BlockingError;
38
39impl std::error::Error for BlockingError {}
40
41impl fmt::Display for BlockingError {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        "Blocking task failed or was canceled".fmt(f)
44    }
45}
46
47/// Future resolving to the result of blocking work.
48///
49/// Resolves to `Ok(output)` or [`BlockingError`] if the work cannot produce a
50/// result, including a closure panic. Dropping this value cancels work that has
51/// not been picked up by a worker; it does not interrupt running work. Use
52/// [`detach`](Self::detach) to discard the result without canceling queued work.
53#[derive(Debug)]
54pub struct BlockingResult<T> {
55    rx: oneshot::AsyncReceiver<Result<T, Box<dyn Any + Send>>>,
56    detached: Option<Arc<AtomicBool>>,
57}
58
59impl<T> BlockingResult<T> {
60    /// Detaches the task so it can continue without awaiting its result.
61    ///
62    /// Does not require a running runtime.
63    pub fn detach(self) {
64        if let Some(detached) = &self.detached {
65            // published by dropping the receiver
66            detached.store(true, Ordering::Relaxed);
67        }
68    }
69}
70
71/// Snapshot of [`ThreadPool`] metrics.
72///
73/// Durations and task counts are totals since the pool was created. Rates
74/// and averages over an interval are computed from the deltas of two
75/// snapshots, e.g. the average queue wait is the `wait_duration` delta
76/// divided by the `tasks` delta.
77#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
78#[non_exhaustive]
79pub struct PoolMetrics {
80    /// Maximum number of worker threads.
81    pub thread_limit: usize,
82    /// Number of worker threads, busy or idle.
83    pub threads: usize,
84    /// Number of tasks being executed.
85    pub active_tasks: usize,
86    /// Number of tasks waiting for a worker thread.
87    pub queue_depth: usize,
88    /// Number of tasks started.
89    pub tasks: u64,
90    /// Total time started tasks waited for a worker thread.
91    pub wait_duration: Duration,
92    /// Total time worker threads spent executing tasks.
93    pub busy_duration: Duration,
94}
95
96#[derive(Debug, Default)]
97struct PoolStats {
98    active: AtomicUsize,
99    tasks: AtomicU64,
100    // nanoseconds
101    wait: AtomicU64,
102    busy: AtomicU64,
103}
104
105fn as_nanos(d: Duration) -> u64 {
106    u64::try_from(d.as_nanos()).unwrap_or(u64::MAX)
107}
108
109type BoxedDispatchable = Box<dyn Dispatchable + Send>;
110
111pub(crate) trait Dispatchable: Send + 'static {
112    fn run(self: Box<Self>);
113}
114
115impl<F> Dispatchable for F
116where
117    F: FnOnce() + Send + 'static,
118{
119    fn run(self: Box<Self>) {
120        (*self)();
121    }
122}
123
124/// Reserved worker slot, released on drop.
125struct CounterGuard(Arc<AtomicUsize>);
126
127impl CounterGuard {
128    fn reserve(counter: &Arc<AtomicUsize>, limit: usize) -> Option<(Self, usize)> {
129        counter
130            .try_update(Ordering::AcqRel, Ordering::Acquire, |cnt| {
131                (cnt < limit).then_some(cnt + 1)
132            })
133            .ok()
134            .map(|cnt| (CounterGuard(counter.clone()), cnt))
135    }
136}
137
138impl Drop for CounterGuard {
139    fn drop(&mut self) {
140        self.0.fetch_sub(1, Ordering::AcqRel);
141    }
142}
143
144/// Runs a task, keeping the worker alive if result delivery or disposal panics.
145fn run_task(f: BoxedDispatchable) {
146    if let Err(payload) = panic::catch_unwind(panic::AssertUnwindSafe(|| f.run())) {
147        log::error!("Blocking pool task panicked during completion");
148        // the payload destructor may panic as well
149        std::mem::forget(payload);
150    }
151}
152
153fn worker(
154    receiver_high_prio: Receiver<BoxedDispatchable>,
155    receiver_low_prio: Receiver<BoxedDispatchable>,
156    guard: CounterGuard,
157    thread_limit: usize,
158    timeout: Duration,
159) -> impl FnOnce() {
160    move || {
161        let mut guard = guard;
162        let mut sel = Select::new_biased();
163        sel.recv(&receiver_high_prio);
164        sel.recv(&receiver_low_prio);
165        loop {
166            let task = match sel.select_timeout(timeout) {
167                Ok(op) if op.index() == 0 => op.recv(&receiver_high_prio),
168                Ok(op) => op.recv(&receiver_low_prio),
169                Err(_) => {
170                    // release the slot, then pick up work queued meanwhile,
171                    // pairs with the fence in `ThreadPool::execute`
172                    let counter = guard.0.clone();
173                    drop(guard);
174                    fence(Ordering::SeqCst);
175                    if receiver_high_prio.is_empty() {
176                        return;
177                    }
178                    match CounterGuard::reserve(&counter, thread_limit) {
179                        Some((g, _)) => guard = g,
180                        None => return,
181                    }
182                    continue;
183                }
184            };
185            if let Ok(f) = task {
186                run_task(f);
187            } else {
188                // the pool is dropped and the channels stay selectable,
189                // run the queued work and exit
190                while let Ok(f) = receiver_high_prio.try_recv() {
191                    run_task(f);
192                }
193                return;
194            }
195        }
196    }
197}
198
199/// A thread pool for executing blocking operations.
200///
201/// The number of worker threads scales dynamically with load, but will
202/// never exceed the `thread_limit` parameter. When all worker threads are
203/// busy, tasks enter an unbounded queue until a worker becomes available.
204/// Clones share the workers, queue, and metrics.
205#[derive(Debug, Clone)]
206pub struct ThreadPool {
207    name: String,
208    sender_low_prio: Sender<BoxedDispatchable>,
209    receiver_low_prio: Receiver<BoxedDispatchable>,
210    sender_high_prio: Sender<BoxedDispatchable>,
211    receiver_high_prio: Receiver<BoxedDispatchable>,
212    counter: Arc<AtomicUsize>,
213    stats: Arc<PoolStats>,
214    thread_limit: usize,
215    recv_timeout: Duration,
216}
217
218impl ThreadPool {
219    /// Creates a pool with a worker limit and idle-worker timeout.
220    ///
221    /// Workers are started on demand and named using `name`. An idle worker
222    /// exits after `recv_timeout` without work. A `thread_limit` of zero is
223    /// treated as one.
224    pub fn new(name: &str, thread_limit: usize, recv_timeout: Duration) -> Self {
225        let (sender_low_prio, receiver_low_prio) = bounded(0);
226        let (sender_high_prio, receiver_high_prio) = unbounded();
227        Self {
228            sender_low_prio,
229            receiver_low_prio,
230            sender_high_prio,
231            receiver_high_prio,
232            thread_limit: thread_limit.max(1),
233            recv_timeout,
234            name: format!("{name}:pool-wrk"),
235            counter: Arc::new(AtomicUsize::new(0)),
236            stats: Arc::default(),
237        }
238    }
239
240    /// Returns a snapshot of the pool metrics.
241    ///
242    /// Gauges and totals are read independently and may be slightly
243    /// inconsistent with each other.
244    pub fn metrics(&self) -> PoolMetrics {
245        PoolMetrics {
246            thread_limit: self.thread_limit,
247            threads: self.counter.load(Ordering::Relaxed),
248            active_tasks: self.stats.active.load(Ordering::Relaxed),
249            queue_depth: self.receiver_high_prio.len(),
250            tasks: self.stats.tasks.load(Ordering::Relaxed),
251            wait_duration: Duration::from_nanos(self.stats.wait.load(Ordering::Relaxed)),
252            busy_duration: Duration::from_nanos(self.stats.busy.load(Ordering::Relaxed)),
253        }
254    }
255
256    pub(crate) fn execute_inplace<F, R>(f: F) -> BlockingResult<R>
257    where
258        F: FnOnce() -> R + Send + 'static,
259        R: Send + 'static,
260    {
261        let (tx, rx) = oneshot::async_channel();
262        let result = panic::catch_unwind(panic::AssertUnwindSafe(f));
263        let _ = tx.send(result);
264        BlockingResult { rx, detached: None }
265    }
266
267    #[allow(clippy::missing_panics_doc)]
268    /// Submits a closure to the thread pool.
269    ///
270    /// The task will be executed by an available worker thread. If no threads
271    /// are available and the pool has reached its maximum size, the work will
272    /// be queued until a worker thread becomes available. Submission does not
273    /// wait for the closure to run or for a worker slot, though it may start an
274    /// OS thread.
275    ///
276    /// Dropping the result cancels work not yet picked up by a worker; use
277    /// [`BlockingResult::detach`] to keep that work queued. Closure panics and
278    /// failures to start a worker when none is available yield [`BlockingError`].
279    pub fn execute<F, R>(&self, f: F) -> BlockingResult<R>
280    where
281        F: FnOnce() -> R + Send + 'static,
282        R: Send + 'static,
283    {
284        let (tx, rx) = oneshot::async_channel();
285        let detached = Arc::new(AtomicBool::new(false));
286        let res = BlockingResult {
287            rx,
288            detached: Some(detached.clone()),
289        };
290        let stats = self.stats.clone();
291        let queued = Instant::now();
292        let f = Box::new(move || {
293            // do not execute operation if receiver is dropped, unless detached,
294            // the fence pairs with the receiver drop in `detach`
295            if !tx.is_closed() || {
296                fence(Ordering::Acquire);
297                detached.load(Ordering::Relaxed)
298            } {
299                let start = Instant::now();
300                stats.tasks.fetch_add(1, Ordering::Relaxed);
301                stats
302                    .wait
303                    .fetch_add(as_nanos(start - queued), Ordering::Relaxed);
304                stats.active.fetch_add(1, Ordering::Relaxed);
305
306                let result = panic::catch_unwind(panic::AssertUnwindSafe(f));
307
308                stats.active.fetch_sub(1, Ordering::Relaxed);
309                stats
310                    .busy
311                    .fetch_add(as_nanos(start.elapsed()), Ordering::Relaxed);
312                let _ = tx.send(result);
313            }
314        });
315
316        // hand over to an idle worker
317        let f = match self.sender_low_prio.try_send(f) {
318            Ok(()) => return res,
319            Err(TrySendError::Full(f)) => f,
320            Err(TrySendError::Disconnected(_)) => {
321                unreachable!("receiver should not all disconnected")
322            }
323        };
324
325        self.sender_high_prio
326            .send(f)
327            .expect("the channel should not be closed");
328        // pairs with the fence in `worker`, either an exiting worker sees
329        // the queued task or a free slot is visible here
330        fence(Ordering::SeqCst);
331
332        if let Some((guard, idx)) = CounterGuard::reserve(&self.counter, self.thread_limit) {
333            let result = thread::Builder::new()
334                .name(format!("{}:{}", self.name, idx))
335                .spawn(worker(
336                    self.receiver_high_prio.clone(),
337                    self.receiver_low_prio.clone(),
338                    guard,
339                    self.thread_limit,
340                    self.recv_timeout,
341                ));
342            if let Err(e) = result {
343                log::error!("Cannot start blocking pool thread: {e}");
344                // no worker can run queued tasks, drop them so they
345                // resolve with `BlockingError`
346                while self.counter.load(Ordering::Acquire) == 0 {
347                    if self.receiver_high_prio.try_recv().is_err() {
348                        break;
349                    }
350                }
351            }
352        }
353        res
354    }
355}
356
357impl<R> Future for BlockingResult<R> {
358    type Output = Result<R, BlockingError>;
359
360    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
361        let this = self.get_mut();
362
363        match Pin::new(&mut this.rx).poll(cx) {
364            Poll::Pending => Poll::Pending,
365            Poll::Ready(result) => Poll::Ready(
366                result
367                    .map_err(|_| BlockingError)
368                    .and_then(|res| res.map_err(|_| BlockingError)),
369            ),
370        }
371    }
372}
373
374#[cfg(test)]
375#[path = "../tests/unit/pool.rs"]
376mod tests;