WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

デリゲートを利用した非同期メソッドの実装

by WebSurfer 2019年6月19日 15:10

Windows Forms アプリでのデリゲートを利用した非同期メソッドの実装について備忘録を書いておきます。.NET Framework 4.5 で async / await / Task.Run が利用できる今はデリゲートを使うことはこの先もうないのかもしれませんが。

非同期メソッドの実装

非同期メソッド実装の変遷については @IT の記事「第1回 .NET開発における非同期処理の基礎と歴史」にまとめられています。デリゲートを使う方法とはその記事の中の「Asynchronous Programming Model(非同期プログラミング・モデル)」です。

上の記事では概要しか書いてなくて分かり難いと思います。詳しく知りたい方は、かなり古い記事ですが同じく @IT の「第2回 .NETにおけるマルチスレッドの実装方法の総括 (1/4)」を見ていただいた方が良く理解できると思います。

上の画像は、以下の同期メソッドを、(1) そのまま同期呼び出し、(2) delegate を利用した非同期呼び出し、(3) async / await / Task.Run を利用した非同期呼び出しを行う windows forms アプリを実行したものです。

// テスト用の時間がかかるメソッド
private string TimeCosumingMethod(string s)
{
    if (string.IsNullOrEmpty(s))
    {
        throw new ArgumentException("引数が無い");
    }
    Thread.Sleep(3000);
    return s + " + ManagedThreadId: " + 
                     Thread.CurrentThread.ManagedThreadId;
}

delegate を利用した非同期呼び出しの場合、(a) EndInvoke を行う対象となるデリゲートの取得、(b) UI スレッドに戻り値を渡す、(c) 例外の捕捉が課題になると思います。

具体例は以下のコードを見てください。上の課題 (a) ~ (c) についてはコールバックメソッド MyCallBack 内で行っています。詳しくはコード内にコメントで書きましたのでそれを見てください。(手抜きでスミマセン)

using System;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Threading;
using System.Runtime.Remoting.Messaging;

namespace WindowsFormsAsyncTest
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            this.textBox1.Text = "呼び出されたメソッド内の";
        }

        // テスト用の時間がかかるメソッド
        private string TimeCosumingMethod(string s)
        {
            if (string.IsNullOrEmpty(s))
            {
                throw new ArgumentException("引数が無い");
            }
            Thread.Sleep(3000);
            return s + " ManagedThreadId: " + 
                       Thread.CurrentThread.ManagedThreadId;
        }

        // 同期呼び出し
        private void button1_Click(object sender, EventArgs e)
        {
            this.label1.Text = 
                "Button.Click イベントハンドラ内の ManagedThreadId: " 
                + Thread.CurrentThread.ManagedThreadId + " (IN) / ";
            this.label2.Text = "";

            // Application.DoEvents で上の文字列を即ラベルに反映
            Application.DoEvents();

            try
            {
                this.label2.Text = TimeCosumingMethod(this.textBox1.Text);
            }
            catch (ArgumentException ex)
            {
                MessageBox.Show(ex.Message);
                throw;
            }

            this.label1.Text += 
                Thread.CurrentThread.ManagedThreadId + " (OUT)";
        }

        // delegate を利用した非同期呼び出し

        // 別スレッドとして処理したいメソッドをデリゲート宣言
        delegate string MyDelegate(string s);

        // 別スレッドによる処理が終了したことをトリガーとして自動的に
        // 呼び出されるコールバックメソッド。
        // スレッドは p.BeginInvoke によって TimeCosumingMethod メソ
        // ッドが実行されるスレッドと同じになる
        private void MyCallBack(IAsyncResult ar)
        {
            // 課題 (a) コールバックで EndInvoke を行う対象となる
            // デリゲートの取得
            MyDelegate p = (MyDelegate)((AsyncResult)ar).AsyncDelegate;
            string result = "";

            // 課題 (c) 例外の捕捉
            // コールバックの中でしか例外は捕捉できないので注意
            try
            {
                result = p.EndInvoke(ar);
            }
            catch (ArgumentException ex)
            {
                // UIスレッドで MessageBox を表示
                this.Invoke((Action)(() => MessageBox.Show(ex.Message)));
                throw;
            }

            // コールバックのスレッドを確認(TimeCosumingMethod の
            // スレッドと同じになる)
            result += " / " + 
                Thread.CurrentThread.ManagedThreadId + " (CallBack)";

            // 課題 (b) UI スレッドに戻り値を渡す
            // Invokeメソッドで UIスレッドの Label に文字列を設定
            this.Invoke((Action)(() => this.label2.Text = result));
        }

        private void button2_Click(object sender, EventArgs e)
        {
            this.label1.Text = 
                "Button.Click イベントハンドラ内の ManagedThreadId: "
                + Thread.CurrentThread.ManagedThreadId + " (IN) / ";
            this.label2.Text = "";

            // デリゲートのインスタンスを作成
            MyDelegate p = new MyDelegate(TimeCosumingMethod);

            // デリゲートによるスレッド処理呼び出し
            p.BeginInvoke(this.textBox1.Text, this.MyCallBack, null);

            // TimeCosumingMethod メソッドの終了は待たず即実行される
            this.label1.Text += 
                Thread.CurrentThread.ManagedThreadId + " (OUT)";
        }

        // async/await/Task を使った非同期呼び出し
        private async void button3_Click(object sender, EventArgs e)
        {
            this.label1.Text = 
                "Button.Click イベントハンドラ内の ManagedThreadId: "
                + Thread.CurrentThread.ManagedThreadId + " (IN) / ";
            this.label2.Text = "";

            try
            {
                this.label2.Text = await Task.Run(() => 
                            TimeCosumingMethod(this.textBox1.Text));
            }
            catch (ArgumentException ex)
            {
                MessageBox.Show(ex.Message);
                throw;
            }

            // await キーワードにより TimeCosumingMethod メソッドが
            // 終了するのを待って実行される
            this.label1.Text += 
                Thread.CurrentThread.ManagedThreadId + " (OUT)";
        }
    }
}

