MemoryStream 的替代品






4.93/5 (76投票s)
解释了在使用 MemoryStream 时经常出现的 OutOfMemoryExceptions 的原因,并介绍了一种替代方案,该方案使用内存段的动态列表作为后备存储,而不是单个数组,从而使其能够更有效地处理大型数据集。
引言
本文解释了在使用 MemoryStream
处理大型数据集时常见的 OutOfMemoryException
异常的原因,并介绍了一个名为 MemoryTributary
的类,该类旨在作为 .NET 的 MemoryStream
的替代方案,能够处理大量数据。
背景
当尝试使用 MemoryStream
处理相对大型数据集(约几十 MB)时,常见 遇到 OutOfMemoryException
。这并非如其名称所示,是因为达到了系统内存的限制,而是因为进程的虚拟地址空间达到了限制。
当进程向 Windows 请求内存时,内存管理器并非从 RAM 分配地址空间,而是分配“页”——存储块(通常为 4KB),这些块可以存在于 RAM、磁盘或内存管理器决定的任何地方。这些页被映射到进程的地址空间中,因此,例如,当进程尝试访问从 [0xAF758000] 开始的内存时,它实际上是在访问 [第 496 页] 开头的字节,而无论第 496 页位于何处。因此,只要磁盘空间允许,进程就可以分配任意数量的内存,并且可以将其适合其虚拟地址空间的任意数量映射到其虚拟地址空间——前提是,这些分配是以大量小块进行的。
这是因为进程地址空间是碎片化的:大部分被操作系统占用,其他部分用于可执行映像、库以及所有其他先前分配。一旦内存管理器分配了一组相当于请求大小的页,进程就必须将其映射到其地址空间——但如果地址空间中不包含足够大的连续部分来容纳请求的大小,则无法映射这些页,并且分配将因 OutOfMemoryException
而失败。
进程并没有耗尽空间,甚至没有耗尽地址:它耗尽的是顺序地址。要看到这一点(如果您使用的是 64 位系统),请将以下程序目标设置为 x86 并运行它,然后将其目标设置为 x64 并查看它还能运行多远。
static void Main(string[] args)
{
List<byte[]> allocations = new List<byte[]>();
for (int i = 0; true; i++)
{
try
{
allocations.Add(new byte[i * i * 10]);
}
catch (OutOfMemoryException e)
{
Console.Write(string.Format("Performed {0} allocations",i));
}
}
}
MemoryStream
的当前实现使用单个字节数组作为后备存储。当尝试向大于该数组大小的位置写入流时,它的大小会翻倍。根据程序的行为,MemoryStream
的后备存储很快就需要比虚拟地址空间中可用的连续内存更多的内存。
使用代码
解决方案是不要求连续内存来存储流中的数据。MemoryTributary
使用 4KB 块的动态列表作为后备存储,这些块在流使用时按需分配。
MemoryTributary 继承自 Stream,因此它的使用方式与其他 Stream(如 MemoryStream)相同。
MemoryTributary
不过是 MemoryStream
的替代方案,而不是“即插即用”的替代品,原因有几点需要注意。
MemoryTributary
未实现MemoryStream
的所有构造函数(因为目前没有人工容量限制)。它可以从byte[]
初始化。MemoryTriburary
继承自Stream
,而不是MemoryStream
,因此不能在接受MemoryStream
作为参数的地方使用。MemoryTributary
未实现GetBuffer()
,因为它没有可以返回的单个后备缓冲区;功能上等同的是ToArray()
,但请谨慎使用。
使用 MemoryTributary
时,请注意以下事项:
- 块在访问时(例如,通过读取或写入调用)按需分配。在读取发生之前,会检查 Position 是否超出 Length,以确保读取操作在流的边界内执行;然而,Length 只是一个健全性检查,并不对应于已分配内存的当前数量,而是对应于已写入流的数据量。设置 Length 不会分配内存,但它允许在未定义的数据上进行读取。
- 内存以顺序块的形式分配,即,如果访问的第一个块是块 3,则会自动分配块 1 和块 2。
MemoryTributary
包含ToArray()
方法,但这不安全,因为它不可避免地会遇到该类试图解决的问题:需要分配大量连续内存。- 相反,使用
MemoryTributary
的ReadFrom()
和WriteTo()
方法,让MemoryTributary
在处理大量数据时与其他流进行交互。
//A new MemoryTributary object: length is 0, position is 0, no memory has been allocated
MemoryTributary d = new MemoryTributary();
//returns -1 because Length is 0, no memory allocated
int a = d.ReadByte();
//Length now reports 10000 bytes, but no memory is allocted
d.SetLength(10000);
//three blocks of memory are now allocated,
//but b is undefined because they have not been initialised
int b = d.ReadByte();
性能指标
MemoryStream
和 MemoryTributary
的性能(包括容量和速度)都难以预测,因为它取决于多种因素,其中一个最重要的因素是当前进程的碎片化和内存使用情况——一个分配大量内存的进程会比不分配大量内存的进程更快地耗尽大的连续区域。但是,在受控条件下进行测量可以了解两者的相对性能特征。
下表比较了 MemoryTributary
和 MemoryStream
的容量和访问时间。 在所有情况下,测试的进程实例仅测试目标流(即,创建了一个新进程,因此对 MemoryStream 的测试不会影响对 MemoryTributary 的测试,并且除了用于读取或写入的目的外,没有进行任何分配)。
容量
要执行此测试,一个循环将一个 1MB 数组的内容反复写入目标流,直到流抛出 OutOfMemoryException
,然后捕获该异常并返回异常发生前的总写入次数。
Stream | 异常前的平均流长度(MB) |
MemoryStream |
488 |
MemoryTributary |
1272 |
(此测试进程目标为 x86。)
速度(访问时间)
对于这些测量,将固定数量的数据写入流,然后从流中读取。数据以 1KB 到 1MB 之间的随机长度写入和读取,写入和读取到一个 1MB 的字节数组。使用 Stopwatch 实例来确定写入然后读取指定数量数据所需的时间。这些仅适用于顺序访问,因为除了读取过程的开始外,没有进行任何查找。
每个进程执行其测试六次,针对同一个对象,因此给定流在给定测试中结果之间的差异表明了分配内存所需的时间与访问内存所需的时间的比例。
流测试执行时间(毫秒) | ||||
写入和读取的数据量(MB) | MemoryStream | MemoryTributary(4KB 块) | MemoryTributary(64KB 块) | MemoryTributary(1MB 块) |
10 | 10 | 13 | 11 | 7 |
3 | 5 | 3 | 3 | |
3 | 6 | 3 | 3 | |
3 | 5 | 3 | 3 | |
4 | 5 | 3 | 3 | |
3 | 6 | 3 | 3 | |
100 | 100 | 148 | 123 | 52 |
34 | 54 | 42 | 35 | |
34 | 48 | 35 | 34 | |
35 | 47 | 36 | 35 | |
34 | 48 | 36 | 35 | |
35 | 51 | 35 | 35 | |
500 | 516 | 390 | 290 | 237 |
167 | 222 | 184 | 170 | |
168 | 186 | 154 | 167 | |
167 | 187 | 151 | 168 | |
167 | 186 | 151 | 168 | |
167 | 185 | 153 | 168 | |
1,000 | 1185 | 1585 | 1299 | 485 |
347 | 547 | 431 | 344 | |
343 | 463 | 350 | 345 | |
338 | 462 | 350 | 345 | |
3377 | 461 | 349 | 345 | |
339 | 465 | 351 | 343 |
结果表明,在理想条件下,MemoryTributary 的存储容量大约是 MemoryStream
的两倍。访问时间取决于 MemoryTributary
的块设置;初始分配略快于 MemoryStream
,但访问时间相当。块越小,必须进行的分配越多,但实例对内存碎片化的敏感性越低。
附带的源代码默认块大小为 64KB,由 blockSize
成员设置。
兴趣点
有关 OutOfMemoryException
原因的完整解释,请参阅 Eric Lippert 的 文章《“内存不足”并非指物理内存》。
MemoryTributary
的方法通过私有属性访问相应的块——这样做的目的是,如果简单的 List<>
变得不可行,可以很容易地替换块的存储方式,而无需修改类的每个成员。MemoryTributary
已在几百 MB 的数据上进行了测试,当数据量达到大约 1GB 时会因 OutOfMemoryException
而失败。
MSDN 上关于 MemoryStream
的页面在这里。
是的,tributary 是我能想到的最富有创意的 stream 的同义词。
历史
- 2012/03/15 - 初始版本。
- 19/03/2012
- 更新了
Read()
方法,内部使用long
计数器而不是int
;以支持十 GB 级别的流。 - 更新了文章,加入了性能测量。
- 现在,当容量传递给
MemoryTributary
的构造函数时,MemoryTributary
将会分配等量的块。