抛开性能,谈谈不该用@Synchronized的原因

栏目: Objective-C · 发布时间: 5年前

内容简介:关于网上关于锁对比的文章也不在少数,太多说集中在用法概述以及性能对比。而本质上来说,在客户端场景下,高密度使用锁的场景是相对较少(比如

关于 Objective-C 中的 @Synchronized ,想必从事 iOS 开发相关工作的同学都不陌生,可以说这是一种最简单的加锁的方式了。

网上关于锁对比的文章也不在少数,太多说集中在用法概述以及性能对比。而 @Synchronized 在不少文章中常常因其 性能 而被建议不要使用。

本质上来说,在客户端场景下,高密度使用锁的场景是相对较少(比如 IM 数据库除外);同时,抛开使用场景单独通过比如 for 循环测试锁的性能,也是比较蛋疼的,不合适的用法、过大的锁范围以及竞态条件,都会导致比较条件的欠考虑性。

因此,今天我想谈谈一个不应该使用 @Synchronized 的本质原因:它是一个和上下文强相关的锁,会导致锁失效。

一个简单的事例

考虑一个场景:

我们后台静默更新一下数据,一旦有了新数据,就整体替换掉现在呈现的数据,这在列表页配合远程数据的时候非常常见。

为了放大多线程 可能出错的场景 ,我放大到 5000 个线程,构造如下代码:

@interface ViewController ()
@property (nonatomic, strong) NSMutableArray *testArray;
@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    self.testArray = @[].mutableCopy;

    for (NSUInteger i = 0; i < 5000; i++) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            [self testThreadArray];
        });
    }
}

- (void)testThreadArray
{
    @synchronized (self.testArray) {
        self.testArray = @[].mutableCopy;
    }
}

可以看出,为了避免多个线程同时更新临界资源 testArray ,我们使用 @synchronized (self.testArray) 进行了资源保护。

备注:为什么需要保护这里的赋值操作,可以阅读我的 从Immutable来谈谈对于线程安全的理解误区

看起来一切都很Ok,但是当你实际运行代码,还是会出现野指针Crash。如下图所示:

抛开性能,谈谈不该用@Synchronized的原因

这里用 @Synchronized(self) 是可以成功锁住的,但是这会陷入到锁的范围太大的场景中去,不再此文探讨的范围内。

Crash的根因

@Synchronized 会变成一对基于 try-catchobjc_sync_enterobjc_sync_exit 的代码,想必都不陌生了,许多网上文章都有,不再赘述,可以参考 clang 的代码:

https://clang.llvm.org/doxygen/RewriteObjC_8cpp_source.html

/// RewriteObjCSynchronizedStmt -
 /// This routine rewrites @synchronized(expr) stmt;
 /// into:
 /// objc_sync_enter(expr);
 /// @try stmt @finally { objc_sync_exit(expr); }
 ///
 Stmt *RewriteObjC::RewriteObjCSynchronizedStmt(ObjCAtSynchronizedStmt *S) {
   // Get the start location and compute the semi location.
   SourceLocation startLoc = S->getBeginLoc();
   const char *startBuf = SM->getCharacterData(startLoc);

   assert((*startBuf == '@') && "bogus @synchronized location");

   std::string buf;
   buf = "objc_sync_enter((id)";
   const char *lparenBuf = startBuf;
   while (*lparenBuf != '(') lparenBuf++;
   ReplaceText(startLoc, lparenBuf-startBuf+1, buf);
   // We can't use S->getSynchExpr()->getEndLoc() to find the end location, since
   // the sync expression is typically a message expression that's already
   // been rewritten! (which implies the SourceLocation's are invalid).
   SourceLocation endLoc = S->getSynchBody()->getBeginLoc();
   const char *endBuf = SM->getCharacterData(endLoc);
   while (*endBuf != ')') endBuf--;
   SourceLocation rparenLoc = startLoc.getLocWithOffset(endBuf-startBuf);
   buf = ");\n";
   // declare a new scope with two variables, _stack and _rethrow.
   buf += "/* @try scope begin */ \n{ struct _objc_exception_data {\n";
   buf += "int buf[18/*32-bit i386*/];\n";
   buf += "char *pointers[4];} _stack;\n";
   buf += "id volatile _rethrow = 0;\n";
   buf += "objc_exception_try_enter(&_stack);\n";
   buf += "if (!_setjmp(_stack.buf)) /* @try block continue */\n";
   ReplaceText(rparenLoc, 1, buf);
   startLoc = S->getSynchBody()->getEndLoc();
   startBuf = SM->getCharacterData(startLoc);

   assert((*startBuf == '}') && "bogus @synchronized block");
   SourceLocation lastCurlyLoc = startLoc;
   buf = "}\nelse {\n";
   buf += "  _rethrow = objc_exception_extract(&_stack);\n";
   buf += "}\n";
   buf += "{ /* implicit finally clause */\n";
   buf += "  if (!_rethrow) objc_exception_try_exit(&_stack);\n";

   std::string syncBuf;
   syncBuf += " objc_sync_exit(";

   Expr *syncExpr = S->getSynchExpr();
   CastKind CK = syncExpr->getType()->isObjCObjectPointerType()
                   ? CK_BitCast :
                 syncExpr->getType()->isBlockPointerType()
                   ? CK_BlockPointerToObjCPointerCast
                   : CK_CPointerToObjCPointerCast;
   syncExpr = NoTypeInfoCStyleCastExpr(Context, Context->getObjCIdType(),
                                       CK, syncExpr);
   std::string syncExprBufS;
   llvm::raw_string_ostream syncExprBuf(syncExprBufS);
   assert(syncExpr != nullptr && "Expected non-null Expr");
   syncExpr->printPretty(syncExprBuf, nullptr, PrintingPolicy(LangOpts));
   syncBuf += syncExprBuf.str();
   syncBuf += ");";

   buf += syncBuf;
   buf += "\n  if (_rethrow) objc_exception_throw(_rethrow);\n";
   buf += "}\n";
   buf += "}";

   ReplaceText(lastCurlyLoc, 1, buf);

   bool hasReturns = false;
   HasReturnStmts(S->getSynchBody(), hasReturns);
   if (hasReturns)
     RewriteSyncReturnStmts(S->getSynchBody(), syncBuf);

   return nullptr;
 }

