2024年3月27日发(作者:)

摘要:在应用程序开发中,经常涉及各式各样的机器的交互通信问题。在

Windows操作系统下,可以使用MFC中的CSocket,也可以使用以Windows Api

为基础的Winsock等等。本文主要描述了Winsock的两种实现方式,即阻塞方式

和非阻塞方式。并对应这两种方式,描述了Select模式和IOCP模式。

关键字:Winsock Blocking NonBlocking Select模式 完成端口(IOCP)模

一、Winsock简介

对于众多底层网络协议,Winsock是访问它们的首选接口。而且在每个Win32

平台上,Winsock都以不同的形式存在着。Winsock是网络编程接口,而不是协

议。在Win32平台上,Winsock接口最终成为一个真正的“与协议无关”接口,

尤其是在Winsock 2发布之后。

Win32平台提供的最有用的特征之一是能够同步支持多种不同的网络协议。

Windows重定向器保证将网络请求路由到恰当的协议和子系统;但是,有了

Winsock,就可以编写可直接使用任何一种协议的网络应用程序了。

在广泛使用的windows平台下,winsock2被简单包装为一组庞大的Api库,

通过WSA Start up加载的关于Winsock版本的信息,初始了winsock相关的dll

和lib,在成功调用了WSA Startup之后,即可设计自主的与通信有关的行为,

当确认了执行完操作后,调用相应的WSA Cleanup,释放对winsock DLL的引用

次数。几乎所有在windows平台下可使用的通信框架都是由Winsock扩展而来的。

这里,之所以要再提windows下的winsock Api编程,并不多余,虽然也可

以使用CSocket或ACE(ADAPTIVE Communication Environment)框架,但直接

对较底层的本地操作系统API,会使我们更深的理解隐藏在框架下的实现,有时

也可以解决一些实用问题。

本文涉及的主要是Winsock中面向连接(TCP)部分。

二、阻塞和非阻塞

阻塞socket,又被称为锁定状态的socket。非阻塞socket,又被称为非锁

定状态的socket。当调用一个Winsock Api时, Api的调用会耗费一定的CPU

时间。当一个操作完成之后才返回到用户态,称其为阻塞,反之,则为非阻塞。

当调用Api产生一个基于流协议(TCP)的socket时,系统开辟了接收和发送两

个缓冲区,所以操作实际都是用户缓冲区和系统缓冲区的数据交互,并不是实际

操作的完成,阻塞也是如此。如果综合被TCP协议默认使用的nagle算法,在数

据包(TCP Payload)比较短时,协议可能推迟其发送,就不难想象了。

很多做过Winsock通信的人,都知道非阻塞的socket,也就是异步socket

的效率远远高于阻塞socket。可是除了直观的认识外,底层的原理又是什么呢?

1

首先,阻塞的socket的最合理方式是:先知道socket句柄的可读写性,再发起

动作。因为如果不做检测而直接发起操作,那么TCP的默认超时将让你后悔不该

这么鲁莽。于是,在一次动作中,完成了两次从用户态到系统态的状态转换,异

步的socket实际只告诉系统,所期望的操作,而不完成这种操作,系统会对每

次提交的操作进行排队。当指定的操作完成时,系统态跳转至用户态。这样,只

用了一次状态转换。其次,由于阻塞的特性,它比非阻塞占用了更多的CPU时间。

当然,程序总是要适合需求,有时阻塞的socket亦可满足要求。

三、Select模式

select模式是Winsock中最常见的I/O模型。之所以称其为“select模式”,

是由于它的中心思想是利用select函数,实现对I/O的管理。利用select函数,

判断套接字上是否存在数据,或者能否向一个套接字写入数据。设计这个函数,

唯一的目的是防止应用程序在套接字处于锁定模式中时,在一次I/O绑定调用

(如send或recv)过程中,被迫进入“锁定”状态。

对select不再赘述,msdn已经给出了详细的解释。这里要讨论下面两个问

题。

的fd_set数组可承受的最大数

细心的coder可能都注意到了msdn中对FD_SETSIZE(winsock2.h)宏的说

明,可以在包含winsock2.h之前重新定义这个宏,它将允许在一个select操作

中处理更多的socket句柄(>64)。但是为何定义就是64呢?这不仅是unix

的遗留,更是select处理能力的一种衡量标准,过多的socket句柄检测毕竟会

影响到对已存在操作的socket的响应。一个最合理的建议是,当程序运行在多

CPU的机器上时,可以从逻辑上将socket句柄分为数个组,每组都小于64,用

多个线程对每组socket进行select,这样可以增加程序的响应能力。如果是单

CPU,则可将FD_SETSIZE增大至256,适当放大timeout。这时每个socket上吞

吐量如果还很大,CPU利用率也据高不下,那就要考虑换种模型。

在多端口侦听中的应用

众所周知,在winsock api中,accept()是一个典型的阻塞操作,通常是建

立一个侦听线程来单独执行accept()。如果程序要完成多于一个端口的侦听,

自然,建立数个线程也是一个办法,但这里最好使用select。Msdn中解释了这

种应用:readfds可以检测出当前listening的socket句柄上是否有有效的

connect发生。把本地listening的socket句柄置入readfds,当某个socket

有效时,对它调用accept(),此时,发现accept()会立刻成功返回,一个线程

就完成了多端口的侦听。

四、非阻塞与完成端口模式

2

IO完成端口:一种windows独有的异步IO机制。它不专属socketIO,更多

的应用是file IO和串口IO。这种模式的优点是:在句柄较多时,较低的CPU

利用率和较高的吞吐量。但设计上有较高的复杂性,只有在应用程序需要同时管

理数百乃至上千个套接字的时候,并希望随着系统内安装的CPU数量的增多,应

用程序的性能也可以线性提升,才应考虑采用“完成端口”模式。

在windows下,IOCP已经可以说是顶级的通信方式了,在网上能搜到的资料

都表明:windows的高效通信 =IOCP + 多线程。IOCP server的工作过程如下,

代码在windows sdk中。

主线程

¦

CreateIoCompletionPort ¦

CreateThread ————————— 完成端口线程

¦

¦---- While(TRUE) While(TRUE)---------- ¦

¦ ¦

Accept ¦------GetQueuedCompletionStatus() ¦

¦ ¦ ¦ ¦

¦ CreateIoCompletionPort ¦ WsaRev/WsaSend------- ¦

¦ ¦ ¦ ¦

¦----WsaRev/WsaSend ------- Windows系统

¦ ¦

Windows系统 ---------

应注意以下两个事项:

3

(1)当调用WsaRecv和WsaSend,会提供一个WSADATA的数据结构,其中的

指针指向的是用户缓冲区,由于以上两个函数在大多数情况下并不是立即就可以

完成的,所以,在GetQueuedCompletionStatus没有收到完成事件前,这个缓冲

区不可修改或释放。

(2)经常会在技术论坛中见到有人提问关于IOCP的异步方式会导致乱包的

问题。首先看一下IOCP的原理,其实它就相当一个由windows底层管理的有事

务功能的消息队列。看过所谓“windows泄漏代码”的人都会注意到windows并

不保证这个队列是有序的,特别是当将完成事件通知用户的多个

GetQueuedCompletionStatus线程时,由于调度算法,并不能保证先递交的操作

会先返回结果。这样,如果在当前队列中对一个socket句柄有两个recv的待完

成操作,此时,socket底层缓冲区内数据为“abcd”,第一个操作完成了“ab”,

第二个操作应该完成 “cd”,但第二个操作先找到了空闲线程返回了,于是,

数据就乱了。解决的方法很简单,只要从逻辑上保证一个socket句柄接收和发

送操作都是同步完成的(即完成一个,才发起下一个),就可以避免乱包。

下面提出两点技巧:

即使在网上能找到关于IOCP的代码,几乎都是照搬了sdk中的实例,在自

己编写代码时,还是会碰到更多的问题。

(1)sdk代码中存在一个没有说明的问题。其中模拟的是一个基于一定固有消

息的服务器,每收到一包消息,处理后发出一包消息,WsaRecv是常发出的,如

果应用突然要发送一包消息呢?由于一个socket只存在一个异步操作使用的

