Teratailで同じような原因の質問が別々の方からあったのでメモ代わりに。
C#のTask配列をforでセットしようとするとエラーになる
基本的な質問ですが、Taskの結果がバラバラになります
#一見普通に動きそうなコードだけど…
さて、まずは下記のサンプルコードを見てください。
Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [for Start]");
for (var i = 1; i <= 5; i++)
{
Task.Run(async () =>
{
await Task.Delay(1000);
Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Task End({i})]");
});
}
Console.ReadLine();
forループで実行されたTask内で、1秒待ってループカウンタ i をコンソール出力しているだけのコードのように見えますね。これを見て、皆さんはどういう実行結果を想像しますか?
Task End(1~5)が出力されるだけだろ?と思う人は結構いるかもしれません。しかし、実際は…?
10:43:01.801 [Start for]
10:43:02.928 [Task End(6)]
10:43:02.928 [Task End(6)]
10:43:02.928 [Task End(6)]
10:43:02.928 [Task End(6)]
10:43:02.928 [Task End(6)]
なんてこった!全部Task End(6)になっています。一体どういう事なんだ…
#何故ループカウンタを直接使用してはいけないのか
先程のサンプルコードで出力結果が全部Task End(6)になった理由は単純で、非同期Taskが実行されて、実際にコンソール出力するタイミングの時点で、ループカウンタは既に6までカウントアップされてループを終えているからです。ラムダ式外のループカウンタを直接使用した場合、いつの時点でのループカウンタの値が使用されているか判らないのです。
ちなみに、VB.NETで同等の処理を行った場合、ビルド時にwarningが発生します。C#でも出してくれてもいいのでは?という気はしないでもないです。
(BC42324より抜粋)
warning BC42324: ラムダ式内で繰り返し変数を使用すると、予期しない結果が発生する可能性があります。代わりに、ループ内にローカル変数を作成して繰り返し変数の値を割り当ててください。
今度はforループ内でローカル変数countを宣言してループカウンタを割り当て、その値をコンソール出力するようにしてみましょう。
Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Start for]");
for (var i = 1; i <= 5; i++)
{
var count = i;
Task.Run(async () =>
{
await Task.Delay(1000);
Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Task End({count})]");
});
}
Console.ReadLine();
11:17:34.883 [Start for]
11:17:36.028 [Task End(1)]
11:17:36.028 [Task End(4)]
11:17:36.028 [Task End(5)]
11:17:36.028 [Task End(3)]
11:17:36.028 [Task End(2)]
1~5がバラバラに出力されましたね。forループ内でローカル変数を宣言する事で、ループ毎に変数が確保され、ループカウンタの値が保持されています。ちなみに、実際にこの手の並列処理を行う場合は、Parallelクラスというものがあるので、そちらを利用した方がよいでしょう。
Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Start Parallel.For]");
Parallel.For(1, 6, async (i) =>
{
await Task.Delay(1000);
Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Task End({i})]");
});
Console.ReadLine();
11:30:34.820 [Start Parallel.For]
11:30:35.966 [Task End(2)]
11:30:35.966 [Task End(1)]
11:30:35.966 [Task End(4)]
11:30:35.966 [Task End(3)]
11:30:35.967 [Task End(5)]
#Task.Runをawaitすれば良いのでは?
そういう感じの回答もありましたが、単なる同期処理になりTask使う意味が無くなりますので、それならむしろTaskを使用しない方が良いでしょう。さて、実際にawaitさせてみましょう。
Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Start for]");
for (var i = 1; i <= 5; i++)
{
await Task.Run(async () =>
{
await Task.Delay(1000);
Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Task End({i})]");
});
}
Console.ReadLine();
11:57:27.309 [Start for]
11:57:28.394 [Task End(1)]
11:57:29.403 [Task End(2)]
11:57:30.420 [Task End(3)]
11:57:31.427 [Task End(4)]
11:57:32.437 [Task End(5)]
Task End(1~5)が出力されていますが、普通に非同期で実行すれば1秒で終わる処理が順次同期実行しているので、全体で5秒掛かっています。