说说网络通信模型

来源:互联网 发布:f125型护卫舰 知乎 编辑:程序博客网 时间:2024/04/29 21:37

在几年前曾经做过一个网络项目,当时对网络通信仅仅是有点基础。tcp/ip协议的基础还算不错,sockt的应用看起来也不算复杂。于是就用异步非阻塞的sockt通信实现了服务器端和客户端。但是项目在联合调试阶段就出现了重大的性能问题。项目的服务器端同时连入的连接数在几百左右,而服务器端的资源消耗非常厉害。就是在这样的环境下,第一次接触到高效通信模型这个概念,IOCP完成端口 (I/O Completion Port)也是在这个时候成为了我心目中windows平台下这个概念的最高峰,同时也成为了我的最大困惑。
作为当时菜虫级的程度,找遍了网络和书店以及msdn,找到了大量关于IOCP的资料,希望能了解它的原理和应用。遗憾的是,一开始就先入为主的认为IOCP是一种网络通信模型,而走入了误区(都是“端口”惹的祸...),更可悲的是在找到的资料中混杂了“重叠端口”(重叠I/O的另一种翻译...找不到具体说法,只是通过代码发现是一回事),后来又有“重叠I/O”、“完成例成(completion routines)”、“select模式的重叠端口”等相似名称的资料,终于最后在海量的资料里面迷失了。都怪自己e文太烂啊。。。。

理一理这样的一批概念:

同步、异步、阻塞、非阻塞
select模式
重叠I/O(Overlapped I/O)
完成例成(completion routines)
IOCP完成端口 (I/O Completion Port)

上面的概念我不知道该用什么来统称它们,只能说它们都和网络开发相关。比较混乱的说,而写这个文章的目的也是想理一理这些概念,做做头脑风暴。

同步、异步、阻塞、非阻塞是IO的基本原理。同步和异步是针对功能的执行顺序来说的,而阻塞和非阻塞是针对等待IO数据的方式说的。因此这是两对概念,同步与阻塞,异步与非阻塞都没有必然的联系。通俗的说,同步就是工作线程在处理IO时等待IO完成再继续后面的工作;异步就是工作线程不等待IO处理的结果就继续后面的工作,而IO处理结果将通过回调方式返回;阻塞是在等待IO时,如果IO没有可用数据或数据没有传送完成,那么一直等待下去,直到IO处理完数据再返回;非阻塞就是不管IO是否有可用数据或数据已经传送,照样返回。只要把同步和阻塞分清,这四个概念就很容易理清了。

同步、异步、阻塞、非阻塞等原理确定了网络通信的基本网络通信模型结构。它们处理的怎么等待数据和怎么收发数据的问题,但是对通信性能并没有提供更多的指导。特别是服务端,当成千上万的连接发生并发时,降低cpu的占用率,减少内存使用率,减少带宽就成为了很关键的问题。因此便有先有了重叠I/O(Overlapped I/O)。

重叠I/O(Overlapped I/O)是怎么一回事呢?
如果应用程序投递了一个10KB大小的缓冲区来接收数据,且数据已经到达套接字,则该数据将直接被拷贝到投递的缓冲区。而阻塞、select、WSAAsyncSelect以及WSAEventSelect等4种模型种,数据到达并拷贝到单套接字接收缓冲区中,此时应用程序会被告知可以读入的容量。当应用程序调用接收函数之后,数据才从单套接字缓冲区拷贝到应用程序的缓冲区,差别就体现出来了。
我想之所以叫重叠也就是因为它是两个缓冲重叠的意思。就这么一重叠就剩了不少空间和不少事情。

