While you can still use closure-based perform methods throughout your code, you might be interested in centralizing all Core Data-related logic into a single place. In this lesson, we’ll be creating a custom actor executor for Core Data, which results in fewer perform { ... } closures in your projects.
Defining the custom serial executor
As we’ve learned in the module on Actors, we need to start by defining a custom serial executor. In this case, we’re creating one with a given managed object context:
import CoreData
final class NSModelObjectContextExecutor: @unchecked Sendable, SerialExecutor {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext) {
self.context = context
}
func enqueue(_ job: consuming ExecutorJob) {
let unownedJob = UnownedJob(job)
let unownedExecutor = asUnownedSerialExecutor()
/// Execute the enqueued job on the configured managed object context.
context.perform {
unownedJob.runSynchronously(on: unownedExecutor)
}
}
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
Note the usage of @unchecked Sendable, which is required because we’re storing the NSManagedObjectContext, which is not sendable.
Using the custom executor in an actor
We can start using the custom executor inside an actor. In this example, we’re defining a CoreDataStore actor:
actor CoreDataStore {
static let shared = CoreDataStore()
let persistentContainer: NSPersistentContainer
nonisolated let modelExecutor: NSModelObjectContextExecutor
nonisolated var unownedExecutor: UnownedSerialExecutor {
modelExecutor.asUnownedSerialExecutor()
}
private var context: NSManagedObjectContext { modelExecutor.context }
private init() {
persistentContainer = NSPersistentContainer(name: "CoreDataConcurrency")
persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
modelExecutor = NSModelObjectContextExecutor(context: persistentContainer.newBackgroundContext())
Task {
do {
try await persistentContainer.loadPersistentStores()
} catch {
print("Failed to load persistent store: \(error)")
}
}
}
}
Note that we’re also loading the persistent store asynchronously using the following NSPersistentContainer extension:
extension NSPersistentContainer {
func loadPersistentStores() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
self.loadPersistentStores { storeDescription, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}
}
}
The custom executor makes sure that any CoreDataStore isolated method performs work on the viewContext. This allows us to write methods like:
actor CoreDataStore {
/// ...
func deleteAllAndSave<T: NSManagedObject>(using fetchRequest: NSFetchRequest<T>) throws {
let objects = try context.fetch(fetchRequest)
for object in objects {
context.delete(object)
}
try context.save()
}
}
However, you might immediately think: why is this so much more useful? Can’t I just write this method as:
struct CoreDataStoreStructure {
var context: NSManagedObjectContext {
CoreDataStore.shared.persistentContainer.viewContext
}
func deleteAllAndSave<T: NSManagedObject>(using fetchRequest: NSFetchRequest<T>) async throws {
try await context.perform {
let objects = try context.fetch(fetchRequest)
for object in objects {
context.delete(object)
}
try context.save()
}
}
}
And yes, you’re right; the above example is not where the real power of a custom executor appears. It’s honestly boilerplate code and moves Core Data logic into the background. Yet, code does not become easier to understand with these hidden implementation details.
Do we really need a custom actor executor for Core Data?
I have seen several articles and forum posts suggesting the use of custom actor executors for Core Data. It’s an interesting concept, and you may find it useful on your journey of migration. You’ve now at least seen an implementation example!
However, the majority of Core Data apps will directly work with viewContext. Introducing a CoreDataStore actor will force you to use concurrency in places where it would currently not be needed. This is simply because you can make use of the viewContext as long as you’re running on the main thread (aka @MainActor).
Things become more interesting as soon as you run into long-running queries that require background execution. However, still, you could simply use something like:
try await backgroundContext.perform {
/// Perform heavy query & work...
try backgroundContext.save()
}
No hidden implementation details, and it’s code that goes well with Swift Concurrency.
Can we use a global actor instead?
You could define a global actor as follows:
@globalActor
actor CoreDataBackgroundContext {
static let shared = CoreDataBackgroundContext()
nonisolated let modelExecutor: NSModelObjectContextExecutor
nonisolated var unownedExecutor: UnownedSerialExecutor {
modelExecutor.asUnownedSerialExecutor()
}
init() {
let backgroundContext = CoreDataStore.shared.persistentContainer.newBackgroundContext()
modelExecutor = NSModelObjectContextExecutor(context: backgroundContext)
}
}
And then maybe think of a CoreDataStore structure that offers several methods:
nonisolated struct CoreDataStoreStructure {
let viewContext: NSManagedObjectContext
let backgroundContext: NSManagedObjectContext
@CoreDataBackgroundContext
func performHeavyQuery(closure: (NSManagedObjectContext) throws -> Void) rethrows {
try closure(backgroundContext)
}
@MainActor
func performWorkOnViewContext(closure: @escaping (NSManagedObjectContext) throws -> Void) async rethrows {
try closure(viewContext)
}
}
This store has the benefit of enforcing the proper isolation domain for the given operation. However, there’s nothing stopping you from using a different managed object context than defined inside the actor’s executor, making this approach error prone.
Alright, so what should I do?
Great question! I’ve been on this journey myself for RocketSim and found myself going back and forth. Honestly, even for this course module on Core Data I’ve been going back and forth, ha! It’s a great way of learning and I believe it’s a skill to accept a given (difficult) solution is too complicated.
Keeping code simple and easy to understand will make it reusable and usable for your future self and your colleagues. Keep it stupid simple. What I did like about the journey we’ve had in this lesson is that we’ve been exploring ways to enforce a proper isolation domain for a given managed object context. This is something I’ve been missing in the traditional managed object context perform method. With the latter I mean: you could access viewContext from any isolation domain, even though this is not thread-safe.
So I went ahead and considered a solution. The final CoreDataStore that you can find in the sample code looks as follows:
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()
}
}
}
There are a few things to point out:
- We’re using
nonisolatedon the enclosing structure to ensure we’re not isolated to the default actor isolation. - The perform method must be called on the
@MainActorbecause it accesses theviewContext, which requires execution on the main thread. - Potentially heavy queries like the
deleteAllAndSavemethod need to perform in the background. To ensure this, we’ve added the@concurrentattribute to dispatch away from the caller’s actor isolation. Inside, we’re making use of a new background context to ensure we’re also not blocking theviewContextwith a long running operation.
In my opinion, we’ve created a solution that’s easy to follow and benefits from compile-time thread-safety. Ideally, we would not allow direct access to the viewContext to enforce that all code in our projects uses the @MainActor isolated perform method. However, when using SwiftUI’s environment, you’ll likely want to inject the managed object context accordingly.
Summary
This lesson represents a journey I’ve taken with Core Data. It demonstrates how to define a custom actor executor using a managed object context, but it also explains why it’s likely not a solution you need. Altogether, we’ve created a simplified CoreDataStore that leverages Swift Concurrency as much as possible.