LoginSignup
6
6

More than 5 years have passed since last update.

コルーチン用途にawaitを使うためのSynchronizationContext

Last updated at Posted at 2015-12-02

 この記事はC# Advent Calender 2015の2日目です。

 自分はいままで、ゲーム制作の折にイテレーターブロックによるコルーチンを使っていたのですが、戻り値が得られないとか、呼び出し方が面倒だったりの問題があったので、async/awaitを使ったコルーチンについて考えていました。ただ、async/awaitを使って実現しようとすると、プログラムはマルチスレッドな動作になってしまいます。コルーチンの用途にはマルチスレッド機能はオーバースペックであり、それなのにマルチスレッドの管理に責任を持たないといけなくなるため、できればシングルスレッドで動作してほしいところです。

マルチスレッド利用者の責任

 というわけでまずは、どんなときにマルチスレッドの管理が必要になるか、例を出して解説します。今回はゲームの例なのでゲームエンジンAltseedを使いますが、そんなに特別な機能は使っていないのでご安心ください。以下の様なプログラムを書いたとします。

class Program
{
    // 現在のスレッドをラベル付きで出力するメソッド
    static void ShowSyncInfo(string title)
    {
        Console.WriteLine($"{title}: {Thread.CurrentThread.ManagedThreadId}");
    }

    // ビューの更新をする
    static async Task ControlViewAsync()
    {
        ShowSyncInfo("ControlView");

        // 描画オブジェクトを作って
        var obj = new TextureObject2D()
        {
            Texture = Engine.Graphics.CreateTexture2D("Image.png"),
        };
        // Altseedのシステムに追加
        Engine.AddObject2D(obj);
    }

    // モデル側。本当はビューに依存しないように書くべきだけど、今回は簡単のため直接ビューを呼び出してる。
    static async Task RunModelAsync()
    {
        ShowSyncInfo("RunAsync");
        await Task.Delay(1000);
        ShowSyncInfo("Delayed");
        await ControlViewAsync();
        ShowSyncInfo("Controled");
        await Task.Delay(3000);
        ShowSyncInfo("Delayed");
    }

    public static void Main(string[] args)
    {
        ShowSyncInfo("Main");

        // Altseedの初期化
        Engine.Initialize("TaskChannel", 640, 480, new EngineOption() { GraphicsDevice = GraphicsDeviceType.OpenGL });
        Engine.File.AddRootDirectory("Resources");

        // モデル側の処理を実行
        var modelTask = RunModelAsync();

        // DoEventsはAltseedの機能。毎フレーム呼ぶ必要があり、アプリケーションの終了時にfalseを返す
        while(Engine.DoEvents() && !modelTask.IsCompleted)
        {
            // Altseedの更新処理をする。毎フレーム呼ぶ必要がある
            Engine.Update();
        }

        if(modelTask.Exception != null)
        {
            Console.WriteLine(modelTask.Exception);
        }

        // Altseedの終了処理
        Engine.Terminate();

        Console.ReadLine();
    }
}

 ゲームプログラムでは一般的な、メインループのあるプログラムなのですが、このプログラムはモデル側とビュー側に分かれていて、今回の主役はRunModelAsyncControlViewAsyncメソッドです。Altseedは(というかOpenGLに基づくライブラリはそうだと思いますが)基本的にマルチスレッドでは動作しなくて、ビュー側の処理はAltseedの初期化と同じスレッド(= メインスレッド)で行わないといけません。そう考えると上のプログラムは全然ダメですね。ControlViewAsyncメソッドがメインスレッドで動作する保証が何もありません。

実際に実行してみると以下のようになります。

Main: 9
RunAsync: 9
Delayed: 6
ControlView: 6

 ControlViewの直後に例外(AccessViolationException)で落ちてしまいました。ControlViewAsyncメソッドがメインスレッド(ID=9)とは別のスレッド(ID=6)で実行されているのがわかると思います。ちょうどawait Task.Delay(1000)の前後でスレッドが異なっているので、時間のかかる処理をawaitすると、その後の処理(= 継続処理)が別のスレッドに投げられてしまうようです。

 さて、描画関連の処理がメインスレッドで動作するよう保証する処理を書いてもいいのですが、別にモデル側を別スレッドで動かす利点があるわけでもないので、シングルスレッドで動作させたいところです。それでいろいろと調べたのですが、同期コンテキストを自作するのがいい方法みたいです。

