发布: 2008-11-19 12:09 | 作者: 明册月 | 来源: 游戏圈
一、游戏中的文件读写
要让一个颇具规模的游戏运行起来,光靠一个可执行文件是不行的,因此大多数游戏都离不开文件读 写。我们不仅需要在游戏开始时载入模型、动画、贴图以及其它各种游戏数据,而且可能还要在游戏运行 时动态地读取背景音乐甚至是相邻区域的关卡数据(譬如说“Diablo Ⅱ”)。而大多数游戏都会提供的 存盘功能,也要求我们能够快速地向持久存储上写入数据。如果不幸遇到那些数据量比较大的情况,而又 想在玩家不会察觉的情况下完成任务,也不是一件简单的事情。 文件读写的API在大多数平台下都是既简单又复杂的,说它简单是因为不外乎打开、关闭、读写之类 的功能,说复杂是因为它牵涉到很多细节,只要有一处未处理好,就可能会影响系统在特定情况下的总体 性能。因此,本文将以Windows平台为例,对文件读写效率方面的一些问题进行探讨。 二、基本的文件读写 Windows下面的文件读写函数想必大家都很熟悉,这里就不再赘述了。主要包括CreateFile、ReadFile、 WriteFile等。譬如说,下面这段小程序就会打开一个文件,写入一些数据,并且读出进行验证: #include <windows.h> #include <iostream> using namespace std; int main() { HANDLE file = CreateFile( "c:\\test.dat", GENERIC_READ | GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, 0, NULL ); if( file != INVALID_HANDLE_VALUE ) { char buffer1[ 8192 ], buffer2[ sizeof( buffer1 ) ]; memset( buffer1, 0x12, sizeof( buffer1 ) ); DWORD bytes; WriteFile( file, buffer1, sizeof( buffer1 ), &bytes, 0 ); SetFilePointer( file, 0, NULL, FILE_BEGIN ); ReadFile( file, buffer2, sizeof( buffer2 ), &bytes, 0 ); CloseHandle( file ); if( memcmp( buffer1, buffer2, sizeof( buffer1 ) ) == 0 ) cerr << "succeeded" << endl; } } 需要注意的是,WriteFile的正确返回并不能保证数据已经写入硬盘。要确保这点,我们需要调用 FlushFileBuffers。或者说,文件的写入只有在FlushFileBuffers调用返回后才算是真正地完成了。 三、异步读写 在读写文件时,当前的线程会被挂起,如果要避免这种情况,就需要使用异步读写。所谓异步读写就是在 发出读写请求以后函数会立即返回,这时候读写请求还没有完成,发出请求的线程继续执行并且在将来的 某个时刻调用其它函数判断读写请求是否完成。要把同步读写变为异步读写其实很简单,只需要按照下面 的步骤进行就可以了: 打开文件的时候,在dwFlagsAndAttributes这个参数上加上FILE_FLAG_OVERLAPPED; 在调用ReadFile和WriteFile的时候,提供一个OVERLAPPED结构; 对ReadFile和WriteFile的返回值进行判断,如果返回值为0,但是GetLastError返回ERROR_IO_PENDING, 这意味着我们开始了一次异步读写; 要判断异步读写是否完成,可以在OVERLAPPED结构中所提供的事件句柄上等待。 因为异步读写的开始位置由OVERLAPPED结构指定,所以文件指针的具体位置不再重要。下面这段程序是前 面同步读写程序的异步版本: int main() { HANDLE file = CreateFile( "c:\\test.dat", GENERIC_READ | GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_FLAG_OVERLAPPED, NULL ); if( file != INVALID_HANDLE_VALUE ) { OVERLAPPED overlapped; overlapped.Offset = overlapped.OffsetHigh = 0; overlapped.hEvent = CreateEvent( NULL, TRUE, FALSE, NULL ); char buffer1[ 8192 ], buffer2[ sizeof( buffer1 ) ]; memset( buffer1, 0x12, sizeof( buffer1 ) ); DWORD bytes; if( !WriteFile( file, buffer1, sizeof( buffer1 ), &bytes, &overlapped ) && GetLastError() == ERROR_IO_PENDING ) WaitForSingleObject( overlapped.hEvent, INFINITE ); // SetFilePointer( file, 0, NULL, FILE_BEGIN ); if( !ReadFile( file, buffer2, sizeof( buffer2 ), &bytes, &overlapped ) && GetLastError() == ERROR_IO_PENDING ) WaitForSingleObject( overlapped.hEvent, INFINITE ); CloseHandle( overlapped.hEvent ); CloseHandle( file ); if( memcmp( buffer1, buffer2, sizeof( buffer1 ) ) == 0 ) cerr << "succeeded" << endl; } } 可以看到,异步读写比同步读写多了一个关键步骤,也就是需要等待读写完成。 四、时间度量 接下来本文要对性能进行精确地测试,因此我们需要一个精度较高的时钟。在这里为了方便,就不使用 Windows所提供的QueryPerformanceCounter函数了,而是直接使用汇编指令rdtsc。rdtsc会把CPU加电以 后经过的时钟周期数通过EDX:EAX返回,这正巧和通常Windows平台上编译器返回一个64位整数时所使用的 方式相同。我们使用下面这个类来获得一个比较精确的时间度量: #ifndef TIMER_HPP_ #define TIMER_HPP_ class Timer { typedef unsigned __int64 Time; static const Time FREQ = 2200000000; Time time_; static Time rdtsc() { __asm rdtsc } public: Timer() : time_( rdtsc() ) {} void restart() { time_ = rdtsc(); } double diff() const { return (double)( rdtsc() - time_ ) / FREQ; } }; #endif//TIMER_HPP_ 这里FREQ使用的是2.2GHz,因为在笔者的Athlon 64 2.2G上每秒大约有22亿个时钟周期。 为了获得一个比较直观的认识,我们把读写测试的总数据量提高到64M,每次读写1M。并且,为了获取在 文件读写的各个阶段花费的时间,我们把测试阶段分为五个部分:发出读取请求、等待读取完成、发出写 入请求、等待写入完成、物理写入完成。表01是测试结果: (表01) 从表01的数据里面可以看到两个事实: 首先,在这台电脑上,同步读取需要5秒左右,同步写入需要2.65秒。即使把总数据量降低到2M,同步读 取也需要0.03秒,同步写入则需要0.10秒。这意味着如果仅仅使用这种最原始也是最常用的方法来进行背 景音乐播放的话,我们的游戏几乎干不了别的事情了(一个30帧的游戏,每帧只有0.033秒的处理时间) ,也就是说这个方法在实际应用中是肯定行不通的。 其次,异步读取的总时间居然比同步读取的还要大,而且发出请求的时间也不算很短。这可能与很多人觉 得异步读写的效率应该比同步读写要高有所冲突。其实从某种意义上说,异步读写并没有比同步读写少做 任何工作,并且还需要消耗额外的资源来进行同步,因此的确没有理由会比同步读写更快。我们所说的效 率更高,只能说是对CPU的利用效率更高,而不是整体所消耗的时间效率。 五、更高效的方法 更进一步地说,所谓文件读写,本质上就是指数据在持久存储和内存中进行移动的过程。由于现代操作系 统在各层次上都有所触及,这其中的具体过程往往不是程序员可以独立决定的。譬如说,当我们使用C运 行库中的fread/fwrite或者是C++标准库中的fstream进行文件读写时,我们通常会和三个不同层次的缓存 打交道:运行库、操作系统和硬盘。设置这些缓存的目的主要是因为在I/O操作中,往往越是底层的操作 耗费的时间越长,因此在上层建立一个缓存可以为程序员节约很多优化的时间(当我们一个一个字节读文 件的时候,程序之所以还能运行如飞,就是因为有这些缓存)。但是,当我们追求最高速度时,这些缓存 反而起到负面作用。譬如说,每读写一个扇区,文件系统会把这个扇区先读到缓存里面,然后再拷贝到我 们提供的内存区域,而对于那些只使用一次的操作来说,这份拷贝毫无意义;不仅如此,在每次写入文件 的时候,我们写入的数据也会被拷贝到缓冲区内,这不仅意味着写入操作成功返回时,我们的数据仍然可 能留在内存中,而且会占用宝贵的CPU时间。 还好,操作系统往往会提供一些更为低级(高级?)的操作来满足我们对性能的特殊需要,在Windows下 面我们可以让操作系统绕过缓存直接对某个文件进行读写。要做到这点,需要按照以下步骤修改原有程序 : 打开文件的时候,在dwFlagsAndAttributes这个参数上加上FILE_FLAG_NO_BUFFERING; 传递给ReadFile/WriteFile的内存必须是文件所在卷扇区大小的整数倍; 文件读写的开始位置和长度也必须是文件所在卷扇区大小的整数倍。 这种读写方式既可以同步进行,也可以异步进行。 (表2) 无缓存异步读写和普通读写方式的对比 模文件读写的时候,应该尽量采用这种方式。 六、为什么没有异步? 如果返回的是TRUE,就说明操作系统把我们的异步请求同步执行了,这样一来我们就不需要再等待了,同 时也意味着当前线程会被阻塞很久。从前面的表格中可以看到,在进行异步写入时,我们几乎没有在等待 上耗费任何时间,事实上那些写入都是同步完成的;而即使是那些异步完成的读取操作,也会在发出读取 请求的时候阻塞很久。 GetLastError返回ERROR_IO_PENDING,而且消耗在这些函数上的时间极少)这个基本上很难(如果不是不 可能的话)。因为这要依赖于读写的文件是否压缩、需要的数据是否在缓存中、写文件会不会改变文件的 大小等等。即使我们可以控制所有这些情况,还要注意系统为异步I/O准备的资源是有限的,因此还会受 到系统当前状况的影响,参考文献2对这方面的问题作了很详尽的分析。总之,这不是我们所能控制的, 而我们不应该在程序中使用无法控制的方法。
塞,现存的游戏通常使用异步I/O或是多线程来实现这点。 一个读写请求以后,线程本身就被阻塞了,直到这个I/O读写完成以后函数调用才会返回,并且线程才能 够继续执行下去。而在使用异步I/O时,我们发出I/O请求的函数调用会立即返回,这时候I/O并没有完成 ,但是线程可以继续执行下去并且在未来的某一时刻检查I/O操作是否完成或是使自己进入挂起状态直到 I/O完成。这样一来,(在理想状态下)我们游戏的界面和渲染线程并不会被堵塞,也不会影响玩家的游 戏体验了。 正常运行。 较小,尤其是在I/O要求较高的情况下,但是异步I/O不仅编写起来略微复杂、容易受到各种客观因素的影 响,而且并不是每个操作系统都支持(譬如说Windows 98就不支持异步文件读写),因此有时候必须求助 于多线程。 看来是一个比较明智的选择。 |
|
既然决定了使用多线程读写,接下去就要决定应该使用多少个线程了。通常如果所有的数据都在硬盘上或
者数据读写不是很频繁的话,一个线程就够了。但是如果很多数据都是动态装载的并且有相当一部分是在
光盘上,那么可以为读写光驱独立设置一个线程。
需要指出的是,虽然我们可以通过使用异步和多线程这两者之一达到目的,但是它们并不互相抵触。在读
写线程中我们也可以通过异步请求来增加同一时刻所能发出的请求数量,只是通常在游戏中并不会对I/O
有如此高的要求。
如果我们的目标平台可以同时执行多个线程,那么使用I/O完成端口(IO Completion Port)来进行文件
读写会是最好的选择。要把现有的异步文件读写修改为IOCP其实非常方便,我们需要做到的就是:
调用CreateIoCompletionPort创建一个I/O完成端口,并且使用同样的函数把所要读写的文件句柄加入这
个I/O完成端口;
使用GetQueuedCompletionStatus来获得读写结果。
使用IOCP的好处在于,我们可以用少量(甚至单个)线程来获得最大的I/O性能,从而避免线程之间切换
所带来的开销。虽然貌似使用普通的异步I/O也可以获得同样的I/O性能,但是考虑下面几种情况,就会知
道略有区别:
如果主线程发出一个异步读写请求,但是希望由工作线程来判断这个I/O是否完成。在普通异步I/O中,由
于工作线程用WaitForMultipleObject等待在一个事件列表上,因此为了把新的读写请求加入这个列表,
我们必须先在主线程中触发一个特定的事件中止工作线程的等待,然后再让它把这个新的事件加入列表,
继续等待,这样一来,就是两次线程切换。而使用IOCP,主线程发出读写请求以后,不需要进行任何特定
的操作;
如果我们使用多个工作线程。如果工作于普通异步I/O下,那么它们会等待在不同的事件列表上。如果线
程A所等待的I/O中有一个完成了,那么它会进行一些处理,这时候,如果事件A所等待的另一个I/O完成了
,它不能被立即处理,因为线程A还在处理上一个请求;虽然线程B空闲,但是它无能为力。如果使用IOCP
,那么等待在同一个IOCP上的线程都是平等的,时刻准备着为已完成的请求服务;
如果我们使用多个工作线程并且工作于普通异步I/O下,那么它们会等待在不同的事件列表上。如果线程A
所等待的I/O中有一个完成了,那么它会进行一些处理,完成处理后,很可能线程A的时间片还没有用完;
这时如果线程B所等待的某个I/O完成了,线程B将会被唤醒进行处理,这就是一次线程切换;而如果使用
IOCP,每次一个请求完成时,都会让上一次进行处理的线程继续处理(当然,如果它正空闲的话),这可
以避免大量的上下文切换。
因此,当目标平台支持IOCP并且我们有大量的I/O请求时(无论是文件、网络还是其它可以作为文件读写
的I/O设备),应该首先考虑IOCP。
九、其它细节
1.光盘和无缓存读写
很多游戏都需要从光盘读写数据,如果只是少量的连续数据,我们可以完全把它放在内存中,如果是大量
数据,可以考虑拷贝到硬盘上去。如果一定需要在光盘上读写数据的话,需要注意的就是除非我们实现了
自己的缓存机制,否则不要用无缓存读写。因为光驱作为一个慢速设备,我们应该尽量地使用操作系统提
供的缓存机制。事实上在很多情况下,把空余内存作为光盘缓存还是比较经济的。
2.怎样设定文件大小
无缓存读写对于文件读写的起始位置和长度有特殊的要求,但是我们要读写的文件通常不会正好是那个长
度。读文件的时候比较简单,只需要通过ReadFile或是GetOverlappedResult的lpNumberOfBytesRead参数
就可以了。如果在写文件时我们需要写入的数据小于扇区大小,也必须写入整个扇区,然后使用
SetEndOfFile函数来把长度设为正确值。
3.事务
通常游戏中使用的文件并不需要很高的容错性,如果偶然情况下在写存档信息的时候死机了,最多就是让
那个倒霉的玩家重新通关一次而已。但是对于那些动辄需要几百上千个小时的游戏来说,还是需要对玩家
多负责一点。我们并不能确保硬件上的问题,我们需要避免的只是在掉电(更可能的情况是,我们的游戏
崩溃了)的情况下,让玩家保证有一份完好的存档文件,哪怕是几分钟或者几小时之前的。要实现这一点
并不难,对于二进制文件来说,我们只需要在文件开头留两个DWORD的内容,表示文件数据的开始位置和
大小。读取的时候,我们必须先读取这两个DWORD,然后再读取真正的文件内容。对文件进行更新就比较
复杂,具体可以参照下面的伪码:
write_file( file, buffer, size )
{
prev_offset = read_dword( file );
prev _size = read_dword( size );
if( prev_offset – sizeof( dword ) * 2 >= size )
{
set_file_pointer( file, sizeof( dword ) * 2 );
write( file, buffer, size );
flush( file );
write_dword( file, sizeof( dword * 2 ) );
write_dword( file, size );
flush( file );
}
else
{
set_file_pointer( file, prev_offset + prev_size );
write( file, buffer, size );
flush( file );
write_dword( file, prev_offset + prev_size );
write_dword( file, size );
flush( file );
}
}
如果是文本文件,可以先写一个新文件,然后再改名。这些方法虽然很简单,但是对于小规模的文件保存