同期コンテキストを変えずにTaskを使う

Taskとawaitの衝突で起きるデッドロックの回避策として、awaitを含んだ非同期メソッドをTaskでくるむことを挙げたけど、非同期メソッド内にUIコントロールに対する処理がある場合は、Task.Runでくるむのはうまくいかない。たとえば、処理の前後にフォームのlabel1の表示を変えるとして…

async Task someAsyncMethod()
{
    label1.Text = "処理開始";
    
    await someAsyncHeavyMethod(); //何か重たい処理の非同期メソッド
    
    label1.Text = "処理完了";
}

…みたいなとき、

Task.Run(() =>
{
    return someAsyncMethod();
});

とすると

例外がスローされました: ‘System.InvalidOperationException’ (System.Windows.Forms.dll の中)

追加情報:有効ではないスレッド間の操作: コントロールが作成されたスレッド以外のスレッドからコントロール ‘label1’ がアクセスされました。

というエラーになる。これは、UIコントロールは自身のUIスレッドからしかアクセスできないのに、Task.Run内の処理はスレッドプールのスレッドで行われるから。

こういう場合に使えるのがTask.Factory.StartNewで、TaskScheduler引数に現在の同期コンテキストのTaskSchedulerを指定すると、UIスレッドの同期コンテキスト上でTaskを実行できる。(普通はスレッドプール上の同期コンテキストになる。)具体的には、引数にTaskScheduler.FromCurrentSynchronizationContext()を入れる。

Task.Factory.StartNew(() =>
{
    return someAsyncMethod();
},
CancellationToken.None,
TaskCreationOptions.DenyChildAttach, //デフォルトはNoneだがTask.Runと同じにした
TaskScheduler.FromCurrentSynchronizationContext()
);

引数が多くなってしまったが、TaskSchedulerを指定できるオーバーロードがこれ(CancellationToken、TaskCreationOptions、TaskSchedulerの3つとも指定)しか用意されてないのでしかたがない。自分に不必要な引数をわざわざ書くのがいやだったら、TaskSchedulerを指定したTaskFactoryオブジェクトを作って

TaskFactory factory = 
    new TaskFactory(TaskScheduler.FromCurrentSynchronizationContext());

factory.StartNew(() =>
{
    return someAsyncMethod();
});

という風にすることもできる。(自分はこの形で使ってる。)

ともかくこうしてUIスレッド上から現在の同期コンテキストを指定してTaskを実行すると、awaitが待つTask以外の処理はUIスレッド上で行われるので、label1の更新も問題なく実行される。

あと、ちなみに、Task.RunやTask.Factory.StartNewのラムダ式内のreturnがないとsomeAsyncMethodの完了を待てなくなるので気をつける。(Task.RunやTask.Factory.StartNewが作るTaskはawaitの手前までで完了になってしまう。Task内で非同期メソッドを実行して、その完了を待つ場合は、その非同期メソッドからもTaskを返して、入れ子のTaskにしないといけない。)