🧵

Swift Concurrency まとめ(正式版対応済)

2021/06/20に公開
4

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

Swift Concurrency にまつわる様々なプロポーザルの関係性を表した図。 Swift Forums より
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)を生成します。最後に、その画像をサムネイル表示に適した新たな画像に変換する、といった処理です。

処理の流れ。StringからURLRequestを作る。URLRequestからDataを取得する。DataからUIImageに変換する。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 を用いて書き直したことで、コードの行数もネストの深さも減って読みやすさが向上しただけでなく、エラーハンドリングに trythrow を用いることが可能になったり[2]guard 節に returnthrow があることが保証されたりと、同期的なコードと同じように Swift の言語機能を活用できていることがわかると思います。

サスペンションポイント (Suspension point)

では、この処理はスレッドをブロックすることはないのでしょうか。今までの Swift のコードでは、基本的に上から下に順番に、同じスレッドで実行され、実行中に処理が中断されたりスレッドが変わったりすることはありませんでした。しかし、async/await を用いたコードでは、 await キーワードのある部分(await 式)が 「サスペンションポイント」(Suspension point)[3] となり、その処理の実行が一時的にサスペンド(保留)される可能性があることを表すようになります。サスペンドされている間、そのスレッドは他の処理の実行に回ることができるため、ブロックされているわけではありません。

サスペンションポイントで一時停止した処理は、またサスペンションポイントから再開します。このとき、サスペンションポイントの前の処理を実行していたスレッドとは別のスレッドで再開することも有り得ます。先ほどのコードには 2 つの await キーワードがあり、3 つのブロックに分かれると考えることができます。それぞれのブロックは、全て異なるスレッドで実行されることもある、ということです。逆に言えば async/await を利用したコードにおいて、スレッドが切り替わるか切り替わらないかというのは(後述するメインスレッドのように特殊な例を除けば)あまり気にする必要はありません。

コードをSuspension pointごとのブロックに分けた図
"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 プロトコルが持つ mapfilter などの多くのオペレーターが、AsyncSequence にも用意されています。

AsyncSequenceがSequenceと同じようなオペレータをサポートすることを紹介するスライド
"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 のメソッドを例に説明されています。

withCheckedThrowingContinuation を用いた処理の流れを説明した図
"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]、ランタイムで違反があればログを出力したりトラップしたりしてくれるようになっています。なお、非常にシビアなパフォーマンスが求められる場面などでどうしてもランタイムでのチェックを省略したい場合のために、 withUnsafeContinuationwithUnsafeThrowingContinuation というインターフェースも用意されています。