提重叠I/O(Overlapped I/O)之前应该先提select模式,因为它是最接近同步、异步、阻塞、非阻塞模式的一种模式。select模式、WSAAsyncSelect以及WSAEventSelect都是通过轮询socket列表来确定那个socket是当前有效的(它们的原理还是有点不一样的)。好处是防止在在阻塞模式的套接字里被锁死,避免在非阻塞套接字里重复检查WSAEWOULDBLOCK错误。
也就是说,select模式提供了一种比较优秀的查询方式来判断当前连接的状态,避免了直接使用阻塞或者非阻塞所导致的问题。

提重叠I/O(Overlapped I/O)提供了比select模式更优秀的缓冲管理,那它是不是也提供了比select更优秀的轮询方式呢?可以说是,也可以说不是。因为提重叠I/O(Overlapped I/O)并没有直接提供这样的方法,而是提供了一种手段。这手段是在Overlapped数据结构里标记了该数据所属的socket和有数据到的来。怎么捕获这个事件?那就是完成例成(completion routines)和事件对象通知(event object notification) 所干的事情了。完成例成(completion routines)类似于dotnet里面的异步,在socket接收数据时给WSARecv传送一个完成后回调的函数句柄,来达到捕获数据到来的事件;至于事件对象通知(event object notification) 利用的是Overlapped数据结构里包含“有数据到来的事件”的WSAEVENT hEvent成员与事件句柄关联实现的,这个就象是使用了类为外部提供事件通知的方式。

使用事件或者是回调都要比轮询高效。因此重叠I/O(Overlapped I/O)可以支持很高的连接数,传说可以上万。但是重叠I/O(Overlapped I/O)也有不足的地方。重叠缓冲是很好的做法,但是对应每个socket都要有一个缓冲,那么一万个连接就会有一万个缓冲了。另外还有就是无论是异步还是事件来唤醒线程,都和缓冲存在一个连接一个线程的情况。不面对大量的并发,这两个问题并不明显,但是如果并发连接数量达到某个级别,cpu和内存都将存在严重的浪费。而且其支持的连接数与硬件性能的比只能是一个固定值。

为了解决重叠I/O的问题,便有了IOCP完成端口 (I/O Completion Port)。为了解决大量并发产生的资源浪费,完成端口引入了类似线程池和资源自动分配回收的两种技术(这里之所以说是类似,是因为对于IOCP的原理还没有作更深入的研究说不准其准确的原理,只能通过它的现象来认为那是线程池+高效调度+资源回收综合起来的)。所谓的大量并发连接不是指同一时间内的连接数量,而是在某时间区间内的连接数量。因此在该时间区间内并不是所有的连接线程都是活动的,只要具有高效的线程分配和调度,那么在每个时刻提供比整个区间内连接线程小得多的活动线程也能把该时间区间内的并发处理,这就是线程池的好处。对于大量的并发短连接,其缓冲绝对是可重复使用的,其线程也是可重复使用的,因此准确高效的资源回收能是资源最大效率的运用起来。通过对线程和缓冲的有效再利用使完成端口在更小的资源环境下取得比重叠I/O更好的性能。

完成端口这个名字使不少人迷惑,我们完全可以通过它的原理和应用来理解这个名字。完成端口是通过调度线程和提供资源重用来取得高效的,因此完成端口把连接的调度和其缓冲的分配都进行了封装,提供了很容易使用的接口。我想完成端口的“完成”便是这样而来--调度和资源分配的自动完成,“端口”--就是指IO端口,每个IO设备都有其自己的IO号,这个IO号又被成为IO的端口。那英文名里面的I/O又是什么回事呢?这和重叠I/O是一样的,就是指它们是应用在I/O上的一种技术。广义的说,网络不也是IO?

重叠I/O和IOCP完成端口确实是可以用在所有的IO应用上。而完成端口因为封装了调度和回收机制,还可以把它当作高效的队列处理技术和线程调度技术来使用。

