iOS 多线程机制

483 阅读17分钟

基础概念

autoreleasepool

autoreleasepool 是看成一个池子,存储着标记为自动释放的对象。当对象被添加到 autoreleasepool 中时,它不会立即被释放,而是autoreleasepool 块结束时统一释放。这通常用于临时对象的管理,确保它们在一定时间后释放,从而减轻手动管理内存的负担。

在ARC环境下的作用

  1. 管理大量临时对象
  2. 多线程应用
  3. 优化性能
for (int i = 0; i < 10000; i++) {
    @autoreleasepool {
        // 创建临时对象
        NSString *tempString = [[NSString alloc] initWithFormat:@"Iteration %d", i];
        NSLog(@"%@", tempString);
        // tempString 会在每次迭代的 autoreleasepool 块结束时被释放
    }
}

runloop

RunLoop是一个事件处理循环器,它的基本作用是让线程在有工作的时候忙碌,没有工作的时候休眠。每个线程都有一个与之关联的RunLoop,但是只有主线程的RunLoop是默认启用的。子线程需要手动启动它们的RunLoop。

工作原理

  1. 启动:调用CFRunLoopRun或[[NSRunLoop currentRunLoop] run]启动RunLoop。
  2. 等待事件:RunLoop会在运行时处于一个循环中,等待输入源或定时器触发事件。
  3. 处理事件:当有事件到达时,RunLoop会处理这些事件。
  4. 进入休眠:如果没有事件需要处理,RunLoop会让线程进入休眠状态,以节省资源。
  5. 唤醒:当有新的事件到达时,RunLoop会被唤醒,并重新进入事件处理循环。

常见用法

GCD (Grand Central Dispatch)

GCD是 Apple 提供的一套用于管理并发任务的底层 API。

常用api:

  1. 获取队列:
    • 使用 dispatch_get_main_queue() 获取主队列(串行),通常用于UI更新。
    • 使用 dispatch_get_global_queue() 获取全局并行队列。
    • 使用 dispatch_queue_create() 创建自定义的串行或并行队列。
  1. 提交任务:
    • 使用 dispatch_async() 异步提交任务。
    • 使用 dispatch_sync() 同步提交任务。
  1. GCD的其他功能:
    • 延时执行:使用 dispatch_after()。
    • 一次性代码:使用 dispatch_once(),确保代码块只被执行一次。
    • 栅栏函数(Barrier):使用 dispatch_barrier_async(),在并行队列中创建一个执行点,在此之前添加到队列的任务必须完成,之后的任务必须等待。
    • 分组执行:使用 dispatch_group_enter(),dispatch_group_leave() 和 dispatch_group_notify() 来监控多个任务组的完成状态。

串行队列

并行队列

栅栏函数

栅栏后的函数,需要等待栅栏前的函数执行完成

DispatchGroup

等待组内所有现场完成执行完毕,执行回调

有两种方式:

不阻塞当前线程: dispatch_group_notify

阻塞当前线程:dispatch_group_wait

NSOperation和NSOperationQueue

NSOperation 的主要特点

  • 添加依赖:可以设置操作之间的依赖关系,确保某个操作在其他操作完成后才开始执行。
  • 控制并发:可以控制同时运行的操作数量。
  • 取消操作:可以随时取消一个或多个操作。
  • 状态监控:可以监控操作的执行状态,如是否正在执行、是否已完成等。

基础用法

线程安全

线程安全是指在多线程环境中,代码能够正确地执行,并且不会引起竞争条件、死锁、数据损坏等问题。

实践

多线程问题的例子

卖票

一共30张票,三个子线程,每个线程卖10张票。

