多线程之GCD与NSOperation

开始之前

首先要解决一个大家对多线程的理解上可能存在的误区:新开一个线程,能提高速度,避免阻塞主线程。
这句话看着好像是对着呢,但是仔细想想这句话是不那么准确的。

举个例子:一个主任务需要十个子任务按顺序执行来完成。现在有两种方式完成这个任务:
1.建十个线程,把每个子任务放在对应的线程中执行。执行完一个线程中的任务就切换到另一个线程。
2.把十个任务放在一个线程里,按顺序执行。

操作系统的基础知识告诉我们,线程,是执行程序最基本的单元,它有自己栈和寄存器。说得再具体一些,线程就是“一个CPU执行的一条无分叉的命令列”。

对于第一种方法,在十个线程之间来回切换,就意味着有十组栈和寄存器中的值需要不断地被备份、替换。 而对于对于第二种方法,只有一组寄存器和栈存在,显然效率完胜前者。

并发与并行的区别

并发指的是一种现象,一种经常出现,无可避免的现象。它描述的是“多个任务同时发生,需要被处理”这一现象。它的侧重点在于“发生”。
比如有很多人排队等待检票,这一现象就可以理解为并发。

并行指的是一种技术,一个同时处理多个任务的技术。它描述了一种能够同时处理多个任务的能力,侧重点在于“运行”。
比如景点开放了多个检票窗口,同一时间内能服务多个游客。这种情况可以理解为并行。

并行的反义词就是串行,表示任务必须按顺序来,一个一个执行,前一个执行完了才能执行后一个。

我们经常提到的“多线程”,正是采用了并行技术,从而提高了执行效率。因为有多个线程,所以计算机的多个CPU可以同时工作,同时处理不同线程内的指令。

并发是一种现象,面对这一现象,我们首先创建多个线程,真正加快程序运行速度的,是并行技术。也就是让多个CPU同时工作。而多线程,是为了让多个CPU同时工作成为可能。

同步与异步

同步方法就是我们平时调用的哪些方法。比如在第一行调用a方法,那么程序运行到第二行的时候,a方法肯定是执行完了。

所谓的异步,就是允许在执行某一个任务时,函数立刻返回,但是真正要执行的任务稍后完成。

比如我们在点击保存按钮之后,要先把数据写到内存,然后更新UI。同步方法就是等到数据保存完再更新UI,而异步则是立刻从保存数据的方法返回并向后执行代码,同时真正用来保存数据的指令将在稍后执行。

区别和联系

串行/并行针对的是队列,而同步/异步,针对的则是线程。最大的区别在于,同步线程要阻塞当前线程,必须要等待同步线程中的任务执行完,返回以后,才能继续执行下一任务;而异步线程则是不用等待。

假设现在有三个任务需要处理。假设单个CPU处理它们分别需要3、1、1秒。

并行/串行讨论的是处理这三个任务的速度问题。如果三个CPU并行处理,那么一共只需要3秒。相比于串行处理,节约了两秒。

同步/异步描述的是任务之间先后顺序问题。假设需要三秒的那个是保存数据的任务,而另外两个是UI相关的任务。那么通过异步执行第一个任务,我们省去了三秒钟的卡顿时间。

对于同步执行的三个任务来说,系统倾向于在同一个线程里执行它们。因为即使开了三个线程,也得等他们分别在各自的线程中完成。并不能减少总的处理时间,反而徒增了线程切换所耗费的时间。

对于异步执行的三个任务来说,系统倾向于在三个新的线程里执行他们。因为这样可以最大程度的利用CPU性能,提升程序运行效率。

总结

在需要同时处理写入写出操作和UI操作的情况下,真正起作用的是异步,而不是多线程。可以不用多线程,但不能不用异步。

GCD

GCD以block(Swift中是闭包,为了方便,下面都以block表示)为基本单位,一个block中的代码可以为一个任务。下文中提到任务,可以理解为执行某个block。

同时,GCD中有两大最重要的概念,分别是“队列”和“执行方式”。

使用block的过程,概括来说就是把block放进合适的队列,并选择合适的执行方式去执行block的过程。

三种队列:
串行队列(先进入队列的任务先出队列,每次只执行一个任务)
并发队列(依然是“先入先出”,不过可以形成多个任务并发)
主队列(这是一个特殊的串行队列,而且队列中的任务一定会在主线程中执行)

两种执行方式:
同步执行
异步执行

关于同步/异步、串行/并行和线程的关系,下面通过一个表格来总结:

同步 异步
主队列 在主线程中执行 在主线程中执行
串行队列 在当前线程中执行 新建线程执行
并发队列 在当前线程中执行 新建线程执行

