Lesson 41 of 44
The Tokio Ecosystem
Tokio provides the runtime, timers, sockets, and synchronization. Most services also use crates built on Tokio for HTTP, middleware, buffers, and logging. This lesson maps those crates to their roles, shows how a tower service and layer work, and runs an axum server that a reqwest client calls inside the same program.
The stack
| Crate | Role | Sits on |
|---|---|---|
mio |
Cross-platform readiness API over epoll, kqueue, and IOCP; Tokio's I/O driver uses it | the operating system |
tokio |
Runtime, scheduler, timers, TCP/UDP/Unix sockets, channels, locks | mio |
tokio-util |
Codecs and Framed, CancellationToken, TaskTracker, StreamReader |
tokio, bytes |
tokio-stream |
StreamExt, stream wrappers for channels and timers |
tokio |
bytes |
Bytes and BytesMut buffers with cheap clones and slices |
none |
tracing |
Structured events and spans; subscribers decide where they go | none |
tower |
Service and Layer traits, plus middleware such as timeouts and rate limits |
tokio, futures |
tower-http |
HTTP middleware built as tower layers: CORS, tracing, compression, and others |
tower, http |
hyper |
HTTP/1 and HTTP/2 protocol implementation | tokio, bytes, http |
axum |
HTTP framework: routing, extractors, handlers | hyper, tower |
reqwest |
HTTP client with connection pooling, JSON, timeouts | hyper, tokio |
tonic |
gRPC client and server | hyper, tower, prost |
sqlx |
Async SQL driver with a connection pool | tokio |
The programs in this lesson use every crate in the table except mio, hyper, tonic, and sqlx. mio and hyper are used through tokio, axum, and reqwest and never imported directly. tonic and sqlx are named because they follow the same conventions: they run on the Tokio runtime and expose tower services or async pools.
graph TD
OS[Operating system: epoll, kqueue, IOCP] --> mio
mio --> tokio
tokio --> tokio-util
tokio --> tokio-stream
bytes --> tokio-util
tokio --> tower
tower --> tower-http
tokio --> hyper
hyper --> axum
tower --> axum
hyper --> reqwest
hyper --> tonic
tower --> tonic
tokio --> sqlx
tracing -.-> axum
tracing -.-> tower-httpSolid arrows mean "is built on". Dotted arrows mean "emits events through". tracing does not depend on Tokio.
Example 1: A Cargo manifest for a service
A service that serves HTTP and calls other services usually depends on the crates below. The versions match those the course examples are checked against.
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["full"] }
tokio-stream = "0.1"
bytes = "1"
tracing = "0.1"
tracing-subscriber = "0.3"
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["trace", "cors"] }
axum = "0.8"
reqwest = { version = "0.13", features = ["json"] }
serde = { version = "1", features = ["derive"] }tower and tower-http gate most of their middleware behind features (timeout, limit, retry, and others in tower; trace, cors, compression-gzip, and others in tower-http). Enable only the features in use, because each one adds code to the build.
Example 2: The small crates together
tokio-util supplies the codec, bytes supplies the buffer type, and tokio-stream supplies StreamExt. The program sends three lines through an in-memory pipe with LinesCodec (Codecs with tokio-util) and reads them back as a stream (Streams).
use bytes::Bytes;
use futures::SinkExt;
use tokio_stream::StreamExt;
use tokio_util::codec::{Framed, LinesCodec};
#[tokio::main]
async fn main() {
let (client_io, server_io) = tokio::io::duplex(256);
let mut client = Framed::new(client_io, LinesCodec::new());
let mut server = Framed::new(server_io, LinesCodec::new());
for line in ["first", "second", "third"] {
client.send(line).await.unwrap();
}
drop(client);
while let Some(line) = server.next().await {
let line = line.unwrap();
let shared = Bytes::from(line.clone());
let copy = shared.clone(); // no byte copy: both handles share one buffer
println!("{line}: {} bytes, same buffer: {}", copy.len(), shared.as_ptr() == copy.as_ptr());
}
}first: 5 bytes, same buffer: true
second: 6 bytes, same buffer: true
third: 5 bytes, same buffer: trueBytes::clone increments a reference count instead of copying the data, so both handles point at the same memory (Bytes and Framing).
Example 3: tracing events
tracing records structured events. A subscriber from tracing-subscriber decides how to print them (Tracing and Observability). The library crates in this lesson (axum, tower-http) emit events in the same way, so one subscriber collects everything.
use tracing::{info, warn};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_ansi(false)
.without_time()
.with_target(false)
.with_writer(std::io::stdout)
.init();
info!(service = "billing", "starting");
warn!(retries = 3, "upstream is slow");
} INFO starting service="billing"
WARN upstream is slow retries=3Example 4: A tower service
tower::Service is a trait for "an async function from a request to a response" with a readiness check. poll_ready says whether the service can take a request, and call starts one. service_fn turns a closure into a service, and ServiceExt::oneshot waits for readiness and makes one call.
use std::convert::Infallible;
use tower::{Service, ServiceExt, service_fn};
#[tokio::main]
async fn main() {
let mut greet = service_fn(|name: String| async move {
Ok::<_, Infallible>(format!("hello, {name}"))
});
let first = greet.ready().await.unwrap().call("tokio".to_string()).await.unwrap();
println!("{first}");
let second = greet.oneshot("tower".to_string()).await.unwrap();
println!("{second}");
}hello, tokio
hello, towerready() waits until poll_ready succeeds. call requires &mut self and a ready service, which is why calls go through ready().await or oneshot.
Example 5: A tower layer
A Layer wraps one service in another. The wrapper implements Service too, so wrappers stack. This layer counts calls and forwards each request unchanged.
use std::convert::Infallible;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::task::{Context, Poll};
use tower::{Layer, Service, ServiceExt, service_fn};
struct CountLayer {
calls: Arc<AtomicUsize>,
}
impl<S> Layer<S> for CountLayer {
type Service = Count<S>;
fn layer(&self, inner: S) -> Count<S> {
Count { inner, calls: Arc::clone(&self.calls) }
}
}
#[derive(Clone)]
struct Count<S> {
inner: S,
calls: Arc<AtomicUsize>,
}
impl<S, Request> Service<Request> for Count<S>
where
S: Service<Request>,
{
type Response = S::Response;
type Error = S::Error;
type Future = S::Future;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), S::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, request: Request) -> S::Future {
self.calls.fetch_add(1, Ordering::SeqCst);
self.inner.call(request)
}
}
#[tokio::main]
async fn main() {
let calls = Arc::new(AtomicUsize::new(0));
let inner = service_fn(|n: u32| async move { Ok::<_, Infallible>(n * 2) });
let mut service = CountLayer { calls: Arc::clone(&calls) }.layer(inner);
for n in [1, 2, 3] {
let doubled = service.ready().await.unwrap().call(n).await.unwrap();
println!("{n} -> {doubled}");
}
println!("calls counted: {}", calls.load(Ordering::SeqCst));
}1 -> 2
2 -> 4
3 -> 6
calls counted: 3The wrapper adds behavior (a counter) without changing the inner service, and poll_ready passes through so that the inner service's readiness rules still apply.
Example 6: Stacking layers with ServiceBuilder
ServiceBuilder applies layers in order: the first layer added is the outermost, so it sees the request first and the response last. map_request and map_response are small layers built from closures.
use std::convert::Infallible;
use tower::{ServiceBuilder, ServiceExt, service_fn};
#[tokio::main]
async fn main() {
let service = ServiceBuilder::new()
.map_request(|text: &str| {
println!("layer 1 sees request {text:?}");
text.trim().to_string()
})
.map_request(|text: String| {
println!("layer 2 sees request {text:?}");
text.to_uppercase()
})
.map_response(|length: usize| {
println!("layer 1 sees response {length}");
length
})
.service(service_fn(|text: String| async move {
println!("inner service gets {text:?}");
Ok::<_, Infallible>(text.len())
}));
let length = service.oneshot(" tokio ").await.unwrap();
println!("final response: {length}");
}layer 1 sees request " tokio "
layer 2 sees request "tokio"
inner service gets "TOKIO"
layer 1 sees response 5
final response: 5Requests travel from the first layer inward. Responses travel back outward. Timeouts, rate limits, retries, and load shedding in tower are layers that stack the same way (Backpressure and Load Shedding explains the ideas behind them).
Example 7: An axum router is a tower service
axum::Router implements Service, so a test can call it without opening a socket. The request is an http::Request (re-exported by axum::http) and the response body is read with axum::body::to_bytes.
use axum::body::{Body, to_bytes};
use axum::extract::Path;
use axum::http::Request;
use axum::{Router, routing::get};
use tower::ServiceExt;
#[tokio::main]
async fn main() {
let app = Router::new().route(
"/hello/{name}",
get(|Path(name): Path<String>| async move { format!("hello, {name}") }),
);
for uri in ["/hello/tokio", "/missing"] {
let request = Request::builder().uri(uri).body(Body::empty()).unwrap();
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), 1024).await.unwrap();
println!("{uri} -> {status}, body {:?}", String::from_utf8_lossy(&body));
}
}/hello/tokio -> 200 OK, body "hello, tokio"
/missing -> 404 Not Found, body ""Route parameters use braces ({name}) in axum 0.8. Router is cheap to clone, which is why app.clone().oneshot(...) can run repeatedly.
Example 8: An axum server on port 0, called by reqwest
Binding to port 0 lets the operating system choose a free port, and local_addr reports it. The server runs in a spawned task, and the client in main calls it over a real TCP connection (TCP Clients and Servers).
use axum::{Router, routing::get};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() {
let app = Router::new().route("/ping", get(|| async { "pong" }));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let response = reqwest::get(format!("http://{addr}/ping")).await.unwrap();
println!("status: {}", response.status());
println!("body: {}", response.text().await.unwrap());
}status: 200 OK
body: pongreqwest::get builds a temporary client for one request. Later examples build one Client and reuse it.
Example 9: JSON, shared state, and status codes
Handlers receive shared state through the State extractor and JSON through Json. The state is cloned per request, so it holds an Arc for data that must be shared. The handler returns a status code and a JSON body.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::post;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
#[derive(Clone, Default)]
struct AppState {
created: Arc<AtomicUsize>,
}
#[derive(Serialize, Deserialize)]
struct NewJob {
name: String,
}
#[derive(Serialize, Deserialize)]
struct Created {
id: usize,
name: String,
}
async fn create_job(
State(state): State<AppState>,
Json(job): Json<NewJob>,
) -> (StatusCode, Json<Created>) {
let id = state.created.fetch_add(1, Ordering::SeqCst) + 1;
(StatusCode::CREATED, Json(Created { id, name: job.name }))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/jobs", post(create_job)).with_state(AppState::default());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let client = reqwest::Client::new();
for name in ["resize", "email"] {
let response = client
.post(format!("http://{addr}/jobs"))
.json(&NewJob { name: name.to_string() })
.send()
.await
.unwrap();
let status = response.status();
let created: Created = response.json().await.unwrap();
println!("{status}: job {} is {}", created.id, created.name);
}
let bad = client
.post(format!("http://{addr}/jobs"))
.header("content-type", "application/json")
.body("{\"wrong\": 1}")
.send()
.await
.unwrap();
println!("malformed body: {}", bad.status());
}201 Created: job 1 is resize
201 Created: job 2 is email
malformed body: 422 Unprocessable Entityaxum rejects a body that does not match NewJob before the handler runs and answers 422 Unprocessable Entity.
Example 10: tower-http middleware
tower-http layers attach to a Router with .layer(...). CorsLayer answers cross-origin rules by adding response headers. The client sends an Origin header and prints the header that comes back.
use axum::{Router, routing::get};
use tokio::net::TcpListener;
use tower_http::cors::CorsLayer;
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/ping", get(|| async { "pong" }))
.layer(CorsLayer::permissive());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let response = reqwest::Client::new()
.get(format!("http://{addr}/ping"))
.header("origin", "https://example.com")
.send()
.await
.unwrap();
let allow = response.headers().get("access-control-allow-origin");
println!("access-control-allow-origin: {:?}", allow.map(|value| value.to_str().unwrap()));
}access-control-allow-origin: Some("*")CorsLayer::permissive() allows any origin, method, and header. A production service lists the origins it accepts.
Example 11: Client timeouts
A reqwest request has no timeout unless one is configured. Client::builder().timeout(...) sets a total limit for each request, and connect_timeout limits only the connection step. This server has a handler that never answers.
use std::time::Duration;
use axum::{Router, routing::get};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/fast", get(|| async { "done" }))
.route(
"/stuck",
get(|| async {
std::future::pending::<()>().await;
"unreachable"
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(1))
.timeout(Duration::from_millis(200))
.build()
.unwrap();
let fast = client.get(format!("http://{addr}/fast")).send().await.unwrap();
println!("fast: {}", fast.text().await.unwrap());
let stuck = client.get(format!("http://{addr}/stuck")).send().await;
match stuck {
Ok(_) => println!("stuck: unexpected response"),
Err(error) => println!("stuck: is_timeout = {}", error.is_timeout()),
}
let patient = client
.get(format!("http://{addr}/stuck"))
.timeout(Duration::from_millis(50))
.send()
.await;
println!("per-request timeout: is_timeout = {}", patient.unwrap_err().is_timeout());
}fast: done
stuck: is_timeout = true
per-request timeout: is_timeout = trueA per-request .timeout(...) overrides the client default for that request. The total timeout runs from the start of connecting until the response body has finished, so it also limits slow body reads. The default is no timeout.
Example 12: Connection reuse
A reqwest::Client keeps a pool of idle connections per host. Requests from the same client reuse a connection when one is idle. Each new Client has its own pool and opens its own connection. The server below wraps its listener to count accepted connections. The Listener trait is axum::serve::Listener.
use std::io;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use axum::serve::Listener;
use axum::{Router, routing::get};
use tokio::net::{TcpListener, TcpStream};
struct CountingListener {
inner: TcpListener,
accepted: Arc<AtomicUsize>,
}
impl Listener for CountingListener {
type Io = TcpStream;
type Addr = SocketAddr;
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
let connection = Listener::accept(&mut self.inner).await;
self.accepted.fetch_add(1, Ordering::SeqCst);
connection
}
fn local_addr(&self) -> io::Result<Self::Addr> {
self.inner.local_addr()
}
}
async fn get_text(client: &reqwest::Client, url: &str) -> String {
let text = client.get(url).send().await.unwrap().text().await.unwrap();
// The pool takes the connection back shortly after the body ends.
tokio::time::sleep(Duration::from_millis(20)).await;
text
}
#[tokio::main]
async fn main() {
let accepted = Arc::new(AtomicUsize::new(0));
let listener = CountingListener {
inner: TcpListener::bind("127.0.0.1:0").await.unwrap(),
accepted: Arc::clone(&accepted),
};
let addr = listener.local_addr().unwrap();
let app = Router::new().route("/ping", get(|| async { "pong" }));
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let url = format!("http://{addr}/ping");
let shared = reqwest::Client::new();
for _ in 0..5 {
get_text(&shared, &url).await;
}
println!("one client, 5 requests: {} connection(s)", accepted.load(Ordering::SeqCst));
accepted.store(0, Ordering::SeqCst);
for _ in 0..5 {
get_text(&reqwest::Client::new(), &url).await;
}
println!("new client per request: {} connection(s)", accepted.load(Ordering::SeqCst));
}one client, 5 requests: 1 connection(s)
new client per request: 5 connection(s)The shared client makes one TCP connection for five requests. Building a client per request pays a connection setup and a client setup each time, and it discards the pool. A service builds its Client once and clones it. The reqwest documentation states that Client uses an Arc internally, so clones share the pool.
Example 13: Graceful shutdown of the axum server
axum::serve(...).with_graceful_shutdown(signal) stops accepting connections when the signal future completes, and it waits for in-flight requests before returning. The pattern matches Graceful Shutdown, with the future usually being ctrl_c in a real service and a oneshot receiver here.
use std::time::Duration;
use axum::{Router, routing::get};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
#[tokio::main]
async fn main() {
let app = Router::new().route(
"/slow",
get(|| async {
tokio::time::sleep(Duration::from_millis(100)).await;
"finished"
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (stop_tx, stop_rx) = oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = stop_rx.await;
})
.await
.unwrap();
});
let request = tokio::spawn(async move {
reqwest::get(format!("http://{addr}/slow")).await.unwrap().text().await.unwrap()
});
tokio::time::sleep(Duration::from_millis(30)).await; // the request is now in flight
stop_tx.send(()).unwrap();
println!("in-flight request: {}", request.await.unwrap());
server.await.unwrap();
println!("server stopped");
}in-flight request: finished
server stoppedThe request that started before the stop signal completes normally, and only then does the server task end.
Example 14: Choosing crates for a service
The criteria below map a requirement to the crate that provides it.
| Requirement | Crate | Criterion |
|---|---|---|
| Serve HTTP routes with typed extractors | axum |
Handlers are plain async functions; the router is a tower service |
| Call an HTTP API | reqwest |
Pooling, JSON, timeouts, redirects; build one Client |
| Add timeouts, rate limits, or retries to any async call | tower |
The behavior is a layer that wraps a Service |
| Add request tracing, CORS, or compression to HTTP | tower-http |
Layers that plug into axum with .layer(...) |
| Speak a custom TCP protocol | tokio-util codecs and bytes |
Framed turns a socket into a stream and sink of typed frames |
| Coordinate shutdown across tasks | tokio-util |
CancellationToken and TaskTracker |
| Consume channels and intervals as streams | tokio-stream |
Wrappers such as ReceiverStream and IntervalStream |
| Log with context across tasks | tracing, tracing-subscriber |
Spans follow the work across .await points |
| Build gRPC services | tonic |
Generated from protobuf definitions; runs on hyper and tower |
| Query SQL databases | sqlx |
Async pool; queries are futures |
| Drive the HTTP protocol directly | hyper |
Needed when axum and reqwest do not fit; reachable through them otherwise |
Two rules cover mixing versions. Crates that share a type in their public API (http, bytes, tower) must resolve to the same major version, or the types do not match. cargo tree -d lists duplicated crates.
Interview points
miowraps epoll, kqueue, and IOCP. Tokio's I/O driver is built on it.tower::Servicehaspoll_readyandcall. ALayerturns one service into another.- The first layer added to a
ServiceBuilderis the outermost. axum::Routeris atowerservice, so it can be called without a socket in tests.axumandreqwestboth build onhyper.tower-httpmiddleware aretowerlayers.- A
reqwest::Clientpools connections; build it once and clone it. reqwesthas no default request timeout.axum::serve(...).with_graceful_shutdown(...)stops accepting and waits for in-flight requests.- Binding to port 0 and reading
local_addravoids port clashes in tests.
Summary
tokiosupplies the runtime;tokio-util,tokio-stream, andbytessupply codecs, stream helpers, and buffers.tracingrecords events;tracing-subscriberprints them.towerdefinesServiceandLayer; middleware wraps services and stacks withServiceBuilder.axumserves HTTP onhyperandtower;reqwestcalls HTTP onhyper.- Extractors (
State,Json,Path) give handlers typed input, and mismatched JSON is rejected before the handler runs. - Set client timeouts explicitly and reuse one
Client. tonicandsqlxfollow the same runtime andtowerconventions.
