使用 ParallelFor 循环时索引超出范围异常

Index out of range exception in using ParallelFor loop

这是一个非常奇怪的情况,首先是代码...

代码

 private List<DispatchInvoiceCTNDataModel> WorksheetToDataTableForInvoiceCTN(ExcelWorksheet excelWorksheet, int month, int year)
        {
            int totalRows = excelWorksheet.Dimension.End.Row;
            int totalCols = excelWorksheet.Dimension.End.Column;
            DataTable dt = new DataTable(excelWorksheet.Name);
            // for (int i = 1; i <= totalRows; i++)
            Parallel.For(1, totalRows + 1, (i) =>
            {
                DataRow dr = null;
                if (i > 1)
                {
                    dr = dt.Rows.Add();
                }
                for (int j = 1; j <= totalCols; j++)
                {
                    if (i == 1)
                    {
                        var colName = excelWorksheet.Cells[i, j].Value.ToString().Replace(" ", String.Empty);
                        lock (lockObject)
                        {
                            if (!dt.Columns.Contains(colName))
                                dt.Columns.Add(colName);
                        }
                    }
                    else
                    {
                        dr[j - 1] = excelWorksheet.Cells[i, j].Value != null ? excelWorksheet.Cells[i, j].Value.ToString() : null;
                    }
                }
            });
            var excelDataModel = dt.ToList<DispatchInvoiceCTNDataModel>();
            // now we have mapped everything expect for the IDs
            excelDataModel = MapInvoiceCTNIDs(excelDataModel, month, year, excelWorksheet);
            return excelDataModel;
        }

问题
当我 运行 在随机场合使用代码时,它会在行 IndexOutOfRangeException 上抛出

  dr[j - 1] = excelWorksheet.Cells[i, j].Value != null ? excelWorksheet.Cells[i, j].Value.ToString() : null;

对于 ij 的一些随机值。当我跨过代码 (F10) 时,因为它在 ParallelLoop 中 运行ning,一些其他线程启动并且抛出其他异常,其他异常类似于(我无法重现它,就来过一次,不过我觉得也跟这个线程问题有关)Column 31 not found in excelWorksheet。我不明白这些异常是怎么发生的?

案例 1
IndexOutOfRangeException 甚至不应该出现,因为唯一的 code/shared 变量 dt 我已经锁定访问它,其余的都是本地或参数,所以不应该有任何与线程相关的问题。此外,如果我在调试 window 中检查 ij 的值,甚至在调试 window 中评估整个表达式 dr[j - 1] = excelWorksheet.Cells[i, j].Value != null ? excelWorksheet.Cells[i, j].Value.ToString() : null; 或其中的一部分, 然后它工作正常,没有任何错误或什么都没有。

案例2
对于第二个错误,(不幸的是现在没有重现,但仍然)它不应该发生,因为 excel.

中有 33 列

更多代码
如果有人可能需要如何调用此方法

using (var xlPackage = new ExcelPackage(viewModel.postedFile.InputStream))
            {
                ExcelWorksheets worksheets = xlPackage.Workbook.Worksheets;

                // other stuff 
                var entities = this.WorksheetToDataTableForInvoiceCTN(worksheets[1], viewModel.Month, viewModel.Year);
                // other stuff 
            }

其他
如果有人需要更多 code/details 让我知道。

更新
好的,回答一些评论。它 在使用 for 循环时工作正常,我已经测试了很多次。此外,没有抛出异常的 ij 的特定值。有时它是 8, 6 其他时候它可以是任何东西,比如 19,2 或任何东西。此外,在 Parallel 循环中,+1 没有造成任何损害,因为 msdn 文档说它是排他性的而不是包容性的。另外,如果那是问题所在,我只会在最后一个索引(i 的最后一个值)出现异常 但事实并非如此.

更新 2
给出的答案锁定代码

  dr = dt.Rows.Add();

我改成了

  lock(lockObject) {
      dr = dt.Rows.Add();
  }

它不工作。现在我得到 ArgumentOutOfRangeException 仍然 如果我 运行 在调试 window 中,它工作正常。

更新 3
这是完整的异常详细信息,在更新 2 之后(我在更新 2 中提到的行中得到了这个)

System.ArgumentOutOfRangeException was unhandled by user code
  HResult=-2146233086
  Message=Index was out of range. Must be non-negative and less than the size of the collection.
Parameter name: index
  Source=mscorlib
  ParamName=index
  StackTrace:
       at System.ThrowHelper.ThrowArgumentOutOfRangeException()
       at System.Collections.Generic.List`1.get_Item(Int32 index)
       at System.Data.RecordManager.NewRecordBase()
       at System.Data.DataTable.NewRecordFromArray(Object[] value)
       at System.Data.DataRowCollection.Add(Object[] values)
       at AdminEntity.BAL.Service.ExcelImportServices.<>c__DisplayClass2e.<WorksheetToDataTableForInvoiceCTN>b__2d(Int32 i) in C:\Projects\Manager\Admin\AdminEntity\AdminEntity.BAL\Service\ExcelImportServices.cs:line 578
       at System.Threading.Tasks.Parallel.<>c__DisplayClassf`1.<ForWorker>b__c()
  InnerException: 

