03-iOS-OC常用语法-----简介与常规应用|多线程【进程与线程、CPU与多线程、生命周期、线程池、线程锁、线程与RunLoop、iOS多线程方案】

1,018 阅读12分钟

前言

最近在围绕iOS编程语言(OC&&Swift)的语法 探索iOS的底层原理实现,在即将探索到 OC 面向对象语法中的:GCD多线程底层原理实现 之前,先简单对在多线程相关的知识点有一个简单的回顾。包括:进程与线程CPU与多线程生命周期线程池线程锁线程与RunLoopiOS的多线程方案

一、进程与线程

我们在学习线程技术的时候,不可避免的要去了解线程的概念
并且,在计算机课程的教材里面,介绍线程的概念的时候,常常把进程的概念一起介绍:

  • 一方面,是因为两者之间有一定的联系;
  • 另一方面,是因为初学线程的同学,往往会把这两个概念给混淆了。

1.什么是进程?

  • 进程概念:
    • 进程是指在系统中正在运⾏的⼀个应⽤程序它是程序执行时的一个实例
      • 程序运行时系统就会创建一个进程,并为它分配资源
      • 然后把该进程放入进程就绪队列
      • 进程调度器选中它的时候就会为它分配CPU时间,程序开始真正运行
  • 每个进程之间是独立的
    • 每个进程运行在其专有的且受保护的内存空间内

在 MAC电脑上,可以通过“活动监视器”查看所开启的进程 image.png

2. 什么是线程

线程概念:

  • 线程进程的基本执行单元一个进程中的所有任务都是在线程中执行的
    • 进程想要执行任务必须得有线程,一个进程至少有一条线程
    • 程序启动是会默认开启一条线程,这条线程被称为主线程或者 UI线程

3. 进程和线程的区别

  • 进程资源分配的最小单位线程程序执行的最小单位
  • 进程有自己的独立地址空间
    • 每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵
  • 线程是共享进程中的数据的
    • 不同的线程使用相同的地址空间(都是一个进程的内存资源)
    • 因此CPU切换一个线程的花费远比进程要小很多
    • 同时创建一个线程的开销也比进程要小很多
  • 线程之间的通信更方便
    • 同一进程下的线程共享全局变量、静态变量等数据
    • 而进程之间的通信需要以通信的方式(IPC)进行
    • 不过如何处理好同步与互斥是编写多线程程序的难点
  • 但是多进程程序更健壮
    • 多线程程序只要有一个线程死掉,整个进程也死掉了
    • 而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间
    • iOS开发 只支持每个应用都是 单进程
    • Android开发 支持一个应用开启 多个进程

二、多线程技术

1. 多线程技术的意义

一个进程的任务都是多个的,单线程执行效率肯定是低下的,在开发中都是多线程编程,为什么要使用多线程呢?

举例说明:

    NSLog(@"开始");
    NSInteger count = 1000 * 100;
    for (NSInteger i = 0; i < count; i++) {
        // 栈区
        NSInteger num = i;
        // 常量区
        NSString *name = @"RENO";
        // 堆区
        NSString *myName = [NSString stringWithFormat:@"%@ - %zd", name, num];
        NSLog(@"%@", myName);
    }
    NSLog(@"结束");

控制台打印:

2022-07-05 21:42:15.517924+0800 001----多线程的作用[35508:936771] 开始
2022-07-05 21:42:15.518147+0800 001----多线程的作用[35508:936771] RENO - 0
2022-07-05 21:42:15.518314+0800 001----多线程的作用[35508:936771] RENO - 1
2022-07-05 21:42:15.518468+0800 001----多线程的作用[35508:936771] RENO - 2
.......
2022-07-05 21:43:03.151830+0800 001----多线程的作用[35508:936771] RENO - 99998
2022-07-05 21:43:03.152314+0800 001----多线程的作用[35508:936771] RENO - 99999
2022-07-05 21:43:03.152691+0800 001----多线程的作用[35508:936771] 结束

在上面的案例中,循环执行十万次的循环,在循中进行还进行局部变量的创建,此过程执行完成共耗时接近一分钟。
如果此流程放在主线程,会造成主线程卡顿,极大的影响用户体验。(因为主线程主要用于界面显示、监听用户交互等事件的)

所以通常情况下,我们都会进行异步处理,开启新的线程对这些事务进行处理,而如果一个事务很复杂,比较耗时,可以将一个大的事务拆分成多个小的事务进行并发处理,这样可以节省时间,并且不会影响用户的体验。

2. 多线程的优缺点

2.1 优点:

  • 能 适当 提⾼程序的执⾏效率
  • 能 适当 提⾼资源的利⽤率(如CPU,内存)
  • 线程上的任务执⾏完成后,线程会⾃动销毁

2.2 缺点:

  • 开启线程需要占⽤⼀定的内存空间(默认情况下,每⼀个线程都占512KB)
  • 如果开启⼤量的线程会占⽤⼤量的内存空间,降低程序的性能
  • 线程越多,CPU在调⽤线程上的开销就越⼤,程序设计更加复杂
    • ⽐如线程间的通信、多线程的数据共享

