smol
just became a much easier choice to build web servers.
smol
is a small and fast asynchronous runtime written in Rust. It serves as an alternative to crates like tokio
with a new architecture and greater user flexibility.
However, tokio
and crates like it are already well established in the async
ecosystem, which makes migrating to smol
hard. Even projects like wezterm
depend on both smol
and tokio
because of this problem.
My goal is to make it so smol
is much easier to make as an organizational choice for a stable runtime. Therefore, I have spent the past month or so writing a few utility crates that make smol
easier to use with other crates.
smol-macros
, a couple of macros forsmol
to make scaffolding easier.smol-hyper
, an integration layer betweensmol
’s types andhyper
’s types.smol-axum
, an integration layer betweensmol
’s runtime and theaxum
web framework.
smol-macros
One of the great things about smol
is that you set up your own executor and runtime. In tokio
, you get handed a runtime that you either have to drive the program with or sequester it to another thread.
In smol
, you can drive the Executor
just about anywhere. You can even run an Executor
inside of another Executor
if you really want to. This property makes smol
much easier to integrate with other runtime-like constructs, like winit
.
However, the trade-off for this trick is that it takes some scaffolding to set up the runtime. You need to call block_on
first, then run the Executor
inside of that. If you want to run the Executor
on a multi-threaded runtime, you need to spawn all of the threads and run the Executor
on top of that. Then, if you want graceful shutdown, you also need to set up some kind of communication mechanism to tell the Executor
to stop.
Overall, it’s between ten and two-hundred extra lines of code depending on how complex your application is. As a maintainer, it’s easy to write it off as typical application boilerplate. However, I’ve heard from some organizations that they are hesitant to adopt smol
because of this additional boilerplate cost, where tokio
doesn’t have any. Therefore, I’ve elected to create a solution for this problem.
smol-macros
provides a handful of macros to make scaffolding a smol
application much easier. The most important one for our case is main!
, which wraps a typical main
function and makes it async
.
use smol_macros::{main, Executor};
main! {
async fn main(ex: &Executor<'_>) {
ex.spawn(async { println!("Hello world!"); }).await;
}
}
Just like that, you already have a multithreaded runtime with a work stealing executor running the full smol
runtime. The Executor
can be wrapped in an Arc
to easily enable it to be shared among different tasks and threads.
Since we are using the multithreaded Executor
, it automatically spawns a set of threads that poll the Executor
for as long as the program runs. These threads are configured to automatically drain tasks and drop the Executor
once the main
function exits.
Consider that we are using declarative macros in this case, instead of the more familiar procedural macro attributes. This design choice is intentional; declarative macros are built into the language, use zero dependencies and are generally faster to execute. If you miss the tokio
style of attribute macros, you can use the macro_rules_attribute::apply
macro in its place.
use macro_rules_attribute::apply;
use smol_macros::{main, Executor};
#[apply(main!)]
async fn main(ex: &Executor<'_>) {
ex.spawn(async { println!("Hello world!"); }).await;
}
My goal with this crate is to make scaffolding easier. If the thing preventing you from switching to smol
was the initial buy-in, I hope this makes it easier. Please give me feedback on how the API works for you.
smol-hyper
Another problem with smol
is its lack of HTTP support. hyper
, the most popular HTTP implementation in the Rust ecosystem, it pretty clearly tailored to support tokio
. There are other async
HTTP implementations like async-h1
, but they don’t hold a candle to the stability and support offered by hyper
.
However, hyper
does have capabilities for working in different runtimes. Therefore, I’ve created smol-hyper
, an integration layer between smol
and hyper
.
Recently, hyper
released version 1.0.0, which removed a lot of the extra support needed to be implemented for tokio
in favor of a more simple trait system. smol-hyper
, therefore, just implements the runtime traits for smol
’s types.
This crate is very deliberately simple and constrained, thanks to hyper
’s new design. It’s mostly intended to act as plumbing for higher level crates, such as…
smol-axum
smol-axum
is an integration layer between axum
and smol
. Like hyper
, axum
is generally designed with tokio
in mind. smol-axum
aims to reverse this status quo.
Rather than listening on a tokio
TcpListener
, you listen on a smol
TcpListener
. Rather than using the axum::serve
, you use smol_axum::serve
.
That’s where the differences end. I’ve intentionally made it easy to port axum
applications to smol-axum
. For instance, take the following standard axum
application:
use axum::{response::Html, routing::get, Router};
#[tokio::main]
async fn main() {
// build our application with a route
let app = Router::new().route("/", get(handler));
// run it
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
async fn handler() -> Html<&'static str> {
Html("<h1>Hello, World!</h1>")
}
Shamelessly taken from axum
’s examples.
You create a Router
, start listening on a TCP socket, and then call serve
to start handling web requests. With smol-axum
, you do this:
use async_io::Async;
use axum::{response::Html, routing::get, Router};
use macro_rules_attribute::apply;
use std::io;
use std::net::TcpListener;
use std::sync::Arc;
#[apply(smol_macros::main!)]
async fn main(ex: &Arc<smol_macros::Executor<'_>>) -> io::Result<()> {
// Build our application with a route.
let app = Router::new().route("/", get(handler));
// Create a `smol`-based TCP listener.
let listener = Async::<TcpListener>::bind(([127, 0, 0, 1], 3000)).unwrap();
println!("listening on {}", listener.get_ref().local_addr().unwrap());
// Run it using `smol_axum`
smol_axum::serve(ex.clone(), listener, app).await
}
async fn handler() -> Html<&'static str> {
Html("<h1>Hello, World!</h1>")
}
There’s hardly a difference, by my reckoning.
tokio
Turnstile
The main trade-off here is that hyper
still brings in tokio
as a dependency, even with its usual tokio
features disabled. Granted, it only enables tokio
’s synchronization features, which avoids bringing it its multi-threaded runtime or its mio
-based reactor. This is a win in my book, but should still be kept in mind for the future.
This could be resolved by porting the underlying logic to another HTTP implementation. axum
is not intrinsically tied to hyper
. However, it is tied to the http
crate. The only other async
HTTP implementation I’m aware of, async-h1
, uses a separate set of underlying traits than http
. It also brings in async-std
, which comes with its own problems.
A problem like this could be resolved by writing my own HTTP implementation based on http
that doesn’t bring in tokio
. However, this would be a lot of work for minimal benefit aside from ideological clout. hyper
is fast, correct and has an active team of maintainers, so it works very well for now.
If this new crate is something your organization is interested in, pay me and I’ll put it higher on my priorities list.
Edit for 2024-03-10: It has been brought to my attention that
trillium_http
is an
HTTP v1.1 implementation that doesn’t involve tokio
. So if you’re looking
for one it might be worth checking out.
smol
v2.0.0
In addition, I’ve released the new breaking changes for smol
.
Most of the subcrates already had their second versions released, so this is more of a formality than anything else. Still, after my last post about smol
v2.0.0, it’s nice to finally get it out there almost five months later.
Most of my predictions from earlier came true, save for !Unpin
futures, as there was some unsoundness that had to be addressed. This optimization may be reintroduced in the future.
There’s also a couple of other things, like not needing to spawn an entire separate thread to poll for child processes. Still, by and large it should be the same smol
you’ve known and loved for the past three years.
Parting Shots
I hope these crates find their place in the increasingly fast-paced Rust ecosystem. Please open GitHub issues if you have feedback or, better, open a PR for any changes you’d like to see!