0%

浅析 CloudWeGo-Netpoll

CloudWeGo-Netpoll ,以下简称为 Netpoll,是由字节跳动开发的一款专注于 RPC 场景的高性能 NIO 网络库。大大多基于多路复用的网络库,基本框架都比较类似。但不同的网络库会针对不同的应用场景对基本框架进行修改,从而使其在固定场景下发挥出更好的性能。本文着重分析 Netpoll 与其他网络库不同的设计,以及这些设计是如何满足 RPC 场景的。

Netpoll 场景

RPC 场景,在我的理解中,主要可以由以下几个特点来概括:

  • 较重的处理逻辑,事务处理中可能会有较长时间的阻塞;
  • 通常采用短连接或者长连接池的形式;
  • 具有超时机制,可能会产生较多的失效连接。

在这种场景下,使用 go 标准网络库开发服务端比较方便,但无法达到较高的性能,这主要来源于 goroutine 的调度开销上。在微服务场景下,服务器之间的交互非常频繁,服务器 A 到服务器 B 之间可能会需要多条 rpc 逻辑连接,如果为这些逻辑连接全部开辟出一条物理连接,会对服务端和客户端都造成比较大的压力。通常,rpc 框架会选择使用多路复用的方式,避免开辟过多的物理连接。但又因为 rpc 依赖链路这种情景,串行处理 rpc 是性能非常低下的。当使用标准网络库时,虽然 rpc 的解析在阻塞 IO 下也可以实现多路复用,但是 rpc 的处理逻辑必须要开辟一个新的 goroutine 来防止队首阻塞。这是因为 rpc 可能是具有依赖链路的。通过分析,我们可以发现,使用 go 标准网络库进行开发,虽然可以避免开辟过多 goroutine 用于解析,但是仍然需要为每一条业务逻辑开辟出一个 goroutine。当 goroutine 过多时,调度器的压力会比较大,造成较大的延迟。

而 go 语言的一些其他网络库如 gnet,底层会使用 ring_buffer 作为缓冲区(新版本中也可以作为 linked_list 作为缓冲区)来获取更高的性能。由于 ring_buffer 中内存地址是会被复用的,并且生命周期难以被管理,如果应用层的业务逻辑没有阻塞的情况下,可以直接在读事件中的 callback 中完成事件处理(如 redis 的 500us 左右的纯内存操作,HAProxy 的转发操作),那么这种网络框架性能是非常高的,核心点就是不需要分配内存。但是在 rpc 场景下,业务的处理逻辑非常重,仍然需要分配内存进行拷贝,防止 ring_buffer 被覆盖,这种框架的优势就没有那么明显了。(当然,可以在业务层自行实现内存池来解决这一问题,不过这相当于把问题抛给了用户)

综上所述,Netpoll 所要做的,核心有两点:

  • 实现非阻塞读写,避免开辟过多 goroutine;
  • 实现生命周期可调控的内存复用,避免分配内存。

高性能网络库“三板斧”

各种实现的 Reactor 模式,其高性能主要来自于避免了各种耗时操作,如内存分配、线程开辟、互斥量竞争,几乎所有的高性能网络库都做了以下几点优化:

  • Multi-Reactors/Master-Workers 模型,避免 epoll 惊群效应;
  • EventLoop 模型的高效运行机制;
  • 高效的内存管理机制,尽量避免内存分配;
  • 高性能线程/goroutine池,避免用户手动开辟处理异步逻辑。

这几点在 Netpoll 中都有涉及,其中内存管理是借助 sync.Pool 和 link_buffer 实现的;高性能协程池则是使用了自家的 gopool

Reactor 模式实现

Netpoll 中使用的是主从 Reactor 模式,能够有效地避免 epoll 惊群效应。主从 Reactor 模式实现上的一个细节问题是如何进行 fd 的传递,即系统调用 accept 获得的 fd 如何注册到 Worker Reactor 的 epoll 上。在 muduo 和 gnet 的实现中,都是以队列的形式进行传递;而在 Netpoll 实现中,则是直接在 Master Reactor 线程使用 epoll 系统调用将新到达连接的 fd 注册到 Worker Reactor 上。

Netpoll 中处理新连接到达的代码比较分散,这里简述一下调用链:server.OnRead -> connection.init -> connection.onPrepare -> connection.register -> FDOperator.Control -> poll.Control