三、CPU与多线程

1. CPU的时间片技术

时间片(timeslice)又称为“量子(quantum)”或“处理器片(processor slice)”

  • 时间片是分时操作系统分配给每个正在运行的进程微观上的一段CPU时间(在抢占内核中是:从进程开始运行直到被抢占的时间)

简单来说就是:CPU时间片CPU分配给多个程序的时间,每个进程被分配一个时间段,称作它的时间片。(每次分配给进程的时间片,可能执行的是同一条线程上的任务,或者不同线程的任务)

  • 宏观上: 我们可以同时打开多个应用程序,每个程序并行不悖,同时运行;
  • 微观上: 由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行,快速切换。 image.png

2. 单核CPU下的多线程

单核CPU的情况下:

  • 多线程技术的本质:
    • 多线程的执行是CPU快速的在多个线程之间进行切换
    • 若线程数过多,CPU会在多个线程之间切换,消耗大量的CPU资源,反而导致执行效率的下降
  • 多线程同时执⾏:
    • 多线程同时执⾏是CPU快速的在多个线程之间的切换
    • CPU调度线程的时间⾜够快,就造成了多线程的同时执⾏的现象
    • 多线程并发执行并不是真正的同时执行,而是多条线程之间快速切换
    • 如果线程数⾮常多,CPU会在N个线程之间切换
      • 消耗⼤量的CPU资源

      • 每个线程被调度的次数会降低

      • 线程的执⾏效率降低。

3. 多核CPU下的多线程

多核CPU的情况下:

  • 多线程技术的本质:
    • 多线程的执行可以是不同的CPU分配计算资源给多条线程
    • 但若线程数过多,多核CPU也可能出现每个单核CPU 快速的在多个线程之间的切换的现象。
    • 过多开启多条线程,就会消耗大量的CPU资源,导致执行效率的下降

4. 总结

  • 单核CPU的多线程技术
    • 是一种CPU通过时间片技术,给不同的进程分配了计算资源,在每个进程自己抢占的时间窗 快速切换 伪造成 多条线程 同时进行的假象;
  • 多核CPU的多线程技术
    • 内部有多个CPU,可以支持 不同的CPU 分别给 不同进程 分配 计算资源,从而达到真正的多条线程并发执行;
    • 但若是开启了过多数量的线程, 同样会回归到单核CPU的伪多线程的现象
  • 无论是单核CPU还是多核CPU,过渡开启线程都会消耗大量的CPU资源,导致执行效率的下降

四、线程的生命周期

1. 生命周期

在程序开发中有个名词——生命周期,我们都知道 APP有它的生命周期,控制器有它的生命周期View视图有它的生命周期实例对象有它的生命周期...... 那么线程的生命周期是什么样子的呢?

2. 线程生命周期

线程生命周期大致包括 5个阶段:

  • New-新建:
    • 通过创建线程的函数方法,创建一个新的线程;
  • Runable-就绪:
    • 线程创建完成之后,调用 start方法,线程这个时候处于等待状态,等待CPU时间分配执行;
  • Running-运行:
    • 当就绪的线程被调度并获得CPU资源时,便进入运行状态,run方法定义了线程的操作和功能;
  • Block-阻塞:
    • 在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态
      • 比如sleep、等待同步锁
      • 线程就从可调度线程池移出,处于了阻塞状态
    • 当sleep的时间结束了、会获取同步锁,此时会重新添加到可调度线程池,线程被重新唤醒
    • 唤醒的线程不会立刻执行 run方法,它们要再次等待CPU分配资源(时间片技术)进入运行状态
  • Dead-销毁:
    • 如果线程正常执行完毕后或线程被提前强制性的终止或出现异常导致结束,那么线程就要被销毁,释放资源 线程生命周期大致流程图如下: image.png

我们通过一段代码 了解一下 线程状态:

@interface ViewController ()
@property (nonatomic, strong) NSThread *p_thread;
@end

/**
 线程状态演练方法
 */
