Async/await
01 何が問題だったのか
Swift での非同期処理は、長らくクロージャとコンプリーションハンドラを使って書かれてきました。たとえば次のように、非同期操作のたびに結果を受け取るクロージャを渡す API が一般的でした。
func loadWebResource(_ path: String, completion: (Resource?, Error?) -> Void)
このスタイルは単体では問題に見えませんが、複数の非同期処理を組み合わせたり、エラー処理を挟んだり、分岐を書こうとすると、さまざまな破綻が生じます。
ピラミッド状のネスト
非同期処理を順に実行すると、クロージャが深くネストしていきます。
func processImageData1(completionBlock: (_ result: Image) -> Void) {
loadWebResource("dataprofile.txt") { dataResource in
loadWebResource("imagedata.dat") { imageResource in
decodeImage(dataResource, imageResource) { imageTmp in
dewarpAndCleanupImage(imageTmp) { imageResult in
completionBlock(imageResult)
}
}
}
}
}
処理の流れを追うのが難しく、どのコードがいつ実行されるのかを頭の中で組み立てる必要があります。
エラー処理が煩雑
Swift 2 で導入された同期コードのエラー処理モデル(throws / try)は、コンプリーションハンドラ方式では活用できません。各段階ごとに guard や do-catch、switch による分岐と、失敗パスでのコンプリーションハンドラ呼び出しを手で書き下す必要があります。Result を使っても、ネストの深さ自体は解消できません。
条件分岐を含む非同期処理が書きにくい
「ある条件のときだけ非同期処理を挟む」というコードは、非同期に揃えるために、後続の処理を続きのクロージャとして先に定義しておく、という不自然な構造になります。処理の自然な上から下への流れが反転し、キャプチャや寿命にも気を使う必要が出てきます。
コンプリーションハンドラの呼び忘れ・二重呼び出し
早期リターンのときにコンプリーションハンドラを呼び忘れる、呼んだ後に return を忘れて続行してしまう、といったバグが非常に起こりやすく、発見しづらい問題でした。コンパイラが抜け漏れを検出できないため、完全に人手のレビュー頼みになります。
非同期 API が敬遠され、同期的に書かれてしまう
コンプリーションハンドラでの非同期 API はあまりに書きづらいため、本来非同期であるべき処理(ブロックしうる処理)が同期 API として公開されてしまう傾向があります。UI の固まりやサーバでのスケーラビリティ低下など、実害のある設計問題につながります。
加えて、非同期処理を中断・再開する仕組みが言語レベルに存在しないため、「ここで処理が中断されうる」というポイントがコード上で見分けられません。これはデータ競合や UI の中途半端な更新(部分的に構築した UI が表示される等)といった、アトミック性に関する問題の温床になります。
全体として、「非同期コードを、同期コードと同じ自然な制御フローで書けること」「中断しうるポイントが構文上明示されること」の両方が欠けており、これらを言語レベルで整備する必要がありました。
02 どのように解決されるのか
非同期関数(async 関数)と await 式を言語に追加し、非同期処理を同期コードと同じ「上から下へ」の制御フローで書けるようにします。コンパイラが async 関数を適切なクロージャとステートマシンへと変換するコルーチン方式のモデルで、ランタイムでの効率的な中断・再開を可能にします。
基本の書き方
関数やイニシャライザを async として宣言します。throws と併用する場合、順序は必ず async throws(または async rethrows)です。
func loadWebResource(_ path: String) async throws -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async throws -> Image
func dewarpAndCleanupImage(_ i: Image) async throws -> Image
func processImageData() async throws -> Image {
let dataResource = try await loadWebResource("dataprofile.txt")
let imageResource = try await loadWebResource("imagedata.dat")
let imageTmp = try await decodeImage(dataResource, imageResource)
let imageResult = try await dewarpAndCleanupImage(imageTmp)
return imageResult
}
コンプリーションハンドラを使った以前のバージョンと比べ、ネストが消え、エラーは通常の throws で扱え、早期リターン時の呼び忘れのような事故も構造的に起こり得なくなります。
非同期関数のセマンティクス
async 関数は「自分のスレッドを手放す能力を持った普通の関数」と考えてください。同期関数のように呼び出しを行い、多くの場合はそのまま結果を待ちますが、内部で中断ポイント(suspension point)に到達した時点でスレッドを手放し、再開可能になるまで他の処理にスレッドを譲ります。再開時に同じスレッドに戻る保証はありません(ただしアクターに結び付いた関数は、そのアクター上に戻ることが保証されます)。
中断ポイントは常に構文上明示されます。具体的には、別の実行コンテキストに結び付いた async 関数の呼び出しが中断ポイントの主な形です。その呼び出し自体が実際に中断するかは呼び出し先の実装や実行時の条件に依存するため、これらは潜在的な中断ポイント(potential suspension point)と呼ばれます。
中断ポイントの間では処理が直列に進むことが保証されますが、中断によってアトミック性が破れる点は重要です。たとえばシリアルキューに守られたコンテキストで動く関数でも、中断した瞬間に別のコードが同じキュー上に割り込める可能性があります。そのため、中断しうる箇所を見落とさずに扱える仕組みが欠かせません。
なお async 関数は、同期的に長時間かかる重い処理を書けば普通にスレッドを占有します。潜在的な中断ポイントは明示された位置にしか現れないため、意図しない場所で処理が途切れる心配はありませんが、計算量の多い処理は別のコンテキストに出すのが基本です。
await 式
潜在的な中断ポイントを含む呼び出しは、await 式の中に置く必要があります。これは、エラーを送出しうる呼び出しを try で包むのと同じ考え方で、「ここで中断が起こりうる」ことを読み手が一目で分かるようにするためです。
let newURL = await server.redirectURL(for: url)
let (data, response) = try await session.dataTask(with: newURL)
ひとつの await で複数の潜在的中断ポイントをまとめて包むこともできます。
let (data, response) = try await session.dataTask(with: server.redirectURL(for: url))
await 自体は値の型や結果を変えず、try と同じく単にマーカーとして機能します。オペランド内に潜在的な中断ポイントが 1 つも含まれていない場合は警告になります。また、try と await の両方を書くときは try await の順序固定です。
潜在的な中断ポイントは async コンテキスト以外では書けません。defer ブロックの中や、async 関数型でない autoclosure の中にも置けません。
非同期関数は同期関数から呼べない
非同期関数はスレッドを手放す能力を持つため、その能力を持たない同期関数からは一般に呼び出せません。強制的に同期呼び出しにしようとすると、非同期関数が再開されるまでスレッド全体をブロックするしかなく、それは非同期の目的を失わせるだけでなく、系全体の問題になります。
逆に、非同期関数から同期関数を呼ぶことは自由です。同期呼び出しの間はスレッドを手放せないというだけで、単に同期関数として呼び出されます。
非同期コードの起点(プログラムのエントリポイント)は本 Proposal の範囲外で、Structured Concurrency(SE-0304)側で @main を async にする形などが提供されます。トップレベルコードも本 Proposal の時点では非同期コンテキストではありません。
非同期関数型
関数型自体にも async が付きます。() async -> Int のように、パラメータリストの後、戻り値の矢印の前に書きます。async と throws を両方持つ場合も () async throws -> Int の順です。
同期関数型から対応する非同期関数型への暗黙変換があります(throws の扱いと同様に組み合わさります)。逆方向(async を取り除く方向)の変換はできません。
struct FunctionTypes {
var syncNonThrowing: () -> Void
var syncThrowing: () throws -> Void
var asyncNonThrowing: () async -> Void
var asyncThrowing: () async throws -> Void
mutating func demonstrateConversions() {
// async や throws を付けるのはOK
asyncNonThrowing = syncNonThrowing
asyncThrowing = syncThrowing
asyncThrowing = asyncNonThrowing
// async や throws を外すのはエラー
syncNonThrowing = asyncNonThrowing // error
syncThrowing = asyncThrowing // error
}
}
クロージャ
クロージャも async 関数型を持てます。明示的に書くことも、await 式を含む場合に暗黙的に async と推論させることもできます。
let closure1 = { () async -> Int in
print("here")
return await getInt()
}
let closure2 = { await getInt() } // 暗黙に async
async 推論は囲む関数や入れ子のクロージャには伝播しません。それぞれのクロージャは独立に同期/非同期が決まります。
プロパティと get async
プロパティやサブスクリプトのゲッタも async にできます(throws と合わせて get async throws も可)。
一方、セッタは async にできません。セッタを async にすると、inout で受け渡したり、プロパティの中のプロパティを書き換えたりする操作が非同期かつ非アトミックになり、破綻するためです。同様に deinit も async にできません。
プロトコル要件
プロトコル要件として async 関数を宣言できます。async 要件は async 関数でも同期関数でも満たせます(同期関数は暗黙に非同期関数として扱えるため)。逆に、同期要件を async 関数で満たすことはできません。
protocol Asynchronous {
func f() async
}
protocol Synchronous {
func g()
}
struct S1: Asynchronous {
func f() async { } // OK
}
struct S2: Asynchronous {
func f() { } // OK(同期関数で async 要件を満たせる)
}
struct S3: Synchronous {
func g() async { } // error
}
オーバーロードと曖昧性
既存の同期 API に async 版を追加できるように、async のみが異なるオーバーロードが許されます。呼び出し側のコンテキストが同期か非同期かに応じて、コンパイラが一意にオーバーロードを選びます。
- 同期コンテキストでは非
async版が選ばれる - 非同期コンテキストでは
async版が選ばれる(呼び出しはawait式の中に置く必要がある)
func doSomething() { ... } // 既存
func doSomething() async { ... } // 新規
func f() async {
await doSomething() // 非同期コンテキストなので async 版
let g = {
doSomething() // 同期コンテキストなので非 async 版
}
g()
}
デフォルト引数付きのコンプリーションハンドラ API と async 版を並べても、同じ原理で衝突なく共存できます。
autoclosure
ある関数が async 関数型の autoclosure 引数を取る場合、その関数自身も async でなければなりません。
// error: 非 async 関数が async autoclosure を取ることはできない
func computeArgumentLater<T>(_ fn: @escaping @autoclosure () async -> T) { }
これは、autoclosure の中に中断ポイントが隠れてしまうと、呼び出し側の await の位置と実際の中断位置がずれ、同期クロージャが誤って async と推論される等の問題が生じるためです。呼び出し元も async であることを強制することで、await の見かけと実体を一致させます。
既存 API との橋渡し
コンプリーションハンドラ方式の API を一夜で置き換えることは現実的ではないため、互換性レイヤーが別 Proposal で整備されます。特に Objective-C で末尾がコンプリーションハンドラの慣習的なメソッドは、SE-0297 のルールにより自動的に async 形式でインポートされ、Swift 側から自然に await で呼べるようになります。
なお、async 関数の ABI は同期関数と互換性がなく、呼び出し規約も大きく異なります。関数の async 化・非 async 化は resilient な変更ではない点は API 設計上押さえておくべきです。
今回のスコープ外
以下は本 Proposal では扱わず、それぞれ別 Proposal に委ねられています。
- タスクの生成・キャンセル・優先度・構造化並行性 → Structured Concurrency(SE-0304)
- アクターによる状態隔離 → Actors(SE-0306)
- Objective-C API との相互運用(コンプリーションハンドラの自動
async化を含む)→ SE-0297
Future Directions
rethrows のクロージャ引数版として reasync が考えられています。引数のクロージャが async かどうかに応じて自身も async として振る舞う、というアイデアです。ただし、async 関数の ABI は同期関数と根本的に異なるためオーバーヘッドが発生しうること、そして Sequence.map のような処理では単に async へ伝播させるよりも並行実装のオーバーロードを分けたほうが有効であることから、適用できる場面はかなり限定的と見られており、現時点では導入されていません。?? 演算子のように、async 版と同期版が素直に重なるケースでの採用余地が議論として残っています。
加えて、await が try を含意するようにする案も検討されましたが、両者は意味するもの(「中断が起こりうる」と「この場所から制御が抜けうる」)が別であるため、採用されていません。