読者です 読者をやめる 読者になる 読者になる

いまさら async/await

C# 非同期

VS 2015 もリリースされて、C# 6.0 が使えるようになった今頃になって、C# 5.0 の新機能の話というのも時機を逸してますが、まぁいいじゃない。
というわけで、今のところ最も新しい非同期処理のお話です。

昔の非同期処理

実のところ、やってることは昔から大して変わらないのです。

Begin/End パターン

Asynchronous Programming Model(APM)とも言うようです。.NET における最古の非同期プログラミング手法です。
BeginXxx というメソッドを呼び出すと、裏でスレッドが立ち上げられて処理が行われます。
非同期処理が終わるとコールバック関数が呼ばれるので、コールバック内で EndXxx メソッドを呼んで結果を受け取ります。
Stream.BeginRead を例にすると、こんな感じ。

stream.BeginRead(buffer, 0, buffer.Length, this.ReadCompletedCallback, stream);

void ReadCompletedCallback(IAsyncResult result)
{
  var stream = (Stream)result.AsyncState;
  int numRead = stream.EndRead(result);
  // ここで buffer に結果が入っている
}

IAsyncResult とかいう謎の型が出てきたり、Begin メソッドに対応する End メソッドを必ず呼ばなければいけなかったり、メソッド間で(BeginRead の第 5 引数/IAsyncResult.AsyncState を通じて)いろいろなオブジェクトを受け渡ししなければいけなかったりと、正直、煩雑な処理が多いです。
上記のサンプルコードでは、ReadCompletedCallback に buffer を渡していないので、読み取り結果が取得できませんし。

ラムダ式を使うと、メソッド間での情報の受け渡しは不要になりますが、代わりにネストが深くなります。

stream.BeginRead(buffer, 0, buffer.Length, result =>
  {
    int numRead = stream.EndRead(result);
    // ここで buffer に結果が入っている
  }, null);

イベントベースの非同期パターン

Event-based Asynchronous Pattern(EAP)とも言います。.NET 2.0 から登場した方法です。
メソッド名は XxxAsync という名前です。やはり実行すると裏でワーカースレッドが作られます。
ワーカースレッドの処理が終わると、メソッド名に対応した XxxCompleted イベントが発生します。
イベントとは一種のコールバック関数ですから、キモとなる部分は変わっていないことになります。
WebClient.DownloadStringAsync を例にとりましょう。

client.DownloadStringCompleted += this.OnDownloadStringComplete;
client.DownloadStringAsync(uri);

void OnDownloadStringComplete(object sender, DownloadStringCompletedEventArgs e)
{
  string result = e.Result;
}

APM の欠点だった、以下のような点が解消されています。

  • IAsyncResult という謎の型が出てくる
  • BeginXxx に対応する EndXxx を呼ぶ必要がある

ラムダ式だとこう。

client.DownloadStringCompleted += (sender, e) =>
  {
    string result = e.Result;
  };
	
client.DownloadStringAsync(url);

変数の共有が簡単になるのはいいですが、記述の順序が逆転する(先に完了時の処理を書く)という、新たな欠点が生まれてしまっています。

タスクベースの非同期パターン

Task-based Asynchronous Pattern(TAP)です。.NET 4.0 から登場した方法です。
非同期メソッドTask を返すので、ContinueWith で後続処理を書きます。

