realm-swift icon indicating copy to clipboard operation
realm-swift copied to clipboard

Opening Realm with async/await in actor causes Crash

Open alexeichhorn opened this issue 2 years ago • 11 comments

How frequently does the bug occur?

All the time

Description

When using the async variant for opening a Realm try await Realm() inside an actor or and actor marked function (any defined global actors), the app crashes because the just created realm is accessed from the wrong thread afterwards.

Let's look at this function:

func handle() async throws {
    
    let realm = try await Realm() // line 1
    
    print(realm.objects(Dog.self)) // line 2
    
}

The async opening function has the @MainActor-attribute so if you define this function inside a normal class following happens:

  1. We enter the function on an arbitrary queue
  2. Line 1 is executed: Realm() initializer is executed on the MainActor aka the main thread always. So the opened Realm is opened on the main thread
  3. We come back to our function on the main thread
  4. Line 2 is therefore executed on the main thread - the same for which the Realm is opened

If the same function is defined inside an actor:

  1. We enter the function on a certain queue (not main thread)
  2. Line 1 is executed: Realm() initializer is executed on the MainActor aka the main thread always. So the opened Realm is opened on the main thread
  3. We come back to our function, but switch back to the previous queue / our actor environment
  4. Line 2 is therefore not executed on the main thread - but the Realm was opened on main thread

Proposed Change

I don't know if you can fix this bug without changing the underlying requirements of how a Realm object in Swift works. It's definitely not a perfect fit for the new concurrency nature of Swift. Therefore I think a note warning about this issue in the documentation is the minimum. It might need more because predicting this crash requires a developer with good knowledge about the Realm threading model and the new Swift concurrency system. Sadly Xcode also produces an error if you use the non-async/await variant (try Realm()) inside an async context, which is most of the time what we want. This is because the initializer is named the same. This could also lead to some confusion or wrong adaption

Stacktrace & log output

2022-04-06 00:48:20.586253+0200 RealmAsyncOpenCrashDemo[14284:228953] *** Terminating app due to uncaught exception 'RLMException', reason: 'Realm accessed from incorrect thread.'
*** First throw call stack:
(
	0   CoreFoundation                      0x00007fff20406d44 __exceptionPreprocess + 242
	1   libobjc.A.dylib                     0x00007fff201a4a65 objc_exception_throw + 48
	2   RealmAsyncOpenCrashDemo             0x0000000104e9f003 -[RLMRealm verifyThread] + 131
	3   RealmAsyncOpenCrashDemo             0x0000000104d00efe _ZL18RLMVerifyRealmReadP8RLMRealm + 78
	4   RealmAsyncOpenCrashDemo             0x0000000104d02758 RLMGetObjects + 72
	5   RealmAsyncOpenCrashDemo             0x0000000104ff82dd $s10RealmSwift0A0V7objectsyAA7ResultsVyxGxmAA0A9FetchableRzlF + 125
	6   RealmAsyncOpenCrashDemo             0x0000000104c151fc $s23RealmAsyncOpenCrashDemo15CrashingHandlerC6handleyyYaKFTY2_ + 156
	7   RealmAsyncOpenCrashDemo             0x0000000104c13fc1 $s23RealmAsyncOpenCrashDemo11ContentViewV4bodyQrvg7SwiftUI05TupleG0VyAE6ButtonVyAE4TextVG_ALtGyXEfU_yycfU0_yyYaYbKcfU_TQ1_ + 1
	8   libswift_Concurrency.dylib          0x00007fff6fa509e1 _ZL22completeTaskAndReleasePN5swift12AsyncContextEPNS_10SwiftErrorE + 1
)
libc++abi: terminating with uncaught exception of type NSException

Can you reproduce the bug?

Yes, always

Reproduction Steps

Use this code:

class CrashfreeHandler {
    
    func handle() async throws {
        
        let realm = try await Realm()
        
        print(realm.objects(Dog.self))
        
    }
    
}


actor CrashingHandler {
    
    func handle() async throws {
        
        let realm = try await Realm()
        
        print(realm.objects(Dog.self))
        
    }
    
}

