线程锁漫谈
01
介绍
01
介绍
锁和多线程有密不可分的关系,多线程环境下,如果线程A访问一段被锁保护的代码,线程B访问时,需要等线程A将锁释放,才可以进行访问。
02
类型
02
类型
这里介绍下主要的几种锁:
NSRecursiveLock:递归锁,可以在一个线程中反复获取锁不会造成死锁,这个过程会记录获取锁和释放锁的次数来达到何时释放锁的作用。 NSConditionLock:条件锁,用户定义条件,确保一个线程可以获取满足一定条件的锁。因为线程间竞争会涉及到条件锁检测,系统调用上下切换频繁导致耗时是几个锁里最长的。 OSSpinLock:自旋锁,不进入内核,减少上下文切换,性能最高。但抢占多时会占用较多 cpu,好点多,这时使用pthread_mutex较好。os_unfair_lock:用来替代 OSSpinLock的,OSSpinLock存在的一些问题,后面会讲到。pthread_mutex_t:互斥锁, C语言层面的锁,底层api性能高。@synchronized:更加简单,通过代码块的方式加锁。 dispatch_semaphore:信号量, GCD中提供的一套API。NSLock:互斥锁,上层的高级封装,使用简单,无需关心底层细节处理。 pthread_spinlock_t: pthread层级的自旋锁,性能比pthread_mutex_t要高。在等待的时候会进入“自旋”状态,不会进入休眠。但是,iOS系统并不支持,所以用不了。
03
资源抢夺
03
资源抢夺
在开发中往往是多线程的,如果多个线程同时对同一资源进行读写,就可能造成资源抢夺的问题,导致数据计算结果不是我们想要的结果。对于资源抢夺,一般都是通过加锁的方式来解决,或者用调度队列来解决。
下面是一个例子,来模拟资源抢夺,通过sleep来模拟不同线程中的逻辑处理。
self.count = 15;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0); dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});
- (void)saleTicket {
NSInteger oldCount = self.count;
sleep(0.1);
oldCount--;
self.count = oldCount;
NSLog(@"count: %ld, thread: %@", self.count, [NSThread currentThread]);
}
从打印结果可以看出,由于是先获取的值,休眠一段时间后,再进行计算和赋值操作。所以,导致进行计算时,使用的还是旧值,导致旧值的计算结果把其他线程的计算结果覆盖。这是资源抢夺中的一种场景。
count: 14, thread: <NSThread: 0x600003670f00>{number = 5, name = (null)}
count: 14, thread: <NSThread: 0x600003668840>{number = 6, name = (null)} // 发生了资源抢夺
count: 13, thread: <NSThread: 0x600003655080>{number = 7, name = (null)}
count: 12, thread: <NSThread: 0x600003670f00>{number = 5, name = (null)}
count: 11, thread: <NSThread: 0x600003668840>{number = 6, name = (null)}
count: 10, thread: <NSThread: 0x600003655080>{number = 7, name = (null)}
count: 9, thread: <NSThread: 0x600003670f00>{number = 5, name = (null)}
count: 8, thread: <NSThread: 0x600003668840>{number = 6, name = (null)}
count: 7, thread: <NSThread: 0x600003655080>{number = 7, name = (null)}
count: 6, thread: <NSThread: 0x600003670f00>{number = 5, name = (null)}
count: 5, thread: <NSThread: 0x600003668840>{number = 6, name = (null)}
count: 4, thread: <NSThread: 0x600003655080>{number = 7, name = (null)}
count: 3, thread: <NSThread: 0x600003670f00>{number = 5, name = (null)}
count: 2, thread: <NSThread: 0x600003668840>{number = 6, name = (null)}
count: 2, thread: <NSThread: 0x600003655080>{number = 7, name = (null)} // 发生了资源抢夺
04
锁的分类
04
锁的分类
这里先从类型的纬度,介绍下有哪些类型的锁。
4.1 自旋锁
自旋锁属于一种盲等的锁,当其他线程执行到已经加锁的代码时,就会进入持续等待,这个等待过程是持续消耗CPU的。
例如下面代码,如果method1中已经执行lock方法,处于上锁的状态。执行到method2的lock方法时,所在的线程就会开始执行一个耗时任务,直到method1的lock放开锁为止。
- (void)method1 {
lock(lock);
// ...
unlock(lock);
}- (void)method2 {
lock(lock);
// ...
unlock(lock);
}
但是,自旋锁的效率相对是很高的,因为不用进行线程唤醒的操作,而是让线程直接进入盲等状态,相当于运行了一个while循环。以OSSpinLock为例,自旋锁的实现是一个循环,下面a32到a43的部分,实际上是循环执行的。
4.2 互斥锁
互斥锁在进入等待的状态时,会进入休眠状态。
以pthread_mutex_lock为例,其本质是调用了syscall进入系统休眠状态,lock后面的代码不再继续执行。直到持有lock的线程调用unlock,会唤醒线程继续执行,线程进行调度的过程中是有一定性能损耗的。
4.3 递归锁
递归锁允许同一个线程,对同一个lock对象重复加锁。
普通锁在执行递归操作时,第二次对同一个lock对象加锁,就会导致死锁。而递归锁则允许出现递归调用的情况,对同一个lock对象递归调用两次也是被允许的。
4.4 选型
目前主流的锁是互斥锁和自旋锁,二者的选型主要看需求场景。自旋锁不涉及线程挂起和唤醒的操作,如果是等待时间比较短的场景,使用自旋锁比较合适。尤其是在多核CPU中,被频繁调用的场景下,很适合用自旋锁。
因为自旋锁比较消耗CPU,如果是比较耗时的任务,用互斥锁可以节省性能。
4.5 优先级反转
CPU执行不同线程的任务,是通过时间片来进行的。实际执行的时候,由系统的时间片调度算法,来调度CPU执行某个线程的任务,执行一段时间后,再去执行另一个线程的任务,这样来实现多线程。
锁会带来优先级反转的问题,即出现高优先级线程等待低优先级线程的执行,甚至导致死锁的问题。
这里用到一张很经典的优先级反转的图,有三条线程,线程A优先级为10,优先级最低,线程B优先级15,线程C优先级20,优先级最高。线程A先获得互斥锁,线程C也要获得互斥锁,随后C进入等待过程。
这时,如果线程B执行任务,就会抢占线程A的时间片,线程A要等线程B执行完,才可以继续执行剩余任务并释放锁。从而,线程B也间接影响了高优先级的线程C的执行,形成了优先级反转。
优先级反转和自旋锁、互斥锁种类并无关系,当不同等级的线程伴随着锁出现时,理论上就会出现优先级反转的问题。在相同优先级的线程中不会出现优先级反转。避免优先级反转的方式也比较简单,就是在低优先级线程获得锁之前,将其设置为高优先级线程,释放锁后再设置回低优先级。
从操作系统的层面,有些系统已经内置了解决方案,在遇到此类优先级反转的情况时,会出让高优先级线程的优先级给让低优先级线程,保证锁内资源执行的正常。
05
都有哪些锁
05
都有哪些锁
5.1 OSSpinLock
OSSpinLock的类型是自旋锁,但是由于其已经不能再保证线程安全性,所以苹果不建议使用,这里仅作为了解,苹果推荐使用iOS 10新出来的os_unfair_lock来替代OSSpinLock。如果抛开安全性单说性能,OSSpinLock是iOS开发中性能最好的锁。
5.2 os_unfair_lock
os_unfair_lock是一个互斥锁,从iOS 10开始支持。和OSSpinLock的实现机制不同,os_unfair_lock在已经lock的情况下,等待os_unfair_lock的线程并不是循环,而是将线程睡眠,不会占用CPU资源。
os_unfair_lock_t本质上是一个结构体,属于比较底层的os层的锁,所以苹果是建议我们用更高级的封装,例如NSLock或者信号量。初始化os_unfair_lock_t时,需要通过指定的方式OS_UNFAIR_LOCK_INIT进行初始化。
typedef struct os_unfair_lock_s os_unfair_lock;
typedef struct os_unfair_lock_s * os_unfair_lock_t;
typedef struct _os_unfair_lock_s {
os_ulock_value_t oul_value;
} *_os_unfair_lock_t;
在使用os_unfair_lock时需要注意,加锁和解锁的线程需要时同一个线程,否则会导致崩溃。
extern void os_unfair_lock_lock(os_unfair_lock_t lock);
extern bool os_unfair_lock_trylock(os_unfair_lock_t lock);
extern void os_unfair_lock_unlock(os_unfair_lock_t lock);
5.3 pthread_mutex_t
pthread_mutex_t是一个C语言层面的锁,相对底层,需要自己进行内存管理。pthread_mutex_t可以进行跨平台使用,可以创建两种类型的锁,一个是互斥锁,一个是递归锁,在初始化时指定类型。
下面是互斥锁的初始化,PTHREAD_MUTEX_DEFAULT表示一个互斥锁。如果想创建一个递归锁,则改成PTHREAD_MUTEX_RECURSIVE即可。需要注意的是,在dealloc时需要将pthread_mutex_t销毁。
- (void)dealloc {
pthread_mutex_destroy(&self->_lock);
}- (void)viewDidLoad {
[super viewDidLoad];
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_DEFAULT);
pthread_mutex_init(&self->_lock, &attr);
pthread_mutexattr_destroy(&attr);
}
- (void)getUserInfo {
pthread_mutex_lock(&self->_lock);
/// 处理业务代码
pthread_mutex_unlock(&self->_lock);
}
除了常规使用方法外,pthread_mutex_t还可以配合pthread_cond_wait和pthread_cond_signal两个函数,实现条件锁,具体和信号量的使用比较像。
5.4 NSLock
NSLock我们平时用的比较多,是高级封装的一个互斥锁,其和NSRecursiveLock等高级封装一样,遵循NSLocking并实现加锁细节。根据汇编源码可以看出,NSLock是对pthread_mutex互斥锁的封装,使用也很简单。
@protocol NSLocking
- (void)lock;
- (void)unlock;
@end@interface NSLock : NSObject <NSLocking>
@property (nullable, copy) NSString *name;
- (BOOL)tryLock;
- (BOOL)lockBeforeDate:(NSDate *)limit;
@end
需要注意的是,因为是互斥锁,连续在同一个线程中执行lock会导致死锁,如果这个线程是主线程,则会导致界面卡死,并在一定时间后被系统kill掉。如果这个线程是子线程,则会导致后面获得这个锁的线程,一直被卡住。
对于死锁的问题,我们有两种方案解决,一种是换成NSRecursiveLock递归锁,另一种是在可能发生重复的lock的地方,增加tryLock的判断。如果发生重复加锁的情况,则返回NO。
NSLock *lock = [[NSLock alloc] init];
// A线程
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[lock lock];
/// 业务代码
sleep(2);
[lock unlock];
});// B线程
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
sleep(1);
if ([lock tryLock]) {
[lock lock];
/// 业务代码
[lock unlock];
} else {
NSLog(@"加锁失败");
}
});
5.5 NSRecursiveLock
NSRecursiveLock正如其名,是一个递归锁,也就是同一个线程,可以多次获取同一个锁,而不会发生死锁。如果是同一个线程,NSRecursiveLock会记录获取的次数,当此线程的unlock和lock次数匹配时,才会释放资源给其他线程。
根据汇编源码可以看出,NSRecursiveLock是一个PTHREAD_MUTEX_RECURSIVE类型的pthread_mutex_t的封装。用下面例子可以验证NSRecursiveLock,其并不会发生死锁。
self.lock = [[NSRecursiveLock alloc] init];
for (NSInteger i = 0; i < 100; i++) {
[self.lock lock];
NSLog(@"加锁");
} for (NSInteger i = 0; i < 100; i++) {
[self.lock unlock];
NSLog(@"解锁");
}
NSLog(@"循环已结束");
5.6 NSCondition
NSCondition是条件锁,是对pthread_mutex_t和pthread_cond的封装。由于继承了NSLocking协议,所以其自身已经具备了锁的功能。
在使用场景方面,正如其名,可以通过设置条件决定锁是否进入waiting状态,如果进入waiting状态,则需要其他broadcast或者signal来触发wait的后续代码执行。
self.condition = [[NSCondition alloc] init]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"thread:%@", [NSThread currentThread]);
[self lockMethod];
});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"thread:%@", [NSThread currentThread]);
[self lockMethod];
});
sleep(1);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"thread:%@", [NSThread currentThread]);
[self unlockMethod];
});
- (void)lockMethod {
[self.condition lock];
NSLog(@"lockMethod lock");
NSLog(@"lockMethod wait...");
[self.condition wait];
NSLog(@"lockMethod wait completion");
[self.condition unlock];
NSLog(@"lockMethod unlock");
}
- (void)unlockMethod {
[self.condition lock];
NSLog(@"unlockMethod lock");
NSLog(@"unlockMethod broadcast");
[self.condition broadcast];
sleep(1);
NSLog(@"unlockMethod unlock");
[self.condition unlock];
}
通过多次测试数据来看,如果只在unlockMethod中执行broadcast或signal,并不会立刻触发wait后的代码执行。因为其除了条件锁之外,本质上还是个互斥锁,需要unlockMethod所在的线程释放锁之后,才会执行到wait后的代码。
thread:<NSThread: 0x600001745200>{number = 6, name = (null)}
thread:<NSThread: 0x600001756540>{number = 3, name = (null)}
lockMethod lock
lockMethod wait...
lockMethod lock
lockMethod wait...
thread:<NSThread: 0x600001750480>{number = 7, name = (null)}
unlockMethod lock
unlockMethod broadcast
unlockMethod unlock
lockMethod wait completion
lockMethod unlock
lockMethod wait completion
lockMethod unlock
5.7 NSConditionLock
NSConditionLock是对NSCondition的进一步封装,NSConditionLock的核心在于,当condition为某个条件值时,才会触发另一个线程的lock代码的执行。
初始化condition时,可以传入一个值,并且可以在加锁和解锁的时候,也传入一个值。lockWhenCondition表示当condition符合传入的值,才执行加锁操作。unlockWithCondition表示,解锁并传入一个condition值,传入的值可能会触发其他的lockWhenCondition。
下面是NSConditionLock常用的一些方法,还有其继承的NSLocking相关方法也是常用方法。
- (instancetype)initWithCondition:(NSInteger)condition;
- (void)lockWhenCondition:(NSInteger)condition;
- (void)unlockWithCondition:(NSInteger)condition;
下面示例中创建了三个线程,并且初始化的时候传入的condition是0。当执行threadActionC方法时,符合初始化时传入的value,则C的lock方法被执行。随后会执行到unlock并Condition传入1,执行threadActionB的lock内方法,直到执行到threadActionA,执行到unlock则锁被释放。
self.lock = [[NSConditionLock alloc] initWithCondition:0]; NSThread *threadA = [[NSThread alloc] initWithTarget:self selector:@selector(threadActionA) object:nil];
[threadA start];
sleep(0.2);
NSThread *threadB = [[NSThread alloc] initWithTarget:self selector:@selector(threadActionB) object:nil];
[threadB start];
sleep(0.2);
NSThread *threadC = [[NSThread alloc] initWithTarget:self selector:@selector(threadActionC) object:nil];
[threadC start];
- (void)threadActionC {
NSLog(@"C begin");
/// 由于Condition初始值是0,此时Condition值为0才能加锁成功,成功后会继续执行后续代码
[self.lock lockWhenCondition:0];
NSLog(@"C threadExcute");
[self.lock unlockWithCondition:1];
}
- (void)threadActionB {
NSLog(@"B begin");
/// C设置Condition为1则将B解锁,并执行B的代码及unlock
[self.lock lockWhenCondition:1];
NSLog(@"B threadExcute");
[self.lock unlockWithCondition:2];
}
- (void)threadActionA {
NSLog(@"A begin");
/// B设置Condition为1则将A解锁,并执行A的代码及unlock,锁被释放出来
[self.lock lockWhenCondition:2];
NSLog(@"A threadExcute");
[self.lock unlock];
}
通过日志可以看出,NSConditionLock很适合用来控制多条线程的执行,例如需要多条线程串行执行,并根据判断条件控制执行顺序。
A begin
B begin
C begin
C threadExcute
B threadExcute
A threadExcute
5.8 DISPATCH_QUEUE_SERIAL
通过GCD的串行队列,也可以实现线程锁的作用。将资源代码放在串行队列中执行即可,这种方式不会出现多次lock崩溃的问题,安全且灵活。
self.serialQueue = dispatch_queue_create("com.sohu.serialQueue", nil);NSThread *threadA = [[NSThread alloc] initWithTarget:self selector:@selector(threadActionA) object:nil];
[threadA start];
sleep(0.2);
NSThread *threadB = [[NSThread alloc] initWithTarget:self selector:@selector(threadActionB) object:nil];
[threadB start];
sleep(0.2);
NSThread *threadC = [[NSThread alloc] initWithTarget:self selector:@selector(threadActionC) object:nil];
[threadC start];
- (void)threadActionA {
NSLog(@"A begin");
dispatch_async(self.serialQueue, ^{
NSLog(@"A threadExcute");
});
}
- (void)threadActionB {
NSLog(@"B begin");
dispatch_async(self.serialQueue, ^{
NSLog(@"B threadExcute");
});
}
- (void)threadActionC {
NSLog(@"C begin");
dispatch_async(self.serialQueue, ^{
NSLog(@"C threadExcute");
});
}
5.9 dispatch_semaphore_t
通过GCD的信号量,也可以实现锁的效果,并且信号量还可以进行wait和signal的控制。信号量可以指定并发执行数量,但一般为了防止资源抢夺,不会允许并发执行。实际上,如果不考虑资源抢夺的场景,semaphore设置的value就是并发的数量,只有其为正数才会执行wait和signal之间的代码。
通过下面的Demo演示下如何防止资源抢夺,将semaphore设置为1,这样可以实现同时只有1个线程访问wait和signal中间的资源。信号量在等待释放锁的过程中,线程也会进入休眠,让出CPU的时间片,不会占用资源。
self.semaphore = dispatch_semaphore_create(1);
for (NSInteger i = 0; i < 5; i++) {
NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(threadAction) object:nil];
[thread start];
}- (void)threadAction {
NSLog(@"semaphore waiting");
dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
NSLog(@"thread:%@", [NSThread currentThread]);
sleep(1);
dispatch_semaphore_signal(self.semaphore);
}
下面是打印结果,semaphore大于等于0会执行wait和signal中间的代码,随后就是等下一个signal信号的到来。
semaphore waiting
semaphore waiting
semaphore waiting
semaphore waiting
semaphore waiting
thread:<NSThread: 0x60000173e040>{number = 7, name = (null)}
thread:<NSThread: 0x60000173de40>{number = 9, name = (null)}
thread:<NSThread: 0x60000173e200>{number = 10, name = (null)}
thread:<NSThread: 0x60000173cec0>{number = 11, name = (null)}
thread:<NSThread: 0x60000173e080>{number = 8, name = (null)}
5.10 @synchronized
@synchronized会以传入的对象为纬度,进行加锁。如果多段代码都用@synchronized修饰,则同一个对象只允许被同时访问一次。但如果同一个类型的对象,是不同的实例,可以直接对类对象进行加锁,以保证对同一个对象加锁。如果不同的对象,会有可能导致@synchronized传入的不同对象,执行时出现“锁失效”的情况。
@synchronized ([self class]) {
[self threadAction];
}
@synchronized本质上就是语法糖,并没有直接的源码实现,需要用clang对@synchronized进行分析,可以看出来@synchronized是由objc_sync_enter和objc_sync_exit实现的,由编译器将objc_sync_enter和objc_sync_exit插入到对应的位置,相关函数的实现可以从runtime中的objc-sync.h和objc-sync.m看到。
从更底层来看,@synchronized本质上是对pthread_mutex_t的高级封装,由于用的是pthread_mutex_t的递归锁,所以@synchronized可以被递归调用。我后续会出一篇文章,详细介绍@synchronized的深层实现,这里不过多展开。
using recursive_mutex_t = recursive_mutex_tt<DEBUG>;
class recursive_mutex_tt : nocopy_t {
pthread_mutex_t mLock; public:
recursive_mutex_tt() : mLock(PTHREAD_RECURSIVE_MUTEX_INITIALIZER) { }
void lock() {
lockdebug_recursive_mutex_lock(this);
int err = pthread_mutex_lock(&mLock);
if (err) _objc_fatal("pthread_mutex_lock failed (%d)", err);
}
}
5.11 atomic
属性的atomic定义在runtime源码的objc-accessors.mm文件中,下面是setter和getter的核心实现。从代码实现可以看到,如果是原子性的属性,会加一个spinlock_t自旋锁。
static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy) {
if (!atomic) {
oldValue = *slot;
*slot = newValue;
} else {
spinlock_t& slotlock = PropertyLocks[slot];
slotlock.lock();
oldValue = *slot;
*slot = newValue;
slotlock.unlock();
} objc_release(oldValue);
}
下面是getter的实现,有趣的是,虽然叫spinlock_t,但其并不是自旋锁,而是由os_unfair_lock实现,其本质是一个互斥锁。这是因为spinlock_t原先是通过OSSpinLock实现,但OSSpinLock存在优先级反转的问题,后来将内部实现替换为os_unfair_lock,但并未改spinlock_t的名字。
id objc_getProperty_non_gc(id self, SEL _cmd, ptrdiff_t offset, BOOL atomic) {
// Retain release world
id *slot = (id*) ((char*)self + offset);
if (!atomic) return *slot; // Atomic retain release world
spinlock_t& slotlock = PropertyLocks[slot];
slotlock.lock();
id value = objc_retain(*slot);
slotlock.unlock();
// for performance, we (safely) issue the autorelease OUTSIDE of the spinlock.
return objc_autoreleaseReturnValue(value);
}
需要注意的是,如果将一个属性设置为atomic,这个关键字只是针对于属性的setter、getter方法,调用属性的setter和getter方法是原子性操作。如果我们调用这个属性的方法,或者通过成员变量的方式直接获取属性,这些操作并不受锁的保护。对于这些会导致资源抢夺的操作,最好在业务层使用lock。
由于atomic性能较差,定义的属性如果使用较频繁,并不适合用atomic修饰。否则会出现大量的锁操作,导致性能下降。
06
锁的性能对比
06
锁的性能对比
下面是对iOS中锁的性能对比,从排行来看,@synchronized性能是最差的,但是这种锁使用起来遇到的问题也比较少,不会出现重复lock的崩溃,如果执行次数很少,还是可以考虑的。个人认为,根据执行次数来选择锁,执行次数越多则越要考虑性能高的锁,然后在业务层做一些必要的安全处理。
OSSpinLock; dispatch_semaphore_t; pthread_mutex_t; DISPATCH_QUEUE_SERIAL; NSLock; NSCondition; os_unfair_lock; pthread_mutex_t(recursive); NSRecursiveLock; NSConditionLock; @synchronized;
07
参考资料
07
参考资料
优先级反转那点事儿:
https://zhuanlan.zhihu.com/p/146132061