xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

非同期メソッド入門 (9) - Awaitableパターンの自前実装

前回awaitするためのコンパイラ要件について確認しました。コンパイラは独自型をawaitするための汎用性を残した展開を行います。ここでは実際にAwaitableな型を自前実装してみます。

Awaitableパターンの実装

Awaitable型

まずawait演算子に渡すAwaitable型を作成します。ここでは、外部からデリゲートとして与えられたタスクの非同期実行や、そのタスクが完了しているかどうかの取得、完了時にコールバックしてほしい処理の登録などを実装します。以下にそのサンプルを示します。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace MyAwaitable
{
    class Awaitable<T>
    {
        private T result                        = default(T);
        private bool isCompleted                = false;
        private Action onCompleted              = null;
        private readonly object resultSync      = new object();
        private readonly object isCompletedSync = new object();
        private readonly object onCompletedSync = new object();

        /// <summary>
        /// コンストラクタ
        /// </summary>
        private Awaitable()
        {}

        /// <summary>
        /// 結果を取得します。
        /// </summary>
        public T Result
        {
            get         { lock (this.resultSync) return this.result;  }
            private set { lock (this.resultSync) this.result = value; }
        }

        /// <summary>
        /// 完了したかどうかを取得します。
        /// </summary>
        public bool IsCompleted
        {
            get         { lock (this.isCompletedSync) return this.isCompleted;  }
            private set { lock (this.isCompletedSync) this.isCompleted = value; }
        }

        /// <summary>
        /// 指定の処理を非同期に実行します。
        /// </summary>
        /// <param name="action">非同期に動かしたい処理</param>
        private void DoWorkAsync(Func<T> action)
        {
            ThreadPool.QueueUserWorkItem(_ =>
            {
                this.Result      = action();
                this.IsCompleted = true;
                lock (this.onCompletedSync)
                    if (this.onCompleted != null)
                        this.onCompleted(); //--- 完了前に登録されていたら実行
            });
        }

        /// <summary>
        /// 完了時に実行する処理を登録します。
        /// </summary>
        /// <param name="action">完了時に実行してほしい処理</param>
        public void ContinueWith(Action action)
        {
            lock (this.onCompletedSync)
            {
                this.onCompleted = action;
                if (this.IsCompleted && this.onCompleted != null)
                    this.onCompleted(); //--- すでに完了してたら即時実行
            }
        }

        /// <summary>
        /// 対応するAwaiterを取得します。
        /// </summary>
        /// <returns>Awaiter</returns>
        public Awaiter<T> GetAwaiter()
        {
            return new Awaiter<T>(this);
        }

        /// <summary>
        /// 非同期処理の実行をラップします。
        /// </summary>
        /// <param name="action">非同期に動かしたい処理</param>
        /// <returns>await可能なオブジェクト</returns>
        public static Awaitable<T> Run(Func<T> action)
        {
            var awaitable = new Awaitable<T>();
            awaitable.DoWorkAsync(action);
            return awaitable;
        }
    }
}

awaitするためには、Awaitable型に対応するAwaiter型を返すGetAwaiterメソッドの実装が必要です。強調表示した箇所が今回の重要なポイントです。インスタンスメソッドで実装していますが、拡張メソッドでもOKです。

Awaiter型

Awaiterは、Awaitable型による非同期処理の完了待機を補助するためオブジェクトです。前回の示したコンパイラ要件に沿って実装しています。OnCompletedで登録される継続処理は、呼び出しスレッド上で動作するようにしています。

using System;
using System.Runtime.CompilerServices;
using System.Threading;

namespace MyAwaitable
{
    class Awaiter<T> : INotifyCompletion
    {
        private readonly Awaitable<T> target = null;

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="target">await対象</param>
        public Awaiter(Awaitable<T> target)
        {
            this.target = target;
        }

        /// <summary>
        /// 完了したかどうかを取得します。
        /// </summary>
        public bool IsCompleted
        {
            get{ return this.target.IsCompleted; }
        }

