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

2,209 阅读7分钟
原文链接: www.jianshu.com

前言

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
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
第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或者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

本文参考