iOS 多线程(一):基础(原理、线程池、锁)

1概述

1.1 进程和线程的区别和联系:

  1. 地址资源:进程有自己的内存地址,进程内的线程可以共享进程的内存地址
  2. 资源分配和调度:进程是系统进行资源分配和拥有的基本单位,同一个进程内的线程可共享进程的资源,线程是 CPU 调度的基本单位。
  3. 二者都可以并发执行

1.2 多线程的优缺点

  • 优点:
    • 提高资源(CPU 和内存)利用率
    • 提高程序的执行效率
    • 线程上的任务执行完后,线程会自动销毁
  • 缺点:
    • 开辟线程需要占用一定的内存空间(默认每个线程占用 512kb)
    • 线程越多,CPU 调度的开销越大

1.3 多线程原理

多线程的并发执行其实并不是同时执行,而是 CPU 在不同的线程间频繁切换,达到的"伪同时"效果。这是由于每一个分得 CPU 的任务都会有一个时间片,它执行完时间片的时间,CPU 就不属于它们了,要等待再次分配。

2 线程和Runloop的关系

  1. Runloop和线程是一一对应的关系
  2. Runloop是来管理线程的,当线程的runloop被开启后,线程执行完任务会休眠,等下次有任务时再执行任务。
  3. 线程在第一次创建是被开启,在线程结束时销毁
  4. Runloop在子线程中默认不开启,需要手动操作才能开启。注意NSTImer

3 线程池

3.1 线程池是什么

线程池是一种"池化"的线程使用模式。线程的创建、销毁、调度都有一定的开销,通过预先创建一定数量的线程,让这些线程处于就绪状态来提高系统响应速度,在线程使用完成后归还到线程池来达到重复利用的目的,从而降低系统资源的消耗、提高响应速度,以及增加了线程的可管理性。

除去线程池,还有其他比较典型的几种使用策略包括:

  1. 内存池(Memory Pooling):预先申请内存,提升申请内存速度,减少内存碎片。
  2. 连接池(Connection Pooling):预先申请数据库连接,提升申请连接的速度,降低系统的开销。
  3. 实例池(Object Pooling):循环使用对象,减少资源在初始化和释放时的昂贵损耗。

3.2 核心参数

线程池的实现大同小异,以 java 中的ThreadPoolExecutor为研究对象

java 复制代码
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit timeUnit,
                          BlockingQueue<Runnable> workQueue, 
                          ThreadFactory threadFactory, 
                          RejectedExecutionHandler handler)

corePoolSize核心线程数

corePoolSize 为线程池核心线程数。默认情况下,核心线程会一直存活。

maximumPoolSize最大线程数

maximumPoolSize 为线程池所能容纳的最大线程数。

keepAliveTime闲置线程超时时长

keepAliveTime 表示闲置线程超时时长。如果线程闲置时间超过该时长,非核心线程就会被回收。

timeUnit超时时间单位

timeUnit 表示线程闲置超时时长的时间单位。常用的有:TimeUnit.MILLISECONDS(毫秒)、TimeUnit.SECONDS(秒)、TimeUnit.MINUTES(分)。

blockingQueue阻塞队列

blockingQueue 表示阻塞队列。

线程池任务队列的常用实现类有:

  1. ArrayBlockingQueue :一个数组实现的有界阻塞队列,此队列按照FIFO的原则对元素进行排序,支持公平访问队列。
  2. LinkedBlockingQueue :一个由链表结构组成的可选有界阻塞队列,如果不指定大小,则使用Integer.MAX_VALUE作为队列大小,按照FIFO的原则对元素进行排序。
  3. PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列,默认情况下采用自然顺序排列,也可以指定Comparator。
  4. DelayQueue:一个支持延时获取元素的无界阻塞队列,创建元素时可以指定多久以后才能从队列中获取当前元素,常用于缓存系统设计与定时任务调度等。
  5. SynchronousQueue:一个不存储元素的阻塞队列。存入操作必须等待获取操作,反之亦然。
  6. LinkedTransferQueue:一个由链表结构组成的无界阻塞队列,与LinkedBlockingQueue相比多了 transfer 和 tryTranfer 方法,该方法在有消费者等待接收元素时会立即将元素传递给消费者。
  7. LinkedBlockingDeque:一个由链表结构组成的双端阻塞队列,可以从队列的两端插入和删除元素。

