Loading... # Windows核心编程-学习笔记7 ## 同步和异步设备I/O 线程发出一个同步设备I/O请求时,会被临时挂起,直至I/O请求完成。此类挂起会损害性能,我们不希望线程被阻塞,因此需要各个线程就它们正在执行的操作进行通信,这种机制在Microsoft中被称为I/O完成端口。利用该机制,应用程序的线程在读写设备时不必等待设备响应,从而显著提升吞吐量。 ### 打开和关闭设备 设备以及打开设备的函数: * 文件:永久存储任意数据;CreateFile(pszName为路径名或UNC路径名) * 目录:属性和文件压缩设置;CreateFile(pszName为目录名或UNC目录名) * 逻辑磁盘驱动器:格式化驱动器;CreateFile(pszName为`\\.\x:`,x是盘符) * 物理磁盘驱动器:访问分区表;CreateFile(pszName为`\\.\PHYSICALDRIVEx`,x是物理驱动器编号) * 串口:通过电话线传输数据;CreateFile(pszName为`COMx`) * 并口:将数据传输至打印机;CreateFile(pszName为`LPTx`) * 邮槽:一对多数据传输,通常是通过网络传到另一台Windows机器;服务器CreateMailslot(pszName为`\\.\mailslot\mailslotname`),客户端CreateFile(pszName为`\\servername\mailslot\mailslotname`) * 命名管道:一对一数据传输,通常是通过网络传到另一台Windows机器;服务器CreateNamedPipe(pszName为`\\.\pipe\pipename`),客户端CreateFile(pszName为`\\servername\pipe\pipename`) * 匿名管道:单机上的一对一数据传输,不经过网络;CreatePipe用来打开服务器和客户端 * 套接字:报文或数据流传输,通常是通过网络传到任何支持套接字的机器上;Socket,accept或AcceptEx * 控制台:文本窗口屏幕缓冲区;CreateConsoleScreenBuffer或GetStdHandler 上面每个函数都可以返回一个用来标识设备的句柄 **CreateFile**: ~~~c++ HANDLE CreateFile(PCTSTR pszName, DWORD dwDesiredAccess, DWORD dwShareMode, PSECURITY_ATTRIBUTES psa, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hFileTemplate) ~~~ * **pszName**:既可标识设备类型,也可标识设备的特定实例 * **dwDesiredAccess**:指定设备的数据传输标志 * 0:不打算从设备读取数据或者写入数据。如果只想改变设备的配置(比如只是修改文件的时间戳),就可以传入0 * GENERIC_READ:允许对设备进行只读访问 * GENERIC_WRITE:允许对设备进行只写访问 * GENERIC_READ|GENERIC_WRITE:允许对设备进行读写操作 * **dwShareMode**:指定设备共享特权 * 0:要求独占对设备的访问。如果设备已经打开,CreateFile调用失败;如果成功打开设备,后续的CreateFile调用就会失败 * FILE_SHARE_READ:要求引用设备的其他任何内核对象不得修改由该设备维护的数据。如果设备以只写或独占方式打开,CreateFile调用失败;如果成功打开设备,后续使用了只写访问标志的CreateFile调用失败 * FILE_SHARE_WRITE:要求引用设备的其他任何内核对象不得读取由该设备维护的数据。如果设备以只读或独占方式打开,CreateFile调用失败;如果成功打开设备,后续使用了只读访问标志的CreateFile调用失败 * FILE_SHARE_READ|FILE_SHARE_WRITE:不关心引用设备的其他内核对象是从设备读取还是向设备写入。如果设备已以独占方式打开,CreateFile调用失败 * FILE_SHARE_DELETE:进行文件操作时,不关心文件是否被逻辑删除或移动 * **psa**:指向一个SECURITY_ATTRIBUTES结构,可用来指定安全信息以及CreateFile返回的句柄是否可继承,一般传NULL * **dwCreationDisposition**:打开是文件时使用 * CREATE_NEW:创建新文件,如存在同名文件则失败 * CREATE_ALWAYS:无视存在直接创建 * OPEN_EXISTING:打开现有文件或设备,如不存在则失败 * OPEN_ALWAYS:打开现有文件或设备,如不存在则新建一个 * TRUNCATE_EXISTING:打开现有文件并将文件大小截断为0字节,如不存在则失败 * **dwFlagsAndAttributes**:设置标志来微调与设备之间的通信;如果设备是文件,还能设置文件的属性 高速缓存标志: * FILE_FLAG_NO_BUFFERING:表示在访问文件时不使用任何数据缓冲机制 * FILE_FLAG_SEQUENTIAL_SCAN:将以顺序方式访问文件,会读取超出请求量的数据 * FILE_FLAG_RANDOM_ACCESS:高速系统不要预读文件数据 * FILE_FLAG_WRITE_THROUGH:禁止对文件写入操作进行缓存,以减少数据丢失的可能性 通信标志: * FILE_FLAG_DELETE_ON_CLOSE:使用这个标志,文件系统会在文件的所有句柄都关闭后删除文件。通常和FILE_ATTRIBUTE_TEMPORARY一起使用,应用程序可以创建一个临时文件,向文件中写入数据,从文件中读取数据,最后关闭文件。文件关闭后,系统就会自动删除该文件 * FILE_FLAG_BACKUP_SEMANTICS:一般用于备份和恢复软件 * FILE_FLAG_POSIX_SEMANTICS:Windows查找文件名时不区分大小写,但POSIX子系统(现在没了)需要区分。这个标志要求CreateFile创建或打开文件时区分大小写来查找文件名 * FILE_FLAG_OPEN_REPARSE_POINT:忽略文件可能存在的重解析属性 * FILE_FLAG_OPEN_NO_RECALL:请求文件数据,但它应继续位于远程存储中。 不应将其传输回本地存储。 此标志供远程存储系统使用 * FILE_FLAG_OVERLAPPED:要以异步方式访问设备;异步I/O指调用一个函数来告诉系统要读取或写入数据,但这个函数调用不会等待系I/O操作完成,而是立即返回,OS自己在线程中完成I/O操作,完成后告诉我们 文件属性标志: * FILE_ATTRUTE_ARCHIVE:文件是一个存档文件。应用程序使用此属性标记文件以供备份或删除。 * FILE_ATTRIBUTE_ENCRYPTED:文件已加密 * FILE_ATTRIBUTE_HIDDEN:文件已隐藏 * FILE_ATTRIBUTE_NORMAL:文件没有设置其他属性,只有单独使用才能生效 * FILE_ATTRIBUTE_OFFLINE:文件的数据不会立即可用。 此属性指示文件数据在物理上移动到脱机存储。 此属性由远程存储(分层存储管理软件)使用 * FILE_ATTRIBUTE_READONLY:文件只读,无法写入或删除 * FILE_ATTRIBUTE_SYSTEM:文件是OS的一部分或独占使用 * FILE_ATTRIBUTE_TEMPORARY:文件临时存储,会将数据保存在内存中 * **hFileTemplate**:既可以标识一个已经打开的文件句柄,也可以是NULL 注意CreateFile失败返回INVALID_HANDLE_VALUE,值为-1 ### 使用文件设备 #### 取得文件大小 获取文件大小,pliFileSize是一个LARGE_INTEGER联合体地址 ~~~c++ BOOL GetFileSizeEx(HANDLE hFile, PLARGE_INTEGER pliFileSize); ~~~ 另一个获取文件大小的函数是GetCompressedFileSize,该函数返回文件的物理大小 ~~~c++ DWORD GetCompressedFileSize(PCTSTR pszFileName, PDWORD pdwFileSizeHigh); ~~~ 但GetCompressedFileSize传入的是文件名字符串,此外文件大小的低32位是函数的返回值,高32位放在pdwFileSizeHigh指向的DWORD中,需要使用ULARGE_INTEGER结构 #### 定位文件指针 调用CreateFile会使系统创建一个文件内核对象来管理文件操作。该内核对象内部有一个文件指针,是一个64位偏移量,表示该在哪里执行下一次同步读取或写入操作。该文件指针最初被设为0,所以如果在CreateFile后立即调用ReadFile,会从偏移为0的位置读取文件,并随着读取数目更新文件指针 要想自定义访问文件不同位置,需要更改与文件内核对象关联的文件指针,为此要调用SetFilePointerEx ~~~c++ BOOL SetFilePointerEx(HANDLE hFile, LARGE_INTEGER liDistanceToMove, PLARGE_INTEGER pliNewFilePointer, DWORD dwMoveMethod); ~~~ * hFile:更改哪个文件内核对象的文件指针 * liDistanceToMove:要移动多少字节,这个值会与文件指针偏移值相加,负数可以使文件指针后移 * dwMoveMethod:指定移动文件指针时的起始位置 * FILE_BEGIN:文件对象指针被设为liDistanceToMove指定的值(无符号64位) * FILE_CURRENT:文件对象指针将与liDistanceToMove相加(有符号,因为要有负数) * FILE_END:文件对象指针被设为文件的逻辑大小+liDistanceToMove(有符号,因为要有负数) SetFilePointerEx更新了文件对象指针后,会在pliNewFilePointer指向的LARGE_INTEGER结构中返回文件指针的新值,不感兴趣就传NULL 需要注意SetFilePointerEx一些事实: * 将文件指针值设为超过文件当前大小是正当操作。除非在该位置向文件写入数据或者调用SetEndOfFile,否则这样做不会增加文件在磁盘上的实际大小 * 如果SetFilePointerEx操作的文件是用FILE_FLAG_NO_BUFFERING标志打开的,那么文件指针只能被设为扇区大小的整数倍 * Windows没有提供一个GetFilePointerEx函数,但可以通过SetFilePointerEx将文件指针移动0字节,并利用pliNewFilePointer获取文件指针值 #### 设置文件尾 通常在关闭文件时,系统会负责设置文件尾(EOF)。但有时需要强制使文件变得更小或更大,可以调用SetEndOfFile ~~~c++ BOOL SetEndOfFile(HADNLE hFile); ~~~ SetEndOfFile会根据文件对象指针所在位置来截短或增大文件 ### 执行同步设备I/O 对设备数据读写 ~~~c++ BOOL ReadFile(HANDLE hFile, PVOID pvBuffer, DWORD nNumBytesToRead, PDWORD pdwNumBytes, OVERLAPPED* pOverlapped); BOOL WriteFile(HANDLE hFile, CONST VOID *pvBuffer, DWORD nNumBytesToWrite, PDWORD pdwNumBytes, OVERLAPPED* pOverlapped); ~~~ * hFile:要访问设备的句柄 * pvBuffer:指向一个缓冲区,函数会将设备数据读取到这个缓冲区或者将缓冲区的数据写入设备 * nNumBytesToRead/nNumBytesToWrite:读取或者写入多少字节 * pdwNumBytes:指向一个DWORD地址,函数会在其中填充设备成功收发的字节数 * pOverlapped:如果同步I/O应该设为NULL 二者调用成功都会返回TRUE #### 将数据回写到设备 ~~~c++ BOOL FlushFileBuffers(HANDLE hFile); ~~~ 此函数强制将与hFile参数所标识的设备相关联的所有缓存数据回写(flush)到设备。设备必须是通过GENEIC_WRITE标志打开的,这样才能正常工作,成功返回TRUE #### 同步I/O取消 执行同步I/O的函数很容易使用,但除非请求完成,否则会阻塞住来自同一个线程的其他操作。如线程因为等待CreateFile返回而被阻塞,窗口消息将无法得到处理,该线程创建的所有窗口都会被冻住。应用程序有时停止响应就是因为要等待同步I/O操作完成而被阻塞 缓解措施: * 异步执行I/O操作 * 提供取消功能,比如Web请求可以点击X取消 * 控制台程序Ctrl+C拿回控制权 可以使用CancelSynchronousIo来取消一个线程尚未完成的同步I/O ~~~c++ BOOL CancelSynchronousIo(HANDLE hThread); ~~~ hThread参数是因为等待同步I/O请求完成而被挂起的线程句柄,该句柄必须是用THREAD_TERMINATE访问权限创建的,否则CancelSynchronousIo调用会失败。 ### 异步设备I/O基础 和计算机执行的其他大多数操作相比,设备I/O是最慢、最不可预测的操作之一。CPU从文件或跨网络读取数据的速度,以及CPU向文件或跨网络写入数据的速度,比它执行算术运算的速度,甚至比它绘制屏幕的速度都要慢得多。但是使用异步I/O可以更好地利用资源并构建更高效的应用程序。 要以异步方式访问设备,必须先调用CreateFile,并在dwFlagsAndAttributes参数中指定FILE_FLAG_OVERLAPPED标志来打开设备,该标志告诉系统要以异步方式访问设备。ReadFile、WriteFile会检查hFile所标识的设备是不是用FILE_FLAG_OVERLAPPED打开,是则执行异步设备I/O 执行异步I/O,需要在ReadFile、WriteFile里的pOverlapped参数传递一个已初始化的OVERLAPPED结构 ~~~c++ typedef struct _OVERLAPPED { DWORD Internal; // out 错误码 DWORD InternalHigh; // out 传输比特数 DWORD Offset; // in DWORD OffsetHigh; // in HANDLE hEvent // in } OVERLAPPED, *LPOVERLAPPED; ~~~ * Offset和OffsetHigh构成一个64位偏移量,表示当访问文件时应该从哪里开始进行I/O操作;为避免对同一个文件内核对象进行多个异步调用时发生混乱,必须在异步I/O请求中指定起始偏移量 * hEvent:在里面存储一个C++对象地址 * Internal:容纳已处理的I/O错误码,初始为STATUS_PENDING,表明没有错误。可以用来定义宏来检查一个异步I/O操作是否已经完成, ~~~c++ #define HasOverlappedIoCompleted(pOverlapped) (pOverlapped)->Internal != STATUS_PENDING ~~~ * InternalHigh:异步I/O请求完成时,容纳已传输的字节数 **异步设备I/O注意事项**: * 设备驱动程序不一定以先入先出的方式来处理队列中的I/O请求:比如,为了降低磁头的移动和寻道时间,文件系统驱动程序会在I/O请求队列中寻找那些要访问物理磁盘上相邻位置的请求 * 如何正确检查错误:大多数Windows函数返回FALSE表示失败,或返回非零值表示成功;但ReadFile、WriteFile不一样 * 如果请求同步I/O:返回非零值 * 如果请求异步I/O或调用发生错误:返回FALSE,此时需要调用GetLastError来检查,如果返回ERROR_IO_PENDING,表明I/O请求已经成功入队;如果返回其他值,表明I/O请求无法添加到设备驱动程序的队列中 * 在异步I/O请求完成前,一定不能移动或销毁在发出异步I/O请求时所用的数据缓存和OVERLAPPED结构,因为传递这些都是用的地址 ~~~c++ VOID ReadData(HANDLE hFile) { OVERLAPPED o = {0}; BYTE b[100]; ReadFile(hFile, b, 100, NULL, &o); } ~~~ 这个代码在异步I/O请求下会返回,导致o和b释放 **取消队列中的设备I/O请求**: * 调用CancelIo来取消由给定句柄对应线程的I/O请求(队列中的),除非该句柄与一个I/O完成端口关联 * 可关闭设备句柄,从而取消已入队的所有I/O请求 * 线程终止时,系统自动取消该线程发出的所有I/O请求,除非请求的句柄和一个I/O完成端口关联 * 如果需要取消发往给定文件句柄的一个指定的I/O请求,使用CancelIoEx;pOverlapped为NULL时取消全部待处理I/O请求 ~~~c++ BOOL CancelIoEx(HANDLE hFile, LPOVERLAPPED pOverlapped); ~~~ ### 接收I/O请求完成通知 #### 触发设备内核对象 线程触发异步I/O请求后会继续执行,完成其他有用的工作。但即便如此最终还是要与I/O操作的完成同步(比如在某个点上,除非来自设备的数据已载入缓冲区,否则线程无法继续执行) 在Windows中,设备内核对象用来线程同步。ReadFile和WriteFile将I/O请求添加到队列之前将设备内核对象设为未触发状态。设备驱动程序完成请求后,该驱动程序将设备内核对象设为触发状态 线程可以通过调用WaitForSingleObject或WaitForMultipleObjects来检查异步I/O请求是否已完成 #### 触发事件内核对象 设备内核对象触发没法很好的处理多个I/O请求,一个操作完成对象就会被触发,不知道是哪个操作触发。 因此可以利用OVERLAPPED结构的最后一个成员hEvent来标识一个事件内核对象。必须调用CreateEvent来创建该事件对象。异步I/O请求完成时,设备驱动程序会检查OVERLAPPED结构的hEvent成员是否为NULL。如果hEvent不为NULL,驱动程序会调用SetEvent来触发事件。 要想同时执行多个异步设备I/O请求,必须为每个请求创建单独的事件对象,初始化每个请求的OVERLAPPED结构中的hEvent成员,然后调用ReadFile和WriteFile。当运行到代码中的那个点,必须与I/O请求的完成状态进行同步的时候,只需调用WaitForMultipleObjects并传入与每个待处理I/O请求的OVERLAPPED结构相关联的事件句柄。 #### 可提醒I/O 略 异步过程调用:Asynchronous Procedure Call,APC #### I/O完成端口 背景:并发模型 * **缺陷1**:并发模型系统中会有多个线程并发执行,由于所有线程都处于可运行状态,所以WIndows内核在这些可运行线程之间切换上下文花费太长时间,以至于各个线程都没有多少CPU时间完成自己的任务 * **缺陷2**:需要为每个客户请求创建新线程,虽然和创建一个有自己虚拟地址空间的进程相比,创建线程的开销要低很多,但依然不容忽视 I/O完成端口背后的理论: * 并发运行的线程数量必须有一个上限 * 在应用程序初始化时创建一个线程池,让线程池中的线程在应用程序运行期间一直保持可用状态,那么服务应用程序的性能就能提高 **创建I/O完成端口**: ~~~c++ HANDLE CreateIoCompletionPort(HANDLE hFile, HANDLE hExistingCompletionPort, ULONG_PTR CompletionKey, DWORD dwNumberOfConcurrentThreads); ~~~ 该函数执行两个不同的任务:不仅会创建一个I/O完成端口,还会将一个设备与一个I/O完成端口关联起来 如果只是想创建一个I/O完成端口,为CreateIoCompletionPort的前三个参数分别传递INVALID_HANDLE_VALUE、NULL和0即可 dwNumberOfConcurrentThreads参数告诉I/O完成端口在同一时间最多能有多少线程处于可运行状态(传递0默认使用主机CPU数量) 创建I/O完成端口时,系统内核实际上会创建5个不同的数据结构: * 设备列表 * I/O完成队列(先入先出) * 等待线程队列(后入先出) * 已释放线程列表 * 已暂停线程列表 **将设备与I/O完成端口关联**: * 设备列表:标识了与端口关联的一个或多个设备。将设备与端口关联需要调用CreateIoCompletionPort函数,需要传入现有I/O完成端口的句柄(之前CreateIoCompletionPort的返回值)、设备的句柄、完成键 * I/O完成队列:设备的一个异步I/O请求完成时,系统会检查设备是否与一个I/O完成端口关联,是则将已完成的I/O请求项追加到完成端口的I/O完成队列的末尾。队列中每一项包含的信息由:已传输的字节数、设备与端口关联时所设置的完成键的值、指向I/O请求的OVERLAPPED结构的指针以及一个错误码。 **I/O完成端口的周边架构**: 线程池多少线程:标准经验法则是CPU数量乘以2 池中所有线程执行相同的函数,通常该函数先进行一些初始化,然后进入一个循环。当服务进程被告知要停止的时候,该循环就应终止。在循环内部,线程进入睡眠状态,等待设备I/O请求完成并进入完成端口。调用GetQueuedCompletionStatus可以达到目的 ~~~c++ BOOL GetQueuedCompletionStatus(HANDLE hCompletionPort, PDWORD pdwNumberOfBytesTransferred, PULONG_PTR pCompletionKey, OVERLAPPED** ppOverlapped, DWORD dwMilliseconds); ~~~ 第一个参数hCompletionPort表示线程希望对哪个完成端口进行监视。GetQueuedCompletionStatus的任务基本就是将调用线程切换到睡眠状态,直到指定完成端口的I/O完成队列中出现一项,或者等待超时 * 等待线程队列:当线程池中的每个线程调用GetQueuedCompletionStatus时,调用线程的线程标识符会被添加到这个等待线程队列,这使I/O完成端口内核对象始终都知道当前有哪些线程正在等待处理完成的I/O请求。当端口的I/O完成队列中出现一项的时候,该完成端口会唤醒等待线程队列中的一个线程。该线程会获得已完成I/O项中的所有信息:已传输的字节数、完成键以及OVERLAPPED结构的地址。这些信息通过GetQueuedCompletionStatus的pdwNumberOfBytesTransferred、pCompletionKey、ppOverlapped参数来返回给线程 注意等待线程队列采用后入先出方式唤醒,也就是最后一个调用GetQueuedCompletionStatus的线程会被唤醒 **I/O完成端口如何管理线程池**: * 已释放线程列表:使完成端口记住哪些线程已被唤醒,并监视其执行情况。如果一个已释放的线程调用的任何函数将该线程切换到等待状态,完成端口会检测到这一情况并更新内部的数据结构,将该线程的线程标识符从已释放线程列表中移除,并添加到已暂停线程列表 * 已暂停线程列表 完成端口的目标是根据在创建完成端口时指定的并发线程的数量,将尽可能多的线程保持在“已释放线程列表”。如果一个已释放线程因为任何原因进入等待状态,“已释放线程列表”会缩减,完成端口就可以释放另一个正在等待的线程。如果一个已暂停的线程被唤醒,就会离开“已暂停线程列表”并重新进入“已释放线程列表” ## 小结 这学的啥啊,好难理解 最后修改:2026 年 01 月 05 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 如果觉得我的文章对你有用,请随意赞赏