如何使用 actor 允许并行读取,但阻止对 Swift 中的资源进行并发读取和写入?

How to use actors to allow parallel reads but block concurrent reads and writes to a resource in Swift?

提问人:Parth 提问时间:11/10/2023 更新时间:11/11/2023 访问量:123

问:

因此,我使用并发调度队列和屏障标志来支持并行读取,但在写入时阻止读取/写入:

struct SafeDict<Element> {
    private var dict = [String: Element]()
    private var queue = DispatchQueue(label: "WriteQueue", attributes: .concurrent)
    
    func get(_ key: String) -> Element? {
        queue.sync { return dict[key] }
    }
    
    mutating func set(_ key: String, value: Element) {
        queue.sync(flags: .barrier) { dict[key] = value }
    }
}


var safeDict = SafeDict<Int>()
for i in 1...4 {
    DispatchQueue.global().async {
        switch i {
        case 1:
            safeDict.get("one")
        case 2:
            safeDict.set("one", value: 1) // Waits for get (good)
        case 3:
            safeDict.get("one") // Runs after set in parallel
        case 4:
            safeDict.get("two") // Runs after set in parallel
        default:
            print("done")
        }
    }
}

但是,由于 actor 函数是异步的,因此并行读取将相互等待。如何才能干净利落地避免这种情况?

我能想到的一种选择是不使用actor,而是使用2个异步方法(get set)。如果不是 nil,get 将等待设定的任务。但这似乎太乏味了。

actor SafeDictActor<Element> {
    private var dict = [String: Element]()
    
    func get(_ key: String) -> Element? { dict[key] }
    
    func set(_ key: String, value: Element) { dict[key] = value }

}

let safeDictActor = SafeDictActor<Int>()
for i in 1...4 {
    Task {
        switch i {
        case 1:
            await safeDictActor.get("one")
        case 2:
            await safeDictActor.set("one", value: 1) // Waits for get (good)
        case 3:
            await safeDictActor.get("one") // waits for set (good)
        case 4:
            await safeDictActor.get("two") // waits for previous get (bad)
        default:
            print("done")
        }
    }
}
iOS Swift async-await 线程安全

评论

0赞 Paulw11 11/10/2023
我的第一个想法是质疑,由于对演员的序列化而导致的访问内存词典的延迟是否会那么严重。我的第二个想法是问这本词典的目的——一本合适吗?NSCache
0赞 Parth 11/10/2023
如果同时收到 1000 个读取请求,延迟会不会变得很大?这只是一个思想实验。NSCache 可能是一个更好的方法,我还没有检查过。
0赞 Parth 11/10/2023
啊,有道理。我怀疑可能是这种情况。顺便说一句,我没有收到有关使用.值类型的缺点是什么?Dict 是一种值类型,所以我认为它的包装器也应该是一种值类型。@unchecked Sendable
0赞 Parth 11/11/2023
哦,是的,这确实很有意义,为什么它不应该是一种价值类型!我想你的意思是因为我正在改变一个属性,我需要将其标记为?但是,队列不可发送会导致任何问题吗?struct SafeDict<Element>: @unchecked Sendable
1赞 Rob 11/11/2023
啊,对不起,明白了。是的,如果你将某物标记为 ,编译器将应用一系列规则来确认它是否真的是。它只能使用 Swift 并发代码,而不是手动 GCD 代码,不能使用锁等。它告诉编译器,“嘿,别担心;我个人已经确保它确实是线程安全的。所以,不要担心,在没有 .只要确保如果你与GCD(或其他什么)一起使用,它确实是线程安全的(它在这里)。Sendable@unchecked Sendable@unchecked@unchecked Sendable

答:

1赞 Rob 11/11/2023 #1

正如 Paulw11 在最近一个问题的评论中提到的,如何用快速并发解决读/写器问题? (您在问题中注意到),actor 使用一种基本的同步机制,该机制本质上与读写器模式不兼容。Actor基于一种简单(但优雅)的机制,该机制通过避免对共享状态的任何并行访问来防止竞争,但读取器-写入器是基于允许并行读取的模式。读写器并行读取的想法与 actor 的基本机制完全不兼容。


