Phần 2 dừng ở byte cuối của 101 Switching Protocols. Từ byte kế tiếp, kết nối không còn là HTTP: không có dòng request, không có tên header, không có dòng trống. Mỗi thông điệp được bọc trong một hoặc nhiều khung với header 2 đến 14 byte, và ba quy tắc nhỏ của khung quyết định ba chuyện lớn trong vận hành: tốn bao nhiêu byte cho một thông điệp 10 byte, vì sao client phải XOR toàn bộ payload với một khoá ngẫu nhiên còn server thì không, và vì sao một thông điệp 1 MB có thể làm một thông điệp 20 byte phía sau nó trễ gần nửa giây. Bài này đếm byte trên dây bằng một net.Conn bọc đếm, đọc thô từng khung server trả về, đo chi phí mask, và dựng tình huống head-of-line với băng thông bị giới hạn bằng tc.
Khung: hai byte, một bit mask, một quy tắc cấm chen

Hình 1: Hai byte header, bit MASK chỉ bật ở chiều client, và quy tắc cấm chen khung dữ liệu giữa các mảnh của thông điệp khác.
Byte đầu của khung chứa bit FIN và 4 bit opcode: 0x1 text, 0x2 binary, 0x0 khung tiếp nối, 0x8/0x9/0xA cho close/ping/pong. Byte thứ hai chứa bit MASK và 7 bit độ dài: tới 125 byte thì xong, giá trị 126 báo "độ dài thật ở 2 byte kế", 127 báo "ở 8 byte kế". Vậy header là 2, 4 hoặc 10 byte, cộng 4 byte khoá nếu có mask. So với HTTP/1.1, nơi một POST tối thiểu của Go mang ~140 byte header đi và ~120 byte header về bất kể body to nhỏ, đây là khác biệt cả hai bậc.
Mask là quy tắc gây thắc mắc nhiều nhất: client phải XOR toàn bộ payload với một khoá 4 byte ngẫu nhiên, server không được mask. Nó không phải mã hoá, khoá nằm ngay trước payload. Nó tồn tại vì một cuộc tấn công cụ thể (Huang và cộng sự, 2011): JavaScript độc trên trình duyệt mở WebSocket tới server của kẻ tấn công và gửi payload trông y hệt một request HTTP tới một URL khác; proxy trong suốt trên đường đi, vốn không hiểu WebSocket, đọc nhầm những byte đó thành request HTTP thật và cache câu trả lời giả cho URL thật. Mask bằng khoá ngẫu nhiên khiến byte trên dây không bao giờ do JavaScript quyết định, nên không thể "vẽ" ra một request HTTP. Server không mask vì server không phải kẻ bị lừa.
Phân mảnh cho phép gửi một thông điệp thành nhiều khung (FIN=0 … FIN=1), hữu ích khi chưa biết độ dài. Nhưng RFC cấm chen khung dữ liệu của thông điệp khác vào giữa các mảnh; chỉ ping/pong/close chen được. Hệ quả: trên một kết nối, một thông điệp nhỏ xếp sau một thông điệp 1 MB phải chờ 1 MB đó đi hết. gorilla có một bất đối xứng đáng biết: phía client tự cắt thông điệp theo WriteBufferSize (mặc định 4 KB) thành nhiều khung; phía server WriteMessage luôn gửi một khung dù 1 MB.
Công cụ đo là một chương trình Go với server echo, server "sink" và "pump" để đo thông lượng, một endpoint /hol gửi 1 MB rồi "tick", và một HTTP POST echo để so cùng payload. Client bọc net.Conn để đếm byte, và sau khi gửi thì đọc thô từng khung echo từ socket để đếm số khung và header thay vì để thư viện gộp lại.
Đo thật: byte trên dây, mask, head-of-line, text và binary

Hình 2: Byte trên dây theo kích thước payload so với HTTP POST, chi phí mask, tick bị chặn 436 ms sau 1 MB, và text so với binary.
| phép đo | kết quả |
|---|---|
| payload 10 B: client gửi / server trả / HTTP POST req + resp | 16 B / 12 B / 151 + 126 B |
| payload 1 MB: client buf 4 KB / buf 2 MB / server trả | +2.048 B (256 khung) / +14 B / +10 B (1 khung) |
| 1.000 × 1 MB client→server, mask, buf 4 KB / buf 2 MB | 868 ms (1.151 MB/s) / 451 ms (2.213 MB/s) |
| 1.000 × 1 MB server→client, không mask | 543 ms (1.838 MB/s) |
| XOR mask thuần 1 MB | 0,29 ms |
| tick sau 1 MB cùng kết nối, 20 Mbit/s | 436,2 ms |
| tick chen sau chunk 1 (256 × 4 KB), 20 Mbit/s | 0,5 ms |
| tick trên kết nối riêng, 20 Mbit/s | 24,0 ms |
| text / binary 300 × 1 MB | 127 / 131 ms |
| 10.000 float64: JSON / binary | 181.565 / 80.000 B |
Header là 2 đến 14 byte, và ngưỡng nằm đúng chỗ RFC nói. Payload 10 byte tốn 16 byte đi (2 header + 4 mask + 10) và 12 byte về (2 + 10); 125 byte vẫn vậy; sang 126 byte, header nhảy lên 4 vì cần 2 byte độ dài mở rộng; 65.535 byte vẫn 4; sang 65.536 nhảy lên 10. Cùng payload 10 byte qua HTTP/1.1 POST: 151 byte đi và 126 byte về, tức 141 và 116 byte header cho 10 byte dữ liệu. Đây là lý do phần 1 thấy WebSocket tốn 1,3 KB một phút cho những sự kiện vài chục byte, trong khi polling tốn 8 KB cho cùng dữ liệu.
Mặc định của gorilla cắt thông điệp client thành khung 4 KB, và điều đó đắt hơn mask. Với WriteBufferSize mặc định, một thông điệp 1 MB từ client thành 256 khung, mỗi khung 8 byte header: thêm 2.048 byte thay vì 14, và 1.000 thông điệp 1 MB mất 868 ms. Nâng buffer lên 2 MB để mỗi thông điệp là một khung: 451 ms, nhanh gần gấp đôi, và nhanh hơn cả chiều server→client không mask (543 ms). Mask thì sao? XOR thuần 1 MB theo từng byte mất 0,29 ms, tức khoảng 3,5 GB/s trước khi tối ưu gì; gorilla XOR theo từng word còn nhanh hơn. Kết luận đo được: nếu bạn thấy client Go gửi chậm, thứ cần chỉnh là WriteBufferSize, không phải tìm cách bỏ mask. Chiều server không bị chuyện này vì WriteMessage phía server đi đường tắt một khung.
Một thông điệp 1 MB chặn thông điệp phía sau đúng bằng thời gian nó chiếm đường truyền. Server trên dist-lab bị giới hạn 20 Mbit/s bằng tc tbf. Gửi 1 MB rồi "tick" trên cùng kết nối: tick đến sau 436 ms, chính xác là lúc 1 MB đi hết, vì khung dữ liệu không được chen. Chia 1 MB thành 256 thông điệp 4 KB và chen tick sau thông điệp đầu: tick đến sau 0,5 ms, 1 MB vẫn xong sau 438 ms, không mất gì. Lưu ý đây là chia ở tầng ứng dụng thành nhiều thông điệp; phân mảnh của giao thức (nhiều khung của một thông điệp) không giúp được vì chính quy tắc cấm chen áp lên nó. Kết nối riêng cho tick: 24 ms, tốt hơn 18 lần nhưng không về 0, vì hai kết nối vẫn xếp hàng chung ở một link 20 Mbit đang bị 1 MB kia chiếm. Đường truyền là tài nguyên chung; kết nối riêng chỉ thoát được hàng đợi của WebSocket, không thoát được hàng đợi của router.
Text và binary nhanh như nhau, vì gorilla không kiểm UTF-8. 300 × 1 MB text tiếng Việt: 127 ms; binary ngẫu nhiên: 131 ms. Gửi một khung text chứa ff fe 61 c0, không phải UTF-8 hợp lệ: gorilla echo về đủ 4 byte, không lỗi. Trình duyệt thì khác: nhận text không hợp lệ UTF-8 là đóng kết nối với mã 1007. Nên "text" trong gorilla chỉ là một bit opcode, trách nhiệm hợp lệ thuộc về bạn. Khác biệt thật giữa text và binary nằm ở mã hoá dữ liệu: 10.000 số float64 ở dạng JSON là 181.565 byte, ở dạng binary là 80.000; làm tròn thành int và JSON còn 58.888 so với 40.000 byte int32. Khi payload là số đo, chuyển sang khung binary với mã hoá nhị phân tiết kiệm 2 lần trước khi nói tới nén.
Đánh đổi cần cân nhắc
Mask là giá cố định, không thương lượng. Trình duyệt luôn mask và server tuân thủ RFC phải từ chối khung client không mask (gorilla đóng với 1002). Chi phí đo được là nhỏ tới mức không nhìn thấy trong tổng; cái đáng lo là thư viện client cắt khung quá nhỏ, như mặc định 4 KB của gorilla với thông điệp lớn. Nếu client Go của bạn gửi tệp hoặc ảnh qua WebSocket, đặt WriteBufferSize bằng cỡ thông điệp lớn thường gặp.
Head-of-line là thuộc tính của giao thức, giải pháp nằm ở ứng dụng. Không có tuỳ chọn nào trong WebSocket để chen thông điệp ưu tiên. Ba lựa chọn đo được ở đây: chia thông điệp lớn thành nhiều thông điệp nhỏ (tick 0,5 ms, không tốn thêm gì), kết nối riêng cho kênh nhạy độ trễ (24 ms, vẫn ăn băng thông chung), hoặc đừng gửi thứ 1 MB qua kênh realtime mà gửi một URL để client tải riêng. Phần 5 về backpressure sẽ thấy mặt còn lại của cùng vấn đề: client đọc chậm.
Phạm vi đo. Thông lượng ở A, B, D là loopback cùng máy, con số tuyệt đối chỉ để so tương đối giữa các cấu hình. Phần C đi qua mạng Docker với tbf nên độ trễ là thứ người dùng thực sự thấy. Chưa chạm: permessage-deflate (RFC 7692), nơi nén làm thay đổi cả kích thước lẫn CPU; và khung ping/pong, chủ đề của phần 4.
Ba ý mang về
- Header khung là 2, 4 hoặc 10 byte cộng 4 byte mask ở chiều client: thông điệp 10 byte tốn 16 byte đi và 12 byte về, so với 151 và 126 byte của HTTP POST; đây là nguồn gốc của khác biệt băng thông ở phần 1.
- Mask gần như miễn phí, phân mảnh mặc định thì không: XOR 1 MB mất 0,29 ms;
WriteBufferSize4 KB của gorilla cắt 1 MB thành 256 khung và làm chậm gần gấp đôi so với một khung. Chỉnh buffer, đừng cố bỏ mask. - Một thông điệp lớn chặn mọi thông điệp sau nó trên cùng kết nối: 436 ms cho 1 MB ở 20 Mbit/s. Chia thành nhiều thông điệp nhỏ ở tầng ứng dụng đưa tick về 0,5 ms; kết nối riêng chỉ giảm xuống 24 ms vì đường truyền vẫn chung.
Nguồn
- RFC 6455 — mục 5 Data Framing, 5.3 Client-to-Server Masking, 5.4 Fragmentation: https://www.rfc-editor.org/rfc/rfc6455#section-5
- Huang, Chen, Barth, Rescorla, Jackson — Talking to Yourself for Fun and Profit (2011), lý do có mask: https://www.adambarth.com/papers/2011/huang-chen-barth-rescorla-jackson.pdf
- gorilla/websocket —
Dialer.WriteBufferSize,Conn.WriteMessage,NextWriter: https://pkg.go.dev/github.com/gorilla/websocket - RFC 7692 — Compression Extensions for WebSocket (permessage-deflate): https://www.rfc-editor.org/rfc/rfc7692
- Linux
tc-tbf(8)— Token Bucket Filter dùng để giới hạn băng thông trong bài: https://man7.org/linux/man-pages/man8/tc-tbf.8.html
Phần sau nói về khung ping/pong và câu hỏi khó nhất của mọi kết nối dài: làm sao biết đầu kia đã chết khi TCP không báo gì, và heartbeat bao nhiêu giây là đủ khi đo với netem cắt mạng thật.