SwiftData, as the successor to Core Data, has introduced a multitude of innovative and modern design ideas. Despite its availability for some time now, many developers have yet to adopt it in their projects. This situation is partly due to SwiftData’s higher requirements for the operating system version. Additionally, because SwiftData is not yet mature enough in some functionalities, developers might choose to continue using Core Data even if their operating system version meets SwiftData’s requirements. Can we integrate some of SwiftData’s excellent design philosophies and ingenious implementations into the practical use of Core Data? This article aims to explore how to introduce elegant and safe concurrency operations similar to those of SwiftData into Core Data, implementing a Core Data version of @ModelActor
.
perform
VS @ModelActor
Although in theory, it only requires adhering to a simple principle to safely perform concurrent operations in Core Data: managed objects should only be manipulated within their bound managed object context and the corresponding thread. However, adherence to this rule entirely depends on the developer’s patience and experience, with the compiler unable to provide assistance in this aspect. Thus, in the practice of concurrent code in Core Data, the context-based perform
method is widely used, a practice that is both cumbersome and difficult to control.
SwiftData has overcome these obstacles. By adopting Swift’s modern concurrency model, developers can bypass perform
and encapsulate data operation logic within an Actor
. Furthermore, SwiftData also introduces the @ModelActor
attribute, allowing an Actor
to execute in a specific thread, providing developers with an elegant, safe, and efficient way of performing concurrent operations.
@ModelActor
actor DataHandler {
func updateItem(identifier: PersistentIdentifier, timestamp: Date) throws {
guard let item = self[identifier, as: Item.self] else {
throw MyError.objectNotExist
}
item.timestamp = timestamp
try modelContext.save()
}
}
For further reading, Several Tips on Core Data Concurrency Programming is recommended to learn more about advice on Core Data concurrency operations. Moreover, delving into Concurrent Programming in SwiftData can provide a deeper understanding of the innovations in SwiftData regarding concurrent operations.
Custom Actor Executors
Since the introduction of the new concurrency model in Swift 5.5, Actor
has become the preferred mechanism for developers to perform serial operations. However, this new concurrency design intentionally obscures the actual execution manner and details of the code, leaving developers unable to determine the specific execution location (i.e., the thread) of an Actor
for a long time.
Following the fundamental principles of Core Data concurrency operations, all operations on managed objects must be performed on the thread of their owning context. This restriction means that the Actor
model cannot be directly applied to Core Data’s concurrent operations.
However, the Swift community proposed the concept of custom Actor
executors through SE-392, and this functionality was implemented in Swift 5.9. SwiftData utilizes this new feature to provide developers with a novel concurrency development experience.
This means that we can now create an Executor
for an Actor
, using it to replace the default task scheduling mechanism of the Actor
.
Creating Custom Executors
Before building a custom Actor
executor, it’s necessary to understand some basic concepts:
- Executors Protocol: A basic executor that doesn’t guarantee any scheduling order, capable of executing submitted tasks either in parallel or serially.
- SerialExecutor Protocol: A serial executor, conforming to the Executors protocol. It ensures the mutual exclusion of tasks, meaning only one task can be executed at a time. This protocol is used by Actors to implement their serial execution semantics.
- UnownedSerialExecutor: An optimized reference type for
SerialExecutor
, providing an efficient executor reference mechanism for the Swift concurrency runtime, thus avoiding unnecessary overhead. This helps enhance the performance of Swift concurrency programming. - ExecutorJob: A task type that can be executed, supporting the
Sendable
protocol and being@noncopyable
. When it’s time to execute a task, the executor calls theExecutorJob.runSynchronously(on:)
method, which consumes theExecutorJob
instance and executes the task synchronously on the specified executor. - UnownedExecutorJob: A supplementary type to
ExecutorJob
, it is copyable, making it easier to store and pass tasks around.
The general steps for building a custom executor for an Actor
are as follows:
- Declare a type that conforms to the
SerialExecutor
protocol. - Implement a mechanism within it that can perform operations serially.
- In the
enqueue
method, convert theExecutorJob
to anUnownedExecutorJob
and submit it to the serial mechanism for execution.
A specific implementation example is as follows:
public final class CustomExecutor: SerialExecutor {
// Serial tool
private let serialQueue: DispatchQueue
public init(serialQueue: DispatchQueue) {
self.serialQueue = serialQueue
}
public func enqueue(_ job: consuming ExecutorJob) {
// Convert ExecutorJob to UnownedJob
let unownedJob = UnownedJob(job)
let unownedExecutor = asUnownedSerialExecutor()
// Execute the task in the serial queue
serialQueue.async {
unownedJob.runSynchronously(on: unownedExecutor)
}
}
// Convert self to an UnownedSerialExecutor
public func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
For Core Data scenarios, we could directly use the perform
method of NSManagedObjectContext
as the tool for serial operations. An appropriately adjusted implementation would look as follows:
public final class NSModelObjectContextExecutor: @unchecked Sendable, SerialExecutor {
public final let context: NSManagedObjectContext
public init(context: NSManagedObjectContext) {
self.context = context
}
public func enqueue(_ job: consuming ExecutorJob) {
let unownedJob = UnownedJob(job)
let unownedExecutor = asUnownedSerialExecutor()
context.perform {
unownedJob.runSynchronously(on: unownedExecutor)
}
}
public func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
By applying this executor to an Actor
, we can ensure that all operations within the Actor
(except the constructor) are executed on the thread corresponding to its managed object context.
Building Actors
Introducing a custom executor within an Actor
is very straightforward, requiring only the declaration of an unownedExecutor
property. Once the compiler recognizes that the Actor
contains this property, task scheduling will be conducted through this executor.
public nonisolated var unownedExecutor: UnownedSerialExecutor
With this, we can implement an Actor
similar to SwiftData, designed for handling Core Data concurrent operations.
actor DataHandler {
public nonisolated let modelExecutor: CoreDataEvolution.NSModelObjectContextExecutor
public nonisolated let modelContainer: CoreData.NSPersistentContainer
public init(container: CoreData.NSPersistentContainer) {
// Initialize private context
let context = container.newBackgroundContext()
// Instantiate custom executor
modelExecutor = CoreDataEvolution.NSModelObjectContextExecutor(context: context)
modelContainer = container
}
// Get the custom executor (UnownedSerialExecutor) required by the Actor
public nonisolated var unownedExecutor: UnownedSerialExecutor {
modelExecutor.asUnownedSerialExecutor()
}
// The managed object context for data operations within the Actor
public var modelContext: NSManagedObjectContext {
modelExecutor.context
}
// Implement a managed object access mechanism similar to SwiftData
public subscript<T>(id: NSManagedObjectID, as _: T.Type) -> T? where T: NSManagedObject {
try? modelContext.existingObject(with: id) as? T
}
}
Implementing the @NSModelActor
Macro: Simplifying Core Data Concurrency
In SwiftData, developers can automate the tedious setup described above simply by using the @ModelActor
attribute. To offer a similar development experience for Core Data, we introduce the @NSModelActor
attribute for Core Data.
First, we abstract the declaration of Actors by introducing the NSModelActor
protocol:
public protocol NSModelActor: Actor {
/// The NSPersistentContainer for the NSModelActor
nonisolated var modelContainer: NSPersistentContainer { get }
/// The executor that coordinates access to the model actor.
nonisolated var modelExecutor: NSModelObjectContextExecutor { get }
}
extension NSModelActor {
/// The optimized, unonwned reference to the model actor's executor.
public nonisolated var unownedExecutor: UnownedSerialExecutor {
modelExecutor.asUnownedSerialExecutor()
}
/// The context that serializes any code running on the model actor.
public var modelContext: NSManagedObjectContext {
modelExecutor.context
}
/// Returns the model for the specified identifier, downcast to the appropriate class.
public subscript<T>(id: NSManagedObjectID, as _: T.Type) -> T? where T: NSManagedObject {
try? modelContext.existingObject(with: id) as? T
}
}
Next, we declare the @NSModelActor
macro, which should conform to the ExtensionMacro
and MemberMacro
protocols:
@attached(member, names: named(modelExecutor), named(modelContainer), named(init))
@attached(extension, conformances: NSModelActor)
public macro NSModelActor() = #externalMacro(module: "CoreDataEvolutionMacrosPlugin", type: "NSModelActorMacro")
Due to the absence of any special handling required for the original code, the implementation of the macro is relatively straightforward:
public enum NSModelActorMacro {}
extension NSModelActorMacro: ExtensionMacro {
public static func expansion(of _: SwiftSyntax.AttributeSyntax, attachedTo _: some SwiftSyntax.DeclGroupSyntax, providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol, conformingTo _: [SwiftSyntax.TypeSyntax], in _: some SwiftSyntaxMacros.MacroExpansionContext) throws -> [SwiftSyntax.ExtensionDeclSyntax] {
// Generate extension code that conforms to the NSModelActor protocol.
let decl: DeclSyntax =
"""
extension \(type.trimmed): CoreDataEvolution.NSModelActor {}
"""
guard let extensionDecl = decl.as(ExtensionDeclSyntax.self) else {
return []
}
return [extensionDecl]
}
}
extension NSModelActorMacro: MemberMacro {
public static func expansion(of _: AttributeSyntax, providingMembersOf _: some DeclGroupSyntax, conformingTo _: [TypeSyntax], in _: some MacroExpansionContext) throws -> [DeclSyntax] {
// Add constructors and necessary properties.
[
"""
public nonisolated let modelExecutor: CoreDataEvolution.NSModelObjectContextExecutor
public nonisolated let modelContainer: CoreData.NSPersistentContainer
public init(container: CoreData.NSPersistentContainer) {
let context = container.newBackgroundContext()
modelExecutor = CoreDataEvolution.NSModelObjectContextExecutor(context: context)
modelContainer = container
}
""",
]
}
}
Now, developers can enjoy the same elegant and safe concurrent operations in Core Data as in SwiftData!
SerialExecutor
andExecutorJob
are only supported on iOS 17, macOS 14, and later systems. Currently, they cannot be applied on older versions of systems. It is hoped that Apple will make these APIs compatible with lower versions of systems, just as they did with the concurrency model, so that more developers can benefit.
For the convenience of developers, I have integrated the above code into the CoreDataEvolution library and look forward to gradually implementing the inspirations gained from SwiftData in this library. We also welcome more developers to participate.
Conclusion
During the Let’s VisionOS 2024 event, I delivered a speech titled New Frameworks, New Mindset: Unveiling the Observation and SwiftData Frameworks. The core aim was to emphasize that while new frameworks aim to address issues with old ones, we should not be bound by old experiences and habits. Instead, we should approach them with an open mindset, learning and applying these tools from new perspectives, viewing the adoption of new frameworks as an opportunity to transition towards a safer and more modernized approach.
The value of exploring new frameworks lies not only in using their new APIs but also in inspiring us to optimize the development process of traditional frameworks through their design principles. Therefore, even if you are unable to use new frameworks such as SwiftData, SwiftUI, and Observation for a long time, I still encourage developers to delve into understanding and learning them, enriching your knowledge base with these new design concepts.