atomic性能真的很差,并发queue+barrier性能真的很好吗?

前言

iOS平台中,开发者都知道几乎所有的属性都应该用nonatomic修饰,那么为什么呢?相信不少初学者都应该看到过stackoverflow上的一个问题:What's the difference between the atomic and nonatomic attributes?
其中一个回答中提到在非竞争并且一些极端的环境下atomic修饰属性的读写方法比nonatomic慢20倍(但并没有指明到底如何算极端)所以平时在自己项目工程里以及一些第三方库中很少见到有用atomic修饰属性的,所以我们定义属性的时候一般想都没想第一个关键字就直接用nonatomic修饰,那么这样究竟会有什么问题呢?看这样一段代码:

@property (nonatomic, strong) NSString *target;

dispatch_queue_t queue = dispatch_queue_create("concurrent", DISPATCH_QUEUE_CONCURRENT);
dispatch_apply(100000, queue, ^(size_t i) {
      self.target = [NSString stringWithFormat:@"abcdefghijk%zu",i]; 
});

这段代码会有什么问题呢?运行结果如下图所示:

crash.png

可以看到,因为对一个已经释放的对方调用了release方法,所以程序崩溃了。这是为什么呢?

我们知道在MRC环境下,一个nonatomicretain修饰的属性的set方法其实等价于:

- (void)setTarget:(NSString *)target {
    [target retain];//先保留新值
    _target = target;//再进行赋值
    [_target release];//最后释放旧值
}

可以想象到,在多线程环境下,如果setTarget这个方法在两个线程中同时被调用,那么很可能[_target release]这行代码在两个线程中被连续调用两次,因为nonatomic没有做任何的保护,所以_target指向的对象被连续释放了两次,过度释放引起crash

方案选择

那么针对这种不安全的场景,我们该如何保护呢?
这里有一篇博客:GCD实践(一)使用GCD保护property,作者总结了几种方式,比如atomic,NSLock,@synchronized,GCD串行queue,GCD并发队列+barrier等方式,并着重强调了GCD并发queue+barrier能够满足多读单写的需求,性能比单纯的串行队列要好。但是并没有对比其他几种方式的性能。
其实并发队列+barrier很多同学第一次看到应该是在《Effective Objective-C 2.0》这本书里吧。一定会对这张图印象深刻:

第41条:多用派发队列,少用同步锁.png

然而其实这种方式到底怎么好,究竟有多好,其实我想大概很少有人真实的写过demo去测试过吧

既然如此,那我们就验证一下吧,毕竟理论是需要实践和数据来支撑嘛

性能测试

其实如果不考虑任何具体的业务逻辑,仅仅测试各种加锁方式的效率,这里有一个测评:起底多线程同步锁(iOS)
从测试数据中可以看到,atomic加锁方式的效率是最高,那如果加上具体的业务逻辑之后呢?
废话不多说,我们还是直接写demo测试下吧~

测试环境

iPhone6真机,10.3.3系统,ARC内存管理模式,dispatch_apply + 并发 queue 执行 10w 次,属性的读写操作比例为9:1,测试atomic,NSLock,并发queue+barrier,等11种方案的效率,连续测试100次

关键测试代码

#define TestThreadSafeMode(identifier,property,time,loop,ratio) \
@autoreleasepool {  \
    TICK(time, identifier); \
    dispatch_apply(loop, self.barrierQueue, ^(size_t i) {   \
        if(!(i % ratio)) { \
            self.property = [NSString stringWithFormat:@"abc%d",loop];  \
        } else {    \
            __unused NSString * temp = self.property;   \
        }   \
    }); \
    dispatch_barrier_sync(self.barrierQueue, ^{ \
        TOCK(time, identifier); \
        CALC(time, identifier); \
    }); \
}

测试结果

测试结果如下(单位s):

ThreadSafeTest[408:46619] {
    Atomic = "0.364874005317688";
    Barrier = "7.570194065570831";
    NSCondition = "6.852829992771149";
    NSConditionLock = "6.897508859634399";
    NSLock = "4.378997981548309";
    NSRecursiveLock = "6.422177016735077";
    PthreadMutex = "1.959827899932861";
    RWLock = "0.7853890657424927";
    Semaphore = "0.4313730001449585";
    Synchronize = "6.652496039867401";
    UnfairLock = "0.3706329464912415";
}

