TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

grpc-go 代码地图:从 ClientConn 到 http2Client,从 Serve 到 Handler

状态:草稿 (2026-10-17),代码阅读。 基准版本 grpc/grpc-go v1.83.2(提交 030ee8becb20,从本地 Go 模块缓存中读取)。最新发布版为 v1.84.0,行号可能略有不同。正文中的 파일:줄 是 v1.83.2 的函数声明行。本篇没有新的实测,仅与第 6 篇的数据进行对比。

目录

1. 一句话总结:公司组织架构

Channel 是 团队长。当接收到工作(调用)时,它不会亲自去做,而是将通讯录负责人(解析器)给出的名单交给分配负责人(负载均衡器),分配负责人会挑选好员工(子通道)并在每次有工作到来时,通过当天的分配表(Picker)选定一人。员工拥有自己的一条电话线(HTTP/2 连接)。分配表是一张整体替换的纸,所以在选择工作的瞬间,不会与任何人争夺锁。

2. 架构图

grpc-go 1.83 의 클라이언트·서버 계층 클라이언트 ClientConn clientconn.go · NewClient ccResolverWrapper → resolver resolver_wrapper.go · dns/passthrough/xds ccBalancerWrapper → balancer balancer_wrapper.go · pick_first/round_robin pickerWrapper.pick picker_wrapper.go · 호출마다 서브채널 선택 addrConn (SubConn) clientconn.go · 연결·재연결·백오프 http2Client internal/transport/http2_client.go 호출 경로: Invoke → newClientStream → pick → NewStream 서버 Server.Serve → handleRawConn server.go · 연결마다 고루틴 http2Server.operateHeaders http2_server.go · :path 해석, 스트림 생성 handleStream → processUnaryRPC server.go · 서비스 맵에서 핸들러 찾기 공용: rpc_util.go(5바이트 접두), codes·status, keepalive
图:grpc-go 1.83 客户端与服务器层级及文件

3. 代码导读

路径 说明 首先阅读的位置
clientconn.go 通道、子通道(addrConn)、空闲模式、重试控制 NewClient(183), addrConn(1240)
call.go, stream.go 一元调用、流式调用、重试尝试(csAttempt) invoke(65), newClientStream(213)
resolver_wrapper.go, resolver/, internal/resolver/ 解析器包装与 dns、passthrough、unix 实现 ccResolverWrapper.UpdateState(129)
balancer_wrapper.go, balancer/ 负载均衡器包装,pick_first、round_robin、ringhash、weightedroundrobin 等 ccBalancerWrapper.NewSubConn(180)
picker_wrapper.go 每次调用选择子通道,等待 pickerWrapper.pick(105)
internal/transport/ HTTP/2 客户端与服务器,流量控制,keepalive,控制缓冲区 http2_client.go, http2_server.go
rpc_util.go 5 字节前缀,压缩,接收上限,调用选项 msgHeader(879), parser.recvMsg(785)
server.go 服务器,服务注册,分发 Serve(888), handleStream(1800)
codes/, status/ 状态码与状态对象 codes.go
health/, reflection/, channelz/, admin/ 附加服务(第 5、8 篇) health/server.go
xds/, internal/xds/ xDS 解析器与负载均衡器(第 5 篇) 规模较大,留待以后

4. 跟踪核心路径

4.1 创建通道:暂不建立任何连接

NewClient 应用选项并解析目标字符串以选择解析器构建器,随后 (initParsedTargetAndResolverBuilder, 默认 scheme dns), 准备好空闲状态并结束。 准备空闲状态(initIdleStateLocked) 会创建解析器包装 (newCCResolverWrapper) 和负载均衡器包装(newCCBalancerWrapper),但解析器此时还不会运行。当收到第一次调用时, exitIdleMode 会将通道更改为 CONNECTING 并启动解析器。如果 30 分钟内没有调用, enterIdleMode 会清理解析器、负载均衡器和连接,并重新回到空闲状态。

4.2 当地址到来时:解析器 → 负载均衡器 → 子通道 → 拾取器 (Picker)

  1. 当解析器找到地址时,会调用 ccResolverWrapper.UpdateState ,通道的 updateResolverStateAndUnlock 应用服务配置后,将其传递给 (applyServiceConfigAndBalancer) 负载均衡器 (updateClientConnState).
  2. 负载均衡器(例如 round_robin)会为每个地址调用 NewSubConn ,这会通过 newAddrConnLocked 创建 addrConn 。这个 addrConn 就是子通道的实体。
  3. addrConn 会按照 resetTransportAndUnlock → tryAllAddrs → createTransport 的顺序进行连接,如果失败则在退避 (backoff) 后重试。如果失败或断开连接,会向解析器请求 ResolveNow (第 5 篇第 4.1 节)。
  4. 每当子通道状态改变时,负载均衡器就会创建一个新的拾取器并上传到 ccBalancerWrapper.UpdateState ,然后 pickerWrapper.updatePicker 会以 原子方式替换拾取器。正在等待的调用会因为与旧拾取器关联的通道关闭而被唤醒。

为什么要这样划分呢?这是为了让查找地址(DNS、xDS、Kubernetes API 等)和选择地址(首选、轮询、加权、哈希)相互解耦,从而能够 组合任意解析器和任意负载均衡器。负载均衡器端的更新通过串行执行器(grpcsync.CallbackSerializer, balancer_wrapper.go 的注释)依次处理,而调用流经的热点路径 (pick) 则只需读取一个原子值,从而确保两个世界互不等待对方的锁。

