Core Data Reform: Achieving Elegant Concurrency Operations like SwiftData

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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.

Swift
@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 the ExecutorJob.runSynchronously(on:) method, which consumes the ExecutorJob 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 the ExecutorJob to an UnownedExecutorJob and submit it to the serial mechanism for execution.

A specific implementation example is as follows:

Swift
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:

Swift
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.

Swift
public nonisolated var unownedExecutor: UnownedSerialExecutor

With this, we can implement an Actor similar to SwiftData, designed for handling Core Data concurrent operations.

Swift
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:

Swift
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:

Swift
@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:

Swift
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 and ExecutorJob 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.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed