iOS探索 -- iOS中的锁(一)

98 阅读15分钟

iOS 探索系列相关文章 :

iOS 探索 -- alloc、init 与 new 的分析

iOS 探索 -- 内存对齐原理分析

iOS 探索 -- isa 的初始化和指向分析

iOS 探索 -- 类的结构分析(一)

iOS 探索 -- 类的结构分析(二)

iOS 探索 -- 消息查找流程(一)

iOS 探索 -- 消息查找流程(二)

iOS 探索 -- 动态方法决议分析

iOS 探索 -- 消息转发流程分析

iOS 探索 -- 离屏渲染

iOS 探索 -- KVC 原理分析

iOS 探索 -- iOS中的锁(一)

iOS 探索 -- iOS中的锁(二)

1. 锁的分类

在 iOS 中基本的锁就包括了三类: 自旋锁互斥锁速写锁, 其他的还有一些锁都是基于这些锁的一些上层封装实现, 比如: 条件锁递归锁 等 :

1. 自旋锁

当线程遇到自旋锁并且无法获取到时, 会一直反复的检查锁是否可用, 线程会始终处于活跃状态, 所以自旋锁是一种忙等锁。由于不会陷入休眠, 在一定时间内相当于是一个 死循环 状态, 所以会消耗较多的 cpu 资源。自旋锁避免了进程上下文的调度开销, 所以自旋锁比较适合用于短时间内短时间内阻塞线程的轻量级访问 (递归调用中使用自旋锁一定会产生死锁) 。

早期 iOS 中属性的 atomic 修饰符就是使用自旋锁来实现的, 这个会放到后面进行探索。

atomic的相关研究会放到后面的文章中

  • OSSpinLock (因为不安全的原因已经废弃)

2. 互斥锁

互斥锁在多线程中是用来 防止两条线程同时对同一公共资源 (比如全局变量) 进行读写的机制, 该机制是通过将代码切成一个一个的临界区来达成的。互斥锁情况下假如一个线程无法获取到锁, 就会进入休眠状态, 等待锁被释放时被唤醒。互斥锁又可以被分为 递归锁非递归锁

  • NSLock
  • pthread_mutex
  • @synchronized

3. 条件锁

条件锁就是包含条件变量, 当某些资源要求 (也就是条件) 不满足时线程就会锁住进入休眠状态。等到满足条件后程序继续运行, 条件锁是对互斥锁的上层封装。iOS中的条件锁有:

  • NSCondition
  • NSConditionLock

4. 递归锁

递归锁就是同一个线程可以被加锁多次而不会引发死锁, 递归锁也包含在互斥锁当中。iOS 中的递归锁:

  • NSRecursiveLock
  • pthread_mutex

5. 信号量 (semaphore)

信号量是一种更高级的同步机制, 互斥锁可以说是 semaphore 在取值 0/1 时的特殊情况。 信号量可以有更多的取值空间, 用来实现更加复杂的同步方式, 不单单是线程间的互斥行为。

  • dispatch_semaphore

信号量的相关内容会在后面专门来研究一下

2. 几种锁的分析

上面介绍了一下 iOS 中各种锁的分类, 接下来再来看一下他们各自的特点以及需要注意的问题:

1. OSSpinLock

OSSpinLock 因为安全性问题已经被评估废弃了, 在 atomic 的底层实现中虽然还能够看到自旋锁 (spinlock_t) 的身影, 但是其实现已经被替换成了 os_unfair_lockos_unfair_lock 是苹果推荐的用来替换 spinlock 的方案, 只有在 iOS 10.0 以上的系统才可以使用它。并且 os_unfair_lock 是一种互斥锁, 它不会像自旋锁那样忙等, 而是在等待时会进入休眠状态。

2. @synchronized

@synchronized 在平时开发中可能是最常见的锁了, 主要是因为它的使用相对来说比较简单, 仅仅需要一个代码块就完事了。但是它并不是在所有情况下都能保证线程安全, 并且他的性能相比其他锁老说也是比较低的。为什么说不是所有情况都能保证线程安全, 下面来看一组测试结果:

1. 使用案例

 // 总共有 20 张车票, 然后使用多个线程同时调用当前方法
 // 测试代码 1
 - (void)saleTicket{
     // 枷锁 - 线程安全
     @synchronized (self) {
         if (self.ticketCount > 0) {
             self.ticketCount--;
             sleep(0.1);
             NSLog(@"当前余票还剩:%ld张",self.ticketCount);
         }else{
             NSLog(@"当前车票已售罄");
         }
     }
 }
 // 测试代码 2
 ​