可以看到,同步方法不一定在本线程,因为加入到主队列的就会在主线程内执行;异步方法方法也不一定新开线程,也是因为主队列的特殊情况。

在我们的实际开发过程中,我们要更多考虑的是怎么准确的使用好串行/并行、同步/异步,而不是仅仅只考虑是否新开线程这个问题。

当然,了解任务运行在那个线程中也是为了更加深入的理解整个程序的运行情况,尤其是接下来要讨论的死锁问题。

GCD的死锁问题

在使用GCD的过程中,如果向当前串行队列中同步派发一个任务,就会导致死锁。
这句话有点绕,先举个例子看看:

override func viewDidLoad() {
        super.viewDidLoad()

        let queue = DispatchQueue.main
        
        queue.sync {
            print("啊哈哈")
        }
}

这段代码就会导致死锁,因为我们目前在主队列中,又将要同步地添加一个block到主队列中。

先分析一波

我们知道.sync表示同步的执行任务,也就是说执行.sync后,当前线程会阻塞。而.sync中的block如果要在当前线程中执行,就得等待当前线程执行完成。

在上面这个例子中,主线程在执行.sync,随后主队列中新增一个任务block。因为主队列是串行队列,所以block要等.sync执行完才能执行,但是.sync是同步派发,要等block执行完才算是结束。在主队列中的两个任务互相等待,导致了死锁。

解决方案

其实在通常情况下我们不必要用.sync,因为.async能够更好的利用CPU,提升程序运行速度。只有当我们需要保证队列中的任务必须顺序执行时,才考虑.sync。在使用.sync的时候应该分析当前处于哪个队列,以及任务会提交到哪个队列。

DispatchGroup

在平时的开发过程中,可能会有这样的需求:我需要在完成一些任务之后紧接着去执行另外一个任务,这里,我们就可以使用GCD任务组解决类似需求。
在单个串行队列中,这个需求不是问题,因为只要把回调block添加到队列末尾即可。
但是对于并行队列,以及多个串行、并行队列混合的情况,就需要使用DispatchGroup了。

  override func viewDidLoad() {
        super.viewDidLoad()

        let concurrentQueue = DispatchQueue(label: "concurrentQueue", qos: .default, attributes: .concurrent, autoreleaseFrequency: .workItem, target: nil)
        let serialQueue = DispatchQueue(label: "serialQueue")
        
        let group = DispatchGroup()
        
        for i in 0...3 {
            concurrentQueue.async(group: group, qos: .default, flags: []) {
                print("concurrentQueue\(i)")
            }
        }
        
        for i in 0...3 {
            serialQueue.async(group: group, qos: .default, flags: []) {
                print("serialQueue\(i)")
            }
        }
        
        //执行完上面的两个耗时操作, 回到主队列中执行下一步的任务
        group.notify(queue: DispatchQueue.main) {
            print("回到主队列执行一些操作")
        }
  }
  输出:
  concurrentQueue1
  serialQueue0
  concurrentQueue2
  concurrentQueue0
  concurrentQueue3
  serialQueue1
  serialQueue2
  serialQueue3
  回到主队列执行一些操作

首先创建一个并发队列和串行队列,然后通过DispatchGroup()方法生成一个组。

接下来,在两个不同的队列里面分别加入不同的任务,并放入到group中去。

最后调用group.notify方法。这个方法表示把第二个参数 block 传入第一个参数队列中去。而且可以保证第二个参数 block 执行时,group中的所有任务已经全部完成。

.asyncAfter方法

通过 GCD 还可以进行简单的定时操作,比如在 1 秒后执行某个 block 。代码如下:

DispatchQueue.main.asyncAfter(deadline:DispatchTime.now() + 1 ) {
    print("我是在一秒后执行的")
}

.asyncAfter方法的调用者表示要执行的任务提交到哪个队列,后面有两个参数。第一个表示时间,也就是从现在起往后一秒钟。第二个参数分别表示要提交的任务。

需要注意的是.asyncAfter仅表示在指定时间后提交任务,而非执行任务。如果任务提交到主队列,它将在main runloop中执行,对于每隔1/60秒执行一次的RunLoop,任务最多有可能在1+1/60秒后执行。

Operation

OperationOperationQueue主要涉及这几个方面:

  • OperationOperationQueue用法介绍
  • Operation的暂停、恢复和取消
  • 通过 KVO 对 Operation的状态进行检测
  • 多个 Operation 的之间的依赖关系

从简单意义上来说,Operation是对 GCD 中的 block 进行的封装,它也表示一个要被执行的任务,Operation对象有一个start()方法表示开始执行这个任务。

不仅如此,Operation表示的任务还可以被取消。它还有三种状态isExecuted、isFinished、isCancelled以方便我们通过 KVC 对它的状态进行监听。

想要开始执行一个任务可以这么写:

        let operation = BlockOperation.init { 
            print("初始化 0 的任务\(Thread.current)")
        }
        
        operation.addExecutionBlock {
            print("第 1 个添加任务\(Thread.current)")
        }
        operation.addExecutionBlock {
            print("第 2 个添加任务\(Thread.current)")
        }        
        operation.addExecutionBlock {
            print("第 3 个添加任务\(Thread.current)")
        }        
        operation.addExecutionBlock {
            print("第 4 个添加任务\(Thread.current)")
        }
        operation.addExecutionBlock {
            print("第 5 个添加任务\(Thread.current)")
        }        
        operation.addExecutionBlock {
            print("第 6 个添加任务\(Thread.current)")
        }        
        operation.addExecutionBlock {
            print("第 7 个添加任务\(Thread.current)")
        }       
        operation.addExecutionBlock {
            print("第 8 个添加任务\(Thread.current)")
        }     
        operation.start()
        print("结束了")
输出内容:
初始化 0 的任务<NSThread: 0x60000007dc40>{number = 1, name = main}
第 2 个添加任务<NSThread: 0x60000026af80>{number = 3, name = (null)}
第 1 个添加任务<NSThread: 0x608000262e80>{number = 5, name = (null)}
第 3 个添加任务<NSThread: 0x608000262d80>{number = 4, name = (null)}
第 4 个添加任务<NSThread: 0x60000007dc40>{number = 1, name = main}
第 5 个添加任务<NSThread: 0x60000026af80>{number = 3, name = (null)}
第 6 个添加任务<NSThread: 0x608000262e80>{number = 5, name = (null)}
第 8 个添加任务<NSThread: 0x60000007dc40>{number = 1, name = main}
第 7 个添加任务<NSThread: 0x608000262d80>{number = 4, name = (null)}
结束了

使用BlockOperation来创建是因为Operation是一个基类,不应该直接生成Operation对象,而是应该用它的子类。BlockOperation是苹果预定义的子类,它可以用来封装一个或多个 block ,后面会介绍如何自己创建Operation的子类。

在上面的例子里面我们创建了一个BlockOperation,并且设置好它的 block ,也就是将要执行的任务,同时,我们调用addExecutionBlock方法追加几个任务,这些任务会并行执行。但是它并非是将所有的 block 都放到放到了子线程中。通过上面的打印记录我们可以发现,它会优先将 block 放到主线程中执行,若主线程已有待执行的代码,就开辟新的线程,但最大并发数为4(包括主线程在内,在真机上最大并发数为2,不必纠结这个,明白原理即可),如果 block 数量大于了线程的最大并发数,那么剩下的 block 就会等待某个线程空闲下来之后被分配到该线程,且依然是优先分配到主线程。

最后,调用start()方法让Operation方法运行起来。start()是一个同步方法,也就是在调用start()方法的那个线程中直接执行,会阻塞调用start()方法的线程。

OperationQueue

从上面我们可以知道Operation是同步执行的。简单的看一下 NSOperation 类的定义会发现它有一个只读属性 asynchronous,这意味着如果想要异步执行,就需要自定Operation的子类。或者使用OperationQueue

OperationQueue类似于 GCD 中的队列。我们知道 GCD 中的队列有三种:主队列串行队列并行队列OperationQueue更简单,只有两种:主队列非主队列。我们自己生成的OperationQueue对象都是非主队列,主队列可以用OperationQueue.main取得。

OperationQueue的主队列是串行队列,而且其中所有Operation都会在主线程中执行。对于非主队列来说,一旦一个Operation被放入其中,那这个Operation一定是并发执行的。因为OperationQueue会为每一个Operation创建线程并调用它的start()方法。

OperationQueue有一个属性叫maxConcurrentOperationCount,它表示最多支持多少个Operation并发执行。如果maxConcurrentOperationCount被设为 1,就认为这个队列是串行队列。需要注意的是设备最大并发数是有上限的,即使你设置maxConcurrentOperationCount为100,它也不会超过设备最大并发数上限,而这个上限的数目也是由具体运行环境决定的。

OperationQueueGCD中的队列有这样的对应关系:

OperationQueue GCD
主队列 OperationQueue.main DispatchQueue.main
串行队列 自建队列设置maxConcurrentOperationCount为 1 DispatchQueue(label: "serialQueue")
并发队列 自建队列设置maxConcurrentOperationCount大于 1 DispatchQueue(label: "concurrentQueue", qos: .default, attributes: .concurrent, autoreleaseFrequency: .workItem, target: nil

想要使用OperationQueue实现异步操作可以这么写:

let operationQueue = OperationQueue()
let operation = BlockOperation()
        
for i in 1...10 {
    operation.addExecutionBlock {
        print("第 \(i) 个添加任务\(Thread.current)")
    }
}
operationQueue.addOperation(operation) 
输出内容:
第 1 个添加任务<NSThread: 0x1702619c0>{number = 5, name = (null)}
第 2 个添加任务<NSThread: 0x170261880>{number = 4, name = (null)}
第 3 个添加任务<NSThread: 0x1702619c0>{number = 5, name = (null)}
第 4 个添加任务<NSThread: 0x170261880>{number = 4, name = (null)}
第 5 个添加任务<NSThread: 0x1702619c0>{number = 5, name = (null)}
第 6 个添加任务<NSThread: 0x170261880>{number = 4, name = (null)}
第 7 个添加任务<NSThread: 0x1702619c0>{number = 5, name = (null)}
第 8 个添加任务<NSThread: 0x170261880>{number = 4, name = (null)}
第 9 个添加任务<NSThread: 0x1702619c0>{number = 5, name = (null)}
第 10 个添加任务<NSThread: 0x170261880>{number = 4, name = (null)}

使用OperationQueue来执行任务与之前的区别在于,首先创建一个非主队列。然后用addOperation方法替换之前的start()方法。刚刚已经说过,OperationQueue会为每一个Operation建立线程并调用他们的start()方法。

观察一下运行结果,所有的Operation都没有在主线程执行,从而成功的实现了异步、并行处理。

除了上述的将Operation添加到队列中的使用方法外,OperationQueue提供了一个更加简单的方法,只需以下两行代码就能实现多线程调用

let operationQueue = OperationQueue()
operationQueue.addOperation {
    print(Thread.current)
}
输出内容:
<NSThread: 0x170460d40>{number = 4, name = (null)}

你可以同时添加一个或这个多个Block来实现你的操作。

取消任务

如果我们有两次网络请求,第二次请求会用到第一次的数据。假设此时网络情况不好,第一次请求超时了,那么第二次请求也没有必要发送了。而且用户也有可能人为地取消某个Operation

当产生这种需求的时候,我们就可以取消这些操作:

//取消Operation
let operation = BlockOperation.init { 
    print("哈哈哈哈哈哈 0")
}
operation.cancel()

//取消某个OperationQueue剩余的Operation
let operationQueue = OperationQueue()
for i in 1...10 {
    operationQueue.addOperation {
       print(Thread.current)
    }
}
operationQueue.cancelAllOperations()

暂停和取消并不会立即暂停或取消当前操作,而是不在调用新的Operation

设置依赖

如果现在需要两次网络请求,第二次请求会用到第一次的数据,所以我们要保证发出第二次请求的时候第一个请求已经执行完,但是我们同时还希望利用到OperationQueue的并发特性(因为可能不止这两个任务)。

这时候我们可以设置Operation之间的依赖关系:

//让operation1在operation2执行完之后执行
let operationQueue = OperationQueue()

let operation1 = BlockOperation.init {
    print("第 1 个添加任务\(Thread.current)")
}        
let operation2 = BlockOperation.init {
    print("第 2 个添加任务\(Thread.current)")
}        
//需要注意的是Operation之间的相互依赖会导致死锁,如果1依赖2,2又依赖1,就会导致死锁。
operation1.addDependency(operation2)
        
operationQueue.addOperation(operation1)
operationQueue.addOperation(operation2)
输入内容:
第 2 个添加任务<NSThread: 0x17027a340>{number = 4, name = (null)}
第 1 个添加任务<NSThread: 0x17027a340>{number = 4, name = (null)}

OperationQueue暂停与恢复

暂停与恢复只需要操作isSuspended属性:

operationQueue.isSuspended = true
operationQueue.isSuspended = false

Operation优先级

每一个Operation的对象都一个queuePriority属性,表示队列优先级。它是一个枚举值,有这么几个等级可选:

public enum QueuePriority : Int {
    case veryLow
    case low
    case normal
    case high
    case veryHigh
}

需要注意的是,这个优先级并不总是起作用,不能完全保证优先级高的任务一定先执行,因为线程优先级代表的是线程获取CPU时间片的能力,高优先级的执行概率高,但是并不能确保优先级高的一定先执行。

参考:iOS多线程编程总结

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容