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


68

我正在尝试将数据库中存储了图像的数据库迁移到数据库中指向硬盘驱动器上文件的记录。我试图使用这种方法Parallel.ForEach加快查询速度,以查询出数据。

但是,我注意到我遇到了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
        }
    }
}

运行时有多少线程处于活动状态?设置ParallelOptions.MaxDegreeOfParallelism值会有帮助吗?
凯文·普林

@Kevin Pullin发生异常时,有9个任务与示例代码一起运行(我在四核上运行)。将其设置为最大2并将阵列大小设置为4Mb,它将稳定在大约64Mb的工作集上。发布此作为答案,我会投票。我认为这样做或不使用TPL可能是我唯一的选择。我将使用这些设置让它运行一整夜,看看是否仍然有异常。
Scott Chamberlain

Answers:


100

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

22
我认为这与我的问题有关,他打在了头上。我可能会使用Enviorment.ProcessorCount并将其设置为最大并行度的限制
Scott Chamberlain

1
不,这确实与问题无关,我尝试过但失败了,将在下面进行更多说明。
evolvedmicrobe

46

因此,尽管Rick提出的建议绝对是重要的一点,但我认为缺少的另一件事是分区的讨论。

Parallel::ForEach将使用默认Partitioner<T>实现,对于IEnumerable<T>长度未知的默认实现,将使用块分区策略。这意味着每个Parallel::ForEach将用于处理数据集的工作线程将从其中读取一些元素,IEnumerable<T>然后仅由该线程处理这些元素(暂时忽略工作偷取)。这样做是为了节省经常不得不返回源头并分配一些新工作并将其安排到另一个工作线程的开销。因此,通常,这是一件好事。但是,在您的特定情况下,假设您在四核上,并且MaxDegreeOfParallelism为工作设置了4个线程,现在每个线程都从您的处理器中提取了100个元素IEnumerable<T>。好吧,那只是针对该特定工作线程的100-400兆,对吧?

那么您如何解决呢?容易,您编写了一个自定义Partitioner<T>实现。现在,分块在您的情况下仍然有用,因此您可能不想采用单个元素分区策略,因为那样的话,您将为此引入所有必要的任务协调开销。相反,我会编写一个可配置的版本,您可以通过应用程序设置对其进行调整,直到找到适合您的工作负载的最佳平衡。好消息是,尽管编写这样的实现非常简单,但是实际上您甚至不必自己编写它,因为PFX团队已经做到了并将其放入并行编程示例项目中


感谢您提供额外的信息。这是一个非常有用的问题。
Scott Chamberlain

1
这是一个很好的问题,我希望很多人遇到它并从中学习。通常,PLINQ / TPL做得很好,可以使您免受许多此类问题的侵扰,但有时不可避免地需要进入那里并使用旋钮和开关,以针对给定的工作量真正为正确的道路指明方向。这恰好是其中一种情况。:)
德鲁·马什

1
到示例项目的链接不再存在,为什么他们不能在不可用的页面中提供到新页面的链接。
zish 2012年

为您+1。我也使用Parallel Foreach和有限的MaxDegreeOfParallelism来进行巨大的数据库导入。问题是分区。
stmax 2013年


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使用率和内存利用率很高的情况下,垃圾收集器无法高效运行。


受到高度认可的作者之类的主题提出的理由是:“如果在生产代码中调用GC.Collect(),则本质上是在声明您比GC的作者了解更多。可能是这种情况。通常不会,因此强烈建议不要这样做。” GC不是开发人员工具包,它是编译器工具包,有一些与之相关的实践,例如,在IDisposable中将其用于未管理的资源。请通过C#参阅stackoverflow.com/questions/118633/…和CLR
Bhanu Chhabra,

问题在于等待的任务没有垃圾。
uli78 '19
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.