Swift Concurrency について学ぶ
Swift Concurrencyの紹介 - 見つける - Apple Developer にある順番で、WWDC21 の Swift Concurrency に関するセッションを見ながら学んでいく。
Swiftにおける非同期および並行プログラミングのパワーをご覧ください。async/awaitは、 完了ハンドラなどを使用する非同期関数を、より読みやすく、より正確に変換します。複数のタスクを同時に実行するための構造化された様々な種類の並行処理を紹介します。Swiftのactorを使用することで、データ競合のないコードを維持します。
Toolchain は swift-5.5-DEVELOPMENT-SNAPSHOT-2021-08-26-a
を使用します。
Release swift-5.5-DEVELOPMENT-SNAPSHOT-2021-08-26-a · apple/swift
LLDB provided no error string.
になっちゃうので Xcode 13 に付属のものを使います。
% swift --version
swift-driver version: 1.26.9 Apple Swift version 5.5 (swiftlang-1300.0.29.102 clang-1300.0.28.1)
Target: arm64-apple-macosx12.0
Swift の async/await について(Meet async/await in Swift) - WWDC21 - Videos - Apple Developer
1回観たことあるけどもう一度。
async/await のまとめ
-
async
キーワードによって関数はサスペンドできるようになる -
await
キーワードは非同期関数が実行をサスペンドする場所を示す - サスペンド中に他の作業ができる
- 待機していた async な関数が完了すると、
await
で止まっていたところ以降の実行が再開される
enum FetchError: Error {
case requestFailed
case badImage
}
func fetchImageThumbnail(from urlString: String) async throws -> NSImage { // キーワードは `async throws` の順番
let url = URL(string: urlString)!
let (data, response) = try await URLSession.shared.data(from: url) // キーワードは `try await` の順番
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw FetchError.requestFailed
}
let maybeImage = NSImage(data: data)
guard let thumbnail = await maybeImage?.thumbnail else {
throw FetchError.badImage
}
return thumbnail
}
extension NSImage {
var thumbnail: NSImage? {
get async { // read-only プロパティでのみ async が利用可能
let size = CGSize(width: 40, height: 40)
return await self.byPrepairngThumbnail(ofSize: size)
}
}
}
// エラーになる: 'async' call in a function that does not support concurrency
let image = try? await fetchImageThumbnail(from: "/url/to/image")
// Task を使う
Task {
let image = try? await fetchImageThumbnail(from: "/url/to/image")
}
async/await を既存プロジェクトに取り入れる
- XCTest は async な関数のテストにも対応した
- 例えば UIKit など Apple 提供のフレームワークで、Objective-C による completion があるような関数は、Swift で
async
にブリッジされるようになっている - コールバックによる関数を async な関数にブリッジするようなコードを自分で書く場合、例えば
withCheckedThrowingContinuation(function:_:)
を使う- "必ず正確に1度だけ" resume するようにする
- resume せずに
CheckedContinuation
を破棄するのは許されない
- Delegate パターンなど、イベント駆動なものを async な関数にする場合、
CheckedContinuation
をクラス内で保持しておいて、イベントで resume する- resume した後は
CheckedContinuation
を破棄するのを忘れずに
- resume した後は
気づき
- 「get」が先頭に入ったままだと、呼び出しの結果が直接返ってこないように聞こえるから省略が推奨される
First, the async alternative’s name is used which drops the leading “get.” We recommend that async functions omit leading words like “get” that communicate when the results of a call are not directly returned.
ここまで観た内容から、SwiftUI App + Core NFC + Swift Concurrency を使って交通系電子マネーカードの残高読み取りサンプルを書いてみた。
Swift における構造化並行処理(Explore structured concurrency in Swift) - WWDC21 - Videos - Apple Developer
字幕では Structured Concurrency を「構造化同時並行処理」や「構造化された並行性」って言っている。セッションの邦題では「構造化並行処理」って言っている。
これまで色んなところに処理がジャンプしているように見えていたけど、これを構造化プログラミングにすると、プログラム全体を上から下に読める。でも今日の非同期が多いコードを上から下に読めるようにするには…?
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
var thumbnails: [String: UIImage] = [:]
for id in ids {
let request = thumbnailURLRequest(for: id)
let (data, response) = try await URLSession.shared.data(for: request)
try validateResponse(response)
guard let image = await UIImage(data: data)?.byPreparingThumbnail(ofSize: thumbSize) else {
throw ThumbnailFailedError()
}
thumbnails[id] = image
}
return thumbnails
}
例えば async
や async throws
でこれが非同期な関数であることを示し、非同期な関数を使うときは await
や try await
キーワードを入れることで、ネストが不要になる。
これまでのクロージャを使っていたときは、 completion
の呼び出し忘れがあってもビルド時に気づくことができなかったが、この Swift Concurrency では return
や throw
のし忘れがコンパイラにかける段階で気づくことができる。
Async-let
let hoge = await 非同期な関数1()
let fuga = await 非同期な関数2()
let hoga = (hoge, fuga)
async let hoge = 非同期な関数1()
async let fuga = 非同期な関数2()
let hoga = (await hoge, await fuga)
上半分は
-
非同期な関数1()
を実行 -
非同期な関数1()
の実行が終わってhoge
に代入される -
非同期な関数2()
を実行 -
非同期な関数2()
の実行が終わってfuga
に代入される -
hoga
が(hoge, fuga)
になる
下半分で出てくる async let
を使うと
-
非同期な関数1()
は子タスクで実行 -
非同期な関数2()
は子タスクで実行 -
hoga
でhoge
とfuga
と書く前にawait
と書くことで2つの関数の実行が待たれる
タスクのキャンセル
try Task.checkCancellation()
let flag = Task.isCancelled
同じ層に位置している子タスクのどれかが異常終了したとしたら?
キャンセルのフラグが立てられ、そのさらに続く子タスクがある場合はそちらもキャンセルのフラグが立てられる。
タスクが勝手に動作を止めることは無いので、処理時間が長い API を実装する側は特に、このキャンセルフラグを自分で見てあげるような実装にする必要がある。
async
なメソッドとして Task.checkCancellation()
、Bool 型がよければ Task.isCancelled
でキャンセルのフラグを確認できる。
グループタスク(Group tasks)
TaskGroup は動的な数の同時実行性を提供する。
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
var thumbnails: [String: UIImage] = [:]
await withThrowingTaskGroup(of: Void.self) { group in
for id in ids {
group.addTask {
// ❌ Mutation of captured var 'thumbnails' in concurrently-executing code
thumbnails[id] = try await fetchOneThumbnail(withID: id)
}
}
}
return thumbnails
}
group.addTask
によって、子タスクを追加する。でも、このままだと thumbnails[id]
のところでデータ競合が起こる可能性がある。コンパイラはこれを静的にエラーとして教えてくれる。
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
var thumbnails: [String: UIImage] = [:]
try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
for id in ids {
group.addTask {
return (id, try await fetchOneThumbnail(withID: id))
}
}
for try await (id, thumbnail) in group {
thumbnails[id] = thumbnail
}
}
return thumbnails
}
先程のプログラムをこのように書き換えてみる。withThrowingTaskGroup(of:returning:body:)
の childTaskResultType
を Void.self
から (String, UIImage).self
にすることで、group
は ThrowingTaskGroup<(String, UIImage), Error>
であると伝える。
for await
のループで、group
のタスクの完了順に thumbnails[id]
に thumbnail
を入れていく。
構造化されていないタスク(Unstructured tasks)
class MyDelegate: NSObject, UICollectionViewDelegate {
func collectionView(_ view: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
let ids = getThumbnailIDs(for: item)
Task {
let thumbnails = await fetchThumbnails(for: ids)
display(thumbnails, in: cell)
}
}
}
自分でタスクを作りたいときは Task.init(priority:operation:)
のクロージャを使う。ただし、これのキャンセルなどは自分で手動で管理する必要がある。
@MainActor
class MyDelegate: NSObject, UICollectionViewDelegate {
var thumbnailTasks: [IndexPath: Task<Void, Never>] = [:]
func collectionView(_ view: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
let ids = getThumbnailIDs(for: item)
thumbnailTasks[item] = Task {
let thumbnails = await fetchThumbnails(for: ids)
display(thumbnails, in: cell)
thumbnailTasks[item] = nil
}
}
func collectionView(_ view: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt item: IndexPath) {
thumbnailTasks[item]?.cancel()
}
}
例えば、collectionView(_:willDisplay:forItemAt:)
で非同期で fetchThumbnails(for:)
を実行してサムネイルを取得しようとする。そのときに作った Task
は thumbnailTasks
に一時的に入れておく。fetchThumbnails(for:)
が終了したら、thumbnailTasks[item] = nil
する。
もし fetchThumbnails(for:)
の実行が終わる前に collectionView(_:didEndDisplaying:forItemAt:)
が呼ばれて、もうその Task
が必要なくなったとき、自分で thumbnailTasks[item]?.cancel()
を呼び出してそれをキャンセルする。
分離されたタスク(Detached tasks)
先程の例では、collectionView(_:didEndDisplaying:forItemAt:)
が呼ばれたらもうそのサムネイルは必要がない、という処理だったが、これを collectionView(_:willDisplay:forItemAt:)
で fetchThumbnails(for:)
したときに、その結果をローカルにキャッシュしておくと便利そう… という例が考えられる。そんなときは分離されたタスクを使う。
@MainActor
class MyDelegate: NSObject, UICollectionViewDelegate {
var thumbnailTasks: [IndexPath: Task<Void, Never>] = [:]
func collectionView(_ view: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
let ids = getThumbnailIDs(for: item)
thumbnailTasks[item] = Task {
let thumbnails = await fetchThumbnails(for: ids)
Task.detached(priority: .background) {
writeToLocalCache(thumbnails)
}
display(thumbnails, in: cell)
thumbnailTasks[item] = nil
}
}
}
Task.detached(priority:operation:)
で priority
を background
などメインではない場所に指定し、キャッシュを行なっている。
では、もしサムネイルのキャッシュだけではなく、それのログを記録したかったり、そのほかバックグラウンドで処理をしたいときはどうすればよいか。
@MainActor
class MyDelegate: NSObject, UICollectionViewDelegate {
var thumbnailTasks: [IndexPath: Task<Void, Never>] = [:]
func collectionView(_ view: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
let ids = [""] // getThumbnailIDs(for: item)
thumbnailTasks[item] = Task {
let thumbnails = await fetchThumbnails(for: ids)
Task.detached(priority: .background) {
await withTaskGroup(of: Void.self) { g in
g.addTask { writeToLocalCache(thumbnails) }
g.addTask { log(thumbnails) }
g.addTask { /* ... */ }
}
}
thumbnailTasks[item] = nil
}
}
}
例えばタスクグループを設定して、それぞれでバックグラウンドのジョブを生成する。こうすることで、後になってこのタスクをキャンセルしたくなったときは、このタスクグループの最上位をキャンセルすると、その下にある全ての子タスクがキャンセルされる。
まとめ
書き方 | 書ける場所 | ライフサイクル | キャンセル | 起点からの継承 | |
---|---|---|---|---|---|
async-let タスク | async let x |
async な関数 |
文の中 | 自動で行われる | 優先度、タスクローカルの値 |
グループタスク | TaskGroup.addTask |
withTaskGroup |
タスクグループの中 | 自動で行われる | 優先度、タスクローカルの値 |
構造化されていないタスク | Task |
どこからでも | スコープされない |
Task 経由で自分で行なう |
優先度、タスクローカルの値、actor |
分離されたタスク | Task.detached |
どこからでも | スコープされない |
Task 経由で自分で行なう |
なし |