点击查看美团、百度、蚂蚁技术专家的软件质量保障技术实践>> 了解详情
写点什么

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

字节跳动视频云技术团队

  • 2023-03-05
    北京
  • 本文字数:4866 字

    阅读完需:约 16 分钟

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

介绍


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


分析方法


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



然后通过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 ,我们的性能就能有较大的提升了。



参数指定了要读取或者发送的数据包的数量,以及一些设置标志,接收还有个超时时间的参数,这个参数有点坑,后面会讲到。这两个系统调用其实是属于 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 一点内存。



允许小于 64K 的 buffer 一次性写入且不会分片,会按照 gsoSize 进行分成多个 IP 包。根据 Google 的测试数据,性能提升能达到 1.7 倍左右 [2] 。不过在一次写入的数据量较少的情况下是比较难以利用起来的。比如直播的码率不高的情况下,单链接能写入的数据量是有限的。不过在码率较低的直播流,一次写入的数据较少的情况下 GSO 效果不是特别明显。在高码率下这个还是能带来很大的提升的,值得尝试。如果可以的话,也是可以聚合下多次的数据来提高 GSO 写入数据量。不过由于 GSO 是等分,所以在如何让每一个 UDP 包等大上比较麻烦。


内存分配

包相关的内存分配



比如像上面的 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 。



其中 rawBuffer 是真正分配的内存区域。 Data 指向 rawBuffer 中数据包的位置, SockAddr 指向 rawBuffer 中相应的位置, Oob 同理。这样可以减少多次 sync.Pool 的调用和内存分配次数。


interface


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



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



举个例子:


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


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



测试结果



锁开销


在我们引入了一批 96C 的服务器后,发现性能并没有提升,反而有所下降。通过 perf top 可以发现__raw_caller_save___pv_queued_spin_unlock 和trigger_load_blance的 CPU 占用比较高。根据测试, 88 核机器和 44 核机器的服务能力差不多一致。所以可以认为 88 核机器改造成两台 44 核的性能提升能有一倍。推荐机器虚拟化/容器化为多个小核心数的机器或者多进程模式,这样能够更好的利用机器资源。包括对于两颗及以上 CPU 的机器,多进程分别绑定到每颗也能有较可观的提升,可以减少 CPU 间切换的代价。绑定可以通过 numactl 来进行控制。


zerocopy



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 一个处理循环


详细实现和效果


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



  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 ,分发策略一般也是不需要动的。


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


  1. 前面两步的 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…

2023-03-05 10:402037
用户头像
鲁冬雪 InfoQ 资深编辑

发布了 111 篇内容, 共 47.7 次阅读, 收获喜欢 115 次。

关注

评论 1 条评论

发布
用户头像
干货挺多的
2023-03-06 16:35 · 上海
回复
没有更多了
发现更多内容

三年Java开发,年底跳槽挂了阿里和字节,却收获美团offer,分享大厂面试心得

Java架构之路

Java 程序员 架构 面试 编程语言

“他者”德意志(二):“走稳路”的德国半导体

脑极体

开发者的福音,go也支持linq了

happlyfox

学习 28天写作 2月春节不断更 Go 语言

紧急寻人,还缺75万!区块链产业为何“一才难求”?

CECBC

区块链人才

第五周 第二课笔记

Geek_娴子

LeetCode题解:121. 买卖股票的最佳时机,JavaScript,动态规划,详细注释

Lee Chen

算法 大前端 LeetCode

2021最新华为面经分享:Java高分面试指南(25分类1000题50w字解析)

比伯

Java 编程 架构 面试 计算机

可能是Java Stream的最佳实践(三)

ES_her0

28天写作

Newbe.Claptrap 框架入门,第一步 —— 开发环境准备

newbe36524

Docker 云计算 分布式 微服务 .net core

Newbe.Claptrap 框架入门,第二步 —— 创建项目

newbe36524

Docker 云计算 分布式 微服务 .net core

2021最全iOS面试题及底层视频分享专栏

ios 面试 音视频 iOS底层 iOS逆向

公链,区块链的未来和归宿

CECBC

区块链

基于matlab的控制系统与仿真6-PID控制模型

AXYZdong

matlab 2月春节不断更

日记 2021年2月27日(周六)

Changing Lin

2月春节不断更

2021年金三银四Java面试突击大全,吃透这套Java真题合集,突破BAT面试官这道“防线”

Java 架构 面试

《华为技术认证HCNA网络技术实验指南》参考配置Ⅰ

依旧廖凯

华为 网络 交换机

成为阿里P7移动架构师到底有多难?带你快速通过字节跳动面试,讲的明明白白!

欢喜学安卓

android 程序员 面试 移动开发

第五周作业

Ashley.

备战金三银四必备:2021最新Java面试汇总(附答案解析)

比伯

Java 编程 架构 面试 计算机

最新“美团+字节+腾讯”三面面经,你能撑到哪一面?

Java架构之路

Java 程序员 架构 面试 编程语言

普本毕业,阿里五面成功斩下offer,名校出身的我究竟输在哪?

Java架构之路

Java 程序员 架构 面试 编程语言

第五周 第一课笔记

Geek_娴子

架构设计篇之微服务实战笔记(八)

小诚信驿站

架构 刘晓成 小诚信驿站 28天写作

诊所数字化:实施私域,诊所店员的赋能路径

boshi

数字化医疗 私域运营 七日更 28天写作

安卓开发环境!一份字节跳动面试官给你的Android技术面试指南,终获offer

欢喜学安卓

android 程序员 面试 移动开发

第五周笔记

Ashley.

金融数字化转型浪潮呼啸而来 大数据区块链落地场景全面开花

CECBC

金融

神操:凭借“阿里Java脑图”,成功斩获腾讯、蚂蚁、B站、字节、滴滴等5个Offer

Java架构师迁哥

Nginx入门的基本使用和配置详解

数据库 架构 运维

Golang 在基于 UDP 的用户态协议的性能提升_语言 & 开发_InfoQ精选文章