Linux安全网 - Linux操作系统_Linux 命令_Linux教程_Linux黑客

会员投稿 投稿指南 本期推荐:
搜索:
您的位置: Linux安全网 > Linux编程 > » 正文

对几种WinSockIO模型的总结

来源: 未知 分享至:

(以下仅是学习中的心得体会,加入了自己的理解,不敢保证完全正确,有错误的地方希望指出。)

1,什么是Socket

一般来说,网络通信都是不同的机器在进程级别进行的,如我们在自己的电脑上通过XX软件客户端同服务器上的XX软件数据库连接,查看信息或者下载新版本。互联网上有无数个这样的通信,那么如何去分辨它们?从一台机器的角度来看,一个5元组可以确定这样的一个通信:1,本机的IP地址,互联网上各个机器是唯一的;2,本地端口号,这是用来辨别消息是同这台机器上哪个程序相关的;3,远端IP地址;4,远端端口号;5,用的是什么通信协议。这5个元素形成一个通信链,socket就是这样一个通信链的句柄(UNIX/Linux是文件标识符,其实都是一个意思)。

以TCP为例(UDP类似,更简单),服务器端基本流程为(括号内为所用函数):

1) 建立一个监听Socket(socket)

2) 绑定到某个端口(bind)

3) 监听,等待客户端的连接请求(listen)

4) 客户端有连接请求,在当前的监听Socket上再建立一个连接Socket用于后续的通信(accept)

5) 服务器端同客户端交换信息(send和recv)

6) 通信完成,关闭用于通信的Socket和监听Socket

客户端的基本流程则是:

1) 建立一个通信Socket

2) 向服务器发送连接请求(connect)

3) 服务器端同客户端交换信息(send和recv)

4) 通信完成,关闭用于通信的Socket

客户端没有绑定端口的过程,并不是不绑定,只是会由Socket隐式绑定,当然也可以自己指定。

由此,一个Socket对应一个端口,由于大部分程序所用端口是固定的,这就相当于不同的端口对应了不同的服务。Socket原意就是多孔插座,软件使用哪个Socket,就好像将插头插入了哪个插座,就是使用了哪个端口,就是使用了哪种服务,就是同哪个机器在通信。

Socket是来自UNIX/Linux的概念,WinSock是对其的扩展,但基本思路一致。

2,为什么需要SOCKET IO模型

上节所说的过程明显只能对应1个服务器、1个客户端的通信,如果想多客户端并发,可以在服务器端循环3到5步,即服务器循环监听客户端的连接请求、建立连接、然后接发数据。

问题是如果某个客户端传送数据耗时过长,或者连接了总不传,recv函数就会死等,后面的客户端就会长时间得不到服务,这种相应是不可接受的。

于是又想到,对于每个客户端,都建立一个新的线程,即一个连接Socket一个线程,主线程只负责建立连接,通信的任务交给子线程。这样,无论某个客户端传不传数据或者是传多久的数据,都不会影响别的客户端。这在并发数小的情况下可行,但是当并发数多的时候,

系统将耗费大量的时间在线程的上下文切换。毕竟,所谓的并发不是并行,不是真的有几个线程就几个线程同时运行,而是每个轮流在CPU上跑一会。这样的切换是很耗费资源的。

这种形式被称作同步阻塞式,为了解决这个问题,UNIX/Linux下有select函数,WinSock继承了这个方法,同时提出了一些在WINDOWS环境下新的IO模型已提高高并发时候的响应并降低资源消耗。

3,同步和异步、阻塞和非阻塞

介绍Socket IO模型前,还有2对概念要明确:同步和异步、阻塞和非阻塞。

简单地说,同步和异步是指消息的通知机制,阻塞和非阻塞是指等待消息时的状态。

具体来说,同步和异步是指所关注的消息如何通知,关注的消息如是否可以进行IO、IO完成有否,同步是消息的处理者自己去等待消息是否触发,异步是由触发机制来通知消息的处理者。而阻塞和非阻塞指在我们所关注的消息到来前,我们能否去做别的事情,可能是同步(这个时候就需要我们不停的来看看消息是否触发了),也可能是异步(这个时候就不用看了,有消息触发会由专门的机制来通知我们)。

