Net #
Networking is one of Rust’s primary use cases — a language that promises C-level performance with compiler-guaranteed memory safety. std::net provides complete synchronous networking primitives: TCP for reliable and ordered communications, UDP for fast communications without delivery guarantees, and types for representing network addresses. Understanding std::net is an important foundation before stepping into async networking with Tokio or Hyper — many of the concepts are the same, just the execution model is different. This article discusses TCP servers and clients, UDP sockets, handling concurrent connections with threads, timeout management, and when synchronous networking is no longer enough.
Network Address Type #
Before creating a connection, Rust provides a type that represents the network address in a type-safe manner.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4};
fn main() {
// IpAddr — can be IPv4 or IPv6
let ipv4: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
let ipv6: IpAddr = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1));
// Parse from string
let ip: IpAddr = "192.168.1.1".parse().expect("IP tidak valid");
let ip6: IpAddr = "::1".parse().expect("IPv6 tidak valid");
// Type checking
println!("{}", ipv4.is_ipv4()); // true
println!("{}", ipv4.is_loopback()); // true (127.0.0.1)
println!("{}", ipv4.is_private()); // false (127.x.x.x is not a private range)
let private: IpAddr = "192.168.1.100".parse().unwrap();
println!("{}", private.is_private()); // true
// Ipv4Addr useful constant
println!("{}", Ipv4Addr::LOCALHOST); // 127.0.0.1
println!("{}", Ipv4Addr::UNSPECIFIED); // 0.0.0.0
println!("{}", Ipv4Addr::BROADCAST); // 255.255.255.255
// SocketAddr — combination of IP and port
let addr: SocketAddr = "127.0.0.1:8080".parse().expect("Alamat tidak valid");
println!("IP: {}", addr.ip()); // 127.0.0.1
println!("Port: {}", addr.port()); // 8080
// Create SocketAddr programmatically
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
let addr_v4 = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 3000);
// DNS resolution — changes hostname to SocketAddr
use std::net::ToSocketAddrs;
let addrs: Vec<SocketAddr> = "localhost:8080"
.to_socket_addrs()
.expect("Resolusi DNS gagal")
.collect();
println!("Resolved: {:?}", addrs);
}
TCP Server — Accepting Connections #
TcpListener listens for incoming connections on a specific address and port. Each connection received produces TcpStream which can be read and written.
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write, BufRead, BufReader};
fn tangani_klien(mut stream: TcpStream) {
let peer_addr = stream.peer_addr().unwrap();
println!("Koneksi baru dari: {}", peer_addr);
// Read data from client
let mut buffer = [0u8; 1024];
match stream.read(&mut buffer) {
Ok(0) => println!("Klien {} menutup koneksi", peer_addr),
Ok(n) => {
let pesan = String::from_utf8_lossy(&buffer[..n]);
println!("Diterima dari {}: {}", peer_addr, pesan.trim());
// Send response
let respons = format!("Echo: {}", pesan.trim());
stream.write_all(respons.as_bytes()).unwrap();
}
Err(e) => eprintln!("Error baca dari {}: {}", peer_addr, e),
}
}
fn main() {
// Bind to address — "0.0.0.0" means all interfaces
let listener = TcpListener::bind("127.0.0.1:7878")
.expect("Gagal bind ke port 7878");
println!("Server mendengarkan di {}", listener.local_addr().unwrap());
// accept() blocks until there is an incoming connection
for stream in listener.incoming() {
match stream {
Ok(stream) => tangani_klien(stream),
Err(e) => eprintln!("Error koneksi: {}", e),
}
}
}
Server that Handles Multiple Connections with Threads #
The server above can only handle one client at a time — the second client must wait for the first client to finish. For a real server, each connection needs to be handled in a separate thread.
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write};
use std::thread;
fn tangani_klien(mut stream: TcpStream) {
let peer = stream.peer_addr().unwrap();
let mut buffer = [0u8; 4096];
loop {
match stream.read(&mut buffer) {
Ok(0) => {
println!("Klien {} terputus", peer);
break;
}
Ok(n) => {
let data = &buffer[..n];
// Echo returns to the client
if stream.write_all(data).is_err() {
break;
}
}
Err(e) => {
eprintln!("Error pada {}: {}", peer, e);
break;
}
}
}
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
println!("Echo server berjalan di port 7878");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
// Spawn a new thread for each connection
thread::spawn(move || tangani_klien(stream));
}
Err(e) => eprintln!("Error accept: {}", e),
}
}
}
Simple HTTP Server from Scratch #
To understand how HTTP works at a low level, we can build a minimal HTTP server:
use std::net::{TcpListener, TcpStream};
use std::io::{BufRead, BufReader, Write};
use std::thread;
fn tangani_http(mut stream: TcpStream) {
let peer = stream.peer_addr().unwrap();
let reader = BufReader::new(stream.try_clone().unwrap());
// Read request line and headers
let mut baris: Vec<String> = Vec::new();
for line in reader.lines() {
match line {
Ok(l) if l.is_empty() => break, // blank line = end of headers
Ok(l) => baris.push(l),
Err(_) => break,
}
}
if baris.is_empty() {
return;
}
// Parse request line: "GET /path HTTP/1.1"
let request_line = &baris[0];
let bagian: Vec<&str> = request_line.split_whitespace().collect();
if bagian.len() < 2 {
return;
}
let method = bagian[0];
let path = bagian[1];
println!("{} {} dari {}", method, path, peer);
// Create response based on path
let (status, body) = match (method, path) {
("GET", "/") => (
"200 OK",
"<h1>Selamat datang!</h1><p>Server Rust berjalan.</p>"
),
("GET", "/health") => (
"200 OK",
r#"{"status": "ok"}"#
),
_ => (
"404 Not Found",
"<h1>404 - Halaman Tidak Ditemukan</h1>"
),
};
let content_type = if path == "/health" {
"application/json"
} else {
"text/html; charset=utf-8"
};
let respons = format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
status,
content_type,
body.len(),
body
);
stream.write_all(respons.as_bytes()).ok();
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
println!("HTTP server berjalan di http://localhost:8080");
for stream in listener.incoming().flatten() {
thread::spawn(move || tangani_http(stream));
}
}
TCP Client — Establishing a Connection #
TcpStream::connect() establishes a TCP connection to the server.
use std::net::TcpStream;
use std::io::{Read, Write};
use std::time::Duration;
fn main() {
// Simple connection
match TcpStream::connect("127.0.0.1:7878") {
Ok(mut stream) => {
println!("Terhubung ke server!");
// Send data
stream.write_all(b"halo server\n").unwrap();
// Read response
let mut buffer = [0u8; 1024];
let n = stream.read(&mut buffer).unwrap();
println!("Respons: {}", String::from_utf8_lossy(&buffer[..n]));
}
Err(e) => eprintln!("Gagal terhubung: {}", e),
}
// Connection with timeout
let addr = "127.0.0.1:7878".parse().unwrap();
match TcpStream::connect_timeout(&addr, Duration::from_secs(5)) {
Ok(stream) => println!("Terhubung dalam batas waktu"),
Err(e) => eprintln!("Timeout atau gagal: {}", e),
}
}
Simple HTTP Client from Scratch #
use std::net::TcpStream;
use std::io::{BufRead, BufReader, Write, Read};
fn http_get(host: &str, path: &str) -> Result<String, Box<dyn std::error::Error>> {
// Make a connection to port 80
let mut stream = TcpStream::connect(format!("{}:80", host))?;
// Send HTTP request
let request = format!(
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
path, host
);
stream.write_all(request.as_bytes())?;
// Read entire response
let mut respons = String::new();
stream.read_to_string(&mut respons)?;
// Separate headers from body
if let Some(pos) = respons.find("\r\n\r\n") {
Ok(respons[pos + 4..].to_string())
} else {
Ok(respons)
}
}
fn main() {
// For HTTPS, use crate reqwest or rustls
// HTTP plain text is increasingly rare in production
match http_get("example.com", "/") {
Ok(body) => println!("Body: {}...", &body[..body.len().min(200)]),
Err(e) => eprintln!("Error: {}", e),
}
}
For HTTP clients in production code, use the reqwest crate — it handles HTTPS, redirects, cookies, connection pooling, and timeouts automatically. A manual HTTP client from scratch is only useful for understanding very specific protocols or scenarios.Buffered I/O on TcpStream #
Reading and writing one byte or one character at a time is very inefficient because each operation results in a system call. BufReader and BufWriter add a buffer on top of the stream to reduce the number of system calls.
use std::net::{TcpListener, TcpStream};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::thread;
fn tangani_line_protocol(stream: TcpStream) {
let peer = stream.peer_addr().unwrap();
// Clone stream for separate reader and writer
let stream_writer = stream.try_clone().expect("Gagal clone stream");
let reader = BufReader::new(stream);
let mut writer = BufWriter::new(stream_writer);
// Read line by line — efficient because BufReader buffers
for line in reader.lines() {
match line {
Ok(baris) => {
println!("Dari {}: {}", peer, baris);
// Simple command processing
let respons = match baris.trim() {
"PING" => "PONG\n".to_string(),
"TIME" => format!("{}\n", chrono_sekarang()),
"QUIT" => {
writer.write_all(b"BYE\n").ok();
writer.flush().ok();
break;
}
perintah => format!("UNKNOWN: {}\n", perintah),
};
// BufWriter does not send immediately — flush required
writer.write_all(respons.as_bytes()).ok();
writer.flush().ok(); // send now
}
Err(_) => break,
}
}
}
fn chrono_sekarang() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
ts.to_string()
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:6379").unwrap();
println!("Server line-protocol berjalan di port 6379");
println!("Coba: nc localhost 6379 lalu ketik PING, TIME, atau QUIT");
for stream in listener.incoming().flatten() {
thread::spawn(move || tangani_line_protocol(stream));
}
}
Timeout on TcpStream #
Without timeouts, read and write operations on sockets can block forever — dangerous for servers that expect responses within a certain time.
use std::net::TcpStream;
use std::io::{Read, Write};
use std::time::Duration;
fn main() {
let mut stream = TcpStream::connect("127.0.0.1:7878").unwrap();
// Set timeout for read operations
stream.set_read_timeout(Some(Duration::from_secs(5)))
.expect("Gagal setel read timeout");
// Set timeout for write operations
stream.set_write_timeout(Some(Duration::from_secs(5)))
.expect("Gagal setel write timeout");
stream.write_all(b"request").unwrap();
let mut buffer = [0u8; 1024];
match stream.read(&mut buffer) {
Ok(n) => println!("Diterima: {}", String::from_utf8_lossy(&buffer[..n])),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
eprintln!("Timeout — server tidak merespons dalam 5 detik");
}
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {
eprintln!("Timeout — server tidak merespons dalam 5 detik");
}
Err(e) => eprintln!("Error: {}", e),
}
// Remove timeout — return to infinite blocking
stream.set_read_timeout(None).unwrap();
// Check the timeout that has been set
println!("{:?}", stream.read_timeout().unwrap()); // Some(5s)
println!("{:?}", stream.write_timeout().unwrap()); // Some(5s)
}
UDP Socket #
UDP is connectionless — each packet is sent independently without a handshake. Faster than TCP but does not guarantee delivery or sequencing.
use std::net::UdpSocket;
use std::time::Duration;
fn main() {
// UDP Server
let socket = UdpSocket::bind("127.0.0.1:8888")
.expect("Gagal bind UDP socket");
println!("UDP server mendengarkan di port 8888");
// recv_from return(number_of_bytes, sender_address)
let mut buffer = [0u8; 1024];
loop {
match socket.recv_from(&mut buffer) {
Ok((n, addr)) => {
let pesan = String::from_utf8_lossy(&buffer[..n]);
println!("Dari {}: {}", addr, pesan.trim());
// Send response to sender
let respons = format!("Echo: {}", pesan.trim());
socket.send_to(respons.as_bytes(), addr).ok();
}
Err(e) => eprintln!("Error: {}", e),
}
}
}
use std::net::UdpSocket;
use std::time::Duration;
fn main() {
// UDP Client
let socket = UdpSocket::bind("0.0.0.0:0") // port 0 = OS selects a free port
.expect("Gagal bind");
socket.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
// Send message
let server = "127.0.0.1:8888";
socket.send_to(b"halo UDP server", server).expect("Gagal kirim");
// Receive response
let mut buffer = [0u8; 1024];
match socket.recv_from(&mut buffer) {
Ok((n, addr)) => {
println!("Respons dari {}: {}", addr, String::from_utf8_lossy(&buffer[..n]));
}
Err(e) => eprintln!("Timeout atau error: {}", e),
}
// connect() on UDP — not a real connection, just an address filter
// After this, send/recv only with the specified address
socket.connect(server).unwrap();
socket.send(b"pesan via connected UDP").unwrap();
}
sequenceDiagram
participant Client
participant TCPServer as TCP Server
participant UDPServer as UDP Server
Note over Client,TCPServer: TCP — connection-oriented
Client->>TCPServer: SYN
TCPServer->>Client: SYN-ACK
Client->>TCPServer: ACK (handshake selesai)
Client->>TCPServer: Data
TCPServer->>Client: ACK + Data
Client->>TCPServer: FIN (tutup koneksi)
Note over Client,UDPServer: UDP — connectionless
Client->>UDPServer: Datagram (langsung)
UDPServer->>Client: Datagram (langsung, tidak dijamin)
Client->>UDPServer: Datagram (mungkin hilang, tidak ada notifikasi)Thread Pool for TCP Server #
Servers that create a new thread for each connection are not scalable — thousands of concurrent connections means thousands of threads, which consumes memory and overloads the OS scheduler. Thread pools limit the number of threads while still serving many connections.
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use std::sync::mpsc;
use std::thread;
type Job = Box<dyn FnOnce() + Send + 'static>;
struct ThreadPool {
_workers: Vec<thread::JoinHandle<()>>,
sender: mpsc::Sender<Option<Job>>,
}
impl ThreadPool {
fn new(ukuran: usize) -> Self {
let (tx, rx) = mpsc::channel::<Option<Job>>();
let rx = Arc::new(Mutex::new(rx));
let workers = (0..ukuran).map(|id| {
let rx = Arc::clone(&rx);
thread::spawn(move || loop {
let pesan = rx.lock().unwrap().recv().unwrap();
match pesan {
Some(job) => job(),
None => {
println!("Worker {} berhenti", id);
break;
}
}
})
}).collect();
ThreadPool { _workers: workers, sender: tx }
}
fn execute<F: FnOnce() + Send + 'static>(&self, f: F) {
self.sender.send(Some(Box::new(f))).unwrap();
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
// Send a stop signal to all workers
for _ in &self._workers {
self.sender.send(None).unwrap();
}
}
}
fn tangani_koneksi(mut stream: TcpStream) {
let mut buffer = [0u8; 1024];
if let Ok(n) = stream.read(&mut buffer) {
let respons = format!(
"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, World!"
);
stream.write_all(respons.as_bytes()).ok();
}
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
let pool = ThreadPool::new(8); // maximum 8 concurrent threads
println!("Server dengan thread pool berjalan di port 8080");
for stream in listener.incoming().flatten() {
pool.execute(move || tangani_koneksi(stream));
}
}
Non-Blocking I/O #
Non-blocking mode allows sockets to be operated without blocking threads — read() and write() return immediately even if there is no data.
use std::net::{TcpListener, TcpStream};
use std::io::{self, Read, Write};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
// Set listener to non-blocking
listener.set_nonblocking(true).unwrap();
let mut koneksi: Vec<TcpStream> = Vec::new();
loop {
// Try accepting new connections — not blocking
match listener.accept() {
Ok((stream, addr)) => {
println!("Koneksi baru dari: {}", addr);
stream.set_nonblocking(true).unwrap();
koneksi.push(stream);
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
// No new connections — continue
}
Err(e) => eprintln!("Error accept: {}", e),
}
// Process all existing connections
let mut masih_aktif = Vec::new();
for mut stream in koneksi.drain(..) {
let mut buffer = [0u8; 1024];
match stream.read(&mut buffer) {
Ok(0) => {
// Connection closed by client
println!("Klien {} terputus", stream.peer_addr().unwrap());
}
Ok(n) => {
// There is data — process and send back
stream.write_all(&buffer[..n]).ok();
masih_aktif.push(stream);
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
// No data currently — connection is still alive
masih_aktif.push(stream);
}
Err(e) => eprintln!("Error baca: {}", e),
}
}
koneksi = masih_aktif;
// Inefficient busy-wait — in production use epoll/kqueue via Tokio
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
Non-blocking I/O with busy-wait loop as above consumes CPU inefficiently. In production, use event-driven I/O viaepoll(Linux),kqueue(macOS), or IOCP (Windows). Tokio abstracts all this with a more ergonomic API — this is the main reason to use async networking in Rust.
When to Switch to Async Networking #
std::net is synchronous and blocking — each operation blocks the thread that called it. For servers handling many concurrent connections, this becomes a bottleneck.
Gunakan std::net jika:
✓ Jumlah koneksi bersamaan rendah (puluhan hingga ratusan)
✓ Setiap koneksi membutuhkan CPU-intensive work — thread per koneksi justru efisien
✓ Prototyping atau memahami networking dari level dasar
✓ Tool CLI yang membuat satu atau beberapa koneksi, bukan server
✓ Tidak ada dependency async di seluruh codebase
Beralih ke Tokio + async/await jika:
✗ Server perlu menangani ribuan koneksi bersamaan (C10K problem)
✗ Banyak operasi I/O-bound yang menunggu network atau disk
✗ Perlu integrasi dengan ecosystem async (reqwest, sqlx, axum)
✗ Latency per-request perlu diminimalkan
✗ Sudah menggunakan async di bagian lain codebase
Comparison of threading models:
| Aspect | std::net + threads per connection | Tokyo async |
|---|---|---|
| Concurrent connections | Hundreds (limited by RAM & OS) | Tens of thousands |
| Overhead per connection | ~8MB stack per thread | ~KB per task |
| Code complexity | Simpler | More complex (lifetime, Pin) |
| CPU-intensive work | Natural — threads running parallel | Need spawn_blocking |
| Ecosystem | Limited | Very rich (reqwest, sqlx, axum) |
| Debugging | Easier | More difficult |
flowchart TD
A{Jenis aplikasi networking?} --> B{Jumlah koneksi bersamaan?}
B -- Sedikit, puluhan --> C["std::net + thread per koneksi\nSederhana, cukup untuk use case ini"]
B -- Banyak, ribuan --> D["Tokio + async/await\nEfisien untuk I/O-bound massif"]
A --> E{CLI tool atau client?}
E -- Yes --> F["std::net langsung\natau reqwest untuk HTTP"]
E -- No --> B
C --> G{Perlu HTTP/HTTPS?}
G -- Yes --> H["Gunakan framework:\nAxum, Actix-web, Rocket"]
G -- No --> I[std::net TCP/UDP is sufficient]
D --> J{Protokol apa?}
J -- HTTP/REST --> K[Axum or Actix-web]
J -- TCP custom --> L[Tokio TcpListener]
J -- UDP --> M[Tokio UdpSocket]
style C fill:#e8f5e9
style D fill:#e3f2fd
style F fill:#e8f5e9
style H fill:#fff3e0
style K fill:#fff3e0Equivalent Example: std::net vs Tokio #
// ===== std::net (synchronous) =====
use std::net::TcpListener;
use std::io::{Read, Write};
use std::thread;
fn server_sync() {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
for stream in listener.incoming().flatten() {
thread::spawn(move || {
let mut stream = stream;
let mut buf = [0u8; 1024];
let n = stream.read(&mut buf).unwrap();
stream.write_all(&buf[..n]).unwrap();
});
}
}
// ===== Tokio (asynchronous) =====
// Cargo.toml: tokio = { version = "1", features = ["full"] }
use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn server_async() {
let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
loop {
let (mut stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let n = stream.read(&mut buf).await.unwrap();
stream.write_all(&buf[..n]).await.unwrap();
});
}
}
The structure is almost identical — the main differences are await and async. Understanding std::net first makes the transition to Tokio easier because the concept is the same.
Summary #
TcpListener::bindfor server,TcpStream::connectfor client — both returnResult; Handle bind errors (port already in use) and connect errors (server does not exist) explicitly.- Spawn threads per connection for simple servers —
thread::spawn(move || tangani_klien(stream))is the easiest pattern for concurrency. Add a thread pool if concurrent connections can reach hundreds.BufReaderandBufWriterfor efficiency — read line by line withBufReader::lines()instead of directread().BufWriteraccumulates data before sending — don’t forgetflush().- Always set timeout —
set_read_timeoutandset_write_timeoutprevent threads from blocking forever when the client is not responding. HandleErrorKind::TimedOutandErrorKind::WouldBlock.- UDP for speed, TCP for reliability — UDP doesn’t guarantee delivery or sequencing, but the overhead is much lower. Suitable for gaming, streaming, DNS, and metrics.
SocketAddr::from(([127,0,0,1], 8080))is safer than string parsing — no need forunwrapas it can’t fail. Use string parsing only for user input.std::netfor limited connections, Tokio for thousands of connections — threads per connection are not scalable as each thread takes up ~8MB of stack. Tokio task is only a few KB overhead.- Non-blocking I/O without an event loop is inefficient — busy-wait loop consumes CPU. If you need non-blocking, use Tokio which has integrated epoll/kqueue/IOCP.
← Previous: Process