iOS:应用程序的线程安全性
本文在于说明iOS应用的Objective-C代码的线程安全性。先是简单介绍一下线程安全的基本知识,然后通过一个小例子来观察非线程安全代码,最后会稍稍介绍一个可以用来分析线程安全隐患的工具。
1) 基础知识 (Threading Basics)
当启动一个应用时,iOS会对应创建一个进程(process)和一块为之分配的内存。简单地说,一个应用进程的内存包括三个部分: (更详细的描述可以看这里):
程序内存(program memory)存储应用的执行代码,它在执行时由一个指令指针(Instruction Pointer, IP)来跟踪程序执行位置。
堆(heap)存储由[… alloc] init]来创建的对象。
堆栈(stack)则用于函数调用。存储参数和函数的局部变量。
一个应用进程默认有一个主线程。如果有多线程,所有线程共享program memory 和 heap , 每个线程又有各自的IP和堆栈。就是说每个线程都有自己的执行流程,当它呼叫一个方法时,其它线程是无法访问调用参数和该方法的局部变量的。而那些在堆(heap)上创建的对象却可以被其它线程访问和使用。
2) 实验 (Experiment)
建个使用如下代码的小程序:
[cpp]
@interface FooClass {}
@property (nonatomic, assign) NSUInteger value;
- (void)doIt;
@end
@implementation FooClass
@synthesize value;
- (void)doIt {
self.value = 0;
for (int i = 0; i < 100000; ++i) {
self.value = i;
}
NSLog(@"执行后: %d (%@)", self.value, [NSThread currentThread]);
}
@end
这个类有一个整型属性value,并且会在doIt方法被连续增加100000次。执行完后,再将它的值和调用doIt方法的线程信息输出出来。 如下在AppDelegate中增加一个_startExperiment方法,然后在application:didFinishLaunchingWithOptions:方法中调用它:
[cpp]
- (void)_startExperiment {
FooClass *foo = [[FooClass alloc] init];
[foo doIt];
[foo release];
}
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// …
[self _startExperiment];
return YES;
}
因为这里还有多线程,所以结果很简单地显示value值为99999。
3) 线程安全 (Thread Safety)
如下以多线程并行执行doIt():
[cpp]
- (void)_startExperiment {
FooClass *foo = [[FooClass alloc] init];
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
for (int i = 0; i < 4; ++i) { //四个线程
dispatch_async(queue, ^{
[foo doIt];
});
}
[foo release];
}
再执行,你的输出可能会类似如下的结果:(实际可能不一样):
after execution: 19851 (NSThread: 0x6b29bd0>{name = (null), num = 3})
after execution: 91396 (NSThread: 0x6b298f0>{name = (null), num = 4})
after execution: 99999 (NSThread: 0x6a288a0>{name = (null), num = 5})
after execution: 99999 (NSThread: 0x6b2a6f0>{name = (null), num = 6})
并不是每个线程的value都是99999。这是因为现在的代码并不是线程安全的。
所谓线程安全就是代码运行在多线程环境下和运行在单线程环境下是一样的。
是什么导致了这个行为呢? 正如前面所说的每个线程都有其自己的IP和堆栈,但却共享堆(heap)。例子中的FooClass是创建在堆上的,所有线程都可以使用。下图展示了两个线程在执行doIt方法时的冲突::
Thread 1和Thread 2正在不同的位置执行。doIt()并没有对多线程的执行进行保护,它的实现是非线程安全的。
一个将doIt()变为线程安全的方式是在其函数体外使用如下编译指示符(directive):
@synchronized
新的代码如下所示:
[cpp]
- (void)doIt {
@synchronized(self) {
self.value = 0;
for (int i = 0; i < 100000; ++i) {
self.value = i;
}
NSLog(@"after execution: %d (%@)", self.value, [NSThread currentThread]);
}
}
使用@synchronized指示符, 每个线程会在doIt()互斥地使用self。不过因为目前的代码中@synchronized包住了整个函数体,并不能达到并行执行的效果。
另一种同步访问机制是使用GCD:Grand Central Dispatch (GCD).
4) 如何识别非线程安全的代码 (How to identify not thread safe code)
上面例子太过于简单了。现实中,花了时间写好的代码,常常遇到死锁、崩溃,或者一些无法复现的问题。总之和期望的行为不一样。
线程问题的主因是共享或全局状态(state)数据。多个对象访问一个全局变量,或者在堆中分享了共同对象,再或者向共同的存储空间写入数据。在前面例子中所共享的状态是self, 对应的访问也就是self.value。例子中所展示要比实际上的情况简单太多了,事实上确定使用的共享或全局状态(share or global state)并不容易。
解决方案就是写了一个工具,由多线程调用的函数来识别。下面是这个工具的核心概念。
工具主要包含了四个类: MultiThreadingAnalysis的实例用于记录一个线程对方法的调用, ThreadingTrace类和MethodExecution类用来输出MultiThreadingAnalysis整理的分析结果, MultiThreadingAnalysisHook类则用于hook到对象并追踪它被调用的所有方法。
MultiThreadingAnalysis类提供两个方法:
recordCallToMethod:ofClass:onThread: 记录某个方法在某个线程上被调用了。
threadingTraceOfLastApplicationRun 需要在分析完成后调用。
[cpp]
@interface MultiThreadingAnalysis : NSObject
- (void)recordCallToMethod:(NSString*)methodName
ofClass:(NSString*)className
onThread:(NSString*)threadID;
- (ThreadingTrace*) threadingTraceOfLastApplicationRun;
@end
分析结果由ThreadingTrace来处理. 它包含了一组MethodExecution实例,每一个都表示了一个线程对一个方法的调用:
[cpp]
/*
* An instance of this class captures
* which methods of which classes have been
* called on which threads.
*/
@interface ThreadingTrace : NSObject
/*
* Set of MethodExecution
*/
@property (nonatomic, readonly) NSSet *methodExecutions;
- (void)addMethodExecution:(MethodExecution*)methodExec;
@end
/*
* An instance of this class represents a call
* to a method of a specific class on a thread
* with a specific threadID.
*/
@interface MethodExecution : NSObject
@property (nonatomic, retain) NSString *methodName;
@property (nonatomic, retain) NSString *className;
@property (nonatomic, retain) NSString *threadID;
@end
为了尽可能方法地记录方法的调用,我使用了NSProxy来hook对一个对象所有方法的调用。MultiThreadingAnalysisHook类继承自NSProxy,并在forwardInvocation: 方法解析对target对象的调用. 在重定位到target对象前,会先使用一个MultiThreadingAnalysis实例来记录下这次调用。
[cpp]
@interface MultiThreadingAnalysisHook : NSProxy
@property (nonatomic, retain) id target;
@property (nonatomic, retain) MultiThreadingAnalysis *analysis;
@end
@implementation MultiThreadingAnalysisHook
-(void)forwardInvocation:(NSInvocation*)anInvocation {
[self.analysis recordCallToMethod:NSStringFromSelector([anInvocation selector])
ofClass:NSStringFromClass([self.target class])
onThread:[NSString stringWithFormat:@"%d", [NSThread currentThread]]];
[anInvocation invokeWithTarget:self.target];
}
@end
现在就可以使用了。在你要分析的类中创建一个私有方法_withThreadingAnalysis 。 这个方法要创建一个MultiThreadingAnalysisHook实例并且将target指到self。在自行指定的初始化函数中调用_withThreadingAnalysis并返回其结果(HOOK的动作)。这样就达到使用MultiThreadingAnalysisHook实例将原本对象的self封装起来,并可以记录所有外部对象的调用。
[cpp]
@implementation YourClass
- (id)init {
//... do init stuff here
return [self _withThreadingAnalysis];
}
- (id)_withThreadingAnalysis {
MultiThreadingAnalysisHook *hook =
[[MultiThreadingAnalysisHook alloc] init];
hook.target = self;
return hook;
}
@end
此后就可以调用MultiThreadingAnalysis 的threadingTraceOfLastApplicationRun方法获取分析结果。最简单地输出到文本文件,结果如下:
begin threading analysis for class FooClass
method doIt (_MultiThreadAccess_)
method init (_SingleThreadAccess_)
如果某个方法被多线程调用(标注为 _MultiThreadAccess_), 你可以看到更多详细信息。