Open7

Swift Concurrency について学ぶ

treastrain / Tanaka Ryogatreastrain / Tanaka Ryoga

Swiftにおける非同期および並行プログラミングのパワーをご覧ください。async/awaitは、
完了ハンドラなどを使用する非同期関数を、より読みやすく、より正確に変換します。複数のタスクを同時に実行するための構造化された様々な種類の並行処理を紹介します。Swiftのactorを使用することで、データ競合のないコードを維持します。

Swift Concurrencyの紹介 - 見つける - Apple Developer

treastrain / Tanaka Ryogatreastrain / Tanaka Ryoga

Swift の async/await について(Meet async/await in Swift) - WWDC21 - Videos - Apple Developer

1回観たことあるけどもう一度。

async/await のまとめ

  • async キーワードによって関数はサスペンドできるようになる
  • await キーワードは非同期関数が実行をサスペンドする場所を示す
  • サスペンド中に他の作業ができる
  • 待機していた async な関数が完了すると、 await で止まっていたところ以降の実行が再開される
enum FetchError: Error {
    case requestFailed
    case badImage
}

func fetchImageThumbnail(from urlString: String) async throws -> NSImage { // キーワードは `async throws` の順番
    let url = URL(string: urlString)!
    let (data, response) = try await URLSession.shared.data(from: url) // キーワードは `try await` の順番
    guard (response as? HTTPURLResponse)?.statusCode == 200 else {
        throw FetchError.requestFailed
    }
    let maybeImage = NSImage(data: data)
    guard let thumbnail = await maybeImage?.thumbnail else {
        throw FetchError.badImage
    }
    return thumbnail
}

extension NSImage {
    var thumbnail: NSImage? {
        get async { // read-only プロパティでのみ async が利用可能
            let size = CGSize(width: 40, height: 40)
            return await self.byPrepairngThumbnail(ofSize: size)
        }
    }
}

// エラーになる: 'async' call in a function that does not support concurrency
let image = try? await fetchImageThumbnail(from: "/url/to/image")
// Task を使う
Task {
    let image = try? await fetchImageThumbnail(from: "/url/to/image")
}

async/await を既存プロジェクトに取り入れる

  • XCTest は async な関数のテストにも対応した
  • 例えば UIKit など Apple 提供のフレームワークで、Objective-C による completion があるような関数は、Swift で async にブリッジされるようになっている
  • コールバックによる関数を async な関数にブリッジするようなコードを自分で書く場合、例えば withCheckedThrowingContinuation(function:_:) を使う
    • "必ず正確に1度だけ" resume するようにする
    • resume せずに CheckedContinuation を破棄するのは許されない
  • Delegate パターンなど、イベント駆動なものを async な関数にする場合、 CheckedContinuation をクラス内で保持しておいて、イベントで resume する
    • resume した後は CheckedContinuation を破棄するのを忘れずに

気づき

  • 「get」が先頭に入ったままだと、呼び出しの結果が直接返ってこないように聞こえるから省略が推奨される

    First, the async alternative’s name is used which drops the leading “get.” We recommend that async functions omit leading words like “get” that communicate when the results of a call are not directly returned.

treastrain / Tanaka Ryogatreastrain / Tanaka Ryoga

Swift における構造化並行処理(Explore structured concurrency in Swift) - WWDC21 - Videos - Apple Developer

字幕では Structured Concurrency を「構造化同時並行処理」や「構造化された並行性」って言っている。セッションの邦題では「構造化並行処理」って言っている。

これまで色んなところに処理がジャンプしているように見えていたけど、これを構造化プログラミングにすると、プログラム全体を上から下に読める。でも今日の非同期が多いコードを上から下に読めるようにするには…?

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    for id in ids {
        let request = thumbnailURLRequest(for: id)
        let (data, response) = try await URLSession.shared.data(for: request)
        try validateResponse(response)
        guard let image = await UIImage(data: data)?.byPreparingThumbnail(ofSize: thumbSize) else {
            throw ThumbnailFailedError()
        }
        thumbnails[id] = image
    }
    return thumbnails
}

例えば asyncasync throws でこれが非同期な関数であることを示し、非同期な関数を使うときは awaittry await キーワードを入れることで、ネストが不要になる。
これまでのクロージャを使っていたときは、 completion の呼び出し忘れがあってもビルド時に気づくことができなかったが、この Swift Concurrency では returnthrow のし忘れがコンパイラにかける段階で気づくことができる。

Async-let

let hoge = await 非同期な関数1()
let fuga = await 非同期な関数2()
let hoga = (hoge, fuga)

async let hoge = 非同期な関数1()
async let fuga = 非同期な関数2()
let hoga = (await hoge, await fuga)

上半分は

  • 非同期な関数1() を実行
  • 非同期な関数1() の実行が終わって hoge に代入される
  • 非同期な関数2() を実行
  • 非同期な関数2() の実行が終わって fuga に代入される
  • hoga(hoge, fuga) になる

下半分で出てくる async let を使うと

  • 非同期な関数1() は子タスクで実行
  • 非同期な関数2() は子タスクで実行
  • hogahogefuga と書く前に await と書くことで2つの関数の実行が待たれる

タスクのキャンセル

try Task.checkCancellation()
let flag = Task.isCancelled

同じ層に位置している子タスクのどれかが異常終了したとしたら?
キャンセルのフラグが立てられ、そのさらに続く子タスクがある場合はそちらもキャンセルのフラグが立てられる。
タスクが勝手に動作を止めることは無いので、処理時間が長い API を実装する側は特に、このキャンセルフラグを自分で見てあげるような実装にする必要がある。
async なメソッドとして Task.checkCancellation()、Bool 型がよければ Task.isCancelled でキャンセルのフラグを確認できる。