async/await を利用した場合と delegate を利用した場合の大きな違いは、前者は await キーワードによって TimeCosumingMethod メソッドが終了するのを待つのに対して(ただし、呼び出し側の UI スレッドのメッセージループは処理されるのでフリーズはしない)、後者は待たない(TimeCosumingMethod メソッドの結果の UI スレッドへの反映は コールバック で行わざるを得ない)・・・ということです。

上のコードの button2_Click, button3_Click メソッド内の最後のコードを見てください。コメントに書いた通り、async/await を利用した場合は TimeCosumingMethod メソッドが終了するのを待って Label に追記され、delegate を利用した場合は即追記されます。


集約的例外ハンドラによる例外処置の話も以下に備忘録として書いておきます。

delegete を利用した非同期呼び出しの例外の処置については、上のコードのコールバックメソッド MyCallBack 内で完了できれば良いのですが、処理しないで集約的例外ハンドラで捕捉してからアプリケーションを終了させるというケースも多々あると思います。

それをどうするかについては以下の Main メソッドのコードを見てください。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Threading;

namespace WindowsFormsClient
{
  static class Program
  {
    [STAThread]
    static void Main()
    {
      // 捕捉されなかった例外を処理する集約的例外ハンドラを実装

      // 同期呼出と async/await を使った非同期呼び出しの例外は
      // Application.ThreadException イベントで捕捉

      Application.ThreadException += 
            new ThreadExceptionEventHandler(ThreadException);

      // delegate を使った非同期呼び出しの例外の捕捉
      // Application.ThreadException では UI スレッドの例外しか
      // 捕捉できないので AppDomain.UnhandledException で捕捉
           
      AppDomain currentDomain = AppDomain.CurrentDomain;
      currentDomain.UnhandledException += UnhandledException;

      // だだし、上記では同期呼出と async/await を使った非同期呼
      // び出しの例外の処理が期待通りにならない。デバッグ実行す
      // ると捕捉できているように見えるが MessageBox が出ない。
      // 理由不明。同期、delegate、async/await 全部に対応するに
      // は両方のハンドラを生かしておく必要がある。

      // アプリケーション構成ファイルの設定を無視し、UI スレッド
      // の例外は常に ThreadException ハンドラに送る
      Application.SetUnhandledExceptionMode(
                        UnhandledExceptionMode.CatchException);

      Application.EnableVisualStyles();
      Application.SetCompatibleTextRenderingDefault(false);
      Application.Run(new Form2());
    }