threadFactory线程工厂

threadFactory 表示线程工厂。用于指定为线程池创建新线程的方式。

RejectedExecutionHandler饱和策略

rejectedExecutionHandler 表示拒绝策略。当达到最大线程数且队列任务已满时需要执行的拒绝策略,常见的拒绝策略如下:

  1. AbortPolicy:中止策略,属于默认的饱和策略,该策略将抛出未检查的RejectedExecutionException。调用者可以捕获这个异常,然后根据需要编写自己的处理代码。
  2. DiscardPolicy:抛弃策略,当新提交的任务无法保存到队列中等待执行时,"抛弃(Discard)"策略会悄悄抛弃该任务。
  3. CallerRunsPolicy:当任务队列满时使用调用者的线程直接执行该任务。
  4. DiscardOldestPolicy:当任务队列满时丢弃阻塞队列头部的任务(即最老的任务),然后添加当前任务。

3.3 线程池工作流程

线程池工作流程如图所示

当提交一个任务时:

  1. 如果线程池的线程数量小于 corePoolSize,则创建线程执行任务。
  2. 如果线程池的线程数量大于等于 corePoolSize,则放入工作队列中。
  3. 如果不巧,工作队列也满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建线程立刻运行这个任务。
  4. 如果正在运行的线程数量大于等于 maximumPoolSize,则交给饱和策略处理。

实际上创建完线程池并不会立即创建核心线程,而是等到有任务提交时才会创建线程,当线程数小于核心线程数时,每提交一个任务就创建一个线程来执行,即使当前有线程处于空闲状态,直到当前线程数达到核心线程数。

当线程池的线程执行完任务以后,会从工作队列取下一个任务执行。 当一个线程空闲,且正在运行的线程数大于 corePoolSize,则开始后计时,超过 keepAliveTime 线程就会被 kill 掉,所以当线程池中任务都完成后,它最终会缩小到 corePoolSize 大小。

网上找了一个更直观的图描绘这个流程

3.4 线程池的生命周期

ThreadPoolExecutor 的运行状态有 5 种,分别为:

其生命周期转换如下图所示:

3.5 线程的生命周期

线程生命周期的每一步如下所示:

  • 1、新建:实例化线程对象
  • 2、就绪:调用start将线程加入可调度线程池,等待CPU调度(分配时间片)。
  • 3、运行:CPU从可调度线程池中分配时间片给线程,线程在未执行完毕情况下可能会在就绪运行之间不断切换,程序员无法干预。
  • 4、阻塞:线程有时会因为同步、锁、sleep等方式阻塞。
  • 5、死亡:分为正常死亡(线程结束)和非正常死亡(线程终止)。

4 线程安全(锁)

当多个线程访问同一块资源时,容易引发数据错乱和数据安全问题,如何避免这种情况发生?

答案是锁,在iOS中锁的基本种类只有两种:互斥锁自旋锁,其他的比如条件锁递归锁信号量都是上层的封装和实现。

  • 互斥锁(Mutual exclusion,缩写Mutex)当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒
    • 递归锁:可重入锁,同一个线程在锁释放前可再次获取锁,即可以递归调用
    • 非递归锁:不可重入,必须等锁释放后才能再次获取锁
  • 自旋锁:当获取锁操作失败时,线程会反复检查锁变量是否可⽤,一直处于等待状态(忙等待)不会进入休眠,因此效率高。

互斥锁性能对比如图所示

4.1 自旋锁

自旋锁是多线程同步的一种锁,线程反复检查锁变量是否可用。由于线程在这一过程中保持执行,因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的

1.OSSpinLock

