1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[C#] ラムダ式内でラムダ式外のループカウンタ変数を使用すると危険

Last updated at Posted at 2021-01-28

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クラスというものがあるので、そちらを利用した方がよいでしょう。

Parallel.For使用版サンプル
    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();
実行結果(Parallel.For使用)
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させてみましょう。

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();
実行結果(await使用)
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秒掛かっています。

#参考URL
ループをParallelクラスで並列処理にするには?[C#/VB]
データとタスクの並列化における注意点

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?