AssociatedObject 源码分析:如何实现线程安全?

1,074 阅读12分钟

TLDR:

函数名函数单独的线程安全性总体使用的线程安全性(non)atomic 之间性能差距
setProperty根据 policy 决定根据标识符决定较大
getProperty根据 policy 决定
setAssociateObject始终线程安全根据标识符决定较小
getAssociateObject根据 policy 决定

起因

西瓜最近在做业务的 Model 治理,业务的 Model 总会有大量的属性是通过在 Category 中添加 AssocitedObject 来实现的。对于多个复杂场景同时使用的 Model 同步框架,就需要同时对性能与稳定性都有所考量。因此进行一些分析。

本文的分析推荐对线程安全只知其名不知其实现的同学观看。网上对于 AssociatedObject 这个老八股也有很多解析,但其实没有与 Property 对应做出对比,也没有对其中到底是如何设计线程安全作出论述,理清思路还是花了很久,探寻思路供大家参考。

Set函数

AssociationsManager 结构

image

图来源于 冬瓜

unordered_map 是STL 函数,不自带线程安全,因此 _map 也并不自带线程安全。

AssociationsManager 是靠 spinlock 实现的。也就是说 AssociationsManager 通过一个自旋锁 spinlock_t 保证对 AssociationsHashMap 的操作是线程安全的,即每次只会有一个线程对 AssociationsHashMap 进行操作。这里注意,说的是 AssociationsManager 是线程安全的,不是说 AssociateObject 是线程安全的。

class AssociationsManager {
    static spinlock_t _lock; // static,全局共用一个
    static AssociationsHashMap *_map;               // associative references:  object pointer -> PtrPtrHashMap.
public:
    AssociationsManager()   { _lock.lock(); } // 初始化函数,创建对象时调用
    ~AssociationsManager()  { _lock.unlock(); } // 析构函数,释放对象时调用
    
    AssociationsHashMap &associations() { // 必须通过实例方法去获取到 _map
        if (_map == NULL)
            _map = new AssociationsHashMap();
        return *_map;
    }
};

我们可以看到在创建 AssociationsManager 的时候就会持有锁,释放 AssociationsManager 的时候就会释放锁。而如果你要拿到 AssociationsHashMap 就必须要创建 AssociationsManager 对象。

这里给不熟悉 C++ 的同学解释下,C++ 可以在 Stack 上创建对象,而不必通过 new/malloc 的方式分配到 Heap 上。

RAII && 锁的使用

光这么描述可能还有些迷茫,我们接下来看 runtime 是怎么使用这把锁的。

void _object_set_associative_reference(id object, void *key, id value, uintptr_t policy) {
    {
        // 创建AssociationsManager对象,隐含lock(),并非new的方式创建
        // 在stack上
        AssociationsManager manager;
        
        // 在manager取_map成员,其实是一个map类型的映射
        AssociationsHashMap &associations(manager.associations());
        
        // 实际set,先省略,后面会详细介绍
        
    }// C++,离开Scope就释放AssociationsManager,隐含unlock()
    
    // release the old value (outside of the lock).
    if (old_association.hasValue()) ReleaseValue()(old_association);
}

这个设计思路很有意思,是一种巧妙保证线程安全的方法,每个线程都要通过AssociationsManager 对象 去拿到实际是 static 的东西,通过 Scope 对AssociationsManager 对象 的释放来控制。这样就可以避免大家忘记去 unlock(),或者提前 return 的时候忘记 unlock() 的事情发生。

这里还有一个小细节是:并非所有操作都是在锁里完成的,可以看到对 old_association 的释放是在锁外的。原因我们后面也会介绍到,现在只需要有个印象就行了。

