Golang 在基于 UDP 的用户态协议的性能提升

avatar
技术运营 @北京字节跳动科技有限公司

介绍

随着近年来直播、短视频、在线会议等音视频相关应用愈发普及,对用户体验的要求也随之提高。为了实现把声音和画面送达到用户面前就需要通过网络来分发,所以如何在各种网络环境下提高网络的效率,从而提高用户体验就变得很重要。如果使用基于TCP的协议来分发音视频数据,不能较大程度地满足用户体验上的需求,比如在直播场景下,播放快、卡顿少、延迟低等问题。我们可以通过减少建联时间,减少重传等手段来优化体验。所以基于UDP构建传输协议的需求越来越多,比如quic,rtp。其次随着云服务的兴起,golang也逐渐在很多地方使用了起来。在我们直播CDN场景下,流媒体服务器需要承担较高的流量,golang+udp这个组合在我们内部实践中会遇到性能不满足要求的情况,所以本文会分享如何提高用golang实现的基于UDP的传输协议的性能提升。

分析方法

早期,我们的流媒体服务器在基于UDP的可靠传输协议下,我们的服务器能承受的带宽远低于TCP几倍,导致了基于UDP的协议无法大面积应用。所以便有了着手提升相关性能的需求,主要是通过分析程序性能火焰图,操作系统各项指标(如锁,软中带,负载分布等)来确定性能低的原因。 下面会介绍下常用的几个工具。 pprof是golang自带的一款性能分析工具,由于其相当的简单好用,所以不管是在性能分析,还是bug定位等场景出现的频率都很高。 要是用pprof,比较简单的一个方法是通过引入net/http/pprof 包,会自动嵌入到默认http服务器里,如果服务没有http服务可以通过在goroutine里创建一个。

   1 import _ "net/http/pprof"
   2 go func() {
   3   _ = http.ListenAndServe(":5601", nil)
   4 }()

然后通过127.0.0.1:5601/debug/pprof这个地址就 可以获取到。 当然golang也提供了可视化的工具,go tool pprof -http=:5555 http://127.0.0.1:5601/debug/pprof/profile 可以从网页里以火焰图的方式分析各部分开销占比,然后针对性的制定我们的优化方法。 对于操作系统的一些开销,通过pprof我们是捕捉不到的,可能得借助一些其他的工具来分析。

比较常用的就是top、perf、sar、ss、ethtool等。

perf可以比较方便的统计出比如CPU开销,cache miss等。top可以比对分析进程和总CPU的占用,软中断等的占用。sar可以统计网卡PPS等。ss和ethtool可以用于诊断socket和网卡的一些信息。 通过pprof,可以比较容易的得出CPU开销主要在内存分配,系统调用,runtime,内存拷贝几部分。

下面会分享一些有用和没用的手段。

系统调用

mmsg

收发数据包的系统调用的占比比较大,这两个系统调用正好提供了在一次系统调用下收发多个包,极大的降低系统调用的数量级,所以用好这两个API,我们的性能就能有较大的提升了。

1 int recvmmsg(int sockfd, struct mmsghdr *msgvec, unsigned int vlen,
2                    int flags, struct timespec *timeout);
3 
4 int sendmmsg(int sockfd, struct mmsghdr *msgvec, unsigned int vlen,
5                   int flags);

参数指定了要读取或者发送的数据包的数量,以及一些设置标志,接收还有个超时时间的参数,这个参数有点坑,后面会讲到。 这两个系统调用其实是属于recvmsg/sendmsg的封装,大致类似于for循环里调用sendmsg/recvmsg。

sendmmsg

sendmmsg的主要点在于如何聚合更多的包在一次系统调用中发送,可以选择把多个session的发送数据包进行聚合,那么聚合数量就可以大大提高。但是这样的话,会对质量有一定的影响,因为聚合的话可能得花一点时间来聚合足够多的数据包。

recvmmsg

