一个典型网络引擎的设计与实现

来源:互联网 发布:大数据信息平台 编辑:程序博客网 时间:2024/06/06 08:50

作者声明: 本文的内容是根据作者的实际开发经历所写,并不代表对或错,只是作者认为,这个网络引擎的实现具有普遍的意义,因此提出供各位参考,至于是否采用以及是否适合您自己的开发,与作者无关。

在CSDN论坛上注册了好多年了,从一个编程小菜鸟逐渐变成一个老菜鸟,经常潜水,因为本人偏好网络编程,所以经常关注MFC网络编程版块,其实一样,UNIX/LINUX下也是这么编写,原理基本一样,随着多核心应用的普及,MFC网络编程版块里使用多线程进行网络编程的问题也多了起来,基于本人刚开发完成FtpAnywhere的一点点心得,写下了这篇文章。

首先,如果您对TCP/IP网络编程还不是太熟悉,那么作者推荐您去看 Richard stevens写的《UNIX网络编程》和《TCP/IP协议卷1-3》,不要怕苦,枯燥是枯燥了点,但是没有这个基础,你在网络编程的世界里,很容易人云亦云,却不知道为什么?

现在,我们进入正题,什么是网络引擎呢?游戏有自己的3D引擎,WEB里有搜索引擎,在这里,网络引擎是我对网络数据处理行为的一个封装,严格意义上说,它包含了网络数据引擎和指令处理引擎,这两个合而为一,当然也可以分开,但是,为了高效处理,避免不必要的延迟和中间开销,我将它合而为一。网络程序的编写,无非是 发送数据-》接收数据-》处理数据 这么一个循环,如果仅有几个或者几十连接,使用多线程直接封装所有的相关操作,也是一种可行的方式,但是,随着现在宽带的发展,或者是为服务器而设计,那么连接常常是几百上千上万的,这个时候再使用一个连接一个线城的方式,光线程切换时间和线程本地栈的虚拟分配,就会吃掉电脑的相当部分资源,在这个时候,网络引擎就派上用场了。

下面这个图片是我编写的一个服务器软件的结构图(非阻塞TCP):

http://www.snowware.com/cn/fastruct.JPG [图片不会显示,只好给URL了]

在这个结构中 ,网络处理引擎和指令处理引擎是核心,ftp/sftp/vlink/proxy监听线程,负责接收新的SOCKET连接,接收到SOCKET后,放入全局内存池中的全局用户结构中,并标记为已经连接,但是没有登录等各种信息,而数据库处理线程,负责处理登录用户的用户名口令的在数据库中的校验以及远程管理指令中数据库相关操作,检索线程负责目录文件数据的分解,检索,并将结果通过全局内存反馈给指令处理引擎。

对于网络数据的接收和发送,目前普遍的有两种形式,一种就是很多RFC文档中定义的格式,也就是纯文本,然后以/r/n或者/n结尾标志一行的结束,例如FTP , POP3 , SMTP ,HTTP等都属于这种形式;另一种是目前在P2P上或者自定义协议如游戏中用的比较普遍的,一般采用包头+内容的形式,一般定义如下:

typedef struct P2pHeadStruct{

__int32   i_id;//报头识别标志

__int32   i_packsize; //给出本次包中实际需要继续接收的后续内容长度

......}S_P2pHead;

对于第一种形式,也就是用特定字符或者字符串表示结尾的,我们可以采用预先分配一定长度的缓冲,例如 4KB,来接收,然后判断是否有结尾字符(串),如果有,则提取并处理,并移动剩余部分内容到接收缓冲最前面,如果没有,则继续接收状态,直到超时或者缓冲过长还没有发现结尾字符(串);对于第二种,则采用先接收包头,然后根据包头分配内容缓冲,然后接收i_packsize长度的缓冲,完全接收后进行处理。

指令和网络处理引擎是一个或者多个线程,根据当前的状态分别执行不同的指令,我的代码是这样的:

__int32   ipos;

//__int32 i_maxpos,i_minpos ;计算本线程具体负责哪些部分的连接,因为需要支持多线程

for(ipos=i_minpos;ipos<=i_maxpos;ipos++)

{

   if(pdata[ipos].bactive==false) //pdata是个用户数据结构全局指针 , bactive用来标记该连接是否有效

   continue;

   switch(pdata[ipos].m_cmd)  //请注意,这里的操作都不可以包含阻塞操作

    {

     case S_NOCMD: //用户刚连接,需要发送欢迎信息

     ...

     break;

    case S_WAITCMD://正在等待用户的指令

    ...

    break;

    case S_SENDCMD://正在发送给用户的回答

    ...

    break;

    ......................

    }

}

注意这里的pdata[ipos].m_cmd的定义是

