name: smol-rust
description: smol async runtime conventions for Rust — block_on, global spawn, Executor/LocalExecutor, Timer, Async I/O adapter, Unblock/blocking thread pool, futures-lite prelude, the smol-rs subcrate ecosystem, running tokio-based libraries via async-compat, and choosing smol vs tokio. Use when writing or reviewing async Rust that uses smol (or deciding between smol and tokio) instead of the tokio runtime.
smol
smol is a small, fast, modular async runtime for Rust. The smol crate is a thin facade that re-exports a family of independent [smol-rs] crates (async-executor, async-io, async-channel, async-lock, async-net, async-fs, async-process, blocking, futures-lite). The reactor (async-io, built on polling) wraps epoll / kqueue / IOCP. Apply these conventions when building async Rust on smol. Current: smol 2.0.2 (the crate itself declares Rust 1.63), dual MIT/Apache-2.0 — but resolved dependencies push the effective MSRV higher (e.g. async-lock 3.4.2 requires Rust 1.85), so target a recent stable toolchain.
Core API (what smol:: gives you)
smol::block_on(future)— run a future to completion on the current thread. The entry point forfn mainand tests (smol has no required#[main]macro).smol::spawn(future) -> Task<T>— spawn onto smol's built-in global executor. Worker-thread count comes from theSMOL_THREADSenv var (default 1). You still need ablock_onsomewhere to drive progress on the calling thread.Task<T>— a spawned task handle. Dropping aTaskcancels it..awaitit for the result, or call.detach()to let it run independently. Forgetting this silently cancels work — a common bug.Executor/LocalExecutor— composable executors when you don't want the global one.ex.spawn(fut), drive withblock_on(ex.run(future)).LocalExecutorruns!Sendfutures on one thread. See thesmol-macroscrate for ready-mademain!/multi-threadedExecutorsetup without proc-macros.Timer(async-io) —Timer::after(Duration),Timer::at(Instant),Timer::interval(Duration)..awaitaTimerfor a delay; poll an interval timer as a stream.Async<T>— wrap a pollable OS I/O handle (sockets/FDs such asTcpStream,UnixStream) to make it async via the reactor.async-netprovides ready-madeTcpStream/TcpListener/UdpSocket/Unix types built on it. Regular files are not pollable — useasync-fs(orUnblock) for those, notAsync.unblock(closure)/Unblock<T>(blockingcrate) — run blocking or CPU-heavy work on a dedicated thread pool.Unblock::new(std::io::stdout())adapts blocking I/O (stdio,std::fs::File) into async;unblock(|| expensive())offloads a closure.- Re-exported modules:
smol::io,smol::future,smol::stream,smol::pin,smol::ready(fromfutures-lite);smol::channel(mpmc),smol::lock(Mutex/RwLock/Semaphore/Barrier),smol::net,smol::fs,smol::process. use smol::prelude::*;— brings inFuture,Stream, and the extension traits (FutureExt,StreamExt,AsyncReadExt,AsyncWriteExt,AsyncBufReadExt,AsyncSeekExt) so.next(),.read_to_end(),.race(), etc. are in scope.
use smol::{io, net, prelude::*, Unblock};
fn main() -> io::Result<()> {
smol::block_on(async {
let mut stream = net::TcpStream::connect("example.com:80").await?;
stream.write_all(b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n").await?;
let mut stdout = Unblock::new(std::io::stdout()); // blocking stdout → async
io::copy(stream, &mut stdout).await?;
Ok(())
})
}
futures-lite
smol uses futures-lite, a smaller, faster-compiling subset of futures. Prefer its combinators (future::or, future::race, future::zip, StreamExt, io::copy, AsyncReadExt) over pulling in the full futures crate. The futures-util combinators you know mostly have lighter equivalents here. Only depend on futures directly when you need something futures-lite lacks (e.g. Sink, select! macro, FuturesUnordered).
Concurrency primitives
- Channels:
smol::channel::{bounded, unbounded}— async multi-producer multi-consumer.send().await/recv().await; closes when all senders or receivers drop. - Locks (
async-lock):Mutex,RwLock,Semaphore,Barrier,OnceCell. These are held across.awaitsafely (unlikestd::sync::Mutex). Usestd::sync::Mutexonly for short non-await critical sections. - Run futures concurrently:
future::zip(a, b).await(both),future::or(a, b).await/.race()(first to finish). For many dynamic tasks,spawneach and collect theTasks, or use anExecutor.
Running tokio-based libraries (async-compat)
Many popular crates (reqwest, tonic, tokio-postgres) require a tokio runtime context and will panic ("no reactor running") under smol. (Hyper 1.x is runtime-agnostic via its rt adapters, but its tokio-based helpers still need a tokio context.) Wrap the offending future with the async-compat adapter:
use async_compat::Compat;
let body = smol::block_on(Compat::new(async {
reqwest::get("https://example.com").await?.text().await
}))?;
Compat starts a background tokio runtime and binds it for the wrapped future and its I/O. Wrap the future that touches tokio types, not your whole program.
smol vs tokio — which to use
| smol | tokio | |
|---|---|---|
| Philosophy | Minimal, modular crates you compose | Batteries-included framework |
| Scheduler | async-executor (global or your own) |
Work-stealing multi-threaded scheduler |
| Reactor | async-io / polling |
mio |
| Footprint / compile | Small deps, fast builds | Larger deps, slower builds |
| Ecosystem | Smaller; many libs need async-compat |
De-facto standard (axum, tonic, hyper, sqlx, …) |
| Entry point | smol::block_on (no macro needed) |
#[tokio::main] / Runtime |
| API style | std-like via futures-lite; wrap std types with Async<T> |
First-party async I/O, tokio::time, tokio::sync, tokio::fs |
Choose smol when: you want a lean dependency tree and fast builds, are writing a small binary / CLI / tool, want to compose your own executor, need to mix async with blocking std code cleanly (Async<T>/unblock), or are writing a runtime-agnostic library (depend on futures-io/futures-core traits, not a full runtime; reach for async-io only when intentionally targeting smol-rs).
Choose tokio when: you need its ecosystem (axum/tonic/hyper/sqlx/tokio-postgres default to it), want a mature work-stealing scheduler for high-throughput multicore workloads, or the team/codebase already standardizes on it.
They interoperate: run tokio libs on smol via async-compat. A runtime-agnostic library should avoid hard-coding either — express I/O in terms of futures-io/futures-core traits rather than depending on a specific runtime.
Patterns
- Drive everything from one
smol::block_onat the top; usesmol::spawn/Executorfor concurrency underneath. - Set
SMOL_THREADS(env) for multi-core throughput with the global executor, or build a multi-threadedExecutorand run it on N threads (seesmol-macros). - Offload blocking syscalls,
std::fs, and CPU-bound work withunblock/Unblockso the reactor thread stays responsive. - Wrap arbitrary std I/O types you must use with
Async::new(...)to integrate them with the reactor instead of blocking. - Hold
async-lockguards (notstd::sync) when a lock must survive an.await.
Antipatterns to Avoid
| Antipattern | Why it hurts | Do instead |
|---|---|---|
smol::block_on inside an async task |
Blocks the executor thread; can deadlock | .await the future directly |
Dropping a Task and expecting it to run |
Drop cancels the task | .await it or .detach() |
| Blocking/CPU work directly in a task | Stalls a worker thread (with the default single worker, all I/O stalls) | unblock(...) / Unblock / blocking pool |
| Calling tokio-based libs (reqwest/hyper) directly | Panics: no tokio runtime/reactor | Wrap with async_compat::Compat |
Holding std::sync::Mutex across .await |
Can deadlock the executor; not async-aware | smol::lock::Mutex |
| Expecting multi-core without configuring threads | Global executor defaults to 1 worker | Set SMOL_THREADS or run a multi-threaded Executor |
Pulling in the full futures crate by habit |
Heavier, slower compile | Use futures-lite (smol::future/stream) first |
| Mixing tokio and smol runtimes ad hoc in one binary | Conflicting reactors, surprising panics | Pick one; bridge only via async-compat |
Letting main return before spawned tasks finish |
Tasks are cancelled at exit | .await the Tasks, or drive them on an Executor you block_on to completion |
Quick Reference
| Need | smol API |
|---|---|
| Run a future | smol::block_on(fut) |
| Spawn (global) | let t = smol::spawn(fut); t.detach() or t.await |
| Custom executor | Executor::new() + block_on(ex.run(fut)) |
!Send tasks |
LocalExecutor |
| Delay / interval | Timer::after(d).await / Timer::interval(d) |
| Async TCP/UDP/Unix | smol::net::{TcpStream, TcpListener, UdpSocket} |
| Adapt a std I/O type | smol::Async::new(std_socket) |
| Blocking work | `smol::unblock( |
| Channel | smol::channel::bounded(n) |
| Async lock across await | smol::lock::Mutex |
| Run a tokio lib | async_compat::Compat::new(fut) |