LoginSignup
66
57

More than 5 years have passed since last update.

【図解】C#のasync/awaitの内部挙動を理解する

Last updated at Posted at 2019-01-31

はじめに

async/awaitとは?

一言でいうと、本来Begin/Endパターンやコールバック関数を使用して非同期的に書かなければいけない処理を同期的に書ける技術です。
非同期的な書き方と異なり、プログラムで実現しようとしているロジックを一連の流れで追いやすくなるという意味で活気的な書き方ができ、非常に魅力があります。その一方で、実際の内部挙動、スレッドの使い方は隠蔽されてしまうため、イメージがつかみにくいという面もあると思います。

この記事で書きたいこと

ある程度async/awaitの書き方を知っている方向けに、内部的な動きを深堀りしてみたいと思います。

本題

ソースコードサンプルを見てみる

早速ですが。
まず、サンプルとしてasync/awaitを使って記述したものがこちら。
(処理内容の意味は皆無です。ただ前処理の結果を受けて連続呼び出しをしたかっただけです。)

public class SampleClass
{
    public async Task<int> SampleMethodAsync(string filepath1)
    {
        var filepath2 = await File.ReadAllTextAsync(filepath1);
        var bytes = await File.ReadAllBytesAsync(filepath2);
        return bytes.Length;
    }
}

これを、async/awaitを使わずに書いてみるとこうなります1

public class SampleClass
{
    public Task<int> SampleMethodAsync(string filepath1)
    {
        var stateMachine = new SampleMethodAsyncStateMachine()
        {
            _arg_filepath1 = filepath1,
            _state = -1,
        };
        stateMachine.MoveNext();
        return stateMachine._tcs.Task;
    }

    private class SampleMethodAsyncStateMachine
    {
        public int _state = 0;
        public string _arg_filepath1;
        public TaskCompletionSource<int> _tcs = new TaskCompletionSource<int>();

        private TaskAwaiter<string> _awaiter1;
        private TaskAwaiter<byte[]> _awaiter2;

        public void MoveNext()
        {
            try
            {
                if (_state == 0) goto label_state0;
                if (_state == 1) goto label_state1;

                _awaiter1 = File.ReadAllTextAsync(_arg_filepath1).GetAwaiter();
                if (!_awaiter1.IsCompleted)
                {
                    _state = 0;
                    _awaiter1.OnCompleted(MoveNext);
                    return;
                }

                label_state0:
                var awaiterResult1 = _awaiter1.GetResult();
                var filepath2 = awaiterResult1;
                _awaiter2 = File.ReadAllBytesAsync(filepath2).GetAwaiter();
                if (!_awaiter2.IsCompleted)
                {
                    _state = 1;
                    _awaiter1.OnCompleted(MoveNext);
                    return;
                }

                label_state1:
                var awaiterResult2 = _awaiter2.GetResult();
                var result = awaiterResult2.Length;
                _state = -2;
                _tcs.SetResult(result);
            }
            catch (Exception e)
            {
                _state = -2;
                _tcs.SetException(e);
            }
        }
    }
}

いかがでしょうか?
まずは詳細に使用しているクラスやメソッドの意味を追求する必要はないです。
是非、クラス名やメソッド名の英語から感じ取れる範囲で眺めてみてください。

ざっと説明してみると下記のようなイメージでしょうか。

  • awaitする処理がIsCompletedでない場合(同期的に完了しない場合)
    • awaitする処理の完了時点で発火するイベントハンドラとしてMoveNext(自身のメソッド)を登録して、
    • 今のstateをフィールドに保存して、
    • 即時returnする。
    • その後、awaitする処理が完了した場合、
    • 完了イベントが発火して、イベントハンドラ経由でMoveNextが、
    • 前回保存したstateで実行される
  • awaitする処理がIsCompletedである場合(同期的に完了した場合)
    • 次のstateへ処理を進める

ポイントは、IsCompletedじゃなかった場合に即時returnしてしまうところ、です。私はこれでやっとawaitの動きがわかった気になれました。

