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

作者: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,它能实现这种效果,你可以试试。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容

  • 《招聘一个靠谱的 iOS》—参考答案(下) 说明:面试题来源是微博@我就叫Sunny怎么了的这篇博文:《招聘一个靠...
    韩发发吖阅读 1,557评论 0 8
  • 1.写一个NSString类的实现 +(id)initWithCString:(c*****t char *)nu...
    韩七夏阅读 3,754评论 2 37
  • 我写稿仍然用纸和笔。虽然自己勉强有用电脑的条件,但对屏幕的冷漠感心有余悸。再加拼音学的不通。面对闪亮的荧屏,脑中一...
    黑土地_6345阅读 555评论 5 3
  • 爷爷奶奶,我们都过的挺好的,你们不用担心,虽然很少回去扫墓,但是我们有我们的难处,等我们家过了这一难关后,我一定会...
    我要开始写字了阅读 191评论 0 0
  • 你对你生命的热爱,并非是要你自己当一个伟人,就像我对诗歌的热爱,并非需要多少人承认,它是我心里的八卦新闻,是我享受...
    6蟲阅读 219评论 1 2