ADR-018
Runtime + sync primitives: single Tokio rt, parking_lot default
accepted · 2026-04-16 · L0.5 L1 L2 L3 · cites 6
0ADR-018: Runtime + sync primitives: single Tokio rt, parking_lot default #
Context #
The workspace has an opinionated lint policy
(Cargo.toml [workspace.lints.clippy]) that already denies unwrap_used,
expect_used, panic, and the full correctness group. The suspicious
group, which contains await_holding_lock, is currently set to warn not
deny.
The kernel's Clock trait (crates/nika-kernel-core/src/io/clock.rs) demonstrates the
hybrid sync/async rule: Clock::now() is sync, Clock::sleep(Duration) is
async. Async appears only where it is mandatory (timer wakeups). Everything
else is sync.
The kernel's process module
(crates/nika-kernel-core/src/io/process.rs:9-11) explicitly states design decision
#9: cancel is fn cancel(&self, id), not a CancellationToken field. The
intent is "tokio-util out of nika-kernel".
All kernel collections use BTreeMap (per ADR-006) for deterministic
iteration. HashMap is allowed only in private internals where iteration
order does not leak.
INV-016 reminds: parking_lot::RwLockReadGuard is !Send. Holding a guard
across .await is unsound; INV-029 adds the Loom test requirement (ADR-013)
for any crate with concurrent primitives.
Decision #
One runtime, one synchronous lock library, one rule for guards across `.await`.
- Runtime: a single Tokio multi-threaded runtime per process. Daemons
(nika-daemon) spawn the runtime in
main. The CLI uses#[tokio::main(flavor = "multi_thread")]. Nonew_current_threadin production code paths. Tests may use either flavor. - Synchronous locks: `parking_lot::Mutex` / `parking_lot::RwLock` by
default.
std::sync::Mutexis banned in production code (futurenika-lintscustom rule will enforce). - `clippy::await_holding_lock` will be elevated to `deny` in
[workspace.lints.clippy]. Currently caught bysuspicious = "warn"; the upgrade lands in a separate "lint hardening" commit so this ADR can ship docs-only. - Async sync primitives:- `tokio::sync::Semaphore` for concurrency limiting (e.g., `for_each` parallelism cap, max parallel provider calls). - `tokio::sync::oneshot` for single-value async handoff. - `tokio::sync::watch` for configuration hot-reload broadcasts. - `tokio::sync::Notify` for wake-up coordination without a value. - `tokio::sync::Mutex` only when the critical section spans `.await` — otherwise `parking_lot::Mutex` (no async overhead).
- No third-party runtimes.
async-std,smol,glommio: not in the dep tree, not allowed. - Lock-across-`.await` is forbidden. INV-016 is the day-to-day rule.
Guards live in tight scopes; if data is needed across yield points, clone
it out, drop the guard, then
.await.
Consequences #
Positive #
- Single runtime ⇒ predictable scheduling, no cross-runtime deadlocks, one set of metrics to monitor.
parking_lotlocks are faster under contention than stdlib (no poisoning, smaller guard size, faster uncontended path).- Banning
std::sync::Mutexremoves the poisoning footgun and aligns the whole workspace on one lock library. - Elevating
await_holding_locktodenymakes INV-016 a compile-time error, not a runtime hazard. - Small async-sync surface (Semaphore / oneshot / watch / Notify) is easy to teach.
Negative #
- New contributors familiar with
std::sync::Mutexwill hit the lint and need orientation. Mitigated by the workspace-level deny + module-level docs. tokio::sync::Mutexis intentionally rare; teams used to it as a default must adjust. Documented in module headers.- Single runtime means the daemon and the CLI share a tokio version. Forces workspace dep alignment but that is already enforced by Cargo.
Neutral #
- Loom tests (ADR-013, INV-029) verify the lock discipline for crates with concurrent primitives. ADR-018 does not change the Loom strategy, just the lock library on top.
Evidence / Affected code #
Cargo.toml [workspace.lints.clippy]— current lint policy (unwrap_used = "deny",panic = "deny",correctness = { level = "deny" }).Cargo.toml [workspace.dependencies] parking_lot— already pinned.crates/nika-kernel-core/src/io/clock.rs— sync/async hybrid pattern.crates/nika-kernel-core/src/io/process.rs:9-11— design decision #9 (no tokio-util in kernel).crates/nika-kernel-mock/Cargo.toml:21—parking_lot = { workspace = true }in mocks (validates the rule).docs/architecture/forward-compat-invariants.md— INV-016, INV-029.- ADR-013 — Loom concurrency verification.
Alternatives considered #
Alt A -- `std::sync::Mutex` everywhere #
"It's in the standard library, no extra dep."
Rejected: stdlib Mutex poisons on panic (every lock() returns Result,
forcing .unwrap() patterns we have already banned). parking_lot is
faster, smaller, and panics-clean.
Alt B -- `async-std` runtime #
Single-file runtime, looks tempting.
Rejected: ecosystem consolidation. rig-core, reqwest, axum, tokio-postgres
all require Tokio. Adding async-std would force compatibility shims for
every L1 crate.
Alt C -- Lock-free data structures everywhere #
crossbeam, dashmap, atomics-only.
Rejected: overkill for kernel data. parking_lot is the right complexity
level for our access patterns. DashMap is fine in specific hotspots
(catalog lookup), but is not the default.
Alt D -- `tokio::sync::Mutex` as default #
"Async-aware, never blocks."
Rejected: every lock().await is a yield point; using it as default would
sprinkle yields in code that does not need them, and slows uncontended
paths. Reserve tokio::sync::Mutex for the (rare) case where the critical
section legitimately spans .await.
Alt E -- Multiple runtimes per process #
One runtime for I/O, one for compute (ML inference).
Rejected: cross-runtime channels are subtle and prone to deadlock; the
benefit (CPU-bound work isolation) is better served by tokio::task::spawn_blocking
or a dedicated rayon pool — neither requires a second runtime.
Related #
- ADR-006 — kernel ISP trait design. ADR-018 is the runtime substrate underneath ADR-006's traits.
- ADR-013 — Loom concurrency verification (INV-029). ADR-018 chooses the lock library; ADR-013 verifies it under concurrent stress.
- ADR-016 — Cancellation model. Both ADRs share the "no tokio-util in kernel" rule.
- ADR-017 — Streaming policy. Bounded mpsc(32) is the streaming face of this runtime choice.
Notes #
Follow-ups:
- Elevate
clippy::await_holding_lockfromsuspicious = "warn"todenyin the workspace lints. Land in a separate "lint hardening" commit so that any preexisting violations are surfaced + fixed in isolation. - Add
nika-lints::no_std_sync_mutexcustom lint whennika-lintsships (Phase 4+). - Document daemon vs CLI runtime spawning rules in the agent self-serve docs (Phase E).
read at v0.107.0 · the decision record ships with the engine