or just use the sample project with these code snippets on an iOS project: https://github.com/alexeichhorn/RealmAsyncOpenCrashDemo

Version

10.25.0

What SDK flavour are you using?

Local Database only

Are you using encryption?

No, not using encryption

Platform OS and version(s)

iOS 15.4 (Simulator and real device)

Build environment

Xcode version: 13.3 Dependency manager and version: SPM

alexeichhorn avatar Apr 05 '22 23:04 alexeichhorn

Hi @alexeichhorn Thank you for the info. We will look in to that

pavel-ship-it avatar Apr 06 '22 14:04 pavel-ship-it

We had the same issue on our project as well using Realm 10.26.0.

I ended up with a workaround by calling let realm = try Realm(queue: nil) which is the same as calling the try Realm() in a non-async version in an async function to make sure the returned realm instance is not on main thread.

yliu342 avatar May 24 '22 04:05 yliu342

I ended up mapping to lightweight structs when returning any data from a function using Realm. This can have a slight overhead, but ensures items are passed as a copy instead of references.

nagra avatar May 24 '22 06:05 nagra

Here's an example of an actor that that performs backgrounded "utility" tasks using anasync function. Also mixed in a GCD for example. Easy way to remember: you cannot use a previously instantiated realm instance anytime after an await is used in the same scope, which actually makes sense.

await Realm() seems nonsensical to me, basically asking to yield the current run loop, asking the system to coordinate concurrency for some work, i picture something like this let realm = Thread.random { returning(Realm()) }

actor BackgroundTasks {
    static let GCDWorker = DispatchQueue(label: "experiments", qos: .utility, autoreleaseFrequency: .workItem)
    
    func example() async {
        let olderThan = 2.months.ago!
        
        // await for filtered frozen objects for reading
        let filteredChallenges: Results<Challenge> = await withCheckedContinuation { continuation in
            // We are confined to one thread in this block, go to town
            let realm = try! Realm()
            
            let challenges = realm.objects(Challenge.self).where { $0.refreshed_at < olderThan }
            continuation.resume(returning: challenges.freeze())
        }
        
        log.info("We filtered \(filteredChallenges.count) challenges, with ids \(filteredChallenges.map {$0.id} )")
        
        // await for old challenges to be deleted, and return the count that were deleted
        let numDeleted: Int = await withCheckedThrowingContinuation { continuation in
            let realm = try! Realm()
            
            let challenges = realm.objects(Challenge.self).where { $0.refreshed_at < olderThan }
            realm.write {
                realm.delete(challenges)
            }
            continuation.resume(returning: challenges.count)
        }
        
        log.info("We just deleted \(numDeleted) challenges")
        
        // Returns immediately
        GCDWorker.async {
            let realm = try! Realm(configuration: .defaultConfiguration, queue: GCDWorker)
            let groups = realm.objects(Group.self).where { $0.refreshed_at < olderThan }
            try! realm.write {
                realm.delete(groups)
            }
            
            log.info("deleted \(groups.count) groups")
        }
        log.info("GCD is doing stuff, i cant tell you anything ")
    }
    
}

do {
    let actor = BackgroundTasks()
    Task.detached(priority: .background) {
        await actor.example()
    }
}

rromanchuk avatar Jun 06 '22 07:06 rromanchuk

Any news on this topic?

Currently I have the problem that I have app code which runs perfectly fine with Xcode 13.4.1 and Swift 5.6.1. But with Xcode 14 Beta 6 Swift 5.7 it is instantly crashing with Terminating app due to uncaught exception 'RLMException', reason: 'Realm accessed from incorrect thread.'

Drag0ndust avatar Aug 24 '22 08:08 Drag0ndust

Any news on this topic?

Currently I have the problem that I have app code which runs perfectly fine with Xcode 13.4.1 and Swift 5.6.1. But with Xcode 14 Beta 6 Swift 5.7 it is instantly crashing with Terminating app due to uncaught exception 'RLMException', reason: 'Realm accessed from incorrect thread.'