グループタスク(Group tasks)

TaskGroup は動的な数の同時実行性を提供する。

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    await withThrowingTaskGroup(of: Void.self) { group in
        for id in ids {
            group.addTask {
                // ❌ Mutation of captured var 'thumbnails' in concurrently-executing code
                thumbnails[id] = try await fetchOneThumbnail(withID: id)
            }
        }
    }
    return thumbnails
}

group.addTask によって、子タスクを追加する。でも、このままだと thumbnails[id] のところでデータ競合が起こる可能性がある。コンパイラはこれを静的にエラーとして教えてくれる。

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
        for id in ids {
            group.addTask {
                return (id, try await fetchOneThumbnail(withID: id))
            }
        }
        for try await (id, thumbnail) in group {
            thumbnails[id] = thumbnail
        }
    }
    return thumbnails
}

先程のプログラムをこのように書き換えてみる。withThrowingTaskGroup(of:returning:body:)childTaskResultTypeVoid.self から (String, UIImage).self にすることで、groupThrowingTaskGroup<(String, UIImage), Error> であると伝える。
for await のループで、group のタスクの完了順に thumbnails[id]thumbnail を入れていく。

構造化されていないタスク(Unstructured tasks)

class MyDelegate: NSObject, UICollectionViewDelegate {
    func collectionView(_ view: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
        let ids = getThumbnailIDs(for: item)
        Task {
            let thumbnails = await fetchThumbnails(for: ids)
            display(thumbnails, in: cell)
        }
    }
}

自分でタスクを作りたいときは Task.init(priority:operation:) のクロージャを使う。ただし、これのキャンセルなどは自分で手動で管理する必要がある。

@MainActor
class MyDelegate: NSObject, UICollectionViewDelegate {
    var thumbnailTasks: [IndexPath: Task<Void, Never>] = [:]
    
    func collectionView(_ view: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
        let ids = getThumbnailIDs(for: item)
        thumbnailTasks[item] = Task {
            let thumbnails = await fetchThumbnails(for: ids)
            display(thumbnails, in: cell)
            thumbnailTasks[item] = nil
        }
    }
    
    func collectionView(_ view: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt item: IndexPath) {
        thumbnailTasks[item]?.cancel()
    }
}

例えば、collectionView(_:willDisplay:forItemAt:) で非同期で fetchThumbnails(for:) を実行してサムネイルを取得しようとする。そのときに作った TaskthumbnailTasks に一時的に入れておく。fetchThumbnails(for:) が終了したら、thumbnailTasks[item] = nil する。
もし fetchThumbnails(for:) の実行が終わる前に collectionView(_:didEndDisplaying:forItemAt:) が呼ばれて、もうその Task が必要なくなったとき、自分で thumbnailTasks[item]?.cancel() を呼び出してそれをキャンセルする。

分離されたタスク(Detached tasks)

先程の例では、collectionView(_:didEndDisplaying:forItemAt:) が呼ばれたらもうそのサムネイルは必要がない、という処理だったが、これを collectionView(_:willDisplay:forItemAt:)fetchThumbnails(for:) したときに、その結果をローカルにキャッシュしておくと便利そう… という例が考えられる。そんなときは分離されたタスクを使う。

@MainActor
class MyDelegate: NSObject, UICollectionViewDelegate {
    var thumbnailTasks: [IndexPath: Task<Void, Never>] = [:]
    
    func collectionView(_ view: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
        let ids = getThumbnailIDs(for: item)
        thumbnailTasks[item] = Task {
            let thumbnails = await fetchThumbnails(for: ids)
            Task.detached(priority: .background) {
                writeToLocalCache(thumbnails)
            }
            display(thumbnails, in: cell)
            thumbnailTasks[item] = nil
        }
    }
}

Task.detached(priority:operation:)prioritybackground などメインではない場所に指定し、キャッシュを行なっている。
では、もしサムネイルのキャッシュだけではなく、それのログを記録したかったり、そのほかバックグラウンドで処理をしたいときはどうすればよいか。

@MainActor
class MyDelegate: NSObject, UICollectionViewDelegate {
    var thumbnailTasks: [IndexPath: Task<Void, Never>] = [:]
    
    func collectionView(_ view: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
        let ids = [""] // getThumbnailIDs(for: item)
        thumbnailTasks[item] = Task {
            let thumbnails = await fetchThumbnails(for: ids)
            Task.detached(priority: .background) {
                await withTaskGroup(of: Void.self) { g in
                    g.addTask { writeToLocalCache(thumbnails) }
                    g.addTask { log(thumbnails) }
                    g.addTask { /* ... */ }
                }
            }
            thumbnailTasks[item] = nil
        }
    }
}

例えばタスクグループを設定して、それぞれでバックグラウンドのジョブを生成する。こうすることで、後になってこのタスクをキャンセルしたくなったときは、このタスクグループの最上位をキャンセルすると、その下にある全ての子タスクがキャンセルされる。

まとめ

書き方 書ける場所 ライフサイクル キャンセル 起点からの継承
async-let タスク async let x async な関数 文の中 自動で行われる 優先度、タスクローカルの値
グループタスク TaskGroup.addTask withTaskGroup タスクグループの中 自動で行われる 優先度、タスクローカルの値
構造化されていないタスク Task どこからでも スコープされない Task 経由で自分で行なう 優先度、タスクローカルの値、actor
分離されたタスク Task.detached どこからでも スコープされない Task 経由で自分で行なう なし