iOS中的线程同步工具

这篇文章的目测不会太长啦,是对苹果官方文档中线程同步这一节的学习总结,小伙伴们也可以直接去看官方文档中的介绍。苹果的官方文档真的是个宝藏,没有比它更权威了,对于学习关于iOS中的知识点,建议大家先去刷一遍官方文档中的介绍,不懂的地方再去Google或看大神们的blog来补齐。官方文档的地址再贴一下:https://developer.apple.com/library/archive/navigation/#,收藏好咯~

这篇文章会分两个部分,先总结一下在iOS中可以用哪些手段来解决线程同步的问题,然后再总结一下写线程安全的代码时要注意的地方。

Synchronization Tools

为什么需要线程同步的工具呢?因为当我们在进行多线程开发时,线程间可能就涉及到要共享资源,每一个线程对共享的资源都有操作的权力,这就可能导致一些潜在发生的问题。比如B线程重写了A线程对某一共享变量设置的值,当A线程再读取该变量的值时,可能就不是它想要的了。

Mr Peak有一篇文章《iOS多线程到底不安全在哪里》非常详细地分析了iOS中线程不安全是怎样造成的,推荐阅读~

一直都让线程之间不互相干扰也不太可能,所以需要一些手段来保证能写线程安全的代码,iOS提供了如下线程同步的工具:

  • 原子操作
  • 内存屏障和挥发变量
  • 条件
  • Perform Selector 系统方法

原子操作

原子操作(Atomic Operations)在百度百科中的解释如下:

“原子操作是不需要synchronized”,这是多线程编程的老生常谈了。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch(切换到另一个线程)。

还记得Objective-C中Property的修饰词有一个nonatomic,与其对应的就是atomic。一旦用atomic修饰后,对Property的读写就是原子操作了,也就是对属性的读和写是线程安全的了。这个原子操作在Runtime库中是用自旋锁来实现的。

iOS中还提供了一些对数值类型的原子操作,它们不会阻塞竞争线程,相比锁的损耗要小一个数量级,在 atomic 的主页可以查看这些API的完整介绍。使用时要导入头文件<libkern/OSAtomic.h>,64位的版本只能用在64位的系统中。

比如

1
2
int32_t theValue = 0;
theValue++

是非原子性的,而

1
2
int32_t theValue = 0;
OSAtomicAdd32(10, &theValue);

是原子性的,多线程安全的。

上面的Atomic Operations中OSAtomicXXXBarrier版本里的Barrier,就是下面要说到的Memorry Barrier,它会让线程间共享的内存是同步的。比如你想初始化一个变量,然后执行++操作,这两步是一个整体,就必须使用OSAtomicIncrement32Barrier()来确保执行++操作之前,变量已被初始化,另一方面变量的调用方,也必须使用OSAtomicDecrement32Barrier()来确保,–操作是在++操作的后面。大部分情况下如果我们不确定,都可以使用OSAtomicXXXBarrier版本的Atomic Operations。

内存屏障和挥发变量

从上面的介绍能够看出,其实内存屏障能够保障对内存操作的顺序。对内存的操作难道不是按照我们书写代码的顺序来的吗?不是一定的,为了提高程序的性能,编译器通常会重新排序汇编级指令,以使处理器的指令流尽可能完整。在维基百科里对内存屏障的解释如下:

1
2
3
4
5
内存屏障(英语:Memory barrier),也称内存栅栏,屏障指令等,是一类同步屏障指令,它使得CPU或编译器在对内存进行操作的时候,严格按照一定的顺序来执行,也就是说在 memory barrier 之前的指令和 memory barrier 之后的指令不会由于系统优化等原因而导致乱序。

大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。

语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。

iOS中使用内存屏障,要先导入#include <libkern/OSAtomic.h>,然后在需要的地方调用OSMemoryBarrier()就可以了。

挥发变量(Volatile Variables)是另一种对个别变量的内存层面的读写的约束。为了性能优化,编译器通常都会先从寄存器中读取变量的值,从寄存器读写的速度会比从物理内存读写快很多,所以使用过的变量会被缓存到寄存器中。对于局部变量是没有问题的,但是对于线程间共享的变量,变量可能已经被当前线程改变了,但其他线程从寄存器读取到的还是旧的值,就会出现数据不一致的情况。定义为挥发变量,就告诉编译器不要对该变量优化,每次从变量的存储位置显式存取。

const一样,volatile也是一个类型修饰符,用的时候只要在变量的类型前面加上volatile关键字即可。

