DispatchQueue:为什么串行完成比并发快?

Eri*_*sma 2 performance multithreading grand-central-dispatch swift

我有一个单元测试设置来证明同时执行多个繁重的任务比串行更快。

现在...在此之前的每个人都对上述陈述并不总是正确的事实失去理智之前,让我解释一下。

我从阅读苹果文档中了解到,您不能保证在请求它们时获得多个线程。操作系统 (iOS) 将分配线程,但它认为合适。例如,如果设备只有一个内核,它会分配一个内核,串行会稍微快一些,因为并发操作的初始化代码需要一些额外的时间,而不会提供性能改进,因为设备只有一个内核。

但是:这种差异应该很小。但在我的 POC 设置中,差异很大。在我的 POC 中,并发速度慢了大约 1/3。

如果串行在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)")
}
Run Code Online (Sandbox Code Playgroud)

细节:

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

这两项测试都是在 iPhone XS Max 模拟器上完成的。这两个测试都是在整个 mac 重新启动后直接完成的(以避免 mac 忙于运行此单元测试以外的应用程序,模糊结果)

此外,两个单元测试都包含在异步 DispatcherWorkItem 中,因为测试用例是为了不被阻塞的主(UI)队列,防止串行测试用例在该部分具有优势,因为它消耗主队列而不是后台队列作为并发测试用例确实如此。

我还将接受一个答案,该答案显示 POC 可靠地对此进行了测试。它不必一直显示并发比串行快(阅读上面的解释为什么不快)。但至少有一段时间

Rob*_*Rob 5

有两个问题:

  1. 我会避免print在循环内做。这是同步的,您可能会在并发实现中遇到更大的性能下降。这不是故事的全部,但这无济于事。

  2. 即使print从循环中删除了,计数器的 50,000 增量也不足以看到concurrentPerform. 正如改进循环代码所说:

    ... 虽然这个 [ concurrentPerform] 可以是提高基于循环代码的性能的好方法,但您仍然必须谨慎地使用这种技术。尽管调度队列的开销非常低,但在线程上调度每个循环迭代仍然存在成本。因此,您应该确保您的循环代码做了足够的工作来保证成本。究竟需要做多少工作是必须使用性能工具来衡量的。

    在调试版本中,我需要将迭代次数增加到接近 5,000,000 的值,然后才能克服这个开销。在发布版本中,即使这样还不够。旋转循环和递增计数器太快了,无法对并发行为进行有意义的分析。

    因此,在我下面的示例中,我用计算量更大的计算(使用历史悠久但效率不高的算法计算?)替换了这个旋转循环。

作为旁白:

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

  2. 如果您要使用调度组使调用线程等待它完成,则将其调度到全局队列是没有意义的。

  3. 您也不需要使用调度组来等待concurrentPerform完成。它同步运行。

    虽然concurrentPerform文档是,让我们说,“薄”,文档dispatch_applyconcurrentPerform使用)说:

    此函数将一个块提交到调度队列以进行多次调用,并在返回之前等待任务块的所有迭代完成。

  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)
            }
        }
    }

}
Run Code Online (Sandbox Code Playgroud)

在我的配备 2.9 GHz Intel Core i9 的 MacBook Pro 2018 上,同时测试平均需要 0.247 秒,而串行测试需要大约四倍的时间,即 1.030 秒。

  • 读完你的答案后,我感觉自己像个白痴,向我解释我在哪里犯了(许多)错误,做得很好。我尽最大努力通过充分阅读苹果文档和阅读一些教程来为 Swift 多线程做好充分准备,但这似乎是软件开发中一个非常困难的主题,或者至少对我来说是这样。我会尝试你的 POC,如果它有效,我会接受它作为答案,并恭喜你获得 300k,看来你已经赢得了它! (2认同)
  • 你真的不应该感到难过。并发已经足够复杂了,Apple 的基于 Swift 的 GCD 文档还有很多不足之处! (2认同)