在 Core Data 中进行并发编程可能并不困难,可是充满了圈套。即便对 Core Data 有充沛的经验,稍有疏忽也可能在代码中埋下隐患,然后使使用程序变得不安全。SwiftData 作为 Core Data 的继任者,供给了一种愈加高雅、愈加安全的并发编程机制。本文将介绍 SwiftData 是怎么处理这些问题的,并为开发者供给更好的并发编程体会。

本文的内容中将涉及 Swift 中的 async/await、Task、Actor 等并发处理功用。读者需求具有必定的 Swift 并发编程经验。

原文宣布在我的博客wwww.fatbobman.com 。 由于技术文章需求不断的迭代,当时耗费了不少的精力在不同的平台之间来维持文章的更新。故从 2024 年起,新的文章将只发布在我的博客上。

用串行行列来防止数据竞赛

咱们经常说,Core Data 中的保管目标实例(NSManagedObject)和保管目标上下文(NSManagedObjectContext)不是线程安全的。那么,为什么会呈现不安全的问题?Core Data 处理这个问题的办法又是什么呢?

其实,主要的不安全点就出在数据竞赛上(在多线程环境中一起对同一个数据进行修正操作)。Core Data 经过在串行行列中对保管目标实例和保管目标上下文实例进行操作,然后防止数据竞赛问题。这也是为什么咱们需求将操作代码放置在performperformAndWait闭包中。

对于视图上下文(viewContext)和其间注册的保管目标实例,开发者应该在主线程行列中进行操作。相同,对于私有上下文和其间注册的保管目标,咱们应该在私有上下文所创立的串行行列中进行操作。perform 办法将保证所有的操作都在正确的行列中进行。

阅读 关于 Core Data 并发编程的几点提示 一文,详细了解不同类型的保管目标上下文、串行行列、perform 的运用办法以及其他在 Core Data 中进行并发编程的注意事项。

从理论上讲,只要咱们严厉依照上述要求进行编程,就能够在 Core Data 中防止大多数并发问题。因此,开发者经常会编写相似以下的代码:

func updateItem(_ item: Item, timestamp: Date?) async throws {
    guard let context = item.managedObjectContext else { throw MyError.noContext }
    try await context.perform {
        item.timestamp = timestamp
        try context.save()
    }
}

当代码中存在许多的 perform 办法时,会下降代码的可读性。这也是许多开发者所抱怨的问题。

怎么创立运用私有行列的 ModelContext

在 Core Data 中,开发者能够运用一种十分明确的办法来创立不同类型的保管目标上下文:

// view context - main queue concurrency type
let viewContext = container.viewContext
let mainContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
// private context - private queue concurrency type
let privateContext = container.newBackgroundContext()
let privateContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)

乃至能够在私有上下文中直接进行操作,而无需显式创立。

container.performBackgroundTask{ context in
    ...
}

可是,SwiftData 对 ModelContext( NSManagedObjectContext 的包装版别 )的创立逻辑进行了调整。新创立的上下文的类型取决于其所在的行列。换句话说,在主线程上创立的 ModelContext 将主动运用主线程行列( com.apple.main-thread ),而在其他线程( 非主线程 )上创立的 ModelContext 将运用私有行列。

Task.detached {
    let privateContext = ModelContext(container)
}

特别需求注意的是,当经过 Task.init 创立一个新的使命时,它会继承创立它的父使命或 Actor 的履行上下文。这意味着,经过下面的代码,并不能创立出运用私有行列的 ModelContext:

// In SwiftUI View
Button("Create New Context"){
    let container = modelContext.container
    Task{
        // Using main queue, not background context
        let privateContext = ModelContext(container)
    }
}
SomeView()
    .task {
        // Using main queue, not background context
        let privateContext = ModelContext(modelContext.container)
    }

这是由于在 SwiftUI 中,视图的 body 被标注为 @MainActor ,因此建议运用 Task.detached 来保证在非主线程上创立运用私有行列的 ModelContext。

在主线程上创立的 ModelContext 是一个独立的实例,与 ModelContainer 实例的 mainContext 特点供给的上下文实例并不相同。虽然它们都在主行列上进行操作,但它们别离管理着独立的注册目标。

Actor:串行行列更高雅的完成