socket,属于noneblock socket,在recvmmsg的时候会出现读取到很少量的东西就返回了,因为当没有东西可读的时候会返回EAGAIN,会导致系统调用直接返回,需要等待一会。但是当使用自己创建的block的socket的时候,如果没制定none block,会出现未读满指定数量的包是不会返回的,即使设置了超时时间,导致前面的包一直得不到处理,这个情况下我们可以使用MSG_WAITFORONE这个flag。 在CDN场景下,主要是下行压力,recvmmsg的主要特点在于可靠传输协议的ack数据包上。如果可以,我们可以考虑降低ack的数量比,从而减少recvmmsg的系统调用开销和中断开销。

效果

从给出的测试性能数据来看,在一次发送64个数据包的情况下能够提高20%的性能**[1]** 。这个测试有一定局限性,在实际应用场景下提升还是远远超过这个值的。

实际在服务器配置和网卡更好的情况下,首先可以提高mmsg的数量,达到128、256等。在协议栈应用上能提升一倍的效果。

GSO

GSO的全称是(Generic Segmentation Offload),他还有另外一个兄弟GRO。由于MTU的限制,所以对于UDP的写入,如果写入的数据超过MTU大小,且没有禁用IP分片,那么将会被进行IP分片,但是IP分片是不利于做可靠传输协议的,因为丢包成本太高了,丢一个IP包就等于丢了所有。前面我们提到减少系统调用,如果使用GSO的话也是可以的,我们可以一次写入更大的buffer来达到减少系统调用的目的,不过这个有个前提是每次需要写入的数据足够大,且对内核版本要求较高。另外GSO除了可以一次写入较大buffer,在支持的GSO offload的网卡还有相应的硬件加速,可以通过ethtool -K eth0 tx-udp-segmentation on来开启。

GSO可以通过两种方式去使用,一种是设置socket option开启,一种是通过oob去对msg级别设置。 一般通过oob的方式去进行设置,因为这样比较灵活一些,缺点的话就是会多copy一点内存。

1 // socket级别
2 setsockopt(fd, SOL_UDP, UDP_SEGMENT, &gsoSize, sizeof(gsoSize))
3 
4 // msg级别
5 type ctlMsgHdr struct {
6  Len   uint64
7  Level int32
8  Type  int32
9 }
10 
11 hdr := (*ctlMsgHdr)(unsafe.Pointer(&b[0]))
12 hdr.Len = 2
13 hdr.Level = SocketLevelUDP
14 hdr.Type = SocketTypeUDPSegment
15 binary.LittleEndian.PutUint16(b[ctlMsgHdrSize:ctlMsgHdrSize+2], uint16(gsoSize))

允许小于64K的buffer一次性写入且不会分片,会按照gsoSize进行分成多个IP包。

根据Google的测试数据,性能提升能达到1.7倍左右[2]。不过在一次写入的数据量较少的情况下是比较难以利用起来的。比如直播的码率不高的情况下,单链接能写入的数据量是有限的。

不过在码率较低的直播流,一次写入的数据较少的情况下GSO效果不是特别明显。

在高码率下这个还是能带来很大的提升的,值得尝试。

如果可以的话,也是可以聚合下多次的数据来提高GSO写入数据量。不过由于GSO是等分,所以在如何让每一个UDP包等大上比较麻烦。

内存分配

包相关的内存分配

1 int recvmmsg(int sockfd, struct mmsghdr *msgvec, unsigned int vlen,
2                     int flags, struct timespec *timeout);
3 
4 struct iovec {                    /* Scatter/gather array items */
5     void  *iov_base;              /* Starting address */
6     size_t iov_len;               /* Number of bytes to transfer */
7 };
8 struct msghdr {
9      void         *msg_name;       /* Optional address */
10     socklen_t     msg_namelen;    /* Size of address */
11     struct iovec *msg_iov;        /* Scatter/gather array */
12     size_t        msg_iovlen;     /* # elements in msg_iov */
13     void         *msg_control;    /* Ancillary data, see below */
14     size_t        msg_controllen; /* Ancillary data buffer len */
15    int           msg_flags;      /* Flags on received message */
16 };

比如像上面的recvmmsg系统调用,我们需要为本次系统调用准备内存空间,iovec结构,msghdr结构等等。对于一个几百万PPS的协议栈来说,上面的内存肯定不能每次都分配,我们必须得采取复用。

