grpc客户端调用链

一、调用链全景图

go 复制代码
生成的存根 SayHello (helloworld_grpc.pb.go)
   │  cc.Invoke(ctx, "/helloworld.Greeter/SayHello", in, out)
   ▼
① ClientConn.Invoke                    call.go:33
   │  有 unaryInt 拦截器?先穿过拦截链(04 篇)
   ▼
② invoke                               call.go:69
   │  一元也是流:unaryStreamDesc
   ▼
③ newClientStream                      stream.go:302
   │  合并 callOptions/拿 method config(重试/超时策略)
   ▼
④ newClientStreamWithParams            stream.go:398
   │  ★ pickerWrapper.pick ──► balancer 给出 SubConn ──► ClientTransport
   │  ★ 失败时决定:等(wait_for_ready)/失败/可重试则走 retry 逻辑
   ▼
⑤ cs.sendMsg ──► transport.ClientStream.SendMsg
   │  codec.Marshal(proto 序列化) + 可选压缩 + 5字节长度前缀
   ▼
⑥ loopyWriter(controlbuf.go:522)       ←------ 每连接唯一的写 goroutine
   │  把消息装进 HTTP/2 DATA 帧,受 flowcontrol 窗口约束
   ▼
⑦ TCP 字节流 ──► reader goroutine 收响应
   │  HEADERS 帧 → 元数据;DATA 帧 → 消息体;trailer → status
   ▼
⑧ cs.RecvMsg(reply) ──► codec.Unmarshal ──► return (out, err) 回到你的代码

二、逐站精读

站① Invoke:拦截器的入口(call.go:33-42)

scss 复制代码
func (cc *ClientConn) Invoke(ctx context.Context, method string,
    args, reply any, opts ...CallOption) error {
    opts = combine(cc.dopts.callOptions, opts)     // 合并通道级+本次调用的选项

    if cc.dopts.unaryInt != nil {                  // 有客户端一元拦截器?
        return cc.dopts.unaryInt(ctx, method, args, reply, cc, invoke, opts...)
        //                                 注意 ↑↑↑↑↑ ↑↑↑↑↑
        // invoke(真正干活的)作为 invoker 参数递给拦截器 ------ 04 篇同心圆的源码实锤
    }
    return invoke(ctx, method, args, reply, cc, opts...)
}

这就是为什么说"客户端拦截器是生成代码→Invoke→你的拦截器→真实调用"的同心圆------invoke 被当作函数值传进拦截器,调用与否完全由拦截器决定。

站② invoke:一元的"流式本质"(call.go:67-92)

go 复制代码
var unaryStreamDesc = &StreamDesc{ServerStreams: false, ClientStreams: false}  // :67

func invoke(ctx context.Context, method string, req, reply any,
    cc *ClientConn, opts ...CallOption) error {
    cs, err := newClientStream(ctx, unaryStreamDesc, cc, method, opts...)  // 建流
    ...
    cs.SendMsg(req)        // 发唯一一条请求
    cs.CloseSend()         // 半关闭:告诉服务端"我没有更多了"(xDS 拦截器需要)
    cs.RecvMsg(reply)      // 收唯一一条响应
    cs.RecvMsg(reply)      // 再收一次拿 trailer(io.EOF = 正常结束)
    ...
}

关键认知:一元调用 = 严格"发1收1"的流。grpc-go 没有为它开小灶------03 篇的论断在这里拿到了源码证据。

站③④ newClientStream:一次调用最复杂的 100 行(stream.go:302/398)

这站干四件事:

  1. 取配置 :从 service config 拿该方法的 MethodConfig(重试策略、超时、消息上限------服务端可以下发!);
  1. 挑连接 (全库核心,picker_wrapper.go):
scss 复制代码
pickerWrapper.pick(ctx)
   │
   ├─ balancer.Picker.Pick(info)          ← 当前负载均衡策略给出 SubConn
   │    ├─ 返回 SubConn → 取它的 ClientTransport,继续
   │    ├─ ErrNoSubConnAvailable → 阻塞等 balancer 更新 Picker(连接还在建)
   │    └─ 返回错误(如 TRANSIENT_FAILURE)
   │         ├─ fail-fast(默认):立即把错误抛给调用方
   │         └─ WaitForReady(true):继续等,直到 READY 或 ctx 超时
   │
   └─ 拿到 transport 后还有一个环路:transport 已断?
        重新 pick(最多重试若干次,配合 transparent retry)
  1. 判定可重试 :请求是否幂等、错误码是否在 retryPolicy 白名单,满足则失败后自动换连接重发(attempt 循环就在这条链上);
  1. 组装 clientStream:把 transport、压缩器、编解码器、stats/binlog 钩子、ctx 打包成一个对象返回。

