In this lesson, you will learn how to design library code that remains agnostic to whether the user runs it on Tokio, async-std, or a custom embedded executor.
The Problem of Hardcoded Runtimes
When you write code that calls tokio::spawn or uses tokio::sync::Mutex, your library becomes "runtime-coupled." A user cannot easily swap out the executor or use your library in a lightweight environment that doesn't support the full Tokio stack. To build robust, reusable crates, we must abstract away the act of spawning tasks and blocking.
The Strategy: Trait-Based Injection
Instead of relying on global runtime functions, you define a trait that represents the specific concurrency capabilities your code requires. By requiring this trait as a generic bound, you shift the responsibility of execution to the library user.
Consider a component that needs to run a background task:
pub trait SpawnTask {
fn spawn_task<F>(&self, future: F)
where
F: std::future::Future<Output = ()> + Send + 'static;
}
pub struct MyService<S: SpawnTask> {
executor: S,
}
impl<S: SpawnTask> MyService<S> {
pub fn start_work(&self) {
self.executor.spawn_task(async {
// Do concurrent work here...
});
}
}
Why This Matters
By using this pattern, you avoid hard-coding dependencies. A user could implement SpawnTask for a wrapper around tokio::spawn, or implement it for a simple std::thread::spawn wrapper in a smaller application.
This approach also simplifies testing. During unit tests, you can inject a "mock" executor that runs futures synchronously or inspects them, rather than requiring a complex multi-threaded runtime to spin up just to verify your logic. You gain total control over the execution environment without sacrificing the ergonomic benefits of async/await.
A Note on Compatibility
While this abstraction is powerful, be mindful of types that are tied to specific runtimes. For example, a tokio::sync::Mutex cannot be awaited on a different executor. When abstracting, ensure that your data structures are also runtime-agnostic—use standard library primitives or portable alternatives where possible to ensure your abstractions actually work across different backends.
Key Takeaway: Decouple your logic from the runtime by defining interfaces for concurrency primitives, allowing the end-user to inject their preferred executor at the boundary.