跟着官方脚手架grpc一把梭之后,中高级程序员一般都可以上手grpc开发;

想要得心应手的操作grpc, 需要深度探究grpc能力来源,下面总结grpc的前世今生、能力来源、最佳实践(golang)。

  1. grpc protoc到底做了什么事情?
  2. 为什么已经有http? 还需要grpc?
  3. 八股文都说grpc是基于http2的rpc框架,到底利用了http2的什么特性 ?
  4. grpc调用源码分析 && 内存泄漏的示例

1. grpc编译器protoc到底做了什么事情?

grpc是基于http/2协议的高性能的rpc框架, 提取句式中关键信息:rpc框架、 http2、 高性能。

跟许多rpc协议一样, grpc也是基于IDL(interface define lauguage)优先。

grpc官方提供protoc编译器和特定语言编译插件, 将.proto文件编译为pb打解包协议的sdk代码、客户端存根、服务端存根代码。

对于golang,需要安装protoc-gen-go protoc-gen-go-grpc插件,protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative proto/Greet.proto 编译会产生两个文件, 一个文件是pb序列化协议的sdk文件, 一个是grpc调用相关的代理文件。

《官方脚手架grpc一把梭》 使用.NET工具Grpc.Tools(https://www.nuget.org/packages/Grpc.Tools)产生了pb协议的sdk、客户端存根代码、服务端待实现基类,生产的代码在{program}\obj\Debug[TARGET_FRAMEWORK]\Protos…cs

2. 目前市面流行http, 为什么还需要grpc?

其实rpc框架比 http协议更早出来。
tcp协议于70年代诞生,tcp是一种可靠的、面向连接的、基于字节流的传输层协议。

在70到90年代之后,互联网并不发达,很多都是client、server点对点的传输,所以在那个年代rpc很活跃,client、server双方约定服务结构,以[类本地调用]的形态通信。

90年代,随着it产业的蓬勃发展,计算机走进了千家万户,90年代初期诞生了浏览器, 浏览器与上面的C/S结构最大的不同是访问的服务端对象是千万家不同的服务提供方。

再像C/S那样一对一提前沟通契约就不合适了, 故浏览器和web服务器作为一种特殊的C/S需要约定一种【固定的、能自表述的传输格式】, 于是诞生了适用于B/S端的http协议。

所以,我们该问的不是“既然有HTTP协议,为什么要有RPC”,而是应该问“为什么有rpc,还需要有http”。

从上面的前世今生可以知道, rpc是通信框架,http是通信协议,rpc可以基于tcp、udp、http2协议来实现。

grpc = http2 + proto buffer,
grpc在众多rpc框架中脱颖而出,取决于底层的http2基础设施和pb序列化打解包协议。

3. grpc到底利用了http2的什么特性?

回过头来看grpc的连两个定语 ① http/2 ② 高性能。

grpc底层传输使用http/2,http/2兼容http1.1语义,还有如下优势

http2 http1.1
用于数据传输的二进制分帧 http1.1是基于文本协议
同一tcp连接支持流式传输,故支持发送多个并行请求、调用 应答模型:http1.1在一个tcp连接上完成[请求/响应]是串行的
减少网络使用率的头部压缩 头部带有大量信息,每次都要重复发送

http2的二进制分帧、流式传输 能力支撑了grpc框架近乎本地的实时服务互调;

http2的多路复用(单tcp连接上并发多个请求,不多占文件描述符)、二进制编码协议、头部压缩支撑了grpc本地互调的高性能。

这里要指出:

  • HTTP2 上的通信只需要在一个TCP连接完成,在这个连接上可见的形态是帧frame和流stream。

上图绘制有问题: 客户端发送的流标识是奇数, 服务端发送的流标识是偶数

  • 而消息(或者说业务调用,业务上的逻辑发送单位)由一或多个帧组成,这些帧可以乱序发送,然后根据每个帧首部的流标识符重新组装。

  • “gRPC双向流通信streaming”与”http2的流式stream传输”是一个东西吗?
    http2流式传输stream是一种底层的传输方式,其作用是支撑单连接多路复用 。
    grpc流式通信streaming,更接近业务通信级别的通信方式,grpc流式通信可用于替代高性能场景下的一元gRPC调用。

我们假设HTTP/2协议中1次RPC请求使用1个并发Stream,每个RPC消息又可 通过帧体中 Length-Prefixed Message 头部确立了边界,这样,在 Stream 中连续地发送多个 DATA 帧,就可以实现流模式 RPC。
https://juejin.cn/post/7249522846211801147

4. grpc调用分析

底层的http2协议给予了grpc很大的性能表现,但同时也带来了新的性能瓶颈, 现在现在压力给到了tcp连接。

  • 除了tcp级别的队头阻塞问题, 我们在http2 层面还会遇到并发流限制问题。

Each gRPC channel uses 0 or more HTTP/2 connections and each connection usually has a limit on the number of concurrent streams. When the number of active RPCs on the connection reaches this limit, additional RPCs are queued in the client and must wait for active RPCs to finish before they are sent. Applications with high load or long-lived streaming RPCs might see performance issues because of this queueing.

通常情况下,一个HTTP/2 tcp连接中流的数量是有限制的, 对端可以通过setting帧来设置本端的并发活动流的数量。

A peer can limit the number of concurrently active streams using the SETTINGS_MAX_CONCURRENT_STREAMS parameter (see Section 6.5.2) within a SETTINGS frame. The maximum concurrent streams setting is specific to each endpoint and applies only to the peer that receives the setting. That is, clients specify the maximum number of concurrent streams the server can initiate, and servers specify the maximum number of concurrent streams the client can initiate.

golang是这样实现的: 每一个 gRPC 连接均有一个独立的队列,挂在该连接的所有 streams 共享,请求相当于生产,往服务端发送请求相当于消费。

grpc调用源码分析

golang grpc客户端源码中tcp连接的并发流限制是100 , 但接受grpc服务端的配置ServerOption: MaxConcurrentStreams

一般情况下服务器都不会配置这个参数,那么这个值就是 int.MaxUint32, 也就是每次rpc请求都被派发一个并发流标识,然后进入调用队列,走生产/消费的逻辑。

当消费和生产速度不匹配,调用队列会堆积,同时设置了Deadline的调用方可能会出现超时报错。

创建gRPC客户端连接,会创建的几个协程:

1)transport.loopyWriter.run 往服务端发送数据协程 (写端), loopyWriter 里面维护了发送队列 controlBuf

