concurrentdictionary 是存储 websocket 连接的首选,因其线程安全、增删查原子,但必须配合 websocket.state == open 检查连接有效性,不可直接遍历发送;普通 dictionary 因非线程安全且无法应对连接随时失效,会导致 invalidoperationexception 或 websocketexception,不适用。

直接说结论:用 ConcurrentDictionary 存连接,遍历发消息前先 TryGetValue 判断连接是否还活着,发完不手动关、不捕获异常后吞掉——否则群发会 silently 失败。
为什么不能用普通 Dictionary 存 WebSocket 连接
WebSocket 连接生命周期不可控:客户端可能断网、关页、超时关闭,Socket 或 WebSocketContext 对象随时变无效。普通 Dictionary 非线程安全,多线程读写(比如一个线程在 OnClose 里删键,另一个在群发时遍历)会抛 InvalidOperationException;就算加锁,也挡不住已断连的 socket 在 Send 时抛 WebSocketException 或 ObjectDisposedException。
必须用线程安全容器,且要配合连接有效性检查:
-
ConcurrentDictionary<string websocketcontext></string>是首选,增删查都原子,不用额外锁 - 但注意:它只保“键值存在”,不保“值对象可用”——存进去的
WebSocketContext可能已关闭 - 所以群发时不能直接 foreach value,必须对每个 value 先调
context.WebSocket.State == WebSocketState.Open
群发时怎么避免卡主线程或丢消息
群发本质是同步 I/O 操作,一个客户端 send 卡住(比如网络抖动),整个 for 循环就堵住。实际部署中,这会导致后续所有消息延迟甚至积压。
关键做法是异步 + 超时 + 忽略单点失败:
- 用
await context.WebSocket.SendAsync(..., CancellationToken),别用同步Send - 给每个 send 绑定独立的
CancellationTokenSource,超时设为 3–5 秒(TimeSpan.FromSeconds(4)) - catch
OperationCanceledException和WebSocketException,记录日志但 continue,不中断整个广播 - 不要等所有 send 完成才返回——用
Task.WhenAll并行发,但别await它;让群发逻辑 fire-and-forget
ConcurrentDictionary 的 Key 选什么才靠谱
Key 不是随便取的。常见错误是用 Guid.NewGuid().ToString() 当 key,结果无法定位用户;或者用 context.User.Identity.Name,但匿名连接没 identity。
可靠方案只有两个:
- 强制前端在建立连接时带唯一标识:URL 参数如
ws://host/chat?id=usr_7a2f,服务端从context.QueryString["id"]提取,作为 key 存入ConcurrentDictionary - 若需登录态绑定,改用
context.Request.Cookies["auth_token"]解析出用户 ID,但要注意 cookie 可能为空或过期,得 fallback 到随机 ID - 绝对不要用
context.WebSocket.GetHashCode()或context.ToString()——每次调用可能不同,且重启后失效
群发代码里最容易漏掉的三件事
不是语法错,而是逻辑盲区:
- 没在
OnClose里及时从ConcurrentDictionary中移除 key:导致字典越来越大,且下次同名 key 进来会覆盖旧值,老连接收不到新消息 - 群发前没 clone 字典的 key collection:
foreach (var key in dict.Keys.ToList())——否则遍历时删 key 会抛异常 - 消息序列化用
JsonConvert.SerializeObject但没设ReferenceLoopHandling.Ignore:如果消息对象含循环引用(比如 User → Group → Users),直接崩在序列化阶段
真实线上环境里,连接闪断、序列化失败、字典膨胀这三类问题占群发故障的 80% 以上,比协议配置或 CORS 问题更常出现。











