Swift 6で来たる並行処理の大型アップデート近況

公開:2020/09/26
更新:2020/09/26
26 min読了の目安(約15600字TECH技術記事
Likes77

最近、 Swift リポジトリに並行処理関係の Pull Request (PR) が続々とマージされています。 たとえば、次のような PR があります。

Swift の並行処理( Concurrency )関連の機能については、 2020 年 1 月に発表された "On the road to Swift 6" という公式アナウンスの中で特に重要な分野の一つとして挙げられていました。

  • Provide excellent solutions for major language features such as memory ownership and concurrency

前述の PR はそれに沿った動きだと考えられます。

さらに、 Swift Core Team の一人である John McCall さんが 2020 年 9 月 17 日に Swift Forums で並行処理のデザイン案が数週間内に提供されると述べています

The concurrency design (coming in a few weeks, I promise!) will likely demote the importance of using this API directly

ちなみに上記の文中の "this API" とは DispatchQueue.main のことで、これを直接利用する重要性は下がると述べています。 DispatchQueue.main は Swift プログラマにとって非常に馴染み深い API だと思いますが、これを使う機会がほとんどなくなる(?)ほどの劇的な変化がありそうだと推測できます。

Swift の並行処理については現状では正式な手順でプロポーザルが作られていないものの、上記の経緯から考えてこれから急速に議論が進み、 Swift の次期メジャーバージョンである Swift 6 (おそらく 2021 年リリース)には並行処理に関する大型のアップデートが含まれる ことになりそうです。

昨日開催されたわいわいswiftc #22 にて上記が話題になったので、その内容を本記事にまとめてみました。

Swift の並行処理

Swift の並行処理については、 2017 年 8 月に( Swift の生みの親であり Core Team のメンバーでもある) Chris Lattner によってマニフェストが示されました。

このマニフェストは五つのパートからなるのですが、 Part 1 が async/await について、 Part 2 以降が actor についてのものとなっています。

async/await についてはプロポーザルのドラフトとして↓に切り出されています。

これらは Swift コミュニティに大きな衝撃を与え、半ば規定路線として扱われていますが、 3 年経った 2020 年 9 月 26 日現在でも正式なマニフェストやプロポーザルは作られていません。その証拠に上記のドキュメントはただの Gist で、 Swift および Swift Evolution リポジトリに取り入れられていません。

しかし前述したように、このところ並行処理関連の動きが加速しており、今後正式に議論が進められていくものと思われます。

Swift の async/await

async/await と言えば JavaScript / TypeScript や C# が有名です。一見すると、それらの async/await と Swift の async/await は同じもののように見えます。

// JavaScript
async function foo() {
    const a = await bar();
    const b = await baz();
    return a + b;
}
// Swift
func foo() async -> Int {
    let a = await bar()
    let b = await baz()
    return a + b
}

async の位置が違う他はそっくりです。しかし、 Swift の async/await はそれらの言語の async/await と少し異なります。

たとえば、 JavaScript / TypeScript では async はその関数の中で await を使えるという意味ですし、 awaitPromise を剥がす(値が得られるまで非同期的に待つ)ためのものです。そして、 async 関数の戻り値は Promise です。

しかし、 Swift の async/await には Promise に相当するものは登場しません。 async はその関数が非同期であることを示し、 awaitasync な関数をコールするときに必要なマークに過ぎません。 async 関数の戻り値の型も Int のような素の型です。

Swift の asyncawait はそれぞれ throwstry にとても良く似ています。 throws が付与された関数を呼び出すときには try を付けることが求められます。

func foo() throws -> Int { ... }

let a: Int = try foo() // try がないとコンパイルエラー

同様に、 async が付与された関数を呼び出すときには await を付けることが求められます。

func foo() async -> Int { ... }

let a: Int = await foo() // await がないとコンパイルエラー

また、 throws 関数を呼び出す場合(つまり try を書く場合)、( catch しない限りは) throws 関数の中でないといけません。

func main() throws { // throws がないとコンパイルエラー
    print(try foo())
}

同様に、 async 関数を呼び出す場合( await を書く場合)、 async 関数の中でないとコンパイルエラーになります。

func main() async { // async がないとコンパイルエラー
    print(await foo())
}

このように、 Swift では throwsasync が、 tryawait が対になるように設計されており、 Promise は介在しません。 Swift の async/await は JavaScript 等の async/await よりも Kotlin の suspend に近く、機能的にも Kotlin と同じくコルーチンをサポートするためのものです。

Swift の async/await そのものや throws/try との関係については、↓の記事および try! Swift Tokyo 2016 の発表で説明しているのでそちらを御覧下さい。

@asyncHandler

async 関数を使おうと思っても、 async 関数は async 関数の中でしか呼べません。 async 関数を呼び出すエントリーポイントはどうすれば良いでしょうか。

前述のプロポーザルでは、非同期エントリーポイントとして標準ライブラリに beginAsync 関数を追加することが提案されていました。

beginAsync の利用例は次のようになります。これは、ボタンが押されたときに非同期でデータをダウンロードするコードです。

func onButtonPressed(_ sender: UIButton) {
    beginAsync {
        let data: Data = await download(from: url)
        ...
    }
}

beginAsync は渡されたクロージャを非同期的に実行します(正確には await 以降を suspend します)。そのため、 onButtonPresseddownload の完了を待たずに即座に終了します。これによって、 download を UI スレッドがブロックされることなく実行できます。

beginAsync に渡すクロージャの中で async 関数である download を呼び出せるのは、 beginAsync のシグネチャが次のようになっているからです。

func beginAsync(_ body: () async throws -> Void) rethrows -> Void

bodyasync が付与されているのがポイントです。そのため、 body として渡されるクロージャ式の中で async 関数を呼び出せるわけです。

しかし、↓の PR を見る限り、これとは異なったアプローチが採用されることになりそうです。

この PR は、 Swift に @asyncHandler という Attribute を追加するものです。

@asyncHandlerbeginAsync と同じ役割を果たします。 @asyncHandler を使って onButtonPressed を実装すると次のようになります。

@asyncHandler
func onButtonPressed(_ sender: UIButton) {
    let data: Data = await download(from: url)
    ...
}

これは、関数全体を beginAsync で包んだ場合と等価です。

わいわいswiftc #22 では、 beginAsync をやめて @asyncHandler にした理由として、次のようなものが推測されていました。

  • ネストを減らせる
  • 専用の Attribute を用意することでより細かくコンパイルエラーの原因を診断することが可能となり、適切なエラーメッセージを提示できる

僕が個人的に気に入っているのは、 @asyncHandler を付与した関数は throws が禁止されている点です。これは、 beginAsync から throwsrethrows を取り除き、↓のように変更したことに相当します。

func beginAsync(_ body: () async -> Void) -> Void

僕は以前からそうするべきだと主張していた(↓)のですが、その通りの形にまとまりそうでよかったです。

@asyncHandlerthrows が禁止されていることは PR に含まれるテストコードからわかります。

@asyncHandler
func asyncHandlerBad3() throws { }
// expected-error@-1{{'@asyncHandler' function cannot throw}}{{25-32=}}

その他にも、テストコードからは @asyncHandler の戻り値は Void でないといけない、 mutating func には @asyncHandler を付けられないなどの( beginAsync と比較してみると自明な)ルールを読み取ることができておもしろいです。

デリゲートと @asyncHandler

この PR はちょっと変わっていて、 did を単語として含む Obj-C プロトコルのメソッドのうち、 @asyncHandler の条件(戻り値が Void など)を満たすものについて、自動的に @asyncHandler を付与しようというものです。

テストコードでは次のようなプロトコルが挙げられています

// Objective-C
@protocol RefrigeratorDelegate<NSObject>
- (void)someoneDidOpenRefrigerator:(id)fridge;
- (void)refrigerator:(id)fridge didGetFilledWithItems:(NSArray *)items;
- (void)refrigerator:(id)fridge didGetFilledWithIntegers:(NSInteger *)items count:(NSInteger)count;
- (void)refrigerator:(id)fridge willAddItem:(id)item;
- (BOOL)refrigerator:(id)fridge didRemoveItem:(id)item;
@end

テストコードによると、これらのデリゲートメソッドの最初の二つには @asyncHandler が付与され、後の三つには付与されないようです。

// CHECK-NEXT: @asyncHandler func someoneDidOpenRefrigerator(_ fridge: Any)
// CHECK-NEXT: @asyncHandler func refrigerator(_ fridge: Any, didGetFilledWithItems items: [Any])
// CHECK-NEXT: {{^}}  func refrigerator(_ fridge: Any, didGetFilledWithIntegers items: UnsafeMutablePointer<Int>, count: Int)
// CHECK-NEXT: {{^}}  func refrigerator(_ fridge: Any, willAddItem item: Any)
// CHECK-NEXT: {{^}}  func refrigerator(_ fridge: Any, didRemoveItem item: Any) -> Bool

refrigerator(_:didGetFilledWithIntegers:count:) については(おそらく) itemsinout 引数相当なこと、 refrigerator(_:willAddItem:)did を含まないこと、 refrigerator(_:didRemoveItem:) は戻り値の型が Void でないことが理由だと思われます。

この PR の意図するところは、 did のデリゲートメソッドの中で非同期処理を発火することが多いので、 @asyncHandler が付与されていると async 関数が使えて便利でしょ、ということだと思われます。

しかし、命名を元にこのような処理を施して本当に良いのか、 did だけで will には必要ないのかなど、わいわいswiftc #22 では様々な議論が交わされました。個人的に興味深かった意見をまとめたものが↓です。

  • will については非同期処理を待たずに後続(の did などの)処理が行われるので適さないのではないか。
  • でも did でも繰り返し呼ばれるデリゲートメソッドもあるけど良いのか。
  • @asyncHandler が付与された関数は通常の関数のスーパータイプになるので、 async 関数を呼ばないといけないわけではない。なので、雑に付与されても良いのではないか。

completion ハンドラーを持つ Obj-C メソッドを async

この PR は、 completion ハンドラーを持つ Obj-C メソッドを async メソッドに変換するというものです。たとえば、 UIViewanimate メソッドは現在 completion ハンドラーを使って次のように使います。

// BEFORE
func onButtonPressed(_ sender: UIButton) {
    UIView.animate(withDuration: 0.5) {
        image.alpha = 1.0
    } completion: { isFinished in
        ... // 完了時の処理
    }
}

この animate メソッドが async に変換されると、それを利用するコードは次のようになります。

// AFTER
@asyncHandler
func onButtonPressed(_ sender: UIButton) {
    let isFinsihed = await UIView.animate(withDuration: 0.5) {
        image.alpha = 1.0
    } 
    ... // 完了時の処理
}

シグネチャで言うと次のような変化になります。

// BEFORE
class func animate(withDuration duration: TimeInterval, 
        animations: @escaping () -> Void, 
        completion: ((Bool) -> Void)? = nil)
// AFTER
class func animate(withDuration duration: TimeInterval, 
        animations: @escaping () -> Void) async -> Bool

テストコードには色々なパターンが挙げられていておもしろいです。

UIView.animate はエラーを発生させない非同期処理でしたが、大抵の非同期処理はエラーを発生させる可能性があります。その場合、たとえば↓のように結果( String )とエラーをそれぞれ Optional で受け取る API が一般的です。

// BEFORE
func doSomethingDangerous(_ operation: String,
    completionHandler handler: ((String?, Error?) -> Void)? = nil)

このような場合は、 async throws が付与された関数に変換してくれるようです。

// AFTER
func doSomethingDangerous(_ operation: String) async throws -> String?

この変換前後で、この関数の利用側のコードは次のように変化します。

// BEFORE
doSomethingDangerous("ABC") { value, error in
    if let error = error {
        // エラーハンドリング
    }
    use(value!)
}
// AFTER
do {
    let value = try await doSomethingDangerous("ABC")
    use(value)
} catch {
    // エラーハンドリング
}

iffor などの制御構文との組み合わせも簡単になり、便利になりそうです!

わいわいswiftc #22 では他にも次のようなことが話されていました。

  • Result が導入されたときに、このようなエラーを伴う非同期 API のハンドラーは (Value?, Error?) ではなく Result<Value, Error> を受け取る形に変換されなかったのか?(そうすれば Forced Unwrapping が必要ない。)
    • Result の導入時にはすでに async/await が論じられていたので、それを見越して( Result が不要な) async/await を待ったのではないか。
  • キャンセラを返すような非同期 API では async にできない。
    • Resultプロポーザルで論じられた URLSession.dataTaskAPI リファレンス)は結局 (Data?, URLResponse?, Error?) を受け取るままだが、この API は(キャンセラに相当する) URLSessionDataTask を返すので結局 async にできない。それなら Result<(URLResponse, Data), Error> を受け取る形に修正されても良いのでは?