- (void)testThreadStatus{
    NSLog(@"%d %d %d", self.p_thread.isExecuting, self.p_thread.isFinished, self.p_thread.isCancelled);
    // 生命周期
    
    if ( self.p_thread == nil || self.p_thread.isCancelled || self.p_thread.isFinished ) {
        self.p_thread = [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
        self.p_thread.name = @"跑步线程";
        [self.p_thread start];
    }else{
        NSLog(@"%@ 正在执行",self.p_thread.name);
        
        //可以设置弹框 ---> 这里直接制空
        [self.p_thread cancel];
        self.p_thread = nil;
    }
}

五、线程池的运行策略

线程的工作执行,也是有一定的策略的,线程池的运行策略见下图: image.png

1. 线程池工作队列管理线程执行

线程池工作队列已经装满了,且在线程池中正在运行的线程数小于可最大线程数(有一个核心线程任务可能刚刚执行完),则新进来的任务,会直接创建非核心线程马上执行(结合前面的附图理解)

  • 线程池刚创建时,里面一个线程也没有
  • 任务队列是作为参数传进来的
  • 不过,就算队列里面有任务,线程池也不会马上执行它们

2. 线程池工作策略

  • 当创建了新的线程任务时,线程池会做如下判断:
    • 如果正在运行的线程数量小于corePoolSize(核心线程数)
      • 那么马上创建核心线程运行这个任务。
    • 如果正在运行的线程数量大于或等于corePoolSize(核心线程数)
      • 那么将这个任务放入队列
    • 如果这时候队列满了,而且正在运行的线程数量小于maximumPoolSize(最大线程数),
      • 那么还是要创建非核心线程立刻运行这个任务
    • 如果队列满了,而且正在运行的线程数量大于或等于maximumPoolSize
      • 那么线程池饱和策略将进行处理。
    • 当一个线程完成任务时,它会从队列中取下一个任务来执行。
    • 当一个线程无事可做,超过一定的时间(超时)时,线程池会判断:
      • 如果当前运行的线程数大于corePoolSize,那么这个线程就被停掉
      • 所以线程池的所有任务完成后,它最终会收缩到corePoolSize的大小

3.饱和策略

如果线程池中的队列满了,并且正在运行的线程数量已经大于等于当前线程池的最大线程数,则进行饱和策略的处理:

  • AbortPolicy直接抛出RejectedExecutionExeception异常来阻⽌系统正常运⾏
  • CallerRunsPolicy将任务回退到调⽤者
  • DisOldestPolicy丢掉等待最久的任务
  • DisCardPolicy直接丢弃任务

六、线程与锁

1. 线程锁的意义

下图是:一个线程的经典案例 image.png 车票售卖系统,是线程工作执行的经典案例
如果是多个窗口卖票,会出现资源抢夺的情况,如果 A窗口卖了一张票,B窗口不知道,或者同一时间 AB窗口卖同一张票,这样就会出现问题,所有线程锁的意义重大了。

2. 两类线程锁

2.1 自旋锁

自旋锁是一种用于保护多线程共享资源的锁,与一般互斥锁mutex)不同之处在于:

  • 当自旋锁尝试获取锁时,以忙等待(busy waiting)的形式不断地循环检查锁是否可用;
  • 当上一个线程的任务没有执行完毕的时候(被锁住),那么下一个线程会一直等待(不会睡眠);
  • 当上一个线程的任务执行完毕,下一个线程会立即执行;

在硬件是多核CPU设备的情况下,对持有锁时间较短的程序来说,使用自旋锁代替一般的互斥锁往往能够提高程序的性能

iOS中常见自旋锁: OSSpinLockdispatch_semaphore_t...

2.2 互斥锁

若是对一段程序使用了互斥锁

  • 当上一个线程的任务没有执行完毕的时候(程序被锁住)
  • 那么下一个要执行该程序的任务线程会进入睡眠状态等待任务执行完毕
  • 当上一个线程的任务执行完毕,下一个线程会自动唤醒然后执行任务
    • 该任务也不会立刻执行,而是成为可执行状态(就绪)
    • 需要重新抢占资源,被调度 iOS中常见互斥锁:pthread_mutex@synchronizedNSLockNSConditionLockNSConditionNSRecursiveLock...

2.3 自旋锁和互斥锁的特点

  • 自旋锁会忙等
    • 所谓忙等,即在访问被锁资源时,调用者线程不会休眠,而是不停循环在那里,直到被锁资源释放锁。
  • 互斥锁会休眠
    • 所谓休眠,即在访问被锁资源时,调用者线程会休眠,此时cpu可以调度其他线程工作,直到被锁资源释放锁。此时会唤醒休眠线程。

2.4 自旋锁优缺点

  • 优点在于,因为自旋锁不会引起调用者睡眠,所以不会进行线程调度,CPU时间片轮转等耗时操作。
    • 所以如果能在很短的时间内获得锁,自旋锁的效率远高于互斥锁
  • 缺点在于,自旋锁一直占用CPU,它在未获得锁的情况下,会一直运行自旋,一直占用着CPU
    • 所以如果不能在很短的时间内获得锁,这无疑会使CPU效率降低。自旋锁不能实现递归调用

七、线程与RunLoop的关系

  • RunLoop与线程是一一对应的
    • 一个RunLoop对应一个核心的线程
    • 为什么说是核心的,是因为RunLoop是可以嵌套的,但是核心的只能有一个
    • 有嵌套关系的RunLoop,他们的嵌套信息保存在一个全局的字典里。
  • RunLoop是来管理线程的
    • 当线程的RunLoop被开启后,线程会在执行完任务后进入休眠状态
    • 有了任务就会唤醒线程去执行任务
  • RunLoop在第一次获取时被创建,在线程结束时被销毁
    • 对于主线程来说,RunLoop在程序一启动就默认创建好了
    • 对于子线程来说,RunLoop是懒加载的
      • 只有当我们使用的时候才会创建,所以在子线程用定时器要注意:
      • 确保子线程的runloop被创建,不然定时器不会回调

八、iOS的多程方案

多线程有PthreadNSThreadGCDNSOperation 等方案。 iOS技术方案如下图: image.png 关于多线程的一手资料,可以去苹果文档去看看
Threading Programming Guide
Threading Programming Guide