iOS中的锁

1,266 阅读15分钟

前言

在实际开发过程中,需要处理一些耗时的操作我们都会用到多线程技术来处理的,但是在使用多线程中,有时候需要保证线程的安全问题,这时候就需要用到了。这篇文章就是介绍ios开发中的各种锁。

1. 锁的种类

我们都知道有八大锁,分别是OSSpinLock,dispatch_semaphore,pthread_mutex,NSLock,NSCondition,NSConditionLock,@synchronizedNSRecursiveLock。但是锁的种类有多少种呢?其实有三大种。分别是:自旋锁互斥锁读写锁

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_enterobjc_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来进行锁定的。如果objnil,就会执行objc_sync_nil(),而这个函数却是什么都不做的,所以说,如果传进来的objnil那么就不具备锁的操作了,就是什么事都不做了。所以传进来的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:就是一个递归锁。这就很好地对应了之前的如果objnil的时候执行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_OBJLIST_FOR_OBJ分别得到sDataLists中的对应的对象的lockdata,因为@synchronized是可以全局使用的,那么这样的话就是以哈希表的形式存储着SyncList结构的数据,而SyncListSyncData之间的关系可以用以下这张图来表示

2.1.2 标记的线程的快速查找

还是在id2data函数中的,其中SUPPORT_DIRECT_THREAD_KEYS表示的是在确切的线程中通过TLSSYNC_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

通过源码可以知道,在获取到的SyncDatadata如果存在,就用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是一旦一个线程获得锁,其他线程就一定需要等待。并且NSConditionLockNSCondition+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进行加锁,耗性能。
  • NSLockNSRecursiveLockNSConditionNSConditionLock底层都是对pthread_mutex的封装。
  • atomic自旋锁只是对同一线程下的gettersetter函数有锁的作用。
  • 读写锁一般用栅栏来代替。
  • 递归锁需要注意死锁的问题。

以下是各种锁的性能对比图