结果 1:

image-20220612011136540

结果 2:

image-20220612011238064

可以发现当传入的对象为空时, 数据就变的不安全了, 相当于此时的 @synchronized 无效了, 那么产生这个问题的原因是什么呢? 下面从它的源码来研究一下吧:

2. 源码分析

在经过 clang 重新编译之后, 调用 @synchronized 的地方被编译成了下面的代码:

 {
             id _rethrow = 0;
             id _sync_obj = (id)appDelegateClassName;
             objc_sync_enter(_sync_obj);
             try {
                 struct _SYNC_EXIT {
                     _SYNC_EXIT(id arg) : sync_exit(arg) {}
                     ~_SYNC_EXIT() {
                         objc_sync_exit(sync_exit);
                     }
                     id sync_exit;
                 }
                 _sync_exit(_sync_obj);
             } catch (id e) {_rethrow = e;}
             {
                 struct _FIN { _FIN(id reth) : rethrow(reth) {}
                     ~_FIN() { if (rethrow) objc_exception_throw(rethrow); }
                     id rethrow;
                 }_fin_force_rethow(_rethrow);
             }
         }

可以看到锁被转化为了对 objc_sync_enterobjc_sync_exit 的一对方法调用, 那么这两个方法是用来干嘛的, 在 objc 源码 中找到了这两个方法的实现:

1. objc_sync_enterobjc_sync_exit
 // Begin synchronizing on 'obj'. 
 // Allocates recursive mutex associated with 'obj' if needed.
 // Returns OBJC_SYNC_SUCCESS once lock is acquired.  
 //开始在“obj”上同步。
 //如果需要,分配与“obj”关联的递归互斥锁。
 //获取锁后返回 OBJC_SYNC_SUCCESS。
 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
 //结束“obj”上的同步。
 //返回 OBJC_SYNC_SUCCESS 或 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;
 }

观察上面的代码和注释可以得到以下几点:

  1. 如果可以, 他会给传进来的对象分配一个 递归互斥锁 (mutex.lock( ))

  2. 如果进来的时候的对象 obj 被释放或者为 nil, 它不会做任何操作, 然后会调用一个方法 objc_sync_nil :

     BREAKPOINT_FUNCTION(
         void objc_sync_nil(void)
     );
    

    在上面的测试代码中我们已经得出过结论, 就是假如传进去的对象为 nil 的话, 是无法保证线程安全的。所以这里推测如果 obj 为 nil 的话就会跳过加锁也就是加锁失败。

  3. mutex 是一个递归锁, 假如在递归过程中对象被释放了就会进入到 objc_sync_nil 触发加锁失败, 进而避免发生死锁

但是代码中的加锁操作并不是直接给 obj 进行的加锁, 而是通过 id2data 先生成一个 SyncData 的对象 data, 然后再对这个对象进行操作, 这一部分的实现又是什么样的, 接下来来看一下:

2. syncDatasyncDataList
 // syncData
 // Allocate a lock only when needed.  Since few locks are needed at any point
 // in time, keep them on a single list.
 // 仅在需要时分配锁。因为任何时候都不需要锁
 // 及时地,把它们放在一张 list 表里。
 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;
 // syncList
 struct SyncList {
     SyncData *data;
     spinlock_t lock;
     constexpr SyncList() : data(nil), lock(fork_unsafe_lock) { }
 };
 ​
 #define LOCK_FOR_OBJ(obj) sDataLists[obj].lock
 #define LIST_FOR_OBJ(obj) sDataLists[obj].data
 static StripedMap<SyncList> sDataLists;
 // stripedMap 中发现的哈希函数
 static unsigned int indexForPointer(const void *p) {
     uintptr_t addr = reinterpret_cast<uintptr_t>(p);
     return ((addr >> 4) ^ (addr >> 9)) % StripeCount; // 哈希函数
 }
  • SyncData
  1. 首先 syncData 是一个结构体结构, 里面包含一个 object , 就是我们传入进来的对象。还包括一个 recursive_mutex_t 类型的锁, 就是在上面调用的 递归互斥锁 mutex
  2. 一个用来指向另外一个 SyncData 的指针 nextData , 所以他可能会被当做链表中的一个元素
  3. 最后还有一个 threadCount 用来记录使用 mutex 锁的线程的数量
  • SyncList
  1. 首先包含一个 SyncData 类型的指针, 所以 SyncData 就是链表中的结点, 然后每一个 SyncList 结构体中都有一个指向以 syncData 为结点的链表头结点的指针 data
  2. 包含 lock 锁, 为了防止被多个线程并发修改
  • sDataLists
  1. 他是一个哈希表, 里面存储的是一个一个的 SyncList 结构, 在 StripedMap 结构体中找到了 indexForPointer 命名的哈希函数, 原理就是通过传进来的对象来找到对应 SyncList 的位置
  2. LOCK__FOR_OBJ(obj) 通过对对象经过哈希函数计算后找到对应的 SyncList 位置
  3. LIST_FOR_OBJ(obj) 拿到 list 后, 在通过对象经过该宏定义算法后拿到对应的 SyncData 数据,

