15
16

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#) 同期コンテキストと Task, async/await

Last updated at Posted at 2019-12-20

.NET framework 4 で追加されたTaskおよび .NET framework 4.5 で追加されたasync, awaitキーワードによって、C# の非同期処理は非常にに簡単になりました。

Windows Form, WPF, UWP, Unity など各種フレームワーク上の場合、非同期処理はスレッドやコールバック処理など面倒なことはほぼ完璧に隠蔽され、難しいことを意識することなく直感的に記述できるようになりました。

 

しかし、例えば上記のような既存のフレームワークを使わず、独自の GUI フレームワークを作りたいような場合、同期コンテキストについて知っておく必要があります。

(GUIフレームワークを自分で作る人がいるのかという疑問はひとまず置いておいて……)

なお、ここでいうフレームワークとは、開発者がその上でアプリケーションを作成することを目的としているような、基盤となるようなライブラリ群のことを指しています。

GUI とスレッド

C# に限らず、GUI のアプリケーションは基本的に UI の操作はシングルスレッドから行うことを前提とし、UI スレッドは特別視されます。理由は、スレッドセーフにGUI フレームワークを作ることは実装面のコストが大きく、実行速度面から見ても不利であるためです。それなら初めから GUI はシングルスレッド前提の設計をしてしまおう、というのが定石です。

しかし、UI スレッドで時間のかかる処理を実行することは、UI のフリーズを意味します。そこで、重い処理は非同期に実行し、実行結果を UI スレッドにコールバックするのが普通です。

void SyncMethod()
{
    // 同期的に計算処理を行う
    int result = HeavyCalculation();
    Debug.WriteLine(result);
}

async Task AsyncMethod()
{
    // 非同期に計算処理を行う
    // ここは UI スレッド
    // 別スレッドに重い処理を実行させる
    int result = await Task.Factory.StartNew(HeavyCalculation);
    // ここは UI スレッドに戻ってきている
    Debug.WriteLine(result);
}

int HeavyCalculation()
{
    int result = 0;

    // ここで重い計算処理をおこなう

    return result;
}

上のSyncMethod()AsyncMethod()はともに同じresultを得ますが、SyncMethod()は同期的に実行されるため、例えば計算処理に10秒かかる場合、10秒間 UI がフリーズします。

一方、AsyncMethod()は計算処理部分を別スレッドに投げ、結果が出た時に再び UI スレッドに処理が戻ってくるため、計算中に UI がフリーズしません。

 

実際に別スレッドで実行され、実行後にUIスレッドに戻ってきているのか確認してみましょう

static async Task CheckThreadID()
{
    // ここは UI スレッド
    var uiThread = Thread.CurrentThread.ManagedThreadId;
    Debug.WriteLine($"UI Thread : {uiThread}");        // 1
    // 別スレッドに重い処理を実行させる
    await Task.Factory.StartNew(AnotherThread);
    // ここは UI スレッドに戻ってきている
    var callbackThread = Thread.CurrentThread.ManagedThreadId;
    Debug.WriteLine($"Callback Thread : {callbackThread}");        // 1
}

static void AnotherThread()
{
    var anotherThread = Thread.CurrentThread.ManagedThreadId;
    Debug.WriteLine($"Another Thread : {anotherThread}");        // 2
}

このCheckThreadID()実行すると、

UI Thread : 1
Another Thread : 2
Callback Thread : 1

のように出力されます。(スレッド ID の数字は実行環境によって変わります)

非同期処理を呼び出した後、呼び出し元のスレッドに処理が戻ってきていることが確認できます。

コンソールアプリケーションの場合

では、GUI フレームワークを使わない環境、つまりコンソールアプリケーションの場合どうなるのでしょうか。

 

なお、コンソールアプリケーションでTaskを使う場合、非同期処理が終了する前にMainメソッドを抜けてしまうとアプリケーションが終了してしまうため、以下のように書きます。

class Program
{
    static void Main(string[] args) => MainAsync(args).Wait();

    static async Task MainAsync(string[] args)
    {
        // ここに普通にMain関数の中身を書く
        await CheckThreadID();    // スレッドIDを確認する
    }
}

ここに先ほどの非同期スレッドのスレッド ID を確認するプログラムを書いてみた場合、結果は以下のようになります。

UI Thread : 1
Another Thread : 2
Callback Thread : 2

(スレッド ID の数字は実行環境によって変わります)

