windows-threadpool-sys
Memory-safe Rust access to the Windows thread pool APIs.
The Windows thread pool integrates work, timers, waits, and asynchronous I/O with the operating system's own scheduling facilities. Its distinguishing property is that an idle workload costs no threads at all: a process waiting on timers, events, or I/O holds no dedicated thread stacks. This crate wraps those facilities while making callback and resource lifetimes explicit in Rust.
The object types
Each thread-pool object is an owned Rust type whose Drop performs the
documented teardown for that object, so a callback can never outlive the state
it captured.
| Type | Wraps | Runs the callback when |
|---|---|---|
work::ThreadpoolWork |
TP_WORK |
you submit it |
timer::ThreadpoolTimer |
TP_TIMER |
a due time arrives, once per arming |
timer::ThreadpoolPeriodicTimer |
TP_TIMER |
every period, until stopped |
wait::ThreadpoolWait |
TP_WAIT |
a handle signals or a wait times out |
io::ThreadpoolIo |
TP_IO |
an overlapped operation completes |
One-shot and periodic timers are separate types on purpose. The platform models
both with one object and a period argument, which hides the property that
matters most when writing the callback: a ThreadpoolPeriodicTimer may queue its next
tick while the previous one is still running, so its callback must tolerate
overlapping with itself. Re-arming a ThreadpoolTimer from inside its own
callback never overlaps — the request is applied only after the callback
returns, so the gap is measured from the end of each firing. Arming a one-shot
from outside while its callback is running is the one case that can still
overlap it.
Three supporting types shape where those callbacks run and how they are torn
down: pool::ThreadpoolPool is an owned private pool,
callback_env::CallbackEnviron is the environment that selects a pool and a
callback priority when an object is created, and cleanup_group::CleanupGroup
releases many objects in one step instead of dropping each individually. The
callback environment provides Rust equivalents of the SDK's header-only inline
helpers, which windows-sys cannot emit.
A cleanup group creates its own members, so the borrow checker prevents using
one after the group has released it. Thread-pool I/O is deliberately excluded: a
TP_IO object must not be closed while an overlapped operation is outstanding,
and a bulk release cannot satisfy that precondition.
Example
use Arc;
use ;
use ThreadpoolWork;
let count = new;
let counter = clone;
let work = new.expect;
for _ in 0..4
work.wait;
assert_eq!;
More examples, including timers, waits, private pools, and overlapped I/O, are in the API documentation.
Callback rules
Callbacks run on shared, process-managed threads, so every object type holds its
callback to the same contract: restore any thread state you change, do not
terminate the thread, and do not block waiting on your own object's rundown. A
callback must not panic — a panic unwinds to the extern "system" trampoline,
where an escaping unwind aborts the process. Nothing contains it. The panic hook
still runs first, so the message and location reach stderr by default; what is
given up is the process, not the diagnostic. A callback that can fail must
handle its own errors rather than panicking.
Safety highlights
Each type carries the invariant its SDK object needs, rather than leaving it to the caller to remember:
- Waits own a handle of proven provenance. The pool supports only some kinds
of handle — a mutex handle, for instance, makes the native behaviour undefined
with no error returned — so
ThreadpoolWaittakes aWaitableHandlerather than anyOwnedHandle. It offers safe constructors for the handle kinds this crate creates itself and one narrowunsafeseam for handles obtained elsewhere, which keeps unsupported handles out of the safe API instead of documenting a rule the caller must remember. The wait then owns the handle and hands it back only as a borrow, so it cannot be closed underneath a pending wait. - Periodic timers reject a period they cannot honour. The pool takes the
period in whole milliseconds, so anything shorter rounds to zero and means "do
not repeat".
ThreadpoolPeriodicTimerrejects periods belowMIN_PERIODrather than returning a "periodic" timer that fires once. - Callbacks get a token for the operations only they can perform.
TimerFiring::rearm_afterre-arms a one-shot from inside its own firing,PeriodicTick::stoplets a periodic timer end itself, andWaitActivation::rearmre-arms a wait — which the SDK requires per activation. - Cleanup-group members are protected at compile time. Members borrow the
group and
close_memberstakes&mut self, so using a member after the group released it is a borrow-check error rather than a documented rule. - A panicking callback aborts. Nothing contains an unwind at the trampoline; the callback contract forbids panicking, and a violation ends the process rather than being silently forgiven.
- Teardown is ordered. Every object disarms or cancels before draining callbacks, then releases its callback context last, so a callback can never outlive the state it captured.
Relationship to windows-overlapped-io-sys
Thread-pool I/O is one of three completion backends for the overlapped model
defined by windows-overlapped-io-sys. This
crate implements the TP_IO backend over that crate's endpoint ownership and
pinned operation storage, adding the balanced StartThreadpoolIo accounting
that only the thread pool requires. The pool's internal completion port is never
exposed.
Timer stress suite
tests/timer_stress.rs applies sustained load to the
timer types: self-re-arming chains, arming churn from many threads, teardown
racing live callbacks, deliberately overlapping periodic ticks, cleanup groups
holding armed members, and a mixed scenario running all of it at once.
It is opt-in and deliberately excluded from CI, where it would be slow and
where a contended shared runner makes timing-sensitive scenarios unreliable.
Nothing runs unless WINDOWS_THREADPOOL_STRESS is set:
$env:WINDOWS_THREADPOOL_STRESS = "1"
cargo test -p windows-threadpool-sys --test timer_stress -- --nocapture
WINDOWS_THREADPOOL_STRESS_SCALE multiplies every load count, so the same
scenarios run harder without editing them:
$env:WINDOWS_THREADPOOL_STRESS_SCALE = "10"
The suite still compiles and lints in CI, so it cannot rot; only the load is skipped. A full run at scale 1 takes about a minute.
Two things worth knowing before reading the output. Pool timers fire on the system timer tick (~15.6ms measured), so a zero-delay re-arm chain advances at roughly 64 links a second however trivial the callback -- scenarios are sized for wall-clock time rather than round iteration counts. And a loop that arms and disarms without pausing outruns the pool entirely, never reaching a tick with the timer armed; scenarios that need firings pause past a tick and assert a floor, so they cannot silently degenerate into testing the arming calls alone.
Assertions are limited to what is invariant under load: non-overlap where the type guarantees it, quiescence after a drain, and the absence of a hang or a crash. Rates, latencies, and exact firing counts are reported rather than asserted, because under load those describe the machine rather than the code.
Status
Work, timers, waits, private pools, cleanup groups, and thread-pool I/O are
implemented and tested. CallbackEnviron::set_cleanup_group remains unsafe as
a raw seam for foreign cleanup groups; use CleanupGroup for a safe one.
This crate is Windows-only. Every item is behind cfg(windows), so the crate
builds to an empty one on other targets rather than failing to compile, and
CI builds, tests, and lints exclusively on Windows.
License
MIT. Copyright (c) Mike Grier.