In this chapter, we'll use asynchronous Rust to modify the Rust book's single-threaded web server to serve requests concurrently. Here's what the code looked like at the end of the lesson:
src/main.rs
:
hello.html
:
404.html
:
If you run the server with cargo run
and visit 127.0.0.1:7878
in your browser,
you'll be greeted with a friendly message from Ferris!
As the book explains, we don't want our web server to wait for each request to finish before handling the next, as some requests could be very slow. Instead of improving throughput by adding threads, we'll use asynchronous code to process requests concurrently.
Let's modify handle_connection
to return a future by declaring it an async fn
:
Adding async
to the function declaration changes its return type
from the unit type ()
to a type that implements Future<Output=()>
.
If we try to compile this, the compiler warns us that it will not work:
Because we haven't await
ed or poll
ed the result of handle_connection
,
it'll never run. If you run the server and visit 127.0.0.1:7878
in a browser,
you'll see that the connection is refused; our server is not handling requests.
We can't await
or poll
futures within synchronous code by itself.
We'll need an executor to handle scheduling and running futures to completion.
Please consult the section Choosing an Executor for more information on executors.
Here, we'll use the run
executor from the smol
crate.
It might be tempting to write something like this:
However, just because this program uses an asynchronous connection handler
doesn't mean that it handles connections concurrently.
To illustrate this, try out the
simulation of a slow request
from the Book. You'll see that one slow request will block any other incoming requests!
This is because there are no other concurrent tasks that can make progress
while we are await
ing the result of handle_connection
.
The problem with our code so far is that listener.incoming()
is a blocking iterator;
we can't read a new request from this stream until we're done with the previous one.
One strategy to work around this is to spawn a new Task to handle each connection in the background:
This works because under the hood, the smol
executor runs handle_connection
on a separate thread.
However, this doesn't completely solve our problem: listener.incoming()
still blocks the executor.
Even if connections are handled in separate threads, futures running on the main thread
are blocked while listener
waits on incoming connections.
In order to fix this, we can use the smol::Async
trait to transform our TcpListener
into an asynchronous TcpListener
:
This change prevents listener.incoming()
from blocking the executor
by allowing us to await
the next TCP connection on this port.
Now, the executor can yield to other futures running on the main thread
while there are no incoming TCP connections to be processed.
(Note that this trait still does not allow the stream to emit items concurrently.
We still need to process a stream or spawn a task to handle it before moving on to the next one.)
Let's update our example to make use of the Async<TcpListener>
.
First, we'll need to update our code to await
the next incoming connection,
rather than iterating over listener.incoming()
:
Lastly, we'll have to update our connection handler to accept an Async::<TcpStream>
:
Let's move on to testing our handle_connection
function.
First, we need a TcpStream
to work with, but we don't want to make a real TCP connection in test code.
We could work around this in a few ways.
One strategy could be to refactor the code to be more modular,
and only test that the correct responses are returned for the respective inputs.
Another strategy is to connect to localhost
on port 0.
Port 0 isn't a valid UNIX port, but it'll work for testing.
The operating system will return a connection on any open TCP port.
Instead of those strategies, we'll change the signature of handle_connection
to make it easier to test.
handle_connection
doesn't actually require an Async<TcpStream>
;
it requires any struct that implements AsyncRead
, AsyncWrite
, and Unpin
.
Changing the type signature to reflect this allows us to pass a mock for testing instead of a TcpStream.
Next, let's create a mock TcpStream with an underlying Vec
representing its contents,
and implement AsyncRead
, AsyncWrite
, and Unpin
.
(For more information on pinning and the Unpin
trait, see the [section on pinning](insert link).)
Now we're ready to test the handle_connection
function.
After setting up the MockTcpStream
containing some initial data,
we can run handle_connection
using the smol
executor, exactly as we did in the main method.