美文网首页
多线程的两个坑

多线程的两个坑

作者: forping | 来源:发表于2021-01-13 10:53 被阅读0次

本文是<<iOS开发高手课>> 第十七篇学习笔记.

在 iOS 开发中,经常会用到系统提供的多线程技术开发 App,期望可以充分利用硬件资源来提高 App 的运行效率。
但像 UIKit 这样的前端框架并没有使用多线程技术。而 AFNetworking 2.0(网络框架)、FMDB(第三方数据库框架)这些用得最多的基础库,使用多线程技术时也非常谨慎。

在 AFNetworking 2.0 中,把每个请求都封装成了单独的 NSOperationQueue,再由 NSOperationQueue 根据当前的 CPU 数量和系统负载来控制并发。
那为什么 AFNetworking 2.0 没有为每个请求创建一个线程,而只是创建了一个线程,用来接收 NSOperationQueue 的回调呢

FMDB 只通过 FMDatabaseQueue 开启了一个线程队列,来串行地操作数据库。

下面介绍多线程技术常见的两个大坑,常驻线程和并发问题

常驻线程

常驻线程,指的是不会停止,一直存在于内存中的线程。 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就退出了
        [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。

但如果你有 30 个库,每个库都常驻一个线程。那这样做,不但不能提高 CPU 的利用率,反而会降低程序的执行效率。也就是说,这样做的话,就不是充分利用而是浪费 CPU 资源了。如果你的库非常多的话,按照这个思路创建的常驻线程也会更多,结果就只会带来更多的坑。

既然常线程是个坑,那为什么 AFNetworking 2.0 库还要这么做呢?

问题的根源在于 AFNetworking 2.0 使用的是 NSURLConnection,而 NSURLConnection 的设计上存在些缺陷。

NSURLConnection 发起请求后,所在的线程需要一直存活,以等待接收 NSURLConnectionDelegate 回调方法。但是,网络返回的时间不确定,所以这个线程就需要一直常驻在内存中。既然这样,AFNetworking 2.0 为什么没有在主线程上完成这个工作,而一定要新创建一个线程来做呢?

这是因为主线程还要处理大量的 UI 和交互工作,为了减少对主线程的影响,所以 AFNetworking 2.0 就新建了一个常驻线程,用来处理所有的请求和回调。AFNetworking 2.0 的线程设计如下图所示:

image.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 的线程回调问题。

如果你需要确实需要保活线程一段时间的话,可以选择使用 NSRunLoop 的另外两个方法 runUntilDate: 和 runMode:beforeDate,来指定线程的保活时长。让线程存活时间可预期,相比让线程常驻,至少在硬件资源利用率这点上要更加合理。或者使用 CFRunLoopRef 的 CFRunLoopRun 和 CFRunLoopStop 方法来完成 runloop 的开启和停止,达到将线程保活一段时间的目的。

并发

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

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

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

这种情况最典型的场景就是数据库读写操作。FMDB是一个开源的第三方数据库框架,通过 FMDatabaseQueue 这个核心类,将与读写数据库相关的磁盘操作都放到一个串行队列里执行,从而避免了线程创建过多导致系统资源紧张的情况。

FMDatabaseQueue 使用起来也很简单


// 标记文章已读
- (RACSignal *)markFeedItemAsRead:(NSUInteger)iid fid:(NSUInteger)fid{
    @weakify(self);
    return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
        @strongify(self);
//你只需要将数据库的操作放到 FMDatabaseQueue 的 inDatabase 方法入参 block 中,就可以在 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];
        }];
        return nil;
    }];
}

内存问题

在并发这部分,线程开多了会有内存问题

创建线程的过程,需要用到物理内存,CPU 也会消耗时间。而且,新建一个线程,系统还需要为这个进程空间分配一定的内存作为线程堆栈。堆栈大小是 4KB 的倍数。在 iOS 开发中,主线程堆栈大小是 1MB,新创建的子线程堆栈大小是 512KB。

除了内存开销外,线程创建得多了,CPU 在切换线程上下文时,还会更新寄存器,更新寄存器的时候需要寻址,而寻址的过程还会有较大的 CPU 消耗。

所以,线程过多时内存和 CPU 都会有大量的消耗,从而导致 App 整体性能降低,使得用户体验变成差。CPU 和内存的使用超出系统限制时,甚至会造成系统强杀。这种情况对用户和 App 的伤害就更大了。

相关文章

  • 多线程的两个坑

    本文是< > 第十七篇学习笔记. 在 iOS 开发中,经常会用到系统提供的多线程技术开发 App,期望可以充分利用...

  • Python多线程-thread.start_new_threa

    在使用python多线程的时候,踩到了主线程未等待多线程进程运行完成就结束,导致多线程无效的坑。后来想到自己写个全...

  • 多线程的坑

    内存占用 线程的创建需要占用一定的内核物理内存以及CPU处理时间,具体消耗参见下表。 此外在CPU上切换线程上下文...

  • Qt多线程编程爬坑笔记

    最近在工作中用到了Qt中的多线程,踩了不少坑,故作下笔记,警示后人 - -! Overview 使用多线程编程可以...

  • iOS多线程使用踩过的坑

    iOS多线程使用踩过的坑 iOS 开发过程中,我们经常使用系统提供的方法使用多线程(全局并发)包括: 使用起来很方...

  • 多线程编程一些注意点

    由于多线程编程的坑非常多,一不小心就会掉进去,然后调试好久好久。 自己在使用多线程编程的场合只有几次,用java中...

  • iOS 多线程坑

    1.首先明确一点,那就是UI必须在主线程中刷新!那么问题来了 如图中显示SPCommonHud(这是一个类似MBP...

  • 入坑多线程

    一、线程与进程 进程(process)是 CPU 分配资源的基本单位,拥有独立的内存空间和资源,而线程(threa...

  • iOS四种多线程的基本使用

    1.pthread 实现多线程操作(不常使用) 2.NSThread实现多线程 3.多线程之GCD讲解 模拟两个异...

  • Java线程池

    身为程序员我们对线程是再熟悉不过了,多线程并发算是Java进阶的知识,用好多线程不容易有太多的坑。创建线程也算是一...

网友评论

      本文标题:多线程的两个坑

      本文链接:https://www.haomeiwen.com/subject/tbfuaktx.html