Announcing smol-macros, smol-hyper and smol-axum

John Nunley · January 1, 2024

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

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!

Twitter, Facebook

This website's source code is hosted via Codeberg