iOS进阶之路 (十五)多线程 - 基础

2,650 阅读14分钟

本篇主要涉及多线程的基础知识,内容相对简单,为接下来的GCD、锁做好铺垫。

一. 进程 & 线程 & 任务

1.1 进程 -- process

  • 进程是指在系统中正在运行的一个应用程序。
  • 每个进程之间是独立的,每个进程均运行在其专用的且受保护的内存

补充:iOS系统是相对封闭的系统,App在各自的沙盒(sandbox)中运行,每个App都只能读取iPhone上系统为该应用程序程序创建的文件夹AppData下的内容,不能随意跨越自己的沙盒去访问别的App沙盒中的内容。也就是说OS是单进程的,一个App就是一个进程。

1.2 线程 - thread

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

补充:对于iOS开发来说,线程的底层实现是基于 POSIX threads API 的,也就是我们常说的 pthreads

1.3 任务:task

  • 通俗的说任务就是就一件事情或一段代码,线程其实就是去执行这件事情。

1.4 进程与线程的关系

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

二. 线程和runloop的关系

苹果不允许直接创建 RunLoop,它只提供了两个自动获取的函数:CFRunLoopGetMain()和 CFRunLoopGetCurrent()。 这两个函数内部的逻辑大概是下面这样:

    /// 全局的Dictionary,key 是 pthread_t, value 是 CFRunLoopRef
    static CFMutableDictionaryRef loopsDic;
    /// 访问 loopsDic 时的锁
    static CFSpinLock_t loopsLock;
     
    /// 获取一个 pthread 对应的 RunLoop。
    CFRunLoopRef _CFRunLoopGet(pthread_t thread) {
        OSSpinLockLock(&loopsLock);
        
        if (!loopsDic) {
            // 第一次进入时,初始化全局Dic,并先为主线程创建一个 RunLoop。
            loopsDic = CFDictionaryCreateMutable();
            CFRunLoopRef mainLoop = _CFRunLoopCreate();
            CFDictionarySetValue(loopsDic, pthread_main_thread_np(), mainLoop);
        }
        
        /// 直接从 Dictionary 里获取。
        CFRunLoopRef loop = CFDictionaryGetValue(loopsDic, thread));
        
        if (!loop) {
            /// 取不到时,创建一个
            loop = _CFRunLoopCreate();
            CFDictionarySetValue(loopsDic, thread, loop);
            /// 注册一个回调,当线程销毁时,顺便也销毁其对应的 RunLoop。
            _CFSetTSD(..., thread, loop, __CFFinalizeRunLoop);
        }
        
        OSSpinLockUnLock(&loopsLock);
        return loop;
    }
     
    CFRunLoopRef CFRunLoopGetMain() {
        return _CFRunLoopGet(pthread_main_thread_np());
    }
     
    CFRunLoopRef CFRunLoopGetCurrent() {
        return _CFRunLoopGet(pthread_self());
    }
  • 线程和 RunLoop 之间是一一对应的,其关系是保存在一个全局的 Dictionary 里, key 是 pthread_t, value 是 CFRunLoopRef。
  • RunLoop 的创建是发生在第一次获取时,RunLoop 的销毁是发生在线程结束时。你只能在一个线程的内部获取其 RunLoop(主线程除外)
  • 主线程runloop 程序一启动就默认创建好了,默认开启
  • 子线程runloop 只有当我们使用的时候才会创建,默认关闭,所以在子线程调用runloop方法要开启runloop。

三. 多线程

在同一时刻,一个CPU只能处理1条线程,但CPU可以在多条线程之间快速的切换,只要切换的足够快,就造成了多线程一同执行的假象。

3.1 多线程的意义

  1. 优点
  • 能适当提高程序的执行效率
  • 能适当提高资源的利用率(CPU,内存)
  • 线程上的任务执行完成后,线程会自动销毁
  1. 缺点
  • 开启线程需要占用一定的内存空间(默认情况下,每一个线程都占 512 KB)
  • 如果开启大量的线程,会占用大量的内存空间,降低程序的性能 * 线程越多,CPU 在调用线程上的开销就越大
  • 程序设计更加复杂,比如线程间的通信、多线程的数据共享

3.2 多线程的生命周期

  • 新建:实例化线程对象
  • 就绪:向线程对象发送start消息,线程对象被加入可调度线程池等待CPU调度。
  • 运行:CPU 负责调度可调度线程池中线程的执行。线程执行完成之前,状态可能会在就绪和运行之间来回切换。就绪和运行之间的状态变化由CPU负责,程序员不能干预。
  • 阻塞:当满足某个预定条件时,可以使用休眠或锁,阻塞线程执行。sleepForTimeInterval(休眠指定时长),sleepUntilDate(休眠到指定日期),@synchronized(self):(互斥锁)。
  • 死亡:正常死亡,线程执行完毕。非正常死亡,当满足某个条件后,在线程内部中止执行/在主线程中止线程对象

3.3 线程池 - thread pool

线程池是一种线程使用模式。 线程过多会带来调度开销,进而影响缓存局部性和整体性能。 而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。 这避免了在处理短时间任务时创建与销毁线程的代价。

