在内存映射文件中存在的活动C++对象

15

我读了Gamasutra上的一篇采访,John Carmack在其中谈到他所谓的“生活在内存映射文件中的实时C ++对象”。以下是一些引用:

JC:是的。而且我从中获得了多重好处...在最后一个iOS Rage项目中,我们使用了一些聪明的技术来创建生活在内存映射文件中、由闪存文件系统支持的实时C ++对象,这也是我想在PC上构建所有未来工作的方式。

...

我的任务是在我们的PC平台上实现两秒钟的游戏加载,这样我们就可以更快地迭代。即使有固态硬盘,你仍然会被所有加载时间所占据,因此需要不同的纪律来说“所有东西都将被减少并用相对地址使用”,所以你只需说:“映射文件,我的所有资源都在那里,并且在15毫秒内完成。”(完整采访请点击这里) 有人知道Carmack在谈论什么以及如何设置类似的东西吗?我已经在网上搜索了一段时间,但似乎找不到任何相关信息。

我认为他正在从闪存中反序列化C++的“不可变”对象。这通常会比较复杂/风险较高,因为除非你“编写”了对象的代码,否则你通常无法控制对象的内存/资源分配。 - xanatos
你在进行移动开发吗?这听起来很有用,特别是当你需要快速切换进出应用程序时,而在普通计算机上这并不是一个问题。 - Kerrek SB
@Kerrek 这对于任何平台上的游戏或需要从磁盘加载大量状态的应用程序都非常有用。 - Justicle
@Justicle:毫无疑问,它对于序列化是很好的,但与使用真实系统内存相比,在桌面上可能会相对较慢。 - Kerrek SB
这就是关键,它的目的是加快从磁盘加载到系统内存的速度。 - Justicle
5个回答

7
这个想法是,通过内存映射访问文件,将程序状态的全部或部分序列化到文件中。这将要求您不使用通常的指针,因为指针只在进程运行时有效。相反,您必须存储从映射开始的偏移量,以便在重新启动程序并重新映射文件时可以继续使用它。这种方案的优点是,您不需要单独进行序列化,这意味着您不需要额外的代码,并且您不需要一次保存所有状态 - 相反,(几乎)所有程序状态都始终由文件支持。

缺点是,如果出现故障,您可能会得到损坏的数据。 - R. Martinho Fernandes
或者,您可以使用指针,但也要存储地图基址。然后,您可以在加载时重新定位指针。 - Don Reba
1
有关更多信息,请参见微软的__based 关键字 - MSalters
或者您可以使用备忘录并序列化备忘录而不是您的真实对象 ;)。 - AlexTheo

2

多年来,我们使用一种称为“相对指针”的东西,它是一种智能指针。它本质上是非标准的,但在大多数平台上都可以很好地工作。它的结构如下:

template<class T>
class rptr
{
    size_t offset;
public:
    T* operator->() { return reinterpret_cast<T*>(reinterpret_cast<char*>(this)+offset); }
};

需要将所有对象存储到相同的共享内存中(这也可以是文件映射)。通常我们还需要只在其中存储与我们自己兼容的类型,并编写自己的分配器来管理该内存。
为了始终具有一致的数据,我们使用通过COW内存映射技巧创建的快照(在Linux用户空间中运行,对其他操作系统不确定)。
随着向64位的大规模转移,有时我们也会使用固定映射,因为相对指针会产生一些运行时开销。由于通常有48位的地址空间,我们选择了一个保留的内存区域用于我们的应用程序,我们总是将这样的文件映射到该内存区域中。

