Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Admission control

The mailbox is bounded and the number of connections is not. Every accepted socket is a task, a buffer, and a file descriptor, and serve will happily accept ten thousand of them. Nothing in the loop counts:

#![allow(unused)]
fn main() {
let (stream, _) = listener.accept().await?;   // ... and again, and again
}

Semaphore holds a fixed number of permits:

#![allow(unused)]
fn main() {
Semaphore::new(limit);                            // an Arc<Semaphore> is what tasks share
Arc::clone(&permits).acquire_owned().await;       // -> Result<OwnedSemaphorePermit, AcquireError>
}

Take one before serving a connection, hold it until the connection ends, and the count of live connections cannot exceed the limit. acquire_owned rather than acquire because the permit has to be moved into the task, and the permit is released by its own Drop rather than by any call.

Where you acquire it is the design

Acquire before accept and the listener stops taking connections off the kernel’s backlog when it is at capacity. A client that cannot be served yet waits in the backlog queue, which is the operating system’s memory rather than yours, and if the backlog fills the kernel refuses the connection outright. That is a fast, cheap “no” that never reaches your process.

Acquire after accept and you have accepted a connection you cannot serve. Now it is your socket, your task, and your memory, waiting for a permit, and you have moved the queue from the kernel into your heap. That is the version that looks fine and falls over.

The general shape: refuse work as early as you can, at the outermost edge where you still know enough to refuse it.

Why acquire_owned

Semaphore::acquire returns a guard that borrows the semaphore, which cannot be moved into a spawned task. acquire_owned takes an Arc<Semaphore> and returns a permit that owns its share, so it can go into the async move block.

The permit’s Drop is the whole release mechanism. There is no release call, so the slot comes back exactly when the permit dies, which means a connection that ends any way at all, including by panicking, returns its permit.

That makes where the permit lives the only thing that matters, so hand it to handle_connection:

#![allow(unused)]
fn main() {
pub async fn handle_connection<S>(
    stream: S,
    store: &StoreHandle,
    idle: Duration,
    _permit: OwnedSemaphorePermit,   // held for the length of the call, dropped when it returns
) -> io::Result<()>
}

Nothing in the body uses it, hence the underscore prefix, which silences the warning while leaving it an ordinary binding. Written this way the signature states the rule the code was only implying, and a caller that forgets the permit is a compile error rather than a limit that quietly does nothing.

The alternative is to park it in the spawned block with let _permit = permit;, which works and is one character away from not working: let _ = permit; is not a binding at all, so it drops the permit on the spot and the limit stops existing silently.

Choosing the number

MAX_CONNECTIONS = 128 is, again, a guess with a meaning. Multiply it by the per-connection memory (a buffer, a task, and whatever the handler allocates) to get the worst case, and check that against the memory you have. Do the same for file descriptors, because ulimit -n is often 1024 and accept failing with EMFILE is a spectacularly confusing failure mode.

The limits in this chapter compose into a pattern worth naming. Bound the number of connections, so memory is bounded. Bound the mailbox, so queueing delay is bounded. Bound the time per request, so no single one can hold a slot forever. Any one of those alone leaves a way to fall over.

Exercise

The exercise for this section is located in 06_backpressure/02_limits