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字节的新数据。

时间: 2024-08-10 09:34:39

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

C++服务器设计(三):多线程支持

如今大多数CPU都具有多个核心,为了最大程度的发挥多核处理器的效能,提高服务器的并发性,保证系统对于多线程的支持是十分必要的.我们在之前的设计都是基于单线程而言,在本文中我们将对系统进行改进,在进一步提升系统性能的同时保证系统对于多线程的支持. 首先考虑这么几个问题,我们之前已经选定了基于I/O复用的Reactor模式,那么在多线程环境下我们该如何处理这些I/O?多线程同时处理同一个套接字描述符安全吗?Reactor模式支持多线程吗? 根据查阅文档可知,针对文件描述符的常见系统调用如read.w

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

一.追溯 去gameloft笔试,有一个题目是说: 叫你去设计一个FPS(第一人称射击游戏),你是要用TCP呢还是要用UDP,说明理由 . 二.学习 这是两篇网上找到的文章,写非常不错. 当时笔试的时候自己没想到这么全,但大概想法都是一致的,摘录下来再学习一下. 1.网络游戏程序员须知 UDP vs TCP 作者:[email protected] 首发链接:http://blog.csdn.net/rellikt/archive/2010/08/21/5829020.aspx 这篇教程让我们就

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

Python服务器开发二:Python网络基础 网络由下往上分为物理层.数据链路层.网络层.传输层.会话层.表示层和应用层. HTTP是高层协议,而TCP/IP是个协议集,包过许多的子协议.包括:传输层的 FTP,UDP,TCP协议等,网络层的ip协议等,高层协议如HTTP,telnet协议等,HTTP是TCP/IP的一个子协议. socket是对TCP/IP协议的封装和应用(程序员层面上).也可以说,TPC/IP协议是传输层协议,主要解决数据如何在网络中传输,而HTTP是应用层协议,主要解决如

Photon服务器引擎(二)socket/TCP/UDP基础及Unity聊天室的实现

Photon服务器引擎(二)socket/TCP/UDP基础及Unity聊天室的实现 我们平时说的最多的socket是什么呢,实际上socket是对TCP/IP协议的封装,Socket本身并不是协议,而是一个调用接口(API). 通过Socket,我们才能使用TCP/IP协议.实际上,Socket跟TCP/IP协议没有必然的联系.Socket编程接口在设计的时候,就希望也能适应其他的网络协议.所以说,Socket的出现只是使得程序员更方便地使用TCP/IP协议栈而已,是对TCP/IP协议的抽象,

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

这个系列把毕业论文的部分贴了出来,以作保存留念.整个系列分为三大部分,其中第一章到第三章是介绍服务器的系统层设计,设计思路参考了libevent和muduo等开源代码的实现:第四章到第六章是介绍服务器的服务层设计,设计思路参考了自己的Khala实现:第七章介绍了如何利用该服务器框架实现一款类似于QQ的聊天系统.全文主要参考了陈硕的<Linux多线程服务端编程>.<Unix网络编程卷1>. 系统简介 本系统是用C++设计实现的TCP网络服务器框架.该系统底层I/O部分采用基于Reac

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

多线程探讨 如今大多数CPU都具有多个核心,为了最大程度的发挥多核处理器的效能,提高服务器的并发性,保证系统对于多线程的支持是十分必要的.我们在之前的设计都是基于单线程而言,在此章我们将对系统进行改进,在进一步提升系统性能的同时保证系统对于多线程的支持. 首先考虑这么几个问题,我们之前已经选定了基于I/O复用的Reactor模式,那么在多线程环境下我们该如何处理这些I/O?多线程同时处理同一个套接字描述符安全吗?Reactor模式支持多线程吗? 根据查阅文档可知,针对文件描述符的常见系统调用如r

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

转载请注明出处,欢迎大家批评指正 1.数据服务器详细设计 数据服务器在设计上采用三个层次的数据同步,实现玩家数据的高速获取和修改. 数据层次上分为:内存数据,redis数据,mysql数据 设计目的:首先保证数据的可靠,防止数据丢失,保证数据完整.然后实现数据的高速访问,减少由玩家数量增加对数据服务器性能造成的影响.最后实现运维数据的入库,以及数据持久化. 在这个基础上数据服务器不再是一个单一服务器,它涉及到与其他服务器之间的交互. 数据服务器的核心在于redis数据层面.通过redis加快玩家

ASP.NET MVC +EasyUI 权限设计(二)环境搭建

请注明转载地址:http://www.cnblogs.com/arhat 今天突然发现博客园出问题了,老魏使用了PC,手机,平板都访问博客园了,都是不能正常的访问,原因是不能加载CSS,也就是不能访问common.cnblogs.com这个域名,一直出现"Aborted",非常的郁闷. 页面就是这样子的,不知道为什么,难道是不是我的3个终端有问题吧,还是园子的服务器有问题呢?还是路由器的问题呢?到现在这个问题还没解决,郁闷死了!弄得心情非常的不爽. 好吧,不在说这个问题了,开始我们的正

Linux操作系统的管理(操作系统与服务器)二

在众多的操作系统里为什么有些操作系统能够脱颖而出呢?很多人不知道这些操作系统不仅仅只是我们在电脑上安装,然后玩游戏.看电影.办公用的.每种操纵系统都有他们的作用透过这些操作系统我来给大家介绍几种"电脑". 世界上第一台电子计算机是阿塔纳索芙-贝瑞计算机,1949年美国宾夕法尼亚大学经过了几年的努力才研究出了世界上第二台电子计算机埃尼阿克.在当时埃尼阿克长30.48米宽1米,占地面积约170平方米,30个操作台,重30多吨,耗电量150千瓦,造价48万美元,它包含17468根真空管.72