【问题标题】:Understanding Swift thread safety理解 Swift 线程安全
【发布时间】:2021-10-01 19:33:10
【问题描述】:

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

我有一个 var 定义为:

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 objectForKey:]: 无法识别的选择器发送到实例 0x8000000000000000" 0x0000000283df6a60

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

【问题讨论】:

  • 不相关但很重要:“我的 setup() 函数本质上是遍历大量数据并填充 myDict。这可能需要一段时间,这就是我们需要异步执行此操作的原因。”那么您应该绝对不要在.userInteractive 队列上执行此操作。您将完成整个应用程序。
  • @Alexander 不错,应该是.userInitiated,对吧?
  • .userInitiated 的文档说:“阻止用户积极使用您的应用程序的任务的服务质量等级。”所以你必须回答,这项工作会阻止你的应用程序的使用吗?只有你会知道。
  • @Alexander 误读了文档,再次查看时,应该是.utility。它不会阻止用户完全使用该应用程序,因为他们仍然可以执行不相关的活动。来自.utility 的文档:“......您可以将此类分配给用户没有积极关注其进度的长时间运行的任务。”适合我的用例。
  • 我正在写一个答案顺便说一句,坚持住

标签: ios swift multithreading


【解决方案1】:

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

就个人而言,我根本不会让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 数据竞争错误会产生非常不同的消息。 (类似于错误 I show here。)现在,如果 setup 正在从后台线程更改 myDict,这无疑会导致线程安全问题,但您报告的异常表明可能还有其他问题,太……

【讨论】:

    【解决方案2】:

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

    了解错误

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

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

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

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

    一些背景故事

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

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

    这非常有效,但有几个取舍。其中之一是一切都必须是一个对象。所谓对象,我们指的是一个堆分配的内存区域,它的前几个内存字存储了对象的元数据。这个元数据将包含一个指向对象类的指针,正如我刚刚描述的那样,这是在objc_msgSend 中执行方法循环过程所必需的。

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

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

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

    如果一个指针值对于这 4 位有 0000,那么系统就会知道它是一个指向真正堆分配对象的真正对象指针。这些 4 位值的所有其他可能值可用于指示在剩余位中存储了哪种数据。 Objective C 运行时实际上是开源的,所以你实际上可以看到the tagged pointer classes and their tags

    {
        // 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 - Advancements in the Objective-C runtime

    奇怪的地址

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

    结论

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

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

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

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

    【讨论】:

      【解决方案3】:

      一种异步访问方式:

      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。

      【讨论】:

      • 感谢您的回答。您能否解释一下 assert() 行以及它在闭包的其余部分中的作用?
      • 这里,仅用于文档。它确保闭包 f 在主线程上被调用,如你所愿。如果那里没有调用它,程序就会停止。有assert 很好,即使在生产代码中也是如此,因为发布版本会将其完全剥离,并且您的调试版本会提前失败,因此您可以及早修复它。 ;)
      • assert 的替代品是dispatchPrecondition(condition: .onQueue(.main))。见developer.apple.com/videos/play/wwdc2016/720/?time=1264
      • @Rob 是否有可能即使我已经明确定义了队列,但当 CPU 使用率非常高时,它会开始在另一个线程上再次调用 setup() 函数导致数据种族?这个答案主要解决了这个问题。但是,如果我通过多次调用 queue.async 调用 setup() 来推送应用程序,我会遇到同样的崩溃。尽管它需要更多的时间才能崩溃。
      • @user14664032 - 不,如果您编写线程安全代码,则不会受到该队列中项目数量的影响。你的问题在别处。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2015-06-27
      • 1970-01-01
      • 2018-08-16
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多