线程池的执行流程如图下:

  1. 线程池大小 小于 核心线程池大小,创建线程执行任务
  2. 线程池大小 大于等于 核心线程池大小,则判断线程池工作队列是否已满
  • 若没满就将任务提交给工作队列
  • 若已满时,将创建新的线程来执行任务;反之则交给 饱和策略 去处理。

饱和策略:

  • AbortPolicy 直接抛出RejectedExecutionExeception 异常来阻止系统正常运行
  • CallerRunsPolicy 将任务回退到调用者
  • DisOldestPolicy 丢掉等待最久的任务‘
  • DisCardPolicy 直接丢弃任务
  • 这四种拒绝策略均实现的RejectedExecutionHandler接口

所以在并发的时候,同时能有多少个线程在运行是由线程池的线程缓存数量决定。GCD和NSOperation的线程池缓存数量都是64条

3.4 多线程的实现方案

  • GCD仅仅支持FIFO队列,不支持异步操作之间的依赖关系设置。而NSOperation中的队列可以被重新设置优先级,从而实现不同操作的执行顺序调整
  • NSOperation支持KVO,可以观察任务的执行状态
  • GCD更接近底层,GCD在追求性能的底层操作来说,是速度最快的
  • 从异步操作之间的事务性,顺序行,依赖关系。GCD需要自己写更多的代码来实现,而NSOperation已经内建了这些支持
  • 如果异步操作的过程需要更多的被交互和UI呈现出来,NSOperation更好;底层代码中,任务之间不太互相依赖,而需要更高的并发能力,GCD则更有优势。

四. 线程的同步

线程编程的危害之一是在多个线程之间的资源争夺。如果多个线程在同一个时间试图使用或者修改同一个资源,就会出现问题。缓解该问题的方法之一是消除共享资源,并确保每个线程都有在它操作的资源上面的独特设置。因为保持完全独立的资源是不可行的,所以你可能必须使用锁,条件,原子操作和其他技术来同步资源的访问。

我们看一下苹果官方给出的线程同步工具:

4.1 Atomic Operations -- 原子操作

Atomic Operations是一种基于基本数据类型的同步形式,底层用汇编锁来控制变量的变化,保证数据的正确性,好处在于不会block互相竞争的线程,且相比锁耗时很少。

4.2 Memory Barriers -- 内存屏障

为了达到最佳性能,编译器通常会讲汇编级别的指令进行重新排序,从而保持处理器的指令管道尽可能的满。作为优化的一部分,编译器可能会对内存访问的指令进行重新排序(在它认为不会影响数据的正确性的前提下),然而,这并不一定都是正确的,顺序的变化可能导致一些变量的值得到不正确的结果。

Memory Barriers是一种不会造成线程block的同步工具,它用于确保内存操作的正确顺序。Memory Barriers像一道屏障,迫使处理器在其前面完成必须的加载或者存储的操作。Memory Barriers常被用于确保一个线程中可被其他线程访问的内存操作按照预期的顺序执行。具体参考Memory Barriers。

在程序中应用Memory Barriers只需要在指定地方调用:

OSMemoryBarrier();

4.3 Volatile Variables -- 挥发变量

Volatile Variables是另外一种针对变量的同步工具。众所周知,CPU访问寄存器的速度比访问内存速度快很多,因此,CPU有时候会将一些变量放置到寄存器中,而不是每次都从内存中读取(例如for循环中的i值)从而优化代码,但是可能会导致错误。 例如,一个线程在CPUA中被处理,CPUA从内存获取变量F的值,此时,并没有其他CPU用到变量F,所以CPUA将变量F存到寄存器中,方便下次使用,此时,另一个线程在CPUB中被处理,CPUB从内存中获取变量F的值,改变该值后,更新内存中的F值。但是,由于CPUA每次都只会从寄存器中取F的值,而不会再次从内存中取,所以,CPUA处理后的结果就是不正确的。

对一个变量加上Volatile关键字可以迫使编译器每次都重新从内存中加载该变量,而不会从寄存器中加载。当一个变量的值可能随时会被一个外部源改变时,应该将该变量声明为Volatile。

4.4 Locks -- 锁

Locks是一种最常用的同步工具。Locks可以对一段代码进行保护,保证同时只有一个线程在执行该段代码。

关于iOS开发中的各种锁的性能和使用,我们后续单独开一个篇章详细学习。

4.5 Conditions -- 条件

Conditions是一种特殊的lock,用于同步操作的顺序。与Mutex Lock不同的是,一个等待Condition的线程保持block,直到另一个线程显示对该Condition调用signal。

由于操作系统的原因,Conditions可能会得到一些不正确的信号,为了避免这类问题,可以在使用Conditions时,加入Predicate(断言)。Predicate是一种有效地判断是否让一个线程处理信号的方式。Conditions保持线程休眠,直到另一个线程调用signal,并设置了Predicate。

4.6 perform selector routines

cocoa应用可以用一种便利而同步的方式向线程传递消息,NSObjec对象声明了在线程上执行selector的方法,这些方法异步地传递消息,而系统确保会同步地在目标线程上执行这些selector,每个请求都会在目标线程的runloop上排上队,并按收到的顺序进行执行。

