深入浅出 iOS 并发编程

作者: 故胤道长 | 来源:发表于2018-09-04 05:53 被阅读3359次

    本文是我在上海 T 沙龙4月7日分享内容的文字版总结和拓展。相关视频和文档请见链接:深入浅出 iOS 并发编程
    其中主要内容包括:GCD与Operation的用法、并发编程中常见的问题、使用Operation进行流程化开发示范。

    什么是并发编程

    在大多数场景下,我们所写的代码是逐行顺序执行——在固定的时段内,程序只执行一个任务。而所谓并发编程,就是指在固定的时段内,程序执行多个任务。举个例子,当我们在微博 App 的首页滑动浏览时,微博也在从网络端预加载新的内容或者图片。并发编程可以充分利用硬件性能,合理分配软件资源,带来优秀的用户体验。在 iOS 开发中,我们主要依靠 GCD 和 Operation 来操作线程切换、异步操作,从而实现并发编程。

    新闻类App首页经常需要同时处理 UI 显示、内容加载、缓存等多个任务

    在 iOS 并发编程中,我们要知道这几个基本概念:

    • 串行(Serial):在固定时间内只能执行单个任务。例如主线程,只负责 UI 显示。
    • 并发(Concurrent):在固定时间内可以执行多个任务。注意,它和并行(Parallel)的区别在于,并发不会同时执行多个任务,而是通过在任务间不断切换去完成所有工作。
    • 同步(Sync):会把当前的任务加入到队列中,除非该任务执行完成,线程才会返回继续运行,也就是说同步会阻塞线程。任务在执行和结束一定遵循先后顺序,即先执行的任务一定先结束。
    • 异步(Async):会把当前的任务加入到队列中,但它会立刻返回,无需等任务执行完成,也就是说异步不会阻塞线程。任务在执行和结束不遵循先后顺序。可能先执行的任务先结束,也可能后执行的任务先结束。

    为了进一步说明说明串行/并发与同步/异步之间的关系,我们来看下面这段代码会打印出什么内容:

    // serial, sync
    serialQueue.sync {
      print(1)
    }
    print(2)
    serialQueue.sync {
      print(3)
    }
    print(4)
    
    // serial, async
    serialQueue.async {
      print(1)
    }
    print(2)
    serialQueue.async {
      print(3)
    }
    print(4)
    
    // serial, sync in async
    print(1)
    serialQueue.async {
      print(2)
      serialQueue.sync {
        print(3)
      }
      print(4)
    }
    print(5)
    
    
    // serial, async in sync
    print(1)
    serialQueue.sync {
      print(2)
      serialQueue.async {
        print(3)
      }
      print(4)
    }
    print(5)
    

    首先,在串行队列上进行同步操作,所有任务将顺序发生,所以第一段的打印结果一定是 1234;

    其次,在串行队列上进行异步操作,此时任务完成的顺序并不保证。所以可能会打印出这几种结果:1234 ,2134,1243,2413,2143。注意 1 一定在 3 之前打印出来,因为前者在后者之前派发,串行队列一次只能执行一个任务,所以一旦派发完成就执行。同理 2 一定在 4 之前打印,2 一定在 3 之前打印。

    接着,对同一个串行队列中进行异步、同步嵌套。这里会构成死锁(具体原因参见下文),所以只会打印出 125 或者 152。

    最后,在串行队列中进行同步、异步嵌套,不会构成死锁。这里会打印出 3 个结果:12345,12435,12453。这里1一定在最前,2 一定在 4 前,4 一定在 5 前。

    现在我们把串行队列改为并发队列:

    // concurrent, sync
    concurrentQueue.sync {
      print(1)
    }
    print(2)
    concurrentQueue.sync {
      print(3)
    }
    print(4)
    
    // concurrent, async
    concurrentQueue.async {
      print(1)
    }
    print(2)
    concurrentQueue.async {
      print(3)
    }
    print(4)
    
    // concurrent, sync in async
    print(1)
    concurrentQueue.async {
      print(2)
      concurrentQueue.sync {
        print(3)
      }
      print(4)
    }
    print(5)
    
    
    // concurrent, async in sync
    print(1)
    concurrentQueue.sync {
      print(2)
      concurrentQueue.async {
        print(3)
      }
      print(4)
    }
    print(5)
    

    首先,在并发队列上进行同步操作,所有任务将顺序执行、顺序完成,所以第一段的打印结果一定是 1234;

    其次,在并发队列上进行异步操作,因为并行对列有多个线程 。所以这里只能保证 24 顺序执行,13 乱序,可能插在任意位置:2413 ,2431,2143,2341,2134,2314。

    接着,对同一个并发队列中进行异步、同步嵌套。这里不会构成死锁,因为同步操作只会阻塞一个线程,而并发队列对应多个线程。这里会打印出 4 个结果:12345,12534,12354,15234。注意同步操作保证了 3 一定会在 4 之前打印出来。

    最后,在并发队列中进行同步、异步嵌套,不会构成死锁。而且由于是并发队列,所以在运行异步操作时也同时会运行其他操作。这里会打印出 3 个结果:12345,12435,12453。这里同步操作保证了 2 和 4 一定在 3 和 5 之前打印出来。

    在实际开发中,我们还需要知道主线程的特性、GCD 和 Operation 的 API、如发现并调试并发编程中的技巧。

    GCD vs. Operation

    在 iOS 开发中,我们一般用 GCD 和 Operation 来处理并发编程问题。我们先来看看 GCD 的基本用法:

    // serial queue
    let serialQueue = DispatchQueue(label: "serial")
    
    // global queue, gcd defined concurrent queue
    let globalQueue = DispatchQueue.global(qos: .default)
    
    // custom concurrent queue
    let concurrentQueue = DispatchQueue(label: "concurrent", attributes: .concurrent)
    

    其中,全局队列的优先级由 QoS (Quality of Service)决定。如果不指定优先级,就是默认(default)优先级。另外还有 background,utility,user-Initiated,unspecified,user-Interactive。下面按照优先级顺序从低到高来排列:

    • Background:用来处理特别耗时的后台操作,例如同步、数据持久化。
    • Utility:用来处理需要一点时间而又不需要立刻返回结果的操作。特别适用于网络加载、计算、输入输出等。
    • Default:默认优先级。一般来说开发者应该指定优先级。属于特殊情况。
    • User-Initiated:用来处理用户触发的、需要立刻返回结果的操作。比如打开用户点击的文件、加载图片等。
    • User-Interactive:用来处理用户交互的操作。一般用于主线程,如果不及时响应就可能阻塞主线程的操作。
    • Unspecified:未确定优先级,由系统根据不同环境推断。比如使用过时的 API 不支持优先级,此时就可以设定为未确定优先级。属于特殊情况。

    在日常开发中,GCD 的常见应用有处理后台任务、延时、单例(Objective-C)、线程组等操作,这里不作赘述。下面我们来看看 Operation 的基本操作:

    // serial queue
    let serialQueue = OperationQueue()
    serialQueue.maxConcurrentOperationCount = 1
    
    // concurrent queue
    let concurrentQueue = OperationQueue()
    

    Operation 作为 NSObject 的子类,一般被用于单独的任务。我们将其继承重写之后加入到 OperationQueue 中去运行。iOS 亦提供 BlockOperation 这个子类去方便地执行多个代码片段。相比于 GCD,Operation 最主要的特点在于其拥有暂停、继续、终止等多个可控状态,从而可以更加灵活得适应并发编程的场景。

    基于 Operation 和 GCD API 的特点,我们可以得出以下结论:GCD 适用于处理并行开发中的简单小任务,总体写法轻便快捷;Operation 适合于封装模块化的任务,支持多任务之间相互依赖的场景。两者之间的区别同 UIAnimation 和 CALayor Animation 差别异曲同工——由此可见苹果在设计 API 时一以贯之的思路:提供一个简单快捷的 API 满足80%的场景,在提供一套更全面的 API 应对剩下20%更复杂的场景。

    并发编程中常见问题

    在并发编程中,一般会面对这样的三个问题:竞态条件、优先倒置、死锁问题。针对 iOS 开发,它们的具体定义为:

    • 竞态条件(Race Condition)。指两个或两个以上线程对共享的数据进行读写操作时,最终的数据结果不确定的情况。例如以下代码:
    var num = 0
    DispatchQueue.global().async {
      for _ in 1…10000 {
        num += 1
      }
    }
    
    for _ in 1…10000 {
      num += 1
    }
    

    最后的计算结果 num 很有可能小于 20000,因为其操作为非原子操作。在上述两个线程对num进行读写时其值会随着进程执行顺序的不同而产生不同结果。

    竞态条件一般发生在多个线程对同一个资源进行读写时。解决方法有两个,第一是串行队列加同步操作,无论读写,指定时间只能优先做当前唯一操作,这样就保证了读写的安全。其缺点是速度慢,尤其在大量读写操作发生时,每次只能做单个读或写操作的效率实在太低。另一个方法是,用并发队列和 barrier flag,这样保证此时所有并发队列只进行当前唯一的写操作(类似将并发队列暂时转为串行队列),而无视其他操作。

    • 优先倒置(Priority Inverstion)。指低优先级的任务会因为各种原因先于高优先级任务执行。例如以下代码:
    var highPriorityQueue = DispatchQueue.global(qos: .userInitiated)
    var lowPriorityQueue = DispatchQueue.global(qos: .utility)
    
    let semaphore = DispatchSemaphore(value: 1)
    
    lowPriorityQueue.async {
      semaphore.wait()
      for i in 0...10 {
        print(i)
      }
      semaphore.signal()
    }
    
    highPriorityQueue.async {
      semaphore.wait()
      for i in 11...20 {
        print(i)
      }
      semaphore.signal()
    }
    

    上述代码如果没有 semaphore,高优先权的 highPriorityQueue 会优先执行,所以程序会优先打印完 11 到 20。而加了 semaphore 之后,低优先权的 lowPriorityQueue 会先挂起 semaphore,高优先权的highPriorityQueue 就只有等 semaphore 被释放才能再执行打印。

    也就是说,低优先权的线程可以锁上某种高优先权线程需要的资源,从而优于迫使高优先权的线程等待低优先权的线程,这就叫做优先倒置。其对应的解决方法是,对同一个资源不同队列的操作,我们应该用同一个QoS指定其优先级。

    • 死锁问题(Dead Lock)。指两个或两个以上的线程,它们之间互相等待彼此停止执行,以获得某种资源,但是没有一方会提前退出的情况。iOS 中有个经典的例子就是两个 Operation 互相依赖:
    let operationA = Operation()
    let operationB = Operation()
    
    operationA.addDependency(operationB)
    operationB.addDependency(operationA)
    

    还有一种经典的情况,就是在对同一个串行队列中进行异步、同步嵌套:

    serialQueue.async {
      serialQueue.sync {
      }
    }
    

    因为串行队列一次只能执行一个任务,所以首先它会把异步 block 中的任务派发执行,当进入到 block 中时,同步操作意味着阻塞当前队列 。而此时外部 block 正在等待内部 block 操作完成,而内部block 又阻塞其操作完成,即内部 block 在等待外部 block 操作完成。所以串行队列自己等待自己释放资源,构成死锁。

    对于死锁问题的解决方法是,注意Operation的依赖添加,以及谨慎使用同步操作。其实聪明的读者应该已经发现,在主线程使用同步操作是一定会构成死锁的,所以我个人建议在串行队列中不要使用同步操作。

    尽管我们已经知道了并发编程中的问题,以及其对应方法。但是日常开发中,我们怎样及时发现这些问题呢?其实 Xcode 提供了一个非常便利的工具 —— Thread Sanitizer (TSan)。在Schemes中勾选之后,TSan就会将所有的并发问题在 Runtime 中显示出来,如下图:

    这里我们有7个线程问题,TSan清晰地告诉了我们这是读写问题,展开之后会告诉我们具体触发代码,十分方便。16年的WWDC上,苹果也郑重向大家宣告,如果有并发问题,请记得用 TSan。

    Operation 流程化开发

    上文中提到 Operation 特别适合模块化工作,也支持多任务的互相依赖。这里我们就来看一个具体的开发案例吧:

    实现一个相册 App,其首页是个滑动列表(Table View)。列表每行展示加上了滤镜的图片。具体实现如下图:

    仔细分析一下相关的操作,实际上就是三步:先加载数据,然后解码成图片,最后再给图片加上滤镜。所以用 Operation 实现起来如下图:

    对于加载数据,我们可以定义如下的 Operation 子类来进行操作:

    class DataLoadOperation: Operation {
      
      fileprivate let url: URL
      fileprivate var loadedData: Data?
      fileprivate let completion: ((Data?) -> ())?
      
      init(url: URL, completion: ((Data?) -> ())? = nil) {
        ...
      }
      
      override func main() {
        if isCancelled { return }
        ImageService.loadData(at: url) { data in
          if isCancelled { return }
            loadedData = data
            completion?(data)
        }
      }
    }
    

    这里我们要注意,DataLoadOperation中的三个变量皆为私有。这是因为其实后续图片解码操作并不关心数据是如何操作的,它只关心是否能提供解码图片的数据,所以我们可以用 Protocol 来提供这个借口即可:

    // 此协议定义应和 ImageDecodeOperation 放在同一文件
    protocol ImageDecodeOperationDataProvider {
      var encodedData: Data? { get }
    }
    
    // 次扩展应和 DataLoadOperation 放在同一文件
    extension DataLoadOperation: ImageDecodeOperationDataProvider {
      var encodedData: Data? { return loadedData }
    }
    

    接着再来看看解码图片的 Operation 如何实现:

    class ImageDecodeOperation: Operation {
      
      fileprivate let inputData: Data?
      fileprivate var outputImage: UIImage?
      fileprivate let completion: ((UIImage?) -> ())?
      
      init(data: Data?, completion: ((UIImage?) -> ())? = nil) {
        ...
      }
      
      override func main() {    
        let encodedData: Data?
        if isCancelled { return }
        if let inputData = inputData {
          encodedData = inputData
        } else {
          let dataProvider = dependencies
            .filter { $0 is ImageDecodeOperationDataProvider }
            .first as? ImageDecodeOperationDataProvider
          encodedData = dataProvider?.encodedData
        }
        
        guard let data = encodedData else { return }
        
        if isCancelled { return }
        if let decodedData = Decoder.decodeData(data) {
          outputImage = UIImage(data: decodedData)
        }
        
        if isCancelled { return }
        completion?(outputImage)
      }
    }
    
    extension ImageDecodeOperation: ImageFilterDataProvider {
      var image: UIImage? { return outputImage }
    }
    

    最后我们再来看 ImageFilterOperation 及其子类如何实现。这里由于直接输出 Image,所以就无需用:

    protocol ImageFilterDataProvider {
      var image: UIImage? { get }
    }
    
    class ImageFilterOperation: Operation {
      fileprivate let filterInput: UIImage?
      fileprivate var filterOutput: UIImage?
      fileprivate let completion: ((UIImage?) -> ())?
      
      init(image: UIImage?, completion:
     ((UIImage?) -> ())? = nil) {
        ...
      }
      
      var filterInput: UIImage? {
        var image: UIImage?
        if let inputImage = _filterInput {
          image = inputImage
        } else if let dataProvider = dependencies
          .filter({ $0 is ImageFilterDataProvider })
          .first as? ImageFilterDataProvider {
            image = dataProvider.image
        }
        return image
      }
    }
    
    // LarkFilter 和 ReyesFilter 的实现也类似
    class MoonFilterOperation : ImageFilterOperation {
      override func main() {
        if isCancelled { return }
        guard let filterInput = filterInput else { return }
     
        if isCancelled { return }
        filterOutput = filterInput.applyMoonEffect()
        if isCancelled { return }
        completion(imageFiltered)
      }
    }
    

    最后我们用 OperationQueue 将这些 Operation 拼接在一起:

    let operationQueue = OperationQueue()
    let dataLoadOperation = DataLoadOperation(url: url)
    let imageDecodeOperation = imageDecodeOperation(data: nil)
    let moonFilterOperation = MoonFilterOperation(image: nil, completion: completion)
    let operations = [dataLoadOperation, imageDecodeOperation, moonFilterOperation]
    
    // Add dependencies
    imageDecodeOperation.addDependency(dataLoadOperation)
    moonFilterOperation.addDependency(imageDecodeOperation)
    
    operationQueue.addOperations(operations, waitUntilFinished: false)
    

    大功告成。从上面我们可以发现,每个操作模块都可以用 Operation 进行自定义和封装。模块的对应逻辑非常清楚,代码复用率和灵活度也非常之高。如果要继续改进,我们还可以实现一个 AsyncOperation 的类,然后让 DataLoadOperation 继承该类,这样数据加载由同步变为异步,其效率会大大提高。

    总结

    iOS 开发中,并发编程主要用于提升 App 的运行性能,保证App实时响应用户的操作。主线程一般用于负责 UI 相关操作,如绘制图层、布局、交互相应。很多 UIKit 相关的控件如果不在主线程操作,会产生未知效果。Xcode 中的 Main Thread Checker 可以将相关问题检测出来并报错。

    其他线程例如后天线程一般用来处理比较耗时的工作。网络请求、数据解析、复杂计算、图片的编码解码管理等都属于耗时的工作,应该放在其他线程处理。iOS 提供了两套灵活丰富的 API:GCD 和 Operation。GCD的优点在于简单快捷,Operation 胜在功能丰富、适合模块化操作。我们享受其便利的同时,也应该及时发现和处理并发编程中的三大问题。

    相关文章

      网友评论

      • 不如吃酒去:感觉懂了,然后又不懂
      • ab6370700196:写啥都是深入浅出,不能直视了- -
      • 内心戏十足的伪胖子:前辈好,看了您的文章想起个问题,文中讲到“并发(Concurrent):在固定时间内可以执行多个任务。注意,它和并行(Parallel)的区别在于,并发不会同时执行多个任务,而是通过在任务间不断切换去完成所有工作”,而GCD的含义:Applications need to be rewritten to take full advantage of all of these cores, but Apple's new operating system has a new technology that they are calling Grand Central Dispatch,那GCD中的DISPATCH_QUEUE_CONCURRENT指的是并行还是并发呢?谢谢
        故胤道长:@内心戏十足的伪胖子 是并发。
      • iOS虞:能把App相册Demo代码发一下吗
        不如吃酒去:@故胤道长 图片地址失效了 道长
        iOS虞:@故胤道长 谢谢大佬
        故胤道长:@三鹿奶粉 https://github.com/soapyigu/Swift-30-Projects/tree/master/Project%2017%20-%20ClassicPhotos
      • MemoryReload:串行队列用async这有用吗?还在里面再嵌套sync,这不是神经病是什么……:flushed::flushed::flushed:
        MemoryReload:@故胤道长 其实,我没太懂,你举的那个例子。:joy::joy::joy:
        MemoryReload:@故胤道长 里面再用sync ?
        故胤道长:@MemoryReload 串行队列async当然有用,你主线程不就经常async嘛
      • struggle3g:道长,道长总结的很详细:+1:🏾

      本文标题:深入浅出 iOS 并发编程

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