卧槽,原来 clang 的rewrite部分也写的这么挫逼啊。

我们就从 objc_sync_enter 来继续挖掘:

if (obj) {
    SyncData* data = id2data(obj, ACQUIRE);
    assert(data);
    data->mutex.lock();
}

关键其实就是在于从 obj 转换到 SyncData ,然后通过 SyncData 中的 mutex 来进行临界区的锁。

有两个部分需要分析一下,首先 SyncData 结构体定义如下:

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;
  • mutex ,一把递归锁,这也是为什么我们可以在 @Synchronized 里面嵌套 @Synchronized 的原因。
  • DisguisedPtr ,还记得我们以前 写安全气垫的时候给一些释放的内存地址填充 0x55 用于拦截 use after free 的场景 ?这里 DisguisedPtr 其实就是对裸对象指针 objc_object 的一层包装改写。

继续回到 id2data 函数往下研究,可以发现一段比较有意思的函数:

static StripedMap<SyncList> sDataLists;

我们具体就关注 [] 对应的操作即可:

class StripedMap {
#if TARGET_OS_IPHONE && !TARGET_OS_SIMULATOR
    enum { StripeCount = 8 };
#else
    enum { StripeCount = 64 };
#endif

    struct PaddedT {
        T value alignas(CacheLineSize);
    };

    PaddedT array[StripeCount];

    static unsigned int indexForPointer(const void *p) {
        uintptr_t addr = reinterpret_cast<uintptr_t>(p);
        return ((addr >> 4) ^ (addr >> 9)) % StripeCount;
    }

 public:
    T& operator[] (const void *p) { 
        return array[indexForPointer(p)].value; 
    }
    const T& operator[] (const void *p) const { 
        return const_cast<StripedMap<T>>(this)[p]; 
    }

抽丝剥茧,这里其实就是一个简单的 Hash 算法,然后将传入的对象地址,通过 indexForPointer 映射到不同的 SyncList 上。而 SyncList 是一个维护 SyncData 的链表,每个 SyncList 都单独维护操作自己的 lock

indexForPointer 公式: ((addr >> 4) ^ (addr >> 9)) % StripeCount ,其中 StripeCount 是个数。

这样做的好处就是创建了一个所谓的散列锁,可以有效的降低不同的对象操作指尖的相互影响性。当然,从本质上看, iOS 上就 8个散列锁,这也是影响大规模使用@Synchronized会影响性能的原因之一。

接着往下走,我们直接 关注没有命中 Thread Local Storage 的场景

#define LOCK_FOR_OBJ(obj) sDataLists[obj].lock
spinlock_t *lockp = &LOCK_FOR_OBJ(object);

// 通过对对象地址hash,算法对应SyncList的锁

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
    // 关注点1 !!!!!!!!!!!!
    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.

// 关注点2 !!!!!!!!!!!!
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;

 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");

