iOS

iOS - 多线程

2019-05-10  本文已影响53人  valentizx
image.png

基本认识

在计算机的发展长河中,为了解决充分能让 CPU 得到利用,出现了多线程的概念,其目的就是为了提高 CPU 的使用率,用多个线程去同时完成几件事情而互不干扰,这样可以大大提高程序的运行效率,用户层面的用户体验也会大大得到提升。

iOS 中的常见多线程方案

技术方案 简介 语言 线程生命周期
pthread 一套通用的多线程 API 可适用于 Unix、Linux、Windows 系统 C 手动管理
NSThread 面向对象、简单易用、可直接操作线程对象 Objective-C 手动管理
GCD 可充分利用多核,并能取代 NSThread C 自动管理
NSOperation 基于 GCD、面向对象 Objective-C 自动管理

GCD

GCD 中有两个来执行任务的函数:同步和异步。GCD 的源码在

GCD 执行任务的方式

用同步的方式执行任务:

dispatch_sync(dispatch_queue_t  _Nonnull queue, ^(void)block)

用异步的方式执行任务:

dispatch_async(dispatch_queue_t  _Nonnull queue, ^(void)block)

queue:队列,block:任务

viewDidLoad: 方法中执行:

dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_sync(queue, ^{
    NSLog(@"%@", [NSThread currentThread]);
});    

这表示以同步执行的方式执行 block 中的逻辑,得到结果:

<NSThread: 0x600002d600c0>{number = 1, name = main}

打印结果为主线程。

若以异步的方式去执行任务:

dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
    NSLog(@"%@", [NSThread currentThread]);
}); 

得到结果:

<NSThread: 0x600001f0f100>{number = 3, name = (null)}

说明开启了子线程执行了 block 中的任务。
执行:

dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
    for (int i = 0; i < 20; i ++) {
            NSLog(@"任务1 %d", i);
        }
});
    
dispatch_async(queue, ^{
    for (int i = 0; i < 20; i ++) {
            NSLog(@"任务2 %d", i);
        }
});

可看到结果:


image

两个任务交替进行。

GCD 的队列

GCD 的队列主要分两大类:并发队列(Concurrent Dispatch Queue)和串行队列(Serial Dispatch Queue)。

并发队列可以让多个任务同时执行,会自动开启多个线程同时执行任务,并发功能只有在异步函数下才有效。
创建并发队列的方式:

dispatch_queue_t queue = dispatch_queue_create("队列名字", DISPATCH_QUEUE_CONCURRENT);

串行队列是让任务一个接着一个执行,一个任务执行完后再执行下一个任务。
创建串行队列的方式:

dispatch_queue_t queue = dispatch_queue_create("队列名字", DISPATCH_QUEUE_SERIAL);

同步异步主要决定的是能不能开启新线程,同步表示在当前线程中执行任务,不具备开启新线程的能力,异步表示在新的线程中执行任务,具备开新线程的能力,但是,不一定真的开启新线程。

并发和串行主要影响任务的执行方式,并发指多个任务同时执行,串行指任务顺序执行。

各种队列执行效果如下:

并发队列 串行队列 主队列
同步 a. 不会开启新线程 b. 串行执行任务 a. 不会开启新线程 b. 串行执行任务 a. 不会开启新线程 b. 串行执行任务
异步 b. 会开启新线程 b. 并发执行任务 b. 会开启新线程 b. 串行执行任务 a. 不会开启新线程 b. 串行执行任务

⚠️ 使用 sync 函数往当前串行队列中添加任务,会卡在当前的串行队列中,产生死锁。

现在思考这样一个问题,在 viewDidLoad 中添加如下代码运行会产生什么结果?:

NSLog(@"Task 1");
dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_SERIAL);
dispatch_block_t block1 = ^{
    NSLog(@"Task 3");
};
dispatch_block_t block0 = ^{
    NSLog(@"Task 2");
    dispatch_sync(queue, block1);
    NSLog(@"Task 4");
};
dispatch_async(queue, block0);
NSLog(@"Task 5");