原本OSSpinLock是性能最高的锁,但因为自旋锁在线程获取锁时,会一直处于忙等待状态,可能造成任务的优先级反转,因此被苹果放弃。

什么是优先级翻转?

iOS 系统中维护了 5 个不同的线程优先级/QoS: backgroundutilitydefaultuser-initiateduser-interactive。高优先级线程始终会在低优先级线程前执行,一个线程不会受到比它更低优先级线程的干扰。这种线程调度算法会产生潜在的优先级反转问题,从而破坏 spin lock。

举例来说:

如果一个低优先级线程获得了锁并访问共享资源,这时一个高优先级的线程也尝试获得锁来访问这个资源,这时OSSpinLock的忙等机制导致高优先级线程 一直 running 等待,占用 CPU 时间片;而低优先级线程 无法和高优先级线程争抢时间片,从而没法完成自己的任务,导致不释放锁的情况。

这个例子要结合线程执行任务的原理来理解:

多线程的并发执行其实并不是同时执行,而是 CPU 在不同的线程间频繁切换,达到的"伪同时"效果。这是由于每一个分得 CPU 的任务都会有一个时间片,它执行完时间片的时间,CPU 就不属于它们了,要等待再次分配。

2.atomic

Objective-C 中定义一个属性的时候,有时属性会被声明为atomic即这个属性的 setter 操作和 getter 操作是原子性的,那么如何确保这些操作的原子性呢?Apple 在 iOS10 之前使用的方案是 OSSpinLock,在 iOS10 以后改用os_unfair_lock

但这种方式也不一定确保数据安全,atomic只能保证 setter、getter 方法的线程安全,

objc 复制代码
- (void)viewDidLoad {
    [super viewDidLoad];
    self.index = 0;
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        for (int i = 0; i < 10000; i++) {
            self.index = self.index + 1;
            NSLog(@"A 线程循环次数%d--self.index=%ld", i, self.index);
        }
    });

    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        for (int i = 0; i < 10000; i++) {
            self.index = self.index + 1;
            NSLog(@"B 线程循环次数%d--self.index=%ld", i, self.index);
        }
    });
}

结果如下图所示

atomic修饰的index变量分别在两次并发异步for循环10000次后输出的结果并不等于20000

由此可知atomic只能保证变量在 setter 和 getter 时的线程安全,但不能保证self.index+1也是安全的,如果改成self.index=i则能保证 setter 方法的线程安全的。

3.读写锁

读写锁(pthread_rwlock_t)实际是一种特殊的自旋锁,它把对共享资源的访问者划分成读者写者,⼀个读写锁同时只能有⼀个写者或多个读者(与CPU数相关),但不能同时既有读者⼜有写者。

这种锁相对于自旋锁而言,能提高并发性,因为在多处理器系统中,它允许同时有多个读者来访问共享资源。

平时很少会直接使用读写锁pthread_rwlock_t,更多的是采用其他方式,比如栅栏函数完成读写锁的需求

4.2 互斥锁

如果调用线程在想要获得锁资源的时候发现锁已经被其他线程持有,那么该调用线程将会进入休眠状态,CPU 进而去执行其他线程任务,直到被锁资源释放锁,此时会唤醒休眠线程,这样抢占式策略不会占用 CPU 资源。但是因为关系到 CPU 上下文切换,因此会有时间的消耗。

1.pthread_mutex

pthread_mutex就是互斥锁本身------当锁被占用,而其他线程申请锁时,不是使用忙等,而是阻塞线程并睡眠,使用方式如下

objc 复制代码
// 导入头文件
#import <pthread.h>
// 全局声明互斥锁
pthread_mutex_t _lock;
// 初始化互斥锁
pthread_mutex_init(&_lock, NULL);
// 加锁
pthread_mutex_lock(&_lock);
// 这里做需要线程安全操作
// ...
// 解锁 
pthread_mutex_unlock(&_lock);
// 释放锁
pthread_mutex_destroy(&_lock);

还有pthread_mutex_trylock是尝试加锁,引用 YYMemoryCache 中的代码解释一下

