一、调用链全景图
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)
这站干四件事:
- 取配置 :从 service config 拿该方法的
MethodConfig(重试策略、超时、消息上限------服务端可以下发!);
- 挑连接 (全库核心,
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)
- 判定可重试 :请求是否幂等、错误码是否在 retryPolicy 白名单,满足则失败后自动换连接重发(
attempt循环就在这条链上);
- 组装 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函数值,同心圆结构成立。