同期コンテキストの自作

 同期コンテキスト(SynchronizationContext)の詳細は割愛しますが、普段はこいつが継続処理を適切なスレッドで実行していると考えてもらえばいいと思います。

 SynchronizationContext.Currentプロパティで得られるものが現在のスレッド上での継続処理をハンドルする同期コンテキストです。SynchronizationContext.SetSynchronizationContextメソッドを呼び出して現在の同期コンテキストを設定することができます。上のプログラム上、たとえばMainメソッドの先頭で試してみると、SynchronizationContext.Currentnullです。初期値はnullのようですね。こいつがnullのときの動作についての資料はあまり見つけられませんでしたが、とりあえずスレッドプールに継続処理が投げられる感じの動作をしているようです。

 継続処理を任意のスレッドで実行する同期コンテキストが作れれば、シングルスレッド動作を保証できそうです。SynchronizationContextを継承したクラスを作ってみましょう。ひとまず次のように書きます。

class MySynchronizationContext : SynchronizationContext
{
    public override void Post(SendOrPostCallback d, object state)
    {
        // 継続処理を1つの特定のスレッドで実行できるようにする
    }
}

 Postメソッドは、この同期コンテキストが設定されているスレッドで継続処理が発生したときに呼ばれます。SendOrPostCallback型の引数に継続処理の入ったデリゲートが渡されます。それで、こうして渡されるデリゲートがすべて単一スレッド上で実行されればいいわけなのですが、どうやって実現しましょうか?Threadクラス使う?せっかく普段から.NETが生Thread触らなくてもよいように計らってくれてるので、そういうのはあんまり……(自分がThreadの扱い知らないですし)

 それで考えたのですが、今回はゲームプログラムでメインループがあるので、やってきた継続処理をメインスレッドから定期的に実行できるようにしてみましょう。以下の様な感じの実装ができます。

using TaskItem = System.Tuple<SendOrPostCallback, object>;

class MySynchronizationContext : SynchronizationContext
{
    // スレッドセーフなキュー。Postメソッドはどのスレッドから呼ばれるかわからないため。
    ConcurrentQueue<TaskItem> continuations = new ConcurrentQueue<TaskItem>();

    public override void Post(SendOrPostCallback d, object state)
    {
        continuations.Enqueue(new TaskItem(d, state));
    }

    public void Update()
    {
        TaskItem cont;
        while(continuations.TryDequeue(out cont))
        {
            cont.Item1(cont.Item2);
        }
    }
}

 これをSetSynchronizationContextメソッドで設定して、定期的にUpdateメソッドを呼び出せば、Postメソッドに渡されてきた継続処理はUpdateメソッド内で処理されます。つまり、継続処理はUpdateを呼び出したスレッドで実行されるわけです。これによってUpdateメソッドさえメインスレッドで呼ばれていれば継続処理はすべてメインスレッドで動作するので、メインループ内でUpdateする限りは問題ないはずです。

 というわけで、これを使うようにしたMainメソッドは次のようになります。

public static void Main(string[] args)
{
    // 自作の同期コンテキストを生成して、Currentに設定
    var sync = new MySynchronizationContext();
    SynchronizationContext.SetSynchronizationContext(sync);

    ShowSyncInfo("Main");
    Engine.Initialize("TaskChannel", 640, 480, new EngineOption() { GraphicsDevice = GraphicsDeviceType.OpenGL });
    Engine.File.AddRootDirectory("Resources");

    var modelTask = RunModelAsync();

    while(Engine.DoEvents() && !modelTask.IsCompleted)
    {
        Engine.Update();
        sync.Update();      // 自作の同期コンテキストをUpdate
    }

    if(modelTask.Exception != null)
    {
        Console.WriteLine(modelTask.Exception);
    }

    Engine.Terminate();

    Console.ReadLine();
}

実行結果は以下のようになります。

Main: 8
RunAsync: 8
Delayed: 8
ControlView: 8
Controled: 8
Delayed: 8

 すべての処理がメインスレッドで行われているのがわかると思います。やったぜ。今回はゲームループありきの解決策でしたが、他のプラットフォームでも定期的にUpdateする手段があれば、似たような方針がとれるかと思います。

 他にもawaitでコルーチンをする上での障害はあるかもしれませんが、逞しくやっていこうと思います。

 C# Advent Calender 2015、明日はtanaka_733さんの担当です。お楽しみに!

6
6
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
6
6