Skip to content

WebSocket 架构与并发模型 ​

适用范围:Mud.Feishu.WebSocket 本文把源码中分散的锁策略注释(WebSocketConnectionManager.cs 的类级 <remarks>、ReconnectionOrchestrator 的闸门注释等) 升格为模块级正式约定。新增/修改代码必须逐条自检本文的"架构不变量"。

1. 组件与职责 ​

组件职责关键约束
WebSocketConnectionManager连接生命周期(建立/关闭/替换 socket)、收发原语事件一律在锁外触发;释放后入口确定性抛 ObjectDisposedException
FeishuWebSocketClient认证、消息接收与分发调度、心跳与重连编排接收路径承担背压;Task.Run 不传取消令牌
BinaryMessageProcessor二进制帧增量接收、ProtoBuf/JSON 解析、去重与 ACK装配串行化;在途处理数有硬上界
MessageRouter按消息类型选择 IMessageHandler处理器超时后仍观察其异常
FeishuWebSocketConcurrencyService并发上界(背压闸门)、热更新、关停等待信号量不随 Dispose 释放
ReconnectionOrchestrator重连编排、熔断、冷却、指标闸门持有期内派发事件,保证"至多一轮重连在途"
HeartbeatManager发送应用层 ProtoBuf Ping;应用服务端下发的 PingInterval不据 Pong 判死(对齐 Python SDK)

2. 锁策略 ​

锁保护对象说明
WebSocketConnectionManager._connectionLock连接生命周期(建立/断开/替换 socket)不可重入;内部方法一律调用无锁版本 DisconnectCoreAsync
WebSocketConnectionManager._sendLock发送(文本 + 二进制)与生命周期解耦:慢发送不阻塞连接/重连。ClientWebSocket 同一时刻只允许一个未完成 SendAsync
BinaryMessageProcessor._processLock整条二进制消息的装配(防止 A→B 帧片段交错)不可重入;ProcessBinaryDataAsync 先取"在途槽位"再取本锁
FeishuWebSocketClient._connectLock连接/断开的编排(含后台任务收尾)重连路径经 FeishuWebSocketManager 串行化
FeishuWebSocketConcurrencyService._semaphoreLock信号量热替换仅保护替换动作

释放策略(I9):本模块从不访问 SemaphoreSlim.AvailableWaitHandle,因此这些信号量不随 Dispose 释放 (不释放 = 无 OS 句柄泄漏;释放 = 与在途 WaitAsync/Release 构成 ObjectDisposedException 竞态)。

唯一登记例外(R2/WS2-06):FeishuWebSocketConcurrencyService 在配置热更新时原子替换信号量, 并延迟释放旧信号量——保留时长为 max(60 秒, 2 × MessageHandlerTimeoutMs)。 该例外是必要的:在途租约仍持有旧信号量的引用,归还时会调用其 Release(); 保留时长必须 ≥ 租约最长合法持有时间(MessageHandlerTimeoutMs),否则慢处理器归还租约时会抛 ObjectDisposedException。公式与 Webhook 侧 FeishuWebhookConcurrencyService 完全一致(跨模块对齐)。 计算入口:FeishuWebSocketConcurrencyService.ResolveLegacySemaphoreRetention(int)(纯函数,可单测)。 新增任何"延迟释放信号量"的代码前必须先在此处登记。

3. 连接代次与断线声明(I2) ​

  • _disconnectedFired 是实例级唯一标志,不区分连接代次。
  • 断线声明必须携带 socket 身份:NotifyDisconnected(args, owner),owner != null 时只接受当前 _webSocket 的声明。
  • 旧接收循环的迟到异常因此被丢弃:不触发 Disconnected、不递减连接计数、不吞掉后续真实断线。
  • DisconnectCoreAsync 在"原子占位"前同样做 owner 校验,并在 ConnectAsync 的成功/失败两条路径上 由调用方在锁外派发事件(失败路径先派发、后以原类型/原堆栈抛出异常)。

4. 重连闸门(I11/I12) ​

