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

Ảnh chụp đoạn mã nền tối khung WebSocket 2 byte header mask và phân mảnh với RFC 6455 mục 5 Go 1.23 gorilla websocket 1.5.3 client đếm byte trên net.Conn đọc thô từng khung echo, một khung bằng header 2 tới 14 byte cộng payload không có tên trường không có dòng trống gồm byte 0 FIN 1 bit RSV 3 bit opcode 4 bit với 0x1 text 0x2 binary 0x0 tiếp nối 0x8 close 0x9 ping 0xA pong, byte 1 MASK 1 bit len 7 bit với len nhỏ hơn hoặc bằng 125 xong 126 thêm 2 byte 127 thêm 8 byte độ dài, 4 byte mask key chỉ chiều client sang server server sang client không mask, payload mỗi byte XOR với key i mod 4, header nhỏ hơn hoặc bằng 125 B là 2 B từ 126 tới 65535 B là 4 B từ 65536 B là 10 B cộng 4 nếu có mask so với HTTP 1.1 một request khoảng 140 B header đi cộng khoảng 120 B header về kể cả khi body 10 byte, vì sao client phải mask không phải để bảo mật JS độc trên trình duyệt mở WebSocket tới server của nó gửi payload trông y hệt một request HTTP GET jquery.js HTTP 1.1 Host cdn.victim proxy trong suốt giữa đường tưởng là HTTP thật cache câu trả lời giả cho URL thật đầu độc cache Huang et al 2011 mask bằng key ngẫu nhiên mỗi khung khiến byte trên dây không do JS quyết định server không mask vì server không phải kẻ bị lừa, phân mảnh một thông điệp bằng n khung FIN 0 tới FIN 1 opcode tiếp nối 0x0 cho phép gửi 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 thông điệp nhỏ xếp sau thông điệp 1 MB cùng kết nối phải chờ head-of-line chỉ ping pong close chen được gorilla client tự phân mảnh theo WriteBufferSize mặc định 4 KB server WriteMessage gửi một khung, đo gì gồm A payload 10 B tới 1 MB client gửi mấy byte buf 4 KB vs 2 MB server echo mấy khung so với HTTP POST, B 1000 lần 1 MB client sang server mask vs server sang client không mask XOR thuần 1 MB mất bao lâu, C server 20 Mbit trên giây gửi 1 MB rồi tick cùng kết nối chia 256 lần 4 KB kết nối riêng tick đến sau bao lâu, D text kiểm UTF-8 vs binary 300 lần 1 MB 10000 số float64 ở dạng JSON vs binary

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

Ảnh chụp output thật nền tối header 2 tới 14 byte mask rẻ HOL 436 ms với go-lab client sang dist-lab server tc tbf 20mbit A B D loopback C trung vị 5 lần, A byte trên dây theo payload client gửi server echo HTTP POST gồm payload 10 B client gửi buf 4 KB 16 B cộng 6 client gửi buf 2 MB 16 B cộng 6 server trả 12 B cộng 2 HTTP POST req 151 cộng 141 resp 126 cộng 116, 125 B 131 cộng 6 131 cộng 6 127 cộng 2 267 cộng 142 242 cộng 117, 126 B 134 cộng 8 134 cộng 8 130 cộng 4 268 cộng 142 243 cộng 117, 1 KB 1032 cộng 8 1032 cộng 8 1028 cộng 4 1167 cộng 143 1142 cộng 118, 65535 B 65663 cộng 128 65543 cộng 8 65539 cộng 4 65679 cộng 144 65654 cộng 119, 65536 B 65664 cộng 128 65550 cộng 14 65546 cộng 10 65680 cộng 144 65655 cộng 119, 1 MB 1050624 cộng 2048 1048590 cộng 14 1048586 cộng 10 1048722 cộng 146 1048697 cộng 121, cộng 128 và cộng 2048 là client gorilla mặc định cắt khung 4 KB 16 và 256 khung nhân 8 B server luôn echo 1 khung, B mask tốn bao nhiêu 1000 lần 1 MB loopback client sang server có mask buf 4 KB 256 khung mỗi MB 868 ms 1151 MB trên giây, có mask buf 2 MB 1 khung mỗi MB 451 ms 2213 MB trên giây nhanh hơn chiều không mask, server sang client không mask 543 ms 1838 MB trên giây, XOR mask thuần theo byte 1 MB 0,29 ms 3477 MB trên giây nút cổ chai là phân mảnh, C head-of-line server gửi 1 MB rồi tick băng thông 20 Mbit trên giây gồm cùng kết nối 1 MB một thông điệp rồi tick không giới hạn tick sau 0,8 ms tbf 20 Mbit tick sau 436,2 ms bằng lúc 1 MB xong, cùng kết nối 256 lần 4 KB tick chen sau chunk 1 tick sau 0,0 ms và 0,5 ms 1 MB vẫn xong sau 438 ms, kết nối riêng cho tick tick sau 0,1 ms và 24,0 ms vẫn xếp hàng ở link, D text vs binary 300 lần 1 MB client sang server text 1 MB UTF-8 tiếng Việt 127 ms 2358 MB trên giây binary 1 MB ngẫu nhiên 131 ms 2274 MB trên giây như nhau, gửi TEXT chứa byte ff fe 61 c0 không phải UTF-8 gorilla echo đủ 4 byte không lỗi trình duyệt đóng 1007, 10000 số float64 JSON 181565 B binary float64 80000 B JSON int 58888 B binary int32 40000 B, nói thẳng A B D đo loopback cùng máy nên MB trên giây chỉ để so tương đối C đo qua mạng Docker có tbf độ trễ ở đó đúng là thứ người dùng thấy

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ề

  1. 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.
  2. 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; WriteBufferSize 4 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.
  3. 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

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.