gRPC 源码分析(六): gRPC server 的流量控制 - connection 和 stream level 的流量控制

1,083 阅读5分钟

gRPC 源码分析(六): gRPC server 的流量控制 - connection 和 stream level 的流量控制

上一篇中我们介绍了基于 BDP 的 sample level 流量控制是如何影响 HTTP/2, connection 以及 stream 的窗口大小的. 在介绍 connection level 和 stream level 的 流量控制之前, 我们先来看一下在 gRPC server 端进行流量控制的关键: controlBuffer .

什么是 controlBuffer

controlBuffer 的定义如下

// controlBuffer is a way to pass information to loopy.
// Information is passed as specific struct types called control frames.
// A control frame not only represents data, messages or headers to be sent out
// but can also be used to instruct loopy to update its internal state.
// It shouldn't be confused with an HTTP2 frame, although some of the control frames
// like dataFrame and headerFrame do go out on wire as HTTP2 frames.
type controlBuffer struct {
	ch              chan struct{}
	done            <-chan struct{}
	mu              sync.Mutex
	consumerWaiting bool
	list            *itemList
	err             error

	// transportResponseFrames counts the number of queued items that represent
	// the response of an action initiated by the peer.  trfChan is created
	// when transportResponseFrames >= maxQueuedTransportResponseFrames and is
	// closed and nilled when transportResponseFrames drops below the
	// threshold.  Both fields are protected by mu.
	transportResponseFrames int
	trfChan                 atomic.Value // chan struct{}
}

controlBuffer 维护了一个 itemList (单链表), 本质上是一块缓存区. 这块缓存区主要有两个作用:

  1. 缓存需要发送的 frame.
  2. 根据缓存中 transportResponseFrame 的数量, 决定是否暂时停止从读取 client 发来的 frame.

其中:

  • ch channel 的作用是在阻塞式读取缓存中的内容时, 当有新的 frame 被放置在 itemList 时, 可以解除阻塞并返回 itemList 中的 frame.
  • consumerWaitingch 配合使用, 确保不向 ch 中放入多余的 struct, 保证阻塞式读取缓存不会因为 ch 中的多余元素而错误地解除阻塞.
  • transportResponseFrames 记录 itemList 中的某些特殊 frame. 目前复合条件的 frame 有incomingSettingsping frame.
  • trfChan 中是否包含一个 channel 取决于 transportResponseFrames 的数量, 当其数量超过某个阈值时, trfChan 中会被赋予一个 channel, 用来控制是否继续从 client 读取 frame.

前面的介绍可能过于抽象, 接下来我们一起来看一下 controlBuffer 实现的几个函数, 就能够大致明白 controlBuffer 的作用了.

controlBuffer 的作用

// throttle blocks if there are too many incomingSettings/cleanupStreams in the
// controlbuf.
func (c *controlBuffer) throttle() {
	ch, _ := c.trfChan.Load().(chan struct{})
	if ch != nil {
		select {
		case <-ch:
		case <-c.done:
		}
	}
}

throttle 函数会在 trfChan 中存在 channel 时 block, 等待 channel 中出现一个元素. 在 gRPC server 的代码中, throttle 函数出现在 gRPC server 接收 client frame 的死循环的开头处. 也就是说, 当 transportResponseFrames 数量过多时, gRPC server 会暂停接收来自 client 的 frame.

func (c *controlBuffer) executeAndPut(f func(it interface{}) bool, it cbItem) (bool, error) {
	var wakeUp bool
	c.mu.Lock()
	if c.err != nil {
		c.mu.Unlock()
		return false, c.err
	}
	if f != nil {
		if !f(it) { // f wasn't successful
			c.mu.Unlock()
			return false, nil
		}
	}
	if c.consumerWaiting {
		wakeUp = true
		c.consumerWaiting = false
	}
	c.list.enqueue(it)
	if it.isTransportResponseFrame() {
		c.transportResponseFrames++
		if c.transportResponseFrames == maxQueuedTransportResponseFrames {
			// We are adding the frame that puts us over the threshold; create
			// a throttling channel.
			c.trfChan.Store(make(chan struct{}))
		}
	}
	c.mu.Unlock()
	if wakeUp {
		select {
		case c.ch <- struct{}{}:
		default:
		}
	}
	return true, nil
}

executeAndPut 函数的作用是向 itemList 中放入新的 frame. 同时, 如果 consumerWaiting 为 true, 需要向 ch 中放入一个元素, 来通知消费者可以从 itemList 中读取 frame 了.

