gRPC 源码分析(四): gRPC server 中 frame 的处理

2,764 阅读7分钟

gRPC 源码分析(四): gRPC server 中 frame 的处理

在前面的两篇文章中, 我们大致了解了 gRPC server 是如何工作的. 从这篇开始, 我们一起来看看 gRPC server 的一些设计细节.

无论 gRPC server 是如何设计的, 它最终都会将 RPC 的执行结果通过 HTTP2 frame 的形式发送给 client. 本篇就来研究一下 server 是如何发送 frame 的.

gRPC Server 的 Framer

gRPC server 在每一次接收到一个新的来自 client 的连接后, 会创建一个 Framer , 这个 Framer 就是实际上负责发送和接收 HTTP2 frame 的接口. 每一个 client 都会对应一个 Framer 来处理来自该 client 的所有 frame, 不管这些 frame 是不是属于一个 stream.

Image.png

type framer struct {
	// 一个包含了 buffer 的 net.Conn 的 writer
	writer *bufWriter
	// 原生的 http2.Framer, 负责数据读写
	fr     *http2.Framer
}

framer 其实就是对 golang 原生的 http2.Framer 的封装.

type bufWriter struct {
	buf       []byte
	offset    int
	batchSize int
	conn      net.Conn
	err       error

	onFlush func()
}

func newBufWriter(conn net.Conn, batchSize int) *bufWriter {
	return &bufWriter{
		buf:       make([]byte, batchSize*2),
		batchSize: batchSize,
		conn:      conn,
	}
}

func (w *bufWriter) Write(b []byte) (n int, err error) {
	// 在 write 之间检查上一次 write 是否发生了错误
	if w.err != nil {
		return 0, w.err
	}
	// 如果 batchSize 为 0, 说明不需要写缓存, 那么直接向 net.Conn 写数据
	if w.batchSize == 0 { // Buffer has been disabled.
		return w.conn.Write(b)
	}
	// 有可能出现需要写入的数据长度大于 buffer 中剩余空间的情况,
	// 因此在 for 循环中持续地向 buf 中写入数据.
	for len(b) > 0 {
		nn := copy(w.buf[w.offset:], b)
		b = b[nn:]
		w.offset += nn
		n += nn
		// 当 buffer 中的数据超过了 batchSize, 就进行一次刷新
		if w.offset >= w.batchSize {
			err = w.Flush()
		}
	}
	return n, err
}

// 刷新, 即向 net.Conn 写入数据
func (w *bufWriter) Flush() error {
	if w.err != nil {
		return w.err
	}
	if w.offset == 0 {
		return nil
	}
	if w.onFlush != nil {
		w.onFlush()
	}
	_, w.err = w.conn.Write(w.buf[:w.offset])
	w.offset = 0
	return w.err
}

gRPC server 在 http_util.go 中实现了一个简单的写缓存给 http2.framer 来作为 io.Writer .

func newFramer(conn net.Conn, writeBufferSize, readBufferSize int, maxHeaderListSize uint32) *framer {
	if writeBufferSize < 0 {
		writeBufferSize = 0
	}
	var r io.Reader = conn
	if readBufferSize > 0 {
		r = bufio.NewReaderSize(r, readBufferSize)
	}
	w := newBufWriter(conn, writeBufferSize)
	f := &framer{
		writer: w,
		fr:     http2.NewFramer(w, r),
	}
	......

	return f
}

而传递给 http2.framerio.Reader 使用了 bufio package.

gRPC Server 如何发送数据

gRPC server 为每一个 client 创建一个 loopyWriter , 由这个 loopyWriter 负责发送数据.

type loopyWriter struct {
	......
	cbuf      *controlBuffer
	......
	// estdStreams 中存储着所有还没有收到 headers frame 的 stream
	estdStreams map[uint32]*outStream // Established streams.
	// activeStreams 是一个 stream 的链表, 而每个 stream 内部也维护者一个链表, 用来存储需要被发送的 data item
	activeStreams *outStreamList
	// 负责数据发送的 framer
	framer        *framer
	......
}

Image.png

