现在的位置: 首页 > 综合 > 正文

Windows系统编程(四):IO同步异步

2018年04月04日 ⁄ 综合 ⁄ 共 11813字 ⁄ 字号 评论关闭
文章目录

作者:yurunsun@gmail.com
新浪微博@孙雨润
新浪博客
CSDN博客
日期:2012年11月8日

1. 打开设备:CreateFile

CreateFile是操作I/O最重要的函数,除了创建和打开磁盘文件,它同样可以打开许多其他设备。

HANDLE WINAPI CreateFile(
  LPCTSTR lpFileName,
  DWORD dwDesiredAccess,
  DWORD dwShareMode,
  LPSECURITY_ATTRIBUTES lpSecurityAttributes,
  DWORD dwCreationDisposition,
  DWORD dwFlagsAndAttributes,
  HANDLE hTemplateFile
);

1.1 lpFileName

既表示设备的类型,也表示该类设备的某个实例。

1.2 dwDesiredAccess

用来指定以何种方式和设备进行数据传输。

  • NULL 不希望从设备读写数据,只是想改变设备的配置
  • GENERIC_READ 对设备只读访问
  • GENERIC_WRITE 对设备只写访问
  • GENERIC_READ | GENERIC_WRITE 对设备读写(最常用)

1.3 dwShareMode

用来指定设备的共享特权。在尚未调用CloseHandle之前,我们可以使用这个参数控制其他的CreateFile调用。

  • NULL 要求独占对设备的访问。如果设备已经打开,CreateFile调用会失败;如果成功打开,后续CreateFile会失败(以下类似)
  • FILE_SHARE_READ 要求其他内核对象不得修改设备的数据
  • FILE_SHARE_WRITE 要求其他内核对象不得读取该设备的数据
  • FILE_SHARE_READ | FILE_SHARE_WRITE 不关心其他设备读写
  • FILE_SHARE_DELETE 对文件进行操作时,不关心文件是否被逻辑删除或者移动。在OS内部会先将文件标记为待删除,然后当该文件所有已打开的句柄都被关闭时再将其真正删除

1.4 dwCreationDisposition

打开设备时此参数意义重大:

  • CREATE_NEW 创建一个新文件,如果存在同名则调用失败
  • CREATE_ALWAYS 如果存在同名则覆盖原文件
  • OPEN_EXISTING 如果文件/设备不存在则调用失败
  • OPEN_ALWAYS 如果文件不存在则创建新文件
  • TRUNCATE_EXISTING 打开一个已有文件并将文件大小截断为0字节,如果文件不存在则调用失败

1.5 dwFlagsAndAttributes

用来微调与设备之间的通信和属性,大多数是优化OS缓存算法提高效率。

缓存相关

  • FILE_FLAG_NO_BUFFERING 在访问文件时不要使用任何数据缓存。通常不使用此flag以提高性能;打开此flag可以提高内存的使用效率。非特殊场合不要打开!
  • FILE_FLAG_SEQUENTIAL_SCAN 当使用缓存时,告诉OS我们将顺序访问文件
  • FILE_FLAG_RANDOM_ACCESS 当使用缓存时,告诉OS我们不保证顺序访问文件
  • FILE_FLAG_WRITE_THROUGH 禁止对文件写入操作进行缓存,而是将修改直接写入磁盘,能减少数据丢失的可能

杂项

  • FILE_FLAG_DELETE_ON_CLOSE 让文件系统在此文件所有句柄都关闭后删除该文件,通常作为临时文件与FILE_ATTRIBUTE_TEMPORARY一起使用

  • FILE_FLAG_BACKUP_SEMANTICS 用于备份和恢复软件,跳过文件安全性检查,但是需要调用者的access token具备对文件/目录进行备份/恢复的权限

  • FILE_FLAG_POSIX_SEMANTICS 按照POSIX要求,查找/打开文件时区分大小写

  • FILE_FLAG_OVERLAPPED 以异步方式访问设备