        /// <summary>
        /// 継続処理を登録します。
        /// </summary>
        /// <param name="continuation">継続処理</param>
        /// <remarks>継続処理は呼び出し元スレッドに同期的に実行するようスケジューリングします。</remarks>
        public void OnCompleted(Action continuation)
        {
            //--- 実行スレッドの同期コンテキストを取得して
            var context = SynchronizationContext.Current;
            this.target.ContinueWith(() =>
            {
                //--- クロージャを使って完了時のコールバックで利用
                context.Post(_ => continuation(), null);
            });
        }

        /// <summary>
        /// 結果を取得します。
        /// </summary>
        /// <returns>処理結果としての値</returns>
        public T GetResult()
        {
            return this.target.Result;
        }
    }
}

利用してみる

ここまで作成してきた独自Awaitable/Awaiter型を実際に利用してみます。特に難しいことはなく、ボタンを押したら2秒後にランダムな値を返してそれを表示しているだけです。

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;

namespace MyAwaitable
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            this.InitializeComponent();
        }

        private async void Button_Click(object sender, RoutedEventArgs e)
        {
            this.button.IsEnabled = false;
            this.display.Text     = string.Empty;
            var result            = await Awaitable<double>.Run(() =>
            {
                Thread.Sleep(2000);  //--- 時間をかけて計算したことにする
                return new Random().NextDouble();
            });
            this.display.Text     = result.ToString();
            this.button.IsEnabled = true;
        }
    }
}

Awaitableパターンの改良

ここまで、独自型をawaitさせるための実装を行ってきました。コンパクトに作ったつもりなのですが、そうは言っても例えば以下のようにアレコレ考慮しなければならないことがあって、とっても面倒くさいです。

  • 登録した継続処理がちゃんと実行されるように配慮
  • 排他制御の考慮
  • ペアとなるAwaiterの実装

独自型によるawaitがどれほどニーズがあるのかは分かりませんが、どうせ作るならもっと楽をしたいところです。ということで、以下のように改良してみました。

using System;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;

namespace MyAwaitableUsingTaskCompletionSource
{
    class Awaitable<T>
    {
        private readonly TaskCompletionSource<T> tcs = null;

        /// <summary>
        /// コンストラクタ
        /// </summary>
        private Awaitable()
        {
            this.tcs = new TaskCompletionSource<T>();
        }

        /// <summary>
        /// 指定の処理を非同期に実行します。
        /// </summary>
        /// <param name="action">非同期に動かしたい処理</param>
        private void DoWorkAsync(Func<T> action)
        {
            ThreadPool.QueueUserWorkItem(_ =>
            {
                try                  { this.tcs.SetResult(action()); }
                catch (Exception ex) { this.tcs.SetException(ex); }
            });
        }

        /// <summary>
        /// 対応するAwaiterを取得します。
        /// </summary>
        /// <returns>対応するAwaiter</returns>
        public TaskAwaiter<T> GetAwaiter()
        {
            return this.tcs.Task.GetAwaiter();
        }

        /// <summary>
        /// 非同期処理の実行をラップします。
        /// </summary>
        /// <param name="action">非同期に動かしたい処理</param>
        /// <returns>await可能なオブジェクト</returns>
        public static Awaitable<T> Run(Func<T> action)
        {
            var awaitable = new Awaitable<T>();
            awaitable.DoWorkAsync(action);
            return awaitable;
        }
    }
}

TaskCompletionSource<T>TaskAwaiter<T>を利用しているのが、今回のポイントです。TaskCompletionSource<T>は内部にTask<T>を持っており、TaskCompletionSource<T>を経由することで外部から任意のタイミングで結果を与えることができます。上のサンプルの28、29行目が外部から結果を与えているところです。通常のTask<T>は、コンストラクタにデリゲートとして与えたタスクを実行させ、その完了に基づいて戻り値や例外などの結果を取得する、という使い方をします。このように、Task<T>は一度インスタンスを作ってしまうと後は外部から結果を取得するだけになってしまいますが、TaskCompletionSource<T>を利用すればそれを上手く解決できます。また、対応するAwaiterもイチイチ実装することはなく、TaskCompletionSource<T>が持っているTask<T>のGetAwaiterメソッドを利用するだけでOKです。

このように、Task/TaskCompletionSourceは非常に汎用的で強力です。もしawaitの独自実装をすることがあれば、その内部でこれらを利用すると手間が軽減して良いと思います。