System.InvalidOperationException:コレクションが変更されました。列挙操作が実行されない可能性があります

コレクションが foreach ループ (列挙型) でループされている間にアイテムを追加/削除しようとすると、次の例外が発生します:

このエラーは、次の 2 つのシナリオで発生する可能性があります:

  • foreach ループでコレクションをループし、同じループでコレクションを変更 (追加/削除) しています。
  • 競合状態があります。別のスレッドがコレクションを変更している間に、あるスレッドでコレクションをループしています。

この問題の解決策は、現在のシナリオによって異なります。この記事では、これらのシナリオと考えられる解決策について説明します。

シナリオ 1 – foreach ループでコレクションが変更される

このシナリオは非常に一般的です。通常、開発者はコレクションからアイテムを削除しようとすると、次のようにこれに遭遇します:

foreach (var movie in movieCollection)
{
	if (movie.Contains(removeMovie))
	{
		movieCollection.Remove(removeMovie);
	}
}
Code language: C# (cs)

これにより、実行時に InvalidOperationException がスローされます。私の意見では、コンパイラがこの問題をキャッチし、代わりにコンパイル時エラーを表示した方がよいでしょう.

解決策は、foreach ループでコレクションを変更していないことを確認することです。

解決策 1 – アイテムを削除する場合は、RemoveAll() を使用します

項目を削除してコレクションを変更する場合、最も簡単な解決策は、次のように代わりに LINQ RemoveAll() を使用することです。

movieCollection.RemoveAll(movie => movie.Contains(removeMovie));
Code language: C# (cs)

これにより、条件を満たすアイテムが削除され、実行時例外がスローされなくなります。

解決策 2 – アイテムを追加する場合は、それらを temp に入れて AddRange() を使用します

foreach ループでループしている間は項目を追加できないため、最も簡単な解決策は、追加する項目のリストを一時リストに保存してから、次のように AddRange() を使用することです:

var itemsToAdd = new List<string>();

foreach (var movie in movieCollection)
{
	if (movie.Contains(duplicateMovie))
	{
		itemsToAdd.Add(duplicateMovie);
	}
}

movieCollection.AddRange(itemsToAdd);
Code language: C# (cs)

解決策 3 – 通常の for ループと逆方向のループを使用する

foreach ループを使用する代わりに、通常の for ループを使用できます。ループ内でコレクションを変更する場合は、逆方向にループすることをお勧めします。以下は、逆方向にループして項目を追加する例です:

for (int i = movieCollection.Count - 1; i >= 0; i--)
{
	if (movieCollection[i].Contains(duplicateMovie))
	{
		movieCollection.Add(duplicateMovie);
	}
}
Code language: C# (cs)

順方向ループ中に同じロジックを試すと、実際には無限ループになります。

シナリオ 2 – 1 つのスレッドがコレクションを変更している間に、別のスレッドがそのコレクションをループしています

実行時例外が発生し、foreach ループがコレクションを変更しておらず、コードがマルチスレッド化されていることがわかっている場合、競合状態が発生している可能性が高くなります。

次のコードは、このシナリオの例を示しています:

//Resource shared between multiple threads (recipe for a race condition)
private List<string> movieCollection = new List<string>();

//Called by thread 1
void Post(string movie)
{
	movieCollection.Add(movie);
}

//Called by thread 2
void GetAll()
{
        //Race condition results in InvalidOperationException (can't modify collection while enumerating) here
	foreach (var movie in movieCollection)
	{
		Console.WriteLine(movie);
	}
}
Code language: C# (cs)

このコードはスレッドセーフではありません。 1 つのスレッドがコレクションを変更している間に、別のスレッドがコレクションをループしています。ループしているスレッドは InvalidOperationException に遭遇します。これは競合状態であるため、毎回エラーが発生するわけではありません。つまり、このバグが製品化される可能性があります。マルチスレッドのバグはそのように卑劣です。

マルチスレッドを使用するときはいつでも、共有リソースへのアクセスを制御する必要があります。これを行う 1 つの方法は、ロックを使用することです。このシナリオでそれを行うより良い方法は、並行コレクションを使用することです。

解決策 – 並行コレクションを使用する

movieCollection フィールドを ConcurrentBag に切り替えると、競合状態がなくなります。

using System.Collections.Concurrent;

private ConcurrentBag<string> movieCollection = new ConcurrentBag<string>();

//Called by thread 1
void Post(string movie)
{
	movieCollection.Add(movie);
}

//Called by thread 2
void GetAll()
{
	foreach (var movie in movieCollection)
	{
		Console.WriteLine(movie);
	}
}
Code language: C# (cs)

ToList() は問題を解決せず、別の例外が発生します

競合状態がある場合、ToList() を使用しても問題は解決しません。実際、競合状態は依然として存在し、別の例外になるだけです。

ToList() を使用して元の競合状態を修正しようとする例を次に示します。

void GetAll()
{
	var snapshot = movieCollection.ToList();
	foreach (var movie in snapshot)
	{
		Console.WriteLine(movie);
	}
}
Code language: C# (cs)

最終的に、これは次の例外に遭遇します:

これは競合状態が原因です。 1 つのスレッドが ToList() を呼び出し、別のスレッドがリストを変更しています。 ToList() が内部で行っていることは何でも、スレッドセーフではありません。

ToList() を使用しないでください。代わりに並行コレクションを使用してください。