iOS - 多线程
基本认识
在计算机的发展长河中,为了解决充分能让 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
是串行队列,block0
和 block1
都会在这个串行队列中执行任务,由于 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 块资源可能被多个线程享用,出现资源抢夺的可能。这个资源可能是一个对象、一个变量、一个文件等。
如果是计算机专业的朋友对 “生产者-消费者” 或者 “银行存款取款” 的经典例子一定不陌生,这两种情况都是经典的线程资源抢夺案例。也就是两个任务在不同时刻访问了同一个资源。
解决方案:使用线程同步技术(同步,就是协同步调,按预定的先后顺序进行)
常用的线程同步技术就是:加锁
在线程 A 对 17 进行操作的时候,先对 A 加锁,然后读取数据进行加法运算,然后写入数据,解锁,当线程 B 也要进行运算的时候,同样先加锁,然后取数进行加法操作,最后写入数据,解锁。加锁的目的就是保证,当前的资源只有一个线程能访问。
iOS 中的线程同步方案
- OSSpinLock
- os_unfair_lock
- pthread_mutex
- dispatch_semphore
- dispatch_queue(DISPATCH_QUEUE_SERIAL)
- NSLock
- NSRecursiveLock
- NSCondition
- NSConditionLock
- @synchronized
在下抢票例子上进行上述同步方案的逐一测试:
@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 读写安全方案
现有以下场景:
- 同一时间,只有 1 个线程进行写的操作;
- 同一时间,允许有多个线程进行读的操作;
- 同一时间,不允许既有写的操作,又有读的操作;
也就是这样的例子:
- (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: 读写锁;
- dispatch_barrier_async: 异步栅栏调用;
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。