使用固定地址映射文件存在风险 - 操作系统可能会在以后改变规则,并将该地址范围用于其他目的。例如,它可能会将程序代码加载到您为数据保留的地址范围中。 - Skizz
确实,这就是为什么我们使用操作系统没有计划用于任何事情的保留区域。 - PlasmaHH
@PlasmaHH 你是如何找到这些保留区域的?它们在哪里有记录?它们在每个操作系统上都不同吗? - fadedbee
@chrisdew:它们在每个操作系统和操作系统配置上都不同。Linux有能力通过/proc检查你的所有内容映射到哪里,因此你可以找到操作系统从未放置任何东西的感兴趣的区域。如果你想要更加确信,可以阅读操作系统的源代码。 - PlasmaHH
这就是boost::interprocess::offset_ptr的工作原理。 - Bruce Adams
显示剩余2条评论

2
你需要使用放置 new,可以直接使用或通过自定义分配器来实现。
可以查看 EASTL,它是一个特别适用于与自定义分配方案(例如嵌入式系统或游戏控制台所需的方案)良好配合的(子集)STL实现。
EASTL 的免费子集在这里:

1
这让我想起了一个文件系统,它可以在极短的时间内加载CD的级别文件(将加载时间从10秒缩短到几乎瞬间),并且也适用于非CD媒体。它由三个版本的类组成,用于包装文件IO函数,所有版本都具有相同的接口:
class IFile
{
public:
  IFile (class FileSystem &owner);
  virtual Seek (...);
  virtual Read (...);
  virtual GetFilePosition ();
};

还有一个额外的类:

class FileSystem
{
public:
  BeginStreaming (filename);
  EndStreaming ();
  IFile *CreateFile ();
};

你可以这样编写加载代码:

void LoadLevel (levelname)
{
  FileSystem fs;
  fs.BeginStreaming (levelname);
  IFile *file = fs.CreateFile (level_map_name);
  ReadLevelMap (fs, file);
  delete file;
  fs.EndStreaming ();
}

void ReadLevelMap (FileSystem &fs, IFile *file)
{
  read some data from fs
  get names of other files to load (like textures, object definitions, etc...)
  for each texture file
  {
    IFile *texture_file = fs.CreateFile (some other file name)
    CreateTexture (texture_file);
    delete texture_file;
  }
}

接下来,您将有三种操作模式:调试模式、流文件构建模式和发布模式。

在每种模式下,FileSystem对象都会创建不同的IFile对象。

在调试模式下,IFile对象只包装了标准IO函数。

在流文件构建中,IFile对象还包装了标准IO函数,但添加了写入到流文件(所有者FileSystem打开了流文件)每个读取的字节,以及写入任何文件指针位置查询的返回值(因此,如果需要知道文件大小,该信息被写入流文件)。这将把各种文件连接成一个大文件,但仅包含实际读取的数据。

发布模式将创建一个IFile,它不会打开文件或在文件中查找,而只是从流文件中读取(由所有者FileSystem对象打开)。

这意味着在发布模式下,所有数据都是在一个连续的读取系列中读取的(操作系统会对其进行良好的缓冲),而不是大量的查找和读取。这对于CD,其中寻找时间非常慢,是理想的。不用说,这是为基于CD的控制台系统开发的。

副作用是数据被剥离了通常会被跳过的不必要的元数据。

它确实有缺点 - 每个级别的所有数据都在一个文件中。这些文件可能会变得非常大,而且数据不能在文件之间共享。例如,如果您有一组纹理在两个或多个级别中是常见的,则每个流文件中都会重复该数据。此外,加载过程必须每次加载数据时都相同,无法有条件地跳过或添加元素到级别。


@Justicle:我不同意:“我希望我们的PC平台上的游戏加载时间为两秒钟”,这是一种真正快速加载游戏数据的方法。 - Skizz
这个问题是关于使用偏移指针在内存映射文件中序列化对象,而你的例子是关于连接文件(一种不同的技术)。引用不是问题,它只是一个参考。 - Justicle
你的代码很棒,但它没有回答被问出的问题。 - Justicle

0
作为Carmack指出,许多游戏(和其他应用程序)的加载代码被结构化为许多小读取和分配。
与其这样做,不如将一个级别文件进行单次fread(或等效操作),直接将指针修复后加载到内存中。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接