上述调用链是发生在 Master Reactor 线程中的,这是利用了 epoll 线程安全的特性,当使用 epoll 相关系统调用时会使用自旋锁来保证红黑树结构的线程安全。poll.Control函数会根据输入参数使用不同的系统调用来维护 epoll 的注册表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Control implements Poll.
func (p *defaultPoll) Control(operator *FDOperator, event PollEvent) error {
var op int
var evt epollevent
*(**FDOperator)(unsafe.Pointer(&evt.data)) = operator
switch event {
case PollReadable: // server accept a new connection and wait read
operator.inuse()
op, evt.events = syscall.EPOLL_CTL_ADD, syscall.EPOLLIN|syscall.EPOLLRDHUP|syscall.EPOLLERR
case PollWritable: // client create a new connection and wait connect finished
operator.inuse()
op, evt.events = syscall.EPOLL_CTL_ADD, EPOLLET|syscall.EPOLLOUT|syscall.EPOLLRDHUP|syscall.EPOLLERR
case PollModReadable: // client wait read/write
op, evt.events = syscall.EPOLL_CTL_MOD, syscall.EPOLLIN|syscall.EPOLLRDHUP|syscall.EPOLLERR
case PollDetach: // deregister
op, evt.events = syscall.EPOLL_CTL_DEL, syscall.EPOLLIN|syscall.EPOLLOUT|syscall.EPOLLRDHUP|syscall.EPOLLERR
case PollR2RW: // connection wait read/write
op, evt.events = syscall.EPOLL_CTL_MOD, syscall.EPOLLIN|syscall.EPOLLOUT|syscall.EPOLLRDHUP|syscall.EPOLLERR
case PollRW2R: // connection wait read
op, evt.events = syscall.EPOLL_CTL_MOD, syscall.EPOLLIN|syscall.EPOLLRDHUP|syscall.EPOLLERR
}
return EpollCtl(p.fd, op, operator.FD, &evt)
}

当新连接到达时,FDOperator.event == PollReadable 或者 FDOperator.event == PollModReadable,因此该函数会将新到达的 fd 注册到对应的 poll 结构体上。

Nocopy Buffer

Nocopy Buffer 是 Netpoll 设计的核心内容,连接多路复用、ZeroCopy 优化都是基于 Nocopy Buffer 结构的。

linkBuffer 数据结构

Nocopy Buffer 本质上是一个基于链表的无锁读写结构,链表的节点是linkBufferNode数据结构。

1
2
3
4
5
6
7
8
9
type linkBufferNode struct {
buf []byte // buffer
off int // read-offset
malloc int // write-offset
refer int32 // reference count
readonly bool // read-only node
origin *linkBufferNode // the root node of the extends
next *linkBufferNode // the next node of the linked buffer
}

linkBufferNode本质上是一个可引用、具有读写标识位的缓冲区,由于单独对读标识位和写标识位操作是可以并发的,所以其是一个单读写可并发的无锁结构。

linkBuffer则是linkBufferNode组成的链表,其数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
type LinkBuffer struct {
length int64 // 可读长度
mallocSize int // 可写长度

head *linkBufferNode // 链表头部
read *linkBufferNode // 读取位置
flush *linkBufferNode // 写入提交位置
write *linkBufferNode // 链表尾部

caches [][]byte // 从内存池中获取的内存,用于跨 node 读取
}

linkBuffer中使用四个标识位来描述当前的读写状态。head 指向链表的头部,head 与 read 节点之间是可以被释放的节点,read 至 flush 节点是当前可读的区域,flush 至 write 节点是当前可以写入的区域。

/images/link_buffer.png

flush 节点指向的 node 是渐变的,代表其中一部分区域是可读的,一部分区域是不可读的。因为同一个节点可能会被同时写入和读取。虽然图中未指出,但是 head 与 read 节点也可能重合,可以被安全释放的范围是 [head,read) 。

Nocopy 体现在哪

linkBuffer 具有 nocopy 特性,但并非所有的接口都是 nocopy 的。

linkBuffer 的所有读接口的处理逻辑都是类似的,这些读取操作是否为 nocopy 取决于读取的位置。以 linkBuffer.Next函数为例,分析什么情况读取是不需要拷贝的,代码只保留了用于分析的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func (b *LinkBuffer) Next(n int) (p []byte, err error) {

// 检查长度并移动更新 length
...

// 读取长度小于一个节点
if b.isSingleNode(n) {
return b.read.Next(n), nil
}

// 大于 1k 的内存块进行内存池管理,避免分配大内存
if block1k < n && n <= mallocMax {
p = malloc(n, n)
b.caches = append(b.caches, p)
} else {
p = make([]byte, n)
}

var pIdx,l int
// 循环读取不同节点,直到满足需求
for ack := n; ack > 0; ack = ack - l {
l = b.read.Len()
if l >= ack {
pIdx += copy(p[pIdx:], b.read.Next(ack))
break
} else if l > 0 {
pIdx += copy(p[pIdx:], b.read.Next(l))
}
b.read = b.read.next
}

_ = pIdx
return p, nil
}

linkBuffer.Next函数中的读取一共具有两种情况。第一种情况是读取的长度小于 read node 的剩余可读取长度,在这种情况下并没有使用 copy 操作,而是复制了地址,这种情况下是 nocopy 的;另外一种情况是读取的长度大于 read node 的剩余可读取长度,这时候需要将分散在各个节点的数据拷贝到一起,如果要求的数据过长,甚至可能会发生多次拷贝。为了避免出现多次拷贝的情况,应该设置linkBufferNode.buf的长度大于用户需要读取数据的平均长度。

另外值得注意的是,函数中还会根据用户要求的长度来决定不同的内存分配策略。当用户需要的长度过大时,会考虑从内存池中获取一块内存,并将这块内存保存在 caches 数组中,再选择合适的时机将内存归还内存池。

linkBuffer 的写接口只有一部分是 nocopy 的,即linkBuffer.Malloc函数。

