关于SRS流媒体服务器的重要缺陷的总结

在流媒体服务器开源项目中,SRS[1]是一个使用挺广泛的开源项目,维护的时间也比较久,从2013年开始,陆陆续续解决了一些问题,但始终有些问题不容易解决,这篇文章总结和分享给大家。

有些朋友总觉得SRS功能很多,该做的都做完了,其实SRS远远不是一个完善的开源项目,也不会有完善的一天,任何时候都可以参与到SRS项目中,任何时候都欢迎大家参与。

Cluster

集群能力是SRS一个非常明显的缺陷。实际上,到底支持什么样的集群,就一直有各种争议和设计上的反复。

SRS是在2.0也就是2015年就支持了Edge集群,一直都宣称可以支持大规模的RTMP/FLV分发,但经过十年发展,发现真正使用Edge的用户很少,特别是支持了SRT和WebRTC协议后,这些协议又没有在Edge支持。我发现其实开源项目的用户,和视频云服务所需要的流媒体集群,在能力上是有很大差别的。

我的判断是,SRS并不需要支持大规模的分发能力,只需要支持小规模集群。但是集群是必要的能力,而且小规模也需要支持上千或者上万级别的并发能力。更重要的是,应该对主流的协议实现完善的集群能力,而不仅仅是RTMP/FLV的集群。

讨论集群之前,一般说的流媒体集群,除了流媒体处理服务器SRS,还包含了调度和运维的系统。尽管通过RTMP URL接入系统,一样也会有DNS解析和调度。而WebRTC的信令一般是HTTPS,和媒体传输是分离的。不管怎样,集群总是隐含了调度和分发的能力。

首先,SRS是没有集群调度能力的,因为它只是开源流媒体服务器。如果希望搭建一个百万并发的流媒体集群,你需要自己解决调度能力。一般CDN使用DNS调度,或者HTTP-DNS调度。调度实际上决定的是系统的水位,是否能最大限度利用现有的资源,也决定了运营的成本。

其次,集群的架构应该是树(源站-边缘),还是图(转发或级联),实际上并没有确定。中国由于多个运营商,在直播中主要解决海量播放的问题,因此树的架构比较常见;而如果需要构建一个全球网络,或者对于海量的冷流的场景(比如网络会议),树肯定是不合适的。

最后,流服务器的负载均衡,包括负载收集和过载保护,一般是在系统内部的均衡。如果把调度看作是系统的第一道负载均衡屏障,内部的负载均衡也非常重要。SRS有实现熔断的能力,不过并没有实现负载收集和均衡,比如过载后通过302跳转,将负载迁移到其他服务器。关于负载均衡,请参考 Load Balancing Streaming Servers[2] 的总结。

下面是SRS的源站-边缘架构,多个边缘Edge从源站Origin回源获取流,对于每个流每个Edge只回源一次,Edge可以向Edge回源,形成多级Edge结构,这样就可以支持海量播放:

                           SRS(Edge)
SRS(Origin) --RTMP/FLV---> SRS(Edge) --RTMP/FLV--> 海量播放
                           SRS(Edge)

Note: 这个Edge架构最初是来自于Adobe AMS,不过AMS使用的是修改过的RTMP协议,使用了一些自定义的消息实现集群的管理,SRS完全使用标准RTMP协议,实现的是一个标准的RTMP客户端。

  • • 只支持RTMP/FLV协议,不支持SRT/WebRTC/HLS协议。实际上WebRTC协议的性能差很多,更需要集群能力支持更多的并发。
  • • 一般还需要Nginx分发HLS,和支持HTTPS-FLV协议。SRS在HTTPS和HLS分发的性能很低,而且可能会因为磁盘阻塞IO导致卡顿问题。
  • • 回源是固定配置,形成了一个树状的分发网络,调整起来比较麻烦。如果需要回源到多个源站,需要使用源站集群或者按vhost回源。

下面是SRS的源站集群架构,多个源站MESH互相连接,交换流的信息,使用RTMP 302定向客户端到目标源站,这样可以将流分散到多个源站,支持更多路的流:

SRS(Origin)
   +(MESH)  ---RTMP---> SRS(Edge) --RTMP/FLV--> 推流或播放
SRS(Origin)
  • • 源站之间使用MESH互相连接,交换流的信息,限制了可以支持的源站的数量。
  • • 若源站没有流,会使用RTMP 302将客户端定向到目标源站,因此依赖Edge处理RTMP 302定向消息。
  • • 流在源站之间迁移时,没有处理好HLS恢复和生成问题,会造成HLS流中断。

实际上,这个源站集群并不是一个好的方案,特别是对于WebRTC协议。由于SRS是单线程,WebRTC协议性能特别低,因此每个SRS源站支持的WebRTC连接大概在300个左右,如果开启了WebRTC和RTMP协议转换可能在40个左右(有音频转码)。直播协议可能支持3000或者5000个并发,而WebRTC完全不是一个数量级,这意味着需要新的源站集群的方案。

SRS新的源站集群架构,使用Proxy代理,转发流量到后端源站。代理本身无状态,也可以水平扩展,可以解决很多流的场景:

SRS(Origin)
SRS(Origin) ---> SRS(Proxy) --> 推流或播放
SRS(Origin)
  • • 源站之间没有关联性,主动注册到Proxy。Proxy之间没有关联性,通过Redis同步状态。可以很方便的在K8s中部署。
  • • Proxy支持RTMP/FLV/HLS/SRT/WebRTC所有的协议,代理API和媒体流量到源站,可以支持除了SRS之外的其他流媒体源站,具备很高的通用性。
  • • 新的集群架构,依然没有解决HLS恢复的问题。

考虑WebRTC协议,如果使用Proxy对接100个SRS Origin,即使开启了WebRTC转RTMP协议,也可以支持最多4000个流,每个流支持最多300个观看。最多可以支持4000个推流,或者可以支持100个流30K观看。

Note: SRS还支持Dynamic Forward,主动将流转发到不同的后端服务,本质上它就是一种代理。不同之处是SRS Proxy使用Go实现,依赖Redis实现无状态,方便实现系统对接和各种业务调度逻辑。而SRS Forward是SRS实现的功能,只支持RTMP协议,修改起来也比较麻烦。

新的源站集群是可以配合Edge使用的,依然可以支持很多路RTMP流,以及每路RTMP流的海量播放:

SRS(Origin)                     SRS(Edge)
SRS(Origin) ---> SRS(Proxy) --> SRS(Edge) --> 推流或播放
SRS(Origin)                     SRS(Edge)
  • • Edge依然只支持RTMP/FLV协议,但未来会支持WebRTC协议,实现直播和RTC场景的海量播放。
  • • Proxy和Origin一般是区域化部署,若需要部署不同区域的集群,一般按照vhost调度,不同vhost分配到不同的区域。
  • • 集群的监控和全链路排查,Proxy还没有支持完善。此外,HLS流的恢复始终不够完善。

除了头部视频云和CDN,需要百万级别的推流和播放,一般的应用场景中,上千路流和播放,就是绝大部分业务场景能达到的规模了。对于一般的小规模应用,WebRTC需要依靠集群解决性能瓶颈,其他协议的集群主要是避免单点故障从而提高可用性。

