跨域传递 NSManagedObjectContext 为什么在 Swift 6.2 中不再报错?真正的变化不在编译器

当同一段与并发有关的代码在 Xcode 16 中无法通过,却能在 Xcode 26 中顺利编译时,你第一时间会想到什么?我最初的判断是编译器进化了,但现实并没有这么简单。本文将记录我最近遇到的一次有意思的排查过程:从测试失败出发,一步步追到 Core Data 的 SDK interface,最终发现,问题的关键并不完全在 Swift 编译器本身,而在 NSManagedObjectContext 被导入 Swift 的方式已经发生了变化。

问题

最近在为 Persistent History TrackingKit 2 补充和调整测试代码时,我遇到了一个特殊情况,为了模拟两个不同角色(例如主程序和扩展、两个不同进程中的组件)之间通过 Persistent History 合并变更的场景,我在测试中让两个不同域共同引用同一个 NSManagedObjectContext 实例:

Swift
let app2Handler = TestAppDataHandler( // actor domain
  container: container,
  context: app2Context,
  viewName: "App2Handler"
)

let kit = PersistentHistoryTrackingKit( // current domain
  container: container,
  contexts: [app2Context],
  currentAuthor: "App2",
  allAuthors: ["App1", "App2"],
  userDefaults: userDefaults,
  uniqueString: uniqueString,
  logLevel: 0,
  autoStart: false
)

完整的测试用例(包括 Actor 隔离与自定义执行器的实现)可查看实际的测试代码

上面的代码,在 Swift 6 模式下, Xcode 26.3(Swift 6.2.4)下可以正常编译,但在 Xcode 16.4(Swift 6.1.2)下却会直接被编译器拦住,给出典型并发安全错误:

Swift
sending 'app2Context' risks causing data races

根据我的最初理解,在 Swift 6.1.2 中,编译器认为这里存在风险:

  • app2Context 被送入了一个 actor 相关的隔离域
  • 当前作用域随后又继续使用了同一个 app2Context
  • 因此可能存在 “actor-isolated and local nonisolated uses” 的重叠访问

但从我的具体实现上看,与 context 有关的并发操作都是在其私有队列中进行的;而 TestAppDataHandler 又通过自定义 executor 将 actor 执行固定在这个 context 的执行环境中。从运行时语义上,这段代码理论上是安全的。

起初,我的第一反应是 Swift 6.2 对于 Region-Based Isolation(SE-0414)相关实现更成熟了,因此对 non-Sendable 值跨域传递的分析更准确,放过了 Swift 6.1 中的保守误报,为此我尝试在 6.2 版本中做进一步验证。

Swift 6.2 的并发分析进步了?

为了验证这一点,我先尝试构造一个不依赖 Core Data 的纯 Swift 版本重现。思路很简单:

  • 准备一个普通的 non-Sendable 引用类型
  • 把它传给一个 actor 的初始化器
  • 然后在当前作用域继续访问它
Swift
final class Token {
  var value: Int = 0
}

private actor TokenActor {
  init(token: Token) {}
}

func repro() {
  let token = Token()
  let actor = TokenActor(token: token)
  _ = token.value
  _ = actor
}

结果很快就推翻了我最初的判断:

  • Swift 6.1.2:报错
  • Swift 6.2.4:仍然报错

也就是说,Swift 6.2 并没有普遍放过“non-Sendable 值进入 actor 域后,当前作用域继续访问”的模式。

如果只是一般的 non-Sendable 引用类型,这两版编译器的判断是一致的。

在尝试了各种不同的 Token 类型设计后,我将目标逐渐锁定到 NSManagedObjectContext 这个类型本身,或许它有什么特别之处。

通过真实代码来锁定问题

既然不是一般的 non-Sendable 值,那我就反过来,从真实测试代码中持续删减,直到得到能稳定复现差异的最小骨架。

最后得到的版本只剩下这几行:

Swift
import CoreData

private actor ReducedContextActor {
  init(context: NSManagedObjectContext) {}
}

func repro() {
  let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
  let actor = ReducedContextActor(context: context)
  context.name = "StillLocal"
  _ = actor
}

具体结果如下:

  • Swift 6.1.2:报错
  • Swift 6.2.4:通过

也就是说,此时我们已经拿到了一个非常干净的 Core Data 特定重现。

在 Core Data 的 interface 中找寻答案

根据我之前对于 Core Data 的理解,只有 NSManagedObjectIDNSPersistentContainerNSPersistentStoreCoordinator 被声明为了 Sendable,难道 Xcode 26 中的 Core Data 有了其他的变化?

于是我开始对比 Xcode 16 和 26 自带的 Core Data 头文件和 Swift interface,很快,我就找到了决定性的差异。

在 Xcode 26.3 的 NSManagedObjectContext.h 中,类声明前多了这样一行:

Objc
NS_SWIFT_NONISOLATED NS_SWIFT_SENDABLE
@interface NSManagedObjectContext : NSObject <NSCoding, NSLocking>

而在 Xcode 16.4 的对应头文件中,这两个标记是不存在的。

除此之外,在 26.3 的 CoreData.swiftinterface 中,还能看到一系列新增的并发相关导入结果,例如:

Swift
convenience nonisolated public init(_ type: CoreData.NSManagedObjectContext.ConcurrencyType)
@preconcurrency nonisolated public func performAndWait<T>(...)
@preconcurrency nonisolated public func perform<T>(...)
nonisolated public func fetch<T>(...)
nonisolated public func count<T>(...)

这与 16.4 版本相比差异非常明显。

到这里,问题的答案已经基本浮出水面了:NSManagedObjectContext 在新 SDK 中被以更强的并发语义导入到了 Swift 中。

NS_SWIFT_SENDABLE 和 NS_SWIFT_NONISOLATED 到底是什么

为了避免凭感觉下结论,我继续追到了宏定义本身。

它们定义在 Foundation 的 NSObjCRuntime.h 中,对应定义如下:

Objc
// Indicates that the thing it is applied to should be imported as 'Sendable' in Swift:
// * Type declarations are imported into Swift with a 'Sendable' conformance.
// * Block parameters are imported into Swift with an '@Sendable' function type. (Write it in the same place you would put 'NS_NOESCAPE'.)
// * 'id' parameters are imported into Swift as 'Sendable', not 'Any'.
// * Other object-type parameters are imported into Swift with an '& Sendable' requirement.
#define NS_SWIFT_SENDABLE __attribute__((swift_attr("@Sendable")))

// Indicates that a specific member of an 'NS_SWIFT_UI_ACTOR'-isolated type is "threadsafe" and should be callable from outside the main actor.
#define NS_SWIFT_NONISOLATED __attribute__((swift_attr("nonisolated")))

如果继续往下看,紧接着还有:

Objc
// Indicates that a specific member of an 'NS_SWIFT_UI_ACTOR'-isolated type does its own data isolation management and does not participate in Swift concurrency checking.
#define NS_SWIFT_NONISOLATED_UNSAFE __attribute__((swift_attr("nonisolated(unsafe)")))

头文件中的注释给出了相当明确的说明。

  • NS_SWIFT_SENDABLE:它表示被标注的声明应当以 Sendable 的方式导入到 Swift 中。对于类型声明而言,效果就是:这个 Objective-C 类型在导入 Swift 时具有 Sendable 语义;对于 block 参数而言,效果就是:对应的 Swift 闭包会被导入为 @Sendable

  • NS_SWIFT_NONISOLATED:它表示被标注的成员应当以 nonisolated 的方式导入到 Swift 中。

    在注释里,这个宏被描述为针对某个 member 的标记。不过在 NSManagedObjectContext 上,Apple 实际上把它放在了类声明前,这意味着至少在导入器层面,它对整个类型的导入行为产生了更广泛的影响。

