了解 Swift 线程安全



我在使用 Xcode 的线程清理器的应用程序中遇到了数据竞争,我有一个关于如何解决它的问题。

我有一个定义为:

var myDict = [Double : [Date:[String:Any]]]()

我有一个线程设置,我在其中调用setup()函数:

let queue = DispatchQueue(label: "my-queue", qos: .utility)
queue.async {
self.setup {                
}
}

我的setup()函数本质上是遍历大量数据并填充myDict。这可能需要一段时间,这就是为什么我们需要异步执行此操作的原因。

在主线程上,我的 UI 访问myDict以显示其数据。在cellForRow:方法中:

if !myDict.keys.contains(someObject) {
//Do something
}

这就是我收到数据竞争警报和随后崩溃的地方。

异常 NSException * "-[_NSCoreDataTaggedObjectID对象 ForKey:]: 发送到实例的无法识别的选择器 0x8000000000000000" 0x0000000283df6a60

请帮助我了解如何在 Swift 中以线程安全的方式访问变量。我觉得我可能在设置上完成了一半,但我对如何处理主线程感到困惑。

前言:这将是一个相当长的不回答。我实际上不知道你的代码出了什么问题,但我可以分享我知道的可以帮助你解决问题的东西,并在此过程中学到一些有趣的东西。

理解错误

异常NSException *"-[_NSCoreDataTaggedObjectID objectForKey:]:无法识别的选择器发送到实例0x8000000000000000

  • 抛出(但未捕获)目标 C 异常。
  • 尝试调用-[_NSCoreDataTaggedObjectID objectForKey:]时发生异常。这是一种以书面形式引用目标C方法的常规方法。在本例中,它是:
    • 一个实例方法(因此是-,而不是用于类方法的+)
    • 关于类_NSCoreDataTaggedObjectID(稍后会详细介绍)
    • 在名为objectForKey:的方法上
  • 接收此方法调用的对象是地址为0x8000000000000000的对象。

这是一个非常奇怪的地址。出事了。

另一个提示是_NSCoreDataTaggedObjectID奇怪的类名。我们可以对此进行一些观察:

  • 带前缀的_NS表明它是 CoreData 的内部实现细节。
  • 我们在谷歌上搜索名称以查找 CoreData 框架的类转储,它向我们展示了:
    • _NSCoreDataTaggedObjectID子类_NSScalarObjectID
    • 哪些子类_NSCoreManagedObjectID
    • 哪些子类NSManagedObjectID
    • NSManagedObjectID是一个公共 API,它有自己的第一方文档。
  • 它的名字中有"标记"一词,在目标C世界中具有特殊含义。

一些背景故事

Objective C 使用消息传递作为其方法调度的唯一机制(与 Swift 不同,Swift 通常更喜欢静态和对表调度,具体取决于上下文)。您编写的每个方法调用本质上都是objc_msgSend(及其变体)之上的语法糖,将接收器对象、选择器(被调用方法的"名称")和参数传递给它。这是一个特殊的函数,它将完成检查接收器对象的类的工作,并查看该类的层次结构,直到找到所需选择器的方法实现。

这很棒,因为它允许您执行许多很酷的运行时动态行为。例如,macOS 应用程序上的菜单栏项将仅定义它们调用的方法名称。单击它们会将该消息"发送到响应器链",响应器链将在具有该消息实现的第一个对象上调用该方法(术语是"响应该消息的第一个对象")。

这非常有效,但有几个权衡。其中之一是一切都必须是物体。对象是指堆分配的内存区域,其内存的前几个字存储对象的元数据。此元数据将包含一个指向对象类的指针,这是我刚才描述的在objc_msgSend中执行方法循环过程所必需的。

问题是,对于小对象(特别是 NSNumber 值、小字符串、空数组等),这几个字的对象元数据的开销可能比你感兴趣的实际对象数据大几倍。 例如,即使NSNumber(value: true /* or false */)存储一位"有用"数据,在 64 位系统上也会有 128 位的对象开销。再加上与处理大量小对象相关的所有malloc/free和保留/释放开销,您会遇到真正的性能问题。

