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
(单链表), 本质上是一块缓存区. 这块缓存区主要有两个作用:
- 缓存需要发送的 frame.
- 根据缓存中
transportResponseFrame
的数量, 决定是否暂时停止从读取 client 发来的 frame.
其中:
ch
channel 的作用是在阻塞式读取缓存中的内容时, 当有新的 frame 被放置在itemList
时, 可以解除阻塞并返回itemList
中的 frame.consumerWaiting
和ch
配合使用, 确保不向ch
中放入多余的 struct, 保证阻塞式读取缓存不会因为ch
中的多余元素而错误地解除阻塞.transportResponseFrames
记录itemList
中的某些特殊 frame. 目前复合条件的 frame 有incomingSettings
和ping
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 高性能的一个保证.