Objective-C 原子属性

Objective-C 在声明一个属性的时候,想必大家都是不用经过大脑思考就会写 @property (nonatomic, ...

我们都知道属性可以是 nonatomic 也可以使 atomic 的,但是好像几乎所有属性在声明的时候 nonatomic,atomic 的属性几乎没出现过。atomic 修饰符仿佛已被大家遗忘。

实际上,如果声明属性时既不写 atomic 也不写 nonatomic,那么这个属性默认是 atomic 的

atomic 的作用和工作原理

从字面上来看 nonatomic 是非原子的,atomic 是原子的。

atomic 的作用为:

atomic 修饰的属性的写操作是一个原子操作。

什么是原子操作?

原子操作就是指不会被线程调度机制打断的操作。这个操作是一个整体,CPU 一旦开始执行它,就会一直到执行结束,在这期间 CPU 不会转而去执行其它线程的操作。

可以用代码来模拟一下 atomic 的工作原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#import "ViewController.h"

@interface ViewController ()
@property (atomic, assign) NSInteger count;
@end

@implementation ViewController
@synthesize count = _count;

- (void)setCount:(NSInteger)count {
@synchronized (self) {
_count = count;
}
}

- (NSInteger)count {
return _count;
}

...

看上面代码,ViewController 有个 count 属性,我们重写了它的 Setter 和 Getter 方法,在 Setter 方法中,通过 @synchronized (self) {} 为复制操作 _count = count 加了一把锁,使得赋值这个操作同一时间只能有一个线程执行,保证了写属性值的时候的线程安全。

上面代码实现了和 atomic 相同的功能,但是底层的工作方式还是有区别的。我们常常用 @synchronized 来加锁,这种锁是互斥锁。而 atomic 修饰的属性自带了一把自旋锁

互斥锁和自旋锁的区别:

锁名 作用
互斥锁 当某个资源被先进入的线程上了锁以后,其它后面进入的线程会进入休眠状态。当锁释放后,进入休眠状态的线程变为唤醒状态。
自旋锁 当某个资源被先进入的线程上了锁以后,其它后进入的线程会开启一个循环,不断检查锁有没有释放,当锁释放后,退出循环开始访问资源,整个过程中后进入的线程一直保持运行状态

大多数情况下,atomic 并不能保证线程安全

既然 atomic 能简单的让一个属性的写操作变成线程安全的,为什么几乎不用它?

下面看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#import "ViewController.h"

@interface ViewController ()
@property (atomic, assign) NSInteger count;
@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];

self.count = 0;

NSThread *threadA = [[NSThread alloc] initWithTarget:self selector:@selector(doSomething) object:nil];
[threadA start];

NSThread *threadB = [[NSThread alloc] initWithTarget:self selector:@selector(doSomething) object:nil];
[threadB start];
}

- (void)doSomething {
for (NSInteger i = 0; i < 10; i++) {
[NSThread sleepForTimeInterval:1.0];
self.count++;
NSLog(@"self.count = %@ %@", @(self.count), [NSThread currentThread]);
}
}

@end

上面代码中,把属性 count 声明为 atomic 的。在 viewDidLoad 中创建了两个线程 threadA 和 threadB,都去执行 doSomething 方法。在 doSomething 方法中,去给 self.count 的值通过每次循环 +1 增加 10 次,然后打印 self.count 的值。为了让异常情况出现的概率提高,加入一句 [NSThread sleepForTimeInterval:1.0];

运行上面的代码,会发现打印的结果中,最后一条 self.count 的值往往是小于 20 的,在中间的某些打印日志中,会发现有些数字被重复打印的两次。

1
2
3
4
5
6
7
8
...
2018-02-07 23:05:08.718744+0800 AtomicDemo[53388:2777211] self.count = 13 <NSThread: 0x600000265f40>{number = 4, name = (null)}
2018-02-07 23:05:08.718791+0800 AtomicDemo[53388:2777210] self.count = 14 <NSThread: 0x600000265f00>{number = 3, name = (null)}
2018-02-07 23:05:09.719374+0800 AtomicDemo[53388:2777210] self.count = 15 <NSThread: 0x600000265f00>{number = 3, name = (null)}
2018-02-07 23:05:09.719374+0800 AtomicDemo[53388:2777211] self.count = 15 <NSThread: 0x600000265f40>{number = 4, name = (null)}
2018-02-07 23:05:10.719673+0800 AtomicDemo[53388:2777211] self.count = 17 <NSThread: 0x600000265f40>{number = 4, name = (null)}
2018-02-07 23:05:10.719673+0800 AtomicDemo[53388:2777210] self.count = 16 <NSThread: 0x600000265f00>{number = 3, name = (null)}
...

上面的结果中 15 出现了两次,这说明在使用 atomic 的情况下,还是出现了资源竞争。

那么原因在哪里呢?

我们看这句代码:

1
self.count++;

这句代码做了两件事,先读取 self.count 的值,然后把读取到的值 + 1 后赋值给 self.count。

由于 atomic 仅仅能保证写是线程安全的,而不是保证 读 -> +1 -> 写,这个整体是线程安全的。

当两个线程都执行到读取完 self.count 的值后,再去写,就会写成一样的值。

所以大部分情况下,为了保证线程安全,还是要自己加锁,可以根据需要来保证某块代码整体的线程安全。

线程安全的代码:

1
2
3
4
5
6
7
8
9
- (void)doSomething {
for (NSInteger i = 0; i < 10; i++) {
[NSThread sleepForTimeInterval:1.0];
@synchronized (self) {
self.count++;
}
NSLog(@"self.count = %@ %@", @(self.count), [NSThread currentThread]);
}
}

因为 atomic 在大部分情况下都无法保证线程安全,并且 atomic 的属性因为增加了原子性而降低了执行效率,因此实际开发中几乎不会出现 atomic 的身影。

nonatomic 对比 atomic

最后简单对比一下 nonatomic 和 atomic

修饰符 优势 劣势
nonatomic 执行效率高,性能好 不是线程安全的
atomic 线程安全,但是仅能保证写操作的线程安全 大幅降低执行效率