🧵

Realm SwiftをSwift ConcurrencyとActorを使って安全に使いたい

2024/02/29に公開

こんにちは!アルダグラムでエンジニアをしている長尾です

久々のtry! Swift Tokyoが2024/03/22から開催されますが、そういえば初期のtry! SwiftはRealmが後援していたような、、と思い出しました。
Realmは現在MongoDBに吸収されていて、時代の流れを感じます。。
(※そして名前もAtlas Device SDKとかに変わるらしい。。)

とはいえ2024年現在でもRealm Swiftは利用が可能であり、弊社のiOSアプリでも使用しています。

また、Swiftは、そろそろ6.0がリリースされる足音が聞こえてきており、Strict Concurrency Checkingに対応しておく必要があります。

iOSアプリにおいては、コードの書き方次第でデータ競合が発生してしまうため、データ競合が発生しないように実装するのは一定難しかったと私は考えています。
この状況が、Strict Concurrency Checkingに適応することで、コンパイラによってデータ競合を引き起こすようなコードに対してチェックが入るようになります。

Strict Concurrency Checkingに適応するのは大変ですが、データ競合による再現しづらく、対応が難しい、不幸な不具合が減少することが期待され、またSwift 6においては、Strict Concurrency Checkingに適応することが必須であることもあり、将来的にStrict Concurrency Checkingに対応することは必須です。

本記事においては、Realm Swiftをswift concurrencyとActorを使ってStrict Concurrency Checkingに対応しつつ、安全に使うために留意すべきことについてまとめています。

Realmのスレッドに関する3つのルール

Realmは、スレッドごとにデータを保持しています。
そのため、Realmインスタンスや、Object、Resultsなどのマネージドオブジェクトのインスタンス自体はスレッドセーフではなく、スレッドを跨いで共有はできません。インスタンスが生成されたスレッドでのみ利用が可能です。

そのため、安定的にRealmを利用するためには、以下の三つのルールを守る必要があります

  1. 読み出しは自由にやれ
  2. UIスレッドでの同期書き込みは避けて、バックグラウンドスレッドで行え
  3. liveオブジェクト(マネージドオブジェクト)を他のスレッドに渡すな

1.は、読み出しの際に、ボトルネックにならないように、各々のスレッドから、読み出せばいいよ、ということなので逆に気にしなければ、大抵のケースでは満たせるのではないかと思います。

問題は、2.と3.です。

2.への対応として、Realmにおいては、writeAsyncというバックグラウンドスレッドで非同期にデータを加工することのできるメソッドが定義されています。

が、やはりswift concurrencyに慣れてしまった軟弱者は、completionブロックがある形式のメソッドは、あまり使いたくありません。。(withCheckedContinuationを使えばいいやん、という話もありますが、ちょっと美しくない。。)

そして対応が最も厄介と思われる3.については、いくつかの対応方法が公式に示されています

が、基本どれも一歩間違えば不具合確定のものばかりです。

1)それぞれのスレッドで、同一の検索条件を用いて、RealmからObjectをそれぞれのスレッドで読み出す
 →queryを間違えると、そもそも意図した動作にならないし、そもそも面倒

2)データを更新したことをトリガーにnotificationを使う
 →noticationをobserveする形式は、ちょっとオーバーなケースが多いのでは。。

3)他のスレッドでrealmインスタンスを更新した際、当該スレッドにおいて、refreshを行う
 →いや絶対書き忘れるパターンですやん、、フラグですやん、、ってやつになりそう

4)読み取り専用のデータであれば、freezeオブジェクトにする
 →freezeしたObjectを解凍するthawが、Actorをサポートしてないっぽかったり、Realmファイルサイズにインパクトが大きいというのもありネガティブ。。

5)読み取り専用で利用する場合、オブジェクトをコピーする
 → 読み取り専用。。

6)threadSafe Referenceを使う
 → スレッドを超えていることを意識して、ThreadSafe Referenceにして、、って時点で、難しい(ARCでオブジェクトが解放されてしまうことも考えると、安全に使えるケースは、限られそう)

と、色々な手はありますが、どれもネガティブな面があり、苦労が絶えません。。

Realmのスレッドに関するルールを守りつつ、楽に扱う方法はないものか、、と公式ドキュメントを見ていたら、Actorのワードが!!

Actorを使おう!

Realmもversion 10.39.0以降から、actorに対応しました
ということで、Actorを使って、Realmのスレッドが絡む厄介さをなんとかしていきましょう!

ざっくり言うと、

  • Realm自体にactorを渡して関連付ける
  • Realmのインスタンスを保持したカスタムのActorを定義し、Actorを介してRealmにアクセスする

