seraphyの日記

日記というよりは過去を振り返るときのための単なる備忘録

C#5.0のasync/awaitによる非同期処理の使い方

async/awaitとは

async/awaitは、DotNET4.5でC#5.0(とVB)で言語仕様とフレームワークに組み込まれた"非同期処理を同期的に記述できる仕組み"である。


PLINQなどが計算処理などを複数スレッドに分散してCPUを効率よく使うことを目的としているのに対して、async/awaitは非同期I/Oなどの待ち受け処理を効率的に、且つ、同期処理的に簡便に記述することを目的としている。


つまり、まずasync/awaitするべき非同期処理が先にあって、これを同期的に記述できるようにすることが目的である。
(その結果として、asyncメソッドも非同期処理にはなる。)


文法的と言語仕様的には、IEnumerableを返すメソッドを生成するためのyield return構文とよく似ており、メソッド内でawaitを記述することによりメソッドは自動的にTaskを返す非同期メソッドとなる。

public async Task<int> ComplexAsyncTask()
{
    Func<int> asyncJob = () =>
    {
        // なにか有益な非同期でやるべき仕事
        return 123;
    };
    int ret1 = await Task.Run(asyncJob);
    int ret2 = await Task.Run(asyncJob);
    return ret1 + ret2;
}


非同期メソッドであることを示すためにメソッドにはasyncの修飾子を必要とするが、これ自身はマーカーみたいなものである。


メソッドの最終的な戻り値(return)が、asyncメソッドのタスクの戻り値となる。

使い方

awaitは以下のように用いる。

var result = await task_object;

このコードは、第一義的には、見たとおりの動きをする。

awaitとは待ち受けを意味しており、まず、右辺にタスクオブジェクトを受けて、その完了を待ち、その結果が左辺となる。

ただし、待ち受けを行い、結果を受け取るのは、別スレッド(もしくは別タスク)というところが肝心なのである。


このメソッドの呼び出し元のスレッドは、awaitで指定したTaskが完了しておらず、結果を受けるために待ちが発生する場合には、実際には完了を待たずに制御を呼び出し元に返す。*1


そのかわりに別の継続タスクが自動的に作成され、awaitで待機しているタスクの完了後の処理を引き継く。*2


仕組みとしては、Task#ContinueWith()の継続タスクとして扱われているようである。
なのでシンプルな垂直落下的なコードの見た目に反して、実際には細切れのタスクがたくさん作られてチェインしている感じになっているようである。


これにより、非同期タスクの待ち受けを同期的に記述しても呼び出し元の並行性を損なうことがなくなる、という仕組みのようである。


とくに、複数の非同期タスクがあり、それらの待ちを順序的に処理する必要がある場合には、このような同期的な記述方法が適しており、呼び出し元スレッドをブロッキングすることなく効率的*3且つ簡便に記述できるようになる。

コンソールアプリによる実験コード