文件属性

  • FILE_ATTRIBUTE_ARCHIVE 创建时自动设置,表示是一个存档文件
  • FILE_ATTRIBUTE_ENCRYPTED 文件经过加密
  • FILE_ATTRIBUTE_HIDDEN 隐藏文件
  • FILE_ATTRIBUTE_NORMAL 仅在单独使用时表示此文件没有其他属性
  • FILE_ATTRIBUTE_READONLY 只读
  • FILE_ATTRIBUTE_SYSTEM 系统文件
  • FILE_ATTRIBUTE_TEMPORARY 临时文件

2. 使用文件设备

2.1 取得文件大小

  • 返回文件的逻辑大小:

    BOOL WINAPI GetFileSizeEx(HANDLE hFile, PLARGE_INTEGER lpFileSize);
    
  • 返回文件的物理大小:

    DWORD WINAPI GetCompressedFileSize(LPCTSTR lpFileName, LPDWORD lpFileSizeHigh);
    ULARGE_INTEGER ulFileSize;
    ulFileSize.LowPart = GetCompressedFileSize(_T("filename.dat"), &ulFileSize.HighPart);
    

例如100KB文件压缩后占85K,前者返回100K后者返回85K。

2.2 设置文件指针

  • 每个文件内核对象有自己的文件指针:

    BYTE pb[10];
    DWORD dwNumBytes;
    HANDLE hFile = CreateFile(_T("file.dat"), ...); // Point to 0
    ReadFile(hFile, pb, 10, &dwNumBytes, NULL); // Reads bytes 0-9
    ReadFile(hFile, pb, 10, &dwNumBytes, NULL); // Reads bytes 10-19
    
    
    HANDLE hFile2 = CreateFile(_T("file.dat"), ...);    // Point to 0
    ReadFile(hFile2, pb, 10, &dwNumBytes, NULL);    // Reads bytes 0-9
    
    
    HANDLE hFile3;
    DuplicateHandle(GetCurrentProcess(), hFile2, GetCurrentProcess(), &hFile3);
    ReadFile(hFile3, pb, 10, &dwNumBytes, NULL);    // Reads bytes 10-19
    
  • 随机访问文件

    BOOL WINAPI SetFilePointerEx(
        HANDLE hFile,
        LARGE_INTEGER liDistanceToMove,
        PLARGE_INTEGER lpNewFilePointer,
        DWORD dwMoveMethod
    );
    
  • 设置文件尾:强制使文件尾变得更小或更大:

    BOOL WINAPI SetEndOfFile(HANDLE hFile);
    

3. 同步IO操作

BOOL WINAPI ReadFile(
    HANDLE hFile,
    LPVOID lpBuffer,
    DWORD nNumberOfBytesToRead,
    LPDWORD lpNumberOfBytesRead,
    LPOVERLAPPED lpOverlapped
);

BOOL WINAPI WriteFile(
    HANDLE hFile,
    LPCVOID lpBuffer,
    DWORD nNumberOfBytesToWrite,
    LPDWORD lpNumberOfBytesWritten,
    LPOVERLAPPED lpOverlapped
);

【Note】hFile在创建时一定不要指定FILE_FLAG_OVERLAPPED,否则该句柄会执行异步IO;同理如果想执行异步IO,一定记得创建时指定FILE_FLAG_OVERLAPPED

3.1 强制将数据从buffer刷进设备

BOOL WINAPI FlushFileBuffers(HANDLE hFile);

3.2 取消同步IO

Vista之后的OS提供下面API用户终止指定线程的同步IO请求:

BOOL WINAPI CancelSynchronousIo(HANDLE hThread);
  • hThread句柄在创建时一定包含了THREAD_TERMINATE访问权限
  • 如果hThread线程并不处于因为等待IO响应而Pending的状态,函数返回FALSE
  • 取消IO请求取决于对应system layer实现的那个驱动程序,如果驱动不支持取消,函数调用还是返回TRUE,因为它已经完成请求任务

4. 异步IO操作

4.1 OVERLAPPED结构