loopyWriter 在运行时协调内部的 controlBufferactiveStreams 来有序地发送数据, 流程大致是这样的:

  1. loopyWriter 从和 client 一一对应的 controlBuffer 中读取下一个要发送给 client 的 message. controlBuffer 中可能保存各种各样的 message, 包括但不限于 dataFrame. controlBuffer 还承担着 client level 的流量控制.
  2. 以 dataFrame 为例, 一个 dataFrame 中可能包含着多个 HTTP2 data frame. 这个 dataFrame 会首先被放入到对应 stream 的 itemList 的末尾. 每个 stream 中都维护着一个 itemList , 用来存放需要发送的数据.
  3. loopyWriter 中维护者一个 stream 的链表, 即 activeStreams . loopyWriter 每次取出链表头部的 stream, 将其 itemList 中最多 16KB 的数据发送给 client, 然后将该 stream 放到 activeStreams 链表的末尾.

gRPC server 在和 client 的通信中, 所有 stream (即所有 RPC call) 共享一个 loopyWriter.

下面我们来看看代码中是怎么做到的:

// run should be run in a separate goroutine.
// It reads control frames from controlBuf and processes them by:
// 1. Updating loopy's internal state, or/and
// 2. Writing out HTTP2 frames on the wire.
//
// Loopy keeps all active streams with data to send in a linked-list.
// All streams in the activeStreams linked-list must have both:
// 1. Data to send, and
// 2. Stream level flow control quota available.
//
// In each iteration of run loop, other than processing the incoming control
// frame, loopy calls processData, which processes one node from the activeStreams linked-list.
// This results in writing of HTTP2 frames into an underlying write buffer.
// When there's no more control frames to read from controlBuf, loopy flushes the write buffer.
// As an optimization, to increase the batch size for each flush, loopy yields the processor, once
// if the batch size is too low to give stream goroutines a chance to fill it up.
func (l *loopyWriter) run() (err error) {
	defer func() {
		if err == ErrConnClosing {
			// Don't log ErrConnClosing as error since it happens
			// 1. When the connection is closed by some other known issue.
			// 2. User closed the connection.
			// 3. A graceful close of connection.
			if logger.V(logLevel) {
				logger.Infof("transport: loopyWriter.run returning. %v", err)
			}
			err = nil
		}
	}()
	for {
		// block 当前 goroutine
		it, err := l.cbuf.get(true)
		if err != nil {
			return err
		}
		// 处理 controlBuffer 中取出的数据, 对于 dataFrame 来说, 会讲 dataFrame 放到 对应的 stream 的 itemList 的末尾
		if err = l.handle(it); err != nil {
			return err
		}
		// 发送 activeStreams 中第一个 stream 中最多 16KB 的数据
		if _, err = l.processData(); err != nil {
			return err
		}
		gosched := true
	hasdata:
		for {
			// 不 block 当前 goroutine
			it, err := l.cbuf.get(false)
			if err != nil {
				return err
			}
			if it != nil {
				if err = l.handle(it); err != nil {
					return err
				}
				if _, err = l.processData(); err != nil {
					return err
				}
				continue hasdata
			}
			isEmpty, err := l.processData()
			if err != nil {
				return err
			}
			if !isEmpty {
				continue hasdata
			}
			if gosched {
				gosched = false
				// 当 framer 的 writer buffer 中数据过少时, yield processer 来让其他 goroutine 向 controlBuffer 中填充数据
				if l.framer.writer.offset < minBatchSize {
					runtime.Gosched()
					continue hasdata
				}
			}
			l.framer.writer.Flush()
			break hasdata

		}
	}
}

run 方法中有几点值得注意:

  1. l.cbuf.get 被调用了两次, 第一次调用会阻塞当前 goroutine, 而第二次不会. 第一次采取阻塞式调用, 是因为当前所有的 activeStreams 中已经没有待发送的数据了, 因此可以阻塞, 避免无意义的轮询. 而第二次采用非阻塞式调用, 是因为此时 activeStreams 可能还有待发送的数据, 无论 controlBuffer 中是否有新的数据出现, 都应该继续向 client 发送数据.
  2. 当 framer 中写缓存中的数据少于 minBatchSize 时, loopyWriter 会主动放弃 CPU. 当 loopyWriter 发送数据的速度快于 gRPC server 产生数据的速度时, loopyWriter 放弃 CPU, 从而让其他的 goroutine (stream 的 goroutine) 去填充数据. 这么做是为了能够尽量多地向 net.Conn 传递数据, 即减少 TCP packet 的数量.

