@synchronized 递归锁详解

如果你已经使用 Objective-C 编写过任何并发程序,那么想必是见过 @synchronized 这货了。@synchronized 结构所做的事情跟锁(NSLock, 更准确的说法应该是递归锁NSRecursiveLock)类似:它可以防止不同的线程同时执行同一段代码。但在某些情况下,相比于使用 NSLock 创建锁对象、加锁和解锁来说,@synchronized 用着更方便,可读性更高, 自然效率会比较低。

递归锁: 同一个线程可以重复的加锁而不会导致死锁(互斥锁: 同一个线程重复加锁会导致死锁) ,加的递归锁全部执行完后 才会把资源让给别的线程。不同的线程要求加锁会陷入等待. 

下面是SDWebImage中的代码, 给一个线程不安全的NSMapTable加递归锁, 保证NSMapTable的线程安全.


在上面的例子中, @synchronized与 [_lock lock] 和 [_lock unlock] 效果相同。你可以把它当成是锁住 self,仿佛 self 就是个 NSLock。锁在左括号 { 后面的任何代码运行之前被获取到,在右括号 } 后面的任何代码运行之前被释放掉。再也不用担心我忘记调用 unlock 了!

你可以给任何 Objective-C 对象上加个 @synchronized。效果和 @synchronized(self)是相同的。

回到研究上来

我对 @synchronized 的实现十分好奇并搜了一些它的细节。我找到了一些答案,但这些解释都没有达到我想要的深度。

  • 锁是如何与你传入 @synchronized 的对象关联上的?
  • @synchronized会保持(retain,增加引用计数)被锁住的对象么?
  • 假如你传入 @synchronized 的对象在 @synchronized 的 block 里面被释放或者被赋值为 nil 将会怎么样?

这些全都是我想回答的问题。而我这次的收获,会要你好看😏。

@synchronized 的文档告诉我们 @synchronized block 在被保护的代码上暗中添加了一个异常处理。为的是同步某对象时如若抛出异常,锁会被释放掉。

SO 上的这篇帖子 说 @synchronized block 会变成 objc_sync_enter 和 objc_sync_exit 的成对儿调用。我们不知道这些函数是干啥的,但基于这些事实我们可以认为编译器将这样的代码:

@synchronized(obj) {
    // do work
}
转化成这样的东东:
@try {
    objc_sync_enter(obj);
    // do work
} @finally {
    objc_sync_exit(obj);    
}

objc_sync_enter 和 objc_sync_exit 是什么鬼?它们是如何实现的?在 Xcode 中按住 Command 键单击它们,然后进到了 <objc/objc-sync.h>,里面有我们感兴趣的这两个函数:


/** 
 * Begin synchronizing on 'obj'.  
 * Allocates recursive pthread_mutex associated with 'obj' if needed.
 * 
 * @param obj The object to begin synchronizing on.
 * 
 * @return OBJC_SYNC_SUCCESS once lock is acquired.  
 */
OBJC_EXPORT int
objc_sync_enter(id _Nonnull obj)
    OBJC_AVAILABLE(10.3, 2.0, 9.0, 1.0, 2.0);

/** 
 * End synchronizing on 'obj'. 
 * 
 * @param obj The object to end synchronizing on.
 * 
 * @return OBJC_SYNC_SUCCESS or OBJC_SYNC_NOT_OWNING_THREAD_ERROR
 */
OBJC_EXPORT int
objc_sync_exit(id _Nonnull obj)
    OBJC_AVAILABLE(10.3, 2.0, 9.0, 1.0, 2.0);

不过,objc_sync_enter 的文档告诉我们一些新东西: @synchronized 结构在工作时为传入的对象分配了一个递归锁。分配工作何时发生,如何发生呢?它怎样处理 nil?幸运的是 Objective-C runtime 是开源的,所以我们可以马上阅读源码并找到答案!

你可以在这里找到 objc-sync 的全部源码。在代码块的下方我将立刻做出解释,所以尝试理解代码时别花太长时间哦。

typedef struct SyncData {
    id object;
    recursive_mutex_t mutex;
    struct SyncData* nextData;
    int threadCount;
} SyncData;

typedef struct SyncList {
    SyncData *data;
    spinlock_t lock;
} SyncList;

// Use multiple parallel lists to decrease contention among unrelated objects.
#define COUNT 16
#define HASH(obj) ((((uintptr_t)(obj)) >> 5) & (COUNT - 1))
#define LOCK_FOR_OBJ(obj) sDataLists[HASH(obj)].lock
#define LIST_FOR_OBJ(obj) sDataLists[HASH(obj)].data
static SyncList sDataLists[COUNT];


