Chinaunix首页 | 论坛 | 博客
  • 博客访问: 323218
  • 博文数量: 100
  • 博客积分: 2620
  • 博客等级: 少校
  • 技术积分: 920
  • 用 户 组: 普通用户
  • 注册时间: 2009-09-16 02:50
文章分类

全部博文(100)

文章存档

2011年(5)

2010年(12)

2009年(83)

分类:

2009-10-12 14:49:37

I/O 策略:怎样提高游戏的文件读写效率

一、游戏中的文件读写
    要让一个颇具规模的游戏运行起来,光靠一个可执行文件是不行的,因此大多数游戏都离不开文件读写。
    我们不仅需要在游戏开始时载入模型、动画、贴图以及其它各种游戏数据,而且可能还要在游戏运行时动态地读取背景音乐甚至是相邻区域的关卡数据( 譬如说“Di ablo Ⅱ”)。而大多数游戏都会提供的存盘功能,也要求我们能够快速地向持久存储上写入数据。如果不幸遇到那些数据量比较大的情况,而又想在玩家不会察觉的情况下完成任务,也不是一件简单的事情。文件读写的A P I 在大多数平台下都是既简单又复杂的,说它简单是因为不外乎打开、关闭、读写之类的功能,说复杂是因为它牵涉到很多细节,只要有一处未处理好,就可能会影响系统在特定情况下的总体性能。因此,本文将以W in d o ws 平台为例,对文件读写效率方面的一些问题进行探讨。

二、基本的文件读写
      W i n d o w s 下面的文件读写函数想必大家都很熟悉,这里就不再赘述了。主要包括C r e a t e F i l e R e a dF i l e W ri t e Fi l e 等。譬如说,下面这段小程序就会打开一个文件,写入一些数据,并且读出进行验证:
#include
#include
using namespace std;
int main()
{
HA NDL E fi l e = Cr eat eFi l e( "c : \ \ t e s t . dat ",
GENERIC_READ | GENERIC_WRITE, 0,
NULL, CREATE_ALWAYS, 0, NULL );
if( file != INVALID_HANDLE_VALUE )
{
char buffer1[ 8192 ], buffer2[ si zeof( buffer1 ) ];
mems et( buffer1, 0x12, si zeof( buffer1 ) );
DWORD bytes;
WriteFile( file, buffer1, sizeof( buffer1 ), &bytes, 0 );
SetFilePointer( file, 0, NULL, FILE_BEGIN );
ReadFi le( fil e, buffer2, s izeof( buffer2 ), &bytes , 0 );
CloseHandl e( file );
if( memcmp( buffer1, buffer2, s izeof( buffer1 ) ) == 0
)
cerr << "succeeded" << endl;
}
}
     需要注意的是,W r i t e F i l e 的正确返回并不能保证数据已经写入硬盘。要确保这点, 我们需要调用F l u s h F i l eB u ffe r s 。或者说,文件的写入只有在F l u s h F i l e B u ff e r s 调用返回后才算是真正地完成了。

三、异步读写
    在读写文件时,当前的线程会被挂起,如果要避免这种情况,就需要使用异步读写。所谓异步读写就是在发出读写请求以后函数会立即返回,这时候读写文请求还没有完成,发出请求的线程继续执行并且在将来的某个时刻调用其它函数判断读写请求是否完成。要把同步读写变为异步读写其实很简单,只需要按照下面的步骤进行就可以了:打开文件的时候,在dwFl agsAndAt t ributes 这个参数上加上FILE_FLAG_OVERLAPPED;在调用ReadFil e Writ eFil e 的时候,提供一个OVERLAPPED结构;对ReadFi l e Wri t eFi l e 的返回值进行判断,如果返回值为0,但是GetLas tError 返回ERROR_IO_PENDING,这意味着我们开始了一次异步读写;要判断异步读写是否完成,可以在OVERLAPPED 结构中所提供的事件句柄上等待。因为异步读写的开始位置由OVERLAPPED 结构指定,所以文件指针的具体位置不再重要。下面这段程序是前面同步读写程序的异步版本:

int main()
{
H AN DL E fi l e = Cr e at eF i l e ( " c: \ \ t e s t . d a t " ,
GENERIC_READ | GENERIC_WRITE, 0,
N U L L , C R E A T E _ A L W A Y S ,
FILE_FLAG_OVERLAPPED, NULL );
if( file != INVALID_HANDLE_VALUE )
{
OVERLAPPED overlapped;
overlapped.Offs et = overlapped.OffsetHigh = 0;
overlapped.hEvent = CreateEvent( NULL, TRUE, FALSE,
NULL );
char buffer1[ 8192 ], buffer2[ si zeof( buffer1 ) ];
mems et( buffer1, 0x12, si zeof( buffer1 ) );
DWORD bytes;
i f( !Writ eFil e( fi le, buffer1, si zeof( buffer1 ), &byt es ,
&overlapped ) &&
GetLastError() == ERROR_IO_PENDING )
WaitForSingl eObj ect ( overl apped. hEvent, INFINITE );
// SetFilePointer( file, 0, NULL, FILE_BEGIN );
i f( !ReadFil e( fi le, buffer2, s i zeof( buffer2 ), &byt es ,
&overlapped ) &&
GetLastError() == ERROR_IO_PENDING )
WaitForSingl eObj ect ( overl apped. hEvent, INFINITE );
CloseHandle( overlapped.hEvent );
CloseHandl e( file );
if( memcmp( buffer1, buffer2, si zeof( buffer1 ) ) == 0 )
cerr << "succeeded" << endl;
}
}

    可以看到,异步读写比同步读写多了一个关键步骤,也就是需要等待读写完成。

四、时间度量

    接下来本文要对性能进行精确地测试,因此我们需要一个精度较高的时钟。在这里为了方便,就不使用Windows 所提供的QueryPerformanceCount er 函数了,而是直接使用汇编指令rdtscrdtsc 会把CPU 加电以后经过的时钟周期数通过EDX : EAX 返回,这正巧和通常Win -dows 平台上编译器返回一个64 位整数时所使用的方式相同。我们使用下面这个类来获得一个比较精确的时间度量:

#ifndef T IMER_HPP_
#define TIMER_HPP_
class Timer
{
typedef unsigned __int64 Time;
st atic const Time FREQ = 2200000000;
Time t ime_;
st ati c Time rdtsc()
{
__asm rdt sc
}
public:
Timer() : t ime_( rdts c() )
{}
void restart ()
{
time_ = rdtsc();
}
doubl e diff() const
{
return (double)( rdts c() - time_ ) / FREQ;
}
};
#endif//TIMER_HPP_

     这里FREQ 使用的是2. 2GHz,因为在笔者的Ath lon64 2. 2G 上每秒大约有22 亿个时钟周期。为了获得一个比较直观的认识,我们把读写测试的游戏创造74 2006 4 月号总数据量提高到6 4M,每次读写1M。并且,为了获取在文件读写的各个阶段花费的时间,我们把测试阶段分为五个部分:发出读取请求、等待读取完成、发出写入请求、等待写入完成、物理写入完成。表0 1 是测试结果:
biao01.jpg
从表0 1 的数据里面可以看到两个事实:
    首先,在这台电脑上,同步读取需要5 秒左右,同步写入需要2 . 6 5 秒。即使把总数据量降低到2M ,同步读取也需要0. 03 秒,同步写入则需要0. 10 秒。这意味着如果仅仅使用这种最原始也是最常用的方法来进行背景音乐播放的话,我们的游戏几乎干不了别的事情了(一个30 帧的游戏,每帧只有0 . 0 33 秒的处理时间),也就是说这个方法在实际应用中是肯定行不通的。其次,异步读取的总时间居然比同步读取的还要大,而且发出请求的时间也不算很短。这可能与很多人觉得异步读写的效率应该比同步读写要高有所冲突。其实从某种意义上说,异步读写并没有比同步读写少做任何工作,并且还需要消耗额外的资源来进行同步,因此的确没有理由会比同步读写更快。我们所说的效率更高,只能说是对C P U 的利用效率更高,而不是整体所消耗的时间效率。
五、更高效的方法
    更进一步地说,所谓文件读写,本质上就是指数据在持久存储和内存中进行移动的过程。由于现代操作系统在各层次上都有所触及,这其中的具体过程往往不是程序员可以独立决定的。譬如说,当我们使用C 运行库中的fread/ fwri t e 或者是C++ 标准库中的fs t ream 进行文件读写时,我们通常会和三个不同层次的缓存打交道:运行库、操作系统和硬盘。设置这些缓存的目的主要是因为在I / O 操作中,往往越是底层的操作耗费的时间越长,因此在上层建立一个缓存可以为程序员节约很多优化的时间(当我们一个一个字节读文件的时候,程序之所以还能运行如飞,就是因为有这些缓存)。但是,当我们追求最高速度时,这些缓存反而起到负面作用。譬如说,每读写一个扇区,文件系统会把这个扇区先读到缓存里面,然后再拷贝到我们提供的内存区域,而对于那些只使用一次的操作来说,这份拷贝毫无意义;不仅如此,在每次写入文件的时候,我们写入的数据也会被拷贝到缓冲区内,这不仅意味着写入操作成功返回时,我们的数据仍然可能留在内存中,而且会占用宝贵的C P U时间。
