如果使用具有大对象的枚举,Parallel.ForEach可能会导致“内存不足”异常

问题描述 投票:61回答:4

我正在尝试将数据库中存储图像的数据库迁移到指向硬盘驱动器上的文件的数据库中的记录。我试图使用Parallel.ForEach来加速using this method进程查询数据。

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

是否有任何方法可以禁用缓存或使TPL更小?


这是一个显示问题的示例程序。这必须在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
        }
    }
}
c# out-of-memory task-parallel-library large-data
4个回答
93
投票

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;
            });
}

41
投票

所以,虽然里克提出的建议绝对是一个重点,但我认为缺少的另一件事是对partitioning的讨论。

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

那你怎么解决这个问题呢?容易,你write a custom Partitioner<T> implementation。现在,在你的情况下,分块仍然很有用,所以你可能不想使用单个元素分区策略,因为那样你就会引入所需的所有任务协调的开销。相反,我会编写一个可配置的版本,您可以通过appsetting调整,直到找到工作负载的最佳平衡。好消息是,虽然编写这样的实现是非常直接的,但实际上你甚至不必自己编写它,因为PFX团队已经做到了并且put it into the parallel programming samples project


14
投票

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

如果数据集很大,似乎TPL的单声道实现保证耗尽内存。这最近发生在我身上(基本上我正在运行上面的循环,并发现内存线性增加,直到它给我一个OOM异常)。

在跟踪问题之后,我发现默认情况下mono将使用EnumerablePartitioner类来划分枚举器。这个类的行为在于,每当它向任务提供数据时,它就会以不断增加的(并且不可更改的)因子2“数据块化”数据。因此,当任务第一次请求数据时,它会获得一大块的大小1,下一次大小2 * 1 = 2,下一次2 * 2 = 4,然后2 * 4 = 8等等。结果是交给任务的数据量,因此存储在内存同时,随着任务的长度而增加,如果正在处理大量数据,则不可避免地会发生内存不足异常。

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

如前所述,使用自定义分区程序可以避免此问题。一个简单地将数据一次返回给每个任务一个项目的一个通用示例如下:

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

首先简单地实例化该类,然后将其交给Parallel.For而不是枚举本身


-2
投票

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

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

虽然它不是超快,但确实解决了内存问题。据推测,在高CPU使用率和内存利用率下,垃圾收集器无法有效运行。

© www.soinside.com 2019 - 2024. All rights reserved.