WWDC
Meet async/await in Swift
非同期処理をコールバックやデリゲートで書いていましたが、async/awaitキーワードを使って自然にかけるようになりました。
非同期の処理を行う関数、変数やイニシャライザの宣言部分にasyncをつけます。これを呼び出す時、awaitキーワードをつけて呼ぶだけです。エラー処理のあるような関数ではthrowsも同時につけることが多いと思いますが、async throws
の順で、呼び出し時はtry await
の順でつけます。
func someAsyncfunction() async throws String {
...
}
let result = try await someAsyncfunction()
awaitで他の関数呼び出している関数自体も、asyncキーワードをつけて宣言し直す必要があります。
async をつけると、そこで一旦処理がストップし、先に進まなくなります。しかしスレッドを占有はしないため、待っている間も他の処理(例えばユーザーのUI操作への対応など)は挟まることができます。asyncをつけた処理が終わると、一旦ストップしていた処理が再開し、次の業に進んでいくことになります。
-
await
は、次の行以降でその関数の結果が必要な時に使う -
async let
は、次の行では直ちにその関数の結果は必要なく、もっと後になってからで良いという時に使う -
await
でもasync let
でも、アプリの他の箇所で行われている非同期処理は止まらない
というのが区別のポイントです。
func someAsyncfunction() async throws String {
...
}
func anotherAsyncfunction() async throws String? {
let result = try? await someAsyncfunction()
return result
}
非同期処理の関数は、XCTestでテストするときexpectation
を使ってわざわざ待ち受けするなど、面倒な書き方をしなくてはいけませんでしたが、これも必要なくなり、普通の関数とほとんど同じように書けるようになります。違うのはasyncをつけるだけです。
なんらかのクロージャの中でasyncを使おうとすると、そのクロージャ自体がawaitに対応していないためそのままだとうまく使えない場合があります。その場合、async { }
クロージャで囲うと対応できます。
既存で、コールバックを使っている非同期処理関数を、continuation
を使って、awaitの関数に置き換えることができます。
continuation.resume()
を呼んだところで結果が返されますが、全てのパスでresume()を一回必ず呼んでいることを確かめる必要があります。呼んでいないとコンパイラから警告が来るとのこと。
なお、このようなコンパイラの警告がないUnsafeContinuation
もあります。これはパフォーマンス重視の場合に使用しますが、resumeの呼び方を間違っていないかどうか入念なテストがいると思われます。
また、逆に一つのパスでresume()を複数回呼ばないように気をつけましょう。これも警告が出るとのこと。
既存でデリゲートを使っている非同期処理も、continuation
をメンバー変数とすることで、awaitの関数に置き換えることができます。
Explore structured concurrency in Swift
https://developer.apple.com/videos/play/wwdc2021/10134/
https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html
上のセッションでも述べられていましたが、
async をつけると、そこで一旦処理がストップし、先に進まなくなります。asyncをつけた処理が終わると、一旦ストップしていた処理が再開し、次の行に進んでいくことになります。複数のasyncをつけた非同期処理があるとなると、そのままでは、一つ目が終わるまでストップし、その後初めて二つ目のasyncに進むことになります。↓
let (data, _) = try await URLSession.shared.data(for: imageRequest) //終わるまで次の行に行かない
let (metadata, _) = try await URLSession.shared.data(for: metadataRequest) //終わるまで次の行に行かない
async let
ここで普通のasyncではなくasync let
というものを使うと、async let
のところで処理が一旦ストップせず、子タスクが作られてそちらで処理される一方、メインの処理はすぐ次の行の処理に進んでいくことになります。そこで処理がサスペンドするわけではないのでawait
ないしtry await
は不要となります。のちに変数の中身を参照するとき初めてawait
をつけていくことになります。
例えば以下では、画像データのダウンロードが終わるのを待たず、メタデータのダウンロードも始まり、二つの非同期処理が同時に走ることになります。
func fetchOneThumbnail(withID id: String) async throws -> UIImage {
async let (data, _) = URLSession.shared.data(for: imageRequest) //子タスクを作り、直ちに次の行に進む
async let (metadata, _) = URLSession.shared.data(for: metadataRequest) //こタスクを作り、直ちに次の行に進む
guard
let size = parseSize(from: try await metadata), // この時点で初めてtry awaitをつける
let image = try await UIImage(data?).byPreparingThumbnail(ofSize: size) // この時点で初めてtry awaitをつける
else {
//throw some error
}
}
この時、内部的には、Task
というクラスのオブジェクトが内部的には作られています。
関数fetchOneThumbnail
が親タスクとなり、その下に子タスクdata
metadata
がぶら下がる形となるのです。
Task
はキャンセル可能です。しかしキャンセルした時も自動的にTaskがストップするわけではないので、キャンセルがありうるようなケースでは、キャンセルされたかどうかをチェックする必要があります。
キャンセルされてた時に何かしたい場合は、withTaskCancellationHandler
が利用できます。
- 以下、引用
import Foundation
class Wrapper<Wrapped> {
var value: Wrapped
init(_ value: Wrapped) { self.value = value }
}
func download(url: URL) async throws -> Data? {
let urlSessionTask: Wrapper<URLSessionTask?> = Wrapper(nil)
return try await withTaskCancellationHandler {
try await withUnsafeThrowingContinuation { continuation in
urlSessionTask.value = URLSession.shared.dataTask(with: url) { data, _, error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: data)
}
}
urlSessionTask.value?.resume()
}
} onCancel: {
urlSessionTask.value?.cancel()
}
}
Storeという会社さんが導入した例が以下になります。open-ai generatorにより生成した内容だそうです。
- 以下、引用
open class func session(sessionParams: SessionParams? = nil) async throws -> SessionToken {
var requestTask: RequestTask?
return try await withTaskCancellationHandler {
try Task.checkCancellation()
return try await withCheckedThrowingContinuation { continuation in
guard !Task.isCancelled else {
continuation.resume(throwing: CancellationError())
return
}
requestTask = sessionWithRequestBuilder(sessionParams: sessionParams).execute { result in
switch result {
case let .success(response):
continuation.resume(returning: response.body)
case let .failure(error):
continuation.resume(throwing: error)
}
}
}
} onCancel: { [requestTask] in
requestTask?.cancel()
}
}
- 以上、引用 STORES 予約 アプリへのasync/await導入より
Group Tasks
動きとしてはasync let
と同じく複数の非同期処理を同時並行でやるものとしてgroup tasks
があります。
withTaskGroup
関数やwithThrowingTaskGroup
関数でGroupTaskを開始します。
GroupTaskのクロージャ内でasync
を呼ぶと、子タスクを手動で開始できます。
複数の子タスクから結果を集約していくことになると思いますが、実は上のようにthumnailsという辞書型にデータを集めていくようなやり方だとコンパイルエラーが出ます。
子タスクからは変数に同時平行的にアクセスが走る可能性があるので、値型の変数や、actor、同時平行アクセスに対する対処を実装したクラス以外にはアクセスすべきではありません。物によってはこのようにコンパイルエラーが出ます。
下のようにfor await
を使って子タスクの結果を集約すれば、コンパイルエラーは出ません。
Unstructured Tasks
以上までのタスク・子タスクの関係性を利用しての同時平行処理をStructured Tasks
と呼んでいますが、タスクを利用しない同時平行処理をUnstructured Tasks
と呼びます。
具体的には、例えばApple のUIKitでさまざまに定義されているデリゲートのメソッドなどはasync
で定義されていないので、そのままだと内部でawait
を呼ぶことができません。その際、async {}
関数を使うと、どこからでもawait
が使えるようになります。
以下は、コレクションビューにおいて、あるアイテムが画面に出現したら画像ダウンロードを開始するが、画面から消えたらキャンセルする一例になります。コレクションビューのデリゲートがasync
なしであるためそのままではawait
を中で使うことができないので、async {}
関数を用いて使っています。
Meet AsyncSequence
for-await-in
ループまたはfor-try-await-in
で、連続した非同期処理を行うことができます。
for-await-in
ループを使うには対象がAsyncSequence
プロトコルに準拠している必要があります。しかし、既存のコールバックやデリゲートを使う処理など、AsyncSequence
プロトコルに準拠していない物でも、AsyncStream
でラップしてしまうと、比較的簡単にfor-await-in
ループに適合させられるようになります。
実際に使ってみると、以下のようになります。
Protect mutable state with Swift actors
- Actorとはshared mutable stateを隔離し、データ競合を防ぐもの
データ競合というのが発生することがあります。二箇所(2スレッド)以上の箇所から同じデータにアクセスし、しかも読み込みではなく書き込みが一つ以上含まれる場合は、データ競合が起こり得ます。
値型(基本型や構造体など)のデータであれば、複数の箇所から参照されるということが起こり得ない(常にローカルでコピーが作られる)ので、データ競合は起こり得ません。が、他方で複数の場所から参照できる共有の状態というのは、値型では実現できないということになります。
というわけで今までは、複数箇所から同時並行的に参照・変更され得るような、共有のデータ(Shared mutable state in concurrent programs
)を実現するには、参照型(クラスなど)で競合が起きないようDispatchQueueなどを用いるなどして注意深くコードを書くしかありませんでした。
今回導入されるActor
では、Shared Mutable State
を簡単に実現できるように設計されたものです。Actor
自身の状態を他のプログラム全ての部分から隔離する設計となっています。
Actor
タイプはクラスと同様参照型で、クラスや構造体と同様変数・関数・イニシャライザ・エクステンションなどを定義することができます。
以下のように、データ競合を避けるため、Actor
へアクセスする際はawaitキーワードで待受をする必要があります。
他方Actor
自身がActor
自身にアクセスするときは特に待受する必要はありません。
- データが想定と異なる変更をされる可能性があることに注意
注意点として、Actor
は複数箇所から(awaitで)アクセスされることから、awaitで待っている間に他の箇所からのアクセスによりActor
の状態が当初の予想から変わってしまっていることがあります。そのためそれをあらかじめ想定しておく必要があります。
例えば以下は、画像をダウンロードするためのActorです。
まず、キャッシュをチェックし、キャッシュがある場合はダウンロードをせずにキャッシュを返しています。(if let cached = chche[url] { return cached }
)
もしキャッシュがなかったら初めてダウンロードをし、ダウンロードした画像をキャッシュに入れていくわけです。
最初の段階でキャッシュがないことをチェックしてはいます。しかし、ダウンロードが時間がかかる上、他の箇所からもこの部分のコードを通って同時にダウンロードをしている可能性があります。すると他の箇所から実行されているダウンロードが先に完了し、キャッシュを埋めている可能性があります。つまり最初の行でキャッシュがないことを確認していたとしても、自分の画像ダウンロードが終わった時には、他の画像でキャッシュが埋められている可能性があるわけです。
そのため、ダウンロードが終わった後(時間がかかる処理が終わった後)も、もう一度キャッシュをチェックする処理を入れています。そうして、キャッシュが入っていた時は元のキャッシュを尊重してダウンロードしてきた画像は破棄する、という例が紹介されています。
(cache[url] = cache[url, default: image]; return cache[url]
)
- nonisolated キーワードを使ったプロトコルへの適合
actorをプロトコルに適合させる際問題が発生することがあります。何かactor内にある状態をにアクセスするようなメソッドを実装しなければいけないのに、awaitキーワードをつけることができないので、結果プロトコルに適合できないというような場合です。
この場合は、そのメソッドだけnonisolated
キーワードをつけると対応できます。nonisolated
キーワードをつけるということは、その部分はActor
の外側として扱われるので、Actor
の内側にある状態(mutable shared state
)にアクセスすることはできません。下の例では、定数のidNumber
にアクセスしているだけなので問題は生じません。
もしnonisolated
をつけているところからActorの変数にアクセスしようとすると、mutable shared state
にアクセスしようとしていることになるので、コンパイルエラーになります。
- Actorが所有できるのはSendableプロトコルに適合した型のみ
もしActorが何かのクラス(以下ではBook
クラス)の変数を所有していたとしましょう。するとクラスは参照型なので、他の箇所からもアクセスできてしまいます。つまり、Actorの内部の状態がActorを経ずに参照されてしまうということになります。
これを防ぐために、Actor
が保有できるのは新しく導入されたSendable
プロトコルに適合した型のみとされています。Sendable
は複数スレッドからの実行の文脈で安全に共有できるかどうかの性質を表しています。値型(構造体・基本型など)や、Actorや、immutableなクラスが適合しています。言い換えれば、普通のクラスはSendable
に適合していません。従って、Actorは変数として普通のクラスを持つことはできません。
なおSendable
に適合するには、ある型の持っている変数が全てSendable
に適合している必要があります。また、ジェネリックを用いる型をSendable
に適合したい場合は、ジェネリックで導入する型が全てSendable
に適合している必要があります。
メソッド・クロージャをSendable
に適合させるには@Sendable
でマーカーをつけます。@Sendable
メソッドは値型の変数しか使うことができません。メソッドがキャプチャする対象もSendable
プロトコルに適合している必要があります。以下では、@Sendable
のクロージャの中でmutable関数を使っているのでエラーとなっています。
- main actor
メインスレッド(UIの更新などを行うスレッド)というものがありました。今まで、例えばAPIなどでデータを持ってきた後、そのデータを用いてUIを更新する場合は、明示的にメインスレッドで行うようにするため、DispatchQueue.main.async {}
等を使っていたかと思います。
今回導入されたmain actor
はこれを簡単にできるようになっています。関数や変数に@MainActor
という文字をつけると、それらの実行は常にメインスレッド上で行われることが保証されます。クラスの宣言などに@MainActor
をつけた場合は、そのクラスの全変数・メソッドに@MainActor
をつけたのと同じになります。
プログラムの中で、データを扱う部分の中でUIと接する部分(データを用いてUIに影響を与える部分)に@MainActor
を使っていくと良いでしょう。
Discover concurrency in SwiftUI
async マークの付いた非同期メソッド(サーバーから画像をダウンロードする) を持っている、ObservableObjectに@MainActor
マークを つける例が紹介されています。
これにより、該当の オブジェクトのメソッドが常にメインスレッド上で実行されることが保証されます。async キーワードをつけていることにより、非同期処理と実行中は一旦メインスレッドが明け渡しされるので、UIが止まってしまう心配はありません。非同期処理の終了次第、画面の更新の処理がメインスレッドで確実に行われることになります。
※ObservableObjectについてはこちら whats-the-difference-between-observedobject-state-and-environmentobject参照
Swift Concurrency
公式解説ページ: Swift Concurrency
Swift Migration Guide
Data Isolation(隔離)
データ競合が起きないことを保証する仕組みのことで、コンパイラがあらゆる可変な状態をチェックしている。
Isolatioin Domain(隔離ドメイン)
全ての変数・関数について、隔離を行うために三種の隔離ドメインの内のどれかに属することになる。どの隔離ドメインに属するかは明示的に示すこともできるし、宣言されている場所のコンテキストによって暗黙的に決まっている場合もある。
- 非隔離(non-isolated)
- アクター隔離(actor-isolated)
- グローバルアクター隔離(global-actor-isolated) 典型的には、MainActor-isolated
actor内部であれば2, @MainActorなどグローバルアクターの内部であれば3, それ以外は全て1となる。
ある隔離ドメインはそれぞれ保護されており(Isolation Boundary 隔離境界),ある隔離ドメインのコンテキストの中から、別の隔離ドメインに属する変数や関数にアクセスしようとするとコンパイラによってエラーとなる。これにより、データ競合を防いでいる。
Sendable
Sendable Types
複数スレッドからの実行の文脈で安全に共有できることを表しているプロトコル。これに適合している型は隔離境界を越えることができる。
Sending
Sendableに適合していない型であっても、場合によっては安全に実行できる場合があり、その場合Sendable キーワードをつけることになる。一々Sendableに適合させなくても良い点が便利。
関数のパラメータにつける場合と、関数の戻り値につける場合がある。
関数のパラメータの場合、その値を関数に渡した後一才変更されない必要がある。
Swift 6 Migration Guide - Flow-Sensitive-Isolation-Analysis
swift-evolution
【Swift Concurrency】引数に付与するsendingキーワードについて
その他参照
Swift Documentation - Task Cancellation
Swift Documentation - Task and Task Group
Swift Documentation - Actors