Rust
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Ownership and Borrowing
-
Section 4: Structuring Data
-
Section 5: Collections and Error Handling
-
Section 6: Traits and Generics
-
Section 7: Concurrency
-
Section 8: Building for the Web
-
Section 9: Memory and Performance
-
Section 10: More Standard Library and Ecosystem
-
Section 11: Advanced Rust
-
Section 12: Rust for Systems and WebAssembly
-
Section 13: Tooling and Best Practices
-
Section 14: Data Structures and Algorithms in Rust
-
Section 15: Practical Projects
-
Section 16: Interview Practice
-
Section 17: std::collections In Depth
-
Section 18: std::io and std::fs In Depth
-
Section 19: std::net
-
Section 20: std::option and std::result In Depth
-
Section 21: std::iter In Depth
-
Section 22: std::sync In Depth
-
Section 23: std::string and std::str
-
Section 24: Cargo and Crates.io Ecosystem
-
Section 25: Popular Crates Ecosystem
-
Section 26: Rust Memory Model Deep Dive
-
Section 27: More Practice Exercises
-
Section 28: More Interview Practice
-
Section 29: Async Rust Deep Dive
-
Section 30: Tokio Ecosystem In Depth
-
Section 31: Error Handling Ecosystem Deep Dive
-
Section 32: Serde In Depth
-
Section 33: Web Development with Rust Deep Dive
-
Section 34: Database Access Ecosystem
-
Section 35: Rust for Embedded Systems Deep Dive
-
Section 36: Rust Macros In Depth
-
Section 37: Advanced Trait System
-
Section 38: Unsafe Rust In Depth
-
Section 39: Rust CLI Development
-
Section 40: Testing Ecosystem Deep Dive
-
Section 41: WebAssembly Deep Dive
-
Section 42: Rust Design Patterns
-
Section 43: More Data Structures in Rust
-
Section 44: Final Practice Projects
-
Section 45: Rust Performance Optimization
-
Section 46: Rust Ecosystem Tooling
-
Section 47: More Interview and Review
86: Building a Simple Chat Server with Tokio
We've spent some time talking about the theory of async Rust and how tokio manages tasks, but now it's time to actually put it to work. We're going to build a basic TCP chat server. The goal is simple: anyone who connects via telnet or netcat should be able to send a message, and that message should be broadcast to every other connected user.
Setting up the TCP listener
First, we need a way to accept incoming connections. I'm using TcpListener from tokio::net. The pattern here is a classic loop where we accept() a connection and then immediately spawn a new task to handle that specific client. I don't want one slow client to block the entire server from accepting new people.
use tokio::net::TcpListener;
use tokio::sync::broadcast;
#[tokio::main]
async fn main() -> tokio::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
// We need a way to send messages to all clients.
// A broadcast channel is perfect for this "one-to-many" pattern.
let (tx, _rx) = broadcast::channel(10);
loop {
let (socket, _) = listener.accept().await?;
let tx = tx.clone();
let rx = tx.subscribe();
tokio::spawn(async move {
if let Err(e) = handle_client(socket, tx, rx).await {
eprintln!("Client error: {:?}", e);
}
});
}
}
Dealing with the Read/Write split
Now for the handle_client function. Here's where I usually trip up if I'm rushing. Initially, I tried to just use the socket directly in a loop. But wait—I need to do two things simultaneously: I need to listen for messages coming from the client, and I need to listen for messages coming from the broadcast channel to send them back to the client.
I originally tried to just wrap the socket in an Arc<Mutex>, but that's overkill and creates a bottleneck. The right way to do this in Tokio is to split the TcpStream into a read half and a write half. This allows us to handle reading and writing in separate concurrent flows without fighting the borrow checker.
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::{broadcast, broadcast::Receiver};
async fn handle_client(mut socket: TcpStream, tx: broadcast::Sender<String>, mut rx: Receiver<String>) -> tokio::io::Result<()> {
let (mut reader, mut writer) = socket.split();
let mut read_buf = [0u8; 1024];
loop {
tokio::select! {
// Case 1: We receive a message from the client socket
result = reader.read(&mut read_buf) => {
let n = result?;
if n == 0 { return Ok(()); } // Connection closed
let msg = String::from_utf8_lossy(&read_buf[..n]).to_string();
let _ = tx.send(msg);
}
// Case 2: We receive a message from the broadcast channel
result = rx.recv() => {
let msg = result.map_err(|e| tokio::io::Error::new(tokio::io::ErrorKind::Other, e))?;
writer.write_all(msg.as_bytes()).await?;
}
}
}
}
Correcting the "Echo" mistake
When I first ran this, I noticed a frustrating bug: every message I sent was immediately echoed back to me. While that's technically a broadcast, it's annoying in a chat app. I realized that the tx.send(msg) sends the message to everyone who has a Receiver, including the person who just sent it.
To fix this properly in a real app, I'd give each user a unique ID and filter messages based on that ID. For this simple example, though, I'll just accept the echo or implement a simple check. But there's a more critical technical detail here: broadcast::channel can return a RecvError::Lagged if a client is too slow to keep up with the messages. In the code above, I'm mapping that error to a tokio::io::Error, which effectively drops the slow client. In a production system, you'd probably want to handle Lagged by notifying the user they missed some messages.
Why tokio::select! is the secret sauce
The most important part of this build is tokio::select!. Without it, you'd have to spawn two tasks per client—one for reading and one for writing. That would double your task overhead. select! lets us wait on multiple async operations and react to whichever one finishes first. It's the cleanest way to implement a "multiplexed" connection where the server is reacting to both the network and internal channel events simultaneously.
📋 Practical Task
Exercise: Implementing User Nicknames
Currently, the chat server is anonymous; you just see a wall of text. Your task is to modify the handle_client function to support a simple nickname system.
- Modify the server so that the first message a client sends is treated as their nickname.
- The nickname should not be broadcast to others, but it should be stored for that session.
- Every subsequent message sent by that client should be broadcast in the format:
"Nickname: message". - Hint: You'll need to introduce a state variable inside
handle_client(e.g.,let mut nickname: Option<String> = None;) to track whether the user has already identified themselves.
There are no comments for now.