Swift 5.5から登場したActorについて

サンプルコード

https://github.com/SatoTakeshiX/first-step-swift-concurrency/tree/main/try-concurrency.playground
対応するサンプルコードにはページ名を記載します。

検証環境

  • Xcode 13.2.1
  • Swift 5.5

データ競合

マルチスレッドプログラミングにおいて、重要な問題はデータ競合(data race)をいかに防ぐかです。複数のスレッドから一つのデータにアクセスした場合、あるスレッドがデータを更新するとデータが不整合を起こしてしまう可能性があります。デバックが非常に難しくやっかいなバグをになることが多いです。

データ競合がどういうものかをコードで解説します。
例えばゲームの点数を管理するScoreという型をクラスで定義します。

// Page: 3-1-data-race
class Score {
    var logs: [Int] = []
    private(set) var highScore: Int = 0

    func update(with score: Int) {
        logs.append(score) 
        if score > highScore { // ①
            highScore = score // ②
        }
    }
}

updateメソッドでは渡されたスコアをlogsに追加し、最高得点よりも多かったらそのスコアでhighScoreプロパティを更新するというシンプルな処理になっています。

複数スレッドからupdateメソッドを実行して何が起こるかをみてみます。

// Page: 3-1-data-race
let score = Score()
DispatchQueue.global(qos: .default).async {
    score.update(with: 100) // ③
    print(score.highScore)  // ④
}
DispatchQueue.global(qos: .default).async {
    score.update(with: 110) // ⑤
    print(score.highScore) // ⑥
}

期待する出力は順不同で100, 110が出力されることです。
③と④はそれぞれ別スレッドで同じscoreインスタンスに対してメソッドを実行します。
データ競合がなければ、それぞれ渡した点数がhighScoreとして出力されるはずです。

ところが、Swift Playgroundで何回か実行すると、どちらも100になる場合や、110になる場合があります。

これはupdateメソッド中で、①scoreが最高点数かを判断する行から②highScoreを更新する行に処理が渡る間にデータの不整合が起こるからです。

例えば、以下の順番で処理が進むとどちらの出力も100になります。

  1. ③が①を通過
  2. ⑤が①と②を通過し、highScoreが110になる
  3. ③が②を通過し、highScoreが100になる
  4. ④が通過し、100を出力
  5. ⑥が通過し、100を出力

シリアルディスパッチキューでデータ競合を解決

従来ではデータ競合を防ぐ方法としてスレッドをロックしたり、DispatchQueueのシリアルキューを導入することでデータ競合を防いでいました。
ロックは一般的に扱いが難しいです。メインスレッドをロックすればUI操作が止まり、デットロックの危険があります。
ここではDispatchQueueのシリアルキューを使った解決方法の例をみてみましょう。

// Page: 3-2-serial-dispatch-queue
class Score {
    private let serialQueue = DispatchQueue(label: "serial-dispatch-queue")
    var logs: [Int] = []
    private(set) var highScore: Int = 0

    func update(with score: Int, completion : @escaping ((Int) -> ())) {
        serialQueue.async { [weak self] in
            guard let self = self else { return }
            self.logs.append(score)
            if score > self.highScore {
                self.highScore = score
            }
            completion(self.highScore)
        }
    }
}

シリアルキューはキューに入れられたタスクを順に実行し、一度に一つのタスクしか実行されません。
Scoreにシリアルキューをもたせ、updateメソッドの処理をキューに入れることで、プロパティ更新中に他の処理が実行されるのを防ぎます。
結果は非同期的に得られるのでcompletionハンドラーを引数に追加しています。

改良版Scoreを複数スレッドで実行してみます。

// Page: 3-2-serial-dispatch-queue
let score = Score()
DispatchQueue.global(qos: .default).async {
    score.update(with: 100) { highScore in
        print(highScore)
    }
}

DispatchQueue.global(qos: .default).async {
    score.update(with: 110) { highScore in
        print(score.highScore)
    }
}

結果は必ず100,110となります。データ競合がなくなりました。

Actorでデータ競合を守る

Swift 5.5からSwift Concurrencyが導入され、データ競合を守る新しい型が導入されました。それがactorです。
actorで作られたインスタンスは、同時に一つの処理のみがそのデータにアクセスできます。これにより、複数タスクがアクセスする場合でも安全にデータを扱えます。
これをActor隔離(Actor isolated)と呼びます。
さらに、データ競合が起きるようなコードがあればSwiftはコンパイル時にエラーを出し、プログラマーに修正を促します。