dr = dt.Rows.Add(); 不是线程安全的,您正在破坏 DataTable 中保存 table.

行的数组的内部状态

初看改成

if (i > 1)
{
    lock (lockObject)
    {
        dr = dt.Rows.Add();
    }
}

应该修复它,但这并不意味着其他线程安全问题不存在 excelWorksheet.Cells 从多个线程访问。 (如果 excelWorksheetthis class 而你是 运行 STA 主线程(WinForms 或 WPF)COM 应该为你编组跨线程调用)


编辑:新想法,问题出在您在并行循环内设置模式并同时尝试写入它这一事实。将所有 i == 1 逻辑拉到循环之前,然后从 i == 2

开始
private List<DispatchInvoiceCTNDataModel> WorksheetToDataTableForInvoiceCTN(ExcelWorksheet excelWorksheet, int month, int year)
{
    int totalRows = excelWorksheet.Dimension.End.Row;
    int totalCols = excelWorksheet.Dimension.End.Column;
    DataTable dt = new DataTable(excelWorksheet.Name);

    //Build the schema before we loop in parallel.
    for (int j = 1; j <= totalCols; j++)
    {
        var colName = excelWorksheet.Cells[1, j].Value.ToString().Replace(" ", String.Empty);
        if (!dt.Columns.Contains(colName))
            dt.Columns.Add(colName);
    }

    Parallel.For(2, totalRows + 1, (i) =>
    {
        DataRow dr = null;
        lock(lockObject) {
            dr = dt.Rows.Add();
        }
        for (int j = 1; j <= totalCols; j++)
        {
            dr[j - 1] = excelWorksheet.Cells[i, j].Value != null ? excelWorksheet.Cells[i, j].Value.ToString() : null;
        }
    });
    var excelDataModel = dt.ToList<DispatchInvoiceCTNDataModel>();
    // now we have mapped everything expect for the IDs
    excelDataModel = MapInvoiceCTNIDs(excelDataModel, month, year, excelWorksheet);
    return excelDataModel;
}

您的代码不正确:

1) Parallel.For 有自己的批处理机制(虽然可以使用带有分区器的 ForEach 进行自定义)并且不保证使用 (for) i==n 的操作将在使用 i== 的操作之后执行m 其中 n>m。 所以行

dr[j - 1] = excelWorksheet.Cells[i, j].Value != null ? excelWorksheet.Cells[i, j].Value.ToString() : null;

当所需列尚未添加时抛出异常(在 {i==1} 操作中}

2) 并且推荐使用NewRow方法:

dr=tbl.NewRow->Populate dr->tbl.Rows.Add(dr)

或Rows.Add(对象[]值):

values=[KnownColumnCount]->Populate values->tbl.Rows.Add(values)

3) 在这种情况下,首先填充列确实更好,因为它是对 excel 文件的顺序访问(查找)并且不会损害性能

您是否尝试过在创建新数据行时使用 NewRow 并将列的创建移动到并行循环之外,就像 Scott Chamberlain 上面建议的那样?通过使用 newrow,您将创建一个与父数据表具有相同架构的行。当我用一个随机 excel 文件尝试你的代码时,我遇到了和你一样的错误,但它是这样工作的:

            for (int x = 1; x <= totalCols; x++)
        {
            var colName = excelWorksheet.Cells[1, x].Value.ToString().Replace(" ", String.Empty);

            if (!dt.Columns.Contains(colName))
                dt.Columns.Add(colName);

        }

        Parallel.For(2, totalRows + 1, (i) =>
        {
            DataRow dr = null;

            for (int j = 1; j <= totalCols; j++)
            {
                dr = dt.NewRow();
                dr[j - 1] = excelWorksheet.Cells[i, j].Value != null
                    ? excelWorksheet.Cells[i, j].Value.ToString()
                    : null;
                lock (lockObject)
                {
                    dt.Rows.Add(dr);
                }
            }
        });

