Danh sách bài viết

Bài 294: std::net — TcpStream, TcpListener Basic

Bài 294 của series Rust Cơ Bản — module std::net trong standard library cung cấp networking primitive thuần blocking: TcpListener mở một port chờ kết nối, TcpStream đại diện cho một kết nối TCP đầy đủ, UdpSocket cho UDP. Khác hẳn ngôn ngữ scripted như Node.js (event loop) hay Go (goroutine), std::net mặc định blocking — một thread chỉ phục vụ được một client tại một thời điểm. Bài này dạy đủ API cốt lõi: TcpListener::bind("127.0.0.1:8080"), accept() trả về tuple (TcpStream, SocketAddr), TcpStream::connect phía client, đọc/ghi qua Read + Write trait quen thuộc, vòng lặp for stream in listener.incoming(), hai option timeout set_nonblocking / set_read_timeout, đồng thời chỉ rõ giới hạn (1 client một thread) để mở đường cho preview tokio::net async ở các bài Group async sau này.

10/06/2026
10 phút đọc
0 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Mở một TCP server bằng TcpListener::bind("127.0.0.1:8080") và hiểu ý nghĩa parameter SocketAddr.
  • Dùng listener.accept() hoặc vòng for stream in listener.incoming() để nhận kết nối client.
  • Kết nối server bằng TcpStream::connect phía client và gửi/nhận data.
  • Đọc/ghi qua TcpStream bằng Read/Write trait quen thuộc, kết hợp BufReader/BufWriter.
  • Hiểu blocking IO mặc định của std::net và giới hạn 1 client một thread.
  • Cấu hình set_nonblocking(true)set_read_timeout(Some(Duration)) khi không muốn block vô hạn.
  • Biết UdpSocket cơ bản và lý do chọn tokio::net khi cần phục vụ hàng nghìn kết nối đồng thời.
2

TcpListener::bind — Mở Port Server

Server TCP bắt đầu bằng việc bind một địa chỉ và port. Module std::net cung cấp TcpListener cho mục đích đó. Hàm bind nhận tham số impl ToSocketAddrs — có thể là string "127.0.0.1:8080", tuple (IpAddr, u16), hoặc SocketAddr đầy đủ.

use std::net::TcpListener;

fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:8080")?;
    println!("Listening on {}", listener.local_addr()?);

    for stream in listener.incoming() {
        let stream = stream?;
        println!("New client: {}", stream.peer_addr()?);
    }
    Ok(())
}

Vài chi tiết quan trọng. Địa chỉ 127.0.0.1 là loopback — chỉ máy hiện tại kết nối được. Dùng "0.0.0.0:8080" để bind mọi network interface (LAN, public). Port dưới 1024 cần quyền root trên Unix. Nếu port đã có process khác chiếm, bind trả Err với code AddrInUse.

Method local_addr() hữu ích khi bind port 0 — OS tự cấp port trống, ta đọc lại để biết. Vòng for stream in listener.incoming() là iterator vô hạn (không bao giờ trả None trừ khi listener bị close), mỗi vòng block đợi client mới.

3

accept() Trả Về (TcpStream, SocketAddr)

Phía dưới incoming() chính là method accept(). Signature:

pub fn accept(&self) -> io::Result<(TcpStream, SocketAddr)>

Hàm này block thread cho tới khi có client kết nối, sau đó trả về tuple gồm TcpStream (kênh đọc/ghi với client đó) và SocketAddr (địa chỉ remote của client). So với incoming(), gọi accept() trực tiếp cho phép xử lý loop tuỳ ý:

use std::net::TcpListener;

let listener = TcpListener::bind("127.0.0.1:0")?;
println!("port = {}", listener.local_addr()?.port());

loop {
    let (stream, addr) = listener.accept()?;
    println!("client {} connected", addr);
    // xử lý stream...
}

incoming() là wrapper iterator vô hạn quanh accept() — mỗi .next() tương đương một call accept() rồi map sang Result<TcpStream> (bỏ phần SocketAddr). Nếu cần địa chỉ remote, hoặc cần kết hợp với break/continue phức tạp, gọi accept() trong loop rõ ràng hơn.

SocketAddr là enum V4(SocketAddrV4) / V6(SocketAddrV6), có method ip(), port(). Lưu log hoặc check whitelist bằng địa chỉ này.

