双向流在c#中不卡死的关键是并发驱动、生命周期独立;proto文件必须双边声明stream,否则生成方法缺失iserverstreamwriter参数;客户端需分离读写任务并调用completeasync()。

双向流在 C# 里不卡死的关键,是两个流必须并发驱动、生命周期各自独立,而不是用 await foreach 串行等完一边再动另一边。
proto 文件里少写一个 stream 就不是双向流
这是最常踩的坑:以为加了 stream 就是双向,结果生成的 C# 方法签名里根本没有 IServerStreamWriter<t></t> 参数。
-
rpc Chat(ChatMessage) returns (stream ChatMessage)→ 服务器流,服务端能发多次,客户端只能发一次 -
rpc Chat(stream ChatMessage) returns (ChatMessage)→ 客户端流,客户端能发多次,服务端只回一次 -
rpc Chat(stream ChatMessage) returns (stream ChatMessage)→ 双向流,两边都带stream,生成方法含IAsyncStreamReader<t></t>和IServerStreamWriter<t></t>
生成后检查服务端实现方法签名,缺 IServerStreamWriter<t></t> 就说明 .proto 写错了,别往下写了。
客户端别在 await foreach 里卡住写逻辑
典型错误是把发送和接收写成线性顺序,导致发送被阻塞、响应收不到:
CentOS Stream 9是基于RHEL 9技术路线的持续交付版本,适合需要贴近RHEL 9生态的软件开发、系统集成和测试环境。它相比传统CentOS Linux更靠近上游开发过程,用户可以更早看到RHEL 9后续小版本中的软件包变化。CentOS Stream 9仍是当前可用的官方版本线之一,适合对稳定性和新功能之间有平衡需求的团队使用。
var call = client.Chat();
await call.RequestStream.WriteAsync(new ChatMessage { Text = "hi" });
await foreach (var msg in call.ResponseStream.ReadAllAsync(ct)) { /* ... */ } // ❌ 这里挂起后,后续 WriteAsync 永远不执行
正确做法是分离读写任务:
- 用
Task.Run(() => SendLoop(call.RequestStream, chunks, ct))单独发数据 - 用
await foreach (var msg in call.ResponseStream.ReadAllAsync(ct))在主线程收响应 - 发送完毕后必须调
await call.RequestStream.CompleteAsync(),否则服务端ReadAsync()不会返回false - 如果服务端返回失败状态(如
status.Success == false),应主动ct.Cancel()并尽快CompleteAsync()
服务端循环里 await responseStream.WriteAsync() 别写在 ReadAsync() 后面
高频场景下,每次 WriteAsync 都可能触发调度切换,堆积大量 Task,CPU 直线上升。这不是代码逻辑错,是调度开销爆炸。
- 避免:
while (await requestStream.MoveNext()) { var resp = Process(requestStream.Current); await responseStream.WriteAsync(resp); } - 推荐:把响应攒成队列或用
Channel<t></t>异步推送,WriteAsync放在单独的消费循环里 - 尤其注意
OperationCanceledException—— 网络抖动、客户端断连时,WriteAsync或ReadAsync都可能抛这个,不捕获整个 call 就崩了 - 别在循环里做耗时同步操作(比如直接
File.WriteAllBytes),文件传输类场景必须流式落盘 + 分块校验
IAsyncEnumerable<t></t> 比 Task<t></t> 更吃 CPU 是帧调度问题,不是语法问题
每个 yield return 都会触发一次 HTTP/2 DATA 帧封装 + 序列化,而 Task<t></t> 是单次完整序列化。小消息高频发时,帧头+序列化开销可能比业务数据还大。
- 128KB~512KB 分块是经验值,太小加重帧调度压力,太大易超默认 4MB 消息限制
-
bytes data字段在 C# 中对应ReadOnlyMemory<byte></byte>或ByteString,别用byte[],否则序列化失败 - 非 TLS 场景下必须显式开启明文 HTTP/2:
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true) -
GrpcChannel必须复用,别每次 new —— 连接池不共享会导致RpcException: Status(StatusCode=Unavailable, Detail="Connection reset")
真正难的不是怎么写,而是怎么让两个流在不同线程、不同生命周期、不同网络条件下,依然保持语义对齐——这需要你亲手压测、观察 CancellationToken 传播路径、检查 Kestrel 的 AllowSynchronousIO 配置,而不是依赖框架自动兜底。