----------
SyncData中的recursive_mutex_t最终是recursive_mutex_tt类型,
recursive_mutex_tt内部有个pthread_mutex_t的锁,
这个锁初始化为一个递归锁 PTHREAD_RECURSIVE_MUTEX_INITIALIZER
----------

class recursive_mutex_tt : nocopy_t {
    pthread_mutex_t mLock;

  public:
    recursive_mutex_tt() : mLock(PTHREAD_RECURSIVE_MUTEX_INITIALIZER) {
        lockdebug_remember_recursive_mutex(this);
    }

    recursive_mutex_tt(const fork_unsafe_lock_t unsafe)
        : 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);
    }

    void unlock()
    {
        lockdebug_recursive_mutex_unlock(this);

        int err = pthread_mutex_unlock(&mLock);
        if (err) _objc_fatal("pthread_mutex_unlock failed (%d)", err);
    }
    ..... 其他方法
};

一开始,我们有一个 struct SyncData 的定义。这个结构体包含一个 object(嗯就是我们给 @synchronized 传入的那个对象)和一个有关联的 recursive_mutex_t(底层实现的递归锁),它就是那个跟 object 关联在一起的锁。每个 SyncData 也包含一个指向另一个 SyncData 对象的指针,叫做 nextData,所以你可以把每个 SyncData 结构体看做是链表中的一个元素。最后,每个 SyncData 包含一个 threadCountthreadCount 就是递归锁在同一线程的加锁次数。每次成功的获得该锁都必须平衡调用锁住和解锁的操作。只有所有的锁住和解锁操作都平衡的时候,锁才真正被释放给其他线程获得。当threadCount==0 就表明了这个 SyncData 实例可以被其他线程获得了。

下面是 struct SyncList 的定义。正如我在上面提过,你可以把 SyncData 当做是链表中的节点。每个 SyncList 结构体都有个指向 SyncData 节点链表头部的指针,也有一个用于防止多个线程对此列表做并发修改的锁。

上面代码块的最后一行是 sDataLists 的声明 - 一个 SyncList 结构体数组,大小为16。通过定义的一个哈希算法将传入对象映射到数组上的一个下标。值得注意的是这个哈希算法设计的很巧妙,是将对象指针在内存的地址转化为无符号整型并右移五位,再跟 0xF 做按位与运算,这样结果不会超出数组大小。 LOCK_FOR_OBJ(obj) 和 LIST_FOR_OBJ(obj) 这俩宏就更好理解了,先是哈希出对象的数组下标,然后取出数组对应元素的 lock 或 data。一切都是这么顺理成章哈。

当你调用 objc_sync_enter(obj) 时,它用 obj 内存地址的哈希值查找合适的 SyncData,然后将其上锁。当你调用 objc_sync_exit(obj) 时,它查找合适的 SyncData 并将其解锁。

噢耶!现在我们知道了 @synchronized 如何将一个锁和你正在同步的对象关联起来,我希望聊聊当一个对象在 @synchronized block 当中被释放或设为 nil 时会发生什么。

如果你看了源码,你会注意到 objc_sync_enter 里面没有 retain 和 release。所以看上去它没有保持传递给它的对象。在MRC环境下引用计数不会增加, 但是在ARC环境下呢?  我们可以用下面的代码来分别做个测试:

//  ARC环境下, 测试
int main(int argc, char * argv[]) {

    NSObject *obj = [[NSObject alloc] init];
    NSLog(@"before count = %lu",(unsigned long)CFGetRetainCount((__bridge CFTypeRef)obj));
    @synchronized (obj) {
          NSLog(@"in syn count = %lu", (unsigned long)CFGetRetainCount((__bridge CFTypeRef)obj));
    }
    NSLog(@"after syn count = %lu", (unsigned long)CFGetRetainCount((__bridge CFTypeRef)obj));
}

-----------

before count = 1
in syn count = 2
after syn count = 1
// MRC环境下, 修改xcode环境到MRC: https://www.jianshu.com/p/b7e97ee75539
int main(int argc, char * argv[]) {

    NSObject *obj = [[NSObject alloc] init];

    NSLog(@"1 -- %lu",[obj retainCount]);
    NSLog(@"before count = %lu",(unsigned long)CFGetRetainCount((__bridge CFTypeRef)obj));
    @synchronized (obj) {
        NSLog(@"2 -- %lu",[obj retainCount]);
        NSLog(@"in syn count = %lu", (unsigned long)CFGetRetainCount((__bridge CFTypeRef)obj));
    }
    NSLog(@"3 -- %lu",[obj retainCount]);
    NSLog(@"after syn count = %lu", (unsigned long)CFGetRetainCount((__bridge CFTypeRef)obj));
    [obj release];
}

