很多知识,只有自己亲自实践了才知道坑在哪里。
socket是内核对象句柄,每次对socket执行操作,需要用户对象到内核对象的转换,执行完成返回结果,需要内核对象到用户对象的转换。
IOCP的中文名称是完成端口,目前是Windows下最高效的网络模型。特点:半异步,非阻塞。(我理解的完全异步是回调式,不需要人工参与,但是IOCP的异步需要轮询)。
其他模型的缺点:
1)select模型:最低效,每次检索对长度有限制(默认是64个链接),可以通过修改头文件的方式修改上限,需要手动循环查询是否有操作可执行,所以很低效;
2)WSAEvent,事件模型,缺点也是有上限,每次最多监听64个事件,在收到事件通知后,去手动recv数据,效率比select高许多,因为操作是系统消息通知的,可以实现异步;
3)完成例程模型,是对事件模型的改进,去掉了64个事件的上限
以上模型还有个缺点,就是每次有操作可执行时,需要手动去执行recv或者accept等操作,涉及到内核对象<->用户对象的两次切换(订制获取消息时一次,recv/accept操作一次),而且对于accept来说,每次手动调用,都会产生一个socket,当大量accept来到时,产生socket的过程会非常耗时。
知道其他模型的缺点,就知道了完成端口的优点:1)没有监听上限;2)对于accept来说,socket是提前建立准备好的,收到连接时直接返回之前传入的socket;3)只涉及到一次内核对象<->用户对象切换(订制消息时一次),因为在订制消息的时候,已经把数据缓存地址给了内核对象,内核对象在收到数据、写入缓存后,才切换回用户对象,让用户拿走数据。总的来说,完成端口是proactor模型,其他的是reactor模型。
下面是完成端口的基本步骤:
0)头文件:winsock2.h
加载库:ws2_32.lib
初始化网络环境:WSAStartup
1)创建完成端口
HANDLE m_hIO = CreateIoCompletionPort ( INVALID_HANDLE_VALUE, 0, 0, 0 );
说明:①一般来说全局一个完成端口就够用,可以参考boost.asio在Windows下的实现,采用的就是完成端口,全局只需要一个io_server,而且是proactor模式。
2)初始化socket
SOCKET sokListen = WSASocket ( AF_INET, SOCK_STREAM, 0, nullptr, 0, WSA_FLAG_OVERLAPPED );
SOCKET sockAccept = WSASocket ( AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED );
说明:①必须用WSASocket来初始化socket,因为只有它有参数设定socket是重叠端口
②sokListen:监听socket,sockAccept:预定义的连接成功socket,可以根据需要预定义几万个或者SOMAXCONN个(猜想)
3)把socket和完成端口绑定
class PER_IO_CONTEXT{
public:
OVERLAPPED ol;
SOCKET sokAccept;
WSABUF wsaBuf;
char buf[4096];
int len = 4096;
OPERATION_TYPE opType = NULL_POSTED;
PER_IO_CONTEXT ( )
{
ZeroMemory ( &ol, sizeof ( OVERLAPPED ) );
ZeroMemory ( buf, 4096 );
wsaBuf.buf = buf;
wsaBuf.len = 4096;
}
virtual void t ( ){};};
PER_IO_CONTEXT *perIOCtx = new PER_IO_CONTEXT;
CreateIoCompletionPort ( (HANDLE)sockListen, m_hIO, (ULONG_PTR)perIOCtx, 0 );
说明:①PER_IO_CONTEXT是自定义数据结构,用来和socket绑定,作用类似于创建线程时传入的用户参数,在获取完成端口数据时会传入进来,所以可以在该结构体中添加数据缓存、socket标识、操作类型等关键信息;
②绑定端口的API和创建完成端口一样,区别是传入的参数不一样,绑定时第一个参数是要绑定的socket,第二个是完成端口实例,第三个是绑定socket的数据。
4)绑定端口,开始监听
sockaddr_in addr{ 0 };
addr.sin_family = AF_INET;
addr.sin_port = htons ( 11111 );
addr.sin_addr.S_un.S_addr = INADDR_ANY;
sockListen = WSASocket ( AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED );
::bind ( sockListen, (sockaddr*)&addr, sizeof ( addr ) );
::listen ( sockListen , SOMAXCONN);
5)获取WSAAcceptEx函数,并执行
LPFN_ACCEPTEX acceptex;
GUID guidAcceptex = WSAID_ACCEPTEX;
DWORD dwBytes = 0;
WSAIoctl ( sockListen, SIO_GET_EXTENSION_FUNCTION_POINTER, &guidAcceptex, sizeof ( guidAcceptex ), &acceptex,
sizeof ( acceptex ), &dwBytes, nullptr, nullptr );
SOCKET sockAccept = WSASocket ( AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED );
printf ( "%d ==\n", WSAGetLastError ( ) );
DWORD len = 0;
perIOCtx->sokAccept = sockAccept;
// acceptex ( sockListen, sockAccept, buf, 4096 - sizeof ( sockaddr_in ) * 2, sizeof ( sockaddr_in ),
// sizeof ( sockaddr_in ), &len, (LPOVERLAPPED)&perIOCtx);
ZeroMemory ( &perIOCtx->ol, sizeof ( OVERLAPPED ) );
acceptex ( sockListen, sockAccept, perIOCtx->buf, 0/*perIOCtx->len - 2*(sizeof(sockaddr_in)+16)*/,
sizeof ( sockaddr_in ) + 16, sizeof ( sockaddr_in ) + 16, &len, &perIOCtx->ol );//
说明:①WSAAcceptEx函数作用是投递accept操作到完成端口内核,只有该函数可以完成此功能
②WSAAcceptEx是扩展函数,本身没有定义在winsock2中,所以需要类似动态加载的方式获取函数指针,Windows提供了函数WSAIoctl和参数SIO_GET_EXTENSION_FUNCTION_POINTER来获取函数指针,宏WSAID_ACCEPTEX是该函数对应的GUID号,完成端口的其他几个功能函数也是用这种方式取得,WSAloctl的第一个参数是一个有效socket,不是特定socket。
③acceptex 参数说明:1)监听socket;2)预先定义的连接socket,等同于其他模式accept函数返回的socket,但是这里的socket是提前申请好的,所以在高并发连接时不会有新建socket的开销,提供了性能;3)该参数为0,表示连接即返回,不为0时传入一个缓存,连接并收到第一份数据时返回,缓存内是收到的数据,高并发时设置缓存,可以等到真实数据发送过来时返回,降低完成端口处理的并发数;4)缓存长度,因为收到数据时,会把远端地址信息和本地地址信息写入缓存,所以长度要减去两个地址信息的长度,也即参数5)+6)的和;5)和6):远端地址、本地地址信息长度,16是预留的16个字节,目前内核没有用到,但是空间要保留,所以必须加16;7)实际接收的数据,没用;8)很关键,后文详细说明(见注①)。
6)此时监听socket的accept操作就被投递给了完成端口内核,接下来就是不断询问完成端口是否有操作抵达
DWORD ret = 0;
PER_IO_CONTEXT *pc = NULL;
OVERLAPPED *ov = NULL;
while (1)
{
if (GetQueuedCompletionStatus ( m_hIO, &ret, (PULONG_PTR)&pc, &ov, INFINITE ))
……
说明:①参数说明:①全局完成端口对象;②返回数据长度,用不大;③返回用户数据,就是监听socket和完成端口对象绑定时传入的用户数据结构体指针;④返回操作对应的数据,就是WSAAcceptex第八个参数,或者WSARecv最后一个参数;⑤超时时间,INFINITE代表永不超时一直等待直到有可用操作到达为止。
7)对对应操作进行响应:从6)我们可以看到,完成端口并不会告诉你当前返回的是哪个操作,所以在投递操作前,应该把操作类型放在用户数据里面,在操作返回时进行响应,具体响应过程按业务需要,比如可以定义初始操作、ACCEPT操作、RECV操作枚举,然后赋值到结构体属性,如PER_IO_CONTEXT的枚举属性OPERATION_TYPE 。
8)响应accept时,要注意,每次响应时,都要重新投递accept,否则监听socket不会再接收连接请求
9)响应recv时,也要处理完数据后,调用WSARecv,再次投递recv请求。
DWORD rlen = 0;
WSAOVERLAPPED oovv{ 0 };
DWORD dwFlags = 0;
WSARecv ( pc->sokAccept, &newpc->wsaBuf, 1, &rlen, &dwFlags, &oovv, nullptr );
说明:①一定要用WSARecv,而不是WSARecvEx,后者作用和原来的recv函数一样。
②WSARecv参数说明:①要接收数据的socket;②数据保存的WSABuf结构体,可以有多个组成数组,写入数据时会按顺序写入;③WSABuf的个数,即参数②的个数;④返回接收的数据长度;⑤标志位,没大用;⑥和接收socket绑定的用户数据,和Accepte中最后一个参数一样;⑦回调函数,完成实例模式的关键,完成端口没用。
10)如果对GetQueuedCompletionStatus设定了一直等待,线程会被阻塞,在需要退出的时候,可以通过调用函数PostQueuedCompletionStatus,手动发一个操作,让它响应,在具体响应代码中退出,函数原型:
WSAIoctl(m_pListenContext->m_Socket,
SIO_GET_EXTENSION_FUNCTION_POINTER,
&GuidGetAcceptExSockAddrs,
sizeof(GuidGetAcceptExSockAddrs),
&m_lpfnGetAcceptExSockAddrs,
sizeof(m_lpfnGetAcceptExSockAddrs),
&dwBytes,
NULL,
NULL)) ;
函数原型:
VOID
(PASCAL FAR * LPFN_GETACCEPTEXSOCKADDRS)(
_In_reads_bytes_(dwReceiveDataLength+dwLocalAddressLength+dwRemoteAddressLength) PVOID lpOutputBuffer,//数据缓存
_In_ DWORD dwReceiveDataLength,//缓存长度-(地址+16)*2
_In_ DWORD dwLocalAddressLength,//本地地址长度+16
_In_ DWORD dwRemoteAddressLength,//远程地址长度+16
_Outptr_result_bytebuffer_(*LocalSockaddrLength) struct sockaddr **LocalSockaddr,//本地地址
_Out_ LPINT LocalSockaddrLength,//获取的本地地址长度
_Outptr_result_bytebuffer_(*RemoteSockaddrLength) struct sockaddr **RemoteSockaddr,//远程地址
_Out_ LPINT RemoteSockaddrLength);//获取的远程地址长度