多线程下的select网络程序结构

时间:2022-03-27 15:04:54
我一直坚信,如果不是处理大规模客户端连接,是不需要使用epoll和IOCP的。我倾向于简单的东西,所以我一直用着select。
一直以来,我的网络程序结构就是在每一帧的开始select,有什么消息就处理一下,然后跑程序的主逻辑。我觉得这个结构挺好,单线程,简单、明了、优雅。
不过最近有头儿告诉我,这个事情虽然可以,但是感觉上不太对头,网络组件的工作应该是独立的,不可以占用主逻辑的时间。
好吧,我改成多线程就是了。一个主线程,负责处理客户端消息和运算主逻辑;一个网络线程,负责从网络上读取数据和将数据发送到网络上,基本上就是select,recv,send三调用。

这里出现了第一个问题:主线程和网络线程之间如何进行数据交换?主线程中待发送的数据需要交给网络线程做实际的发送,网络线程接收到的数据需要交给主线程处理。
基于尽可能短的lock-time这一原则,我给主线程和网络线程各分配了一个完全一样的容器,这个容器由各线程独自享有,容纳发送和接收的数据。
然后在特定的时候,lock主其中一个容器,进行数据拷贝即可——将欲发送的数据从主线程容器拷贝到网络线程的容器,将收到的数据从网络线程的容器拷贝到主线程的容器。
这一lock只有拷贝工作,时间上应该是十分短暂的。

第二个问题:由谁负责这个拷贝,主线程还是网络线程?负责拷贝的线程,必然去lock另一个线程的容器。
我选择了主线程负责拷贝操作。在每帧的开始,锁住网络线程的容器,将它收到的数据拷出来,将要发送的数据拷进去,解锁,然后处理收到的消息。网络线程则需要在操作自己的容器的时候加锁。
好处是,主线程的 send_packet 操作不需要加锁,并且收到的数据是拷出来就消耗掉。
顺便也想想网络线程负责拷贝的情形,在select之前,锁住主线程的容器,将欲发送的数据拷进,解锁;然后是select,recv,send;然后再次锁住主线程的容器,将收到的数据拷出。相应的,主线程需要在操作自己容器的时候加锁。
看起来我并不想主线程在一帧内有太多次的加锁解锁操作,因此就选择了第一个方案。

至此,程序跑起来了。不过出现了一个单线程所没有的新问题——CPU占用率太高了。
原因应该是,select能挂起程序,所以单线程的时候,程序多多少少总会有挂起的机会;但是多线程以后,主线程就跟while ( true )差不多,浪费了太多的资源。
因此,让主线程在每帧也睡一会就好了。游戏的主逻辑是限帧的,一般每秒25帧,称逻辑帧。但是处理网络消息不是限帧的,而是希望能尽可能快的处理他们,因此处理网络消息是在实际帧中进行的。
通常游戏主逻辑的一次tick并不能完全消耗掉一个逻辑帧的时间,因此让主线程在逻辑帧剩下的时间里睡上一觉就好。

第三个问题是:如何让主线程在剩下的逻辑帧时间里挂起,并在有网络消息的时候立即激活?
信号/EVENT——主线程在进行容器的数据拷贝之前,如果自己没有欲发送的数据,则等待信号,等待的时间是上一个逻辑帧所剩余的时间。相应的网络线程中,如果收到新的数据,则激活这个信号,那么主线程会被立即唤醒。
等待超时或者被唤醒后,就会执行数据拷贝和消息处理。这样,既实现了sleep,又兼顾了即时反应能力。

编译运行,程序看起来挺稳定,CPU占用率为0.。。。。。。新项目,逻辑上几乎啥都没有呢。