底层原理-21-多线程原理

1,266 阅读14分钟

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

1. 进程和线程的概念

1.1 进程

在iOS开发中我们常说进程指的是一个app程序,每个app是相互独立的,所以进程是独立,iOS开发是单进程开发。

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

1.2 线程

线程是日常开发中常用的,我们开发iOS应用的时候,进程默认开启主线程,请求在子线程,得到数据后刷新UI一般在主线程操作

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

1.3 进程和线程的关系

地址空间:同一进程线程共享本进程的地址空间,而进程之间则是独立的地址空间。 
资源拥有:同一进程内的线程共享本进程的资源如内存I/Ocpu等,但是进程之间的 资源是独立的。

1: 一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮
2: 进程切换时,消耗的资源大,效率高。所以涉及到频繁的切换时,使用线程好于进程。同样如果要求同时进行并且又要共享某些变量的并发操作,只能用线程不能用进程 
3: 执行过程:每个独立的进程有一个程序运行的入口顺序执行序列程序入口。但是 线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制

4: 线程是处理器调度的基本单位,但是进程不是。
5: 线程没有地址空间,线程包含在进程地址空间中。

2. 多线程原理

我们开发都是多线程开发,同一时间,CPU 只能处理 1 个线程,因此多线程实际上是CPU在多线程之间切换执行任务。

2.1 多线程的意义

  • 优点
    1. 能适当提高程序的执行效率

    2. 能适当提高资源的利用率(CPU,内存)

    3. 线程上的任务执行完成后,线程会自动销毁

  • 缺点
    1. 开启线程需要占用一定的内存空间(默认情况下,每一个线程都占 512 KB)
    2. 如果开启大量的线程,会占用大量的内存空间,降低程序的性能
    3. 线程越多,CPU 在调用线程上的开销就越大
    4. 程序设计更加复杂,比如线程间的通信、多线程的数据共享

2.2 多线程原理

时间片的概念:CPU在多个任务直接进行快速的切换,这个时间间隔就是时间片

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

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

    1. 是 CPU 快速的在多个线程之间的切换

    2. CPU 调度线程的时间足够快,就造成了多线程的“同时”执行的效果

  • 如果线程数非常多

    1. CPU 会在 N 个线程之间切换消耗大量的 CPU 资源

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

image.png

由于其底层内核支持,操作对象通常可以更快地创建线程。他们不是每次都从头创建线程,而是使用已经驻留在内核中的线程池来节省分配时间

2.3 多线程实现方案

iOS中线程有4种创建方式:pthreadNSThreadGCDNSOperation,其中常用的CGDNSOperation,下面是他们的区分和实现。

image.png

/**

     pthread_create 创建线程

     参数:

     1. pthread_t:要创建线程的结构体指针,通常开发的时候,如果遇到 C 语言的结构体,类型后缀 `_t / Ref` 结尾

     同时不需要 `*`

     2. 线程的属性,nil(空对象 - OC 使用的) / NULL(空地址,0 C 使用的)

     3. 线程要执行的`函数地址`

     void *: 返回类型,表示指向任意对象的指针,和 OC 中的 id 类似

     (*): 函数名

     (void *): 参数类型,void *

     4. 传递给第三个参数(函数)的`参数`

     

     返回值:C 语言框架中非常常见

     int

     0          创建线程成功!成功只有一种可能

     非 0       创建线程失败的错误码,失败有多种可能!

     */
    

    // 1: pthread

    pthread_t threadId = NULL;

    //c字符串

    char *cString = "HelloCode";


    int result = pthread_create(&threadId, NULL, pthreadTest, cString);

    if (result == 0) {

        NSLog(@"成功");

    } else {

        NSLog(@"失败");

    }

    // 2: NSThread

    [NSThread detachNewThreadSelector:@selector(threadTest) toTarget:self withObject:nil];

    // 3: GCD

    dispatch_async(dispatch_get_global_queue(0, 0), ^{

        [self threadTest];

    });

    // 4: NSOperation

    [[[NSOperationQueue alloc] init] addOperationWithBlock:^{

        [self threadTest];

    }];
    
    - (void)threadTest{

    NSLog(@"begin");

    NSInteger count = 1000 * 100;

    for (NSInteger i = 0; i < count; i++) {

        // 栈区

        NSInteger num = i;

        // 常量区

        NSString *name = @"zhang";

        // 堆区

        NSString *myName = [NSString stringWithFormat:@"%@ - %zd", name, num];

        NSLog(@"%@", myName);

    }

    NSLog(@"over");

}

