Mở đầu loạt bài về mạng cho lập trình viên, ta bắt đầu từ thứ xảy ra trước khi bất kỳ dữ liệu nào được gửi: bắt tay TCP. Mỗi lần bạn gọi net.Dial, mở một kết nối database, hay gửi một request HTTP tới server mới, TCP thực hiện một nghi thức ba bước — SYN, SYN-ACK, ACK — để hai bên đồng ý bắt đầu nói chuyện. Nghi thức này tốn đúng một vòng khứ hồi (RTT) trước khi byte dữ liệu đầu tiên đi qua. Hiểu và đo được chi phí này là nền tảng để hiểu vì sao keep-alive, connection pool, và HTTP/2 tồn tại. Bài này (phần 1 loạt Mạng) chạy thật trong Go để nhìn bắt tay từ góc code.
Cơ chế: ba bước và ánh xạ với lời gọi
Bắt tay TCP gồm ba gói, và chúng ánh xạ trực tiếp với các lời gọi trong code:
CLIENT SERVER
| ---- SYN ---------------> | // client: net.Dial gửi SYN
| <--- SYN-ACK ------------ | // server: Accept trả SYN-ACK
| ---- ACK ---------------> | // client: trả ACK -> ESTABLISHED
| [ ESTABLISHED ] |
Điểm mấu chốt: ba gói này là một vòng khứ hồi (client gửi SYN, chờ SYN-ACK về, rồi gửi ACK). Chỉ sau khi bắt tay xong, dữ liệu mới bắt đầu chảy. Trên mạng thực với RTT 20–100ms, mỗi kết nối mới trả từng đó thời gian chỉ để chào hỏi, trước cả khi gửi một byte hữu ích.
// SERVER
ln, _ := net.Listen("tcp", "127.0.0.1:0")
c, _ := ln.Accept() // nhận SYN, hoàn tất bắt tay phía server
// CLIENT
conn, _ := net.Dial("tcp", addr) // gửi SYN, chờ SYN-ACK, trả ACK
// Dial trả về = bắt tay XONG, sẵn sàng gửi dữ liệu

Hình 1: Bắt tay ba bước SYN/SYN-ACK/ACK ánh xạ với Dial (client) và Accept (server) — một RTT trọn vẹn trước khi gửi dữ liệu; so kết nối mới mỗi request với tái dùng một kết nối.
Đo thật: chi phí bắt tay và lợi ích tái dùng
Mình chạy một TCP echo server và client trong cùng container (localhost) rồi đo:

Hình 2: Chạy thật — bắt tay localhost trung bình 16,7µs (localhost RTT~0; mạng thực = 1 RTT); gửi 2000 request: kết nối mới 40,35µs/request vs tái dùng 3,05µs/request (nhanh hơn ~13,2 lần); trạng thái socket LISTEN=1, ESTABLISHED=80, TIME_WAIT=4001.
Đọc kết quả đo được:
- Thời gian bắt tay trên localhost = 16,7µs: rất nhanh, vì localhost có RTT gần bằng 0 — thời gian này chỉ là chi phí kernel dựng socket, không có độ trễ mạng. Báo trung thực: con số này KHÔNG đại diện cho mạng thực. Trên Internet, bắt tay tốn đúng một RTT — nếu RTT là 50ms thì mỗi kết nối mới tốn ~50ms chỉ để chào hỏi, lớn hơn con số localhost hàng nghìn lần.
- Tái dùng kết nối nhanh hơn ~13 lần: gửi 2000 request, mở kết nối mới mỗi lần tốn 40,35µs/request (gồm bắt tay mỗi lần), còn tái dùng một kết nối chỉ 3,05µs/request (bắt tay đúng một lần). Ngay trên localhost nơi bắt tay đã rẻ, tái dùng vẫn nhanh hơn 13 lần. Trên mạng thực, nơi bắt tay tốn cả RTT, khoảng cách này còn khổng lồ hơn — đây chính là lý do keep-alive và connection pool tồn tại.
- Trạng thái socket từ /proc/net/tcp: mở 40 kết nối thấy
LISTEN=1(server đang nghe),ESTABLISHED=80(40 kết nối nhưng cả hai đầu client+server đều trên localhost nên đếm gấp đôi), và đặc biệtTIME_WAIT=4001— tích lũy từ hàng nghìn kết nối đã đóng trong các phép đo trên. Việc mở/đóng kết nối liên tục để lại rất nhiều socket ở trạng thái TIME_WAIT (giữ 2×MSL để dọn gói lạc) — một hiện tượng ta sẽ mổ kỹ ở bài về trạng thái socket.
Đánh đổi cần cân nhắc
Bắt tay là cái giá cố định của độ tin cậy TCP. Ba bước SYN/SYN-ACK/ACK không phải lãng phí — chúng thiết lập số thứ tự (sequence number) ban đầu cho cả hai chiều, thỏa thuận cửa sổ và tùy chọn (MSS, window scaling...), đảm bảo cả hai bên thực sự nói chuyện được trước khi truyền. Đây là nền tảng để TCP đảm bảo thứ tự và không mất gói (bài sau so với UDP). Cái giá một RTT là để đổi lấy độ tin cậy đó.
Đừng đo mạng trên localhost rồi kết luận cho production. Toàn bộ con số microsecond ở đây là vì localhost không có độ trễ mạng thật. Trên mạng thực, bắt tay bị chi phối bởi RTT (khoảng cách vật lý + số hop), không phải tốc độ CPU. Một dịch vụ gọi API xuyên lục địa với RTT 150ms mà mở kết nối mới mỗi request sẽ cảm nhận chi phí bắt tay rõ rệt — điều localhost không bao giờ cho bạn thấy. Luôn đo trên đường mạng gần với thực tế.
Giảm bắt tay là hướng tối ưu lớn nhất cho mạng có độ trễ cao. Vì bắt tay tốn một RTT cố định, cách hiệu quả nhất để tăng tốc không phải làm bắt tay nhanh hơn mà là bắt tay ít lần hơn: tái dùng kết nối (keep-alive, pool), gộp nhiều request trên một kết nối (HTTP/2), hoặc dùng giao thức giảm số vòng bắt tay (TCP Fast Open, QUIC/HTTP3 gộp bắt tay TLS vào). Các bài sau của loạt sẽ đo từng hướng này.
Ba ý mang về
- Bắt tay TCP là một RTT overhead trước mọi byte dữ liệu: ba bước SYN/SYN-ACK/ACK ánh xạ với
Dial/Accept; đo thật trên localhost 16,7µs (RTT~0) nhưng trên mạng thực bằng đúng một RTT (20–100ms) — chi phí cố định của độ tin cậy TCP. - Tái dùng kết nối bỏ được bắt tay lặp lại: đo thật gửi 2000 request, tái dùng một kết nối nhanh hơn ~13 lần so với mở mới mỗi lần (3,05 vs 40,35µs) — và khoảng cách này còn lớn hơn nhiều trên mạng có RTT cao.
- Trạng thái socket kể câu chuyện: đọc
/proc/net/tcpthấy LISTEN/ESTABLISHED/TIME_WAIT; mở-đóng kết nối liên tục để lại đống TIME_WAIT — dấu hiệu bạn nên tái dùng kết nối, nền cho keep-alive và pool ở các bài sau.
Nguồn
- RFC 9293 — Transmission Control Protocol (TCP): https://www.rfc-editor.org/rfc/rfc9293.html
- Go docs — net package (Dial, Listen): https://pkg.go.dev/net
- Kernel docs — proc/net/tcp: https://docs.kernel.org/networking/proc_net_tcp.html
Phần sau ta so TCP với UDP: TCP đảm bảo thứ tự và không mất gói nhờ bắt tay + xác nhận, còn UDP bỏ hết để nhanh và nhẹ — đo thật sự khác biệt về độ tin cậy, thứ tự và độ trễ giữa hai giao thức.