      // 关注点3
#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;
    1. 通过散列,计算这个对象应该落入的 SyncList ,由于需要操作 SyncList ,用其对应的锁进行加锁。
    1. 关注点1和2,其实本质干的是一件事,就是找出一个可以被使用的 SyncData ,如果没有就创建一个,设定好对应的成员变量,然后返回。
    1. 关注点3,就是做完了以后,利用一下 Thread Local Storage ,存一下,这块不关注无伤大雅。

Ok,到现在我们分析完成 @Synchronized 的实现原理后,我们可以回过头再来看看为什么对象被更改后会产生Crash了。

其实一言以蔽之,就是 @Synchronized 锁不住对象赋值变化的场景。

回到我们上一小节 Crash 的问题:

考虑三个线程的场景,分别定义为线程A,线程B,线程C,初始的时候在线程A, self.testArray 的初始值为 arr0 (实质上操作的是 arr0 地址,下文简述为 arr0 ),我们来理下时间线:

  • 线程A获取 self.testArray 的值,为 arr0
  • 线程B获取 self.testArray 的值,也为 arr0
  • 线程A,B由于对象地址一致,产生竞争,A获取到了对应的锁,我们称之为 lock0
  • 线程A在锁的保护下,执行 self.testArray = @[].mutableCopyself.testArray 指向了 arr1
  • 线程A unlock
  • 此时线程C开始尝试获取 self.testArray ,获取到了 arr1
  • 这个时候线程B由于线程A释放锁了,线程B继续,线程B使用之前获取的 arr0 进行获取锁的操作。
  • 这个时候线程C也尝试进行锁操作,由于线程C是 arr1 ,所以使用的是 arr1 对应的锁操作。
  • 由于 arr0arr1 对应的锁不是一个(当然理论上可能散列计算为同一个),所以这两个线程都进入了临界区
  • 线程B和线程C都执行 self.testArray = @[].mutableCopy
  • Setter 的赋值并不是 atomic 的,实质上会转换成如下这样的代码:

    static inline void reallySetProperty(id self, SEL _cmd, id newValue, 
      ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy) 
    {
        id oldValue;
        // 计算结构体中的偏移量
        id *slot = (id*) ((char*)self + offset);
    
        if (copy) {
            newValue = [newValue copyWithZone:NULL];
        } else if (mutableCopy) {
            newValue = [newValue mutableCopyWithZone:NULL];
        } else {
            // 某些程度的优化
            if (*slot == newValue) return;
            newValue = objc_retain(newValue);
        }
    
        // 危险区
        if (!atomic) {
             // 第一步
            oldValue = *slot;
    
            // 第二步
            *slot = newValue;
        } else {
            spin_lock_t *slotlock = &PropertyLocks[GOODHASH(slot)];
            _spin_lock(slotlock);
            oldValue = *slot;
            *slot = newValue;        
            _spin_unlock(slotlock);
        }
    
        objc_release(oldValue);
    }
  • 上述危险区的第二步, _testArray 在线程B和线程C分别指向了新地址 addr2addr3 但是获取到的 oldValue 可能都是 arr1

  • 通过 objc_releaseoldValue ,也就是 arr1 进行了两次释放,妥妥的 double free 过度释放场景,导致崩溃。

抛开性能,谈谈不该用@Synchronized的原因

备注:多线程的场景在于不确定性,可能在其中任何一个指令处挂掉。

结语

所以,从本质上来说, @Synchronized 的确是最不应该推荐给用户使用的一种锁机制,但是其根本原因并 不一定是 性能差距, Hash 离散设计的优雅的话,一样能保证性能。但是其内在 锁和对象上下文相关的联系会导致锁失效的场景 ,一旦有对象发生变化(被赋值),导致潜在的锁不住多线程的场景,我们也应该去了解学习。


以上所述就是小编给大家介绍的《抛开性能,谈谈不该用@Synchronized的原因》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

机器消灭秘密

机器消灭秘密

安迪•格林伯格 (Andy Greenberg) / 王崧、王涛、唐禾 / 重庆出版社 / 2017-8-10 / 49.8

《机器消灭秘密》一书中,格林伯格深入研究并生动再现了那些拥有全能技术的网络安全魔术师,他们将任何企图染指个人隐私的所谓国家机密的保密性打得粉碎。这本精心组织的著作是对此题材感兴趣的读者的必读之书,即便现在你可能不感兴趣,将来也极有可能希望了解这些内容,因为任何人都会不可避免地置身其中。无论你是初涉电脑屏幕之后的虚拟战场的新生,还是经验丰富的维基解密观察家,本书都是不可多得的上乘之作,你总会在其中发......一起来看看 《机器消灭秘密》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具