C++服务器设计(二):应用层I/O缓冲
数据完整性讨论
我们已经选择了I/O复用模型作为系统底层I/O模型。但是我们并没有具体解决读写问题,即在我们的Reactor模式中,我们怎么进行读写操作,才能保证对于每个连接的发送或接收的数据是完整的,而且在某个连接进行读写时对整个系统的其他连接处理影响尽可能小。
在之前我们论述了为什么不能选择非阻塞I/O作为底层I/O模型。同样在I/O复用中,也不能使用非阻塞I/O。因为非阻塞I/O中read、write、accept和connect等系统调用都有可能阻塞当前线程,如果Reactor反应器中注册了多个事件,其中某个事件处理器调用系统调用而阻塞,就算这个线程中依旧存在多个待处理的事件,也无法进一步对这些事件做处理。
因此,非阻塞I/O在Reactor模式中的核心就是避免使系统阻塞在I/O系统调用上,这样才能最大程度的复用线程,让一个线程能服务于多个套接字连接。而在非阻塞I/O中,因为read、write等系统调用中可读写的数据量并不可知,因此对于每个I/O的应用层缓冲也是必须的。
应用层I/O缓冲的运用场景
我们来考虑一个关于输出缓冲的场景:事件处理器希望发送80kb的数据,但是在write调用中,系统由于发送窗口的缘故,只能接受50kb的数据。此时由于不能阻塞继续等待,因此该事件处理器应该尽快交出控制权。这种情况下,剩余的30kb数据应该怎么办?
对于业务逻辑而言,我们在平时应该只管发送数据,而没必要关心我们需要被发送的数据是被一次性发出送出去的还是分成几次发送出去的。因此我们此时应该通过服务器系统应用层缓冲机制,来接管这80kb的数据,同时向反应器注册相应套接字可写的事件。一旦套接字可写事件产生,就尽力发送该应用层中的缓冲数据。当然,这次发送可能只能发送缓冲中一部分的数据,如果应用层缓冲中的数据没有发送完毕,则继续关注该套接字可写的事件,直到下一次可写时继续发送缓冲中的数据。直到该应用层缓冲中的数据被全部发送完毕为止,才停止关注该套接字可写事件。
如果当应用层缓冲中80kb数据只发送完30kb的时候,业务逻辑又需要向该连接发送20kb的数据。我们依旧只需将这20kb的数据追加到该应用层缓冲,然后参照之前的流程将应用层缓冲数据发送完毕。由于我们采用的TCP协议具有有序的特点,而应用层缓冲中的数据也是按序发送的,因此只要我们将追加的数据添加到缓冲的后面,就能保证接收端接收到的数据是按我们第一次发送,第二次发送的顺序接收的。
我们再来考虑一个关于输入缓冲的场景:因为TCP是一个无边界的字节流协议,在一般的网络传输中我们都会在制定相关的应用层网络协议来确定每个消息的边界。根据TCP接收窗口大小是动态变化的可知,当反应器接收到套接字可读的事件时,如果我们对这个套接字进行读操作,也许读到的数据不足以构成一条完整的消息。但是由于我们是选择的水平触发的epoll方式,必须一次性将该可读套接字的数据读完,否则epoll会反复被该水平信号激活并通知该套接字可读事件,使我们整个系统退化为轮询方式。
因此当我们收到“不完整”的数据消息时,应用层输入缓冲也就派上了用场。每当收到某个套接字可读的事件时,可以将该套接字读操作接收的数据全部放入到该套接字的应用层输入缓冲尾部。然后再对该缓冲内的数据进行分析,是否能够构成一条完整的消息。如果不能的话直接将控制权从该事件处理器返回。如果检查到了消息边界,则从应用层缓冲中取出该条消息数据,再调用具体的应用业务逻辑代码。
应用层I/O缓冲需求分析
根据我们对系统的分析,应用层I/O缓冲应该满足如下需求:
- l 类似于一个queue容器,从末尾写入数据,从头部读取数据。
- l 对外表现为一块连续内存,而且长度还可以自动增长,以适应不同大小的消息。
- l 即可支持作为输入缓冲,也可支持作为输出缓冲。
STL中常用数据结构如vector、deque、list容器均可满足第一点要求。
Vector作为单向连续储存容器,需要维护相关下标索引,记录当前读取位置和写入位置分别作为头部和尾部。同时vector本身即为连续内存,可以直接作为读写系统调用的传入参数。vector支持动态增长,但如果超过本身分配内存大小,将会重新分配内存,并对旧数据进行复制转移,此处有一定开销。同时随着容器内数据读写导致读写下标移动,将会出现容器头部置空而数据后移的现象,我们需要动态维护数据位置,防止数据后移导致vector头部出现空间的浪费。
Deque作为动态增长分段连续的双向容器,我们可以直接利用其特点,将其一段作为头部用于读取数据,另一端做尾部写出数据。由于deque同样也是动态增长的,这样通过交由deque本身维护的方式,免去了类似vector需要自己维护下标的烦恼。同时deque也不会出现由于数据移动导致空间浪费的现象。但是deque内部数据并不一定是连续内存的方式进行储存的,也就是说如果我们期望将deque中的某段数据读取出来,并交由read、write等系统调用时,我们必须再开辟一段新的内存,并将该段数据转化为头指针为char* p、长度为int len的形式,才能传送给具体的系统调用参数。因此我们不考虑将deque作为I/O缓冲的底层结构。
List作为双向链表,并非内存连续,同样不适合作为I/O缓冲。原因同上deque分析,不做重复解释。
同时根据我们对输入缓冲和输出缓冲的运用环境的研究,虽然我们将缓冲分为了两个部分,其中输入缓冲从套接字读取数据并写入,再留给业务逻辑从缓冲中读取数据;输出缓冲从业务逻辑中写入数据,并写入到套接字中。这儿的输入缓冲和输入缓冲都是针对客户代码而言,从本质上两者都是相同的设计逻辑,只是读写相反。
我们根据需求出发,在易用性和性能之间做出权衡,最后采用STL的std::vector<char>作为应用层缓冲的底层容器,来保存缓冲数据。
应用层I/O缓冲设计
在应用层I/O缓冲的内部,是一个std::vector<char>,它是一块连续的内存,可以直接作为基本读写系统调用的参数。同时在缓冲中维护两个下标索引,指向该vector中的元素,标示当前可读取位置和当前可写入位置。值得注意的是,这两个下标索引并非传统上的指针,而是直接记录下标值的int类型。因为vector中的内存可能会随着扩容而重新分配,当内存扩容现象发生时,原有的指针迭代器将会失效。
图3-7 初始化缓冲区
如图3-7是一个初始化大小为1024 byte的缓冲的数据结构。结构主体大小为1024 byte的vector<char>,同时存在两个index,分别为readIndex和writeIndex。通过这两个下标,整个连续内存空间可以被分为缓冲头部、readable和writable三个部分。
其中内存空间起始部分0到readIndex部分为缓冲头部。头部一般是由于实际数据后移产生的,由于数据只会从尾部被写入,因此头部的空间将不能被缓冲直接利用,导致出现空间浪费。因此我们应该通过某种策略调整移动数据位置,消除缓冲头部。
从readIndex到writeIndex部分为readable,此部分作为当前实际储存的数据缓冲区。每次从readIndex处开始读取缓冲内的数据,读取多少位便将readIndex右移多少位。其中readIndex到writeIndex的偏移量是当前实际缓冲的数据量。当readIndex和writeIndex相同时,表明此刻缓冲中已无可供读取的缓冲数据。
从writeIndex到内存的结尾部分为writable,此部分作为当前可被写入新数据的缓冲空间。此处虽然有大小限制,但是由于vector是可以动态增长的,当writable大小不足以容纳应用程序希望写入缓冲的数据大小时,将在某些情况下通过vector扩容重新分配一个更大的连续内存空间,并重新填充之前数据,确保能够写入更多的缓冲数据。但是在当前实现中只能满足动态增长,增长后如果数据被读取完毕,此时的内存空间并不能再缩小。
图3-8 写入数据的缓冲区
如图3-8所示,如果向初始化后的缓冲写入800字节数据,readIndex不变,writeIndex后移800个字节,其中readable所示区域即为储存这800个字节数据的内存。此时整个内存部分还剩224字节,这部分即为writable区域,如果要追加更多数据,只需将新数据复制到writeIndex所指内存之后,并继续后移writeIndex下标。
图3-9 读取数据的缓冲区
如图3-9所示,此时从原缓冲readIndex处读取了400个字节的数据,并将readIndex后移400个字节。此时writeIndex未变,writable区域依旧为224字节大小,但由于readable区域被读取了400个字节,因此新的readable区域由800个字节缩小到400个字节大小。
此时在内存空间开头部分到readIndex处,出现了400个字节大小的缓冲头部。因为新写入数据都是写到writeIndex之后,而读取数据都是从readIndex开始,因此缓冲头部所占的空间其实并没有被利用起来。随着缓冲数据的进一步读写,writeIndex和readIndex两个下标索引将会进一步后移,导致缓冲头部区域越来越大,所造成的内存浪费现象也会越来越严重。因此我们需要通过某种机制动态移动调整缓冲数据位置,消除缓冲头部,让这部分内存重新被利用起来。但是移动缓冲数据同样存在一定开销,比如图3-9所示,此时readable区域存在400字节数据,如果将这些数据移动到内存开始位置,就会产生400字节的数据拷贝开销。所以我们不能频繁的进行这种数据调整。
如果我们在图3-9的基础上,继续读取400个字节的数据,readIndex再次后移400个字节,与writeIndex重合。此时缓冲中readable区域大小为0,无可读数据,缓冲头部扩大为800字节大小。我们将readIndex和writeIndex均移动到内存开始位置,因为整个缓冲中并无数据,因此并无数据移动的开销。此时缓冲头部大小恢复为0,之前浪费的800字节内存空间又可以被使用了,整个缓冲看起来又回到了初始化状态。
通过readable为0时对缓冲下标索引进行调整我们能够实现开销最小的情况下重新利用缓冲头部内存的工作。但是我们并不知道系统何时能够达到readable区域为0这个条件。也许存在某个缓冲由于频繁的写入与读出操作导致长期无法达到该条件,如果这种情况发生的话,则很长一段时间中该缓冲都会存在一段被浪费的缓冲头部区域。我们需要进一步完善缓冲头部调整策略。
我们在图3-9的基础上,继续写入300字节的数据。此时剩余缓冲writable区域只剩下224字节大小,无法直接写入300字节数据。我们可以通过让vector扩容的方式,扩大writable区域。但是vector扩容开销极大,我们需要申请更大的连续内存区域,然后将原内存中的数据全部转移到新内存上,最后将旧内存释放。
通过观察可以发现,虽然writable区域只有224个字节大小,但是整段内存中,缓冲头部存在400字节大小的空闲内存。而缓冲头部加上writable区域有624个字节大小,如果稍加调整,在原有内存大小的基础上完全能够再写入300字节的数据。
图3-10 调整缓冲头部的缓冲区
因此当writable区域不足以容纳新数据,但writable加上缓冲头部的大小能够容纳新数据时,我们再次对缓冲头部进行调整。将readIndex移到内存起始位置,同时将原readable区域的数据也复制到内存起始位置,消除缓冲头部。再在数据末尾添加追加的新数据,最后确定当前的writeIndex位置。
最终的结果如图3-10所示。添加了300字节新数据后,writeIndex位于700字节处,此时缓冲中剩余可写入的内存大小为324字节。虽然我们在消除缓冲头部的过程中被迫移动了整个旧数据部分,但是相对于vector扩容的方式,这些开销是相对较小,可以被接受的。
图3-11 扩容后的缓冲区
我们在图3-10的基础上,继续写入500字节的新数据。此时剩余缓冲writable区域只剩下324字节大小,且缓冲头部为空,整个连续内存区域都无法提供500字节大小的空间了。因此此时只剩下vector扩容的方式。在此处,我们通过vector的reserve调用将连续内存扩充为2048字节,由于reserve操作能够帮我们完成旧数据的移动复制操作,因此我们只要在新的writeIndex后添加500字节新数据,并再次调整writeIndex即可。
如图3-11所示显示了扩容后的数据结构,此时writeIndex位于1200字节处,readable区域内数据大小为1200字节,同时整段缓冲中最多还可以添加848字节的新数据。