iOS多线程之超实用理论+demo演示(可下载)

2020-09-20  本文已影响0人  Dast1

[toc]

背景简介

     在初学iOS相关知识过程中,大多都对多线程有些恐惧的心里,同时感觉工作中用上的概率不大。但是如果平时不多积累并学透多线程,当工作中真的需要用到的时候,就很可能简单百度后把一些知识点稀里糊涂地就用到工作中了,殊不知里面有很多的坑,也有很多技巧需要在理论上先做了解,再结合实战,进一步去体会多线程的魅力和强大。

     接下来,就对多线程来源的背景进行简单的介绍:

     在计算的早期,计算机可以执行的最大工作量是由 CPU 的时钟速度决定的。但是随着技术的进步和处理器设计的紧凑化,热量和其他物理约束开始限制处理器的最大时钟速度。因此,芯片制造商寻找其他方法来提高芯片的总体性能。他们决定的解决方案是增加每个芯片上的处理器核心数量。通过增加内核的数量,一个单独的芯片可以每秒执行更多的指令,而不用增加 CPU 的速度或改变芯片的大小或热特性。唯一的问题是如何利用额外的内核。

     应用程序使用多核的传统方法是创建多个线程。与依赖线程不同,iOS 采用异步设计方法来解决并发问题。通常,这项工作涉及获取一个后台线程,在该线程上启动所需的任务,然后在任务完成时向调用方发送通知(通常通过一个回调函数)。

     iOS 提供了一些技术,允许您异步执行任何任务,而无需自己管理线程。异步启动任务的技术之一是 Grand Central Dispatch (GCD)。这种技术采用线程管理代码,并将该代码移动到系统级别。您所要做的就是定义要执行的任务,并将它们添加到适当的分派队列中。GCD 负责创建所需的线程,并安排任务在这些线程上运行。由于线程管理现在是系统的一部分,GCD 提供了任务管理和执行的整体方法,比传统线程提供了更高的效率。

     OperationQueue(操作队列,api 类名为 NSOperationQueue )是 Objective-C 对象,是对 GCD 的封装。其作用非常类似于分派队列。您定义要执行的任务,然后将它们添加到 OperationQueue 中, OperationQueue 处理这些任务的调度和执行。与 GCD 一样, OperationQueue 为您处理所有线程管理,确保在系统上尽可能快速有效地执行任务。

     接下来,就对现在工作中常用的这两种技术进行比较和实例解析。

GCD、OperationQueue 对比

核心理念

区别

     接下来通过文字,结合实践代码(工程链接在文末)和运行效果 gif 图对部分功能进行分析。

GCD

队列

串行队列(Serial Queues)

     串行队列中的任务按顺序执行;但是不同串行队列间没有任何约束; 多个串行队列同时执行时,不同队列中任务执行是并发的效果。比如:火车站买票可以有多个卖票口,但是每个排的队都是串行队列,整体并发,单线串行。

     注意防坑:串行队列创建的位置。比如下面代码示例中:在for循环内部创建时,每个循环都是创建一个新的串行队列,里面只装一个任务,多个串行队列,结果整体上是并发的效果。想要串行效果,必须在for循环外部创建串行队列。

     串行队列适合管理共享资源。保证了顺序访问,杜绝了资源竞争。

      代码示例:

    private func serialExcuteByGCD(){
        let lArr : [UIImageView] = [imageView1, imageView2, imageView3, imageView4]
        
        //串行队列,异步执行时,只开一个子线程
        let serialQ = DispatchQueue.init(label: "com.companyName.serial.downImage")
        
        for i in 0..<lArr.count{
            let lImgV = lArr[i]
            
            //清空旧图片
            lImgV.image = nil
            
         //注意,防坑:串行队列创建的位置,在这创建时,每个循环都是一个新的串行队列,里面只装一个任务,多个串行队列,整体上是并行的效果。
            //            let serialQ = DispatchQueue.init(label: "com.companyName.serial.downImage")
            
            serialQ.async {
                
                print("第\(i)个 开始,%@",Thread.current)
                Downloader.downloadImageWithURLStr(urlStr: imageURLs[i]) { (img) in
                    let lImgV = lArr[i]
                    
                    print("第\(i)个 结束")
                    DispatchQueue.main.async {
                        print("第\(i)个 切到主线程更新图片")
                        lImgV.image = img
                    }
                    if nil == img{
                        print("第\(i+1)个img is nil")
                    }
                }
            }
        }
    }

gif 效果图:

serialGCD

图中下载时可顺利拖动滚动条,是为了说明下载在子线程,不影响UI交互

log:

第0个 开始
第0个 结束
第1个 开始
第0个 更新图片
第1个 结束
第2个 开始
第1个 更新图片
第2个 结束
第3个 开始
第2个 更新图片
第3个 结束
第3个 更新图片

      由 log 可知: GCD 切到主线程也需要时间,切换完成之前,指令可能已经执行到下个循环了。但是看起来图片还是依次下载完成和显示的,因为每一张图切到主线程显示都需要时间。