    // Application.ThreadException のハンドラ
    private static void UnhandledException(object sender, 
                             UnhandledExceptionEventArgs e)
    {
      Exception ex = (Exception)e.ExceptionObject;
      MessageBox.Show("AppDomain のハンドラ\n" + ex.Message);
      Application.Exit();
    }

    // AppDomain.UnhandledException のハンドラ
    static void ThreadException(object sender, 
                                ThreadExceptionEventArgs e)
    {
      Exception ex = e.Exception;
      MessageBox.Show("Application のハンドラ\n" + ex.Message);

      // Environment.Exit を使わないとダイアログが出るとの記事が
      // あったが、このサンプルでは Application.Exit で問題なし  
      Application.Exit();
    }
  }
}

Microsoft のドキュメント Control.BeginInvoke Method に "Exceptions within the delegate method are considered untrapped and will be sent to the application's untrapped exception handler." と書いてあります。

デリゲートメソッド内で発生した例外を捕捉するには AppDomain.UnhandledException イベントのハンドラを利用します。Application.ThreadException イベントは UI スレッドで発生した例外しか捕捉できません。

なので、(1) 同期、(2) delegate、(3) async / await の全てのケースで集約的例外処理を行うには、(1) と (3) は Application.ThreadException イベントのハンドラで、(2) は AppDomain.UnhandledException イベントのハンドラで処理できるように両方のハンドラを設定するのが正解のようです。

Tags: , , , ,

.NET Framework

非同期 HTTP ハンドラ (2)

by WebSurfer 2018年3月8日 18:56

先の記事「非同期 HTTP ハンドラ」で、Web サービスに非同期でアクセスする HTTP ジェネリックハンドラのサンプルを書きました。

その HTTP ハンドラは IHttpAsyncHandler インターフェイスを継承して BeginProcessRequest, EndProcessRequest メソッドを実装するという旧来の方法を使っており、記事に書いてありますように非常に複雑なコードになります。

ここまで複雑なことをしなければならないのなら、HTTP 503 エラー (Server Too Busy) が頻発して困っているというような事情がなければ、従来通り同期版のままでもいいかなって気もします。(笑)

ですが、.NET 4.5 から簡単に非同期呼び出しが実装できるようになったそうです。どのぐらい簡単にできるのか、同じ機能を async / await を利用して実装してみました。

非同期 HTTP ハンドラ

確かにはるかに簡単でした。前のように、何がどうなっているのかを調べて、悩んで、時間をかけて実装するという手間は大幅に減っています。

上の画像は、この記事に書いた方法で作成した HTTP ハンドラを、ブラウザから直接呼び出した結果です。具体的にどのように実装したかは以下に書きます。

HelloWorldWcfService.cs

先の記事の Web サービスは WCF サービスに変更しました。HelloWorld メソッドの実装は先の記事の Web サービスのものと全く同じです。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.Text;
using System.Threading;
using System.ServiceModel.Activation;

[AspNetCompatibilityRequirements(RequirementsMode =
            AspNetCompatibilityRequirementsMode.Allowed)]
public class HelloWorldWcfService : IHelloWorldWcfService
{
    public string HelloWorld(int callDuration)
    {
        Thread.Sleep(callDuration);
        return String.Format(
          "Hello World from WcfService. Call Time: {0}",
          callDuration);
    }
}

WCF サービスに変更したのは、Web サービスが Legacy Technology だからということもありますが、一番の理由はサービス参照の追加を行うと自動生成されるサービスプロキシに含まれる非同期版メソッドが利用できるからです。

(非同期版メソッドについて、詳しくは先に記事「WCF サービスの非同期呼び出し」を見てください)

HelloWorldAsyncHnadler.ashx

