美文网首页GCD
iOS与多线程(五) —— GCD之一个简单应用示例(一)

iOS与多线程(五) —— GCD之一个简单应用示例(一)

作者: 刀客传奇 | 来源:发表于2018-08-19 17:31 被阅读0次

    版本记录

    版本号 时间
    V1.0 2018.08.19

    前言

    信号量机制是多线程通信中的比较重要的一部分,对于NSOperation可以设置并发数,但是对于GCD就不能设置并发数了,那么就只能靠信号量机制了。接下来这几篇就会详细的说一下并发机制。感兴趣的可以看这几篇文章。
    1. iOS与多线程(一) —— GCD中的信号量及几个重要函数
    2. iOS与多线程(二) —— NSOperation实现多并发之创建任务
    3. iOS与多线程(三) —— NSOperation实现多并发之创建队列和开启线程
    4. iOS与多线程(四) —— NSOperation的串并行和操作依赖

    开始

    首先看一下本文写作环境。

    Swift 4.2, iOS 12, Xcode 10

    GCD是 Apple 开发的一个多核编程的解决方法,简单易用,效率高,速度快。通过 GCD,开发者只需要向队列中添加一段代码块(block或C函数指针),而不需要直接和线程打交道。GCD在后端管理着一个线程池,它不仅决定着你的代码块将在哪个线程被执行,还根据可用的系统资源对这些线程进行管理。这样通过GCD来管理线程,从而解决线程被创建的问题。

    Grand Central Dispatch(GCD)是用于管理并发操作的低级API。它可以通过将计算成本高昂的任务推迟到后台来帮助您提高应用程序的响应能力。它是处理锁和线程的一种更简单的并发模型。

    在本文中,您将了解GCD及其Swifty API的细节。第一部分将解释GCD的作用并展示几个基本的GCD功能。在下一篇中,您将了解GCD提供的一些高级功能。

    您将构建一个名为GooglyPuff的现有应用程序。 GooglyPuff是一个未优化的“线程不安全”应用程序,它使用Core Image的面部检测API覆盖检测到的面部上的googly眼睛。您可以选择图像以从照片库中接收此效果,或选择从互联网下载的图像。

    本文的任务是使用GCD优化应用程序并确保您可以安全地从不同的线程调用代码。

    在Xcode中打开示例App并运行它以查看您必须使用的内容。

    主屏幕最初是空的。 点击+,然后选择Le Internet以从互联网下载预定义图像。 点击第一张图片,你会看到脸上添加了googly眼睛。

    在本文中,您将主要使用四个类:

    • PhotoCollectionViewController:初始视图控制器。它以缩略图的形式显示所选照片。
    • PhotoDetailViewController:从PhotoCollectionViewController显示所选照片,并将googly眼睛添加到图像中。
    • Photo:此协议描述了照片的属性。它提供图像,缩略图及其相应的状态。该项目包括两个实现协议的类:DownloadPhoto,它实例化来自URL实例的照片,以及AssetPhoto,它实例化来自PHAsset实例的照片。
    • PhotoManager:它管理所有Photo对象。

    在第一部分中,您将进行一些改进,包括优化googly-fying过程并使PhotoManager线程安全。


    GCD Concepts - GCD概念

    要理解GCD,您需要熟悉与并发和线程相关的几个概念。

    1. Concurrency - 并发

    在iOS中,进程或应用程序由一个或多个线程组成。 操作系统调度程序彼此独立地管理线程。 每个线程可以并发执行,但由系统来决定是否发生这种情况,何时发生以及如何发生。

    单核设备通过称为时间切片time-slicing的方法实现并发。 它们运行一个线程,执行上下文切换,然后运行另一个线程。

    另一方面,多核设备通过parallelism同时执行多个线程。

    GCD建立在线程之上。 它负责管理共享线程池。 使用GCD,您可以添加代码块或工作项来调度队列dispatch queues,GCD决定执行它们的线程。

    在构建代码时,您会发现可以同时运行的代码块和不可以同时运行的代码块。 然后,这允许您使用GCD来利用并发执行。

    请注意,GCD根据系统和可用的系统资源决定它需要多少并行度。 重要的是要注意parallelism需要并发性,但并发性并不能保证parallelism

    基本上,并发是关于结构,而parallelism是关于执行。

    2. Queues - 队列

    如前所述,GCD通过一个名为DispatchQueue的类来操作调度队列。 您将工作单元提交到此队列,GCD将以FIFO顺序(先进先出)执行它们,保证提交的第一个任务是第一个启动的任务。

    DispatchQueue是线程安全的,这意味着您可以同时从多个线程访问它们。 当您了解调度队列如何为您自己的代码的某些部分提供线程安全时,GCD的好处是显而易见的。 关键是要选择正确的调度队列和正确的调度函数,将您的工作提交到队列。

    队列可以是串行serial的,也可以是并发concurrent的。 串行队列保证在任何给定时间只运行一个任务。 GCD控制执行时间。 你不会知道一个任务结束和下一个任务开始之间的时间量:

    并发队列允许多个任务同时运行。 队列保证任务以您添加它们的顺序开始。 任务可以按任何顺序完成,您不知道下一个任务启动所需的时间,也不了解在任何给定时间运行的任务数。

    这是设计使然:您的代码不应该依赖于这些实现细节。

    请参阅下面的示例任务执行:

    请注意任务1,任务2和任务3如何一个接一个地快速启动。另一方面,任务1需要一段时间才能在任务0之后启动。还要注意,当任务3在任务2之后启动时,它首先完成。

    何时开始任务的决定完全取决于GCD。如果一个任务的执行时间与另一个任务的执行时间重叠,则由GCD决定是否应该在不同的核心上运行,如果只是单核,则执行上下文切换以运行不同的任务。

    GCD提供三种主要类型的队列:

    • Main queue - 主队列:在主线程上运行,是一个串行队列。
    • Global queues - 全局队列:整个系统共享的并发队列。有四个这样的队列具有不同的优先级: high, default, low, and background。后台优先级队列具有最低优先级,并在任何I / O活动中受到限制,以最大限度地减少负面系统影响。
    • Custom queues - 自定义队列:您创建的可以是串行或并发的队列。这些队列中的请求实际上最终位于其中一个全局队列global queues中。

    将任务发送到全局并发队列时,不直接指定优先级。而是指定服务质量(QoS)类属性。这表明任务的重要性,并指导GCD确定赋予任务的优先级。

    QoS类是:

    • User-interactive - 用户交互:这表示必须立即完成的任务,以提供良好的用户体验。将其用于UI更新,事件处理和需要低延迟的小型工作负载。在执行您的应用程序期间,此类中完成的工作总量应该很小。这应该在主线程上运行。
    • User-initiated - 用户启动:用户从UI启动这些异步任务。当用户等待立即结果以及继续用户交互所需的任务时使用它们。它们在高优先级全局队列中执行。
    • Utility - 实用程序:这表示长时间运行的任务,通常具有用户可见的进度指示器。用于计算,I / O,网络,后续的数据馈送和类似任务。本类旨在提高能源效率。这将被映射到低优先级全局队列。
    • Background - 背景:这表示用户不需要直接了解的任务。用于预取,维护和其他不需要用户交互且时间不敏感的任务。这将被映射到后台优先级全局队列。

    3. Synchronous vs. Asynchronous - 同步与异步

    使用GCD,您可以同步或异步分派任务。

    任务完成后,同步函数会将控制权返回给调用者。 您可以通过调用DispatchQueue.sync(execute :)来同步调度工作单元。

    异步函数立即返回,命令任务开始但不等待它完成。 因此,异步函数不会阻塞当前执行线程继续执行下一个函数。 您可以通过调用DispatchQueue.async(execute :)来异步调度工作单元。

    4. Managing Tasks - 管理任务

    你现在已经听说过相当多的任务了。 出于本教程的目的,您可以将任务视为闭包closure.。 闭包是可以存储和传递的自包含,可调用的代码块。

    您提交给DispatchQueue的每个任务都是DispatchWorkItem。 您可以配置DispatchWorkItem的行为,例如其QoS类或是否生成新的分离线程。


    Handling Background Tasks - 处理后台任务

    根据所有这些GCD知识,是你第一次改进应用程序的时候了!

    回到应用程序并添加照片库中的一些照片或使用Le Internet选项下载一些。 点按照片。 请注意照片详细信息视图显示所需的时间。 在较慢的设备上查看大图像时,滞后更明显。

    重载视图控制器的viewDidLoad()很容易,导致在视图出现之前等待很长时间。 如果在加载时不是绝对必要的话,最好将工作移动到后台。

    这听起来像是DispatchQueue async的工作!

    打开PhotoDetailViewController.swift。 修改viewDidLoad()并替换这两行:

    let overlayImage = faceOverlayImageFrom(image)
    fadeInNewImage(overlayImage)
    

    接着是下面的代码

    // 1
    DispatchQueue.global(qos: .userInitiated).async { [weak self] in
      guard let self = self else {
        return
      }
      let overlayImage = self.faceOverlayImageFrom(self.image)
    
      // 2
      DispatchQueue.main.async { [weak self] in
        // 3
        self?.fadeInNewImage(overlayImage)
      }
    }
    

    下面一步一步进行拆解详述:

    • 1)您将工作移动到后台全局队列(background global queue )并异步运行闭包中的工作。这使得viewDidLoad()在主线程上更早完成,并使加载感觉更加敏捷。同时,面部检测处理开始并在稍后的某个时间结束。
    • 2)此时,面部检测处理已完成,您已生成新图像。由于您要使用此新图像更新UIImageView,因此您需要向主队列添加一个新闭包。记住 - 任何修改UI的东西都必须在主线程上运行!
    • 3)最后,使用fadeInNewImage(_ :)更新UI,执行新的googly眼睛图像的淡入过渡。

    在两个点中,您添加[weak self]以捕获每个闭包中对self的弱引用。如果您不熟悉捕获列表,应该多补习一些内存管理方面的内容。

    Build并运行应用程序。通过Le Internet选项下载照片。选择一张照片,您会发现视图控制器加载速度明显加快,并在短暂延迟后添加了googly眼睛:

    googly眼睛出现时,这为应用程序提供了一个很好的前后效果。即使您尝试加载一个非常大图像,您的应用程序也不会在视图控制器加载时挂起。

    通常,当您需要在后台执行基于网络或CPU密集型的任务而不阻止当前线程时,您希望使用异步(async)

    以下是如何以及何时使用异步(async)的各种队列的快速指南:

    • 1)Main Queue - 主队列:这是在完成并发队列上的任务中的工作之后更新UI的常见选择。为此,您需要在另一个内部编写一个闭包。定位主队列并调用异步(async)可确保在当前方法完成后的某个时间执行此新任务。
    • 2)Global Queue - 全局队列:这是在后台执行非UI工作的常见选择。
    • 3)Custom Serial Queue - 自定义串行队列:当您想要连续执行后台工作并跟踪它时,这是一个很好的选择。这消除了资源争用和竞争条件,因为您知道一次只执行一个任务。请注意,如果需要方法中的数据,则必须声明另一个闭包以检索它或考虑使用同步(sync)

    Delaying Task Execution - 延迟任务执行

    DispatchQueue允许您延迟任务执行。 不要通过引入延迟等hacks来解决竞争条件或其他时间错误。 相反,当您希望任务在特定时间运行时,请使用此选项。

    暂时考虑一下您的应用的用户体验。 用户可能会对第一次打开应用时该怎么做感到困惑 - 是吗

    如果没有任何照片,最好向用户显示提示。 您还应该考虑用户的眼睛如何在主屏幕上浏览。 如果您过快地显示提示,他们可能会错过它,因为他们的眼睛停留在视图的其他部分。 两秒钟的延迟应足以引起用户的注意并引导他们。

    打开PhotoCollectionViewController.swift并填写showOrHideNavPrompt()的实现:

    // 1
    let delayInSeconds = 2.0
    
    // 2
    DispatchQueue.main.asyncAfter(deadline: .now() + delayInSeconds) { [weak self] in
      guard let self = self else {
        return
      }
    
      if PhotoManager.shared.photos.count > 0 {
        self.navigationItem.prompt = nil
      } else {
        self.navigationItem.prompt = "Add photos with faces to Googlyify them!"
      }
    
      // 3
      self.navigationController?.viewIfLoaded?.setNeedsLayout()
    }
    

    以下是上面发生的事情:

    • 1)您指定延迟的时间量。
    • 2)然后等待指定的时间,然后异步运行更新照片计数的块并更新提示。
    • 3)设置提示后强制导航栏布局以确保它看起来更舒服。

    只要您的UICollectionView重新加载,showOrHideNavPrompt()就会在viewDidLoad()中执行。

    Build并运行应用程序。 在看到显示的提示之前应该有一点延迟:

    注意:您可以忽略Xcode控制台中的自动布局消息。 它们都来自iOS,并不表示您的错误。

    为什么不使用Timer? 如果您有重复的任务更容易使用Timer调度,您可以考虑使用它。 以下是坚持调度队列的asyncAfter()的两个原因。

    一个是可读性。 要使用Timer,您必须定义一个方法,然后使用选择器或调用定义的方法创建计时器。 使用DispatchQueueasyncAfter(),您只需添加一个闭包。

    Timer在运行循环上进行调度,因此您还必须确保在正确的运行循环上进行调度(在某些情况下,运行循环模式正确)。 在这方面,使用调度队列更容易。


    Managing Singletons - 管理单例

    单例。喜欢他们或恨他们,他们在iOS中像在网络上的猫照片一样受欢迎。

    单例常常令人担心的是,他们通常不是线程安全的。考虑到它们的使用,这种担忧是合理的:单身通常是从多个控制器同时访问单例实例中使用的。您的PhotoManager类是单例,因此您需要考虑此问题。

    可以从多个线程或并发任务安全地调用线程安全代码,而不会导致任何问题,例如数据损坏或应用程序崩溃。非线程安全的代码一次只能在一个上下文中运行。

    需要考虑两种线程安全情况:在单例实例的初始化期间以及对实例的读写期间。

    由于Swift如何初始化静态变量,初始化变的很简单。它首次访问时初始化静态变量,并保证初始化是原子的(atomic)。也就是说,Swift将执行初始化的代码视为关键部分,并保证在任何其他线程访问静态变量之前完成。

    关键部分是一段不能同时执行的代码,即一次从两个线程执行。这通常是因为代码操纵共享资源(如变量),如果它由并发进程访问,则该变量可能会损坏。

    打开PhotoManager.swift以查看如何初始化单例:

    class PhotoManager {
      private init() {}
      static let shared = PhotoManager()
    }
    

    私有初始化程序确保只有一个shared分配给共享的PhotoManager。 这样,您就不必担心在不同管理器之间同步对照片库的更改。

    在访问操作共享内部数据的单例中的代码时,您仍然必须处理线程安全性。 您可以通过同步数据访问等方法来处理此问题。 您将在下一节中看到一种方法。


    Handling the Readers-Writers Problem - 处理读写问题

    在Swift中,使用let关键字声明的任何变量都是常量,因此是只读和线程安全的。 但是,使用var关键字声明变量,除非数据类型设计为可变,否则它变为可变且不是线程安全的。 声明可变时,像ArrayDictionary这样的Swift集合类型不是线程安全的。

    虽然许多线程可以同时读取一个可变的Array实例而没有问题,但让一个线程修改数组而另一个线程正在读取它是不安全的。 您的单例不会阻止此情况在当前状态下发生。

    要解决这个问题,请查看PhotoManager.swift中的addPhoto(_ :),如下所示:

    func addPhoto(_ photo: Photo) {
      unsafePhotos.append(photo)
      DispatchQueue.main.async { [weak self] in
        self?.postContentAddedNotification()
      }
    }
    

    这是一个write方法,因为它修改了一个可变数组对象。

    现在来看看photos属性,如下:

    private var unsafePhotos: [Photo] = []
      
    var photos: [Photo] {
      return unsafePhotos
    }
    

    此属性的getter被称为read方法,因为它正在读取可变数组。 调用者获取数组的副本,并防止不适当地改变原始数组。 但是,这不会对调用write方法addPhoto(_ :)的一个线程,而另一个线程同时调用photos属性的getter提供任何保护。

    这就是为什么支持变量被命名为unsafePhotos - 如果它在错误的线程上访问,你可能会得到一些古怪的行为!

    注意:在上面的代码中,为什么调用者会获得照片数组的副本? 在Swift中,函数的参数和返回类型可以通过引用或值传递。按值传递会生成对象的副本,对副本的更改不会影响原始对象。 默认情况下,在Swift中,类实例通过引用传递,结构体通过值传递。 Swift的内置数据类型(如ArrayDictionary)实现为结构体(structs)。在来回传递集合时,您的代码可能进行很多副本的copy。 不要担心这会对内存使用产生影响。 Swift集合类型经过优化,仅在必要时才进行复制,例如,当您的应用程序首次修改按值传递的数组时。

    这是经典的软件开发Readers-Writers Problem。 GCD提供了一种使用dispatch barriers创建read/write lock的优雅解决方案。 dispatch barriers是一组在使用并发队列时充当串行式瓶颈的函数。

    当您将DispatchWorkItem提交到调度队列时,您可以设置标志以指示它应该是该特定时间在指定队列上执行的唯一项目。 这意味着在dispatch barriers之前提交到队列的所有项必须在DispatchWorkItem执行之前完成。

    DispatchWorkItem开始执行时,barrier执行它并确保队列在此期间不执行任何其他任务。 完成后,队列将返回其默认实现。

    下图说明了barrier对各种异步任务的影响:

    请注意,在正常操作中,队列的行为与普通并发队列的作用相同。 但是当barrier执行时,它基本上就像一个串行队列。 也就是说,barrier是唯一执行的事情。 在barrier完成后,队列将返回到正常的并发队列。

    在全局后台并发队列中使用障碍时要小心,因为这些队列是共享资源。 在自定义串行队列中使用barrier是多余的,因为它已经穿行执行。 在自定义并发队列中使用barrier是处理原子或关键代码区域中的线程安全性的绝佳选择。

    您将使用自定义并发队列来处理barrier函数并分离读写功能。 并发队列将允许同时进行多个读取操作。

    打开PhotoManager.swift并在unsafePhotos声明上方添加一个私有属性:

    private let concurrentPhotoQueue =
      DispatchQueue(
        label: "com.xxxx.GooglyPuff.photoQueue",
        attributes: .concurrent)
    

    这会将concurrentPhotoQueue初始化为并发队列。 您可以使用在调试期间有用的描述性名称设置标签(label)。 通常,您使用反向DNS样式命名约定。

    接下来,使用以下代码替换addPhoto(_ :)

    func addPhoto(_ photo: Photo) {
      concurrentPhotoQueue.async(flags: .barrier) { [weak self] in
        // 1
        guard let self = self else {
          return
        }
    
        // 2
        self.unsafePhotos.append(photo)
    
        // 3
        DispatchQueue.main.async { [weak self] in
          self?.postContentAddedNotification()
        }
      }
    }
    

    以下是您的新写入方法的工作原理:

    • 1)您可以使用barrier异步调度写入操作。 执行时,它将是队列中唯一的项目。
    • 2)您将对象添加到数组。
    • 3)最后,您发布了添加照片的通知。 您必须在主线程上发布此通知,因为它将执行UI工作。 因此,您将另一个任务异步调度到主队列以触发通知。

    这样可以处理写入,但您还需要实现photos读取方法。

    要确保写入的线程安全,您需要对concurrentPhotoQueue队列执行读取操作。 您需要从函数调用返回数据,因此异步调度不会删除它。 在这种情况下,同步(sync)将是一个很好的选择。

    使用sync可以跟踪调度barrier,或者在需要等待操作完成之后才能使用闭包处理的数据。

    你需要小心。 想象一下,如果你调用sync并定位你正在运行的当前队列。 这将导致死锁(deadlock)情况。

    两个(或有时更多)项目 - 在大多数情况下,线程 - 如果它们都被卡住等待彼此完成或执行另一个操作则会死锁。 第一个无法完成,因为它正在等待第二个完成。 但第二个无法完成,因为它正在等待第一个完成。

    在你的情况下,同步(sync)调用将等到闭包完成,但闭包无法完成(或启动!),直到当前正在执行的闭包完成,它不能! 这应该会强制您小心您正在调用的队列 - 以及您传入的队列。

    以下是使用同步(sync)的概述:

    • Main Queue - 主要队列:出于与上述相同的原因,要非常小心;这种情况也有可能造成死锁。 这在主队列上尤其糟糕,因为整个应用程序将无法响应。
    • Global Queue - 全局队列:这是通过调度barrier同步工作或等待任务完成以便您可以执行进一步处理的一个很好的选择。
    • Custom Serial Queue - 自定义串行队列:在这种情况下要非常小心;如果你在一个队列中运行并调用同一个队列的同步(sync),你肯定会创建一个死锁。

    仍然在PhotoManager.swift中修改photos属性getter

    var photos: [Photo] {
      var photosCopy: [Photo]!
    
      // 1
      concurrentPhotoQueue.sync {
    
        // 2
        photosCopy = self.unsafePhotos
      }
      return photosCopy
    }
    

    这是进行分步细说:

    • 1)同步调度到concurrentPhotoQueue以执行读取。
    • 2)将照片数组的副本存储在photosCopy中并将其返回。

    Build并运行应用程序。 通过Le Internet选项下载照片。 它应该像以前一样。

    恭喜 - 您的PhotoManager单例现在是线程安全的! 无论您在何处或如何读或写照片,您都可以确信它会以安全的方式发生而不会出现任何意外。

    如果您计划优化自己的App,那么您真的应该使用Xcode的内置Time Profiler来分析您的工作。

    您可能还想看看Rob Pike关于Concurrency vs Parallelism上的精彩演讲 - this excellent talk by Rob Pike

    参考文章

    1. iOS GCD详解(一)
    2. GCD 深入理解:第一部分
    3. iOS中GCD的使用小结

    后记

    本篇主要讲述了GCD相关一个简单应用示例,感兴趣的给个赞或者关注~~~

    相关文章

      网友评论

        本文标题:iOS与多线程(五) —— GCD之一个简单应用示例(一)

        本文链接:https://www.haomeiwen.com/subject/jgdciftx.html