数据完整性讨论

  我们已经选择了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字节的新数据。

C++服务器设计(二):应用层I/O缓冲的更多相关文章

  1. reactor模式前序(二):NIO WEB服务器设计

    前文介绍了传统IO的WEB经典服务器 reactor模式前序:传统IO的WEB服务器设计 下面看看JAVA NIO的WEB服务器设计 NIO是基于事件驱动的,对于NIO来说,重要组件是Selector ...

  2. 基于内存,redis,mysql的高速游戏数据服务器设计架构

    转载请注明出处,欢迎大家批评指正 1.数据服务器详细设计 数据服务器在设计上采用三个层次的数据同步,实现玩家数据的高速获取和修改. 数据层次上分为:内存数据,redis数据,mysql数据 设计目的: ...

  3. C++服务器设计(零):总体设计

    这个系列把毕业论文的部分贴了出来,以作保存留念.整个系列分为三大部分,其中第一章到第三章是介绍服务器的系统层设计,设计思路参考了libevent和muduo等开源代码的实现:第四章到第六章是介绍服务器 ...

  4. C++服务器设计(三):多线程模型设计

    多线程探讨 如今大多数CPU都具有多个核心,为了最大程度的发挥多核处理器的效能,提高服务器的并发性,保证系统对于多线程的支持是十分必要的.我们在之前的设计都是基于单线程而言,在此章我们将对系统进行改进 ...

  5. 基于内存,redis,mysql的高速游戏数据服务器设计架构 ZT

    zt  http://www.cnblogs.com/captainl1993/p/4788236.html 1.数据服务器详细设计 数据服务器在设计上采用三个层次的数据同步,实现玩家数据的高速获取和 ...

  6. FPS游戏服务器设计的问题 【转】

    一.追溯 去gameloft笔试,有一个题目是说: 叫你去设计一个FPS(第一人称射击游戏),你是要用TCP呢还是要用UDP,说明理由 . 二.学习 这是两篇网上找到的文章,写非常不错. 当时笔试的时 ...

  7. (转)FPS游戏服务器设计的问题

    FPS游戏服务器设计的问题出处:http://www.byteedu.com/thread-20-1-1.html一.追溯 去gameloft笔试,有一个题目是说: 叫你去设计一个FPS(第一人称射击 ...

  8. Python服务器开发二:Python网络基础

    Python服务器开发二:Python网络基础   网络由下往上分为物理层.数据链路层.网络层.传输层.会话层.表示层和应用层. HTTP是高层协议,而TCP/IP是个协议集,包过许多的子协议.包括: ...

  9. Redis缓存项目应用架构设计二

    一.概述 由于架构设计一里面如果多平台公用相同Key的缓存更改配置后需要多平台上传最新的缓存配置文件来更新,比较麻烦,更新了架构设计二实现了缓存配置的集中管理,不过这样有有了过于中心化的问题,后续在看 ...

随机推荐

  1. 1、发布C++实现的TCP网络框架Khala

    1.Khala简介 Khala(卡拉)是用C++实现的TCP网络框架.底层采用muduo网络库作为网络IO+线程模型,并封装实现了网络实现与业务逻辑分离的多线程网络框架,具有超时退出.多设备多事件注册 ...

  2. C/C++:Unions 联合

    原文:http://msdn.microsoft.com/en-us/library/5dxy4b7b(v=vs.80).aspx 联合是用户定义的数据或类类型,在任何时间里,它只包含成员列表中的一个 ...

  3. 客户端技术:Cookie 服务端技术:HttpSession

    客户端技术:Cookie 服务端技术:HttpSession 07. 五 / android基础 / 没有评论   一.会话技术1.什么是会话:客户打开浏览器访问一个网站,访问完毕之后,关闭浏览器.这 ...

  4. dedecms友情链接flink的调用方法

    标记名称:flink[标签简介][功能说明]:用于获取友情链接,其对应后台文件为"includetaglibflink.lib.php".[适用范围]:全局标记,适用V55,V56 ...

  5. 布尔值(Boolean values)

    布尔值是特殊的整数. 尽管布尔值由常量 True 和 False 来表示, 如果将布尔值放到一 个数值上下文环境中(比方将 True 与一个数字相加), True 会被当成整数值 1, 而 False ...

  6. 列表:一个打了激素的数组3 - 零基础入门学习Python012

    列表:一个打了激素的数组3 让编程改变世界 Change the world by program 列表的一些常用操作符 比较操作符 逻辑操作符 连接操作符 重复操作符 成员关系操作符 ...... ...

  7. Python网络编程踩的坑

    错误:socket.error: [Errno 10013] 原因:端口号被占用 解决:换其他的端口号或者将其他应用的端口号关闭 错误:File "D:/pyworkspace/homewo ...

  8. sgu Ice-cream Tycoon

    题意:供应商提供n块价格为c的冰淇淋,一个学生想买n块冰淇淋,手中的钱数总共有t元,为了不让买n块冰淇淋所花费的钱数不超过t元,先尽可能卖给这个学生便宜的冰淇淋. 如果这个学生不能买到所需要的冰淇淋则 ...

  9. 微软在MSDN中更新了Win8.1批量授权版镜像(中文版更新完毕&版本说明)

    微软在MSDN中更新了Win8.1大客户专业版和企业版镜像,零售版镜像(即专业版+核心版二合一镜像)没有更新,依然是9月份发布的版本.已证实,新的批量授权版镜像是集成了GA Rollup A更新,并且 ...

  10. 阵列卡,组成的磁盘组就像是一个硬盘,pci-e扩展出sata3.0

    你想提升性能,那么组RAID0,主板上的RAID应该是软RAID,肯定没有阵列卡来得稳定.如果你有闲钱,可以考虑用阵列卡. 不会的.即使不能起到RAID的作用,起码也可以当作直接连接了2个硬盘.不会影 ...