Visual Studio で、既存の ASP.NET Web Forms アプリにジェネリックハンドラーを追加します。.ashx ファイルには何も手を加える必要はありません。

<%@ WebHandler Language="C#"
    CodeBehind="HelloWorldAsyncHandler.ashx.cs"
    Class="WebFormsApp.HelloWorldAsyncHandler" %>

Web アプリケーションプロジェクトの場合、.ashx ファイルとそのコードビハインドの .ashx.cs ファイルは別になります。Web サイトプロジェクトの場合、すべて .ashx ファイルに含まれるという違いがあります。

HelloWorldAsyncHandler.ahsx.cs

自動生成されたコードは同期版ハンドラのベースとなるものです。これを HttpTaskAsyncHandler クラスを継承したクラスに書き換えます。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Threading;
using System.Threading.Tasks;
using WebFormsApp.HelloWorldServiceReference;

namespace WebFormsApp
{
    public class HelloWorldAsyncHandler : HttpTaskAsyncHandler
    {
        HelloWorldWcfServiceClient client;

        // これが呼び出されることはない。万一呼び出されたら例外
        // をスローして自爆する
        public override void ProcessRequest(HttpContext context)
        {
            throw new InvalidOperationException();
        }
        
        public override async Task ProcessRequestAsync(
                                            HttpContext context)
        {
            context.Response.Write(
                "<p>Before await:<br />" +
                " IsThreadPoolThread is " +
                Thread.CurrentThread.IsThreadPoolThread +
                "<br />" +
                " ManagedThreadId is " +
                Thread.CurrentThread.ManagedThreadId +
                "</p>");

            client = new HelloWorldWcfServiceClient();

            string result = await client.HelloWorldAsync(3000);

            context.Response.Write(
                "<p>After await:<br />" +
                " IsThreadPoolThread is " +
                Thread.CurrentThread.IsThreadPoolThread +
                "<br />" +
                " ManagedThreadId is " +
                Thread.CurrentThread.ManagedThreadId +
                "</p>");

            context.Response.Write("<p>" + result + "</p>");
        }

        public override bool IsReusable
        {
            get
            {
                return false;
            }
        }
    }
}

上のコードの WebFormsApp.HelloWorldServiceReference はサービスプロキシの名前空間です。HelloWorldWcfServiceClient コンストラクタ、HelloWorldAsync メソッドの定義は自動生成される Reference.cs ファイルにあります。

結局は先の記事「非同期 HTTP ハンドラ」と同様なことを行っていて、先の記事のコードの BeginProcessRequest メソッドが await キーワードの前に、EndProcessRequest メソッドが await の後に実行されているようです。

BeginProcessRequest, EndProcessRequest メソッドをプログラマがコードを書いて実装しなくて済むよう、コンパイラが肩代わりしているという感じです。

Tags: , ,

ASP.NET

WCF サービスの非同期呼び出し

by WebSurfer 2018年2月28日 16:47

Visual Studio 2015 Community で WCF のサービス参照を追加して生成されるサービスプロキシには非同期版のメソッドも含まれ、それを利用すれば async / await キーワードを付与することで簡単に非同期呼び出しができるという話を書きます。

WCF のクライアント

何を今さらと言われるかもしれませんが、自分としては新発見で、これは備忘録として残しておかねばと思いましたので。(笑)

