Swift Concurrency まとめ(正式版対応済)
Swift 5.5 では、待望の async/await 構文をはじめとした Swift Concurrency Roadmap における並行処理機能、いわゆる Swift Concurrency のサポートが多く開始され、WWDC21 でも各機能の使いかたや仕組みが詳細に解説されていました。
Swift Concurrency についてはすでに koher 氏の 『Swift 6 で来たる並行処理の大型アップデート近況』や『先取り! Swift 6 の async/await』 などの資料でも詳細に解説されています。この記事では WWDC の各セッションで行われた説明の紹介を中心に、この記事ひとつで Swift Concurrency についての現時点での概観を把握できる状態を目指して、改めてまとめてみました。
async/await
Concurrency にまつわる様々なプロポーザルの関係性を表した図。 Swift Forums より
async/await が解決する課題
Swift 5.5 で追加される非同期処理関連の機能として、言語の構文としての async/await と、各並行処理間の関係性を整理するための概念である Structured Concurrency、そして安全な並行処理を記述するための新たな型である Actor の 3 つに大別できると考えています。まずは、 "Meet async/await in Swift" セッションで説明されている、構文としての async/await 導入の背景から見ていきましょう。
まずは例として、以下のような処理が挙げられています。ある ID(String
)から対応するサムネイルの URL リクエストを生成し、そこから画像のデータ(Data
)を取得した後に画像(UIImage
)を生成します。最後に、その画像をサムネイル表示に適した新たな画像に変換する、といった処理です。
"Meet async/await in Swift" 2:59
これらの処理の中では、最初の URL リクエスト生成と 3 番目のデータから画像への変換は即座に終わるような処理のため、 "同期的" に実行されます。2 番目のデータ取得と最後の画像変換は時間がかかる処理のため、完了後の処理(コールバック)をクロージャとして受け取って "非同期的" に実行されます。
あるスレッド(一連の処理の流れ)の中で実行する処理が同期的(synchronous)であるとは、その処理の実行中はスレッドで他の処理が実行されない(スレッドがブロックされる)ということを意味します。一方、非同期的(asynchronous)であるとは、スレッドは実行を開始した処理の完了を待たずに他の処理の実行へと移れる、といったことを意味します。
macOS/iOS アプリ開発では、UI の状態を変更できるのがメインスレッドと呼ばれるスレッドただ一つのみに限られているため、メインスレッドがブロックされて画面描画やユーザーの操作を妨げることがないように、時間のかかる処理を別のスレッド非同期的に実行することが一般的だと思います。
"Meet async/await in Swift" 1:33
これまでの Swift では、実行に時間がかかる処理には完了後の処理をクロージャで渡し、その引数で結果を受け取る書き方がよく使われていました。以下のような書き方です。
// "Meet async/await in Swift" 3:43
func fetchThumbnail(for id: String, completion: @escaping (UIImage?, Error?) -> Void) {
let request = thumbnailURLRequest(for: id)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(nil, error)
} else if (response as? HTTPURLResponse)?.statusCode != 200 {
completion(nil, FetchError.badID)
} else {
guard let image = UIImage(data: data!) else {
completion(nil, FetchError.badImage)
return
}
image.prepareThumbnail(of: CGSize(width: 40, height: 40)) { thumbnail in
guard let thumbnail = thumbnail else {
completion(nil, FetchError.badImage)
return
}
completion(thumbnail, nil)
}
}
}
task.resume()
}
これらの処理自体も引数の completion
クロージャで結果を返す関数としてまとめられているため、全ての処理の完了後や各処理の失敗時に completion
クロージャを呼び出して、非同期的に結果を返しています。このコードは問題なく動きますが、いくつか懸念があります。
ひとつは、今後の修正やリファクタの過程で completion
クロージャを呼び出し忘れたり、処理の流れによっては 2 回以上呼んでしまったりする可能性があるという点です。これは、同期的な関数から return
で結果を返す場合は、コンパイラがチェックしてくれていた点でした。
また、エラーが発生したことを知らせるために標準のエラーハンドリング(throws/try/catch)ではなくクロージャの引数を用いてエラーを渡す形になっているため、こちらもコンパイラによるチェックの恩恵を受けられません。エラーハンドリングの問題については Result
型を使用することにより軽減することができますが、コードの冗長性が増してしまいます。
このように、クロージャを多用した非同期的なコードは読みづらく書きづらいだけでなく、同期的なコードでは受けられていた安全で正しいコードを書くための言語機能によるサポートが受けられないことが大きな問題だった、というふうに説明されています[1]。
この度導入された async/await では、同期的な処理と非同期的な処理をほぼ同じ見た目で書けるようになる上に、同じ言語機能によるサポートを受けることができるようになります。先ほどのコードを async/await を用いて実装し直すと、以下のようになります。
// "Meet async/await in Swift" 8:30
func fetchThumbnail(for id: String) async throws -> UIImage {
let request = thumbnailURLRequest(for: id)
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badID }
let maybeImage = UIImage(data: data)
guard let thumbnail = await maybeImage?.thumbnail else { throw FetchError.badImage }
return thumbnail
}
この関数自体に async
キーワードを付けたことで、この関数の中は "非同期なコンテキスト" ("asynchronous context")になり、 URLSession.shared.data(for:)
のような他の async
が付いた関数を呼び出すことが可能になりました。async
が付いた関数を呼び出す際は、クロージャで完了を受け取る代わりに await
というキーワードを付けて呼び出すことで、結果を "待つ" ことができます。async/await を用いて書き直したことで、コードの行数もネストの深さも減って読みやすさが向上しただけでなく、エラーハンドリングに try
や throw
を用いることが可能になったり[2]、 guard
節に return
や throw
があることが保証されたりと、同期的なコードと同じように Swift の言語機能を活用できていることがわかると思います。
サスペンションポイント (Suspension point)
では、この処理はスレッドをブロックすることはないのでしょうか。今までの Swift のコードでは、基本的に上から下に順番に、同じスレッドで実行され、実行中に処理が中断されたりスレッドが変わったりすることはありませんでした。しかし、async/await を用いたコードでは、 await
キーワードのある部分(await
式)が 「サスペンションポイント」(Suspension point)[3] となり、その処理の実行が一時的にサスペンド(保留)される可能性があることを表すようになります。サスペンドされている間、そのスレッドは他の処理の実行に回ることができるため、ブロックされているわけではありません。
サスペンションポイントで一時停止した処理は、またサスペンションポイントから再開します。このとき、サスペンションポイントの前の処理を実行していたスレッドとは別のスレッドで再開することも有り得ます。先ほどのコードには 2 つの await
キーワードがあり、3 つのブロックに分かれると考えることができます。それぞれのブロックは、全て異なるスレッドで実行されることもある、ということです。逆に言えば async/await を利用したコードにおいて、スレッドが切り替わるか切り替わらないかというのは(後述するメインスレッドのように特殊な例を除けば)あまり気にする必要はありません。
"Meet async/await in Swift" 19:50
少なくとも現在のところ、Swift においてサスペンションポイントとなり得るのは await
キーワードを使用した場所だけです。すなわち、 await
キーワードが明示的にサスペンションポイントを表すマーカーとなる、ということです。 await
キーワードだけを見ればどこで処理がサスペンドされる可能性があるのか把握できますし、 await
キーワードが現れない場所で処理がサスペンドされることはないと確信することができます。
AsyncSequence
async/await と共に追加される機能として、 "Meet AsyncSequence" セッションで紹介されている AsyncSequence
があります。これは文字通り、 Sequence
プロトコルの async/await 版と捉えることができるもので、以下のような書き方を可能とします[4]。
// "Meet AsyncSequence" 4:28
for await quake in quakes {
if quake.magnitude > 3 {
displaySignificantEarthquake(quake)
}
}
これまでの Swift でも、 for
... in
文でループ処理できる対象は Array
に限られたものではなく、 Sequence
プロトコルに準拠していればどんな型に対しても使うことができました。そのため、for
... in
文は実質的に while
文のシンタックスシュガーであると捉えることもできました。
// "Meet AsyncSequence" 3:24, 3:52
for quake in quakes {
if quake.magnitude > 3 {
displaySignificantEarthquake(quake)
}
}
// ↓
var iterator = quakes.makeIterator()
while let quake = iterator.next() {
if quake.magnitude > 3 {
displaySignificantEarthquake(quake)
}
}
for await
... in
文でも同様に、Sequence
のイテレーターが次の値を返す関数(next()
)が非同期になり、結果が返ってくるのを await
式として待つようになったもの、と見なすことができます。
// "Meet AsyncSequence" 4:11, 4:28
for await quake in quakes {
if quake.magnitude > 3 {
displaySignificantEarthquake(quake)
}
}
// ↓
var iterator = quakes.makeAsyncIterator()
while let quake = await iterator.next() {
if quake.magnitude > 3 {
displaySignificantEarthquake(quake)
}
}
そして、同期的な for
... in
文と同様に、 break
キーワードでループを止めたり、 continue
キーワードで次のイテレーションへ進むこともできます。
// "Meet AsyncSequence" 5:36, 5:51 から改変
for await quake in quakes {
if quake.location == nil {
break
}
if quake.depth > 5 {
continue
}
if quake.magnitude > 3 {
displaySignificantEarthquake(quake)
}
}
さらに、同期的な Sequence
プロトコルが持つ map
や filter
などの多くのオペレーターが、AsyncSequence
にも用意されています。
"Meet AsyncSequence" 10:00
セッションではこれらのオペレータが使えることの紹介のみでしたが、手元でも少し試してみましょう。 Foudation
フレームワークの URL
クラスに、データを文字列として 1 行ずつ取得できる lines
というプロパティが追加されています。以下のコードでは、指定した URL から meta
タグだけ抜き出しています(タグが複数行にわたる場合のことは、今はとりあえず考えないでおきます)。なお、 URL.lines
プロパティのようにエラーが起きる可能性のある AsyncSequence
に対しては for try await
... in
という構文が使えます[5]。
let url = URL(string: "https://example.com")!
let lines = url.lines
.map { (line: String) -> String in
print("[map] ", line)
return line.trimmingCharacters(in: .whitespaces)
}
.filter { (line: String) -> Bool in
print("[fil] ", line)
return line.starts(with: "<meta")
}
.prefix(2)
for try await line in lines {
print("<for> ", line)
}
print("\nEnd")
これを実行した時のログは以下のようになります。
[map] <!doctype html>
[fil] <!doctype html>
[map] <html>
[fil] <html>
[map] <head>
[fil] <head>
[map] <title>Example Domain</title>
[fil] <title>Example Domain</title>
[map] <meta charset="utf-8" />
[fil] <meta charset="utf-8" />
<for> <meta charset="utf-8" />
[map] <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
[fil] <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<for> <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
End
1 行 1 行が取得されるたびに、 map
による変換と filter
による判定が行われていることがわかります。要素ごとに毎回 await
で次の値を待っているので、各イテレーション(for
文の中身)が全て並行に実行されるわけではなく、あくまで順番に実行されます。また、prefix(2)
により、2 件のmeta
タグが取得できた時点でループを終えていることがわかります。このことから、同期的なSequence
における lazy
を付けたときと似た挙動をしていることがわかります[6]。
AsyncStream
"Meet AsyncSequence" セッションではもうひとつ、 AsyncStream
というものも紹介されています。この型は AsyncSequence
に準拠し、既存の async/await に対応しない実装からも簡単に AsyncSequence
を作ることができるものです。
セッションでは具体的なコードはほとんど紹介されませんでしたが、 Swift Evolution のプロポーザル にいくつか例があります。たとえば、システムイベントを受け取る DispatchSource
から AsyncStream
を作るには、以下のようにイニシャライザにクロージャを渡すだけです。
// https://github.com/apple/swift-evolution/blob/main/proposals/0314-async-stream.md から改変
extension QuakeMonitor {
static var quakes: AsyncStream<Quake> {
AsyncStream { continuation in
let monitor = QuakeMonitor()
monitor.quakeHandler { quake in
// 値を非同期的に受け取ったら continuation の yield 関数を呼び出すことで
// AsyncStream に値が流れる。
continuation.yield(quake)
}
monitor.onTermination = { _ in
// AsyncStream が終了した後の処理を記述できる。
// 引数には、末端まで到達したために終了したのかキャンセルされて終了したのかを
// 表す値が入る。
// ここでは stopMonitoring 関数を呼び出して監視を終了している。
monitor.stopMonitoring()
}
monitor.startMonitoring()
}
}
}
for await quake in QuakeMonitor.quakes {
// ...
}
このコードを見て、既存のリアクティブプログラミングのためのライブラリを思い出される方も多いのではないでしょうか。実際、たとえば以下のようなシンプルなコードで、 RxSwift のObservable
から AsyncStream
のエラー対応版である AsyncThrowingStream
に変換することができます。もちろん、同様に RxSwift 6 で追加された Infallible
から AsyncStream
へも変換できるでしょう。
import RxSwift
extension ObservableType {
public var async: AsyncThrowingStream<Element, Error> {
AsyncThrowingStream { continuation in
let disposable = self.subscribe(
onNext: { continuation.yield($0) },
onError: { continuation.finish(throwing: $0) },
onCompleted: { continuation.finish() }
)
continuation.onTermination = { @Sendable _ in
disposable.dispose()
}
}
}
}
既存のコードを async/await に対応させる
構文としての async/await についての最後のテーマとして、既存のコードを async/await に対応させる方法を確認しておきましょう。 "Meet async/await in Swift" セッションにおける、 "Async alternatives and continuations" というパートで説明されています。
パートの名前に含まれる "continuation"("継続")とは、単にプログラムの "続き" の部分のことであると捉えられます。 async/await においては await
式の後、すなわちサスペンションポイントの後に続く処理にあたります。同様に、既存の完了後の処理をクロージャとして引数に渡していた形も、まさに継続を引数として渡していたと捉えることができると思います。
グローバルな withCheckedContinuation
関数と withCheckedThrowingContinuation
を使うと、クロージャの引数に await
式の続きの処理が継続として渡されるので、それを用いて既存の実装を async/await に対応させることができます。セッションでは、新しく async/await に対応された Core Data のメソッドを例に説明されています。
"Meet async/await in Swift" 26:59
Core Data についての詳細や新しいインターフェースについてここでは触れませんが、getPersistentPosts
関数で非同期的に Post
の取得を行い、クロージャで結果を受け取れることがわかります。このクロージャの中で、エラーが発生していれば continuation
オブジェクトに対して resume(throwing:)
メソッドを呼び、結果が取得できていれば resume(returning:)
メソッドを呼んでいます。 withCheckedThrowingContinuation
関数の呼び出しに try await
が付いていることからも察せられる通り、resume(throwing:)
メソッドにエラーを渡すと throw
したのと同じことになり、resume(returning:)
に結果を渡すと await
のつづきへと処理が継続されることになります。これにより、既存のクロージャで結果を受け取るインターフェースを簡単に async/await に対応させることができます。クロージャで結果を返す関数のように非同期処理を記述できる、または async/await における継続とクロージャとしての継続を変換できる[7]、と表現することもできると思います。
withCheckedContinuation
/withCheckedThrowingContinuation
は、関数名に "Checked" という語が含まれています。これは、この関数は継続の呼び出しに対するいくつかの契約(ルール)が守られていることを確認する、という意味です。その契約とは、 "継続は必ず 1 回再開されること" というものです。継続を一度も再開しないと、文字通りプログラムの実行が継続しないことになり、停止してしまいます。そして Swift の async/await では継続が一度しか再開されない前提の上で成り立っているため、2 回以上再開してしまうと致命的なエラーにつながることになります。同期的に結果を返す関数で return
が一度も現れなかったり、逆に複数現れたりするケースを考えるとわかりやすいかもしれません。同期的な関数と異なり現在の Swift ではコンパイラが静的に確認することはできないので[8]、ランタイムで違反があればログを出力したりトラップしたりしてくれるようになっています。なお、非常にシビアなパフォーマンスが求められる場面などでどうしてもランタイムでのチェックを省略したい場合のために、 withUnsafeContinuation
や withUnsafeThrowingContinuation
というインターフェースも用意されています。
構造化された並行性(Structured Concurrency)
さて、 AsyncSequence
の説明の際に、ループの各イテレーションは並行に実行されるわけではない、という点に触れました。実際に複数の処理を並行に実行するためには、async/await と同時に Swift に導入される概念である 「構造化された並行性」(Structured concurrency) について理解する必要があります。
"Explore structured concurrency in Swift" セッションでは、構造化された並行性を説明するための前提として、まず構造化プログラミング(Structured programming)について触れられています。
構造化プログラミング以前のコードは、単に一列に並んだ命令の羅列であり、実行中に自由に他の場所へ "ジャンプ" することが許されていたため、処理の流れを追うことが難しいものでした。対して Swift のように構造化プログラミングを取り入れた言語では、原則として処理は上から下に流れます。これは if
文や switch
文による分岐があっても、それらが複数ネストされていても変わりません。さらに、Swift では変数は定義されたブロックの中でしか使えない(変数の束縛が静的スコープである)ため、ある変数がどこまで有効なのか簡単に判断することができます。このように、処理や変数の間の関係性が "構造化" されていることでコードが理解しやすくなることが、構造化プログラミングの特長です。そして、Swift では async/await により、今後は非同期処理を含むコードもこの構造化プログラミングの恩恵を受けられるようになる、というのが大きな利点の一つでした。
"Explore structured concurrency in Swift" 1:29
一方、構造化された並行性で構造化されているのは "並行に実行されるそれぞれの処理の関係性" ということになります。そして Swift では、各並行処理を表す 「タスク」(Task) という概念が導入され、このタスクの関係を構造化することで構造化された並行性を実現しています。
"Explore structured concurrency in Swift" 4:32
セッションでは、タスクの持つ基本的な性質について紹介されています。まず、タスクは非同期的な処理を並行に(concurrently)コードを実行するためのコンテキストを提供するものです。スレッドと違い、複数のタスクを "安全で効率的なときだけ" 並列で(parallely)実行します。タスクは Swift の言語機能と統合されているので、Swift コンパイラは並行処理におけるバグを事前に発見して警告することができます。そして、タスクは async
のついた関数を呼び出すたびに作られるわけではなく、ある非同期な関数から他の非同期な関数を呼び出したときはそのまま同じタスクで実行されます。タスクを作る方法・作られるタイミングは決まっていて、必ず明示的に作られるものです。
こういったタスクという概念やタスク間に作られる依存関係は単なる Swift 内部の実装詳細というわけではなく、後述するキャンセルや優先度などを理解する上で重要な概念である、とも説明されています。
async let
タスクを作る方法の一つとして、 async let
を使った変数の定義があります。ループを並列化する前に、まずは 2 つの処理を並列に実行してみましょう。セッションで例示されている以下のコードでは、ID に対応する画像データとメタデータを両方取得して、メタデータに基づいて画像をサムネイルに変換しています。
// "Explore structured concurrency in Swift" 7:24
func fetchOneThumbnail(withID id: String) async throws -> UIImage {
let imageReq = imageRequest(for: id), metadataReq = metadataRequest(for: id)
let (data, _) = try await URLSession.shared.data(for: imageReq)
let (metadata, _) = try await URLSession.shared.data(for: metadataReq)
guard let size = parseSize(from: metadata),
let image = await UIImage(data: data)?.byPreparingThumbnail(ofSize: size)
else {
throw ThumbnailFailedError()
}
return image
}
このコードでは、それぞれの URLSession.shared.data(for:)
の前に try await
が付けられているのがわかります。つまり、サスペンションポイントである await
で結果が返ってくるのを順番に待ち、1 番目のリクエストでエラーが発生すればその時点で実行は終了します。このコードを、 async let
を用いて 2 つのリクエストが同時に実行されるように変更すると、以下のようになります。
// "Explore structured concurrency in Swift" 7:59
func fetchOneThumbnail(withID id: String) async throws -> UIImage {
let imageReq = imageRequest(for: id), metadataReq = metadataRequest(for: id)
async let (data, _) = URLSession.shared.data(for: imageReq)
async let (metadata, _) = URLSession.shared.data(for: metadataReq)
guard let size = parseSize(from: try await metadata),
let image = try await UIImage(data: data)?.byPreparingThumbnail(ofSize: size)
else {
throw ThumbnailFailedError()
}
return image
}
リクエストごとの try await
が消え、代わりに let
の前に async
が付いたことがわかります。そして、 async let
で定義された変数を参照している場所(guard let
の部分)に try await
が移動しています[9]。
async let
による変数定義では、変数の初期化処理(URLSession.shared.data(for:)
部分)を実行するための新しいタスクが、現在処理を実行中のタスクの "子タスク" として作られ、初期化処理であるリクエストの発行は子タスクのほうで実行が開始されます。ただし、先述したとおりサスペンションポイントとなるのは await
キーワードが現れる場所だけなので、 async let
による変数の定義はサスペンションポイントとならず、この段階では処理がサスペンドされることはありません。そのため、 async let
で定義した変数に実際の値が代入されないまま、プレースホルダーが設定されただけの状態になります。以下の図における緑の部分が子タスクで実行される部分にあたり、白い線で表されている元のタスクはその後の処理へと進んでいくことになります。
"Explore structured concurrency in Swift" 6:48
その後、2 つ目のリクエストについても同様に子タスクの作成とリクエストの開始、プレースホルダーの設定が行われ、そのまま guard
文へと進みます。この時点で、2 つのリクエストが 2 つの子タスクで同時に実行されている状態になりました。
async let
で定義した値を guard let
内で参照するときには実際の結果を取得したいため、まだリクエストが完了していなかった場合はその場で結果を待つことになります。そのため、値を参照する際に await
キーワードを用いて、サスペンションポイントとする必要があります。また、リクエスト中にエラーが発生していた可能性もあります。その場合、変数を参照しようとしたタイミングでエラーも参照することになるため、 try
キーワードも必要となります。
このように、async let
を用いることで一部の処理を新たに作成したタスクに任せ、現在のタスクはその後の処理へ進むことができます。このとき、作成されたタスクは作成元のタスクを親として持つ子タスクとして作成されます。親のタスクは、全ての子タスクが完了するまで完了することはありません。また、タスクのキャンセルについては後述しますが、親のタスクがキャンセルされると全ての未完了の子タスクもキャンセルされます[10]。構造化された並行性では、タスクの間にこういった "構造" が保たれることで、タスクが管理しやすく、並行処理のライフサイクルが理解しやすい状態を保つことができるのです。
なお、プロポーザルによると、Swift で記述されたプログラムのエントリーポイントである main.swift ファイルや、 @main
を持った型に定義した main()
関数に async
が付いていれば必ず新しく作成されたタスクの中で実行されるため、これらの中では await
が使えます[11]。このタスクが終了する時がプログラムも終了する時であるため、Swift プログラムの実行中に親とするべきタスクが一つも存在しない、という状況はありえないことになります。
タスクのキャンセル
構造化プログラミングでは、外側の処理が完了するときには必ず内側の処理も完了している、というルールがありました。これは構造化された並列性における親タスクと子タスクの関係においても同じです。構造化プログラミングではまた、例外によって内側の処理を中断し、外側のエラーハンドリングへと実行を戻すこともできました(大域脱出)。構造化された並列性では、子タスクでエラーがあったときにどのように処理が中断されるのでしょうか。
"Explore structured concurrency in Swift" 10:01
セッションでは先ほどのコードをもとに、並列で実行される 2 つの子タスクのうち片方でエラーが発生したケースを例に説明されています。片方のリクエストでエラーが発生したとき、まだもう片方のリクエストは実行中だった場合は、そのリクエストを実行中の子タスクは "キャンセル" されることになります。
タスクをキャンセルしても、その場で即座に処理が停止するわけではありません。もしそのタスクが、たとえばトランザクションが必要な処理や使用中のネットワーク接続などを持っていたら、それらの処理をいきなり止めてしまうと一貫性が崩れたりリソースがリークしたりする可能性があります。そのため、タスクのキャンセルはタスクに "キャンセルされた" というフラグを立てるだけで、キャンセルされたかどうかは各タスク側で明示的に確認する必要がある、ということになっています。この仕様は 「協調的なキャンセル」(Cooperative cancellation) と呼ばれるものです。
セッションでは、現在のタスクのキャンセル状態をハンドリングする方法が 2 種類紹介されています。先ほどの ID からサムネイルを取得する処理を複数の ID に対して行う関数を例にして説明されています。
ひとつは、Task
型が持つ checkCancellation
関数を呼ぶことです。この関数は、現在のタスクがキャンセルされていたときに CancellationError
を発生させます。キャンセルがエラーとして扱われることで、関数を呼ぶ側は throws
キーワードの有無によって、キャンセルを含めて何らかのエラーによって処理が中断される可能性があるかどうかを判断することができる、というわけです。
// "Explore structured concurrency in Swift" 11:46
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
var thumbnails: [String: UIImage] = [:]
for id in ids {
try Task.checkCancellation()
thumbnails[id] = try await fetchOneThumbnail(withID: id)
}
return thumbnails
}
もうひとつの選択肢は、同じく Task
型の isCancelled
プロパティで Bool
型の値としてキャンセルされているかどうかを確認することです。この場合( CancellationError
を即座に catch
せずとも)エラーを伝搬させずに処理を終了させるだけ、ということが可能です。なお、こういった実装を選択する際は、タスクがキャンセルされたときに部分的な結果だけが返ることを明示的にしておくべきである、とセッションでは説明されています。以下のような実装の場合、結果が返ってきたとしてもタスクがキャンセルされると引数として渡した ID のうち一部しか含まれていない可能性がある、ということです。
// "Explore structured concurrency in Swift" 12:46
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
var thumbnails: [String: UIImage] = [:]
for id in ids {
if Task.isCancelled { break }
thumbnails[id] = try await fetchOneThumbnail(withID: id)
}
return thumbnails
}
セッションでは触れられていませんでしたが、グローバルな withTaskCancellationHandler
関数を使うと、タスクがキャンセルされた時点で即座に行いたい処理をクロージャで指定することもできます。プロポーザルで紹介されている例のように、キャンセルのためのインターフェースを持つ既存の実装を用いる際に便利です。
// https://github.com/apple/swift-evolution/blob/main/proposals/0304-structured-concurrency.md#cancellation-handlers
// コメントのみ改変
func download(url: URL) async throws -> Data? {
var urlSessionTask: URLSessionTask?
return try withTaskCancellationHandler {
return try await withUnsafeThrowingContinuation { continuation in
urlSessionTask = URLSession.shared.dataTask(with: url) { data, _, error in
if let error = error {
// 理想的には NSURLErrorCancelled を CancellationError に変換すべき
continuation.resume(throwing: error)
} else {
continuation.resume(returning: data)
}
}
urlSessionTask?.resume()
}
}, onCancel: {
urlSessionTask?.cancel() // キャンセルされた直後に実行される
}
}
タスクグループ (Task Group)
async let
により、コードで定義したいくつかの変数を初期化する処理を並列に実行することが可能になりましたが、より柔軟に、任意の数の処理を並列に実行するには 「タスクグループ」(Task Group) を用いることになります。これも新しい概念ではありますが、Dispatch フレームワークの DispatchGroup
を使ったことのある方には理解しやすい概念だと思います。
タスクグループを使うには、グローバルな withTaskGroup
関数、もしくはそのエラー対応版である withThrowingTaskGroup
を用います。どちらも結果の型を指定するとクロージャの引数としてタスクグループが受け取れるので、そこに対して addTask
メソッドで処理を追加することができます。 addTask
メソッドに渡すクロージャの中身もまた非同期的なコンテキストになっているので、他の非同期処理を await
することができます。タスクグループはまた、追加されたそれぞれのタスクが結果を返すたびに値を返す AsyncSequence
としても扱うことができるので、 for await
... in
文で結果を取得することができます。
// "Explore structured concurrency in Swift" 16:32 からコメントのみ改変
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
}
addTask
メソッドで追加された処理は、現在のタスクを親として各処理を実行するための子タスクが作成されて実行されます。そしてタスクグループも構造化された並列性の一部であるため、これらのタスクは async let
により追加されるタスクと同じ性質を持ちます。親タスクがキャンセルされれば各子タスクもキャンセルされ、全ての子タスクが完了しなければ親タスクは完了しません。また、各子タスク内でさらに async let
やタスクグループにより "孫タスク" が生まれても、構造化された並列性としてのルールは保たれ続けます。なお、タスクグループに対して cancelAll
メソッドを呼び出すことで、タスクグループに含まれるタスクを全て明示的にキャンセルすることもできます。
セッションではこの時点では紹介されていませんが、タスクグループの addTask
メソッドにはそのタスクの優先度を TaskPriority
型で指定することができます。この優先度は、Dispatch フレームワークにおける QoS(Quality-of-service) に相当する概念です[12]。また、 addTaskUnlessCancelled
というメソッドも用意されており、こちらはキャンセルされていない限りタスクを追加する、というものです。こちらのメソッドでは、正常にタスクを追加できたかどうかを Bool
型の戻り値として受け取ることができます。
構造化 "されていない" 並行性
Swift に導入される構造化された並行性は、複数の並行処理を正しく効率的に管理するための優れた概念ですが、ときには構造化 "されていない" 並行性が必要な場合もあります。その理由として "Explore structured concurrency in Swift" セッションで挙げられているのは、ひとつは非同期的でないコンテキストから非同期的な処理を開始したい場合です。もうひとつは、構造化された並行性では子タスクのライフサイクルが親タスクを越えられないのに対して、親タスクのライフサイクルを越えて実行され続けるような処理を行いたい場合です。
Swift には構造化された並行性と同時に、構造化されていない状態で並行処理を行う方法も導入されています。そのひとつが 「構造化されていないタスク」("Unstructured tasks")[3:1] です。構造化されていないタスクは、 async let
やタスクグループによって作られるタスクと同様、実行の優先度などのもともとのコンテキストが持っていた情報を多く引き継ぎます。一方、こちらは非同期的なコンテキストではない場面、すなわち async
キーワードが付いていない普通の関数の中からも作ることができます。また、構造化されていないタスクが必要となる理由の一つである、親のライフサイクルに縛られないという性質も持っています。この性質は、逆に言えばタスクのキャンセルや完了の管理を Swift ランタイム側に任せられず、自前で管理しないといけないということを意味します。
"Explore structured concurrency in Swift" 21:39
構造化されていないタスクの代表的な使用例として、UIKit や SwiftUI など既存の仕組みのなかで使う場合が挙げられるでしょう。実際セッションでも、UIKit の UICollectionView でサムネイルの取得を非同期的に行うコードを用いて説明されています。
以下のコード例のように Task
型のイニシャライザに処理を指定することで、構造化されていないタスクを作成することができます。以下のコードでは、 UICollectionViewDelegate
の willDisplay
が発火したタイミングで、セルが表示された際に構造化されていないタスクにサムネイル取得と設定の処理を任せ、そのタスクを Dictionary
型のプロパティに保持しています。こうすることで、didEndDisplaying
でセルが非表示になったことが通知された際にタスクに対して cancel
メソッドを呼び出し、非同期で実行中の処理をキャンセルさせることができます。なお、 Task
型のイニシャライザには @discardableResult
属性が付けられており、タスクを作って処理を開始するだけで特に管理しない、という使い方も想定されています。
// "Explore structured concurrency in Swift" 22:11 から改変
@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 {
defer { thumbnailTasks[item] = nil }
let thumbnails = await fetchThumbnails(for: ids)
display(thumbnails, in: cell)
}
}
func collectionView(_ view: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt item: IndexPath) {
thumbnailTasks[item]?.cancel()
}
}
構造化されていない状態で並行処理を行うための方法のうち、もうひとつが 「分離されたタスク」(Detached tasks)[3:2] を作ることです。こちらは Task.detached
という関数によって作ることができ、これも構造化されていないタスクを作るための Task
イニシャライザとほぼ同じインターフェースを持っています。異なるのは、実行の優先度などのもともとのコンテキストが持っていた情報を全く引き継がない、という点です。
セッションでは、構造化されたタスク、構造化されていないタスク、そして分離されたタスクの分類について以下の表のように整理されています[13]。上 2 つの、async let
により作成されるタスクとタスクグループに含まれるタスクのみが、ライフサイクルやキャンセルが管理されている構造化されたタスクにあたります。逆に Task
型のイニシャライザや Task.detached
関数などを用いて作成した Task
型の値を直接扱っている場合は、そのライフサイクルをプログラマ自身が管理しなければいけないという意味で「構造化されていない("structured" ではない)」ことになる、という点に留意してください。実際、タスクグループは構造化されている性質を保つために、あえて子タスクに直接アクセスできないようにしている、とプロポーザルでも述べられています。
"Explore structured concurrency in Swift" 26:21 から改変
語弊を恐れずに言えば、Swift に導入される構造化された並行性における構造化されていないタスクや分離されたタスクは、いわば構造化プログラミングにおける "goto 文" にあたるようなものと見なすことができるでしょう。盲目的に避けるべきではないと思いますが、既存実装との橋渡しなどの有効な使い所を見極め、作成したタスク(のハンドル)を広い範囲で共有しすぎないなど利用を局所化しつつ、可能な限り構造化された並行性を活用していくことが求められそうです。
さて、説明の都合上無視する形になってしまいましたが、先ほどのコード例に出てきた @MainActor
や、スライドで挙げられている性質のひとつである "Inherit actor isolation"、そして構造化されていないタスクと分離されたタスクの使い分けを理解するには、Swift Concurrency におけるもうひとつの大きな要素である「アクター」について知る必要があります。
Sendable
アクターについての説明に入る前に、タスクグループの説明をする際に出てきた複数のサムネイルを並列に取得するサンプルコードを思い出してみてください。以下のようなものです。
// "Explore structured concurrency in Swift" 16:32 からコメントのみ改変
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
}
このコード例では、タスクグループに含まれる各タスクが任意の値を結果として返すことができる特性を活用していました。では、以下のように thumbnails
に直接結果を追加していくことはできないのでしょうか。
// "Explore structured concurrency in Swift" 13:58
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
var thumbnails: [String: UIImage] = [:]
try await withThrowingTaskGroup(of: Void.self) { group in
for id in ids {
group.addTask {
thumbnails[id] = try await fetchOneThumbnail(withID: id)
}
}
}
return thumbnails
}
実は、このコードでは Mutation of captured var 'thumbnails' in concurrently-executing code
("キャプチャされた 'thumbnails' 変数を並行に実行されるコードの中で変更しています")というコンパイルエラーになります。そのため、タスクグループに追加される各処理の外側で、 for await
... in
による逐一実行で結果を集約する必要があったのです。
このコードがコンパイルエラーになる原因を確認するために、タスクグループに処理を追加する TaskGroup
型の addTask
メソッドのシグネチャを見てみると、以下のようになっています。追加する処理を指定するためのクロージャに、 @Sendable
という見慣れない属性が付いていることがわかります。
// https://developer.apple.com/documentation/swift/taskgroup/3862704-addtask
mutating func addTask(
priority: TaskPriority? = nil,
operation: @escaping @Sendable () async -> ChildTaskResult
)
@Sendable
属性 は、このクロージャ内で使うためにキャプチャする値が全て Sendable
プロトコルに準拠する必要があることを示す属性です。そして Sendable
プロトコル は、その値が並行処理で安全に扱えることを明示する役割を持っています。 Sendable
プロトコルと @Sendable
属性についての詳細は、 "Protect mutable state with Swift actors" セッションで紹介されています。
まず、並行処理ではどのようなケースで値が安全に "扱えない" のかを確認してみましょう。以下のようなカウンタークラスに対してカウントを増やす処理が同時に実行されると、「データ競合」(data race)と呼ばれる並行処理における代表的な問題が起きる可能性があります。データ競合は 2 つのスレッドがある値に並行してアクセスし、どちらか一方がそのデータを書き換えようとしている際に起こり得ます。以下のコードでは increment
メソッドを 2 回呼んでいるので、どちらかの print
が "1"
、もう一方が "2"
と出力するのが正しいはずですが、実際には両方が "1"
と出力したり、両方が "2"
と出力したりすることもありえます。実際にどのような動きをするのかは毎回異なってしまうので、起こしやすく解決しにくいのがデータ競合の厄介なところです。
// "Protect mutable state with Swift actors" 0:42 から改変
class Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
Task.detached {
print(counter.increment()) // データ競合が起きる
}
Task.detached {
print(counter.increment()) // データ競合が起きる
}
データ競合を回避するには、カウンターを並行処理で安全に使えるように("スレッドセーフ" に)する必要があります。たとえば以下のコードのように Counter
クラスを struct
による実装に置き換えて値型にし、各並行処理側でコピーすれば、状態を共有することがなくなるのでデータ競合は起きません。しかし当然、全ての並行処理にまたがってカウントを数えることができる、という機能は失われてしまうことになります。
// "Protect mutable state with Swift actors" 2:59 から改変
struct Counter {
var value = 0
mutating func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
Task.detached {
var counter = counter
print(counter.increment()) // 毎回 1 を出力する
}
Task.detached {
var counter = counter
print(counter.increment()) // 毎回 1 を出力する
}
Sendable
プロトコルは、こうした "並行処理で安全に扱える値" であることを明示するためのものです。Sendable
に準拠できる代表的な型は、 struct
や enum
などの値型です[14]。上記の例のように、値型はコピーして別の状態として扱うことができるため、それぞれの値が互いに影響せず、並行処理で安全に扱うことができます。また、 @Sendable
属性のついた関数やクロージャも Sendable
に準拠しているものとして扱うことができます。
一方で、 class
は限られた状況でのみ Sendable
プロトコルに準拠することができます。そのひとつは、全てのプロパティが不変(let
)である場合です。この場合は、どこかで書き込みが起こるというデータ競合の発生条件を満たさないため、並行処理で扱っても問題になることがありません。もうひとつは、既存のスレッドセーフな実装がそうであるように、クラス内部でロックなどの機構を用いて安全になるように実装されている場合です。この場合は @unchecked Sendable
という書き方で準拠させることで Sendable
であることを表明できますが、もちろんこの場合、安全に実装されていることの保証は実装者に任されることになります。
"Protect mutable state with Swift actors" 19:35
そして、コンパイラのサポートを受けながら効率的かつ安全に状態を共有するための新たに導入される型こそがアクターです。
アクター (Actor)
「アクター」("Actor") は、struct
や class
と並んで新たに Swift に導入される型の一種であり、その定義方法は既存の型と似ています。アクターは既存の型と同様に、プロパティやメソッド、イニシャライザなどを持つことが可能です。異なるのは、アクターはそのインスタンスが持つデータをプログラムの他の部分から "隔離する"("isolate" する)という点です。セッションでは、アクターが持つ他の性質も全てこの考え方を中心としている、と説明されています。
先ほどのカウンターを、アクターを用いて実装してみましょう。 actor
キーワードを用いている以外は、struct
や class
を用いて実装する場合と同様に定義できることがわかります。見た目は同じですが、アクターはひとつの状態が並行にアクセスされないことを保証してくれます。この場合、 increment
メソッドの実行が一度始まってから完了するまでは、このアクターについては他のどんな処理も行われないことになります。
// "Protect mutable state with Swift actors" 5:23
actor Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
では、このカウンターを使う側のコードはどうなるでしょうか。並行に行われる 2 つの increment
メソッドの呼び出しのうち、どちらが先に実行されるかはわかりませんが、同時に実行されるのは 1 つまでです。そのため、どちらが先だったとしても、後に実行されたほうは先に実行されたほうの処理が完了するまでは待機する必要があります。そのため、アクター外部からアクターを操作する際は必ず await
する必要があります。もしアクターが別の処理を実行中であれば、待機している側の CPU はその間別の処理を実行できることになります。increment
メソッド自体には async
キーワードが付いていないにも関わらず、アクター外部からの呼び出しには await
キーワードが必要になっている点に注意してください。
// "Protect mutable state with Swift actors" 7:22
let counter = Counter()
Task.detached {
print(await counter.increment())
}
Task.detached {
print(await counter.increment())
}
一方で、アクターのインスタンス内部で自身のメソッドを呼び出す場合は、既に同時に実行されていないことが保証されており、待機する必要性が生じないため await
キーワードを付ける必要がありません。セッションでは、引数に指定された数に到達するまで increment
メソッドを呼びだす resetSlowly
メソッドを用いて説明されています。
// "Protect mutable state with Swift actors" 7:51
extension Counter {
func resetSlowly(to newValue: Int) {
value = 0
for _ in 0..<newValue {
increment()
}
assert(value == newValue)
}
}
もちろん、この実装はあくまで例示のためのもので、 value
プロパティに直接 newValue
を代入することもできます(実際、 0
は直接代入できています)。ただし、状態を変更できるのは同じアクターの同じインスタンスからのみです。そのため、たとえば別のカウンターに値をコピーする場合は、やはり別のインスタンスに対するメソッド呼び出しに await
キーワードを付ける必要があります。そして、その場合は await
キーワードを含むメソッド自体にも async
キーワードを付ける必要が生まれます。
func copyValue(to other: Counter) async {
// await キーワードを付けることで値の読み出しは可能
print("Current value: \(await other.value)")
// 値の書き込みは不可能
// counter.value = value => error: Actor-isolated property 'value' can only be mutated on 'self'
for _ in 0..<value {
await other.increment()
}
}
このように、アクターの各インスタンスはそれぞれ "アクターごとの隔離されたコンテキスト" ("actor-isolated context") を形成し、このコンテキストの境界をまたがってメソッドを呼び出したり状態を参照したりする際は await
キーワードが必要になる(サスペンションポイントとなる)とイメージすることができます。また、アクターごとの隔離されたコンテキストの境界をまたがって状態を変更することはできません。ただし、状態が変更されないことが保証されている let
で定義された定数へのアクセスなど、条件を満たす一部の操作は await
キーワードを必要とせず、同期的に行うことができます。
アクターごとの隔離されたコンテキスト内外での状態やメソッドへのアクセス方法。同一のコンテキスト内では同期的に状態の読み書きやメソッドの呼び出しが行えるが、コンテキストの外側からは同期的なメソッドでも非同期的に呼び出さなければならない。また、コンテキストの外側からは状態の読み出しも非同期的に行う必要があり、状態の書き込みは同じ型であったとしても行えない。
アクターの再入可能性(reentrancy)
アクターを用いることで、データ競合の可能性を静的に発見し未然に防ぐことが可能になりますが、並行処理における全ての問題を解決できるわけではありません。たとえば、並行処理におけるまた別の代表的な問題である「競合状態」(race condition)は防ぐことができません。この問題は、アクターが「再入可能」(reentrant)であるという性質を持っているために防ぐことが難しい問題です。
await
キーワードが全く現れない同期的な処理では問題になりませんが、 await
キーワードが現れる場所、すなわちサスペンションポイントの前後では、アクターの状態が変わってしまっている可能性があります。セッションでは、以下のようなキャッシュ機能を持った画像ダウンローダーを想定して説明されています。最初にキャッシュの有無を確認して、キャッシュが無いときだけ画像のダウンロードを開始し、完了した後にキャッシュへ保存しています。
// "Protect mutable state with Swift actors" 9:02 からコメントのみ改変
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
return cached
}
let image = try await downloadImage(from: url)
// 潜在的なバグ: `cache` が変化している可能性がある
cache[url] = image
return image
}
}
このコードは一見すると問題なさそうですが、潜在的なバグを含んでいます。 try await downloadImage(from: url)
はサスペンションポイントとなっているため、その前後でアクターの状態が変化している可能性があります。すなわち、image(from:)
メソッドがこのアクターに対して同時に複数回呼び出されていた場合、 await
でサスペンドする前の時点ではキャッシュが存在しなかったとしても、ダウンロードが完了してアクターに処理の実行が戻ってきた時点では、既に他の処理によってキャッシュが設定されている可能性がある、ということです。その場合、URL から取得された画像の内容が変わっていた場合はキャッシュの内容も変更されることになり、たとえば UI に表示される画像が変わってしまう、などの問題につながることが考えられます。
セッション ではアニメーション付きで丁寧に解説されているため、こちらの文章による説明がわかりにくければ是非そちらも参照してください。セッションでは、この問題を回避する方法も紹介されています。そのうちのひとつは、サスペンションポイントの後、つまりデータの取得後にもキャッシュの有無を再度確認し、キャッシュが存在すれば上書きしないようにする方法です。もうひとつは、こちらはセッションでも詳細には説明されていませんが、 Task
型(構造化されていないタスク)を用いて既に実行中のリクエストがあれば再実行されないようにする方法です。セッションのコード一覧に、こちらの方法を用いたコード例が掲載されています。
ちなみに、アクターを反対の性質である再入不可能(non-reentrant)にすることで、この状態の一貫性についての問題を避けることができますが、並行処理におけるまた別の主要な問題である「デッドロック」のリスクを生むことになります。プロポーザルではこのトレードオフと最終的な決定についても 詳細に説明されています。
プロトコルへの準拠
Swift に新しい型としてアクターが導入される上で、どのように既存の実装と組み合わせて用いることができるのか、というのが気になる点だと思います。既存実装と組み合わせる手段のひとつとして、アクターを既存のプロトコルに準拠させる方法を確認してみましょう。
アクターも他の型と全く同じように、制約を満たせばプロトコルに準拠することができます。ただし、プロトコルが async
キーワードの付いていない関数の実装を求めている場合などは、注意すべき点があります。以下のコードは、 LibraryAccount
アクターを Equatable
プロトコルと Hashable
プロトコルに準拠させている例です。
// "Protect mutable state with Swift actors" 13:30, 14:15 から改変
actor LibraryAccount {
let idNumber: Int
var booksOnLoan: [Book] = []
}
extension LibraryAccount: Equatable {
static func ==(lhs: LibraryAccount, rhs: LibraryAccount) -> Bool {
lhs.idNumber == rhs.idNumber
}
}
extension LibraryAccount: Hashable {
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(idNumber)
}
}
まず、 Equatable
プロトコルに準拠するには他の型と同じく ==
という名前の static
メソッドを実装する必要があります。アクターに定義された static
メソッドは、これも他の型と同じく、特定のインスタンスと関係なく呼び出すことができます。そのため、 static
メソッドは必ず、アクターごとの隔離されたコンテキストの外側で実行されることになります。そのため、==
メソッドは同期的に呼び出すことができ、Equatable
プロトコルにも問題なく準拠できます。ただし、==
メソッドは同期的なメソッドであるため、変更されない定数である idNumber
プロパティのみ参照することができます。var
で定義された booksOnLoan
プロパティへのアクセスは非同期的に行う必要があるため、アクターの可変なプロパティを Equatable
の比較対象に含めることはできない、ということになります。
一方の Hashable
プロトコルに準拠するためには、 func hash(into:)
メソッドを実装する必要があります。しかし、普通にアクターのインスタンスメソッドとして hash
メソッドを実装してしまうと、そのメソッドはアクターごとの隔離されたコンテキストの中で実行される必要があるため、非同期的に呼び出す必要が生まれてしまいます。Hashable
プロトコルは hash
メソッドが同期的に呼び出せることを要求しているため、そのままでは準拠させることができません。そこで、 hash
メソッドに nonisolated
キーワードを付けて、このメソッドがアクターごとの隔離されたコンテキストの外側で実行されるように明示的に指定することができます。こちらも ==
メソッドと同様に、変更されうる booksOnLoan
プロパティなどを参照することはできません。
==
メソッドと hash
メソッドは同期的に呼び出せる必要があるため、アクターごとの隔離されたコンテキストの外側で実行する必要がある。変更されうる状態へのアクセスは非同期的に行う必要があるため、ここでは使えない。
MainActor
構造化されていないタスクの説明のコード例に、 @MainActor
という属性が出てきていました。この属性は、これが付けられた部分の処理が必ず MainActor
という特殊なアクターのコンテキストで実行されることを表しています。MainActor
は「グローバルアクター」("Global actor")というプログラムの様々な場所からアクセスできるアクターのひとつで、その名の通りメインスレッドで実行されることが保証されています。メインスレッドで実行したい処理を、全て単一のアクターに実装するのは現実的ではありません。そのため、コードの様々な場所で部分的に @MainActor
属性を付与し、 MainActor
で実行されるように指定することができるようになっています。
@MainActor
属性を付けることができる対象のうちのひとつは関数です。関数に @MainActor
属性を付けると、MainActor
の外からはその関数がアクターのインスタンスメソッドであるかのように非同期的に呼び出す必要が生まれますが、メインスレッドで実行されることが保証されます。iOS/macOS アプリ開発ではお馴染みの、メインキュー(DispatchQueue.main
)に対して処理を追加していたコードに対応するものになっています。
// "Protect mutable state with Swift actors" 24:19, 25:01 から改変
func checkedOut(_ booksOnLoan: [Book]) {
booksView.checkedOutBooks = booksOnLoan
}
// メインスレッドを指定して実行する必要がある
DispatchQueue.main.async {
checkedOut(booksOnLoan)
}
// ↓
@MainActor func checkedOut(_ booksOnLoan: [Book]) {
booksView.checkedOutBooks = booksOnLoan
}
// Swift によって処理がメインスレッドで実行されることが保証される
await checkedOut(booksOnLoan)
また、 @MainActor
属性はプロパティにも付与することができます。その場合も、プロパティの読み書きが必ずメインスレッドで行われるようになり、アクターに定義されたプロパティと同様に MainActor
外からは非同期的にアクセスする必要が生まれます。
@MainActor
属性を付与できるもうひとつの対象は、型自体です。構造化されていないタスクのコード例に表れたように、型定義に @MainActor
属性を付与することで、全てのメソッドやプロパティに @MainActor
属性が付いているのと同じことになります。逆に、その型に定義される一部のメソッドやプロパティのみ MainActor
外でアクセスされるように指定したい場合は、 nonisolated
属性を指定することができます。
@MainActor class MyViewController: UIViewController {
func onPress(...) { ... } // implicitly @MainActor
nonisolated func fetchLatestAndDisplay() async { ... }
}
なお、最新のドキュメントで UIViewController などの定義を見ると、既に @MainActor
属性が付けられていることがわかります。プロポーザル によると、親クラスにグローバルアクターを指定する属性が付いていたりグローバルアクターが指定されたメソッドをオーバーライドしたりする際は自動的にグローバルアクターも継承されるそうなので、新たに定義した UIViewController
などのサブクラスに @MainActor
属性を付与する必要はないと考えられます。
ちなみに、セッションでは言及がありませんでしたが、同プロポーザルによるとクロージャにもグローバルアクターを指定できる、という記述があります。そのため、既存の DispatchQueue.main.async
を用いていたコードは以下のようにも書き換えられます。
// https://github.com/apple/swift-evolution/blob/main/proposals/0316-global-actors.md#closures から改変
DispatchQueue.main.async {
// ...
}
// ↓
Task.detached { @MainActor in
// ...
}
アクターと構造化されていないタスク
構造化されていない並列性の説明の際、Task
型のイニシャライザで作られるタスクの性質のひとつに、 "アクターの隔離を引き継ぐ"("Inherit actor isolation")というものがありました。これは、Task.detached
関数で作られる分離されたタスクは持たない性質です。少し前に出てきた Counter
アクターの例に、構造化されていないタスクの使用を追加してみましょう。
actor Counter {
var value = 0
func increment() -> Int {
value = value + 1
Task {
// アクターのコンテキストが引き継がれているため、 await が不要
print("incremented: \(value)")
}
Task.detached {
// アクターのコンテキスト外で実行されるため、 self のキャプチャと await が必要
print("incremented: \(await self.value)")
}
return value
}
}
async
関数で作成した構造化されていないタスクは、あくまでも同じアクターごとの隔離されたコンテキスト内で実行されます。そのため、もとのアクターに対するメソッド呼び出しやプロパティアクセスは、同期的に行うことができます。もとのアクターが MainActor
であれば、 async
関数で作成したタスクもメインスレッドで実行されます。
一方で、分離されたタスクは異なるコンテキストで実行されるため、もとのアクターに対するメソッド呼び出しやプロパティアクセスは非同期的に行う必要があります。また、分離されたタスクに指定する処理でアクターを参照する場合は、クラスと同じようにアクターもキャプチャする必要があり、明示的に self
に対するプロパティアクセスとして記述する必要があります。ただし、分離されたタスクに渡すクロージャをもとのアクターが保持することはないため、ここでは [weak self]
と弱参照にせずとも循環参照が起こることはありません。
Swift Concurrency を試す
最後に、本稿で紹介しきれていないトピックを含めて、より詳細な情報を得るための関連記事やセッションをご紹介します。
関連 URL
WWDC
Meet Swift Concurrency ページ で、 Swift Concurrency 関連のセッションを一覧することができます。特に Swift concurrency: Behind the scenes セッションでは、タスクが実際にどのように実行されるのかであったり、Grand Central Dispatch における課題と Swift Concurrency による解決であったりと、本稿では触れられなかった多くの詳細が語られています。
Swift Evolution Proposals
Swift Evolution のプロポーザルは、Swift に加えられる変更の具体的な背景や内容だけでなく、他に考慮されて却下された代替案や今後の展望など、幅広い視点から Swift の進化を理解できる貴重な資料です。特に議論が続けられている Swift Concurrency 関連のプロポーザルでは "Revision history" としてこれまでの更新内容も追記されていることが多いです。ぜひ参照してみてください。
以下に、本項で参照したプロポーザルの一覧を掲載しておきます。
- Async/await
https://github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md - Async/Await: Sequences
https://github.com/apple/swift-evolution/blob/main/proposals/0298-asyncsequence.md - AsyncStream and AsyncThrowingStream
https://github.com/apple/swift-evolution/blob/main/proposals/0314-async-stream.md - Continuations for interfacing async tasks with synchronous code
https://github.com/apple/swift-evolution/blob/main/proposals/0300-continuation.md - async let bindings
https://github.com/apple/swift-evolution/blob/main/proposals/0317-async-let.md - Structured concurrency
https://github.com/apple/swift-evolution/blob/main/proposals/0304-structured-concurrency.md#spawning-taskgroup-child-tasks - Sendable and @Sendable closures
https://github.com/apple/swift-evolution/blob/main/proposals/0302-concurrent-value-and-concurrent-closures.md - Actors
https://github.com/apple/swift-evolution/blob/main/proposals/0306-actors.md - Global actors
https://github.com/apple/swift-evolution/blob/main/proposals/0316-global-actors.md
その他、本項で紹介できなかった Swift Concurrency 関連のプロポーザルもあります。
- Concurrency Interoperability with Objective-C
https://github.com/apple/swift-evolution/blob/main/proposals/0297-concurrency-objc.md- 既存の
completionHandler
などを持った Objective-C のメソッドは、コンパイラによって自動的に非同期なメソッドとして扱えるようになります。
- 既存の
- Effectful Read-only Properties
https://github.com/apple/swift-evolution/blob/main/proposals/0310-effectful-readonly-properties.md- Computed property や
subscript
にasync
やthrows
を指定することができるようになります。
- Computed property や
- Task Local Values
https://github.com/apple/swift-evolution/blob/main/proposals/0311-task-locals.md- スレッド局所記憶 に似た、タスク単位のデータを保持するための仕組みです。
Swift Forums
多くのプロポーザルが、まずは Swift Forums での議論から出発します。筆者自身は貢献できたことがありませんが、日々多くのコミュニティメンバーが侃々諤々の議論を続けています。アカウントを登録することで更新があるごとに通知を受けたり、定期的に更新内容のサマリーをメールで受け取ったりすることができます。
Swift Evolution について決定した内容だけを把握したい場合は、 Evolution カテゴリ内の Announcements カテゴリ をチェックするのがおすすめです。決定する前の議論まで追いかけたい場合は、 Proposal Reviews カテゴリや Pitches カテゴリも覗いてみると良いでしょう。
-
async/await のプロポーザルでは、クロージャを用いた非同期的な書き方によるこれらの問題を避けるためにスレッドをブロックするような処理でも同期的なインターフェースで実装されてしまいがちである、ということも挙げられています。 ↩︎
-
try
とawait
を併用するときはtry await
ですが、throws
とasync
はasync throws
になり、 try/throws と async/await の順番が逆になります。これは前後ではなく "内側が先" と考えるとわかりやすい、と 説明されています。 ↩︎ -
これらの単語は一般的な訳語を見つけられなかったものの、記事内の一貫性を優先してそのままカタカナにしたり直訳したりしています。より適切な用語があればご教示いただけると幸いです。 ↩︎ ↩︎ ↩︎
-
セッションでは触れられていなかったと思いますが
where
句にも対応しているため、for await quake in quakes where quake.magnitude > 3 { ... }
とも書けます。 ↩︎ -
これは現在 Pitch 段階の仕様によるものです。 ↩︎
-
逆に言えば、
AsyncSequence
はlazy
プロパティを持っていません。 ↩︎ -
詳しくないので下手なことは言えませんが、CPS 変換と呼ばれるものに対応するのではないかと思っています。 ↩︎
-
プロポーザルの "Alternatives considered" には、現時点で継続を表す型が
CheckedContinuation
と命名されていることに対して "一度しか再開されない、という性質を静的に強制できるようになればContinuation
という命名のほうが相応しいと言えるかもしれない" と言及されています。 ↩︎ -
async let
で定義されたdata
への参照とasync
な関数であるbyPreparingThumbnail
を同時に用いている式でawait
が一つしかないように、一つのawait
は複数のサスペンションポイントを含むことができます。これは複数のthrows
に対するtry
がまとめられる仕様と共通しています。 ↩︎ -
"子" のライフサイクルが必ず "親" よりも短くなるというのは現実と即していないため、親子("parent"/"child")の表現を用いるのは適切ではないのではないかという意見も上がっています。 ↩︎
-
現時点では
main.swift
は未対応 のようです。 ↩︎ -
実際
DispatchQoS
に対応する.userInteractive
/.userInitiated
/.utility
が用意されていますが、.userInitiated
と.utility
はそれぞれ.high
と.low
のエイリアスとして定義され、.userInteractive
は廃止されています。また、.default
の代わりにデフォルト値として.medium
が用意されています。 ↩︎ -
セッション当時からインターフェースが大きく変更されているため、上書きする形で編集しています。なお、この表を見る限りは、構造化されていないタスクと分離されたタスクは別物であるようにも見えますが、プロポーザルでは分離されたタスクも構造化されていないタスクのひとつであると 表現されています。 ↩︎
-
プロポーザルで 詳細に説明されている 通り、
struct
がコンパイラによって自動的にSendable
に準拠していると見なされるには、全てのプロパティがSendable
であるなどいくつかの条件があります。 ↩︎
Discussion
actor
でも以下のような銀行の振込の値参照のずれは防げないのでしょうか?実行してみたところ、実行のたびに結果が異なり、期待通りに出力されないことがありました。
興味深いご質問ありがとうございます。
まず、掲載いただいたコードでは3つの
Task
を作ってActorのプロパティにアクセスされていますが、これらは 「構造化されていないタスク」 であるため、実行の順番は担保されていませんし、これらのタスクが終了するまでプログラムが終了しない保証もない、というのが自分の理解です。単に「振込前の残高の表示、振込処理、振込後の残高の表示をそれぞれ順番に行いたい」という場合であれば、タスクは分けないほうがいいかもしれません。
ちなみに、プロポーザルではトップレベルのコード(main.swiftなど)にはasync呼び出しができると 記載されている ため、本来は以下のような記述ができるはずなのですが、
この仕様は現時点でのSwiftには未実装のようで、一旦は
_runAsyncMain
という関数を用いるとベタ書き(に近い形)でasyncな関数の呼び出しができるようです。手元での出力は以下のようになりました。これは期待通りの結果かと思います。
ただし、このコードも懸念がないわけではありません。単独で実行した場合は問題なさそうですが、並行で実行するとやはり実行するたびに結果が変わることになります。
状態の変更はアクターが保護しているため最終的な結果こそ安定していますが、途中で
300
という値が現れたり現れなかったりします。これはawait
した時点でそこが サスペンションポイント となっており、その前後で別の処理が行われて状態が変わっている可能性があるためです。(
accounts[0].balance が変わっている可能性が〜
などと書いていますが、もちろん他の状態も変わっている可能性があります)変更した後の状態を確実に取得できるのは、間に他の処理が挟まらない=サスペンションポイント(
await
)が現れない場合、すなわち各アクターごとの隔離されたコンテキスト内で実行される場合のみです。今回のコードではdeposit
メソッドやtransfer
メソッドの中になります。アクターにより状態の変更は保護されるようになりますが、並行に実行されうるコードを書く上では、サスペンションポイント前後でアクセスした状態が変わる可能性があるということに留意が必要そうです。
丁寧な回答ありがとうございます。
タスクの構造化を理解できていませんでした。
並行処理までは、actorでは担保されていないということですね。ありがとうございます。
_runAsyncMainについても毎回
Task {}
で書くの微妙だなぁと思っていたので、教えていただきありがとうございます。構造化されたタスクの場合、classでも値の保証はできている気がします。
以下のコードは常に同じ結果のように思えます。