async/awaitは状態遷移機械を作る

上記で見たことをまとめると、async/awaitは状態遷移機械を作ります。
(コンパイラが吐くコードからもStateMachine等のキーワードが見て取れます)

statemachine.png

awaitの個数に比例して状態が作られます。
このように進捗を記録することで、制御上は一旦returnしても、途中の状態から処理を再開できます。
このようにすることで、実際には非同期的に実行している処理も、プログラム上は同期的に見せかけることができるのです。

awaitの動きをシーケンスで俯瞰する

1個のメソッド内の動きはだいたい上の部分で把握できたと思います。
awaitをした処理の呼び出し先を考えてみましょう。
何らかの処理をawaitをしているかもしれません。
もしそうだとしたら、その何らかの処理のその呼び出し先は?
さらにその先は?その果てには何が待っているでしょう?

次のどれかになるはずです。

  • OSに依頼するI/O処理2
  • 明示的に他のスレッドを使って並列化した処理
  • 単なる普通の同期的な処理

まず前者2つを見てみます。これらは性質の全く異なるものですが、

  • 自分のスレッド以外の他スレッドに処理を依頼する
  • 他スレッドの処理の完了を検知できる(完了イベントで発火するイベントハンドラを登録できる)

という部分は共通項として括ることができそうです(ここでは強引に括ります)。

この場合、呼び出し関係のシーケンスは下のようになります3

basic.png

色が付いた角が丸い囲いは、囲いの中の処理はすべて同じスレッドで処理されることを示しています。
また、囲い同士はそれぞれ異なるスレッドとなる可能性があります。
(実際に、どういう風にスレッドが選択されるかについては次の節で説明しようと思います)

最後に「単なる普通の同期的な処理」です。これは意外と見落としがちかもしれませんが、

private string cache = null;
public async Task<string> GetCachedData()
{
   if (cache == null) {
       cache = await GetDataAsync();
   }
   return cache;
}

みたいなメソッドがをあった時、2回目以降のアクセスでcacheがnullでない場合などはこれに当てはまります。

この場合、awaitの内部挙動で言うところのIsCompletedがtrueで評価されるパターンとなり、即座に同じスレッドで後続処理が続行されます。結果的に、最初から最後まで同じスレッドで、同期的に実行されることになります。

同様にシーケンスで示すと以下のようになります。

sync.png

また、最後に参考として、asyncメソッドをawaitではなくWait()またはResultを使用して同期的に待ってみた場合、どのようなシーケンスになるかを見てみましょう。

block-non-gui.png

このように呼び出し元を実行していたスレッドが最後まで使用され続けます。
待っている間はスレッドがブロックされている状態となり、

  • この間はこのスレッドがスレッドプールに戻れなくなり(他の作業に活用することができない)
  • それによって他のスレッドが作られやすくなり(スレッドを作ることにコストがかかる)
  • さらにスレッドが増えることによりコンテキストスイッチのコストも増える可能性があります

asyncメソッドはできるだけawaitするほうがよいといえるでしょう。

awaitする前後で使用されるスレッドの関係

もう一度、最初のシーケンスを見てみましょう。

basic.png

この色が異なる角が丸い囲いは、囲いの中の処理はすべて同じスレッドで処理されることを示していて、また、囲い同士はそれぞれ異なるスレッドとなる可能性がある、ということでした。

ここでは、これらのスレッドの関係がどのようになるか見ていきます。

おおまかに言えば、次のようになります4

  • 「前のスレッド」がGUIスレッドである場合は、「後のスレッド」もGUIスレッドとなる。
  • 「前のスレッド」がGUIスレッドでない場合は、スレッドプールから渡されるスレッドが「後のスレッド」となる。

GUIスレッドは、WindowsFormやWPFなどのクライアントアプリケーションで特別扱いされるスレッドです。ユーザからの入力を受け取ったり、画面に表示しているUI部品を更新したりするのはこのGUIスレッドしか行うことができません。