从 5.5 版别开端,Swift 引入了 Actor 的概念。与串行行列相同,它们能够用于处理数据竞赛问题,并保证数据的一致性。与经过 perform 办法运转在特定串行行列上的办法相比,Actor 供给了一种更高级和更高雅的完成办法。

每个 Actor 都有一个相关的串行行列,用于履行其办法和使命。这个行列根据 GCD,由 GCD 负责底层线程管理和使命调度。这样能够保证 Actor 的办法和使命以串行办法履行,即同一时间只能有一个使命在履行。这保证了 Actor 内部的状况和数据在任何时候都是线程安全的,防止了并发拜访的问题。

虽然从理论上来说,能够运用 Actor 来限制代码对保管目标上下文和保管目标的操作,但由于之前的 Swift 版别并没有供给自定义 Actor 履行者(Executor)的才能,这种办法并没有被选用。好在,Swift 5.9 版别弥补了之前的惋惜,让 SwiftData 经过 Actor 供给了愈加高雅的并发编程体会。

Custom Actor Executors: 该提案介绍了一种自定义 Swift Actor 履行器的基本机制。经过供给履行器的实例,Actor 能够影响它们运转使命的履行位置,一起坚持 Actor 模型所保证的互斥性和隔离性。

得益于 Swift 的新功用 “宏”,在 SwiftData 中,创立一个对应特定串行行列的 Actor 十分简单:

@ModelActor
actor DataHandler {}

经过为该 Actor 增加更多数据操作逻辑代码,开发者能够安全地运用该 Actor 实例来操作数据。

extension DataHandler {
    func updateItem(_ item: Item, timestamp: Date) throws {
        item.timestamp = timestamp
        try modelContext.save()
    }
    func newItem(timestamp: Date) throws -> Item {
        let item = Item(timestamp: timestamp)
        modelContext.insert(item)
        try modelContext.save()
        return item
    }
}

你能够运用以下办法调用上述代码:

let handler = DataHandler(modelContainer: container)
let item = try await handler.newItem(timestamp: .now)

之后,无论在哪个线程中调用 DataHandler 的办法,这些操作都将在一个特定的串行行列中进行。开发者再也不用为编写许多包含 perform 的代码而苦恼了。

还记得上一节评论的创立 ModelContext 应注意的事项吗?在创立一个经过 ModelActor 宏构建的实例时,所选用的规则也是相同的。新创立的 Actor 实例所选用的串行行列类型取决于创立它的线程。

Task.detached {
    // Using private queue
    let handler = DataHandler(modelContainer: container)
    let item = try await handler.newItem(timestamp: .now)   
}

ModelActor 宏的秘密

ModelActor 宏到底有什么魔法?而 SwiftData 又是怎么保证 Actor 的履行序列与 ModelContext 运用的串行行列坚持一致呢?

经过在 Xcode 中打开 ModelActor 宏,咱们能够看到生成的完好代码:

actor DataHandler {
    nonisolated let modelExecutor: any SwiftData.ModelExecutor
    nonisolated let modelContainer: SwiftData.ModelContainer
    init(modelContainer: SwiftData.ModelContainer) {
        let modelContext = ModelContext(modelContainer)
        modelExecutor = DefaultSerialModelExecutor(modelContext: modelContext)
        self.modelContainer = modelContainer
    }
}
extension DataHandler: SwiftData.ModelActor {}
// The code below is not generated by ModelActor
public protocol ModelActor : Actor {
    /// The ModelContainer for the ModelActor
    /// The container that manages the app’s schema and model storage configuration
    nonisolated var modelContainer: ModelContainer { get }
    /// The executor that coordinates access to the model actor.
    ///
    /// - Important: Don't use the executor to access the model context. Instead, use the
    /// ``context`` property.
    nonisolated var modelExecutor: ModelExecutor { get }
    /// The optimized, unonwned reference to the model actor's executor.
    nonisolated public var unownedExecutor: UnownedSerialExecutor { get }
    /// The context that serializes any code running on the model actor.
    public var modelContext: ModelContext { get }
    /// Returns the model for the specified identifier, downcast to the appropriate class.
    public subscript<T>(id: PersistentIdentifier, as as: T.Type) -> T? where T : PersistentModel { get }
}

经过代码能够看出,在结构过程中主要会进行两个操作:

  • 运用传入的 ModelContainer 创立一个 ModelContext 实例。