这种类似的设计其实有特定的名字:RAII,全称资源获取即初始化(英语:Resource Acquisition Is Initialization)。RAII要求,资源的有效期与持有资源的对象的生命期(英语:Object lifetime)严格绑定,即由对象的构造函数完成资源的分配(英语:Resource allocation (computer))(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄露(英语:Resource leak)问题。

那么既然这个这么好,我们 iOS 开发能不能也整一个呢?答案是不能直接用,但可以有替代方案。

OC 是因为对象可能被加入 autoreleasepool 中,一旦被加入 pool 了,对象的释放不跟 scope 挂钩,是跟 pool 什么时候被 pop 挂钩;OC 也没有在 stack 上创建 对象的能力;OC(Swift也是) 目前使用较多的 ARC 是 基于使用情况的(use-based),也有本质的不同。

Swift 变量也不是离开 scope 才释放的,是最后一次使用之后就不再保证存在的,虽然实际中可能会延后释放的事件,但依赖现在的不确定延后时机去写代码是不安全的,容易随着后续的编译器优化而产生极难排查的问题。具体可以看:【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

当然 OC 中也有 @onExit{} , Swift 中也有 defer 可以达到在退出 Scope 时进行一些资源释放。这个就不在这里论述了,大家很可能都使用过。

分析 setAssociateObject 的主路径

具体代码有删减,我们主要分析有 old_association ,并替换新的 new_value 的这条链路。

删减之后如下。一些边缘Case就隐藏了,有兴趣的同学可以自己去研究关于 DISGUISE 、替换/创建 AssociateObject 相关逻辑。

void _object_set_associative_reference(id object, void *key, id value, uintptr_t policy) {
    // retain the new value (if any) outside the lock.
    ObjcAssociation old_association(0, nil);
    id new_value = value ? acquireValue(value, policy) : nil;
    {
        AssociationsManager manager;
        AssociationsHashMap &associations(manager.associations());
        disguised_ptr_t disguised_object = DISGUISE(object);
        if (new_value) {
            // 遍历链表去拿到j,j 就是一个遍历器,j->second 就是实际的对象
            ObjectAssociationMap::iterator j = findJ();
            
            // 重要逻辑
            old_association = j->second;
            j->second = ObjcAssociation(policy, new_value);
        }
        // other code
        // 包括 新建节点、删除节点等
    }
    // release the old value (outside of the lock).
    if (old_association.hasValue()) ReleaseValue()(old_association);
}

最重要的就是在锁内,有一个把 old_associate 取出,把新的 ObjcAssociation 赋值的步骤。这个步骤在锁内,于是保证了线程安全。

最后离开锁的区域,并释放 old_associate

那这个没有对比,我们虽然有感觉 setAssociateObject 中锁内的操作跟是否 NONATOMIC 的 policy 没有关系,但这个还不是实锤。那么我们找一个别的参考。

参考 setProperty

类比下 reallySetProperty 函数,发现 reallySetProperty 还是区分 atomic 的操作的。但原子性的操作都是一致的,甚至包括在锁外释放 old 对象

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();
    }
    
    // release 对象也在锁外
    objc_release(oldValue);
}

我们来看下 nonatomic 跟 atomic 的代码,其实都是一致的,区别只是 atomic 的部分在锁内,nonatomic 的没有加锁。那我们就来造一种黄色会崩溃的场景。

这里我们设计的场景是多线程对一个属性同时设置。

UML 图.jpg

设想一下多线程对一个属性同时设置的情况,我们首先在线程A处获取到了执行第一步代码后的oldValue,然后此时线程切换到了B,B也获得了第一步后的 oldValue,所以此时就有两处持有 oldValue 。然后无论是 线程A 或者 线程B 执行到最后都会执行 objc_release(oldValue),对同一块内存区域释放两次,就会发生崩溃。

但如果加锁了就没有这个问题了。后面拿到的 oldValue 已经是前面一次 set 设置的对象了。不存在一个对象 release 多次。

也就是说,oldValue 的设置是必须只有一个线程能同时进入的,即需要通过在锁内保证线程安全。

分析之后我们发现,对 oldValuerelease 并不涉及竞争,那么根据锁最小原则,为了尽可能得优化性能,我们就把 oldValue 的释放放在 lock 外。

同理,setAssociateObject 也是一样,并且我们发现:setAssociateObject 只有对应 setPropertyatomic 的分支。那我觉得可以说明 setAssociateObject 方法一定是线程安全的了,我们用 demo 实际验证一下。

验证

这里可以做一个对比,如果你是多线程 setProperty ,是会崩的。同时崩溃的原因也与我们的分析一致,是某个对象被过度释放。

@property(nonatomic, strong) NSMutableData *data;

for (int i = 0; i < 100000; i++) {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        self.data = [[NSMutableData alloc] init];
    });
}


// error for object: pointer being freed was not allocated。

但如果你是多线程 setAssocited,是不会崩的。也就验证了源码分析。

for (NSInteger i = 0; i < 1000000; i++) {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
      [self setAssocitedObject:[NSObject new]];
    });
}
  
