use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::Semaphore;
#[derive(Debug, thiserror::Error)]
pub enum KdfError {
#[error("blocking task failed: {0}")]
Join(String),
#[error("blocking executor is shut down")]
Closed,
}
#[derive(Debug)]
enum Mode {
Pool,
Inline,
}
#[derive(Debug)]
pub struct KdfExecutor {
mode: Mode,
permits: Arc<Semaphore>,
started: AtomicU64,
}
impl KdfExecutor {
pub fn pool() -> Self {
let cpus = std::thread::available_parallelism().map_or(1, usize::from);
Self::pool_with_permits(cpus)
}
pub fn pool_with_permits(permits: usize) -> Self {
Self {
mode: Mode::Pool,
permits: Arc::new(Semaphore::new(permits.max(1))),
started: AtomicU64::new(0),
}
}
pub fn inline() -> Self {
Self {
mode: Mode::Inline,
permits: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
started: AtomicU64::new(0),
}
}
pub fn jobs_started(&self) -> u64 {
self.started.load(Ordering::Relaxed)
}
pub async fn run<T, F>(&self, job: F) -> Result<T, KdfError>
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
let _permit = self
.permits
.clone()
.acquire_owned()
.await
.map_err(|_| KdfError::Closed)?;
self.started.fetch_add(1, Ordering::Relaxed);
match self.mode {
Mode::Inline => Ok(job()),
Mode::Pool => tokio::task::spawn_blocking(job)
.await
.map_err(|e| KdfError::Join(e.to_string())),
}
}
}
impl Default for KdfExecutor {
fn default() -> Self {
Self::pool()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
#[tokio::test]
async fn inline_runs_the_job_and_counts_it() {
let ex = KdfExecutor::inline();
assert_eq!(ex.run(|| 2 + 2).await.unwrap(), 4);
assert_eq!(ex.run(|| "x").await.unwrap(), "x");
assert_eq!(ex.jobs_started(), 2);
}
#[tokio::test]
async fn the_pool_runs_on_a_different_thread_than_the_caller() {
let ex = KdfExecutor::pool();
let caller = std::thread::current().id();
let job = ex.run(|| std::thread::current().id()).await.unwrap();
assert_ne!(caller, job, "the job ran on the blocking pool");
assert_eq!(ex.jobs_started(), 1);
}
#[tokio::test]
async fn a_panicking_job_is_an_error_not_a_crash() {
let ex = KdfExecutor::pool();
let err = ex
.run(|| -> u8 { panic!("boom") })
.await
.expect_err("a panic is reported");
assert!(matches!(err, KdfError::Join(_)), "got {err:?}");
}
#[tokio::test(flavor = "current_thread")]
async fn a_pool_job_leaves_the_runtime_free_and_an_inline_one_does_not() {
async fn ticks_during(ex: KdfExecutor) -> usize {
let ticks = Arc::new(AtomicUsize::new(0));
let counter = ticks.clone();
let ticker = tokio::spawn(async move {
loop {
tokio::task::yield_now().await;
counter.fetch_add(1, Ordering::SeqCst);
}
});
ex.run(|| std::thread::sleep(std::time::Duration::from_millis(60)))
.await
.unwrap();
let seen = ticks.load(Ordering::SeqCst);
ticker.abort();
seen
}
assert!(ticks_during(KdfExecutor::pool()).await > 0);
assert_eq!(ticks_during(KdfExecutor::inline()).await, 0);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_semaphore_caps_concurrent_jobs() {
let ex = Arc::new(KdfExecutor::pool_with_permits(2));
let running = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
let mut tasks = Vec::new();
for _ in 0..8 {
let (ex, running, peak) = (ex.clone(), running.clone(), peak.clone());
tasks.push(tokio::spawn(async move {
ex.run(move || {
let now = running.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(20));
running.fetch_sub(1, Ordering::SeqCst);
})
.await
.unwrap();
}));
}
for t in tasks {
t.await.unwrap();
}
assert_eq!(ex.jobs_started(), 8);
assert!(
peak.load(Ordering::SeqCst) <= 2,
"peak concurrency {} exceeded the 2 permits",
peak.load(Ordering::SeqCst)
);
}
}