×

关于@synchronized 比你想知道的还多

96
箪食豆羹
2017.03.13 13:14* 字数 2748

作者:Ryan Kaplan 译者:徐嘉宏

原文地址:More than you want to know about @synchronized

如果你曾经使用Objective-C做过并发编程,那你肯定见过@synchronized这个结构。@synchronized这个结构发挥了和锁一样的作用:它避免了多个线程同时执行同一段代码。和使用NSLock进行创建锁、加锁、解锁相比,在某些情况下@synchronized会更方便、更易读。

如果你从来没有使用过@synchronized,具体如何使用可以参考下面的实例。本文的将围绕我对@synchronized的原理的探究进行讲述。

使用@synchronized的例子

假如要用Objective-C实现一个线程安全的队列,我们大概会这样写:

@implementation ThreadSafeQueue {
    NSMutableArray *_elements;
    NSLock *_lock;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        _elements = [NSMutableArray array];
        _lock = [[NSLock alloc] init];
    }
    return self;
}

- (void)push:(id)element {
    [_lock lock];
    [_elements addObject:element];
    [_lock unlock];
}

@end

ThreadSafeQueue这个类首先有一个init方法,这里初始化了两个变量:一个_elements数组和一个NSLock。另外,有一个需要获取这个锁以插入元素到数组中然后释放锁的push:方法。许多线程会同时调用push:方法,然而[ _elements addObject:element];这行代码也只能同时被一条线程访问。这个流程应该是这样的:

  1. 线程A调用push:方法
  2. 线程B调用push:方法
  3. 线程B调用[_lock lock],因为没有其他线程持有这个锁,因此线程B取得了这个锁
  4. 线程A调用[_lock lock],但是此时这个锁被线程B所持有,所以这个方法调用并没有返回,使线程A暂停了执行
  5. 线程B添加了一个元素到_elements中,然后调用[ _lock unlock]方法。此时,线程A的[ _lock unlock]方法返回了,接着继续执行线程A的元素插入操作

使用@synchronized,我们可以更简洁明了的实现刚才的功能:

@implementation ThreadSafeQueue {
    NSMutableArray *_elements;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        _elements = [NSMutableArray array];
    }
    return self;
}

- (void)increment {
    @synchronized (self) {
        [_elements addObject:element];
    }
}

@end

这个@synchronized的代码块和前面例子中的[ _lock unlock][ _lock unlock]的作用相同作用效果。你可以把它理解成把self当作一个NSLock来对self进行加锁。在运行{后的代码前获取锁,并在运行}后的其他代码前释放这个锁。这非常的方便,因为这意味着你永远不会忘了调用unlock

你也可以在任何Objective-C的对象上使用@synchronized。因此,同样的我们也可以像下面的例子里一样,使用@synchronized(_elements)来代替@synchronized(self),这两者的效果是一致的。

回到我的探究上来

我对@synchronized的实现很好奇,于是我在谷歌搜索了它的一些细节。我找到了关于这个的一些回答 @synchronized是如何加锁/解锁的 在@synchronized中改变加锁的对象 Apple的文档,但没有一个答案能给我足够深入的解释。传入@synchronized的参数和这个锁有什么关系?@synchronized是否持有它所加锁的对象?如果传入@synchronized代码块的对象在代码块里被析构了或者被置为nil了会怎么样?这些都是我想问的问题。在下文中,我会分享我的发现。

关于@synchronized的Apple的文档中提到,@synchronized代码块隐式地给被保护的代码段添加了一个异常处理块。这就是为什么在给某个对象保持同步的时候,如果抛出了异常,锁就会被释放。

stackoverflow的一个回答中提到,@synchronized块会转化成一对objc_sync_enterobjc_sync_exit的函数调用。我们并不知道这些函数都干了什么,但是根据这个我们可以推断,编译器会像这样转化代码:

@synchronized(obj) {
    // do work
}

转换成大概像这样的:

@try {
    objc_sync_enter(obj);
    // do work
} @finally {
    objc_sync_exit(obj);
}

具体什么是objc_sync_enterobject_sync_exit以及它们是如何实现的,我们通过Command+点击这两个函数跳转到了<objc/objc-sync.h>里,这里有我们要找的两个函数:

// Begin synchronizing on 'obj'.
// Allocates recursive pthread_mutex associated with 'obj' if needed.
int objc_sync_enter(id obj)

// End synchronizing on 'obj'.
int objc_sync_exit(id obj)

在文件的最后,有一个苹果工程师也是人的提示;)

// The wait/notify functions have never worked correctly and no longer exist.
int objc_sync_wait(id obj, long long milliSecondsMaxWait);
int objc_sync_notify(id obj);