所以这两对概念是完全无关的。同步可以非阻塞(只是此时相对于同步阻塞,事实上没有任何优势),异步也可以阻塞(只不过通常不是在处理消息时阻塞,而是在等待消息被触发时被阻塞。如我在编写select形式Socket Demo时候,将select最后一个timeout设为了无限等待,只要关注的事件没有触发,程序就会一直阻塞在这个select调用处,最终的结果就是后续的连接有可能要等待前面的连接发送数据后服务器端才能收到它发送的数据)。

总之,异步非阻塞是并发时好的选择,用ioctlsocket函数设置后,所有的WinSock API都会立刻返回,这不仅提高了各个客户端的响应速度,同时可以根据返回的信息去获得此时IO的状态。

4,Select形式

UNIX/Linux下有同名函数,同样的使用方法。简单的说就是检查,在IO前检查下这个Socket是否可用,不可用就不IO。

所以网上对Select是否属于异步也有不同的声音,认为它是同步的说它需要我们自己去轮询地检查Socket是否可用,认为它是异步的说虽然要自己检查,但是是通过特定的fd_set和select函数检验的,可以认为这是一种通知机制。

Select的基本使用方法:

fd_set是一个Socket集合,有四个宏操纵它:

FD_ZERO(*set),初始化为空集合
 
FD_CLR(s,*set)从set集合中移除套接字s
 
FD_ISSET(s,*set)若s是set成员,返回TRUE
 
FD_SET(s,*set)添加s到set
 

select函数的原型是select(int nfds,fd_set* readfds,fd_set* wtriefds,fd_set* exceptfds,const struct timeval* timeout)

第2个参数检验Socket集合中是否有可读的,若有,会将其从当前集合中移除,并返回可读的数量,于是我们通过将调用select函数前和后的集合进行比较,调用前有的而调用后没有的socket即可读,于是可以在这个socket上进行IO。

第3个参数检验Socket集合中是否有可写的,第4个参数检验是否有异常,使用方法同检验可读性类似。

这样,我们就有效利用了当前的线程,只进行可进行的IO,不会浪费时间在等待不可读写的IO上,管理了多个套接字,于是select也被称为多路复用。

在默认情况下,添加到fd_set结构的套接字数量是有限的,最大值由FD_SETSIZE定义,为64,即默认最多只能并发64个连接。可以修改这个值,是支持更多的并发数,但一是这个值收到WinSock下层服务提供者的限制(一般是1024),二是如果设得太大,服务器性能也会受到影响。毕竟,select后要检查很多套接字(select只返回数量,不指明是哪一个),select后又要重置这些套接字(select函数会修改传进去的fd_set集合)。当然,也可用多线程突破这个限制,每个子线程只管理一部分套接字。

5,WSAEventSelect形式

很多资料上认为从这里开始才是真正的异步形式,因为不用程序自己去检验Socket是否可用了。在这种模型中,通过调用WSAEventSelect将某个Socket同我们所感兴趣的event绑定,这些event都是事先定义好的。例如对于监听Socket我们然后感兴趣的可能是FD_ACCEPT和FD_CLOSE,而对于通信的Socket我们感兴趣的可能FD_READ、FD_WRITE和FD_CLOSE。

然后,调用WSAWaitForMutipleEvents函数等待。当这些特定的事件到了时,函数会返回。我们需要再次调用WSAWaitForMutipleEvents确定是哪一个Socket可用。

找到这个Socket后,调用WSAEnumNetworkEvents判断需要进行什么操作,并给予相应处理。

由于在调用WSAWaitForMutipleEvents时,需要传入一个事件等待数组,而这个数组的大小默认情况下的大小由WSA_MAXIMUM_WAIT_EVENTS定义为64,这同样限制了并发数。突破这个限制的方法同Select形式一样,要么是更改这个定义,要么是开启新的线程管理更多的连接。

6,基于事件通知的重叠IO模型

重叠就是Overlapped,Jeffrey Richter的解释是因为“执行I/O请求的时间与线程执行其他任务的时间是重叠(Overlapped)的”。

使用Overlapped结构时候,要使用新的recv、send等函数,即WSARecv、WSASend,主要区别是可以传递一个Overlapped结构给它们作为参数。这里拿WSARecv做个介绍。

WSARecv的原型是:

int WSARecv(
 
SOCKET s, //投递这个操作的套接字
 
LPWSABUF lpBuffers, // 接收缓冲区,WSABUF数组
 
DWORD dwBufferCount, // 数组中WSABUF结构的数量,设置为1即可
 
LPDWORD lpNumberOfBytesRecvd, // 如果接收操作立即完成,返回函数调用所接收到的字节数
 
LPDWORD lpFlags, // 这里设置为0 即可
 
LPWSAOVERLAPPED lpOverlapped, // 这个Socket对应的重叠结构
 
LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine // 这个参数只有完成例程模式才会用到,基于事件的模式中设置为NULL即可
 
);
 

前面的几种模型都是数据先到达单Socket的缓冲区,程序再通过某种方式知晓后从Socket的缓冲区拷贝到最终的目的缓冲区。而在重叠IO模型中Socket则是通过WSARecv同某个Overlapped结构和WSABUF绑定(不是WSAEventSelect中那样显式绑定,而是一起作为WSARecv的函数传入,具体见上面的函数原型)。Overlapped结构再指定一个事件event。这样,数据会直接发送到特定的最终目的缓冲区,而不用经过Socket的缓冲区,这无疑会大大提高效率。完成后,依然是通过event使WSAWaitForMutipleEvents函数返回,用户再通过调用GetOverlappedResult决定是传送完成关闭套接字还是采取什么措施处理得到的数据。

同样,因为这里我们还是通过时间得知传送是否完成,还是调用了WSAWaitForMutipleEvents函数,所以依然会受到64个并发数的限制。解决方法同前面两种模型,这里不再说了。

7,基于完成历程的重叠IO模型

在WSAEventSelect形式中我们看到,系统会在可以IO时通知我们,再由我们自己处理IO过程。而在基于事件通知的重叠IO模型中进步了点,系统会在IO完成时通知我们,我们只要进行下善后工作并对数据进行处理就可以了。在将要介绍的方法中,更进了一步,系统不是在IO完成时就通知我们,而是在IO完成后、并且处理好后才通知我们。既然已经处理好了,为什么还要通知我们呢?因为可能还需要我们进行点善后工作,比如关闭套接字什么的。而这一切,只需要我们在前种模型的基础上,在调用WSARecv投递接收请求时,给最后一个参数传递一个CALLBACK的完成例程函数即可,系统会去自动调用以处理接收到的数据。

由于需要进行一些善后工,因此我们依然需要调用WSAWaitForMutipleEvents函数。不同的是是,因为我们不再需要事件来通知我们去具体处理哪个Socket(具体处理哪个Socket已经由做完参数传入的完成例程函数负责了,我们可以理解为每个Socket都已经绑定了一个专有的处理方法)。所以调用WSAWaitForMutipleEvents函数时,只需传递给其一个只有一个元素的伪WSAEVENT类型事件等待数组就可以了,反正这个数组用不到。因为用不到event,所以这里的Overlapped结构不用指定event,于是这里可以突破64个并发数的限制。

当然,上面的两种方法在并发的时候,还是要准备Socket数量的Overlapped结构,准备Socket数量*2的WSABUF缓冲区(一个发送,一个接受)。

8,完成端口

同样是使用Overlapped结构的IO模型,可以说是目前最高效的IO模型。它也是通过Overlapped结构,由系统自己去完成IO。同基于事件通知和基于完成例程的区别是:系统完成IO后,不会通知我们,而是将已完成的IO放入公关消息队列(这也是完成端口名字的由来:一个端口暴露了IO完成的消息,其实我觉得用完成队列似乎更合适),然后由worker线程自己去取来并处理。这就好像程序对系统说:“你IO完成就完成了,放那吧,别来打扰我,我有空自己会去看的”。

由于worker线程数量的精心控制(理论上等于处理器数量,事件上为了避免sleep等问题,取处理器数量*2),最大程度减少线程切换,让每个处理器几乎都只有一个线程在运行。它们不停地从完成端口中取走已完成的IO并处理。可以说是提供了一种最公平的机制用几个线程处理多个客户端的IO,优雅地实现异步通信和负载平衡的问题。

这里拷贝一段完成端口的基本使用过程:

大体上来讲,使用完成端口只用遵循如下几个步骤:
 
        (1) 调用 CreateIoCompletionPort() 函数创建一个完成端口,一般情况下,只需要建立这一个完成端口,把它的句柄保存好,今后会经常用到;
 
        (2) 建立正确数量的Worker线程,这几个线程是专门用来和客户端进行通信的,目前暂时没什么工作;
 
        (3)接收连入的Socket连接;
 
        (4) 每当有客户端连入调用CreateIoCompletionPort()函数,这里却不是新建立完成端口了,而是把新连入的Socket与目前的完成端口(步骤1里保存了它的句柄)绑定在一起。
 