2)transport.http2Client.reader 读取服务端数据协程 (读端), 并会调用 t.controlBuf.throttle() 执行流控.

func newHTTP2Client(connectCtx, ctx context.Context, addr resolver.Address, opts ConnectOptions, onPrefaceReceipt func(), onGoAway func(GoAwayReason), onClose func()) (_ *http2Client, err error) {
  conn, err := dial(connectCtx, opts.Dialer, addr.Addr)
  t.controlBuf = newControlBuffer(t.ctxDone) // 含发送队列的初始化

  if t.keepaliveEnabled {
		t.kpDormancyCond = sync.NewCond(&t.mu)
		go t.keepalive()           // 保活协程
	}
  
  // Start the reader goroutine for incoming message. Each transport has
  // a dedicated goroutine which reads HTTP2 frame from network. Then it
  // dispatches the frame to the corresponding stream entity.
  go t.reader()
  
  // Send connection preface to server.
   n, err := t.conn.Write(clientPreface)
  
  go func() {
      t.loopy = newLoopyWriter(clientSide, t.framer, t.controlBuf, t.bdpEst)
      err := t.loopy.run()
  }
}

数据发送协程的操作对象是 controlBuffer, 是一个发送队列,下面围观入队、出队时机。

//  controlBuffer is a way to pass information to loopy. information  not only represents data, messages or headers to be sent out  but can also be used to instruct loopy to update its internal state.
type controlBuffer struct {
  list *itemList // 队列
}

从调用堆栈看入队过程

//  一元RPC 调用从 grpc.ClientConn.Invoke 开始:
//    greet_client.pb.go // 编译 .proto 生成的文件
// -> examples.greet_client.SayHello
// -> grpc.ClientConn.Invoke        // 在 call.go 中,如果是 stream RPC,则从调用 grpc.ClientConn.NewStream 开始
// -> grpc.invoke // 在 call.go 中
// -> grpc.newClientStream          // 在 stream.go中 Start tracking the RPC for idleness purposes. This is where a stream is created for both streaming and unary RPCs, and hence is a good place to track active RPC count.  
// -> grpc.clientStream.newAttemptLocked          // 在 stream.go 中
// -> grpc.ClientConn.getTransport        // 在 clientconn.go 中
// -> grpc.pickerWrapper.pick // 在 picker_wrapper.go 中
// -> grpc.addrConn.getReadyTransport
// -> grpc.addrConn.connect // 创建协程 resetTransport
// -> grpc.addrConn.resetTransport // ***是一个协程***
// -> grpc.addrConn.tryAllAddrs
// -> grpc.addrConn.createTransport // 在clientconn.go 中
// -> transport.NewClientTransport // 在 transport.go 中
// -> transport.newHTTP2Client
// -> transport.dial
// -> net.Dialer.DialContext // net 为 Go 自带包,不是 gRPC 包
// -> net.sysDialer.dialSerial
// -> net.sysDialer.dialSingle
// -> net.sysDialer.dialTCP/dialUDP/dialUnix/dialIP
// -> net.sysDialer.doDialTCP // 以 dialTCP 为例
// -> net.internetSocket // 从这开始,和 C 语言的使用类似了,只不过包装了不同平台的
// -> net.socket
// -> net.sysSocket

