Khi chương trình của bạn gọi connect(), có cảm giác kết nối "có ngay". Nhưng dưới lớp vỏ đó, TCP phải trao đổi ba gói trước khi bạn gửi được một byte dữ liệu nào. Bài này dựng hai máy ảo trong container, bắt đúng chuỗi bắt tay của một kết nối mới, rồi đo xem nó tốn bao nhiêu gói và bao nhiêu thời gian — và vì sao con số đó nhỏ xíu trong phòng thí nghiệm nhưng lại quan trọng trên đường truyền thật.

Bắt tay TCP ba bước

Chuỗi ba gói, nhìn từ trên dây

Tôi tạo một cặp veth nối hai không gian mạng, chạy một máy chủ TCP ở một đầu, rồi bắt gói trong lúc mở một kết nối, gửi một byte, và đóng lại:

ip link add veth0 type veth peer name veth1
ip addr add 10.10.0.1/24 dev veth0        # máy A (khách)
# veth1 ở netns khác: 10.10.0.2/24         # máy B (chủ, cổng 9000)
tcpdump -i veth0 tcp -w h.pcap

Bản bắt vẽ ra toàn bộ vòng đời của một kết nối. Đây là các mốc thời gian tính từ gói đầu:

t=  0,0 us  A->B  SYN       hdr 40B
t= 22,9 us  B->A  SYN+ACK   hdr 40B
t= 33,9 us  A->B  ACK       hdr 32B   <= xong bắt tay
t= 73,9 us  A->B  PSH 1B    (yêu cầu)
t= 78,9 us  B->A  ACK
t=152,8 us  B->A  PSH 1B    (trả lời)
t=166,9 us  A->B  ACK
t=196,9 us  A->B  FIN       (bắt đầu đóng)

Ba gói đầu là bắt tay ba bước, và chúng là điều kiện bắt buộc trước mọi dữ liệu:

  • SYN (A → B): "tôi muốn mở, số thứ tự của tôi bắt đầu ở đây."
  • SYN + ACK (B → A): "đồng ý, số thứ tự của tôi bắt đầu ở đây, và tôi xác nhận của bạn."
  • ACK (A → B): "xác nhận của bạn — xong." Kể từ gói này, kết nối đã ở trạng thái ESTABLISHED và máy A gửi được dữ liệu.

Chú ý cột hdr: hai gói SYN và SYN-ACK mang header TCP 40 byte, còn các gói sau chỉ 32 byte. Bắt tay là nơi hai bên thỏa thuận mọi tùy chọn — MSS (kích thước segment lớn nhất), cho phép SACK, dấu thời gian, cửa sổ tỉ lệ — nên header TCP phình to nhất đúng ở đầu cuộc. 20 byte tùy chọn đó không đi kèm mọi gói; chúng chỉ nặng một lần lúc mở.

"Sẵn sàng" nghĩa là sau một vòng khứ hồi

Điểm dễ hiểu nhầm: kết nối không sẵn sàng ngay lúc gửi SYN, mà sau một vòng khứ hồi. Máy A gửi SYN, chờ SYN-ACK về (một vòng), rồi gửi ACK và bắt đầu truyền dữ liệu ngay sau đó. Trong bản bắt của tôi, gói yêu cầu (PSH 1Bt=73,9) đi ngay sau gói ACK bắt tay — máy A không phải chờ thêm.

Nhưng nhìn từ phía máy chủ thì khác: máy B chỉ biết kết nối đã lên khi nhận được gói ACK thứ ba, tức 1,5 vòng khứ hồi kể từ lúc nó gửi SYN-ACK. Đây là chi tiết quan trọng khi bạn nghĩ về độ trễ: chi phí bắt tay mà khách cảm nhận là một RTT trước byte đầu tiên gửi đi; chi phí mà chủ cảm nhận trước byte dữ liệu đầu tiên nhận được là một RTT rưỡi.

Trên veth trong cùng một máy, một RTT chỉ vài chục micro giây, nên toàn bộ bắt tay xong trong ~34 us. Nhưng RTT là thứ co giãn khủng khiếp theo khoảng cách. Qua Internet giữa hai châu lục, một RTT có thể là 150 mili giây — và bắt tay TCP một mình đã ngốn chừng đó trước khi request đầu tiên kịp rời máy.