上の画像は Microsoft のチュートリアル「10 行でズバリ!! [C#] WCF サービスの作成と利用」をベースに作った WPF クライアントアプリです。string 型のデータを応答として返す WCF サービスを呼び出して、その応答を表示しています。

上のチュートリアルにある通り、クライアントアプリから WCF サービスを呼び出すのに利用するサービスプロキシを生成するため、サービス参照の追加を行います。

その際、「サービス参照の追加」ダイアログで[詳細設定(V)...]をクリックすると表示される「サービス参照設定」ダイアログを見ると、Visual Studio 2015 Community ではデフォルトで以下の画像の設定になっています。

サービス参照の追加

この設定画面で[非同期操作の生成を許可する(P)]にチェックが入っていて、[タスクベースの操作を生成する(T)]が選択されているのがポイントのようです。自信度はあまり高くないですが・・・(汗)

ダイアログ右上の ? マークをクリックすると表示されるヘルプを読んでも何のことだかよく分かりませんでしたが、Microsoft のドキュメント「同期操作と非同期操作」を読むと、その中の「タスク ベースの非同期パターン」が相当するのではないかと思いました。

(Visual Studio 2010 Professional では[非同期操作の生成(G)]というのがありますが、デフォルトではチェックは入っていません。未確認ですが、.NET 4.5 以降でしか使用できない async / await キーワードを利用して使うものではなさそうです)

サービス参照の追加が完了すると、以下の画像の通り、同期版の GetData メソッドに加えて非同期版の GetDataAsync メソッドも生成されているのがインテリセンスの表示から分かります。

インテリセンスの表示

クライアントアプリからは、async / await キーワードを使って非同期版メソッドを呼び出せば、時間がかかる WCF サービスから応答が返ってくるまで UI がブロックされるということはなくなります。

それを試すには以下のようにします。

WCF サービス

まず、WCF のメソッドに 3 秒の待ち時間を入れます。これにより、同期の場合は呼び出し側の WPF アプリは 3 秒ブロックされますが、非同期の場合はブロックされないことを確認できます。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.Text;
using System.ServiceModel.Activation;

// このサービスをホストする ASP.NET アプリの web.config
// で aspNetCompatibilityEnabled="true" と設定されている
// 場合は以下の属性が必要
[AspNetCompatibilityRequirements(RequirementsMode = 
             AspNetCompatibilityRequirementsMode.Allowed)]
public class Service : IService
{
  public string GetData(int value)
  {
    // 非同期実行を確認するため追加
    System.Threading.Thread.Sleep(3000);

    return string.Format("You entered: {0}", value);
  }

  // CompositeType は IService.cs に自動生成されたクラス
  public CompositeType 
      GetDataUsingDataContract(CompositeType composite)
  {
    // 非同期実行を確認するため追加        
    System.Threading.Thread.Sleep(3000);
        
    if (composite == null)
    {
      throw new ArgumentNullException("composite");
    }

    if (composite.BoolValue)
    {
      composite.StringValue += "Suffix";
    }

    return composite;
  }
}

WPF クライアントアプリ

呼び出し側のクライアントアプリでは以下のようにします。一番上の画像で、[同期呼び出し]ボタンのクリックイベントのハンドラが button_Click で、[非同期呼び出し]の方が button1_Click です。

// 同期
private void button_Click(object sender, RoutedEventArgs e)
{
  var client = new TestService.ServiceClient();
  var composite = new TestService.CompositeType();
  label.Content = client.GetData(12345);
  composite.BoolValue = checkBox.IsChecked == true;
  composite.StringValue = textBox.Text;
  composite = client.GetDataUsingDataContract(composite);
  label1.Content = composite.StringValue;
}

// 非同期
// async void を async Task に変更すると、メソッドとデリゲート
// 型との間に互換性が無いというエラーになる
private async void button1_Click(object sender, 
                                         RoutedEventArgs e)
{
  var client = new TestService.ServiceClient();
  var composite = new TestService.CompositeType();
  label.Content = await client.GetDataAsync(12345);
  composite.BoolValue = checkBox.IsChecked == true;
  composite.StringValue = textBox.Text;
  composite = 
    await client.GetDataUsingDataContractAsync(composite);
  label1.Content = composite.StringValue;
}

[同期呼び出し]ボタンをクリックすると上の画像のアプリは WCF サービスから応答が返ってくるまで操作できませんが、[非同期呼び出し]ボタンクリックなら操作可能で応答も期待通り返ってくることが確認できました。こんな簡単なことでホントにいいのか・・・という不安はありますが。(汗)

あと、Microsoft の文書「非同期プログラミングのベストプラクティス」には「async void メソッドよりも async Task メソッドを利用する」ということが書いてありますが、それはできなかったです(上のコードのコメントに書いたようにエラーになります)。解決方法があるのかどうかわかりませんが、分かったら追記することにします。

Tags: , ,

.NET Framework

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar