[iOS翻译]iOS中的并发和多线程

521 阅读14分钟

原文地址:

原文作者:

发布时间:

并发和多线程是iOS开发的核心部分。让我们深入了解是什么让它们如此强大,以及我们如何在自己的Cocoa Touch应用程序中利用它们。

并发是指同时发生多件事情的概念。这一般是通过时间分割来实现的,如果主机操作系统有多个CPU核,则可以真正实现并行。我们都有过缺乏并发性的经历,很可能是在运行繁重的任务时,应用程序冻结的形式。UI冻结不一定是由于缺乏并发性--它们可能只是软件错误的症状--但只要软件需要做一些资源密集型的事情,不利用其可支配的所有计算能力,就会产生这些冻结。如果你剖析过一个应用以这种方式挂起,你可能会看到这样的报告。

image.png

任何与文件I/O、数据处理或网络有关的事情通常都需要后台任务(除非你有一个非常令人信服的借口来停止整个程序)。这些任务应该阻止你的用户与你的应用程序的其他部分进行交互的理由并不多。考虑一下,如果相反,剖析器报告这样的东西,你的应用程序的用户体验可能会好得多。

image.png

分析一幅图像,处理一个文档或一段音频,或者将一大块数据写入磁盘,这些任务都可以从委托给后台线程中大大受益。让我们来研究一下如何在iOS应用中强制执行这种行为。


简史

在过去的日子里,计算机每个CPU周期所能完成的最大工作量是由时钟速度决定的。随着处理器设计变得更加紧凑,热量和物理限制开始成为限制更高时钟速度的因素。因此,芯片制造商开始在每个芯片上增加额外的处理器内核,以提高总性能。通过增加核心数量,单个芯片可以在不增加速度、尺寸或热输出的情况下,每个周期执行更多的CPU指令。只是有一个问题...

我们如何利用这些额外的核心?多线程。

多线程是一种由主机操作系统处理的实现,允许创建和使用n个线程的数量。它的主要目的是提供同时执行一个程序的两个或多个部分,以利用所有可用的CPU时间。多线程是程序员工具箱中一个强大的技术,但它也有自己的责任。一个常见的误解是,多线程需要多核处理器,但事实并非如此--单核CPU完全可以在许多线程上工作,但我们将在一下看看为什么线程首先是一个问题。在我们深入研究之前,我们先用一个简单的图来看看什么是并发和并行的细微差别。

image.png

在上面介绍的第一种情况下,我们观察到任务可以并发运行,但不能并行。这类似于在聊天室里有多个对话,并且在它们之间进行交错(上下文切换),但从来没有真正同时与两个人对话。这就是我们所说的并发性。它是多件事情同时发生的假象,而实际上,它们的切换速度非常快。并发就是要同时处理很多事情。与并行模式形成鲜明对比的是,两个任务同时运行。这两种执行模型都表现出多线程,即多个线程参与,为一个共同的目标而努力。多线程是一种将并发性和并行性结合起来引入程序的通用技术。


线程的负担

像iOS这样的现代多任务操作系统在任何特定时刻都有数百个程序(或进程)在运行。然而,这些程序大多是系统守护进程或后台进程,它们的内存占用率非常低,因此真正需要的是一种方法,让各个应用程序利用额外的可用核心。一个应用程序(进程)可以有许多线程(子进程)在共享内存上运行。我们的目标是能够控制这些线程,并利用它们来发挥我们的优势。

从历史上看,将并发性引入应用程序需要创建一个或多个线程。线程是需要手动管理的低级结构。快速浏览一下Apple的《线程编程指南》,就能看出线程代码给代码库增加了多少复杂性。除了构建一个应用,开发者还得。

  • 负责任地创建新的线程,随着系统条件的变化动态调整线程数量
  • 仔细管理它们,一旦它们完成执行,就从内存中重新分配。
  • 利用同步机制(如mutexes、锁和semaphores)来协调线程之间的资源访问,从而为应用程序代码增加更多的开销。
  • 减少与应用程序编码相关的风险,该应用程序承担了与创建和维护其使用的任何线程相关的大部分成本,而不是主机操作系统。

这是很不幸的,因为它增加了巨大的复杂性和风险,却不能保证性能的提高。


Grand Central Dispatch

iOS采用异步的方式来解决管理线程的并发问题。异步函数在大多数编程环境中都很常见,经常用来启动一些可能需要很长时间的任务,比如从磁盘上读取一个文件,或者从网络上下载一个文件。当调用异步函数时,异步函数会在幕后执行一些工作来启动后台任务,但会立即返回,而不管原来的任务可能需要多长时间才能实际完成。

iOS提供的异步启动任务的核心技术是Grand Central Dispatch(简称GCD)。GCD将线程管理代码抽象出来,并将其下移到系统层面,暴露出一个轻量级的API,用于定义任务并在适当的调度队列上执行。GCD负责所有的线程管理和调度,提供了一个整体的任务管理和执行方法,同时也提供了比传统线程更好的效率。

我们来看看GCD的主要组件。

image.png

都有哪些内容?我们从左边开始。

  • DispatchQueue.main 主线程,也就是UI线程,由一个串行队列支持。所有的任务都是连续执行的,所以要保证执行顺序的保留。确保所有的UI更新都指定给这个队列,并且永远不要在这个队列上运行任何阻塞任务,这一点至关重要。我们要确保应用程序的运行循环(称为CFRunLoop)永远不会被阻塞,以保持最高帧率。随后,主队列具有最高优先级,任何推送到这个队列上的任务都会立即得到执行。
  • DispatchQueue.global:一组全局并发队列,每个队列管理自己的线程池。根据你的任务的优先级,你可以指定在哪个特定的队列上执行你的任务,尽管你应该在大多数时候求助于使用默认。因为这些队列上的任务是并发执行的,所以并不能保证保留任务排队的顺序。

注意到我们不再处理单个线程了吗?我们正在处理的是内部管理线程池的队列,你很快就会明白为什么队列是一种更可持续的多线程方法。

串行队列。串行队列:主线程

作为一个练习,让我们看看下面的一段代码,当用户按下应用中的按钮时,这段代码就会被启动。这个昂贵的计算函数可以是任何东西。让我们假设它正在对存储在设备上的图像进行后处理。

import UIKit

class ViewController: UIViewController {
    @IBAction func handleTap(_ sender: Any) {
        compute()
    }

    private func compute() -> Void {
        // Pretending to post-process a large image.
        var counter = 0
        for _ in 0..<9999999 {
            counter += 1
        }
    }
}

乍一看,这可能看起来无伤大雅,但如果你在一个真实的应用程序中运行这个任务,UI会完全冻结,直到循环被终止,这需要......一段时间。我们可以通过在Instruments中对这个任务进行剖析来证明这一点。你可以通过进入Xcode菜单选项中的Xcode > Open Developer Tool > Instruments来启动Instruments的Time Profiler模块。让我们看看剖析器的线程模块,看看哪里的CPU使用率最高。

image.png

我们可以看到,主线程在近5秒内明显处于100%的容量。这对于阻塞UI来说是一个非同小可的时间。观察图表下方的调用树,我们可以看到主线程在99.9%的容量下工作了4.43秒! 鉴于串行队列以FIFO的方式工作,任务总是会按照插入的顺序完成。显然,compute()方法是这里的罪魁祸首。你能想象点击一个按钮却让UI冻结了那么久吗?

后台线程

如何才能让这一切变得更好?DispatchQueue.global()来拯救你! 这就是背景线程的作用。参考上面的GCD架构图,我们可以看到,在iOS中,任何不是主线程的东西都是背景线程。它们可以和主线程一起运行,让主线程完全闲置,随时可以处理其他UI事件,比如滚动、响应用户事件、动画等。让我们对上面的按钮点击处理程序做一个小小的改动。

class ViewController: UIViewController {
    @IBAction func handleTap(_ sender: Any) {
        DispatchQueue.global(qos: .userInitiated).async { [unowned self] in
            self.compute()
        }
    }

    private func compute() -> Void {
        // Pretending to post-process a large image.
        var counter = 0
        for _ in 0..<9999999 {
            counter += 1
        }
    }
}

除非指定,否则一段代码通常会默认在主队列上执行,所以为了迫使它在不同的线程上执行,我们将把我们的计算调用包在一个异步闭包里面,这个闭包会被提交到DispatchQueue.global队列中。请记住,我们在这里并不是真的在管理线程。我们是将任务(以闭包或块的形式)提交到所需的队列中,并假设它在某个时间点被保证执行。队列决定将任务分配给哪个线程,它完成了评估系统需求和管理实际线程的所有艰苦工作。这就是Grand Central Dispatch的神奇之处。正如那句老话所说,你无法改进你无法测量的东西。所以我们测量了我们真正糟糕的按钮点击处理程序,现在我们已经改进了它,我们将再次测量它,以获得一些关于性能的具体数据。

image.png

再看一下剖析器,我们很清楚,这是一个巨大的进步。这个任务需要相同的时间,但这次是在后台进行的,没有锁定UI。尽管我们的应用在做同样的工作量,但感知到的性能要好得多,因为在应用处理时,用户将可以自由地做其他事情。

你可能已经注意到,我们访问了一个.userInitiated优先级的全局队列。这是一个属性,我们可以用它来给我们的任务一种紧迫感。如果我们在全局队列上运行同样的任务,并给它传递一个qos属性为background ,iOS会认为这是一个实用任务,从而分配较少的资源来执行它。所以,虽然我们无法控制任务何时被执行,但我们可以控制它们的优先级。

关于主线程与主队列的说明

你可能会好奇为什么Profiler会显示 "主线程",为什么我们要把它称为 "主队列"。如果你参考我们上面介绍的GCD架构,主队列只负责管理主线程。《并发编程指南》中的调度队列部分说:"主调度队列是一个全局可用的串行队列,它在应用程序的主线程上执行任务。因为它运行在应用程序的主线程上,主队列经常被用作应用程序的关键同步点。"

术语 "在主线程上执行 "和 "在主队列上执行 "可以互换使用。


并发队列

到目前为止,我们的任务完全是以串行方式执行的。DispatchQueue.main默认是一个串行队列,而DispatchQueue.global则根据你传入的优先级参数,为你提供了四个并发的调度队列。

假设我们想拍摄五张图片,并让我们的应用在后台线程上并行处理它们。我们要怎么做呢?我们可以用我们选择的标识符旋转一个自定义的并发队列,并在那里分配这些任务。所需要的只是在队列的构造过程中的.concurrent属性。

class ViewController: UIViewController {
    let queue = DispatchQueue(label: "com.app.concurrentQueue", attributes: .concurrent)
    let images: [UIImage] = [UIImage].init(repeating: UIImage(), count: 5)

    @IBAction func handleTap(_ sender: Any) {
        for img in images {
            queue.async { [unowned self] in
                self.compute(img)
            }
        }
    }

    private func compute(_ img: UIImage) -> Void {
        // Pretending to post-process a large image.
        var counter = 0
        for _ in 0..<9999999 {
            counter += 1
        }
    }
}

通过剖析器运行,我们可以看到,现在应用程序正在旋转5个离散线程来并行一个for-loop。

image.png

N个任务的并行化

到目前为止,我们已经研究了如何在不阻塞UI线程的情况下,将计算昂贵的任务推送到后台线程上。但是,如果执行有一定限制的并行任务呢?Spotify如何并行下载多首歌曲,同时限制最大数量为3首?我们可以通过一些方法来解决这个问题,但现在是探讨多线程编程中另一个重要结构的好时机:semaphores

Semaphores是一种信号机制。它们通常用于控制对共享资源的访问。想象一下这样的场景:一个线程可以在执行某段代码时锁定对该段代码的访问,并在完成后解锁,让其他线程执行上述部分代码。例如,你会在数据库的写和读中看到这种类型的行为。如果你希望只有一个线程向数据库写入,并防止在此期间进行任何读取,怎么办?这是线程安全中常见的问题,称为读者-写者锁。Semaphores可以用来控制我们应用中的并发性,允许我们锁定n个线程的数量。

let kMaxConcurrent = 3 // Or 1 if you want strictly ordered downloads!
let semaphore = DispatchSemaphore(value: kMaxConcurrent)
let downloadQueue = DispatchQueue(label: "com.app.downloadQueue", attributes: .concurrent)

class ViewController: UIViewController {
    @IBAction func handleTap(_ sender: Any) {
        for i in 0..<15 {
            downloadQueue.async { [unowned self] in
                // Lock shared resource access
                semaphore.wait()

                // Expensive task
                self.download(i + 1)

                // Update the UI on the main thread, always!
                DispatchQueue.main.async {
                    tableView.reloadData()

                    // Release the lock
                    semaphore.signal()
                }
            }
        }
    }

    func download(_ songId: Int) -> Void {
        var counter = 0

        // Simulate semi-random download times.
        for _ in 0..<Int.random(in: 999999...10000000) {
            counter += songId
        }
    }
}

image.png

请注意我们是如何有效地将下载系统限制在k个下载次数内的。当一次下载完成(或线程完成执行)的那一刻,它就会递减旗语,允许管理队列产生另一个线程并开始下载另一首歌曲。在处理并发读写时,你可以将类似的模式应用到数据库事务中。

Semaphores通常不需要像我们例子中的代码,但当你需要在消耗异步API时强制执行同步行为时,它们就会变得更加强大。上面的内容也可以用一个自定义的NSOperationQueue和maxConcurrentOperationCount来实现,但无论如何,这都是一个值得探讨的问题。


使用OperationQueue进行更精细的控制

当你想以 "设置--忘记--"的方式将一次性任务或关闭任务派遣到队列中时,GCD是非常好的,它提供了一种非常轻量级的方式。但如果我们想创建一个可重复的、结构化的、长期运行的任务,并产生相关的状态或数据呢?如果我们想对这一连串的操作进行建模,使它们可以被取消、暂停和跟踪,同时仍然使用一个闭合友好的API呢?想象一下这样的操作。

image.png

这在GCD中是相当麻烦的。我们希望用一种更加模块化的方式来定义一组任务,同时保持可读性,还能暴露更多的控制权。在这种情况下,我们可以使用Operation对象,并将它们排到OperationQueue上,OperationQueue是DispatchQueue的高级封装器。让我们来看看使用这些抽象的一些好处,以及与低级GCI API相比,它们提供了什么。

  • 你可能想在任务之间创建依赖关系,虽然你可以通过GCD来实现,但你最好把它们具体定义为Operation对象或工作单位,并把它们推送到你自己的队列中。这将允许最大限度地重用,因为你可以在应用程序的其他地方使用相同的模式。
  • Operation和OperationQueue类有许多可以观察的属性,使用KVO(键值观察)。如果您想监视操作或操作队列的状态,这是另一个重要的好处。
  • 可以暂停、恢复和取消操作。一旦你使用Grand Central Dispatch调度一个任务,你就不再能控制或洞察该任务的执行情况。Operation API在这方面更加灵活,让开发人员可以控制操作的生命周期。
  • OperationQueue允许你指定可以同时运行的排队操作的最大数量,让你对并发方面的控制更加精细。

Operation和OperationQueue的用法可以写满一整篇博文,但让我们来看一个快速的例子,说明建模依赖是什么样子的。(GCD也可以创建依赖关系,但你最好把大型任务分成一系列可组成的子任务)。为了创建一个相互依赖的操作链,我们可以这样做。

class ViewController: UIViewController {
    var queue = OperationQueue()
    var rawImage = UIImage? = nil
    let imageUrl = URL(string: "https://example.com/portrait.jpg")!
    @IBOutlet weak var imageView: UIImageView!

    let downloadOperation = BlockOperation {
        let image = Downloader.downloadImageWithURL(url: imageUrl)
        OperationQueue.main.async {
            self.rawImage = image
        }
    }

    let filterOperation = BlockOperation {
        let filteredImage = ImgProcessor.addGaussianBlur(self.rawImage)
        OperationQueue.main.async {
            self.imageView = filteredImage
        }
    }

    filterOperation.addDependency(downloadOperation)

    [downloadOperation, filterOperation].forEach {
        queue.addOperation($0)
     }
}

那么为什么不选择更高层次的抽象,完全避免使用GCD呢?虽然GCD是内联异步处理的理想选择,但Operation提供了一个更全面的、面向对象的计算模型,用于封装应用程序中围绕结构化的、可重复的任务的所有数据。开发者应该对任何给定的问题使用尽可能高的抽象层次,对于调度一致的、可重复的工作,这种抽象就是Operation。其他时候,对于我们想要启动的一次性任务或闭包,撒入一些GCD更有意义。我们可以将OperationQueue和GCD混合使用,以获得两全其美的效果。


并发的成本

DispatchQueue和朋友们的目的是让应用开发者更容易并发执行代码。然而,这些技术并不能保证改善应用程序的效率或响应能力。你要以一种既有效又不会对其他资源造成过度负担的方式来使用队列。例如,创建10,000个任务并将它们提交到队列中是完全可行的,但这样做会分配一笔不小的内存,并为操作块的分配和重新分配引入大量的开销。这与你想要的恰恰相反! 最好对你的应用进行彻底的剖析,以确保并发性增强了你的应用的性能,而不是降低了它。

我们已经谈到了并发是如何在复杂性和系统资源分配方面付出代价的,但引入并发也会带来一系列其他风险,比如。

  • 死锁 一个线程锁住了代码的关键部分 并可能使应用程序的运行循环完全停止的情况。在GCD的上下文中,在使用dispatchQueue.sync { }调用时应该非常小心,因为你很容易让自己陷入两个同步操作互相等待的情况。
  • 优先级反转。一个低优先级的任务阻止一个高优先级的任务执行的情况,这实际上是颠倒了它们的优先级。GCD允许后台队列有不同的优先级,所以这种情况很容易发生。
  • 生产者-消费者问题:一个线程在创建数据资源,而另一个线程在访问数据资源的竞赛条件。这是一个同步问题,可以使用锁、信号灯、串行队列来解决,如果你在GCD中使用并发队列,还可以使用屏障调度。
  • ……还有很多其他种类的锁和数据竞赛条件,这些都是很难调试的! 在处理并发的时候,线程安全是最值得关注的。

离别感想+进一步阅读

如果你走到了这一步,我为你鼓掌。希望这篇文章能让你在iOS上的多线程技术方面有一个铺垫,以及如何在你的应用中使用其中的一些技术。我们没有涉及到许多低级别的构造,比如锁、mutexes以及它们如何帮助我们实现同步,也没有深入了解并发性如何伤害你的应用的具体例子。我们会把这些内容留到另一天再讲,但如果你渴望深入了解,你可以挖掘一些额外的阅读和视频。


通过www.DeepL.com/Translator(免费版)翻译