新しもの好きプログラマの耳より情報ブログ

仕事でもあるプログラミングについて役に立ちそうな情報を発信していこうというブログです。役に立たなそうな情報はfacebookで。

TaskFactory.StartNewをWaitする場合に、うっかりTask<Task>を待つとすぐ通過してしまうので注意(気付きづらいうっかりミスの紹介)

概要

タイトル通りうっかりミスでTaskの完了を待てていなかったという話なんですが、見た目に分かりづらくかなり恐ろしいなと思ったので、紹介します。皆さん同じ落とし穴に気をつけてください。

最初に結論まとめ

次のコード、じつはawait task;でTaskの完了を待てていません。

async Task ThreadFunc(){
  await Task.Delay(TimeSpan.FromSeconds(10));
}

var task = Task.Factory.StartNew(ThreadFunc);
await task;

完了を待つには、Unwrap()メソッドを入れて、こうする必要があります。

var task = Task.Factory.StartNew(ThreadFunc).Unwrap();

説明

次のコード、一見何の変哲も無いTask完了待ちのコードです。※1のスレッドが完了すると、Waitが終わって※2が実行されます。・・・という風に、なると思いますか?

async Task ThreadFunc(){
  await Task.Delay(TimeSpan.FromSeconds(10));
  //※1
}

var task = Task.Factory.StartNew(ThreadFunc);

await task;
//※2

これがぱっと見では気付きづらい罠にはまっていまして、※1のスレッド完了を待たずに、すぐ※2が実行されてしまいます。コーディングミスなのですが、型が上手いこと噛み合ってしまってコンパイルエラーにもなりません。

ThreadFuncがTask func()ではなく、次のようにvoid func()の場合は、問題ありません。

void ThreadFunc(){
  Thread.Sleep(TimeSpan.FromSeconds(10));
}

var task = Task.Factory.StartNew(ThreadFunc);

await task;

何が悪いのか、見えてきたでしょうか?

コード上は全く同じに見える、Task.Factory.StartNewのオーバーロードが曲者です。

StartNewメソッドにFunc<Task>を渡すと、戻り値にTask<Task>が返ってきます。これは、「ThreadFuncがTaskを返してくるのを待つTask」です。(メソッド定義: https://learn.microsoft.com/ja-jp/dotnet/api/system.threading.tasks.taskfactory.startnew?view=net-9.0#system-threading-tasks-taskfactory-startnew-1(system-func((-0)))

このtaskをawaitすると・・・本来待ちたかったTaskが返ってきた時点で、すぐに処理を抜けてしまいます。上のコードだと、await task;で、実はTask task2 = await task;のように戻り値が返ってきています。これを捨てているので、狙ったとおりに処理を待てずに先に抜けてしまうというわけです。

このような場合、Task<Task>の中身をちゃんと待てるように、一工夫してやる必要があります。次のように、Unwrap()すればOKです。

async Task ThreadFunc(){
  await Task.Delay(TimeSpan.FromSeconds(10));
  //※1
}

var task = Task.Factory.StartNew(ThreadFunc).Unwrap();

await task;
//※2

このようにすると、await task;が待つTaskは、ちゃんとThreadFuncが返してきたTaskとなります。これで解決です!

ちなみにこの問題、Task.Runメソッドを使う場合は起きません。このケースに対応するオーバーロードがあるようです。(メソッド定義: https://learn.microsoft.com/ja-jp/dotnet/api/system.threading.tasks.task.run?view=net-9.0#system-threading-tasks-task-run(system-func((system-threading-tasks-task)))

TaskCreationOptionsを使いたい、スケジューラを分けたいなど、TaskFactory.StartNewを使う必要がある場合に注意すれば大丈夫です。

まとめ

ぱっと見では同じようなコードでTaskの完了を待っているはずなのに、オーバーロードの罠にはまって完了を待てない、というなかなか厄介なバグを紹介しました。知っていればメソッド呼び出しを1つ加えて解決ですが、コンパイルエラーにもならないし、なかなか気付きづらい罠だと思います。同じようなコードを書いた時は、罠にはまらないようにこの話を思い出してみてください。