actorの一種として異なるインスタンスが同じスレッドで実行される、Global Actorもあります。具体例としてメインスレッドで実行されるMainActorがありますが、解説は別の機会にします。

Scoreactorを使って再実装します。

// Page: 3-3-actor
actor Score {
    var logs: [Int] = []
    private(set) var highScore: Int = 0

    func update(with score: Int) {
        logs.append(score)
        if score > highScore {
            highScore = score
        }
    }
}

3-1-data-raceで実装したScoreclassactorに変えただけです。
これだけで、データ競合を防ぐことができます。

さっそくScoreのインスタンスを同時にアクセスするコードを実行しましょう。
3-1-data-raceと違って複数同時アクセスにはTask.detachedを使います。
これはSwift Concurrencyで非同期処理を行うコンテキストを提供するものです。

let score = Score()
Task.detached {
    await score.update(with: 100)
    print(await score.highScore)
}
Task.detached {
    await score.update(with: 110)
    print(await score.highScore)
}

actorのメソッドやプロパティに外からアクセスするにはawaitが必要です。

結果は必ず100, 110になります。classactorに変えるだけでデータ競合がなくなりました。

Actorの文法

actorclassと同じく参照型のインスタンスを作ります。actorは外から共有して利用することが目的だからです。
さきほども言いましたが、同時に一つのタスクしかアクセスされない型を表します。

classstructenumと同じ機能を提供します。例えばプロパティやメソッドの定義、イニシャライザー、添字、そしてプロトコルの適合などです。
ただし、actorclassとは違って継承はできません。
次のようなコードを書いてもエラーになります。

// 3-4-actor-syntax
actor A {}
actor B: A {} // Actor types do not support inheritance

actorはインスタンスごとにActor隔離として他のプログラムから守られています。
actor外からアクセスする際はプロパティやメソッドの前にはawaitがつけます。
これはコンパイルに他のタスクがアクセスしている場合にプログラムを中断するし、そのタスクが終わるまで待つことを伝えるためです。

試しにactorのメソッドの前にawaitを削除するとコンパイルエラーになります。