C#4.0のTask自身、あまり活用できていない状況なのでasync/awaitの効果的な使い方を思いつかないのだが、複数の非同期メソッドの待ち合わせ処理の簡略化、という視点で実験してみた。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace AsyncTest
{
    class Program
    {
        /// <summary>
        /// asyncメソッドの呼び出しテスト
        /// </summary>
        /// <param name="args"></param>
        static void Main(string[] args)
        {
            // asyncによる非同期ジョブを開始
            print(1);
            var complexJob = MyAsyncComplexJob();
            print(2);

            // 完了タスクの設定
            // (※ 完了タスクはタスク完了後に設定しても大丈夫)
            // Thread.Sleep(6000);
            var finJob = complexJob.ContinueWith(job =>
            {
                // 結果表示
                print(3, "result=" + job.Result);
            });

            // 非同期ジョブ完了ジョブ完了まち
            print(4);
            finJob.Wait();
            print(5);
        }

        /// <summary>
        /// 複数の非同期ジョブを扱うasyncメソッド.
        /// 最終的な結果は、戻り値のタスクにアクセスして得ることができる.
        /// 
        /// 非同期ジョブの待ち受け分岐(await)を行う場合は、メソッドは
        /// asyncキーワードで修飾する必要があり、戻り値もTaskにラップされる.
        /// </summary>
        /// <returns>最終的な結果を得るためのタスク</returns>
        public async static Task<int> MyAsyncComplexJob()
        {
            // 非同期ジョブを開始する
            print(1);
            var job1 = MyAsyncJob("JOB1", 2); // 2sec

            // もう一つ、非同期ジョブを開始する
            print(2);
            var job2 = MyAsyncJob("JOB2", 5); // 5sec
            print(3);

            // 最初の非同期ジョブの完了を待機する.
            // ※ 最初のawaitの時点で呼び出し元に制御が帰る.
            // ※ job1の結果を待受け以降は別スレッドが引き継ぐ.
            // ※ つまり、awaitでスレッドが分岐する形になる
            int ret1 = await job1; // 2sec待ち
            print(4);

            // 新たに非同期ジョブを開始する.
            var job3 = MyAsyncJob("JOB3", 2); // 2sec
            print(5);

            // 二番目の非同期ジョブの完了を待機する
            // ※ 待受けに入った場合、結果が返るまでスレッドは一旦解放され
            // 待受け完了後にスレッドが再開した場合、同じスレッドとは限らない.
            int ret2 = await job2; // 3sec待ち(5sec中2sec待ち済み)
            print(6);

            // 三番目の非同期ジョブの完了を待機する
            // job2のほうが長いのでjob3は完了済み、待ちなし、スレッドも変わらず
            System.Diagnostics.Trace.WriteLine("job3=" + job3.IsCompleted);
            int ret3 = await job3; // 0sec待ち(待ちなし)
            print(7);

            // 全体の非同期処理の結果を返す
            int ret = ret1 + ret2 + ret3;
            print(8, "ret=" + ret);
            return ret; // 戻り値はintのままで良い.
        }

        /// <summary>
        /// 非同期ジョブ.
        /// 呼び出しによって非同期にジョブは開始される.
        /// 結果を得るには戻り値のTaskにアクセスする.
        /// </summary>
        /// <param name="name">識別用文字列</param>
        /// <param name="mx">最大ループ数</param>
        /// <returns>結果を受け取るためのタスク</returns>
        public static Task<int> MyAsyncJob(string name, int mx)
        {
            print(1, name);
            return Task.Run(() =>
            {
                int result = 0;
                for (int idx = 0; idx < mx; idx++)
                {
                    print(2, name + "/idx=" + idx);
                    Thread.Sleep(1000);
                    result += idx;
                }
                print(3, name + "/result=" + result);
                return result;
            });
        }

        /// <summary>
        /// 診断用メッセージの出力.
        /// マーカー番号、メッセージのほか、呼び出し元メンバ名とスレッドも出力する.
        /// 
        /// ※ 呼び出し元メンバ名はランタイム情報ではなく、コンパイル時に引数として
        /// 呼び出し側のコードに埋め込まれる定数情報であるため、リリースビルドでも有効である.
        /// </summary>
        /// <param name="marker">マーカー番号</param>
        /// <param name="msg">メッセージ</param>
        /// <param name="member">呼び出し元メンバ名(コンパイラによる自動適用)</param>
        [MethodImpl(MethodImplOptions.Synchronized)]
        private static void print(
            int marker,
            string msg = "",
            [CallerMemberName] string member = ""
            )
        {
            var buf = new StringBuilder();
            sequence++;
            buf.Append(sequence.ToString("000"));
            buf.Append(" [").Append(member).Append("]");
            buf.Append("{");
            buf.Append(Thread.CurrentThread.ManagedThreadId);
            buf.Append("} #");
            buf.Append(marker);
            if (!string.IsNullOrEmpty(msg))
            {
                buf.Append(" : ");
                buf.Append(msg);
            }
            System.Diagnostics.Trace.WriteLine(buf.ToString());
        }

        /// <summary>
        /// 連番
        /// </summary>
        private static int sequence;
    }
}

