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

436 阅读16分钟

前言

多线程是iOS开发中很重要的一个环节,无论是开发过程还是在面试环节中,多线程出现的频率都非常高。今天我们来分析多线程知识。

线程 和 进程

线程和进程的定义

线程

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

进程

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

所以,可以简单的理解为:进程是线程的容器,而线程用来执行任务。在iOS中是单进程开发,一个进程就是一个app进程之间是相互独立的,如支付宝、微信、qq等,这些都是属于不同的进程

进程与线程的关系

进程与线程之间的关系主要涉及两个方面:

  • 地址空间

    • 同一个进程的线程共享本进程的地址空间
    • 进程之间则是独立的地址空间
  • 资源拥有

    • 同一个进程内线程共享本进程的资源,如内存、I/O、cpu等
    • 但是进程之间资源是独立的

两个之间的关系就相当于工厂与流水线的关系,工厂与工厂之间是相互独立的,而工厂中的流水线是共享工厂的资源的,即 进程相当于一个工厂线程相当于工厂中的一条流水线

针对进程和线程,还有以下几点说明:

  • 1: 多进程要比多线程健壮

    • 一个进程崩溃后,在保护模式下不会对其他进程产生影响
    • 一个线程崩溃整个进 程都死掉
  • 2: 使用场景:频繁切换、并发操作

    • 进程切换时,消耗的资源大,效率高。所以涉及到频繁的切换时,使用线程要好于进程
    • 同样如果要求同时进行并且又要共享某些变量的并发操作只能用线程不能用进程
  • 3: 执行过程

    • 每个独立的进程有一个程序运行的入口、顺序执行序列和程序入口
    • 但是 线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
  • 4: 线程是处理器调度的基本单位,但是进程不是。

  • 5: 线程没有地址空间,线程包含在进程地址空间中

线程和Runloop的关系

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

多线程

多线程原理

  • 对于单核CPU同一时间,CPU只能处理一条线程,即只有一条线程在工作,
  • iOS中的多线程同时执行的本质是 CPU在多个任务直接进行快速的切换,由于CPU调度线程时间足够快,就造成了多线程的“同时”执行的效果。其中切换的时间间隔就是时间片

多线程意义

优点

  • 能适当提高程序的执行效率
  • 能适当提高资源的利用率,如CPU、内存
  • 线程上的任务执行完成后,线程会自动销毁

缺点

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

多线程生命周期

多线程的生命周期主要分为5部分:新建 - 就绪 - 运行 - 阻塞 - 死亡,

多线程声明周期.png

  • 新建:主要是实例化线程对象

  • 就绪:线程对象调用start方法,将线程对象加入可调度线程池等待CPU的调用,即调用start方法,并不会立即执行,进入就绪状态,需要等待一段时间,经CPU调度后才执行,也就是从就绪状态进入运行状态

  • 运行CPU负责调度可调度线城市中线程的执行,在线程执行完成之前,其状态可能会在就绪和运行之间来回切换,这个变化是由CPU负责,开发人员不能干预。

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

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

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

简要说明,就是处于运行中的线程拥有一段可以执行的时间(称为时间片),

  • 如果时间片用尽,线程就会进入就绪状态队列
  • 如果时间片没有用尽,且需要开始等待某事件,就会进入阻塞状态队列
  • 等待事件发生后,线程又会重新进入就绪状态队列
  • 每当一个线程离开运行,即执行完毕或者强制退出后,会重新从就绪状态队列选择一个线程继续执行

线程的exitcancel说明\

  • exit:一旦强行终止线程,后续的所有代码都不会执行
- `cancel`:取消当前线程,但是不能取消正在执行的线程

线程池原理

线程池原理.png

iOS中多线程的实现方案

iOS中的多线程实现方式,主要有四种:pthread、NSThread、GCD、NSOperation,汇总如图所示

多线程实现方案.png 下面是以上四种方案的简单示例

// *********1: pthread*********
pthread_t threadId = NULL;
//c字符串
char *cString = "HelloCode";
/**
 pthread_create 创建线程
 参数:
 1. pthread_t:要创建线程的结构体指针,通常开发的时候,如果遇到 C 语言的结构体,类型后缀 `_t / Ref` 结尾
 同时不需要 `*`
 2. 线程的属性,nil(空对象 - OC 使用的) / NULL(空地址,0 C 使用的)
 3. 线程要执行的`函数地址`
 void *: 返回类型,表示指向任意对象的指针,和 OC 中的 id 类似
 (*): 函数名
 (void *): 参数类型,void *
 4. 传递给第三个参数(函数)的`参数`
 */
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");
}

void *pthreadTest(void *para){
    // 接 C 语言的字符串
    //    NSLog(@"===> %@ %s", [NSThread currentThread], para);
    // __bridge 将 C 语言的类型桥接到 OC 的类型
    NSString *name = (__bridge NSString *)(para);
    
    NSLog(@"===>%@ %@", [NSThread currentThread], name);
    
    return NULL;
}

C和OC的桥接

其中涉及C与OC的桥接,有以下几点说明

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

多线程相关知识

同步线程:dispatch中的sync函数,即是在当前线程做事情

异步函数:dispatch中的async函数,即在另外一条线程做事情

并发队列:允许多个任务同时执行
可以让多个任务并发(同时)执行(自动开启多个线程同时执行任务)
并发功能只有在异步函数(dispatch_async)下才有效

串行队列:让任务一个接一个地执行(执行完一个再执行下一个)

注:通过CF开头的函数创建出来的变量,需要手动调用CFRealease去释放,但GCD的是不用的

同步异步:能否开启新线程 (决定了是在哪个线程执行)

  • 同步:在当前线程中执行任务.不具备开启新线程的能力
  • 异步:在新的线程中执行任务,具备开启新线程的能力
  • 同步函数:立马在当前线程执行任务,执行完毕后才能继续往下执行,即同步函数内的任务不执行完,该函数就会卡住,不会继续往下执行
  • 异步函数:不要求立马在当前线程执行任务,会等上一个任务执行完再执行

并发和串行:任务的执行方式

  • 并发:多个任务并发(同时)执行
  • 串行:一个任务执行完成后,再执行下一个任务

主队列是一种特殊的串行队列
只要是放到主队列的任务,都是在主线程执行

image.png

死锁:

队列的特点:FIFO(First In First Out)先进先出

产生死锁的两个情况:

  • 当同步函数内的队列是主队列时,会产生死锁
  • 使用sync函数往当前串行队列中添加任务,就会产生死锁

队列组:

dispatch_group_t group = dispatch_group_create();
dispatch_queue_t queue = dispatch_queue_create("myQueue", DISPATCH_QUEUE_CONCURRENT);

dispatch_group_async(group, queue, ^{
    for (int i = 0; i<5; i++) {
        NSLog(@"任务1 ----%@",[NSThread currentThread]);
    };
});

dispatch_group_async(group, queue, ^{
    for (int i = 0; i<5; i++) {
        NSLog(@"任务2 ----%@",[NSThread currentThread]);
    };
});

//等前面的任务都执行完,会自动执行这个任务
dispatch_group_notify(group, queue, ^{
    
    dispatch_async(dispatch_get_main_queue(), ^{
        for (int i = 0; i<5; i++) {
            NSLog(@"任务3 ----%@",[NSThread currentThread]);
        };
    });
});

多线程的安全隐患:

Q:一块资源可能会被多个线程共享,容易造成数据错乱和数据异常的问题
A:解决方法:使用线程同步技术(同步:协同步调,按预定的先后顺序次序进行)
常用的线程同步技术:加锁

  • OSSpinLock(自旋锁):等待锁的线程会处于忙等状态,一直占用着CPU资源(high-level lock)
//需要导入头文件<libkern/OSAtomic.h>

_lock = OS_SPINLOCK_INIT;//初始化
/*
 //也可以这么初始化锁
 static OSSpinLock lock;
 
 static dispatch_once_t onceToken;
 dispatch_once(&onceToken, ^{
 lock = 0;//OS_SPINLOCK_INIT的值就是0
 });
 
 */

OSSpinLockLock(&_lock);//加锁
//需加锁代码
OSSpinLockUnlock(&_lock);//解锁

注意点:

  • 所有线程应该共用一把锁,不然还是会存在问题,每次都创建一把新锁
    若有好几个方法同一时间只能使用一个方法,需要共用一把锁
  • 原理:类似写了一个while循环
  • 目前已经不再安全,有可能出现优先级反转的问题
    若有线程1和线程2,假设线程1的优先级大于线程2,线程2先进入代码,发现锁未加锁,故加锁执行代码,线程1后进入,发现该锁已经被加锁,故忙等,但由于线程1优先级大于线程2,CPU分配更多时间执行线程1的代码,可能导致线程2的代码没有时间执行,导致无法解锁会进入一个类似死锁的状态,目前苹果已经不推荐使用

也可以使用static静态初始化锁,使自旋锁唯一,故锁不一定需要使用属性的方式
无法在static变量初始化时动态调用函数,只能取个值,因为static是静态初始化,右边的值在编译就需要确定,若需要动态调用函数,需要再次使用once函数,在once代码里赋值

当多条线程需要同时修改同一个值时,基本需要加锁,读取则不需要

  • os_unfair_lock(low-level lock)

底层调用看,为互斥锁,等待os_unfair_lock锁的线程会处于休眠,而并非忙等,等不到锁就休眠(ios10以上使用)

//需要导入头文件<os/lock.h>

_lock = OS_UNFAIR_LOCK_INIT;//初始化

os_unfair_lock_lock(&_lock);//加锁
//需加锁代码
os_unfair_lock_unlock(&_lock);//解锁
  • pthread_mutex(low-level lock)

互斥锁,等待锁的线程会处于休眠状态,不需要使用时,要手动销毁

注意点:结构体静态初始化只允许定义的同时进行赋值,不允许后续使用set方法赋值

struct Date {
    int year;
    int month;
}

struct Date date = {2011,10}//这样是可以的

struct Date date;
date = {2011,10}//这样是不可以的

//就是因为这样,解释了为什么不能定义一个pthread_mutex,再通过self的点语法方式对其进行静态初始化的原因


//需要导入头文件<pthread.h>

//静态初始化
//self.lock = PTHREAD_MUTEX_INITIALIZER;

pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_DEFAULT);

pthread_mutex_init(&_lock, &attr);

//锁和条件可以在对象销毁时销毁(declloc)
//属性可以立马销毁
pthread_mutexattr_destroy(&attr);


/*
 pthread_mutex_init(mutex, NULL);
 //初始化时,可以将&attr传NULL,即默认为PTHREAD_MUTEX_DEFAULT
 
 */

PTHREAD_MUTEX_DEFAULT属性替换为PTHREAD_MUTEX_RECUESIVE,将锁变为递归锁,解决递归可能导致死锁的问题

递归锁的本质:允许同一个线程对一把锁进行重复加锁

//条件锁

- (void)define{
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_DEFAULT);
    
    pthread_cond_init(&_con, NULL);//创建一个条件
    
    pthread_mutex_init(&_lock, &attr);
    
    pthread_mutexattr_destroy(&attr);
    
}

- (void)using1{
    pthread_mutex_lock(&_lock);
    
    //需要执行的代码
    pthread_cond_wait(&_con, &_lock);//会休眠,将锁放开,等待信号量到来,再次加锁执行下面代码
    
    pthread_mutex_unlock(&_lock);
}

- (void)using2{
    pthread_cond_signal(&_con);//告知对应的条件锁,该锁已经放开,可以继续使用
}

通过创建条件锁后,pthread_mutex能够办到线程等待的效果(多线程的依赖问题)

tip:

  • 在汇编中敲入si,就是汇编指令级别的一行,遇到函数调用会进入函数内部
  • 汇编中敲入c,就是直接到断点的位置
  • NSLock(low-level lock)

是pthread_mutex的普通锁的封装

遵守NSLocking协议会执行下面两个函数:

  • - (void)lock;
  • - (void)unlock;

其常用的API有2个:

  • - (BOOL)tryLock; 会进行尝试加锁,若能加锁,则加锁执行后续代码,不能加锁,则返回NO,继续执行后续代码(即相当于没有锁)
  • - (BOOL)lockBeforeDate:(NSDate *)limit;会判断在limit时间到来之前,能加锁成功,则加锁,执行后续代码,若等到limit时间到来时,还没加锁成功,则执行后续代码,返回加锁失败
self.lock = [[NSLock alloc] init];

[self.lock lock];
//执行的代码
[self.lock unlock];
  • NSRecursiveLock(low-level lock)

是pthread_mutex的递归锁的封装,用法与NSLock一致

  • NSCondition(low-level lock)

是对pthread_mutex和cond的封装,即加锁解锁条件都能使用

其常用的API有4个:

  • - (void)wait;
  • - (BOOL)waitUntilDate:(NSDate *)limit;
  • - (void)signal;
  • - (void)broadcast;
self.condition = [[NSCondition alloc] init];

[self.condition lock];
//需要执行的代码
[self.condition wait];

[self.condition unlock];


[self.condition signal];//给NSCondition发送信号
[self.condition broadcast];//给NSCondition发送广播
  • NSConditionLock(low-level lock)

是对NSCondition的进一步封装,可以设置具体的条件值

  • - (instancetype)initWithCondition:(NSInteger)condition; 会初始化条件值,若直接使用init方法,则默认初始条件值为0
  • - (void)lockWhenCondition:(NSInteger)condition; 当条件值为condition时加锁
  • - (void)unlockWithCondition:(NSInteger)condition; 该方法会解锁,并将条件值修改为condition
self.lock = [[NSConditionLock alloc]initWithCondition:1];//初始化条件值为1;

[self.lock lockWhenCondition:1];//当条件值为1时加锁

//要做的事

[self.lock unlockWithCondition:2];//该方法会解锁,并将条件值修改为2

注:若调用lock方法是不管条件值,即无论条件值为多少,lock方法都能进;

  • GCD串行队列

直接使用gcd的串行队列也可以实现线程同步的

  • dispatch_semaphore

semaphore:信号量

  • wait函数:如果信号量的值>0,就让信号量的值减1,继续往下执行代码
    如果信号量的值<=0,就会休眠等待,知道信号量的值变成>0,然后就让信号量的值减1,继续往下执行代码
  • signal函数:让信号量的值+1


self.lock = dispatch_semaphore_create(5);//信号量的初始值,可以用来控制线程并发访问的最大数量,最大并发数量在这里就是5

dispatch_semaphore_wait(self.lock, DISPATCH_TIME_FOREVER);

//这里的代码最多只有最大并发数条线程能同时执行,即最多只有5条线程能同时执行

dispatch_semaphore_signal(self.lock);
  • @synchronized

是对mutex递归锁的封装,底层实现是根据传入的对象,在hash表中寻找对应的锁

@synchronized([self class]) { // objc_sync_enter
    //要做的事
}// objc_sync_exit

//()中可以传入任何对象为锁对象,当()中的锁对象一致时,表示共用一把锁

以上的锁的性能从高到低排序:

  • os_unfair_lock
  • OSSpinLock
  • dispatch_semaphore
  • pthread_mutex
  • GCD串行队列
  • NSLock
  • NSCondition
  • pthread_mutex(recursive)
  • NSRecursiveLock
  • NSConditionLock
  • @synchronized

推荐使用:dispatch_semaphore和pthread_mutex,是相对来说性能最高的

当要实现一个方法一把锁时,可以通过static静态初始化变量,再通过dispatch_once方法,令该方法使用唯一一把锁(记得写在方法内部),也可以抽成宏定义,若多个地方需共用一把锁,只能把锁抽出来一起使用

属性的原子性与非原子性:

  • atomic:给属性加上atomic修饰,可以保证属性的setter和getter都是原子性操作,也就是说,保证setter和getter内部是线程同步,即在setter和getter内部中做加锁解锁的操作

原子性操作:即保证多行代码能够按顺序执行完成,就如当做一个整体,同一行代码

atomic并不能保证使用属性的过程是线程安全的,即只能保证setter和getter内部是线程安全的

但atomic耗费性能,因为属性的setter和getter是调用很频繁的,但真正需要加锁操作时,再去进行加锁操作即可

文件I/O操作:

多读单写(读写安全):
1.读取文件是同一时间允许多条线程同时进行读的操作
2.只允许单条线程进行写的操作
3.不允许既有读的操作又有写的操作 ,常用于文件等数据的读写操作

  • pthread_rwlock:等待锁的线程会进入休眠
//需要导入#import <pthread.h>

pthread_rwlock_init(&_lock, NULL);//初始化锁

pthread_rwlock_rdlock(&_lock);//读锁加锁

pthread_rwlock_wrlock(&_lock);//写锁加锁

pthread_rwlock_unlock(&_lock);//解锁
  • dispatch_barrier_async

执行到barrier里面的任务时,会自动出现一个栅栏,执行任务,其他任务时无法再进行读取操作的

//这个函数传入的并发队列必须是自己通过dispatch_queue_create创建的,如果传入的是一个串行或者是一个全局的并发队列,那这个函数等同于dispatch_async函数的效果
self.queue = dispatch_queue_create("queue", DISPATCH_QUEUE_CONCURRENT);

- (void)read {
    dispatch_async(self.queue, ^{
        //读操作
    });
}

- (void)write {
    dispatch_barrier_async(self.queue, ^{
        //写操作
    });
}

注意点:这个函数传入的并发队列必须是自己通过dispatch_queue_create创建的,如果传入的是一个串行或者是一个全局的并发队列,那这个函数等同于dispatch_async函数的效果

原文