结构办法运转在哪个线程,决定了创立的 ModelContext 所选用的串行行列,然后也影响了 Actor 的履行行列。

  • 根据新创立的 ModelContext,创立一个 DefaultSerialModelExecutor(自定义的 Actor 履行者)。

DefaultSerialModelExecutor 是 SwiftData 声明的 Actor 履行者。它的主要职责是将传入的 ModelContext 实例运用的串行行列作为当时 Actor 实例的履行行列。

为了判别 DefaultSerialModelExecutor 的效果是否和咱们预期的相同,咱们能够经过下面的代码进行验证:

import SwiftDataKit
actor DataHandler {
    nonisolated let modelExecutor: any SwiftData.ModelExecutor
    nonisolated let modelContainer: SwiftData.ModelContainer
    init(modelContainer: SwiftData.ModelContainer) {
        let modelContext = ModelContext(modelContainer)
        modelExecutor = DefaultSerialModelExecutor(modelContext: modelContext)
        self.modelContainer = modelContainer
    }
    func checkQueueInfo() {
        // Get Actor run queue label
        let actorQueueLabel = DispatchQueue.currentLabel
        print("Actor queue:",actorQueueLabel)
        modelContext.managedObjectContext?.perform {
            // Get context queue label
            let contextQueueLabel = DispatchQueue.currentLabel
            print("Context queue:",contextQueueLabel)
        }
    }
}
extension DataHandler: SwiftData.ModelActor {}
// get current dispatch queue label
extension DispatchQueue {
    static var currentLabel: String {
        return String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown"
    }
}

SwiftDataKit 将让开发者能够拜访 SwiftData 元素底层的 Core Data 目标。

checkQueueInfo 办法中,咱们别离获取并打印了当时 actor 的履行序列和保管目标上下文对应的行列的称号。

Task.detached {
    // create handler in non-main thread
    let handler = DataHandler(modelContainer: container)
    await handler.checkQueueInfo()
}
// Output
Actor queue: NSManagedObjectContext 0x600003903b50
Context queue: NSManagedObjectContext 0x600003903b50

当在非主线程上创立 DataHandler 实例时,保管目标上下文将创立一个名为 NSManagedObjectContext + 地址 的私有串行行列,Actor 的履行行列与其一致。

Task { @MainActor in
    // create handler in main thread
    let handler = DataHandler(modelContainer: container)
    await handler.checkQueueInfo()
}
// Output
Actor queue: com.apple.main-thread
Context queue: com.apple.main-thread

在主线程上创立 DataHandler 实例时,保管目标上下文和 Actor 均为主线程行列。

根据输出能够看到,Actor 的履行行列与上下文所运用的行列彻底一致,证实了咱们之前的猜想。

SwiftData 经过运用 Actor 来保证操作在正确的行列上运转,这也能够给 Core Data 开发者供给启示。能够考虑在 Core Data 中经过自定义的 Actor 履行者来完成相似的功用。

经过 PersistentIdentifier 获取数据

在 Core Data 的并发编程中,除了要在正确的行列上进行操作外,另一个重要的原则是不要在上下文之间传递 NSManagedObject 实例。这个规则相同适用于 SwiftData。

假如想在另一个 ModelContext 中对某个 PersistentModel 对应的存储数据进行操作,能够经过传递该目标的 PersistentIdentifier 来处理。PersistentIdentifier 能够被视为 NSManagedObjectId 的 SwiftData 完成。

下面的代码将测验经过传递进来的 PersistentIdentifier 获取对应的数据并进行修正:

extension 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()
    }
}
let handler = DataHandler(container:container)
try await handler.updateItem(identifier: item.id, timestamp: .now )

在代码中,咱们运用了一个 ModelActor 协议供给的下标办法。该办法首先测验从当时 actor 持有的 ModelContext 中查找是否有对应的 PersistentModel。假如没有的话,它将测验从行缓存以及耐久化存储中获取。能够将其视为 Core Data NSManagedObjectContext 的 existingObject(with:) 办法的对应版别。有趣的是,这个直接穿透到耐久化存储的办法只在 ModelActor 协议中供给了完成。从这个角度来看,SwiftData 的开发团队也在有认识地引导开发者运用这种( ModelActor )数据操作逻辑。