"标记指针"是此问题的解决方案。这个想法是,对于特定特权类的足够小的值,我们不会为其对象分配堆内存。相反,我们将直接将它们对象的数据存储在它们的指针表示形式中。当然,我们需要一种方法来知道给定的指针是真正的指针(指向真正的堆分配对象),还是内联编码数据的"假指针"。

malloc 只返回与 16 字节边界对齐的内存的关键实现。这意味着每个内存地址的 4 位始终0(如果不是,那么它就不会是 16 字节对齐的)。这些"未使用"的 4 位可用于区分真实指针和标记指针。确切地说,使用哪些位以及进程体系结构和运行时版本之间的差异,但总体思路是相同的。

如果指针值0000这 4 位,那么系统就会知道它是一个指向真正的堆分配对象的真实对象指针。这些 4 位值的所有其他可能值都可用于指示剩余位中存储的数据类型。Objective C 运行时实际上是开源的,因此您实际上可以看到标记的指针类及其标记:

{
// 60-bit payloads
OBJC_TAG_NSAtom            = 0, 
OBJC_TAG_1                 = 1, 
OBJC_TAG_NSString          = 2, 
OBJC_TAG_NSNumber          = 3, 
OBJC_TAG_NSIndexPath       = 4, 
OBJC_TAG_NSManagedObjectID = 5, 
OBJC_TAG_NSDate            = 6,
// 60-bit reserved
OBJC_TAG_RESERVED_7        = 7, 
// 52-bit payloads
OBJC_TAG_Photos_1          = 8,
OBJC_TAG_Photos_2          = 9,
OBJC_TAG_Photos_3          = 10,
OBJC_TAG_Photos_4          = 11,
OBJC_TAG_XPC_1             = 12,
OBJC_TAG_XPC_2             = 13,
OBJC_TAG_XPC_3             = 14,
OBJC_TAG_XPC_4             = 15,
OBJC_TAG_NSColor           = 16,
OBJC_TAG_UIColor           = 17,
OBJC_TAG_CGColor           = 18,
OBJC_TAG_NSIndexSet        = 19,
OBJC_TAG_NSMethodSignature = 20,
OBJC_TAG_UTTypeRecord      = 21,
// When using the split tagged pointer representation
// (OBJC_SPLIT_TAGGED_POINTERS), this is the first tag where
// the tag and payload are unobfuscated. All tags from here to
// OBJC_TAG_Last52BitPayload are unobfuscated. The shared cache
// builder is able to construct these as long as the low bit is
// not set (i.e. even-numbered tags).
OBJC_TAG_FirstUnobfuscatedSplitTag = 136, // 128 + 8, first ext tag with high bit set
OBJC_TAG_Constant_CFString = 136,
OBJC_TAG_First60BitPayload = 0, 
OBJC_TAG_Last60BitPayload  = 6, 
OBJC_TAG_First52BitPayload = 8, 
OBJC_TAG_Last52BitPayload  = 263,
OBJC_TAG_RESERVED_264      = 264

您可以看到,字符串、索引路径、日期和其他类似的"小而多"类都有保留的指针标记值。对于这些"普通类"(NSStringNSDateNSNumber等),都有一个特殊的内部子类,它实现所有相同的公共 API,但使用标记指针而不是常规对象。

如您所见,OBJC_TAG_NSManagedObjectID有一个值。事实证明,NSManagedObjectID对象数量众多且足够小,因此它们将极大地受益于这种标记指针表示。毕竟,NSManagedObjectID的值可能是单个整数,就像 NSNumber 一样,堆分配会很浪费。

如果你想了解更多关于标记指针的信息,我推荐Mike Ash的著作,比如 https://www.mikeash.com/pyblog/friday-qa-2012-07-27-lets-build-tagged-pointers.html

最近还有一个关于这个主题的WWDC演讲:WWDC 2020 - Objective-C运行时的进步

The strange address

所以在上一节中我们发现_NSCoreDataTaggedObjectIDNSManagedObjectID的标记指针子类。现在我们可以注意到其他奇怪的事情,我们看到的指针值有很多零:0x8000000000000000.因此,我们正在处理的可能是对象的某种未初始化状态。

结论

调用堆栈可以进一步阐明这种情况的确切位置,但我们知道的是,在程序中的某个地方,objectForKey:方法正在未初始化的值NSManagedObjectID上调用。

您可能在正确初始化之前过早地访问了某个值。

要解决此问题,您可以采用以下几种方法之一:

  1. 在未来的理想世界中,使用将只使用 Swift 5.5 的结构化并发性(一旦在足够多的设备上可用)并 async/await 在后台推送工作并等待结果。
  2. 仅使用完成处理程序在值准备就绪后调用使用值的代码。这是最直接的,但会用完成处理程序样板和错误来破坏你的代码库。
  3. 使用并发抽象库,如 Combine、RxSwift 或 PromiseKit。这将需要更多的设置工作,但通常会导致比在任何地方抛出完成处理程序更清晰/更安全的代码。

异步访问它的一种方法:

typealias Dict = [Double : [Date:[String:Any]]]
var myDict = Dict()
func getMyDict(f: @escaping (Dict) -> ()) {
queue.async {
DispatchQueue.main.async {
f(myDict)
}
}
}
getMyDict { dict in
assert(Thread.isMainThread)
}

假设queue可能会安排长期关闭。

它是如何工作的?

您只能从queue中访问myDict。在上面的函数中,将在此队列上访问myDict,并将其副本导入到主队列中。在 UI 中显示myDict的副本时,可以同时更改原始myDictDictionary上的"写入时复制"语义可确保副本便宜。

您可以从任何线程调用getMyDict,它将始终调用主线程上的闭包(在此实现中)。

警告:

getMyDict是一个异步函数。现在这根本不应该是一个警告,但我只想强调这个;)

