Networking with the Standard Library

Blocking Network IO

By default, all socket operations in std::net (TcpListener::accept, TcpStream::read, TcpStream::write, and UdpSocket::recv_from) perform blocking network I/O: when an operation cannot complete immediately, the OS kernel puts the calling thread to sleep until data arrives or buffer space becomes available.

Blocking Mode (Default):
  Thread calls stream.read(&mut buf)
       |
       v
  [Kernel Socket Receive Buffer Empty] ---> Thread sleeps (0% CPU)
       |
  [Packet Arrives from Network]        ---> Kernel wakes thread -> returns Ok(n)

Non-Blocking Mode (.set_nonblocking(true)):
  Thread calls stream.read(&mut buf)
       |
       v
  [Kernel Socket Receive Buffer Empty] ---> Returns immediately:
                                            Err(io::ErrorKind::WouldBlock)

Thread-Per-Connection Trade-Offs

In blocking mode, a thread blocked inside stream.read() cannot service any other connection until that read() completes.

Concurrency Scale Blocking I/O (std::net + OS Threads) Non-Blocking Event Loop (epoll/kqueue/IOCP)
1 to ~500 concurrent sockets Simple, sequential control flow; low overhead Unnecessary complexity unless latency-critical
10,000+ concurrent sockets High memory cost (~2 MiB stack per thread) and context-switch overhead Scales with a small fixed thread pool (e.g., mio, tokio)

Switching Sockets to Non-Blocking Mode

TcpListener, TcpStream, and UdpSocket all provide .set_nonblocking(bool) -> io::Result<()>. When set to true, any operation that would have waited for network activity returns Err with io::ErrorKind::WouldBlock immediately:

use std::io::{self, ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream};

fn main() -> io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:0")?;
    let addr = listener.local_addr()?;

    // Switch listener to non-blocking mode
    listener.set_nonblocking(true)?;

    // No client has connected yet, so accept() immediately returns WouldBlock
    match listener.accept() {
        Err(e) if e.kind() == ErrorKind::WouldBlock => {
            println!("listener.accept() returned WouldBlock as expected");
        }
        other => panic!("unexpected result: {other:?}"),
    }

    // Connect a client and accept it
    let mut client = TcpStream::connect(addr)?;
    let (mut server_stream, _) = listener.accept()?;
    server_stream.set_nonblocking(true)?;

    // Client hasn't sent data yet -> server read returns WouldBlock
    let mut buf = [0u8; 16];
    assert_eq!(
        server_stream.read(&mut buf).unwrap_err().kind(),
        ErrorKind::WouldBlock
    );

    // Client sends 5 bytes -> server read succeeds immediately
    client.write_all(b"hello")?;
    let n = server_stream.read(&mut buf)?;
    println!(
        "Non-blocking read after write got: {:?}",
        std::str::from_utf8(&buf[..n]).unwrap()
    );

    Ok(())
}
listener.accept() returned WouldBlock as expected
Non-blocking read after write got: "hello"

Avoiding Busy-Wait Spin Loops

In production code, never poll a non-blocking socket in a tight loop { match stream.read(...) { Err(WouldBlock) => continue, ... } } without sleeping or waiting on an OS readiness notification, as a spin loop pegs a CPU core at 100%. For single-socket bounded waiting in standard-library code, prefer set_read_timeout and set_write_timeout on a blocking socket.