站⑤ SendMsg:消息 → 字节

clientStream.SendMsg(stream.go)最终落到 transport:

scss 复制代码
SendMsg(m)
  1. codec.Marshal(m)          proto 默认编解码 → []byte(encoding/codec.go 注册表)
  2. 可选 compressor            gzip/zstd(压缩器也是注册表)
  3. 组帧: 5 字节前缀 + 体      [压缩标志1B][长度4B][消息体]
  4. 写入 transport 的发送队列   → 交给 loopyWriter,自己立即返回(异步!)

注意:SendMsg 返回≠字节已上网,只是进了发送队列。真正的阻塞点在 HTTP/2 流控窗口(11 篇)。

站⑥ loopyWriter:每连接唯一的写者(controlbuf.go:522, run:595)

scss 复制代码
应用 goroutine(多条流)          loopyWriter goroutine(每连接1个)
      │ 多路写入                     │
      ▼                              ▼
  controlBuf(发送队列,带优先级) ──► run() 循环:
  - headers/data 等待发送              ① 取最高优先级待发项
  - 流控信息优先                       ② 检查该流 HTTP/2 窗口够不够
                                      ③ 够 → 打帧写出;不够 → 记账,先发别的
                                      ④ 处理 PING/SETTINGS/窗口更新等控制帧

单写者模型是 grpc-go 传输层的灵魂:所有流共享一个写 goroutine,避免多 goroutine 写 socket 的锁竞争与帧交错------07 篇"顺 goroutine 读"的技巧在这里最有效。

站⑦⑧ 收响应与解包

reader goroutine(每连接 1 个,http2_client.go)用 http2.Framer 逐帧解析:

  • HEADERS 帧 → 响应 metadata(stream.Header() 拿到的就是它);
  • DATA 帧 → 去掉 5 字节前缀 → 解压 → codec.Unmarshal → RecvMsg(m) 返回;
  • HEADERS(trailer) 帧 → grpc-status/grpc-message → 流终态;
  • 一元路径收完第一条消息后,那次"多余的 RecvMsg"(站②)正是在等 trailer,拿到 io.EOF 即 codes.OK。

错误在客户端的还原路径:trailer 的 grpc-status: 7 → status.Error(codes.PermissionDenied, ...) → 06 篇你手里的那个 err。

三、超时与元数据如何上网

  • ctx 的 deadline 在建流时被 encoding/timeout.go 编码成 grpc-timeout: 3000m 放进 HEADERS------这就是跨服务超时预算的物理载体;
  • metadata.NewOutgoingContext 塞的 md 同样拼进 HEADERS,与服务端 FromIncomingContext 首尾呼应;
  • ctx 取消 → 关闭对应 HTTP/2 stream(RST_STREAM),对端立刻收到 Canceled

四本篇小结

站点 文件:行 干什么
Invoke call.go:33 合并选项,穿过客户端拦截器
invoke call.go:69 用"退化的流"实现一元
newClientStream stream.go:302 Picker 选连接 + 重试判定 + 组流
SendMsg stream.go codec 序列化 + 5 字节前缀入队
loopyWriter controlbuf.go:522 单写者打 HTTP/2 帧,受流控约束
reader + RecvMsg http2_client.go 收帧→解包→Unmarshal→还原 status
  • 一元即流 :unaryStreamDesc 证明四种模式同源(call.go:67);
  • 阻塞点不在 Send 而在 pick 与流控窗口;
  • 拦截器拿到 invoke 函数值,同心圆结构成立。
相关推荐
时间海8711 小时前
对象键清洗之前,先拦住重名
javascript·后端
歌谣1 小时前
07|【30岁前端转型全栈】前后端遇到报错怎么处理?当然是console.log和System.out.println?
后端
对象存储与RustFS1 小时前
HelixDB 上手:Agent 记忆新方案,基于 RustFS 对象存储,附完整 docker-compose 部署示例
后端·rust·开源
深入云栈1 小时前
Netty 4.2.x 源码深度解析 (十八):ChannelOutboundBuffer 写缓冲区 —— 发送队列与流量控制
java·后端·架构
谢亮_vipxieliang1 小时前
Go gRPC基础与实战
开发语言·后端·golang
god_cvz1 小时前
第二章:5、RAG进阶优化
后端·ai编程
今年下半年1 小时前
【低成本埋点统计】Spring Boot+vue2前后端分离项目统计系统访问、使用、登录次数
spring boot·后端·状态模式
codigger1 小时前
Java 26 深度解析:一次“性能兑现 + 契约收紧”的版本
java·后端·编程·编程语言
盖世英雄酱581362 小时前
格式化成日期为什么提前一天呢,同样的时间点还是非必现???
java·后端