Memory barrier和Volatile Variables都是会减少编译器优化的次数,所以只在确定需要使用的地方再用。

锁(Locks)是一种比较常见的线程间数据同步的工具,对于会引起争议的代码块加上锁以后,在同一时间就只能有一个线程来访问。下面是一些在编程中比较常用的锁的类型。

描述
互斥锁 互斥锁扮演者资源周围的保护屏障。互斥锁是信号量的一种,它在同一时间只会给一个线程对资源的访问权。如果一个互斥锁正在使用中,另一个线程试图获取它,那这个线程会被阻塞直到锁被原来的持有者释放。如果多个线程竞争同一个锁,同时只会有一个线程能得到。
递归锁 递归锁是互斥锁的变种。递归锁允许一个线程在释放锁之前多次获得该锁。其他的线程会一直被阻塞,直到锁的持有者释放了它获得该锁的次数。递归锁主要用于递归迭代,也有可能用于多个方法需要单独获得锁。
读写锁 读写锁也叫做共享排他锁。这种锁通常被用于规模较大的操作,如果被保护的数据被频繁地读取,但只是偶尔修改,读写锁会有效提高性能。在正常的操作中,多个读取方可同时读。当有一个线程想要写的时候,它就会被阻塞,知道所有的读取方释放这个锁。当一个写线程正在等待锁时,新的读线程就会阻塞,直到写线程完成了。系统只支持使用 POSIX 线程的读写锁。可以查看pthread的文档获得详细信息。
自旋锁 自旋锁会重复轮训锁定条件,直到条件变为true。自旋锁通常被用于多处理器系统,其中锁的预期等待时间很短。在这种情况下,轮训会比阻塞线程更加高效,阻塞线程还要涉及到上下文切换和线程数据的更新。官方文档里说,因为它们的轮训特性,系统并没有提供对自旋锁的实现。但是在<libkern/OSAtomic.h>里提供了OSSpinLock,如果有看到这的小伙伴可以说一下,为什么呢?
双重检查锁 双重检查锁首先验证锁定条件(第一次检查),只有通过锁定条件验证才真正的进行加锁逻辑并再次验证条件(第二次检查)。因为双重检查锁存在潜在的安全隐患,所以系统没有提供明确的支持,同时也不鼓励使用它们。

下面我们再来看一下iOS中常见的一些锁的使用。

pthread_mutex

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#import <pthread.h>

pthread_mutex_t mutex;
// create a mutex lock
void MyInitFunction() {
pthread_mutex_init(&mutex, NULL);
}
// lock and unlock mutex lock
void MyLockingFunction() {
pthread_mutex_lock(&mutex);
// Do work.
pthread_mutex_unlock(&mutex);
}
// destroy mutex lock when you are done with the lock.
void MyDestroyFunction() {
pthread_mutex_destroy(&mutex);
}

NSLock

Cocoa中提供的互斥锁都遵循了NSLocking协议,它里面只简单定义了lock和unlock方法。

1
2
3
4
5
6
@protocol NSLocking

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

@end

NSLock同样遵循了NSLocking协议,但又额外多了两个方法。

1
2
3
4
// 尝试获取锁,并立即返回,不会阻塞当前线程。
- (BOOL)tryLock;
// 在限制的时间之前尝试获得锁,当前线程会被阻塞,直到获得了锁,或超过了时间限制。
- (BOOL)lockBeforeDate:(NSDate *)limit;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
- (void)testNSLock {
NSLock *lock = [[NSLock alloc] init];
// thread 1
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"thread 1 attemp to lock");
[lock lock];
NSLog(@"thread 1 is using the shared data ...");
sleep(10);
NSLog(@"thread 1 unlock the lock");
[lock unlock];
});
// thread 2
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// let thread 1 to get the lock first, otherwise both thread 1 and thread 2 possibly get the lock first.
sleep(1);
NSLog(@"thread 2 attemp to lock");
[lock lock];
NSLog(@"thread 2 is using the shared data ...");
sleep(2);
NSLog(@"thread 2 unlock the lock");
[lock unlock];
});
// thread 3
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
sleep(2);
NSLog(@"thread 3 attemp to lock");
if ([lock lockBeforeDate:[NSDate dateWithTimeIntervalSinceNow:5]]) {
NSLog(@"thread 3 is using the shared data ...");
sleep(2);
NSLog(@"thread 3 unlock the lock");
[lock unlock];
} else {
NSLog(@"thread 3 failed to acquire the lock");
}
});
}

输出如下:

1
2
3
4
5
6
7
8
thread 1 attemp to lock
thread 1 is using the shared data ...
thread 2 attemp to lock
thread 3 attemp to lock
thread 3 failed to acquire the lock
thread 1 unlock the lock
thread 2 is using the shared data ...
thread 2 unlock the lock

可以看到,thread 3超过了时间限制就获取锁失败了。将尝试获取锁的时间调整的长一些,就会看到获得到锁后的输出。

@synchronized

@synchronized是系统提供给开发者的一种快捷加锁的方式,直接向下面这样使用即可:

1
2
3
4
5
- (void)myMethod:(id)anObj {
@synchronized (anObj) {
// everything between the braces is protected by the @synchronized directive.
}
}

{}内部是被保护的代码块,传入的对象作为唯一标示符来区分被保护的代码块,如果在两个不同的线程中调用该方法时出入的是不同的对象,则线程不会被彼此block。如果传入的是相同的对象,则后面要上锁的线程会被block,直到前面的线程执行了被保护的代码块。

@synchronized的内部实际上是用递归锁实现的,上面说到了,递归锁可以使得一个线程多次获得锁,所以我们也可以嵌套使用@synchronized。

1
2
3
4
5
6
@synchronized (anObj) {
NSLog(@"1st sync");
@synchronized (anObj) {
NSLog(@"2nd sync");
}
}

关于@synchronized的分析,推荐看一下Mr Peak的这篇文章:《正确使用多线程同步锁@synchronized()》,总结下来有如下几点须注意:

  • 慎用@synchronized(self),避免在两个公共锁交替使用的场景产生死锁。正确的做法是传入一个类内部维护的NSObject对象,而且这个对象是对外不可见的。
  • 不同的数据使用不同的锁,尽量将锁的粒度控制在最细的程度。
1
2
3
4
5
6
7
@synchronized (tokenA) {
[arrA addObject:obj];
}

@synchronized (tokenB) {
[arrB addObject:obj];
}
  • 在{}内部尽量不要有其他隐蔽的函数调用,否则会让@synchronized很容变慢。

NSRecursiveLock

递归锁主要用在递归调用中,像下面的代码,如果不用递归锁的话,就会发生死锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)testNSRecursiveLock {
_recursiveLock = [[NSRecursiveLock alloc] init];

}

- (void)myRecursiveLock:(int)value {
[_recursiveLock lock];
if (value != 0) {
--value;
[self myRecursiveLock:value];
}
[_recursiveLock unlock];
}

NSConditionLock

NSConditionLock也是互斥锁的一种,它可以用特定的值进行加锁和释放锁。NSConditionLock虽然和下一小节要介绍的Condition行为很像,但是在其内部实现上却大有不同。下面是NSConditionLock特有的几个API,可以用来实现生产者消费者模式。

1
2
3
4
5
6
7
8
// 初始化一个NSConditionLock,并且设置condition的值。
- (instancetype)initWithCondition:(NSInteger)condition;

// 在获得到锁的同时还需要内部条件等于condition,才能执行被保护的代码块,否则就一直等待,
- (void)lockWhenCondition:(NSInteger)condition;

// 释放锁,并将条件置为condition
- (void)unlockWithCondition:(NSInteger)condition;

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (void)testNSConditionLock {
NSConditionLock *lock = [[NSConditionLock alloc] initWithCondition:0];
// thread 1
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"thread 1 attemp to lock when condition is 0");
[lock lockWhenCondition:0];
NSLog(@"producer add data to the queue");
sleep(4);
NSLog(@"thread 1 unlock the lock and set the condition 1");
[lock unlockWithCondition:1];
});
// thread 2
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"thread 2 attemp to lock when condition is 1");
[lock lockWhenCondition:1];
NSLog(@"consumer remove data from the queue");
sleep(2);
NSLog(@"thread 2 unlock the lock and set the condition 0");
[lock unlockWithCondition:0];
});
}

输出如下:

1
2
3
4
5
6
thread 2 attemp to lock when condition is 1
thread 1 attemp to lock when condition is 0
producer add data to the queue
thread 1 unlock the lock and set the condition 1
consumer remove data from the queue
thread 2 unlock the lock and set the condition 0

thread 1 和 thread 2 同时竞争锁,但是只有condition符合的一方才能获得锁。

dispatch_semaphore

dispatch_semaphore是GCD提供的API,通过信号计数来控制同事能访问资源的线程数,在之前的文章《Objective-C and Swift GCD API 》中有详细介。dispatch_semaphore的使用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

