如果使用大型对象的可枚举数,则会导致“内存不足”异常

我试图迁移数据库中的图像存储在数据库中的一个记录在数据库中指向一个文件的硬盘驱动器。我试图使用 Parallel.ForEach来加快 使用这种方法查询数据的过程。

但是,我注意到我得到了一个 OutOfMemory异常。我知道 Parallel.ForEach会查询一批可枚举数,以减少开销,如果有一个间隔的查询(所以你的源将更有可能有下一个记录缓存在内存中,如果你一次做一堆查询,而不是间隔)。这个问题是由于我返回的一个记录是一个1-4Mb 字节数组,缓存导致整个地址空间被用完(程序必须在 x86模式下运行,因为目标平台将是一台32位机器)

有没有什么方法可以禁用缓存或者对于 TPL 来说 make 更小?


下面是一个示例程序来说明这个问题。这必须在 x86模式下编译,以显示问题是否需要很长时间或没有发生在您的机器上提高数组的大小(我发现 1 << 20需要大约30秒在我的机器和 4 << 20几乎是瞬间)

class Program
{


static void Main(string[] args)
{
Parallel.ForEach(CreateData(), (data) =>
{
data[0] = 1;
});
}


static IEnumerable<byte[]> CreateData()
{
while (true)
{
yield return new byte[1 << 20]; //1Mb array
}
}
}
33791 次浏览

Parallel.ForEach 只有当任务受 CPU 限制并且线性扩展时才能正常工作的默认选项。当任务受 CPU 限制时,一切工作都很完美。如果您有一个四核并且没有其他进程在运行,那么 Parallel.ForEach使用所有四个处理器。如果您有一个四核处理器,并且计算机上的其他进程使用一个完整的 CPU,那么 Parallel.ForEach大约使用三个处理器。

但是如果任务不受 CPU 限制,那么 Parallel.ForEach就会继续启动任务,努力让所有 CPU 保持忙碌。然而,无论有多少任务并行运行,总是有更多未使用的 CPU 马力,因此它不断创建任务。

如何判断任务是否受 CPU 限制?希望只是检查一下。如果你是质数的因数分解,这是显而易见的。但其它情况就没那么明显了。判断任务是否受 CPU 限制的经验方法是限制与 ParallelOptions.MaximumDegreeOfParallelism的最大并行度,并观察程序的行为。如果您的任务是 CPU 绑定的,那么您应该在四核系统上看到这样的模式:

  • ParallelOptions.MaximumDegreeOfParallelism = 1: 使用一个完整的 CPU 或25% 的 CPU 利用率
  • ParallelOptions.MaximumDegreeOfParallelism = 2: 使用两个 CPU 或50% 的 CPU 利用率
  • ParallelOptions.MaximumDegreeOfParallelism = 4: 使用所有 CPU 或100% CPU 利用率

如果它的行为是这样的,那么您可以使用默认的 Parallel.ForEach选项并获得良好的结果。线性 CPU 利用率意味着良好的任务调度。

但是如果我在我的 Intel i7上运行您的示例应用程序,无论我设置多大程度的并行性,都会得到大约20% 的 CPU 利用率。为什么会这样?分配了如此多的内存,以至于垃圾收集器阻塞了线程。应用程序是资源绑定的,资源是内存。

同样,对数据库服务器执行长时间运行查询的 I/O 绑定任务也永远不能有效地利用本地计算机上可用的所有 CPU 资源。在这种情况下,任务调度程序无法“知道何时停止”开始新任务。

如果您的任务不受 CPU 限制,或者 CPU 利用率不能与最大并行度成线性关系,那么您应该建议 Parallel.ForEach不要同时启动太多任务。最简单的方法是指定一个数字,该数字允许重叠的 I/O 绑定任务具有一定的并行性,但不能过多到超出本地计算机对资源的需求或使任何远程服务器负担过重。为了得到最好的结果,需要反复试验:

static void Main(string[] args)
{
Parallel.ForEach(CreateData(),
new ParallelOptions { MaxDegreeOfParallelism = 4 },
(data) =>
{
data[0] = 1;
});
}

因此,虽然里克的建议肯定是一个重要的一点,另一件事情,我认为是缺少的是对 分区的讨论。

Parallel::ForEach将使用默认的 Partitioner<T>实现,对于没有已知长度的 IEnumerable<T>,该实现将使用块分区策略。这意味着 Parallel::ForEach将用于处理数据集的每个工作线程将从 IEnumerable<T>中读取一些元素,然后这些元素将只由该线程处理(暂时忽略工作窃取)。它这样做是为了节省不断回到源代码并分配一些新的工作和为另一个工作线程安排它的开销。所以,通常来说,这是件好事。然而,在您的特定场景中,假设您在一个四核心上,并且您已经为您的工作设置了 MaxDegreeOfParallelism到4个线程,现在每个线程从 IEnumerable<T>中提取100个元素。好吧,那就是100-400兆就为了那个特殊的工作线程,对吧?

那么你怎么解决这个问题呢?放松,你 编写一个定制的 Partitioner<T>实现。现在,在您的情况下,分块仍然是有用的,所以您可能不希望使用单个元素分区策略,因为这样做会带来开销,并且需要进行所有必要的任务协调。相反,我将编写一个可配置的版本,您可以通过 appset 进行调优,直到找到工作负载的最佳平衡。好消息是,虽然编写这样一个实现非常简单,但实际上您甚至不需要自己编写,因为 PFX 团队已经完成了这项工作并编写了 把它放到并行编程示例项目中

这个问题与分区器有关,而与并行程度无关。解决方案是实现一个自定义数据分区程序。

如果数据集很大,那么似乎 TPL 的 mono 实现是有保证的 内存不足。这种情况最近发生在我身上(实际上我正在运行上面的循环,并发现内存线性增加,直到它给我一个 OOM 异常)。

在跟踪这个问题之后,我发现在默认情况下 mono 会将 使用 EnumerablePartifier 类的枚举器 每次向任务发送数据时,它都会“块” 数据由一个不断增加(和不可改变)的因子2 当一个任务请求数据时,它得到一个大小为1的块,下一次大小 2 * 1 = 2,下一次2 * 2 = 4,然后2 * 4 = 8,等等 传递给任务的数据量,因此存储在内存中 同时,随着任务的长度增加,如果大量的数据 正在处理时,不可避免地会发生内存不足异常。

据推测,这种行为的最初原因是它想要避免 让每个线程返回多次以获取数据,但似乎 基于这样一个假设,即所有正在处理的数据都可以放入内存中 (从大文件中读取时不是这种情况)。

如前所述,使用自定义分区程序可以避免这个问题。下面是一个通用示例,它一次只向每个任务返回一个项目的数据:

Https://gist.github.com/evolvedmicrobe/7997971

只需要首先实例化该类并将其交给并行

虽然使用自定义分区器无疑是最“正确”的答案,但一个更简单的解决方案是让垃圾收集器赶上来。在我尝试的情况下,我重复调用一个函数内部的并行.for 循环。尽管每次退出函数,程序使用的内存仍然保持线性增长,如下所述。我补充道:

//Force garbage collection.
GC.Collect();
// Wait for all finalizers to complete before continuing.
GC.WaitForPendingFinalizers();

虽然速度不是很快,但确实解决了内存问题。据推测,在 CPU 使用率和内存利用率较高的情况下,垃圾收集器不能有效地运行。