Khi chương trình của bạn gọi write(sock, data, n), dữ liệu không bay thẳng lên dây — nó được chép vào một vùng đệm của nhân trước. Và đôi khi lời gọi đó chặn lại hoặc trả về ít hơn n byte, khiến người mới lập trình mạng bối rối. Nguyên nhân nằm ở hai vùng đệm socket: đệm gửi và đệm nhận. Bài này dựng hai đầu kết nối thật trong container rồi đo chính xác chúng giữ được bao nhiêu, và điều gì xảy ra khi chúng đầy.

Đệm gửi và nhận

Hai đệm, hai nhiệm vụ

Mỗi socket TCP có hai vùng đệm riêng biệt, dễ nhầm nhưng giữ hai thứ hoàn toàn khác nhau:

  • Đệm gửi (SO_SNDBUF): giữ dữ liệu mà ứng dụng đã write() nhưng nhân chưa gửi xong hoặc chưa nhận được ACK. Nhân phải giữ lại để còn phát lại nếu gói rơi. Chỉ khi đầu kia xác nhận đã nhận, phần đó mới được giải phóng.
  • Đệm nhận (SO_RCVBUF): giữ dữ liệu đã tới nơi nhưng ứng dụng chưa read() ra. Nó cũng chính là thứ quyết định cửa sổ nhận mà bên kia được phép gửi — đầy đệm nhận thì cửa sổ báo về 0.

Điểm mấu chốt để hiểu phần sau: hai đệm này nằm ở hai máy khác nhau và cùng nhau tạo thành "đường ống chứa" giữa hai ứng dụng. Tổng dung lượng chứa được là tổng của cả hai.

Đo: send() nuốt được bao nhiêu trước khi chặn

Câu hỏi cụ thể mà mọi lập trình viên mạng đều gặp: nếu người nhận ngừng đọc hẳn, thì người gửi còn write() được bao nhiêu trước khi bị chặn? Tôi dựng một máy chủ cố tình không bao giờ đọc — nó accept() kết nối rồi ngồi im, không gọi recv() lần nào — rồi cho máy khách đẩy dữ liệu ở chế độ không chặn, đếm số byte gửi được cho tới khi gặp EWOULDBLOCK:

# máy chủ: accept rồi KHÔNG recv (người đọc đứng hình)
# máy khách:
s.setsockopt(SOL_SOCKET, SO_SNDBUF, snd)
s.connect(...); s.setblocking(False)
while True:
    try: total += s.send(chunk)
    except BlockingIOError: break   # đệm đầy, không nuốt thêm được

Tôi thử nhiều cặp kích thước đệm, mỗi cặp chạy ba lần cho ra con số giống hệt tới từng KB:

đệm gửi (xin) đệm nhận (xin) hiệu dụng gửi/nhận send() nuốt trước khi chặn
64 KB 64 KB 128 / 128 KB 188 KB
128 KB 64 KB 256 / 128 KB 324 KB
64 KB 128 KB 128 / 256 KB 296 KB
256 KB 256 KB 512 / 512 KB 750 KB
512 KB 512 KB 1024 / 1024 KB 1515 KB

Đọc bảng này có hai điều quan trọng. Thứ nhất, người nhận không đọc một byte nào mà người gửi vẫn tống được từ 188 KB tới 1,5 MB vào đường ống trước khi bị chặn — toàn bộ lượng đó nằm kẹt trong hai đệm. Thứ hai, cả hai đệm cùng góp: so hai dòng giữa, tăng riêng đệm gửi (128/64) cho 324 KB, tăng riêng đệm nhận (64/128) cho 296 KB — mỗi bên đều nâng khả năng chứa. Con số nuốt được xấp xỉ 1,5 lần tổng hai đệm bạn xin (188 ≈ 1,5 × 128; 1515 ≈ 1,5 × 1024).

Đây chính là kiểm soát luồng

Cái "send() bị chặn" đó không phải lỗi — nó là kiểm soát luồng (flow control) của TCP đang làm việc. Chuỗi sự kiện khi người nhận chậm hơn người gửi diễn ra thế này: dữ liệu tới chất đầy đệm nhận của người nhận; đầy rồi, người nhận báo về "cửa sổ = 0", nghĩa là "đừng gửi thêm"; người gửi ngừng gửi nhưng ứng dụng vẫn write(), nên đệm gửi của nó cũng chất đầy; và khi đệm gửi đầy nốt, lời gọi send() tiếp theo bị chặn (hoặc trả EWOULDBLOCK nếu socket ở chế độ không chặn).

Đây là cơ chế đẹp đẽ giúp một người gửi nhanh tự động bị ghìm theo tốc độ một người nhận chậm, mà không cần ai điều phối. Tổng hai đệm chính là lượng "đệm đàn hồi" giữa hai bên: nó cho phép người gửi chạy trước một nhịp, nhưng không quá xa. Phép đo ở trên đo đúng cái nhịp đó — bao nhiêu dữ liệu được phép "chạy trước" trước khi cơ chế ghìm siết lại.