// 设置为0时,一开始执行到dispatch_semaphore_wait时线程就被阻塞了,直到到了你设置的等待时间。
// 设置为1时,在dispatch_semaphore_wait中又指定了DISPATCH_TIME_FOREVER,就可以达到同时只有一个线程访问被保护的代码片段。
// 设置为大于1时,就意味着允许同时有多少个线程对资源进行访问。
dispatch_semaphore_t semaphore = dispatch_semaphore_create(1);

NSMutableArray *mbArray = [[NSMutableArray alloc]init];
for (int i=0; i<100; i++) {
dispatch_async(queue, ^{
// 如果当前信号量为0就阻塞来访的线程,直到到了指定的时间。
// 如果当前信号量不为0则将其减一,但不会阻塞。
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

[mbArray addObject:[NSNumber numberWithInt:i]];
NSLog(@"%i", i);
sleep(3);

// 任务执行结束,信号量加1
dispatch_semaphore_signal(semaphore);
});
}

条件

条件是一种特殊的锁的类型,它可以用来同步要执行的操作的顺序。等待条件的线程会被阻塞,直到另一个线程发信号告知不用等待了。与NSConditionLock类型,条件也可以用来实现生产者消费者模式。下面看一下要如何使用条件。

NSCondition

NSCondition的使用过程如下:

  1. 调用lock方法锁定condition对象。
  2. 检测布尔断言是否可以安全执行任务。
  3. 如果断言为false,调用wait方法或waitUntilDate:阻塞线程。一旦从这些方法返回后,就再次进入第2步重现检测。
  4. 如果断言为true,执行任务。
  5. 更新断言条件或者调用signal方法唤醒其他线程。
  6. 当任务执行完毕,调用unlock方法释放condition对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- (void)testNSCondition {
NSMutableArray *products = [NSMutableArray array];
NSCondition *condition = [[NSCondition alloc] init];
// thread 1
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[condition lock];
while ([products count] == 0) {
NSLog(@"wait for product");
[condition wait];
}
NSLog(@"consume a product");
[products removeObjectAtIndex:0];
[condition unlock];
});
// thread 2
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[condition lock];
NSLog(@"produce a product");
[products addObject:[[NSObject alloc] init]];
NSLog(@"signal other thread");
[condition signal];
[condition unlock];
});
}

看到这段代码你可能会疑惑,既然线程1已经执行了lock,那线程2怎么还能lock,并且执行任务,再调用signal唤醒线程1。在苹果的官方文档中NSCondition找到了对此的解释:

When a thread waits on a condition, the condition object unlocks its lock and blocks the thread. When the condition is signaled, the system wakes up the thread. The condition object then reacquires its lock before returning from the wait or waitUntilDate: method. Thus, from the point of view of the thread, it is as if it always held the lock.

是在调用wait的时候,condition对象会先释放锁,并阻塞线程。当condition收到信号后,系统唤醒了线程。condition对象在wait或waitUntilDate:返回之前又重新获得了锁,这样就好像线程一直在持有锁。

POSIX Conditions

POSIX thread 条件需要一个condition对象和一个mutex lock,但是使用过程和NSCondition类似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pthread_mutex_t mutex;
pthread_cond_t condition;
Boolean ready_to_go = true;

void MyConditionFunction() {
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&condition, NULL);
}

void MyWaitOnConditionFunction() {
// Lock the mutex
pthread_mutex_lock(&mutex);

// If the predicate is already set, then the while loop is bypassed;
// otherwise, the thread sleeps until the predicate is set.
while (ready_to_go == false) {
pthread_cond_wait(&condition, &mutex);
}

// Do work. (The mutex should stay locked.)

// Reset the predication and release the mutex.
ready_to_go = false;
pthread_mutex_unlock(&mutex);
}

发信号的线程负责设置布尔断言,同时发信号给条件锁。

1
2
3
4
5
6
7
8
9
10
void SignalThreadUsingCondition() {
// At this point, there should be work for the other thread to do.
pthread_mutex_lock(&mutex);
ready_to_go = true;

// Signal the other thread to begin work.
pthread_cond_signal(&condition);

pthread_mutex_unlock(&mutex);
}

Tips for Thread-Safe Designs

尽管这些同步工具能够让我们写出线程安全的代码,但是同步工具不是万能的,锁和其他同步工具都会降低程序的性能,所以平衡好程序的安全性和性能。

避免需要同步