// Page: 3-3-actor
Task.detached {
    score.update(with: 100) // Expression is 'async' but is not marked with 'await'

また、awaitをつけたからといってactorのデータを外から更新はできません。

// Page: 3-4-actor-syntax
actor A {
    var number: Int = 0
}
let a = A()
Task.detached {
    await a.number = 1 // Actor-isolated property 'number' can not be mutated from a Sendable closure
}

一方で、actor内でのプロパティ更新にはawaitはいりません。
Actorが他のコードから隔離されているので、Actor内では自由に自身のプロパティにアクセスしたり、更新しても問題ないからです。

// Page: 3-3-actor
actor Score {
    var logs: [Int] = []
    private(set) var highScore: Int = 0

    func update(with score: Int) {
        logs.append(score) // データを更新
        if score > highScore {
            highScore = score // データを更新
        }
    }
}

nonisolatedでactor隔離をやめる

actorはデフォルトで他のプログラムから隔離されており、外からのアクセスにはawaitが必要です。ですが、それでは都合が悪い場合が出てきます。
例えばHashableプロトコルの適合です。
Hashableプロトコルに適合にはhash(into:)メソッドを実装しなければいけません。
しかしactorのメソッドは外からawaitをつけて実行しなければいけないのでそのままではコンパイルエラーになります。

// Page: 3-4-actor-syntax
actor B: Hashable {
    static func == (lhs: B, rhs: B) -> Bool {
        lhs.id == rhs.id
    }
    func hash(into hasher: inout Hasher) { // Actor-isolated instance method 'hash(into:)' cannot be used to satisfy a protocol requirement
        hasher.combine(id)
    }

    let id: UUID = UUID()
    private(set) var number = 0
    func increace() {
        number += 1
    }
}

このような場合、hash(into:)メソッドの前にnonisolatedというキーワードをつけることで解決できます。

// Page: 3-4-actor-syntax
actor B: Hashable {
    ...
    nonisolated func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

nonisolatedは文字通りActor分離をやめることを表すキーワードです。これをつけることでactorの外ではawaitなしで実行できます。

// Page: 3-4-actor-syntax
let b = B()
let dic = [b: "xxx"]

nonisolatedはメソッドの他にプロパティにもつけることができます。
ただし、書き込み可能なデータやその操作に対してnonisolatedをつけることはできません。
もしつけるとコンパイルエラーになります。

actor B: Hashable {
    nonisolated func hash(into hasher: inout Hasher) {
        hasher.combine(number) // Actor-isolated property 'number' can not be referenced from a non-isolated context
    }
    private(set) var number = 0
}

上記の例ではnumberプロパティが書き込み可能なのにも関わらずnonisolated内でアクセスをしたためエラーになりました。書き込みできるデータはデータ競合が起きる可能性があるのでコンパイルが守っているのです。

nonisolatedでエラーになるかどうかはSendableプロトコルに適合しているかどうかによります。Sendableプロトコルについてはまた次回解説します。

awaitと状態変化

メソッド内でawaitを使う場合はawaitの前後でactorの状態が変化するので注意が必要です。

3-3-actorで実装したScoreを改良し、最高得点をサーバーに問い合わせるようにします。
次のようなコードを書きます。

// Page: 3-5-actor-await
actor Score {
    var localLogs: [Int] = []
    private(set) var highScore: Int = 0

    func update(with score: Int) async {
        localLogs.append(score) // ①
        highScore = await requestHighScore(with: score) // ②
    }
    func requestHighScore(with score: Int) async -> Int {
        try? await Task.sleep(nanoseconds: 2 * 1_000_000_000)  // 2秒待つ
        return score
    }
}

requestHighScoreメソッドはサーバーに点数を送るとサーバーが集計した自分の最高得点が得られると想定するメソッドですが、実際には2秒間処理を止め引数のscoreを返すだけのメソッドです。

2つのタスクを非同期で実行させます。

// Page: 3-5-actor-await
let score = Score()
Task.detached {
    await score.update(with: 100) // ③
    print(await score.localLogs) // ③'
    print(await score.highScore) // ③''
}

Task.detached {
    await score.update(with: 110) // ④
    print(await score.localLogs) // ④'
    print(await score.highScore) // ④''
}

結果はこうなります。

[100, 110]
100
[100, 110]
110

どちらもlocalLogsの出力は[100, 110]と同じ結果になりました。
処理の流れを見ていきましょう。

  1. ③が実行。③が①を通る
  2. ③が②を通る。await requestHighScoreで処理が中断される
  3. ④が実行。④が①を通る
  4. ④が②を通る。await requestHighScoreで処理が中断される
  5. ③のawait requestHighScoreが再開され、highScoreが更新される。
  6. ③'、③''が実行
  7. ④のawait requestHighScoreが再開され、highScoreが更新される。
  8. ④'、④''が実行

並行処理なので、1と4は順番入れ替わる可能性はあります。

それでは、続いて、localLogsの更新をawaitの後に実装すると出力結果はどうなるでしょうか?

func update(with score: Int) async {
    highScore = await requestHighScore(with: score) // ②
    localLogs.append(score) // ①
}

結果はこうなります。

[100]
100
[100, 110]
110

先ほどと異なり、③'と④'のlocalLogsにはそれぞれ別になっています。
どうして結果が異なったのでしょうか?

処理の流れはこうです。

  1. ③が実行。③が②を通る。await requestHighScoreで処理が中断される
  2. ④が実行。④が②を通る。await requestHighScoreで処理が中断される
  3. ③のawait requestHighScoreが再開され、highScoreが更新される
  4. ③が①を通る(localLogsをappendする)
  5. ③'、③''が実行
  6. ④のawait requestHighScoreが再開され、highScoreが更新される。
  7. ④が①を通る(localLogsをappendする)
  8. ④'、④''が実行

ただし並行処理なので1と2は実行順が入れ替わる可能性はあります。

awaitキーワードをつけると、プログラムが中断し他の処理が実行されることを意味します。
await前後でプロパティを更新すると、結果が異なる場合があるので注意が必要です。

処理をわかりやすくする方法の一つとして、プロパティの更新は一つのメソッドに分ける方法があります。

// Page: 3-5-actor-await
actor ScoreV2 {
    var localLogs: [Int] = []
    private(set) var highScore: Int = 0

    func update(with score: Int) async {
        let highScore = await requestHighScore(with: score)
        update(highScore: highScore, score: score)
    }

    // 同期的にプロパティを更新する
    private func update(highScore: Int, score: Int) {
        self.highScore = highScore
        localLogs.append(score)
    }
}

上記のScoreV2ではプロパティの更新はupdate(highScore: score:)メソッドにまとめています。actor内の同期的なメソッド、つまりasyncがつかないメソッドはActor隔離で守られているので、安全にデータを更新できます。

続いてawaitでプログラム状態が変わる例をもう一つみてみましょう。
URLから画像をリクエストするImageDownloaderを作ります。
ただし、同じURLで画像のリクエストを何度もしたくないのでキャッシュで保存します。

// Page: 3-5-actor-await
actor ImageDownloader {
    private var cached: [String: String] = [:]

    func image(from url: String) async -> String {
       // キャシュがあればそれを使う
        if cached.keys.contains(url) {
            return cached[url]!
        }
        // ダウンロード
        let image = await downloadImage(from: url) // ①
        // キャッシュに保存
        cached[url] = image // ②
        return cached[url]!
    }

    func downloadImage(from url: String) async -> String {
        try? await Task.sleep(nanoseconds: 2 * 1_000_000_000)  // 2秒待つ
        switch url {
            case "monster":
                // サーバー側でリソースが変わったことを表すため、ランダムで返す
                return Bool.random() ? "👾" : "🎃"
            default:
                return ""
        }
    }
}

downloadImageはサーバーに画像をリクエストを想定するメソッドです。サーバー側のリソースが変わることを表すため、ランダムで👾か🎃の絵文字を返しています。
一見問題なさそうですが、場合によっては不具合の可能性を秘めるコードです。

ではこれを実行します。

// Page: 3-5-actor-await
let imageDownloader = ImageDownloader()
Task.detached {
    let image = await imageDownloader.image(from: "monster") // ③
    print(image) // ③'
}
Task.detached {
    let image = await imageDownloader.image(from: "monster") // ④
    print(image) // ④'
}

すると時々👾と🎃がどちらも出力される場合がありました。

👾
🎃

これはバグの可能性があります。キャッシュはクリアするまで同じURLなら同じ画像(ここでは絵文字)が出力されてほしいですが、異なる結果になりました。

処理の流れを見てみましょう。

  1. ③が実行され、①を通り、await downloadImage(from: url)で処理が中断される
  2. ④が実行され、①を通り、await downloadImage(from: url)で処理が中断される
  3. ③の①が再開され、👾または🎃がimageに代入される
  4. ③の②が通り、キャッシュが更新される
  5. ④の①が再開され、👾または🎃がimageに代入される
  6. ④の②が通り、キャッシュが更新される

データ競合という意味では不具合はないですが、キャッシュという意味では潜在的な不具合を秘めています。

コードを改良しましょう。awaitの後でキャッシュを調べ同じURLがあれば更新しないようにします。

// Page: 3-5-actor-await
func image(from url: String) async -> String {
    if cached.keys.contains(url) {
        return cached[url]!
    }
    let image = await downloadImage(from: url)
    if !cached.keys.contains(url) { // 追加
        cached[url] = image
    }
    return cached[url]!
}

今度は、👾のみの場合、または🎃の場合で出力されるようになります。

このように、awaitの前後ではプログラムの状態が変わります。actorを使えばデータ競合は起きなくなりますが、await前後でプロパティを更新する場合は注意が必要です。
今回提示した解決策は次の2つです。

  • 更新メソッドを切り分ける
  • awaitの後でデータを処理する

awaitの後はプログラムが中断することを踏まえ、actorの状態を更新しましょう。

まとめ

Actorという新しい型について解説しました。
データ競合という並行プログラミングにおいて厄介な問題をスマートに解決する新しい型です。

Actor隔離のおかげで複数タスクから同時にデータを読み込んでもデータを守れるコードを簡単に実装できます。
ただし、Actor内のメソッドでawaitを呼び出すときは注意が必要です。
プログラムの中断と再開でデータの状態が変わるので、それに対応するコードを書かないと思わぬ不具合に繋がります。

今回の記事ではではSendableとMainActorの解説はしませんでした。
SendableはActorの外に渡せるデータかどうかをコンパイラが判断するもので、MainActorはメインスレッドで実行される特別なactorです。

次の機会にこの2つをそれぞれ解説しますのでお楽しみに。

参考文献

宣伝

BOOTHより、同人版「Swift Concurrency入門」発売中です。
Swift Concurrencyを網羅的に学べ、さらに既存アプリへの適応方法も解説しています。
日本語で体系的に学べる解説本は他になかなかありません。
1章、2章が立ち読みできるおためし版もありますので、ぜひチェックしてください!





https://personal-factory.booth.pm/items/3888109