io模型

2020-02-19 fishedee 后端

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以后,只有一个线程获取到了事件。

参考资料

相关文章