Mục lục
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 parameterSocketAddr. - Dùng
listener.accept()hoặc vòngfor stream in listener.incoming()để nhận kết nối client. - Kết nối server bằng
TcpStream::connectphía client và gửi/nhận data. - Đọc/ghi qua
TcpStreambằngRead/Writetrait quen thuộc, kết hợpBufReader/BufWriter. - Hiểu blocking IO mặc định của
std::netvà giới hạn 1 client một thread. - Cấu hình
set_nonblocking(true)vàset_read_timeout(Some(Duration))khi không muốn block vô hạn. - Biết
UdpSocketcơ bản và lý do chọntokio::netkhi cần phục vụ hàng nghìn kết nối đồng thời.
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.
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.
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.
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.
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.
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.
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 .await và async 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 đó.
Tổng Kết
std::netcung 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 quanhaccept(), chỉ trảTcpStream(mấtSocketAddr).- Phía client:
TcpStream::connect("addr:port")hoặcconnect_timeout(&SocketAddr, Duration). TcpStreamimpl cảRead+Write— dùng được vớiBufReader,BufWriter,io::copynhư mọi I/O thông thường; cầntry_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ảWouldBlocknếu không sẵn sàng);set_read_timeout(Some(Duration))giới hạn thời gian block.UdpSocketcho connectionless protocol — không cóaccept, phân biệt sender quarecv_fromtrả về địa chỉ.tokio::netlà 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.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Khác biệt giữa bind địa chỉ
"127.0.0.1:8080"và"0.0.0.0:8080"là gì? Khi nào nên dùng mỗi loại? - Vì sao
BufReader::new(stream)cần đi kèmstream.try_clone()nếu muốn vừa đọc vừa ghi cùng socket? - Trong mô hình thread-per-client, server bị "C10K problem" — giải thích vấn đề và nói tại sao
tokio::netgiải quyết được. - Khác biệt giữa
set_nonblocking(true)vàset_read_timeout(Some(Duration))— cả hai cùng "không block vô hạn" nhưng semantics khác thế nào? - 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ý? - 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
127.0.0.1là 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.0bind 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 bind127.0.0.1tránh exposed; production deploy sau reverse-proxy (nginx) thường vẫn bind127.0.0.1và để nginx forward, chỉ bind0.0.0.0khi server đứng trực tiếp public.BufReader::new(stream)consume (move)streamvào reader để giữ quyền sở hữu duy nhất buffer + source. Nhưng socket TCP cần handle khác để gọiwriteđồng thời.try_clone()tạo thêm mộtTcpStreamchia 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.- "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.
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 đadgiâ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.- 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 quaSocketAddrtrả về: muốn gửi reply về đúng client thìsock.send_to(data, addr). Muốn track state cho từng client, dùngHashMap<SocketAddr, ClientState>tự quản lý — UDP không tự nhớ gì. - 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ùngio::copyđọc từ socket clone, ghi lại socket gốc, chạy đến khi EOF (client đóng).
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.
