浅谈iOS中多线程开发

244 阅读19分钟

目录:

(一)线程与进程之间的区别 (二)为什么需要学习多线程 (三)多线程任务执行方式 (四)多线程执行的原理 (五)多线程的优缺点 (六)在iOS开发中的多线程实现技术方案

  • (A)PThread
  • (B)NSThread
  • (C)GCD
  • (D)NSOperation (七)线程锁相关
    (八)总结

    【文章篇幅有点偏多,有兴趣的可以继续读下去】 一般说到线程,那么首先要区分一下线程进程,首先来简单的区分一下两者的关系

    进程: 是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。

    线程: 是指进程内的一个执行单元,也是进程内的可调度实体。是进程的一个实体,是CPU调度和分派的基本单位,他是比进程更小的能独立运行的基本单位,线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(寄存器,栈,程序计数器),但是它可与同一个进程的其他线程共享进程所拥有的全部资源

    (一)线程与进程之间的区别

    (1)地址空间:进程内的一个执行单元,进程至少包含一个线程,他们共享进程的地址空间,而进程有自己独立的地址空间

    (2)资源拥有:进程是资源分配和拥有的单位,同一个进程内的线程共享进程资源 【进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。】

    (3)线程是处理器调度的基本单位,但进程不是

    (4)二者皆可并发执行

    (二)为什么需要学习多线程

    因为在程序运行中,对于网络请求、图片加载、文件处理、数据存储、任务执行等等这些操作都需要放到异步线程中进行处理,这也显得多线程的重要性

    (三)多线程任务执行方式

    主要分为两种:串行并行

    串行: (简易)指的是多个任务按照一定顺序执行(任务执行有顺序依赖关系),例如有三个任务执行,并且需要的执行顺序是 线程1->线程2->线程3,那么这三个任务执行完毕所需的时间就是 t1 + t2 + t3

    串行.png

    并行:(简易)并发执行多个任务(任务执行没有顺序依赖关系),例如有三个任务执行,假设任务2的执行时间最长,那么这三个任务执行完毕所需的时间就是 t2

    并行.png
    有一点需要明白的是:两种任务执行方式并没有好坏之分的,只是根据自己的需求进行选择使用并行执行还是串行执行

    (四)多线程执行的原理

    单核操作系统执行多线程.png
    在单核操作系统的多线程执行,其实是采用时间片轮转调度来实现的,操作系统会采用时间片轮转调度的方式为每一个线程间接性的分配时间执行任务,当线程1执行的时候,线程2就处于阻塞或者空闲的状态,当时间片执行到线程2时,执行循序有会反过来,所以对于单核操作系统来说的多线程执行方式就是:宏观上的并行,微观上的串行
    多核操作系统执行多线程.png
    对于多核操作系统来说,就可以说是真正意义上的并行执行,因为每一个处理器都会按照时间片轮转的方式执行任务,多个核心处理器就可以实现多个任务同时执行的效果

    (五)多线程的优缺点

    优点:

    (1)简化了变成模型:可以将原本放在一个线程中执行的一些耗时或较为大的任务进行分割到多个线程中执行
    (2)更加轻量级
    (3)提高了执行效率
    (4)提高资源利用率
    缺点:

    (1)增加了程序设计的复杂性:因为在多线程中我们需要处理的最大问题就是资源共享问题数据读写问题,如果两个线程同时修改同一个数据或属性,就会出现问题,所以在一定程度上增加了程序设计的复杂性
    (2)占用内存空间:因为如果不分场合随意使用多线程的时候,会导致程序内存的增加,这对客户端开发来说是一个绝对不能忽视的问题,所以我们需要适度、合理的使用多线程开发
    (3)增加CPU调度开销:因为在多线程执行任务时,是使用时间片调度的方式进行的,频繁的切换时间片,必然会增大CPU的调度开销

    (六)在iOS开发中的多线程实现技术方案

    iOS多线程实现技术方法.png
    下面就通过Demo对这四种方式进行一一解释

    (A)PThread

    #pragma mark ---- 测试 pThread
    /**
     测试 pThread
     */
    - (IBAction)runPThread:(id)sender {
        
        NSLog(@"我是在主线程中执行\n\n");
        pthread_t pthread;
        
        pthread_create(&pthread, NULL, run, NULL);
    }
    /**
     C语言函数
    */
    void * run(void * data){
        
        NSLog(@"我是在子线程中执行\n\n");
    
        for (int i = 1; i <= 10; i++) {
            
            NSLog(@"%d \n\n",i);
            sleep(1);
        }
        
        return NULL;
    }
    

    从代码中可以看出pThread的创建执行其实也是比较简单的,不过实现过程是通过C语言进行的,从创建方法pthread_create(<#pthread_t _Nullable *restrict _Nonnull#>, <#const pthread_attr_t *restrict _Nullable#>, <#void * _Nullable (* _Nonnull)(void * _Nullable)#>, <#void *restrict _Nullable#>)可以看出,第一个参数是需要一个pthread 对象指针,第三个是需要一个C语言函数方法(就当于OC中绑定的执行方法),至于第二个和第四个参数,暂时没有什么用(其实偶也不晓得什么作用)可以直接传入NULL

    pthread运行结果.png
    从打印结果中可以看出和我们预期的结果相同,成功的开启了一个子线程 细心地童鞋可以会发现图中红色箭头指向的两组数字,其实在我们的输出控制台输出的都有这两组数字,但是很多朋友可能并没有注意过这些,也不知道是什么意思?!
    控制台.png
    其实第一组数字24592表示的是当前程序所处的 进程 ID,而第二组数字1923132则表示当前所处的线程 ID,所以我们就可以通过线程ID进行判断是否成功开启了一个子线程 ####(B)NSThread NSThread可能是我们在OC开发中接触最早的多线程实现技术,而且NSThread的实现多线程的方式也有三种,下面就通过代码做解释

    NSThread的实现方式一:

    #pragma mark ---- 测试 NSThread
    /**
     测试 NSThread
     */
    - (IBAction)runNSThread:(id)sender {
        NSLog(@"我是在主线程中执行\n");
        /*
         创建方式 1 :通过 alloc initWithTarget 进行创建
         好处:可以通过 NSThread 对象设置一些线程属性;例如线程 名字
         */
        NSThread * thread1 = [[NSThread alloc]initWithTarget:self selector:@selector(runThread1) object:nil];
        [thread1 setName:@"Name_Thread1"];// 设置线程名字
        [thread1 setThreadPriority:0.1];// 设置线程优先级
        [thread1 start];
        
        NSThread * thread2 = [[NSThread alloc]initWithTarget:self selector:@selector(runThread1) object:nil];
        [thread2 setName:@"Name_Thread2"];// 设置线程名字
        [thread2 setThreadPriority:0.5];// 设置线程优先级
        [thread2 start];
    }
    // 方式一
    -(void)runThread1{
        for (int i = 11; i <= 20; i++) {
            
            NSLog(@"%d -- %@",i,[NSThread currentThread].name);
            sleep(1);
            if (i == 20) {
                [self performSelectorOnMainThread:@selector(runMainThread) withObject:nil waitUntilDone:YES];
            }
        }
    }
    -(void)runMainThread{
        NSLog(@" 回调主线程");
    }
    

    NSThread方式一.png
    方式一是通过 alloc initWithTarget 进行创建,这种方式的好处是可以通过 NSThread 对象设置一些线程属性;例如线程 名字,从控制台信息可以看出来,当设置了不同的NSThread对象的优先级属性,可以控制其执行的顺序,优先级越高,越先执行;而设置名字属性后,可以通过调试监控当前所处线程,便于问题分析

    NSThread的实现方式二:

        // 创建方式 2 :通过 detachNewThreadSelector 方式创建并执行线程
        [NSThread detachNewThreadSelector:@selector(runThread2) toTarget:self withObject:nil];
    

    /// 方式二绑定方法

    -(void)runThread2{
        NSLog(@"我是在子线程中执行\n\n");
        for (int i = 11; i <= 20; i++) {
            NSLog(@"%d \n\n",i);
            sleep(1);
            if (i == 20) {
                [self performSelectorOnMainThread:@selector(runMainThread) withObject:nil waitUntilDone:YES];
            }
        }
    }
    

    NSThread方式二.png

    NSThread的实现方式三:

        // 创建方式 3 :通过 performSelectorInBackground 方式创建并执行线程
        [self performSelectorInBackground:@selector(runThread3) withObject:nil];
    

    /// 方式三绑定方法

    /// 方式三
    -(void)runThread3{
        NSLog(@"我是在子线程中执行\n");
        for (int i = 21; i <= 30; i++) {
            NSLog(@"%d \n",i);
            sleep(1);
            if (i == 30) {
                [self performSelectorOnMainThread:@selector(runMainThread) withObject:nil waitUntilDone:YES];
            }
        }
    }
    

    NSThread方式三.png
    在三组控制台输出结果对比可以发现,三种方式都能达到预期效果

    (C)GCD

    关于GCD可能也是我们开发过程中使用最多的一种方式,但是大多数可能都只是只知其一,不知其二,会用其中一两个方法,就觉得会用GCD啦,其实这是远远不够的,那我们就一起来探讨一下GCD的强大之处:

    1、GCD的描述:
    纯C语言开发,是苹果公司为多核的并行运算提出的解决方案,会自动利用更多的CPU内核(比如双核、四核),可以自动管理线程的生命周期(创建线程、调度任务、销毁线程)。
    2、GCD的两个核心
    2.1 任务
    执行的操作,在GCD中,任务是通过 block来封装的。并且任务的block没有参数也没有返回值。
    2.2 队列存放任务包括
    串行队列
    并发队列
    主队列
    全局队列
    

    首先还是像上面一样通过简单Demo看看它的基本功能:

    #pragma mark ---- 测试 GCD
    - (IBAction)runGCD:(id)sender {
        NSLog(@"执行 GCD");
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            NSLog(@" start tast 1");
            // 执行耗时任务
            [NSThread sleepForTimeInterval:3];
            dispatch_async(dispatch_get_main_queue(), ^{
               
                NSLog(@"回调主线程刷新UI");
            });
        });
    }
    

    打印结果:

    GCD打印输出.png
    同样能够实现这样的功能,接下来就一步步的来具体分析GCD:

    (1) dispatch_get_global_queue 探究:

    GCD测试1.png
    由打印信息可以看出,三个线程是同一时间开始执行,同一时间结束执行的, 这就说明GCD中的dispatch_get_global_queue是全局并发的队列

    /*
    第一个参数设置队列 优先级,这样可以控制任务开始执行的先后顺序,第二个参数没有用到
    #define DISPATCH_QUEUE_PRIORITY_HIGH 2 高优先级
    #define DISPATCH_QUEUE_PRIORITY_DEFAULT 0 默认
    #define DISPATCH_QUEUE_PRIORITY_LOW (-2) 低优先级
    */
    dispatch_get_global_queue(long identifier, unsigned long flags)
    

    GCD_global优先级.png
    这样可以根据自己的需要控制任务开始执行的先后顺序。但是如果想让任务结束的时间也按照我们的意愿进行,那就需要使用到串行队列,我们可以根据需要自定义串行队列或者并行队列

    /*
    自定义队列 queue
    参数一:队列标识符
    参数二:定义队列是串行还是并行,NULL(默认)或者 DISPATCH_QUEUE_SERIAL 为串行,DISPATCH_QUEUE_CONCURRENT 表示并行队列
    */
    dispatch_queue_create(<#const char * _Nullable label#>, <#dispatch_queue_attr_t  _Nullable attr#>)
    

    自定义串行队列.png
    自定义并行队列.png
    由上面的这列张图所示的输出信息可以清楚的看出自定义串行队列和并行队列的区别。

    (2)dispatch_group的探索:

    队列组就是可以对多个队列进行操作的一个组,在队列组中可以对不同队列进行操作监听结果等等,首先来说一下队列组的监听方法dispatch_group_notify的用法:

    NSLog(@"执行GCD");
       dispatch_queue_t queue = dispatch_queue_create("GCD_Group", DISPATCH_QUEUE_CONCURRENT);
       dispatch_group_t group = dispatch_group_create();
       dispatch_group_async(group, queue, ^{
           NSLog(@"start task 1");
           [NSThread sleepForTimeInterval:2];
           NSLog(@"end task 1");
       });
       dispatch_group_async(group, queue, ^{
           NSLog(@"start task 2");
           [NSThread sleepForTimeInterval:2];
           NSLog(@"end task 2");
       });
       dispatch_group_async(group, queue, ^{
           NSLog(@"start task 3");
           [NSThread sleepForTimeInterval:2];
           NSLog(@"end task 3");
       });
       /// group 组的监听通知,所有task结束之后回调
       dispatch_group_notify(group, queue, ^{
           NSLog(@"All tasks over");
           /*
               并非另外开辟一个新线程,而是在三个任务中的其中一个子线程进行回调,
               所以如果需要进行刷新 UI的话,需要回调到主线程处理
            */
           dispatch_async(dispatch_get_main_queue(), ^{
               NSLog(@"回调主线程刷新UI");
           });
       });
    

    运行结果为:

    组队咧监听通知.png
    由打印结果可以看出,将三个并行队列放入到队列组中时,使用dispatch_group_notify方法可以对队列执行的结果进行监听,而且这个监听回调只有在队列组中的三个异步线程都处理完成时才会执行回调,这在我们实际开发过程中也是一项非常常见的需求! 不少童鞋看到这里可能觉得会用dispatch_group_notify队列组了,但是还有一种更常见的情况是需要倍加注意的,具体请见下列demo:
    特殊情况队列组.png
    输出结果为:
    特殊情况队列组输出.png
    从队列组输出的信息可以看出,这完全不是预期的输出效果,预期效果因为是:当任务1和任务2都执行完之后在回调dispatch_group_notify,现在打印的结果却是:任务1和任务2开始之后,队列组就回调了dispatch_group_notify,顿时感觉自己使用了一个假的dispatch_group队列组...... 其实这才是实际开发中最常遇到的场景:当我们执行的任务中调起了一个异步的API请求,那么只要这个异步请求开始发送之后,dispatch_group_async就会认为当前任务已经处理完毕,之后这个异步API处理的事情就不在我的监控范围之内啦,所以就造成了这种打印结果的出现。 那么面对这种情况,需要如何处理才能正确监听任务执行结果呢?如下处理:
    使用dispatch_group_enter监听.png
    打印输出结果:
    dispatch_group_enter监听结果.png
    由此可以看出,强大的GCD应对这种情况已经为我们提供了解决方法,使用dispatch_group_enterdispatch_group_leave便可对队列组中的不同异步请求进行监听,最终执行回调dispatch_group_async方法。但是有两点需要注意的是:(1)dispatch_group_enterdispatch_group_leave的使用必须是成对出现;(2)dispatch_group_leave必须放在任务的最后一句执行 当然GCD的队列组的奥秘远不止这些,目前只是列出了常用的集中以及使用场景,如果感兴趣的大神可以继续参考官方API研究!

    (3)dispatch_once探究:

    dispatch_once是GCD提供的一种创建单例的API方法,因为在我们的实际开发过程中,单例也是非常常用的一个场景,例如全局的数据、公共对象等等这些都需要通过单例进行处理,而单例顾名思义,就是在工程的整个运行过程中只会创建一次,然后会存在于内存中。例如:

    /// 单例的创建
    +(instancetype)instance {
        static dispatch_once_t onceToken;
        static SingleTest * inst = nil;
        dispatch_once(&onceToken, ^{
            NSLog(@"初始化单例对象");
            inst = [[SingleTest alloc]init];
        });
        return inst;
    }
    

    调用方法

    单例输出.png
    从输出也可以看出来,只有当第一次点击方法时会创建对象,之后点击方法时将不会在次创建对象,所有打印的对象内存地址都相同,证明是同一个单例对象

    (4)dispatch_after探究:

    延迟执行.png
    这是GCD中提供的一个延时操作API,使用起来很简单,但是在个方法会存在一个陷阱,当延时操作开始之后将无法取消,所以当在一个界面执行延时操作时,界面消失之后仍然会执行操作,这样就可能造成程序crash,所以使用的时候需要多加注意。以上就是对GCD进行的一个简单了解

    (D)NSOperation

    1、NSOperation简介
    1.1 NSOperation与GCD的区别:
    OC语言中基于 GCD 的面向对象的封装;
    使用起来比 GCD 更加简单;
    提供了一些用 GCD 不好实现的功能;
    苹果推荐使用,使用 NSOperation 程序员不用关心线程的生命周期
    1.2 NSOperation的特点
    NSOperation 是一个抽象类,抽象类不能直接使用,必须使用它的子类
    抽象类的用处是定义子类共有的属性和方法
    
    2、核心概念
    将操作添加到队列,异步执行。相对于GCD创建任务,将任务添加到队列。
    将NSOperation添加到NSOperationQueue就可以实现多线程编程
    
    3、操作步骤
    先将需要执行的操作封装到一个NSOperation对象中
    然后将NSOperation对象添加到NSOperationQueue中
    系统会自动将NSOperationQueue中的NSOperation取出来
    将取出的NSOperation封装的操作放到一条新线程中执行
    
    (1)NSInvocationOperation探究

    NSInvocationOperation.png
    1、从打印输出的线程 ID可以看出:NSInvocationOperation的输出操作和[invocationOper start]是在同一个线程中,即[invocationOper start]如果在主线程中发起,则NSInvocationOperation的输出操作也在主线程;[invocationOper start]如果在子线程中发起,则NSInvocationOperation的输出操作也在相应的子线程中;NSInvocationOperation不会开启一个新线程 2、有打印输出的顺序可以看出:NSInvocationOperation的执行是同步执行的

    (2)NSBlockOperation探究

    NSBlockOperation.png
    可以发现NSBlockOperation打印的结果和上面NSInvocationOperation如出一辙,一毛一样,这也就证明了系统提供的两个子类NSInvocationOperation ``NSBlockOperation都是同步执行的

    (3)NSOperationQueue探究

    首先来看一下其相关的概念及关键词

    概念.png
    NSOperationQueue.png
    用输出效果可以看出: 在使用NSOperationQueue对象addOperation的方式执行任务,而不是通过 start执行,输出打印的结果会有明显的不同 1、NSOperationQueue执行任务会开启一个新线程 2、NSOperationQueue执行任务是一个异步的操作过程

    (4)自定义NSOperation子类探究

    首先我们可以创建一个NSOperation的子类,并且重写main方法,在代码中是一个什么效果呢?

    自定义NSOperation子类.png
    调用与输出结果.png
    从结果看出执行任务依然是开启了一个新线程,而且也是异步执行的过程。

    (4.1)maxConcurrentOperationCount 属性:

    未设置并发数时,默认所有任务同时并发执行

    未设置并发数.png
    当设置了最大并发数为 2 时,如下图可以看出NSOperationQueue同时执行的任务数也为两个,当前两个任务执行完毕之后才继续执行后面的任务
    最大并发数.png

    (4.2)addDependency 方法添加依赖:

    一般在我们的实际开发过程中,会遇到异步任务一需要等待异步任务二完成之后才能执行,这种情况下可以就会想到使用多线程的依赖进行实现(当然使用上面说的GCD也可以),那下面就说一下 NSOperation中的addDependency方法:

    addDependency.png
    首先要看一下Demo中的依赖关系是如何添加的?

       [customA addDependency:customC];
       [customC addDependency:customB];
       [customB addDependency:customD];
    

    这三句表示的依赖关系是:customA -> customC -> customB -> customD``customA任务需要在customC任务执行之后才能执行;customC任务需要在customB任务执行之后才能执行;customB任务需要在customD任务执行之后才能执行【注意:customD任务不能再依赖于customA任务,否则就会造成死锁】;使用看到了最终控制台输出的顺序效果。

    当然上面这是一种理想的状态,如果出现了下面这种 “ 变态 ” 情况,这种依赖关系还可靠吗??? 当自定NSOperation的自定义类中的main方法执行的是一个异步任务:

    -(void)main{  
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            [NSThread sleepForTimeInterval:1];
            if (self.cancelled) {
                return ;
            }
            NSLog(@"---%@",self.operName);
        });
    }
    

    输出的打印顺序如下:

    依赖异常.png
    这明显不是按照依赖顺序输出的!那问题到底出在哪呢? 其实是因为自定义的NSOperation子类main方法中,因为main方法执行的是一个异步任务,当任务开始执行之后,NSOperation子类就默认依赖任务完成,而无法监听到这个异步任务执行结束。 但是这种场景也是实际开发中经常用到的,所以要怎样处理呢?解决方法就是使用NSRunLoop进行解决:
    NSRunLoop进行解决.png

    while (!self.over && !self.cancelled) {
            [[NSRunLoop currentRunLoop]runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
        }
    

    在代码中的这句作用就是让当前的 RunLoop 在main方法中等待异步任务的结束,这样一来问题就完美解决啦,下面看一下输出效果:

    结果.png
    输出的结果符合自己设置的依赖预期,问题完美解决。

    (七)线程锁相关

    多线程在开发中给我们带来了很多遍历,但是正如上面所说的多线程也存在一些缺点,例如: 统一个资源可能会被多个线程共享,也就是多个线程可能会访问同一块资源,当多个线程访问同一块资源时,很容易引发数据错乱和数据安全问题,那么这里就需要强调一下线程锁的概念: 关于线程锁的说明,有一个最经典的例子就是购票系统的例子:下面我也根据这个场景说明一下线程锁的使用及重要性:

    #import "TicketManager.h"
    
    @interface TicketManager ()
    /**
     剩余票数
     */
    @property (nonatomic,assign)NSInteger tickets;
    /**
     卖出票数
     */
    @property (nonatomic,assign)NSInteger saleCount;
    /**
     杭州卖票点(线程模拟)
     */
    @property (nonatomic,strong)NSThread * thread_HZ;
    /**
     上海买票点(线程模拟)
     */
    @property (nonatomic,strong)NSThread * thread_SH;
    @end
    
    #define TotalTicket 10// 总票数
    
    @implementation TicketManager
    
    - (instancetype)init{
        if (self = [super init]) {
            
            self.tickets = TotalTicket;
            self.thread_HZ = [[NSThread alloc]initWithTarget:self selector:@selector(sale) object:nil];
            [self.thread_HZ setName:@"HZ_Thread"];
            self.thread_SH = [[NSThread alloc]initWithTarget:self selector:@selector(sale) object:nil];
            [self.thread_SH setName:@"SH_Thread"];
        }
        return  self;
    }
    /// 访问同一份资源,票库
    -(void)sale {
        
        while (true) {
            if (self.tickets > 0) {
                [NSThread sleepForTimeInterval:0.5];
                self.tickets -- ;
                self.saleCount = TotalTicket - self.tickets;
                
                NSLog(@"站点:%@, 当前余票:%ld,售出:%ld",[NSThread currentThread].name,(long)self.tickets,(long)self.saleCount);
            }
        }
    }
    /// 开始卖票
    -(void)startToSaleTicket{
        [self.thread_HZ start];
        [self.thread_SH start];
    }
    
    @end
    

    这种是一个没有线程锁的情况,那先看一下打印的输出结果:

    售票.png
    从结果可以明显的看出多线程访问统一资源的问题,会出现数据错乱。接下来就看一下几种线程锁: 1、互斥锁@synchronized (self) 【使用简单,但是小号CPU资源较大】
    互斥锁.png
    2、NSCondition加锁
    NSCondition加锁.png
    3、NSLock加锁
    NSLock加锁.png
    从三种加锁方式的输出结果可以看出,都能达到预期,能有效防止因多线程抢夺资源造成的数据安全问题。至于具体使用哪种方式,可以根据自己的需求进行选择。

    (八)总结

    本文只是对多线程进行了一个简单的探索研究,希望能够帮助到有需要的童鞋,文章提到的一些知识点并不是很深,需要进行深入研究的朋友可以直接翻看官方API,如果文章中有不足的地方,欢迎指正!