前言
iOS 线程管理是一个老生常谈的话题,也是新人绕不过去的一道难关。用好多线程不容易,下面我简单谈一谈我的体会,希望对屏幕前的你有所帮助。
一、什么时候需要多线程
首先,要知道线程不是越多越好,创建线程和切换线程都有一定的开销,线程使用的不当也容易造成崩溃。那么什么时候需要使用多线程呢?一个主要的衡量标准是这个操作是否耗时,比如读写文件、网络请求、加解密等。特别是IO密集的操作,一定是要多线程的,否则会阻塞当前线程。
其次,线程和队列有着紧密的联系(ios里面特指GCD队列),如果某些操作需要按照一定的时序来执行并且对执行的时间不是那么敏感的话,那么最好就是放在一个串行队列里,比如写缓存。如果这些操作对执行时间敏感,且不是很讲究顺序的话,那么放在并行队列里比较合适,比如从分批下载视频片段(例如dash和hls)。如果是对执行时间敏感,并且又有一定的执行顺序,那么可以考虑NSOperationQueue,或者用dispatch_group、dispatch_semaphore来管理多个线程及其依赖关系。如果对这些都不讲究,那就用不着多线程了。
二、同步还是异步
一般情况下,能用异步还是用异步,除非是需要等待结果返回的才用同步。这主要是因为同步操作会阻塞线程,弄的不好还会导致死锁。编写同步代码的话,主要是用在同步读取某些属性这种场景,比如以下这个方法
ini
- (BOOL)hasSubscribeTopic:(NSString*)topic {
__block BOOL subscribed = NO;
dispatch_sync(self.syncQueue, ^{
subscribed = [self.subscribedTopics containsObject:topic];
});
return subscribed;
}
但是这样写有一个问题,就是如果别的方法在syncQueue对应的线程上调用了hasSubscribeTopic这个方法,就会导致死锁,所以正确的方式应该是这样
objectivec
static const void * kDispatchQueueSpecificKey = &kDispatchQueueSpecificKey;
//init方法中调用
dispatch_queue_set_specific(_syncQueue, kDispatchQueueSpecificKey, (__bridge void *)self, NULL);
- (BOOL)hasSubscribeTopic:(NSString*)topic {
void* value = dispatch_get_specific(kDispatchQueueSpecificKey);
if (value == (__bridge void *)(self)) {
return [self.subscribedTopics containsObject:topic];
}else{
__block BOOL subscribed = NO;
dispatch_sync(self.syncQueue, ^{
subscribed = [self.subscribedTopics containsObject:topic];
});
return subscribed;
}
}
有些第三方库没有注意这方面,比如SDWebImage的SDImageCache,使用的时候就需要尤其注意
objectivec
- (void)storeImageDataToDisk:(nullable NSData *)imageData
forKey:(nullable NSString *)key {
if (!imageData || !key) {
return;
}
dispatch_sync(self.ioQueue, ^{
[self _storeImageDataToDisk:imageData forKey:key];
});
}
三、串行还是并行
这个如前所述,主要看对执行时间的敏感程度和有无顺序要求。一般使用dispatch_create创建的队列以串行为主(swift的dispatchQueue默认就是串行的)。并行队列使用global_queue就可以了,但是有一个需要特别注意的是,不管是dispatch_get_global_queue还是dispatch_create分配的线程都是有上限的,如果超出上限,系统要么就是等待前面的线程执行完成(iOS模拟器),要么就会因为分配资源过多而导致崩溃(iOS真机)。通过下面这段代码,可以测试出系统最多能分配多少个线程,在iphone 15的模拟器上我测试得到的是global_queue能分配64个左右线程,而dispatch_create相对多一点,100多不到200个。
ini
dispatch_queue_t syncQueue = dispatch_queue_create("test", DISPATCH_QUEUE_SERIAL);
for (int i=0;i<1000;i++) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
pthread_t tid = pthread_self();
printf("1 tid=%lu\n",(unsigned long)tid);
dispatch_sync(syncQueue, ^{
NSLog(@"2");
});
NSLog(@"3");
});
}
还有一个问题就是,使用dispatch_get_global_queue创建的线程看似64个也够用了,但如果在这些线程里面使用了同步操作等待串行队列执行完成的话,就会造成阻塞,最终超出线程数量上限而崩溃。比如将上面代码中的NSLog(@"2") 改为一个写缓存之类的耗时操作。
四、线程池
由于线程数量是有上限的,并且线程切换比较耗时,所以对于性能要求较高的程序需要有线程池来管理多线程。iOS是没有系统自带的线程池的,一般都是自己实现(推荐使用dispatch_semaphore或NSOperationQueue,具体实现可以参考java的executor相关代码。需要注意的是,什么时候切换到线程池是有讲究的,一般规则是逻辑层的代码尽早切换到线程池,特别是有些逻辑可能会创建多个线程的时候,比如多个图片的下载和缓存。
五、线程的同步
线程的同步也是一个比较经典的话题了,我在这里就不想赘述了,大家可以在网上随便搜一搜,我只提一下,一般线程间同步就几种方式:
- 加锁
- 条件变量 3.信号量
- 串行队列+同步读异步写
- 内存屏障
- CAS原子操作
个人比较推荐的是加锁(性能要求没那么高)和条件变量(性能要求较高,逻辑相对简单的场景)。串行队列如果管理不当可能会创建多个线程,因此不做推荐。内存屏障和CAS原子操作比较底层,使用起来也没那么方便,除非是对时序和性能要求极高。
六、线程间通信
除了使用C语言的pthrea_create和pthread_join来进行线程创建和销毁时的通信外,iOS还可以使用NSMachPort和NSThread的performSelectorOnThread来做线程间通信。前者跟runloop结合,在runloop的生命周期内注册一个特定的事件来定期检查并执行,后者类似于pthread_create,在创建线程时传递一个参数。 除了这种系统提供的方法外,还有一种通用的方式,就是在线程内维护一个事件队列,外部需要给这个线程发消息时,就往队列插入一个事件,然后该线程在一个循环内定时去取事件执行。有点类似runloop的感觉,如果要跨平台的话可以考虑使用libevent(一般用来做网络通信)来实现。
结语
不管是在iOS还是其他的平台上,多线程管理都是一个复杂的话题。要用好多线程,除了要掌握一些常见的方法外,最主要还是平时编程的时候多思考,什么时候应该用多线程,以及怎么样做好线程同步和队列的选择,在追求高性能的同时保证安全性。