Same issue here. I'm forced to use 5.7 to avoid thread races which crash the app otherwise, but it keeps giving me the incorrect realm error immediately on 5.7. Please give us any update / workaround.

LilaQ avatar Aug 26 '22 17:08 LilaQ

Using Realm in a non-@MainActor async function is currently not supported. In Swift 5.6 it would often work by coincidence because execution after an await would continue on whatever thread the awaited thing ran on, so await Realm() in an async function would result in the code following that running on the main thread until your next call to an actor-isolated function. Swift 5.7 instead hops threads whenever changing actor isolation contexts, so an unisolated async function always run on a background thread instead.

If you have code which uses await Realm() and works in 5.6, marking the function as @MainActor will make it work with Swift 5.7 and function very similarly to what it happened to be doing in 5.6.

tgoyne avatar Aug 26 '22 17:08 tgoyne

@LilaQ @Drag0ndust Have you tried the workaround proposed by @yliu342: let realm = try Realm(queue: nil). It's more a compiler issue than one of Realm probably, because the compiler produces an error if there is an equivalent async variant. However, in this case the async variant should not be used except you are using a synced Realm.

alexeichhorn avatar Aug 26 '22 17:08 alexeichhorn

@tgoyne Thanks, that did the trick. Now I'm finally rid of the thread race crashes.

LilaQ avatar Aug 28 '22 22:08 LilaQ

...However, in this case the async variant should not be used except you are using a synced Realm.

The fix doesn't work for me. I use RealmSync and need to open the Container with Realm.asyncOpen(...) What would be the suggestion to do the asyncOpen of the remote realm?

Drag0ndust avatar Sep 07 '22 10:09 Drag0ndust

Any news on this topic?

Drag0ndust avatar Sep 18 '22 20:09 Drag0ndust

Any news on this topic?

Also looking for an assist on the issue.

Mainly, I think it would be helpful if the naming were updated to reflect that when we are using async / await patterns, invoking a function that says "asyncOpen" sort if feels like it should be usable with async / await for opening a realm.

I am also using a realm that is synced with the new MongoDB Realm Sync service.

DanBurkhardt avatar Sep 28 '22 03:09 DanBurkhardt

Any news???

Drag0ndust avatar Oct 23 '22 08:10 Drag0ndust

We will be working on this in the next quarter.

jsflax avatar Oct 23 '22 13:10 jsflax

So you will work on this issue earliest next year?

Drag0ndust avatar Nov 07 '22 09:11 Drag0ndust

gently asking for an update since last ETA has passed, thanks

aehlke avatar May 03 '23 00:05 aehlke

This is now included in release 10.38.3:

@MainActor function mainThreadFunction() async throws {
    // These are identical: the async init continues to produce a
    // MainActor-confined Realm if no actor is supplied
    let realm1 = try await Realm()
    let realm2 = try await Realm(MainActor.shared)
}

// A simple example of a custom global actor
@globalActor actor BackgroundActor: GlobalActor {
    static var shared = BackgroundActor()
}

@BackgroundActor backgroundThreadFunction() async throws {
    // Explicitly specifying the actor is required for everything but MainActor
    let realm = try await Realm(actor: BackgroundActor.shared)
    try await realm.write {
        _ = realm.create(MyObject.self)
    }
    // Thread-confined Realms would sometimes throw an exception here, as we
    // may end up on a different thread after an `await`
    print("\(realm.objects(MyObject.self).count)")
}

actor MyActor {
    // 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(MyObject.self).count
    }

    func create() async throws {
        try await realm.asyncWrite {
            realm.create(MyObject.self)
        }
    }
}

// This function isn't isolated to the actor, so each operation has to be async
func createObjects() async throws {
    let actor = try await MyActor()
    for _ in 0..<5 {
      await actor.create()
    }
    print("\(await actor.count)")
}

// In an isolated function, an actor-isolated Realm can be used synchronously
func createObjects(in actor: isolated MyActor) async throws {
    await actor.realm.write {
        actor.realm.create(MyObject.self)
    }
    print("\(actor.realm.objects(MyObject.self).count)")
}

jsflax avatar May 03 '23 00:05 jsflax