Cái giá của một kết nối mới

Để đo cái giá đó một cách sạch, tôi so ba thứ, mỗi thứ lấy trung vị của 40 lần:

Thao tác Trung vị
Chỉ bắt tay (connect) ~5 us
Vòng đời mới (mở + gửi + đóng) ~40 us
Gửi trên kết nối có sẵn (warm) ~30 us

Chênh giữa "vòng đời mới" và "gửi trên kết nối có sẵn" là khoảng 8–10 micro giây — đó là thuế thiết lập cộng đóng cho mỗi kết nối mới. Con số này nhỏ vì đây là loopback ảo. Điều đáng nhớ không phải bản thân con số, mà là hình dạng của nó: một kết nối mới luôn đắt hơn một kết nối tái dùng đúng bằng phần bắt tay và đóng, và phần đó tỉ lệ thẳng với RTT. Trên đường truyền thật, "8 micro giây" trở thành "một RTT đầy đủ", và nếu dịch vụ của bạn mở một kết nối mới cho mỗi request nhỏ, bạn trả cái RTT đó hàng nghìn lần mỗi giây một cách vô ích.

Đây chính là lý do tồn tại của keep-alive HTTP, bể kết nối (connection pool) trong mọi thư viện cơ sở dữ liệu, và cả việc HTTP/2 dồn nhiều request vào một kết nối. Tất cả đều là cách tránh trả lại cái thuế ba-gói-một-RTT này.

Một lần tôi làm hỏng phép đo

Lần chạy đầu tiên, chương trình đo của tôi vỡ ngay ở phép "kết nối ấm" với lỗi BrokenPipe: [Errno 32]. Nguyên nhân là ở máy chủ tôi tự viết: tôi cho nó nhận đúng một lần rồi vọng lại rồi đóng kết nối. Nhưng phép đo warm cần giữ một kết nối bền và gửi 40 lần trên đó — tới lần gửi thứ hai thì máy chủ đã đóng đầu kia, và sendall ném BrokenPipe.

Lỗi này lành tính và dễ sửa, nhưng nó là một cái bẫy điển hình khi tự dựng cả hai đầu để đo: hành vi của máy chủ cũng là một biến trong phép đo, không chỉ máy khách. Cách sửa là cho máy chủ vọng lại trong một vòng lặp recv/sendall cho tới khi khách chủ động đóng (lúc đó recv trả về rỗng). Sau khi sửa, phép đo warm chạy đủ 40 vòng trơn tru. Bài học: khi đo chi phí của việc tái dùng kết nối, hãy chắc chắn phía bên kia thật sự giữ kết nối mở — nếu không bạn đang vô tình đo lại chính chi phí mở kết nối mà mình định loại trừ.

Vì sao điều này quan trọng khi lập trình

Bắt tay ba bước giải thích vài quan sát thực tế. Thứ nhất, request đầu tiên tới một máy chủ luôn chậm hơn các request sau trên cùng kết nối, đúng bằng một RTT — nếu bạn đo hiệu năng bằng cách mở kết nối mới mỗi lần, bạn đang đo lẫn cả thuế bắt tay vào con số của mình. Thứ hai, một dịch vụ nhận nhiều kết nối ngắn (mỗi request một kết nối) tốn CPU và RTT cho hàng loạt bắt tay mà lẽ ra tránh được. Thứ ba, SYN là gói mở cửa, nên nó cũng là mục tiêu của tấn công SYN flood — kẻ tấn công gửi SYN rồi bỏ dở, để máy chủ ôm hàng loạt kết nối nửa-mở.

Với người lập trình, kết luận rất cụ thể: tái dùng kết nối bất cứ khi nào có thể. Bể kết nối tới cơ sở dữ liệu, keep-alive cho HTTP client, giữ kết nối gRPC sống — tất cả đều mua lại đúng cái RTT mà bạn vừa thấy đo được là ba gói trên dây.

Thử ba mươi giây

Chạy tcpdump -i any -c 3 'tcp[tcpflags] & tcp-syn != 0' rồi mở một kết nối bất kỳ (ví dụ curl https://example.com). Ba gói đầu bạn bắt được chính là SYN, SYN-ACK, ACK — cái giá cố định mà mọi kết nối mới phải trả, ngay trước khi byte dữ liệu đầu tiên được phép đi.