对于iovec这样的结构,完全可以每次系统调用复用同一块内存,把对应的值copy过去就行。

对于目的地址,oob等,我们也可以与分配并存储在session的内存里,直接传他的地址就行。oob的话,可能每次传递的都不一样,我们可以准备多份,比如是否使用GSO,选择需要的那一份就行。

像 golang 的 github.com/golang/net/… 提供的recvmmsg API的话由于没有处理内存分配的问题,所以不适合直接使用。

数据包的buffer这个也是毋庸置疑需要复用的,简单点的场景我们可以使用sync.Pool。这个buffer我们可以和sockaddr、oob等的buffer复用同一片buffer。

1 type Buffer struct {
2    ref       int64
3    rawBuffer []byte
4    Data      []byte
5    SockAddr  []byte
6    Oob       []byte
7 }

其中rawBuffer是真正分配的内存区域。

Data指向rawBuffer中数据包的位置,SockAddr指向rawBuffer中相应的位置,Oob同理。

这样可以减少多次sync.Pool的调用和内存分配次数。

interface

在golang里,interface(指empty interface)由下面的struct表示。

1 type eface struct {
2    _type *_type
3    data  unsafe.Pointer
4 }

当把一个具体类型的变量转换为interface类型时,如果这个值是copy类型,那么需要给他分配空间然后拷贝过去,所以这是一个潜在的内存分配case。下面的代码展示把64位的整数转换到interface,最近的版本增加了优化,对于小于256的数字提前预分配好。

1 func convT64(val uint64) (x unsafe.Pointer) {
2   if val < uint64(len(staticuint64s)) {
3       x = unsafe.Pointer(&staticuint64s[val])
4    } else {
5       x = mallocgc(8, uint64Type, false)
6       *(*uint64)(x) = val
7    }
8    return
9 }

举个例子:

1 log.Debug(formats string, v ...interface)

像上面的那个debug的log函数,虽然在线上的日志级别不会执行,但是由于参数是interface,所以会涉及到类型转换,在大量出现调用的情况下就会有很大的内存分配开销。下面是一个简单场景测试,说明下问题。

1 var showDebug bool
2 
3 func debug(format string, args... interface{}) {
4    if showDebug {
5       fmt.Println(format, args)
6    }
7 }
8 
9 func BenchmarkWithInterface(b *testing.B) {
10    for i := 0; i < b.N; i++ {
11       debug("", 1)
12    }
13 }
14 
15 func BenchmarkNoneInterface(b *testing.B) {
16    for i := 0; i < b.N; i++ {
17       if showDebug  {
18          debug("", 1)
19       }
20    }
21 }

测试结果

1 BenchmarkWithInterface
2 BenchmarkWithInterface-12            50442586                23.3 ns/op
3 PASS
4 
5 BenchmarkNoneInterface
6 BenchmarkNoneInterface-12            1000000000                 0.343 ns/op
7 PASS

锁开销

在我们引入了一批96C的服务器后,发现性能并没有提升,反而有所下降。

通过perf top可以发现__raw_caller_save___pv_queued_spin_unlocktrigger_load_blance的CPU占用比较高。

根据测试,88核机器和44核机器的服务能力差不多一致。所以可以认为88核机器改造成两台44核的性能提升能有一倍。

推荐机器虚拟化/容器化为多个小核心数的机器或者多进程模式,这样能够更好的利用机器资源。

包括对于两颗及以上CPU的机器,多进程分别绑定到每颗也能有较可观的提升,可以减少CPU间切换的代价。绑定可以通过numactl来进行控制。

zerocopy

1 if (setsockopt(fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one)))
2         error(1, errno, "setsockopt zerocopy");
3         
4 ret = send(fd, buf, sizeof(buf), MSG_ZEROCOPY);
5 
6 ret = recvmsg(fd, &msg, MSG_ERRQUEUE);
7 if (ret == -1)
8         error(1, errno, "recvmsg");

UDP的ZEROCOPY在5.0内核也支持上了,zerocopy的API主要两部分组成,一个发送时声明使用zerocopy,另一个是接收内存可回收的回调。

但是对于小数据包的ZEROCOPY,性能反而会负向,因为会多产生一次系统调用。并且在UDP下并不能在一次MSG里传送超过MTU的数据包,即使开启GSO,也不过64K,从Google给出的数据来看,有13%的提升 [3]

根据我们的测试,在GSO数量较小的情况下,性能负向还挺多的。这个特性使用起来也有一定的复杂性,所以不建议使用,可以更多的考虑下UIO相关的技术。

RPS

我们接入了一批核心数比较高的虚拟机后,发现负载并没有上升。通过观察CPU负载,发现只有少数核心负载比较高。在查询资料后了解到这可能是网卡接收队列数和CPU核心数不匹配,导致接收队列的软中断都调用到前面的CPU核心上了。

通过ethtool可以查看网卡队列的数量,如果和CPU核心数量不匹配,目前解决方法有两个,一个是要求机器的核心数和网卡队列要匹配;另一个较次一点的方案是配置RPS,RPS可以让一个队列绑定到指定的CPU核心上。这个实际上会带来一部分的额外CPU开销,因为他的实现原理是收到软中断后再分发到其他的CPU核心上,不过好处是可以把空置的CPU核心利用起来。

RPS通过/sys/class/net/eth0/queues/rx-[n]/rps_cpus 这个文件来进行配置,文件内容代表每个网卡队列到CPU核心的映射关系,每一个二进制位为1代表可映射,以16进制字符串保存。

IO模型

关键手段

  • reuseport
  • 原生socket
  • 无锁队列
  • 每个CPU一个处理循环

详细实现和效果

在做性能提升工作期间,也摸索了一些代码结构模型相关的东西。可以作为一个参考,每个项目自身不一样,所以并不一定适合。

1234.jpeg

  1. 直接通过syscall创建socket,因为golang的API创建的socket是绑定了epoll的,在我们的场景下socket的数量是一定且少量的,不需要IO多路复用来提高吞吐,这样能够避免掉epoll带来的CPU开销。设置socket为非阻塞的,这样才可以在一个goroutine处理收和发。 对于创建的socket,通过使用reuseport来负载均衡到所有CPU核心上,提高机器的利用率。现在的网卡一般都是多队列的,所以是能够做到分发到多个核心上的。分发策略一般通过ethtool -N eth0 rx-flow-hash udp4 sdfn来设置,具体设置项可以参考man page,分发策略一般也是不需要动的。

  2. goroutine来处理链接。每个这样的goroutine处理一组链接的收包,发包,超时等,也可以比较方便的汇聚多个链接的数据调用一次系统调用。协议栈和应用层,数据包收发层通过无锁队列传递数据,采用这样的结构设计主要目的是为了减少golang调度的触发和锁的开销。goroutine的数量不随链接增加,也可以减少调度的压力。

  3. 前面两步的goroutine可以调用runtime.LockOSThread把自己送入调度器全局队列来提高调度优先级,避免调度延迟。

总结

在用golang来实现的协议栈,性能问题可以分为两块:语言层面和系统层面。语言层面内存分配,和rutime的开销一般比较多。系统层面系统调用和锁的开销比较多。像开源的quic-go就会有这些问题,需要我们逐步去分析并解决。

在QUIC和HTTP3的趋势下,内核在UDP方面的功能和性能相关工作也开始增多,相信也会有更多的方法来提高性能。不过个人觉得Golang确实不太适合在高性能,高调度精度等场景,GC和内存分配的不可控性,与操作系统的抽象隔离也会在这些场景下限制发挥。

现在服务器配置也越来越高,核心数量多,代码就容易出现锁竞争激烈的情况,导致额外开销大幅增加。所以容器化、虚拟化等技术把机器变为核心数较小的多实例模式也是很有价值的。

参考文献

[1]lwn.net/Articles/44…

[2]vger.kernel.org/lpc_net2018…

[3]patchwork.ozlabs.org/project/net…

[4]lwn.net/Articles/75…

[5]lwn.net/Articles/35…