Search code examples
iosswiftmultithreadingasynchronouscore-data

Correctly accessing Core Data from another thread using the new Asynchronous Task in Swift


To test my Core Data implementation I have enabled the launch argument com.apple.CoreData.ConcurrencyDebug 1. I am getting breakpoints triggered whenever I access a managed object from within a Task using Swifts new async APIs.

I use a single context (viewContext) for fetching and ephemeral background contexts to perform write operations. See some snippets of the important parts at the bottom.

My app functions perfectly and I get no breakpoints triggered except for the scenarios where I access a Core Data managed object from within a Task.

See an example here

func performReloadForecasts() {
    Task {
        await reloadForecasts()
    }
}

The method reloadForecasts is too long to post here but it references the users favourite location (Hence the locations) and uses it to fetch the forecast from an API for that location. Referencing the users locations in views or view models functions fine.

But from what I can tell, by using a Task to perform an asynchronous operation, I am performing the task in a whole different thread that is chosen (from a pool?) at run time.

Usually the thread that the Task is run in is com.apple.root.user-initiated-qos.cooperative (serial) which makes sense I suppose.

How can I refactor or change either my asynchronous functions (such as reloadForecast) or my core data stack (detailed below) to perform operations in a manor that abides by the concurrency rules for Core Data?

Can I force a Task to run on a particular thread? Since my main context is the viewContext it would have to be the main thread, which sort of defeats the purpose of the async Task.

Can I refactor my core data stack to create some sort of thread safe reference to my managed objects? I have seen suggestions to pass object ID's in and refetch the object inside the target thread but surely there is a more elegant way to abide by the concurrency rules.

Core Data Implementation

Context Setup

container = NSPersistentCloudKitContainer(name: "Model")

context = container.viewContext
context.automaticallyMergesChangesFromParent = true
context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy

Fetching

@Published private var locations: [LocationModel] = []
private func reloadData() {
    context.perform { [context] in
        do {
            self.locations = try context.fetch(LocationModel.fetchRequest())
        } catch {
            Logger.error("Failed to reload persistence layer.")
            Logger.error(error.localizedDescription)
        }
    }
}

Performing Write Operations

func perform(_ block: @escaping (NSManagedObjectContext) -> Void) {
    do {
        let context = container.newBackgroundContext()

        try context.performAndWait {
            block(context)
            try context.save()
        }
    } catch {
        Logger.error(error.localizedDescription)
    }
}

Solution

  • I fixed some of these issues by adding the @MainActor flag to the Task

        Task { @MainActor in
            await reloadForecasts()
        }
    
    

    However I was still getting breakpoints for certain issues, especially higher order functions like maps or sorts. I ended up adding the @MainActor wrapper to all my view models.

    This fixed all of the weird crashes regarding the higher order functions accessing the core data objects, but I faced new problems. My second core data context used for saving objects was now the cause of concurrency breakpoints being triggered.

    This made more sense and was much more debug-able. I had strong references to objects fetched in the main context, used to construct another object in the background context.

    Model A <---> Model B

    I had Model A that had relationship to another Model B. To setup the relationship to Model B, I used a reference to a Model B object that had been fetched on the main context. But I was creating the Model A object in the background thread.

    To solve this I used the suggested methods of refetching the required objects by ObjectID in the correct context. (Using a bunch of nice helper methods to make things easier)

    Here's a forum post asking a related question about ensuring asynchronous tasks are run on the main thread

    https://forums.swift.org/t/best-way-to-run-an-anonymous-function-on-the-main-actor/50083

    My understanding of the new swift concurrency models is that when you await on an async function, the task is run on another thread chosen from a pool and when the task is complete, execution returns to the point (and thread) you used await.

    In this case I have forced the Task to start on the main thread (By using @MainActor), execute its task on a thread from the available pool, and return back to the main thread once its completed.

    The swift concurrency explains some of this in detail: https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html