并发队列(Concurrent Queues)

     并发队列依旧保证中任务按加入的先后顺序开始(FIFO),但是无法知道执行顺序,执行时长和某一时刻的任务数。按 FIFO 开始后,他们之间不会相互等待。

     比如:提交了 #1,#2,#3 任务到并发队列,开始的顺序是 #1,#2,#3。#2 和 #3 虽然开始的比 #1 晚,但是可能比 #1 执行结束的还要早。任务的执行是由系统决定的,所以执行时长和结束时间都无法确定。

     需要用到并发队列时,强烈建议 使用系统自带的四种全局队列之一。但是,当你需要使用 barrier 对队列中任务进行栅栏时,只能使用自定义并发队列。

Use a barrier to synchronize the execution of one or more tasks in your dispatch queue. When you add a barrier to a concurrent dispatch queue, the queue delays the execution of the barrier block (and any tasks submitted after the barrier) until all previously submitted tasks finish executing. After the previous tasks finish executing, the queue executes the barrier block by itself. Once the barrier block finishes, the queue resumes its normal execution behavior.

     对比:barrier 和锁的区别

      代码示例:

private func concurrentExcuteByGCD(){
        let lArr : [UIImageView] = [imageView1, imageView2, imageView3, imageView4]
        
        for i in 0..<lArr.count{
            let lImgV = lArr[i]
            
            //清空旧图片
            lImgV.image = nil
            
            //并行队列:图片下载任务按顺序开始,但是是并行执行,不会相互等待,任务结束和图片显示顺序是无序的,多个子线程同时执行,性能更佳。
            let lConQ = DispatchQueue.init(label: "cusQueue", qos: .background, attributes: .concurrent)
            lConQ.async {
                print("第\(i)个开始,%@", Thread.current)
                Downloader.downloadImageWithURLStr(urlStr: imageURLs[i]) { (img) in
                    let lImgV = lArr[i]
                      print("第\(i)个结束")
                    DispatchQueue.main.async {
                        lImgV.image = img
                    }
                    if nil == img{
                        print("第\(i+1)个img is nil")
                    }
                }
            }
        }
    }

gif 效果图:


conGCD

log:

第0个开始,%@ <NSThread: 0x600002de2e00>{number = 4, name = (null)}
第1个开始,%@ <NSThread: 0x600002dc65c0>{number = 6, name = (null)}
第2个开始,%@ <NSThread: 0x600002ddc8c0>{number = 8, name = (null)}
第3个开始,%@ <NSThread: 0x600002d0c8c0>{number = 7, name = (null)}
第0个结束
第3个结束
第1个结束
第2个结束

串行、并发队列对比图

gcd-cheatsheet

注意事项

/**
 Submits a block for asynchronous execution on a main queue and returns immediately.
 */
static inline void dispatch_async_on_main_queue(void (^block)()) {
    if (NSThread.isMainThread) {
        block();
    } else {
        dispatch_async(dispatch_get_main_queue(), block);
    }
}

block(块)相关

     调度队列复制添加到它们中的块,并在执行完成时释放块。
     虽然队列在执行小任务时比原始线程更有效,但是创建块并在队列上执行它们仍然存在开销。如果一个块执行的工作量太少,那么内联执行它可能比将它分派到队列中要便宜得多。判断一个块是否工作量太少的方法是使用性能工具为每个路径收集度量数据并进行比较。
     您可能希望将 block 的部分代码包含在 @autoreleasepool 中,以处理这些对象的内存管理。尽管 GCD 调度队列拥有自己的自动释放池,但它们不能保证这些池何时耗尽。如果您的应用程序是内存受限的,那么创建您自己的自动释放池可以让您以更有规律的间隔释放自动释放对象的内存。

dispatch_after

     dispatch_after 函数并不是在指定时间之后才开始执行处理,而是在指定时间之后将任务追加到队列中。这个时间并不是绝对准确的。
  代码示例:

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"2s后执行");
    });

dispatch_semaphore

      在多线程访问可变变量时,是非线程安全的。可能导致程序崩溃。此时,可以通过使用信号量(semaphore)技术,保证多线程处理某段代码时,后面线程等待前面线程执行,保证了多线程的安全性。使用方法记两个就行了,一个是wait(dispatch_semaphore_wait),一个是signal(dispatch_semaphore_signal)。

具体请参考文章Semaphore回顾

dispatch_apply

     当每次迭代中执行工作与其他所有迭代中执行的工作不同,且每个循环完成的顺序不重要时,可以用 dispatch_apply 函数替换循环。注意:替换后, dispatch_apply 函数整体上是同步执行,内部 block 的执行类型(串行/并发)由队列类型决定,但是串行队列易死锁,建议用并发队列。

原循环:

for (i = 0; i < count; i++) {
   printf("%u\n",i);
}
printf("done");

优化后:

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
 
 //count 是迭代的总次数。
dispatch_apply(count, queue, ^(size_t i) {
   printf("%u\n",i);
});