//卖票问题
-(void)sellTicketTest {
    self.ticketsCount = 30;
    
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    
    dispatch_async(queue, ^{
        for (int i= 0; i<10; i++) {
            [self sellTicket];
        }
        
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
}

-(void)sellTicket {
    NSInteger oldTicketsCount = self.ticketsCount;
    sleep(.2);//模拟任务时长,便于问题显现
    oldTicketsCount--;
    self.ticketsCount = oldTicketsCount;
    NSLog(@"还剩%ld张票-------%@",(long)oldTicketsCount, [NSThread currentThread]);
}

预期是全卖完,但是发现还有多余的票。

保证线程安全的方法
  1. 减少单例使用,单例可以在项目中的任意地方访问,增加了共享数据的范围。
  2. 减少引用传递,赋值的时候是引用传递,引用指向相同的内存区域,增加了共享数据的范围
  3. 加锁
  4. 信号量
  5. 串行队列
加锁

基础概念

公平性:

公平性是指锁的分配方式是否公平,即能否保证等待时间较长的线程有限获得锁,不至于让一些线程饿死。

OSSpinLock

自旋锁,需要导入头文件#import <libkern/OSAtomic.h>

api:

  • OSSpinLock lock = OS_UNFAIR_LOCK_INIT; ——初始化锁对象lock
  • OSSpinLockTry(&lock);——尝试加锁,加锁成功继续,加锁失败返回,继续执行后面的代码,不阻塞线程
  • OSSpinLockLock(&lock);——加锁,加锁失败会阻塞线程,进行等待
  • OSSpinLockUnlock(&lock);——解锁

尝试使用OSSpinLock来解决卖票问题

//卖票问题
-(void)sellTicketTest {
    self.ticketsCount = 30;
    
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    
    dispatch_async(queue, ^{
        for (int i= 0; i<10; i++) {
            [self sellTicket];
        }
        
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
}

-(void)sellTicket {
    //初始化锁
    OSSpinLock lock = OS_SPINLOCK_INIT;
    //加锁
    OSSpinLockLock(&lock);
    
    
    NSInteger oldTicketsCount = self.ticketsCount;
    sleep(.2);//模拟任务时长,便于问题显现
    oldTicketsCount--;
    self.ticketsCount = oldTicketsCount;
    NSLog(@"还剩%ld张票-------%@",(long)oldTicketsCount, [NSThread currentThread]);
    
    //解锁
    OSSpinLockUnlock(&lock);
}
@end

但是结果是错误的,原因是使用了不同的锁给不同的线程上锁,锁没有有发挥作用。

这是加锁原理图

下面是正确的做法,使用全局变量锁

@interface ViewController ()

@property (nonatomic, assign) NSInteger ticketsCount;

@property (nonatomic, assign) OSSpinLock oSSpinLocklock;

@end

-(void)sellTicketTest {
    self.ticketsCount = 30;
    //初始化锁
    self.oSSpinLocklock = OS_SPINLOCK_INIT;
    
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    
    dispatch_async(queue, ^{
        for (int i= 0; i<10; i++) {
            [self sellTicket];
        }
        
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
}

-(void)sellTicket {
    //加锁
    OSSpinLockLock(&_oSSpinLocklock);
    
    
    NSInteger oldTicketsCount = self.ticketsCount;
    sleep(.2);//模拟任务时长,便于问题显现
    oldTicketsCount--;
    self.ticketsCount = oldTicketsCount;
    NSLog(@"还剩%ld张票-------%@",(long)oldTicketsCount, [NSThread currentThread]);
    
    //解锁
    OSSpinLockUnlock(&_oSSpinLocklock);
}

下面是结果,票全部卖出了。

自旋锁原理:

先了解让线程阻塞的两种方法。

  1. 让线程真正休眠,RunLoop里的mach_msg实现的效果是这一种。它借助系统内核的指令,让线程停下来,cpu不再分配资源给线程。
  2. 自旋锁的忙等,本质上是一个while循环,不断去判断加锁条件。自旋锁并没有真的让线程停下来,线程不过是被困在while循环中,cpu还是不断分配资源处理。

深入底层

能否从底层查看,自旋锁阻塞线程的方式到底是让线程休眠还是忙等待呢?🤔

  1. 源码分析,这边尝试检索runtime源码,查看和spinLock相关实现,但是很可惜,只有.h和外部封装。

  1. 尝试看代码运行时的汇编指令

在控制台输入si,进入具体函数的汇编实现,下面这段汇编的意思是大致是:

用x16寄存器储存一个地址,用于获取自旋锁的实现。br指令用于调整x16的寄存器所储存的地址,即获取自旋锁的实现。

下面是具体汇编指令的含义:

  1. adrp x16, 9:该指令将地址相对于页的高位部分加载到寄存器x16中。具体解释如下:
    • adrp:表示加载地址相关的页。
    • x16:目标寄存器,用于存储加载的地址。
    • 9:页偏移量,表示加载地址相对于页的高位部分的偏移量。
  1. ldr x16, [x16, #0x258]:该指令用于将存储在x16寄存器所指向的地址处的值加载到寄存器x16中。具体解释如下:
    • ldr:表示加载值。
    • x16:目标寄存器,用于存储加载的值。
    • [x16, #0x258]:表示从x16寄存器所指向的地址加上偏移量0x258处加载值。
  1. br x16:该指令用于无条件地跳转到寄存器x16所存储的地址。具体解释如下:
    • br:表示跳转。
    • x16:目标地址寄存器,用于存储跳转的目标地址。

😵‍💫 但其实看到这里好像并看不出底层实现,没办法只能网上找资料了。

下面是引用wikipediax86下spinlock的汇编实现

#The following example uses x86 assembly language to implement a spinlock. It will work on any Intel 80386 compatible processor.
; Intel syntax

//初始化锁
locked:                      ; The lock variable. 1 = locked, 0 = unlocked.
     dd      0

//加锁
spin_lock:
     mov     eax, 1          ; Set the EAX register to 1.

     xchg    eax, [locked]   ; Atomically swap the EAX register with
                             ;  the lock variable.
                             ; This will always store 1 to the lock, leaving
                             ;  the previous value in the EAX register.

     test    eax, eax        ; Test EAX with itself. Among other things, this will
                             ;  set the processor's Zero Flag if EAX is 0.
                             ; If EAX is 0, then the lock was unlocked and
                             ;  we just locked it.
                             ; Otherwise, EAX is 1 and we didn't acquire the lock.

     jnz     spin_lock       ; Jump back to the MOV instruction if the Zero Flag is
                             ;  not set; the lock was previously locked, and so
                             ; we need to spin until it becomes unlocked.

     ret                     ; The lock has been acquired, return to the calling
                             ;  function.
//解锁
spin_unlock:
     mov     eax, 0          ; Set the EAX register to 0.

     xchg    eax, [locked]   ; Atomically swap the EAX register with
                             ;  the lock variable.

     ret                     ; The lock has been released.

主要看加锁这段代码,结合test eax, eax 和 inz spin_lock 中可以看到,这里会检查eax的值,如果eax不为0,则跳转回spinLock开始的位置,继续尝试机型获取锁。从这里能侧面的延迟spinLock使用的是忙等待的方式。😮‍💨

spin_lock:
     mov     eax, 1          ; 将 EAX 寄存器设置为 1。

     xchg    eax, [locked]   ; 原子性地交换 EAX 寄存器与锁变量的值。
                             ; 这将始终把 1 存储到锁中,并将锁的先前值存入 EAX 寄存器。

     test    eax, eax        ; 测试 EAX 自身的值。
                             ; 这会设置处理器的零标志(ZF),如果 EAX 为 0,零标志将被置位。
                             ; 如果 EAX 为 0,则锁是未锁定状态,我们刚刚锁定了它。
                             ; 否则,EAX 为 1,表示我们未能获取锁。

     jnz     spin_lock       ; 如果零标志未置位,跳回到 MOV 指令;
                             ; 这表示锁是锁定状态,我们需要继续旋转直到它变为未锁定。

     ret                     ; 锁已经被获取,返回调用函数。

自旋锁被放弃

多线程并发的本质是线程间的切换,cpu会预先处理高优先级的线程,如果在低优先级的临界区内cpu选择去处理高优先级线程,会导致低优先线程未解锁。如果二者公用同一个锁,低优先A线程未解锁,高优先B线程将会无法加锁,被阻塞。但是低优先A线程没有cpu资源,导致无法解锁,这个时候就形成死锁了。这个现象又被称为优先级反转问题。

优先级反转:

优先级反转是指一个高优先级线程被迫等待一个低优先级线程释放资源。如果没有合适的机制处理,可能会导致死锁。

假设有三个线程:高优先级线程 A、中优先级线程 B 和低优先级线程 C。

  1. 优先级反转的情景:
    • 低优先级线程 C 获得了一个锁并开始执行。
    • 这时,高优先级线程 A 也需要这个锁并进入等待状态。
    • 中优先级线程 B 不需要这个锁,它在高优先级线程 A 等待期间继续执行。
    • 由于线程 B 的优先级高于线程 C,但低于线程 A,它会一直占用 CPU 资源,使得线程 C 无法继续执行和释放锁,导致线程 A 长时间等待。
  1. 避免优先级反转的机制:
    • 优先级继承: 当高优先级线程 A 等待低优先级线程 C 持有的锁时,线程 C 临时继承线程 A 的优先级,直到它释放锁。
    • 优先级天花板协议: 线程 C 的优先级被提升到该锁所允许的最高优先级,直到它释放锁。
os_unfair_lock

摒弃了忙等待的方式,使用真正让线程休眠的方法,来阻塞线程。

api:

  • os_unfair_lock lock = OS_UNFAIR_LOCK_INIT; 初始化锁对象lock
  • os_unfair_lock_trylock(&lock); 尝试加锁,加锁成功继续,加锁失败返回,继续执行后面的代码,不阻塞线程
  • os_unfair_lock_lock(&lock); 加锁,加锁失败会阻塞线程进行等待
  • os_unfair_lock_unlock(&lock); 解锁
pthread_mutex

pthread_mutex来自与pthread,是一个跨平台的解决方案。即互斥锁,等待锁的过程,会让线程进行休眠。

api:

pthread_mutexattr_t attr;

pthread_mutexattr_init(&attr);

pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NOMAL);初始化锁

pthread_mutex_t mutex;

pthread_mutex_init(&mutex, &attr);尝试加锁

pthread_mutex_trylock(&mutex);加锁

pthread_mutex_lock(&mutex);解锁

pthread_mutex_unlock(&mutex);销毁相关资源

pthread_mutexattr_destroy(&attr);

pthread_mutex_destroy(&attr);

下面是os_unfair_lock和pthread_mutex的对比

os_unfair_lockpthread_mutex
类型Apple 提供的一种锁POSIX 线程库提供的一种锁
简单性API 简单,易于使用API 相对复杂,需要初始化和销毁
性能性能优于 pthread_mutex性能不如 os_unfair_lock
公平性不保证公平性。会发生优先级反转,但有机制对其进行处理保证公平性,避免优先级反转
使用场景适用于需要高性能且不需要公平性的锁定场景适用于需要公平性和稳定性能的锁定场景
初始化使用简单的宏或函数初始化需要使用 pthread_mutex_init 初始化
销毁自动销毁,无需显式调用需要使用 pthread_mutex_destroy 显式销毁
递归锁不支持递归锁类型支持递归锁类型,通过属性设置
平台依赖仅适用于 Apple 平台(iOS, macOS)跨平台(支持多个操作系统)
mutex递归锁

互斥递归锁

直接在递归函数中使用 互斥锁,会导致无法解锁。普通的锁是不支持多次加锁的,多次加锁会导致死锁,因为线程会阻塞自己,无法继续执行,也无法释放已持有的锁🔐。

@property (nonatomic, assign) pthread_mutex_t ticketMutexLock;


- (void)viewDidLoad {
    [super viewDidLoad];
    
    pthread_mutex_init(&_ticketMutexLock, NULL);

    // 开启多线程
    pthread_t thread1;
    pthread_create(&thread1, NULL, threadFunction, (__bridge void *)(self));

    // 等待线程完成
    pthread_join(thread1, NULL);
    
    NSLog(@"viewDidLoad");
    
//    //销毁锁
    pthread_mutex_destroy(&_ticketMutexLock);
}

void *threadFunction(void *arg) {
    ViewController *vc = (__bridge ViewController *)arg;
    [vc mutexLockRecursionTest:3];
    return NULL;
}

// 互斥锁 递归函数
- (void)mutexLockRecursionTest:(NSInteger)count {
    pthread_mutex_lock(&_ticketMutexLock);
    
    NSLog(@"Thread %ld entered recursiveFunction with count: %ld", pthread_self(), count);

    if (count>0){
        [self mutexLockRecursionTest:count-1];
    }
    
    NSLog(@"Thread %ld exiting recursiveFunction with count: %ld", pthread_self(), count);
    pthread_mutex_unlock(&_ticketMutexLock);
}

针对上面的问题,对pthread_mtex 使用PTHREAD_MUTEX_RECURSIVE 递归锁属性,可以在递归函数中,正常使用。递归锁允许同一个线程多次加锁,但必须确保每次加锁都对应一次解锁。递归锁的实现维护了一个计数器,记录同一个线程已经加锁的次数。只有当线程解锁的次数与加锁次数匹配时,锁才会真正被释放。

@property (nonatomic, assign) pthread_mutex_t ticketMutexLock;


- (void)viewDidLoad {
    [super viewDidLoad];
    
    //设置递归锁属性
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
    
    // 初始化递归锁
    pthread_mutex_init(&_ticketMutexLock, &attr);
    
    // 销毁递归锁属性
    pthread_mutexattr_destroy(&attr);
    
    // 开启多线程
    pthread_t thread1;
    pthread_create(&thread1, NULL, threadFunction, (__bridge void *)(self));

    // 等待线程完成
    pthread_join(thread1, NULL);
    
    NSLog(@"viewDidLoad");
    
    //    //销毁锁
    pthread_mutex_destroy(&_ticketMutexLock);
}

void *threadFunction(void *arg) {
    ViewController *vc = (__bridge ViewController *)arg;
    [vc mutexLockRecursionTest:3];
    return NULL;
}

// 互斥锁 递归函数
- (void)mutexLockRecursionTest:(NSInteger)count {
    pthread_mutex_lock(&_ticketMutexLock);
    
    NSLog(@"Thread %ld entered recursiveFunction with count: %ld", pthread_self(), count);

    if (count>0){
        [self mutexLockRecursionTest:count-1];
    }
    
    NSLog(@"Thread %ld exiting recursiveFunction with count: %ld", pthread_self(), count);
    pthread_mutex_unlock(&_ticketMutexLock);
}

mutex条件锁

pthread_cond_t 条件变量是 Pthreads提供的一种同步机制,用于让线程等待某个特定的条件变为真。条件变量和互斥锁一起使用,可以实现线程之间的协调和同步。

api:

  • pthread_cond_init:初始化条件变量。
  • pthread_cond_destroy:销毁条件变量。
  • pthread_cond_wait:等待条件变量。当等待时,线程会释放持有的互斥锁,直到被唤醒。
  • pthread_cond_signal:唤醒一个等待条件变量的线程。
  • pthread_cond_broadcast:唤醒所有等待条件变量的线程。
NSLock、NSRecursiveLock、NSCondition

上面提到的mutex普通锁,mutex递归锁、mutex条件锁,都是基于C语言的API,苹果在此基础上,进行了一层面向对象封装,为开发者供了对应的OC锁如下

  • NSLock--->封装了pthread_mutex_t
  • NSRecursiveLock--->封装了pthread_mutex_t
  • NSCondition---> 封装了pthread_mutex_t + pthread_cond_t
//普通锁
NSLock *lock = [[NSLock alloc] init];
[lock lock];
[lock unlock];
//递归锁
NSRecursiveLock *rec_lock = [[NSRecursiveLock alloc] 
[rec_lock lock];
[rec_lock unlock];init];
//条件锁
NSCondition *condition = [[NSCondition alloc] init];
[self.condition lock];
[self.condition wait];
[self.condition signal];
[self.condition unlock];
串行队列

dispatch_queue(DISPATCH_QUEUE_SERIAL),让队列内的线程一个一个执行。

信号量

dispatch_semaphore,GCD提供的用于处理多线程同步的问题。

api:

  • dispatch_semaphore_create(value)根据一个初始值创建信号量
  • dispatch_semaphore_wait(semaphore, 等待时间) 如果信号量的值<=0,当前线程就会进入休眠等待(直到信号量的值>0) 如果信号量的值>0,就减1,然后往下执行后面的代码。
  • dispatch_semaphore_signal(semaphore)
    让信号量的值加1
- (void)viewDidLoad {
    [super viewDidLoad];
//创建信号量
    self.semaphore = dispatch_semaphore_create(1);
    
    //创建并发队列
    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    
    for (int i=0; i<5; i++) {
        dispatch_async(queue, ^{
            [self accessSharedResourceWithIndex:i];
        });
    }
}

-(void)accessSharedResourceWithIndex:(int)index {
    //等待信号量
    dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
    
    NSLog(@"task %d started", index);
    [NSThread sleepForTimeInterval:1];
    NSLog(@"task %d finished", index);
    
    //发送信号量
    dispatch_semaphore_signal(self.semaphore);
}

@synchronized

@synchronized 是 Objective-C 提供的一种便捷机制,用于在多线程环境下保护共享资源,确保同一时间只有一个线程能够访问特定的代码块。它通过在内部实现一个递归锁(NSRecursiveLock),使得同一线程可以多次进入被保护的代码块,而不会导致死锁。

缺点,效率较低,在对性能要求较高的场景,建议使用NSlock代替。

用法:

@synchronized {
    //需要保证线程安全的代码

}

原理:

通过内部实现一个递归锁,确保同一时间内只有一个线程可以运行被保护的代码块,避免竞争和数据不一致问题。

性能优化

优化思路:优化多线程代码效率的方法有很多,主要包括减少线程间的竞争和等待、提高任务的并行度、避免不必要的上下文切换、以及合理使用同步机制等。

减小临界区大小

减少锁的粒度可以减少线程间的竞争,从而提高效率。

- (void)task1 {
    [self.lock lock];
    self.count += 1;
    [self.lock unlock];
}

使用信号量控制任务并发数

使用信号量来控制同时并发的任务数量,可以避免系统过载,提高性能。

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_semaphore_t semaphore = dispatch_semaphore_create(5); // 最多并发5个任务

for (int i = 0; i < 100; i++) {
    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
    dispatch_async(queue, ^{
        NSLog(@"Task %d started", i);
        [NSThread sleepForTimeInterval:1];
        NSLog(@"Task %d completed", i);
        dispatch_semaphore_signal(semaphore);
    });
}
减少上下文切换

尽量减少任务在不同线程之间的切换,减少上下文切换的开销。

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

for (int i = 0; i < 10; i++) {
    dispatch_async(queue, ^{
        for (int j = 0; j < 10; j++) {
            NSLog(@"Task %d - Subtask %d", i, j);
        }
    });
}

在这个示例中,每个任务内有多个子任务,但这些子任务在同一线程内执行,减少了线程间的切换,从而提高效率。

主线程任务的优化
  1. 内存复用 UITableViewCell 的复用
  2. 懒加载任务
  3. 不阻塞主线程函数执行
//这里是主线程上下文
dispatch_async(dispatch_get_main_queue(), ^{
    //等到主线程空闲执行该任务
});

参考: juejin.cn/post/696577…