objc 复制代码
 while (!finish) {
    // 如果锁被其他线程使用,则返回其他值
    // 如果没被其他线程使用,则加锁,并返回 0
    if (pthread_mutex_trylock(&_lock) == 0) {
        if (_lru->_tail && (now - _lru->_tail->_time) > ageLimit) {
            _YYLinkedMapNode *node = [_lru removeTailNode];
            if (node) [holder addObject:node];
        } else {
            finish = YES;
        }
        pthread_mutex_unlock(&_lock);
    } else {
        usleep(10 * 1000); //10 ms
    }
}

可以通过判断决定是否使用锁,而不需要等待,提高实时性。

trylock 平时并不常用,最常用的还是初始化,lock,unlock,然后在 dealloc 中 destory 就行。

FBKVOController 中有使用pthread_mutex

2.@synchronized

@synchronized 是一个互斥递归锁 ,日常开发中用的比较多的一种互斥锁,因为它的使用比较简单,但并不是在任意场景下都能使用@synchronized,且它的性能较低。

使用方式如下:

objc 复制代码
@synchronized (obj) {
    // Add operation
} 

3.NSLock

NSLock是对互斥锁(pthread_mutex)的简单封装,使用如下

ini 复制代码
NSLock *lock = [[NSLock alloc] init];
[lock lock];
[lock unlock];

看一下 NSLock 的 api

objc 复制代码
@interface NSLock : NSObject <NSLocking>
- (BOOL)tryLock;
- (BOOL)lockBeforeDate:(NSDate *)limit;
@property (nullable, copy) NSString *name;
@end
  • tryLock方法和 pthread_mutex_trylock 一样,都是尝试获取锁,不同的是返回值的意义:如果获取到锁返回YES,没获取到返回NO

    • 使用场景:如果获取当前线程锁失败,也可以继续其他任务,就用 tryLock。如果只有获取到锁才能执行有意义的任务,那就用 lock。
  • lockBeforeDate视图在指定时间获取一个锁,如果没获取到,就让线程从阻塞状态变成非阻塞状态。

需要注意互斥锁在递归调用时会造成堵塞,并非死锁------这里的问题是后面的代码无法执行下去,看下面例子

objc 复制代码
- (void)test {
    NSLock *lock = [[NSLock alloc] init];
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        static void (^block)(int);
        
        block = ^(int value) {
            NSLog(@"加锁前");
            [lock lock];
            NSLog(@"加锁后");
            if (value > 0) {
                NSLog(@"value------%d", value);
                block(value - 1);
            }
            [lock unlock];
        };
        block(10);
    });
}

输出结果并没有按代码表面的想法去走,而是只打印了一次value值

objc 复制代码
加锁前 
加锁后 
value------10 
加锁前