/*
 * [実行結果]
 * 
001 [Main]{10} #1
002 [MyAsyncComplexJob]{10} #1
003 [MyAsyncJob]{10} #1 : JOB1
004 [MyAsyncComplexJob]{10} #2
005 [MyAsyncJob]{10} #1 : JOB2
006 [MyAsyncComplexJob]{10} #3
007 [MyAsyncJob]{11} #2 : JOB1/idx=0
008 [MyAsyncJob]{12} #2 : JOB2/idx=0
009 [Main]{10} #2
010 [Main]{10} #4
011 [MyAsyncJob]{11} #2 : JOB1/idx=1
012 [MyAsyncJob]{12} #2 : JOB2/idx=1
013 [MyAsyncJob]{12} #2 : JOB2/idx=2
014 [MyAsyncJob]{11} #3 : JOB1/result=1
015 [MyAsyncComplexJob]{11} #4
016 [MyAsyncJob]{11} #1 : JOB3
017 [MyAsyncJob]{13} #2 : JOB3/idx=0
018 [MyAsyncComplexJob]{11} #5
019 [MyAsyncJob]{12} #2 : JOB2/idx=3
020 [MyAsyncJob]{13} #2 : JOB3/idx=1
021 [MyAsyncJob]{12} #2 : JOB2/idx=4
022 [MyAsyncJob]{13} #3 : JOB3/result=1
023 [MyAsyncJob]{12} #3 : JOB2/result=10
024 [MyAsyncComplexJob]{12} #6
job3=True
025 [MyAsyncComplexJob]{12} #7
026 [MyAsyncComplexJob]{12} #8 : ret=12
027 [Main]{12} #3 : result=12
028 [Main]{10} #5
*/

コードの、それぞれの箇所で、実行順序と実行スレッドを判別するための診断コードを仕込んでいる。

