前言
??前面的两篇文章已经记录了服务器收到完整的数据包之前的所有流程,这篇将记录收发数据包、线程池的使用以及多线程技术。 ??实际上,数据包的定义和多线程的实现都是可以长篇大论的内容,但是目前我们只去梳理一下服务器框架的核心流程,所以暂时不会详细去讲。
研究的出发点
??上一篇已经把网络通信的架构实现了,那么接下来研究的问题就是,收到客户端数据之后我们服务器该干什么。 ??回忆一下上篇的内容,我们的客户端socket连接到服务器,即完成三次握手之后,其对应的epoll类型事件会被我们取出来。我们暂时取出来了三样东西:事件类型、读事件函数指针,写事件函数指针。事件类型在判断环节当场就用上了,显然,我们接下来要去研究读事件函数和写事件函数。 ??数据包的定义,拆包和合成包都是很重要的步骤,但是这次我们的目的是快速缕清架构流程,因此暂时不做解释。
从读事件函数出发
??首先我们需要明确一个点,就是这个时候线程池已经被创建完毕了。假设线程池里我们设置是50个线程,那么此时此刻,必然有50个线程存在着,只不过它们目前都阻塞在自己的入口函数里等待唤醒,可以理解为都在睡觉。 ??我们用一个数据结构定义包头,包头的内容和长度是我们必须知道的,此外包头内还需要记录一个重要数据:包体的长度。知道这些后就可以写读事件函数了。 ??我们的读事件函数事务是设置为LT模式的,因此在函数中不需要循环判断当次数据是否收集完,因为即使一次收集不完,epoll依旧会自动触发我们的读事件函数去继续收数据。 ??首先我们立刻调用recv 函数去收取字节,根据目前收到的字节数量先设定三个状态:刚开始收数据、收到数据了但是还没有把包头收全,包头已经收全。在包头收全之后我们去判断包头是否合法,判断合法之后我们为其增加一个消息头,消息头用来记录该数据是否过期(客户端断开连接之后该数据就过期了以及客户端socket对应的连接池内存块)。 ??之后开始收取包体,由于在包头里记录了该包内有多少个字节的包体,因此随后固定收取这个字节数的包体就可以了。 ??最后,我们收到了一个完整的包,由于我们对数据包的处理是依靠线程池进行的(有时候数据包可能会很多,因此需要多个线程来并发处理)。 ??我们回忆一下最常见的多线程模型,再对应到这个情景中来,是不是类似于传统的生产者-消费者模型?生产者就是读事件函数,它源源不断的获得数据包,我们的多线程都是消费者,一起去拿包然后解包。因此我们的设计也要模仿这个模型来进行。 ??我们设计一个队列,该队列里保存收到的数据包内容,随后线程池的线程们依次来这个队列取数据包内容。 ??于是在我们收到一个完整的数据包之后,应该执行的操作为: ??第一步:先给消息队列的操作上互斥锁。 ??因为消息队列目前是个临界量,因此关于它的任何操作都应该在互斥锁的区域中进行。 ??第二步:将数据包放入到设计好的队列中。 ??第三步:唤醒一个线程,令该线程从等待状态中唤醒。 ??在代码中可以视作该线程跳出了while循环,从入口函数中继续向下走,执行从消息队列中取数据包内容的代码。 ??取到数据包之后,我们做一些关于数据包信息的判断,比如CRC32校验等等。包头里除了刚才说到的包体长度外,还存了一个数字。这个数字是客户端开发人员与服务器开发人员共同协定的,即某某数字代表使用某某业务处理函数。比如数字1代表升级武器装备函数,数字2代表商店物品购买函数等等,服务器开发人员需要根据包头存的数字来选择合适的业务处理函数去处理数据,关于业务处理函数的内容就不在我们的讨论范围内了。 ??需要额外一提的是,同一个客户端发来多个数据包,我们应当对其使用互斥。比如说用户在游戏中有100块钱,他想买一个90块钱的东西,不知道是什么原因(有可能是多次点击之类的),他在短时间内发出了多个相同的数据包,都是为了买那个东西。如果这几个数据包都被线程们拿去并发执行,那这个用户等于100块钱买了n个90块钱的商品,岂不逆天?因此同一个用户的几个数据包中,要采用互斥锁进行临界,避免它们被一下子同时处理掉。
从写事件函数出发
??除了接收客户端的数据包,我们还要向客户端写数据包,因此写事件函数也是必须的。 ??向客户端发送数据我们采取多线程技术,将需要发送的数据包也放到一个消息队列中,然后单独用一个线程来进行数据发送,来减少主线程的任务压力。 ??这个时候问题来了:你说你要用单独的线程来发送数据包,那发消息这件事怎么和我epoll扯上关系?你给我注册的写事件函数又是为了啥? ??其实写事件的设计思路是这样的:我们有数据包了之后先用最普通的send() 函数去发送,单独用send() 发送数据包是很稳妥的。但是可能会出现一个情况:服务器的发送缓冲区和客户端的接收缓冲区都被塞满了。出现这种情况的原因大概率是客户端接收数据包的频率比服务器发送数据包的频率低。这个时候我们就不能使用send() 函数去发送了,但此时还有需要发送的数据包,那该怎么处理这些数据包呢? ??这个时候epoll就又出现了,高并发自动处理技术岂是浪得虚名?当我们的缓冲区都被塞满时,我们可以将需要发送的数据包通过epoll中对应客户端socket的节点中的写事件来实现,这样我们就不需要去关心后续,因为epoll会自动帮我们在缓冲区有空间时将数据包发送。 ??因此我们调整研究的起点,从发送数据线程的入口函数出发,研究数据发送的流程。
从发送数据线程的入口函数出发
??我们开始用多线程程序设计的思路来考虑一下发送数据线程是如何工作的。首先,该线程在很早的初始化工作流程中就已经初始化完毕了,也就是创建的时间在整个服务器流程中算比较早的。显然,在刚创建的时候是不可能有数据包让它去发送的(客户端都还没有连接上),其次,客户端即便连接成功了,也未必随时都有数据包应该去发送。 ??这样一分析我们会发现,在该线程的工作生涯中,必然是有一段时间不去工作的,对于线程来说不去工作的最好处理方式就是阻塞,将时间片让给有需要的同志。 ??于是该线程应该引入阻塞-唤醒机制,本质上与收数据包时唤醒线程池里的线程来取数据包是一样的。我们这里可以使用相同的cond-signal 互斥量函数来实现这个线程同步机制,也可以使用wait-post 信号量函数来实现这个线程同步机制。 ??当线程被唤醒后,就从需要发送的数据包消息队列中取数据包,开始向客户端发送。当发送的数据没发完,只发送了一部分说明缓冲区已经慢了。在这种情况下我们需要该客户端socket的发送事件添加到epoll红黑树中,这样epoll就可以帮我们在缓冲区有空的时候去发送数据了。 ??那么什么时候线程被唤醒去发送数据包呢?其实这个是靠业务处理函数决定的,更确切的说是读事件的业务处理函数。因为客户端和服务器的数据一般都有来有回的,客户端的数据发送到了服务器中,服务器调用业务处理函数去处理客户端的数据,在业务处理函数中将客户端的请求整明白了之后进行一些业务操作,生成需要回复客户端的消息,并将其封装成数据包放在待发送的数据包消息队列中。随后发送通知,喊发送数据包的线程起床干活。 ??好,既然发送数据包的线程的工作流程我们搞清楚了,我们去看看epoll那边怎么处理数据包发送的。 ??当可以发送数据到缓冲区时,epoll会自动调用该客户端socket绑定的内存块中注册的写事件函数,继续调用send() 函数发送数据,当数据发送完成之后,我们会将epoll上对应的写事件通知从红黑树上删掉。注意这里我们删掉的只是写事件通知,而不是整个红黑树节点。因为如果这个时候不删除,下次需要发数据就直接靠epoll来实现了,这就违反了我们的设计思路。 ??在删除了写事件通知之后,我们应当立刻去唤醒发数据包的线程,因为这时候缓冲区有空了,所以应当将其唤醒一下并令其判断是否需要工作。
结束语
??自此,整个服务器框架的工作流程就解释完毕了。服务器开发中设计非常多的细节,是相当难处理的,因此在该文章中尽量忽略了很多细节的处理方案,因为我们需要先对框架的整体流程有一个认识,才有利于今后对细节的处理。
|