之前写过一篇 gRPC-go 建立 TCP 连接的过程 博客,主要研究了 client 程序启动后,如何与 server 建立 TCP 连接。

今天,在思考 redis-go 的连接池实现的时候,突然想到:

  1. 当 gRPC 的 TCP 连接断开后,能自动重连吗?
  2. 如果可以,是如何实现的 ?

首先要注意,这里指的是 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 等等逻辑复杂导致的? 还是本身设计的就不好,从而导致实现复杂?