调度队列:为什么串行比并发更快完成?

4

我有一个单元测试组,旨在证明并发执行多个重任务比串行执行更快。

现在...在所有人都因为上面的说法不总是正确而失去理智之前,因为多线程存在许多不确定性,请允许我解释一下。

从阅读苹果文档可以得知,当请求多个线程时,您无法保证获得多个线程。操作系统(iOS)会根据自己的判断分配线程。例如,如果设备只有一个核心,则会分配一个核心,并且由于并发操作的初始化代码需要一些额外的时间,而且不会提供性能提升,因此串行将稍微快一些。

然而,这种差异应该只是很小的。 但是,在我的 POC 设置中,差异非常大。 在我的 POC 中,并发需要比串行慢约三分之一的时间才能完成。

如果串行完成需要6秒,并发将需要9秒才能完成。
即使负载更重,这种趋势也会持续。 如果串行完成需要125秒,并发将需要215秒才能完成。 这种情况不仅发生一次,而且每次都如此。

我想知道在创建这个 POC 时是否犯了错误,如果是这样,我应该如何证明并发执行多个重任务确实比串行更快呢?

我的 swift 单元测试 POC:

func performHeavyTask(_ completion: (() -> Void)?) {
    var counter = 0
    while counter < 50000 {
        print(counter)
        counter = counter.advanced(by: 1)
    }
    completion?()
}

// MARK: - Serial
func testSerial () {
    let start = DispatchTime.now()
    let _ = DispatchQueue.global(qos: .userInitiated)
    let mainDPG = DispatchGroup()
    mainDPG.enter()
    DispatchQueue.global(qos: .userInitiated).async {[weak self] in
        guard let self = self else { return }
        for _ in 0...10 {
            self.performHeavyTask(nil)
        }
        mainDPG.leave()
    }
    mainDPG.wait()
    let end = DispatchTime.now()
    let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds // <<<<< Difference in nano seconds (UInt64)
    print("NanoTime: \(nanoTime / 1_000_000_000)")
}

// MARK: - Concurrent
func testConcurrent() {
    let start = DispatchTime.now()
    let _ = DispatchQueue.global(qos: .userInitiated)
    let mainDPG = DispatchGroup()
    mainDPG.enter()
    DispatchQueue.global(qos: .userInitiated).async {
        let dispatchGroup = DispatchGroup()
        let _ = DispatchQueue.global(qos: .userInitiated)
        DispatchQueue.concurrentPerform(iterations: 10) { index in
            dispatchGroup.enter()
            self.performHeavyTask({
                dispatchGroup.leave()
            })
        }
        dispatchGroup.wait()
        mainDPG.leave()
    }
    mainDPG.wait()
    let end = DispatchTime.now()
    let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds // <<<<< Difference in nano seconds (UInt64)
    print("NanoTime: \(nanoTime / 1_000_000_000)")
}

细节:

操作系统:macOS High Sierra
型号名称:MacBook Pro
型号标识符:MacBookPro11,4
处理器名称:Intel Core i7
处理器速度:2.2 GHz
处理器数量:1
总核心数:4

两个测试都是在iPhone XS Max模拟器上完成的。两个测试都是在整个Mac重启后立即进行的(为了避免Mac忙于运行其他应用程序而不是运行此单元测试,从而使结果模糊)

此外,两个单元测试均包装在异步DispatcherWorkItem中,因为测试用例是为了防止主(UI)队列被阻塞,以防串行测试用例在这方面具有优势,因为它消耗主队列而不是像并发测试用例一样使用后台队列。

我还将接受一种可靠测试此功能的POC的答案。它不必始终显示并发比串行更快(请参阅上述解释)。但至少有时候是这样的。


2
抱歉,但是你的测试没有任何意义:
  1. testSerial 不是串行的,它是异步执行的 - DispatchQueue.global(qos: .userInitiated).async
  2. 如果目标只是为了测试串行与并发,那么任务应该是相同的,唯一不同的是你如何将它添加到队列中 - sync vs. async
  3. 你在 testConcurrent 中做了一些非常奇怪的事情。