能够避免需要使用同步工具的情况是最好的了。如果无法避免,也最好让每个线程只依赖其内部数据结构,而不需要与外部进行交互。如果两个线程中的任务必须共享数据,可以提供数据的副本给线程中的任务使用。但是复制数据也是有性能消耗的,所以要平衡好复制数据和同步工具对性能的影响。

理解同步的约束

同步工具只有在所有的线程都使用它时才会起作用。如果你创建了一个互斥锁来限制对特殊资源的访问,所有性能都必须在获得了同一个锁后才能修改这个资源。不这样的做话就破坏了这个锁对资源的保存,同时这也是一个程序错误。

注意代码正确性带来的威胁

在使用锁和内存屏障时要注意它们放置的位置,有时看似是对的,但其实存在潜在风险。例如下面这个例子,在一个可变数组中存储不可变对象,如果像下面这样放置锁的位置,程序就有可能发生异常。

1
2
3
4
5
6
7
8
9
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;

[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[arrayLock unlock];

[anObject doSomething];

虽然防止了多个线程同时从数组中取对象,但是在获取对象后锁就被释放了,而doSomething还没有机会执行,其他线程过来将数组中的对象都移除了,如果在MRC时代,那么代码中正在持有的对象就会被释放,使得anObject指向一个无效的内存地址。为了修复这个问题,可以将doSomething放在释放锁之前。

1
2
3
4
5
6
7
8
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;

[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[anObject doSomething];
[arrayLock unlock];

这样保证了在执行doSomething时对象还是有效的。但是如果doSomething的执行会耗时很久,就会让你的代码长时间持有锁,这将会产生性能瓶颈。

其实问题不在于有争议的代码,而在于其他线程的出现导致的对象内存管理问题。因为对象在其他线程被释放,所以最好的处理办法是在释放锁之前持有对象。

1
2
3
4
5
6
7
8
9
10
11
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;

[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[anObject retain];
[arrayLock unlock];

[anObject doSomething];
[anObject release];

这个例子虽然简单,但是它向我们演示了很重要的一点。当谈到正确性时,你必须还要考虑到更深层的问题。内存管理或你设计上的其他方面也会受多线程的影响。此外,你也应该总是考虑到编译器会如何处理最糟糕的情况。

小心死锁和活锁

关于死锁,在网上看到一个非常形象的例子;

在一条河上有一座桥,桥面较窄,只能容纳一辆汽车通过,无法让两辆汽车并行。如果有两辆汽车A和B分别由桥的两端驶上该桥,则对于A车来说,它走过桥面左面的一段路(即占有了桥的一部分资源),要想过桥还须等待B车让出右边的桥面,此时A车不能前进;对于B车来说,它走过桥面右边的一段路(即占有了桥的一部分资源),要想过桥还须等待A车让出左边的桥面,此时B车也不能前进。两边的车都不倒车,结果造成互相等待对方让出桥面,但是谁也不让路,就会无休止地等下去。这种现象就是死锁。

到了多线程中,就是当两个不同的线程各持一把对方需要的锁,只有获得了对方的锁才能释放自己的锁,导致彼此永远被对方block住,因为谁都无法获得另一把锁。

活锁与死锁类似,当两个不同的线程竞争相同的资源时,一个线程会先释放掉第一把锁,尝试去获得第二把锁,在获得了第二把锁之后,又去尝试获得第一把锁。两个线程就这样一直在释放一把锁,获取另外一把锁,而没有去执行真正的任务。

避免死锁和活锁最好的办法就是一个线程同一时间只需要一把锁就能处理任务。如果必须同时获得两把锁才行,那你要保证不要让其他线程也做同样的事。

正在使用挥发变量

如果你已经使用了互斥锁保护有争议的代码,就不要再这个代码区间对重要变量使用volatile修饰。互斥锁包含了内存屏障来确保属性的加载和存储操作的顺序。这两种同步技术在一些特殊的情况时一起使用也许是必要的,但是也会导致性能问题。如果互斥锁已经足够用了,就不要再使用挥发变量了。

此外,也不要用挥发变量来代替使用互斥锁。挥发变量只能确保变量从内存中读取而不是从寄存器中,它并不会保证变量能被你的代码正确的获取。

至此,总结完毕~

参考资料

【1】 苹果官方文档线程同步

【2】 iOS 开发中的八种锁(Lock)

【3】 正确使用多线程同步锁@synchronized()

【4】 《iOS多线程到底不安全在哪里》

本文作者:意林
本文链接:http://shinancao.cn/2019/07/01/iOS-Synchronization/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!