typedef struct _OVERLAPPED {
    ULONG_PTR Internal;         // [out] Error code
    ULONG_PTR InternalHigh;     // [out] Number of bytes transferred
    union {
        struct {
            DWORD Offset;       // [in] Low 32-bit file offset
            DWORD OffsetHigh;   // [in] High 32-bit file offset
        };
        PVOID  Pointer;
    };
    HANDLE    hEvent;           // Event handle or data
} OVERLAPPED, *LPOVERLAPPED;

Offset, OffsetHigh, hEvent必须在调用ReadFile/WriteFile之前完成初始化,Internal, InternalHigh由驱动程序设置。

  • Offset, OffsetHigh

    这两个成员构成一个64位偏移量,表示IO操作的起点。之所以要求在OVERLAPPED中指定起点是因为对于多次异步调用,OS无法确定第二次之后的起点,这与同步IO不同。非文件设备会忽略Offset, OffsetHigh,调用时必须将其初始化为0,否则IO请求会失败。

  • hEvent

    用来接收IO完成通知的4种方法之一会用到hEvent,一般在其中保存一个C++对象地址,后续会进一步介绍。

  • Internal

    用来保存已处理的IO请求的错误码,初始为STATUS_PENDING,下面宏可以检查IO是否完成:

    #define HasOverlappedIoCompleted(pOverlapped) \
        ((pOverlapped)->Internal != STATUS_PENDING)
    
  • InternalHigh

    用来保存已传输的字节数。

4.2 异步IO的注意事项

  • 驱动设备程序不一定以先入先出方式处理队列中IO请求,例如:

    OVERLAPPED o1 = {0};
    OVERLAPPED o2 = {0};
    BYTE bBuffer[100];
    ReadFile(hFile, bBuffer, 100, NULL, &o1};
    WriteFile(hFile, bBuffer, 100, NULL, &o1};
    

    驱动程序可能先Write后Read

  • 使用正确方式检查错误:

    大多数WindowsAPI返回FALSE表示失败,但ReadFileWriteFile不同。当我们试图将一个异步IO请求添加到队列中时,驱动程序可能会选择以同步方式处理请求,例如当我们想要的数据已经在OS的缓存中。如果被以同步方式执行,则ReadFile/WriteFile会返回非零值,如果被以异步方式执行,或执行中发生错误,则返回FALSE,这是要根据GetLastError是否为ERROR_IO_PENDING来确定是否成功加入队列。

  • 在异步IO请求完成之前一定不能移动或销毁在发出请求时使用的数据缓存和OVERLAPPED结构

    OS将IO请求加入驱动设备程序队列中时会传入数据缓存和OVERLAPPED结构的地址。如下代码就是错误的:

    VOID ReadData(HANDLE hFile) {
        OVERLAPPED o = {0};
        BYTE b[100];
        ReadFile(hFile, b, 100, NULL &o);
    }
    

    问题在于当异步IO请求被加入队列之后函数返回,栈上缓存和OVERLAPPED结构被释放。

4.3 取消队列中IO请求

OS提供了多种方式:

  • CancelIo来取消由给定句柄标识的线程,所添加到队列中的所有IO请求,除了IOCP

    BOOL CancelIo(HANDLE hFile);
    
  • 关闭设备句柄来取消已添加到队列中所有IO请求,无论由哪个线程添加

  • 线程终止时OS会自动取消该线程发出的所有IO请求,除了IOCP
  • CancelIoEx来取消指定文件句柄的指定IO请求

    BOOL CancelIoEx(HANDLE hFile, LPOVERLAPPED pOverlapped);
    

5. 异步IO完成通知

5.1 触发设备内核对象

一个很自然的想法是利用上一节线程中提到的设备内核对象,调用WaitForSingleObject

HANDLE hFile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
BYTE bBuffer[100];
OVERLAPPED o = {0};
o.Offset = 345;

BOOL bReadDone = ReadFile(hFile, bBuffer, 100, NULL, &o);
DWORD dwError = GetLastError();

if (!bReadDone && (dwError == ERROR_IO_PENDING)) {
    // IO正在以异步方式执行,等待完成
    WaitForSingleObject(hFile, INFINITE);
    bReadDone = TRUE;
}
if (bReadDone) {
    // 读出oInternal, oInternalHigh, bBuffer
} else {
    // 读出dwError
}

