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

Ảnh chụp đoạn mã Go nền tối minh hoạ TCP bắt tay 3 bước, client server SYN client net Dial gửi SYN SYN-ACK server Listen Accept trả SYN-ACK ACK client trả ACK ESTABLISHED 3 gói này bằng 1 vòng khứ hồi RTT trước khi gửi byte dữ liệu đầu tiên trên mạng thực RTT 20-100ms mỗi kết nối mới tốn từng đó chỉ để chào, server cộng client bằng net chạy cùng container localhost 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ằng bắt tay xong sẵn sàng gửi dữ liệu, đo overhead kết nối mới mỗi lần vs tái dùng kết nối mới mỗi request trả giá bắt tay mỗi lần for i N c net Dial c Write msg c Read buf c Close tái dùng 1 kết nối bắt tay 1 lần rồi gửi nhiều lần c net Dial for i N c Write msg c Read buf

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:

Ảnh chụp bảng kết quả chạy thật TCP bắt tay output thật, một thời gian bắt tay net Dial trung bình trên localhost bằng 16.7 µs N bằng 2000 localhost RTT 0 nên chỉ tốn chi phí kernel dựng socket trên mạng thực bắt tay bằng 1 RTT vd 20-100ms lớn hơn hàng nghìn lần, hai gửi 2000 request kết nối mới vs tái dùng kết nối mới mỗi request 40.35 µs mỗi request gồm bắt tay mỗi lần tái dùng 1 kết nối 3.05 µs mỗi request bắt tay 1 lần duy nhất tái dùng nhanh hơn 13.2 lần bắt tay lặp lại là chi phí lớn trên mạng thực khoảng cách càng lớn vì RTT cao, ba trạng thái socket đọc từ proc net tcp khi mở 40 kết nối LISTEN 1 ESTABLISHED 80 TIME_WAIT 4001 LISTEN 1 server đang nghe ESTABLISHED 80 40 kết nối x 2 đầu client server đều trên localhost TIME_WAIT 4001 tích lũy sau khi đóng hàng nghìn kết nối test mở đóng kết nối liên tục để lại nhiều TIME_WAIT 2 MSL bài 9, kết luận bắt tay là 1 RTT overhead trước mọi byte dữ liệu tái dùng kết nối bỏ được bắt tay lặp nhanh hơn 13x localhost mở đóng nhiều kết nối để lại đống TIME_WAIT nền cho keep-alive pool

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ệt TIME_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ề

  1. 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.
  2. 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.
  3. Trạng thái socket kể câu chuyện: đọc /proc/net/tcp thấ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

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.