之前写过一篇 gRPC-go 建立 TCP 连接的过程 博客,主要研究了 client 程序启动后,如何与 server 建立 TCP 连接。
今天,在思考 redis-go
的连接池实现的时候,突然想到:
- 当 gRPC 的 TCP 连接断开后,能自动重连吗?
- 如果可以,是如何实现的 ?
首先要注意,这里指的是 TCP 连接,而不是 http2 中的 stream。 我们知道,gRPC 数据的传输使用 http2 的多路复用,也就是在一个 TCP 连接上有多个全双工的 http2 stream,这里的 stream 如果被断开后怎么重连与 http2 的实现有关,不在本文讨论范围。
对于上面第一个问题,使用 gRPC 的经验告诉我是可以自动重连的,不妨再做个简单的测试,client 端代码如下:
func main() {
conn, _ := grpc.Dial("127.0.0.1:8080", grpc.WithInsecure())
defer conn.Close()
cli := protobuf.NewTestClient(conn)
req := &protobuf.EchoRequest{
Msg: "hi",
}
for i := 0; i < 10000; i++ {
time.Sleep(time.Second)
resp, err := cli.Echo(context.Background(), req)
if err != nil {
log.Printf("%v\n", err)
continue
}
log.Printf("[D] resp: %s", resp.Reply)
}
}
server 端代码略。 启动 client 后,不断启动和 ctrl-c
结束 server,证实 client 能自动重连 TCP 。 使用 netstat 查看 TCP 连接也能看到 client 使用了新的端口号重连。
那么第二个问题,gRPC 是怎么实现的?
搜索了网上资料无果,自己翻阅了一下代码,client 端的逻辑实现复杂,毫无头绪。
这时想到了 gdb(dlv) 大法: 最后必定都要调用 net.Conn 的方法重新建立 TCP的。 开始试了 net.Dial() 发现不对,查了下文档试了下 DialContext
0 0x00000000005d6152 in net.(*Dialer).DialContext
at /usr/local/go/src/net/dial.go:372
1 0x0000000000760d99 in google.golang.org/grpc.DialContext.func2
at ./vendor/google.golang.org/grpc/clientconn.go:197
2 0x000000000076d17d in google.golang.org/grpc.newProxyDialer.func1
at ./vendor/google.golang.org/grpc/proxy.go:134
3 0x00000000006fc5b4 in google.golang.org/grpc/internal/transport.dial
at ./vendor/google.golang.org/grpc/internal/transport/http2_client.go:142
4 0x00000000006fc86b in google.golang.org/grpc/internal/transport.newHTTP2Client
at ./vendor/google.golang.org/grpc/internal/transport/http2_client.go:175
5 0x0000000000768679 in google.golang.org/grpc/internal/transport.NewClientTransport
at ./vendor/google.golang.org/grpc/internal/transport/transport.go:577
6 0x0000000000768679 in google.golang.org/grpc.(*addrConn).createTransport
at ./vendor/google.golang.org/grpc/clientconn.go:1282
7 0x000000000076808b in google.golang.org/grpc.(*addrConn).tryAllAddrs
at ./vendor/google.golang.org/grpc/clientconn.go:1212
8 0x0000000000767635 in google.golang.org/grpc.(*addrConn).resetTransport
at ./vendor/google.golang.org/grpc/clientconn.go:1127
9 0x0000000000765be6 in google.golang.org/grpc.(*addrConn).connect.func1
at ./vendor/google.golang.org/grpc/clientconn.go:813
10 0x00000000004649e1 in runtime.goexit
at /usr/local/go/src/runtime/asm_amd64.s:1571
顺着调用栈,可以看到是 (*addrConn).resetTransport
函数中的 for 循环不断地调用 tryAllAddrs()
建立 TCP 连接,如果失败了,还有 backoff 机制。
看第 10 个调用栈为什么是 runtime.goexit
呢? 这是因为第 9 行的 (*addrConn).connect
使用 go 关键字启动了 resetTransport()
, 此时这个函数就脱离了 connect,作为一个独立的 goroutine 在运行,因此最顶层的 caller 是 runtime.goexit
。
好了,看到这里就可以解答本文开头提出的问题了。
再展开想一想,上面重建 TCP 的 goroutine 是独立运行的,那么每次 rpc call 的时候,该选择哪一个 tcp connection 呢? 同样通过 dlv 可以看到以下调用栈:
(dlv) bt
0 0x000000000076aff2 in google.golang.org/grpc.(*pickerWrapper).pick
at ./vendor/google.golang.org/grpc/picker_wrapper.go:84
1 0x0000000000766795 in google.golang.org/grpc.(*ClientConn).getTransport
at ./vendor/google.golang.org/grpc/clientconn.go:898
2 0x0000000000773ece in google.golang.org/grpc.(*clientStream).newAttemptLocked
at ./vendor/google.golang.org/grpc/stream.go:359
3 0x0000000000773348 in google.golang.org/grpc.newClientStream
at ./vendor/google.golang.org/grpc/stream.go:283
4 0x0000000000760c1d in google.golang.org/grpc.invoke
at ./vendor/google.golang.org/grpc/call.go:66
5 0x0000000000760b05 in google.golang.org/grpc.(*ClientConn).Invoke
at ./vendor/google.golang.org/grpc/call.go:37
6 0x000000000077d32e in learn-grpc/proto.(*testClient).Echo
at ./proto/00_grpc.pb.go:37
7 0x000000000077d5a5 in main.main
at ./clientReconn/main.go:29
8 0x0000000000438052 in runtime.main
at /usr/local/go/src/runtime/proc.go:250
9 0x00000000004649e1 in runtime.goexit
at /usr/local/go/src/runtime/asm_amd64.s:1571
其中 pick 函数 returns the transport that will be used for the RPC.
, 第 5,6项的 Echo 函数就是每次 rpc call。
所以实现逻辑是 每次 rpc call 的时候都去 pool 里面找一个可用的 tcp 连接。
总结: 原先是在读 redis-go
的连接管理时联想到 gRPC 是如何实现的? 通过打断点和阅读代码,了解了 gRPC 基本的思路,感觉还是很复杂的,相比之下,redis-go
的实现要简单清晰的多。
不知道这是因为 gRPC client 端要做 load balance 等等逻辑复杂导致的? 还是本身设计的就不好,从而导致实现复杂?