iOS开发多线程的那些坑

在平时iOS开发工作中,我们经常会用到系统提供的方法来使用多线程技术开发App,期望可以充分利用硬件资源来提高 App 的运行效率。但是,我们不禁会想到,像UIKit这样的前端框架并没有使用多线程技术。AFNetworking 2.0(网络框架)、FMDB(第三方数据库框架)这些用得最多的基础库,使用多线程技术时也非常谨慎。
UIKit不是线程安全的,一定要在主线程去刷新UI。
在 AFNetworking 2.0 中,把每个请求都封装成了单独的NSOperationQueue,再由NSOperationQueue根据当前的CPU数量和系统负载来控制并发。那么,为什么 AFNetworking 2.0 没有为每个请求创建一个线程,只是创建了一个线程,用来接收NSOperationQueue的回调。
FMDB只通过FMDatabaseQueue开启了一个线程队列,来串行地操作数据库。
原因就是多线程技术有坑。特别是 UIKit 干脆就做成了线程不安全,只能在主线程上操作。
我们使用多线程技术之前要了解这些坑
写 UIKit、AFNetworking、FMDB 这些库的“大神”们,并不是解决不了多线程技术可能会带来的问题,而相反正是因为他们非常清楚这些可能存在的问题,所以为避免使用者滥用多线程,亦或是出于性能考虑,而选择了使用单一线程来保证这些基础库的稳定可用。
虽然有坑,但是多线程技术还是有很多适用场景的。就比如说,在需要快速进行多个任务计算的场景里,多线程技术确实能够明显提高单位时间内的计算效率。
以照片处理为例,当选择一张照片后,你希望能够看到不同滤镜处理后的效果。如果这些效果图都是在一个队列里串行处理的话,那么你就得等着这些滤镜一个一个地来处理。这么做的话,不仅会影响用户体验,也没能充分利用硬件资源,可以说是把高端手机当作低端机来用了。换句话说就是,用户花大价钱升级了手机硬件,操作App的体验却没有得到提升。

所以,我们不能因为多线程技术有坑就不去用,正确的方法应该是更多地去了解多线程会有哪些问题,如果我们能够事先预见到那些问题的话,那么避免这些问题的发生也就不在话下了。
接下来我们看下多线程的坑。

常驻线程

常驻线程,指的就是那些不会停止,一直存在于内存中的线程。我们在文章开始部分,说到的AFNetworking 2.0 专门创建了一个线程来接收 NSOperationQueue 的回调,这个线程其实就是一个常驻线程。接下来,我们就看看常驻线程这个问题是如何引起的,以及是否有对应的解决方案。
我们先通过 AFNetworking 2.0 创建常驻线程的代码

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        // 先用 NSThread 创建了一个线程
        [[NSThread currentThread] setName:@"AFNetworking"];
        // 使用 run 方法添加 runloop
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}

如代码所示,AFNetworking 2.0 先用 NSThread 创建了一个线程,并使用 NSRunLoop 的 run 方法给这个新线程添加了一个 runloop。
通过NSRunLoop添加runloop的方法有三个:

  • run方法。通过 run 方法添加的 runloop ,会不断地重复调用runMode:beforeDate: 方法,来保证自己不会停止。
  • runUntilDate: 和 runMode:beforeDate 方法。这两个方法添加的runloop,可以通过指定时间来停止 runloop。

常驻线程创建起来很简单,但是常驻线程不能随便创建,创建的多了,不但不能提高CPU的利用率,反而会降低程序的执行效率。也就是说,这样做的话,就不是充分利用而是浪费CPU 资源了。
AFNetworking 2.0创建常驻线程的原因是使用了NSURLConnection,而NSURLConnection的设计上存在些缺陷,AFNetworking 2.0也是不得已而为之。
NSURLConnection 发起请求后,所在的线程需要一直存活,以等待接收 NSURLConnectionDelegate回调方法。但是,网络返回的时间不确定,所以这个线程就需要一直常驻在内存中。当然也可以选择在主线程中去处理,但是主线程还要处理大量的UI 和交互工作,为了减少对主线程的影响,所以AFNetworking 2.0 就新建了一个常驻线程,用来处理所有的请求和回调。AFNetworking 2.0的线程设计如下:

AFNetworking 2.0线程设计.png

正是因为NSURLConnection 的请求必须要有一个一直存活的线程来接收回调,AFNetworking 2.0 才创建一个常驻线程。虽然说,在一个 App 里网络请求这个动作的占比很高,但也有很多不需要网络的场景,所以线程一直常驻在内存中,也是不合理的。
在AFNetworking 在3.0版本时,使用苹果公司新推出的 NSURLSession 替换了 NSURLConnection,从而避免了常驻线程这个坑。NSURLSession 可以指定回调 NSOperationQueue,这样请求就不需要让线程一直常驻在内存里去等待回调了。实现代码如下:

self.operationQueue = [[NSOperationQueue alloc] init];
self.operationQueue.maxConcurrentOperationCount = 1;
self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];

NSURLSession发起的请求,可以指定回调的delegateQueue,不再需要在当前线程进行代理方法的回调。所以说,NSURLSession 解决了 NSURLConnection 的线程回调问题。

