ネコのために鐘は鳴る

寺院に住み着くパソコ〇好き

(C#) 同期コンテキストと Task, async/await

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

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

f:id:ikorin2:20191220185655p:plain

図のユーザー処理の部分で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の実装はこれです。

referencesource.microsoft.com

 

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

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

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

その他

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

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

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

<追記>

この続きの記事書きました

ikorin2.hatenablog.jp

参考文献

.NET クラスライブラリ探訪-040 (System.Windows.Forms.WindowsFormsSynchronizationContext)(SynchronizationContext, 同期コンテキスト, Send, Post) - いろいろ備忘録日記

async/await と SynchronizationContext (1) - 鷲ノ巣