1
2
3
4
5
6
7
8
9
10
11
func (b *LinkBuffer) Malloc(n int) (buf []byte, err error) {
if n <= 0 {
return
}
b.mallocSize += n

// 寻找写入位置,若无位置,则创建新节点
b.growth(n)
// 更新标志位
return b.write.Malloc(n), nil
}

函数的逻辑也非常简单,就是在链表中找到一个连续的内存区域,然后将该内存区域返回给用户。如果所有的内存区域都不足以写入,那么将分配一个新的节点,该节点大小正好为用户要求的区域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (b *LinkBuffer) growth(n int) {
if n <= 0 {
return
}
// 跳过只读节点,并且要求剩余可写入空间充足
for b.write.readonly || cap(b.write.buf)-b.write.malloc < n {
// 寻找至最后一个节点后,分配新节点
if b.write.next == nil {
// 新节点的大小正好为用户需求大小
b.write.next = newLinkBufferNode(n)
b.write = b.write.next
return
}
b.write = b.write.next
}
}

linkBuffer.growth函数很好地封装了分配新节点和寻找内存区域的功能,这个函数还是比较好的。注意到,函数中有一段逻辑是跳过了所有的只读节点,那么什么情况下会出现只读节点呢?linkBuffer 中只读节点只来源于用户的接口函数linkBuffer.WriteDirect。该函数可能会导致用户方的乱序写入,因此可能会出现下图中的情况。

/images/link_buffer_read_only_area

用户在获取内存后,首先写入了 node2 中的部分,而 node1 中虽然仍有空白内存,但该内存已经被用户 hold。此时,如果用户又调用了一次写入操作,必须将数据写入到“未写入部分”,因为“待写入部分”后续会被其他写操作覆盖。linkBuffer 实现中的做法是直接将 node1 设置未 readonly 状态,这里的 readonly 并不是完全禁止写入,而是禁止再从该节点上获取位置进行写入,已经获取位置的区域仍然是可以写入的。

为什么是无锁的

linkBuffer 可以用作读缓冲或写缓冲,由于每一个 connection 都会被分配到一个 poller 上,在读缓冲区时,poller 底层只会对 poller 进行写入(接收数据),在写缓冲时,只会对 poller 进行读取(发送数据)。所以无论哪种情况下,Netpoller 都保证框架对缓冲区的读写是单线程的,由于读与写操作使用了不同的标志位,因此只要用户可以保证也使用单线程进行读写,就能够保证 linkBuffer 的无锁并发访问。

连接多路复用

连接多路复用这个概念是存在于客户端的,服务端中不需要连接多路复用。Netpoll 在客户端实现连接多路复用的基础是非阻塞 IO,而 linkBuffer 则是实现高性能的多路复用手段。同时,由于连接多路复用是协议依赖的,NetPoll 只是提供了多路复用的支持,并在官方 blog 中给出了可行的方案。

/images/link_buffer_read_only_area

连接多路复用方案包含以下几个要素:

  • Virtual Connection:建立在真实连接之上的虚拟连接,具有一个 uuid 用于区分;
  • Shared Map:根据 uuid-virtual conn 的方式来存储虚拟连接;
  • Dispatcher:用于读取并解析数据包,根据数据包中的 id 选择对应的 Virtual Connection;
  • Rpc Protocol:一个支持多路复用的通信协议。

方案中,一个真实连接能够承载多个虚拟连接。这些虚拟连接都通过一个分发器来间接与读写缓冲区交互,虽然多个虚拟连接可能运行在不同的 goroutine 中,但是读写操作最终只能够由 Dispatcher 来处理,因此 linkBuffer 之上依然是一读或一写,能够保持无锁并发的特性。Dispatcher 在读取数据后,可以不拷贝数据,而是直接返回对应的切片位置,不同虚拟客户端之间操作不同的切片位置,仍然能够保证无锁并发。

ZeroCopy

Netpoll 目前并不提供 zero copy 的支持。如果要使用 zero copy 的系统调用,就必须要保证需要发送的数据在被内核拷贝掉网卡没有被释放掉。这对于 Netpoll 的框架来说会比较麻烦,因为 linkBuffer 每一次在进行写入时,会根据写入的字节长度来对内存区域进行释放。被释放的内存会进入可 GC 状态或进入内存池,这两种状态下都不能够保证内存的存活周期。如果想要在解决这个问题,可能需要大幅度修改代码框架。

NetPoll 的官方博客中有这样一段介绍,我不太能够理解:

于是,字节跳动框架组和字节跳动内核组合作,由内核组提供了同步的接口:当调用 sendmsg 的时候,内核会监听并拦截内核原先给业务的回调,并且在回调完成后才会让 sendmsg 返回。 这使得我们无需更改原有模型,可以很方便地接入 ZeroCopy send。同时,字节跳动内核组还实现了基于 unix domain socket 的 ZeroCopy,可以使得业务进程与 Mesh sidecar 之间的通信也达到零拷贝。

这样是让 sendmsg阻塞直到将内核将内存写入,如果在写入速度比较频繁的情况下,这样会不会导致写入操作被阻塞过长时间。