Một lần tôi đo hớ vì quên kernel nhân đôi

Trước khi chạy, tôi đã đoán kết quả: đệm gửi 64 KB cộng đệm nhận 64 KB là 128 KB, nên send() sẽ nuốt được khoảng 128 KB rồi chặn. Đo ra 188 KB — nhiều hơn tới 1,5 lần. Tôi tưởng mình tính sai đâu đó.

Hóa ra nhân Linux âm thầm nhân đôi giá trị bạn đặt cho SO_SNDBUFSO_RCVBUF. Khi tôi hỏi lại bằng getsockopt ngay sau khi đặt 64 KB, nó trả về 128 KB. Nửa phần thêm đó nhân dành cho sổ sách nội bộ của nó (cấu trúc quản lý gói, không phải chỗ chứa dữ liệu thuần), nhưng phần dữ liệu thật vẫn lớn hơn con số tôi xin. Tính bằng giá trị tôi đặt (64+64=128) cho ra dự đoán sai; tính bằng giá trị hiệu dụng mà nhân thật sự dùng (128+128=256, rồi trừ phần sổ sách còn ~188) mới khớp.

Bài học đo lường: con số bạn đặt và con số nhân dùng là hai thứ khác nhau. Với SO_SNDBUF/SO_RCVBUF, luôn getsockopt đọc lại giá trị hiệu dụng thay vì tin vào giá trị mình vừa setsockopt. Đây không chỉ là chuyện học thuật: nếu bạn tính toán bộ nhớ hay ngưỡng chặn dựa trên giá trị yêu cầu, bạn sẽ lệch đúng gấp đôi.

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

Hệ quả đầu tiên rất thực tế: write() chặn hoặc ghi thiếu là chuyện bình thường, không phải lỗi. Trên một socket chặn, write() một khối lớn sẽ nằm đó tới khi đệm có chỗ; trên socket không chặn, nó trả về số byte thật sự nhận được (có thể ít hơn bạn đưa) hoặc EWOULDBLOCK. Mọi code mạng đúng đắn phải xử lý ghi từng phần — lặp lại write() với phần còn lại — chứ không giả định một lời gọi đẩy hết. Đây là lý do các thư viện IO không chặn (epoll, async) luôn theo dõi sự kiện "ghi được" chứ không ghi mù.

Hệ quả thứ hai là về thiết kế backpressure. Vì đệm đầy sẽ chặn người gửi, bạn có sẵn một cơ chế ghìm tải miễn phí: nếu bên tiêu thụ chậm, socket tự làm chậm bên sản xuất. Nhưng nếu code của bạn nuốt lỗi bằng cách ghi vào một hàng đợi vô hạn trong ứng dụng thay vì để socket chặn, bạn vứt bỏ cơ chế đó và đổi lấy nguy cơ phình bộ nhớ. Để socket làm việc ghìm của nó thường là lựa chọn đúng.

Hệ quả thứ ba là đánh đổi kích thước đệm. Ở bài về cửa sổ TCP ta thấy đệm phải đủ lớn (≥ tích băng thông–độ trễ) thì thông lượng mới cao. Nhưng bài này cho thấy mặt trái: đệm càng lớn thì càng nhiều dữ liệu có thể nằm kẹt chờ, cộng thêm độ trễ cho dữ liệu đến sau, và tốn RAM. Với một máy chủ giữ một triệu kết nối, mỗi kết nối một đệm vài trăm KB là hàng trăm GB bộ nhớ — đủ để sập máy. Đây là lý do Linux mặc định tự chỉnh kích thước đệm (autotuning) theo nhu cầu thật của từng kết nối, thay vì cấp cứng một cỡ lớn cho tất cả; và là lý do bạn chỉ nên đặt SO_SNDBUF/SO_RCVBUF tay khi có lý do đo được, chứ không đặt đại một số to.

Con số mang theo: hai đệm socket cùng nhau chứa được khoảng 1,5 lần tổng dung lượng bạn xin, và khi chúng đầy thì send() chặn — đó không phải sự cố mà là kiểm soát luồng của TCP tự ghìm người gửi theo người nhận. Hiểu điều đó là hiểu vì sao write() đôi khi dừng, vì sao phải xử lý ghi từng phần, và vì sao chỉnh đệm là con dao hai lưỡi giữa thông lượng và bộ nhớ.

Thử ba mươi giây

Trên máy đang có kết nối TCP, chạy ss -tm và nhìn dòng skmem của mỗi socket: nó in ra lượng bộ nhớ đệm gửi (t) và nhận (r) đang dùng cùng giới hạn của chúng — bạn thấy trực tiếp dữ liệu đang nằm chờ trong đệm. Muốn thấy send() bị chặn tận mắt: mở một kết nối tới một máy chủ, cho phía nhận ngừng read(), rồi đẩy dữ liệu — sau khoảng "tổng hai đệm" byte, lời gọi gửi của bạn sẽ đứng khựng lại đúng như bảng đo ở trên.