4.3 单次调用:从 Invoke 到流 (Stream)

  1. 生成的 Stub 会调用 ClientConn.Invoke ,经过拦截器后, invoke 进入 newClientStream → newClientStreamWithParams 。 一元调用 (Unary call) 在内部本质上也是“发送一个消息并接收一个消息的流”。
  2. 创建一个尝试(newAttemptLocked), getTransport 从拾取器中选择一个子通道。在所选子通道的传输层上,使用 csAttempt.newStream → http2Client.NewStream 发送 HEADERS(第 3 篇的 createHeaderFields).
  3. SendMsg 负责序列化、压缩并加上 5 字节前缀写入 csAttempt.sendMsg ,并通过 RecvMsg → recv → parser.recvMsg 读取响应。如果失败, withRetry 会参考第 4 篇的 shouldRetry 创建新的尝试,并重新播放缓存在缓冲区中的已发送消息。

4.4 拾取器:是等待还是立即失败?

如果来了调用,但没有处于 READY 状态的子通道,会发生什么呢? pickerWrapper.pick 的一个分支给出了答案。

pickResult, err := p.Pick(info)
if err != nil {
	if err == balancer.ErrNoSubConnAvailable {
		continue
	}
	if st, ok := status.FromError(err); ok {
		...
		return pick{}, dropError{error: err}
	}
	// For all other errors, wait for ready RPCs should block and other
	// RPCs should fail with unavailable.
	if !failfast {
		lastPickErr = err
		continue
	}
	return pick{}, status.Error(codes.Unavailable, err.Error())
}

来源: grpc/grpc-go v1.83.2 picker_wrapper.go 摘录自 157~176 行,Apache-2.0

在生产环境中,这种差异表现为:在重启服务器的几秒钟内,fail fast 客户端会不断抛出 UNAVAILABLE(就像第 6 篇中 Collector 的 10-13 记录一样), 而 wait-for-ready 客户端则会静静等待,如果在 deadline 内服务器恢复,就会调用成功。

4.5 服务器:从 Serve 到 Handler

  1. Server.Serve 每当 Accept 发生 handleRawConn 时,就会启动一个 goroutine 来运行
  2. 。在凭据握手之后, NewServerTransport 会交换 HTTP/2 设置, 并且 serveStreams 会读取帧。并发流数的默认值是无限制(math.MaxUint32, server.go:189)。
  3. 当 HEADERS 到来时, operateHeaders 会读取 :path, content-type, grpc-timeout 以创建流(无效的 content-type 会返回 415), handleStream 会将路径拆分为服务名和方法名, 并在 RegisterService 注册表中(第 1 篇的 ServiceDesc)进行查找。如果找不到,则返回 UNIMPLEMENTED。
  4. 如果是一元调用(unary),则由 processUnaryRPC处理;如果是流式调用,则由 processStreamingRPC 读取消息、调用拦截器和 handler,然后通过 writeStatus 写入 trailer。
  5. GracefulStop 会发送 GOAWAY 以阻止新流并等待现有流完成。第 5 篇中的 MaxConnectionAge 也使用了相同的 GOAWAY。

5. 与第 6 篇实测的对比

第 6 篇中测得的数据 在此拓扑图中
apiserver → etcd 247 个 TCP 连接 247 个 etcd 客户端 = 247 个 ClientConn ,各自拥有一个 READY addrConn (因为目标只有一个 127.0.0.1)
节点 A 打开的 Watch 流 242 个 每个 ClientConn 的 http2Client 上方有一个长连接流
kubelet → containerd 2 个通道 运行时和镜像 ClientConn 各一个, passthrough 是 Unix 套接字而非
hubble-relay 连接了 8 个节点 每个节点上有 ClientConn(由 relay 的 peer pool 管理,推测)
kubelet gRPC 探针 每个探针都会 ClientConn 创建和销毁(第 5 篇第 6 节)

即使使用相同的 grpc-go, 创建多少个通道以及如何使用它们完全由编写客户端的代码决定,这种选择决定了连接数、内存和负载均衡。

6. 从哪里开始阅读

  1. Documentation/ 的 keepalive.md·concurrency.md·anti-patterns.md 首先阅读。 anti-patterns.md 解释了为什么要避免旧的 Dial·WithBlock 。
  2. call.go → stream.go 的 newClientStreamWithParams·csAttempt 可以追踪单个调用。
  3. picker_wrapper.go → balancer_wrapper.go → balancer/roundrobin 查看选择端, resolver_wrapper.go → internal/resolver/dns 查看查找端。
  4. internal/transport/http2_client.go 的 NewStream·reader·keepalive, controlbuf.go 可以深入到写入循环中。
  5. 对于服务器,只需阅读 server.go 的 handleStream → processUnaryRPC 就能了解分发架构。

7. 适合新手贡献的地方

8. 局限性

Titi 的小结。 因为可以把分配表(picker)整个替换掉,所以即使顾客蜂拥而至,也不用跟分配负责人排队争吵。相反,如果员工全都累倒了,是该等待(WaitForReady)还是请回(fail fast)由顾客自己决定!

参考资料

所有资料均查阅于 2026-10-17。

登录后即可点赞

评论

还没有评论。

登录后即可发表评论