の2パターンの対応方法があるので、ご紹介します。

RealmとActor-isolatedを使う

Actorを指定するだけであれば、とても簡単です。
Realmクラスのイニシャライザの引数に、actorがあるので、そこにActorインスタンスを指定してあげればOKです。

@MainActor
func mainThreadFunction() async throws {
    // These are identical: the async init produces a
    // MainActor-isolated Realm if no actor is supplied
    let realm1 = try await Realm()
    let realm2 = try await Realm(actor: MainActor.shared)

    try await useTheRealm(realm: realm1)
}

Actorインスタンスは、もちろんGlobalActorでも可です。

// A simple example of a custom global actor
@globalActor actor BackgroundActor: GlobalActor {
    static var shared = BackgroundActor()
}
@BackgroundActor
func backgroundThreadFunction() async throws {
    // Explicitly specifying the actor is required for anything that is not MainActor
    let realm = try await Realm(actor: BackgroundActor.shared)
    try await realm.asyncWrite {
        _ = realm.create(Todo.self, value: [
            "name": "Pledge fealty and service to Gondor",
            "owner": "Pippin",
            "status": "In Progress"
        ])
    }
    // Thread-confined Realms would sometimes throw an exception here, as we
    // may end up on a different thread after an `await`
    let todoCount = realm.objects(Todo.self).count
    print("The number of Realm objects is: \\(todoCount)")
}
@MainActor
func mainThreadFunction() async throws {
    try await backgroundThreadFunction()
}

独自のActorクラスを定義し、Realmを組み込む

あるいは、独自のActorクラスを定義し、Realmを組み込む方法もあります
ここでは、asyncWriteを使って書き込みをします。

actor RealmActor {
    // An implicitly-unwrapped optional is used here to let us pass `self` to
    // `Realm(actor:)` within `init`
    var realm: Realm!
    init() async throws {
        realm = try await Realm(actor: self)
    }
    var count: Int {
        realm.objects(Todo.self).count
    }

    func createTodo(name: String, owner: String, status: String) async throws {
        try await realm.asyncWrite {
            realm.create(Todo.self, value: [
                "_id": ObjectId.generate(),
                "name": name,
                "owner": owner,
                "status": status
            ])
        }
    }

    func getTodoOwner(forTodoNamed name: String) -> String {
        let todo = realm.objects(Todo.self).where {
            $0.name == name
        }.first!
        return todo.owner
    }

    struct TodoStruct {
        var id: ObjectId
        var name, owner, status: String
    }

    func getTodoAsStruct(forTodoNamed name: String) -> TodoStruct {
        let todo = realm.objects(Todo.self).where {
            $0.name == name
        }.first!
        return TodoStruct(id: todo._id, name: todo.name, owner: todo.owner, status: todo.status)
    }

    func updateTodo(_id: ObjectId, name: String, owner: String, status: String) async throws {
        try await realm.asyncWrite {
            realm.create(Todo.self, value: [
                "_id": _id,
                "name": name,
                "owner": owner,
                "status": status
            ], update: .modified)
        }
    }

    func deleteTodo(id: ObjectId) async throws {
        try await realm.asyncWrite {
            let todoToDelete = realm.object(ofType: Todo.self, forPrimaryKey: id)
            realm.delete(todoToDelete!)
        }
    }

    func close() {
        realm = nil
    }
}

asyncWriteを使えば、呼び出し元のスレッドをロックすることなく書き込みができます。

asyncWriteを使う方法は非同期に書き込む方法でしたが、同期的に書き込む方法もあります。
Actorに定義したメソッド内では、Actorの特性により、排他的に処理が実行されるため、同期的に書き込みを行うメソッドwriteを使って書き込みを行ったとしても、問題ありません。

func createObject(in actor: isolated RealmActor) async throws {
    // Because this function is isolated to this actor, you can use
    // realm synchronously in this context without async/await keywords
    try actor.realm.write {
        actor.realm.create(Todo.self, value: [
            "name": "Keep it secret",
            "owner": "Frodo",
            "status": "In Progress"
        ])
    }
    let taskCount = actor.count
    print("The actor currently has \\(taskCount) tasks")
}
let actor = try await RealmActor()
try await createObject(in: actor)

それでもActor Boundaryを超えるのは大変。。

Actorを使うことで、ルール2「UIスレッドでの同期書き込みは避けて、バックグラウンドスレッドで行え」の書き込みについては、ある程度目処がつきます。

そうなのです。