enum USER_OP //该状态由CMD线程负责状态修改,说明该连接目前处在什么状态

{//没有任何操作,一般用在连接被断开的情况 ,或者当新的连接进入处理的时候

S_NOCMD,

//等待指令的到达

S_WAITCMD,

//等待回答发送的完成

S_SENDCMD,

...

}

需要特别提醒您注意的是:引擎处理中不可以有阻塞操作(正如我们的SOCKET选择了非阻塞一样),所有的调用都应该是立即模式,各位可以看到我部署的检索线程和数据库线程,目的就是为了避免引起阻塞,当用户发送了用户名和口令后,需要进行验证,而数据库的指令调用通常是无法立即完成的,一般都需要一点延迟,因此,把这个验证操作独立出来,遇到了验证后,我们将 pdata[ipos].m_cmd状态修改为S_LOGIN,然后等待数据库操作完成,或者超时,同样的情况用在检索操作中,我们将检索操作也独立出来,和验证操作一样,修改一下状态,让检索线程来完成,引擎本身继续执行下一个检查。当然,您也许有您自己的扩展,但是请您把握一个度量,那就是如果一个操作可能引发阻塞(不包括同步锁,因为这个代价在多线程编程中是必须要付出的),那么就在USER_OP中增加一个定义,修改m_cmd为该状态,然后让具体的线程去负责执行,引擎本身不要等待,只需要检查执行结果就可以。

以下是可能导致一定时间阻塞的操作: 磁盘文件的读/写,数据库操作,海量数据内的检索比对等操作,各类内核对象的锁定(千万注意,我们建议您的编程中不要出现一个线程等待另一个线程修改内核对象如EVENT的代码,这样的代码很容易导致问题)等

由于TCP流的关系,您不要指望一次有数据的接收肯定包含一个成功的指令,这个思想是要不得的,即使你的客户端程序设计的在好,关闭了SOCKET的延迟发送,但是对于接收端来讲,数据的到达依然是不可预期的,因此,在数据接收到了以后,您必须调用您自己的函数检查是否有完整的指令,这里我的接收缓冲pdata[ipos].m_recvbuff是我自己的一个类封装,直接包含了结尾字符的检查.

ptr=pdata[ipos].m_recvbuff.FindChar2('/n');

if(ptr==NULL)

{

//检查是否超时,否则继续S_WAITCMD

}

else

{

//提取并处理指令

}

至于网络模型的选择,由于我们的服务器程序是TCP,并且引擎不可以阻塞,因此选择了效率很高的非阻塞模型,至于为什么不使用完成端口或者异步,不可否认,完成端口很热门,但是它不可以移植到LINUX/UNIX,其次,完成端口的内部实现其实和我们用非阻塞加多线程是基本相同,但是我们可以直接控制接收什么和发送什么,并灵活调度缓冲,如果使用完成端口,我就失去了这个能力,而异步消息的传递需要经由内核操作,在我们的测试中,WINDOWS的消息比我们自己设计的消息速度正好慢了10倍,最后,我一直不信任MakeDollar公司关于完成端口的描述以及网络上很多关于完成端口节省资源和高效的言论,所以我坚决不使用它。

各位可能从这个结构图中发现,我的服务器软件主要采用全局内存变量,是乎和某些书籍说程序设计中尽可能少用全局变量冲突,这看你怎么用了,因为多线程的优点就是进程内全局内存的共享,否则我们直接采用子进程不更好?至于少用全局变量,我是这么理解的,将某些只在线程中使用的临时变量,放到线程本地栈中分配使用,不参与全局分配,而服务器的核心数据,例如用户数据库,大块的缓冲分配,则完全依赖全局内存池,而且我们的数据很多都是赤裸裸的,没有进行类封装,这并非我们疏忽,而是效率问题。

再补充一点,我看了有人讨论了攻击问题,其实这个引擎速度是可以调节的,另外还可以通过设置接收缓冲区大小来限制用户发送的数据,大家看到这个for(...)循环吧,只需要在前面加个开始时间,然后在结束后加一个时间,计算一下循环使用的时间,然后调用Sleep( )消耗掉剩余的时间,这样,无论客户端发送的速度有多快,但是服务器端是不受任何影响。 此外还可以通过限制最长接收缓冲来限制,例如RFC指令,一行一般是不会超2048字符的,因此,您完全可以设置2200为缓冲长度,如果合理时间内还是没有完整的指令,直接DISCONNECT就可以了,至于过载攻击,只需要设计合理的任务队列长度,发现队列过长就临时丢弃,或者设置任务的超时,就可以避免过载攻击,只于DDOS攻击,那是IP层的问题,我们都没有办法的

同样,这个代码中有很多部分是可以根据实际情况优化的,但是这不属于本文的讨论范围。

 

原创粉丝点击