前言
在实际开发过程中,需要处理一些耗时的操作我们都会用到多线程技术来处理的,但是在使用多线程中,有时候需要保证线程的安全问题,这时候就需要用到锁了。这篇文章就是介绍
ios开发中的各种锁。
1. 锁的种类
我们都知道锁有八大锁,分别是OSSpinLock,dispatch_semaphore,pthread_mutex,NSLock,NSCondition,NSConditionLock,@synchronized和NSRecursiveLock。但是锁的种类有多少种呢?其实有三大种。分别是:自旋锁,互斥锁和读写锁。
1.1 互斥锁
互斥锁是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全局变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区而成。其中属于互斥锁的有NSLock,pthread_mutex和@synchronized。互斥锁也分为递归锁和不递归锁。
-
递归锁:有的叫可重入锁,指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),就是同一线程 外层函数获得锁之后,内层递归函数仍然有获取该锁的代码。 -
不递归锁:有的叫不可重入锁,与递归锁相反,不可递归调用,递归调用就发生死锁。即若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞。同一个线程可以多次获取同一个递归锁,不会产生死锁。而如果一个线程多次获取同一个非递归锁,则会产生死锁。
1.2 自旋锁
线程反复检查锁变量是否可用。由于线程在这一过程中保持执行,因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。自旋锁避免上下文的调度开销,因此对于线程只会阻塞,在短时间的场合是很有效的。
1.3 读写锁
读写锁实际是一种特殊的自旋锁,它把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。这种锁相对于自旋锁而言,能提高并发性。通常,当读写锁处于读者模式锁住状态时,如果有另外线程试图以写模式加锁,读写锁通常会阻塞随后的读模式锁请求,这样可以避免读模式锁长期占用,而等待的写模式锁请求长期阻塞。
2. 互斥锁
通过上面的概念对互斥锁有了一定的了解,接下来就是对互斥锁进行介绍。
2.1 @synchronized
为了查找到@synchronized的底层源码是在哪里的,可以在创建的项目中的main.m文件中添加如下代码
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
@autoreleasepool {
// Setup code that might create autoreleased objects goes here.
appDelegateClassName = NSStringFromClass([AppDelegate class]);
@synchronized (appDelegateClassName) {
NSLog(@"进到里面了");
}
}
return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}
然后在NSLog里面打断点,通过Xcode开启汇编,得到的了如下
0x1099a501d <+125>: callq 0x1099a5372 ; symbol stub for: objc_sync_enter
-> 0x1099a5022 <+130>: leaq 0x211f(%rip), %rdi ; @
0x1099a5029 <+137>: xorl %edx, %edx
0x1099a502b <+139>: movb %dl, %r8b
0x1099a502e <+142>: movl %eax, -0x44(%rbp)
0x1099a5031 <+145>: movb %r8b, %al
0x1099a5034 <+148>: callq 0x1099a5324 ; symbol stub for: NSLog
0x1099a5039 <+153>: jmp 0x1099a503e ; <+158> at main.m
0x1099a503e <+158>: movq -0x40(%rbp), %rdi
0x1099a5042 <+162>: callq 0x1099a5378 ; symbol stub for: objc_sync_exit
然后这些汇编中分别有objc_sync_enter和objc_sync_exit成对出现,就是在这两行代码成对包围的。然后通过符号断点,最终可以知道@synchronized的源码是在objc里面,所以这块的探究还是用以前比较熟悉的objc源码来。
// Begin synchronizing on 'obj'.
// Allocates recursive mutex associated with 'obj' if needed.
// Returns OBJC_SYNC_SUCCESS once lock is acquired.
int objc_sync_enter(id obj)
{
int result = OBJC_SYNC_SUCCESS;
if (obj) {
SyncData* data = id2data(obj, ACQUIRE);
assert(data);
data->mutex.lock();
} else {
// @synchronized(nil) does nothing
if (DebugNilSync) {
_objc_inform("NIL SYNC DEBUG: @synchronized(nil); set a breakpoint on objc_sync_nil to debug");
}
objc_sync_nil();
}
return result;
}
// End synchronizing on 'obj'.
// Returns OBJC_SYNC_SUCCESS or OBJC_SYNC_NOT_OWNING_THREAD_ERROR
int objc_sync_exit(id obj)
{
int result = OBJC_SYNC_SUCCESS;
if (obj) {
SyncData* data = id2data(obj, RELEASE);
if (!data) {
result = OBJC_SYNC_NOT_OWNING_THREAD_ERROR;
} else {
bool okay = data->mutex.tryUnlock();
if (!okay) {
result = OBJC_SYNC_NOT_OWNING_THREAD_ERROR;
}
}
} else {
// @synchronized(nil) does nothing
}
return result;
}
BREAKPOINT_FUNCTION(
void objc_sync_nil(void)
);
通过注释可以知道,这是一个递归互斥锁,通过关联的对象obj来进行锁定的。如果obj为nil,就会执行objc_sync_nil(),而这个函数却是什么都不做的,所以说,如果传进来的obj是nil那么就不具备锁的操作了,就是什么事都不做了。所以传进来的obj的生命周期也是很重要的。如果传进来的obj是存在的,就进去到SyncData* data = id2data(obj, ACQUIRE);,通过源码可以知道SyncData的数据结构。
typedef struct alignas(CacheLineSize) SyncData {
struct SyncData* nextData;
DisguisedPtr<objc_object> object;
int32_t threadCount; // number of THREADS using this block
recursive_mutex_t mutex;
} SyncData;
- nextData: 指的是链表中下一个SyncData。
- object:当前加锁的对象。
- threadCount:使用该对象进行加锁的线程数。
- recursive_mutex_t:就是一个递归锁。这就很好地对应了之前的如果
obj为nil的时候执行objc_sync_nil什么都不做,这样它们搭配使用就可以很好的防止死锁。
2.1.1 id2data函数
由于这个函数里面的代码很多只能部分截取来解析一下。
//函数开始的代码
spinlock_t *lockp = &LOCK_FOR_OBJ(object);
SyncData **listp = &LIST_FOR_OBJ(object);
SyncData* result = NULL;
//=================
// Use multiple parallel lists to decrease contention among unrelated objects.
#define LOCK_FOR_OBJ(obj) sDataLists[obj].lock
#define LIST_FOR_OBJ(obj) sDataLists[obj].data
static StripedMap<SyncList> sDataLists;
通过宏LOCK_FOR_OBJ和LIST_FOR_OBJ分别得到sDataLists中的对应的对象的lock和data,因为@synchronized是可以全局使用的,那么这样的话就是以哈希表的形式存储着SyncList结构的数据,而SyncList和SyncData之间的关系可以用以下这张图来表示
2.1.2 标记的线程的快速查找
还是在id2data函数中的,其中SUPPORT_DIRECT_THREAD_KEYS表示的是在确切的线程中通过TLS的SYNC_DATA_DIRECT_KEY宏的这个key来查找SyncData。
#if SUPPORT_DIRECT_THREAD_KEYS
// Check per-thread single-entry fast cache for matching object
// 检查每线程单项快速缓存中是否有匹配的对象
bool fastCacheOccupied = NO;
SyncData *data = (SyncData *)tls_get_direct(SYNC_DATA_DIRECT_KEY);
if (data) {
fastCacheOccupied = YES;
if (data->object == object) {
// Found a match in fast cache.
uintptr_t lockCount;
result = data;
lockCount = (uintptr_t)tls_get_direct(SYNC_COUNT_DIRECT_KEY);
if (result->threadCount <= 0 || lockCount <= 0) {
_objc_fatal("id2data fastcache is buggy");
}
switch(why) {
case ACQUIRE: {
lockCount++;
tls_set_direct(SYNC_COUNT_DIRECT_KEY, (void*)lockCount);
break;
}
case RELEASE:
lockCount--;
tls_set_direct(SYNC_COUNT_DIRECT_KEY, (void*)lockCount);
if (lockCount == 0) {
// remove from fast cache
tls_set_direct(SYNC_DATA_DIRECT_KEY, NULL);
// atomic because may collide with concurrent ACQUIRE
OSAtomicDecrement32Barrier(&result->threadCount);
}
break;
case CHECK:
// do nothing
break;
}
return result;
}
}
#endif
通过源码可以知道,在获取到的SyncData的data如果存在,就用data中的object与传进来的被锁的对象object来对比。如果是相等的,通过tls_get_direct(SYNC_COUNT_DIRECT_KEY)获取到被锁的次数lockCount。因为是一个递归锁可以锁多次,得到的数量可能是多次的。然后通过传进来的why的值来判断,如果值是ACQUIRE,就是加锁就对lockCount++,如果值是RELEASE,就是解锁对lockCount--并且都通过SYNC_COUNT_DIRECT_KEYkey存在哈希表中,如果是CHECK就什么都不操作。在lockCout == 0的时候,需要将在缓存中的SYNC_DATA_DIRECT_KEY设置为NULL,并且通过OSAtomicDecrement32Barrier进行屏障地减去当前的result的线程数。
2.1.3 锁线程下全部缓存查找
// Check per-thread cache of already-owned locks for matching object
// 检查已拥有锁的每个线程高速缓存中是否有匹配的对象
SyncCache *cache = fetch_cache(NO);
if (cache) {
unsigned int i;
for (i = 0; i < cache->used; i++) {
SyncCacheItem *item = &cache->list[i];
if (item->data->object != object) continue;
// Found a match.
result = item->data;
if (result->threadCount <= 0 || item->lockCount <= 0) {
_objc_fatal("id2data cache is buggy");
}
switch(why) {
case ACQUIRE:
item->lockCount++;
break;
case RELEASE:
item->lockCount--;
if (item->lockCount == 0) {
// remove from per-thread cache
cache->list[i] = cache->list[--cache->used];
// atomic because may collide with concurrent ACQUIRE
OSAtomicDecrement32Barrier(&result->threadCount);
}
break;
case CHECK:
// do nothing
break;
}
return result;
}
}
这块的查找与上面的差不多,这块是在标记的线程中查找不到了,就查找锁的每个线程高速缓存。逻辑都是一样的只不过多了一个for循环。
2.1.4 全局查找
// Thread cache didn't find anything.
// Walk in-use list looking for matching object
// Spinlock prevents multiple threads from creating multiple
// locks for the same new object.
// We could keep the nodes in some hash table if we find that there are
// more than 20 or so distinct locks active, but we don't do that now.
lockp->lock();
{
SyncData* p;
SyncData* firstUnused = NULL;
for (p = *listp; p != NULL; p = p->nextData) {
if ( p->object == object ) {
result = p;
// atomic because may collide with concurrent RELEASE
OSAtomicIncrement32Barrier(&result->threadCount);
goto done;
}
if ( (firstUnused == NULL) && (p->threadCount == 0) )
firstUnused = p;
}
// no SyncData currently associated with object
if ( (why == RELEASE) || (why == CHECK) )
goto done;
// an unused one was found, use it
if ( firstUnused != NULL ) {
result = firstUnused;
result->object = (objc_object *)object;
result->threadCount = 1;
goto done;
}
}
// Allocate a new SyncData and add to list.
// XXX allocating memory with a global lock held is bad practice,
// might be worth releasing the lock, allocating, and searching again.
// But since we never free these guys we won't be stuck in allocation very often.
posix_memalign((void **)&result, alignof(SyncData), sizeof(SyncData));
result->object = (objc_object *)object;
result->threadCount = 1;
new (&result->mutex) recursive_mutex_t(fork_unsafe_lock);
result->nextData = *listp;
*listp = result;
done:
lockp->unlock();
这里加了一把锁来保证线程安全的情况下,遍历清单列表里面的SyncData,如果在for循环里面的p->nextData查找得到并且与object对比相等的,说明是在多线程的情况下的。对当前的线程数OSAtomicIncrement32Barrier(&result->threadCount);相当于加1. 直接去到done。如果在for循环中没有找到的,但是p是有值的,会将p赋值给firstUnused。在没有查找到的话,如果why值为RELEASE或者CHECK直接去到done。因为在for循环中有对firstUnused赋值过,这一个是找到但是没有使用的,也是重新赋值给result再去到done。最后如果都没有的话(即链表不存在——对象对于全部线程来说是第一次加锁)就会创建SyncData并存在result里,方便下次进行存储。
2.1.5 done
done:
lockp->unlock();
if (result) {
// Only new ACQUIRE should get here.
// All RELEASE and CHECK and recursive ACQUIRE are
// handled by the per-thread caches above.
if (why == RELEASE) {
// Probably some thread is incorrectly exiting
// while the object is held by another thread.
return nil;
}
if (why != ACQUIRE) _objc_fatal("id2data is buggy");
if (result->object != object) _objc_fatal("id2data is buggy");
#if SUPPORT_DIRECT_THREAD_KEYS
if (!fastCacheOccupied) {
// Save in fast thread cache
tls_set_direct(SYNC_DATA_DIRECT_KEY, result);
tls_set_direct(SYNC_COUNT_DIRECT_KEY, (void*)1);
} else
#endif
{
// Save in thread cache
if (!cache) cache = fetch_cache(YES);
cache->list[cache->used].data = result;
cache->list[cache->used].lockCount = 1;
cache->used++;
}
}
return result;
在done中进行解锁。在上面的全局查找中result有值的情况下,因为可以进入到done流程的都是新的why == ACQUIRE进来的,所以在why == RELEASE返回nil,如果返回其他的就直接报错了。在标记线程和全局线程进来的话都会缓存起来,并且对线程数量设置为1.这样就和上面的形成了一个闭环。所以在使用@synchronized锁性能是比较低的,因为它在对哈希表进行了一系列的增删改查等操作。并且传进来的值不能为nil,因为会执行objc_sync_nil()什么都不做,传进来的值是要id形式的。
2.2 NSLock
在objc源码中是没有NSLock的,因为它是在Foundation框架里面的,但是这部分内容在Swift中开源了,如果需要了解的可以在swift-corelibs-foundation
下载源码来了解。其中NSLock就是对互斥锁的简单封装。分别用到
pthread_mutex_init(mutex, nil) //初始化
pthread_mutex_destroy(mutex) //销毁
pthread_mutex_lock(mutex) //加锁
pthread_mutex_unlock(mutex) //解锁
在使用NSLock互斥锁进行递归操作的时候会造成线程阻塞的,例如
- (void)test{
NSLock *lock = [[NSLock alloc] init];
dispatch_async(dispatch_get_global_queue(0, 0), ^{
static void (^testMethod)(int);
testMethod = ^(int value){
NSLog(@"加锁===");
[lock lock];
if (value > 0) {
NSLog(@"当前的值 = %d",value);
testMethod(value - 1);
}
[lock unlock];
NSLog(@"解锁===");
};
testMethod(10);
});
}
//=====打印结果======
加锁===
当前的值 = 10
加锁===
会发现此时线程出现了阻塞,因为此时多次加锁但是没有解锁就导致了出现阻塞,注意此时并不是死锁。如果要解决这种情况就需要用到了递归锁NSRecursiveLock。
2.3 NSCondition
NSCondition是条件锁,作为一个锁和线程检查器:锁主要为了检测条件时保护数据源,执行条件引发的任务;线程检查器主要是根据条件决定是否继续运行线程,即线程是否被阻塞。与信号量差不多的。介绍一下里面的主要几个方法使用:
- 1:[condition lock];//一般用于多线程同时访问、修改同一个数据源,保证在同一 时间内数据源只被访问、修改一次,其他线程的命令需要在lock 外等待,只到 unlock ,才可访问
- 2:[condition unlock];//与lock 同时使用
- 3:[condition wait];//让当前线程处于等待状态
- 4:[condition signal];//CPU发信号告诉线程不用在等待,可以继续执行
2.4 NSConditionLock
NSConditionLock是一旦一个线程获得锁,其他线程就一定需要等待。并且NSConditionLock是NSCondition+lock的封装,但是NSConditionLock可以设置锁条件,而NSCondition确只是无脑的通知信号。
2.5 NSRecursiveLock
上面的代码换成NSRecursiveLock之后就可以很正常地执行了。但是递归锁也需要注意一个问题就是死锁。例如下面的代码就会造成死锁.
- (void)test{
NSRecursiveLock *lock = [[NSRecursiveLock alloc] init];
for (NSInteger i = 0; i < 100; i ++) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
static void (^testMethod)(int);
testMethod = ^(int value){
NSLog(@"加锁===");
[lock lock];
if (value > 0) {
NSLog(@"当前的值 = %d",value);
testMethod(value - 1);
}
[lock unlock];
NSLog(@"解锁===");
};
testMethod(10);
});
}
}
执行会报野指针。因为线程之间的相互等待,即在执行操作的过程中没有找到合适的出口导致的。但是@synchronized也是递归锁,用@synchronized替换NSRecursiveLock就可以正常地执行了。同样都是递归锁这两者之间有什么区别呢?因为@synchronized是锁一个对象的,第一次进去会缓存起来,下次再进去是从缓存中取的,只会对lockCount的值进行增加或者减少而已,但是NSRecursiveLock是每次加锁都直接在底层调用创建的,这就是两者之间的不一样。
3. 自旋锁
自旋锁与互斥锁最大的区别就是,自旋锁会忙等,就是线程会不断地请求直到有回应。自旋锁的性能低但是效率高。对于自旋锁的使用,最具代表的就是atomic,因为atomic一般都是用在属性修饰上的,还是用到objc源码来分析。以下展示的是set的底层源码。
void objc_setProperty_atomic(id self, SEL _cmd, id newValue, ptrdiff_t offset)
{
reallySetProperty(self, _cmd, newValue, offset, true, false, false);
}
void objc_setProperty_nonatomic(id self, SEL _cmd, id newValue, ptrdiff_t offset)
{
reallySetProperty(self, _cmd, newValue, offset, false, false, false);
}
static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
if (offset == 0) {
object_setClass(self, newValue);
return;
}
id oldValue;
id *slot = (id*) ((char*)self + offset);
if (copy) {
newValue = [newValue copyWithZone:nil];
} else if (mutableCopy) {
newValue = [newValue mutableCopyWithZone:nil];
} else {
if (*slot == newValue) return;
newValue = objc_retain(newValue);
}
if (!atomic) {
oldValue = *slot;
*slot = newValue;
} else {
spinlock_t& slotlock = PropertyLocks[slot];
slotlock.lock();
oldValue = *slot;
*slot = newValue;
slotlock.unlock();
}
objc_release(oldValue);
}
using spinlock_t = mutex_tt<LOCKDEBUG>;
class mutex_tt : nocopy_t {
os_unfair_lock mLock;
从源码可以看到,如果不是atomic的直接是新值替换旧值。如果是atomic的就用到了spinlock_t自旋锁,但是这个锁已经是在iOS10弃用的了,在这里是一个假的,内部是用了os_unfair_lock互斥锁来替换了。其中PropertyLocks[slot];通过加盐的方式获取自旋锁(防止冲突)来进行加锁和解锁。接下来是对get方法的底层源码分析。
id objc_getProperty(id self, SEL _cmd, ptrdiff_t offset, BOOL atomic) {
if (offset == 0) {
return object_getClass(self);
}
// 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的直接返回值的,如果是atomic的还是需要通过spinlock_t锁来的。
从中可以看出,atomic的底层就是自旋锁来加锁的,本质上在底层的set/get方法加一把锁,生成原子性的get和set方法。所以atoimc只能保证代码进入setter和getter函数内部时是安全的,一旦出了getter和setter函数多线程只能靠程序员保障了。因为atoimc只对同一线程有锁的功能,如果在多线程的情况下是起不到绝对的锁作用的,所以atomic不是绝对安全的。所以在实际开发中不建议直接使用atomic的,因为耗性能并且不能保证线程的安全比nonatoimc慢了近20倍,还不如使用nonatoimc。
原子性理解:假如当前有两个线程A和B,线程A执行getter方法的时候,线程B想执行setter方法,必须要等到getter方法执行完毕之后才可以执行setter方法。
4. 读写锁
读写锁在实际开发中用得比较少,通过pthread_rwlock_destroy对读写锁进行清理工作,释放由init分配的资源。
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock)
成功则返回0, 出错则返回错误编号.
以下3个函数分别实现获取读锁, 获取写锁和释放锁的操作.
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
成功则返回0, 出错则返回错误编号.
获取锁的两个函数是阻塞操作,同样,成功则返回0,出错则返回错误编号.非阻塞的函数为:
#include <pthread.h>
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
但是在实际开发中一般都是用dispatch_barrier_async来作为读写锁,因为读的时候是不需要关心线程的问题的,只有写的时候才需要关心,因为栅栏可以对未执行完的操作做阻塞,在写的时候添加到dispatch_barrier_async里面可以起到同步的效果,这可以自己试一下。
5. 最后
@synchronized在底层通过哈希链表对SyncData进行增删改查,使用recursive_mutex_t进行加锁,耗性能。NSLock、NSRecursiveLock、NSCondition和NSConditionLock底层都是对pthread_mutex的封装。atomic自旋锁只是对同一线程下的getter和setter函数有锁的作用。- 读写锁一般用
栅栏来代替。 - 递归锁需要注意
死锁的问题。
以下是各种锁的性能对比图