Java 中网络编程接口在java.net 包中在使用C/C++进行网络编程时，针对TCP Server端需要这些操作创建SOCKET绑定监听接受连接收取数据包发送数据包TCP Client端需要这些操作创建SOCKET连接Server端发送数据包读取响应包Java中针对Server 端和Client端分别提供了两个类 ServerSocket 和 SocketServerScoket 使用步骤如下:创建ServerSocket 对象并传入一个端口号到构造函数中。在构造的时候会自动创建Socket对象并执行绑定端口、监听端口的操作调用对象的 accept 方法等待连接调用对象的 getInputStream 和 getOutputStream 获取输入输出流，并通过输入输出流来进行收发数据在不用时调用 close 方法关闭套接字Socket 类使用步骤如下:创建 Socket 对象调用 connet 方法连接到指定服务器端口(或者在构造时传入服务器和端口进行连接)调用对象的 getInputStream 和 getOutputStream 获取输入输出流，并通过输入输出流来进行收发数据在不用时调用 close 方法关闭套接字一个普通的TCP通信的实例如下:import java.net.ServerSocket; import java.net.Socket; import java.io.IOException; import java.io.OutputStream; import java.io.InputStream; public class TCPServer{ public static void main(String[] args) throws IOException{ ServerSocket server = new ServerSocket(6666); Socket socket = server.accept() ; InputStream is = socket.getInputStream(); OutputStream os = socket.getOutputStream(); byte[] bytes = new byte[1024]; int len = is.read(bytes); os.write(("echo:" + new String(bytes, 0, len)).getBytes()); server.close(); socket.close(); } }import java.net.Socket; import java.io.IOException; import java.io.OutputStream; import java.io.InputStream; public class TCPClient{ public static void main(String[] args)throws IOException{ Socket socket = new Socket("127.0.0.1", 6666); InputStream is = socket.getInputStream(); OutputStream os = socket.getOutputStream(); os.write("hello world!".getBytes()); byte[] bytes = new byte[1024]; int len = is.read(bytes); System.out.println(new String(bytes, 0, len)); socket.close(); } }下面是一个使用TCP协议进行文件传输的例子:import java.net.Socket; import java.io.IOException; import java.io.FileOutputStream; import java.io.InputStream; import java.net.ServerSocket; public class FileUploadServer{ public static void main(String[] args) throws IOException{ ServerSocket server = new ServerSocket(6666); FileOutputStream fos = new FileOutputStream("1.avi"); Socket socket = server.accept(); InputStream is = socket.getInputStream(); byte[] buff = new byte[1024]; int len = 0; while((len = is.read(buff)) != -1){ fos.write(buff, 0, len); } System.out.println("upload success!"); fos.close(); server.close(); socket.close(); } }import java.net.Socket; import java.io.IOException; import java.io.FileInputStream; import java.io.OutputStream; public class FileUploadClient{ public static void main(String[] args) throws IOException{ Socket socket = new Socket("127.0.0.1", 6666); FileInputStream fis = new FileInputStream("1.avi"); OutputStream os = socket.getOutputStream(); int len = 0; byte[] buff = new byte[1024]; while((len = fis.read(buff)) != -1){ os.write(buff, 0, len); } fis.close(); socket.close(); } }最后提供一个简易的http server 的例子import java.net.Socket; import java.net.ServerSocket; import java.io.InputStreamReader; import java.io.InputStream; import java.io.FileInputStream; import java.io.OutputStream; import java.io.BufferedReader; import java.io.IOException; public class HttpServer{ public static void main(String[] args)throws IOException{ ServerSocket server = new ServerSocket(8080); while(true){ Socket socket = server.accept(); new Thread(()->{ try{ BufferedReader bf = new BufferedReader(new InputStreamReader(socket.getInputStream())); OutputStream os = socket.getOutputStream(); String head = bf.readLine(); String path = head.split(" ")[1].substring(1); System.out.println(path); FileInputStream fis = new FileInputStream(path); int len = 0; byte[] buff = new byte[1024]; os.write("HTTP/1.1 200 OK\r\n".getBytes()); os.write("Content-Type:text/html\r\n".getBytes()); os.write("\r\n".getBytes()); while((len = fis.read(buff)) != -1){ os.write(buff, 0, len); } bf.close(); fis.close(); socket.close(); }catch(IOException e){ e.printStackTrace(); } }).start(); } //server.close(); } }
最近根据项目需要，要在产品中添加对IpV6的支持，因此研究了一下IPV6的相关内容，Ipv6 与原来最直观的改变就是地址结构的改变，IP地址由原来的32位扩展为128，这样原来的地址结构肯定就不够用了，根据微软的官方文档，只需要对原来的代码做稍许改变就可以适应ipv6。修改地址结构Windows Socket2 针对Ipv6的官方描述根据微软官方的说法，要做到支持Ipv6首先要做的就是将原来的SOCKADDR_IN等地址结构替换为SOCKADDR_STORAGE 该结构的定义如下:typedef struct sockaddr_storage { short ss_family; char __ss_pad1[_SS_PAD1SIZE]; __int64 __ss_align; char __ss_pad2[_SS_PAD2SIZE]; } SOCKADDR_STORAGE, *PSOCKADDR_STORAGE;ss_family：代表的是地址家族，IP协议一般是AF_INET, 但是如果是IPV6的地址这个参数需要设置为 AF_INET6。后面的成员都是作为保留字段，或者说作为填充结构大小的字段，这个结构兼容了IPV6与IPV4的地址结构，跟以前的SOCKADDR_IN结构不同，我们现在不能直接从SOCKADDR_STORAGE结构中获取IP地址了。也没有办法直接往结构中填写IP地址。使用兼容函数除了地址结构的改变，还需要改变某些函数，有的函数是只支持Ipv4的，我们需要将这些函数改为即兼容的函数，根据官方的介绍，这些兼容函数主要是下面几个:WSAConnectByName : 可以直接通过主机名建立一个连接WSAConnectByList: 从一组主机名中建立一个连接getaddrinfo: 类似于gethostbyname, 但是gethostbyname只支持IPV4所以一般用这个函数来代替GetAdaptersAddresses: 这个函数用来代替原来的GetAdaptersInfoWSAConnectByName函数:函数原型如下:BOOL PASCAL WSAConnectByName( __in SOCKET s, __in LPSTR nodename, __in LPSTR servicename, __inout LPDWORD LocalAddressLength, __out LPSOCKADDR LocalAddress, __inout LPDWORD RemoteAddressLength, __out LPSOCKADDR RemoteAddress, __in const struct timeval* timeout, LPWSAOVERLAPPED Reserved ); s: 该参数为一个新创建的未绑定，未与其他主机建立连接的SOCKET，后续会采用这个socket来进行收发包的操作nodename: 主机名，或者主机的IP地址的字符串servicename: 服务名称，也可以是对应的端口号的字符串，传入服务名时需要传入那些知名的服务，比如HTTP、FTP等等， 其实这个字段本身就是需要传入端口的，传入服务名，最后函数会根据服务名称转化为这些服务的默认端口LocalAddressLength, LocalAddress， 返回当前地址结构，与长度RemoteAddressLength, RemoteAddress，返回远程主机的地址结构，与长度timeout: 超时值Reserved: 重叠IO结构为了使函数能够支持Ipv6，需要在调用前使用setsockopt函数对socket做相关设置，设置的代码如下:iResult = setsockopt(ConnSocket, IPPROTO_IPV6, IPV6_V6ONLY, (char*)&ipv6only, sizeof(ipv6only) );调用函数的例子如下(该实例为微软官方的例子):SOCKET OpenAndConnect(LPWSTR NodeName, LPWSTR PortName) { SOCKET ConnSocket; DWORD ipv6only = 0; int iResult; BOOL bSuccess; SOCKADDR_STORAGE LocalAddr = {0}; SOCKADDR_STORAGE RemoteAddr = {0}; DWORD dwLocalAddr = sizeof(LocalAddr); DWORD dwRemoteAddr = sizeof(RemoteAddr); ConnSocket = socket(AF_INET6, SOCK_STREAM, 0); if (ConnSocket == INVALID_SOCKET){ return INVALID_SOCKET; } iResult = setsockopt(ConnSocket, IPPROTO_IPV6, IPV6_V6ONLY, (char*)&ipv6only, sizeof(ipv6only) ); if (iResult == SOCKET_ERROR){ closesocket(ConnSocket); return INVALID_SOCKET; } bSuccess = WSAConnectByName(ConnSocket, NodeName, PortName, &dwLocalAddr, (SOCKADDR*)&LocalAddr, &dwRemoteAddr, (SOCKADDR*)&RemoteAddr, NULL, NULL); if (bSuccess){ return ConnSocket; } else { return INVALID_SOCKET; } }WSAConnectByList该函数从传入的一组hostname中选取一个建立连接，函数内部会调用WSAConnectByName，它的原型，使用方式与WSAConnectByName类似，这里就不再给出具体的原型以及调用方法了。getaddrinfo该函数的作用与gethostbyname类似，但是它可以同时支持获取V4、V6的地址结构，函数原型如下:int getaddrinfo( const char FAR* nodename, const char FAR* servname, const struct addrinfo FAR* hints, struct addrinfo FAR* FAR* res );nodename: 主机名或者IP地址的字符串servname: 知名服务的名称或者端口的字符串hints：一个地址结构，该结构规定了应该如何进行地址转化。res：与gethostbyname类似，它也是返回一个地址结构的链表。后续只需要遍历这个链表即可。使用的实例如下:char szServer[] = "www.baidu.com"; char szPort[] = "80"; addrinfo hints = {0}; struct addrinfo* ai = NULL; getaddrinfo(szServer, szPort, NULL, &ai); while (NULL != ai) { SOCKET sConnect = socket(ai->ai_family, SOCK_STREAM, ai->ai_protocol); connect(sConnect, ai->ai_addr, ai->ai_addrlen); shutdown(sConnect, SD_BOTH); closesocket(sConnect); ai = ai->ai_next; } freeaddrinfo(ai); //最后别忘了释放链表针对硬编码的情况针对这种情况一般是修改硬编码，如果希望你的应用程序即支持IPV6也支持IPV4，那么就需要去掉这些硬编码的部分。微软提供了一个工具叫"Checkv4.exe" 这个工具一般是放到VS的安装目录中，作为工具一起安装到本机了，如果没有可以去官网下载。工具的使用也非常简单checkv4.exe 对应的.h或者.cpp 文件这样它会给出哪些代码需要进行修改，甚至会给出修改意见，我们只要根据它的提示修改代码即可。几个例子因为IPV6 不能再像V4那样直接往地址结构中填写IP了，因此在IPV6的场合需要大量使用getaddrinfo函数，来根据具体的IP字符串或者根据主机名来自动获取地址信息，然后根据地址信息直接调用connect即可，下面是微软的例子int ResolveName(char *Server, char *PortName, int Family, int SocketType) { int iResult = 0; ADDRINFO *AddrInfo = NULL; ADDRINFO *AI = NULL; ADDRINFO Hints; memset(&Hints, 0, sizeof(Hints)); Hints.ai_family = Family; Hints.ai_socktype = SocketType; iResult = getaddrinfo(Server, PortName, &Hints, &AddrInfo); if (iResult != 0) { printf("Cannot resolve address [%s] and port [%s], error %d: %s\n", Server, PortName, WSAGetLastError(), gai_strerror(iResult)); return SOCKET_ERROR; } if(NULL != AddrInfo) { SOCKET sConnect = socket(AddrInfo->ai_family, SOCK_STREAM, AddrInfo->ai_protocol); connect(sConnect, AddrInfo->ai_addr, AddrInfo->ai_addrlen); shutdown(sConnect, SD_BOTH); closesocket(sConnect); } freeaddrinfo(AddrInfo); return 0; }这个例子需要传入额外的family参数来规定它使用何种地址结构，但是如果我只有一个主机名，而且事先并不知道需要使用何种IP协议来进行通信，这种情况下又该如何呢？针对服务端，不存在这个问题，服务端是我们自己的代码，具体使用IPV6还是IPV4这个实现是可以确定的，因此可以采用跟上面类似的写法:BOOL Create(int af_family) { //这里不建议使用IPPROTO_IP 或者IPPROTO_IPV6，使用TCP或者UDP可能会好点，因为它们是建立在IP协议之上的 //当然,具体情况具体分析 s = socket(af_family, SOCK_STREAM, IPPROTO_TCP); } BOOL Bind(int family, UINT nPort) { addrinfo hins = {0}; hins.ai_family = family; hins.ai_flags = AI_PASSIVE; /* For wildcard IP address */ hins.ai_protocol = IPPROTO_TCP; hins.ai_socktype = SOCK_STREAM; addrinfo *lpAddr = NULL; CString csPort = ""; csPort.Format("%u", nPort); if (0 != getaddrinfo(NULL, csPort, &hins, &lpAddr)) { closesocket(s); return FALSE; } int nRes = bind(s, lpAddr->ai_addr, lpAddr->ai_addrlen); freeaddrinfo(lpAddr); if(nRes == 0) return TRUE; return FALSE; } //监听，以及后面的收发包并没有区分V4和V6，因此这里不再给出跟他们相关的代码针对服务端，我们自然没办法事先知道它使用的IP协议的版本，因此传入af_family参数在这里不再适用，我们可以利用getaddrinfo函数根据服务端的主机名或者端口号来提前获取它的地址信息，这里我们可以封装一个函数int GetAF_FamilyByHost(LPCTSTR lpHost, int nPort, int SocketType) { addrinfo hins = {0}; addrinfo *lpAddr = NULL; hins.ai_family = AF_UNSPEC; hins.ai_socktype = SOCK_STREAM; hins.ai_protocol = IPPROTO_TCP; CString csPort = ""; csPort.Format("%u", nPort); int af = AF_UNSPEC; char host[MAX_HOSTNAME_LEN] = ""; if (lpHost == NULL) { gethostname(host, MAX_HOSTNAME_LEN);// 如果为NULL 则获取本机的IP地址信息 }else { strcpy_s(host, MAX_HOSTNAME_LEN, lpHost); } if(0 != getaddrinfo(host, csPort, &hins, &lpAddr)) { return af; } af = lpAddr->ai_family; freeaddrinfo(lpAddr); return af; }有了地址家族信息，后面的代码即可以根据地址家族信息来分别处理IP协议的不同版本，也可以使用上述服务端的思路，直接使用getaddrinfo函数得到的addrinfo结构中地址信息，下面给出第二种思路的部分代码:if(0 != getaddrinfo(host, csPort, &hins, &lpAddr)) { connect(s, lpAddr->ai_addr, lpAddr->ai_addrlen); }当然，也可以使用前面提到的 WSAConnectByName 函数，不过它需要针对IPV6来进行特殊的处理，需要事先知道服务端的IP协议的版本。VC中各种地址结构在学习网络编程中，一个重要的概念就是IP地址，而巴克利套接字中提供了好几种结构体来表示地址结构，微软针对WinSock2 又提供了一些新的结构体，有的时候众多的结构体让人眼花缭乱，在这我根据自己的理解简单的回顾一下这些常见的结构SOCKADD_IN 与sockaddr_in结构在Winsock2 中这二者是等价的， 它们的定义如下:struct sockaddr_in{ short sin_family; unsigned short sin_port; struct in_addr sin_addr; char sin_zero[8]; };sin_family: 地址协议家族sin_port：端口号sin_addr: 表示ip地址的结构sin_zero: 用于与sockaddr 结构体的大小对齐，这个数组里面为全0in_addr 结构如下:struct in_addr { union { struct{ unsigned char s_b1, s_b2, s_b3, s_b4; } S_un_b; struct { unsigned short s_w1, s_w2; } S_un_w; unsigned long S_addr; } S_un; }; 这个结构是一个公用体，占4个字节，从本质上将IP地址仅仅是一个占4个字节的无符号整型数据，为了方便读写才会采用点分十进制的方式。仔细观察这个结构会发现，它其实定义了IP地址的几种表现形式，我们可以将IP地址以一个字节一个字节的方式拆开来看，也可以以两个字型数据的形式拆开，也可以简单的看做一个无符号长整型。当然在写入的时候按照这几种方式写入，为了方便写入IP地址，微软定义了一个宏:#define s_addr S_un.S_addr因此在填入IP地址的时候可以简单的使用这个宏来给S_addr这个共用体成员赋值一般像bind、connect等函数需要传入地址结构，它们需要的结构为sockaddr，但是为了方便都会传入SOCKADDR_IN结构sockaddr SOCKADDR结构这两个结构也是等价的，它们的定义如下struct sockaddr { unsigned short sa_family; char sa_data[14];};从结构上看它占16个字节与 SOCKADDR_IN大小相同，而且第一个成员都是地址家族的相关信息，后面都是存储的具体的IPV4的地址，因此它们是可以转化的，为了方便一般是使用SOCKADDR_IN来保存IP地址，然后在需要填入SOCKADDR的时候强制转化即可。sockaddr_in6该结构类似于sockaddr_in，只不过它表示的是IPV6的地址信息，在使用上，由于IPV6是128的地址占16个字节，而sockaddr_in 中表示地址的部分只有4个字节，所以它与之前的两个是不能转化的，在使用IPV6的时候需要特殊的处理，一般不直接填写IP而是直接根据IP的字符串或者主机名来连接。sockaddr_storage这是一个通用的地址结构，既可以用来存储IPV4地址也可以存储IPV6的地址，这个地址结构在前面已经说过了，这里就不再详细解释了。各种地址之间的转化一般我们只使用从SOCKADDR_IN到sockaddr结构的转化，而且仔细观察socket函数族发现只需要从其他结构中得到sockaddr结构，而并不需要从sockaddr转化为其他结构，因此这里重点放在如何转化为sockaddr结构从SOCKADDR_IN到sockaddr只需要强制类型转化即可从addrinfo结构中只需要调用其成员即可从SOCKADDR_STORAGE结构到sockaddr只需要强制转化即可。其实在使用上更常用的是将字符串的IP转化为对应的数值，针对IPV4有我们常见的inet_addr、inet_ntoa 函数,它们都是在ipv4中使用的，针对v6一般使用inet_pton,inet_ntop来转化，它们两个分别对应于inet_addr、inet_ntoa。但是在WinSock中更常用的是WSAAddressToString 与 WSAStringToAddressINT WSAAddressToString( LPSOCKADDR lpsaAddress, DWORD dwAddressLength, LPWSAPROTOCOL_INFO lpProtocolInfo, OUT LPTSTR lpszAddressString, IN OUT LPDWORD lpdwAddressStringLength );lpsaAddress: ip地址dwAddressLength: 地址结构的长度lpProtocolInfo: 协议信息的结构体，这个结构一般给NULLlpszAddressString：目标字符串的缓冲lpdwAddressStringLength:字符串的长度而WSAStringToAddress定义与使用与它类似，这里就不再说明了。
WinSock中提供的5种网络模型已经可以做到很高效了，特别是完成端口，它的高效的原因在于它不仅另外开启了线程来处理完成通知而不是占用主程序的时间，同时也在于我们在完成端口中运用了大量异步IO处理函数。比如WSASend、WSARecv等等。为了高效的处理网络IO，WinSock提供了大量这样的异步函数。这篇博文主要探讨这些函数的用法和他们与传统的巴克利套接字相比更加高效的秘密AcceptEx其实在使用TCP协议编程时，接受连接的过程也是需要进行收发包操作的，具体的过程请参考TCP的三次握手。针对这种特性WinSock提供了对应的异步操作函数AcceptEx。函数原型如下:BOOL AcceptEx( SOCKET sListenSocket, SOCKET sAcceptSocket, PVOID lpOutputBuffer, DWORD dwReceiveDataLength, DWORD dwLocalAddressLength, DWORD dwRemoteAddressLength, LPDWORD lpdwBytesReceived, LPOVERLAPPED lpOverlapped );sListenSocket: 监听套接字sAcceptSocket：该参数是一个SOCKET的句柄，一旦连接成功建立，那么会使用该SOCKET作为通信的SOCKETlpOutputBuffer：是三个数据一体化的缓冲区的指针，这三个数据分别是接收连接时顺带接收客户端发过来的数据的缓冲，之后是本地地址结构的缓冲，最后是远程客户端地址结构的指针dwReceiveDataLength：是lpOutputBuffer的缓冲长度dwLocalAddressLength：是本地地址结构长度,其值等于sizeof(SOCKADDR)+16dwRemoteAddressLength：是远程客户端地址结构长度,其值也等于sizeof(SOCKADDR)+16lpdwBytesReceived：该参数用于返回接受连接请求时接收的数据的长度lpOverlapped：就是重叠I/O需要的结构第一个参数是一个十分重要的参数，这个参数是AcceptEx比较高效的一个重要的原因。从功能上来看它与传统的accept函数并没有什么区别，都是接受客户端连接的。它与accpet相比比较高效的原因如下：从内部机理来说accpet在内部其实有一个创建SOCKET的操作，当函数成功后会返回这个SOCKET，所以AcceptEx与accept相比少了一个创建SOCKET的操作，它的功能更加纯粹，这就给了我们一个启示：我们可以在初始化的时候创建大量的SOCKET，并投递到AcceptEx中，这样在接受连接时省去了创建SOCKET的时间，能够更快速的响应客户端的连接。由于AcceptEx不用创建SOCKET，所以它也将accept内部对socket设置的操作给省去了，也少了一些其他的附带操作，比如地址的解析，其实这里我们可以简单的理解为lpOutputBuffer中保存的信息就是TCP三次握手中的SYN包和ACK包，这些包的信息需要在函数返回后由用户通过其他方法来解析，而accpet帮我们解析了，所以AcceptEx比accept更加高效因为AcceptEx的设计目标纯粹就是为了性能,所以监听套接字的属性不会被代表客户端通讯的套接字自动继承。要继承属性(包括socket内部接受/发送缓存大小等等)就必须调用setsockopt使用选项SO_UPDATE_ACCEPT_CONTEXT,如下:int nRet = ::setsockopt(skClient, SOL_SOCKET ,SO_UPDATE_ACCEPT_CONTEXT ,(char *)&skListen, sizeof(skListen));AcceptEx函数除了能够接受客户端的连接之外，它也可以在接受连接的同时接收客户端随着连接请求一块发过来的数据，只要我们设置dwReceiveDataLength 参数大于0，并在lpOutputBuffer中分配相应的缓冲即可，但是这里会存在一个安全问题，当我们设置了这些之后，如果客户端只发送连接请求，但是不发送数据，AcceptEx会一直等待，如果有大量这样的客户端，那么可能会给服务器造成大量的资源浪费从而不能及时的服务其他正常客户端。要防止这样的情况，可以采用下列措施：设置dwReceiveDataLength为0，并且不分配对应的缓冲，也就是关闭这个接收数据的功能。启动一个监视线程对用于连接的SOCKET轮询调用：int iSecs; int iBytes = sizeof( int ); getsockopt( hAcceptSocket, SOL_SOCKET, SO_CONNECT_TIME, (char *)&iSecs, &iBytes ); //获取SOCKET连接时间iSecs 为 -1 表示还未建立连接, 否则就是已经连接的时间.当iSecs超过某个筏值时,就果断断开这个连接GetAcceptExSockAddr前面说AcceptEx不会对地址进行解析，而是返回一个经过编码的地址信息，可以将它理解为原始的三次握手包。而函数GetAcceptExSockAddr的主要作用就是通过原始的二进制数据得到对应的地址结构。函数原型如下:void GetAcceptExSockaddrs( PVOID lpOutputBuffer, DWORD dwReceiveDataLength, DWORD dwLocalAddressLength, DWORD dwRemoteAddressLength, LPSOCKADDR* LocalSockaddr, LPINT LocalSockaddrLength, LPSOCKADDR* RemoteSockaddr, LPINT RemoteSockaddrLength);lpOutputBuffer:之前提供给AcceptEx函数的缓冲，如果AcceptEx调用成功，会在这个缓冲中写入地址信息，GetAcceptExSockaddrs通过这个缓冲中保存的地址信息来解析出地址结构dwReceiveDataLength：接收到的数据长度，注意这个长度不是lpOutputBuffer，而是客户端随着连接请求一起发送过来的其他数据的长度，其实这里应该理解为地址信息在缓冲中的偏移dwLocalAddressLength：本地地址信息的长度，这个长度为sizeof(SOCKADDR)+16dwRemoteAddressLength：远程客户端的地址信息的长度，长度为sizeof(SOCKADDR)+16LocalSockaddr：解析出来的本地地址结构LocalSockaddrLength:本地地址结构的长度，这个参数是一个输出参数RemoteSockaddr: 解析出来的远程客户端的地址结构RemoteSockaddrLength：解析出来的远程客户端的地址长度，这个参数是一个输出参数这里为什么要返回本地的地址结构呢，主要有两个原因：一般的服务器可能有多块网卡，返回本地地址我们就可以知道服务器用哪块网卡与客户端通信服务器用来监听的端口与用来进行通信的端口不是同一个，返回本地地址我们就能够知道服务器在使用哪个端口与客户端通信TransmitFile对于一些网络应用来说,发送文件有时是一个基本的功能,比如:web服务,FTP服务等。在Winsock中为此而专门提供了一个高效传输文件的API——TransmitFile。函数原型如下:BOOL TransmitFile( SOCKET hSocket, HANDLE hFile, DWORD nNumberOfBytesToWrite, DWORD nNumberOfBytesPerSend, LPOVERLAPPED lpOverlapped, LPTRANSMIT_FILE_BUFFERS lpTransmitBuffers, DWORD dwFlags);这个函数主要工作在TCP协议上hSocket：与客户端进行通信的SOCKEThFile：是对应文件的句柄nNumberOfBytesToWrite：表示发送文件的长度，这个长度可以小于文件长度nNumberOfBytesPerSend：当文件较大时，可以进行拆包发送，这个参数表示每个数据包的大小，如果这个参数为0，将采用系统默认的包大小，NT内核中默认大小为64KlpOverlapped：重叠I/O需要的结构lpTransmitBuffers：是一个TRANSMIT_FILE_BUFFERS结构体,利用它可以指定在文件开始发送前需要发送的额外数据以及文件发送结束后需要发送的额外数据,这个参数也可以置为NULL,仅表示发送文件数据。它的结构如下所示:typedef struct _TRANSMIT_FILE_BUFFERS { PVOID Head; DWORD HeadLength; PVOID Tail; DWORD TailLength; } TRANSMIT_FILE_BUFFERS;dwFlags：它是一个按位组合的标识。它的各个标识的含义如下标识含义TF_DISCONNECT在传输文件结束后,开始一个传输层断开动作TF_REUSE_SOCKET重置套接字,使其可以被AcceptEx等函数重用,这个标志需要与TF_DISCONNECT标志合用TF_USE_DEFAULT_WORKER指定发送文件使用系统默认线程,这对传输大型文件很有利TF_USE_SYSTEM_THREAD使用系统线程发送文件，它与TF_USE_DEFAULT_WORKER作用相同TF_USE_KERNEL_APC指定利用内核APC队列来代替工作线程来处理文件传输. 需要注意的是系统内核APC队列只在应用程序进入等待状态时才工作. 但不一定非要一个可警告状态的等待TF_WRITE_BEHIND指定TransmitFile函数尽可能立即返回,而不管远端是否确认已收到数据.这个标志不能与TF_DISCONNECT和TF_REUSE_SOCKET一起使用可以使用TF_DISCONNECT加上TF_REUSE_SOCKET 来回收SOCKET，以便像AcceptEx这样的函数可以重新利用。此时应该指定hFile为NULL，但这不是这个函数的主业(我觉得应该让专门的函数干专门的事，自己在封装函数的时候也应该要注意，不要向Win32 API这样使用各种标志来控制函数的功能)同时TransmitFile函数只有在服务器版Windows上才能发挥其全部功能。而在专业版或家庭版等Windows上它被限定为最多同时有两个调用在传输,而其他的调用都被置为排队等待状态。发送文件这个功能，是一个十分简单的功能，无非是应用层不断从磁盘文件中读取文件并使用WSASend这样的异步函数来发送，另一端不断用WSARecv接收并写入到文件中，为了性能在读写文件时也可以用IOCP的方式，那么为什么微软为了这么一个简单的功能还要独自封装一个函数，难道它封装的函数就一定比我们自己实现的性能高？上图揭示了TransmitFile能够高效工作的秘密，一般我们来封装这个功能的时候会调用ReadFile，此时由内核层读取文件并将文件文件内容保存在内核的内存空间中，然后通过系统调用们将内容拷贝到R3层，在调用WSASend的时候会将文件内容再从R3层拷贝到R0层，这个过程经过系统调用们，需要调用各种函数，并且进行各种验证。这个操作是十分耗时的。而TransmitFile则相对要高效的多，既然最终是要发送文件，那么它将内容从文件中读取出来后直接将R0层中保存的文件内容通过SOCKET发送出去，有的时候直接采用文件映射的方式将磁盘地址映射到网卡中，直接由网卡读取并发送，这样又省去了从内核中读取文件并拷贝到网卡缓存中的操作。所以它比我们自己封装来的更加高效。TransmitPackets有的时候需要发送超大型数据(有时是几十G)到客户端,有时甚至需要发送多个文件到客户端。这个时候TransmitFile就不再有效了。请注意TransmitFile的第三个参数 nNumberOfBytesToWrite 是一个DWORD类型，这也就标明这个函数最大只能发送4GB的文件，而对于更大的文件它就无能为力了，为了发送更大的文件WinSock专门封装了一个函数——TransmitPacketsBOOL PASCAL TransmitPackets( SOCKET hSocket, LPTRANSMIT_PACKETS_ELEMENT lpPacketArray, DWORD nElementCount, DWORD nSendSize, LPOVERLAPPED lpOverlapped, DWORD dwFlags );这个函数不但可以在面向连接(面向流)式的协议(TCP)上工作,还可以在无连接式的数据报协议(UDP)上工作,而TransmitFile函数只能工作在TCP上hSocket：表示发送所用的SOCKETlpPacketArray：它是一个结构体数组的指针，这个结构体表示发送文件的相关信息，结构体的定义如下:typedef struct _TRANSMIT_PACKETS_ELEMENT { ULONG dwElFlags; ULONG cLength; union { struct { LARGE_INTEGER nFileOffset; HANDLE hFile; }; PVOID pBuffer; }; } TRANSMIT_PACKETS_ELEMENT;这个结构体主要包含3个部分，第一个部分是一个标志，表示该如何解释后面的部分，这个标志有如下几个值标志含义TP_ELEMENT_FILE标明它将发送一个文件，此时会使用共用体中的结构体部分TP_ELEMENT_MEMORY标明它将要将发送内存中的一段空间的数据，此时会使用共用体中pBuffer部分TP_ELEMENT_EOP而最后一个标志用于辅助说明前两个标志,说明当前结构表示的数据应当作为一个结束包来发送,也就说之前所有的数据到当前这个结构描述的数据应当视为一个包第二部分是cLength用以说明当前结构描述的数据长度/发送文件内容的长度第三个部分联合定义根据第一个部分的实际标志值,用于说明是一个文件还是一个内存块,当是一个文件时还可以指定一个64位长整数型的文件内偏移,这为应用利用TransmitPackets发送大于4GB的文件创造了可能.当偏移为-1时,表示从文件当前指针位置开始发送需要注意的是因为TransmitPackets能够很快的处理数据发送,因此可能会造成大量待发送数据堆积在下层协议的协议栈上.而对于无连接的面向数据报的协议来说,有时协议驱动会选择将它们简单丢弃.另外对于TransmitPackets来说也只有服务器版的Windows能够发挥它全部的性能,而对于家庭版和专业版来说,最多能够同时处理两个TransmitPackets调用,其它的调用都会被排队处理最后TransmitPackets在发送文件时工作机理与TransmitFile是类似的,而TransmitPackets可以发送多个文件,并且可以发送超大文件(大于4GB),在发送内存块上,TransmitPackets也有很多优化,调用者可以放心的将超大的缓冲块传递给TransmitPackets而不必过多的担心ConnectEx作为客户端应用来说,或者说一些需要反连接工作的应用来说(如:Active FTP方式的服务器),使用传统的connect进行阻塞式或非阻塞式的编程都无法得到很好的性能响应它的定义如下:BOOL PASCAL ConnectEx( SOCKET s, const struct sockaddr* name, int namelen, PVOID lpSendBuffer, DWORD dwSendDataLength, LPDWORD lpdwBytesSent, LPOVERLAPPED lpOverlapped );s: 进行连接操作的SOCKET句柄,这个SOCKET句柄需要事先绑定，这里与调用普通的connect函数不同，它需要先调用bind函数将本地地址与SOCKET绑定name：要连接的远端服务器的地址结构namelen：就是远端地址结构的长度lpSendBuffer,dwSendDataLength,lpdwBytesSent三个参数共同用于描述在连接到服务器成功之后向服务器直接发送的数据缓冲,长度以及实际发送的数据长度lpOverlapped就是重叠I/O操作需要的结构体与AcceptEx类似，在连接成功后，需要调用 setsocketopt 来设置SOCKET的属性。与传统的connect函数不同,ConnectEx函数要求一个已经绑定过的SOCKET句柄参数,其实这也是将connect内部的绑定操作排除在真正connect操作之外的一种策略。最终连接的操作也会很快的就被完成,而绑定可以提前甚至在初始化的时候就完成。这样做也是为了能够快速的处理网络事件。DisConnectEx前面在TransmitFile中说它可以使用TF_DISCONNECT加上TF_REUSE_SOCKET 来回收SOCKET，也提到应该用专门的函数来干专门的事，这里ConnectEx就是专门的函数。它主要的作用与普通的closesocket函数类似。BOOL DisconnectEx( SOCKET hSocket, LPOVERLAPPED lpOverlapped, DWORD dwFlags, DWORD reserved );hSocket ：表示将要被回收的SOCKETlpOverlapped：重叠IO所使用的结构dwFlags：它是一个标志值，表示是否需要回收SOCKET，如果为0则表示不需要回收，此时它的作用与closesocket类似。如果为TF_REUSE_SOCKET表示将回收SOCKETreserved：是一个保留值直接传0即可当以重叠I/O的方式调用DisconnectEx时,若该SOCKET还有未完成的传输调用时,该函数会返回FALSE,并且最终错误码是WSA_IO_PENDING,即断开/回收操作将在传输完成后执行。如果使用了重叠IO，同样在完成之后会调用完成处理函数。如果未采用重叠IO操作，那么函数会阻塞，直到数据发送完成并断开连接。扩展函数的动态加载之前介绍的这一系列Winsock2.0的扩展API,最好都动态加载之后再行调用,因为它们具体的导出位置在不同平台上变动太大,如果静态联编的话,会给开发编译工作带来巨大的麻烦,所以使用运行时动态加载来调用这些API。但是这些函数的加载与加载普通的dll函数不同，为了方便操作，WinSock提供了一套完整的支持。这表示我们不需要知道它们所在的dll，我们可以直接使用WinSock提供的方法，即使以后它们所在的dll文件变化了，也不会影响我们的使用。加载它们需要使用到函数WSAIoctl,函数原型如下:int WSAIoctl( SOCKET s, DWORD dwIoControlCode, LPVOID lpvInBuffer, DWORD cbInBuffer, LPVOID lpvOutBuffer, DWORD cbOutBuffer, LPDWORD lpcbBytesReturned, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine );这个函数的使用方法与ioctlsocket 相似。这里不对它的详细用法进行讨论。这里就简单的说说该怎么用它加载这些函数。要加载WinSock API，首先需要将第二个控制码参数设置为SIO_GET_EXTENSION_FUNCTION_POINTER，表示获取扩展API的指针。设置了这个参数后，lpvInBuffer参数需要设置成相应函数的GUID，下面列举了各个函数的GUID值GDUI函数WSAID_ACCEPTEXAcceptExWSAID_CONNECTEXConnectExWSAID_DISCONNECTEXDisconnectExWSAID_GETACCEPTEXSOCKADDRSGetAcceptExSockaddrsWSAID_TRANSMITFILETransmitFileWSAID_TRANSMITPACKETSTransmitPacketsWSAID_WSARECVMSGWSARecvMsgWSAID_WSASENDMSGWSASendMsg函数的指针通过 lpvOutBuffer 参数返回，而cbOutBuffer表示接受缓冲的长度，lpcbBytesReturned表示返回数据的长度。后面两个参数都与完成IO有关，所以这里可以直接给NULL。下面是一个加载AcceptEx函数的例子typedef BOOL (PASCAL FAR * LPFN_ACCEPTEX)( IN SOCKET sListenSocket, IN SOCKET sAcceptSocket, IN PVOID lpOutputBuffer, IN DWORD dwReceiveDataLength, IN DWORD dwLocalAddressLength, IN DWORD dwRemoteAddressLength, OUT LPDWORD lpdwBytesReceived, IN LPOVERLAPPED lpOverlapped ); LPFN_ACCEPTEX pFun = NULL; SOCKET sTemp = WSASocket(af, type, protocol, NULL, NULL, 0); GUID funGuid = WSAID_ACCEPTEX; if (INVALID_SOCKET != skTemp) { DWORD dwOutBufferSize = 0; int Ret = ::WSAIoctl(skTemp, SIO_GET_EXTENSION_FUNCTION_POINTER, &funGuid, sizeof(funGuid), &pFun, sizeof(pFun), &dwOutBufferSize, NULL, NULL); } 这里调用WSAIoctl加载扩展函数时需要传入SOCKET句柄，它其实是利用传入的SOCKET的相关信息来导出对应版本的扩展函数，比如这里我们传入的是一个用在TCP协议之上的SOCKET，所以它会返回一个使用TCP协议的API，利用这个SOCKET，这个函数以及它返回的API真正做到了与协议无关。
title: WinSock 完成端口模型tags: [WinSock 模型, 网络编程, 完成端口]date: 2018-07-06 20:44:39categories: Windows 网络编程keywords: WinSock 模型, 网络编程, 完成端口之前写了关于Winsock的重叠IO模型，按理来说重叠IO模型与之前的模型相比，它的socket即是非阻塞的，也是异步的，它基本上性能非常高，但是它主要的缺点在于，即使我们使用历程来处理完成通知，但是我们知道历程它本身是在对应线程暂停，它借用当前线程的线程环境来执行完成通知，也就是说要执行完成通知就必须暂停当前线程的工作。这对工作线程来说也是一个不必要的性能浪费，这样我们自然就会想到，另外开辟一个线程来执行完成通知，而本来的线程就不需要暂停，而是一直执行它自身的任务。处于这个思想，WinSock提供了一个新的模型——完成端口模型。完成端口简介完成端口本质上是一个线程池的模型，它需要我们创建对应的线程放在那，当完成通知到来时，他会直接执行线程。在这5中模型中它的性能是最高的。在文件中我们也提到过完成端口，其实我们利用Linux上一切皆文件的思想来考虑这个问题就可以很方便的理解，既然我们需要异步的方式来读写网卡的信息，这与读写文件的方式类似，既然文件中存在完成端口模型，网络上存在也就不足为奇了。对于完成端口Windows没有引入新的API函数，而是仍然采用文件中一堆相关的函数。可以使用CreateIoCompletionPort来创建完成端口的句柄，该函数原型如下:HANDLE WINAPI CreateIoCompletionPort( __in HANDLE FileHandle, __in_opt HANDLE ExistingCompletionPort, __in ULONG_PTR CompletionKey, __in DWORD NumberOfConcurrentThreads );第一个参数是与完成端口绑定的文件句柄，如果我们要创建完成端口句柄，这个值必须传入INVALID_HANDLE_VALUE。如果是要将文件句柄与完成端口绑定，这个参数必须穿入一个支持完成端口的文件句柄。在Winsock中如果要绑定SOCKET到完成端口只需要将SOCKET强转为HANDLE。第二个参数是一个已知的完成端口句柄，如果是创建完成端口，这个参数填入NULL。第三个参数是一个LONG型的指针，它作为一个标志，由完成通知传入完成线程中，用来标识不同的完成通知。一般我们会定义一个扩展来OVERLAPPED结构来标识不同的完成通知，所以这个参数一般不用传入NULL。第四个参数是同时执行的线程数，如果是绑定文件句柄到完成端口，则这个参数填入0我们可以在对应的完成线程中调用GetQueuedCompletionStatus函数来获取完成通知，这个函数只有当有IO操作完成时才会返回，函数原型如下:BOOL WINAPI GetQueuedCompletionStatus( __in HANDLE CompletionPort, __out LPDWORD lpNumberOfBytes, __out PULONG_PTR lpCompletionKey, __out LPOVERLAPPED* lpOverlapped, __in DWORD dwMilliseconds ); 它的第一个参数是一个完成端口的句柄。第二个参数表示当前有多少字节的数据完成IO操作。第三个参数是一个标记值，用来标识不同文件句柄对应的完成通知，它是通过 CreateIoCompletionPort 函数设置的那个标识。第四个参数是OVERLAPPED结构。第五个参数表示等待的时间，如果填入INFINITE则会一直等到有IO操作完成。完成端口的示例：下面是一个完成端口的示例typedef struct _tag_MY_OVERLAPPED { OVERLAPPED m_overlapped; SOCKET m_sClient; long m_lEvent; DWORD m_dwNumberOfBytesRecv; DWORD m_dwFlags; char *m_pszBuf; LONG m_dwBufSize; }MY_OVERLAPPED, *LPMY_OVERLAPPED; unsigned int __stdcall IOCPThread(LPVOID lpParameter); #define BUFFER_SIZE 1024 #define SERVER_PORT 6000 int _tmain(int argc, TCHAR *argv) { WSADATA wd = {0}; WSAStartup(MAKEWORD(2, 2), &wd); SYSTEM_INFO si = {0}; GetSystemInfo(&si); //创建完成端口对象 HANDLE hIocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, NULL, si.dwNumberOfProcessors); //创建完成端口对应的线程对象 HANDLE *pThreadArray = (HANDLE *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 2 * si.dwNumberOfProcessors); for (int i = 0; i < 2 * si.dwNumberOfProcessors; i++) { pThreadArray[i] = (HANDLE)_beginthreadex(NULL, 0, IOCPThread, &hIocp, 0, NULL); } SOCKET SrvSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_IP); SOCKADDR_IN SockAddr = {0}; SockAddr.sin_family = AF_INET; SockAddr.sin_port = htons(SERVER_PORT); SockAddr.sin_addr.s_addr = htonl(INADDR_ANY); bind(SrvSocket, (SOCKADDR*)&SockAddr, sizeof(SOCKADDR)); listen(SrvSocket, 5); SOCKET sClient = accept(SrvSocket, NULL, NULL); CreateIoCompletionPort((HANDLE)sClient, hIocp, NULL, 0); WSABUF buf = {0}; buf.buf = (char*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, BUFFER_SIZE); buf.len = BUFFER_SIZE; MY_OVERLAPPED AcceptOverlapped = {0}; AcceptOverlapped.m_dwBufSize = BUFFER_SIZE; AcceptOverlapped.m_lEvent = FD_READ; AcceptOverlapped.m_pszBuf = buf.buf; AcceptOverlapped.m_sClient = sClient; WSARecv(sClient, &buf, 1, &AcceptOverlapped.m_dwNumberOfBytesRecv, &AcceptOverlapped.m_dwFlags, &AcceptOverlapped.m_overlapped, NULL); while (TRUE) { int nVirtKey = GetAsyncKeyState(VK_ESCAPE); //用户按下退出键(ESC) { break; } } for (int i = 0; i < si.dwNumberOfProcessors * 2; i++) { //向IOCP发送FD_CLOSE消息，以便对应线程退出 AcceptOverlapped.m_lEvent = FD_CLOSE; PostQueuedCompletionStatus(hIocp, si.dwNumberOfProcessors * 2, 0, &AcceptOverlapped.m_overlapped); } WaitForMultipleObjects(2 * si.dwNumberOfProcessors, pThreadArray, TRUE, INFINITE); for (int i = 0; i < si.dwNumberOfProcessors * 2; i++) { CloseHandle(pThreadArray[i]); } HeapFree(GetProcessHeap(), 0, buf.buf); shutdown(sClient, SD_BOTH); closesocket(sClient); CloseHandle(hIocp); WSACleanup(); return 0; } unsigned int __stdcall IOCPThread(LPVOID lpParameter) { HANDLE hIocp = *(HANDLE*)lpParameter; DWORD dwNumberOfBytes = 0; MY_OVERLAPPED *lpOverlapped = NULL; ULONG key = 0; BOOL bLoop = TRUE; while (bLoop) { BOOL bRet = GetQueuedCompletionStatus(hIocp, &dwNumberOfBytes, &key, (LPOVERLAPPED*)&lpOverlapped, INFINITE); if (!bRet) { continue; } switch (lpOverlapped->m_lEvent) { case FD_CLOSE: //退出 { bLoop = FALSE; printf("线程[%08x]准备退出......\n", GetCurrentThreadId()); } break; case FD_WRITE: { printf("数据发送完成......\n"); shutdown(lpOverlapped->m_sClient, SD_BOTH); closesocket(lpOverlapped->m_sClient); } break; case FD_READ: { printf("client>%s", lpOverlapped->m_pszBuf); lpOverlapped->m_lEvent = FD_WRITE; WSABUF buf = {0}; buf.buf = lpOverlapped->m_pszBuf; buf.len = dwNumberOfBytes; lpOverlapped->m_dwFlags = 0; WSASend(lpOverlapped->m_sClient, &buf, 1, &lpOverlapped->m_dwNumberOfBytesRecv, lpOverlapped->m_dwFlags, &lpOverlapped->m_overlapped, NULL); } } } return 0; }在上述代码中，首先定义了一个结构体用来保存额外的数据。在main函数中首先查询CPU的核数，然后创建这个数目2倍的线程。接着创建一个完成端口对象。然后进行SOCKET的创建、绑定、监听、接收连接的操作。当有连接进来的时候。创建对应的扩展结构并调用WSARecv投递一个接收操作。由于后面的收发操作都在对应的线程中操作，因此在主线程中只需要等待即可。当用户确定退出时。先调用PostQueuedCompletionStatus函数向完成线程中发送完成通知，并将网络事件设置为FD_CLOSE，表示让线程退出。在这里没有使用TerminateThread这种暴力的方式，而选择了一种让线程自动退出的温和的方式。接着进行资源的回收，最后退出。在线程中，我们首先在循环中调用 GetQueuedCompletionStatus函数来获取完成通知，当发生完成事件时，我们在switch中根据不同的额网络事件来处理，针对FD_CLOSE事件，直接退出线程。针对FD_READ事件，先打印客户端发送的信息，然后调用WSASend将信息原样返回，接着设置网络事件为FD_WRITE，以便断开与客户端的链接。几种模型的比较最后针对5种模型和两种socket工作模式来做一个归纳说明。最先学习的是SOCKET的阻塞模式，它的效率最低，它会一直等待有客户端连接或者有数据发送过来才会返回。这就好像我们在等某个人的信，但是不知道这封信什么时候能送到，于是我们在自家门口的收信箱前一直等待，直到有信到来。为了解决这个问题，提出了SOCKET的非阻塞模式，它不会等待连接或者收发数据的操作完成，当我们调用对应的accept或者send、recv时会立即返回，但是我们不知道它什么时候有数据要处理，如果针对每个socket都等待直到有数据到来，那么跟之前的阻塞模式相比没有任何改进，于是就有了socket模式，它会等待多个socket，只要其中有一个有数据就返回，并处理。用收信的模型类比的话，现在我们不用在邮箱前等待了。但是我们会每隔一段时间就去邮箱那看看，有没有信，有信就将它收回否则空手而归。我们说select模型的最大问题在于不知道什么时候有待决的SOCKET，因此我们需要在循环中不停的等待。为了解决这个时机问题，又提出了WSAAsyncSelect模型和WSAEvent模型，它们主要用来解决调用对应函数的时机。用收信的例子类比就是现在我在邮箱上装了一个报警的按钮，只有有信，警报就会响，这个时候我们就去收信。而不用向之前那样每隔一段时间就去邮箱看看我们说解决了时机的问题，但是调用send和recv对网卡进行读写操作仍然是同步的操作，CPU需要傻傻的等着数据从网卡读到内存或者从内存写到网卡上。因此又有了重叠IO的模型和一些列的新的API，向WSARecv和WSASend等等函数。这样就相当于当有信来的警报响起时，我们不需要自己去取信了，另外派了一个人帮我们拿信，这样我们的工作效率又提高了一些。节约了我们的时间重叠IO也有它的问题，如果使用重叠IO的事件模型时，也需要在合适的时候等待，就好像我们虽然派了一个人来帮忙拿信，但是我们自己却需要停下手头上的工作，询问拿信的人回来了。而使用完成历程也存在自己的问题，因为它需要使用主线程的资源来执行历程，它需要主线程暂停下来，这样就可能出现两种情况：1）有通知事件到来，但是并没有进入可警告状态；2）进入可警告状态却没有客户端发送请求。这就相当于可能我们不停的等待但是拿信的那个人却没有回来，或者拿信的人回来了，我们却没有时间处理信件。针对重叠IO的上述问题，提出了完成端口的解决方案，完成事件由对应的线程处理，而主线程只需要专注于它自己的工作就好了，这就相当于警报响了，我们知道信来了，直接派一个人去拿信，后面的我就不管了，而拿信的人把信拿回来的时候将信放好。当我们忙完之后去处理这封信。没忙完的话信就一直放在那，甚至让拿信的人处理这封信，这样就能更高效的集中注意力来处理眼前的工作。
在前面我们说了WSAAsyncSelect 模型，它相比于select模型来说提供了这样一种机制：当发生对应的IO通知时会立即通知操作系统，并调用对应的处理函数，它解决了调用send和 recv的时机问题，但是它有一个明显的缺点，就是它必须依赖窗口。对此WinSock 提供了另一种模型 WSAEventSelect模型简介该模型主要特色在于它使用事件句柄来完成SOCKET事件的通知。与WSAAsyncSelect 模型类似，它也允许使用事件对象来完成多个socket的完成通知。该模型首先在每个socket句柄上调用WSACreateEvent来创建一个WSAEvent对象句柄（早期的WSAEvent与传统的Event句柄有一定的区别，但是从WinSock2.0 以后二者是同一个东西）。接着调用WSAEventSelect将SOCKET句柄和WSAEvent对象绑定，最终通过WSAWaitForMultiEvents来等待WSAEvent变为有信号，然后再来处理对应的socketWSAEvent有两种工作模式和工作状态工作状态有有信号和无信号两种工作模式有手工重置和人工重置，手工重置指的是每当WSAWaitForMultiEvents或者WSAWaitForSingleEvents 返回之后，WSAEvent不会自动变为无信号，需要手工调用WSAResetEvent来将WSAEvent对象设置为无信号，而自动重置表示每次等待函数返回后会自动重置为无信号；调用WSACreateEvent创建的WSAEvent对象是需要手工重置的，如果想创建自动重置的WSAEvent对象可以调用CreateEvent函数来创建（由于WinSock2.0 之后二者没有任何区别，所以只需要调用CreateEvent并将返回值强转为WSAEvent即可）WSAEventSelect函数的原型如下:int WSAEventSelect( SOCKET s, WSAEVENT hEventObject, long lNetworkEvents);其中s表示对应的SOCKET，hEventObject表示对应的WSAEvent对象，lNetworkEvents 表示我们需要处理哪些事件，它有一些对应的宏定义网络事件对应的含义FD_READ当前可以进行数据接收操作，此时可以调用像 recv, recvfrom, WSARecv, 或者 WSARecvFrom 这样的函数FD_WRITE此时可以发送数据，可以调用 send, sendto, WSASend, or WSASendToFD_ACCEPT可以调用accept (Windows Sockets) 或者 WSAAccept 除非返回的错误代码是WSATRY_AGAIN.FD_CONNECT表示当前可以连接远程服务器FD_CLOSE当前收到关闭的消息当WSAWaitForMultipleEvents返回时同时会返回一个序号，用于标识是数组中的哪个WSAEvent有信号，我们使用 index - WSA_WAIT_EVENT_0 来获取对应WSAEvent在数组中的下标，然后根据这个事件对象找到对应的SOCKET即可获得了对应的SOCKET以后，还需要获取到当前是哪个事件发生导致它变为有信号，我们可以调用WSAEnumNetworkEvents函数来获取对应发生的网络事件int WSAEnumNetworkEvents( SOCKET s, WSAEVENT hEventObject, LPWSANETWORKEVENTS lpNetworkEvents );s就是要获取其具体事件通知的SOCKET句柄hEventObject就是对应的WSAEvent句柄,可以不传入,因为SOCKET句柄已经说明了要获取那个句柄上的通知,当然如果传入了,那么这个函数会对这个WSAEvent做一次重置,置为无信号的状态,相当于WSAResetEvent调用。此时我们就不需要调用WSAResetEvent函数了最后一个参数是一个结构，结构的定义如下:typedef struct _WSANETWORKEVENTS { long lNetworkEvents; int iErrorCode[FD_MAX_EVENTS]; } WSANETWORKEVENTS, *LPWSANETWORKEVENTS;第一个数据是当前产生的网络事件。iErrorCode数组是对应每个网络事件可能发生的错误代码,对于每个事件错误代码其具体数组下标是预定义的一组FD_开头的串再加上一个_BIT结尾的宏,比如FD_READ事件对应的错误码下标是FD_READ_BIT下面的代码演示了处理接收(读取)数据的事件错误的例子代码if (NetworkEvents.lNetworkEvents & FD_READ) { if (NetworkEvents.iErrorCode[FD_READ_BIT] != 0) { printf("FD_READ failed with error %d\n", NetworkEvents.iErrorCode[FD_READ_BIT]); } }到目前为止，我们可以总结一下使用WSAEventSelect模型的步骤调用WSACreateEvent为每一个SOCKET创建一个等待对象，并与对应的SOCKET形成映射关系调用WSAEventSelect函数将SOCKET于WSAEvent对象进行绑定调用WSAWaitForMultipleEvents 函数对所有SOCKET句柄进行等待当WSAWaitForMultipleEvents 函数返回时利用返回的索引找到对应的WSAEvent对象和SOCKET对象调用WSAEnumNetworkEvents来获取对应的网络事件，根据网络事件来进行对应的收发操作重复3~5的步骤示例下面是一个简单的例子int _tmain(int argc, TCHAR *argv[]) { WSADATA wd = {0}; WSAStartup(MAKEWORD(2, 2), &wd); SOCKET skServer = socket(AF_INET, SOCK_STREAM, IPPROTO_IP); SOCKADDR_IN AddrServer = {AF_INET}; AddrServer.sin_port = htons(SERVER_PORT); AddrServer.sin_addr.s_addr = htonl(INADDR_ANY); bind(skServer, (SOCKADDR*)&AddrServer, sizeof(SOCKADDR)); listen(skServer, 5); printf("服务端正在监听...........\n"); CWSAEvent WSAEvent; WSAEvent.InsertClient(skServer, FD_ACCEPT | FD_CLOSE); WSAEvent.EventLoop(); WSACleanup(); return 0; }在代码中定义了一个类CWSAEvent，该类封装了关于该模型的相关操作和对应事件对象和SOCKET对象的操作，在主函数中首先创建监听的SOCKET，然后绑定、监听,并提交监听SOCKET到类中，以便对它进行管理,函数InsertClient的定义如下:void CWSAEvent::InsertClient(SOCKET skClient, long lNetworkEvents) { m_socketArray[m_nTotalItem] = skClient; m_EventArray[m_nTotalItem] = WSACreateEvent(); WSAEventSelect(skClient, m_EventArray[m_nTotalItem++], lNetworkEvents); }这个函数中主要向事件数组和SOCKET数组的对应位置添加了相应的成员，然后调用WSAEventSelect。而类的EventLoop函数定义了一个循环来重复前面的3~5步,函数的部分代码如下:int CWSAEvent::WaitForAllClient() { DWORD dwRet = WSAWaitForMultipleEvents(m_nTotalItem, m_EventArray, FALSE, WSA_INFINITE, FALSE); WSAResetEvent(m_EventArray[dwRet - WSA_WAIT_EVENT_0]); return dwRet - WSA_WAIT_EVENT_0; } int CWSAEvent::EventLoop() { WSANETWORKEVENTS wne = {0}; while (TRUE) { int nRet = WaitForAllClient(); WSAEnumNetworkEvents(m_socketArray[nRet], m_EventArray[nRet], &wne); if (wne.lNetworkEvents & FD_ACCEPT) { if (0 != wne.iErrorCode[FD_ACCEPT_BIT]) { OnAcceptError(nRet, m_socketArray[nRet], wne.iErrorCode[FD_ACCEPT_BIT]); }else { OnAcccept(nRet, m_socketArray[nRet]); } }else if (wne.lNetworkEvents & FD_CLOSE) { if (0 != wne.iErrorCode[FD_CLOSE_BIT]) { OnCloseError(nRet, m_socketArray[nRet], wne.iErrorCode[FD_CLOSE_BIT]); }else { OnClose(nRet, m_socketArray[nRet]); } }else if (wne.lNetworkEvents & FD_READ) { if (0 != wne.iErrorCode[FD_READ_BIT]) { OnReadError(nRet, m_socketArray[nRet], wne.iErrorCode[FD_READ_BIT]); }else { OnRead(nRet, m_socketArray[nRet]); } }else if (wne.lNetworkEvents & FD_WRITE) { if (0 != wne.iErrorCode[FD_WRITE_BIT]) { OnWriteError(nRet, m_socketArray[nRet], wne.iErrorCode[FD_WRITE_BIT]); }else { OnWrite(nRet, m_socketArray[nRet]); } } } }函数首先进行了等待，当等待函数返回时，获取对应的下标，以此来获取到socket和事件对象，然后调用WSAEnumNetworkEvents来获取对应的网络事件，最后根据事件调用不同的处理函数来处理在上面的代码中，这个循环有一个潜在的问题，我们来设想这么一个场景，当有多个客户端同时连接服务器，在第一次等待返回时，我们主要精力在进行该IO事件的处理，也就是响应这个客户端A的请求，而此时客户端A又发送了一个请求，而另外几个客户端B随后也发送了一个请求，在第一次处理完成后，等待得到的将又是客户端A，而后续客户端B的请求又被排到了后面，如果这个客户端A一直不停的发送请求，可能造成的问题是服务器一直响应A的请求，而对于B来说，它的请求迟迟得不到响应。为了避免这个问题，我们可以在函数WSAWaitForMultipleEvents 返回后，针对数组中的每个SOCKET循环调用WSAWaitForMultipleEvents将等待的数量设置为1，并将超时值设置为0，这个时候这个函数的作用就相当于查看数组中的每个SOCKET，看看是不是有待决的，当所有遍历完成后依次处理这些请求或者专门创建对应的线程来处理请求最后，整个示例代码
在Windows中所有的socket函数都是阻塞类型的，也就是说只有网络中有特定的事件发生时才会返回，在没有发生事件时会一直等待，虽说我们将它们设置为非阻塞状态，但是在对于服务器段而言，肯定会一直等待客户端的消息，也就是说即使设置为非阻塞状态，时间到了函数返回，但是程序不能结束，需要一个循环不断的侦听，特别是对于有多个客户端需要管理的时候，每一个与客户端通信的socket都需要一个侦听，这样管理起来非常麻烦，我们希望系统帮助我们管理，告诉我们有哪些socket现在可以操作。为了实现这个，我们可以使用select模型select模型中需要一个结构体fd_set，该结构体是一个socket的集合，我们可以看到该结构体的定义：typedef struct fd_set { u_int fd_count; /* how many are SET? */ SOCKET fd_array[FD_SETSIZE]; /* an array of SOCKETs */ } fd_set;从这个定义中可以看到，结构体中主要保存了一个socket的数组和一个保存数组的大小变量；使用select模型主要使用函数select，该函数原型如下：int select ( int nfds, //系统保留，无意义 fd_set FAR * readfds,//可读的socket集合 fd_set FAR * writefds,//可写的socket集合 fd_set FAR * exceptfds,//外带socket集合 const struct timeval FAR * timeout//该函数的超时值 );在程序中使用该函数前需要在特定的集合中放入需要检测的socket值，当发生某一时间导致该函数返回时，函数会将特定集合中未待决的socket全部剔除出去，保留待决套接字，比如在readfds集合中放入几个套接字并执行完成函数，那么留下的套接字都是可以从系统的相应缓冲区读数据的。通过遍历相应的集合我们知道如何对套接字做相应的操作；select模型最多支持64个套接字，这个值由FD_SETSIZE宏定义的，我们可以修改这个宏的值，以便支持更多的套接字，修改时尽量不要在系统文件中修改，在我们的工程文件中修改，可用使用如下方式：#ifdef FD_SETSIZE #undef FD_SETSIZE #endif #define FD_SETSIZE 200这段代码使得select模型支持200个套接字；虽然可以修改，但是这个数组太大，会消耗过多的系统资源，每次在遍历数组时总会从头到尾遍历，数组太大效率必然底下，所以最好不要修改这个值，处理大于64个套接字的情况下可以使用多线程的方式，多定义几个集合处理;为了操作这个集合，Windows专门定义了一组宏，他们分别是：FD_SET(fd, &set) //将fd套接字压入集合set中 FD_ISSET(fd, &set)//判断fd是否在set中 FD_ZERO(&set)//将集合set清零 FD_CLR(fd, &set)//将fd从集合set中删除下面说一下服务端一个简单的select模型的编写1)创建套接字，绑定、侦听；2）等待客户端链接3)将连接返回的套接字压入一个数组中保存4)将数组的套接字填入集合中5)调用select函数6)检测特定集合中的套接字7)进行读写操作8)返回到第四步，等待客户端下一步请求在编写时需要注意以下几点：1）为了与多个客户端保持连接，需要一个数组保存与客户端连接的所有的socket，由于select函数只会执行一次，每次返回后需要再次将徐监控的套接字压入集合，调用select，以便进行下一次检测；所以一般将这一步写在一个死循环中2）注意select是一个阻塞函数，所以为了可以支持多个客户端可以采用一些方法：第一种就是采用多线程的方式，每有一个客户端连接都需要将新开一个线程处理并调用select监控；另一种就是调用select对侦听套接字以及与客户端通信的套接字；为什么可以这样呢，这就要说到TCP/IP中的三次握手，首先一般由客户端发起链接，发送一条数据包到服务器，服务器接收到数据，发送一条确认信息给客户端，然后客户端再发送一条数据，这样就正式建立连接，所以在客户端与服务器建立连接时必然会发送数据，而服务器一定会收到数据，所以将侦听套接字放入到read集合中，当有客户端需要连接时自然会收到一条数据，这个时候select会返回，我们需要校验集合中的套接字是否是侦听套接字，如果是则表明有客户端需要连接；这样当客户端有请求select会返回，可以进行下一次的侦听，没有请求，会死锁在select函数上，但是对于所有客户端并没有太大的影响；3）我们用数组存储所有的套接字时，每当有客户端链接，我们需要添加，而有客户端断开链接我们需要在数组中删除，并将下一个套接字添加进该位置，为了管理套接字数组，我们另外需要一个队列用来记录退出客户端的socket在数组中的位置，下一次有新的链接进来就将相应的套接字放到这个位置。下面是一个简单的例子：SOCKET g_sockArray[FD_SETSIZE] = { 0 }; int g_nCount = 0; int _tmain(int argc, _TCHAR* argv[]) { WSAData wd; WSAStartup(MAKEWORD(2, 2), &wd); SOCKET sockListen = socket(AF_INET, SOCK_STREAM, IPPROTO_IP); if (INVALID_SOCKET == sockListen) { cout << "创建套接字失败,错误码为:" << WSAGetLastError() << endl; closesocket(sockListen); WSACleanup(); return 0; } SOCKADDR_IN SrvAddr = { AF_INET }; SrvAddr.sin_port = htons(6666); SrvAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); if (SOCKET_ERROR == bind(sockListen, (SOCKADDR*)&SrvAddr, sizeof(SOCKADDR))) { cout << "绑定失败,错误码：" << WSAGetLastError() << endl; closesocket(sockListen); WSACleanup(); return 0; } if (SOCKET_ERROR == listen(sockListen, 5)) { cout << "侦听失败,错误码：" << WSAGetLastError() << endl; closesocket(sockListen); WSACleanup(); } g_sockArray[g_nCount] = sockListen; g_nCount++; fd_set fdRead = { 0 }; while (true) { FD_ZERO(&fdRead); for (int i = 0; i < g_nCount; i++) { FD_SET(g_sockArray[i], &fdRead); } select(0, &fdRead, NULL, NULL, NULL); for (int i = 0; i < g_nCount; i++) { if (FD_ISSET(g_sockArray[i], &fdRead)) { if (sockListen == g_sockArray[i]) { SOCKET sockClient = accept(sockListen, NULL, NULL); if (INVALID_SOCKET == sockClient) { cout << "连接失败， 错误码为：" << WSAGetLastError() << endl; break; } cout << "有客户端链接进来" << endl; if (g_nCount == FD_SETSIZE) { cout << "以达到服务器管理上限" << endl; break; } if (NULL == g_pFirst) { g_sockArray[g_nCount] = sockClient; } else { int n = Pop(); g_sockArray[n] = sockClient; } g_nCount++; } else { char szBuf[255] = ""; recv(g_sockArray[i], szBuf, 255, 0); cout << "客户端发送消息："<< szBuf << endl; if (0 == strcmp(szBuf, "exit")) { Push(i); closesocket(g_sockArray[i]); cout << "与客户端链接断开" << endl; g_nCount--; } break; } } } } WSACleanup(); return 0; }上述代码中，每当检测到有待决套接字就处理，处理完一个后就不在继续检测了，我们知道在理论上select执行完成后，保留的是所有待决套接字，那么待决套接字可不可能有多个呢，我觉得这个基本上不可能，因为服务器端判定在某一时刻该套接字是否处于待决状态是在毫秒级别的，就算有几个客户端在某时刻毫秒不差的向服务器发送数据，那么我们还要考虑双方之间的距离（虽说光速很快可以忽略不计但是当单位是毫秒是应该还是有影响）以及网络状况，虽说在大规模的情况可能出现在毫秒级别上同时响应，但是我们的select只支持64个（超过64时需要另外开线程再创建一个相应的集合），在64个客户端中找到这样的两个客户端是不可能的，所以我们就假定每次只有一个待决套接字，使用break为了让其跳出循环，避免做无用功；
最近学完了简单的socket编程，发现其实socket的网络编程其实并没有什么难度，只是简单的函数调用，记住客户端与服务端的步骤，写起来基本没有什么问题。在服务器程序的设计中，一个服务器不可能只相应一个客户端的链接，为了响应多个客户端的链接，需要使用多线程的方式，每当有一个客户端连接进来，我们就开辟一个线程，用来处理双方的交互（主要是利用recv或者recvfrom用于收发信息），由于但是在网络中可能出现这样一种情况：由于处理比较复杂，下一条信息到来之后，上一条信息的处理还没有完成，这样信息太多了之后系统的缓冲占满之后可能会发生丢包的现象，所以为了解决这个问题，需要另外再开一个线程，专门用来处理接收到的数据，这样总共至少有3个线程，主线程，收发信息的线程，处理线程；这样可能也不完整，处理的操作种类多了的话可能需要根据不同的请求来开辟不同的线程用来处理这一类请求，下面是实现这一思路的部分代码：全局变量：DWORD WINAPI AcceptThread(LPVOID lpParameter); DWORD WINAPI RecvThread(LPVOID lpParameter); DWORD g_nAcceptID = 123; DWORD g_nRecvID = 234; HANDLE g_hAccpetThread; HANDLE g_hRecvThread;主线程函数：int _tmain(int argc, _TCHAR* argv[]) { WSADATA wd; WSAStartup(MAKEWORD(2, 2), &wd); SOCKET sockListen = socket(AF_INET, SOCK_STREAM, IPPROTO_IP); if (INVALID_SOCKET == sockListen) { cout << "创建侦听套接字失败，错误码为：" << WSAGetLastError() << endl; return -1; } SOCKADDR_IN srvAddr = { 0 }; srvAddr.sin_family = AF_INET; srvAddr.sin_port = htons(6666); srvAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); if (SOCKET_ERROR == bind(sockListen, (SOCKADDR*)&srvAddr, sizeof(SOCKADDR))) { cout << "绑定失败，错误码为:" << WSAGetLastError() << endl; WSACleanup(); return -1; } if (SOCKET_ERROR == listen(sockListen, 5)) { cout << "侦听失败，错误码为：" << WSAGetLastError() << endl; WSACleanup(); return -1; } while (true) { SOCKET sockConn = accept(sockListen, NULL, 0); if (INVALID_SOCKET == sockConn) { cout << "本次连接失败，即将进入下一次连接，错误码为:" << WSAGetLastError() << endl; closesocket(sockConn); closesocket(sockListen); WSACleanup(); continue; } g_hAccpetThread = CreateThread(NULL, 0, AcceptThread, &sockConn, 0, &g_nAcceptID); } WaitForSingleObject(g_hAccpetThread, INFINITE); WSACleanup(); return 0; }收发数据函数：DWORD WINAPI AcceptThread(LPVOID lpParameter) { cout << "有客户端连接进来" << endl; SOCKET sockConn = *(SOCKET*)lpParameter; while (true) { char *pszBuf = (char*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY | HEAP_NO_SERIALIZE, 255); if (SOCKET_ERROR == recv(sockConn, pszBuf, 255, 0)) { cout << "接受数据失败，错误码为:" << WSAGetLastError() << endl; cout << "准备进行下一次接受数据....." << endl; continue; } g_hRecvThread = CreateThread(NULL, 0, RecvThread, pszBuf, 0, &g_nRecvID); WaitForSingleObject(g_hRecvThread, INFINITE); if (0 == strcmp("exit", pszBuf)) { cout << "正在断开与该客户端的连接" << endl; HeapFree(GetProcessHeap(), 255, pszBuf); return 0; } } return 0; }信息处理子线程：DWORD WINAPI RecvThread(LPVOID lpParameter) { cout << "接受到客户端的数据：" << (char*)lpParameter << endl; return 0; }虽说这个解决了多个客户端与服务器通信的问题，但是这样写确定也很明显：所有的与客户端通信的socket都有程序员自己管理，无疑加重了程序员的负担；每有一个连接都需要创建一个线程，当有大量的客户端连接进来开辟的线程数是非常多的，线程是非常耗资源的，所以为了解决这些问题就提出了异步的I/O模型，它们解决了这些问题，由系统管理套接字，不要要人为的一个个管理，同时不需要开辟多个线程来处理与客户端的连接，我们可以将线程主要用于处理客户端的请求上；