
如果你写过 RPC 接口八成被同一个问题憋过服务端有状态变化怎么把数据主动推到客户端传统请求-响应模式下客户端只能隔几秒轮询一次要么忍受延迟要么被无效请求打爆。后来我深入研究 .NET 里的全双工 RPC 方案才发现这个问题其实能从根本上解决。本文不会只讲概念我会把传统 RPC 的单向到底卡在哪拆开说清楚再给出一套能在 .NET 里直接落地的全双工 RPC 实现思路包含 gRPC 双向流、自研 TCP 帧协议、压测结论以及我实际踩过的坑。无论你是做微服务、IoT 网关还是桌面工具只要需要双向实时通信这篇都值得花几分钟看完。1. 先从单向的槽点说起RPC 到底被什么卡住了1.1 请求-响应模型的三个硬伤很多人一听到传统 RPC 只能单向就会反驳RPC 明明有请求也有响应怎么会是单向这里说的单向不是指数据只走一个方向而是指通信的主动权与消息流的组织方式存在天然限制。第一个硬伤是主动权单向。在标准的请求-响应模型里永远是客户端发起调用服务端被动应答。服务端如果有新的状态变化比如订单从待支付变成已支付、设备离线、价格波动它没办法主动开口告诉客户端。客户端只能反复轮询有没有新状态轮询间隔短延迟低但资源浪费大轮询间隔长资源是省了业务实时性又没了。本质上是服务端的长了嘴但说话的权利握在客户端手里。第二个硬伤是连接生命周期与请求绑定。早期的 RPC 实现里一个连接往往只为一次请求服务请求完就断开。后来有了 keep-aliveTCP 连接可以复用但 HTTP/1.x 时代同一连接上的请求仍然是串行处理的一个请求没返回后面的请求就得排队。如果某个请求特别慢整条连接上的所有请求都会被拖住这就是常说的队头阻塞。对高频调用场景来说连接资源被大量占用吞吐上限被卡得很死。第三个硬伤是消息流单向。即使连接是持久的服务端在一个请求周期内也只能老老实实把响应体写完中途无法插入其他消息也无法分多条推送。想在一个连接上承载请求-响应和服务端主动推送两件事传统协议栈根本安排不过来。这三个问题叠加起来你会发现实时性差、资源浪费、连接利用率低几乎是必然结果。1.2 几个典型场景为什么轮询和长连接都别扭我最早意识到这个问题是在做一个设备监控网关。现场几百台设备每台设备的状态随时会变客户端界面要实时显示。最初的方案是客户端每 3 秒拉一次所有设备状态一页 500 台设备每拉一次就是 500 条 RPC。实际上大部分状态根本没变90% 的请求都是白费的。后来改成按设备订阅但订阅本身还是客户端先发起服务端变化了依然没法主动送达只能客户端再定期拉。绕了一大圈问题没根治。另一个印象深的场景是协作编辑。一个文档被多人同时编辑A 改了内容要立刻推给 B。用传统 RPC 做B 必须不停问文档变了吗而且每次还要带版本号服务端做全量对比实现复杂不说版本冲突处理也容易出错。这类场景真正需要的能力是A 的变更一旦到达服务端服务端在同一时刻把变更推给 B不需要 B 主动来问。这就是全双工通信要解决的核心问题一条连接上两端可以同时、主动、独立地发送数据。2. 全双工 RPC 的本质一条连接两个方向同时说话2.1 从 HTTP/2 多路复用说起全双工的概念其实不难理解。拿对讲机类比传统 RPC 是半双工按住说话键的时候听不到对方说完松开才能接收。而全双工就像打电话两人可以同时说、同时听没有先来后到的规矩。要实现这种效果首先解决的是多路复用问题。HTTP/2 引入了一个关键机制在一条 TCP 连接上可以同时存在多个独立的 stream每个 stream 都有自己的消息流。之前的 HTTP/1.x 是一个请求占一个连接现在是一条连接里塞很多虚拟通道大家互不排队。这直接干掉了队头阻塞问题。RPC 框架只要沿用这个思想把每个 stream 设计成双向的两端就能在同一连接上各自独立读写。gRPC 就是这么干的。它把 HTTP/2 的双向流能力封装成了四种 RPC 模式一元调用unary一问一答、服务端流式服务端连续推、客户端流式客户端连续发、双向流式两边同时发。双向流式模式下客户端在写请求的同时服务端可以往同一条流里写推送两边的读写是独立的。很多人以为全双工只是并发量大一点其实真正关键是在协议层面服务端终于获得了主动发起通信的权利。2.2 .NET 生态里的三条可行路线把全双工 RPC 落到 .NET 里我实际评估过三条路各自的出发点和适用场景差异很大。方案通信底座优势劣势最适合的场景gRPC 双向流HTTP/2生态成熟跨语言拦截器、认证、重试都有现成组件依赖 HTTP/2浏览器不能直接用原生 gRPC需要 grpc-web 做适配服务端到服务端的实时双向通信、IoT 数据上报与指令下发SignalR 强类型 RPCWebSocket / SSE / 长轮询穿透性好自带重连和分组服务端主动推送能力原生不是标准 RPC 协议二进制性能不如 gRPC跨语言支持弱浏览器页面、移动端实时推送、聊天、协作编辑自研 TCP System.IO.Pipelines裸 TCP协议完全可控帧结构可定制开销最低拆包、粘包、重连、背压、心跳全部要自己写嵌入式设备、私有长连接网关、极致性能场景如果你问我现在推荐哪个默认答案是 gRPC 双向流。SignalR 适合客户端是浏览器的场景但如果通信双方都是服务或桌面应用gRPC 更专业。自研方案看起来最硬核但成本很高后文我会给出一个能跑通的帧协议设计方便你评估是否真的要自己动手。3. 主推路线ASP.NET Core gRPC 双向流实战落地3.1 proto 服务定义一个双向流方法长什么样我用订单实时监控举个例子。传统 RPC 里要订阅某个订单的状态变化得自己写轮询接口。用双向流写的话proto 文件大概是这样syntax proto3; package order; service OrderService { // 客户端持续发送订阅请求服务端持续推送订单事件 rpc Monitor(stream SubscribeRequest) returns (stream OrderEvent); } message SubscribeRequest { string order_id 1; int32 watch_interval_ms 2; } message OrderEvent { string order_id 1; int32 order_status 2; string payload 3; int64 sequence 4; }注意stream关键字出现在请求和响应两侧这就定义了一个双向流方法。客户端不是只发一个订阅请求就结束而是可以持续发送服务端也不是只回一条就关闭而是可以持续推送。这个接口设计天然消掉了如何让服务端主动推的问题。3.2 服务端实现读循环与写循环完全解耦服务端实现OrderService.Monitor方法时核心思路是把读客户端请求和写事件推送拆成两个互不干扰的任务。我这里给你一个精简但可运行的结构public override async Task Monitor( IAsyncStreamReaderSubscribeRequest requestStream, IAsyncStreamWriterOrderEvent responseStream, ServerCallContext context) { var channel Channel.CreateUnboundedOrderEvent(); var consumer Task.Run(() ConsumeAndWriteAsync(channel.Reader, responseStream, context.CancellationToken)); try { await foreach (var request in requestStream.ReadAllAsync(context.CancellationToken)) { // 根据订阅请求把对应的订单事件源注册到 channel await RegisterOrderSourceAsync(request.OrderId, channel.Writer, context.CancellationToken); } } finally { channel.Writer.TryComplete(); await consumer; } } private static async Task ConsumeAndWriteAsync( ChannelReaderOrderEvent reader, IAsyncStreamWriterOrderEvent writer, CancellationToken cancellationToken) { await foreach (var evt in reader.ReadAllAsync(cancellationToken)) { await writer.WriteAsync(evt); } }这个模式里有三个关键设计点用 Channel 解耦业务推送和网络写入。订单状态变化可能来自多个后台任务它们只需要往 Channel 里写ConsumeAndWriteAsync统一负责网络写出避免了多线程同时调用WriteAsync导致的无序问题。ReadAllAsync是按需取数。客户端发多少请求服务端就处理多少不会因为客户端突然不发了就把连接挂死。CancellationToken 贯穿所有循环。客户端断开时context.CancellationToken会被触发读循环和写循环都能及时退出不会留下孤儿任务。3.3 客户端一边写指令一边收推送客户端的结构也很直白。创建一个双向流调用后拿到RequestStream和ResponseStream两个独立的流对象一个用于发送订阅指令一个用于接收推送事件using var call _client.Monitor(cancellationToken: ct); // 读推送循环放在独立任务里持续消费服务端事件 var readTask Task.Run(async () { await foreach (var evt in call.ResponseStream.ReadAllAsync(ct)) { _eventHandler.Handle(evt); } }); // 写订阅请求按需发送不阻塞读推送 foreach (var orderId in new[] { ORD-001, ORD-002 }) { await call.RequestStream.WriteAsync(new SubscribeRequest { OrderId orderId, WatchIntervalMs 500 }); await Task.Delay(1000, ct); } // 发送结束信号并等待读循环完成 await call.RequestStream.CompleteAsync(); await readTask;客户端这里最大的变化是发送订阅请求和接收推送是两个独立异步流程。写的时候不用等读读的时候也不管你写了没有。如果你观察过传统 RPC 客户端就会发现这种双写模型在原来的框架里根本不存在。还有一个细节必须提醒反序列化对象在跨线程之间传递时尽量保持只读。如果_eventHandler.Handle(evt)内部做了修改或者把evt放进了多个消费者的共享队列很可能碰到并发读写污染。3.4 超时、元数据与取消别把双向流当成永不断开的管子双向流不是连上就永远活着超时和取消机制在设计阶段就要考虑清楚。gRPC 的调用可以通过CallOptions.Deadline设置整体截止时间。对双向流来说这个 Deadline 是整个流的生命周期上限而不是单条消息的时限。如果你希望流长期存活但要保持活跃就应该配合心跳消息来维持通道否则网络空闲时间过长中间设备可能把连接回收掉。客户端构造调用时可以这样加 metadata 和 Deadlinevar headers new Metadata { { client-id, gateway-01 } }; var options new CallOptions( headers: headers, deadline: DateTime.UtcNow.AddHours(2), cancellationToken: ct);在服务端context.RequestHeaders里能读到客户端传的元数据。我习惯把设备唯一标识放在这里而不是塞进每条消息的字段里这样消息体积更小日志也能直接通过RequestHeaders关联到具体调用方。4. 如果必须自研TCP System.IO.Pipelines 全双工帧协议怎么设计4.1 为什么还要自研说点大实话看到这里你可能想问既然 gRPC 双向流这么好为什么还有人自研我经历过的真实场景是某些工控设备只支持裸 TCP固件里没有 HTTP/2 栈甚至没有 TLS另一种情况是数据包格式被甲方指定了必须兼容私有协议。这时候把 gRPC 硬塞进去是不现实的。但自研最大的代价不是编码而是协议设计。消息边界怎么定粘包怎么拆双向同时写会不会互踩断线怎么重连这些问题每个都要有明确答案。下面这个方案我实际跑过用 System.IO.Pipelines 做底层帧协议采用定长头 变长载荷的经典设计既能满足全双工需求又有足够的扩展空间。4.2 帧结构让收包方一眼认出边界我把帧头设计成 19 字节定长结构如下字段长度说明Magic4 字节固定为0x4E 0x54 0x50 0x01用于快速过滤脏数据MessageId8 字节long 类型大端序全局递增用于请求-响应配对FrameType1 字节0x01请求0x02响应0x03推送0x04心跳0x05AckFlags1 字节第 1 位表示 payload 是否压缩第 2 位表示是否为分片尾帧PayloadLength4 字节int 类型大端序表示 payload 字节数Checksum1 字节对帧头 18 字节的 XOR 校验防止解析错位后无法察觉messageId 是全双工通信的核心。客户端发出一个请求时生成一个递增的 messageId服务端处理完返回时把相同的 messageId 带回响应。这样客户端只管发收到响应后根据 messageId 去匹配对应的等待任务。推送消息的 messageId 可以由服务端自己生成客户端通过 FrameType0x03 直接走推送回调。4.3 服务端管线PipeReader 解析 Channel 写出服务端收到一个 TCP 连接后用PipeReader.Create(stream)和PipeWriter.Create(stream)分别管理读和写。读循环负责解析帧写循环负责从 Channel 里取帧写出。private static async Task ProcessConnectionAsync(Socket socket) { var stream new NetworkStream(socket, ownsSocket: true); var reader PipeReader.Create(stream); var writer PipeWriter.Create(stream); var outQueue Channel.CreateBoundedFramePayload(new BoundedChannelOptions(1024) { FullMode BoundedChannelFullMode.Wait }); var writeTask WriteLoopAsync(writer, outQueue.Reader); var readTask ReadLoopAsync(reader, outQueue.Writer); await Task.WhenAll(readTask, writeTask); // 关闭前把剩余帧 flush 掉 await writer.CompleteAsync(); }Channel.CreateBounded在这里扮演了一个极其重要的角色背压。如果对端读得慢outQueue写满后后续业务往 Channel 里写就会被阻塞从而让上层生产者减速而不是无限堆积内存导致 OOM。这就是全双工和半双工在工程上的一个巨大分水岭——不仅要能双向发还要能控制双向的节奏。4.4 客户端请求表用 Dic 完成配对客户端的读循环和服务端类似但多了一张关键的字典private readonly ConcurrentDictionarylong, TaskCompletionSourceFramePayload _pendingRequests new(); public async TaskFramePayload SendRequestAsync(FramePayload payload, CancellationToken ct) { var messageId Interlocked.Increment(ref _messageId); var tcs new TaskCompletionSourceFramePayload(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests[messageId] tcs; using var registration ct.Register(() tcs.TrySetCanceled(ct)); await WriteFrameAsync(FrameType.Request, messageId, payload); return await tcs.Task; }读循环收到 FrameType0x02 的响应帧时会把 Frame 交给_pendingRequests.TryRemove(messageId)对应的 tcs完成请求-响应配对。而收到 FrameType0x03 的推送帧时不进这张表直接交给推送回调。这样客户端就能做到发起请求的同时别的推送消息也能被即时处理互不阻塞。自研方案里最容易翻车的地方是TaskCompletionSource的创建模式。如果忘记指定RunContinuationsAsynchronously当响应帧到达时读循环所在的线程会直接同步执行等待方继续的逻辑可能导致读循环长时间阻塞在业务代码里后面所有帧都跟着排队。4.5 心跳与断线判定全双工连接最忌讳静默死亡。我采用的方案是客户端每 10 秒发一个 FrameType0x04 的心跳帧服务端收到后立即回一个 FrameType0x05 的 Ack。客户端如果连续 3 次心跳没收到 Ack就认为连接已死主动触发重连。心跳还有一个额外好处能对抗网络中间设备的空闲连接回收。很多防火墙会对空闲 TCP 连接做超时清理定期的心跳流量可以保证连接一直处于活跃状态避免连接被默默切断后还要等系统默认 TCP 超时才被发现。5. 实测压测与踩坑全双工不是连上就能双向5.1 压测方法模拟双向高频消息流我验证方案时搭了一套本地压测环境客户端 50 个并发任务同时持续发送请求和接收服务端推送每 30 秒统计一次双向吞吐。用dotnet-counters monitor观察进程层面的 CPU、内存和线程池再配合dotnet-trace抓热点。压测结果可以给你们一个参考量级单连接 小帧200 字节以内纯计算不落盘的场景每秒双向合计 8 万到 12 万条消息是可以做到的继续往上走瓶颈一般在序列化和业务回调而不是传输层。但压测时最值得盯的不是尖峰吞吐而是 99 分位延迟。全双工有一个隐蔽问题如果服务端写循环和客户端读循环的速度不匹配大量推送会堆积在某一侧的缓冲区里。表面上看吞吐没降实际延迟被无限拉大表现就是某个客户端已经收到的消息是好几秒之前的。开启背压后吞吐会降一些但延迟分位数会稳定下来实际体验反而更好。5.2 坑一RPC 调用 30 秒超时查了半天才发现是慢消费者线上遇到过最诡异的问题一个看似简单的订阅接口运行一段时间后客户端开始报类似cannot finish rpc call in 30 seconds: null的超时错误。第一反应是网络问题后来发现网络完全正常。真正原因是服务端某个下游调用偶尔会卡住几秒写循环里的WriteAsync被前面的业务逻辑拖住堆积的推送把 Channel 写满客户端读不到心跳直到调用级别的 30 秒 Deadline 到期。排查链路其实很有规律先看错误码是不是DeadlineExceeded说明超时还是Unavailable说明连接断开再抓dotnet-trace看服务端写循环在哪个方法上阻塞最后打开客户端日志看最后收到推送的时间点是否与超时时刻吻合。只要把服务端慢消费者和后端恢复的逻辑做好降级这类问题就会明显减少。5.3 坑二连接重置很多时候是 HTTP/2 流量窗口太小自研 TCP 方案很少遇到连接重置问题但 gRPC 双向流在 Windows 上跑高流量时我踩过connection reset的坑表现形式类似浏览器里的net::ERR_CONNECTION_RESET或curl 56 recv failure: connection timeout。原因通常是 HTTP/2 的流控窗口太小。.NET 默认的InitialHttp2StreamWindowSize偏保守高吞吐推送时数据停留在传输层缓冲对端等不到后续数据就会判定连接异常。解决办法是在客户端启动时把流控窗口调大// 建议放到 Program.cs 最前面 AppContext.SetSwitch(System.Net.Http.SocketsHttpHandler.Http2StreamWindowSize, true);然后创建SocketsHttpHandler时设置InitialHttp2StreamWindowSize 1024 * 1024。我把默认的 64KB 调到 1MB 后问题明显缓解。注意这个设置是全局的影响该进程下所有 HTTP/2 连接做服务端对接时建议先压测确认。5.4 坑三乱序与重复推送流内有序不等于流间有序gRPC 双向流保证同一条流内消息的接收顺序与发送顺序一致但很多人会忽略一个前提这个保证只针对单条流。如果服务端内部开多个任务同时往同一个IAsyncStreamWriterT发实际上并不保证最终写入顺序客户端如果有两条不同的流跨流顺序更是完全不可控。另一个隐蔽问题是重试导致的重复推送。客户端在流断开后重试时服务端无从判断这个消息之前推送过没有结果就是下游收到重复事件。我的建议是业务消息里带上sequence序号客户端维护一个去重缓冲序号小于等于已处理序号的消息直接丢弃。这样即使网络抖动触发重连也不会造成重复处理。6. 选型建议什么场景该上全双工什么场景其实用不上我见过不少团队因为全双工很酷就把所有接口改成双向流结果问题更多这是典型的过度设计。判断是否需要全双工可以走一个简单的决策清单服务端是否需要主动推送数据如果没有这个需求普通 unary 调用简单可靠不要自找麻烦。客户端是浏览器吗如果是浏览器、移动端 H5优先考虑 SignalR它基于 WebSocket 的穿透性更好自带重连和分组做实时推送最顺。客户端是服务端应用或桌面应用且要求跨语言互操作首选 gRPC 双向流生态完整方案成熟。有没有必须遵守的私有协议、嵌入式设备、无法上 HTTP/2 的场景再考虑基于 TCP Pipelines 自研帧协议。对成本和排障能力有要求吗自研方案的排障成本非常高线上遇到问题时gRPC 有现成的 metrics、日志和拦截器自研只能靠自己的埋点和日志。从通信模型角度传统 RPC 是寄信发起后等回信中间所有信息传递都依赖主动询问全双工 RPC 是打电话服务端终于有了随时开口的能力。但这个能力的代价是连接生命周期变长、心跳必须跟上、消费速度必须可控、消息顺序和幂等要自己设计。把这些都考虑清楚全双工才能真正改变你系统的通信方式。根据我实际做过的项目经验最稳妥的落地方式反而是混合低频查询、关键操作走传统 unary实时状态同步、服务端事件推送走双向流。核心业务里保留最熟悉的简单模型实时能力按需局部引入复杂度可控故障面也小得多。真把双向流无脑铺满整系统出问题时你连到底是谁在推都难定位。