可见,AFNetworking 2.0 使用常驻线程也是无奈之举,一旦有方案能够替代常驻线程,它就会毫不犹豫地废弃常驻线程。我们没有理由去使用常驻线程。即使确实需要保活线程一段时间的话,可以选择使用 NSRunLoop 的另外两个方法 runUntilDate: 和 runMode:beforeDate,来指定线程的保活时长。让线程存活时间可预期,总比让线程常驻,至少在硬件资源利用率这点上要更加合理。或者,可以使用 CFRunLoopRef 的 CFRunLoopRun 和 CFRunLoopStop 方法来完成 runloop 的开启和停止,达到将线程保活一段时间的目的。

并发

并发是多线程技术的第二个大坑。

在iOS 并发编程技术中,GCD的使用率是最高的。
GCD(Grand Central Dispatch)是由苹果公司开发的一个多核编程解决方案。它提供的一套简单易用的接口,极大地方便了并发编程。同时,它还可以完成对复杂的线程创建、释放时机的管理。但是,GCD带来这些便利的同时,也带来了资源使用上的风险。

例如,在进行数据读写操作时,总是需要一段时间来等待磁盘响应的,如果在这个时候通过 GCD 发起了一个任务,那么GCD就会本着最大化利用 CPU的原则,会在等待磁盘响应的这个空档,再创建一个新线程来保证能够充分利用 CPU。

而如果GCD发起的这些新任务,都是类似于数据存储这样需要等待磁盘响应的任务的话,那么随着任务数量的增加,GCD 创建的新线程就会越来越多,从而导致内存资源越来越紧张,等到磁盘开始响应后,再读取数据又会占用更多的内存。结果就是,失控的内存占用会引起更多的内存问题。

这种情况最典型的场景就是数据库读写操作。FMDB是一个开源的第三方数据库框架,通过FMDatabaseQueue 这个核心类,将与读写数据库相关的磁盘操作都放到一个串行队列里执行,从而避免了线程创建过多导致系统资源紧张的情况。
当然如果项目里并发量不大,也可以使用FMDatabase。FMDatabaseQueue的使用很简单,代码如下:

FMDatabaseQueue *queue = [FMDatabaseQueue databaseQueueWithPath:self.feedDBPath];
        [queue inDatabase:^(FMDatabase *db) {
            FMResultSet *rs = [FMResultSet new];
            // 读取文章数据
            if (fid == 0) {
                rs = [db executeQuery:@"select * from feeditem where isread = ? and iid >= ? order by iid desc", @(0), @(iid)];
            } else {
                rs = [db executeQuery:@"select * from feeditem where isread = ? and iid >= ? and fid = ? order by iid desc", @(0), @(iid), @(fid)];
            }
            NSUInteger count = 0;
            while ([rs next]) {
                count++;
            }
            // 更新文章状态为已读
            if (fid == 0) {
                [db executeUpdate:@"update feeditem set isread = ? where iid >= ?", @(1), @(iid)];
            } else {
                [db executeUpdate:@"update feeditem set isread = ? where iid >= ? and fid = ?", @(1), @(iid), @(fid)];
            }
            
            [subscriber sendNext:@(count)];
            [subscriber sendCompleted];
            [db close];
        }];

总的来说,类似数据库这种需要频繁读写磁盘操作的任务,尽量使用串行队列来管理,避免因为多线程并发而出现内存问题。
同样并发还会造成内存问题,创建线程的过程,需要用到物理内存,CPU 也会消耗时间。而且,新建一个线程,系统还需要为这个进程空间分配一定的内存作为线程堆栈。堆栈大小是 4KB 的倍数。在iOS 开发中,主线程堆栈大小是 1MB,新创建的子线程堆栈大小是 512KB。
除了内存开销外,线程创建得多了,CPU 在切换线程上下文时,还会更新寄存器,更新寄存器的时候需要寻址,而寻址的过程还会有较大的 CPU 消耗。
所以,线程过多时内存和 CPU 都会有大量的消耗,从而导致App 整体性能降低,使得用户体验变成差。CPU 和内存的使用超出系统限制时,甚至会造成系统强杀。这种情况对用户和App的伤害就更大了。

总结,之前一提到多线程技术,我们往往都会联想到死锁等锁的问题,但其实锁的问题是最容易查出来的,反而是那些藏在背后,会慢慢吃尽你系统资源的问题,才是我们使用多线程时需要注意的。
结论:常驻内存一定不要滥用,最好不用。对于多线程并发也是一样,除非是并发数量少且可控,或者必须要在短时间内快速处理数据的情况,否则我们在一般情况下为避免数量不可控的并发处理,都需要把并行队列改成串行队列来处理。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容

  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,084评论 1 32
  • 1,NSObject中description属性的意义,它可以重写吗?答案:每当 NSLog(@"")函数中出现 ...
    eightzg阅读 4,131评论 2 19
  • 2017年4月11日晚上,有着“中国第一狗仔”之称的卓伟发出预告,称要曝一个跟了12年的大料,并提前放出部分暗示意...
    fly12358阅读 228评论 0 0
  • 九月好冷 刺眼的阳光没有温暖 枯枝上的果子还没成熟就碎在了泥中 我害怕孤独 所以躲在了没人的角落 ——鹅飞墨池(2...
    鹅飞墨池阅读 117评论 0 1
  • 寒风夜雨画悲秋 路上行人多少愁 霜染两鬓白似雪 孤魂还同梦里游。 ,,,,,,,,花已落
    花已落下雨声渐远阅读 421评论 0 1