actor

マニフェストの Part 1 に当たる async/await だけでなく、 Part 2 以降の actor に関する PR もマージされています。

このことから、 Swift 6 での並行処理は async/await に留まらず、 actor まで含めた広範囲のものになると予想されます。

この actorアクターモデルactor で、マニフェストの中でも次のように述べられています。

We propose the introduction of a first-class actor model

アクターモデルについては僕も勉強中なので詳しく説明できませんが、アクターモデルとは多数のアクター同士が非同期のメッセージパッシングによって通信し処理を実行するモデルのことです。

アクターモデルを採用した最も有名なプログラミング言語は Erlang ではないかと思います。ここでは、実際に Erlang を使っているエンジニアから聞いた、 Erlang を学ぶのにオススメの書籍を二冊挙げておきます。前者は Erlang の設計者自身が書いたもので、後者は数年前に発売されて人気を博したものです。

本節の以下の内容はあやふやな知識によって書かれています。

間違いがあるかもしれませんが、僕は Erlang のアクターは次のような特徴を持っていると理解しています。

  • Erlang プロセスという軽量なプロセス( OS のプロセスとは別物)がアクターとして振る舞う
  • Erlang プロセスはメッセージボックスを持っており、 Erlang プロセス同士がメッセージを送り合うことで通信する
  • Erlang プロセスは自身のメッセージボックスから順番にメッセージを取り出し処理を実行する(キューに似ている?)
  • Erlang プロセスは共有メモリを持たない(ので共有メモリに由来する問題を引き起こさない)
  • Erlang プロセスがクラッシュしても OS のプロセスがクラッシュするわけではない