下面来通过一张图表达一下大概的关系:

注: 图片来源

sync_lock_5.png

3. id2data

分析完了大概的结构, 加下来看看 id2data 中都做了些什么:

 static SyncData* id2data(id object, enum usage why)
 {
     // 1. 准备工作
     spinlock_t *lockp = &LOCK_FOR_OBJ(object);
     SyncData **listp = &LIST_FOR_OBJ(object);
     SyncData* result = NULL;
     
     // @syn - vc model view 任何地方都可以直接使用 - 全局
     // data 存储节点
     // map - obj - 表
     
     // 2. TLS 快速缓存逻辑
 #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
 ​
     // 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;
         }
     }
 ​
     // 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.
     
     // 4. 哈希表 sDataLists 查找
     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;
     // 5. 结束工作
  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;
 }

可以看到代码很长很长 (有将近 200 行了), 下面来分块展开对其进行分析一下:

由于代码太多, 下面分步骤的时候就不再贴出来了, 只在代码中做了一些标识。

1. 准备工作
     spinlock_t *lockp = &LOCK_FOR_OBJ(object);
     SyncData **listp = &LIST_FOR_OBJ(object);
     SyncData* result = NULL;

lockp: 获取到 syncList 结构体中的 锁, 如果要对 list 做操作的话, 为保证线程安全肯定要进行加锁

listp: 获取到 syncList 结构体中的链表结构

result: 定义的返回值

2. TLS 快速查找缓存

TLS (Thread Local Storage) : 在 iOS 中的每个线程都有自己的 TLS , 负责保存当前线程的一些变量, 并且 TLS 不需要锁保护。

SYNC_DATA_DIRECT_KEYSYNC_COUNT_DIRECT_KEY 两个宏定义都是 tls_key_t 类型的, 主要是配合 tls_get_directtls_set_direct 用来读取和设置缓存中的 SyncCacheItem.dataSyncCacheItem.lockCount

tls 相关内容这里不做过多研究了, 这里的内容也是本人通过大神的博客了解到的, 下面就只说一下这里的大概思路吧。

  1. 通过 TLS 方法 tls_get_direct 查找缓存的 syncData 结构, 如果没有找到就直接跳过
  2. 如果找到了, 再通过 tls_get_direct 方法获取到 lockCount (被锁的次数), 然后根据外部传过来的 why 参数来决定是进行 ++ 操作 还是进行 -- 操作
  3. 如果是 objc_sync_enter 过来的 ACQUIRE, 就是加锁过程, 进行 ++ 操作并且将新的 lockCount 值保存
  4. 如果是 objc_sync_exit 过来的 RELEASE , 就是结果过程, 进行 -- 操作并且保存新值。如果此时的 lockCount == 0 , 就会从快速缓存中移除
3. 慢速遍历查找缓存

先看一下缓存相关的这两个结构体:

 typedef struct {
     // 缓存的 syncData 对象数据
     SyncData *data;
     // 缓存数据被加锁的次数
     unsigned int lockCount;  // number of times THIS THREAD locked this block
 } SyncCacheItem;
 ​
 typedef struct SyncCache {
     // 缓存容量大小
     unsigned int allocated;
     // 缓存被使用的大小
     unsigned int used;
     // 缓存数组
     SyncCacheItem list[0];
 } SyncCache;
 /*
   Fast cache: two fixed pthread keys store a single SyncCacheItem. 
   This avoids malloc of the SyncCache for threads that only synchronize 
   a single object at a time.
   SYNC_DATA_DIRECT_KEY  == SyncCacheItem.data
   SYNC_COUNT_DIRECT_KEY == SyncCacheItem.lockCount
  */
  1. SyncCache 的缓存数组进行遍历, 尝试找到与传入对象相等的缓存数据, 如果找不到就跳过
  2. 如果找到了, 就根据当前的操作对 SyncCacheItem 的加锁次数进行 ++-- 操作, 流程跟上面差不多。如果 -- 操作后 lockCount== 0 , 让 used 进行 -- 操作, 并且 list[x](x 表示查找到缓存的位置) 的数据替换为最后一条数据 (就相当于用最后面的数据覆盖掉当前数据, 然后长度减 1, 也是变向的移除当前数据)
4. 哈希表查找

还记得第一步准备工作的那几个数据吗, 现在要用了。经过两次缓存查找都没有找到的话, 就来到了对全局保存的哈希表 (sDataLists) 中进行查找 :

  1. 首先对要查找的 SyncDataList 进行加锁操作
  2. 使用 for 循环遍历链表, 如果首个 SyncData 的 threadCount == 0 , 说明是新的 list, 就将他赋值给 firstUnused , 然后在后面判断如果是第一次使用 (也就是 firstUnused 有值) , 就将 threadCount 变为 1, 并且将当前对象加入进去 然后将值赋值给 result 进入 goto done
  3. for 循环中如果查询到了相关数据, 就赋值给 result 然后进入 goto done
  4. 如果是 RELEASE 也就是解锁过程没有查找到匹配数据, 就直接进入 goto done
  5. 如果直到最后确实都找不到, 就是以上条件都不满足, 就说明该对象确实是第一次被加锁操作。那么就按照 SyncData 和 SyncDataList 的结构重新初始化一个 data 数据, 并将新的 *listp 指向该结点。然后返回赋值后的 result
5. 结束工作

最后一步来到了一个 done: 代码块里, 先看一下代码块中的注释内容:

 // Only new ACQUIRE should get here.
 // All RELEASE and CHECK and recursive ACQUIRE are 
 // handled by the per-thread caches above.
 // 只有首次获取锁的对象才应该来到这里
 // 所有的 释放和检查和递归获取 
 // 都是由上面的每条线程缓存处理的

所以正常流程下只有首次被添加的对象来到这里, 这里面主要做的工作就是一下异常问题的判断处理还有对首次进入的对象的数据缓存工作。

  1. 首先对第 4 步中的锁进行解锁

  2. 然后是对 3 种不应该来到这里的情况进行了一些判断和处理

    • 当前为 RELEASE 情况, 直接返回 nil
    • 如果当前不是 ACQUIRE 加锁操作, 调用 _objc_fatal
    • 如果获取到的 result->object != 原对象 , 调用 _objc_fatal
  3. 进行缓存处理, 判断 fastCacheOccupied , 这里有一点需要说明一下:

    • 首先一种情况是确实在 tls 快速查找中没有找到 , 这里就是需要进行 tls 快速缓存
    • 还有一种情况是什么呢, 就是在 tls 快速查找找到了对象, 但是找到的对象并不等于传进来的 obj , 但是在代码中 fastCacheOccupied 已经被赋值了 YES , 所以在这里还有一个 else 如果快速缓存被占用了的话, 就存入到 SynCache 缓存中

3. 总结

  1. Synchronized 在实现中给每个传入的对象 object 都分配了一把递归互斥锁, 并且会将对象存储到 tls 或者 哈希表中, 并且允许进行多次加锁, 加锁的次数被存储在 SyncData 结构的 lockCount
  2. 加锁和解锁过程是通过 objc_sync_enterobjc_sync_exit 两个方法实现的
  3. 如果在加锁过程中传入的对象是 nil 的话, 就会导致加锁失败
  4. 存储加锁对象的数据结构是一个哈希表结构, 里面首先存储的是一个一个的 SyncDataList 结构数据, 然后在每个 list 下面都会有一个用来存储对象的链表结构, 链表以 SyncData 结构体为结点。加锁和解锁的过程就是对 SyncData 结构体下的 mutex 递归互斥锁进行的 lockunlock 操作
  5. SyncData 对象的获取过程是在 id2data 方法中进行的, 里面的主要流程是对缓存的数据的相关操作, 当然如果是首次加进来的对象也要做处理并加入缓存以便下次使用。
  6. 最后一开始所说的 @synchronize 的性能其实并不好我想应该也有答案了吧, 在它的使用中伴随了很多缓存的查找以及对哈斯表的查找等, 会导致它的性能方法不如那些可以直接被拿来使用的其他锁。

最后

终于把 @synchronize 搞完了, 由于篇幅问题后面的锁就放到下一篇来继续介绍吧, 这篇就到此为止吧。