構造化された並行性(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 で定義した変数に実際の値が代入されないまま、プレースホルダーが設定されただけの状態になります。以下の図における緑の部分が子タスクで実行される部分にあたり、白い線で表されている元のタスクはその後の処理へと進んでいくことになります。

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 型のイニシャライザに処理を指定することで、構造化されていないタスクを作成することができます。以下のコードでは、 UICollectionViewDelegatewillDisplay が発火したタイミングで、セルが表示された際に構造化されていないタスクにサムネイル取得と設定の処理を任せ、そのタスクを 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 に準拠できる代表的な型は、 structenum などの値型です[14]。上記の例のように、値型はコピーして別の状態として扱うことができるため、それぞれの値が互いに影響せず、並行処理で安全に扱うことができます。また、 @Sendable 属性のついた関数やクロージャも Sendable に準拠しているものとして扱うことができます。

一方で、 class は限られた状況でのみ Sendable プロトコルに準拠することができます。そのひとつは、全てのプロパティが不変(let)である場合です。この場合は、どこかで書き込みが起こるというデータ競合の発生条件を満たさないため、並行処理で扱っても問題になることがありません。もうひとつは、既存のスレッドセーフな実装がそうであるように、クラス内部でロックなどの機構を用いて安全になるように実装されている場合です。この場合は @unchecked Sendable という書き方で準拠させることで Sendable であることを表明できますが、もちろんこの場合、安全に実装されていることの保証は実装者に任されることになります。

Sendable プロトコルに準拠できる型を列挙したスライド
"Protect mutable state with Swift actors" 19:35

そして、コンパイラのサポートを受けながら効率的かつ安全に状態を共有するための新たに導入される型こそがアクターです。

アクター (Actor)

「アクター」("Actor") は、structclass と並んで新たに Swift に導入される型の一種であり、その定義方法は既存の型と似ています。アクターは既存の型と同様に、プロパティやメソッド、イニシャライザなどを持つことが可能です。異なるのは、アクターはそのインスタンスが持つデータをプログラムの他の部分から "隔離する"("isolate" する)という点です。セッションでは、アクターが持つ他の性質も全てこの考え方を中心としている、と説明されています。

先ほどのカウンターを、アクターを用いて実装してみましょう。 actor キーワードを用いている以外は、structclass を用いて実装する場合と同様に定義できることがわかります。見た目は同じですが、アクターはひとつの状態が並行にアクセスされないことを保証してくれます。この場合、 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メソッドがアクターごとの隔離されたコンテキストの外側で実行されることを表した図
== メソッドと 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" としてこれまでの更新内容も追記されていることが多いです。ぜひ参照してみてください。

以下に、本項で参照したプロポーザルの一覧を掲載しておきます。

その他、本項で紹介できなかった Swift Concurrency 関連のプロポーザルもあります。

Swift Forums

多くのプロポーザルが、まずは Swift Forums での議論から出発します。筆者自身は貢献できたことがありませんが、日々多くのコミュニティメンバーが侃々諤々の議論を続けています。アカウントを登録することで更新があるごとに通知を受けたり、定期的に更新内容のサマリーをメールで受け取ったりすることができます。

Swift Evolution について決定した内容だけを把握したい場合は、 Evolution カテゴリ内の Announcements カテゴリ をチェックするのがおすすめです。決定する前の議論まで追いかけたい場合は、 Proposal Reviews カテゴリや Pitches カテゴリも覗いてみると良いでしょう。

脚注
  1. async/await のプロポーザルでは、クロージャを用いた非同期的な書き方によるこれらの問題を避けるためにスレッドをブロックするような処理でも同期的なインターフェースで実装されてしまいがちである、ということも挙げられています↩︎

  2. tryawait を併用するときは try await ですが、throwsasyncasync throws になり、 try/throws と async/await の順番が逆になります。これは前後ではなく "内側が先" と考えるとわかりやすい、と 説明されています↩︎

  3. これらの単語は一般的な訳語を見つけられなかったものの、記事内の一貫性を優先してそのままカタカナにしたり直訳したりしています。より適切な用語があればご教示いただけると幸いです。 ↩︎ ↩︎ ↩︎

  4. セッションでは触れられていなかったと思いますが where 句にも対応しているため、 for await quake in quakes where quake.magnitude > 3 { ... } とも書けます。 ↩︎

  5. これは現在 Pitch 段階の仕様によるものです。 ↩︎

  6. 逆に言えば、 AsyncSequencelazy プロパティを持っていません。 ↩︎

  7. 詳しくないので下手なことは言えませんが、CPS 変換と呼ばれるものに対応するのではないかと思っています。 ↩︎

  8. プロポーザルの "Alternatives considered" には、現時点で継続を表す型が CheckedContinuation と命名されていることに対して "一度しか再開されない、という性質を静的に強制できるようになれば Continuation という命名のほうが相応しいと言えるかもしれない" と言及されています。 ↩︎

  9. async let で定義された data への参照と async な関数である byPreparingThumbnail を同時に用いている式で await が一つしかないように、一つの await は複数のサスペンションポイントを含むことができます。これは複数の throws に対する try がまとめられる仕様と共通しています。 ↩︎

  10. "子" のライフサイクルが必ず "親" よりも短くなるというのは現実と即していないため、親子("parent"/"child")の表現を用いるのは適切ではないのではないかという意見も上がっています↩︎

  11. 現時点では main.swift は未対応 のようです。 ↩︎

  12. 実際 DispatchQoS に対応する .userInteractive/.userInitiated/.utility が用意されていますが、.userInitiated.utility はそれぞれ .high.low のエイリアスとして定義され、.userInteractive は廃止されています。また、 .default の代わりにデフォルト値として .medium が用意されています。 ↩︎

  13. セッション当時からインターフェースが大きく変更されているため、上書きする形で編集しています。なお、この表を見る限りは、構造化されていないタスクと分離されたタスクは別物であるようにも見えますが、プロポーザルでは分離されたタスクも構造化されていないタスクのひとつであると 表現されています↩︎

  14. プロポーザルで 詳細に説明されている 通り、 struct がコンパイラによって自動的に Sendable に準拠していると見なされるには、全てのプロパティが Sendable であるなどいくつかの条件があります。 ↩︎

Discussion

zundazunda

actorでも以下のような銀行の振込の値参照のずれは防げないのでしょうか?

実行してみたところ、実行のたびに結果が異なり、期待通りに出力されないことがありました。

// 実行結果(期待外れ)
100.0
200.0
100.0
200.0
Transferring 100.0 from 1 to 2
Akio YasuiAkio Yasui

興味深いご質問ありがとうございます。

まず、掲載いただいたコードでは3つの Task を作ってActorのプロパティにアクセスされていますが、これらは 「構造化されていないタスク」 であるため、実行の順番は担保されていませんし、これらのタスクが終了するまでプログラムが終了しない保証もない、というのが自分の理解です。

単に「振込前の残高の表示、振込処理、振込後の残高の表示をそれぞれ順番に行いたい」という場合であれば、タスクは分けないほうがいいかもしれません。

ちなみに、プロポーザルではトップレベルのコード(main.swiftなど)にはasync呼び出しができると 記載されている ため、本来は以下のような記述ができるはずなのですが、

actor BankAccount {
    ...
}
let accounts: [BankAccount] = ...

print(await accounts[0].balance)
print(await accounts[1].balance)

await accounts[0].transfer(amount: 100, to: accounts[1])

print(await accounts[0].balance)
print(await accounts[1].balance)

この仕様は現時点でのSwiftには未実装のようで、一旦は _runAsyncMain という関数を用いるとベタ書き(に近い形)でasyncな関数の呼び出しができるようです。

actor BankAccount {
    ...
}
let accounts: [BankAccount] = ...

_runAsyncMain {
    print(await accounts[0].balance)
    print(await accounts[1].balance)

    await accounts[0].transfer(amount: 100, to: accounts[1])

    print(await accounts[0].balance)
    print(await accounts[1].balance)
}

手元での出力は以下のようになりました。これは期待通りの結果かと思います。

100.0
200.0
Transferring 100.0 from 1 to 2
0.0
300.0

ただし、このコードも懸念がないわけではありません。単独で実行した場合は問題なさそうですが、並行で実行するとやはり実行するたびに結果が変わることになります。

_runAsyncMain {
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            print("a 0:", await accounts[0].balance)
            print("a 1:", await accounts[1].balance)
            await accounts[0].transfer(amount: 100, to: accounts[1])
            print("a 0:", await accounts[0].balance)
            print("a 1:", await accounts[1].balance)
        }
        group.addTask {
            print("b 0:", await accounts[0].balance)
            print("b 1:", await accounts[1].balance)
            await accounts[1].transfer(amount: 100, to: accounts[0])
            print("b 0:", await accounts[0].balance)
            print("b 1:", await accounts[1].balance)
        }
    }
    print("last 0:", await accounts[0].balance)
    print("last 1:", await accounts[1].balance)
}

状態の変更はアクターが保護しているため最終的な結果こそ安定していますが、途中で 300 という値が現れたり現れなかったりします。これは await した時点でそこが サスペンションポイント となっており、その前後で別の処理が行われて状態が変わっている可能性があるためです。

print("a 0:", await accounts[0].balance)
// printした時点から accounts[0].balance が変わっている可能性がある
print("a 1:", await accounts[1].balance)
// printした時点から accounts[1].balance が変わっている可能性がある
await accounts[0].transfer(amount: 100, to: accounts[1])
// trasfer 後にさらに残高が変わっている可能性がある
print("a 0:", await accounts[0].balance)
// printした時点から accounts[0].balance が変わっている可能性がある
print("a 1:", await accounts[1].balance)
// printした時点から accounts[1].balance が変わっている可能性がある

accounts[0].balance が変わっている可能性が〜 などと書いていますが、もちろん他の状態も変わっている可能性があります)

変更した後の状態を確実に取得できるのは、間に他の処理が挟まらない=サスペンションポイント(await)が現れない場合、すなわち各アクターごとの隔離されたコンテキスト内で実行される場合のみです。今回のコードでは deposit メソッドや transfer メソッドの中になります。

アクターにより状態の変更は保護されるようになりますが、並行に実行されうるコードを書く上では、サスペンションポイント前後でアクセスした状態が変わる可能性があるということに留意が必要そうです。

zundazunda

丁寧な回答ありがとうございます。

タスクの構造化を理解できていませんでした。

並行処理までは、actorでは担保されていないということですね。ありがとうございます。

_runAsyncMainについても毎回Task {}で書くの微妙だなぁと思っていたので、教えていただきありがとうございます。

zundazunda

構造化されたタスクの場合、classでも値の保証はできている気がします。
以下のコードは常に同じ結果のように思えます。

class BankAccount {
  ...
}

let accounts: [BankAccount] = [
  .init(accountNumber: 1, initialDeposit: 100),
  .init(accountNumber: 2, initialDeposit: 200),
]

_runAsyncMain  {
  print(accounts[0].balance)
  print(accounts[1].balance)
  await accounts[0].transfer(amount: 100, to: accounts[1])
  print(accounts[0].balance)
  print(accounts[1].balance)
}