iOS底层探索-----多线程原理

1,609 阅读14分钟

前言

这是我参与8月更文挑战的第17天,活动详情查看:8月更文挑战

资源准备

线程和进程

线程和进程的定义

什么是进程

  • 进程是指在系统中正在运行的一个应用程序;
  • 每个进程都是独立的,每个进程均运行在其专用的且受保护的内存空间中;
  • 通过活动监视器可查看 Mac 系统中所开启的进程;
    • MAC是多进程的,iOS是单进程的。

什么是线程

  • 线程是进程的基本执行单元,一个进程中的所以任务都在线程中执行;
  • 进程要想执行任务,必须得有线程,一个进程至少要有一条线程;
  • 程序启动会默认开启一条线程,这条线程称之为主线程或UI线程; 进程中包含多个线程,进程负责任务的调度,线程负责任务的执行。在iOS中并不支持多进程,所有程序都是单一进程运行,进程之间相互独立。

线程与进程的关系

进程与线程的关系,涉及地址空间资源拥有两个方面:

地址空间:同一进程的线程共享本进程的地址空间,而进程与进程之间是独立的地址空间;

资源拥有:同一进程的线程共享本进程的资源,如:内存、IOCPU等,而进程与进程之间的资源是独立的。

两者的使用特点:

  • ①、一个进程崩溃后,在保护模式下不会对其他进程产生影响,但一个线程崩溃会导致整个进程都死掉。所以多进程要比多线程健壮;
  • ②、进程切换时,消耗的资源大,效率高。所以涉及到频繁的切换时,使用线程要好于进程。如果要求同时进行并且又要共享某些变量的并发操作,只能用线程不能用进程;
  • ③、执行过程:每个独立的进程有一个程序运行的入口、顺序执行序列和程序入口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制;
  • ④、线程是处理器调度的基本单位,但是进程不是;
  • ⑤、线程没有地址空间,线程包含在进程地址空间中;

