深入浅出 iOS 并发编程

作者 : 开心源码 本文共8910个字,预计阅读时间需要23分钟 发布时间: 2022-05-12 共149人阅读

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

什么是并发编程

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

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

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

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

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

// serial, syncserialQueue.sync {  print(1)}print(2)serialQueue.sync {  print(3)}print(4)// serial, asyncserialQueue.async {  print(1)}print(2)serialQueue.async {  print(3)}print(4)// serial, sync in asyncprint(1)serialQueue.async {  print(2)  serialQueue.sync {    print(3)  }  print(4)}print(5)// serial, async in syncprint(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, syncconcurrentQueue.sync {  print(1)}print(2)concurrentQueue.sync {  print(3)}print(4)// concurrent, asyncconcurrentQueue.async {  print(1)}print(2)concurrentQueue.async {  print(3)}print(4)// concurrent, sync in asyncprint(1)concurrentQueue.async {  print(2)  concurrentQueue.sync {    print(3)  }  print(4)}print(5)// concurrent, async in syncprint(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 queuelet serialQueue = DispatchQueue(label: "serial")// global queue, gcd defined concurrent queuelet globalQueue = DispatchQueue.global(qos: .default)// custom concurrent queuelet 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 queuelet serialQueue = OperationQueue()serialQueue.maxConcurrentOperationCount = 1// concurrent queuelet 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 = 0DispatchQueue.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 dependenciesimageDecodeOperation.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 胜在功能丰富、适合板块化操作。我们享受其便利的同时,也应该及时发现和解决并发编程中的三大问题。

上一篇 目录 已是最后

说明
1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长!
2. 分享目的仅供大家学习和交流,您必须在下载后24小时内删除!
3. 不得使用于非法商业用途,不得违反国家法律。否则后果自负!
4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解!
5. 如有链接无法下载、失效或广告,请联系管理员处理!
6. 本站资源售价只是摆设,本站源码仅提供给会员学习使用!
7. 如遇到加密压缩包,请使用360解压,如遇到无法解压的请联系管理员
开心源码网 » 深入浅出 iOS 并发编程

发表回复