好的。所以你现有的代码存在一些问题,其中大部分已被其他人触及:

  • 并行线程受 OS 调度程序支配;因此,尽管线程是按顺序排队的,但它们可能(而且经常)乱序地完成执行。例如,给定 Parallel.For(0, 10, (i) => { Console.WriteLine(i); });,前四个线程(在四核系统上)将使用 i 值 0-3 排队。但是这些线程中的任何一个都可能先于其他线程开始或完成执行。所以你可能会看到 2 首先打印出来,于是线程 4 将被排队。然后线程 1 可能会完成,线程 5 将排队。然后线程 4 可能会完成,甚至在线程 0 或 3 完成之前。等等,等等。 TL;DR:你不能假设一个并行的有序输出。
  • 考虑到这一点,正如@ScottChamberlain 指出的那样,在并行循环中生成列是一个非常糟糕的主意 - 因为您无法保证执行列生成的线程会在另一个线程开始分配数据之前创建所有列行到那些列索引。例如。您可以在 table 实际上有第五列之前将数据分配给单元格 [0,4]。
    • 值得注意的是,纯粹从代码整洁的角度来看,无论如何都应该将其从循环中打破。目前,您有两个嵌套循环,每个循环在一次迭代中都有特殊的行为;最好将该设置逻辑分离到它自己的循环中,并让主循环分配数据而不是其他任何东西。
  • 出于同样的原因,您不应在并行循环中的 table 中创建新行 - 因为您无法保证这些行将添加到其源中的 table命令。也打破它,并根据索引访问循环中的行。
  • 有人提到在 Rows.Add() 之前使用 DataRow.NewRow()。 技术上,NewRow() 是处理问题的正确方法,但实际推荐的访问模式与可能适用于逐个单元函数的访问模式略有不同,尤其是在并行处理时是有意的(参见 MSDN: DataTable.NewRow Method)。事实仍然是,使用 Rows.Add() 向 DataTable 添加一个新的空白行并在之后填充它可以正常运行。
  • 您可以使用 null 合并运算符 ?? 清理字符串格式,它会评估前面的值是否为 null,如果是,则分配后续值。例如,foo = bar ?? "" 等价于 if (bar == null) { foo = ""; } else { foo = bar; }.

所以马上开始,您的代码应该更像这样:

private void ReadIntoTable(ExcelWorksheet sheet)
{
    DataTable dt = new DataTable(sheet.Name);
    int height = sheet.Dimension.Rows;
    int width = sheet.Dimension.Columns;

    for (int j = 1; j <= width; j++)
    {
        string colText = (sheet.Cells[1, j].Value ?? "").ToString();
        dt.Columns.Add(colText);
    }
    for (int i = 2; i <= height; i++)
    {
        dt.Rows.Add();
    }

    Parallel.For(1, height, (i) =>
    {
        var row = dt.Rows[i - 1];
        for (int j = 0; j < width; j++)
        {
            string str = (sheet.Cells[i + 1, j + 1].Value ?? "").ToString();
            row[j] = str;
        }
    });

    // convert to your special Excel data model
    // ...
}

好多了!

...但是还是不行!

是的,它仍然因 IndexOutOfRange 异常而失败。但是,由于我们采用了您的原始行 dr[j - 1] = excelWorksheet.Cells[i, j].Value != null ? excelWorksheet.Cells[i, j].Value.ToString() : null; 并将其分成几部分,因此我们可以准确地看到它在哪一部分上失败了。它在 row[j] = str; 失败,我们实际上将文本写入行。

呃哦。

MSDN: DataRow Class

Thread Safety

This type is safe for multithreaded read operations. You must synchronize any write operations.

*叹息*。是的。谁知道为什么 DataRow 在赋值时使用 static anything,但你知道了;写入 DataRow 不是线程安全的。果然,这样做...

private static object s_lockObject = "";

private void ReadIntoTable(ExcelWorksheet sheet)
{
    // ...
    lock (s_lockObject)
    {
        row[j] = str;
    }
    // ...
}

...神奇地让它工作。当然,它完全破坏了并行性,但它有效。

嗯,它几乎完全破坏了并行性。对具有 18 列和 46319 行的 Excel 文件进行的轶事实验表明,Parallel.For() 循环平均在大约 3.2 秒内创建其 DataTable,而将 Parallel.For() 替换为 for (int i = 1; i < height; i++) 大约需要 3.5 秒。我的猜测是,由于锁仅用于写入数据,因此在一个线程上写入数据并在另一个线程上处理文本所带来的好处非常小。

当然,如果您可以创建自己的 DataTable 替代品 class,您可以看到更大的速度提升。例如:

string[,] rows = new string[height, width];
Parallel.For(1, height, (i) =>
{
    for (int j = 0; j < width; j++)
    {
        rows[i - 1, j] = (sheet.Cells[i + 1, j + 1].Value ?? "").ToString();
    }
});

对于上面提到的相同 Excel table,这平均执行时间约为 1.8 秒 - 大约是我们勉强并行的 DataTable 时间的一半。在此代码段中用标准 for() 替换 Parallel.For() 使其在大约 2.5 秒内达到 运行。

因此您可以看到并行性带来的显着性能提升,但自定义数据结构也是如此 - 尽管后者的可行性将取决于您轻松将返回值转换为该 Excel 数据模型的能力东西,不管它是什么。