text
TryReconnectAsync
  ├─ 熔断打开 / 未启用自动重连 → false
  ├─ Interlocked.CompareExchange(gate, 1, 0) != 0 → "重连已在进行中" → false   ← 锁外,快速失败
  ├─ await _reconnectLock(仅保护一轮重连内部状态)
  │    └─ 冷却期 → 循环:ShouldContinueReconnect → 退避 → ReconnectAsync → 成功则复位计数
  ├─ finally 释放 _reconnectLock
  ├─ 在闸门持有期内、锁外派发 ReconnectSucceeded/Failed/LimitReached
  └─ finally: Volatile.Write(gate, 0)     ← 任何路径(含提前 return、回调抛异常)都必须复位
  • ReconnectState.IsReconnecting 是闸门的派生值(单一真源,不再维护第二个布尔字段)。
  • 订阅者约定:严禁在回调内同步阻塞(会占住闸门,使后续重连被跳过);重入 TryReconnectAsync 会 立即返回 false(既不阻塞也不会开启嵌套重连轮次)。
  • 达到重连上限后打开熔断(IsCircuitOpen),仅在连接成功时关闭。

5. 事件时序 ​

事件触发时机是否在锁外
Connected新连接握手成功(若有旧连接,先 Disconnected 再 Connected)是
Disconnected① 服务端关闭帧 ② 客户端主动断开 ③ 接收循环异常 ④ 接收循环因取消退出但 socket 仍为 Open(R2/P0-1 新增) ⑤ 握手失败但旧连接已关是(最多一次/代次)
Error可恢复/不可恢复错误是
ReconnectSucceeded/Failed/LimitReached一轮重连结束后是(闸门持有期内)
MessageReceived收到文本帧后,在并发租约内、处理任务体中(R2/WS2-08 变更)是;可能并发、可能乱序,属观测钩子

MessageReceived 线程契约(R2 行为变更,务必知悉):该事件不再在接收循环线程上同步派发, 而是在取得并发租约后的任务体内派发(与《背压模型》§6 一致)。因此:

  • 慢订阅者不会阻塞接收管道;它只占用一个并发槽位(形成反压),代价是该连接的可处理并发度下降。
  • 事件可能并发执行、且不保证与帧到达顺序一致。
  • 需要"顺序处理"或"受 MessageHandlerTimeoutMs 保护"的业务逻辑,必须改用 IMessageHandler(经 MessageRouter)。

主动断开的顺序约束(R2/I13):FeishuWebSocketClient 的停机路径先结束连接(由 WebSocketConnectionManager.DisconnectCoreAsync 完成"占位 + 关闭握手"),再取消接收循环令牌。 顺序颠倒会产生两类问题:① 接收循环因取消退出时 socket 仍 Open,会按"接收循环被取消"补发 Disconnected,把本端主动重连描述成异常断线;② 接收循环异步退出与 DisconnectCoreAsync 的原子占位互相抢先,事件有可能一次都不派发。

僵尸连接(R2/P0-1):若接收循环已结束而 WebSocketState 仍为 Open,模块会把它判为僵尸态:

  • Disconnected 在取消路径被补发(I13);
  • FeishuWebSocketHostedService 的周期性检查与 FeishuWebSocketHealthCheck 均会判定异常并触发重连 (存活探针字段见《故障排查手册》§2);
  • 判定的唯一确定性组合是"State == Open 且接收循环已结束"——"长时间无帧"不判死 (飞书长连接空闲时本就没有事件帧,客户端心跳是单向的)。

6. 背压模型(I10) ​

text
接收循环 ──await AcquireAsync(并发闸门,槽位耗尽即挂起 = TCP 反压)──▶ 拷贝本次帧 ──▶ Task.Run
                                                                                    ├─ MessageReceived 派发(观测钩子)
                                                                                    ├─ 认证闸门(AuthGateTimeoutMs)
                                                                                    ├─ 业务处理(可含 ProtoBuf 装配)
                                                                                    └─ finally 归还槽位

R2/WS2-08:MessageReceived 已从"接收循环线程同步派发"移入上述任务体。 改造前它在 AcquireAsync 之前同步调用订阅者,慢订阅者会直接堵住整条接收管道 (不读帧、不回 ACK、不推进心跳),与本节"接收路径承担背压"的设计意图相反—— 背压应向上游 TCP 施加,而不是被下游回调反噬。

  • 排队任务与消息副本数一并受 MaxConcurrentHandlers 约束(此前只约束"处理中",排队无界)。
  • Task.Run 不得传入取消令牌:令牌已取消时委托不会执行,而租约所有权已移交 → 永久泄漏 → 接收管道卡死。
  • MaxConcurrentHandlers = 0 表示无限制,此时接收侧永不阻塞(与改造前行为一致)。
  • I10:消息处理器不得反向依赖接收循环的推进(例如在同一 socket 上做请求-响应式等待),否则构成循环等待。
  • 副作用:背压期间不读帧、不回 ACK,服务端重投窗口随之变长;去重层(EventId/SeqID)负责兜底。

