Runtime architecture
So far we've been talking about async runtimes as an abstract concept. Let's dig a bit deeper into the way they are implemented—as you'll see soon enough, it has an impact on our code.
Flavors
tokio
ships two different runtime flavors.
You can configure your runtime via tokio::runtime::Builder
:
Builder::new_multi_thread
gives you a multithreadedtokio
runtimeBuilder::new_current_thread
will instead rely on the current thread for execution.
#[tokio::main]
returns a multithreaded runtime by default, while
#[tokio::test]
uses a current thread runtime out of the box.
Current thread runtime
The current-thread runtime, as the name implies, relies exclusively on the OS thread
it was launched on to schedule and execute tasks.
When using the current-thread runtime, you have concurrency but no parallelism:
asynchronous tasks will be interleaved, but there will always be at most one task running
at any given time.
Multithreaded runtime
When using the multithreaded runtime, instead, there can up to N
tasks running
in parallel at any given time, where N
is the number of threads used by the
runtime. By default, N
matches the number of available CPU cores.
There's more: tokio
performs work-stealing.
If a thread is idle, it won't wait around: it'll try to find a new task that's ready for
execution, either from a global queue or by stealing it from the local queue of another
thread.
Work-stealing can have significant performance benefits, especially on tail latencies,
whenever your application is dealing with workloads that are not perfectly balanced
across threads.
Implications
tokio::spawn
is flavor-agnostic: it'll work no matter if you're running on the multithreaded
or current-thread runtime. The downside is that the signature assumes the worst case
(i.e. multithreaded) and is constrained accordingly:
#![allow(unused)] fn main() { pub fn spawn<F>(future: F) -> JoinHandle<F::Output> where F: Future + Send + 'static, F::Output: Send + 'static, { /* */ } }
Let's ignore the Future
trait for now to focus on the rest.
spawn
is asking all its inputs to be Send
and have a 'static
lifetime.
The 'static
constraint follows the same rationale of the 'static
constraint
on std::thread::spawn
: the spawned task may outlive the context it was spawned
from, therefore it shouldn't depend on any local data that may be de-allocated
after the spawning context is destroyed.
#![allow(unused)] fn main() { fn spawner() { let v = vec![1, 2, 3]; // This won't work, since `&v` doesn't // live long enough. tokio::spawn(async { for x in &v { println!("{x}") } }) } }
Send
, on the other hand, is a direct consequence of tokio
's work-stealing strategy:
a task that was spawned on thread A
may end up being moved to thread B
if that's idle,
thus requiring a Send
bound since we're crossing thread boundaries.
#![allow(unused)] fn main() { fn spawner(input: Rc<u64>) { // This won't work either, because // `Rc` isn't `Send`. tokio::spawn(async move { println!("{}", input); }) } }
Exercise
The exercise for this section is located in 08_futures/03_runtime