spawn_localis nowunsafe. BothThreadpool::spawn_localand the freeaffinitypool::spawn_localare markedunsafe fn. They accept closures that borrow non-'staticdata, and their soundness depends on the returned future's destructor running (it blocks until the worker stops touching the borrows). Because safe code can skip a destructor by leaking the future (mem::forget,Box::leak, anRc/Arccycle, a leaked enclosing future), that obligation cannot be guaranteed by the type system and is now the caller's responsibility via an# Safetycontract: do not leak the returned future while it borrows non-'staticdata. This is the classic leak-based unsoundness (the pre-1.0std::thread::scopedhole); there is no sound, fully safespawn_localin async Rust — the only leak-proof design is a synchronous scoped API, which has no.await-able equivalent. Migration: wrap existing calls inunsafe; the commonpool.spawn_local(..).await(or dropping the future normally) already upholds the contract. Closures that capture only'staticdata should move to the safespawn. Seetests/unsound.rsfor a demonstration of the hazard.
- Worker self-spawn fast path. When a closure running on a
worker thread calls
pool.spawn(...), the new task is pushed directly into that worker's own local deque instead of routing through the shared injector. The producer (this thread) is also the consumer, so no cross-thread wake is issued — other workers currently parked stay parked. This is intentional and almost always what you want (it saves the wake roundtrip), but a workload that fans out only via self-spawn cascades, with no external producer to wake parked peers, will serialise on the spawning worker until something else wakes them. The spawning worker's local deque is still a stealer target, so peers that wake on a later foreign push will recover the imbalance.
A major internal rewrite that closes the 8-15× performance gap versus
tokio::task::spawn_blocking and ends up beating it on most
heavy-contention workloads while preserving CPU affinity — the
feature this library exists for. Public trait methods
(Threadpool::new, spawn, spawn_local, Builder, affinity::*,
global spawn/spawn_local, Error, MAX_THREADS) are unchanged;
see Breaking changes for two behavioural shifts and one concrete-type
rename.
Head-to-head with tokio::task::spawn_blocking (--quick criterion
run, system idle):
| Bench | 0.5.0 | this PR | Tokio | this PR vs Tokio |
|---|---|---|---|---|
spawn_overhead/4w/10000 |
48.4 ms | 6.4 ms | 21.2 ms | AP wins 3.3× |
spawn_overhead/4w/100 |
728 µs | 66.7 µs | 144 µs | AP wins 2.2× |
spawn_overhead/1w/10000 |
68.8 ms | 1.62 ms | 2.27 ms | AP wins 1.4× |
burst_drain/100k |
n/a | 50.7 ms | 153 ms | AP wins 3.0× |
burst_drain/1M |
n/a | 564 ms | 683 ms | AP wins 1.2× |
concurrent_pipeline (8p×100k, 8w) |
n/a | 316 ms | 463 ms | AP wins 1.5× |
multi_producer/8p_4w |
6.68 ms | 2.15 ms | 6.23 ms | AP wins 2.9× |
multi_producer/4p_4w |
5.03 ms | 1.06 ms | 1.35 ms | AP wins 1.3× |
round_trip/4w |
6.30 µs | 4.93 µs | 7.10 µs | AP wins 1.4× |
realistic_cost/100ns |
n/a | 4.57 ms | 21.0 ms | AP wins 4.6× |
realistic_cost/250µs |
n/a | 99.7 ms | 98.9 ms | parity (work dominates) |
sustained_throughput |
n/a | 52.3 ms | 61.8 ms | AP wins 1.2× |
Sustained throughput: ~1.9 M tasks/s on 4 workers, ~2.5 M tasks/s
on 8 workers (concurrent_pipeline).
- Task layout:
async-task. Single allocation per spawn viaasync-taskv4. Replaces the hand-rolledJob<F, R>layout,OwnedTaskvtable, andAtomicWaker. async-task is mature, used by smol/fuchsia, and loom-tested upstream. - Sharded queue.
parking_lot::Mutex<VecDeque<Runnable>>×num_shards, wherenum_shards = num_workers.next_power_of_two().min(8).workers == 1→ 1 shard (no scan cost, no regression).workers ≥ 5→ 8 shards (capped). Power-of-two count enables bitmask routing instead of modulo division. - CPU-affinity routing. Producers pick a shard via a thread-local
cache of
sched_getcpu()(Linux) /GetCurrentProcessorNumber()(Windows), refreshed every 64 pushes. Other platforms hash the thread ID — less geographical, but stable per producer thread, which is what gets you cache-locality for long-lived producers. - Shard scanning, not work-stealing. Each worker has a preferred
shard (
worker_idx & mask); on empty, scans remaining shards in cyclic order before parking. No private deques, no victim selection, noSTEAL_RETRY_BUDGETspin loop, no SeqCst fence handshake. - Lost-wakeup-free park protocol. Producers acquire the shard
mutex, push, release, then check a
parkedatomic. If any worker may be parked, the producer briefly acquires theparkmutex to callnotify_one. Workers, when parking, hold theparkmutex acrossparked.fetch_addand a final re-scan of all shards beforecv.wait— so any push whoseparked.loadsees the worker armed must serialise throughpark.lock, and the worker'scv.waitatomically releases that lock with starting to wait. Seesrc/queue.rsfor the full proof sketch andtests/loom_queue.rsfor the exhaustive model.
-
Threadpool::spawnis now a synchronous function returningimpl Future<Output = R> + Send + 'staticrather than anasync fn. The closure is scheduled immediately whenspawnis called, not on the first poll of the returned future. Callers that usedpool.spawn(closure).awaitare unaffected. Callers that built many futures and awaited them later will see the closures start running in parallel right away — typically a performance win and the behavior most users expect. -
Dropping the future returned by
Threadpool::spawnbefore it resolves now cancels the task. A queued-but-unrun task is dropped without running; a currently-running task completes but its result is discarded. Previously the task would run to completion regardless. This matchestokio::JoinHandleandasync_task::Tasksemantics. -
The concrete future type returned by
Threadpool::spawn_localchanged fromSpawnFuture<'pool, F, R>toSpawnFuture<'pool, R>(the closure type parameter was dropped — the closure now lives inside anasync_task::Task<R>). Callers that named the type in awhereclause, stored it in a struct, or returned it from a function will need to drop theFparameter. Callers that only used the returned value as aFuture(the common case) are unaffected. -
spawn_localkeeps its pre-rewrite lazy-schedule semantic: the runnable is pushed onto the queue on first poll of the returnedSpawnFuture, not at the call site. Constructing and dropping aSpawnFuturewithout ever polling it is a no-op and never touches a worker — required so a 1-worker pool cannot deadlock when the only worker doeslet _ = pool.spawn_local(...). Onlyspawn(which has no'poolborrow and no drop-blocking contract) was switched to eager scheduling.
- Task allocation, refcounting, completion state machine, and waker
storage are now delegated to
async-task. - Deleted modules:
job,task,atomic_waker. - Deleted tests: the previous
tests/loom.rs(it modelled the removedAtomicWakerandJob<F, R>protocols). Replaced bytests/loom_queue.rs, which models the new arm-then-rescan park handshake insrc/queue.rs. - New module:
src/cpu.rs— thread-local cachedcurrent_cpu()lookup for shard routing. - Dependencies: added
async-task; removedarc-swap,crossbeam. - CI: miri (scoped to
--libandtests/async_task_smoke) and loom (tests/loom_queue) jobs cover the remainingunsafesurface and the queue handshake respectively.
// Before (0.5.0):
let h = pool.spawn(|| compute());
// ... h is unscheduled until polled.
// std::mem::drop(h) ran the closure to completion.
// After (this PR):
let h = pool.spawn(|| compute());
// ... the closure is already running on a worker.
// std::mem::drop(h) cancels the task.
// If you previously relied on "fire and forget" via drop:
let h = pool.spawn(|| compute());
std::mem::forget(h); // explicit: keep running, discard result
// or: tokio::spawn(async move { h.await; }); // detach on tokio