结果就是输出:

Task 1
Task 5
Task 2

然后产生死锁,程序挂掉。
首先我们知道 queue 是串行队列,block0block1 都会在这个串行队列中执行任务,由于 block0 是在异步函数中执行的,那么 Task 5 会先打印,那么 queue 执行任务的原则就是,一个任务执行完执行下一个任务,很明显在打印 Task 2 后任务还没有执行完就要去执行 Task 3 的打印,所以在这里会造成死锁。若 queue 是并发队列则不会产生死锁。

dispatch_queue_global_t dispatch_get_global_queue(long identifier, unsigned long flags) 全局队列是并发队列,全局只有一份。

再来看个例子,在 viewDidLoad 中加入:

dispatch_async(dispatch_get_global_queue(0, 0), ^{
    NSLog(@"Task 1");
    [self performSelector:@selector(printTask2) withObject:nil afterDelay:0];
    NSLog(@"Task 3");   
});

printTask2 的逻辑为:

- (void)printTask2 {
    NSLog(@"Task 2");
}

运行结果为:

Task 1
Task 3

那么为什么没有打印 Task 2?
这是因为 performSelector: withObject: afterDelay: 的本质是在 RunLoop 中添加定时器,而所在函数为异步函数 dispatch_async,也就意味着,添加定时器是在子线程中进行的,但是,子线程默认是没有 RunLoop 的,也就是说,添加的定时器是无效的,没有人处理它,所以 Task 2 永远不会执行。解决办法就是:

dispatch_async(dispatch_get_global_queue(0, 0), ^{
    NSLog(@"Task 1");
    [self performSelector:@selector(printTask2) withObject:nil afterDelay:0];
    NSLog(@"Task 3");
    [[NSRunLoop currentRunLoop] addPort:[[NSPort alloc]init] forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];   
});

或者:

dispatch_async(dispatch_get_global_queue(0, 0), ^{
    NSLog(@"Task 1");
    [self performSelector:@selector(printTask2) withObject:nil];
    NSLog(@"Task 3");   
});

performSelector: withObject: afterDelay:performSelector: withObject: 虽然长相差不多,但是实现是大有不同的,前者是通过 RunLoop 处理计时器,后者的本质就是 runtime 机制的消息发送机制。

我们在 GNUstep 开源的 Objective-C 源码中发现线索:

- (void) performSelector: (SEL)aSelector
          withObject: (id)argument
          afterDelay: (NSTimeInterval)seconds
{
  NSRunLoop     *loop = [NSRunLoop currentRunLoop];
  GSTimedPerformer  *item;

  item = [[GSTimedPerformer alloc] initWithSelector: aSelector
                         target: self
                       argument: argument
                          delay: seconds];
  [[loop _timedPerformers] addObject: item];
  RELEASE(item);
  [loop addTimer: item->timer forMode: NSDefaultRunLoopMode];
}

GNUStep 开源的源码是自己实现的,具有相当高的参考价值,其他部分的源码可以配合 Apple 开源的 Core Foundation 源码研究。源码地址在

GCD 队列组的使用

在开发中,我们通常有这样的需求:异步执行任务 1、2,这两个任务都执行完毕再去执行任务 3。那么遇到这种问题,我们就可以通过队列组来实现。如:

dispatch_group_t group = dispatch_group_create(); // 创建队列组
dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_CONCURRENT); // 创建并发队列
// 任务1
dispatch_group_async(group, queue, ^{
    for (int i = 0; i < 3; i ++) {
        NSLog(@"【任务1】%d", i);
    }
});
// 任务2
dispatch_group_async(group, queue, ^{
    for (int i = 0; i < 3; i ++) {
        NSLog(@"【任务1】%d", i);
    }
});    
// 任务3
dispatch_group_notify(group, queue, ^{
    // 回到主线程就用主队列,异步做事情就用全局队列或者手动创建异步队列
    dispatch_group_async(group, dispatch_get_main_queue(), ^{
        for (int i = 0; i < 3; i ++) {
            NSLog(@"【任务3】%d", i);
        }
    });
});