UI スレッド (この場合Main関数が走っているメインスレッド) の ID が 1なのに対し、非同期処理から戻ってきたときのスレッドは、元のスレッドとは別のスレッドになっています。

これはどうしてなのでしょうか。

同期コンテキスト (SynchronizationContext)

System.Threading名前空間にSynchronizationContextというクラスがあります。これが同期コンテキストです。

実はTaskが非同期処理からどのスレッドに帰ってくるかはこの同期コンテキストが関係しています。

 

System.ComponentModel名前空間にAsyncOperationManagerというstaticクラスがあり、これがSynchronizationContextというstaticプロパティを持っています。

このプロパティは、このプロパティを呼び出したスレッドに紐づいている同期コンテキストを取得することができます。

この同期コンテキストというのは各スレッドごとに1つあり、nullまたはSynchronizationContextクラスのインスタンスです。

(以降、スレッドに紐づいている同期コンテキストがnullまたはSynchronizationContextインスタンスの状態のことを、デフォルトの同期コンテキストと呼ぶことにします)

 

デフォルトの同期コンテキストは、実質何も処理をしないため、Taskによる非同期処理のコールバックは元のスレッドに戻ってくることはありません。

では Windows Form や WPF などのフレームワークではどうして元のスレッドに戻ってくるのでしょうか。

GUI フレームワークの同期コンテキスト

Win Form の場合の同期コンテキストを見てみましょう。

Win Form の場合、System.Windows.Forms名前空間にWindowsFormsSynchronizationContextというクラスが定義されており、このクラスはSynchronizationContextを継承しています。

Win Form の UI スレッドは、デフォルトの同期コンテキストではなくこのWindowsFormsSynchronizationContextのインスタンスを同期コンテキストとして持っています。

この Win Form 用の同期コンテキストによって、UI スレッドからTaskで非同期処理を実行した場合、コールバックがUIスレッドに戻ってきます。

GUI のループと同期コンテキスト

GUI アプリケーションは大きく見れば常にループすることによって成り立っています。例えばゲームなどの場合、1フレームが1回のループと考えていいでしょう。

GUI のフレームワークを使ってアプリケーションを作る場合、多くの場合このループ部分がフレームワークとして隠蔽されているため特にループについて意識することはありませんが、同期コンテキストはこの部分で処理されています。

以下に簡単なループの図を示します。

スクリーンショット 2019-12-20 18.54.47.png

図のユーザー処理の部分でTaskによる非同期処理が実行されたとします。

図の黄色の部分は UI スレッドで実行され、青色の部分が別スレッドで非同期に開始されます。

UI スレッドはawaitの時点でこのメソッドを打ち切って抜け、あとは通常通りに実行が続きます。(GUIのループがUIスレッドで回り続けます)

一方、ワーカースレッドは処理が終わった時点で、元スレッド (UI スレッド) の同期コンテキストにコールバック処理 (await後のこのメソッドの未実行の処理、つまり赤色の部分) をPostし、キューにコールバックが登録されます。

その後、ループ中でキューを見張っている UI スレッドによって、コールバックが実行され、まるでTaskawaitした続きの部分から UI スレッド再開されたように実行されます。

小ネタ

図の右側は、ボタンをクリックした時に非同期処理が走るようなコードですが、awaitによる中断は、その部分でメソッドを打ち切って残りをコールバックとしているだけなので、UI スレッドはそのまま GUI ループに戻っていきます。つまり、非同期処理 (青の部分) がまだ終わっていない状態で、次回以降のループでもう一度ボタンがクリックされた場合、また別の非同期処理を走らせてしまうことになります。(いわゆる再入)

再入を防ぎたい場合は、図のようにisRunningのようなフラグを用意しておくことで再入を防げます。


 
このループは GUI フレームワークによって実装されるため、Postされたコールバック処理をためておくキューは当然フレームワークによって異なります。そのため、各 GUI フレームワークごとに専用の同期コンテキストを実装する必要があります。

とはいえ、基本的にはコールバック処理をポストする場所が異なるだけです。

SynchronizationContext.Postメソッドはvirtualで定義されており、overrideすることで専用の実装をすることができます。

これによって、Win Form には Win Form 用の同期コンテキスト、WPF には WPF 用の同期コンテキストクラスが定義されています。

例えば Win Form の同期コンテキストWindowsFormsSynchronizationContextの実装はこれです。

 

つまり、自分でこの GUI ループ処理を書いて、Taskのコールバックが UI スレッドになるような同期コンテキストクラスを定義すれば、WPF などと同じようにTaskが使えるようになります。