选择:

  • 快速结合。使 myDict 成为来自某个发布者的已发布值,该发布器实现了您的逻辑。

  • 稍后,您也可以考虑在可用时使用 Async & Await。

实现线程安全的基本模式是永远不要同时从多个线程更改/访问相同的属性。最简单的解决方案是永远不要让任何后台队列直接与您的属互。因此,创建一个后台队列将使用的局部变量,然后将属性的更新调度到主队列。

就个人而言,我根本不需要setupmyDict交互,而是通过完成处理程序返回结果,例如

// properties
var myDict = ...
private let queue = DispatchQueue(label: "my-queue", qos: .utility) // some background queue on which we'll recalculate what will eventually be used to update `myProperty`
// method doesn't reference `myDict` at all, but uses local var only
func setup(completion: @escaping (Foo) -> Void) {
queue.async {
var results = ...                                           // some local variable that we'll use as we're building up our results
// do time-consuming population of `results` here;
// do not touch `myDict` here, though;
// when all done, dispatch update of `myDict` back to the main queue
DispatchQueue.main.async {                                  // dispatch update of property back to the main queue
completion(results)
}
}
}

然后,调用setup的例程可以更新属性(并触发必要的 UI 更新)。

setup { results in
self.myDict = results
// also trigger UI update here, too
}

(请注意,您的闭包参数类型(在我的示例中Foo)将是myDict的任何类型。也许是其他地方建议的类型别名,或者更好的是,使用自定义类型而不是字典中的字典中的字典。使用您喜欢的任何类型。


顺便说一下,你的问题的标题和序言谈到了 TSAN 和线程安全,但你随后分享了一个"无法识别的选择器"异常,这是一个完全不同的问题。因此,您很可能有两个完全不同的问题。TSAN 数据争用错误会产生非常不同的消息。(类似于我在这里显示的错误。现在,如果setup从后台线程myDict变异,这无疑会导致线程安全问题,但您报告的异常表明可能还存在其他问题......

最新更新