这是我对这些概念的理解,可能理解得并不准确。上面提及的网络模型都是在windows平台上提供了API支持的,能找到的示例代码大部分也是c++的代码。在接触完成端口时的想法是如何把它用在dotnet上,因此在研究完成端口的原理和应用时,也就一直在思考能否在dotnet上使用这么一个高效的模型。当然不仅仅是完成端口,select模式,重叠端口等在dotnet上也是少见的。因此后面就这个方向进行一下探讨。

另外,CodeProject上有个托管环境下的IOCP例子很值得研究。
Managed I/O Completion Ports (IOCP)
Managed I/O Completion Ports (IOCP) - Part 2



[转载]IOCP模型的总结

IOCP(I/O Completion Port,I/O完成端口)是性能最好的一种I/O模型。它是应用程序使用线程池处理异步I/O请求的一种机制。在处理多个并发的异步I/O请求时,以往的模型都是在接收请求是创建一个线程来应答请求。这样就有很多的线程并行地运行在系统中。而这些线程都是可运行的,Windows内核花费大量的时间在进行线程的上下文切换,并没有多少时间花在线程运行上。再加上创建新线程的开销比较大,所以造成了效率的低下。 

而IOCP模型是事先开好了N个线程,存储在线程池中,让他们hold。然后将所有用户的请求都投递到一个完成端口上,然后N个工作线程逐一地从完成端口中取得用户消息并加以处理。这样就避免了为每个用户开一个线程。既减少了线程资源,又提高了线程的利用率。 

完成端口模型是怎样实现的呢?我们先创建一个完成端口(
::CreateIoCompletioPort())。然后再创建一个或多个工作线程,并指定他们到这个完成端口上去读取数据。我们再将远程连接的套接字句柄关联到这个完成端口(还是用::CreateIoCompletionPort())。一切就OK了。 

工作线程都干些什么呢?首先是调用
::GetQueuedCompletionStatus()函数在关联到这个完成端口上的所有套接字上等待I/O的完成。再判断完成了什么类型的I/O。一般来说,有三种类型的I/O,OP_ACCEPT,OP_READ和OP_WIRTE。我们到数据缓冲区内读取数据后,再投递一个或是多个同类型的I/O即可(::AcceptEx()::WSARecv()::WSASend())。对读取到的数据,我们可以按照自己的需要来进行相应的处理。 

为此,我们需要一个以OVERLAPPED(重叠I
/O)结构为第一个字段的per-I/O数据自定义结构。 

typedef struct _PER_IO_DATA
{
         
OVERLAPPED ol;      
// 重叠I/O结构 
         
char buf[BUFFER_SIZE];  // 数据缓冲区 
         
int nOperationType;         //I/O操作类型 
#define OP_READ 1
#define OP_WRITE 2
#define OP_ACCEPT 3
PER_IO_DATA, *PPER_IO_DATA

将一个PER_IO_DATA结构强制转化成一个OVERLAPPED结构传给::GetQueuedCompletionStatus()函数,返回的这个PER_IO_DATA结构的的nOperationType就是I/O操作的类型。当然,这些类型都是在投递I/O请求时自己设置的。 

这样一个IOCP服务器的框架就出来了。当然,要做一个好的IOCP服务器,还有考虑很多问题,如内存资源管理、接受连接的方法、恶意的客户连接、包的重排序等等。以上是个人对于IOCP模型的一些理解与看法,还有待完善。另外各Winsock API的用法参见MSDN。

 

补充IOCP模型的实现: 

//创建一个完成端口
FCompletPort := CreateIoCompletionPortINVALID_HANDLE_VALUE0,0,); 

//接受远程连接,并把这个连接的socket句柄绑定到刚才创建的IOCP上
AConnect := acceptFListenSockaddrlen);
CreateIoCompletionPortAConnectFCompletPortnil); 

//创建CPU数*2 + 2个线程
for i:=to si.dwNumberOfProcessors*2+do
begin
  AThread 
:= TRecvSendThread.Createfalse );
  
AThread.CompletPort := FCompletPort;
//告诉这个线程,你要去这个IOCP去访问数据
end