从测试数据中可以看到,atomic加锁方式的效率最高的,并发队列+barrier方式竟然是最慢的,比atomic慢了20倍。这个结果想必让各位大跌眼镜了吧。其实我也是,直到我跑了很多遍之后才敢确认。那么静下来,不禁要问一句:为什么atomic能这么快呢?atomic关键字又是如何实现的呢?基于苹果的开源代码:accessors source code,我们可以找到set方法的真正实现:

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();
    }
 
    objc_release(oldValue);
}

可以看到,atomic的实现主要依赖于自旋锁,而常见的NSLock,pthread_mutex_t等都是基于互斥锁

他们的区别到底是什么呢?

  • 自旋锁是一种非阻塞锁,也就是说,如果某线程需要获取自旋锁,但该锁已经被其他线程占用时,该线程不会被挂起,而是在不断的消耗CPU的时间,不停的试图获取自旋锁
  • 互斥锁是阻塞锁,当某线程无法获取互斥量时,该线程会被直接挂起,该线程不再消耗CPU时间,当其他线程释放互斥量后,操作系统会激活那个被挂起的线程,让其投入运行

因此, 如果是多核处理器,如果预计线程等待锁的时间很短,短到比线程两次上下文切换时间要少的情况下,使用自旋锁是效率更高的。

看到这里,虽然没有直接的资料可以查询到,不过也可以大概猜到并发queue+barrier方式慢的原因了:因为读操作和写操作都需要配发到一个并发队列中,那么最终执行代码的线程和最初调用代码的线程很可能不是同一个,这里有一次线程切换的开销,如果这时候遇到互斥锁需要等待的话,当前线程被挂起,再等到cpu唤醒代码最终被执行的时候一来一回又是两次线程的切换,而对于很多轻量级的操作,这种线程之间切换的开销要比自旋锁的那种一直忙等待的方式慢很多。

总结

由上面的测试结论可见,atomic的性能远远比我们想象中要好,并发queue+barrier的方式远远比我们想象中要差。但是atomic真的是万能的吗?答案是否定的。

atomic适用的场景

  • 多线程环境下简单对象属性,仅有set/get的访问操作
  • 读写操作本身就很轻量,实际上只是简单的读写实例变量

atomic不适用的场景

  • 单线程环境,比如UIKit中所有类的属性,因为不存在多线程竞争的问题,加锁会影响效率

  • 期望原子的操作是若干个setget方法的组合,比如 i++实际上等价于:

    int temp = i + 1; i = temp; 
    

    如果需要保证i++这个操作的线程安全相当于setget方法组合起来的原子性,而这是atomic无法做到的

    测试代码:

    - (void)testComplex {
         int loop = 100000; //loop times
         dispatch_apply(loop, self.barrierQueue, ^(size_t i) {
             self.atomicNumber++;
         });
     
         dispatch_barrier_sync(self.barrierQueue, ^{
             NSLog(@"atomicNumber total:%lu", (unsigned long)self.atomicNumber);
         });
     
         dispatch_apply(loop, self.barrierQueue, ^(size_t i) {
             [_lock lock];
             self.nonatomicNumber++;
             [_lock unlock];
         });
     
         dispatch_barrier_sync(self.barrierQueue, ^{
             NSLog(@"nonatomicNumber total:%lu", (unsigned long)self.nonatomicNumber);
         });
     }
    

    测试结果:

    2017-09-05 14:42:39.598103+0800 ThreadSafeTest[420:52484]     atomicNumber total:99281
    2017-09-05 14:42:39.909207+0800 ThreadSafeTest[420:52484] nonatomicNumber total:100000
    

    在这种场景下,只能手动加锁去保证,通过上面的测试结果可以看到,@synchronizedNSConditionLock效率较差,iOS10以后出的新自旋锁unfairLockdispatch_semaphore以及pthread_mutex_t效率比较高。推荐使用dispatch_semaphore_t或者pthread_mutex_t
    注:OSSpinLock因为不再安全已经被苹果弃用,具体见郭曜源的这篇博客:不再安全的 OSSpinLock

  • set或者get方法中有任意一个逻辑比较复杂需要手动重写
    因为atomic修饰的属性靠编译器自动生成的getset方法实现原子操作,如果重写了任意一个,atomic关键字的特性将失效

  • 可变集合类对象的属性
    形如:

    @peroperty(atomic, strong) NSMutableArray * array;
    [self.array addObject:dummyObject];//线程不安全,在读取array后,执行addObject 的过程中,array所指向的对象可能已经在其他地方被释放了
    

性能测试demo地址

A Test Project for Thread Safe Protection by 11 ways

本文参考

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

推荐阅读更多精彩内容