/**

 1. 循环的执行速度很快

 2. 栈区/常量区的内存操作也挺快

 3. 堆区的内存操作有点慢

 4. I(Input输入) / O(Output 输出) 操作的速度是最慢的!

 * 会严重的造成界面的卡顿,影响用户体验!

 * 多线程:开启一条线程,将耗时的操作放在新的线程中执行

 */

void *pthreadTest(void *para){

    // 接 C 语言的字符串

    //    NSLog(@"===> %@ %s", [NSThread currentThread], para);

    // __bridge 将 C 语言的类型桥接到 OC 的类型

    NSString *name = (__bridge NSString *)(para);

    

    NSLog(@"===>%@ %@", [NSThread currentThread], name);

    

    return NULL;

}

2.4 C和OC的桥接

  • __bridge只做类型转换,但是不修改对象(内存)管理权

  • __bridge_retained(也可以使用CFBridgingRetain)将Objective-C的对象转换为 Core Foundation的对象,同时将对象(内存)的管理权交给我们,后续需要使用 CFRelease或者相关方法来释放对象

  • __bridge_transfer(也可以使用CFBridgingRelease)将Core Foundation的对象 转换为Objective-C的对象,同时将对象(内存)的管理权交给ARC

2.5 线程的生命周期

线程在内存使用性能方面会给你的程序(和系统)带来真正的成本。每个线程都需要在内核内存空间和程序的内存空间中分配内存。管理线程和协调其调度所需的核心结构使用有线内存存储在内核中。线程的堆栈空间和每个线程数据存储在程序的内存空间中。大多数这些结构都是在首次创建线程时创建和初始化的——由于需要与内核的交互,这个过程可能相对昂贵。

未命名文件-6.jpg 线程创建好后执行start操作加入线程池,等待CPU调用,是一个准备的状态。CPU在线程池中调度可执行的线程进行执行任务,当满足一些条件会进行线程堵塞,比如线程休眠同步锁。正常执行完任务,结束线程,但是有可能非正常结束,平时代码崩溃,线程就会退出,终止任务

系统提供自动和全面的线程池管理
未命名文件-7.jpg

线程池执行策略: image.png
1.判断核心线程池是否都正在执行任务,不是的话创建线程执行。
2.线程池工作队列是否饱满,不饱满将任务储存到工作队列。
3.线程池中线程是不是满负荷运行,不是的话安排任务执行。
4.满负荷状态下,执行饱和策略处理。

饱和策略:

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

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

3. 多线程安全

应用程序中存在多个线程,这开启了安全访问多个执行线程资源的潜在问题。两个修改同一资源的线程可能会以意想不到的方式相互干扰。例如,一个线程可能会覆盖另一个线程的更改,或将应用程序置于未知且可能无效的状态。如果您幸运的话,损坏的资源可能会造成明显的性能问题或崩溃,这些问题或崩溃相对容易跟踪和修复。然而,如果你运气不好,腐败可能会导致微妙的错误,这些错误要到很久以后才显现出来,或者这些错误可能需要对你的基本编码假设进行重大改革
是最常用的同步工具之一。您可以使用锁来保护代码的关键部分,该部分代码段一次只能访问一个线程。例如,关键部分可能会操作特定数据结构或使用一次最多支持一个客户端的某些资源
解决多线程安全问题有2种方式可以解决互斥锁和自旋锁。

3.1 锁类型

  • 互斥锁 :相互排斥(或互斥)锁充当资源周围的保护屏障。互斥体是一种信号量,一次只授予对一个线程的访问权。如果一个互斥体正在使用,而另一个线程试图获取它,则该线程会阻塞,直到其原始持有人释放互斥体。如果多个线程竞争同一互斥体,一次只允许一个线程访问它。互斥锁的锁定范围,应该尽量小,锁定范围越大效率越差!

  • 递归锁:递归锁是互斥锁上的变体。递归锁允许单个线程在释放锁之前多次获取锁。其他线程一直被阻塞,直到锁的所有者以相同次数释放锁。递归锁主要用于递归迭代,但也可用于多个方法需要单独获取锁的情况。

  • 读写锁:读写锁也称为共享排他锁。这种类型的锁通常用于更大规模的操作,如果经常读取受保护的数据结构,并且偶尔修改数据结构,则可以显著提高性能。正常情况下,多个读取器可以同时访问该数据结构。但是,当一个线程想要写入结构时,它会阻塞,直到所有的读取者释放锁,然后它会获得锁并更新结构。当一个写线程正在等待锁时,新的读线程会阻塞,直到写线程完成。系统仅支持POSIX线程的读写锁。

  • 分布式锁:分布式锁在进程级别提供互斥访问。与真正的互斥体不同,分布式锁不会阻止进程或阻止其运行。它只是报告锁何时忙,让流程决定如何继续。

  • 自旋锁:自旋锁反复轮询其锁条件,直到该条件变为。自旋锁最常用于多处理器系统,其中锁的预期等待时间很小。在这些情况下,轮询通常比阻塞线程更有效,这涉及上下文切换线程数据结构的更新。由于自旋锁的轮询性质,系统不提供任何自旋锁的实现,但在特定情况下您可以轻松地实现它们

  • 双重检查锁: 双重检查锁是通过在取锁之前测试锁定条件来减少取锁的开销。由于双击锁可能不安全,系统不为其提供明确支持,因此不鼓励使用它们。

3.2 atomic 原子锁 与 nonatomic 非原子锁

nonatomic 非原子属性
atomic 原子属性(线程安全),针对多线程设计的,默认值保证同一时间只有一个线程能够写入(但是同一个时间多个线程都可以取值) atomic 本身就有一把锁(自旋锁) 单写多读:单个线程写入,多个线程可以读取

atomic:线程安全,需要消耗大量的资源
nonatomic:非线程安全,适合内存小的移动设备

iOS 开发的建议:
所有属性都声明为 nonatomic。 尽量避免多线程抢夺同一块资源 尽量将加锁、资源抢夺的业务逻辑交给服务器端处理,减小移动客户端的压力

3.3 线程和Runloop的关系

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

4. 线程间通信

虽然一个好的设计可以将所需的通信量降至最低,但在某个时候,线程之间的通信变得必要。(线程的工作是为您的应用程序工作,但如果该工作的结果从未被使用过,那有什么好处?)线程可能需要处理新的作业请求或将其进度报告到应用程序的主线程。在这种情况下,您需要一种方法从一个线程获取信息到另一个线程。幸运的是,线程共享相同的进程空间这一事实意味着您有很多通信选项

  • 直接消息:Cocoa应用程序支持在其他线程上直接执行选择器的能力。这种能力意味着一个线程基本上可以在任何其他线程上执行方法。由于它们是在目标线程的上下文中执行的,因此以这种方式发送的消息会自动在该线程上序列化。有关输入源的信息,请参阅Cocoa Perform Selector Sources

  • 全局变量、共享内存和对象:两个线程之间传递信息的另一个简单方法是使用全局变量、共享对象或共享内存块。虽然共享变量快速简单,但它们也比直接消息更脆弱。共享变量必须使用锁或其他同步机制仔细保护,以确保代码的正确性。不这样做可能会导致种族条件、数据损坏或崩溃。

  • 条件:条件是一个同步工具,可用于控制线程何时执行特定部分代码。您可以将条件视为门禁,仅在满足所述条件时允许线程运行。有关如何使用条件的信息,请参阅使用条件

  • 运行循环源:自定义运行循环源是您为接收线程上特定于应用程序的消息而设置的源。由于它们是事件驱动的,当无事可做时,运行循环源会自动将线程置于睡眠状态,从而提高线程的效率。有关运行循环和运行循环源的信息,请参阅运行循环

  • 消息队列: 传统的多处理服务定义了一个先进先出(FIFO)队列抽象,用于管理传入和传出数据。虽然消息队列简单方便,但不如其他一些通信技术高效。有关如何使用消息队列的更多信息,请参阅*消息队列编程指南*。

  • Cocoa分布式对象:分布式对象是一种cocoa技术,为基于端口的通信提供高级实现。虽然可以使用这项技术进行线程间通信,但由于它会产生大量开销,因此非常不鼓励这样做。分布式对象更适合与其他进程通信,在这些进程中之间移动的开销已经很高。有关更多信息,请参阅*分布式对象编程主题*。

image.png

5. 总结

进程是一个程序,线程是基本执行单元,程序运行的时候主线程默认开启,runloop也是默认开启的。iOS是单进程开发,同一时间只有一个线程可以让CPU执行。多线程原理是CPU循环执行线程池线程,间隔是时间片(线程默认执行的时间,执行时间还没执行完则进入准备状态Runnable,等待再次执行)。多线程在iOS中主要是4钟创建方式:pthreadNSThreadGCDNSOperation。常用的是GCDNSOperation。多线程开发涉及到了线程安全问题,因此需要加锁操作,常用的锁互斥锁自旋锁。atomic和nonatomic修饰的属性一个是原子属性一个是非原子,原子操作写入的时候加锁,单写多读