もちろん、ループが開始する前に UI スレッドに対してこの同期コンテキストを紐づけておく必要があります。

// 現在のスレッドの同期コンテキストとして、自作の同期コンテキストを設定
AsyncOperationManager.SetSynchronizationContext(new MySynchronizationContext());
while(true)
{
    // GUI スレッドのループ
}

非 UI スレッドでのawait

前述したように、各種 GUI フレームワークの UI スレッドの同期コンテキストは、そのフレームワーク専用の同期コンテキストとなっていますが、非 UI スレッドの同期コンテキストはデフォルトの同期コンテキストです。

そのため、await後に元のスレッドに戻ってくるのは UI スレッド上の awaitのみです。

ワーカースレッド上でのawait後のコールバックは、元と同じワーカスレッドには戻ってきません。(しかし、UI スレッド以外で元と同じスレッドでないと困るようなことは基本的にないため、問題にはなりませんが)

UI スレッドへの復帰コスト

GUI ループでのキューの監視は、ループごとにキューにコールバックがPostされていないか確認しています。

しかし、GUIのループは基本的に描画速度に依存し、60FPSを前提とする環境では1ループ最短で 16ms かかります。つまり、キューを確認した直後に非同期からコールバックをPostされた場合、UI スレッドがきちんと回っている場合でも、コールバックを拾うまでに最長 16ms 遅れます。

これは、わざわざコールバックで UI スレッド復帰する必要がない場合、不要なコストです。

UI スレッド復帰しないawait

Taskawaitのコールバックを UI スレッドに戻す必要がない場合、Task.ConfigureAwaitメソッドを使うことで、UI スレッドに復帰せずにそのままコールバックを実行できます。

// ここは UI スレッド
Debug.WriteLine("Start");
var task = await Task.Run(() => 
{
    // ここはワーカースレッド
    Debug.WriteLine("Worker Thread");
}).ConfigureAwait(false);    // スレッド復帰しない
// ここは同じワーカースレッド
Debug.WriteLine("Callback");

TaskのデフォルトではConfigureAwaittrueに設定されており、この場合、同期コンテキストによる通知はSynchronizationContext.Postメソッドによって行われます。

Task.ConfigureAwait(false)にした場合、通知はSynchronizationContext.Sendによって行われ、同期的に実行されます。混乱しそうですが、ワーカースレッドから見て同期的ということは、コールバックは同じワーカースレッドで実行されるということです。

つまり、Task中の処理の続きで連続して行われるため、UI スレッド復帰によるコストは発生しません。

ありがちな罠

// ここは UI スレッド
var task1 = await Task.Run(() => 
{
    // ここはワーカースレッド1
}).ConfigureAwait(false);

// ここはワーカースレッド1
var task2 = await Task.Run(() => 
{
    // ここはワーカースレッド2
}).ConfigureAwait(true);

// ***注意***
// ここはワーカースレッド2

上記のコードの場合、一番下の部分はConfigureAwait(true)の後なので UI スレッドのように見えますが、UI スレッドではありません

ならば、task2を呼び出したのはワーカースレッド1なのだから、ここはスレッド復帰してワーカースレッド1なのかというと、それも違います。正解はワーカースレッド2です。

そもそも 非 UI スレッドの同期コンテキストはスレッド復帰を行わないので最後の部分はワーカースレッド2で実行されます。

 

最後の部分で UI スレッドに復帰したい場合はTask.ContinueWithメソッドを使います。

// ここは UI スレッド
var task = await Task.Run(() => 
{
    // ここはワーカースレッド1
}).ContinueWith(() => 
{
    // ここはワーカースレッド1
}).ConfigureAwait(true);

// ここは UI スレッド

説明のためConfigureAwait(true)をつけていますが、もともとtrueなのでなくても動作は同じです。

ライブラリ作成時の注意点

Taskを内部で使って非同期処理を行うようなライブラリを作る場合、awaitを使う場合原則としてConfigureAwait(false)を必ず付ける必要があります。

つけ忘れると、意図しない部分でスレッドが UI スレッドに復帰してしまう上、コンパイルされたライブラリ dll の利用者から見ると、そのライブラリが中でawaitをつかったスレッド復帰をしているかどうかを確認する術がありません。

なので、基本的にライブラリとして他者に使ってもらう前提のコード中のTaskにはConfigureAwait(false)を必ず付ける必要があります。

参考文献

15
16
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
15
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?