总之,关于objc_sync_enter的文档告诉了我们:@synchronized是基于一个递归锁[1] 来传递一个对象的。什么时候分配内存、如何分配内存的?如何处理nil值?幸运的是,Objective-C运行时是开源的,所以我们可以阅读它的源码找到答案。

你可以在这里查看所有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;

static SyncList sDataLists[16];

首先,我们看到了结构体struct SyncData的定义。这个结构体包含了一个object(传入@synchronized的对象)还有一个关联着这个锁以及被锁对象的recursive_mutex_t。每个SyncData含有一个指向其他SyncData的指针nextData,因此你可以认为每个SyncData都是链表里的一个节点。最后,每个SyncData含有一个threadCount来表示在使用或者等待锁的线程的数量。这很有用,因为SyncData是被缓存的,当threadCount == 0时,表示一个SyncData的实例能被复用。

接着,我们有了struct SyncList的定义。正如我在前文中所提到的,你可以把一个SyncData当作链表中的一个节点。每个SyncList结构都有一个指向SyncData链表头部的指针,就像一个用于避免多线程并发的修改该链表的锁一样。

这个代码块的最后一行之上是一个sDataLists的定义,这是一个SyncList结构的数组。刚开始可能看起来不太像,但这个sDataList数组是一个哈希表(类似NSDictionary),用于把Objectice-C对象映射到他们对应的锁。

当你调用objc_sync_enter(obj)的时候,它通过一个记录obj地址的哈希表来找到对应的SyncData,然后对其加锁。当你调用objc_sync_exit的时候,它以同样的方式找到对应的SyncData并将其解锁。

很好!现在我们知道了@synchronized是如何关联一个锁和那个被加同步锁的对象,接下来,我会讲讲当一个对象在@synchronized代码块中被析构或者被置nil会发生什么。

如果你看源码的话,你会发现objc_sync_enter里面并没有retains或者release。因此,它并不会持有传入的对象,或者也有可能是因为它是在arc中编译的。我们可以通过以下的代码来进行测试:

NSDate *test = [NSDate date];
// This should always be `1`
NSLog(@"%@", @([test retainCount]));

@synchronized (test) {

    // This will be `2` if `@synchronized` somehow
    // retains `test`
    NSLog(@"%@", @([test retainCount]));
}

对于每个的持有数,输出总为1。因此objc_sync_enter不会持有传入的对象。这很有意思。如果你需要同步的对象呗析构了,然后可能另外一个新的对象被分配到了这个内存地址上,很可能其他线程正尝试同步那个有着和原对象有着相同地址的新的对象。在这种情况下,其他线程会被阻塞直到当前线程完成了自己的同步代码块。这似乎没什么毛病。这听起来像这种实现是已被知晓的而且也没什么问题。我并没有看到其他更好的替代方案。

那如果这个对象在@synchronized代码块中被设成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。若objc_sync_exit传入nil的时候什么都不做,那么也不再会有人去释放这个锁。这很糟糕。

Objective-C会那么轻易的被这种问题影响吗?下面的代码把一个会被置nil的指针传入@synchronized。然后在后台线程中往@synchronized中传入一个指向同一对象的指针。如果在@synchronized中把一个对象置为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_enterobjc_sync_exit的对象总是相同的。当传入nil的时候他们什么都不会做。这引出了一个很棘手的debug场景:如果你往@synchronized里传入nil,那么相当于你并没有进行过加锁操作,同时你的代码将不再是线程安全的了!如果你被莫名其妙的问题困扰着,那么先确保你没有把nil传入你的@synchronized代码块。你可以通过给objc_sync_nil设置一个符号断点来检查,objc_sync_nil是一个空方法,会在往objc_sync_enter传入nil的时候调用,这会让调试方便的多。

现在,我的问题得到了回答。

  1. 对于每个加了同步的对象,`Objective-C的运行时都会给其分配一个递归锁,并且保存在一个哈希表中。
  2. 一个被加了同步的对象被析构或者被置为nil都是没有问题的。然而文档中并没有对此进行什么说明,所以我也不会在任何实际的代码中依赖这个。
  3. 注意不要往@synchronized代码块中传入nil!这会毁掉代码的线程安全性。通过往objc_sync_nil加入断点你可以看到这种情况的发生。

探究的下一步是研究synchronized代码块转成汇编的代码,看看是否和我前面的例子相似。我打赌synchronized代码块转换的汇编代码不会和我们猜想的任何Objective-C代码相似,上述的代码例子只是@synchronized实现的模型而已。你能想到更好的模型吗?或者在我的这些例子中哪里有瑕疵?请告诉我。

-完-

[1] 递归锁,是一种在已持有锁的线程重复请求锁却不会发生死锁的锁。你可以在这里找到一个相关的例子。有个很好用的类NSRecursiveLock,它能实现这种效果,你可以试试。

翻译
Web note ad 1