GUIスレッドで時間のかかる処理をしてしまった場合、その間ユーザの入力を受け付けることができないため、ユーザエクスペリエンスはとても悪いものになります。そのため、このようなクライアントアプリケーションで非同期処理を扱うことは非常に重要となります。

また、UI部品を更新できるのはGUIスレッドのみであるため、他のスレッドからUI部品を直接更新しようとしても、実際に更新することはできません。
それでは時間のかかる処理の結果を、ユーザエクスペリエンスを損なうことなく画面に反映するためにはどうすればよいのか、ということになります。
そこで、これらのアプリケーションではSynchronizationContextという仕組みを使うことにより、どのスレッドからでもGUIスレッドに作業を依頼できるようになっています。

そしてこのSynchronizationContextの仕組みを表からは見えない形で利用しているのがawaitなのです。冒頭に書いたように、awaitの前の処理を実行するスレッドがGUIスレッドである場合は、awaitの処理が完了した後の後続処理を実行するスレッドもGUIスレッドとなるように、awaitが取り計らってくれるのです。これにより、クライアントアプリケーションの開発者はSynchronizationContextを意識せずとも、awaitした処理の結果を使って画面を更新することができます。ほぼ同期処理を同じ書き方で、時間のかかる処理を非同期で実行し、その結果を画面に反映させることができるのです。次のようなイメージです。

private void button1_Click(object sender, EventArgs e)
{
    var result = await GetResultAsync();
    this.Label.Text = result;
}

このことを踏まえて、先ほどの図を眺めてみましょう。内容に合わせて少し色を変えています。

gui-basic.png

呼び出し元のスレッドがGUIスレッドであるとします。そうするとawait前の処理を全てGUIスレッドが実行することになります。そのあと、呼び出し先の末端まで進んで、IsCompletedでないことを確認して戻ってきて、呼び出し元まで戻ってきたとします。
そうするとGUIスレッドは現時点で処理することがなくなり、ユーザからの入力を待機する状態となります。
その後、呼び出し先の末端部分の時間のかかる処理が完了した後、各Asyncメソッドの後続処理が実行されていきますが、そのとき使用されるスレッドが全てGUIスレッドとなります。

結果として、呼び出し先の末端部分以外はすべてGUIスレッドで実行されますが、同期的にWait()またはResultを呼んだ場合と異なり、呼び出し先の末端が処理をしている間はGUIスレッドは、他の作業ができる状態となっています。

かなり、前者の「前のスレッド」がGUIスレッドである場合の話が長くなってしまいました。
後者の「前のスレッド」がGUIスレッドでない場合は、awaitした処理が完了した時点で、後続の作業がスレッドプールのキューに入り、順次スレッドプールのスレッドに処理されていきます。
そのため、先ほどのGUIのスレッドと異なり、awaitの後の処理は、全て異なるスレッドで処理される可能性があります。

デッドロックとConfigureAwait

上の節で、awaitの前がGUIスレッドであった場合、awaitの後もGUIスレッドになり、そのおかげでUI部品の更新が可能になっている、という話をしました。この話には裏があります。

負の側面として、デッドロックを引き起こす原因となりうる、というものがあります。
具体例を見てみましょう。GUIスレッド上で実行している呼び出し元のメソッドが、AsyncメソッドをawaitではなくWait()またはResultで同期的に待つことにしたとしましょう。

呼び出し先の末端部分が長時間の処理を行っている間、GUIスレッドは呼び出し元のWait()またはResultでブロックされた状態となっています。

呼び出し先の末端部分の処理が完了したとします。
この完了を起点として、末端部分を直接呼び出したAsyncメソッドの後続処理が実行されます。どのスレッドで実行しようとするかというと、上の節の内容からするとGUIスレッドでしたね。

  • GUIスレッドは呼び出し元が呼んだAsyncメソッドの完了を待っている
  • 末端部分を直接呼んだAsyncメソッドはGUIスレッドが空くのを待っている

これだけでデッドロックが発生してしまいます。怖いですね。
しつこいですが、また例のシーケンス図を載せておきます。

block-deadlock.png