WSAOVERLAPPED结构,所以最常见的办法是使用

api PostQueuedCompletionStatus()将一个已投出的WsaRecv“召回”,

再投出WsaSend,这样浪费了一次操作,在连接数较大时,这种代价是不可忽略

的。更好的办法是使用两个WSAOVERLAPPED结构,我使用的数据结构如下:

typedef enum _IO_OPERATION

{

ClientIoAccept,

ClientIoRead,

ClientIoWrite

} IO_OPERATION, *PIO_OPERATION;

typedef struct _PER_IO_CONTEXT

{

WSAOVERLAPPED Overlapped;

4

char *Buffer;

WSABUF wsabuf;

IO_OPERATION IOOperation;

SOCKET SocketAccept;

int State;

int nTotalBytes;

int nSentBytes;

struct _PER_IO_CONTEXT *pIOContextForward;

} PER_IO_CONTEXT, *PPER_IO_CONTEXT;

typedef struct _PER_SOCKET_CONTEXT

{

int state;

SOCKET sock;

struct sockaddr_in remote;

PPER_IO_CONTEXT pRIOContext;

PPER_IO_CONTEXT pSIOContext;

struct _PER_SOCKET_CONTEXT *pCtxtBack;

struct _PER_SOCKET_CONTEXT *pCtxtForward;

} PER_SOCKET_CONTEXT, *PPER_SOCKET_CONTEXT

PER_IO_CONTEXT中包含了一个WSAOVERLAPPED结构,每个socket上下文

PER_SOCKET_CONTEXT中包含了两个PER_IO_CONTEXT的指针,这样WsaRecv使用

pRIOContext中的WSAOVERLAPPED,而WsaSend使用pSIOContext中的

WSAOVERLAPPED,互不干扰。

(2)上述问题解决了,但又带来了一个新的问题。在socket注册入IOCP时,

completionkey是PER_SOCKET_CONTEXT的地址。此时,如果在这个socket上有

完成事件,completionkey会被返回,但如何知道是接收还是发送完成了呢?你

5

一定会想到WSAOVERLAPPED的地址也不被返回了,和pRIOContext以及

pSIOContext中的WSAOVERLAPPED地址比一下就可以了,然而,地址比较是C语

言的大忌。其实,上面的数据结构也是在sdk代码上改来的,注意在WsaRecv

以及WsaSend中用的Overlapped指针,正是PER_IO_CONTEXT的地址,强制类型

转换即可,其中IO_OPERATION正是对操作类型的描述。

五、几点补充

IOCP到底有多高效呢?<

2nd>>有个技术统计,采用IOCP时:

Attempted/Connected: 50,000/49,997

Memory Used (KB): 242,272

Non-Paged Pool: 148,192

CPU Usage: 55–65%

Threads: 2

Throughput (Send/Receive Bytes Per Second):4,326,946/4,326,496 (The

server was a Pentium 4 1.7 GHz Xeon with 768 MB memory)

因此,接受几千个连接是可能的,但要稳定处理就有赖于服务器性能和程序的健

壮性。

对自己程序的测试还要注意一些windows特性:

(1)连接测试时,win2k缺省的出站连接的临时端口为1024-5000,要想

使用更多的出站端口需要修改注册表,修改方式:

HKEY_LOCAL_MACHINESYSTEMCurrentControlSetServicesTcpipParame

ters项下建一个MaxUserPort(双字节值),例如:取值为10000时,大约有

9000个端口可用。

(2)在intel x86上,非页面内存池仅占物理内存的1/8,windows将socket

正是分配在这一区域上,根据“Busy-ness”原则,为每个socket分配的内存将

随着socket的使用方式发生变化,但最少在2k以上,overlap也正是在申请非

页面内存池,在NT下分块的大小是4k,接收发送共需要8k,这样一个socket

大约需要10k的空间。服务器如果有1G内存,那么能支撑的socket数也在12,000

左右。所以,所谓接受几万个连接完全不可能。真正这样的需求都是通过集群实

现的。

(3)欲知socket的状态,一般要使用iphelper.h中GetTcpTable()实现,当

6

然枚举属性为0x1a的handle也可以实现。

7