- mag_zbc
2
@mag_zbc 同时,两个单元测试都包装在异步的DispatcherWorkItem中,因为测试用例是针对主(UI)队列而不是阻塞的,防止串行测试用例在该部分具有优势,因为它使用主队列而不是与并发测试用例相同的后台队列。此外,并发测试用例中的2个DispatchGroup是为了确保单元测试不会立即完成。 - ErikBrandsma
5
当并行任务不竞争共同资源时,它们比串行任务具有更高的性能。在您的performHeavyTask()函数中调用print()可能会引起这种冲突,并且很可能是导致您出现意外结果的原因。 - Alain T.
请参考以下链接:https://stackoverflow.com/questions/39948082/long-cycle-blocks-application/39949292#39949292。或者https://stackoverflow.com/a/46499306/1271826。或者https://stackoverflow.com/questions/22847511/ios-concurrency-not-reaching-anywheres-near-theoretical-maximum/22850936#22850936。 - Rob
4
我同意的原因是print()函数,这才是真正的瓶颈,所有速度都由最慢的那个函数决定。如果将print函数移除并将数字增加到500000,再次测量,你会得到真正的差异:并发大约是串行的1/10。 - E.Coms
显示剩余4条评论
1个回答

7
有两个问题:
  1. 我建议避免在循环内部使用 print。这是同步的,你可能会在并发实现中遇到更大的性能降低。这并不是整个故事,但它并没有帮助。

  2. 即使从循环中删除了 print,对计数器进行50,000次递增还不足以看到 concurrentPerform 的好处。正如《改进循环代码》所说:

    ……虽然这种方法 [concurrentPerform] 可以改善基于循环的代码的性能,但您仍必须明智地使用此技术。尽管调度队列的开销非常低,但每个循环迭代在线程上调度仍然有成本。因此,您应确保您的循环代码执行足够的工作来证明这些成本是值得的。您需要使用性能工具来测量需要执行多少工作。

    在调试版本中,我需要将迭代次数增加到接近5,000,000的值,才能克服这种开销。而在发布版本中,即使如此也不足够。旋转循环并递增计数器太快了,无法提供有意义的并发行为分析。

    因此,在下面的示例中,我使用了一个更加计算密集的计算(使用历史悠久但效率不高的算法来计算π)代替了这个旋转循环。

顺便说一下:

  1. 如果你想要测量性能,可以在XCTestCase单元测试中使用measure来进行基准测试。这会多次重复基准测试,捕获经过的时间,平均结果等等。只需确保编辑方案,以便测试操作使用优化的“发布”构建而不是“调试”构建。

  2. 如果您将其分派到全局队列,但又使用了dispatch group让调用线程等待它完成,那么这样做没有意义。

  3. 您也不需要使用dispatch groups来等待concurrentPerform完成,因为它会同步运行。

    正如concurrentPerform 文档所说:

    调度队列执行提交的块指定的次数,并在返回之前等待所有迭代完成。

  4. 虽然这并不重要,但值得注意的是,您的for _ in 0...10 { ... }会执行11次迭代,而不是10次。显然,您想使用..<

因此,这是一个示例,将其放入单元测试中,但用更具计算性的东西替换了“重”计算:

class MyAppTests: XCTestCase {

    // calculate pi using Gregory-Leibniz series
    
    func calculatePi(iterations: Int) -> Double {
        var result = 0.0
        var sign = 1.0
        for i in 0 ..< iterations {
            result += sign / Double(i * 2 + 1)
            sign *= -1
        }
        return result * 4
    }
    
    func performHeavyTask(iteration: Int) {
        let pi = calculatePi(iterations: 100_000_000)

        print(iteration, .pi - pi)
    }
    
    func testSerial() {
        measure {
            for i in 0..<10 {
                self.performHeavyTask(iteration: i)
            }
        }
    }
    
    func testConcurrent() {
        measure {
            DispatchQueue.concurrentPerform(iterations: 10) { i in
                self.performHeavyTask(iteration: i)
            }
        }
    }
}

在我的MacBook Pro 2018上,搭载2.9 GHz英特尔Core i9处理器,在发布版本的情况下,同时测试平均需要0.247秒,而串行测试则需要大约四倍的时间,即1.030秒。


2
阅读了您的回答后,我感觉自己有点傻,非常感谢您向我解释我犯了哪些(很多)错误。我尽力通过完全阅读苹果文档和一些教程来充分准备Swift多线程,但似乎这是软件开发中非常困难的话题,或者至少对我来说是这样。我会尝试您的POC,如果它有效,我会接受它作为答案,并祝贺您获得了300k,看起来您真的很努力! - ErikBrandsma
3
你真的不应该感到难过。并发本来就很复杂,而苹果基于Swift的GCD文档还有很多需要改善的地方! - Rob
你可能会发现,在发布版本中,编译器将循环优化为单个赋值操作! - Hector

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接