CFile 替换方案:重叠 I/O 和用户定义进度回调






4.71/5 (30投票s)
提供了一个类,能够提供类似 CFile 的接口,没有 MFC 依赖,更重要的是能够以完全重叠 I/O 模式读/写,并在每个读/写段之间提供用户定义的毁掉函数。
引言
本文旨在解决 MFC 的 CFile
类存在的一些重大问题(至少在我看来是这样)。我首先对这个类感到担忧的是它是一个 MFC 类。我编写了大量的 ATL 代码,需要比 MFC 提供的更具可移植性的东西。此外,CFile
的实现存在一个重大缺陷。这个缺陷是缺少一个良好的重叠 I/O 接口来执行文件操作。在本文中,我将为您提供一个 CFile
类的替代品,它同时提供重叠 I/O 功能,并且摆脱了对 MFC 的依赖。
优点
- 无 MFC 依赖
- 重叠 I/O
- 在 I/O 操作期间提供回调的能力
- 在不使用多线程的情况下,在执行重叠 I/O 时进行消息处理。
缺点
- 与 MFC 一起使用时,不提供与 CArchive 一起使用的任何接口。
背景
在开始阅读本文之前,有一些非常基本的要求。您应该对 MFC (演示是使用 MFC 编写的) 有一定的了解,并对 MSVC++ 有深入的认识。为了真正理解文件 I/O 例程中的消息循环是如何工作的,您应该已经对 Win32 消息处理有了很好的理解,因为我无意在本文中深入探讨这个广泛的主题。了解基本的文件操作是一个加分项。如果您已经理解了基本的重叠 I/O 模型,那么对您来说这将易如反掌;不过,我也会详细解释,所以如果您不了解,也不必担心。
重叠 I/O
在我们深入了解文件对象本身之前,有必要对重叠 I/O 是什么以及它为我们提供了什么有一个坚实的理解。简单的解释是,当调用一个支持重叠 I/O 的函数时,该调用会立即返回,而不是等到操作完成。在后台,Windows 会将传输排队执行,并按照调用顺序处理。一旦传输完成,Windows 可以通过多种不同的方法通知您。选择哪种方法来获取 I/O 完成通知,很大程度上取决于您采用的设计模型以及您具体要执行的操作。
一种确定 I/O 操作是否完成的方法是使用 ::GetOverlappedResult
API 函数。您将此函数与 OVERLAPPED
结构以及一个用于保存传输字节数的变量一起传递。您还必须传递 I/O 操作所基于的结构的句柄。进行此调用时,您有两种选择:1) 您可以选择等待 I/O 完成后再返回,届时您应该已经获得了完整的传输量;2) 您可以让它立即返回。如果它返回一个负值,并且您没有指定等待,那么 ::GetLastError
会返回 ERROR_IO_INCOMPLETE,表示操作仍在挂起。实际上,这将是轮询完成状态,这不是一个好方法。
这就引出了一个更有用的方法来获取挂起 I/O 操作完成的通知。这种方法是使用完成例程。为了利用这种方法(在文件 I/O 的情况下),我们必须依赖 ::ReadFileEx
和 ::WriteFileEx
这两个 API 调用。这两个函数都可以接受一个回调函数作为参数。回调函数看起来如下:
void CALLBACK FileIoCompletionRoutine( DWORD dwErrorCode, DWORD dwNumTrans,
LPOVERLAPPED lpOverlapped )
使用此模型,我们现在可以调用其中一个 I/O 例程,它会立即返回。然后,Windows 会像之前一样将 I/O 操作加入队列并传输数据。一旦数据完全传输完毕,它就会调用 I/O 调用本身中指定的回调例程。
完成例程要求
正如您所见,这在执行长时间 I/O 操作时非常有帮助,因为发起调用的线程不再会因为等待 I/O 完成而阻塞。现在我们可以继续进行任何其他待处理的处理,只需等待系统通知我们操作何时完成。但是,为了让这一切正常工作,必须完成几件事。第一件事是,您的线程必须在某个时候停止并等待某些事情。如果您的线程从不暂停,那么完成例程将永远无法运行,因为它将在同一线程中执行。如果您在此线程中进行密集的数据处理,那么最好将 I/O 移到另一个线程,该线程可以在某个时候进入“可中断”等待状态。
以下任何函数都提供了可中断的等待状态:::SleepEx
、::WaitForSingleObjectEx
、::WaitForMultipleObjectsEx
、::MsgWaitForMultipleObjectsEx
。这些函数中的每一个都可以通过某种方式接受一个标志,使其进入“可中断”模式。这意味着,除了正常的等待样式行为(无论是等待一段时间还是等待句柄)之外,当 I/O 完成回调准备好执行时,它也可以被唤醒。当这种情况发生时,回调会立即执行,然后您的代码继续执行。这一点非常重要,因为正如我所提到的,如果您不提供此功能,您的完成例程将永远不会运行。
其他
还有其他捕获 I/O 操作完成的方法。其中一种方法是使用 I/O 完成端口。然而,这是一个另一个文章的主题,因为它是一种更复杂的方法,需要更多的解释。要使用下面定义的 CFileEx
类,完全不需要理解 I/O 完成端口。
CFileEx
现在我们已经对重叠 I/O 的表面(我指的是表面)有所了解,我们可以继续介绍 CFileEx
类的实现和使用细节。类的公共接口部分定义如下:
class CFileEx
{
public:
CFileEx();
~CFileEx();
BOOL Open( LPCTSTR lpFile, DWORD dwCreateDisp = OPEN_EXISTING,
DWORD dwAccess = GENERIC_READ | GENERIC_WRITE,
DWORD dwShare = 0, LPSECURITY_ATTRIBUTES lpSec = NULL )
throw( CFileExException );
void Close();
DWORD Read( BYTE* pBuffer, DWORD dwSize ) throw( CFileExException );
DWORD Write( BYTE* pBuffer, DWORD dwSize ) throw( CFileExException );
DWORD ReadOv( BYTE* pBuffer, DWORD dwSize,
LPFN_LRGFILEOP_PROGCALLBACK lpCallback,
LPVOID pParam, BOOL bUseMsgPump = TRUE )
throw( CFileExException );
DWORD WriteOv( BYTE* pBuffer, DWORD dwSize,
LPFN_LRGFILEOP_PROGCALLBACK lpCallback,
LPVOID pParam, BOOL bUseMsgPump = TRUE )
throw( CFileExException );
BOOL SetOvSegReadWriteSize( DWORD dwSegmentSize
= OVLFILE_DEFAULT_SEGSIZE ) throw( CFileExException );
DWORD GetOvSegReadWriteSize();
BOOL AbortOverlappedOperation();
BOOL IsOpen();
void SetThrowErrors( BOOL bThrow = TRUE );
ULONGLONG GetFileSize() throw( CFileExException );
ULONGLONG Seek( LONGLONG lToMove, DWORD dwMoveFrom = FILE_BEGIN )
throw( CFileExException );
ULONGLONG SeekToBegin() throw( CFileExException );
ULONGLONG SeekToEnd() throw( CFileExException );
void Flush() throw( CFileExException );
BOOL GetFileName( TCHAR* lpName, DWORD dwBufLen );
BOOL GetTimeLastAccessed( SYSTEMTIME& sys );
BOOL GetTimeLastModified( SYSTEMTIME& sys );
BOOL GetTimeCreated( SYSTEMTIME& sys );
public:
static BOOL GetTimeLastAccessed( LPCTSTR lpFile, SYSTEMTIME& sys );
static BOOL GetTimeLastModified( LPCTSTR lpFile, SYSTEMTIME& sys );
static BOOL GetTimeCreated( LPCTSTR lpFile, SYSTEMTIME& sys );
public:
// public data member. we can let people use
// the handle direct if they wish.
HANDLE m_hFile;
protected:
....
private:
....
};
这大部分都应该很清楚了。在使用此类之前,有几个点需要澄清。首先是与 ReadOv
和 WriteOv
函数一起使用的回调例程。此函数与前一部分提到的回调函数不是同一个。该完成例程包含在类本身内部。您提供给这两个函数的函数大约在同一时间被调用,并且可以接受您选择的额外参数。该函数的定义如下:
BOOL CALLBACK ProgressCallback( DWORD dwWritten, DWORD dwTotalSize,
LPVOID pParam );
另一个值得关注的点是可以在这两个函数中覆盖的“使用消息循环”标志。这允许您在调用时决定消息是否会在调用完成例程之间的时间段内被处理。指定 false 仅关闭消息循环,而不关闭回调函数。您提供的函数仍会正常调用。如果在一个不处理任何消息的线程中运行,这会很有用。
另外两个 Read
和 Write
调用模仿了 MFC 的 CFile
类中找到的标准 Read/Write 调用。它们接受相应的缓冲区并执行 I/O 操作,直到 I/O 操作完成才返回。对于这两个函数,不能使用回调。这里唯一需要知道的是 SetThrowErrors 函数。当此函数为 true 时,该对象将以 CFileExException
对象的形式抛出任何遇到的错误。该对象的定义可以在 FileEx.h 头文件中找到。
此异常类提供了两种错误报告形式。一种是报告 Win32 API 调用返回的标准错误,包括 ::GetLastError
返回的错误。另一种方法是为此类专门定义的自定义错误。如果您捕获了错误,必须同时检查两个错误代码以确保您捕获了正确的错误。如果此标志设置为 false,则不会抛出任何错误,函数将尝试使用 BOOL
值报告错误,但对于返回数字的函数,没有真正有效的方法来报告错误。在(大多数)情况下,返回 0。
这一切的根本
现在我们来看看有趣的部分。它到底是如何工作的。使用这个类固然不错,但知道它是如何工作的更好。要真正理解这个类,需要理解两个主要函数(它们大部分功能相同)以及另外三个辅助函数。我们从最复杂的那个开始,即 DoFileOperationWithMsgPump
函数。该函数的源代码定义如下:
DWORD CFileEx::DoFileOperationWithMsgPump( BOOL bWrite, BYTE* pBuffer,
DWORD dwSize, LPFN_LRGFILEOP_PROGCALLBACK lpCallback, LPVOID pParam )
{
RDWROVERLAPPEDPLUS ovp;
DWORD dwNumSegs = 0;
DWORD dwCurSeg = 1;
BOOL bDone = FALSE;
BOOL bQuit = 0;
int nRet = 0;
ZeroMemory( &ovp.ov, sizeof( OVERLAPPED ) );
ovp.lpCallback = lpCallback;
ovp.pParam = pParam;
ovp.dwTotalSizeToTransfer = dwSize;
ovp.dwTotalSoFar = 0;
ovp.dwError = 0;
ovp.bContinue = TRUE;
dwNumSegs = ( dwSize + m_dwSegSize - 1 ) / m_dwSegSize;
// eliviates need for floating point lib calc.
if( ! NextIoSegment( bWrite, ovp, pBuffer, dwNumSegs, dwCurSeg ) ) {
// something fouled up in our NextIoSegment routine ( ReadFileEx
// or WriteFilEx failed ) so we set the error in the ovp structure
// and set quit and fake the nRet code. By doing this an
// exception will be thrown on the way out of this function.
// setting bDone to TRUE makes sure the loop never runs.
ovp.dwError = ::GetLastError();
bQuit = TRUE;
nRet = WAIT_IO_COMPLETION;
bDone = TRUE;
}
while( ! bDone ) {
nRet = ::MsgWaitForMultipleObjectsEx( 1, &m_hStop, INFINITE,
QS_ALLEVENTS, MWMO_ALERTABLE );
switch( nRet )
{
case WAIT_OBJECT_0:
bQuit = TRUE;
::ResetEvent( m_hStop );
break;
case WAIT_OBJECT_0 + 1:
PumpMsgs();
break;
case WAIT_IO_COMPLETION:
{
bDone = ( ovp.dwTotalSoFar == ovp.dwTotalSizeToTransfer );
if( bDone || bQuit) {
break;
}
// this signals either an error happened on the last I/O
// compeletion rountine or the user returned FALSE from
// their callback signaling to stop the IO process.
if( ! ovp.bContinue ) {
bQuit = TRUE;
}else{
dwCurSeg++;
if( ! NextIoSegment( bWrite, ovp, pBuffer, dwNumSegs,
dwCurSeg ) ) {
// something failed with our read/write call. This
// is an API error so we need to handle it
// accordingly. Setting the ovp.dwError and
// setting Quit to TRUE will force an exception to
// be thrown back to the user notifying them of
// the failure.
ovp.dwError = ::GetLastError();
bQuit = TRUE;
}
}
}
break;
};
// For Some reason we are now dropping out of this loop. This is
// mostly likely a kill event that got signaled but we need to
// check for an actual API error just in case.
if( ( nRet == WAIT_IO_COMPLETION ) && bQuit ) {
if( ovp.dwError != 0 ) {
ThrowErr( ovp.dwError, FALSE );
}
break;
}
}
return ovp.dwTotalSoFar;
}
在深入研究之前,您还应该花一秒钟回顾一下 RDWROVERLAPPEDPLUS
,该定义如下:
typedef struct _RDWROVERLAPPEDPLUS
{
OVERLAPPED ov;
DWORD dwTotalSizeToTransfer;
DWORD dwTotalSoFar;
LPFN_LRGFILEOP_PROGCALLBACK lpCallback;
LPVOID pParam;
BOOL bContinue;
DWORD dwError;
} RDWROVERLAPPEDPLUS, *LPRDWROVERLAPPEDPLUS;
这里的主要目标是将我们收到的缓冲区分解为一定数量的段。我们实际上并没有分解缓冲区,但概念上是这样做的。接下来,我们设置扩展的重叠结构,以保存有关传输的各种数据,包括用户请求在 I/O 完成例程中调用的回调函数。我们还会跟踪发送的总字节数以及一个继续标志。继续标志可以由用户设置。如果用户选择中止操作,他们的回调函数可以返回 false,并且将停止所有进一步的 I/O。在我们拥有基于重叠的结构之后,我们可以通过调用 NextIoSegment 函数来启动 I/O 操作。其定义如下:
BOOL CFileEx::NextIoSegment( BOOL bWrite, RDWROVERLAPPEDPLUS& ovp,
BYTE* pBuffer, DWORD dwTtlSegs, DWORD dwCurSeg )
{
BOOL bSuccess = FALSE;
BYTE* pOffBuf = NULL;
DWORD dwTransfer = 0;
pOffBuf = ( BYTE* ) POINTEROFFSET( pBuffer, ovp.dwTotalSoFar );
dwTransfer = ( dwCurSeg == dwTtlSegs ) ?
( ovp.dwTotalSizeToTransfer % m_dwSegSize ) : m_dwSegSize;
ovp.ov.Offset = ovp.dwTotalSoFar;
if( bWrite ) {
bSuccess = ::WriteFileEx( m_hFile, pOffBuf, dwTransfer, &ovp.ov,
CFileEx::FileIoCompletionRoutine );
}else{
bSuccess = ::ReadFileEx( m_hFile, pOffBuf, dwTransfer, &ovp.ov,
CFileEx::FileIoCompletionRoutine );
}
return bSuccess;
}
在这里,我们根据类型调用其中一个 I/O 例程。需要注意的主要一点是,我们必须在重叠结构中设置偏移量变量。这是为了确保我们在文件中向前读/写,取决于我们已经读/写了多少字节。我们根据当前段计算总共要传输的量。这样做是因为文件大小很可能不能被我们的段长度整除。在这种方法中,最后一个段就是文件的剩余部分。除此之外,这里没有什么特别之处。我们只是调用带有自定义回调函数的 I/O 例程。我不会深入研究那个函数,因为它很简单。它本质上所做的就是调出扩展的重叠结构,增加传输的总字节数,并在提供回调函数时调用用户的回调函数。
当这个函数返回时,我们将继续到函数中的主循环。在这里,我使用 ::MsgWaitForMultipleObjectsEx
函数来等待 I/O 完成或一个内部停止事件,我用它来处理中止和文件关闭。快速说明一下,这引出了文件关闭和中止的问题。您**绝不能**在有挂起的 I/O 操作时关闭文件。这会导致您的重叠结构超出作用域,并导致程序行为异常。我们快完成了。接下来是等待函数返回时。我们有三种选择。1) 停止事件发出信号,这时我们设置退出标志并等待挂起的 I/O 操作完成,然后循环退出。2) 我们在消息队列中收到一条消息。这时,我们调用本地的 PumpMsgs() 辅助函数来清除收到的所有消息。3) I/O 操作已完成。这时,我们需要检查是否已完成。如果完成,我们可以退出;否则,我们增加段计数器并调用下一个 I/O 函数。就这样!我们的操作现在将自行完成。
我之前提到的另一个相关函数是 DoFileOperation 函数。如果您愿意,可以自己查看它,但它基本上与上面的函数相同,只是它使用不同的等待调用,并且没有处理传入消息的程序。当用户选择不在 ReadOv
/WriteOv
函数调用中使用消息循环时,就会调用此函数。
收尾
打开文件时请记住,此类仅使用标准的 ::CreateFile
来创建文件,并接受相同的参数。标志参数是内部处理的,因为我们必须确保设置了 FILE_FLAG_OVERLAPPED
标志,否则此类中包含的所有好东西都将无法工作。
进度指示
正如标题所述,此类可以帮助您为操作提供进度状态指示。虽然它不直接执行此操作,但可以通过用户定义的 are 回调函数来实现。您可以指定要调用的函数,从而在每个段之后接收调用,告知您总共将传输的字节数以及已经传输的字节数。演示应用程序通过使用进度条来显示这一点。当我从慢速磁盘(例如我的外部硬盘驱动器,它不幸被卡在公司的 USB 1.x 上)传输大文件时,我发现这是绝对必需的。演示源代码相当直观。它演示了文件的打开和关闭,读写操作以及中止传输的各种方法。
尽情享用!!
注释
关于操作系统。我已在 Windows 2000 和 Windows XP Pro 上进行了测试。根据我看到的文档,此代码应该可以在 NT4 上运行,但我现在没有 NT4 机器可以测试。如果有人能在 such 机器上运行此代码,请告诉我,我将在此文章中更新它作为确定的信息。谢谢。
历史
2003/4/10 - 首次公开发布