(診断メソッドには、C#5.0でサポートされた呼び出し元メソッド名を受け取る属性を使ってみた。)

上記トレース結果から読み取れること
  • asyncメソッドの呼び出しは、awaitで制御が呼び出し元に返るが、その後、別スレッドでasync内コードが継続されている
  • asyncメソッド内でawaitで待ちが発生した場合、その前後でスレッドを変わっている場合がある
  • awaitで待ちが発生しない = 完了済みの場合はスレッドは変わらず、そのまま同期的に処理されるようである。

GUIアプリ(WindowsForm)での実験コード (および進捗とキャンセルの使い方)

GUIコードでasync/awaitを使うことのメリットは、以下のようなシナリオで代表されるものと考えられる。

  • 何らかの時間のかかるジョブを開始し、且つ、UIスレッドがブロッキングしないようにすぐに制御を戻したい場合
  • 何らかのジョブが完了したあとに、UIスレッドに対して何らかのアクションを起こしたい場合
  • ジョブの中で発生した例外を通常のtry-catchの構文でハンドリングしたい場合


従来的なコードでは、非同期のタスクを生成し、完了タスクをつなげて実行する、という形になるが、
これを従来的な垂直落下的なコードで記述可能にすることができる。


また、asyncメソッドには、Taskの結果を受け取らないというvoidの指定が可能であり、これは特にイベントハンドラなどで活躍する。

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace GUIAsyncTest
{
    public partial class AsyncTestGUIForm : Form
    {
        public AsyncTestGUIForm()
        {
            InitializeComponent();
        }

        /// <summary>
        /// ジョブをキャンセルするためのもの
        /// </summary>
        private CancellationTokenSource cancelSrc;

        /// <summary>
        /// スタートボタンのハンドラ
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private async void BtnStart_Click(object sender, EventArgs e)
        {
            // ボタンハンドラの呼び出し時点は当然UIスレッド
            print(1);

            var progress = new Progress<double>((v) =>
            {
                // このプログレスの通知はUIスレッドで実行されている.
                print(2, "progress=" + v);
                ProgressBar1.Value = (int)(v * 100);
            });

            cancelSrc = new CancellationTokenSource();

            LblMessage.Text = "Runnung...";
            BtnStart.Enabled = false;
            BtnStop.Enabled = true;

            try
            {
                // awaitによりボタンハンドラは、ここで制御を復帰するため
                // タスクの完了を待たずとも、他のUIイベントを処理できるようになる.
                var ret = await AsyncMyTask(5, progress, cancelSrc.Token);

                // タスク完了後、スレッドはUIスレッドに戻されている.
                // Task#ConfigureAwait(boolean)により、awaitの後続処理を
                // 元のスレッドと同じコンテキストとするか指定できる。
                // WindowsForm内であれば自動的にtrueになる模様。
                // http://msdn.microsoft.com/ja-jp/library/hh194876(v=vs.110).aspx
                print(3, "done ret=" + ret);

                // awaitの待ち完了後に実行される、この処理は
                // UIスレッドに戻っているので以下のコードは安全である.
                LblMessage.Text = "Done. result=" + ret;
            }
            catch (OperationCanceledException)
            {
                // 非同期タスク内で発生した例外はawaitによって待ち受けした場合には
                // あたかも、awaitの行で例外が発生したかのように伝搬される.
                // awaitから抜けた時点でUIスレッドに戻っている.
                print(4);
                LblMessage.Text = "Canceled.";
            }
            BtnStart.Enabled = true;
            BtnStop.Enabled = false;
            print(5);
        }

        /// <summary>
        /// 非同期タスク
        /// </summary>
        /// <param name="count">ループ回数</param>
        /// <param name="progress">進捗状態を受ける</param>
        /// <param name="cancelToken">キャンセルを通知する</param>
        /// <returns>実行結果</returns>
        private Task<int> AsyncMyTask(int count, IProgress<double> progress, CancellationToken cancelToken)
        {
            print(1);
            return Task.Run(() =>
            {
                print(2);
                for (int idx = 0; idx < count; idx++)
                {
                    print(3);

                    // キャンセル要求があれば例外を発生させタスクを終了させる.
                    cancelToken.ThrowIfCancellationRequested();

                    // 進捗状態を通知する.
                    double v = (idx + 1) / (double)count;
                    progress.Report(v);

                    // 待ち
                    Thread.Sleep(300);
                }
                print(4);
                return count;
            });
        }

        /// <summary>
        /// キャンセルボタンのハンドラ
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void BtnStop_Click(object sender, EventArgs e)
        {
            print(1);
            // 進行中のタスクをキャンセルする.
            if (cancelSrc != null)
            {
                cancelSrc.Cancel();
            }
        }

        /// <summary>
        /// 診断用メッセージの出力.
        /// マーカー番号、メッセージのほか、呼び出し元メンバ名とスレッドも出力する.
        /// 
        /// ※ 呼び出し元メンバ名はランタイム情報ではなく、コンパイル時に引数として
        /// 呼び出し側のコードに埋め込まれる定数情報であるため、リリースビルドでも有効である.
        /// </summary>
        /// <param name="marker">マーカー番号</param>
        /// <param name="msg">メッセージ</param>
        /// <param name="member">呼び出し元メンバ名(コンパイラによる自動適用)</param>
        [MethodImpl(MethodImplOptions.Synchronized)]
        private static void print(
            int marker,
            string msg = "",
            [CallerMemberName] string member = ""
            )
        {
            var buf = new StringBuilder();
            sequence++;
            buf.Append(sequence.ToString("000"));
            buf.Append(" [").Append(member).Append("]");
            buf.Append("{");
            buf.Append(Thread.CurrentThread.ManagedThreadId);
            buf.Append("} #");
            buf.Append(marker);
            if (!string.IsNullOrEmpty(msg))
            {
                buf.Append(" : ");
                buf.Append(msg);
            }
            System.Diagnostics.Trace.WriteLine(buf.ToString());
        }

        /// <summary>
        /// 連番
        /// </summary>
        private static int sequence;
    }
}

/**
 * 実行結果
 *
001 [BtnStart_Click]{9} #1
002 [AsyncMyTask]{9} #1
003 [AsyncMyTask]{10} #2
004 [AsyncMyTask]{10} #3
005 [BtnStart_Click]{9} #2 : progress=0.2
006 [AsyncMyTask]{10} #3
007 [BtnStart_Click]{9} #2 : progress=0.4
008 [AsyncMyTask]{10} #3
009 [BtnStart_Click]{9} #2 : progress=0.6
010 [AsyncMyTask]{10} #3
011 [BtnStart_Click]{9} #2 : progress=0.8
012 [AsyncMyTask]{10} #3
013 [BtnStart_Click]{9} #2 : progress=1
014 [AsyncMyTask]{10} #4
015 [BtnStart_Click]{9} #3 : done ret=5
016 [BtnStart_Click]{9} #5

 キャンセルした場合

001 [BtnStart_Click]{9} #1
002 [AsyncMyTask]{9} #1
003 [AsyncMyTask]{6} #2
004 [AsyncMyTask]{6} #3
005 [BtnStart_Click]{9} #2 : progress=0.2
006 [AsyncMyTask]{6} #3
007 [BtnStart_Click]{9} #2 : progress=0.4
008 [AsyncMyTask]{6} #3
009 [BtnStart_Click]{9} #2 : progress=0.6
010 [BtnStop_Click]{9} #1
011 [AsyncMyTask]{6} #3
型 'System.OperationCanceledException' の初回例外が mscorlib.dll で発生しました
型 'System.OperationCanceledException' の初回例外が mscorlib.dll で発生しました
012 [BtnStart_Click]{9} #4
013 [BtnStart_Click]{9} #5
*/


上記トレース結果から読み取れること
  • awaitの後続の処理は、呼び出し元と同じスレッドになっている。
    • 既定ではSynchronizationContextがnullでなければ、それがキャプチャされる様子。
  • awaitを使うと、非同期タスク内で発生した例外をtry-catchの通常の構文でハンドリング可能である
  • Progressの通知を受け取る側はUIスレッドになっている。(とくに意識しなくてもよい様子。)

結論

つまるところ「awaitとは非同期ジョブの待ち受けのために(必要ならば)タスクを分割させるもの」と言い表せるかもしれない。


また、これに付随して

  • スレッドのコンテキストのマーシャリングを自動サポート (UIスレッドでasyncメソッドを開始して、その中でタスクを分割したら、それらのタスクは、すべてUIスレッドに属する、というような。)
  • awaitで待機中のタスク内で発生した例外は、それが別スレッドであろうとtry-catchでキャッチ可能にする
    • (ただし同時に複数のタスクを待ち受けするawaitの場合には、その最初の例外のみが伝搬される。)

といった支えがあり、async/awaitを、より同期的な記述方法がしやすいようにしているようである。


このawaitの動きは、上記の実験での見た感じ、とても効率的に作られているようであるから、わずかでも非同期化するほうがスケーラビリティやパフォーマンスが上がると思われる場所には、積極的に使えるようになっているような感じではある。


また、WindowsFormのインベトハンドラで使った場合の相性の良さは抜群の様子である。


まだ私自身がマルチコア時代に適したコードをかける発想になっていないのが一番のネックなのだが、
まずは

  • 従来、自前でスレッドを使っていたところ
  • 非同期にしたほうがよいと分かっていてもしてなかったところ

これらに順にasyncを適用してゆくことが容易になっているのかなー、という印象を受けた。

とりあえず、このあたりから使い始めてみたいと思う。

参考文献/URL


以上、メモ終わり。

*1:もし非同期タスクが完了済みだった場合には、わざわざ制御を戻して複数スレッド/複数タスクに分岐したりはしない。

*2:同期コンテキストがとくに設定されていない場合は、スレッドも別になる可能性がある。UIスレッドで実行し同期コンテキストがある場合は、後続のタスクもUIスレッドに設定される。

*3:awaitの前後でスレッドが切り替わっていることから推定するに、awaitで待ち受け中ではスレッドはスレッドプールに返却されているのではないかと思われる。