現象
エラー内容
以下のような、DataRowのデータを参照するコードを通った際に、例外が発生
row["columnName"]
メッセージ
インデックスが配列の境界外です。
スタックトレース
場所 System.Data.Common.StringStorage.Get(Int32 recordNo) 場所 System.Data.DataRow.get_Item(String columnName)
状況
同一環境で同一操作をしても100%再現しなく、発生頻度は低い。
原因
スレッド間で同じインスタンスのDataRowを参照していたため。
読み取りだけなら問題ないものの、該当の処理では片方は値の編集処理を行っていた。
この型は、マルチスレッド読み取り操作に安全です。 すべての書き込み操作を同期する必要があります。
DataRow クラス (System.Data) | Microsoft Learn
該当のコード
以下のようなコードでした。
・Windowsフォームアプリケーションで、DataGridViewにDataTableをバインドして使用。
・画面スレッドと別の処理スレッドでメインの処理を行う(非同期)。
・画面スレッドはProgress<T>処理スレッドから通知を受けて表示を更新する。
※バインドされているDataTableのDataRowを編集
◯画面スレッド側の処理
private async void AsyncTest()
{
    IProgress<string[]> progress = new Progress<string[]>(UpdateDisplay);
    DataRow[] rows = _dt.Select();
    await Task.Run(() =>
    {
         ProcessMain(progress, rows);
    });
}
private void UpdateDisplay(string[] args)
{
    for (int rowIndex = 0; rowIndex < dataGridView.Rows.Count - 1; rowIndex++)
    {
        if (dataGridView[0, rowIndex].Value.ToString() == args[0])
        {
            dataGridView[1, rowIndex].Value = args[1];
        }
    }
}
◯処理スレッド側の処理
private void ProcessMain(IProgress<string[]> progress, DataRow[] rows)
{
    foreach (DataRow row in rows)
    {
        progress.Report(new string[] { row["ROWID"].ToString(), string.Format("ROWID:{0}のVALUE(編集)", row["ROWID"]) });
        string refTest = row["ROWID"].ToString();
    }
}
Progress<T>のReportで画面スレッド側に通知されたあとは、Progressのコールバック(UpdateDisplay)と処理スレッドは別スレッドで並列に処理されるので、タイミングが悪いと件の例外が発生するみたい。
今回はDataRowの編集は直ではなく、DataGridViewにバインドされてそっちを編集されている分、追うのにワンステップ必要な状態。
DataTable.Selectで得られるDataRow[]も元のDataTableと同一の実体を参照することを知っている必要がありますね。
何が起こっているの・・・?
単純に禁止されてる使い方するな、という話ですが、勉強かてら一応なにが起こっているのか.NETのソースを見てみることに。
何のインデックス境界外・・・?と思っていましたがどうやら行インデックスの境界外みたい。
.NETのソースは以下より参照可能
Reference Source
※.NET Framework
.NET Platform · GitHub
※.NETのGitHubのページ
◯DataRowのデータ取得/設定処理(ColumnName指定)
※Reference Sourceより引用
public object this[string columnName] {
    get {
        DataColumn column = GetDataColumn(columnName);
        int record = GetDefaultRecord();
        _table.recordManager.VerifyRecord(record, this);
        VerifyValueFromStorage(column, DataRowVersion.Default, column[record]);
        return column[record];
    }
    set {
        DataColumn column = GetDataColumn(columnName);
        this[column] = value;
    }
}
internal int GetDefaultRecord() {
    if (tempRecord != -1)
        return tempRecord;
    if (newRecord != -1) {
        return newRecord;
    }
    // If row has oldRecord - this is deleted row.
    if (oldRecord == -1)
        throw ExceptionBuilder.RowRemovedFromTheTable();
    else
        throw ExceptionBuilder.DeletedRowInaccessible();
}
データの持ち方としてはどうやらDataRow側ではなくDataColumn側に持っているみたいですね。
DataRowとしてはDataColumnの参照情報と、自身のIndex情報をもっていると。
(tempRecord、newRecordはDataRowのフィールド)
◯DataColumnのデータ取得処理
スタックトレースに出ていた「StringStorage」は、DataColumn内部でデータ保持のために使っているクラスの模様。列のデータ型に応じて実体が変わる雰囲気(今回は文字型なのでString?)。
※Reference Sourceより引用
internal object this[int record] {
    get {
        table.recordManager.VerifyRecord(record);
        Debug.Assert(null != _storage, "null storage");
        return _storage.Get(record);
    }
    set {
        try {
            table.recordManager.VerifyRecord(record);
            Debug.Assert(null != _storage, "no storage");
            Debug.Assert(null != value, "setting null, expecting dbnull");
            _storage.Set(record, value);
            Debug.Assert(null != this.table, "storage with no DataTable on column");
        }
        catch (Exception e) {
            ExceptionBuilder.TraceExceptionForCapture(e);
            throw ExceptionBuilder.SetFailed(value, this, DataType, e);
        }
        if (AutoIncrement) {
            if (!_storage.IsNull(record)) {
                this.AutoInc.SetCurrentAndIncrement(_storage.Get(record));
            }
        }
        if (Computed) {// if and only if it is Expression column, we will cache LastChangedColumn, otherwise DO NOT
            DataRow dr = GetDataRow(record);
            if (dr != null) {
                // at initialization time (datatable.NewRow(), we would fill the storage with default value, but at that time we wont have datarow)
                dr.LastChangedColumn = this;
            }
        }
    }
}
上記の5行目がインデックスエラーを吐いてるみたいですね。
Getメソッドは中身を見ると単純に配列にインデックスアクセスしているみたいです。
DataRowのデータ設定処理では内部で保持しているインデックス情報(tempRecord/newRecord)の値の書き換え処理がいくつか行われているようなので、たしかに参照処理と競合するとエラーになりそう…
解決策
スレッド間でDataTableのインスタンスを分ける(ディープコピーする) など
想定されるデータ量によっては、別の解決方法を検討する必要はありそう。
どうしても並列で処理したい場合は、ロック制御をするらしい。
中々実動作では再現させられない類の現象なので、実際の業務だと検査はどこまでやってOKとするかも難しいところですね。
とりあえず検証なら該当部分を抜粋したテストコードを作り大量にループさせることで簡単に再現は可能です。
おわりに
原因自体はイージーミスでも、エラーの内容から追いづらかったり、再現率が100%でないとか環境依存のエラーって嫌ですよね…
しかも働いているとそれが99%は他人が書いたコードなのでストレスがマッハ…