RTMP/FLV协议的集群,是最容易实现的。对于Edge来说,RTMP/FLV属于无状态,当出现问题时,只需要尝试下一个源站即可,客户端可能会出现内容跳变或者重复,但这种微小变化基本上无法感知。一般说无状态,实际上只是这些状态可以被丢弃,出错时可以通过重试解决;如果Edge本身出现问题,那就只能客户端重试,这实际上是依赖客户端重试的一种错误恢复。

HLS协议的集群,最难实现的是流的切换。客户端在重新推流时,HLS协议可以正确处理,可以通过DISCONTINUITY标记实现切片连续。但如果是源站崩溃或重启,SRS目前并没有实现切片信息的持久化和恢复,那么M3U8内容会重新生成,这会导致客户端异常。如果流在源站之间迁移,那么问题也是一样的。HLS集群需要处理这种异常情况。

HLS协议还有一个非常难以处理的,就是数据统计,比如连接数。由于HLS是基于切片的协议,本身并没有连接数的概念,因此SRS实现了HLS_CTX,通过QueryString实现连接数的统计。但是对于HLS集群,则需要Edge实现HLS的分发和连接数统计,目前SRS都没有实现。如果使用Nginx做Edge分发HLS,则需要在Nignx上支持连接数统计。

对于SRT和WebRTC,目前SRS只是实现了Proxy,也就是支持将流调度到不同的Origin上,但Edge并没有支持这两个协议。这决定了如果一个流被大量播放,则无法使用SRT或WebRTC协议。如何在Edge上实现完善的协议栈支持,是集群的难点。

一般使用K8s或HELM管理集群,包括部署、升级、扩容、缩容、回滚等。SRS的集群考虑了K8s的支持,其中很关键的一个能力是Gracefully Quit,也就是退出时先关闭端口,然后等现有连接慢慢关闭后,再重启服务,实现平滑升级。对于TCP协议容易实现Gracefully Quit,但是UDP协议暂时还没有实现。

对于全链路追踪,SRS在RTMP协议中传递了连接ID等信息,并且支持了OpenTelemetry的标准协议,而且对接了腾讯云的APM。但是APM本身还没有像Prometheus那样的开源平台,而且对接不同的APM云服务,是有一些接入的工作量的。而且SRS Proxy还没有支持APM埋点。SRS本身的APM埋点也比较少,只有一些关键的信息,不够完善。

使用Go实现Proxy,有潜在的性能瓶颈问题。由于Go代理了媒体流量,因此无法和C++一样的高性能,尤其在多核CPU时,Go可能会损失30%的性能。这个问题可能在小型的集群中并不明显,绝大部分场景都不需要如此高性能。RUST可能是一个潜在的选项,但RUST会增加社区的维护难度,需要同时维护C++、GO和RUST三种不同的技术栈,成本非常高。

和集群相关的,是多进程或多线程问题。

Multi-processing

多核(multi-processing)或者多线程(multi-threading),都是因为服务器一般会支持很多个CPU核,因此可以单机对外提供更多的服务。从技术上实际上有多种解决方案:

  • • 多进程:比如Nginx其实就是多进程,每个进程都是单线程。由于Nginx的影响力,基本上和Nginx同时代的服务器,都是采用这种架构。这种架构的问题,就是长链接问题,比如流媒体直播,都是长连接,而且两个连接需要调度到一个进程。简单来说,多进程并不是一个好的流媒体服务器架构,这也是为何目前流媒体服务器很少有这种架构,太难维护了。
  • • 多线程:实际上比Nginx更早的服务器是多线程,比如Adobe AMS、Real Helix、Wowza、Janus,这是一种落后的架构,因为多线程除了有线程切换的性能损失,还会造成线程和数据竞争问题,容易崩溃和死锁。但最近十年,多线程开始演化出一种thread local的架构,比如Envoy和ZLM使用这种架构,实际上thread local是多进程架构,但是进程之间共享数据会比多进程方便。这种架构是最吸引SRS的演进方向的,但目前还不足以吸引SRS真的朝这个方向实施,简单来说它还是不够好,具体原因我在后面分析。
  • • 集群架构:实际上K8s属于这种架构,它可以算是一种分布式架构,将一台机器虚拟化为K8s集群,每个Pod可以是单进程,多个Pod和Service组成一个集群,对外提供服务。SRS的Proxy-Origin-Edge集群,就是这种架构。这种架构引入了一个Proxy网元,会损失性能,但从维护和可扩展性上,是具备优势的。因为K8s实际上不仅仅解决了流媒体分发问题,还解决了部署、扩缩容、监控、更新等问题。

SRS一直是单进程单线程,相当于一个单进程版本的Nginx,同时引入了协程(Coroutine)实现并发处理。协程是使用StateThreads这个库实现的,我们已经改造了StateThreads,让它支持了thread local,可以在多线程环境中运行。但是SRS经过多年尝试和分析,并没有采用thread local处理流媒体架构,而是一种不同的多线程架构,目前规划的架构还未实现:

  • • 流的处理在一个thread上,日志、写文件、DNS解析等阻塞操作,使用独立的thread实现。也就是SRS使用多线程解决阻塞问题,当然如果未来Linux支持完全的异步读写后,也可以不用多线程实现,参考liburing[3]
  • • StateThreads多线程,在Windows的C++异常处理上,会有问题。Windows的异常机制,和Linux是不同的,当StateThreads实现了setjmp和longjmp后,会出现不兼容问题,参考SEH[4]
  • • 多线程的调度和负载均衡问题。thread local多线程只解决了使用多核,但依然限制推流和播放需要在一个线程,实际上这意味着不能完全将负载均衡到多个线程。当然,如果不使用thread local,那么就有更严重的锁和竞争的问题。本质上是将多个K8s Pod,运行在一个进程中,然后自己实现调度、监控和负载均衡,这实际上会更难处理。

实际上SRS 5.0就将StateThreads改造成了thread local,并启动了一个主线程和子线程,尝试将架构改变成多线程架构。但在后续遇到各种不同的问题,最终还是在SRS 6.0默认将架构改成了单线程架构,未来可能会去掉多线程的能力,因为多线程在新的Proxy和Edge架构下,变得不那么重要了。未来Proxy如果不断增强能力,具备多种协议和Edge的能力,那么将逐步变成Proxy+Origin集群,完全解决多线程的问题。

此外,我们还调研过另外一种可能的架构,将部分的能力分散到不同的线程,比如将WebRTC加解密使用独立的线程,但这样就变成了一般的多线程程序,而不是thread local架构,这种架构的问题就是锁的性能开销和稳定性降低,这并不是一个好的方向。

实际上,Go也可以算得上一种传统的多线程架构,而不是thread local架构,这也是为何Go也需要锁,这是为何Go的性能一样很低的原因之一。对于流媒体集群而言,或许Go并不是很好的语言,性能损失对于流媒体服务器来说意味着成本增加,而C++容易崩溃的问题无法根本解决,或许RUST会是个可能的选项,但需要时间调研和尝试。

中大型C++项目的稳定性问题,确实是个痛点,尤其是当我们引入了SharedPtr,用来释放Source对象时。

