lizining1231 opened a new issue, #3694:
URL: https://github.com/apache/dubbo-go/issues/3694

   ### 摘要
   
   Triple 是 dubbo-go 默认 RPC 协议,unary 是最常见的调用形态。当前 Triple unary 客户端发送路径基于 
connect-go 的 `duplexHTTPCall` 实现,每个 unary 请求都要承受**与业务无关的固定开销**:创建 
`io.Pipe`、启动后台 goroutine、管道交接带来的额外系统调用与调度唤醒。
   
   小报文高并发场景下固定开销被放大,根据wsl2环境基准测试报告,128B 小包下 QPS 差距约 8\~9 倍,dubbo-go 与同协议栈的 
gRPC-Go 存在数量级差距。
   
   | 场景            | dubbo-go               | gRPC-Go                | 比值       
  |
   | ------------- | ---------------------- | ---------------------- | 
---------- |
   | 128B × 50 并发  | QPS 4,425 / P99 18.5ms | QPS 37,183 / P99 3.5ms | QPS 
\~8.4x |
   | 128B × 100 并发 | QPS 4,635 / P99 32.7ms | QPS 42,171 / P99 6.1ms | QPS 
\~9.1x |
   
   ### 相关代码
   
   | 位置                                                                         
                                                                                
                         | 问题                                                   
                  |
   | 
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 | ---------------------------------------------------------------------- |
   | [duplex\_http\_call.go 
L60-103](https://github.com/apache/dubbo-go/blob/master/protocol/triple/triple_protocol/duplex_http_call.go#L60-L103)(`newDuplexHTTPCall`)
                   | 每请求 `io.Pipe()`,请求体在用户态与 transport 之间经管道交接,一次读一次写          
            |
   | [duplex\_http\_call.go 
L108-126](https://github.com/apache/dubbo-go/blob/master/protocol/triple/triple_protocol/duplex_http_call.go#L108-L126)(`Write`)
                             | 数据先写入 `pipeWriter`,由 transport 后台从 `pipeReader` 
读走,多一跳内存拷贝与一次同步        |
   | [duplex\_http\_call.go 
L265-307](https://github.com/apache/dubbo-go/blob/master/protocol/triple/triple_protocol/duplex_http_call.go#L265-L307)(`ensureRequestMade`
 / `makeRequest`) | 每请求 `go d.makeRequest()`启动后台 
goroutine,请求未发完前一直存活,高并发下放大调度(futex 唤醒)压力 |
   | 
[protocol\_triple.go](https://github.com/apache/dubbo-go/blob/master/protocol/triple/triple_protocol/protocol_triple.go)(unary
 发送路径)                                                | unary 无独立发送路径,统一走 
duplex 实现;而 unary 是请求-响应一一对应语义,不需要管道这种全双工交接机制        |
   
   128B × 100 并发,pprof:dubbo-go 客户端 goroutine 数约为 gRPC-Go 的 2.7 倍,`io.Pipe` 读写 
goroutine 与每请求 `makeRequest` goroutine 是主要增量;CPU 火焰图中 syscall 与 futex 占比显著高于 
gRPC-Go。
   
   **CPU 火焰图,128B × 100 并发**:
   dubbo-go
   <img width="2000" height="1200" alt="Image" 
src="https://github.com/user-attachments/assets/656d98be-0f99-4de8-92d3-5fcfc744aaaf";
 />
   grpc
   <img width="2000" height="1200" alt="Image" 
src="https://github.com/user-attachments/assets/3ede7ae8-45a5-45a2-8e28-4bb3a088d452";
 />
   
   dubbo-go 火焰图中 syscall 与 futex 热点占比显著高于 gRPC-Go,syscall6 展开图可见管道交接在用户态的系统调用开销。
   
   ### 建议修复
   
   为 unary 新增独立快路径 `unaryFastPathCall`,与现有 duplex 路径并存,由独立开关(默认关闭)切换。复用现有 
marshaler/unmarshaler、bufferPool、错误处理与拦截器,仅替换发送路径。
   
   **1. 请求体:去** **`io.Pipe`,池化单拷贝**
   
   请求体不再经管道交接,改为累积进协议层 bufferPool(初始 512B、上限 8MiB,对齐现有 
[buffer\_pool.go](https://github.com/apache/dubbo-go/blob/master/protocol/triple/triple_protocol/buffer_pool.go)),发送时单次拷贝进
 wire。消除"写端写入 → 读端读走"的两跳拷贝与同步等待。
   
   **池化可能引入的风险**:
   
   - **防止归还竞态与跨请求污染**:缓冲归还过早会与 transport 后台读取产生数据竞争,或被下个请求复用污染。计划用互斥锁串行化 
Read/Close,归还委托 transport 的 Close 恰好一次回调,并配套 `-race` 并发测试(测试 9/10);
   - **防止常驻内存增大**:池持有缓冲不归还操作系统会增大常驻内存。计划以 8MiB 为上限,超限缓冲直接交 GC 回收,池只保留小缓冲。
   
   **2. 发送时机:去除每请求 goroutine**
   
   `CloseWrite` 内 `sendOnce.Do(makeRequest)` **同步执行**,请求体发完才返回,不再 `go 
makeRequest()`,无后台 goroutine 存活,消除调度(futex 唤醒)压力。
   
   - **防止调用方无限等待**:Write 检查 ctx 取消(`ctx.Err()`),调用方超时可中止,不会发生(测试 20)。
   
   **3. Content-Length 显式声明**
   
   unary 请求体一次性累积完整,可精确声明 `Content-Length`,transport 走确定长度快速路径,避免 chunked 分块语义。
   
   - **防止空 body 产生无效发送流程**:空 body 时按 net/http 标准使用 `NoBody` + 
`Content-Length=0`,transport 直接跳过写请求体流程,不产生后台读(测试 14)。
   
   **4. 请求体归还:锁保护 + 恰好一次**
   
   这是快路径与 duplex 最关键的行为差异:duplex 靠管道 EOF 终止后台读,快路径没有等效机制,若 buffer 归还过早会与 
transport 后台读取产生 data race、跨请求污染。设计:
   
   - `unaryRequestBody` 在互斥锁保护下持有 buffer,`Read` 与 `Close` 互斥;
   - 归还委托给 transport 对 `Close()` 的**恰好一次回调**(正常读完或 abort 均只归还一次);
   - 对服务端提前响应(非 2xx)等 abort 路径有确定性行为,不泄漏 buffer、无跨请求数据污染。
   
   **5. 独立开关:默认关闭,可回退**
   
   两级入口:客户端 option(`WithUnaryFastPath`)+ 配置项 
`unary-fast-path`(默认关闭)。线上异常时改配置重启即回退 duplex 社区路径,零代码变更、零发布。
   
   **改造前(duplex,每请求固定开销)**:
   
   ```mermaid
   flowchart TD
       subgraph caller["调用方"]
           W1[Write] -->|"① ensureRequestMade 启动后台 goroutine"| GO["go 
makeRequest"]
           W1 -->|"② pipeWriter.Write 阻塞"| PW[io.Pipe 写端]
           RCV1[Receive] -->|"BlockUntilResponseReady 阻塞等待"| RD1[responseReady]
       end
       subgraph bg1["transport 后台"]
           MR1["makeRequest<br/>每请求一个 goroutine"] -->|"pipeReader 读走请求体"| 
SEND1[发送请求]
           SEND1 -. "Do 返回后 close responseReady" .-> RD1
       end
       PW -. "管道交接" .-> MR1
       style GO fill:#ffe0b2,color:#e65100
       style MR1 fill:#ffe0b2,color:#e65100
       style PW fill:#ffe0b2,color:#e65100
   ```
   
   **改造后(fast path,同步直发)**:
   
   ```mermaid
   flowchart TD
       W2["Write"] --> BUF2["池化 buffer(bufferPool)"]
       BUF2 --> CW2["CloseWrite"]
       CW2 -->|"sendOnce.Do 同步执行"| MR2["makeRequest"]
       MR2 -->|"Content-Length 声明"| SND2["transport 同步写请求体"]
       SND2 -->|"unaryRequestBody 锁保护归还"| BUF2
       SND2 --> DONE2["Receive 返回响应,不阻塞"]
       style BUF2 fill:#ffe0b2,color:#e65100
       style MR2 fill:#ffe0b2,color:#e65100
       style SND2 fill:#ffe0b2,color:#e65100
   ```
   
   图例:黄色块 = 改造涉及的环节,两图一一对应。改造前是 duplex 中被替换的环节(io.Pipe 写端 → 每请求 makeRequest 
goroutine),改造后是 fast path 中替换后的实现(池化 buffer → 同步 makeRequest → 
同步写)。`marshaler/unmarshaler`、bufferPool、HTTP/2 transport 复用现有实现。
   
   ### 测试
   计划新增以下单元测试
   1. **gRPC 协议分支**:防止 gRPC 协议侧漏接开关、行为与 Triple 不一致(gRPC 协议客户端开开关后仍走 duplex)。验证 
gRPC 协议侧 NewConn 镜像开关分支;
   2. **开关回退**:防止关闭开关后残留快路径状态(线上灰度关闭后请求仍带快路径行为)。验证开关开→关切换回退 duplex,无残留;
   3. **写体累积**:防止 Write 阶段提前触发网络发送、数据未写全就被 transport 读走(请求体写一半被截断)。验证 Write 
只累积进池化 buffer,不触碰网络;
   4. **归还池**:防止 buffer 重复归还(池内双份引用)或漏归还(池越借越少、泄漏)。验证 Close 恰好一次归还 bufferPool;
   5. **关闭后读**:防止归还后 transport 仍读取已回收缓冲(use-after-return 读到脏数据)。验证关闭后 Read 返回 
EOF;
   6. **abort 归还**:防止服务端提前响应(非 2xx 中断请求体写入)时 buffer 不归还、泄漏。验证 abort 路径 buffer 
仍归还池;
   7. **并发读关**:防止 abort 回调 Close 与 transport 在途 Read 并发产生数据竞争(-race 误报/真竞争)。验证 
Read 与 Close 并发无竞争;
   8. **复用无污染**:防止缓冲复用到下个请求带出上一个请求的残留数据(跨请求污染)。验证 `-race` 下复用无污染;
   9. **恰好一次发送**:防止 Write/CloseWrite 并发触发多次 makeRequest、重复发请求。验证 `sendOnce` 
恰好执行一次;
   10. **失败后拒写**:防止请求已失败后继续累积数据、错误状态被掩盖(对齐 duplex 行为)。验证 SetError 后 Write 拒绝写入;
   11. **长度声明**:防止 Content-Length 与 body 实际长度不符、服务端读串或读死等。验证请求体 Content-Length 
精确声明;
   12. **空请求体**:防止空 body 走 chunked 或触发后台写流程(产生无谓 goroutine/读)。验证空 body 使用 
NoBody + Content-Length=0;
   13. **wire 一致**:防止快路径报文与服务端/其他客户端不兼容、协议漂移(服务端解析失败)。验证 fast path 与 duplex 
wire 字节级一致;
   14. **非 2xx 响应**:防止服务端提前报错时归还竞态、错误不传播、响应体不关闭(连接泄漏)。验证无竞态、错误正确传播、响应体兜底关闭;
   15. **互通一致**:防止 header/trailer/metadata 丢失、与 duplex 功能差异(带 metadata 
的调用行为不一致)。验证真实服务端互通一致;
   16. **读响应**:防止 CloseWrite 后 Receive 阻塞或响应丢失(同步派发后读不到响应)。验证 CloseWrite 后 
Receive 正常读响应;
   17. **错误传播**:防止 transport 失败或 trailer 带错误时调用方看不到错误(错误吞掉)。验证 SetError / 
ResponseTrailer 传播正确;
   18. **上下文取消**:防止 ctx 取消后发送不中止、goroutine/缓冲泄漏(同步派发干等)。验证 ctx 取消后发送中止、资源释放;
   19. **端到端**:防止组合场景(真实客户端 + 开关 + 并发)下功能回归。验证端到端 unary 调用正常;
   20. **多编码**:防止快路径只对 protobuf 生效、破坏 codec 通用性(JSON/generic 调用失败)。验证 generic 
调用 + 多 codec 互通。
   
   ### 参考实现:
   
   connect-go 在 [issue 
#609](https://github.com/connectrpc/connect-go/issues/609) 中承认:所有 RPC 共用 
`duplexHTTPCall` 对 unary 是 overkill,其中的同步机制、每请求额外 goroutine 与 `io.Pipe` 对 unary 
是 **pure overhead**,并通过 [PR 
#611](https://github.com/connectrpc/connect-go/pull/611) 与 [PR 
#649](https://github.com/connectrpc/connect-go/pull/649) 落地 unary 
专用发送流程(`sendUnary`:同步直发 + Content-Length + payloadCloser),与本方案方向相符
   
   其实现面向 connect-go 通用协议,无法覆盖 Triple 协议细节,本方案做以下**本地化适配**:
   
   - **架构适配**:connect-go直接改造 duplexHTTPCall 本体(Send 内按请求类型分支);本方案保持 
duplexHTTPCall 原封不动,NewConn 按 RPC 类型将 unary 路由到新建的 
`unaryFastPathCall`。streaming 语义不受影响,快路径可独立演进,开关关闭时零开销;
   - **复用现有组件**:直接接入 triple\_protocol 现有 marshaler/unmarshaler 与 
bufferPool,而非照搬connect-go的 `payloadCloser` 的独立分配路径;
   - **请求体归还**: `sendUnary` 用 payloadCloser 包装上层传入的 
payload(所有权在上层,不归还池);本方案请求体来自协议层 bufferPool,归还必须回到池中,故实现 `unaryRequestBody` 加锁 
Read/Close,归还委托 transport 对 Close 的恰好一次回调,与后台 bodyWriter 读互斥,杜绝跨请求 buffer 
污染(connect-go 曾因 requestBodyWriter 竞态触发 SIGSEGV,[issue 
#918](https://github.com/connectrpc/connect-go/issues/918) / [PR 
#919](https://github.com/connectrpc/connect-go/pull/919) 同类问题);
   
   ### 复现方式
   
   ````
   
   压测复现
   
   ```bash
   cd tools/benchmark
   ./scripts/run_single.sh dubbo-go 128 protobuf none 50 unary
   ./scripts/run_single.sh grpc 128 protobuf none 50 unary
   ````
   
   pprof 采样与火焰图
   
   ```bash
   # dubbo-go
   ./benchmark-client --framework dubbo-go --payload 128 --serialization 
protobuf --compression none \
     --concurrency 50 --mode unary --duration 60s --warmup 10s --pprof-addr 
127.0.0.1:6060
   
   # gRPC-Go 端
   ./benchmark-client --framework grpc --payload 128 --serialization protobuf 
--compression none \
     --concurrency 50 --mode unary --duration 60s --warmup 10s --pprof-addr 
127.0.0.1:6061
   
   # CPU profile(30s)与 goroutine 全量 dump
   curl -s -o cpu_dubbo.prof 
"http://127.0.0.1:6060/debug/pprof/profile?seconds=30";
   curl -s -o cpu_grpc.prof 
"http://127.0.0.1:6061/debug/pprof/profile?seconds=30";
   curl -s "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1";
   
   # 火焰图
   go tool pprof -svg cpu_dubbo.prof > cpu_dubbo.svg
   go tool pprof -svg cpu_grpc.prof > cpu_grpc.svg
   ```
   
   
   ### 参考链接
   性能基准测试与更加详细具体的性能瓶颈定位报告请见:https://github.com/apache/dubbo-go/discussions/3673
   connect-go参照请见:
   1. 提案:https://github.com/connectrpc/connect-go/issues/609
   2. 首次实现:https://github.com/connectrpc/connect-go/pull/601
   3. 最终落地:https://github.com/connectrpc/connect-go/pull/649
   4. 落地后遇到的问题:
   v1.19.1 与 main 发生 requestBodyWriter 竞态触发 SIGSEGV:
   https://github.com/connectrpc/connect-go/issues/918
   https://github.com/connectrpc/connect-go/pull/919
   
   
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to