【问题标题】:DispatchQueue: why does serial complete faster than concurrent?DispatchQueue:为什么串行完成比并发快?
【发布时间】:2019-07-05 05:10:33
【问题描述】:

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

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

我从阅读苹果文档中知道,您不能保证在请求多个线程时获得多个线程。操作系统(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)")
}

详情:

操作系统:macOS High Sierra
型号名称:MacBook Pro
型号标识符:MacBookPro11,4
处理器名称:英特尔酷睿 i7
处理器速度:2,2 GHz
处理器数量:1
核心总数:4

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

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

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

【问题讨论】:

  • 对不起,您的测试没有任何意义:1. testSerial 不是串行的,它异步执行 - DispatchQueue.global(qos: .userInitiated).async 2. 如果目标只是测试串行与. 并发,任务应该是相同的,唯一不同的是你如何将它添加到队列中 - syncasync 3. 你在 testConcurrent 中做了一些非常奇怪的事情。
  • @mag_zbc 同样,两个单元测试都包装在异步 DispatcherWorkItem 中,因为测试用例用于不阻塞主(UI)队列,从而防止串行测试用例在该部分具有优势,因为它像并发测试用例那样使用主队列而不是后台队列。此外,并发的 2 个 DispatchGroups 是为了确保单元测试不会立即完成
  • 在不竞争公共资源的情况下,并行任务比串行任务获得更高的性能。在您的 performHeavyTask() 函数中调用 print() 可能会导致这样的冲突,并且很可能是给您这些意外结果的原因。
  • @AlainT。有可能,没想到,什么是合适的 performHeavyTask 函数?
  • 我同意原因是 print() 是这里真正的瓶颈,所有的速度都是由最慢的速度决定的。如果去掉print,将数量增加到500000。再测一下,你就可以知道真正的区别:并发量大约是连续量的1/10。

标签: swift multithreading performance grand-central-dispatch


【解决方案1】:

有两个问题:

  1. 我会避免在循环内执行print。这是同步的,您可能会在并发实现中遇到更大的性能下降。这不是故事的全部,但它没有帮助。

  2. 即使在从循环中删除 print 之后,计数器的 50,000 增量也不足以看到 concurrentPerform 的好处。正如Improving on Loop Code 所说:

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

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

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

顺便说一句:

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

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

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

    虽然concurrentPerform documentation 是“瘦”,但documentation for dispatch_applyconcurrentPerform uses)表示:

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

  4. 这不是很重要,但值得注意的是,您的 for _ in 0...10 { ... } 进行了 11 次迭代,而不是 10 次。您显然打算使用 ..&lt;

因此,这里有一个示例,将其置于单元测试中,但将“繁重”的计算替换为计算量更大的计算:

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

}

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

【讨论】:

  • 阅读您的回答后,我觉得自己像个白痴,很好地向我解释了我在哪里犯了(很多)错误。我尽我所能通过完整阅读苹果文档和阅读一些教程来为 Swift 多线程做好充分准备,但这似乎是软件开发中一个非常困难的话题,或者至少对我来说是这样。我会试试你的 POC,如果它有效,我会接受它作为答案,恭喜你获得 300k,看来你已经赚到了!
  • 你真的不应该感到难过。并发本身已经足够复杂,Apple 的基于 Swift 的 GCD 文档还有很多不足之处!
  • 您可能会发现,对于发布版本,编译器会将循环优化为单个分​​配!
猜你喜欢
  • 1970-01-01
  • 2020-08-30
  • 1970-01-01
  • 2017-05-18
  • 2019-06-28
  • 1970-01-01
  • 2019-11-02
  • 1970-01-01
  • 2011-06-21
相关资源
最近更新 更多