僕は特に最後の点が興味深く、 Erlang ではエラーハンドリングとしてプロセスをクラッシュさせることが普通に行われると聞いたことがあります。

これに関連することがマニフェストの "Part 3: Reliability through fault isolation" で述べられています。

これまで Swift は Forced Unwrapping の失敗や Array の index out of bounds のエラー、 preconditoon の失敗などの Logic Failure をハンドリングすることができませんでした。 actor の導入によって、これらのクラッシュをプロセスのクラッシュではなく actor のクラッシュとして隔離し、 Logic Failure をハンドリングすることが可能となります。

僕もまだきちんとマニフェストを読めていないですし、 "a few weeks" で正式なドキュメントが出てきそうなのでそれを待ちたいと思いますが、 Swift への actor 導入が本格的に動きそうなので、そろそろちゃんと勉強したいと考えています。前述の Erlang 本も途中まで読んで積読になってるので、それを再開してみるのも良さそうです。

その他の PR

軽く調べて見たところ、昨日わいわい swiftc #22 で話題になった以外にも次のような PR が見つかりました。広範囲の構文が実装されつつあることがわかり、期待が膨らみます。

async/await 関連

actor 関連

まとめ

最近、 Swift リポジトリに async/awaitactor といった並行処理関連の PR が大量にマージされています。 Swift Core Team メンバーの発言からも、今後数週間以内に Swift の並行処理に関する正式なドキュメントが出てきそうで、今後急速に話が進むと思われます。 Concurrency Menifesto が出てから 3 年、待ち侘びていた Swift の並行処理関連機能が Swift 6 でついに導入されそうです。

async/await はともかく、 actor は多くの Swift プログラマにとって馴染みの薄い概念だと思います。来年に向けて勉強を進めることが求められるでしょう。しかし、このアップデートによって、 Swift で非同期処理や並行処理を扱うのが劇的に楽になると予想されます。それらを使える未来を考えるとわくわくしますね。 Swift 6 のリリースが待ち遠しいです!