线程局部存储(TLS

线程局部存储全称:Thread Local Storage:线程是没有地址空间的,但是存在线程局部存储。线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的容量。

在以前的文章: 类的加载 上,里面就有分析。在objc源码中,_objc_init方法中包含了对tls的初始化操作,如下图:

7E46382C-286D-43EE-9865-3FB4C226C423.png

tls_init 中的详细实现:

89C03F29-AE5B-449D-8B0F-AC394AE5C1E2.png

多线程

多线程的原理

iOS中的多线程,由CPU在多个任务之间进行快速切换,CPU调度线程的时间足够快,就造成了多线程的 同时 执行的效果;所以,多线程并不是真正的并发。而真正的并发,必须建立在多核CPU的基础上;

多线程的意义

  • 优点:

      1. 适当提高执行效率。
      1. 适当提高资源的利用率(CPU、内存等)。
      1. 线程上的任务执行完后,线程会自动销毁。
  • 缺点

      1. 开启线程需要占用一定的内存空间(参照下面 线程成本 ),默认情况下,每一个线程都占512KB
      1. 如果开启大量线程,会占用大量内存空间,降低程序性能。
      1. 线程越多,CPU在调度线程上的开销越大。
      1. 程序设计更加复杂(如线程间的通讯,多线程的数据共享等)。

时间片

时间片的定义: CPU多个任务之间进行快速切换,这个时间间隔就是时间片

  • 单核CPU同一时间,CPU只能处理1个线程

    • 换言之,同一时间只有1个线程在执行。
  • 多线程同时执行:

    • CPU快速的在多个线程之间的切换。
    • CPU调度线程的时间足够快,就造成了多线程的同时执行的效果。
  • 如果线程数非常多,CPU会在N个线程之间切换,消耗大量的CPU资源。

  • 每个线程被调度的次数会降低,线程的执行效率会降低。

线程的成本

线程在内存使用和性能方面,需要消耗程序和系统一定的代价;

每个线程都需要在内核的内存空间和程序的内存空间中进行分配内存;

管理线程和协调线程,需要调度所需的核心结构,使用有限内存存储在内核中;

线程的堆栈空间和每个线程的数据存储在程序的内存空间中;

大多数结构都是在第一次创建线程时,创建和初始化的 ---- 由于需要与内核进行交互,这个进程的开销相对较大。

以下表格中,量化了在应用程序中创建一个新的用户级线程的大约成本。其中一些成本是可配置的,比如分配给次级线程的堆栈空间量。创建线程的时间成本是一个粗略的近似值,应该仅用于彼此之间的相对比较。线程创建时间的差异很大,这取决于处理器负载、计算机的速度以及可用的系统和程序内存的数量。

线程创建成本: 61415BE6-50C1-4780-8369-70D237EE006D.png

多线程技术方案

7BF10874-3BEB-4B60-A780-F12650541A87.png

C 与 OC 的桥接

  • __bridge只做类型转换,但是不修改对象(内存)管理权。
  • __bridge_retained(也可以使用CFBridgingRetain)将Objective-C的对象转换为Core Foundation的对象,同时将对象(内存)的管理权交给我们,后续需要使用CFRelease或者相关方法来释放对象。
  • __bridge_transfer(也可以使用CFBridgingRelease)将Core Foundation的对象 转换为Objective-C的对象,同时将对象(内存)的管理权交给ARC

线程生命周期

未命名文件-2.png

  • 新建:实例化线程对象;

  • 就绪:线程对象调用start方法,将线程对象加入可调度线程池,等待CPU的调用(调用start方法并不会立即执行,而是进入就绪状态,之后会经过CPU的调度,才会进入运行状态);

  • 运行:CPU负责调度可调度线程池中线程的执行,在线程执行完成之前,其状态可能会在就绪和运行之间来回切换,这个变化是由CPU负责;

  • 阻塞:当满足某个预定条件时,可以使用休眠,即:sleep,或者同步锁,阻塞线程执行。当进入sleep时,会重新将线程加入就绪中。下面关于休眠的时间设置,都是NSThreadAPI

    • sleepUntilDate:阻塞当前线程,直到指定的时间为止,即休眠到指定时间;
    • sleepForTimeInterval:在给定的时间间隔内休眠线程,即指定休眠时长;
    • 同步锁:@synchronized(self)
  • 死亡:分为两种情况:

    • 正常死亡,即:线程执行完毕;
    • 非正常死亡,即:当满足某个条件后,在线程内部或者主线程中终止执行(调用exit方法等退出);

线程池原理

A9B484ED-EC96-41E7-A18E-F983CAD82542.png

  • ①、判断核心线程池是否都正在执行任务:

    • 如果返回NO,创建新的工作线程去执行;
    • 如果返回YES,进入
  • ②、判断线程池工作队列是否饱满:

    • 如果返回NO,将任务存储到工作队列,等待CPU调度
    • 如果返回YES,进入
  • ③、判断线程池中的线程是否都处于执行状态:

    • 如果返回NO,安排可调度线程池中空闲的线程去执行任务
    • 如果返回YES,进入
  • ④、交给饱和策略去执行,分为以下四种拒绝策略

    • AbortPolicy:抛出RejectedExecutionExeception异常,阻止系统正常运行
    • CallerRunsPolicy:将任务回退到调用者
    • DisOldestPolicy:丢掉等待最久的任务
    • DisCardPolicy:直接丢弃任务

    四种拒绝策略均实现的RejectedExecutionHandler接口。

面试题分析

影响任务执行速度的因素有哪些

这个问题可以从一下几个维度分析:CPU的调度情况任务的复杂度任务的优先级线程状态

目前iOS中,线程优先级的threadPriority属性已经弃用,被NSQualityOfService类型的qualityOfService所代替,看先底层的枚举设置:

typedef NS_ENUM(NSInteger, NSQualityOfService) {
    NSQualityOfServiceUserInteractive = 0x21,
    NSQualityOfServiceUserInitiated = 0x19,
    NSQualityOfServiceUtility = 0x11,
    NSQualityOfServiceBackground = 0x09,
    NSQualityOfServiceDefault = -1
} API_AVAILABLE(macos(10.10), ios(8.0), watchos(2.0), tvos(9.0));
  • 开发者自己指定NSQualityOfService:服务质量。用于表示工作的性质和对系统的重要性。当存在资源争用时,使用高质量的服务类比使用低质量的服务类获得更多的资源

    • NSQualityOfServiceUserInteractive:用于直接涉及提供交互式UI的工作。例如:处理控制事件或在屏幕上绘图;

    • NSQualityOfServiceUserInitiated:用于执行用户明确要求的工作,并且为了允许进一步的用户交互,必须立即显示这些工作的结果。例如:在用户在邮件列表中选择邮件后加载邮件;

    • NSQualityOfServiceUtility:用于执行用户不太可能立即等待结果的工作。这项工作可能是由用户请求的,也可能是自动启动的,并且通常使用非模式进度指示器在用户可见的时间尺度上操作。例如:定期内容更新或批量文件操作,如媒体导入;

    • NSQualityOfServiceBackground:用于非用户发起或不可见的工作。通常,用户甚至不知道正在进行这项工作。例如:预抓取内容、搜索索引、备份或与外部系统同步数据;

    • NSQualityOfServiceDefault:表示没有明确的服务质量信息。只要可能,适当的服务质量是根据可用的资源确定的。否则,使用NSQualityOfServiceUserInteractiveNSQualityOfServiceUtility之间的服务质量级别。

优先级反转

线程分为以下两种:

  • IO密集型,频繁等待的线程;

  • CPU密集型,很少等待的线程;

IO密集型比CPU密集型更容易得到线程优先级的提升。

  • I(Input输入) / O(Output输出) 操作的速度是最慢的,并且等待频繁,如果它的优先级又低,很容易被饱和策略所淘汰;

  • 为了避免这种情况,当CPU发现一个频繁等待的线程,会将其优先级提升,从而提升线程被执行的可能性。

优先级的影响因素

  • 用户指定线程的服务质量;

  • 根据线程等待的频繁程度提高或降低;

  • 长时间不执行的线程,提升它的优先级。

线程安全

当多个线程同时访问同一块资源时,很容易引发资源抢夺,造成数据错乱和数据安全问题,有以下两种解决方案:

  • 互斥锁(同步锁):@synchronized

  • 自旋锁

最常见的,当多窗口卖票时,如下图所示,会产生资源的抢夺,这时我们的常规操作就是加锁。 5928833f4af34974aaf13def4e729308~tplv-k3u1fbpfcp-watermark.image.png

互斥锁

  • 用于保护临界区,确保同一时间,只有一条线程能够执行;

  • 如果代码中只有一个地方需要加锁,大多都使用self,这样可以避免单独再创建一个锁对象;

  • 加了互斥锁的代码,当新线程访问时,如果发现其他线程正在执行锁定的代码,新线程就会进入休眠。

使用互斥锁的注意事项:

  • 互斥锁的锁定范围,应该尽量小,锁定范围越大,效率越差;

  • 能够加锁的任意NSObject对象;

  • 锁对象一定要保证所有的线程都能够访问。

自旋锁

  • 自旋锁与互斥锁类似,但它不是通过休眠使线程阻塞,而是在获取锁之前一直处于忙等(即原地打转,称为自旋)阻塞状态;

  • 使用场景:锁持有的时间短,且线程不希望在重新调度上花太多成本时,就需要使用自旋锁,属性修饰符atomic,本身就有一把自旋锁;

  • 加入了自旋锁,当新线程访问代码时,如果发现有其他线程正在锁定代码,新线程会用死循环的方法,一直等待锁定的代码执行完成,即不停的尝试执行代码,比较消耗性能。

自旋锁与互斥锁异同点

相同点:

  • 在同一时间,保证只有一条线程执行任务,即保证了相应同步的功能。

不同点:

  • 互斥锁:发现其他线程执行,当前线程休眠(即就绪状态),进入等待执行,即挂起。一直等其他线程打开之后,然后唤醒执行;

  • 自旋锁:发现其他线程执行,当前线程一直询问(即一直访问),处于忙等状态,耗费的性能比较高。

使用场景:

  • 根据任务复杂度区分,使用不同的锁,但判断不全时,更多是使用互斥锁去处理;

  • 当前的任务状态比较短小精悍时,用自旋锁;

  • 反之的,用互斥锁。

atomic & nonatomic

atomicnonatomic的作用

atomicnonatomic用于属性的修饰,两种修饰符的特定分别如下:

  • atomic是原子属性,为多线程开发准备的,是默认属性,需要消耗大量的资源

    • 仅仅在属性的setter方法中,增加了锁(自旋锁),能够保证同一时间,只有一条线程对属性进行写操作;

    • 同一时间单线程写,多线程读的线程处理技术;

    • Mac开发中常用。

  • nonatomic是非原子属性,适合内存小的移动设备

    • 没有锁,性能高;

    • 移动端开发常用。 然而iOS官方建议:

  • 所有属性都声明为nonatomic,避免多线程抢夺同一块资源。

  • 尽量将加锁、资源抢夺的业务逻辑交给服务器端处理,减小移动客户端的压力。

atomicnonatomic的区别

  • atomic

    • 原子属性(线程安全),针对多线程设计的,默认值;

    • 保证同一时间只有一个线程能够写入,但是同一个时间多个线程都可以取值;

    • atomic本身就有一把锁(自旋锁),支持单写多读。单个线程写入,多个线程可以读取;

    • 线程安全,需要消耗大量的资源。

  • nonatomic

    • 非原子属性;

    • 非线程安全,适合内存小的移动设备。

源码分析

打开objc源码,找到objc_setProperty的方法实现,看源码:

void objc_setProperty(id self, SEL _cmd, ptrdiff_t offset, id newValue, BOOL atomic, signed char shouldCopy) { 
    bool copy = (shouldCopy && shouldCopy != MUTABLE_COPY); 
    bool mutableCopy = (shouldCopy == MUTABLE_COPY); 
    reallySetProperty(self, _cmd, newValue, offset, atomic, copy, mutableCopy); 
}

接着,再进入reallySetProperty方法,看源码:

07B371F4-AC83-44BE-86AF-569727F9B50C.png

  • atomic修饰,增加了spinlock_t的锁操作;
  • 所以atomic是标示,自身并不是锁。而atomic所谓的自旋锁,由底层代码实现。

线程与Runloop的关系

  • Runloop与线程是一一对应的,一个Runloop对应一个核心的线程。为什么说是核心的,是因为Runloop是可以嵌套的,但核心只能有一个,它们的关系保存在一个全局的字典里;

  • Runloop是来管理线程的,当线程的Runloop被开启后,线程会在执行完任务后进入休眠状态,有了任务就会被唤醒去执行任务;

  • Runloop在第一次获取时被创建,在线程结束时被销毁;

  • 主线程的Runloop,在程序启动时默认创建;

  • 对于子线程来说,Runloop是懒加载的,只有当我们使用的时候才会创建。所以,当子线程使用定时器时,要确保子线程的Runloop被创建,不然定时器无法回调。

线程之间的通讯

在某些时候,线程可能需要处理新的工作请求或向您的应用程序的主线程报告它们的进度。在这些情况下,线程之间的通信变得必要,您需要一种将信息从一个线程获取到另一个线程的方法。

线程之间的通信方式有很多种,每种方式都有自己的优点和缺点。

配置线程本地存储列出了您可以在OS X中使用的最常见的通信机制。除了消息队列和Cocoa分布式对象,这些技术在iOS中也可用。

按通讯机制的复杂度升序排列:

5B1E35A0-E62E-4FD2-9F49-4BF166F74453.png