アジョブジ星通信

進捗が出た頃に更新されるブログ。

I/O待ちのためのTaskとバックグラウンド処理のためのTask

ポエポエ~~

の一連のツイートのまとめでもしておこうかと。

I/O 待ちのための Task

I/O 処理を OS に投げて、完了を待機するのを抽象化した Task を「I/O 待ちのための Task」と呼んでいきます。身近かつ純粋な I/O 待ちを行う Task の例として、 FileStream を FileOptions.Asynchronous オプションをつけて開いたときの ReadAsync が挙げられます。

例として、 ReadAsync を await したときの挙動を見てみましょう。

async Task FileReadSample()
{
    const int bufSize = 10;

    // FileOptions を指定するために省略できないオプション大杉
    using (var fs = new FileStream(
        "test.txt",
        FileMode.Open,
        FileAccess.Read,
        FileShare.ReadWrite,
        bufSize,
        FileOptions.Asynchronous))
    {
        var buf = new byte[bufSize];

        var readBytes = await fs.ReadAsync(buf, 0, bufSize);

        Console.WriteLine(readBytes);
    }
}

「FileReadSample 続き」がどこで実行されるかは、このメソッドがどんな SynchronizationContext で呼び出されたかによりますが、とにかく OS からの非同期処理完了をスレッドプールが管理している I/O 完了ポートで待ち受け、 Task の完了を通知して、継続タスクを実行するという動きをします。

このとき ReadAsync 単体で見ると、 I/O 完了ポート用のスレッドが足りていれば、新しいスレッドを作成することも、スレッドプールのキューに新たにタスクを追加することもありません。つまり「I/O 待ちのための Task」は OS の非同期処理 API をラップし、継続タスクへの通知を行うだけのものといえます。

ただし、 await は SynchronizationContext が特別な場合(UIスレッドなど)を除き、続きをスレッドプールのキューに追加します。これはコールスタックが大きくなるのを防ぐためらしいですが、それが嫌なら ContinueWith で TaskContinuationOptions.ExecuteSynchronously を指定することで、完了通知を受け取ったスレッド上でそのまま続きが実行されます(本当?)。

より詳しく: Async訪ねて3000里

スレッドプールの有効利用

I/O 待ちのための Task は、スレッドプールを有効的に利用するために使います。

スレッドプールは短時間で終わる処理が大量にあるときに効率良くスレッドを利用する手段ですから、 I/O 待ちでスレッドプールのスレッドをブロックしてしまうのはスレッドプールの効率を落としてしまいます。そこで I/O を非同期で行い、完了を待っている間にキューに溜まった他の処理を実行できるように、I/O 待ちのための Task を利用します。

逆に、スレッドプール内で動いている Task でバックグラウンド処理のための Task を await するのは得策ではありません。なぜなら、そのまま続行できる処理をわざわざスレッドプールのキューに入れることになり、切り替えコストが発生するからです。

スレッドプールを有効利用することが求められるのは、web アプリケーションとライブラリです。

まず、 web アプリケーションでは、リクエストごとの処理がスレッドプールのキューに追加されます。ここでスレッドをブロックするようなコードを書いてしまうと、十分な性能を出すために多くのスレッドが必要となってしまいます。スレッドプールに割り当てるスレッドが多くなりすぎると、メモリ使用量がどんどん増えていってしまいますし、スレッドの管理コストも上がってしまいます。このような理由から、I/O 待ちのための Task を利用すると必要なスレッド数を減らせて、リソースを削減できます。

次にライブラリですが、ライブラリはあらゆる .NET アプリケーションから使われるので、 web アプリケーションから使われることを考えると同じことが言えます。

バックグラウンド処理のための Task

UI スレッドのフリーズを防ぐため、または並列処理のために処理を分割したものを「バックグラウンド処理のための Task」と呼んでいきます。バックグラウンド処理のための Task は、 CPU で大量の計算を行うときに Task.Run などを使って作成します。

「~Async」メソッドの罠

「~Async」という名前がついているメソッドは、一般的なライブラリの場合 I/O 待ちのための Task を表していることが多いです。理由は「スレッドプールの有効活用」で示した通りです。

I/O 待ちのための Task を返すメソッドが実際に非同期で処理するのは I/O だけです。だから、 I/O が発生する前に重い計算をしていても、それはメソッドを呼び出したスレッドで実行されているかもしれません。もし、 GUI アプリケーションでそのようなメソッドを UI をフリーズさせずに実行したいならば、

var taskResult = await Task.Run(async () =>
{
    var fooResult = await FooAsync();
    // 後処理
});
// taskResult を UI に反映したり

といったように、バックグラウンド処理のための Task に変換する必要があります。

また、 Task を返すメソッドだからといって、必ず非同期処理が行われるわけではありません。例えば、条件によって I/O が発生しないメソッドは、条件を満たすときに完了済みタスクを返します。 await は Awaiter の IsCompleted が true のとき、すなわち完了済みタスクが返ってきたときには、現在のスレッドのまま処理を続行します。このことを覚えておかないと、「メソッド内の1回目の await で ConfigureAwait(false) を指定したから、2回目からは要らないよね」とか言って死にます。

バックグラウンド処理のための Task の乱用防止

スレッドを切り替えずに済む処理をわざわざ Task にする必要はありません。処理を別スレッドに移譲する必要があるのは GUI アプリケーションくらいでしょう。ライブラリ開発者はこのことを頭に入れて、 I/O に関する部分だけを TaskAsync メソッドとして公開するべきです。そして、 GUI アプリケーション開発者は必要に応じて自分で重い処理を Task で包むようにします。このようにすることで、スレッドプールを有効に利用したコードが書けるのではないかと思います。