这是[手把手一起学live555]的第5篇(按这个序号看,请找正确顺序看)。
live555工程在我的gitee下(doc下有思维导图、drawio图):https://gitee.com/lure_ai/live555/tree/master
章节目录链接
0.前言——章节目录链接与为何要写这个?
https://blog.csdn.net/yhb1206/article/details/127259190?spm=1001.2014.3001.5502
学习demo
live555mediaserver.cpp
学习线索和姿势
1.学习的线索和姿势
网络编程
流媒体的地基是网络编程(socket编程)。
[网络编程学习]-0.学习路线。
绘图规则
本文的对象图和思维导图遵守的规则详见:
2.绘图规则
本节内容和目标
(1)TCP非阻塞服务端网络编程流程第一个select节点(TCP非阻塞服务网络编程流程:socket创建、bind、listen、select、accept、select、recvfrom/send、close)
(2)思维导图绘制
(3)对象图绘制
(4)c++纯虚函数继承知识
(5)双向循环链表
正式开始
3.live555mediaserver学习-从socket创建到listen已经追踪到了TCP非阻塞服务端网络编程的listen了,本节主要讲解接下来的流程:第一个select。
对于阻塞式服务端网络编程模式,listen后一般直接accept调用,然后阻塞等待客户端的链接,但是上一节我们一起知道live555是TCP非阻塞服务端网络编程,非阻塞accept是立即返回结果有还是没有,这怎么办?开个线程不断轮询?这也太废cpu了,那怎么办?IO多路复用呀!一般用select呀(几十几百个客户端链接下够用了)。
这个时候我们得把这个服务端的socket找个地方管理起来呀!在哪管理呢?而且最好和已连接的客户端socket一起管理。因为select会放到一个线程里面跑——一个select只监听一个socket也太大材小用了!——会不会合理利用线程资源?我们的目标应该是一个线程中一个select不仅监听服务端socket还要能监听已链接的客户端socket,这样线程资源利用最大化,这样才能榨干CPU性能!
这种需求,就要求我们要把服务端和已连接的客户端socket统一管理起来!具体方案呢,就是套接字放在一起select进行监听,然后监听到了就去匹配socket,匹配到socket则执行对应方法。那么两部分,一部分是socket、方法等信息要保存,这可以用链表链起来统一管理,另外就是select监听集里要放进去,这个操作要操作下。
这是总体思路,live555也是这么做的。这我怎么知道的?我也是看过后才说这些话的。
我是怎么发现的呢?偶然也是必然,追根结底是因为我看代码是怀着网络编程的线索来看的——创建socket后必然是bind,bind后必然是listen,listen后不用想必然是select,select后必然是accept,accept后必然又是select,select后必然是recvfrom/send——这是网络编程天然的线索!小样儿!你这live555代码,能跑哪里去?!(可惜网上没有人从这个角度出发去分析的)
那么来看下live555是如何实现这个方案的。
先把上节的一张图搬来,如下。
3.live555mediaserver学习-从socket创建到listen已经把DynamicRTSPServer::createNew里调用的方法setUpOurSocket讲完了,那么接着就要new DynamicRTSPServer来创建第3个对象了。
那么创建这第3个对象DynamicRTSPServer做了什么操作?和我们的下一个TCP非阻塞服务端网络编程的流程select又有什么关系呢?(其实呢,答案就在上面)
来看下,new这第3个对象的思维导图:
因为思维导图太长了,截图分成了3份再合并到一个图里了——推荐一个非常好用的截图工具——网上搜下snipaste自然知道——谁用谁知道。
如上思维导图,创建DynamicRTSPServer对象的过程都干了什么事?
我只捡我关注的点说:
(1)保存服务端监听套接字socket、端口和请求方法,并放到链表中去管理。
(2)把服务端监听socket放到select的监听集里。
(3)把环境对象BasicUsageEnvironment的基类UsageEnvironment引用保存到对象DynamicRTSPServer基类Medium的成员fEnviron中(见下图)。
(4)其他。
那么现在咱们只需关注(1)和(2)。看下它们是如何实现的,我觉得先上图吧(思维导图你说看的太长不懂,那就结合下面对象图吧!),这样容易理解!我把创建DynamicRTSPServer对象的流动图画出来,如下。
我前面为啥没讲前2个类对象的创建呢?因为直到第3个类对象DynamicRTSPServer创建时它们才派上用场!它们没用的时候,我去干巴巴地讲它们创建、分析它们有啥意思?
从上图看再结合思维导图和代码,我发现:
创建DynamicRTSPServer对象时,拿走了BasicUsageEnvironment对象的基类UsageEnvironment的引用,保存到它的基类Medium的成员fEnviron中。
而创建UsageEnvironment对象创建时,拿走了BasicTaskScheduler对象的基类TaskScheduler的引用,保存到它的基类UsageEnvironment的成员fScheduler中。
——这种创建子类,拿走基类指针/引用来使用的模式叫做接口继承。 ——这可是多态的基础呀,屏蔽了子类实现的细节。可查看我相关博客。这样的话,第3个兄弟(第3个创建的对象)可以调用前面2个兄弟的方法和属性——记住这一点(其实类似c语言的指针,你就可以这么理解)——我才称之为3兄弟。
然后你说你还是不知道这有啥用?你说没看到我说的流动,那是你没有仔细看,也许是我画的那个虚线箭头太细了,好吧,我给你指出来吧,如下图。
图3-1 DynamicRTSPServer对象构造流动
看到了么,这是创建第三个兄弟对象DynamicRTSPServer时的流动。——我把它创建时的构造函数的操作进行了图形化显示!(我真想写成ppt,这样子的话会好许多)
现在拿着上面的对象流动图结合之前的思维导图,再结合代码,来和我一起分析下这对象的构造流程:
来先从图3-1的红色数字1流动方向看起来,建议把上面的图放到旁边对照代码、思维导图看——建议这个图保存下来,或者用snipaste截图工具F1再按F3直接可以驻留当前屏幕——可以任意拖放到屏幕的任何位置,滚轮滚动可以放大缩小,且不影响你看代码和我这篇文笔记。
如图3-1的红色数字1的流程方向,这是DynamicRTSPServer对象创建时执行其父类构造函数GenericMediaServer::GenericMediaServer中会有这一句调用:
env.taskScheduler().turnOnBackgroundReadHandling(fServerSocketIPv4, incomingConnectionHandlerIPv4, this)
而它这个调用链就长了,env是第1个对象BasicUsageEnvironment的基类UsageEnvironment引用,env.taskScheduler()就取到了第1个对象BasicTaskScheduler的基类TaskScheduler的引用,然后这就是调用第1个对象的方法turnOnBackgroundReadHandling的走向,即图3-1的红色数字2的走向,接着就走图3-1的红色数字就是3、4的流向——2调用3但是3是个纯虚函数,它的实现是在子类的子类实现的——红色数字4的流向。——这就涉及到c++的纯虚函数继承的知识点。
c++纯虚函数继承
有没有想过为啥TaskScheduler::setBackgroundHandling这个虚函数不在它的子类
BasicTaskScheduler0实现的,而是在它子类的子类TaskScheduler里实现的?不是说继承父类包含纯虚函数的子类不实现该纯虚函数会编译报错么?
注意注意,这个知识点——如果继承的父类含有纯虚方法的子类不去创建子类对象则不实现该纯虚方法也是可以的,没问题的。
而我们上图中的TaskScheduler::setBackgroundHandling纯虚方法在它子类的子类里实现的,是因为没人去实例化它的子类,反而是会实例化它的孙子辈类——BasicTaskScheduler类,所以在它的孙子辈类实现了该纯虚方法。——另外它的其他纯虚方法已经在它的子类BasicTaskScheduler0里实现了,总体来说实例化孙子辈类——BasicTaskScheduler类,所有父类的纯虚方法都已经被实现了。所有没问题。
回来,继续看我们的图的流向——最终找到了红色数字4流向的节点,即最终调用的是:
BasicTaskScheduler::setBackgroundHandling(fServerSocketIPv4, SOCKET_READABLE, incomingConnectionHandlerIPv4, this),
如下图:
因为传入的第二个形参是SOCKET_READABLE,所以在这里它会把这个服务端socket加入监听可读的socket集(BasicTaskScheduler的成员fReadSet)里。
这样就把服务端socket添加到select的监听集里面了!此时,我们前面的一个构思实现了。接着就是看它是怎么将socket和对应的执行方法保存起来放到链表里管理的了。 ——离select越来越近了!
它又调了这个函数:
fHandlers->assignHandler(fServerSocketIPv4, SOCKET_READABLE, incomingConnectionHandlerIPv4, this)
这对应于图3-1的红色数字5
既然调用了BasicTaskScheduler0的成员fHandlers的assignHandler方法,那我先介绍下fHandlers在什么时候创建的,是什么类型,然后再介绍这个被调用的方法。
BasicTaskScheduler0的成员fHandlers创建时机
来,看图3-1,BasicTaskScheduler0的成员fHandlers是在什么时机创建的呢?是在第一个兄弟BasicTaskScheduler对象new出来的时候创建的。如下图,BasicTaskScheduler的父类BasicTaskScheduler0的构造函数里new HandlerSet出来后给成员fHandlers了,fHandlers的类型自然是 HandlerSet*。
BasicTaskScheduler0的成员fHandlers所在位置图形化表示如下图。
如上图,它指向的对象是图中左上的HandlerSet类对象——它连接到了图中右边BasicTaskScheduler0类的成员fHandlers(这个时候是实线+实心棱形是包含关系)。——注意,HandlerSet类里也有一个成员叫fHandlers——但是类型和BasicTaskScheduler0类的成员fHandlers不一样,不同类的成员只是同名而已,可不要混淆了——搞不懂为啥起一样的成员名字,难道没发现阅读代码会误导人么?——幸好我有这图。
而HandlerSet是管理链表的对象。它的成员fHandlers指向链表头。在new HandlerSet时,它的构造函数HandlerSet::HandlerSet会调用这个链表头的构造函数HandlerDescriptor::HandlerDescriptor,在这里它会把链表头结点HandlerSet的成员fHandlers初始化完毕——主要是将next和prev都指向它自己,如下函数整理(用截图工具spinaste绘制)。
分析下上面这个构造函数,它的形参nextHandler是指向链表的队尾(队头在最右,队尾在最左),如果队尾和这个构造函数创建的对象地址一样,那说明这个形参就是链表头——说明你要初始化链表头呀——这时走if语句。如果链表不为空,那么形参nextHandler指向的链表尾和当前的新开辟的对象地址肯定不一样,那此时走else流程。
而上图就是介绍链表头是如何初始化的——在HandlerSet::HandlerSet里初始化fHandlers时,调用HandlerDescriptor构造函数传入的形参是它本身的地址——那形参nextHandler传递的就是链表头的地址,this也是指向这个链表头,所以走的是if语句。链表头初始化后的模样如下图。
可以看到,此时,链表头next和prev都是指向自己——自己指向自己的地址,自己和自己玩耍——链表头表示很孤单、很寂寞、很冷。
到这介绍完BasicTaskScheduler0的成员fHandlers是何时创建及初始化的了。那么继续分析调用的它的方法assignHandler,对应图3-1 红色数字5的流向,如下图HandlerSet::assignHandler做了什么呢?它做了两件事:
(1)调用new HandlerDescriptor并添加到 HandlerSet 维护的fHandlers链表中去。这就完成了之前我们构思的链表管理了。——对应到图3-2的红色数字6。
(2)把服务端监听socket及其对应的处理函数等都传给这个new出来的链表成员对象了——HandlerDescriptor类对象了。它的作用(意义):
保存服务端监听socket用以select匹配监听到后是哪个socket
保存对应的执行方法——select只要监听到有新客户端链接后就执行对应方法,保存的方法如下:
GenericMediaServer::incomingConnectionHandlerIPv4(void* instance, int /mask/)。
这个链表成员是第1个入队的,和链表头是元老,固定的——保存服务端socket等信息的链表队员。它也是唯一的、且不重复的,因为只创建了一个服务端监听socket。
第2条不用多讲,详细分析下第1条的创建链表新队员并入队的过程——有意思,new HandlerDescriptor时调用的构造函数如下图。
形参nextHandler指向队尾,此时队尾就是链表头——链表队列为空时,队尾和队头都指向链表头。新new出来的这个队员(this)肯定和队尾(nextHandler)不是同一个地址,上图走else流程——一顿操作后,就把新队员挂到了HandlerSet对象成员fHandlers这个链表头的左边,成为了链表的一员。此时,链表图的模样如下:
链表头终于不孤单了——链表有2个成员了,一个是队长,一个是队员,好基友。
** 可以看到这个是双向循环链表**。
——实际上,此时链表成员应该有3个,一个是链表头,一个是保存IPV4服务端端口554/8554的信息的队员,一个是保存IPV6服务端端口554/8554的信息的队员,不想画了,一样的流程。——记住此时链表已经有2个队员了(去除链表头这个队长)
但是这个链表队列特殊的是new HandlerDescriptor的时候形参传入队尾的指针,新队员就顺势把自己插入链表了——原来链表插入可以这么搞的!!大开眼界!
到此,我们开头的第2个构思实现了。
到此,new DynamicRTSPServer的操作已经讲完了。
到此,也已经把socket加入select监听集和链表管理流程讲完了。
好了,怎么样,结合对象图、思维导图和代码,是不是非常容易理解代码?而且关键是图形化了!我的目标和追求就是看完后,这些图能在大脑中动起来!
视野再切换到main函数里,按顺序应该是下面的流程了:
略过那个IP打印——这个主要是获取本地的诸多网卡中的一个网卡地址——因为创建服务端socket时绑定的IP是ANY——只要你往你本地的任意一个网卡发送数据它都会接收到如果是传给554/8554那它都能收到——后面可以实验下,url的IP改成本地的任意一个网卡IP,这个服务器照样能收到。
注意这个IP地址打印的IP信息如果获取到ipv4的地址了就不再去获取ipv6的地址了。
接下来是http的服务端socket创建,它要绑定80/8000/8080端口——和我们创建服务端socket并绑定554/8554的流程一样,不分析了,重复了——最后结果是:创建了2个新的链表队员放到链表里,然后把这2个socket加入select监听集。——一个是保存IPV4的http服务端socket信息,另一个是保存IPV6的http服务端socket信息。——这块就不展开了,和我们之前讲的流程再重复一次就行了。
此时,实际的链表图我也不画了,我只介绍下就行了,此时链表里应该有4个队员+一个链表头队员共5个成员。具体,从右到左:
链表头(队长),
第1个队员:IPV4的绑定554/8554端口的服务端监听socket队员,对应方法GenericMediaServer::incomingConnectionHandlerIPv4(void* instance, int /mask/),
第2个队员:
IPV6的绑定554/8554端口的服务端监听socket队员,对应方法GenericMediaServer::incomingConnectionHandlerIPv6(void* instance, int /mask/),
第3个队员:
IPV4的绑定80/8000/8080端口的服务端监听socket队员,对应方法RTSPServer::incomingConnectionHandlerHTTPIPv4(void* instance, int /mask/)
第4个队员:
IPV6的绑定80/8000/8080端口的服务端监听socket队员,对应方法RTSPServer::incomingConnectionHandlerHTTPIPv6(void* instance, int /mask/)
其实画图也好画——在后续的总结里会放出该图。
从这可以看到live555的伸缩性很强——支持标准rtsp端口554/8554,支持rtsp over http(端口80/8000/8080)。
我这一节的目标是select在哪里?——铺垫了这么多,准备了这么多,终于才能来到select身边——live555mediaserver.cpp的main函数的最后2句,如下图。
来上我的思维导图:
select在哪里? 在BasicTaskScheduler::SingleStep里,你又会问:你怎么又知道?来上图,我慢慢道来。
图3-2 main循环和select追踪
先看下调用代码:
env->taskScheduler().doEventLoop();
其中env->taskScheduler()就是拿到了对象BasicTaskScheduler基类TaskScheduler的引用,而再.doEventLoop()就是调用基类TaskScheduler的成员doEventLoop(对应图3-2数字1的流向),而它是纯虚方法,它的实现是在子类BasicTaskScheduler0来实现的(对应图3-2数字2的流向)来看下它的实现:
它是个while循环,变量watchVariable默认是NULL的。在这个循环里,它调用了BasicTaskScheduler0::SingleStep(对应图3-2数字3的流向),然鹅,它也是个纯虚函数:
它的实现在在哪呢?也在它的子类——BasicTaskScheduler类里实现的——最后指向了BasicTaskScheduler::SingleStep(对应图3-2数字4的流向),用snipaste截图工具截取了下我关注的点(很容易截取几秒种3步搞定——就问你想不想用这么好的截图工具?):
而我们前面已经说过在BasicTaskScheduler::setBackgroundHandling里面,已经将服务端socket添加到BasicTaskScheduler的socket监控集fReadSet里了,而这个时候,BasicTaskScheduler::SingleStep里就把它进行select监听了。——原来select在这里!
此时,万事具备只欠东风了——只待客户端来发起链接了。
好,本次select流程节点结束,下一节追踪accept流程节点。
我发现在select前,live555做了很多准备工作,主要是2个:第一个是添加socket到select监控集,第二个创建一个链表成员将socket和对应的方法等保存起来,并用双向循环链表管理起来。
也就是说前半部分是为select做了许多准备,导致我们这节前部分内容也很长,但是最后高潮部分却特别短。