五. 线程间通信

线程间通信的表现为:

  • 一个线程传递数据给另一个线程;
  • 在一个线程中执行完特定任务后,转到另一个线程继续执行任务。

先看下官方文档推荐的线程通信方案:

  1. 直接消息传递: 通过 performSelector 的一系列方法,可以实现由某一线程指定在另外的线程上执行任务。因为任务的执行上下文是目标线程,这种方式发送的消息将会自动的被序列化
  2. 全局变量、共享内存块和对象: 在两个线程之间传递信息的另一种简单方法是使用全局变量,共享对象或共享内存块。尽管共享变量既快速又简单,但是它们比直接消息传递更脆弱。必须使用锁或其他同步机制仔细保护共享变量,以确保代码的正确性。 否则可能会导致竞争状况,数据损坏或崩溃。
  3. 条件执行: 条件是一种同步工具,可用于控制线程何时执行代码的特定部分。您可以将条件视为关守,让线程仅在满足指定条件时运行。
  4. Runloop sources: 一个自定义的 Runloop source 配置可以让一个线程上收到特定的应用程序消息。由于 Runloop source 是事件驱动的,因此在无事可做时,线程会自动进入睡眠状态,从而提高了线程的效率
  5. Ports and sockets:基于端口的通信是在两个线程之间进行通信的一种更为复杂的方法,但它也是一种非常可靠的技术。更重要的是,端口和套接字可用于与外部实体(例如其他进程和服务)进行通信。为了提高效率,使用 Runloop source 来实现端口,因此当端口上没有数据等待时,线程将进入睡眠状态
  6. 消息队列: 传统的多处理服务定义了先进先出(FIFO)队列抽象,用于管理传入和传出数据。尽管消息队列既简单又方便,但是它们不如其他一些通信技术高效
  7. Cocoa 分布式对象: 分布式对象是一种 Cocoa 技术,可提供基于端口的通信的高级实现。尽管可以将这种技术用于线程间通信,但是强烈建议不要这样做,因为它会产生大量开销。分布式对象更适合与其他进程进行通信,尽管在这些进程之间进行事务的开销也很高

个人常用的通信方案有:

5.1 NSThread 线程间通信

NSThread这套方案是经过苹果封装后,并且完全面向对象的。不过它的生命周期还是需要我们手动管理,所以实际上使用也比较少。

  1. performSelectorOnMainThread
//数据请求完毕回调到主线程,更新UI资源信息  waitUntilDone  设置YES ,代表等待当前线程执行完毕
[self performSelectorOnMainThread:@selector(dothing:) withObject:@[@"1"] waitUntilDone:YES];
  1. performSelectorInBackground
//将当前的逻辑转到后台线程去执行
[self performSelectorInBackground:@selector(dothing:) withObject:@[@"2"]];
  1. 自己定义线程,将当前数据转移到指定的线程内去通信操作
//支持自定义线程通信执行相应的操作
NSThread * thread = [[NSThread alloc]initWithTarget:self selector:@selector(entryThreadPoint) object:nil];
[thread start];
//当我们需要在特定的线程内去执行某一些数据的时候,我们需要指定某一个线程操作
[self performSelector:@selector(dothing:) onThread:thread withObject:nil waitUntilDone:YES];

5.2 GCD 线程间通信

  1. 需要更新UI操作的时候使用下面这个GCD的block方法
//回到主线程更新UI操作
dispatch_async(dispatch_get_main_queue(), ^{
    //数据执行完毕回调到主线程操作UI更新数据
});
  1. 有时候省去麻烦,我们使用系统的全局队列:一般用这个处理遍历大数据查询操作
DISPATCH_QUEUE_PRIORITY_HIGH  全局队列高优先级
DISPATCH_QUEUE_PRIORITY_LOW 全局队列低优先级
DISPATCH_QUEUE_PRIORITY_BACKGROUND  全局队里后台执行队列
// 全局并发队列执行处理大量逻辑时使用   
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

});
  1. 当在开发中遇到一些数据需要单线程访问的时候,我们可以采取同步线程的做法,来保证数据的正常执行
//当我们需要执行一些数据安全操作写入的时候,需要同步操作,后面所有的任务要等待当前线程的执行
dispatch_sync(dispatch_get_global_queue(0, 0), ^{
    //同步线程操作可以保证数据的安全完整性
});

5.3 NSOperation 线程间通信

if ([[NSThread currentThread] isMainThread]) {
    NSLog(@"## 我是主线程 可以更新UI ##");
} else {
    [[NSOperationQueue mainQueue] addOperationWithBlock:^{
        NSLog(@"###  我是在主队列执行的block  ####");
    }];
}

六. 总结

本篇参照官方文档,学习了多线程的基础知识,下篇开始学习宏大的中央调度系统 - GCD。

参考资料

苹果官方文档 -- Threading Programming Guide

jackyshan -- iOS多线程详解:概念篇

YI_LIN -- 线程同步详解

我是好宝宝 -- iOS探索 多线程原理