字节跳动 Go RPC 框架 KiteX 性能优化实践

前言

KiteX 是字节跳动框架组研发的下一代高性能、强可扩展性的 Go RPC 框架。除具备丰富的服务治理特性外,相比其他框架还有以下特点:集成了自研的网络库 Netpoll;支持多消息协议 (Thrift、Protobuf) 和多交互方式(Ping-Pong、Oneway、 Streaming); 提供了更加灵活可扩展的代码生成器。

目前公司内主要业务线都已经大范围使用 KiteX,据统计当前接入服务数量多达 8k。KiteX 推出后,我们一直在不断地优化性能,本文将分享我们在 Netpoll 和 序列化方面的优化工作。

自研网络库 Netpoll 优化

自研的基于 epoll 的网络库 —— Netpoll,在性能方面有了较为显著的优化。测试数据表明,当前版本(2020.12) 相比于 上次分享 时(2020.05),吞吐能力 ↑30% ,延迟 AVG ↓25% ,TP99  ↓67% ,性能已远超官方 net 库。以下,我们将分享两点显著提升性能的方案。

epoll_wait 调度延迟优化

Netpoll 在刚发布时,遇到了延迟 AVG 较低,但 TP99 较高的问题。经过认真研究 epoll_wait,我们发现结合 polling 和 event trigger 两种模式,并优化调度策略,可以显著降低延迟。

首先我们来看 Go 官方提供的 syscall.EpollWait 方法:

func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error)

这里共提供 3 个参数,分别表示 epoll 的 fd、回调事件、等待时间,其中只有 msec 是动态可调的。

通常情况下,我们主动调用 EpollWait 都会设置 msec=-1,即无限等待事件到来。事实上不少开源网络库也是这么做的。但是我们研究发现,msec=-1 并不是最优解。

epoll_wait 内核源码(如下) 表明,msec=-1 比 msec=0 增加了 fetch_events 检查,因此耗时更长。

static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)

{
...
if (timeout > 0) {
...
} else if (timeout == 0) {
...
goto send_events;
}

fetch_events:
...
if (eavail)
goto send_events;

send_events:
...

Benchmark 表明,在有事件触发的情况下,msec=0 比 msec=-1 调用要快 18% 左右,因此在频繁事件触发场景下,使用 msec=0 调用明显是更优的。

而在无事件触发的场景下,使用 msec=0 显然会造成无限轮询,空耗大量资源。

综合考虑后,我们更希望在有事件触发时,使用 msec=0 调用,而在无事件时,使用 msec=-1 来减少轮询开销。伪代码如下:

var msec = -1
for {
   n, err = syscall.EpollWait(epfd, events, msec)
   if n <= 0 {
      msec = -1
      continue
   }
   msec = 0
   ...
}

那么这样就可以了吗?事实证明优化效果并不明显。

我们再做思考:

msec=0 仅单次调用耗时减少 50ns,影响太小,如果想要进一步优化,必须要在调度逻辑上做出调整。

进一步思考:

上述伪代码中,当无事件触发,调整 msec=-1 时,直接 continue 会立即再次执行 EpollWait,而由于无事件,msec=-1,当前 goroutine 会 block 并被 P 切换。但是被动切换效率较低,如果我们在 continue 前主动为 P 切换 goroutine,则可以节约时间。因此我们将上述伪代码改为如下:

var msec = -1
for {
n, err = syscall.EpollWait(epfd, events, msec)
if n <= 0 {
msec = -1
runtime.Gosched()
continue
}
msec = 0
...
}

测试表明,调整代码后,吞吐量 ↑12% ,TP99 ↓64% ,获得了显著的延迟收益。

合理利用 unsafe.Pointer

继续研究 epoll_wait,我们发现 Go 官方对外提供的 syscall.EpollWait 和 runtime 自用的 epollwait 是不同的版本,即两者使用了不同的 EpollEvent。以下我们展示两者的区别:

// @syscall
type EpollEvent struct {
Events uint32
Fd int32
Pad int32
}
// @runtime
type epollevent struct {
events uint32
data [8]byte // unaligned uintptr
}

我们看到,runtime 使用的 epollevent 是系统层 epoll 定义的原始结构;而对外版本则对其做了封装,将 epoll_data(epollevent.data) 拆分为固定的两字段:Fd 和 Pad。那么 runtime 又是如何使用的呢?在源码里我们看到这样的逻辑:

*(**pollDesc)(unsafe.Pointer(&ev.data)) = pd

pd := *(**pollDesc)(unsafe.Pointer(&ev.data))

显然,runtime 使用 epoll_data(&ev.data) 直接存储了 fd 对应结构体(pollDesc)的指针,这样在事件触发时,可以直接找到结构体对象,并执行相应逻辑。而对外版本则由于只能获得封装后的 Fd 参数,因此需要引入额外的 Map 来增删改查结构体对象,这样性能肯定相差很多。

所以我们果断抛弃了 syscall.EpollWait,转而仿照 runtime 自行设计了 EpollWait 调用,同样采用 unsafe.Pointer 存取结构体对象。测试表明,该方案下 吞吐量 ↑10% ,TP99 ↓10% ,获得了较为明显的收益。

Thrift 序列化/反序列化优化

序列化是指把数据结构或对象转换成字节序列的过程,反序列化则是相反的过程。RPC 在通信时需要约定好序列化协议,client 在发送请求前进行序列化,字节序列通过网络传输到 server,server 再反序列进行逻辑处理,完成一次 RPC 请求。Thrift 支持 Binary、Compact 和 JSON 序列化协议。目前公司内部使用的基本都是 Binary,这里只介绍 Binary 协议。

Binary 采用 TLV 编码实现,即每个字段都由 TLV 结构来描述,TLV 意为:Type 类型, Lenght 长度,Value 值,Value 也可以是个 TLV 结构,其中 Type 和 Length 的长度固定,Value 的长度则由 Length 的值决定。TLV 编码结构简单清晰,并且扩展性较好,但是由于增加了 Type 和 Length,有额外的内存开销,特别是在大部分字段都是基本类型的情况下有不小的空间浪费。

序列化和反序列的性能优化从大的方面来看可以从空间和时间两个维度进行优化。从兼容已有的 Binary 协议来看,空间上的优化似乎不太可行,只能从时间维度进行优化,包括:

  1. 减少内存操作次数,包括内存分配和拷贝,尽量预分配内存,减少不必要的开销;

  2. 减少函数调用次数,比如可调整代码结构和 inline 等手段进行优化;

调研

根据 go_serialization_benchmarks 的压测数据,我们找到了一些性能卓越的序列化方案进行调研,希望能够对我们的优化工作有所启发。

通过对 protobuf、gogoprotobuf 和 Cap'n Proto 的分析,我们得出以下结论:

  1. 网络传输中出于 IO 的考虑,都会尽量压缩传输数据,protobuf 采用了 Varint 编码在大部分场景中都有着不错的压缩效果;

  2. gogoprotobuf 采用预计算方式,在序列化时能够减少内存分配次数,进而减少了内存分配带来的系统调用、锁和 GC 等代价;

  3. Cap'n Proto 直接操作 buffer,也是减少了内存分配和内存拷贝(少了中间的数据结构),并且在 struct pointer 的设计中把固定长度类型数据和非固定长度类型数据分开处理,针对固定长度类型可以快速处理;

从兼容性考虑,不可能改变现有的 TLV 编码格式,因此数据压缩不太现实,但是 2 和 3 对我们的优化工作是有启发的,事实上我们也是采取了类似的思路。

思路

减少内存操作

buffer 管理

无论是序列化还是反序列化,都是从一块内存拷贝数据到另一块内存,这就涉及到内存分配和内存拷贝操作,尽量避免内存操作可以减少不必要的系统调用、锁和 GC 等开销。

事实上 KiteX 已经提供了 LinkBuffer 用于 buffer 的管理,LinkBuffer 设计上采用链式结构,由多个 block 组成,其中 block 是大小固定的内存块,构建对象池维护空闲 block,由此复用 block,减少内存占用和 GC。

刚开始我们简单地采用 sync.Pool 来复用 netpoll 的 LinkBufferNode,但是这样仍然无法解决对于大包场景下的内存复用(大的 Node 不能回收,否则会导致内存泄漏)。目前我们改成了维护一组 sync.Pool,每组中的 buffer size 都不同,新建 block 时根据最接近所需 size 的 pool 中去获取,这样可以尽可能复用内存,从测试来看内存分配和 GC 优化效果明显。

string / binary 零拷贝

对于有一些业务,比如视频相关的业务,会在请求或者返回中有一个很大的 Binary 二进制数据代表了处理后的视频或者图片数据,同时会有一些业务会返回很大的 String(如全文信息等)。这种场景下,我们通过火焰图看到的热点都在数据的 copy 上,那我们就想了,我们是否可以减少这种拷贝呢?

答案是肯定的。既然我们底层使用的 Buffer 是个链表,那么就可以很容易地在链表中间插入一个节点。