Smart Pointer

SRS的内存泄露问题,大概10年左右才解决,详细参考 Source cleanup[5]

实际上并不能算是泄露,而是缓存不释放。最初SRS是解决少量的RTMP流,大量的观看播放,因此只释放了客户端连接,并没有释放Stream Source对象。因为大量的内存都是客户端连接占用了,而Stream Source数量比较少,占用内存很少。而不释放Stream Source对象,能极大的简化问题,能避免很多内存问题,稳定性也会非常高。

但是, 如果总是改变流的URL,或者有非常多的推流,但少量的观看,这样就会造成内存上涨很快,这个问题就很明显了。

要释放Stream Source,首先要解决的就是各种对象对Stream Source的引用。因为SRS支持RTMP、HTTP-FLV、HLS、SRT、WebRTC、DASH、GB28181等协议,而且支持它们之间互相转换,实际上就会出现互相引用的情况。

考虑一个RTMP客户端,推流到SRS时,先创建Stream Source,每个URL就代表了一个Stream Source资源:

srs_error_t SrsRtmpConn::stream_service_cycle() {
    SrsLiveSource* source = NULL;
    _srs_sources->fetch_or_create(req, server, &source);
}

在开始推流时,会创建HTTP mount,这样可以通过HTTP FLV播放这个流:

srs_error_t SrsHttpStreamServer::http_mount(SrsLiveSource* s, SrsRequest* r) {
    entry =newSrsLiveEntry(mount);
    entry->source = s;
    entry->stream =newSrsLiveStream(s, r, entry->cache);
    mux.handle(mount, entry->stream);
}

srs_error_t SrsLiveStream::do_serve_http(ISrsHttpResponseWriter* w, ISrsHttpMessage* r) {
SrsLiveConsumer* consumer =NULL;
    source->create_consumer(consumer);
}

如果Source一直有效,那么就可以很容易实现业务逻辑。如果需要清理,则需要考虑多种不同的场景,极大增加了野指针崩溃的可能:

  • • 在unpublish时,需要清理HTTP Stream,这意味着需要先踢掉客户端,等连接销毁后才能释放Source。
  • • 由于SRS允许先播放然后推流,也就是需要在所有HTTP Stream都断开时,清理掉Source。
  • • 此外,Edge模式下,播放HTTP Stream时会创建Source,然后触发回源获取流进入publish,最后一个客户端断开时触发清理,这就意味着HTTP Stream会有自我销毁的风险。

实际上,C++ 11的shared ptr,还有一些高级特性,除了引用计数管理内存,还涉及到:

  • • shared_from_this:这个是一个还挺常见的能力,就是从裸指针中返回一个shared ptr,此时裸指针肯定是被shared ptr管理的,所以不能直接创建一个新的shared ptr,不然就会有多次释放了。其实这个能力并不是必须的,所以SRS没有实现。
  • • inheritancecompare:继承和比较,涉及到智能指针继承和比较的场景,也不是必须的,SRS没有实现。
  • • weak ptr:如果存在循环引用,那么就会造成shared ptr的引用计数失效,必须用weak ptr避免循环引用。其实weak ptr和裸指针有点像,只是提供了一个函数,来检查是否目前shared ptr还可用。在SRS的场景下,是可以避免循环引用的,因此SRS也没有实现weak ptr这个能力。

使用简化的smart ptr后,Stream Source使用定时器检查和释放。这里是典型的引用计数的指针的用法,引用计数为零后就会调用Source的析构函数:

srs_error_t SrsLiveSourceManager::notify(int event, srs_utime_t interval, srs_utime_t tick) {
    std::map< std::string,SrsSharedPtr<SrsLiveSource>>::iterator it;
for(it = pool.begin(); it != pool.end();){
SrsSharedPtr<SrsLiveSource>& source = it->second;
        source->cycle();

if(source->stream_is_dead()){
            pool.erase(it++);// 释放Source smart ptr
}

对于循环引用问题,我们通过注释和避免循环引用,直接使用裸指针,比如:SrsLiveSourceSrsOriginHub就是循环引用,但实际上从生命周期上看,SrsLiveSource创建和释放了SrsOriginHub,因此我们就可以直接使用裸指针:

// Source持有和释放hub
classSrsLiveSource{
SrsOriginHub* hub;
}

classSrsOriginHub:publicISrsReloadHandler{
// Because source references to this object, so we should directly use the source ptr.
SrsLiveSource* source_;
}

还有一种情况,两个对象可能都会触发销毁,但它们的生命周期不是独立的。比如session: SrsRtcConnection包含了SrsRtcTcpConn,session超时时需要关闭TCP连接;同时,TCP关闭连接时,也需要触发session的销毁:

// session包含了TCP连接
classSrsRtcConnection{
SrsRtcNetworks* networks_;
}
classSrsRtcTcpNetwork:publicISrsRtcNetwork{
SrsSharedResource<SrsRtcTcpConn> owner_;
}

// TCP连接直接使用session的裸指针,因为生命周期比session短
classSrsRtcTcpConn{
// Because session references to this object, so we should directly use the session ptr.
SrsRtcConnection* session_;
}

// TCP断开连接时,触发session的expire和销毁
srs_error_t SrsRtcTcpConn::cycle() {
// Only remove session when network is established, because client might use other UDP network.
if(session_ && session_->tcp()->is_establelished()){
        session_->tcp()->set_state(SrsRtcNetworkStateClosed);
        session_->expire();
}
}

GB28181的情况比较复杂,它的Session包含了Sip和Media,实际上也并不存在循环引用问题,只是可能Session是从Sip创建,而Sip和Media可能需要更新Session:

class SrsGbSession{
SrsSharedResource<SrsGbSipTcpConn> sip_;
SrsSharedResource<SrsGbMediaTcpConn> media_;
}

classSrsGbSipTcpConn{
// The owner session object, note that we use the raw pointer and should never free it.
SrsGbSession* session_;
}

classSrsGbMediaTcpConn{
// The owner session object, note that we use the raw pointer and should never free it.
SrsGbSession* session_;
}

srs_error_t SrsGbSipTcpConn::bind_session(SrsSipMessage* msg, SrsGbSession** psession) {
SrsSharedResource<SrsGbSession>* session =dynamic_cast<SrsSharedResource<SrsGbSession>*>(_srs_gb_manager->find_by_id(device));
// 从SIP通道创建Session对象
if(!session){
        raw_session =newSrsGbSession();
        session =newSrsSharedResource<SrsGbSession>(raw_session);
        _srs_gb_manager->add_with_id(device, session);
}

// 更新Session中的SIP通道对象。
    raw_session->on_sip_transport(*wrapper_);
}

srs_error_t SrsGbMediaTcpConn::bind_session(uint32_t ssrc, SrsGbSession** psession) {
SrsSharedResource<SrsGbSession>* session =dynamic_cast<SrsSharedResource<SrsGbSession>*>(_srs_gb_manager->find_by_fast_id(ssrc));
SrsGbSession* raw_session =(*session).get();

// 更新Session中的Media通道对象。
    raw_session->on_media_transport(*wrapper_);
}

Note: 和RTC不同的是,GB的SIP和Media通道,在释放时都只释放自己的连接对象,不会触发GB Session的销毁,而是通过超时销毁GB Session。

实际应用中,SRS的smart ptr是相当受限的场景,没有应用任何语法糖和高级特性。这使得这个方案的维护性是比较高的。

Error vs Logging

一般可能出错的地方,迟早都会出现错误,因此,错误处理决定了系统的运营难度。一般而言,错误和日志是混淆的,在日志中打印出错误信息,是比较常见的做法。实际上错误和日志是两个完全不同的问题。

日志是系统的行为记录,可以通过日志排查问题。错误是一种特殊的行为,错误和日志的关联是:

  • • 日志会分级别,需要排查一些非常难以排查的问题时,需要打开更详细级别的日志。错误没有级别,但错误是可以写入日志。
  • • 错误应该提供完善的堆栈,因为发生错误时,调用路径非常关键。一般错误发生在底层函数,而不同的调用路径对于理解和解决错误很关键。
  • • C++支持错误码和异常两种错误表达,一般采用错误码的情况更多,更容易正确处理错误。

一般并不会有错误,但可能也会出现问题,比如异常的帧率,因此日志一般需要给出关键路径,而且一般不包含错误,才可以判断系统是否正常:

[2024-09-2710:32:46.245][INFO][58467][f951w1of] RTMP client ip=127.0.0.1:57591, fd=13
[2024-09-2710:32:46.246][INFO][58467][f951w1of] complex handshake success
[2024-09-2710:32:46.247][INFO][58467][f951w1of] connect app, tcUrl=rtmp://localhost:1935/live, pageUrl=, swfUrl=, schema=rtmp, vhost=localhost, port=1935, app=live, args=null
[2024-09-2710:32:46.247][INFO][58467][f951w1of] protocol in.buffer=0,in.ack=0,out.ack=0,in.chunk=128,out.chunk=128
[2024-09-2710:32:46.247][INFO][58467][f951w1of] client identified, type=fmle-publish, vhost=localhost, app=live, stream=livestream, param=, duration=0ms
[2024-09-2710:32:46.247][INFO][58467][f951w1of] connected stream, tcUrl=rtmp://localhost:1935/live, pageUrl=, swfUrl=, schema=rtmp, vhost=__defaultVhost__, port=1935, app=live, stream=livestream, param=, args=null
[2024-09-2710:32:46.247][INFO][58467][f951w1of]new live source, stream_url=/live/livestream
[2024-09-2710:32:46.248][INFO][58467][f951w1of] source url=/live/livestream, ip=127.0.0.1, cache=1/2500, is_edge=0, source_id=/
[2024-09-2710:32:46.250][INFO][58467][f951w1of] start publish mr=0/350, p1stpt=20000, pnt=5000, tcp_nodelay=0
[2024-09-2710:32:46.251][INFO][58467][f951w1of] got metadata, width=768, height=320, vcodec=7, acodec=10
[2024-09-2710:32:46.251][INFO][58467][f951w1of]46B video sh, codec(7, profile=High, level=3.2,768x320,0kbps,0.0fps,0.0s)
[2024-09-2710:32:46.251][INFO][58467][f951w1of]4B audio sh, codec(10, profile=LC,2channels,0kbps,44100HZ), flv(16bits,2channels,44100HZ)
[2024-09-2710:32:46.253][INFO][58467][f951w1of] RTMP2RTC:Init audio codec to 10(AAC)
[2024-09-2710:32:48.385][INFO][58467][f951w1of] cleanup when unpublish

对于服务器日志,一定要包含ID,比如会话级别的ID,这样在服务多个客户端时,或者集中收集日志后,才可以快速找到指定的日志,SRS把这个机制叫做可追踪日志,可以根据ID找到这个ID所有的日志。由于SRS基于协程,一个会话比如RTMP推流连接,可能包含一个或多个协程,因此在打印日志时就可以不用传递这个ID,而是自动获取:

srs_error_t SrsRtmpConn::do_cycle() {
    srs_trace("RTMP client ip=%s:%d, fd=%d", ip.c_str(), port, srs_netfd_fileno(stfd));
}

void SrsProtocol::print_debug_info() {
    srs_trace("protocol in.buffer=%d, in.ack=%d, out.ack=%d, in.chunk=%d, out.chunk=%d", in_buffer_length,
        in_ack_size.window, out_ack_size.window, in_chunk_size, out_chunk_size);
}

// 这个日志宏定义,自动从get_id()获取协程ID
#define srs_trace(msg, ...) srs_logger_impl(SrsLogLevelTrace, NULL, _srs_context->get_id(), msg, ##__VA_ARGS__)

Note: 从协程中自动获取ID是非常重要的一个特性,因为SrsProtocol是底层函数,如果需要明确传递ID,那所有函数的第一个参数都必须是ID,这样就和Go的第一个参数是ctx: Context有点类似,有点笨拙而且很难完全做到。

SRS的错误是包含了堆栈信息和错误信息,借鉴了Go的error和wrap很像,这样发生错误时,仅仅根据错误对象,就可以获取完整的错误上下文:

[2024-09-27 10:40:30.836][INFO][62805][l9067692] RTMP client ip=127.0.0.1:57942, fd=15
[2024-09-27 10:40:30.837][INFO][62805][l9067692] client identified, type=fmle-publish, vhost=localhost, app=live, stream=livestream, param=, duration=0ms
[2024-09-27 10:40:30.838][ERROR][62805][l9067692][35] serve error code=1028(StreamBusy)(Stream already exists or busy) : service cycle : rtmp: stream service : rtmp: stream /live/livestream is busy
thread [62805][l9067692]: do_cycle() [./src/app/srs_app_rtmp_conn.cpp:263][errno=35]
thread [62805][l9067692]: service_cycle() [./src/app/srs_app_rtmp_conn.cpp:457][errno=35]
thread [62805][l9067692]: acquire_publish() [./src/app/srs_app_rtmp_conn.cpp:1078][errno=35](Resource temporarily unavailable)

Note: 除了错误的堆栈,还包含了errno,每个函数对错误的描述信息比如stream service等。

Note: 值得注意的是,这个错误日志是会打印成多行日志,每行都有一个日志ID比如l9067692,这样通过日志系统查找时不会漏掉。

由于SRS的错误是一个对象,除了可以获取完整上下文,除了用日志来表达错误,还可以将错误对象通过追踪系统发送,比如通过OpenTelemetry[6] APM全链路展示错误信息。这要求SRS的错误必须是wrap而不是直接返回int:

srs_error_t SrsRtmpConn::do_cycle() {
if((err =service_cycle())!= srs_success){
        err =srs_error_wrap(err,"service cycle");
}
}

srs_error_t SrsRtmpConn::service_cycle() {
if(!srs_is_system_control_error(err)){
returnsrs_error_wrap(err,"rtmp: stream service");
}
}

srs_error_t SrsRtmpConn::acquire_publish(SrsSharedPtr<SrsLiveSource> source) {
if(!source->can_publish(info->edge)){
returnsrs_error_new(ERROR_SYSTEM_STREAM_BUSY,"rtmp: stream %s is busy", req->get_stream_url().c_str());
}
}

一般习惯是不返回error对象,而是直接打印错误日志,这实际上是不高明的做法。比如底层函数的错误,有些错误是可以忽略的,所以如果打印错误会造成日志刷屏,底层函数只好返回错误码,这样就会丢失堆栈信息。而通过返回错误对象的方式,由应用层决定,也只有应用层才能决定,是丢弃和忽略错误,还是打印警告日志,还是打印错误日志,或者发送到监控系统告警。直接打印错误日志的根本问题是:把打印错误到日志,当成了错误处理的唯一方法,限制了错误处理的可能性。

SRS这种错误机制的问题是:

  • • 所有函数必须全部返回srs_error_t指针对象,而不是返回int错误码。写起代码来有点呆板,而且只能依靠Code Review维护这个规则。
  • • 如果调用第三方库,比如FFmpeg,则无法获取完整堆栈,只能获取错误码信息。一般会提供log的hook,可以把内部日志打印变成SRS的日志,但一般C的库都没有提供错误对象机制。
  • • 如果遇到高频的可忽略错误,会造成性能问题,比如某些UDP包的错误是需要忽略的。由于UDP包的数量巨大,如果每个错误都生成一个错误对象,则会造成性能瓶颈。当然错误的UDP包并不常见,而且可以通过提前判断来直接丢弃。

SRS日志最初采用的是全局整数递增的方式,后来发现集中收集到日志系统后,会出现ID冲突。因此后来改成了随机的字符串ID,参考GIT的Commit ID生成,减少了ID冲突的可能。除了ID还有时间,一般搜索日志时会限制时间比如1小时或3小时,随机字符串ID冲突的概率几乎没有。

SRS 6.0支持了OpenTelemetry APM,也就是应用性能监控,全链路日志和错误。由于APM必须要对接平台,所以对接的是腾讯云的APM。APM的协议栈实际上是HTTP/2和Protobuf,SRS实现了Protobuf协议,但是只支持HTTP/1协议。一般APM平台支持使用HTTP/1+Protobuf方式接入,但并不是默认的方式。过于新的协议和每家云的鉴权不一致,限制了APM的应用。

其实比日志和错误更常用的,是系统OpenAPI,下面详细说明。

OpenAPI

API就是系统对外的接口,狭义的API一般指HTTP API。实际上,可以把日志和错误看成是广义的API,还包括:系统配置、HTTP API、Prometheus Exporter等。

SRS在早期就支持了HTTP API和Callback,可以方便的和业务系统集成。除了支持查询streams和clients信息,还支持kickoff踢流。Callback包含了推流、播放、录制、HLS等事件的回调。以下是还不够完善的地方:

  • • 只支持HTTP Basic Authentication,不支持Bearer Token和其他认证方式。一般推荐使用Go实现一个HTTP代理,实现认证后将请求转发给SRS,比如Oryx就实现了这个代理。
  • • 文档不够完善,只说明了请求和响应格式,包含什么字段,但没有详细说明字段的含义。早期是因为API还不够稳定,想着等稳定后再详细解释字段含义,然后就没然后了。
  • • 分页和查询能力不强,clients这个API可能包含较多数据,需要支持分页查询。还有比如不能根据流名称模糊查询,只能根据id查询具体信息。也不能根据时间排序等。一般推荐自己根据回调维护stream和clients的数据库,支持比较完善的查询。

由此可见,提供API和Callback是远远不够的,需要对这些数据进行处理,支持完善的查询,甚至还需要支持图形化展示,如果要运维还需要监控告警,还需要持久化要支持历史查询。这些其实都可以通过Prometheus和Grafana实现,SRS提供Exporter:

+-----+               +-----------+     +---------+
| SRS +--Exporter-->--| Promethus +-->--+ Grafana +
+-----+   (HTTP)      +-----------+     +---------+

Prometheus是一个非常完善的监控和运营系统,它通过标准化的Exporter API从SRS抓取数据,然后存储在自己的时序数据库,然后使用自己的查询语句PromQL实现查询。通过Grafana可以实现图形化展示。这也就可以避免每个用户都需要自己转化HTTP API和Callback,对接自己的监控和运营系统了。使用SRS的Prometheus Exporter功能,你可以在10分钟之内获得一个图形化的监控和运营后台,详细参考SRS Exporter[7]。以下是Exporter还不够完善的地方:

  • • 支持的指标太少,还只支持机器级别的监控数据,还没有支持stream或者client级别的数据。比如你可以知道某个SRS,或者整个集群的连接数、带宽、流数量和客户端数量等,但无法知道某个流的统计信息。
  • • 支持的大盘还比较简单,只提供了一个常用的Grafana大盘。针对不同的场景,需要提供不同的Grafana大盘。
  • • 错误率和成功率的数据还不够准确。一般这种数据需要客户端提供,服务器上能提供的只是部分场景的成功率。而且SRS目前的成功率还不够准确。

除了数据指标,实际上配置也可以算是接口的一部分,决定了如何控制系统的行为。SRS使用nginx的配置文件方式,支持reload。由于配置文件是用户管理的,SRS对兼容性做了非常强的保证,SRS 1.0的配置文件可以一直在后续版本中使用,每次配置变更时比如改名,SRS会在解析配置后兼容新旧两个名称。以下是配置值得改进的地方:

  • • 配置文件在云原生的系统中不太友好,实际上环境变量的方式是更方便的配置方法,参考Grafana的配置,除了使用文件配置,每个配置项都可以使用环境变量控制。SRS实现了部分配置使用环境变量配置,但对于vhost级别的配置,还不支持环境变量配置。
  • • 有段时间SRS支持了一个HTTP RAW API,可以修改配置并将配置序列化为配置文件,这样可以实现配置下发。但这种方式会导致很多问题,容易造成数据冲突和竞争,导致崩溃。因此在正式版本中这个功能被删除了。其实在云原生的环境中,配置是通过yaml中的环境变量管理的,只需要变更yaml就可以变更配置。
  • • SRS支持配置的Reload,特别是在系统性能优化时,通过改变配置和reload,就可以快速验证优化是否生效,而不需要重启整个压测。但这个功能过于复杂,有些功能比如侦听端口的改变,需要非常复杂的实现,对维护性和稳定性都有非常大的影响。未来可能这个功能会逐步弱化和删除。

特别对比下配置文件和环境变量配置的方式,下面是配置文件example.conf

listen 1935;
http_api{
enabledon;
}
rtc_server{
enabledon;
candidate192.168.3.82;
}
vhost __defaultVhost__ {
rtc{
enabledon;
}
}
docker run --rm -it -p 1985:1985 -p 8000:8000 
  -v $(pwd)/example.conf:/usr/local/srs/conf/docker.conf 
  ossrs/srs:5

需要在文档中描述配置文件内容,用户需要拷贝内容,创建和编辑文件,然后启动时指定文件,整个步骤比较繁琐容易出错。如果使用环境变量,则一个复制粘贴就可以搞定,而且用户操作时没有出错的可能性:

docker run --rm -it -p 1985:1985 -p 8000:8000 
  -e SRS_LISTEN=1935 
  -e SRS_HTTP_API_ENABLED=on 
  -e SRS_RTC_SERVER_ENABLED=on 
  -e CANDIDATE="192.168.3.82" 
  -e SRS_VHOST_RTC_ENABLED=on 
  ossrs/srs:5

维护一套完善的API需要非常大的努力和时间,特别是新增功能时,需要确保API和文档都能及时更新。SRS的API还远未到完善的时候,还需要很多努力。

Protocols

流媒体协议是流媒体服务器的核心能力,而服务器上需要支持的协议能力,和一般的媒体处理并不相同。比如ffserver停止维护,是因为FFmpeg的IO设计主要是客户端,而无法作为服务器处理很高的并发。比如SRS并没有使用WebRTC代码库实现WebRTC协议,因为SFU服务器需要的能力,只是非常轻量的协议栈,并不需要媒体处理、设备管理、信号处理和算法的部分。

SRS对RTMP、HTTP-FLV、HLS、WebRTC这些协议是原生支持,稳定性和维护性也最高。SRT嫁接了libsrt这个库,稳定性有保障,在协议转换和性能上不够完善。DASH和GB28181是协议本身就有各种缺陷,会有各种问题。HDS和RTSP都是淘汰的流媒体协议,不过在AI时代RTSP在某些场景下还有应用。

SRS最早只支持了直播的常用协议:RTMP、HTTP-FLV和HLS,以及直播集群即Edge和Origin集群。随着直播的发展,WebRTC和SRT协议也逐渐使用得越来越多,这对整个系统架构有较大的影响,特别是协议转换。

最初的协议转换,只需要支持RTMP转HLS。虽然OBS能使用HLS推流,但一般较少使用。实际上SRS还能支持POST HTTP-FLV方式推流,和POST HLS类似,但FLV推流没有被CDN和常用客户端支持,这种方式也比较少用。不过SRS是使用Stream Caster结构支持这种特殊的流接入,然后转换成RTMP,比如PUSH MPEGTS over UDP就是这种方式。

SRT和GB28181最早都是使用Stream Caster结构,SRS接收流后,会启动一个RTMP Client推流到localhost,因此它实际上有点类似外挂的方式接收这两种协议的流。这两种协议遇到的问题还不一样,先说SRT的问题:

  • • SRS并没有完全实现SRT的协议栈,而是使用了libsrt这个库,而这个库是有自己的线程的,这使得SRS早期支持SRT会有很多问题,而且维护难度很高。后来在SRS 5.0重写了这个逻辑,同样还是用libsrt库,但根据StateThreads做了改造,整个稳定性和维护性也提升了。
  • • SRT使用UDP协议传输数据,Linux内核的UDP传输性能本来就很低,更何况SRT使用TS over UDP的方式,在应用层也有很多消耗。再加上SRT实现了传输协议,因此SRT主要并不是用于高并发场景,而是少量并发传输场景。
  • • SRS支持SRT推拉流,支持SRT转RTMP和WebRTC协议,但没有支持RTMP转SRT协议。本质上SRS还是把SRT作为一个接入协议,但并不是一个分发的协议。

GB28181的情况有所不同,实际上SRS 5.0完全重新实现了GB28181协议栈,一般是使用GB28181推流到SRS后使用WebRTC观看,这样就可以无插件直接用浏览器看摄像头的流,主要问题包括:

  • • GB28181是设计为内网协议,没有考虑丢包抖动等场景,在互联网上只能使用TCP协议而不能使用UDP协议。这是为何SRS重写GB28181协议后,只支持了GB28181 2016也就是TCP协议栈,没有实现UDP。
  • • GB28181有很多业务能力,比如回看,存储,云台等功能,这些控制消息使用SIP实现。SRS实现的是一个非常简单的SIP协议栈,基于HTTP改造的一个SIP实现,只能用于简单的场景。后来有实现了一个独立的srs-sip项目,可以不使用SRS的内置SIP协议栈,这个方案可以让SRS在实际项目中使用GB28181协议,但这个项目还不够完善,目前只实现了基本功能。
  • • GB28181协议主要应用在国内安防领域,非常注重安全性,这决定了协议有很多私有信息和协议,而且越多私有信息就越安全。实际上GB28181在互联网的应用是非常少的,极少情况用GB28181做直播,实际上主要还是安防领域在使用。以私有网络和安全为主的项目,很难在开源项目中维护,反过来说,开源项目很难在这个领域做得很好。

RTSP协议是从SRS中移除了,早期以Stream Caster方式支持过RTSP流的接入,主要问题包括:

  • • 并没有RTSP推流场景,实际上安防摄像头并不会使用RTSP推流到SRS,而是播放器从安防摄像头用RTSP协议播放流。这是SRS为何支持GB28181推流的原因,当然也发现GB28181在互联网中也没有那么大的应用。
  • • 和WebRTC有很大区别,RTSP也没有完善的拥塞控制能力,在互联网上传输时,容易造成卡顿和花屏的问题。反过来说,RTSP并不是在互联网上传输流媒体的协议。
  • • 由于AI落地的一个场景就是安防摄像头的物体识别,因此很多AI系统喜欢使用RTSP协议对接。但未来的趋势肯定是RTMP和WebRTC,而不是RTSP,比如Google的摄像头一代只支持RTSP,而二代只支持WebRTC。

Note: SRS未来愿意支持RTSP协议,不过长远判断主要的流媒体协议还是RTMP、WebRTC和SRT,而不是RTSP协议。社区有多大意愿和精力支持和维护一个行将没落的RTSP协议,是一个大大的问号。

SRS的RTMP无疑是支持得最完善的,填了不少的坑,解决了不少的问题,不过也有不够完善的地方:

  • • RTMP的标准在更新了,Enhanced RTMP协议支持了HEVC、AV1和Opus等新编码标准,这样可以在转成WebRTC协议时避免音频转码。SRS目前只在RTMP中支持了HEVC,还没有支持AV1和Opus。

SRS是将WebRTC作为核心协议支持的,因为浏览器支持的流媒体协议,只有WebRTC这一个选项了,不过目前不完善的地方还挺多:

  • • WebRTC的拥塞控制算法支持不完善,目前只有NACK,没有FEC和GCC。一方面是因为目前SRS的WebRTC协议栈性能就不高,一方面是因为这些算法的复杂度高,一方面是只有少量场景才能用到这些算法。
  • • 还没有支持WebRTC集群。目前SRS 7.0支持了WebRTC的Origin集群,也就是Proxy方案,可以扩展源站支持很多路流。但还没有支持Edge集群,不支持一路流很多人观看,预计要到SRS 8.0才能支持了。
  • • 性能和兼容性。单进程架构很难提升性能,因为WebRTC性能瓶颈不仅在应用层也在内核层。协议转换的兼容性,还需要不断适配和填坑才能提升。可能RUST是一个潜在的解决方案,正在评估这个方案,详细参考后面关于RUST的说明。

SRS的切片协议HLS、DASH和HDS,其中HLS是使用最多的,HDS几乎没人用了,DASH有一些用户但有一些问题:

  • • SRS的HLS不支持多码率,由于涉及视频转码,多码率HLS只能使用FFmpeg实现,而且FFmpeg已经实现了。
  • • SRS的HLS还不支持MP4,不支持LLHLS。实际上SRS已经支持了DASH,也就是支持了MP4封装格式,支持LLHLS也是比较容易的。
  • • DASH对于直播不友好,在协议设计上不如HLS那么简单可靠,具体参考DASH的Issue,有详细的描述。

MP4也算是一种协议,一般在录制的文件中使用;当然LLHLS也是用的MP4,特别是HLS HEVC,Safari只能播放MP4的切片文件,不支持TS的切片文件。MP4的兼容性问题比RTMP更多,有时会碰到各种奇怪的问题,建议使用FFmpeg实现录制和转码,只用SRS实现流的接收和分发。

Testing

SRS的质量保障机制包括:基于gtest的utest,基于srs-bench的黑盒测试,Code Review。这些机制是通过GitHub Actions串起来,每个Pull Request、Commit、Release都会自动触发。

这些测试非常有效,经常会出现PullRequest无法通过,绝大部分都是代码修改导致了其他地方的逻辑问题。看起来测试用例起不到作用,基本上是证明1+1=2的常识性判断,实际上也很少会在修改的地方引入错误,而往往是在其他地方导致问题。特别随着代码越来越多,代码之间总是存在千丝万缕的关系,这种互相影响的问题就经常碰到。

程序员对自己的代码一般非常有信心,对别人的代码一般没有什么信心。很少有开发者主动写测试用例,在Code Review时要求补utest后,绝大部分都认为测试很有必要,特别是发现测试能找到逻辑问题之后。我自己的经验,即使认识到测试很有效果,主动写测试依然是痛苦的事情,依然无法做到先写测试,然后写功能代码。

我认为,Code Review最重要的一个作用,就是要求提交测试代码。当然,Code Review最重要的作用是一个将贡献者的代码,变成自己代码的一部分,因此需要了解代码的作用和意图,是否有更好的实现方案。比较常见的问题是,由于开发者不熟悉现有代码,重复实现了现有代码的函数,或者可以使用现有代码改进。此外,贡献者往往不会考虑最容易维护的实现,而是最习惯的实现。

对于Code Review,开源社区一般会遵守单一规则:一个Pull Request只包含一个功能或Bugfix。由于开源社区时间有限,如果一个变更包含多个功能,给不同的人解释代码时会非常费劲,需要解释每一行是哪个功能,Review时也会非常难受。而在公司中Review Code,几乎每个变更都会包含点“顺手”的改动,实际上导致Code Review效果不好,这导致严重的代码质量问题。

此外,公司虽然有大把的时间写代码,由于客户都很着急,实际上会有大量的功能和变更都着急上线,这也是导致一个Pull Request往往包含多个功能。实际上公司花在代码上的时间,没有社区花在代码的时间多,没有花足够时间权衡最容易维护的方案。这是为何社区更有可能有高质量代码,而公司却更有可能有更低劣代码,尽管公司拥有众多顶尖程序员。

这并不是说社区总是会有高质量代码,Code Review很容易就放松,特别是在引入大量代码变更时,尤其是在引入大的功能时。SRS的GB28181和RTSP就是典型的例子,由于变更很大,没有足够的时间Review,但又心急推出这些新功能时,只好降低代码质量的要求,结果导致有大量的Bug报告,不得不重写现有代码,踏踏实实的补充测试和工具。

特别强调的是,并非指其他开发者提交的代码质量低,而是在Code Review时没有花足够的时间,导致合并后的代码质量低。比如Review GB28181代码时,我并没有花时间详细学习GB28181的协议,没有每行代码都仔细Review,没有要求完善测试,没有压测和黑盒测试。实际上就是偷懒,没有严格要求和Review,这完全是我自己的责任。

SRS的测试也有很大的问题,主要是覆盖率不足,尽管有53%的覆盖率,但有些核心的功能并没有覆盖,比如Edge集群。有时候总是很懒惰,比如新开发的Proxy集群,一样也没有测试覆盖。明明知道测试覆盖很重要,但有时候就是做不到,想着这个东西还不稳定,等未来稳定了再加上测试。

代码质量和语言无关,如果没有有效的代码质量规范,如果不严格执行规范,任何语言都可以写出很低质量的代码。

RUST

Go显然不是流媒体服务器的未来,主要有几个问题:

  • • Go的性能损失太大,多线程和GC损失30%,意味着总体运营成本增加30%,这实际上对于一个线上的系统是难以承受的。当然如果你只是学习流媒体,或者只是快速搭建原型,是可以使用Go的技术栈,毕竟快速完成KPI也是非常重要的,还能不能活到后天都不知道。对于长远的技术路线看,Go的性能问题,不能胜任流媒体服务器领域的工作。
  • • Go的生态和C差异太大,如果在Go中使用C的代码,可以用Cgo但是会有很多内存问题。这相当于抛弃了Go的协程,采用了C更多的内存问题,除了能复用现有代码,真的一无是处。将整个生态完全重写的冲动是存在的,比如pion,但理性看待,是很难持续跟进流媒体领域,持续将C的流媒体生态重写为Go的,非人力所能及。
  • • Go并不是C和C++的替代者,这意味着未来的继续发展,C的程序员群体和项目,也依然无法依靠Go的完善和提升来实现替代。从根本上来说,Go就不是流媒体服务器的解决方案,无论用什么努力,都无法继续这个路线。

生活还要继续,项目还要继续推进,新能力还要继续完成,我们无法等待,必须要采取行动。RUST是不是一个合适的路线?我觉得可以看一看流媒体服务器需要解决哪些问题,如果RUST解决了这些问题,以及RUST所带来的问题能够承受,或许可以尝试下这个路线。

首先,先看看前面提到的一些主题:

  • • Cluster:集群能力,用C还是RUST其实没有区别,都是一样需要实现集群的逻辑,无论是Proxy代理、Origin流的处理和转换、Edge聚合回源等,都是一样的。
  • • Multi-processing:多进程或多线程能力,RUST会比C更有优势一些。由于需要使用异步和协程技术,这给多线程造成了一些影响,前面详细描述过C目前的问题。而RUST毕竟是新的语言,在语言层面上对于线程有封装,所以加上异步后,在各个系统之间的移植性要好很多,也有channel之类的基础通信组件。
  • • Smart Pointer:智能指针,RUST和C++ 11是一样的,C需要自己实现。从这点上看,RUST可能会稍微有点优势,标准库中就有智能指针。C++虽然标准库也有,但是得使用C++ 11以上,这会导致很多高级特性,有些环境比如嵌入式系统可能用不了,此外很多C的程序员也看不懂C++ 11各种高级语法。

除了这些问题,还有些没有提到过的问题,我在下面详细总结下。

野指针问题,RUST是有明显优势的,RUST不会碰到野指针问题,所以不会因为野指针造成崩溃。这个问题本质上是由于C太灵活没有限制,比如线程中对于变量的引用,可能造成野指针问题,下面是C++和RUST代码对比:

int x = 100;
std::thread t([&]() {
  std::cout << "x is " << x << std::endl;
});
t.join();

Note: 上面的C++代码是没野指针问题的,但是如果线程没有按预期释放,而是延后释放了,在局部变量释放后又使用了这个变量,就会导致野指针问题了。这并不是假想的问题,实际上我真实碰到过这个问题;当然可以用拷贝变量的方式避免,但是实际上并不是强制拷贝,而是灵活的可以自己决定,这导致了引入问题的风险,比如最初启动线程时并没有引用任何线程外变量,后来的代码添加了,但忘记修改传参的方式,再后来修改了线程的生命周期,或者在特殊条件下线程延迟结束,造成野指针问题。

let x = 100;
thread::spawn(move || {
    println!("x is {}", x);
}).join().unwrap();

Note: 这个问题在RUST是不存在的,编译器会保证必须变量必须move到线程中。RUST就是限制了普通变量不能在两个线程共享,因为有潜在的风险。实际上这里共享也没有问题,因为变量肯定会在线程结束前有效,但是它并不是一直有效,特别是在不断维护项目,不断增加逻辑时,不知不觉就引入了问题。这类野指针问题是非常常见的,我在实际项目的崩溃问题的排查中,经常看到这类问题。

RUST也并非没有问题,它解决了一些问题,也引入了一些问题:

  • • 很高的学习门槛:为了不引入GC的前提下解决内存问题,RUST引入了所有权机制,这个机制本身并不复杂很容易理解,但是当所有权和多线程以及async碰撞到一起,就变得难以理解了。基本上GPT4已经无法正确解释这些复杂的概念和编译错误,但是,好消息是O1工作得很好。
  • • 可选的异步机制:RUST的异步和python一样,并非所有的函数都是async,如果IO库没有实现async,那么你需要自己实现它。Go的策略是全部异步,根本就没有同步,所以Go的所有库都能做到异步。RUST的std只有非常少量的async和await的支持,runtime则是第三方库比如tokio,这意味着tokio需要自己实现一个async std,以及其他的库也需要支持async的方式。如果第三方库中包含了线程,可能无法和tokio正常工作,因为tokio也包含了线程池。
  • • 很弱的std和参差不齐的三方库:RUST的std文档很齐全,质量也很好,不过RUST std比Go std少太多了。很多RUST的第三方库文档和质量难以保障,这非常开源社区。

对于服务器程序,异步IO(async)是必须要支持的功能,实际上RUST的async和ST是非常类似的。主要的异步io都是基于poll的,比如linux epoll,Nginx就是直接使用epoll,当读取和写入时可能是部分写入,意味着缓冲区满了,此时就需要poll(fd)为ready后再次写入。如果每次读写都需要处理这个问题,那么会造成应用层非常繁琐,也就是回调地狱问题。Go和ST是创建应用层协程,而RUST也是类似的spawn执行一个future。代码对比:

//////////////////////////////////////////////////////////////////////////////
// Go
listener, _ := net.Listen("tcp","0.0.0.0:8080")
for{
    conn, _ := listener.Accept()
    go handleTCP(conn)
}

// 启动goroutine服务这个TCP连接
func handleTCP(conn net.Conn){
    defer conn.Close()
    buf :=make([]byte,1024)
    n, _ := conn.Read(buf)
}

//////////////////////////////////////////////////////////////////////////////
// ST(State Threads)
int fd =socket(AF_INET, SOCK_STREAM,0);
::bind(fd,(const sockaddr*)&addr,sizeof(addr));// addr是侦听的地址
::listen(fd,10);
st_netfd_t stfd =st_netfd_open_socket(fd);
for{
st_netfd_t client =st_accept(stfd,NULL,NULL, ST_UTIME_NO_TIMEOUT);
st_thread_create(serverTCP, client,0,0);
}

// ST coroutine服务TCP连接
voidserverTCP(void* arg) {
st_netfd_t client =(st_netfd_t)arg;
char buf[1024];
int n =st_read(client, buf,sizeof(buf), ST_UTIME_NO_TIMEOUT);
}

//////////////////////////////////////////////////////////////////////////////
// RUST async
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
loop {
let(socket, _)= listener.accept().await.unwrap();
    tokio::spawn(processTCP(socket));
}

// 使用async处理TCP连接
async fn processTCP(mut socket: tokio::net::TcpStream) {
    let mut buf = vec![0;1024];
    let n = socket.read(&mut buf).await;
}

Note: RUST的runtime支持单线程和多线程,因此spawn和await时,任务是可能在不同的线程之间移动的。ST只支持了thread local,需要应用层处理任务如何传递,目前还没有支持。Go的M和N模型是多线程调度器,不过它是在语言层面实现,支持的完善度是最高的。

Note: 从性能角度分析,ST完全使用汇编在协程之间切换,没有多线程机制,代码规模也只有5千行左右。RUST的tokio runtime的性能非常高,没有GC的影响。在高性能服务器领域,RUST tokio是比较合适的方案,特别是它的async是第三方库,比较适合定制和修改。可维护性方面RUST tokio比ST要好。

Note: 在兼容性方面,RUST和Go都非常好。SRS的ST由于需要自己实现协程的汇编,在遇到新的硬件和CPU芯片时,需要适配硬件的寄存器和函数调用机制。特别是对于Windows SEH异常机制,ST目前还无法适配。RUST和Go支持了广泛的系统和CPU,没有适配的问题。

Next

流媒体实际上属于传媒领域,在不同的国家传媒的技术可能是不同的,而且有些国家可能对传媒有管制,导致传媒领域可能并不能每个人都可以提供服务。但毕竟SRS的全球用户和开发者,证实了有现实的应用价值。我们的所作所为,能在这个地球上持续发挥作用,是值得持续做的事情。我认为nginx-rtmp最可惜的,是停止了维护,实际上它的用户远比SRS多。

SRS的架构基本上保持了可维护的特点,比起加入更多功能,更需要的是在不同国家和地区倾听不同的声音,尝试不同的技术方案和语言。我想RUST会是一个有趣的常识,尽管最终可能不会落地,但可以从这个过程中吸收很多有趣的想法,就像SRS中有很多Go的想法。此外,没有线下交流,就无法形成有效的开源社区,我们应该去不同国家,线下参与开源的会议和交流。

在维护SRS的过程中,有很多有趣的技术问题,有趣的讨论,有趣的观点。这些乐趣大多不能换成人民币,也无法以此谋生,但若全力以赴只为谋生,未免太压抑了。反过来说,即使对谋生无益处,这些乐趣依然温暖人心。

引用链接

[1] SRS: https://github.com/ossrs/srs
[2] Load Balancing Streaming Servers: https://ossrs.io/lts/en-us/blog/load-balancing-streaming-servers
[3] liburing: https://github.com/axboe/liburing
[4] SEH: https://github.com/ossrs/srs/issues/3251#issuecomment-2046209863
[5] Source cleanup: https://github.com/ossrs/srs/discussions/3667#discussioncomment-8969107
[6] OpenTelemetry: https://opentelemetry.io
[7] SRS Exporter: https://ossrs.io/lts/en-us/docs/v7/doc/exporter

本篇文章来源于微信公众号: SRS开源服务器

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。

(0)

相关推荐

发表回复

登录后才能评论