----------------
 1 -- 1
 before count = 1
 2 -- 1
 in syn count = 1
 3 -- 1
 after syn count = 1

interesting.在MRC环境下输出结果都是 1; 在ARC环境下,synchronized中输出的retainCount为2.

我们使用汇编确认下.在ARC环境下, 2次NSLog之间 除了有正常的objc_sync_enter/objc_sync_exit之外, 还多了objc_retain/objc_release操作, 而且可以确认是由synchronized本身带来的.

 同样, 在MRC下查看汇编, 发现objc_sync_enter/objc_sync_exit之外根本没有objc_retain/objc_release.

结合汇编和打印的信息, 我们可以确认MRC下使用synchronized不会增加引用计数, ARC下会使引用计数加1, 这个加1猜测就是 SyncData 下的 object 强引用导致增加的.

所以, 在ARC下基本不用考虑执行中被释放的操作,在MRC下需要考虑下.

MRC下如果你正在同步的对象被释放了,然后有可能另一个新的对象在此处(被释放对象的内存地址)被分配内存。有可能某个其他的线程试着去同步那个新的对象(就是那个在被释放的旧对象的内存地址上刚刚新创建的对象)。在这种情况下,另一个线程将会阻塞,直到当前线程结束它的同步 block。这看起来并不是很糟。这听起来像是这种事情实现者早就知道并予以接受。我没有遇到过任何好的替代方案。

假如对象在 “synchronized block” 中被设成 nil 呢?我们再回顾下实现吧:

NSString *test = @"test";
@try {
    // Allocates a lock for test and locks it
    objc_sync_enter(test);
    test = nil;
} @finally {
    // Passed `nil`, so the lock allocated in `objc_sync_enter`
    // above is never unlocked or deallocated
    objc_sync_exit(test);   
}

objc_sync_enter 被调用时传入的是 test 而 objc_sync_exit 被调用时传入的是 nil。而传入 nil 的时候 objc_sync_exit 是个空操作,所以将不会有人释放锁。这真操蛋!

如果 Objective-C 容易受这种情况的影响,我们知道么?下面的代码调用 @synchronized 并在 @synchronized block 中将一个指针设为 nil。然后在后台线程对指向同一个对象的指针调用 @synchronized。如果在 @synchronized block 中设置一个对象为 nil 会让锁死锁,那么在第二个 @synchronized 中的代码将永远不会执行。我们将不会在控制台中看见任何东西打印出来。

NSNumber *number = @(1);
NSNumber *thisPtrWillGoToNil = number;

@synchronized (thisPtrWillGoToNil) {
    /**
     * Here we set the thing that we're synchronizing on to `nil`. If
     * implemented naively, the object would be passed to `objc_sync_enter`
     * and `nil` would be passed to `objc_sync_exit`, causing a lock to
     * never be released.
     */
    thisPtrWillGoToNil = nil;
}

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^ {

    NSCAssert(![NSThread isMainThread], @"Must be run on background thread");

    /**
     * If, as mentioned in the comment above, the synchronized lock is never
     * released, then we expect to wait forever below as we try to acquire
     * the lock associated with `number`.
     *
     * This doesn't happen, so we conclude that `@synchronized` must deal
     * with this correctly.
     */
    @synchronized (number) {
        NSLog(@"This line does indeed get printed to stdout");
    }

});

当我们执行上面的代码时,那行代码确实打印到控制台了!所以 Objective-C 很好地处理了这种情形。我打赌是编译器做了类似下面的事情来解决这事儿的。

NSString *test = @"test";
id synchronizeTarget = (id)test;
@try {
    objc_sync_enter(synchronizeTarget);
    test = nil;
} @finally {
    objc_sync_exit(synchronizeTarget);   
}

用这种方式实现的话,传递给 objc_sync_enter 和 objc_sync_exit 总是相同的对象。他们在传入 nil 时都是空操作。这带来了个棘手的 debug 场景:如果你向 @synchronized 传递 nil,那么你就不会得到任何锁而且你的代码将不会是线程安全的!如果你想知道为什么你正收到出乎意料的竞态(race),确保你没向你的 @synchronized 传入 nil。你可以在 objc_sync_nil 上设置一个符号断点来达到此目的。objc_sync_nil 是一个空方法,当 objc_sync_enter 函数被传入 nil 时会被调用,这让 debug 更容易些。

下面是 objc_sync_enter/objc_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) {
        // 查找这个obj是否已经生成SyncData,如果没有生成一个
        SyncData* data = id2data(obj, ACQUIRE); 
        assert(data);
        data->mutex.lock(); // 调用SyncData的递归锁加锁
    } else {
        // @synchronized(nil) does nothing
        // 如果传入nil, 打印了一个log,然后什么都不做
        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上的同步操作, 
int objc_sync_exit(id obj)
{
    int result = OBJC_SYNC_SUCCESS;
    
    if (obj) {
        //还是找到这个对象所在的结构体SyncData
        SyncData* data = id2data(obj, RELEASE); 
        if (!data) {
            // 如果这个结构体在block执行过程中找不到了,会返回error
            result = OBJC_SYNC_NOT_OWNING_THREAD_ERROR;
        } else {
            // 尝试解锁,解锁失败也会返回error
            bool okay = data->mutex.tryUnlock();
            if (!okay) {
                result = OBJC_SYNC_NOT_OWNING_THREAD_ERROR;
            }
        }
    } else {
        
        // @synchronized(nil) does nothing
        // 如果这个对象在block执行过程中变成nil了,会什么都不做
    }
	

    return result;
}

最后回答上述的问题: 

锁是如何与你传入 @synchronized 的对象关联上的?

  • 你调用 sychronized 的每个对象,Objective-C runtime 都会为其分配一个递归锁并存储在哈希表中。

@synchronized会保持(retain,增加引用计数)被锁住的对象么?

  • 在MRC下, 使用@synchronized不会导致此对象的引用计数增加.
  • 在ARC下, 会导致此对象的引用计数增加.

假如传入 @synchronized 的对象在 @synchronized 的 block 里面被释放或者被赋值为 nil 将会怎么样?

  • 如果在 sychronized 内部对象被释放或被设为 nil 看起来都 OK。不过这没在文档中说明,所以我不会再生产代码中依赖这条。

如果传入@synchronized 的对象值为 nil 将会怎么样?

  • @synchronized(nil)不会有任何作用,hash计算为空,加锁失败,代码块不是线程安全的。你可以通过在 objc_sync_nil 上加断点来查看是否发生了这样的事情。

最后总结一下@synchronized的原理, @synchronized使用传入的object的内存地址作key,通过hash map对应的一个系统维护的递归锁。所以不管是传入什么类型的object,只要是有内存地址,就能启动同步代码块的效果。如果传入nil, 那就相当于没有加锁.

参考链接: 关于 @synchronized,这儿比你想知道的还要多

  • 5
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
@synchronized是Objective-C中用于实现线程安全的关键字。它可以用于保护一段代码,确保同一时间只有一个线程可以执行这段代码。当你传入一个对象给@synchronized时,这个对象会与一个递归关联起来。递归是一种特殊的,它允许同一个线程多次对它进行加,而不会造成死。 在实现中,每个被@synchronized保护的对象都会有一个与之关联的递归。这个递归会在代码块执行之前被加,然后在代码块执行完毕后被解。这样可以确保同一时间只有一个线程可以执行被@synchronized保护的代码块。 当你传入的对象在@synchronized的代码块中被释放或者赋值为nil时,递归会继续保持对这个对象的引用。这是因为递归会在加时对对象进行retain操作,而在解时对对象进行release操作。所以即使对象被释放或者赋值为nil,递归仍然可以正常工作。 引用\[2\]和引用\[3\]提供了一些关于@synchronized实现的细节。在底层,使用了一个结构体SyncList来管理被@synchronized保护的对象和对应的递归。每个SyncList结构体都有一个指向SyncData节点链表头部的指针,以及一个用于防止多个线程对列表做并发修改的。SyncData结构体包含了被@synchronized保护的对象和与之关联的递归。每个SyncData对象也包含一个指向另一个SyncData对象的指针,形成了一个链表结构。通过这种方式,可以实现对不同对象的并发保护。 总结起来,@synchronized关键字通过与递归关联来实现线程安全。传入的对象会与一个递归关联起来,递归会在代码块执行前加,在代码块执行完毕后解。即使对象被释放或者赋值为nil,递归仍然可以正常工作。通过使用SyncList和SyncData结构体,可以管理多个被@synchronized保护的对象和对应的递归。 #### 引用[.reference_title] - *1* *2* *3* [@synchronized 递归详解](https://blog.csdn.net/u014600626/article/details/107915866)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值