このデッドロックを回避するためにはどうすればよいでしょうか。一つには、呼び出し元がWait()やResultをできるだけ使わないようにして、できるだけawaitで待つということが挙げられます。ユーザエクスペリエンスを損なわないためにもこれがベターな回避策です。

ですが、とはいっても、何らかの理由で同期的に待たなければいけない可能性を排除できません。また、Asyncメソッドをpublicとして公開したいライブラリの開発者の立場に立つと、同期的に待つだけで簡単にデッドロックしてしまうような機能を公開したくはないですよね。

そこで、 ConfigureAwait(bool continueOnCapturedContext) というTaskクラスのメソッドを使って、Asyncメソッドの作成者側からこの問題を回避することができます。次のように使います。

var result = await GetResultAsync().ConfigureAwait(false);

引数にtrueを渡すとConfigureAwaitを使わなかった場合と同じ挙動になり、falseを渡すと異なる挙動となります。どのような挙動になるかというと、awaitの後の処理がGUIスレッドではなく、スレッドプールから渡されるスレッドによて実行されるようになります。これにより、デッドロックを回避することができます。後続処理がGUIスレッドを必要としない場合や、ライブラリを開発する場合にはConfigureAwait(false)を付けるようにしておくとよいでしょう。

また、例によってシーケンス図で確認しておきます。

block-gui-configure.png

ConfigureAwai(false)をつけることによって、GUIスレッドではなくスレッドプールのスレッドを使うようになったため、デッドロックを起こさず最後まで処理を完遂することができました。注意としては、全てのawaitの箇所でConfigureAwait(false)を呼び出さないとデッドロックに陥ってしまうということです。awaitの前の処理を全てGUIスレッドで実行しているので、全てのawaitの後の処理は何もしなければ全てGUIスレッドに戻ろうとしてしまいます。

また、呼び出し元がWait()やResultを使用せずにawaitを使用した場合は、ConfigureAwait(false)をつけることでGUIスレッドの占有時間を減らすことができます。

gui-configure.png

この図では青い囲いの部分のみがGUIスレッドとなっています。
このように、呼び出し元メソッドの後続処理以外はGUIスレッドでない別のスレッドで処理させることができます。その結果、別のスレッドで処理している間はGUIスレッドは別の作業を待機することができることになります。

ただ、勢い余ってUI部品を更新したい場合にまで ConfigureAwait(false) をしないように注意してくださいね。この場合は逆にGUIスレッドを使わないと反映できなくなってしまいます。

最後に

最後まで読んでいただき、ありがとうございました!
うっかりとても長くなってしまいました。。。

いざ書き始めてみると自分自身もわかっていないことが多くあり、調べたり勉強したりしながらなんとか書き終えました。まだ理解が足りず曖昧になってしまっている部分もあったりするかと思います。学びながら追記、または新記事を書くなどしてみようと思います!
あと、もっと書くこと整理しなければ、、ですね。

もし誤りやよくない記述、改善コメントがありましたら、是非コメントお願いいたします!


  1. このコードは実際にコンパイラが出力するものとは異なります。できるだけ本物に忠実に書いてみましたが、(だから長いのですが)、何箇所かは解説用に修正しています。実際にコンパイラが出力するコードを見てみたい場合は、ILSpyというツールを使って見ることができます(Visual Studioの拡張機能にもあります)。ILSpyを使うと、C#をビルドした結果のMSILから逆コンパイルしたC#を見ることができます。そのC#のバージョンをasync/awaitのないC# 4.0まで下げてやれば、上のようなコードを見ることができると思います。 

  2. 例えばキー入力を取得したり、ネットワークからデータを送受信したりする場合等。先ほどのサンプルコードで扱ったファイル処理もこれです。ライブラリを使っていて一番よく出くわすのはこれだと思います。 

  3. とても単純化しています。具体的には呼び出し先の全てのメソッドでawaitは1回しかしていないものと仮定しています。 

  4. 厳密にはSynchronizationContextを使って判定されます。 

66
57
1

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
66
57