サンプルは WebClient.DownloadStringTaskAsync
もうラムダ式でない版は不要でしょう(ラムダ式C# 3.0 からなので、EAP と TAP の中間のタイミングにあたります)。

client.DownloadStringTaskAsync(url)
  .ContinueWith(task => { string result = task.Result; });

EAP にあった、処理の記述順序の逆転という欠点が解消されています。
ただし、ネストが深くなるのは相変わらずです。
ネストが深くなるのは、コールバック関数で結果を受け取る非同期処理の宿命であり、APM の頃からずっと付きまとっている問題です。

ちなみに、EAP が出た時に XxxAsync というメソッドを作ったクラスは、TAP では XxxTaskAsync というメソッド名になっています。
EAP に対応しなかったクラスは、TAP で XxxAsync というメソッド名を使います。
APMEAP、TAP のすべてに対応しているクラスは、標準では無いみたいです。

async/await パターン

というわけで最新の方法です。
内部的には Task が使われているので、サンプルは相変わらず WebClient.DownloadStringTaskAsync です。

string result = await client.DownloadStringTaskAsync(url);

最大の特徴は、コールバック関数がどこにもないということです。
まるで、同期処理である WebClient.DownloadString にそっくりではないですか。
ちなみに同期版はこう。

string result = client.DownloadString(url);

ね。

いやしかし、非同期処理である以上、コールバック関数は必須のはずです。
どこに隠れているのでしょうか。

async/await パターンでは、C# コンパイラーによって複雑なメソッドの変換が行われます。コールバック関数はその中に隠蔽されているのです。
いったい、どのような変換が行われるのか、ちょっと覗いてみましょう。

その前に、変換前のコードは以下のようなものだとします。

async Task AsyncSample()
{
  var client = new WebClient();
  
  string url2 = await client.DownloadStringTaskAsync(url1);
  string result = await client.DownloadStringTaskAsync(url2);
}

つまり、最初のアクセスで取ってきた文字列には別のどこかの URL が書いてあるので、改めてそちらを見に行くという処理です。
サーバー側でリダイレクトしろよというのはもっともですが、ここでは置いておきましょう。

await と Awaitable と Awaiter

新しく登場した await キーワードは、メソッド呼び出しにつけるものではありません。
これは Task オブジェクトにつけるものです。
そのため、以下のように書き換えることができます。

async Task AsyncSample()
{
  var client = new WebClient();
  
  var task1 = client.DownloadStringTaskAsync(url1);
  string url2 = await task1;
  
  var task2 = client.DownloadStringTaskAsync(url2);
  string result = await task2;
}

await は正しくは Awaitable オブジェクトにつけるものです。
Awaitable オブジェクトとは、Awaiter オブジェクトを返す GetAwaiter というメソッドを持つオブジェクトのことです。
Task は TaskAwaiter を返す GetAwaiter メソッドを持つので、Awaitable オブジェクトです。

では Awaiter オブジェクトとは何でしょうか。
TaskAwaiter を例にしますが、IsCompletedGetResultOnCompleted という 3 つのメンバーを持つことが、Awaiter オブジェクトであることの要件です。
Awaitable オブジェクト(Task)に対して await するということは、これらのメソッドを呼び出すことなのです。

この中で注目すべきは OnCompleted です。
これは、現在の非同期操作が完了したときに実行するコールバック関数を指定するものだからです。Task.ContinueWith のようなものです。
さぁ、コールバック関数が出てきました。

コンパイラーによるコードの変形

上記のコードがコンパイラーによってどのように変形されるのかを見てみましょう。
なお、ここに示す例は簡略化した模式的なものであって、実際のものとは異なります。

int _state = 1;
TaskAwaiter<string> _awaiter;
WebClient _client;

void AsyncSample()
{
  if (this._state == 1)
  {
    var client = new WebClient();
    this._client = client;
    
    var task1 = client.DownloadStringTaskAsync(url1);
    var awaiter1 = task1.GetAwaiter();

    this._awaiter = awaiter1;
    this._state = 2;
    
    awaiter1.OnCompleted(AsyncSample);
  }
  else if (this._state == 2)
  {
    var awaiter1 = this._awaiter;
    string url2 = awaiter1.GetResult();
    
    var client = this._client;
    var task2 = client.DownloadStringTaskAsync(url2);
    var awaiter2 = task2.GetAwaiter();
    
    this._awaiter = awaiter2;
    this._state = 3;
    
    awaiter2.OnCompleted(AsyncSample);
  }
  else if (this._state == 3)
  {
    var awaiter2 = this._awaiter;
    string result = awaiter2.GetResult();
  }
}

何をしているか、一言で言うなら、自分自身をコールバック関数として登録しているのです。

もう一度、元のコードを見てみましょう。
このコードには 2 回の await があります。
まずはこのメソッドを、await を境界として 3 つに分割します。

async Task AsyncSample()
{
  // 1: ここから
  var client = new WebClient();
  
  var task1 = client.DownloadStringTaskAsync(url1);
  string url2 = await task1;
  // 1: ここまで
  // 2: ここから
  
  var task2 = client.DownloadStringTaskAsync(url2);
  string result = await task2;
  // 2: ここまで
  // 3: ここから
}

今コメントにつけた番号が、上のごちゃごちゃしたコードの _state だと思ってください。
本当は = を挟んで右辺の await までが「ここまで」で、左辺の結果を string に入れるところは「ここから」なのですが、そういう風にコメントを書くとわかりづらくなってしまうのでこうしました。
あのごちゃごちゃしたコードは、_state に応じた if 文によって 3 つのブロックに区切られており、一度の呼び出しではいずれか 1 つのブロックしか実行されません。
あるブロック(の中にある非同期メソッド)を実行したら、次回は次のブロックが実行されるように _state を設定して、自分自身をコールバック関数として登録する。
これがコンパイラーが内部でやっていることです。

その仕組み上、ブロック間で共有されなければならないローカル変数は、すべてメンバー変数に置き換えられています。
ブロックを抜ける前にメンバー変数に入れておき、次のブロックが開始されたら、メンバー変数から復元するということをやっているわけです。

何をしているのかわかりやすいよう、コメントをつけてみましょう。
処理の流れを追ってみてください。

int _state = 1; // 最初は _state は 1
TaskAwaiter<string> _awaiter;
WebClient _client;

void AsyncSample()
{
  if (this._state == 1)
  {
    // 最初は(_state が 1 なので)ここが実行される
    var client = new WebClient();
    
    // ローカル変数はメンバー変数に保持
    this._client = client;
    
    var task1 = client.DownloadStringTaskAsync(url1);
    
    // これが await task1 に相当(ここでは処理は止まらない)
    var awaiter1 = task1.GetAwaiter();

    // awaiter1 は次のブロックでも使うのでメンバー変数に保持
    this._awaiter = awaiter1;
    
    // 次回は次のブロックが実行されるように設定
    this._state = 2;
    
    // 自分自身をコールバック関数に設定
    // DownloadStringTaskAsync が終わったら、また AsyncSample が呼ばれる
    awaiter1.OnCompleted(AsyncSample);
    
    // DownloadStringTaskAsync の完了を待たずに AsyncSample は一旦終了
  }
  else if (this._state == 2)
  {
    // 最初の DownloadStringTaskAsync が終わったら(_state が 2 になっているので)ここに来る
    // メンバー変数から awaiter1 を復元
    var awaiter1 = this._awaiter;

    // awaiter1 から結果を取得
    string url2 = awaiter1.GetResult();
    
    // client をメンバー変数から復元
    var client = this._client;

    var task2 = client.DownloadStringTaskAsync(url2);

    // await task2
    var awaiter2 = task2.GetAwaiter();
    
    // 後は同じ…
    this._awaiter = awaiter2;
    this._state = 3;
    
    awaiter2.OnCompleted(AsyncSample);
  }
  else if (this._state == 3)
  {
    var awaiter2 = this._awaiter;
    string result = awaiter2.GetResult();
  }
}

念のため繰り返しますが、これは簡略化した模式的なものであって、実際のものとは異なります。

まとめ

コンパイラーがやっていることは、await を境界としてメソッド複数のブロックに分割し、非同期処理が終わったら次のブロック(後続の処理)が実行されるように、自分自身をコールバック関数に登録するということです。
自分自身をコールバック関数にしてしまうことで、コード上に明示的なコールバック関数が現れないようになっているのです。

さて、長くなったのでいったんここで締めます。
次回は SynchronizationContext について。