OK,就这么简单,我们要做的就是建立一个IOCP,把远程连接的socket句柄绑定到刚才创建的IOCP上,最后创建n个线程,并告诉这n个线程到这个IOCP上去访问数据就可以了。 

再看一下TRecvSendThread线程都干些什么: 

procedure TRecvSendThread
.Execute;
var
  
......
begin
  
while (not self.Terminateddo
  
begin
    
//查询IOCP状态(数据读写操作是否完成)
    
GetQueuedCompletionStatusCompletPortBytesTransdCompletKeyPOVERLAPPED(pPerIoDat), TIME_OUT ); 

    
if BytesTransd <> 0  then
       
....;
//数据读写操作完成 

    //再投递一个读数据请求
    
WSARecvCompletKey@(pPerIoDat^.BufData), 1BytesRecvFlags@(pPerIoDat^.Overlap), nil );
  
end;
end

读写线程只是简单地检查IOCP是否完成了我们投递的读写操作,如果完成了则再投递一个新的读写请求。
应该注意到,我们创建的所有TRecvSendThread都在访问同一个IOCP(因为我们只创建了一个IOCP),并且我们没有使用临界区!难道不会产生冲突吗?不用考虑同步问题吗?
呵呵,这正是IOCP的奥妙所在。IOCP不是一个普通的对象,不需要考虑线程安全问题。它会自动调配访问它的线程:如果某个socket上有一个线程A正在访问,那么线程B的访问请求会被分配到另外一个socket。这一切都是由系统自动调配的,我们无需过问。

[转载]Windows网络编程系列教程之四:Select模型

原文:
http://www.51see.com/asp/bbs/public/bp_show.asp?t_id=200308131152297103

讲一下套接字模式和套接字I/O模型的区别。先说明一下,只针对Winsock,如果你要骨头里挑鸡蛋把UNIX下的套接字概念来往这里套,那就不关我的事。
套接字模式:阻塞套接字和非阻塞套接字。或者叫同步套接字和异步套接字。
套接字模型:描述如何对套接字的I/O行为进行管理。
Winsock提供的I/O模型一共有五种:
select,WSAAsyncSelect,WSAEventSelect,Overlapped,Completion。今天先讲解select。

1:select模型(选择模型)
先看一下下面的这句代码:
int iResult = recv(s, buffer,1024);
这是用来接收数据的,在默认的阻塞模式下的套接字里,recv会阻塞在那里,直到套接字连接上有数据可读,把数据读到buffer里后recv函数才会返回,不然就会一直阻塞在那里。在单线程的程序里出现这种情况会导致主线程(单线程程序里只有一个默认的主线程)被阻塞,这样整个程序被锁死在这里,如果永远没数据发送过来,那么程序就会被永远锁死。这个问题可以用多线程解决,但是在有多个套接字连接的情况下,这不是一个好的选择,扩展性很差。Select模型就是为了解决这个问题而出现的。
再看代码:

int iResult = ioctlsocket(s, FIOBIO, (unsigned long *)&ul);
iResult = recv(s, buffer,1024);

这一次recv的调用不管套接字连接上有没有数据可以接收都会马上返回。原因就在于我们用ioctlsocket把套接字设置为非阻塞模式了。不过你跟踪一下就会发现,在没有数据的情况下,recv确实是马上返回了,但是也返回了一个错误:WSAEWOULDBLOCK,意思就是请求的操作没有成功完成。看到这里很多人可能会说,那么就重复调用recv并检查返回值,直到成功为止,但是这样做效率很成问题,开销太大。
感谢天才的微软工程师吧,他们给我们提供了好的解决办法。
先看看select函数
int select(
int nfds, 
fd_set FAR *readfds, 
fd_set FAR *writefds, 
fd_set FAR *exceptfds, 
const struct timeval FAR *timeout 
);
第一个参数不要管,会被系统忽略的。第二个参数是用来检查套接字可读性,也就说检查套接字上是否有数据可读,同样,第三个参数用来检查数据是否可以发出。最后一个是检查是否有带外数据可读取。
参数详细的意思请去看MSDN,这里限于篇幅不详细解释了。
最后一个参数是用来设置select等待多久的,是个结构:


struct timeval {
long tv_sec; // seconds 
long tv_usec; // and microseconds 
};
如果将这个结构设置为(0,0),那么select函数会马上返回。
说了这么久,select的作用到底是什么?
他的作用就是:防止在在阻塞模式的套接字里被锁死,避免在非阻塞套接字里重复检查WSAEWOULDBLOCK错误。
他的工作流程如下:
1:用FD_ZERO宏来初始化我们感兴趣的fd_set,也就是select函数的第二三四个参数。
2:用FD_SET宏来将套接字句柄分配给相应的fd_set。
3:调用select函数。
4:用FD_ISSET对套接字句柄进行检查,如果我们所关注的那个套接字句柄仍然在开始分配的那个fd_set里,那么说明马上可以进行相应的IO操作。比如一个分配给select第一个参数的套接字句柄在select返回后仍然在select第一个参数的fd_set里,那么说明当前数据已经来了,马上可以读取成功而不会被阻塞。

下面给出一个简单的select模型的服务端套接字。

#include “iostream.h”
#include “winsock2.h”
#include “windows.h”


#define InternetAddr "127.0.0.1"
#define iPort 5055

#pragma comment(lib, "ws2_32.lib")


void main()
{
    WSADATA wsa;
    WSAStartup(MAKEWORD(
2,2), &wsa);
    
    SOCKET fdServer 
= socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    
    
struct sockaddr_in server;
    server.sin_family 
= AF_INET;
    server.sin_addr.s_addr 
= inet_addr(InternetAddr);
    server.sin_port 
= htons(iPort);
    
    
int ret = bind(fdServer, (sockaddr*)&server, sizeof(server));
    ret 
= listen(fdServer, 4);


    SOCKET AcceptSocket; 
    fd_set fdread;
    timeval tv;
    
int nSize; 

    
while(1)
    
{
        
        FD_ZERO(
&fdread);//初始化fd_set
        FD_SET(fdServer, &fdread);//分配套接字句柄到相应的fd_set
        
        
        tv.tv_sec 
= 2;//这里我们打算让select等待两秒后返回,避免被锁死,也避免马上返回
        tv.tv_usec = 0;
        
        select(
0&fdread, NULL, NULL, &tv);
        
        nSize 
= sizeof(server);
        
if (FD_ISSET(fdServer, &fdread))//如果套接字句柄还在fd_set里,说明客户端已经有connect的请求发过来了,马上可以accept成功
        {
            AcceptSocket 
= accept(fdServer,( sockaddr*&server, &nSize);
            
break;
        }

        
        
else//还没有客户端的connect请求,我们可以去做别的事,避免像没有用select方式的阻塞套接字程序被锁死的情况,如果没用select,当程序运行到accept的时候客户端恰好没有connect请求,那么程序就会被锁死,做不了任何事情
        {
            
//do something
            ::MessageBox(NULL, "waiting""recv", MB_ICONINFORMATION);//别的事做完后,继续去检查是否有客户端连接请求
        }

    }


    
char buffer[128];
    ZeroMemory(buffer, 
128);

    ret 
= recv(AcceptSocket,buffer,128,0);//这里同样可以用select,用法和上面一样

    ::MessageBox(NULL, buffer, 
"recv", MB_ICONINFORMATION);

    closesocket(AcceptSocket);
    WSACleanup();
    
    
return;

}



基本上就这样,个人感觉select模型用处不是很大,我只用过一次,去年写端口扫描器的时候用select来检查超时。
感觉讲得不是很清楚,虽然东西我很明白,但是要讲解出来讲解得很清楚真不容易。