试图创建http2 流时,会进入如下代码:

// NewStream creates a stream and registers it into the transport as "active"
// streams.  All non-nil errors returned will be *NewStreamError.
func (t *http2Client) NewStream(ctx context.Context, callHdr *CallHdr) (*Stream, error) {
  ...
for {
     //  检查通过则入队,入队的对象是 headerFrame
      success, err := t.controlBuf.executeAndPut(func(it any) bool {   
	      return checkForHeaderListSize(it) && checkForStreamQuota(it)   
	  }, hdr)                                                                               
     ...... 
  }
}

func (c *controlBuffer) executeAndPut(f func(it any) bool, it cbItem) (bool, error) {
     ....
     c.list.enqueue(it)
     ....
}

出队: 循环读取controlBuf,轮询处理有消息发送的stream,当有stream要发送数据时,则将data写入到controlBuf待处理。

// 源码所在文件:internal/transport/controlbuf.go
func (l *loopyWriter) run() (err error) {
  // 通过 get 间接调用出队函数dequeue 和 dequeueAll
  for {
    it, err := l.cbuf.get(true)   //  阻塞读消息(消息包括setting、header、data、ping、goaway frame)
    if err != nil {
			return err
		}
	if err = l.handle(it); err != nil {   // 消息处理
			return err
		}
	if _, err = l.processData(); err != nil {   // 发送data frame
			return err
		}
  }
}

func (c *controlBuffer) get(block bool) (interface{}, error) {
  for {
    c.mu.Lock() // 队列操作需要加锁保护
    ......
    // 消费队列(出队)
    h := c.list.dequeue().(cbItem)
    ......
    if !block {
			c.mu.Unlock()
			return nil, nil
		}
    // 阻塞
    c.consumerWaiting = true
		c.mu.Unlock()
		select {
		case <-c.ch: // 对应 executeAndPut 中唤醒的:c.ch <- struct{}
		case <-c.done:
			c.finish() // 清空队列
			return nil, ErrConnClosing // indicates that the transport is closing
		}
  }
}

这样队列的入队/出队的姿势我们都已经找到了,每一个gRPC客户端连接均建立了上面的队列,gRPC 并没有直接限定队列大小,所以如果不加任何限制则会内存暴涨,直到OOM发生。

bug表象分析

我们在日常开发中就遇到的这样的例子, 下面给出我们的错误示范。

go func(ctx context.Context) {
		ch := c.election.Observe(ctx)           // 上下文取消或者底层信道被干扰,信道会被关闭
		tick := time.NewTicker(c.askTime)
		defer tick.Stop()
		for {
			select {
			case <-c.sessionCh:
				log.Warning("Recv session event")
				fmt.Errorf("session Done")
			case e := <-ch: ,                //  能持续读取零值,目前形成死循环,  todo: 此时要重建信道
				log.WithField("topic", "watch-loop").Info(e)
			case <-tick.C:
			}
			ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
			defer cancel()
			resp, err := c.election.Leader(ctx)
           ...
         }
}

pprof 显示这个election.Leader函数导致的内存持续增长。

etcd v3 发起的请求为grpc请求,

  • 因为从closed信道能持续读取零值,形成死循环。

  • 每次grpc调用均会利用流,队列的入队速度明显超过消费速度, 导致在队列中堆积,形成缓慢内存泄漏。

在设计 gRPC 服务时,稳定高效维持tcp连接,合理配置tcp并发流的数量是非常重要的, 这有助于确保服务的稳定性和高效性, 有一些最佳实践:

  • 尽量重用 存根和调用通道
  • 当处理从客户端到服务器的长期逻辑数据流时 使用流式 RPC
  • 保持keepalive ping, 以维持活跃的http2连接,避免rpc调用的延迟
  • 上例的问题:可采用 ①对于”热点调用”建立独立的调用通道 ② 使用通道池。

https://segmentfault.com/a/1190000041716350?utm_source=sf-similar-article
https://github.com/shimingyah/pool