性能无疑是服务器的核心能力,几乎每个开源服务器的介绍都是”高性能XXX服务器“。视频服务器由于业务的超复杂度,特别是WebRTC服务器,要做到高性能是非常有挑战的难点。
为何性能很重要?完备的功能需要用性能交换,安全性需要用性能交换,成本需要用性能交换,产品体验需要用性能交换,甚至系统弹性都需要性能交换。有了基础性能,就有了竞争力的资本;基础性能若有问题,举步维艰,想要干点啥都不容易,就像天生羸弱的身子板。
SRS虽然是单进程单线程模型,性能一直都很高,比如:
- 单进程能跑满千兆或万兆网卡,一般的场景完全能覆盖。
- 性能是NginxRTMP或Janus的三倍左右,目前还没有更高性能的开源同类产品。
- 提供集群能力,水平扩展性能,在开源项目中也不多见。
这并不是终点,这个性能基准只够5年左右,随着业务发展一定会有更高性能的需求。目前服务器属于第二代高并发架构,也就是单线程架构:
- 第一代高并发架构,1990~2010年,多线程架构,一般比较老的服务器都是这种架构,一般无法解决C10K问题,比如Adobe AMS,Apache HTTP Server,Janus WebRTC Server。核心问题是多线程的水平扩展性问题,并发越多,线程之间的同步和竞争开销就越大(这个问题也是现代语言Go在性能方面的硬伤,特别是在超多CPU比如64核或128核时,多线程的损耗会更大)。
- 第二代高并发架构,2010~2020年,单线程架构,多进程或单进程单线程都是这种架构,C10K问题得到比较好的解决,比如Nginx,SRS,MediaSoup。核心问题是单线程引入的异步回调问题,新的语言比如Go引入轻量线程goroutine(协程)解决这个问题,老的语言比如C++20、JS await等都有对应的机制。另外多进程的进程间通信也会引入额外复杂性,比如直播和RTC的流的跨进程回源和拉流问题。
- 第三代高并发架构,隔离的多线程架构,比如云原生的数据面反向代理Envoy线程模型,还有比Nginx更高性能的反向代理么,这就是Envoy了,如下图所示。其实Envoy和Nginx都是事件驱动,但是Envoy是完全非阻塞。而Envoy的多线程实际上和第一代的多线程也不同,线程之间几乎没有交互,可以看作是隔离的进程。由于是线程,所以它们之间的(少量)通信也很容易。同样,多线程也可以和轻量线程结合使用。
SRS目前还属于第二代架构,第三代架构验证过是可行的(#2188),由于目前SRS性能还不是短板,所以没有合并到主干分支,有需要可以自己合并feature/threads。目前SRS的性能数据如下:
SFU | Clients | CPU | Memory | 线程 | VM |
---|---|---|---|---|---|
SRS | 4000 players | ~94% x1 | 419MB | 1 | G5 8CPU |
NginxRTMP | 2400 players | ~92% x1 | 173MB | 1 | G5 8CPU |
SRS | 2300 publishers | ~89% x1 | 1.1GB | 1 | G5 8CPU |
NginxRTMP | 1300 publishers | ~84% x1 | 198MB | 1 | G5 8CPU |
SFU | Clients | CPU | Memory | 线程 | VM |
---|---|---|---|---|---|
SRS | 1000 players | ~90% x1 | 180MB | 1 | G5 2CPU |
Janus | 700 players | ~93% x2 | 430MB | 24 | G5 2CPU |
SRS | 950 publishers | ~92% x1 | 132MB | 1 | G5 2CPU |
Janus | 350 publishers | ~93% x2 | 405MB | 23 | G5 2CPU |
Note: CentOS7, 600Kbps, ECS/G5-2.5GHZ(SkyLake), SRS/v4.0.105, NginxRTMP/v1.2.1。虽然系统有8CPU但只能使用单个CPU,选择8CPU是因为只有8CPU的内网带宽才能到10Gbps。
当我们提到性能,一般隐含条件是“满足业务场景的体验”下的性能优化,比如直播要求卡顿率低、延迟在3秒之内,比如WebRTC要求端到端延迟400ms之内(服务器0延迟),一般现在服务器的内存可以是4GB、8GB、16GB、32GB或64GB,这意味着我们可以尽量用内存Cache来降低CPU运算。
SRS | Protocol | VP6 | H.264 | VP6+MP3 | H.264+MP3 |
---|---|---|---|---|---|
2.0.72 | RTMP | 0.1s | 0.4s | 0.8s | 0.6s |
2.0.70 | RTMP | 0.1s | 0.4s | 1.0s | 0.9s |
1.0.10 | RTMP | 0.4s | 0.4s | 0.9s | 1.2s |
4.0.87 | WebRTC | x | 80ms | x | x |
Note: 在音视频服务器的性能优化中,延迟是必须要考虑的一个因素,特别是在RTC服务器,性能优化不能以增大延迟为方法。
性能基准
如果没有压测能力,就无法优化性能。
SRS的基准是并发流,比如使用srs-bench推流可以获得支持的最高推流(发布)并发,和最高拉流(播放)并发。压测工具一般读取文件,可以选择典型的业务场景,录制成样本文件,这样压测可以尽量模拟线上场景。
性能优化前,必须使用压测获得目前的性能基准,分析目前的性能瓶颈和优化思路,然后修改代码获得新的性能基准,如此反复不断提升性能。如下图所示:
上图就是性能分析的主面板,左三右二加一个浏览器:
- 左上:服务器的top图,命令是
top -H -p $(cat objs/srs.pid)
,看CPU和内存。还有每个CPU的消耗情况(进入top后按数字1),比如us是用户空间函数,sy是内核函数,si是网卡软中断。 - 左中:系统的网络带宽图,命令是
dstat
,看出入口带宽。比如视频平均码率是600kbps,那么900个推流时,网卡的recv流量应该是600*900/8000.0KBps
也就是67.5MBps
,如果网卡吞吐率达不到预期,那么肯定会出现卡顿等问题,比如可能是系统的网卡队列缓冲区太小导致丢包。 - 左下:服务器关键日志,命令是
tail -f objs/srs.log |grep -e 'RTC: Server' -e Hybrid
,查看RTC的连接数和关键日志,以及进程的CPU等信息。如果连接数达不到预期,或者CPU接近100%,也是有问题的。 - 右上:服务器热点函数列表,命令是
perf top -p $(cat objs/srs.pid)
,可以看到当前主要的热点函数列表,以及每个函数所占用的百分比。性能优化一般的思路,就是根据这个表,优化掉排名在前面的热点函数。 - 右下:压测客户端的top图,如果压测服务器的CPU满载,也一样达不到预期,会出现卡顿等情况。同样也需要先检查系统的网卡队列缓冲区,避免系统丢包。
- 浏览器:在浏览器中播放流,比如webrtc://8.126.115.13:1985/live/livestream100?eip=8.126.115.13,可以通过eip指定外网ip,这样压测工具可以推内网地址,而浏览器观看可以看外网的地址。浏览器观看可以随机抽查某个流,判断是否播放流畅,声音和画面是否正常等。
Note: 我们在左上的图中,截图时加上了标注,可以更快的看出这个性能图的摘要,比如
ECS/C5 2CPU
说明是ECS的C5机型一共是2个CPU,900 publish streams
是RTC推流一共900个并发流。
工具链
没有工具链就无法做性能优化,前一章我们分享了压测工具srs-bench,查看网络带宽工具dstat
,查看热点函数工具perf
,查看CPU工具top
。
还有一些工具链,总结在SRS性能(CPU)、内存优化工具用法,我们挑一些和性能优化相关的工具重点介绍。包括:
-
sysctl
:修改内核UDP缓冲区,防止内核丢包。 -
GPERF: GCP
:使用GCP分析热点函数的调用链,图形化展示。 -
taskset
:进程绑核后,避免软中断干扰,便于查看数据。
对于RTC,很重要的是需要把内核协议栈的缓冲区改大,默认只有200KB,必须改成16MB以上,否则会导致丢包:
sysctl net.core.rmem_max=16777216
sysctl net.core.rmem_default=16777216
sysctl net.core.wmem_max=16777216
sysctl net.core.wmem_default=16777216
可以直接修改文件/etc/sysctl.conf
,重启也能生效:
# vi /etc/sysctl.conf
net.core.rmem_max=16777216
net.core.rmem_default=16777216
net.core.wmem_max=16777216
net.core.wmem_default=16777216
如果perf热点函数比较通用,比如是malloc
,那我们可能需要分析调用链路,看是哪个执行分支导致malloc
热点,由于SRS使用的协程,perf
无法正确获取堆栈,我们可以用GPERF: GCP
工具:
# Build SRS with GCP
./configure --with-gperf --with-gcp && make
# Start SRS with GCP
./objs/srs -c conf/console.conf
# Or CTRL+C to stop GCP
killall -2 srs
# To analysis cpu profile
./objs/pprof --text objs/srs gperf.srs.gcp*
图形化展示时,需要安装依赖graphviz
:
yum install -y graphviz
然后就可以生成SVG图,用浏览器打开就可以看了:
./objs/pprof --svg ./objs/srs gperf.srs.gcp >t.svg
还可以使用taskset
绑定进程到某个核,这样避免在不同的核跳动,和软中断跑在一个核后干扰性能,比如一般软中断会在CPU0,我们绑定SRS到CPU1:
taskset -pc 1 $(cat objs/srs.pid)
Note:如果是多线程模式,可以增加参数
-a
绑定所有线程到某个核,或者在配置文件中,配置cpu_affinity
指定线程的核。
内存交换性能
现代服务器的内存都很大,平均每个核有2GB内存,比如:
- ECS ecs.c5.large, 2CPU 4GB 内存,1Gbps内网带宽。
- ECS ecs.c5.xlarge, 4CPU 8GB 内存,1.5Gbps内网带宽。
- ECS ecs.c5.2xlarge, 8CPU 16GB 内存,2.5Gbps内网带宽。
还有其他型号的,比如G5每个核是4GB内存,比如R5更是每个核高达8GB内存。这么多内存,对于无磁盘缓存型的网络服务器,直播转发或者SFU转发,一般内存是用不了这么多的,收包然后转发,几乎不需要缓存很久的数据。
因此,线上的视频服务器一般内存都是很充足的,有些情况下可以用内存来优化性能的地方,就可以果断的上内存缓存(Cache)策略。
比如,在直播播放时,SRS有个配置项叫合并写入(发送):
vhost __defaultVhost__ {
play {
# Set the MW(merged-write) min messages.
# default: 0 (For Real-Time, min_latency on)
# default: 1 (For WebRTC, min_latency off)
# default: 8 (For RTMP/HTTP-FLV, min_latency off).
mw_msgs 8;
}
}
如果是非低延迟(默认)模式是8,也就是收到了8个音视频包后,才会转发给播放器。关键代码如下:
srs_error_t SrsRtmpConn::do_playing(SrsLiveSource* source, SrsLiveConsumer* consumer, SrsQueueRecvThread* rtrd)
{
mw_msgs = _srs_config->get_mw_msgs(req->vhost, realtime);
mw_sleep = _srs_config->get_mw_sleep(req->vhost);
while (true) {
consumer->wait(mw_msgs, mw_sleep);
if ((err = consumer->dump_packets(&msgs, count)) != srs_success) {
return srs_error_wrap(err, "rtmp: consumer dump packets");
}
if (count > 0 && (err = rtmp->send_and_free_messages(msgs.msgs, count, info->res->stream_id)) != srs_success) {
return srs_error_wrap(err, "rtmp: send %d messages", count);
}
如果是25fps,那么8个包大约是在320ms,考虑音频包大约是160ms延迟,这个队列的额外延迟在直播中也是可以接受的。
如果是8个包一次发送,按照平均码率1Mbps,差不多是300Mb也就是40KB的数据。如果按照峰值5Mbps码率计算,那就是一次发送200KB的数据。我们可以用writev一次发送这些数据,就可以极大的提高分发的性能了。
每个连接我们需要的内存按照1MB来计算,那么4000个连接需要4GB内存。如果是7000个连接,需要7GB的内存。可以认为直播分发的性能优化,是典型的内存(加少量延迟)来换更低的CPU使用。
Note: 当然服务器引入额外的160ms延迟对于RTC场景就是不可以接受的,只能在直播中使用这种优化。
Note: RTC的UDP发送是否能使用类似的优化?我们调研过UDP/sendmmsg和UDP/GSO是可以提升一部分,但是由于UDP每个连接可合并发送的数据很少,目前压测分析热点也不在这里,所以优化有限,目前SRS并没有做这两个优化。
查找优化
STL的vector和map的查找算法,已经优化得很好了,实际上还是会成为性能瓶颈。
比如,RTC由于实现了端口复用,需要根据每个UDP包的五元组(或其他信息),查找到对应的Session处理包;Session需要根据SSRC找到对应的track,让track处理这个包。
比如,SRS的日志是可追溯的,打印时会打印出上下文ID,可以将多个会话的日志分离。这个Context ID是存储在全局的map中的,每次切换上下文需要根据协程ID查找出对应的上下文ID。
如果每个包都需要这么运算一次,那开销也是相当可观的。考虑根据UDP包查找Session,如下图:
int SrsUdpMuxSocket::recvfrom(srs_utime_t timeout)
{
nread = srs_recvfrom(lfd, buf, nb_buf, (sockaddr*)&from, &fromlen, timeout);
getnameinfo((sockaddr*)&from, fromlen, (char*)&address_string, 64, (char*)&port_string
peer_ip = std::string(address_string);
peer_port = atoi(port_string);
srs_error_t SrsUdpMuxListener::cycle()
{
while (true) {
SrsUdpMuxSocket skt(lfd);
int nread = skt.recvfrom(SRS_UTIME_NO_TIMEOUT);
err = handler->on_udp_packet(&skt);
srs_error_t SrsRtcServer::on_udp_packet(SrsUdpMuxSocket* skt)
{
string peer_id = skt->peer_id();
ISrsResource* conn = _srs_rtc_manager->find_by_id(peer_id);
session = dynamic_cast<SrsRtcConnection*>(conn);
ISrsResource* SrsResourceManager::find_by_id(std::string id)
{
map<string, ISrsResource*>::iterator it = conns_id_.find(id);
return (it != conns_id_.end())? it->second : NULL;
}
这个逻辑有几个地方会有热点,通过压测可以在perf上看到:
- 每个UDP包都调用
getnameinfo
将sockaddr转成字符串的ip:port
,也就是地址标识,会有大量的string开辟和释放。 - 每个UDP包都需要根据
ip:port
,在map中查找出对应的Session(Resource或Conneciton),字符串查找的速度是很慢的。
改进其实也容易,查找时不转成string,而是生成uint64_t的地址,目前支持的是IPv4地址只需要6字节就可以表达ip:port
(如果是IPv6则需要两个uint64_t),如下所示:
int SrsUdpMuxSocket::recvfrom(srs_utime_t timeout)
{
nread = srs_recvfrom(lfd, buf, nb_buf, (sockaddr*)&from, &fromlen, timeout);
sockaddr_in* addr = (sockaddr_in*)&from;
fast_id_ = uint64_t(addr->sin_port)<<48 | uint64_t(addr->sin_addr.s_addr);
srs_error_t SrsRtcServer::on_udp_packet(SrsUdpMuxSocket* skt)
{
uint64_t fast_id = skt->fast_id();
session = (SrsRtcConnection*)_srs_rtc_manager->find_by_fast_id(fast_id);
虽然解决了string查找的热点,随着并发的提升,map<key: uint64_t>
的查找也变成了热点,在perf上可以看到map的不断平衡,我们还可以改成vector查找:
ISrsResource* SrsResourceManager::find_by_fast_id(uint64_t id)
{
SrsResourceFastIdItem* item = &conns_level0_cache_[(id | id>>32) % nn_level0_cache_];
if (item->available && item->fast_id == id) {
return item->impl;
}
map<uint64_t, ISrsResource*>::iterator it = conns_fast_id_.find(id);
return (it != conns_fast_id_.end())? it->second : NULL;
}
Note: 首先我们在vector中取余查找,如果取余碰撞了(两个不同id但是取余后一样,比如1001%1000和2001%1000是一样的),那么就用map查找。
Note:快速查找,可以考虑C++11的unordered_set,实现原理是类似的。
通过不同的查找方式,string变uint64_t优化了查找速度,而更快的优化是不用map查找,直接使用数组取余就是无查找了。
当然,这样的优化,让逻辑变得复杂了。
无代码优化
当我们优化完明显的热点,优化完头部热点,会发现perf显示已经没有明显的热点,有时候有些不太明显的函数也会排在前头,比如拷贝RTP Packet:
SrsRtpPacket* SrsRtpPacket::copy()
{
SrsRtpPacket* cp = new SrsRtpPacket();
cp->header = header;
cp->payload_ = payload_? payload_->copy():NULL;
cp->payload_type_ = payload_type_;
cp->nalu_type = nalu_type;
cp->shared_buffer_ = shared_buffer_? shared_buffer_->copy2() : NULL;
cp->actual_buffer_size_ = actual_buffer_size_;
cp->frame_type = frame_type;
cp->cached_payload_size = cached_payload_size;
// For performance issue, do not copy the unused field.
cp->decode_handler = decode_handler;
return cp;
}
这个函数有啥可以优化的么?没有什么可以优化的,全都是赋值和拷贝(无深拷贝),但是在perf上它就是排名在前头。
Note: 这时候千万别怀疑perf有问题,确实热点是这个拷贝是没有错的,perf不会出错,perf不会出错,perf不会出错,千万不要把焦点挪开去优化其他函数。
并不是函数性能高,就不会成为瓶颈,有个公式如下:
性能热点 = 函数执行效率 x 函数执行次数
一般我们会优先优化函数执行效率,让函数更高效。但是我们也不能忽略了函数的执行次数,如果一个高效的函数被反复的执行,一样也会变成性能热点。这时候我们的优化思路就是:如何让代码不执行,或明显减少执行次数
。
通过分析可以发现,这个SrsRtpPacket::copy
调用点有:
- 从Publisher拷贝到每个Consumer,函数是
SrsRtcSource::on_rtp
。 - 包发送后,放到Track的NACK队列,函数是
SrsRtcRecvTrack::on_nack
和SrsRtcSendTrack::on_nack
。
上面第二个拷贝可以省略,由于每个Player都有NACK,所以可以减少一倍的调用,优化后这个热点也就不排在前头了:
srs_error_t SrsRtcRecvTrack::on_nack(SrsRtpPacket** ppkt)
{
rtp_queue_->set(seq, pkt);
*ppkt = NULL;
同样的,这个优化的代价就是增加了风险,参数也从指针,变成了指针的指针,一路都改成了指针的指针,可以犯错的概率就大太多了。
UDP协议栈
在直播优化中,我们使用writev
一次写入大量的数据,大幅提高了播放的性能。
其实UDP也有类似的函数,UDP的sendto
对应TCP的write
,UDP的sendmmsg
对应TCP的writev
,我们调研过UDP/sendmmsg是可以提升一部分性能,不过它的前提是:
- 在Perf中必须看到UDP的相关函数成为热点,如果有其他的热点比UDP更耗性能,那么上sendmmsg也不会有改善。
- 一般并发要到2000以上,UDP协议栈才可能出现在perf的热点,较低并发时收发的包,还不足以让UDP的函数成为热点。
- 由于不能增加延迟,需要改发送结构,集中发给多个地址的UDP包统一发送。这对可维护性上是比较大的影响。
还有一种优化是GSO,延迟分包。我们调研过UDP/GSO,比sendmmsg
提升还要大一些,它的前提是:
- 和
sendmmsg
一样,只有当UDP相关函数成为perf的热点,优化才有效。 - GSO只能对一个客户端的包延迟组包,所以他的作用取决于要发给某个客户端的包数目,由于RTC的实时性要求,一般2到3个比较常见。
Note: 从上图可见,开启Padding后,UDP组包效能可以提升10%左右。GSO虽然不能减少实际网络上UDP包的数目,但是可以让内核延迟到最后才组UDP包,可以把GSO发送的多个包认为是一个包,相当于减少了发送UDP包的次数。
还有一种优化的可能,就是ZERO_COPY,其实TCP的零拷贝支持得比较早,但是UDP的支持得比较晚一些。收发数据时,需要从用户空间到内核空间不断拷贝,不过之前测试没有明显收益,参考ZERO-COPY。
多线程
文章开头我们提到,第三代高并发架构,将是隔离的多线程架构,比如云原生的数据面反向代理Envoy线程模型。
我们也调研过SRS可能的多线程架构,参考#2188。和Envoy不同,SRS涉及到了TCP和UDP,API和媒体服务,级联和QoS等问题,可能的架构也比较多。
SRS 1/2/3/4一直都是单线程(第二代架构),如下图所示:
Note:这个架构的风险一直都存在,写磁盘可能是阻塞的,DNS解析可能是阻塞的,RTC无法使用多CPU的能力(直播可以用集群或REUSE_PORT扩展多核能力)。
很显然,写磁盘应该由单独线程完成,可以避免阻塞,这就是SRS 5.0使用的架构:
Note: 其实DVR和HLS也是写磁盘操作,未来也会由写磁盘的线程实现,目前还没有实现。
Note: DNS解析也是阻塞的,和写磁盘不同,DNS解析本质上是UDP请求,是可以自己实现协议解析,不需要用多线程做。
针对RTC的多核扩展能力,有一种很自然(也是改动较小)的思路,就是将更多的能力拆分到线程中。比如SRTP加解密,占用了30%的CPU,如果能拆分到独立线程肯定对并发能力有提升。比如UDP收发也可以放到独立线程,也可以避免内核UDP收发效率不高的问题。如下图所示:
Note: 这个架构是被标记为
废弃
,原因就是SRTP和UDP确实效率不高,但是Hybrid里面的QoS算法是瓶颈所在,而这部分不方便拆分多线程。
Note: 另外,就算QoS拆分成多线程,这个架构最多用到大约3~4CPU,并不能用到32或64核CPU,也就是并发能力还是受限。
Note: 最后,这种多线程架构,线程之间交互较多,所以会有锁的开销,也不算第三代服务器架构。
最终的多线程架构,是能水平扩展的多线程架构,实现的原型参考feature/threads分支,如下图所示:
Note: 我们实现的一个版本是多端口模型,通过端口分割不同的Hybrid线程,Hybrid线程之间独立不需要交互。实际上多线程之间也是可以复用同样端口的,只是切网时需要考虑新五元组的绑定。
Note: 这个架构完全解决了水平扩展的问题,也避免了线程之间需要交互数据,压测在32核C5机器上,能跑到10K左右并发(可以在更多核的机器上扩展)。
当然,最后这个架构也并非没有问题,目前看还需要解决以下问题,才能在线上使用:
- API必须非常简单,如果是Janus那种复杂的API,就无法使用这种结构。SRS目前的API是比较合适实现这种架构。API实际上承担了调度的能力。
- 直播需要改进,适配这种多线程结构。直播Edge相对比较容易改造,可以用REUSE_PORT,相当于多个进程。而直播Origin改造比较麻烦。
- 统计和API需要改造,系统的CPU使用率,告警和水位统计,限流策略,都会因为多线程有所不同。比如在线人数,需要汇总每个线程的连接数。
- 全局变量和局部静态变量,必须仔细Check,保障是线程安全(thread-safe),或者是线程局部(thread-local),虽然我们在原型中已经改造得差不多,但还是需要更多确认。
由于多线程本质上和集群的能力是有一部分重合的,只是多线程的效率更高。比如直播其实可以用多个Edge部署在一台机器上(Edge后面挂一堆Edge),实现多核的扩展。RTC如果实现了级联,也一样是可以扩展多核能力,比如单核支持800个并发,每个SRS跑在一个Pod中,也可以用级联扩展能力(当然会造成进程之间的带宽比多线程要高,多线程是不走带宽)。
硬件加速
SRS没有使用硬件加速,但这个是一个很不错的思路,包括:
- CPU指令集优化:加解密和编解码的算法,有些可以用到CPU的特殊指令,增加批处理的能力,比如AVX512。
- 专用加解密硬件卡,加解密是比较通用的算法,有专门硬件,可以调研看看。
- UDP收发优化,不经过内核协议栈,直接从用户空间和网卡交互:DPDK。