这段代码枉费了异步IO的设计意图,但是展示了一些重要的概念,是对异步操作的一个总结。

5.2 触发事件内核对象

5.2.1 引入原因

设备内核对象不能处理多个IO请求:例如我们要从文件读取10个字节再写入10个字节,任何一个操作完成都会触发设备内核对象,而无法区分是读操作还是写操作完成。

5.2.2 事件内核对象

首先通过CreateEvent创建事件对象并赋给OVERLAPPEDhEvent,这样驱动程序在完成异步IO后会调用SetEvent触发事件。当然5.1中的设备内核对象同样会触发,但是不要去等待。为了略微提高性能,可以禁用5.1的设备内核对象触发:

UCHAR flag = FILE_SKIP_SET_EVENT_ON_HANDLE;
BOOL SetFileCompletionNotificationModes(HANDLE FileHandle, UCHAR Flags);

现在如果想要同时执行多个异步设备IO请求,先要为每个请求创建不同的事件对象,并初始化每个请求的OVERLAPPED结构的hEvent成员,再调用ReadFile/WriteFile,在需要同步的地方调用WaitForMultipleObjects

5.2.3 示例

HANDLE hFile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
byte bReadBuffer[10];
OVERLAPPED oRead = {0};
oRead.Offset = 0;
oRead.hEvent = CreateEvent(...);
ReadFile(hFile, bReadBuffer, 10, NULL &oRead);
BYTE bWriteBuffer[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
OVERLAPPED oWrite = {0};
oWrite.Offset = 10;
oWrite.hEvent = CreateEvent(...);
WriteFile(hFile, bWriteBuffer, _countof(bWriteBuffer), NULL, &oWrite);

HANDLE h[2];
h[0] = oRead.hEvent;
h[1] = oWrite.hEvent;
DWORD dw = WaitForMultipleObjects(2, h, FALSE, INFINITE);
switch (dw - WAIT_OBJECT_0) 
{
case 0: break;  // read complete
case 1: break;  // write complete
}

这段代码一样没有实用价值,无法体现异步的作用。

5.3 Alertable IO

5.3.1 Alertable IO

系统创建一个thread时会同时创建一个thread相关的队列,称为异步过程调用(APC)队列。发出IO请求时让驱动程序在APC队列添加一项回调函数:

BOOL ReadFileEx(
    HANDLE hFile,
    LPVOID lpBuffer,
    DWORD nNumberOfBytesToRead,
    LPOVERLAPPED lpOverlapped,
    LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
BOOL WriteFileEx(
    HANDLE hFile,
    LPCVOID lpBuffer,
    DWORD nNumberOfBytesToWrite,
    LPOVERLAPPED lpOverlapped,
    LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
typedef VOID (WINAPI *LPOVERLAPPED_COMPLETION_ROUTINE)(
    DWORD dwErrorCode,
    DWORD dwNumberOfBytesTransfered,
    LPOVERLAPPED lpOverlapped);

ReadFile/WriteFile的不同点是:表示已传输字节数的输出参数移到了回调函数中,当然这就要求提供一个回调函数lpCompletionRoutine

5.3.2 OS执行流程

以下边代码为例:

hFile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
ReadFileEx(hFile, ...);
WriteFileEx(hFile, ...);
ReadFileEx(hFile, ...);
SomeFunc();

假设SomeFunc()执行需要一段时间,返回前OS就完成了3个异步IO。驱动则正在讲已完成的IO一个个添加到线程的APC队列中,注意添加顺序与调用顺序无关。而thread必须将自己置为可唤醒状态,才能出发APC队列中的函数回调,以下6个API可以将自身置为可唤醒:

DWORD SleepEx(DWORD dwMilliseconds,BOOL bAlertable);
DWORD WaitForSingleObjectEx(HANDLE hHandle, DWORD dwMilliseconds, BOOL bAlertable);
DWORD WaitForMultipleObjectsEx(
    DWORD nCount, 
    CONST HANDLE *lpHandles, 
    BOOL bWaitAll, 
    DWORD dwMilliseconds, 
    BOOL bAlertable);
DWORD SignalObjectAndWait(
    HANDLE hObjectToSignal,
    HANDLE hObjectToWaitOn, 
    DWORD dwMilliseconds,
    BOOL bAlertable);
BOOL GetQueuedCompletionStatusEx(
    HANDLE CompletionPort,
    lpCompletionPortEntries,
    ULONG ulCount,
    PULONG ulNumEntriesRemoved,
    DWORD dwMilliseconds,
    BOOL fAlertable
    );
MsgWaitForMultipleObjectsEx(
    DWORD nCount,
    CONST HANDLE *pHandles,
    DWORD dwMilliseconds,
    DWORD dwWakeMask,
    DWORD dwFlags);

前5个函数的bAlertable设置为TRUEMsgWaitForMultipleObjectsEx则使用MWMO_ALERTABLE让thread进入可提醒状态。Sleep/WaitForSingleObject/WaitForMultipleObjects在内部调用了*Ex的对应版本,并总将bAlertable置为FALSE

5.3.3 Alertable IO的优劣

缺点

  • 回调函数:回调函数很难保存某个问题有关的上下文,导致可能需要大量全局变量/成员变量。
  • 线程问题:发出IO请求的线程必须是执行回调函数的线程,无法调度

优点

  • 能够手动添加回调函数到APC队列:

    DWORD QueueUserAPC(PAPCFUNC pfnAPC, HANDLE hThread, ULONG_PTR dwData);
    typedef void ( __stdcall *PAPCFUNC )(DWORD_PTR dwParam);
    

    使用hThread允许跨线程、跨进程;跨进程时pfnAPC必须在hThread所处的地址空间中,此API可以用于非常高效的线程/进程间通信。

  • 能够强制让线程退出Pending状态:

    加入某线程处于WaitForSingleObject等待内核对象被触发,则QueueUserAPC能够干净地唤醒此thread并使其退出。

5.4 IOCP

首先考虑两种服务器模型:

  • 串行:一个thread等待一个client request,当请求到达时线程被唤醒处理client request

  • 并行:一个thread等待一个client request,当请求到达时创建一个新的thread来处理请求,同时进入下一次循环等待新的client

显而易见后者的高并发更受欢迎,但问题是这种模型使所有thread都处于runnable而非pending状态,OS浪费大量时间进行thread切换,因此Windows引入了IOCP来解决这个问题。

IOCP的两个理论基础:

  • 并行thread数量必须有一个上限,因为一旦runnable thread > CPU数量,OS就必须进行thread切换而影响CPU cycle
  • 使用线程池来避免为每个client创建新thread的开销,线程池内thread数量一般为CPU数量*2

5.4.1 创建IOCP

HANDLE CreateIoCompletionPort(
    __in     HANDLE FileHandle,
    __in_opt HANDLE ExistingCompletionPort,
    __in     ULONG_PTR CompletionKey,
    __in     DWORD NumberOfConcurrentThreads
    );

从可读性角度,一般将此API拆分成两步:

  • 创建IOCP

    HANDLE CreateNewCompletionPort(DWORD dwNumberOfConcurrentThreads)
    {
        return CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, dwNumberOfConcurrentThreads);
    }   
    

    这个函数唯一参数dwNumberOfConcurrentThreads 告诉IOCP并行thread数量上限,传0则IOCP会使用CPU数量作为默认值。

  • 绑定设备

    BOOL AssociateDeviceWithCompletionPort(HANDLE hCompletionPort, HANDLE hDevice, DWORD dwCompletionKey)
    {
        HANDLE h = CreateIoCompletionPort(hDevice, hCompletionPort, dwCompletionKey, 0);
        return (h == hCompletionPort) ? TRUE : FALSE;
    }
    

    其中hCompletionPort为刚创建的Handle, hDevice为设备Handle,
    dwCompletionKey
    为对我们有意义的完成key,OS不关心。

5.4.2 IOCP在OS中的数据结构(DS)

  • DS1:设备列表:hDevice -> dwCompletionKey的map

    hDevice dwCompletionKey

    AssociateDeviceWithCompletionPort时添加,设备Handle关闭时删除

  • DS2:IO完成队列:

    dwBytesTransferred dwCompletionKey pOverlapped dwError

    IO完成或`PostQueuedCompletionStatus时添加,IOCP从等待队列中删除时删除

  • DS3:等待线程队列(后进先出)

    dwThreadId

    thread调用GetQueuedCompletionStatus时添加,IO完成队列不空且此时runnable threads数小于dwNumberOfConcurrentThreads则删除,并转移到DS4。

  • DS4:已释放线程列表

    dwThreadId

    从DS4转移过来,或暂停的thread被唤醒时添加;thread再次调用GetQueuedCompletionStatus时转移到DS3,或pending自己时转移到DS5

  • DS5:已暂停线程列表

    dwThreadId

    从DS4转移过来时添加;pending自己的thread被唤醒时转移回DS4

5.4.3 IOCP的工作流程

假设双核CPU

  • 创建IOCP时指定并发线程数上限:2
  • 创建线程池,拥有thread A B C D
  • 每个thread初始化时调用GetQueuedCompletionStatus进入DS3
  • 如果3个client的request已经完成,插入IO完成队列。然后DS3中的2个thread A B会被唤醒,转到DS4,执行2个client的回调函数;另两个C D继续睡眠
  • thread A处理完继续调用GetQueuedCompletionStatus试图休眠进入DS3,这时OS发现IO完成队列中还有第三个client的request的回调任务,会再次被唤醒。
  • 在执行回调过程中的runable thread,即在DS4中的thread(例如thread B)如果调用了Sleep/WaitFor*等API使自己pending,则会转移到DS5;OS立即检测到一个runnable thread将自己暂停,为了保证以2个thread为上限前提下满负荷运行,出现下一个完成端口的回调任务时OS即刻从DS3中唤醒thread C进行处理。
  • 此时thread B恢复runnable 状态,导致DS4中数量变成3,超过了并发线程数上限2。IOCP系统允许在短时间内出现这种情况,对应的策略是:在runnable threads <= 2之前不会再唤醒任何thread

5.4.3 实例代码

class CIOCP {
public:
   CIOCP(int nMaxConcurrency = -1) { 
      m_hIOCP = NULL; 
      if (nMaxConcurrency != -1)
         (void) Create(nMaxConcurrency);
   }
   ~CIOCP() { 
      if (m_hIOCP != NULL) 
         CloseHandle(m_hIOCP); 
   }
   BOOL Close() {
      BOOL bResult = CloseHandle(m_hIOCP);
      m_hIOCP = NULL;
      return(bResult);
   }
   BOOL Create(int nMaxConcurrency = 0) {
      m_hIOCP = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, nMaxConcurrency);
      return m_hIOCP != NULL;
   }
   BOOL AssociateDevice(HANDLE hDevice, ULONG_PTR CompKey) {
      return (CreateIoCompletionPort(hDevice, m_hIOCP, CompKey, 0) == m_hIOCP);
   }
   BOOL AssociateSocket(SOCKET hSocket, ULONG_PTR CompKey) {
      return AssociateDevice((HANDLE) hSocket, CompKey);
   }
   BOOL PostStatus(ULONG_PTR CompKey, DWORD dwNumBytes = 0, OVERLAPPED* po = NULL) {
      return PostQueuedCompletionStatus(m_hIOCP, dwNumBytes, CompKey, po);
   }
   BOOL GetStatus(ULONG_PTR* pCompKey, PDWORD pdwNumBytes, OVERLAPPED** ppo, DWORD dwMilliseconds = INFINITE) {
      return GetQueuedCompletionStatus(m_hIOCP, pdwNumBytes, pCompKey, ppo, dwMilliseconds);
   }
private:
   HANDLE m_hIOCP;
};

这里仅仅列出了对IOCP的常用封装,后续章节将介绍如何使用IOCP与WinSock一起打造一个简洁高并发的服务器框架。


抱歉!评论已关闭.