至此,已经完成主线程上完成端口的相关部署工作了;
 
       (5)客户端连入之后,可以在这个Socket上提交一个网络请求,例如WSARecv(),然后系统就会帮我们去执行接收数据的操作,我们大可以放心的去做别的事情;
 
       (6) 而此时,预先准备的那几个worker线程就不能闲着了,都需要分别调用GetQueuedCompletionStatus函数扫描完成端口的队列里是否有网络通信的请求存在(例如读取数据,发送数据等),一旦有的话,就将这个请求从完成端口的队列中取回来,继续执行本线程中后面的处理代码,处理完毕之后,再继续投递下一个网络通信的请求(注意这里是由worker线程投递的了,也就是说,主线程只投递某个Socket上的第一个网络请求),如此循环。
 

看了上面的步骤,会有一个疑问:当所有的数据信息形成完成端口时,如何辨别一个IO是来自何处(它对应的Socket是什么)?它想完成什么工作(它放在了这个缓冲区中,那么它是接收来的,还是想发送出去)?上面的步骤6里说了每个Socket的第一个网络请求是主线程投递的,然后worker线程去处理的。那么worker线程又是如何去得到上面2个问题的答案呢?

于是主线程通过两个数据结构向worker线程传递这些信息:单IO数据和单句柄数据。

单IO数据是用来管理一个Socket上的多个IO操作的(如多个处理器操作一个Socket,有的读,有的写)。它是一个我们自己定义的结构体,首元素必须是Overlapped结构,还可以包含是何种操作、同哪个Socket有关等信息。它通过WSARecv(指一个网络请求,WSASend当然也可以)传给子线程,注意,各参数都是结构体中的元素,LPOVERLAPPED参数也是结构体中系统定义的Overlapped元素。

单句柄数据是用来管理某个套接字的,是在将Socket绑定到已存在的完成端口时通过LPVOID CompletionKey传入的,它同样是一个我们自己定义的结构体。

worker线程通过GetQueuedCompletionStatus函数获得这些信息,GetQueuedCompletionStatus函数的原型是

BOOL WINAPI GetQueuedCompletionStatus(
 
HANDLE CompletionPort, // 唯一的完成端口
 
LPDWORD lpNumberOfBytes, // 操作完成后返回的字节数
 
PULONG_PTR lpCompletionKey, // 将某个Socket绑定到完成端口的自定义的单句柄数据结构体
 
LPOVERLAPPED *lpOverlapped, // 连入Socket投递网络请求时自定义的单IO数据结构体
 
DWORD dwMilliseconds // 等待完成端口的超时时间,若线程不需要做其他的事情,设为WSA_INFINITE 
 
);
 

其第3个参数和第4个参数都是输出参数,通过这些接收主线程来的信息。这里要注意的是,第4个参数不是系统定义的Overlapped结构,是我们自己定义的包含Overlapped元素的单IO数据结构体。函数返回时,这两个结构体中就有了一个取回的信息,根据其中元素的取值,就可以做出相应的操作。

由于处理“IO完成消息”机制的改变(前面的Wait消息,这里是worker自己去取,没有的话worker线程就休眠),所以完成端口占用更少的CPU,却能维持更大的吞吐量。

再提一点关于系统的“伸缩性”问题。“伸缩性”是指一个Socket上投递多个IO操作可以通过前面提到的单IO数据和单句柄数据去区分它们。完成端口的伸缩性最好,因为它的CPU占用少,线程的上下文切换也大大减少。由于可在一个Socket上执行多个IO操作,所以不用为多个IO操作建立多个Socket,也就不会因为连接量的增加,使得系统来不及为新的连接准备环境而发生饱和。这样,一是由于完成端口对每个Socket的利用率更高,二是由于不用花费大量时间在线程的上下文切换,CPU占用低,可支持更多的客户端连接,从而完成端口的“伸缩性”或称“可扩展性”是目前最好的。


Tags:
分享至:
最新图文资讯
1 2 3 4 5 6
验证码:点击我更换图片 理智评论文明上网,拒绝恶意谩骂 用户名:
关于我们 - 联系我们 - 广告服务 - 友情链接 - 网站地图 - 版权声明 - 发展历史