1 概述
linux和windows下的io模型对比,无论是编写文件读写,串口事件,还是网络读写,在Windows下我们总是离不开一个参数OverLapped,这个函数作用究竟是什么呢,它和linux下的select和epoll有什么关系呢?
总的来说,windows的重叠IO模式是一个比linux下的select和epoll更为优秀的设计,避免了惊群的问题,但是引入的复杂性太高,很不容易使用。
2 非重叠模式
和普通的同步开发一样,就不啰嗦了
BOOL ReadFile(
HANDLE hFile, //文件的句柄
LPVOID lpBuffer //用于保存读入数据的一个缓冲区
DWORD nNumberOfBytesToRead, //要读入的字节数
LPDWORD lpNumberOfBytesRead, //指向实际读取字节数的指针
LPOVERLAPPED lpOverlapped
//如文件打开时指定了FILE_FLAG_OVERLAPPED,那么必须,用这个参数引用一个特殊的结构。
//该结构定义了一次异步读取操作。否则,应将这个参数设为NULL
//(暂时不看异步读取,只看基本的话,用NULL就好了)
);
这是Windows下的ReadFile的API,当处于非重叠模式时,lpOverlapped参数就是为空。
m_hFile = (HANDLE)CreateFile(m_strFileName, GENERIC_WRITE, FILE_SHARE_READ, NULL, OPEN_EXISTING, NULL, NULL);
ReadFile(m_hFile,&buf,&bufferCount,&realCount,NULL);
那么,在创建文件以后,就执行ReadFile操作,它会一直阻塞直至file里面有数据为止。
3 重叠模式
3.1 linux
int []sockets;
int socketsLen;
for{
//将所有文件加入到readset中
fd_set readset;
FD_ZERO(&readset);
for( i = 0 ; i != socketsLen;i++ ){
FD_SET(sockets[i],&readset);
}
//同时检查多个文件描述符是否可读,可写,出现异常
select(socketsLen, &readset, NULL, NULL,NULL);
for( i = 0 ;i != socketsLen ;i++){
if( FD_ISSET(sockets[i],readset)==true){
//某个文件可读
read(sockets[i],buf,count)
}
}
}
linux下的重叠模式,也被称为多路复用模式。它的思路是进行read之前,用select来同时检查这个描述符是否可读。可读的时候执行read,系统调用返回才有数据,不可读的时候执行read,系统调用马上返回就是空的数据。
3.2 API设计
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes,// 安全属性
BOOL bManualReset,// 复位方式
BOOL bInitialState,// 初始状态
LPCTSTR lpName // 对象名称
);
BOOL ResetEvent(
HANDLE hEvent
);
Windows抛开了文件的HANDLE,而是用EVENT来描述一个文件的读完成状态,写完成状态,异常执行完成状态等。一般我们在创建CreateEvent时都设置bManualReset为TRUE,也就是手动复位的方式。当一个事件触发以后,要想下一次重新触发,就需要ResetEvent。
DWORD WaitForSingleObject(
HANDLE hHandle,
DWORD dwMilliseconds
);
DWORD WaitForMultipleObjects(
DWORD nCount,
const HANDLE* lpHandles,
BOOL bWaitAll,
DWORD dwMilliseconds
);
然后用WaitForSingleObject和WaitForMultipleObjects来等待事件已经触发,可以看出,WaitForMultipleObjects就是相当于linux下面的select。注意,返回值是DWORD类型,它的返回值减去WAIT_OBJECT_0,就是触发了事件的那个lpHandles数组的位置。
typedef struct _OVERLAPPED {
DWORD Internal;
DWORD InternalHigh;
DWORD Offset;
DWORD OffsetHigh;
HANDLE hEvent;
} OVERLAPPED
BOOL GetOverlappedResult(
HANDLE hFile, // 串口的句柄,指向重叠操作开始时指定的OVERLAPPED结构
LPOVERLAPPED lpOverlapped,// 指向一个32位变量,该变量的值返回实际读写操作传输的字节数。
LPDWORD lpNumberOfBytesTransferred,//返回已完成读操作,或者已完成写操作的数据长度
BOOL bWait,
// 该参数用于指定函数是否一直等到重叠操作结束。
// 如果该参数为TRUE,函数直到操作结束才返回。
// 如果该参数为FALSE,函数直接返回,这时如果操作没有完成,
// 通过调用GetLastError()函数会返回ERROR_IO_INCOMPLETE。
);
OVERLAPPED可以说是整个设计最迷惑的地方。因为linux的设计是,select告诉你的是,是不是可读。而windows的设计是,WaitForMultipleObjects告诉你的是,读是否已经完成。也就是说,在windows下面,操作方式是,先调用ReadFile来触发读操作,然后用WaitForMultipleObjects来检查读操作是否已经完成,最后用GetOverlappedResult来获取已经完成读操作的数据长度。GetOverlappedResult的主要任务就是获取lpNumberOfBytesTransferred!
//等待线程结束
hThread = CreateThread(NULL, 0, FunProc, NULL, 0, NULL);
DWORD dwRet = WaitForSingleObject(hThread, INFINITE);
当然,对于Mutex,Semaphore,Process,Thread这些事件,是不再需要OVERLAPPED来包含就能直接放入WaitForSingleObject了。因为,这些事件仅仅需要触发的动作,而不需要像读写操作要知道它的数据长度等额外信息的。
3.3 流程
//打开文件,必须要指定为FILE_FLAG_OVERLAPPED的打开方式
m_hFile = (HANDLE)CreateFile(m_strFileName, GENERIC_WRITE, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
//创建一个EVENT,然后与OVERLAPPED绑定在一起。
OVERLAPPED osReader = {0};
osReader.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
for{
//先执行读操作
BOOL isOk = ReadFile(m_hFile,&buf,&bufferCount,&realCount,&osReader);
if( isOk == TRUE ){
//刚好就有数据可读时,不需要等,这种情况出现比较低
return (buf,realCount);
}else{
//没有数据可以读,也是具有错误的返回码
if( GetLastError() != ERROR_IO_PENDING ){
//错误的原因,就是串口被拔出,或者硬盘突然坏了等异常错误
return (error);
}else{
//错误的原因,就是需要等待读操作是否已经完成
//手动重置事件
ResetEvent(&osReader.hEvent);
//等待读操作的完成
WaitForSingleObject(osReader.hEvent,INFINITE);
//获取读操作完成的数据长度
GetOverlappedResult(m_hFile, &osReader, &realCount2, FALSE)
return (buf,realCount2);
}
}
}
这是非重叠IO的一个基本流程,注意,这个流程中没有细致检查ResetEvent,WaitForSingleObject,GetOverlappedResult等系统调用的错误码,但就是这样的一个基本流程。另外,这个流程中,一个线程仅对一个文件描述符进行非阻塞读操作,是不具有现实意义的,还不如直接用同步操作来执行。
//创建两个文件
m_hFile1 = (HANDLE)CreateFile(...)
m_hFile2 = (HANDLE)CreateFile(...)
//创建两个EVENT
osReader1.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
osReader2.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
//先提前执行两个文件的ReadFile
ReadFile(m_hFile1,&buf1,&bufferCount,&count1,&osReader1);
ReadFile(m_hFile2,&buf2,&bufferCount,&count2,&osReader2);
for{
//同时等待两个描述符,其中一个触发就可以了
waitResult = WaitForMultipleObjects(2,hEvents,false,INFINITE)
index = waitResult - WAIT_OBJECT_0;
if( index == 0 ){
//获取第一个文件读完成的数据长度
GetOverlappedResult(m_hFile1, &osReader1, &realCount1, FALSE);
//通知已经完成
callback_file1(buf1,realCount1);
//继续触发这个文件的读操作
ResetEvent(&osReader1.hEvent);
ReadFile(m_hFile1,&buf1,&bufferCount1,&count1,&osReader1);
}else if ( index == 1 ){
//获取第二个文件读完成的数据长度
GetOverlappedResult(m_hFile2, &osReader2, &realCount2, FALSE);
//通知已经完成
callback_file2(buf2,realCount2);
//继续触发这个文件的读操作
ResetEvent(&osReader2.hEvent);
ReadFile(m_hFile2,&buf2,&bufferCount2,&count2,&osReader2);
}else{
//出错了!
}
}
这个流程就是同时对两个文件描述符进行非阻塞读操作,这个时候用非重叠IO就很有实际意义了,同理,可以同时对多个文件描述符进行非阻塞读操作。
4 完成端口
4.1 linux
完成端口对WaitForMultipleObjects的改进,就像epoll对select的改进一样。select在获取哪个文件描述符可读时,检查的时间复杂度为\(O(n)\),并且最大的同时检查数量为1024。而epoll在获取哪个文件描述符可读时,检查的时间复杂度为\(O(1)\),最大的同时检查数量为不受限制,仅与系统配置与内存有关。
#define EPOLL_SIZE 64
int []sockets;
int socketsLen;
//创建epoll事件列表,参数只要是大于1就可以了
epfd=epoll_create(1024);
//将所有文件加入到epoll中
for( i = 0 ;i != socketsLen;i++ ){
//指定为read事件
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = sockets[i];
epoll_ctl(epfd, EPOLL_CTL_ADD, sockets[i], &event);
}
for{
//同时检查多个文件描述符是否可读,可写,出现异常
struct epoll_event wait_ep_events[EPOLL_SIZE];
event_cnt = epoll_wait(epfd, &wait_ep_events, EPOLL_SIZE, -1);
for( i = 0 ;i < event_cnt ;i++){
//某个文件可读
read(wait_ep_events[i].data.fd,
buf,
count);
}
}
类似地,将FD_ZERO改为epoll_create,然后FD_SET改为epoll_ctl的EPOLL_CTL_ADD,将select改为epoll_wait,返回值就能避免使用FD_ISSET了,大大降低了同时检查的时间复杂度。
4.2 API设计
HANDLE WINAPI CreateIoCompletionPort(
_In_ HANDLE FileHandle,
_In_opt_ HANDLE ExistingCompletionPort,
_In_ ULONG_PTR CompletionKey,
_In_ DWORD NumberOfConcurrentThreads
CreateIoCompletionPort创建一个完成端口,可以实现类似于epoll_create的功能。
//创建一个完成端口
m_hIOCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0 );
//将某个文件,绑定到完成端口,依然是用CreateIoCompletionPort函数
HANDLE hTemp = CreateIoCompletionPort((HANDLE)m_Socket, m_hIOCompletionPort, (DWORD)pContext, 0);
但是,它与linux不同的是,添加文件描述符到完成端口,不是用新的函数,而是依然沿用于这个函数。另外,当m_Socket关闭的时候,完成端口自动会将其从中删除这个socket。所以,该设计倾向于将一个文件绑定到一个固定的完成端口,不能在不同的完成端口之间迁移。
BOOL WINAPI GetQueuedCompletionStatus(
_In_ HANDLE CompletionPort,
_Out_ LPDWORD lpNumberOfBytes,
_Out_ PULONG_PTR lpCompletionKey,
_Out_ LPOVERLAPPED *lpOverlapped,
_In_ DWORD dwMilliseconds
);
而类似于epoll_wait功能的方法是GetQueuedCompletionStatus,它可以对一个完成端口下绑定的所有文件描述符进行同时的检查。当某个文件触发后,lpCompletionKey就会返回该文件描述符在CreateIoCompletionPort传入的CompletionKey。另外,lpOverlapped会指定触发的是该文件的读操作,还是写操作,这个时候我们需要传入自己扩展过的OVERLAPPED结构体。从API设计可以看出,每次只返回一个触发了的文件描述符。
另外,要特别注意它和WaitForMultipleObjects的不同,WaitForMultipleObjects等待的是事件,而GetQueuedCompletionStatus等待的是文件描述符。所以,不再需要CreateEvent,ResetEvent了,并且GetQueuedCompletionStatus中含有lpNumberOfBytes这个已经完成读操作或写操作的数据长度,就不再需要调用GetOverlappedResult了。
BOOL WINAPI PostQueuedCompletionStatus(
_In_ HANDLE CompletionPort,
_In_ DWORD dwNumberOfBytesTransferred,
_In_ ULONG_PTR dwCompletionKey,
_In_opt_ LPOVERLAPPED lpOverlapped
);
PostQueuedCompletionStatus的方法是没有必要的,它是一种开发者可以手动向完成端口投递事件的工具,一般用来投递线程退出信息等。
4.3 流程
struct CompletionContext{
HANDLE file;
}
struct CompletionOverLapped{
OVERLAPPED overlapped;
BOOL isReadType;
}
//创建两个文件
m_hFile1 = (HANDLE)CreateFile(...)
m_hFile2 = (HANDLE)CreateFile(...)
//创建一个完成端口
completionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0 );
//将两个文件加入到完成端口
completionContext1 = CompletionContext{file:m_hFile1};
CreateIoCompletionPort((HANDLE)m_hFile1,completionPort,(DWORD)completionContext1,0);
completionContext2 = CompletionContext{file:m_hFile1};
CreateIoCompletionPort((HANDLE)m_hFile2,completionPort,(DWORD)completionContext2,0);
//创建两个OverLapped
CompletionOverLapped osReader1 = {overlapped:OVERLAPPED{},isReadType:TRUE};
CompletionOverLapped osReader2 = {overlapped:OVERLAPPED{},isReadType:TRUE};
//先提前执行两个文件的ReadFile
ReadFile(m_hFile1,&buf1,&bufferCount,&count1,&osReader1);
ReadFile(m_hFile2,&buf2,&bufferCount,&count2,&osReader2);
for{
//同时等待两个描述符,其中一个触发就可以了
BOOL bReturn = GetQueuedCompletionStatus(completionPort,&realCount,&context,&overlapped,INFINITE);
if( bReturn && context.HANDLE == m_hFile1 && overlapped.isReadType == TRUE ){
//通知已经完成
callback_file1(buf1,realCount);
//继续触发这个文件的读操作
ReadFile(m_hFile1,&buf1,&bufferCount1,&count1,&osReader1);
}else if ( bReturn && context.HANDLE == m_hFile2 && overlapped.isReadType == TRUE ){
//通知已经完成
callback_file2(buf2,realCount);
//继续触发这个文件的读操作
ReadFile(m_hFile2,&buf2,&bufferCount2,&count2,&osReader2);
}else{
//出错了!
}
}
整体流程和WaitForMultipleObjects已经很类似了,它依然是先read后wait,而不是像linux的先wait后read。
5 FAQ
5.1 终端偶发性堵塞
我在做串口测试时发现,当Windows终端选择了快速编辑模式以后,一旦,鼠标点中了终端,整个进程就会停顿下来。这个时候,就会收不到串口发送过来的数据,需要手动按下回车才能继续收到数据。这个不是代码写错了,而是Windows终端的这个傻逼设定导致的。
6.1 总结
windows的这种设计避免了惊群问题,因为它是先Read后Wait,而不是linux的先Wait后Read。当一个accept触发一个socket以后,只有一个线程获取到了事件。
参考资料
- 本文作者: fishedee
- 版权声明: 本博客所有文章均采用 CC BY-NC-SA 3.0 CN 许可协议,转载必须注明出处!