async/await のしくみ
岩永 信之
前振り: C#の歴史
1.0
2.0
3.0
4.0
5.0
6.0
7.0
初期リリース
ジェネリクス
イテレーター
LINQ
dynamic
相互運用
async/await
Compiler
Platform (Roslyn)
タプル
パターンマッチ
Unity 5.X 以前 Unity 2017/2018
Incremental
Compiler
本日のテーマ
ちなみに、
C# 7.2まで行ける
await演算子: 登場以前
• 元(async/await以前)
• コールバック地獄
var c = new HttpClient();
c.GetAsync("http://ufcpp.net").ContinueWith(tr =>
{
var res = tr.Result;
res.Content.ReadAsStringAsync().ContinueWith(tc =>
{
var content = tc.Result;
Console.WriteLine(content);
});
});
もっと面倒なことも
• 例外処理は?
• 分岐やループは?
await演算子: 登場後
• C# 5.0以降
• 非同期なところにawait演算子を足すだけ
• 例外処理 → try-catchが普通に使える
• 分岐やループ → if, for, while, foreachが普通に使える
var c = new HttpClient();
var res = await c.GetAsync("http://ufcpp.net");
var content = await res.Content.ReadAsStringAsync();
Console.WriteLine(content);
今日の話
• ほんとに非同期なの?
• ちゃんとコールバック呼び出しに展開されてる
• オーバーヘッド大きくない?
• ある程度やっぱある
• 用途には注意
• Rxもあるけど?
• 単一の値待ちにはawaitを
• UniRxにもAwaiterあるよ
この辺りのことを
中身を追いつつ説明
非同期処理
そもそも、非同期って?
非同期もいろいろ
• 同時実行 (concurrency)
• 並列実行 (parallelism)
• I/O待ち (I/O completion)
同時実行 (concurrency)
• CPUをシェア
• 誰かが重たいことやってても、他のみんなに迷惑かけない
• OSの強権(preemptive)で処理を奪い取って切り替え
スレッド1
スレッド2
• 一定時間ごとにスレッドを切り替え
• 1人の独占を許さない(OSが強制)
• 例え単一CPUコアでも同時に複数の処理が動いてる
同時実行とUIスレッド
• GUIアプリには「UIスレッド」がある
• ユーザーからの入力を受け付けているスレッド
• このスレッドを止めるとフリーズ感がひどい
UIスレッド
その他のスレッド
UIスレッドが空いているときだけ
ユーザー入力を受付可能
ユーザー入力
UIスレッド上で
重たい処理をすると
フリーズを起こす
並列実行 (parallelism)
• 複数のCPUをフルにぶん回したい
• それぞれのCPUで別スレッドを実行
• 上手く使えれば、N個のCPUでN倍の処理ができる
• (複数のスレッドで同じデータを共有すると上手くいかない)
スレッド1
スレッド2
…
• 複数のCPUで処理を振り分け
I/O待ち (I/O Completion)
• CPUの外の世界とのやり取り
• CPUと比べて、外の世界は数ケタ以上遅い
• 遅いものを待っている間、他の処理をしないのはもったいない
• 使いもしないリソースを握りっぱなしにするのはもったいない
メイン メモリ
(2~3桁遅い)
シリコン ドライブ
(3~4桁遅い)
HDD
(5~6桁遅い)
インターネット
(物理的に近くても7桁くらい遅い)
CPUの
外の世界
リソースを解放
コールバック
awaitはどれ?(1)
• awaitが一番活躍するのは非同期I/O待ち
var c = new HttpClient();
var res = await c.GetAsync("http://ufcpp.net");
var content = await res.Content.ReadAsStringAsync();
Console.WriteLine(content);
コールバック
リソースを解放
リソースを
解放
awaitはどれ?(2)
• UIスレッドを止めないのにも有効
async void OnClick(object sender, EventArgs args)
{
// ユーザー入力を受け取り
await Task.Run(重たい処理);
// 結果を UI に表示
}
UIスレッド
その他のスレッド
重たい処理はUIス
レッドの外でやら
ないとフリーズ
Runの中身は別のスレッド
で実行される
UIスレッドに戻す仕組み(同期コンテキスト)については後述
標準ライブラリの
非同期処理機能
C#で非同期処理するのにどんなクラスを使うか
.NETの非同期処理がらみ
• Thread (生のスレッド)
• ThreadPool (スレッドの再利用)
• その上にいろいろ
• Task
• UniTaskとかもこのレイヤー
Thread (System.Threading)クラス
• 「同時実行」で話したスレッドそのものを表すクラス
• OSが強権を持って切り替えを保証
• その代わりだいぶ重たい
スレッド1
スレッド2
これ
var t = new Thread(() =>
{
// 新しいスレッドで実行したい処理
});
t.Start();
スレッドの負担
• スレッドが消費するリソース(1スレッド辺り)
• スレッド自体の管理情報(1KB※)
• スタック メモリ(1MB※)
• スレッド開始・終了時のコスト
• OSのイベントが飛ぶ
• スレッド切り替えに伴うコスト
• OSの特権モードへの移行・復帰
• レジスターの保存・復元
• 次に実行するスレッドの決定
※ Windowsの場合
どれもそれなりに
性能へのインパクト大きい
Threadクラスを生で使うことは
ほとんどない
スレッドプール
• 事前にいくつかスレッドを立てておいて、使いまわす仕組み
• スレッドに係る負担を削減
• ただし、優先度とか実行順とかの細かい保証はできない
スレッド プール
キュー
タスク1
タスク2
…
数本のスレッドだけ用意
(足りなくなったら増やす)
空いているスレッドを探して実行
(長時間空かない時だけ新規スレッド作成)
新規タスク
タスクは一度
キューに溜める
I/O待ちとスレッドプール
• 非同期I/O API
• WindowsだとI/O完了ポートっていうAPIを利用
• (Linuxだとepoll、BSD/Macだとkqueue)
• I/Oが完了したらスレッドプールにコールバック処理を投函する作り
var bytes = await File.ReadAllBytesAsync("file name");
スレッド プール
キュー
I/O完了ポート
I/O開始
I/O完了
タスク1
ThreadPool (System.Threading)クラス
• 名前通り、スレッド プールを使うためのクラス
• .NET 3.5時代まではよく使った
• まだ使いづらい点がある
• 非同期処理の完了を待って違う非同期処理を開始したいとき
• 特に、例外や、処理の結果得られる値を使いたいとき
ThreadPool.QueueUserWorkItem(_ =>
{
// スレッド プール上で実行したい処理
});
// ここに何か書くと、↑とは同時実行になる(完了を待てない)
Task (System.Threading.Tasks)クラス
• 非同期処理の「続き」が書けるクラス
• 新規に非同期処理を始める時はRun
• 非同期処理のあとに何か続けたいときはContinueWith
Task.Run(() =>
{
// 非同期処理
return 戻り値;
}).ContinueWith(t =>
{
var result = t.Result;
// 続きの処理
});
特に指定がない場合※、
スレッドプール上で実行される
※ 指定が必要ならTaskSchedulerと言うものを渡す
Taskクラスとawait
• ここで冒頭の方で話したコールバック地獄の話に
• ちなみに、Taskクラス以外に対してもawaitできる
• この後、その仕組みについての話を
Task.Run(() =>
{
// 非同期処理
return 戻り値;
}).ContinueWith(t =>
{
var result = t.Result;
// 続きの処理
});
var result = await Task.Run(() =>
{
// 非同期処理
return 戻り値;
});
// 続きの処理
awaitの中身
サンプル:
https://github.com/ufcpp/UfcppSample/tree/master/Demo/2018/AsyncInternal
例として: ネットとかから一覧取得
• 同期処理で良ければ
// インデックスをどこかから取って来て
var indexes = GetIndex();
// その中の一部分を選んで
var selectedIndexes = SelectIndex(indexes);
// 選んだものの中身を取得
var contents = new List<string>();
foreach (var index in selectedIndexes)
{
var content = GetContent(index);
contents.Add(content);
}
ネットから取ってきたり
ファイルから読むにしてもI/O
ユーザーに選択してもらったり
ユーザー入力もI/O
同じくネットから で、これを非同期にしたい
コールバック型の非同期だと
// インデックスをどこかから取って来て
GetIndex(indexes =>
{
// その中の一部分を選んで
SelectIndex(indexes, selectedIndexes =>
{
// 選んだものの中身を取得
var contents = new List<string>();
var e = selectedIndexes.GetEnumerator();
Action<string> getContentCallback = null;
void next()
{
if (e.MoveNext()) GetContent(e.Current, getContentCallback);
else callback(contents);
}
getContentCallback = content =>
{
contents.Add(content);
next();
};
next();
});
});
ネストが
深くなる
フォントを12ptに変えないと
入らない程度に長くなる
foreachを手動で展開
async/awaitなら
• awaitを使って書き直し
// インデックスをどこかから取って来て
var indexes = await GetIndex();
// その中の一部分を選んで
var selectedIndexes = await SelectIndex(indexes);
// 選んだものの中身を取得
var contents = new List<string>();
foreach (var index in selectedIndexes)
{
var content = await GetContent(index);
contents.Add(content);
}
(2ページ前の同期コードと比べて)
非同期処理なところにawaitを足すだけ
処理フローは同期の場合とまったく同じ
async/awaitの中身
• 同期っぽいコードから非同期コードを機械的に生成してる
• 生成しているのはコールバック型のコード
• 次のスライドから生成手順を紹介
• 何段階かにわけて説明
• 段階ごとにコミットした例:
https://github.com/ufcpp/UfcppSample/commits/85f7901c19264b2b9b1547a87ef2
d80bb49b076d/
段階1: クラス生成
• ただのメソッドからクラスを生成する
• (このデモでは匿名関数で代用※)
void anonymous() =>
{
var indexes = GetIndex();
var selectedIndexes = SelectIndex(indexes);
var contents = new List<string>();
foreach (var index in selectedIndexes)
{
var content = GetContent(index);
contents.Add(content);
}
};
※ プレゼン都合(匿名関数の方がコードが短い)。匿名関数も内部的にはクラスが生成される
段階2: 中断・再開コード
• 状態の記録とgoto用ラベル挿入
var indexes = await GetIndex();
state = 1;
tIndexes = GetIndex();
if (!tIndexes.IsCompleted)
{
tIndexes.ContinueWith(_ => anonymous());
return;
}
Case1:
var indexes = tIndexes.Result;
tIndexes = default;
どこまで実行したかを記録して
再開時にはここにgoto
完了済みの場合は素通りして
未完了ならContinueWithで
自分自身をコールバック登録
結果を受け取って
一時変数を後片付け
(ほんとは ContinueWithだけじゃなく
SynchronizationContext.Postも)
この時点で言えること
• 非同期メソッドは必ずしも非同期じゃない
• 1個目のawaitよりも手前までは普通に同期実行してる
• 完了済みタスクをawaitしてもコストは低い
• イテレーター(yield return)に似てる
• どこまで実行したかの記録と、再開時のgoto
• Unityのコルーチンと源流は同じ
• イテレーターそのもので非同期処理しなかったのは:
• 戻り値があるとしんどい
• 例外処理しんどい
段階3: Awaitableパターン化
• Task以外の型もawaitできるように(Awaitableパターン)
var indexes = await GetIndex();
state = 1;
tIndexes = GetIndex().GetAwaiter();
if (!tIndexes.IsCompleted)
{
tIndexes.OnCompleted(() => anonymous());
return;
}
Case1:
var indexes = tIndexes.GetResult();
tIndexes = default;
そのものではなく、所定のパターンを
満たすAwaiterと呼ばれる型を介する
OnCompletedメソッドと
GetResultメソッドを
持っていればどんな型でもOK
awaitableに求められる要件
• awaitable (await演算子を使える型)
• awaiter GetAwaiter()
• awaiter
• bool IsCompleted, T GetResult()
• INotifyCompletion.OnCompleted(Action)
• ICriticalNotifyCompletion.UnsafeOnCompleted(Action)
一段別の型を挟む(拡張できるように)
awaiterの例
• 継続呼び出しの仕方をカスタマイズ
Task t;
await t;
Task t;
await t.ConfigureAwait(false);
Task.GetAwaiter
TaskAwaiterクラス※
var s = SynchronizationContext.Current;
t.ContinueWith(t1 =>
{
s.Post(_ => continuation(), null);
});
ConfiguredTaskAwaitable.GetAwaiter
ConfiguredTaskAwaiterクラス※
t.ContinueWith(t1 =>
{
continuation();
});
※ この通りのコードになっているわけではなく、似たような挙動になるコードを例示
同期コンテキスト
(次項で説明)
UIスレッド(メイン スレッド)
• UI関連の処理は単一のスレッドで行われている(UIスレッド)
UIスレッド その他のスレッド
ユーザーからの入力イベント
はUIスレッド上で処理される
描画APIはUIスレッド上
でだけ呼べる
同期コンテキスト
• SynchronizationContextクラス(System.Threading)
UIスレッド その他のスレッド
入力
イベント Task.Run
SynchronizationContext
.Post
重たい処理
描画
所望のスレッドに戻ってくるため
に使うのが同期コンテキスト
同期コンテキストの使い方
1. Currentで現在のスレッドのコンテキスト(文脈)を取得
2. Postでそのコンテキストに処理を戻す
// ユーザー入力を受け取り
await Task.Run(重たい処理);
// 結果を UI に表示
// ユーザー入力を受け取り
var sc = SynchronizationContext.Current;
Task.Run(重たい処理).ContinueWith(t =>
{
sc.Post(_ =>
{
// 結果を UI に表示
}, null);
});
TaskAwaiterはこの処理を
内部で自動的にやってる
段階4: ローカル変数をフィールドに
• awaitをまたぐためにはローカル変数ではダメ
• 継続呼び出し時に値を保持できるように、フィールドに変更する
• (このデモでは匿名関数+変数キャプチャで代用※)
void anonymous() =>
{
...
var indexes = tIndexes.GetResult();
tIndexes = default;
...
}
List<string> indexes;
void anonymous() =>
{
...
indexes = tIndexes.GetResult();
tIndexes = default;
...
}
※ 匿名関数で変数をキャプチャすると、内部的にはフィールドが生成される
段階5: 戻り値
• TaskCompletionSourceに置き換え
void anonymous() =>
{
...
return contents;
}
var r = new TaskCompletionSource<IEnumerable<string>>()
void anonymous() =>
{
...
r.SetResult(contents);
}
return r.Task;
段階6: ビルダー パターン化
• 戻り値や、OnCompleted呼び出しの部分もパターン化
(非同期メソッド ビルダー パターン)
• Task以外の戻り値を返せるようにする
var r = new TaskCompletionSource<...>()
...
{
...
tIndexes.OnCompleted(...);
...
r.SetResult(contents);
}
return r.Task;
var builder = new AsyncTaskMethodBuilder<...>()
...
{
...
builder.AwaitOnCompleted(ref tIndexes, ref this);
...
builder.SetResult(contents);
}
return builder.Task;
(MethodBuilderに求められる要件は複雑なので割愛)
Task以外の戻り値の例
• ValueTask構造体(System.Threading.Tasks名前空間)
• 同期的に完了している場合は値をそのまま保持
• 真に非同期が必要な場合だけTaskクラスを作る
async Task<int> XAsync()
{
if (_9割方true()) return 1;
await Task.Delay(1);
return 0;
}
9割方、同期にもかかわらず
常にTaskクラスがnewされる
async ValueTask<int> XAsync()
{
if (_9割方true()) return 1;
await Task.Delay(1);
return 0;
}
• 戻り値をValueTaskに変えるとTaskのnewがなくなる
• 内部ではAsyncValueTaskMethodBuilderが使われる
まとめ
• await演算子
• 同期処理と同じ書き方で非同期処理できる
• 中断・再開コードを生成してる
• 最初のawaitまでは実は同期処理
• 真に非同期処理を必要としている場合だけContinueWith
• パターン化
• awaitableパターン: Task以外をawaitできる
• 非同期メソッド ビルダー パターン: Task以外を戻り値にできる

async/await のしくみ

Editor's Notes

  • #11 ゲームだと、あんまり重たい処理だったら、事前に近似値テーブル化したりするんで、案外ゲームでCPUフルにぶん回す案件意外とないかも まあ、最近はUnity上でも動く機械学習ライブラリとかもあったりするし?
  • #12 http://www.prowesscorp.com/computer-latency-at-a-human-scale/
  • #37 staticなもの(Current)への依存がキモイとか、自動的にやるのが怖いとかいう問題はあるものの。 なので、それが嫌なら自前のtask-like型作成をしたり → UniTask