c# WebSocket 和高并发双向通信 C# 实现

生产环境必须用 Microsoft.AspNetCore.WebSockets,因其内置 HTTP 升级、连接管理、超时控制与 DI 集成;裸用 System.Net.WebSockets 需手动处理握手、生命周期等,易出错且不支持高并发。

WebSocket 服务端用 Microsoft.AspNetCore.WebSockets 还是 System.Net.WebSockets

直接上结论:生产环境必须用 Microsoft.AspNetCore.WebSockets(即 ASP.NET Core 内置 WebSocket 中间件),而不是裸用 System.Net.WebSockets。后者只是底层协议封装,不处理 HTTP 升级、连接生命周期、并发调度、TLS 终止等关键环节——你得自己写握手、解析 Upgrade 头、管理 socket 池,极易出错且无法承载高并发。

ASP.NET Core 的 UseWebSockets 中间件已内置超时控制、缓冲区复用、连接限流(WebSocketOptions 可配 KeepAliveIntervalReceiveBufferSize),且天然集成 DI、日志、中间件管道。

  • WebSocketOptions.KeepAliveInterval = TimeSpan.FromSeconds(30):避免 NAT/防火墙静默断连
  • WebSocketOptions.ReceiveBufferSize = 4 * 1024:小包多频场景下比默认 4KB 更省内存
  • 务必在 Startup.Configure 中调用 app.UseWebSockets(options => { ... }),且位置要在 UseRouting 之后、UseEndpoints 之前

如何安全地广播消息给所有在线连接?别用静态集合存 WebSocket

WebSocket 对象不是线程安全的,且不可跨请求重用。常见错误是把 WebSocket 直接塞进 static ConcurrentDictionary,然后在另一个线程里直接 SendAsync——这会触发 InvalidOperationException: "The WebSocket is in an invalid state",因为连接可能已在其他线程关闭或正被读取。

正确做法是:为每个连接分配唯一 ID(如 GUID),用 ConcurrentDictionary 存储包装类,内部封装 WebSocket + CancellationTokenSource + 状态标记,并在发送前检查 State == WebSocketState.Open

public class WebSocketConnection
{
    public WebSocket Socket { get; }
    public CancellationTokenSource CloseToken { get; } = new();
public WebSocketConnection(WebSocket socket) => Socket = socket;

public async Task SendAsync(byte[] data)
{
    if (Socket.State != WebSocketState.Open) return;
    try
    {
        await Socket.SendAsync(new ArraySegmentzuojiankuohaophpcnbyteyoujiankuohaophpcn(data), 
            WebSocketMessageType.Binary, true, CloseToken.Token);
    }
    catch (OperationCanceledException) { }
    catch (WebSocketException) { /* 连接已断,后续清理 */ }
}

}

ReceiveAsync 阻塞模型怎么应对高并发读?必须配合 MemoryPool

每个 WebSocket 连接默认独占一个后台线程执行 ReceiveAsync 循环,若用 new byte[bufferSize] 分配缓冲区,在万级连接下会引发 GC 压力暴增和内存碎片。实测 5000 连接持续收 1KB 消息时,Gen2 GC 频率从 2 分钟一次飙升至每秒多次。

解决方案:用 MemoryPool.Shared.Rent(8192) 替代 new byte[8192],并在处理完后调用 .Return() 归还缓冲区。注意 ArraySegment 必须指向 Memory.Span,不能直接传 MemoryPool.Rent().MemoryToArray()(会触发拷贝)。

  • 缓冲区大小建议设为 2^n(如 4096、8192),匹配 MemoryPool 默认块大小
  • ReceiveAsync 返回的 WebSocketReceiveResultCount 是实际接收字节数,不是缓冲区长度
  • 必须用 while (!token.IsCancellationRequested) 包裹接收循环,否则连接断开时线程不会退出

为什么用了 ConcurrentDictionary 还出现连接丢失?检查 OnConnectedAsync 异常捕获

很多人把 WebSocket 接入逻辑写在 MapGet("/ws", async context => { ... }) 里,但没包裹 try/catch。一旦 AcceptWebSocketAsync() 后的初始化代码(如鉴权、DB 查询)抛异常,连接会被静默关闭,客户端收到 1006 错误,而服务端日志里只有未捕获异常堆栈,找不到对应连接 ID。

必须确保整个 WebSocket 生命周期都在 try/catch 内,且异常时主动调用 websocket.CloseAsync(WebSocketCloseStatus.InternalServerError, "...", CancellationToken.None),再清理字典中对应项。

  • 不要在 AcceptWebSocketAsync 前做耗时操作(如查 DB),否则会阻塞 HTTP 升级响应
  • 客户端重连间隔建议用指数退避(如 1s → 2s → 4s),避免雪崩式重连冲击
  • Ke

    strel 默认单连接最大请求体是 30MB,若需传大文件,要显式配置 options.Limits.MaxRequestBodySize = null

真正难的不是写通 WebSocket,而是让成千上万个连接在内存、GC、线程调度、网络丢包、客户端异常断连这些边界条件下稳定跑满 7×24 小时。每个 WebSocket 实例背后都是操作系统 socket、TLS 状态、.NET 线程池资源的精确配比,漏掉任意一环,压测时都会在凌晨三点给你发告警。