Actorを使っても、ルール3「liveオブジェクト(マネージドオブジェクト)を他のスレッドに渡すな」に関わる、スレッド間で安全にデータを受け渡す、Actor Boundaryを超えるのは大変なのです。。
(Actor採用が銀の弾丸にはなりませんでした。。。)

公式ドキュメントによると、Actor Boundaryを超える方法は以下のようなものがあると紹介されていました。

ThreadSafeReferenceを使って超える

超えた先のActorで、resolveして、取り出す

// We can pass a thread-safe reference to an object to update it on a different actor.
let todo = todoCollection.where {
    $0.name == "Arrive safely in Bree"
}.first!
let threadSafeReferenceToTodo = ThreadSafeReference(to: todo)
try await backgroundActor.deleteTodo(tsrToTodo: threadSafeReferenceToTodo)

actor BackgroundActor {
    public func deleteTodo(tsrToTodo tsr: ThreadSafeReference<Todo>) throws {
        let realm = try! Realm()
        try realm.write {
            // Resolve the thread safe reference on the Actor where you want to use it.
            // Then, do something with the object.
            let todoOnActor = realm.resolve(tsr)
            realm.delete(todoOnActor!)
        }
    }
}

プリミティブな値のみでActor Boundaryを超え、超えた先で、Objectを引き出す

プリミティブな型は、Actor Boundaryを超えられるので、プロパティの一部のみで境界を超え、超えた先で、Objectを引き出す

@MainActor
func mainThreadFunction() async throws {
    // Create an object in an actor-isolated realm.
    // Pass primitive data to the actor instead of
    // creating the object here and passing the object.
    let actor = try await RealmActor()
    try await actor.createTodo(name: "Prepare fireworks for birthday party", owner: "Gandalf", status: "In Progress")

    // Later, get information off the actor-confined realm
    let todoOwner = await actor.getTodoOwner(forTodoNamed: "Prepare fireworks for birthday party")
}

プライマリーキーのみで、Actor Boundaryを超え、超えた先で、Objectを引き出す

↑の渡すものがprimaryKeyのバージョン

// Execute code on a specific actor - in this case, the @MainActor
@MainActor
func mainThreadFunction() async throws {
    // Create an object off the main actor
    func createObject(in actor: isolated BackgroundActor) async throws -> ObjectId {
        let realm = try await Realm(actor: actor)
        let newTodo = try await realm.asyncWrite {
            return realm.create(Todo.self, value: [
                "name": "Pledge fealty and service to Gondor",
                "owner": "Pippin",
                "status": "In Progress"
            ])
        }

        // Share the todo's primary key so we can easily query for it on another actor
        return newTodo._id
    }
    // Initialize an actor where you want to perform background work
    let actor = BackgroundActor()
    let newTodoId = try await createObject(in: actor)
    let realm = try await Realm()
    let todoOnMainActor = realm.object(ofType: Todo.self, forPrimaryKey: newTodoId)
}

Sendableに対応した型に情報を載せ替えてActor Boundaryを超える

Sendableに適応できるstructのようなものに、同じプロパティを格納できるようにした上で、載せ替えて、Sendableなオブジェクトで超える

struct TodoStruct {
    var id: ObjectId
    var name, owner, status: String
}
func getTodoAsStruct(forTodoNamed name: String) -> TodoStruct {
    let todo = realm.objects(Todo.self).where {
        $0.name == name
    }.first!
    return TodoStruct(id: todo._id, name: todo.name, owner: todo.owner, status: todo.status)
}

@MainActor
func mainThreadFunction() async throws {
    // Create an object in an actor-isolated realm.
    let actor = try await RealmActor()
    try await actor.createTodo(name: "Leave the ring on the mantle", owner: "Bilbo", status: "In Progress")

    // Get information as a struct or other Sendable type.
    let todoAsStruct = await actor.getTodoAsStruct(forTodoNamed: "Leave the ring on the mantle")
}

まとめ

Realm SwiftをSwift ConcurrencyとActorを使って安全に使うために留意すべきことを、公式ドキュメントを情報源にまとめてみました。
文章にまとめると理解が進むのでおすすめです。

まだまだデータ競合を起こさないようにするのは難しいですが、データ競合を起こさないようにするポイントは
・一つのRealmインスタンスに対して、同時に書き込み処理が発生しないようにする
・スレッドを超えてデータを受け渡す際や、Actor Boundaryを超えるには、Sendableなオブジェクトを使ってインスタンスではなく、情報の受け渡しを意識すれば良い
と言う二点に集約できるかなと思います。

上記内容を留意できれば、スレッドを跨いでも、Realmを安全に利用できると思います。

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion