本文选自“字节跳动基础架构实践”系列文章。
“字节跳动基础架构实践”系列文章是由字节跳动基础架构部门各技术团队及专家倾力打造的技术干货内容,和大家分享团队在基础架构发展和演进过程中的实践经验与教训,与各位技术同学一起交流成长。
KiteX 自 2020.04 正式发布以来,公司内部服务数量 8k+,QPS 过亿。经过持续迭代,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) 拆分为