Có một nghịch lý ai làm hệ thống phân tán cũng gặp: đường truyền băng thông cả gigabit, mà tải một tệp từ máy chủ bên kia địa cầu vẫn ì ạch vài MB/s. Băng thông rộng thênh thang nhưng tốc độ thật thì thấp. Thủ phạm không phải băng thông — mà là RTT, thời gian một vòng khứ hồi. Bài này dựng một đường mạng thật trong container, tăng RTT bằng netem, rồi đo chính xác thông lượng tụt theo RTT như thế nào.

RTT và thông lượng

Vì sao RTT chặn thông lượng

Gốc rễ nằm ở cách TCP kiểm soát luồng. Một luồng TCP không được phép bắn dữ liệu vô hạn ra dây rồi mới chờ; nó chỉ được có tối đa một cửa sổ byte "đang bay" mà chưa nhận được xác nhận (ACK). Gửi hết cửa sổ, nó phải dừng lại chờ ACK quay về mới gửi tiếp — và ACK quay về mất đúng một RTT. Nghĩa là cứ mỗi RTT, luồng chỉ đẩy đi được nhiều nhất một cửa sổ dữ liệu:

thông lượng  <=  cửa sổ / RTT

Đây là công thức gốc của tích băng thông–độ trễ (bandwidth-delay product). Nếu cửa sổ cố định, thì RTT nằm dưới mẫu số: RTT gấp đôi, thông lượng còn một nửa — bất kể băng thông vật lý rộng bao nhiêu. Băng thông là đường ống to cỡ nào; RTT là đường ống dài bao nhiêu; và một cửa sổ cố định chỉ đổ được ngần ấy nước cho mỗi vòng đi-về.

Đo: cửa sổ cố định, thông lượng tỉ lệ nghịch RTT

Để thấy công thức trần trụi, tôi cố định cửa sổ nhận bằng cách đặt SO_RCVBUF trên máy khách (tắt cơ chế tự nới của nhân), rồi tăng dần RTT bằng netem và đo thông lượng của một luồng tải liên tục trong 3 giây:

ip link add veth0 type veth peer name veth1     # khách <-> chủ
tc qdisc replace dev veth0 root netem delay ${d}ms
ip netns exec srv tc qdisc replace dev veth1 root netem delay ${d}ms
# máy chủ blast dữ liệu; máy khách đặt SO_RCVBUF=64KB, đo bytes/thời gian

Tôi thêm độ trễ ở cả hai chiều nên RTT xấp xỉ gấp đôi độ trễ mỗi chiều, và đo RTT thật bằng ping chứ không tự suy. Lấy trung vị ba lần chạy:

RTT (ms) Thông lượng (MB/s) thông lượng × RTT
4 37,0 ~145 KB
27 3,0 ~81 KB
60 1,5 ~87 KB
112 0,79 ~87 KB
208 0,39 ~81 KB

Đọc cột cuối trước: từ RTT 27 ms trở lên, tích thông lượng × RTT gần như là một hằng số ~85 KB — chính là cửa sổ hiệu dụng. Đó là bằng chứng trực tiếp cho công thức thông lượng = cửa sổ / RTT: cửa sổ không đổi, nên tích không đổi, nên thông lượng phải tụt đúng theo tỉ lệ nghịch của RTT. Nhìn cột giữa: RTT từ 27 lên 60 (khoảng gấp đôi) thì thông lượng từ 3,0 xuống 1,5 (đúng một nửa); 112 lên 208 thì 0,79 xuống 0,39. Mỗi lần RTT gấp đôi, tốc độ mất một nửa.

Riêng dòng RTT 4 ms lệch khỏi quy luật (tích 145 KB, cao hơn hẳn): ở RTT quá nhỏ, cửa sổ 85 KB chưa phải nút thắt — thông lượng lúc đó bị chặn bởi tốc độ xử lý chứ không phải bởi cửa sổ/RTT. Công thức chỉ khống chế khi đường đủ dài để cửa sổ trở thành trần, và bảng cho thấy nó bắt đầu khống chế từ khoảng RTT 25 ms.

Cái chặn là cửa sổ, không phải RTT tự thân

Nếu thông lượng tụt vì cửa sổ cố định quá nhỏ so với RTT, thì nới cửa sổ ra phải gỡ được nút thắt. Linux hiện đại làm đúng thế một cách tự động: auto-tuning liên tục tăng cửa sổ nhận cho tới khi lấp đầy tích băng thông–độ trễ. Tôi bỏ SO_RCVBUF cố định, để nhân tự lo, rồi đo lại ở RTT thấp:

RTT ~4 ms, cửa sổ CỐ ĐỊNH 128 KB  ->    37 MB/s
RTT ~4 ms, cửa sổ TỰ NỞ           ->  1510 MB/s

Cùng một đường, chỉ khác ở chỗ cho cửa sổ nở hay không, thông lượng chênh nhau hơn 40 lần. Điều này khẳng định: RTT không tự nó giết thông lượng — cái giết thông lượng là một cửa sổ quá nhỏ so với RTT. Cho cửa sổ đủ lớn, một đường RTT thấp đạt tốc độ khổng lồ. Đây cũng là lý do vì sao "window scaling" (tùy chọn TCP cho phép cửa sổ vượt mốc 64 KB của thiết kế gốc) là bắt buộc trên mọi đường truyền tốc độ cao ngày nay.