4

TcpStream::connect — Phía Client

Phía client tạo kết nối bằng TcpStream::connect. Tham số cũng impl ToSocketAddrs nên dùng string là tiện nhất:

use std::io::Write;
use std::net::TcpStream;

fn main() -> std::io::Result<()> {
    let mut stream = TcpStream::connect("127.0.0.1:8080")?;
    stream.write_all(b"GET / HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
    Ok(())
}

connect block thread cho tới khi handshake TCP hoàn tất hoặc lỗi. Nếu server không tồn tại, trả ConnectionRefused; nếu host không resolve được, lỗi DNS. Có phiên bản connect_timeout(&addr, Duration) giới hạn thời gian chờ — hữu ích khi mạng chậm, tránh treo thread vĩnh viễn.

Lưu ý sự khác biệt: bind nhận string parse trực tiếp được, còn connect_timeout nhận &SocketAddr đã parse sẵn (vì cần resolve DNS trước rồi mới đo timeout cho từng địa chỉ). Pattern:

use std::net::{SocketAddr, TcpStream};
use std::time::Duration;

let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?;

Sau khi có TcpStream, phía client và phía server dùng API hoàn toàn giống nhau để đọc/ghi — TCP là full-duplex.

5

TcpStream Implement Read + Write

Điểm thiết kế tinh tế nhất của std::net: TcpStream implement cả std::io::Read lẫn std::io::Write. Tức là toàn bộ kỹ thuật học ở Group I/O áp dụng được — read_to_string, write_all, BufReader, BufWriter, copy... đều dùng chung.

use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;

fn handle(mut stream: TcpStream) -> std::io::Result<()> {
    let mut reader = BufReader::new(stream.try_clone()?);
    let mut line = String::new();
    reader.read_line(&mut line)?;
    println!("Received: {}", line.trim());

    stream.write_all(b"HTTP/1.0 200 OK\r\n\r\nHello\n")?;
    Ok(())
}

Lưu ý BufReader::new consume TcpStream, vì vậy cần try_clone() để giữ một handle riêng cho ghi. try_clone trả về một TcpStream mới chia sẻ cùng socket OS — đọc và ghi đồng thời được vì TCP full-duplex.

Một echo server hoàn chỉnh chỉ khoảng 10 dòng nhờ tận dụng std::io::copy:

use std::io::copy;
use std::net::TcpListener;

fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:7000")?;
    for stream in listener.incoming() {
        let mut s = stream?;
        let mut r = s.try_clone()?;
        std::thread::spawn(move || { let _ = copy(&mut r, &mut s); });
    }
    Ok(())
}

10 dòng cho echo server đầy đủ thread-per-client — đây là sức mạnh của trait abstraction. copy đọc r và ghi sang s đến khi EOF (client đóng nửa-send), hoạt động vì TCP cho phép half-close mỗi chiều độc lập.

6

Blocking IO: 1 Client Một Thread

Mặc định toàn bộ std::net là blocking. accept() block đến khi có client; read block đến khi có byte; write block đến khi buffer kernel có chỗ. Hệ quả: một thread chỉ phục vụ được một client cùng lúc — nếu client đang ở giữa request, thread không thể đi nhận client mới.

Giải pháp truyền thống: thread-per-client. Mỗi lần accept() trả về một TcpStream, spawn thread mới xử lý nó:

use std::net::TcpListener;
use std::thread;

let listener = TcpListener::bind("127.0.0.1:8080")?;
for stream in listener.incoming() {
    let stream = stream?;
    thread::spawn(move || {
        // handle(stream) — chạy trong thread riêng
    });
}

Mô hình này đơn giản, code thẳng. Nhược điểm: mỗi thread OS chiếm ~2MB stack default, context-switch tốn CPU. Với 100 client cùng lúc thì OK, với 10.000 client ("C10K problem") không scale — đây là lý do ngành chuyển sang async (event loop) cho server high-throughput.

Cải tiến nhẹ: dùng rayon, threadpool crate để giới hạn số thread tối đa, tránh fork không kiểm soát. Cải tiến lớn: chuyển sang tokio::net — sẽ nói ở bước 8.

7

set_nonblocking & set_read_timeout