运行结果为:


image

dispatch_group_notify 函数可以保证,前面的异步任务执行完毕后才会执行 dispatch_group_notify 中的 block。

多线程的安全隐患

最典型的就是资源共享问题,1 块资源可能被多个线程享用,出现资源抢夺的可能。这个资源可能是一个对象、一个变量、一个文件等。

如果是计算机专业的朋友对 “生产者-消费者” 或者 “银行存款取款” 的经典例子一定不陌生,这两种情况都是经典的线程资源抢夺案例。也就是两个任务在不同时刻访问了同一个资源。

解决方案:使用线程同步技术(同步,就是协同步调,按预定的先后顺序进行)
常用的线程同步技术就是:加锁

image

在线程 A 对 17 进行操作的时候,先对 A 加锁,然后读取数据进行加法运算,然后写入数据,解锁,当线程 B 也要进行运算的时候,同样先加锁,然后取数进行加法操作,最后写入数据,解锁。加锁的目的就是保证,当前的资源只有一个线程能访问。

iOS 中的线程同步方案

在下抢票例子上进行上述同步方案的逐一测试:

@interface ViewController ()

@property(assign, nonatomic) NSInteger tickets;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.tickets = 30;
    [self test];
}

- (void)test {
    
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self getTicket];
        }
    });
    
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self getTicket];
        }
    });
    
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self getTicket];
        }
    });
}

- (void)getTicket {
    
    NSInteger tmp = self.tickets;
    self.tickets = -- tmp;
    NSLog(@"还剩 %ld 张票,%@", (long)self.tickets, [NSThread currentThread]);
}

@end

抢票结果:


image

发生资源抢夺的部分都在 getTicket 函数中,所以加锁的逻辑也是在这里。

OSSpinLock

OSSpinLock 叫做“自旋锁”,等待锁的线程会处于忙等的状态,一直占用 CPU 的资源。处理上述抢票问题:

- (void)getTicket {
    // 加锁
    OSSpinLockLock(&_lock);
    NSInteger tmp = self.tickets;
    self.tickets = -- tmp;
    NSLog(@"还剩 %ld 张票,%@", (long)self.tickets, [NSThread currentThread]);
    // 解锁
    OSSpinLockUnlock(&_lock);
}

声明一个全局的一把锁,然后初始化。

OSSpinLock 的使用需要导入 libkern/OSAtomic.h
自旋锁的初始化为:lock = OS_SPINLOCK_INIT

OSSpinLock 的原理是,新来的线程会判断 _lock 是否加锁,如果加锁,则处于等待状态,等待上一个任务执行完毕,上一个任务执行完毕,新来的这个线程会立即加锁,执行自己的逻辑。

运行程序结果正常。[不贴图了]

但是目前该 OSSpinLock 可能出现优先级反转的问题,所以不再安全:假如有三个线程 A、B、C,其中 A 的优先级最高,B 的最低,在 A 执行任务的时候,发现资源已经被 B 在某个时间点上锁,所以 A 会一直处于等待状态,由于 A 的优先级最高,可能会导致 CPU 一直分配时间给 A,导致 B、C 不能执行任何操作,也就无法解锁。

os_unfair_lock

OSSpinLock 已经被 os_unfair_lock 替代,从底层调用来看,等待 os_unfair_lock 的线程会处于休眠状态,而不是忙等。处理上述抢票问题:

- (void)getTicket {
    
    // 加锁
    os_unfair_lock_lock(&_lock);
    NSInteger tmp = self.tickets;
    self.tickets = -- tmp;
    NSLog(@"还剩 %ld 张票,%@", (long)self.tickets, [NSThread currentThread]);
    // 解锁
    os_unfair_lock_unlock(&_lock);
}

声明一个全局的一把锁,然后初始化。

os_unfair_lock 的使用需要导入 os/lock.h
自旋锁的初始化为:lock = OS_UNFAIR_LOCK_INIT