而实际导入结果已经表明:NSManagedObjectContext 在 Xcode 26 对应的 SDK 中,不再被当作一个普通的 non-Sendable Objective-C 类来处理。它同 NSManagedObjectIDNSPersistentContainerNSPersistentStoreCoordinator 一样现在都是 Sendable 类型了。

把这两个宏用到我们自己的 Objective-C 类上

尽管我们已经接近答案了,但我还是希望做最后一步验证:如果把 NS_SWIFT_SENDABLENS_SWIFT_NONISOLATED 挂到我自己的 Objective-C 类上,Swift 的行为会不会跟着变化?

为此我构造了两个本地 Objective-C 模块:

未标注版本

Objc
@interface ObjCPlainToken : NSObject
@property (nonatomic) NSInteger value;
@end

标注版本

Objc
NS_SWIFT_NONISOLATED NS_SWIFT_SENDABLE
@interface ObjCAnnotatedToken : NSObject
@property (nonatomic) NSInteger value;
@end

Swift 测试代码两边都保持同样的结构:

Swift
private actor TokenActor {
  init(token: ObjC...Token) {}
}

func repro() {
  let token = ObjC...Token()
  let actor = TokenActor(token: token)
  token.value = 1
  _ = actor
}

实验结果非常干脆:

  • 未标注版本
    • Swift 6.1.2:报错
    • Swift 6.2.4:报错
  • 标注版本
    • Swift 6.1.2:通过
    • Swift 6.2.4:通过

这一步几乎可以视为实锤:这两个宏确实能够改变这类诊断的结果。

NSManagedObjectContext 在新 SDK 中恰好获得了这两个标记。NSManagedObjectIDNSPersistentContainerNSPersistentStoreCoordinator 至少从 Xcode 16 时便已经使用了 NS_SWIFT_SENDABLE,但并没有像 NSManagedObjectContext 这样同时获得 NS_SWIFT_NONISOLATED

到这里,我们该如何重新理解最初的问题

现在可以给出一个更准确的结论了。

最初我把问题理解为:

  • Swift 6.1 过于保守
  • Swift 6.2 更聪明,因此放过了 NSManagedObjectContext

现在看来,更准确的说法应该是:

  1. 一般的 non-Sendable 引用类型,Swift 6.1 和 Swift 6.2 对这种模式的态度是一致的,都会报错。
  2. NSManagedObjectContext 在 Xcode 26 的 SDK 中,被以 NS_SWIFT_SENDABLENS_SWIFT_NONISOLATED 等更强的并发语义导入到了 Swift。
  3. 因此它在 Xcode 26 的 SDK 中不再是一个 non-Sendable Objective-C 类型而是一个 Sendable 类型。

看似是 Swift 编译器的进化,真正发生变化的,其实是框架的导入语义

结语

尽管 NSManagedObjectContext 现在可以跨域传递,但这并不意味着在生产环境中我们也应该这样使用。从 SwiftData 的 interface 来看,ModelContext 虽然有一个 @unchecked Sendable 扩展,但该扩展同时被标记为 unavailable,并明确写了 contexts cannot be shared across concurrency contexts。除非确实遇到需要跨域的场景,还是应该尽量遵循 Core Data 一贯的并发编码准则。

如果我在发现问题后第一时间检查 NSManagedObjectContext 的声明变化,或许很快就能查明真相。但正是因为先入为主地把问题归因于编译器,才有了这次一路追到 SDK 头文件的过程。这次经历也给了我一个提醒:当新旧版本的编译行为出现差异时,除了考虑编译器本身的变化,也值得去检查框架的导入语义是否同步发生了调整。很多时候,答案藏在你没有第一时间想到去看的地方。

订阅 Fatbobman 周报

每周精选 Swift 与 SwiftUI 开发技巧,加入众多开发者的行列。

立即订阅