Game server nên chọn TCP, UDP hay WebSocket?
Nếu dữ liệu nhạy thời gian và chỉ bản mới nhất có giá trị (vị trí nhân vật, input), game hành động thường chọn UDP kèm một lớp độ tin cậy tự xây hoặc lấy từ thư viện. Nếu mọi thông điệp phải đến đủ và đúng thứ tự (chat, giao dịch, lệnh quản trị), TCP đơn giản và đủ tốt. Nếu client chạy trong trình duyệt, WebSocket (chạy trên TCP) là lựa chọn thực tế nhất.
Glenn Fiedler, tác giả loạt bài Gaffer On Games, giải thích lý do: TCP giao dữ liệu tin cậy và đúng thứ tự, nên khi một gói bị mất, các gói mới hơn bị giữ lại trong hàng đợi chờ gói cũ được gửi lại. Với game bắn súng góc nhìn thứ nhất, điều đó tạo ra các cú khựng tương ứng bội số thời gian khứ hồi, vì vậy ông cho rằng gần như mọi game FPS đều dùng UDP.
So sánh TCP, UDP và WebSocket cho hệ thống realtime
Fiedler khuyên không nên trộn TCP và UDP cho cùng một giao thức game, vì mất kiểm soát và phải xử lý NAT hai lần; thay vào đó, hãy cài đặt những tính năng của TCP mà bạn thực sự cần trên nền UDP. Ông cũng lưu ý UDP không có kiểm soát tắc nghẽn: gửi quá nhiều có thể khiến router đệm gói và gây độ trễ rất lớn, nên giao thức trên UDP phải tự điều tiết tốc độ gửi.
| Tiêu chí | TCP | UDP | WebSocket |
|---|---|---|---|
| Độ tin cậy | Có, tự gửi lại gói mất | Không; ứng dụng tự xây nếu cần | Có (chạy trên TCP) |
| Thứ tự | Giữ đúng thứ tự | Không giữ thứ tự | Giữ đúng thứ tự |
| Head-of-line blocking | Có: gói mất chặn các gói sau | Không ở tầng giao thức | Có, kế thừa từ TCP |
| Mô hình dữ liệu | Luồng byte, ứng dụng tự chia thông điệp | Datagram rời rạc | Thông điệp có frame (text/binary) |
| Kiểm soát tắc nghẽn | Có sẵn | Không; ứng dụng tự xử lý | Có (từ TCP) |
| NAT / tường lửa | Thường đi qua được kết nối ra ngoài | Có thể cần xử lý NAT; một số mạng chặn | Dùng cổng HTTP/HTTPS, đi qua proxy dễ hơn |
| Hỗ trợ trình duyệt | Không dùng socket thô được | Không dùng socket thô được | Có |
WebSocket làm gì và không làm gì
Theo RFC 6455, WebSocket gồm bước bắt tay mở đầu rồi đến cơ chế frame thông điệp cơ bản, chạy trên TCP. Giao thức được thiết kế với nguyên tắc frame tối thiểu: frame chỉ để biến luồng thành thông điệp và phân biệt text với binary; siêu dữ liệu khác do tầng ứng dụng đặt lên trên. Mọi frame client gửi lên server phải được mask. Vì chạy trên TCP, WebSocket vẫn chịu head-of-line blocking khi mất gói.
Serialization: nhị phân hay JSON?
JSON dễ đọc, dễ debug và có sẵn ở mọi nền tảng, nhưng tốn băng thông và CPU để phân tích hơn định dạng nhị phân. Với hệ thống gửi hàng chục gói mỗi giây cho mỗi người chơi, kích thước gói và chi phí phân tích cộng dồn nhanh.
Tài liệu Microsoft cho biết SignalR hỗ trợ hai hub protocol: JSON dạng text (mặc định) và MessagePack dạng nhị phân, trong đó MessagePack thường tạo thông điệp nhỏ hơn JSON. gRPC dùng Protobuf, một định dạng nhị phân hiệu quả; đổi lại, Microsoft lưu ý thông điệp Protobuf không đọc được bằng mắt và cần công cụ bổ sung để phân tích. MagicOnion dùng MessagePack để serialize tham số và giá trị trả về.
Framing: chia luồng TCP thành thông điệp
TCP là luồng byte: một lần đọc có thể nhận nửa thông điệp hoặc nhiều thông điệp dính nhau. Ứng dụng phải tự định ranh giới, phổ biến nhất là tiền tố độ dài (length-prefix: vài byte đầu cho biết độ dài payload) hoặc ký tự phân cách. Bài viết của Microsoft về System.IO.Pipelines minh họa đúng vấn đề này bằng một server TCP nhận thông điệp phân cách bằng ký tự xuống dòng, và chỉ ra rằng mã đọc stream thủ công nhanh chóng trở nên phức tạp khi phải xử lý dữ liệu đến từng phần.
Với UDP, mỗi datagram đã là một đơn vị, nhưng gói vượt quá kích thước MTU cần được chia nhỏ và ghép lại ở tầng ứng dụng; Gaffer On Games có cả loạt bài về phân mảnh và ghép gói. Dù chọn giao thức nào, hãy đặt độ dài tối đa: một peer có thể khai báo độ dài lớn rồi không gửi payload, và parser không giới hạn sẽ giữ bộ nhớ vô thời hạn.
Giảm cấp phát bộ nhớ trong .NET cho server realtime
Ở hệ thống realtime, cấp phát bộ nhớ liên tục làm tăng áp lực lên bộ thu gom rác (GC) và có thể gây những lần dừng làm tăng độ trễ đuôi. .NET có sẵn một số công cụ để giảm cấp phát trên đường nóng (hot path).
System.IO.Pipelines
Theo Microsoft Learn, System.IO.Pipelines là thư viện giúp viết I/O hiệu năng cao dễ hơn, và trong các phiên bản .NET hiện đại đã có sẵn trong shared framework. Bài .NET Blog cho biết Pipelines ra đời từ công việc tối ưu Kestrel; trong ví dụ dùng Pipelines, không có buffer nào được cấp phát tường minh vì việc quản lý buffer được giao cho PipeReader/PipeWriter. Pipe còn có ngưỡng tạm dừng để kiểm soát luồng khi bên đọc chậm hơn bên ghi, và tách logic phân tích khỏi mã mạng nên dễ viết unit test với dữ liệu trong bộ nhớ.
- Luôn gọi PipeReader.AdvanceTo sau mỗi ReadAsync.
- Luôn Complete cả PipeReader và PipeWriter.
- Mỗi PipeReader/PipeWriter chỉ nên có một ngữ cảnh sở hữu vì chúng không thread-safe.
SocketAsyncEventArgs
Microsoft Learn mô tả SocketAsyncEventArgs là mẫu bất đồng bộ thay thế dành cho các ứng dụng socket hiệu năng cao chuyên biệt. Điểm chính là tránh cấp phát và đồng bộ hóa đối tượng lặp lại khi I/O khối lượng lớn: các thao tác được mô tả bằng đối tượng SocketAsyncEventArgs tái sử dụng, do ứng dụng cấp phát và quản lý, thường kết hợp với pool buffer. Đổi lại, API này phức tạp hơn async/await thông thường; chỉ nên dùng khi đã đo được nhu cầu.
Span<T>, Memory<T> và ArrayPool<T>
Span<T> và Memory<T> cho phép làm việc trên một vùng bộ nhớ có sẵn mà không sao chép. Microsoft có trang hướng dẫn riêng về cách dùng đúng hai kiểu này, đặc biệt về quyền sở hữu và vòng đời buffer. ArrayPool<T> cho phép thuê và trả mảng thay vì cấp phát mới; bài .NET Blog về Pipelines cũng nhắc tới ArrayPool<T> và MemoryPool<T> như các kiểu nền tảng. Lỗi phổ biến là trả buffer về pool khi một thao tác bất đồng bộ vẫn đang dùng nó, hoặc gửi cả mảng thuê (có thể lớn hơn yêu cầu) thay vì chỉ phần dữ liệu thật.
Tick rate, snapshot và đồng bộ trạng thái
Theo tài liệu Source Multiplayer Networking của Valve, server mô phỏng game theo các bước thời gian rời rạc gọi là tick; mặc định bước là 15 ms, tức khoảng 66,67 tick mỗi giây, và mỗi tick server xử lý lệnh người dùng, chạy một bước mô phỏng vật lý, kiểm tra luật chơi rồi cập nhật trạng thái. Sau mỗi tick, server quyết định client nào cần cập nhật và chụp snapshot trạng thái thế giới khi cần. Tick rate cao hơn tăng độ chính xác mô phỏng nhưng đòi hỏi nhiều CPU và băng thông hơn ở cả server lẫn client.
Phía client dùng prediction (dự đoán kết quả input của chính mình) và interpolation (nội suy giữa các snapshot). Valve cho biết độ trễ nội suy mặc định là 0,1 giây; tốc độ snapshot bị giới hạn bởi tick rate của server. Server cũng dùng lag compensation, và Valve nhấn mạnh server không thể tin client trong các quyết định quan trọng như “trúng đạn”, vì gói tin có thể bị sửa trên đường đi.
Mã hóa: TLS hay AEAD ở tầng ứng dụng?
Với TCP và WebSocket, cách tiêu chuẩn là TLS (wss:// cho WebSocket). TLS được hỗ trợ rộng, có sẵn trong .NET qua SslStream và Kestrel, và dựa trên hạ tầng chứng chỉ quen thuộc. Với UDP, TLS không áp dụng trực tiếp; các lựa chọn là DTLS hoặc tự xây lớp bảo mật ở tầng ứng dụng.
Mã hóa ở tầng ứng dụng thường gồm trao đổi khóa (ví dụ X25519) rồi mã hóa xác thực từng gói bằng thuật toán AEAD (ví dụ ChaCha20-Poly1305 hoặc AES-GCM). Cách này cho phép kiểm soát chi tiết nhưng đẩy toàn bộ trách nhiệm về đúng đắn mật mã (nonce không lặp, chống phát lại, xác thực server) sang đội phát triển. Nguyên tắc chung: dùng TLS khi có thể; chỉ tự xây khi có lý do rõ và có người đủ chuyên môn rà soát.
Kiểm thử tải: những cái bẫy thường gặp
- Benchmark trên loopback (127.0.0.1) không phản ánh mạng thật: không có mất gói, jitter, giới hạn băng thông hay độ trễ khứ hồi. Hãy coi số liệu loopback là giới hạn trên của chi phí phần mềm, không phải độ trễ người dùng cảm nhận.
- Mô phỏng mạng xấu: thêm độ trễ, jitter và mất gói (công cụ như tc/netem trên Linux, hoặc tính năng mô phỏng mất gói có sẵn trong một số thư viện như LiteNetLib).
- Đo phân vị (p50, p99, p99.9), không chỉ giá trị trung bình; người chơi cảm nhận các cú khựng ở đuôi phân phối.
- Đo cấp phát và số lần GC cùng lúc với độ trễ.
- Tách máy sinh tải khỏi máy server, nếu không chính client giả lập sẽ tranh CPU.
- Kiểm thử số kết nối đồng thời, không chỉ số thông điệp mỗi giây.
- So sánh các thư viện với cùng payload, cùng phần cứng, cùng điều kiện mạng; số liệu do tác giả thư viện tự công bố cần được kiểm chứng lại trong môi trường của bạn.
Chọn thư viện mạng .NET: SignalR, gRPC, MagicOnion, LiteNetLib, Nalix
Bảng dưới tóm tắt từ tài liệu chính thức của từng dự án. Đây không phải xếp hạng; mỗi thư viện giải quyết một bài toán khác nhau.
| Thư viện | Transport | Điểm phù hợp | Lưu ý |
|---|---|---|---|
| ASP.NET Core SignalR | WebSockets (ưu tiên), Server-Sent Events, Long Polling | Ứng dụng web realtime, broadcast tới nhiều client, có fallback transport; hỗ trợ JSON và MessagePack | Microsoft khuyến nghị SignalR thay vì WebSocket thô cho hầu hết ứng dụng; chạy trên TCP |
| gRPC (.NET) | HTTP/2 | Giao tiếp service-to-service, hợp đồng .proto, streaming | Trình duyệt không gọi trực tiếp được, cần gRPC-Web; không có khái niệm broadcast sẵn |
| MagicOnion | gRPC (HTTP/2) | Dùng interface C# làm schema, StreamingHub cho realtime, hỗ trợ client Unity | Chạy trên HTTP/2 (TCP); dùng MessagePack thay vì Protobuf |
| LiteNetLib | UDP có độ tin cậy | Game cần UDP với nhiều chế độ giao (ví dụ ReliableOrdered), hỗ trợ Unity | Cần tự thiết kế giao thức tầng game |
| Nalix | TCP, UDP, WebSocket | Server game và realtime trên .NET 10, packet nhị phân định nghĩa bằng class C#, bảo mật X25519 + ChaCha20-Poly1305 | Chỉ .NET 10; không hỗ trợ Unity; dự án mã nguồn mở của tác giả bài viết |
Công bố lợi ích liên quan về Nalix
Nalix là dự án mã nguồn mở (giấy phép Apache-2.0) do Phan Phúc Nguyên, tác giả bài viết, phát triển. Theo README, Nalix là framework mạng nhị phân độ trễ thấp cho server game .NET và hệ thống realtime, tương thích Native AOT, có client SDK .NET và hỗ trợ Blazor WebAssembly qua WebSocket. README có công bố benchmark tự đo trên loopback so với SignalR, gRPC và MagicOnion; như đã nói ở phần kiểm thử, đó là số liệu loopback do chính dự án đo và nên được kiểm chứng trong môi trường thật. README cũng có mục “When NOT to use Nalix”. Chi tiết xem bài Nalix: mạng độ trễ thấp cho .NET.
Checklist khi bắt đầu dự án realtime bằng C#
Nếu backend của bạn còn kết hợp AI, xem thêm bài C# và .NET cho backend AI. EYC xây dựng phần mềm và hệ thống trên .NET; thông tin dịch vụ xem tại trang phần mềm và SaaS và R&D AI ứng dụng.
- Phân loại dữ liệu: dữ liệu nào cần tin cậy và đúng thứ tự, dữ liệu nào chỉ cần bản mới nhất.
- Xác định nền tảng client: trình duyệt, Unity, ứng dụng .NET, thiết bị IoT.
- Chọn transport và thư viện theo hai yếu tố trên, không theo benchmark.
- Thiết kế định dạng thông điệp có version, giới hạn kích thước và framing rõ ràng.
- Mặc định server có thẩm quyền, kiểm tra mọi input từ client.
- Mã hóa kết nối ngay từ đầu (TLS/wss hoặc lớp AEAD đã được rà soát).
- Kiểm thử tải với mạng mô phỏng mất gói, đo p99 và cấp phát bộ nhớ.
- Lên kế hoạch mở rộng ngang: WebSocket/SignalR cần cơ chế fan-out giữa nhiều node.
Câu hỏi thường gặp
TCP và UDP khác nhau thế nào khi làm game server?
TCP giao dữ liệu tin cậy và đúng thứ tự nhưng gói mất sẽ chặn các gói sau trong lúc chờ gửi lại. UDP không gửi lại gói mất và không giữ thứ tự, nhưng cho phép dùng ngay dữ liệu mới nhất, nên game hành động thường dùng UDP kèm lớp độ tin cậy tự xây.
Game chạy trên trình duyệt có dùng UDP được không?
Trình duyệt không cho mở socket UDP hay TCP thô; lựa chọn phổ biến là WebSocket, vốn chạy trên TCP. SignalR có thể dùng WebSockets và tự fallback sang transport khác khi cần.
Nên dùng SignalR hay WebSocket thô trong ASP.NET Core?
Microsoft khuyến nghị SignalR cho hầu hết ứng dụng vì có fallback transport, mô hình RPC và trong đa số trường hợp không có bất lợi hiệu năng đáng kể so với WebSocket thô. WebSocket thô phù hợp khi cần kiểm soát giao thức hoàn toàn.
System.IO.Pipelines dùng để làm gì?
Là thư viện .NET giúp viết mã phân tích dữ liệu luồng hiệu năng cao dễ hơn, tự quản lý buffer, hỗ trợ kiểm soát luồng và có sẵn trong shared framework của .NET hiện đại. Kestrel được xây trên nền tảng này.
Tick rate trong game server là gì?
Là số bước mô phỏng server chạy mỗi giây. Tài liệu Valve mô tả mặc định 15 ms mỗi tick (khoảng 66,67 tick/giây); tick rate cao tăng độ chính xác nhưng tốn thêm CPU và băng thông.