概述
早上起床,你先打开洗衣机,然后用热水把泡面泡上,接着打开电脑开启一天的码农生活。其中“洗衣服”、“泡泡面”和“码代码”3个任务(线程)同时进行,这就是多线程。网上有许多关于多线程的经典解释,此处就不再菜鸟弄斧了,以免贻笑大方。当今流行于世的系统基本都会提供多线程这项基本功能,iOS也不例外。其中Swift提供了3种可选方案:NSThread,GCD和NSOperation,接下来我们将对3种方案进行运用和分析。
NSThread
NSThread是Objective-C给Swift留下的众多遗产之一,并且Swift给其定义了一个简体名称Thread。
1 class ViewController: UIViewController { 2 3 @IBOutlet weak var testLabel: UILabel! 4 var testThread:Thread? = nil 5 var count = 0 6 7 override func viewDidLoad() { 8 super.viewDidLoad() 9 testLabel.text = "Charpter9" 10 11 testThread = Thread.init(target: self, selector: #selector(threadFunc), object: "菜鸟先飞") 12 testThread!.start() 13 } 14 15 override func didReceiveMemoryWarning() { 16 super.didReceiveMemoryWarning() 17 // Dispose of any resources that can be recreated. 18 } 19 20 21 @objc func threadFunc(p: String) { 22 23 while(true) { 24 sleep(1) 25 count += 1 26 print("\(p): \(count)") 27 } 28 } 29 }
创建线程使用Thread.init(target:Any, selector: Selector, object: Any?)。
其中target表示selector函数所属的类,selector表示线程的函数名,object表示函数参数。
如上代码所示,当我们调用testFunc!.start()时,系统就会创建线程并执行threadFunc函数。
(注意gif右下角打印变化)
提示:针对target参数,网上有个千篇一律的说法是:“selector消息发送的对象”。针对这个解释,我只能说“非常忠于原文”,基本就是直译apple的文档。target其实就是selector函数所在的类实例,为什么要说的这么复杂呢?这是因为selector本身并非函数入口地址,而是1个字符串。线程启动时,selector字符串被交给了target,target调用和selector字符串同名的方法,进而启动线程,所以才有之前的那个晦涩的说法。
NSThread是1个轻量级线程调用(相对GCD和NSOperation而言),不带任何“赠品”。如果你要做数据同步,那你得自己加同步锁或信号量(NSLock和NSCondition);线程不用了要记得cancel掉,不然会内存泄漏。总之你得留个心眼儿好好管着NSThread这个熊孩子。
“什么?!内存泄漏?!NSThread太可怕了,又不好管,有没有安全听话一点的东东啊?”
GCD(Grand Central Dispatch)
这是iOS开发中出镜率最高的一种线程机制。如下图所示,
GCD涉及1个先入先出(FIFO)队列,任务依次入队,再依次出队交给线程执行。整个过程中,除了创建队列、新任务和加入任务到队列的动作外,其他均由系统自己处理。FIFO队列和线程的生老病死养老送终都由系统负责解决。真是名副其实的“大中央调度”。接下来我们看看GCD有哪些特性以及如何使用。
首先我们先创建1个队列,label为队列的唯一标识:
queue = DispatchQueue.init(label: "com.ansersion.charpter9.queue.serial")
DispatchQueue有2个将任务加入队列的函数:sync(同步)和async(异步)。这两者有什么区别呢?
在以上代码中,先是循环入队3个任务,并同步执行,然后再循环入队3个任务,并异步执行,每个任务先sleep一秒钟,然后打印线程和时间信息。
我们不妨看看以上代码的运行结果。
我们可以发现“sync”是阻塞的,相当于把任务中的代码原地执行一边,和直接调用1个函数没多大区别。但是“async”就不一样了,它将任务入队就立刻返回,无需等待任务执行完毕。再看看打印信息,我们可以发现“sync”执行时,直接使用的是主程序所在的main线程,而“async”则重开了1个新线程。
GCD有2个重要特性,第一个就是“同步”和“异步”。
同步:任务入队后在当前线程下阻塞执行,不开启新线程。
异步:任务入队后不在当前线程下执行,而是开启新线程,将任务出队到新线程中执行。
如果我们再仔细思考思考“async”执行时打印的信息,我们会发现3个任务是1个接着1个执行的(根据打印时间)。我们不禁会想,系统咋就这么抠门,异步执行只开了1个线程。假如我现在有急事,想让它们同时执行要怎么做呢?
我们只需要将
queue = DispatchQueue.init(label: "com.ansersion.charpter9.queue.serial") //创建串行队列
改为
queue = DispatchQueue.init(label: "com.ansersion.charpter9.queue.concurrent", qos: .default, attributes: .concurrent, autoreleaseFrequency: .inherit, target: nil) // 创建并发队列
就可以了。
假如我想让3个任务一起执行要怎么做呢?
我们再来看看运行结果
根据打印时间,我可以发现3个同步执行的任务和之前没有多大区别,但是3个异步执行的任务是同时执行的,而且系统开了3个线程。
GCD的第二个重要特性是:串行队列和并发队列。
串行队列:只绑定了1个线程,前一个任务执行完毕后,下一个任务才能出队给绑定线程执行。
并发队列:根据需要可以绑定多个线程,不管前一个任务是否执行完毕,只要当前有空闲线程,就将任务出队给空闲线程执行。
关于DispatchQueue.init(label: "com.ansersion.charpter9.queue.concurrent", qos: .default, attributes: .concurrent, autoreleaseFrequency: .inherit,target: nil)
这是一个比较新的init接口,往后会继续传承还是遗弃尘封都还无定数,截至博主发文日期,apple官网上关于这个接口的描述还是个空页面。但是我们要记住attributes这个参数“.concurrent”:表示并发队列。
那么并发队列最多能异步并发多少个线程呢?
stackoverflow上有人做了个实验,发现最多可以实现66个线程并发,这位同学真的很6。然而apple并未给出官方说法,66权且作为1个参考。
现在我们总结一下:
其实我们大可不必自己创建队列,系统本身就为app创建了2个队列:1个是主线程串行队列,1个是全局并发队列。
我们来讲讲主线程队列,app的所有UI更新都是在主线程中进行的。以上我们的实验之所以只靠打印来查看效果是因为其他线程无法更新UI,否则就崩溃伺候。
如果我想在主线程以外的其他线程里更新UI要怎么办呢?
这时候就要靠主线程串行队列了,因为它绑定的是主线程,所以更新UI的任务交给它执行就不会出问题啦。
我们在之前的代码中,再后缀以下内容
queue?.async {
sleep(5)
DispatchQueue.main.async {
self.navigationItem.title = "update UI"
}
}
程序启动5秒钟之后,你会发现导航栏的标题改变了。
注意:不能在主线程中使用主线程队列调用sync,否则直接死锁,因为你已经在执行主线程了,主线程并不空闲,你不能再同步调用它了(不容易理解,需细细体会)。
另外,GCD还可以延时执行任务,分组执行(挨组执行,每组可以有多个任务),这些可以使用DispatchWorkItem作为任务单元或者为async添加参数实现。
此处仅以抛砖引玉,不作细说。
NSOperation
NSOperation本身是一个抽象类,我们通过使用其现成的子类(BlockOperation)或继承它自定义子类来实现1个操作(或任务),然后我们就可以直接运行这个操作,或者将其放入操作队列里执行。
也许你已经发现,其操作和操作队列的概念和GCD的任务和任务队列的概念很像。NSOperation官文文档提到:
An operation queue executes its operations either directly, by running them on secondary threads, or indirectly using the libdispatch library (also known as Grand Central Dispatch)
简言之,NSOperation是对GCD的封装。
既然是一脉相承,那么NSOperation是个长江后浪推前浪的勇进后生呢,还是说只是个既生瑜何生亮的花瓶?
实现真正的同步并发
如前文所述,在GCD中,无论是串行队列还是并发队列,当我们调用同步运行(sync)时,都只相当于原地调用线程的代码。并不存在什么并发之说。现在我们来看看NSOperation是怎么实现同步并发的。
此处我们使用NSOperation的子类BlockOperation,通过调用其接口addExecutionBlock添加了3个操作,每个操作先sleep一秒钟,然后打印线程和时间信息。通过打印,我们看到3个任务是使用了3个线程同时执行的,所以是并发的。因为print("end BlockOperation")是在3个线程打印结束后才执行的,所以是同步的。
实现操作依赖
假使现在我们要开发某款APP,该APP有5个模块,每个模块都有1个线程,现在我们将各个模块分配给5个工程师去开发。
正当我们在为自己优秀的项目管理和分工能力而沾沾自喜的时候。开发A模块的工程师告诉你:“我要等到B模块的运行结果后才能开始启动,否则#¥**&@%^...”。你感觉没什么难度,于是爽快的修改了一下主程序再加了一些线程通信的内容,把A放在B之前运行……这虽然只是你主程序修改的一小步,但却是你悲剧命运的一大步。时过境迁,沧海桑田,你的APP从5个模块变成了50个模块,当这时再有某个工程师找到你,敢问你爽直的豪情是否依旧?
终于有一天,你受不了大喊一声:“你们能不能自己折腾,别来找我?!”
"操作依赖,你值得拥有”
首先我们创建了一个操作队列(OperationQueue),操作队列都是并发异步执行的,但是可以设置最大并发数(maxConcurrentOperationCount),如果设置为1就等于是串行异步执行了。(注:系统提供了一个默认串行异步的操作队列:主操作队列OperationQueue.main,修改其maxConcurrentOperationCount没有任何作用。由于它使用的是主线程,也就是说可以通过它修改UI。)
我们创建了1个slow操作和1个quick操作,并且让quick操作依赖slow操作。通过打印我们发现,2个操作是运行在不同的线程中的,即便quick操作只需要执行1秒,而slow操作需要执行3秒,但是quick操作还是等到slow操作执行完之后才启动的。
那么假使两个操作相互依赖会怎么样呢?
回答是:都无法执行。
唉,码代码永远是走在一条追求完美而不得的不归路上。
支持继承
这并非什么新功能,但却能帮助我们实现良好的代码结构。闲话不多说,代码见分明。
线程安全
线程安全是一个伴随多线程一生的话题。其核心问题就是如何保证对共享资源的串行访问,多线程的一大利器就是并发,而共享资源却是排斥并发的。并发利器和共享资源的矛盾遍布中外贯穿古今。惯用的做法就是对共享资源加锁,有锁的线程访问共享资源,其他线程继续等待(想象一下排队蹲茅厕的场景)。
Swift提供了两种锁,NSLock和NSRecursiveLock,前者是不可递归锁,锁了一次后必须解开后才能再次上锁;后者是可递归锁,比NSLock多了一种功能:在同一个线程中,允许连续n次上锁(相应的也得有n次解锁)。
线程安全是个高深而细分的主题,此处点到为止。
源码下载(NSThread):https://pan.baidu.com/s/1BfVxj9yzkLw22qIk8IAiBg
源码下载(GCD):https://pan.baidu.com/s/1oiBENGLszz6lb1dJRVnulQ
源码下载(NSOperation同步并发):https://pan.baidu.com/s/1TLLtuIP1fjluDwjaIWa8Rg
源码下载(NSOperation操作依赖):https://pan.baidu.com/s/1yAmh1ZLAb2j4zUL2lsU6-Q