运行程序结果正常。[不贴图了]

pthread_mutex

mutex 一般称作互斥锁,等待锁的线程会处于休眠状态,处理上述抢票问题:

- (void)getTicket {
    // 加锁
    pthread_mutex_lock(&_mutex);
    NSInteger tmp = self.tickets;
    self.tickets = -- tmp;
    NSLog(@"还剩 %ld 张票,%@", (long)self.tickets, [NSThread currentThread]);
    // 解锁
    pthread_mutex_unlock(&_mutex);
}

声明一个全局的一把锁,然后初始化。

pthread_mutex 的使用需要导入 pthread.h
互斥锁的初始化步骤相对麻烦,还要对锁进行属性的设置:

// 初始化属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
//设置属性
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL);
// 初始化锁
pthread_mutex_init(&_mutex, &attr);
// 销毁属性
pthread_mutexattr_destroy(&attr);

锁的属性除了 PTHREAD_MUTEX_NORMAL 还有以下:

#define PTHREAD_MUTEX_NORMAL 0
#define PTHREAD_MUTEX_ERRORCHECK 1
#define PTHREAD_MUTEX_RECURSIVE 2// 递归锁,表示同一个线程可以对一个资源重复加锁
#define PTHREAD_MUTEX_DEFAULT PTHREAD_MUTEX_NORMAL

运行程序结果正常。[不贴图了]

另外,需要在 dealloc 函数中调用 pthread_mutex_destroy(&_mutex) 销毁锁。

这里自旋锁和互斥锁的区别就是:在自旋锁机制中,如果自旋锁被别的执行单元加锁,则新来的调用者会一直处于循环等待的状态,等待持有者解锁。而互斥锁则不同,新来的调用者发现互斥锁被加锁,则会进入阻塞的状态。并且将这个调用者放入等待的消息队列等待调度。

假如,两个子线程存在依赖关系,那么可通过条件来解决:

// 初始化条件
int pthread_cond_init(
        pthread_cond_t * __restrict,
        const pthread_condattr_t * _Nullable __restrict)
        __DARWIN_ALIAS(pthread_cond_init);
// 等待方:
int pthread_cond_wait(pthread_cond_t * __restrict,
        pthread_mutex_t * __restrict) __DARWIN_ALIAS_C(pthread_cond_wait); // 等待,休眠状态放开锁,被唤醒后加锁
// 被依赖方:
int pthread_cond_signal(pthread_cond_t *); // 唤醒,一对一唤醒

// 被依赖方:
int pthread_cond_broadcast(pthread_cond_t *); // 唤醒,一次唤醒多个线程

NSLock、NSRecursiveLock

NSLock 是对 mutex 的普通锁封装,NSRecursiveLock 是对 mutex 的递归锁封装。处理上述抢票问题:

- (void)getTicket {
    // 加锁
    [self.lock lock];
    NSInteger tmp = self.tickets;
    self.tickets = -- tmp;
    NSLog(@"还剩 %ld 张票,%@", (long)self.tickets, [NSThread currentThread]);
    // 解锁
    [self.lock unlock];
}

NSRecursiveLock 使用方法和 NSLock 基本一致。

在互斥锁中,还有个条件的概念,同样的,在对应的 Objective-C 互斥锁中,也有条件的概念,那就是 NSCondition。对应 API 和 mutex 的一样。

运行程序结果正常。[不贴图了]

NSConditionLock

NSConditionLock 又称条件锁,是对 NSConditionLock 的进一步封装,可以设置具体的值。

使用方法几乎和 NSLock 一样,并且根据条件值来加锁,使用起来更方便。详见文档

dispatch_queue(DISPATCH_QUEUE_SERIAL)

直接使用 GCD 的串行队列,也可以实现线程同步。处理上述抢票问题:

- (void)getTicket {  
    dispatch_sync(self.queue, ^{
        NSInteger tmp = self.tickets;
        self.tickets = -- tmp;
        NSLog(@"还剩 %ld 张票,%@", (long)self.tickets, [NSThread currentThread]);
    });
}

queue 的初始化为:self.queue = dispatch_queue_create("queue", DISPATCH_QUEUE_SERIAL)

dispatch_semphore

semphore 为信号量,信号量的初始值,可以用来控制线程并发访问的最大数量,为 1 表示只允许一条线程访问资源,保证线程同步。处理上述抢票问题:

- (void)getTicket {
    dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
    NSInteger tmp = self.tickets;
    self.tickets = -- tmp;
    NSLog(@"还剩 %ld 张票,%@", (long)self.tickets, [NSThread currentThread]);
    dispatch_semaphore_signal(self.semaphore);
}

信号量的初始化为:self.semaphore = dispatch_semaphore_create(int value),若解决抢票问题,则为 1。
dispatch_semaphore_wait 若信号量的值 <= 0,当前线程就会进入休眠状态,若 > 0,就减 1,然后执行后面的代码。
dispatch_semaphore_signal 让信号量的值加 1。

运行程序结果正常。[不贴图了]

@synchronized

@synchronized 是对 mutex 的封装。也是最简单的方案,处理上述抢票问题:

- (void)getTicket {  
    @synchronized (self) {
        NSInteger tmp = self.tickets;
        self.tickets = -- tmp;
        NSLog(@"还剩 %ld 张票,%@", (long)self.tickets, [NSThread currentThread]);
    }  
}

@synchronized (对象),该对象必须是唯一不会变的,形如前面的锁,都是全局唯一的,self 表示当前控制器对象,可以满足需求,但是其他情况下未必满足,所以这个对象的选择还需要视情况而定。

我们可以在源码中看到:

int objc_sync_enter(id obj)
{
    int result = OBJC_SYNC_SUCCESS;

    // obj 是当前对象
    if (obj) {
        SyncData* data = id2data(obj, ACQUIRE); // 传入对象得到 SyncData 对象
        assert(data);
        data->mutex.lock(); // 从 SyncData 对象中取出锁然后加锁,从这里也可以看出,一个对象对应一把锁
    } else {
        if (DebugNilSync) {
            _objc_inform("NIL SYNC DEBUG: @synchronized(nil); set a breakpoint on objc_sync_nil to debug");
        }
        objc_sync_nil();
    }
    return result;
}

SyncData 的形式:

typedef struct alignas(CacheLineSize) SyncData {
    struct SyncData* nextData;
    DisguisedPtr<objc_object> object;
    int32_t threadCount;
    recursive_mutex_t mutex; // 递归锁
} SyncData;

苹果并不推荐用 @synchronized 来加锁,会损耗很多性能。

运行程序结果正常。[不贴图了]

以上线程同步方案性能对比

性能由高到低:
a. os_unfair_lock // iOS 10 才开始支持
b. OSSpinLock
c. dispatch_semaphore // 推荐
d. pthread_mutex // 跨平台
e. dispatch_queue(DISPATCH_QUEUE_SERIAL)
f. NSLock
g. NSCondition
h. pthread_mutex(recursive)
i. NSRecursiveLock
j. NSConditionLock
k. @synchronized

Q. 自旋锁和互斥锁什么情况下选择对应的锁?
A. 预计线程等待锁的时间很短、加锁的代码(临界区)经常被调用,但竞争情况很少发生、CPU 资源不紧张、多核处理器的情况下选择自旋锁,反之临界区有 IO 操作、单核处理器、预计线程等待锁的时间比较长、临界区逻辑复杂循环量大的情况下用互斥锁。

atomic

atomic 用于保证属性 setter/getter 的原子操作,相当于在 setter/getter 内部增加了线程同步锁。但它并不能保证使用属性的过程是一定线程安全的。
objc-accessors.mm 源码中有体现,其 setter 方法为:

static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
    ...
    // 判断是否具有原子性,也就是是否是 atomic 修饰
    if (!atomic) {
        // 不是的话,直接赋值,也就是 noatomic
        oldValue = *slot;
        *slot = newValue;
    } else {
        // 是的话先获得锁
        spinlock_t& slotlock = PropertyLocks[slot];
        // 加锁
        slotlock.lock();
        oldValue = *slot;
        *slot = newValue;
        // 解锁        
        slotlock.unlock();
    }

    objc_release(oldValue);
}

getter 方法为:

id objc_getProperty(id self, SEL _cmd, ptrdiff_t offset, BOOL atomic) {
    ...
    if (!atomic) return *slot;
        
    // 加锁
    spinlock_t& slotlock = PropertyLocks[slot];
    slotlock.lock();
    id value = objc_retain(*slot);
    // 解锁
    slotlock.unlock();
    
    return objc_autoreleaseReturnValue(value);
}

为什么说 atomic 修饰的不一定是线程安全的?因为我们看到,只有对属性的 setter/getter 操作才有加锁解锁的操作,那么对于其他的操作:如数组添加元素、删除元素以及其他操作就不具备加锁解锁的操作,会导致线程不安全。

在进行 iOS 开发的过程中,通常是不使用 atomic 关键字修饰属性的,因为会消耗性能,在 macOS 开发的过程中使用的较多。

iOS 读写安全方案

现有以下场景:

也就是这样的例子:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    for (int i = 0; i < 10; i ++) {
        [[[NSThread alloc] initWithTarget:self selector:@selector(read) object:nil] start];
        [[[NSThread alloc] initWithTarget:self selector:@selector(write) object:nil] start];
    }
}

- (void)read {
    NSLog(@"%s", __func__);
}

- (void)write {
     NSLog(@"%s", __func__);
}

@end

上面的场景就是典型的“多读单写”,经常用于文件数据的读写操作,iOS 中的实现方案有:

pthread_rwlock

等待锁的线程会进入休眠状态。
其 API 有如下:

pthread_rwlock_t lock;
// 初始化锁
pthread_rwlock_init(&lock, NULL);
// 读-加锁
pthread_rwlock_rdlock(&lock);
// 读-尝试加锁
pthread_rwlock_tryrdlock(&lock);
// 写-加锁
pthread_rwlock_wrlock(&lock);
// 写-尝试加锁
pthread_rwlock_trywrlock(&lock);
// 解锁
pthread_rwlock_unlock(&lock);
// 销毁锁
pthread_rwlock_destroy(&lock);

解决上述问题的处理为:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 初始化锁
    pthread_rwlock_init(&_lock, NULL);

    for (int i = 0; i < 10; i ++) {
        [[[NSThread alloc] initWithTarget:self selector:@selector(read) object:nil] start];
        [[[NSThread alloc] initWithTarget:self selector:@selector(write) object:nil] start];
    }
}

- (void)read {
    
    pthread_rwlock_rdlock(&_lock);
    NSLog(@"%s", __func__);
    pthread_rwlock_unlock(&_lock);
}

- (void)write {
    
    pthread_rwlock_wrlock(&_lock);
    NSLog(@"%s", __func__);
    pthread_rwlock_unlock(&_lock);
}

@end

dispatch_barrier_async

这个函数传入的并发队列必须是自己通过 dispatch_queue_create 创建的。
解决上述问题的处理为:

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    _queue = dispatch_queue_create("rw_queue", DISPATCH_QUEUE_SERIAL);

    for (int i = 0; i < 10; i ++) {
        [[[NSThread alloc] initWithTarget:self selector:@selector(read) object:nil] start];
        [[[NSThread alloc] initWithTarget:self selector:@selector(write) object:nil] start];
    }
}

- (void)read {
    // 读
    dispatch_async(_queue, ^{
        NSLog(@"%s", __func__);
    });
    
}

- (void)write {
    // 写
    dispatch_barrier_async(_queue, ^{
        NSLog(@"%s", __func__);
    });
}
@end

若传入的队列是串行队列或者全局并发队列,则 dispatch_barrier_async 的效果等同于 dispatch_async。

上一篇下一篇

猜你喜欢

热点阅读