//同样在上面循环结束后才调用。
printf("done");

     您应该确保您的任务代码在每次迭代中完成合理数量的工作。与您分派到队列的任何块或函数一样,调度该代码以便执行会带来开销。如果循环的每次迭代只执行少量的工作,那么调度代码的开销可能会超过将代码分派到队列可能带来的性能优势。如果您在测试期间发现这一点是正确的,那么您可以使用步进来增加每个循环迭代期间执行的工作量。通过大步前进,您可以将原始循环的多个迭代集中到一个块中,并按比例减少迭代次数。例如,如果您最初执行了 100次 迭代,但决定使用步长为 4 的迭代,那么您现在从每个块执行 4 次循环迭代,迭代次数为 25次 。

自问自答

OperationQueue

PS:常见的抽象类有:

可以实现 非FIFO 效果

通过对不同操作设置依赖,或优先级,可实现 非FIFO 效果。
  代码示例:

func testDepedence(){
        let op0 = BlockOperation.init {
            print("op0")
        }
        
        let op1 = BlockOperation.init {
            print("op1")
        }
        
        let op2 = BlockOperation.init {
            print("op2")
        }
        
        let op3 = BlockOperation.init {
            print("op3")
        }
        
        let op4 = BlockOperation.init {
            print("op4")
        }
        
        op0.addDependency(op1)
        op1.addDependency(op2)
        
        op0.queuePriority = .veryHigh
        op1.queuePriority = .normal
        op2.queuePriority = .veryLow
        
        op3.queuePriority = .low
        op4.queuePriority = .veryHigh
        
        gOpeQueue.addOperations([op0, op1, op2, op3, op4], waitUntilFinished: false)
    }

log:

 op4
 op2
 op3
 op1
 op0

 op4
 op3
 op2
 op1
 op0

说明:操作间不存在依赖时,按优先级执行;存在依赖时,按依赖关系先后执行(与无依赖关系的其他任务相比,依赖集合的执行顺序不确定)

队列暂停/继续

通过对队列的isSuspended属性赋值,可实现队列中未执行任务的暂停和继续效果。正在执行的任务不受影响。

///暂停队列,只对未执行中的任务有效。本例中对串行队列的效果明显。并发队列因4个任务一开始就很容易一起开始执行,即使挂起也无法影响已处于执行状态的任务。
    @IBAction func pauseQueueItemDC(_ sender: Any) {
        gOpeQueue.isSuspended = true
    }
    
    ///恢复队列,之前未开始执行的任务会开始执行
    @IBAction func resumeQueueItemDC(_ sender: Any) {
       gOpeQueue.isSuspended = false
    }

gif 效果图:


pauseResume

取消操作

取消单个操作对象

取消(cancel)时,有 3 种情况:
1.操作在队列中等待执行,这种情况下,操作将不会被执行。
2.操作已经在执行中,此时,系统不会强制停止这个操作,但是,其 cancelled属性会被置为 true 。
3.操作已完成,此时,cancel 无任何影响。

取消队列中的所有操作对象

方法: cancelAllOperations。同样只会对未执行的任务有效。
demo 中代码:

    deinit {
        gOpeQueue.cancelAllOperations()
        print("die:%@",self)
    }

自问自答

func addOperation(_ op: Operation)
Discussion:
Once added, the specified operation remains in the queue until it finishes executing.
Declaration

func addOperation(_ block: @escaping () -> Void)
Parameters
block
The block to execute from the operation. The block takes no parameters and has no return value.
Discussion
This method adds a single block to the receiver by first wrapping it in an operation object. You should not attempt to get a reference to the newly created operation object or determine its type information.

常见问题

如何解决资源竞争问题

资源竞争可能导致数据异常,死锁,甚至因访问野指针而崩溃。

  func testDeadLock(){
        //主队列同步执行,会导致死锁。block需要等待testDeadLock执行,而主队列同步调用,又使其他任务必须等待此block执行。于是形成了相互等待,就死锁了。
        DispatchQueue.main.sync {
            print("main block")
        }
        print("2")
    }

但是下面代码不会死锁,故串行队列同步执行任务不一定死锁

- (void)testSynSerialQueue{
    dispatch_queue_t myCustomQueue;
    myCustomQueue = dispatch_queue_create("com.example.MyCustomQueue", NULL);
     
    dispatch_async(myCustomQueue, ^{
        printf("Do some work here.\n");
    });
     
    printf("The first block may or may not have run.\n");
     
    dispatch_sync(myCustomQueue, ^{
        printf("Do some more work here.\n");
    });
    printf("Both blocks have completed.\n");
}

如何提高代码效率

“西饼传说”

代码设计优先级:系统方法 > 并行 > 串行 > 锁,简记为:<u>西饼传说</u>

确定操作对象的适当范围

术语解释摘录

官方并发编程词汇表

本文 demo 地址

MultiThreadDemo

参考文章

Concurrency Programming Guide
iOS Concurrency: Getting Started with NSOperation and Dispatch Queues

下节预告

文中提到的知识点,<u>“与其用操作对象淹没队列,不如批量创建这些对象。当一个批处理完成执行时,使用完成块告诉应用程序创建一个新的批处理”</u>,在最近的工作中的确有需要类似的需求,等有时间会进行总结,就作为下一篇文章的预告吧。

本文由博客群发一文多发等运营工具平台 OpenWrite 发布

上一篇下一篇

猜你喜欢

热点阅读