[C#]async, awaitの使い方を正しく理解する

カテゴリ: C# | タグ:

C#で非同期処理を行うための機能であるasync/awaitのふるまいに関する説明です。

サンプルコードを見ながら確認していきます。

処理に時間が掛かるメソッドHeavyFunction()を準備する

まず、処理に時間がかかるメソッドを用意します。この例ではSleepさせていますが、実際はWebAPIを呼び出すとかDBアクセスのような時間がかかる処理をイメージしてもらうと良いです。

// 時間のかかる処理
private string HeavyFunction() {
    // 1秒間待つ
    System.Threading.Thread.Sleep( 1000 );

    // 結果を返す
    return "Hello";
}

HeavyFunction()をボタンクリック時のハンドラで呼び出す

先ほど作ったHeavyFunction()メソッドは、以下の形で呼び出せますが、HeavyFunction()の実行に1秒かかるため、画面の描画が1秒間ストップしてしまいます。

画面が固まったように見えるので、これは良くない処理です。

// ボタンクリック時のハンドラ
private void button1_Click( object sender, EventArgs e ) {
    string message = HeavyFunction();
    textBox1.Text = message;
}

HeavyFunction()を非同期バージョンに書き換える

先ほど作ったHeavyFunction()の作りが良くなかったので、非同期版であるHeavyFunctionAsync()を作ります。

// 時間のかかる処理
private async Task<string> HeavyFunctionAsync() {
    // 非同期で1秒間待つ
    await Task.Delay( 1000 );

    // 結果を返す
    return "Hello";
}

このメソッドの違いは下記の2点です。

  • 戻り値がstringからasync Taskに変わった
  • スリープ処理にawaitがついており、非同期での待ちに変わった

HeavyFunctionAsync()をボタンクリック時のハンドラで呼び出す

HeavyFunctionAsync()メソッドがasync版になったことで、呼び元側は以下のように変わります。

// ボタンクリックのハンドラ
private async void button1_Click( object sender, EventArgs e ) {
    string message = await HeavyFunctionAsync();
    textBox1.Text = message;
}

こちらの違いは2点です

  • button1_Clickのシグネチャにasyncが追加された
  • 関数呼び出しの前にawaitがついている

非同化したことによって処理の実行タイミングがどう変わったかを確認するために、ログを入れてみます。

// ボタンクリックのハンドラ
private async void button1_Click( object sender, EventArgs e ) {
    textBox1.AppendText( "[1]" );

    string message = await HeavyFunctionAsync();

    textBox1.AppendText( "[2]" );
    textBox1.AppendText( message );
    textBox1.AppendText( "[3]" );
}

この時の実行結果は以下のようになります。実際に実行してみると分かりますが、1秒かけて処理をしている間もFormの再描画がストップすることは愛りません。

  • "[1]"が出力される
  • 1秒待つ(待っている間もウィンドウは再描画され続ける)
  • "[2]Hello[3]"が続けて出力される

HeavyFunctionAsync()を同期呼び出ししてみる

HeavyFunctionAsync()の呼び出し時にawaitをつけず、非同期処理を行っているTaskオブジェクトで終了待ち(Waitメソッドの呼び出し)を行ってみます

private void button1_Click( object sender, EventArgs e ) {
    Task<string> messageTask = HeavyFunctionAsync();
    messageTask.Wait();                              // NG:ここでデッドロックする
    textBox1.AppendText( message.Result );
}

// 時間のかかる処理
private async Task<string> HeavyFunctionAsync() {
    // 非同期で1秒間待つ
    await Task.Delay( 1000 );

    // 結果を返す
    return "Hello";
}

ですが、これは正しく動作しません。

理由はタスクがシングルスレッドで動作しているのでデッドロックが発生するためです。button1_Click()はWait()の完了待ちになる一方で、messageTaskはbutton1_Click()が終了しないとTaskの作業を行えないため、お互いが相手待ちになってしまっています。

非同期処理を別スレッド上で行わせてみる

デッドロックするのは、UIスレッドとTaskの実行が同一スレッドで動作するためです。

これを回避するために、以下のようにConfigureAwait(false)を追加すると、非同期のTask処理を別スレッドで実行させることができます。

このためデッドロックは発生せず1秒後にメッセージが表示されます。

ですが、message.Wait()の待ちがUIスレッドで行われるため、画面の再描画が行われずフリーズしたように見えます。

private void button1_Click( object sender, EventArgs e ) {
    Task<string> message = HeavyFunctionAsync();
    message.Wait();
    textBox1.AppendText( message.Result );
}

// 時間のかかる処理
private async Task<string> HeavyFunctionAsync() {
    // 非同期で1秒間待つ
    await Task.Delay( 1000 ).ConfigureAwait( false ); // 別スレッドでTask処理を実行

    // 結果を返す
    return "Hello";
}

データが届くたびに少しづつ処理を進める

大量のデータを受信する時は、asyncなメソッド内でTask.Delay()を適切に挟み込んでいくことで、他の処理と並列ですこしづつ作業を進めていくことができます。Delay()の数字を大きくすると他の作業に受け渡す時間が長くなるのので、GetHtmlBodyAsync()が終わるまでの時間は長くなります。

private void button1_Click( object sender, EventArgs e ) {
    GetHtmlBodyAsync();
}

private async void GetHtmlBodyAsync() {

    string url = "http://www.yahoo.co.jp/";

    using ( var client = new HttpClient() ) {
        // 非同期でGETリクエストを送信
        System.IO.Stream body = await client.GetStreamAsync( url );

        using( var bodyTextReader = new System.IO.StreamReader( body ) ) {

            // 応答を最後まで取得するまで繰り返し
            while( !bodyTextReader.EndOfStream ) {
                // 1行分だけテキストを取得
                string bodyLine = await bodyTextReader.ReadLineAsync();

                // 取得したテキストを描画
                textBox1.AppendText( bodyLine + Environment.NewLine );

                // 5mSec待つ
                await Task.Delay( 5 );
            }
        }
    }
}

async指定したメソッドでは、戻り値をTask<>以外にvoidで指定することも可能です。ただし、voidを指定すると非同期実行している処理がいつ終わったかを呼び元は認識できません。また非同期処理内で例外が発生したときに補足することができません。

非同期処理を匿名メソッド・ラムダ式にする

関数定義の頭に付けるasyncは、delegeteと組み合わせて使うこともできます。

public Form1() {
    InitializeComponent();

    // Form1のコンストラクタで、ボタンのClickハンドラを登録
    button2.Click += async delegate ( object sender, EventArgs e ) {
        // 非同期で1秒間待つ
        await Task.Delay( 1000 ); // 別スレッドでTask処理を実行

        // 結果を画面に出力
        textBox1.Text = "Hello";
    };
}

さらにラムダ式にすることも可能です。

public Form1() {
    InitializeComponent();

    // Form1のコンストラクタで、ボタンのClickハンドラを登録
    button2.Click += async ( sender, e ) => {
        // 非同期で1秒間待つ
        await Task.Delay( 1000 ); // 別スレッドでTask処理を実行

        // 結果を画面に出力
        textBox1.Text = "Hello";
    };
}

非同期処理の中で発生した例外を補足する

非同期処理の中で発生した例外は、処理を呼び出した時ではなく、Wait()もしくはResultが呼ばれたタイミングでAggregateException例外がthrowされます。
実際に発生した内部例外はInnerExceptionsプロパティから取得できます。内部例外は複数発生しうるのでforeachでのループ処理が必要です。

// 1秒後に例外が発生する非同期処理
private async Task<string> BadFunctionAsync() {
    // 非同期で1秒間待つ
    await Task.Delay( 1000 ).ConfigureAwait( false ); // 別スレッドでTask処理を実行

    // 結果を返す
    throw new Exception( "例外が発生しました" );
}


private void button1_Click( object sender, EventArgs e ) {
    Task<string> messageAsync = null;

    // 非同期処理を呼び出し、3秒待つ
    try {
        messageAsync = BadFunctionAsync();
        System.Threading.Thread.Sleep( 3000 );
    } catch ( Exception ex ) {
        textBox1.Text = "例外をキャッチしました[1]" + ex.Message;
    }

    // 非同期処理の結果を取得する
    try {
        string message = messageAsync.Result;
    } catch ( AggregateException ex ) {
        textBox1.Text = "例外をキャッチしました[2]" + Environment.NewLine;
        foreach ( var inner in ex.InnerExceptions ) {
            textBox1.Text += inner.Message + Environment.NewLine;
        }
    }
}

このコードを実行すると以下の出力になります。たしかにResultの実行タイミングで例外を補足できていることが分かります。

例外をキャッチしました[2]
例外が発生しました


プログラミングC# 第7版

こちらもおススメ

コメントを残す

メールアドレスが公開されることはありません。