[.NET]複数のTaskを一度に開始しようとすると遅延が発生する

10個のスレッドを同時に走らせて並列処理したいとする。

まさか new Thread(…).Start() なんてやらないですよね。
LINQでasync/awaitだーってのもアリかもしれませんが、
素直にTaskを10個作ろうとすると↓こんな感じ。

for (int i = 0; i < 10; i++)
{
    Task.Run(() =>
    {
        Thread.Sleep(4000);  //4秒くらいかかる処理
    });
}

もちろん10個ほぼ同時に走ってくれることを期待する。
確認してみる。

var watch = Stopwatch.StartNew();

for (int i = 0; i < 10; i++)
{
    Task.Run(() =>
    {
        var threadId = Thread.CurrentThread.ManagedThreadId;
        Console.WriteLine("{0:D2}: {1}ms後に開始",
                          threadId, watch.ElapsedMilliseconds);

        Thread.Sleep(4000);  //4秒くらいかかる処理
    });
}
>11: 32ms後に開始
>12: 32ms後に開始
>06: 33ms後に開始
>13: 32ms後に開始
>14: 661ms後に開始
>15: 1661ms後に開始
>16: 2660ms後に開始
>17: 3661ms後に開始
>11: 4035ms後に開始
>13: 4035ms後に開始

 

4個目までは同時に始まっているが、
それ以降はスレッド開始がちょっとずつ遅延していることがわかる。

これ、決してTaskの実装バグではなく、Taskが内部的に使用している
ThreadPoolの仕様なのです。

MSDNにはわかりやすい説明を見つけられなかった。
プログラミングMS .NET FRAMEWORK 第2版』によると、
CLRのスレッドプールは、スレッドの数を無駄に増やしてしまわないよう、
新しいスレッドが必要になった場合は、500ミリ秒に1つの割合を
超えないペースでスレッドを生成する設計だとのこと。

上の実行結果を見ても、たしかに500ms~1000msくらいずつ遅延してますね。
9番目以降で遅延が起きなくなっているのは、
4秒たって最初のスレッドが実行終了したため。
スレッドIDを見ても、11と13が再利用されているのがわかる。
 

で、じゃあこの遅延をなくすにはどうするかという話ですが、
ThreadPool.SetMinThreadsメソッドで、スレッドを何個まで
遅延なしで生成させるかという値を設定することができる。

既定値はCPUの数と同数らしい。
既定値を取得してみる。

int workMin;
int ioMin;
ThreadPool.GetMinThreads(out workMin, out ioMin);

Console.WriteLine("MinThreads work={0}, i/o={1}", workMin, ioMin);
>MinThreads work=4, i/o=4

 

上の実行結果でも、4個までは遅延なしで開始されているので
取得した値と一致しますね。

outパラメータ2つのうち、2番目は非同期I/Oの完了通知用スレッド数だそうだ。
1つ目がワーカースレッド数なので、これを必要なだけ大きい値に変える。

//I/Oスレッド数は取得した値のまま再設定
ThreadPool.SetMinThreads(20, ioMin);

再実行。

>11: 27ms後に開始
>15: 30ms後に開始
>17: 30ms後に開始
>16: 30ms後に開始
>14: 27ms後に開始
>20: 33ms後に開始
>13: 32ms後に開始
>21: 39ms後に開始
>12: 27ms後に開始
>18: 41ms後に開始

 

最近のC#の言語思想としては、
できるだけスレッドとか意識せずに非同期処理を実装できるように
Taskやらasync/awaitやらで隠ぺい工作してる感じなんだけども
隠ぺいして見えない部分で勝手に工夫されると
どう動くのかわかんなくて、それはそれで困るもんなんだよね。
難しいところだ。