7. 架构不变量清单(评审硬门槛) ​

#不变量
I1用户事件一律在锁外触发(SemaphoreSlim/Monitor 均不可重入)
I2断线声明必须携带连接代次(socket 身份),跨代次通知一律丢弃
I3持锁范围内禁止 await 长耗时操作(握手、重连循环、消息处理)
I4公开 API 传递共享缓冲区时必须在文档中声明生命周期
I5新增配置项必须同步 Validate() + 4 份 Readme + CHANGELOG
I6AOT:禁止反射版 JsonSerializer;protobuf 必须走 FeishuWebSocketProtoModel.Instance
I7netstandard2.0 不得使用 net5+ API(必要处条件编译)
I8每个 .cs 必须第 1 行以 6 行版权头开始
I9信号量不随 Dispose 释放(未访问 AvailableWaitHandle)
I10处理器不得依赖接收循环的推进
I11闸门释放必须与获取严格配对(任何路径都在最外层 finally 复位);所有权移交不得依赖"委托一定执行"
I12IsXxx 状态标志只允许单一真源(不得同时维护字段与闸门两套状态)
I13连接终止路径穷尽占位:任何"使 socket 不再被读取"的路径(服务端关闭帧、客户端主动断开、接收循环异常、接收循环因取消退出、Dispose/DisposeAsync)都必须经过 TryClaimDisconnected;主动断开必须先占位、后关闭握手
I14接收循环唯一性由原子占位保证(不得依赖"先检查后使用"的字段读);循环 Task 必须登记到 _receiveTask 以纳入停机等待与存活判定
I15调用方 CancellationToken 只约束建连阶段(握手 + 认证),不构成连接生命周期;连接生命周期由客户端自持 CTS 控制
I16配置面双向约束:数值配置必须同时校验下界与上界;所有 TimeSpan 在转换为 CancellationTokenSource/Task.Delay 前必须经过钳制(Core/TimeSpanGuards.cs)。netstandard2.0 禁用 Math.Clamp

I13–I16 的落地位置:WebSocketConnectionManager(I13 的占位与顺序、I2 身份校验)、 FeishuWebSocketClient(I14 的原子占位与任务登记、I15 的自持 CTS、连接存活性派生)、 Core/TimeSpanGuards.cs + FeishuWebSocketOptions.Validate()(I16)。 对应的源码级守卫测试见 Tests/Mud.Feishu.WebSocket.Tests/ContractGuards/WebSocketContractGuards.cs。

8. 单应用装配前提(SeqID 去重键的口径) ​

本模块在同一进程内只装配一个 WebSocket 连接,且只绑定默认应用:

  • FeishuWebSocketManager.ResolveCurrentContext() 使用 IFeishuAppManager.GetDefaultApp(), 不遍历 GetAllApps();
  • IFeishuWebSocketClient 与 IFeishuSeqIDDeduplicator 均为单例(FeishuWebSocketServiceBuilder 注册), 因此二者各只有一份;
  • FeishuAppManager 为"每个应用"创建的 IServiceScope 只用于装配 HTTP/认证/令牌上下文, 不产出 WebSocket 客户端。

由此可以解释一个容易被误读的实现细节:SeqID 去重键是裸 SeqID(无应用/租户维度)。 在有前提的装配下(单连接 + 默认应用)它不可能误伤——不同应用的消息不会进入同一个去重集合。

⚠️ 升级前提:若将来引入多应用 WebSocket 装配(每应用一个客户端/连接), 必须同时给 SeqID 去重键加入应用维度(TryMarkAsProcessedAsync(seqId, scopeKey))。 否则 A 应用已处理的 SeqID 会抑制 B 应用的同号消息,表现为静默事件丢失 (无异常、无告警——"重复"是被当作正常路径跳过的)。 该耦合由守卫 SeqIdDeduplication_ShouldStaySingleAppScoped_OrGainAppDimension 守护: 一旦源码中出现多应用装配,"裸 SeqID 调用点"的断言会一并失败并指向本节。