- (void)setAssocitedObject:(NSObject *)object {
  objc_setAssociatedObject(self, @selector(associtedObject), object, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

// fine, no crash

这里也是一个 setAssociateObjectsetProperty 在表现上不同。

最后我们再类比下我们正常会自己写的线程安全的 set 方法,objc_release 方法是ARC帮我们自动加上的,但也是在 lock 外面的。所以我们正常的写法也是暗含这个优化逻辑在里面的。

- (void)setSomeString:(NSString *)aString
{
    NSString *old = nil
    @synchronized(self)
    {
        if (someString != aString)
        {
            old = someString;
            someString = [aString copy];
        }
    }
    [old release]; // 编译器会帮我们自动加上的
}

当然 @synchronized 这把锁不建议大家使用,会有很多问题,可以用别的锁。这里只是偷懒了举个例子。

Get函数

经过上面的分析,我觉得我又行了,那分析一半我觉得是不行的,肯定也得把 get 的部分给分析了。

首先这里我们需要看看我们熟悉的 5 个枚举在 runtime 内部是如何使用的。我们可以看到实际上会拆成 SETTTER 跟 GETTER 的结合。

// runtime.h
enum { 
    OBJC_ASSOCIATION_ASSIGN = 0, /**< Specifies a weak reference to the associated object. */ 
    OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, /**< Specifies a strong reference to the associated object. * The association is not made atomically. */ 
    OBJC_ASSOCIATION_COPY_NONATOMIC = 3, /**< Specifies that the associated object is copied. * The association is not made atomically. */ 
    OBJC_ASSOCIATION_RETAIN = 01401 = 0x0301, /**< Specifies a strong reference to the associated object. * The association is made atomically. */ 
    OBJC_ASSOCIATION_COPY = 01403 = 0x0303 /**< Specifies that the associated object is copied. * The association is made atomically. */ 
};

// objc-references.mm
enum { 
    OBJC_ASSOCIATION_SETTER_ASSIGN = 0, 
    OBJC_ASSOCIATION_SETTER_RETAIN = 1, 
    OBJC_ASSOCIATION_SETTER_COPY = 3, // NOTE: both bits are set, so we can simply test 1 bit in releaseValue below. 
    OBJC_ASSOCIATION_GETTER_READ = (0 << 8), 
    OBJC_ASSOCIATION_GETTER_RETAIN = (1 << 8), 
    OBJC_ASSOCIATION_GETTER_AUTORELEASE = (2 << 8) 
}; 

因此我们可以从枚举处得到:

OBJC_ASSOCIATION_RETAIN_NONATOMIC == OBJC_ASSOCIATION_SETTER_RETAIN

OBJC_ASSOCIATION_RETAIN == OBJC_ASSOCIATION_SETTER_RETAIN | OBJC_ASSOCIATION_GETTER_RETAIN | OBJC_ASSOCIATION_GETTER_AUTORELEASE

也就是说 OBJC_ASSOCIATION_RETAIN 相比 OBJC_ASSOCIATION_RETAIN_NONATOMIC 多了 GETTER_RETAINGETTER_AUTORELEASE

这里可以发现在枚举的语义中。是否 NONATOMIC,在 SETTER 没有任何差别,只是在 GETTER 中有所差别。这个也符合我们前面的分析。

然后我们再看get的代码:

id _object_get_associative_reference(id object, void *key) {
    id value = nil;
    uintptr_t policy = OBJC_ASSOCIATION_ASSIGN;
    {
        // lock()
        AssociationsManager manager;
        AssociationsHashMap &associations(manager.associations());
        disguised_ptr_t disguised_object = DISGUISE(object);
        AssociationsHashMap::iterator i = associations.find(disguised_object);
        if (i != associations.end()) {
            ObjectAssociationMap *refs = i->second;
            ObjectAssociationMap::iterator j = refs->find(key);
            if (j != refs->end()) {
                ObjcAssociation &entry = j->second;
                value = entry.value();
                policy = entry.policy();
                if (policy & OBJC_ASSOCIATION_GETTER_RETAIN) ((id(*)(id, SEL))objc_msgSend)(value, SEL_retain); // retain 一次,锁内
            }
        }
        // unlock()
    }
    if (value && (policy & OBJC_ASSOCIATION_GETTER_AUTORELEASE)) {
        ((id(*)(id, SEL))objc_msgSend)(value, SEL_autorelease); // autorelease 一次,锁外
    }
    return value;
}

错误结论的得出

ATOMATIC 会额外运行的两行代码,一次锁内的 retain,一次锁外的 autorelease

整体从 AssociationsManager 中获取具体值的操作也都在锁内,感觉没啥不安全的。

同时由于分析 Set 函数的时候,我们考虑了 多个线程同时 Set 的情况,这里我也设想了 多个线程同时 Get 的情况,分析后觉得没什么 Bad Case。不要笑,当时我确实绕进去了,这里实际应该考虑的是 多个线程同时Set Get 的场景

image

但是很明显,我们写一段代码同时有 Set,有 Get 就会发现情况不对,实际上是会崩溃的。因此我们从结论倒推,来分析下原因。

更正错误

我们来简化一下 getAssociateObject 的代码。

流程图.jpg

atomic 的 的标识符会多走:一次retain,一次autorelease。

这里会有几个问题

  1. 拿到 value 是在锁内,那为啥不是线程安全的?一般我们的 get 函数不都是拿到 value,再return?这锁难道不保熟?

  2. 从结果倒推,加了 retain 跟 autorelease 就能保证线程安全,这是为什么?

  3. 为什么 autorelease 可以放在锁外不用放在锁内?

参考 getProperty

要理解这个问题,我们需要先回到正常get属性的时候,看看getProperty的实现

id objc_getProperty_non_gc(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);
    // nonatomic,直接return
    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);
}