loopyWriter 如何发送 stream 中的数据

func (l *loopyWriter) processData() (bool, error) {
	// connection level 流量控制
	if l.sendQuota == 0 {
		return true, nil
	}
	// 取出 activeStreams 中的第一个 stream
	str := l.activeStreams.dequeue()
	if str == nil {
		return true, nil
	}
	// 第一个 item 一定是 dataFrame, dataFrame 是 gRPC 中定义的数据结构, 其中可能包含多个 HTTP2 的 data frame
	dataItem := str.itl.peek().(*dataFrame)

	if len(dataItem.h) == 0 && len(dataItem.d) == 0 { // Empty data frame
		......
	}
	// 确定需要发送的数据大小, 并存放在 buf 中
	var (
		buf []byte
	)
	......
	// 通过 framer 向 client 发送数据
	if err := l.framer.fr.WriteData(dataItem.streamID, endStream, buf[:size]); err != nil {
		return false, err
	}
	......
	if str.itl.isEmpty() {
		......
	} else {
		// 如果 stream 中还有数据待发送, 那么将这个 stream enqueue 回 activeStreams
		l.activeStreams.enqueue(str)
	}
	return false, nil
}

loopyWriter.run() 中, 会不断地执行 processData 函数, 在函数中:

  1. activeStreams 中的第一个 stream 取出.
  2. 将 stream 的 itemList 中的第一个元素取出, 该元素一定是一个 dataFrame. dataFrame 是 gRPC 中定义的一种数据结构, 包含了需要向 client 发送的数据. 一个 dataFrame 的大小可能会超过 http2MaxFrameLen 的限制, 最终会被拆分成多个 HTTP2 data frame 发送出去.
  3. 经过流量控制, 确定本次可以发送的数据的大小, 并将要发送的数据存放在 buf 中.
  4. 通过 framer 将数据发送给 client.
  5. 如果 stream 中还有待发送的数据, 就把 stream 重新放回到 activeStreams 中.

stream 中的数据是怎么存储的

stream 中的数据被存放在一个 itemList 单链表中, 我们来看看 gRPC 是如何实现这个单链表的.

// 链表中的节点
type itemNode struct {
	it   interface{}
	next *itemNode
}

// 一个链表包含了指向链表的头尾指针
type itemList struct {
	head *itemNode
	tail *itemNode
}

// enqueue 函数
func (il *itemList) enqueue(i interface{}) {
	// 构造节点
	n := &itemNode{it: i}
	// 链表尾指针为空, 说明链表中不存在节点
	if il.tail == nil {
		// 链表头尾指针都应该指向这个新节点
		il.head, il.tail = n, n
		return
	}
	// 链表已经存在节点, 让尾节点的 next 指针指向新节点
	il.tail.next = n
	// 更新链表的尾指针
	il.tail = n
}

// 返回链表中的第一个节点中的数据, 但不移除该节点
func (il *itemList) peek() interface{} {
	return il.head.it
}

// 将链表中的第一个节点移除
func (il *itemList) dequeue() interface{} {
	// 头指针为空, 返回 nil
	if il.head == nil {
		return nil
	}
	// i 指向头节点
	i := il.head.it
	// 链表头指针指向下一个节点
	il.head = il.head.next
	// 如果发现链表中没有节点了, 那么更新尾指针为空
	if il.head == nil {
		il.tail = nil
	}
	return i
}

// 清空链表
func (il *itemList) dequeueAll() *itemNode {
	// 直接让头尾指针为空
	h := il.head
	il.head, il.tail = nil, nil
	return h
}

// 根据头指针判断链表是否为空
func (il *itemList) isEmpty() bool {
	return il.head == nil
}

总结

本篇中我们了解了 gRPC server 是如何向 client 发送 HTTP2 frame 的. 可以看到 gRPC server 会循环发送所有 stream 中的数据, 相当于采用了一种 round robin 的策略, 来确保各个 stream 中的数据能够被均等地传输给 client.