还好,操作系统往往会提供一些更为低级(高级?)的操作来满足我们对性能的特殊需要,在W in d o ws 下面我们可以让操作系统绕过缓存直接对某个文件进行读写。要做到这点,需要按照以下步骤修改原有程序:
    打开文件的时候,在dwFl agsAndAt tribut es 这个参数上加上FILE_FLAG_NO_BUFFERING> 传递给ReadFi le/Wri teFi le 的内存必须是文件所在卷扇区大小的整数倍;> 文件读写的开始位置和长度也必须是文件所在卷扇区大小的整数倍。这种读写方式既可以同步进行,也可以异步进行。
biao02.jpg
0 2 :无缓存异步读写和普通读写方式的对比

    可以看到与前两者相比,无缓存读写无论在总体效率上还是在异步性能上都提高了很多。因此在进行大规模文件读写的时候,应该尽量采用这种方式。

六、为什么没有异步?
    前面提到过,在进行异步读写时,我们必须判断ReadFi le WriteFil e 的返回值才能决定是否需要等待,如果返回的是T R U E ,就说明操作系统把我们的异步请求同步执行了,这样一来我们就不需要再等待了,同时也意味着当前线程会被阻塞很久。从前面的表格中可以看到,在进行异步写入时,我们几乎没有在等待上耗费任何时间,事实上那些写入都是同步完成的;而即使是那些异步完成的读取操作,也会在发出读取请求的时候阻塞很久。究竟同步读写异步读写无缓存异步读写(也就是不仅Read Fi l e /W r i t e F i l e 返回F A L S E 并且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 完成。这样一来,(在理想状态下)我们游戏的界面和渲染线程并不会被堵塞,也不会影响玩家的游戏体验了。多线程是另一种避免阻塞的方法。通过把文件I / O放在另一个线程中进行,也可以保证界面和渲染线程的正常运行。
    这两种方法孰优孰劣并不一定。通常来说,因为不存在线程上下文切换带来的额外开销,异步I / O 的开销较小,尤其是在I / O 要求较高的情况下,但是异步I / O 不仅编写起来略微复杂、容易受到各种客观因素的影响,而且并不是每个操作系统都支持(譬如说Windows 98 就不支持异步文件读写),因此有时候必须求助于多线程。对于不幸的游戏程序员来说,我们对于目标平台并没有太多的选择余地,因此使用多线程加上无缓存读写看来是一个比较明智的选择。八、多线程文件读写和IOCP既然决定了使用多线程读写,接下去就要决定应该使用多少个线程了。通常如果所有的数据都在硬盘上或者数据读写不是很频繁的话,一个线程就够了。但是如果很多数据都是动态装载的并且有相当一部分是在光盘上,那么可以为读写光驱独立设置一个线程。
    需要指出的是,虽然我们可以通过使用异步和多线程这两者之一达到目的,但是它们并不互相抵触。在读写线程中我们也可以通过异步请求来增加同一时刻所能发出的请求数量,只是通常在游戏中并不会对I / O 有如此高的要求。如果我们的目标平台可以同时执行多个线程,那么使用I/O 完成端口(IO Compl et ion Port)来进行文件读写会是最好的选择。要把现有的异步文件读写修改为IO C P 其实非常方便,我们需要做到的就是:
    调用CreateIoCompl etionPort 创建一个I/O 完成端口,并且使用同样的函数把所要读写的文件句柄加入这个I /O 完成端口;
   使用GetQueuedCompl et ionStatus 来获得读写结果。使用I O C P 的好处在于,我们可以用少量(甚至单个)线程来获得最大的I / O 性能,从而避免线程之间切换所带来的开销。虽然貌似使用普通的异步I / O 也可以获得同样的I / O 性能,但是考虑下面几种情况,就会知道略有区别:
如果主线程发出一个异步读写请求,但是希望由工作线程来判断这个I / O 是否完成。在普通异步I / O 中,由于工作线程用Wai tFo rMul t ipl eO bj ect 等待在一个事件列表上,因此为了把新的读写请求加入这个列表,我们必须先在主线程中触发一个特定的事件中止工作线程的等待,然后再让它把这个新的事件加入列表,继续等待,这样一来,就是两次线程切换。而使用IO C P ,主线程发出读写请求以后,不需要进行任何特定的操作;如果我们使用多个工作线程。如果工作于普通异步I /O 下,那么它们会等待在不同的事件列表上。如果线程A 所等待的I / O 中有一个完成了,那么它会进行一些处理,这时候,如果事件A 所等待的另一个I/ O 完成了,它不能被立即处理,因为线程A 还在处理上一个请求;虽然线程B 空闲,但是它无能为力。如果使用IOC P ,那么等待在同一个IO C P 上的线程都是平等的,时刻准备着为已完成的请求服务;
    如果我们使用多个工作线程并且工作于普通异步I /O 下,那么它们会等待在不同的事件列表上。如果线程A 所等待的I / O 中有一个完成了,那么它会进行一些处理,完成处理后,很可能线程A 的时间片还没有用完;这时如果线程B 所等待的某个I /O 完成了,线程B 将会被唤醒进行处理, 这就是一次线程切换; 而如果使用IO C P ,每次一个请求完成时,都会让上一次进行处理的线程继续处理(当然,如果它正空闲的话),这可以避免大量的上下文切换。因此,当目标平台支持IO C P 并且我们有大量的I / O请求时(无论是文件、网络还是其它可以作为文件读写的I/ O 设备),应该首先考虑IO CP

八、其它细节
1 . 光盘和无缓存读写
    很多游戏都需要从光盘读写数据,如果只是少量的连续数据,我们可以完全把它放在内存中,如果是大量数据, 可以考虑拷贝到硬盘上去。如果一定需要在光盘上读写数据的话, 需要注意的就是除非我们实现了自己的缓存机制,否则不要用无缓存读写。因为光驱作为一个慢速设备, 我们应该尽量地使用操作系统提供的缓存机制。事实上在很多情况下,把空余内存作为光盘缓存还是比较经济的。
2 . 怎样设定文件大小
    无缓存读写对于文件读写的起始位置和长度有特殊的要求,但是我们要读写的文件通常不会正好是那个长度。读文件的时候比较简单,只需要通过R e a d F i l e 或是G e t O v e r l a p p e d R e s u l t lpNumberOfBytesRead 参数就可以了。如果在写文件时我们需要写入的数据小于扇区大小,也必须写入整个扇区,然后使用SetEndOfFil e 函数来把长度设为正确值。
3. 事务
    通常游戏中使用的文件并不需要很高的容错性, 如果偶然情况下在写存档信息的时候死机了,最多就是让那个倒霉的玩家重新通关一次而已。但是对于那些动辄需要几百上千个小时的游戏来说,还是需要对玩家多负责一点。我们并不能确保硬件上的问题,我们需要避免的只是在掉电( 更可能的情况是, 我们的游戏崩溃了)的情况下,让玩家保证有一份完好的存档文件, 哪怕是几分钟或者几小时之前的。要实现这一点并不难,对于二进制文件来说,我们只需要在文件开头留两个DW OR D 的内容,表示文件数据的开始位置和大小。读取的时候,我们必须先读取这两个DW O R D ,然后再读取真正的文件内容。对文件进行更新就比较复杂,具体可以参照下面的伪码:
write_fi le( fi le, buffer, s ize )
{
prev_offset = read_dword( fi le );
prev _si ze = read_dword( size );
i f( prev_offset – s izeof( dword ) * 2 >=
size )
{
set_fil e_pointer( file, sizeof( dword ) * 2 );
write( fi le, buffer, size );
flush( file );
write_dword( fil e, sizeof( dword * 2 ) );
write_dword( fi le, size );
flush( file );
}
else
{
set_fil e_pointer( fi le, prev_offs et +
prev_si ze );
wri te( fil e, buffer, size );
flush( file );
wri te_dword( fil e, prev_offset +
prev_si ze );
write_dword( file, s ize );
flush( file );
}
}
    如果是文本文件,可以先写一个新文件,然后再改名。这些方法虽然很简单,但是对于小规模的文件保存来说已经够了。

【参考文献】
1.
2.

作者简介
  李敏,码捷(苏州)科技有限公司软件工程师,致力于研究系统架构以及针对各种软硬件系统的性能优化。

阅读(2921) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~