我们可以看到,get在 nonatomic 时候,是不加锁的,直接拿到就返回。

但是在 atomic 的时候,先加锁,再 retain ,解锁,再 autorelease 。是不是跟 _object_get_associative_reference 有点像啊?

set 的时候,我们分析 bad case 的时候是用 同时有 多个 set 的,但分析 get 的时候,不能分析有多个 get ,因为没有修改,本来就没问题。所以我们应该是思考 set 跟 get 混用的情况。

因此我们先分析 get_property 的时候,为什么要 retain (锁内) + autorelease (锁外)。如果我只是加锁,不retain + autorelease 会发生什么?

// wrong 实现
id objc_getProperty_non_gc(id self, SEL _cmd, ptrdiff_t offset, BOOL atomic) {
    // Retain release world
    id *slot = (id*) ((char*)self + offset);
    
    // Atomic retain release world
    spinlock_t& slotlock = PropertyLocks[slot];
    slotlock.lock();
    id value = objc_retain(*slot); // delete this line
    id value = *slot; // instead
    slotlock.unlock();
    
    // for performance, we (safely) issue the autorelease OUTSIDE of the spinlock.
    return objc_autoreleaseReturnValue(value); // delete this line
    
    return value; // instead
}

UML 图 (1).jpg

我们由此可以发现:getter 需要在锁内进行提前的 retain,防止 setter 提前对 oldValue 调用 objc_release() 。因此解答了为什么单单加锁,不能保证线程安全

得到结论

我们再回到 get_assciate 的方法,不管是不是atomic的,都加锁了。但加锁还不能保证安全。因此单看 get_assciate 方法会有错觉,感觉是 retain + autorelease 方法保证了线程安全,其实不是的。两个因素加起来才能保证。**

因此这就可以回答上面三个问题了。

  1. 为什么要在get的时候 retain + autorelease,因为不这样会崩,release可能会走到 retain 前面。

  2. 那为什么 retain 必须在锁内,autorelease 可以在锁外?因为 get 拿到value之后,必须在 set 方法 objc_release 之前调用 retain,不然还是崩。同时这里就算是 nonatomic 也需要加锁是为了防止多线程操作写坏 AssociationsHashMap 。

  3. for performance,锁最小原则。

所以 property 的 get 更暴力一些,演都不演了直接 return了。正因如此,property的 nonatomic/atomic 之间性能差距较大,而 AssociateObject 的就差距不这么大,正常使用也不会有太多性能问题。

分析完毕。因此一开始我就在思考为什么 retain + autorelease 方法能保证线程安全属实是把我自己绕进去了。通过分析 property 的 get/set 有助于我们更好的理解 AssociateObject 的 get/set。

研究线程安全的通用方法

研究 set 方法时,需要考虑同时多个线程 set,也可以 set 的同时 有 get。

研究 get 方法时,则一定不能考虑多个线程同时 get,因为这个是没有实际操作意义的,应该考虑 get 的同时也有 set 操作。

结论

只set,不get,是正常的,不会崩。但这个不符合实际, 不get你存他干什么。

不set,疯狂get,是正常的。但这个其实也是不符合实际, 不加锁也是能保证的。

又set,又get,由于get不是线程安全的(如果你的标识符号是 NONATOMIC ),会崩;又set,又get,get是线程安全的(如果你的标识符号是 ATOMIC ),不会崩。

不论你的标识符是 OBJC_ASSOCIATION_XXX_NONATOMIC 还是 OBJC_ASSOCIATION_XXXobjc_setAssociatedObject 函数都是 线程安全 的;但是 objc_getAssociatedObject 函数则是根据你的标识符来决定是否是 线程安全 的。

使用 AssocitedObject 时根据业务场景决定是否使用对应标识符,毕竟使用时又有 set 又有 get 才是常态,单独看 set 或者 get 不具有实际意义。

Property的 (non)atomic 之间性能差距较大,而 AssociateObject 的 (non)atomic 之间就差距不大,正常使用也不会有太多性能问题。

最后关于 AssociatedObject/Property 相关的源码其实还有很多知识,例如 StripeMap 等等,其实都很有意思,搞清楚这些更多是带来自己探索问题的思路,一个个相对独立的知识点的学习其实只是知识的积累,还是要掌握自己学习探索的方式,如果单纯看一个函数想不通原因,可以寻找别的类似机制的函数做参考,往往能够获得突破。

参考资料

浅谈Associated Objects

关联对象 AssociatedObject 完全解析

opensource.apple.com/source/objc…

stackoverflow.com/questions/2…

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

en.wikipedia.org/wiki/Spinlo…