defaultIsolation MainActor and Core Data background tasks

With Xcode 26 defaultIsolation is set to MainActor. I want to do some Core Data stuff in a background thread like this.

Because everything is a MainActor now, even the generated class by the model Item is now a MainActor, which gives me this warning.

Assuming this is the correct way to handle background tasks with this new defaultIsolation:
What, if anything, can I do about that? Should Core Data adopt nonisolated for its auto generated NSManagedObject classes?

nonisolated
struct BackgroundDataHandler {
    @concurrent
    func saveItem() async throws {
        let context = await PersistenceController.shared.container.newBackgroundContext()
                
        try await context.perform {
            let newGame = Item(context: context)
            
            newGame.timestamp = Date.now // Main actor-isolated property 'timestamp' can not be mutated from a nonisolated context; this is an error in the Swift 6 language mode
        
            try context.save()
        }
    }
}

This is up to CoreData developers I think. We cannot decide what they should do.

BTW I think that a timestamp should not be Main-actor isolated, there is no reason for it to do so.

And why is @concurrent attribute not explained in Documentation ? Does it add something to async ?

The @concurrent attribute allows functions on the MainActor to always run in the background in response to the defaultIsolation now being the MainActor for Xcode 26 projects.

As I said, this is the reason that all variables on a NSManagedObject class are now Main Actor-isolated by default. If there's no way around fixing the warning given, which I have no idea about right now, then Apple has to update Core Data, which they have not done yet as of iOS 26 beta 1.

1 Like

Thanks for the video link. I will watch it.

I believe that the Core Data team just don't like the Swift Сoncurrency innovations :)

1 Like

I was under impression that a background execution has nothing to do with main thread. To be honest, what I was expecting from async/await from the beginning, was a syntax sugar over GCD.

From the video: "Interleaving improves system performance" - are we in 1984 inventing Classic MacOS?

That's surely the case looking at the last few years. The annoying part is that this would make Core Data unusable for background tasks, which is kind of bad.

Very bad.

I never liked Core Data anyway. It's just a strange wrapper over SQLite. I think you can find/write better/simpler solutions.

With all due respect to Core Data team of course...

It's just common knowledge to have data organized in tables, and fetch data using SQL queries. I mean, it would be trivial to write your own nonisolated func executeSQL(statement: String) using SQLite API under the hood. Most likely you can find something ready-made like that on GitHub.

Aha. Restoring status quo after introducing SE-0420. Cool.

It’s already nonisolated, the errors you see is for instances inside your class that is isolated to the main actor. CoreData itself, unfortunately, lacks some of the important annotations for new concurrency and given the amount of effort put into SwiftData (which I don’t quite like in comparison to CoreData), not sure if it will be updated properly.

The most convenient way to work with CoreData in the new concurrency system is via actors with custom executors, where custom executor is defined based on NSManagedObjectContext.

For main thread contexts you can either try to define another custom actor or use nonisolated(unsafe) to avoid unnecessary await, since CoreData itself guarantees main thread here.

I think your understanding of SE-0420 is incorrect. It hasn’t changed behaviour introduced by SE-0338.

However, SE-0461 which did introduce @concurrent attribute, does change semantics of nonisolated functions to sticky with calling actor isolation (and relative attribute to opt-out) in order to simplify work with non-Sendable types. And does so behind feature flag.

2 Likes

For main context stuff, this should be enough now that the struct is on the Main Actor, right?

struct MainActorDataHandler {
    func saveItem() async throws {
        let context = PersistenceController.shared.container.viewContext
        
        let newGame = Item(context: context)
        newGame.timestamp = .now
        
        try context.save()
    }
}

Yes, just keep in mind that same rules as previously apply to CoreData context — if it is not on main queue (actor), you need wrap that in perform block. From that perspective, wrapping everything into actors is beneficial — much harder to make a mistake.

So, I've now tried multiple things in relation to actors and also this actor with a custom executor (https://fatbobman.com/en/posts/core-data-reform-achieving-elegant-concurrency-operations-like-swiftdata/). Still, using defaultIsolation MainActor, the warning stays. I think there's a bit more going on here.

When I disable code gen for the managed object class, create it myself and add nonisolated to it, the warning goes away, which makes me think NSManagedObject has no such annotation and now uses MainActor when defaultIsolation it set as such.

I've been running into the same while writing a Core Data module for my Swift Concurrency Course. The only way out at this point is to create managed objects manually and add the nonisolated keyword in front of the class, e.g.:

nonisolated class Article: NSManagedObject { ... }

The most convenient way to work with CoreData in the new concurrency system is via actors with custom executors, where custom executor is defined based on NSManagedObjectContext.

I considered this as well. I even went ahead, made it all work, but concluded that it's a bit overkil. In my opinion, using the async perform method on NSManagedObjectContext is more than enough. In fact, a custom actor executor could make things more complicated—nothing is stopping you from using that actor, but calling objects with another managed object context.

Therefore, keeping the perform method close to the NSManagedObject context used seems to be the safest solution.

Yet, you can decide to centralize access to the background context by setting up something like:

nonisolated struct CoreDataStore {
    
    static let shared = CoreDataStore()
    
    let persistentContainer: NSPersistentContainer
    private var viewContext: NSManagedObjectContext { persistentContainer.viewContext }
    
    private init() {
        persistentContainer = NSPersistentContainer(name: "CoreDataConcurrency")
        persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
        
        Task { [persistentContainer] in
            do {
                try await persistentContainer.loadPersistentStores()
            } catch {
                print("Failed to load persistent store: \(error)")
            }
        }
    }
    
    @MainActor
    func perform(_ block: (NSManagedObjectContext) throws -> Void) rethrows {
        try block(viewContext)
    }
    
    @concurrent func deleteAllAndSave<T: NSManagedObject>(using fetchRequest: NSFetchRequest<T>) async throws {
        let backgroundContext = persistentContainer.newBackgroundContext()
        try await backgroundContext.perform {
            let objects = try backgroundContext.fetch(fetchRequest)
            for object in objects {
                backgroundContext.delete(object)
            }
            try backgroundContext.save()
        }
    }
}

This way, you enforce access to the viewContext on the @MainActor and you define long running tasks as @concurrent to run in the background with their own task background context.

I'm still exploring this matter and I'm about to migrate a large Core Data project again, so I'm open to any other insights and suggestions based on my input!

I must admit that I still don't understand how to use Swift concurrency in regard to long running tasks synchronous functions. There is a contract of always going forward because the concurrency system is cooperative. At the same time any long running synchronous function blocks progress and violates the contract, no?

I recall that someone at this forum advised me not to call long running synchronous functions from an async context, and use Dispatch instead, but this may be a false memory.

That's not the contract. The contract is that you cannot block cooperative threads to wait synchronously for future asynchronous work.

Thank you for the explanation, but I still don't understand. In my understanding calling a synchronous function does cause a synchronous wait.

Yes, but not for future asynchronous work. Whether occupying a thread for a while is a desirable result is situational. It may or may not be what you wanted, but it's not inherently incorrect. It will always return the thread to the pool once the work is done. Waiting synchronously for future asynchronous work may never recover (i.e. may deadlock), so is incorrect.

1 Like
func longRunningWork() {
    // long running synchronous code
}

async func weUseConcurrency() {
    longRunningWork()
}

Is this example correct or not? If not, how to make it correct?
(it is pseudo-code, I misplaced the async keyword for readability)