Khi không muốn thread block vô hạn, có hai cơ chế. Thứ nhất, set_nonblocking(true) đặt socket sang non-blocking mode — mọi operation trả ngay với lỗi WouldBlock nếu chưa có data:

use std::io::ErrorKind;
use std::net::TcpListener;

let listener = TcpListener::bind("127.0.0.1:8080")?;
listener.set_nonblocking(true)?;

loop {
    match listener.accept() {
        Ok((stream, addr)) => { /* xử lý */ }
        Err(e) if e.kind() == ErrorKind::WouldBlock => {
            // không có client — làm việc khác hoặc sleep nhẹ
            std::thread::sleep(std::time::Duration::from_millis(10));
        }
        Err(e) => return Err(e),
    }
}

Mode này là nền tảng cho event loop manual (dùng poll/epoll/kqueue qua crate mio). Tokio cũng dùng non-blocking ngầm bên dưới.

Thứ hai, nếu vẫn muốn blocking nhưng giới hạn thời gian: set_read_timeout / set_write_timeout nhận Option<Duration>. Khi timeout, operation trả lỗi WouldBlock hoặc TimedOut:

use std::time::Duration;

stream.set_read_timeout(Some(Duration::from_secs(10)))?;
let mut buf = [0u8; 1024];
match stream.read(&mut buf) {
    Ok(n) => println!("got {} bytes", n),
    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
        println!("client im lặng quá 10s — đóng kết nối");
    }
    Err(e) => return Err(e),
}

Idiom thường gặp: set timeout ngắn (5-30 giây) để dọn dẹp client zombie không gửi gì, tránh server chết treo vì connection quên close.

8

UdpSocket Và Preview tokio::net

std::net còn có UdpSocket cho protocol UDP — connectionless, không bảo đảm thứ tự, mỗi datagram độc lập. API ngắn gọn:

use std::net::UdpSocket;

let sock = UdpSocket::bind("127.0.0.1:9000")?;
let mut buf = [0u8; 1500];
let (n, peer) = sock.recv_from(&mut buf)?;
sock.send_to(&buf[..n], peer)?; // echo UDP

UDP dùng cho DNS, NTP, game realtime, video streaming — chỗ chấp nhận mất gói, ưu tiên latency thấp. So với TCP, UDP không có accept() — một socket nhận datagram từ mọi sender, phân biệt qua SocketAddr trả về từ recv_from.

Preview tokio::net: Khi cần phục vụ hàng nghìn kết nối, đổi sang Tokio. API rất giống nhưng async:

use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    loop {
        let (mut socket, _) = listener.accept().await?;
        tokio::spawn(async move {
            socket.write_all(b"hi\n").await.unwrap();
        });
    }
}

Khác biệt: thêm .awaitasync fn, runtime #[tokio::main]. Mỗi tokio::spawn là một task — chi phí vài KB thay vì 2MB của thread OS. Một runtime đa luồng có thể xử lý đồng thời 10.000+ kết nối trên 1 binary. Group async sẽ học sâu Tokio; std::net là nền tảng để hiểu API đó.

9

Tổng Kết

  • std::net cung cấp TCP/UDP thuần blocking trong standard library, không cần dependency ngoài.
  • TcpListener::bind("addr:port") mở port server; accept() trả (TcpStream, SocketAddr), block đến khi có client.
  • for stream in listener.incoming() là wrapper iterator vô hạn quanh accept(), chỉ trả TcpStream (mất SocketAddr).
  • Phía client: TcpStream::connect("addr:port") hoặc connect_timeout(&SocketAddr, Duration).
  • TcpStream impl cả Read + Write — dùng được với BufReader, BufWriter, io::copy như mọi I/O thông thường; cần try_clone() để có handle đọc và ghi riêng.
  • Blocking mặc định ⇒ mô hình thread-per-client; không scale 10.000+ kết nối.
  • set_nonblocking(true) chuyển socket sang non-blocking (operation trả WouldBlock nếu không sẵn sàng); set_read_timeout(Some(Duration)) giới hạn thời gian block.
  • UdpSocket cho connectionless protocol — không có accept, phân biệt sender qua recv_from trả về địa chỉ.
  • tokio::net là API async tương đương — đổi sang khi cần phục vụ nhiều kết nối đồng thời mà không cần một thread mỗi client.
10

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Khác biệt giữa bind địa chỉ "127.0.0.1:8080""0.0.0.0:8080" là gì? Khi nào nên dùng mỗi loại?
  2. Vì sao BufReader::new(stream) cần đi kèm stream.try_clone() nếu muốn vừa đọc vừa ghi cùng socket?
  3. Trong mô hình thread-per-client, server bị "C10K problem" — giải thích vấn đề và nói tại sao tokio::net giải quyết được.
  4. Khác biệt giữa set_nonblocking(true)set_read_timeout(Some(Duration)) — cả hai cùng "không block vô hạn" nhưng semantics khác thế nào?
  5. UDP không có accept() — vậy server UDP phân biệt các client như thế nào trong vòng lặp xử lý?
  6. Viết pseudo code echo server TCP blocking 1 client một thread không quá 12 dòng dùng API trong bài.
Đáp án
  1. 127.0.0.1 là loopback — chỉ accept kết nối từ chính máy đang chạy server, không thấy được từ LAN/internet. Dùng cho dev local, IPC nội bộ, healthcheck. 0.0.0.0 bind mọi network interface — accept cả LAN và public (nếu public IP). Dùng khi muốn server tiếp khách thật. Khuyến nghị: dev luôn bind 127.0.0.1 tránh exposed; production deploy sau reverse-proxy (nginx) thường vẫn bind 127.0.0.1 và để nginx forward, chỉ bind 0.0.0.0 khi server đứng trực tiếp public.
  2. BufReader::new(stream) consume (move) stream vào reader để giữ quyền sở hữu duy nhất buffer + source. Nhưng socket TCP cần handle khác để gọi write đồng thời. try_clone() tạo thêm một TcpStream chia sẻ cùng socket descriptor ở tầng OS — đọc trên handle này, ghi trên handle kia, kernel coi cả hai là cùng connection. TCP full-duplex cho phép song song đọc/ghi không xung đột.
  3. "C10K problem" = thách thức phục vụ 10.000 client đồng thời trên 1 máy. Với thread-per-client: 10.000 thread × 2MB stack = 20GB RAM — không khả thi; context-switch giữa 10.000 thread tốn CPU lớn. Tokio dùng async/await trên một số ít OS thread (thường = số CPU core), mỗi task chỉ chiếm vài KB stack ảo (state machine). Khi task block I/O, runtime park task và chạy task khác trên cùng thread. Kết quả: 10.000+ kết nối chạy trên 4-8 thread OS, RAM vài chục MB thay vì hàng GB.
  4. set_nonblocking(true): mọi operation trả ngay — có data thì trả Ok, không thì trả Err(WouldBlock) ngay lập tức không chờ. Caller phải tự retry hoặc tích hợp event loop (mio/tokio). set_read_timeout(Some(d)): vẫn block nhưng tối đa d giây — sau đó trả WouldBlock/TimedOut. Nonblocking là semantic "không bao giờ chờ", timeout là semantic "chờ có giới hạn". Nonblocking dùng với event loop; timeout dùng với code blocking truyền thống để dọn dẹp connection zombie.
  5. UDP không có connection — mỗi datagram độc lập. Server gọi sock.recv_from(&mut buf) trả tuple (n_bytes, SocketAddr). Server phân biệt client qua SocketAddr trả về: muốn gửi reply về đúng client thì sock.send_to(data, addr). Muốn track state cho từng client, dùng HashMap<SocketAddr, ClientState> tự quản lý — UDP không tự nhớ gì.
  6. Tham khảo: let l = TcpListener::bind("127.0.0.1:7000")?; sau đó for s in l.incoming() { let mut s = s?; let mut r = s.try_clone()?; thread::spawn(move || { std::io::copy(&mut r, &mut s).ok(); }); }. Đúng đủ chức năng echo: spawn thread mỗi client, dùng io::copy đọc từ socket clone, ghi lại socket gốc, chạy đến khi EOF (client đóng).
11

Bài Tiếp Theo

Bài 295: std::sync — Mutex, RwLock, Arc, Barrier — review các primitive đồng bộ trong stdlib, bổ sung Barrier (rendezvous N thread), Once/OnceLock (lazy init thread-safe), và preview CondVar cho producer-consumer pattern. Cần để build TCP server đa thread chia sẻ state an toàn.