话虽如此,我还要补充几点意见:

  1. 如果你真的想要 reader-writer(我不确定你为什么要这样做;参见下面的第 3 点),你可以坚持使用 GCD 来处理该对象。是的,我们通常避免将 GCD 与 Swift 并发代码库混合在一起,但这是可以做到的(特别是如果划分为单一类型,而不是在单个类型中混合不同的技术堆栈)。除非绝对必要,否则我一般不会建议这样做,但这是可以做到的。

    但是,如果将其与 Swift 并发代码库集成,您可以考虑将此类型与 .Swift 并发性采用代码线程安全的编译时验证(尤其是将“严格并发”构建设置设置为“完成”;实际上是我们将在 Swift 6 中享受的那种检查的预览)。有效地让编译器知道您正在保证此对象的线程安全。它可以让您的对象在 Swift 并发中很好地运行。@unchecked Sendable@unchecked Sendable

    有关 Sendable 的更多信息,WWDC 2022 的视频 Eliminate data races using Swift Concurrency 是该主题的入门读物。

  2. 我注意到您的读写器实现正在使用值类型: 但是值类型的整个想法是提供一种简单的线程安全机制,通过该机制,我们为每个线程提供自己的对象副本,从而消除潜在的竞争。但是,读取器-写入器的想法是允许从多个线程对同一对象进行线程安全共享访问。现在,您可以有一个读取器-写入器值类型,但有点奇怪地说,您既希望线程安全访问以跨线程共享对象,又希望为每个线程提供自己的副本。在某些情况下,您可能会这样做,但许多读写用例需要共享的可变状态,这需要引用语义。(顺便说一句,您考虑的 actor 实现也使用引用语义。

  3. 我理解读者-作者模式的直观吸引力。但根据我的经验,基于 GCD 的实现通常无法实现其承诺。在我所有的基准测试中,(a) 它的性能仅比 GCD 串行队列高一点;(b) 它通常比简单的锁慢得多;(c)它引入的问题往往多于解决的问题。例如,如果按照您的设想,您有 1000 次读取,则存在更深层次的线程爆炸问题,而读取器-写入器只会使该问题复杂化。根据我的经验,GCD 的开销可能会开始超过并行执行的潜在好处。我曾徒劳地试图构建现实的场景,在这些场景中,读者-作者比简单的不公平锁更快。我建议对你的特定用例进行基准测试,并避免假设读写器会更快。

    总而言之,Actor对于高级线程的安全性/完整性非常有用。在性能至关重要的情况下(例如,Knuth 设想的 3% 场景,性能确实至关重要,例如计算密集型并行化算法),我个人跳过读写器并跳到性能更高的内容(例如,不公平的锁)。但对于大多数实际用例来说,actor 绰绰有余。

  4. 你说:

    但是,由于 actor 函数是异步的,因此并行读取将相互等待。如何才能干净利落地避免这种情况?

    我能想到的一种选择是不使用actor,而是使用2个异步方法(get set)。如果不是 nil,get 将等待设定的任务。但这似乎太乏味了。

    actor SafeDictActor<Element> {
        private var dict = [String: Element]()
        
        func get(_ key: String) -> Element? { dict[key] }
        
        func set(_ key: String, value: Element) { dict[key] = value }
    }
    

    我要撇开你建议的“不使用actor”,然后向我们展示了一个actor实现。我猜你的意思是“使用演员”。我还打算抛开 not are 的建议,因为它们不是。 (是的,当你调用它们时需要它们,但它们是 actor 的同步函数。getsetasyncawait

    但是,演员确实优雅地为我们同步了这一点。不需要 GCD 队列或锁。只需简单地让它成为,你就完成了。但你是对的,两个调用不能并行运行。执行组件将一次执行一个函数。这就是它确保螺纹安全的方式。actorget

    对于绝大多数情况,这种简单的执行组件实现就足够了。但是,对于关键的 3% 的情况,性能确实是最重要的问题,只有这样,我们才会考虑其他模式。