Một lần tôi đo hớ vì slow start

Nhưng câu chuyện auto-tuning có một cái bẫy, và tôi rơi vào nó. Lần đầu đo với cửa sổ mặc định ở RTT cao (~286 ms), trong đúng 3 giây, tôi được 0,40 MB/s — thấp thảm hại, gần như bằng với cửa sổ cố định. Tôi suýt viết "auto-tuning cũng bó tay khi RTT cao, nới cửa sổ chẳng cứu được gì".

May là con số quá thấp khiến tôi nghi, nên tôi kéo dài phép đo trên cùng một đường:

RTT ~286 ms, cửa sổ tự nở:
  đo trong  3 giây  ->   0,40 MB/s
  đo trong  8 giây  ->  13,2  MB/s
  đo trong 20 giây  ->  16,8  MB/s

Thông lượng leo dần theo thời gian đo. Nguyên nhân là khởi động chậm (slow start): TCP không mở cửa sổ lớn ngay, mà tăng dần qua từng RTT — mỗi vòng khứ hồi thành công thì cửa sổ mới nới thêm. Ở RTT 286 ms, 3 giây chỉ chứa được khoảng mười vòng khứ hồi, chưa đủ để cửa sổ nở tới mức lấp đầy đường ống. Đo càng lâu, cửa sổ càng kịp nở, thông lượng càng cao.

Bài học có hai lớp. Thứ nhất về phương pháp: đo thông lượng trong một cửa sổ thời gian ngắn là trộn lẫn giai đoạn khởi động chậm với thông lượng ổn định — con số nhận được không phải tốc độ thật của đường mà là trung bình của một quá trình đang tăng tốc. Muốn đo thông lượng ổn định thì phải để luồng chạy đủ lâu, hoặc bỏ đi giai đoạn ramp ban đầu. Thứ hai về bản chất: ngay cả khi cửa sổ có thể nở đủ lớn, RTT vẫn phạt bạn — vì thời gian để nở cửa sổ cũng đo bằng số RTT. Đường càng xa, càng tốn nhiều giây thực để đạt tốc độ tối đa. RTT nằm ở mẫu số của cả thông lượng ổn định lẫn tốc độ khởi động.

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

Hệ quả đầu tiên và lớn nhất: khoảng cách địa lý là một giới hạn hiệu năng cứng, không phải thứ mua thêm băng thông là xong. Một dịch vụ đặt máy chủ ở Mỹ phục vụ người dùng Việt Nam (RTT ~200 ms) sẽ thấy mỗi luồng TCP bị bó ở vài MB/s trừ khi cửa sổ được nới rất lớn — và ngay cả khi nới được, các truy vấn ngắn vẫn trả giá bằng thời gian khởi động chậm. Đây là lý do vật lý đằng sau sự tồn tại của CDN và máy chủ biên: đưa dữ liệu lại gần người dùng để cắt RTT, vì RTT là thứ không thể "tăng băng thông" mà giải quyết.

Hệ quả thứ hai, cho thiết kế giao thức: giao thức "lắm lời" (nhiều vòng khứ hồi) chết trên đường xa. Một thao tác cần 10 vòng đi-về tuần tự tốn 10×RTT chỉ riêng độ trễ — ở RTT 200 ms là 2 giây, bất kể mỗi vòng truyền ít dữ liệu đến đâu. Đây là lý do người ta gộp nhiều truy vấn thành một (batch), dùng pipelining, HTTP/2 ghép luồng, và tránh cái vòng lặp "hỏi một câu, chờ một câu" qua mạng xa. Nó cũng liên quan tới thuế bắt tay đã đo ở bài về bắt tay TCP: mỗi kết nối mới là thêm một RTT trước khi byte đầu tiên đi.

Hệ quả thứ ba, cho vận hành: nếu một luồng đơn qua đường xa chậm dù băng thông rộng, hãy nhìn vào cửa sổ trước tiên — tcp_rmem/tcp_wmem, window scaling, và các thuật toán kiểm soát tắc nghẽn như BBR vốn được thiết kế để mở cửa sổ nhanh và đúng hơn trên đường có RTT cao. Con số mang theo từ bài này: với cửa sổ cố định, thông lượng tỉ lệ nghịch với RTT; nới cửa sổ gỡ được trần đó, nhưng RTT vẫn phạt bạn qua thời gian khởi động chậm. Băng thông cho bạn biết đường ống to cỡ nào; RTT quyết định bạn thật sự rót được bao nhiêu qua nó.

Thử ba mươi giây

Trên máy có tc, thêm độ trễ vào loopback rồi đo cảm giác: tc qdisc add dev lo root netem delay 100ms, sau đó curl -o /dev/null -s -w '%{time_total}\n' http://localhost:<cổng> tới một máy chủ local — bạn sẽ thấy thời gian nhảy vọt dù dữ liệu chẳng đi đâu xa. Với một tải lớn, so thời gian tải trước và sau khi thêm delay để thấy thông lượng tụt. Xong nhớ tc qdisc del dev lo root trả lo về bình thường.