func (c *controlBuffer) get(block bool) (interface{}, error) {
	for {
		c.mu.Lock()
		if c.err != nil {
			c.mu.Unlock()
			return nil, c.err
		}
		if !c.list.isEmpty() {
			h := c.list.dequeue().(cbItem)
			if h.isTransportResponseFrame() {
				if c.transportResponseFrames == maxQueuedTransportResponseFrames {
					// We are removing the frame that put us over the
					// threshold; close and clear the throttling channel.
					ch := c.trfChan.Load().(chan struct{})
					close(ch)
					c.trfChan.Store((chan struct{})(nil))
				}
				c.transportResponseFrames--
			}
			c.mu.Unlock()
			return h, nil
		}
		if !block {
			c.mu.Unlock()
			return nil, nil
		}
		c.consumerWaiting = true
		c.mu.Unlock()
		select {
		case <-c.ch:
		case <-c.done:
			return nil, ErrConnClosing
		}
	}
}

get 函数会从 itemList 中读取排在单链表的第一位的 frame. 当 itemList 为空, 且读取方式被指定为阻塞式读取时, get 函数会等待 ch 中出现新元素为止, 也就是 itemList 中出现新的 frame 为止. 可以看到 gRPC 的 controlBuffer 中实现了一个很典型的 生产者-消费者 模型.

Connection level 流量控制

Connection level 的流量控制会控制对于某一个 client 某一时刻能够发送的数据总量.

type loopyWriter struct {
	......
	sendQuota uint32
	......
}

控制的方式就是在 loopyWriter 中用一个 sendQuota 来标记该 client 目前可发送数据的额度.

func (l *loopyWriter) processData() (bool, error) {
	......
	l.sendQuota -= uint32(size)
	......
}

sendQuota 会被初始化为 65535, 并且每当有数据被 gRPC server 发送给 client 的时候, sendQuota 都会减少和被发送数据相等的大小.

func (l *loopyWriter) incomingWindowUpdateHandler(w *incomingWindowUpdate) error {
	// Otherwise update the quota.
	if w.streamID == 0 {
		l.sendQuota += w.increment
		return nil
	}
	......
}

当 gRPC server 收到来自 client 的 HTTP2 FrameWindowUpdate frame 时, 才会将这一 quota 增加. 也就是说 sendQuota 会在 server 发出数据时减少, 在收到来自 client 的 FrameWindowUpdate frame 时增加, connection level 的流量控制是 server 和 client 相互交互的结果, 由双方共同决定窗口大小.

Stream level 流量控制

一个 stream 的流量控制有三种状态, 分别是

  • active: stream 中有数据且数据可以被发送
  • empty: stream 中没有数据
  • waitingOnStreamQuota: stream 的 quota 不足, 等待有 quota 时再发送数据.

一个 stream 一开始的状态是 empty , 因为一个 stream 在被创建出来时还没有待发送的数据.

func (l *loopyWriter) preprocessData(df *dataFrame) error {
	str, ok := l.estdStreams[df.streamID]
	if !ok {
		return nil
	}
	// If we got data for a stream it means that
	// stream was originated and the headers were sent out.
	str.itl.enqueue(df)
	if str.state == empty {
		str.state = active
		l.activeStreams.enqueue(str)
	}
	return nil
}

当 server 接收到了发往某个 stream 的 frame 后, 会将该 stream 转化成 active 状态. active 状态的 stream 可以发送数据.

func (l *loopyWriter) processData() (bool, error) {
	......
	if strQuota := int(l.oiws) - str.bytesOutStanding; strQuota <= 0 { // stream-level flow control.
		str.state = waitingOnStreamQuota
		return false, nil
	}
	......
	str.bytesOutStanding += size
	......
}

发送数据之后, byteOutStanding 会增加相应的数据大小, 表明该 stream 有这些数据被发送给 client, 还没有收到回应. 而当 byteOutStanding 的大小超过了 loopWriter.oiws , 也就是 65535 后, 会拒绝为该 stream 继续发送数据. 这种策略避免了不断地向一个失去回应的 client 持续发送数据, 以避免浪费网络带宽.

总结

本篇中我们详细讨论了 gRPC server 端流量控制的一些方法. gRPC 中的流量控制是应用层的流量控制, 和传统 TCP 那种依靠 ack 进行猜测的流量控制不同, 因为应用层的行为是比较容易修改和调整的, 因此 gRPC 的流量控制依赖于对端的反馈信息. 这样的流量控制相较于依靠 ack 的流量控制更加精准, 也是 gRPC 高性能的一个保证.