别的,ModelContext 还供给了两种经过 PersistentIdentifier 获取 PersistentModel 的办法。registeredModel(for:) 对应于 NSManagedObjectContext 的 registeredObject(for:) 办法;model(for:) 对应于 NSManagedObjectContext 的 object(with:) 办法。

func updateItemInContext(identifier: PersistentIdentifier, timestamp: Date) throws {
    guard let item = modelContext.registeredModel(for: identifier) as Item? else {
        throw MyError.objectNotInContext
    }
    item.timestamp = timestamp
    try modelContext.save()
}
func updateItemInContextAndRowCache(identifier: PersistentIdentifier,timestamp: Date) throws {
    if let item = modelContext.model(for: identifier) as? Item {
        item.timestamp = timestamp
        try modelContext.save()
    }
}

这三种办法的差异如下:

  • existingObject(with:)

假如上下文识别到了指定的目标,该办法会回来该目标。不然,上下文会从耐久化存储中获取并回来一个彻底实例化的目标。与object(with:)办法不同,该办法永久不会回来一个惰值状况的目标。假如目标既不在上下文中,也不在耐久化存储中,该办法会抛出一个过错。简单来说,除非该数据在耐久化存储上并不存在,不然必然会回来一个非惰值状况的目标。

  • registeredModel(for:)

此办法只能回来在当时上下文中已注册的目标(标识符相同)。假如找不到,则回来 nil。当回来值为 nil 时,并不表明该目标必定不存在于耐久化存储中,只表明该目标未在当时上下文中注册。

  • model(for:)

即便目标没有在当时上下文中注册,该办法仍会回来一个空的惰值目标——一个占位目标。当用户实际拜访该占位目标时,上下文将测验从耐久化存储中获取数据。假如数据不存在,可能会导致使用溃散。

第二道防地

并非每个开发者都会严厉依照 SwiftData 所期望的办法(ModelActor)进行并发编程。在代码逐步杂乱后,或许会不小心呈现拜访或设置其他行列上的 PerisistentModel 特点的状况。根据 Core Data 的经验,在敞开调试参数 com.apple.CoreData.ConcurrencyDebug 1 的状况下,这种拜访将必然导致使用溃散。

更多调试参数,请阅读 Core Data with CloudKit(四)—— 调试、测试、搬迁及其他 一文。

可是,在 SwiftData 中,虽然咱们会收到一些正告信息(Capture non-sendable),上述操作并不会呈现问题,能够正常进行数据拜访和修正。这是为什么呢?

下面的代码将在一个非主线程中修正主线程上的 Item 目标特点。点击按钮后,特点修正成功。

Button("Modify in Wrong Thread") {
    let item = items.first!
    DispatchQueue.global().async {
        print(Thread.current)
        item.timestamp = .now
    }
}

SwiftData 中的并发编程

假如你看过上一篇文章 揭秘 SwiftData 的数据建模原理,或许会记得其间说到 SwiftData 为 PersistentModel 和 BackingData 供给的 Get 和 Set 办法不只能够读取和设置特点,还具有行列调度的才能(保证线程安全)。换句话说,即便咱们在过错的线程(行列)对特点进行修正,这些办法会主动将操作切换到正确的行列中进行。经过进一步测验,咱们发现这种调度才能至少存在于 BackingData 协议的完成层面。

Button("Modify in Wrong Thread") {
    let item = items.first!
    DispatchQueue.global().async {
        item.persistentBackingData.setValue(forKey: \.timestamp, to: Date.now)
    }
}

本节内容并非鼓舞大家绕过 ModelActor 的办法进行数据操作,但经过这些细节,咱们能够看出 SwiftData 团队为了防止呈现线程安全问题而做出了许多努力。

总结

或许有人会和我相同,在了解了 SwiftData 新的并发编程办法后,在欣喜之余会有一种说不出来的感觉。经过了一段时间的思考,我好像找到了这种异样感觉的原因 —— 代码风格。显然,之前在 Core Data 中常用的数据处理逻辑并不彻底适用于 SwiftData。那么怎么写出更具有 SwiftData 味道的代码呢?怎么让数据处理代码与 SwiftUI 愈加符合?这是咱们往后要研究的课题。

订阅我的电子周报 Fatbobman’s Swift Weekly,你将每周及时获取有关 Swift、SwiftUI、CoreData 和 SwiftData 的最新文章和资讯。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公众号:【肘子的Swift记事本】