因为互斥锁在循环调用时造成了阻塞,想循环调用就使用``NSRecursiveLock`。

NSLock在 AFNetworking 中有使用。

4.NSRecursiveLock

NSRecursiveLock使用和NSLock类似,比如上面的代码就可以用NSRecursiveLock代替,

objc 复制代码
- (void)test {
    NSRecursiveLock *lock = [[NSRecursiveLock alloc] init];
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        static void (^block)(int);
        
        block = ^(int value) {
            [lock lock];
            if (value > 0) {
                NSLog(@"value------%d", value);
                block(value - 1);
            }
            [lock unlock];
        };
        block(10);
    });
}

NSRecursiveLock在 YYKit 中的 YYWebImageOperation.m 中有用到。

5.dispatch_semaphore_t

Dispatch Semaphore 提供了三个方法:

  • dispatch_semaphore_create:创建一个 Semaphore 并初始化信号的总量
  • dispatch_semaphore_signal:发送一个信号,让信号总量加 1
  • dispatch_semaphore_wait:可以使总信号量减 1,信号总量小于 0 时就会一直等待(阻塞所在线程),否则就可以正常执行。

引用 YYModel 中的一段代码举例

objc 复制代码
+ (instancetype)metaWithClass:(Class)cls {
    if (!cls) return nil;
    static CFMutableDictionaryRef cache;
    static dispatch_once_t onceToken;
    static dispatch_semaphore_t lock;
    dispatch_once(&onceToken, ^{
        cache = CFDictionaryCreateMutable(CFAllocatorGetDefault(), 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
        lock = dispatch_semaphore_create(1);
    });
    dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
    _YYModelMeta *meta = CFDictionaryGetValue(cache, (__bridge const void *)(cls));
    dispatch_semaphore_signal(lock);
    if (!meta || meta->_classInfo.needUpdate) {
        meta = [[_YYModelMeta alloc] initWithClass:cls];
        if (meta) {
            dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
            CFDictionarySetValue(cache, (__bridge const void *)(cls), (__bridge const void *)(meta));
            dispatch_semaphore_signal(lock);
        }
    }
    return meta;
}

Dispatch Semaphore 在实际开发中主要用于:

  • 保持线程同步,将异步执行任务转换为同步执行任务
  • 保证线程安全,为线程加锁

6.NSCondition

NSCondition条件锁,它实际上是作为一个锁和一个线程检查器:

  • 锁主要为了当检测条件时保护数据源,执行条件引发的任务,用起来和NSLock 一样
  • 线程检查器主要是根据条件决定是否继续运行线程,即线程是否被阻塞。通俗的说,也就是条件成立,才会执行锁住的代码。条件不成立时,线程就会阻塞,直到另一个线程向条件对象发出信号解锁为止。

常见为生产者-消费者模型使用,使用方式如下:

  • NSCondition是对mutexcond的一种封装(cond就是用于访问和操作特定类型数据的指针)
  • wait操作会阻塞线程,使其进入休眠状态,直至超时
  • signal操作是唤醒一个正在休眠等待的线程
  • broadcast会唤醒所有正在等待的线程

SGDownload 中有使用,如下

objc 复制代码
[self.concurrentCondition lock];
while (self.taskTupleQueue.tuples.count >= self.maxConcurrentOperationCount) {
    [self.concurrentCondition wait];
}
[self.concurrentCondition unlock];

当任务组大于等于最大并发数时,就让线程休眠,等待被唤醒,等研究 SGDownload 时再细研究。

7.NSConditionLock

NSConditionLockNSCondition又做了一层封装,自带条件探测,能够更简单灵活的使用。

objectivec 复制代码
@interface NSConditionLock : NSObject <NSLocking> {
@private
    void *_priv;
}

- (instancetype)initWithCondition:(NSInteger)condition NS_DESIGNATED_INITIALIZER;

@property (readonly) NSInteger condition;
- (void)lockWhenCondition:(NSInteger)condition;
- (BOOL)tryLock;
- (BOOL)tryLockWhenCondition:(NSInteger)condition;
- (void)unlockWithCondition:(NSInteger)condition;
- (BOOL)lockBeforeDate:(NSDate *)limit;
- (BOOL)lockWhenCondition:(NSInteger)condition beforeDate:(NSDate *)limit;

@property (nullable, copy) NSString *name API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));

@end

8.os_unfair_lock

由于OSSpinLock自旋锁的bug,替代方案是内部封装了os_unfair_lock,而os_unfair_lock在加锁时会处于休眠状态,而不是自旋锁的忙等状态。

5 什么是死锁

死锁总结就一句话: 串行队列(当然也包括主队列)中向该队列添加同步任务,必定导致死锁。

两个死锁案例帮助理解这句话,
例 1:

objectivec 复制代码
- (void)test1 {
    //1,5,2,造成死锁,
    //1,5,2,async是异步,所以会开辟线程,当然开辟线程需要耗时,所以5在先,2在后
    //为什么会造成死锁?因为队列中加入任务的顺序是2、4、3,按照队列的FIFO原则,理应4执行完再执行3,但执行完2遇到了sync同步函数,需要阻塞线程,4需要等待3执行完再执行,造成了互相等待即死锁。
    NSLog(@"1==%@", [NSThread currentThread]);
    dispatch_queue_t queue = dispatch_queue_create("com.serial", DISPATCH_QUEUE_SERIAL);
    dispatch_async(queue, ^{
        NSLog(@"2==%@", [NSThread currentThread]);
        dispatch_sync(queue, ^{
            NSLog(@"3==%@", [NSThread currentThread]);
        });
        NSLog(@"4==%@", [NSThread currentThread]);
    });
    NSLog(@"5==%@", [NSThread currentThread]);
}

输出结果是 1、5、2,然后发生死锁崩溃,执行过程如下

  1. 主队列按顺序先执行 test 代码块简称 A,由于dispatch_async(queue是异步串行的另一个队列,不阻塞当前队列,所以这里先执行了 A 的 1 和 5。
  2. 来到dispatch_async(queue代码块,另起了新的线程处理队列 queue,已知 queue 里面是(2、dispatch_sync(queue、4)代码块简称 B。
  3. 执行 B 的 2
  4. 执行dispatch_sync(queue时发现这是同步函数 ,需要阻塞队列 queue,在当前线程执行dispatch_sync(queue代码块,但这个代码块的要在 queue 中执行,而 queue 已经被阻塞了,造成了互相等待,导致死锁。

例 2:

scss 复制代码
- (void)viewDidLoad {
    [super viewDidLoad];
    dispatch_sync(dispatch_get_main_queue(), ^{
        NSLog(@"deadlock");
    });
}

同步对于任务是立即执行的,当把任务放进主队列时,它会立即执行,只有执行完这个任务,viewDidLoad才会继续执行。而viewDidLoad和任务都在主队列上,根据队列的先入先出原则,需要先执行完viewDidLoad再执行任务,造成了互相等待,导致死锁。

6 多线程的4种使用方法

多线程有四种技术方案分别是 pthread、NSThread、GCD 和 NSOperation,如下图所示 我们一般使用比较多的是 GCD,因为开发者只需要告诉 GCD 想要执行什么任务,不需要编写任何线程管理代码,但这也是 GCD 的不够灵活的地方,我们无法监控线程的各个状态,这也是很多大框架中使用 NSOperation 的原因,NSOperation 相比 GCD 更加灵活,开发者可以通过 KVO 监测 Operation 的状态,自定义 NSOperation 等。

7 面试题(未完)

主线程是做什么的?

主线程是 iOS 程序运行后开辟的第一个线程,也叫 UI 线程,用来显示刷新 UI 界面和处理 UI 事件。

UI 为什么要在主线程更新?

苹果为了性能考虑,UIKit不是线程安全的,试想如果UI可以在子线程更新,那么如果有多个线程同时修改某个资源时将会出现很多莫名其妙的错误。

参考

Java线程池实现原理及其在美团业务中的实践

相关推荐
二流小码农3 小时前
鸿蒙开发:异步并发操作
android·ios·harmonyos
MavenTalk6 小时前
前端技术选型之uniapp
android·前端·flutter·ios·uni-app·前端开发
豪冷啊8 小时前
iOS 17.4 Not Installed
ios
Topstip8 小时前
iOS 19 重大更新泄露,将带来更“聪明”的 Siri 挑战 ChatGPT
人工智能·ios·ai·chatgpt
软件聚导航1 天前
uniapp 安卓和ios震动方法,支持息屏和后台震动,ios和安卓均通过测试
android·ios·uni-app
键盘敲没电2 天前
【iOS】知乎日报总结
学习·ios·objective-c·xcode
安和昂2 天前
【iOS】UICollectionView的学习
学习·ios·cocoa
二流小码农2 天前
鸿蒙开发:自定义一个任意位置弹出的Dialog
android·ios·harmonyos
小江村儿的文杰3 天前
UE4 iOS Package的过程与XCode工程中没有游戏Content的原因
macos·ios·ue4·xcode
__WanG3 天前
Flutter将应用打包发布到App Store
前端·flutter·ios