C#でListの内の要素を削除したときに時々例外が発生してアプリが落ちる件の続きです。
非同期処理をしているときにforeach文を実行するときは注意が必要
foreach文のなかでリストの要素の追加と削除をすることはできません。「System.InvalidOperationException: コレクションが変更されました。列挙操作は実行されない可能性があります」という例外が発生します。
気をつけないといけないのは非同期処理をしているときです。リストの要素の加除とforeach文が別スレッドでおこなわれる場合は例外が発生する危険性があります。
以下のコードはコンストラクタ内で非同期処理でループを回してMyClassオブジェクトのNumberプロパティの総和を計算しています。ボタンをクリックすると新しいオブジェクトをリストに追加させようとしているのですが、例外が発生してしまいます。これはforeach文が実行されているときにリストの要素が変更されたためです。foreach文で重い処理を実行している場合、リストに新しい要素を追加するタイミングとかぶるため、ほぼ確実に例外が発生します。一方でThread.Sleep(10);がないと例外が発生する可能性はほとんどありません(非同期処理をしている以上、100%安全というわけではない)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
public class MyClass { public int Number { get; } public MyClass(int number) { Number = number; } } public partial class Form1 : Form { List<MyClass> list = new List<MyClass>(); public Form1() { InitializeComponent(); for (int i = 0; i < 10; i++) list.Add(new MyClass(i)); Loop(); } void Loop() { Task.Run(() => { string errorMessage = ""; while (true) { try { int ret = 0; foreach (MyClass obj in list) // ① { ret += obj.Number; Thread.Sleep(10); } } catch (Exception ex) { errorMessage = ex.ToString(); break; } } MessageBox.Show(errorMessage, "例外発生"); }); } private void button1_Click(object sender, EventArgs e) { list.Add(new MyClass(1)); } } |
43行目で例外発生とありますが、それが上記コード内の①の部分です。
1 2 3 4 5 |
System.InvalidOperationException: コレクションが変更されました。列挙操作は実行されない可能性があります。 場所 System.ThrowHelper.ThrowInvalidOperationException(ExceptionResource resource) 場所 System.Collections.Generic.List`1.Enumerator.MoveNextRare() 場所 System.Collections.Generic.List`1.Enumerator.MoveNext() 場所 Form1.<.ctor>b__2_0() 場所 C:\(省略)\Form1.cs:行 43 |
リストをコピーしてからforeach文を実行すれば例外は回避できる?
リストをコピーしてコピーしたものでforeach文を実行すれば、foreach文が実行されているときにmyClassesの要素が変更されても問題はおきません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public partial class Form1 : Form { void Loop() { Task.Run(() => { while (true) { try { int ret = 0; List<MyClass> copy = list.ToList(); // リストのコピーを作成 foreach (MyClass obj in copy) { ret += obj.Number; Thread.Sleep(10); } } catch (Exception ex) { } } }); } } |
ただし、非同期処理 foreach文での例外発生を回避するのであればリストのコピーをつくればよいとは断言できません。リストのコピーを作成している最中に要素の加除がおこなわれたらどうなるでしょうか?これが非同期処理の怖いところです。
以下のコードはコンストラクタ内でリストのなかに10個の要素を格納して、以降は要素数が偶数であれば新しい要素を先頭に追加、奇数であれば先頭の要素を削除するという処理を無限ループで繰り返しています。常にリストの要素は変化しているわけです。このような状態でリストをコピーしてforeach文を実行してみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
public partial class Form1 : Form { List<MyClass> list = new List<MyClass>(); public Form1() { InitializeComponent(); for (int i = 0; i < 10; i++) list.Add(new MyClass(i)); Loop2(); } void Loop2() { Task.Run(() => { while (true) { if (list.Count % 2 == 0) list.Insert(0, new MyClass(1)); else list.RemoveAt(0); } }); } private void button1_Click(object sender, EventArgs e) { int ret = 0; List<MyClass> copy = list.ToList(); // ここで例外が発生する foreach (MyClass obj in copy) ret += obj.Number; } } |
問題なく処理ができる場合もあるのですが、高確率で例外が発生します。そして例外が発生するのはlist.ToList();の部分です。
1 2 3 4 5 6 7 |
System.ArgumentException: ターゲット配列の長さが足りません。destIndex、長さ、および配列の最小値を確認してください。 場所 System.Array.Copy(Array sourceArray, Int32 sourceIndex, Array destinationArray, Int32 destinationIndex, Int32 length, Boolean reliable) 場所 System.Collections.Generic.List`1.CopyTo(T[] array, Int32 arrayIndex) 場所 System.Collections.Generic.List`1..ctor(IEnumerable`1 collection) 場所 System.Linq.Enumerable.ToList[TSource](IEnumerable`1 source) 場所 Form1.button1_Click(Object sender, EventArgs e) 場所 C:\(省略)\Form1.cs:行 88 (以下、省略) |
どうもlist.ToList()を実行中にリストの要素の加除がおこなわれたのが原因のようです。
排他ロックで解決
ではどうすればよいのか?
マルチスレッドプログラムにおいて、 単一のリソースに対して、 複数の処理が同時期に実行されると、 破綻をきたす部分をクリティカルセクションを呼びます。クリティカルセクションでは排他制御をおこなうなどして処理途中の状態にあるときには他からアクセスできないようにしなければなりません。
そのひとつの方法がlock文です。上記で実験したlist.ToList()は不要です。lockされているときにおこなわれようとしたもう一つの処理は前の処理が完了してからおこなわれます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
public partial class Form1 : Form { void Loop2() { Task.Run(() => { while (true) { lock (list) { if (list.Count % 2 == 0) list.Insert(0, new MyClass(1)); else list.RemoveAt(0); } } }); } private void button1_Click(object sender, EventArgs e) { lock (list) { int ret = 0; foreach (MyClass obj in list) ret += obj.Number; Text = ret.ToString(); } } } |