怎样做才能保证线程安全?
在软件编程中,多线程是个绕不开的话题。多线程的使用,能够提高程序的运行效率,但也带来新的问题:如何保证线程安全?
在维基百科中线程安全的解释是:指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。换句话说,就是某个变量在被某条线程访问期间是“一致”的。这个“一致”指的是这条线程从开始访问这个变量到结束访问这个变量期间,这个变量不会发生任何变化。
那么,保证某个变量的线程安全,也就可以理解成保证某个变量在某个特定时间段内是一致的。这个某个特定时间,也就可以理解成为线程安全的原子性粒度,具体下面有介绍。
例子
具体到iOS上,经常能看到下面的代码例子:
// 例子1
@property (atomic, assign) int num;
// thread A
for (int i = 0; i < 10000; i++) {
self.num = self.num + 1;
NSLog(@"Thread A: %d\d ",self.num);
}
// thread B
for (int i = 0; i < 10000; i++) {
self.num = self.num + 1;
NSLog(@"Thread B: %d\d ",self.num);
}
// 例子2
@property (atomic, strong) NSString * stringA;
//thread A
for (int i = 0; i < 10000; i ++) {
if (i % 2 == 0) {
self.stringA = @"a very long string";
}
else {
self.stringA = @"string";
}
NSLog(@"Thread A: %@\n", self.stringA);
}
//thread B
for (int i = 0; i < 10000; i ++) {
if (self.stringA.length >= 10) {
NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)];
}
NSLog(@"Thread B: %@\n", self.stringA);
}
例子A最后输出不一定是20000,例子B有可能会crash。这两个例子说明了一个问题:property加上atomic关键字,并不一定能保证属性的线程安全
。
线程安全的原子性粒度
那为什么用了atomic
关键字不能保证上述场景的property变量的线程安全?
atomic
关键字的作用其实就是对属性的读写操作进行加锁,换句话说就是对属性的Setter/Getter操作加锁。但atomic
关键字只能保证在同一时间段内,最多有且只有一条线程对当前关键字进行读写。
例子1中self.num = self.num + 1;
包含了三个操作:通过Getter读取num,对读取的num进行加1,将加1后的结果写回num。atomic
关键字能保证每一个操作都是原子的。但是,每个操作之间的间隙时间,atomic
不能保证属性不被其他线程访问。在TheadA对num进行加1操作后,此时CPU时间被分配给了Thread B,Thread B有可能对num进行了修改,当CPU时间再次分配回Thread A的时候,此时的num+1不一定是原来的num+1,此时Thread 将当前的num值修改成原来的的num+1的值,最后导致预期值跟实际值不一样,这种场景就是多线程的线程不安全
。而且使用atomic
无法避免一个问题,如果多线程对属性的访问是直接通过Ivar来访问, 不通过调用Getter/Setter来访问的话,atomic
没有任何作用。
同样,例子2也是一样,当执行代码self.stringA.length >= 10
时,假设stringA的值是“a very long string”,符合判断条件,此时线程切换到Thread A,Thread A将stringA修改成“string”。这时CPU时间再次分配给Thread B,此时Thread B会执行[self.stringA substringWithRange:NSMakeRange(0, 10)]
,但当前的stringA的值已经被Thread A修改成了“string”,所以会字符串访问越界,直接crash。
例子1和例子2出现问题的原因在于虽然对字符串的每次读写都是安全的,但是并不能保证各个线程组合起来的操作是安全的,这就是一个线程安全的原子性粒度问题。atomic
的原子粒度是Getter/Setter,但对多行代码的操作不能保证原子性。针对例子1和例子2的问题,更好的办法是使用锁机制。
// 例子3
// thread A
[_lock lock];
for (int i = 0; i < 10000; i++) {
self.num = self.num + 1;
NSLog(@"Thread A: %d\d ",self.num);
}
[_lock unlock];
// thread B
[_lock lock];
for (int i = 0; i < 10000; i++) {
self.num = self.num + 1;
NSLog(@"Thread B: %d\d ",self.num);
}
[_lock unlock];
// 例子4
//thread A
[_lock lock];
for (int i = 0; i < 10000; i ++) {
if (i % 2 == 0) {
self.stringA = @"a very long string";
}
else {
self.stringA = @"string";
}
NSLog(@"Thread A: %@\n", self.stringA);
}
[_lock unlock];
//thread B
[_lock lock];
for (int i = 0; i < 10000; i ++) {
if (self.stringA.length >= 10) {
NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)];
}
NSLog(@"Thread B: %@\n", self.stringA);
}
[_lock unlock];
对代码进行加锁后,只有对加锁代码加锁了的线程才能访问加锁代码,这样就保证了加锁代码不会被其他线程执行,从而从更大粒度上保证了线程安全。如果使用了锁机制进行代码级原子粒度的控制,就没有必要再使用更小粒度的atomic
了。因为大粒度的原子性已经能够保障相关业务代码的线程安全,如果再加多更小粒度的原子性控制,一来会多此一举,二来atomic
是一种更小粒度的加锁机制,会对性能有不少的影响,所以一般来说如果使用了更大粒度的原子性,就没有必要使用更小粒度的原子性了,所以加锁后的代码中的属性变量,没有必要再使用atomic
。
不加锁的小技巧
对于例子2,如果不加锁,怎么保证不会代码不会crash?
// 例子5
for (int i = 0; i < 10000; i ++) {
NSString *immutableTempString = self.stringA;
if (immutableTempString.length >= 10) {
NSString* subStr = [immutableTempString substringWithRange:NSMakeRange(0, 10)];
}
}
例子2发生crash的原因是,stringA指向的内存区域发生了变化,访问时发生了越界。但例子5中则不会有这种情况,因为例子5中使用了临时变量immutableTempString,指向stringA未发生变化前的内存空间,当stringA指向的内存发生变化后,由于原来stringA指向的内存被immutableTempString指向,所以暂时不会被系统回收。当[immutableTempString substringWithRange:NSMakeRange(0, 10)]
调用时,immutableTempString指向的还是原来的stringA的值,所以不会发生crash。这种方法的原理是,通过使用临时变量来持有原来变动前的值,所有操作都对这个临时变量指向的值进行操作,而不是直接使用属性指向的值,这样的话能保证上下文情景下变量的值是一致的,而且由于变量是临时变量,所以只会对当前线程可见,对其他线程不可见,从而在某种程度上保证了线程安全。
总结
在iOS中,不能简单的认为只要加上atomic
关键字就能保证属性的线程安全。而在实际使用中,由于业务代码的复杂性,大部分情况下都会使用比atomic
更大粒度的锁控制。由于使用了更大粒度的锁,从性能和必要性方面考虑,就不需要再使用atomic
了。在某些情况下,如果不能采用加锁的做法,又要保证代码不会发生crash,可以使用临时变量指向原值,保证一定程度的线程安全。
总而言之,多线程的线程安全是个复杂的问题,最好的做法是尽量避免多线程的设计