避免使用 GCD Global队列创建Runloop常驻线程

避免使用 GCD Global队列创建Runloop常驻线程

本文对应 Demo 以及 Markdown 文件在仓库中,文中的错误可以提 PR 到这个文件,我会及时更改。

目录


GCD Global队列创建线程进行耗时操作的风险

先思考下如下几个问题:

  • 新建线程的方式有哪些?各自的优缺点是什么?
  • dispatch_async 函数分发到全局队列一定会新建线程执行任务么?
  • 如果全局队列对应的线程池如果满了,后续的派发的任务会怎么处置?有什么风险?

答案大致是这样的:dispatch_async 函数分发到全局队列不一定会新建线程执行任务,全局队列底层有一个的线程池,如果线程池满了,那么后续的任务会被 block 住,等待前面的任务执行完成,才会继续执行。如果线程池中的线程长时间不结束,后续堆积的任务会越来越多,此时就会存在 APP crash的风险。

比如:

- (void)dispatchTest1 {
    for (NSInteger i = 0; i< 10000 ; i++) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            [self dispatchTask:i];
        });
    }
}

- (void)dispatchTask:(NSInteger)index {
        //模拟耗时操作,比如DB,网络,文件读写等等
        sleep(30);
        NSLog(@"----:%ld",index);
}

以上逻辑用真机测试会有卡死的几率,并非每次都会发生,但多尝试几次就会复现,伴随前后台切换,crash几率增大。

下面做一下分析:

参看 GCD 源码我们可以看到全局队列的相关源码如下:

DISPATCH_NOINLINE
static void
_dispatch_queue_wakeup_global_slow(dispatch_queue_t dq, unsigned int n)
{
   dispatch_root_queue_context_t qc = dq->do_ctxt;
   uint32_t i = n;
   int r;

   _dispatch_debug_root_queue(dq, __func__);
   dispatch_once_f(&_dispatch_root_queues_pred, NULL,
           _dispatch_root_queues_init);

#if HAVE_PTHREAD_WORKQUEUES
#if DISPATCH_USE_PTHREAD_POOL
   if (qc->dgq_kworkqueue != (void*)(~0ul))
#endif
   {
       _dispatch_root_queue_debug("requesting new worker thread for global "
               "queue: %p", dq);
#if DISPATCH_USE_LEGACY_WORKQUEUE_FALLBACK
       if (qc->dgq_kworkqueue) {
           pthread_workitem_handle_t wh;
           unsigned int gen_cnt;
           do {
               r = pthread_workqueue_additem_np(qc->dgq_kworkqueue,
                       _dispatch_worker_thread4, dq, &wh, &gen_cnt);
               (void)dispatch_assume_zero(r);
           } while (--i);
           return;
       }
#endif // DISPATCH_USE_LEGACY_WORKQUEUE_FALLBACK
#if HAVE_PTHREAD_WORKQUEUE_SETDISPATCH_NP
       if (!dq->dq_priority) {
           r = pthread_workqueue_addthreads_np(qc->dgq_wq_priority,
                   qc->dgq_wq_options, (int)i);
           (void)dispatch_assume_zero(r);
           return;
       }
#endif
#if HAVE_PTHREAD_WORKQUEUE_QOS
       r = _pthread_workqueue_addthreads((int)i, dq->dq_priority);
       (void)dispatch_assume_zero(r);
#endif
       return;
   }
#endif // HAVE_PTHREAD_WORKQUEUES
#if DISPATCH_USE_PTHREAD_POOL
   dispatch_pthread_root_queue_context_t pqc = qc->dgq_ctxt;
   if (fastpath(pqc->dpq_thread_mediator.do_vtable)) {
       while (dispatch_semaphore_signal(&pqc->dpq_thread_mediator)) {
           if (!--i) {
               return;
           }
       }
   }
   uint32_t j, t_count;
   // seq_cst with atomic store to tail <rdar://problem/16932833>
   t_count = dispatch_atomic_load2o(qc, dgq_thread_pool_size, seq_cst);
   do {
       if (!t_count) {
           _dispatch_root_queue_debug("pthread pool is full for root queue: "
                   "%p", dq);
           return;
       }
       j = i > t_count ? t_count : i;
   } while (!dispatch_atomic_cmpxchgvw2o(qc, dgq_thread_pool_size, t_count,
           t_count - j, &t_count, acquire));

   pthread_attr_t *attr = &pqc->dpq_thread_attr;
   pthread_t tid, *pthr = &tid;
#if DISPATCH_ENABLE_PTHREAD_ROOT_QUEUES
   if (slowpath(dq == &_dispatch_mgr_root_queue)) {
       pthr = _dispatch_mgr_root_queue_init();
   }
#endif
   do {
       _dispatch_retain(dq);
       while ((r = pthread_create(pthr, attr, _dispatch_worker_thread, dq))) {
           if (r != EAGAIN) {
               (void)dispatch_assume_zero(r);
           }
           _dispatch_temporary_resource_shortage();
       }
   } while (--j);
#endif // DISPATCH_USE_PTHREAD_POOL
}

对于执行的任务来说,所执行的线程具体是哪个线程,则是通过 GCD 的线程池(Thread Pool)来进行调度,正如Concurrent Programming: APIs and Challenges文章里给的示意图所示:

上面贴的源码,我们关注如下的部分:

其中有一个用来记录线程池大小的字段 dgq_thread_pool_size。这个字段标记着GCD线程池的大小。摘录上面源码的一部分:

uint32_t j, t_count;
  // seq_cst with atomic store to tail <rdar://problem/16932833>
  t_count = dispatch_atomic_load2o(qc, dgq_thread_pool_size, seq_cst);
  do {
      if (!t_count) {
          _dispatch_root_queue_debug("pthread pool is full for root queue: "
                  "%p", dq);
          return;
      }
      j = i > t_count ? t_count : i;
  } while (!dispatch_atomic_cmpxchgvw2o(qc, dgq_thread_pool_size, t_count,
          t_count - j, &t_count, acquire));

从源码中我们可以对应到官方文档 :Getting the Global Concurrent Dispatch Queues里的说法:

A concurrent dispatch queue is useful when you have multiple tasks that can run in parallel. A concurrent queue is still a queue in that it dequeues tasks in a first-in, first-out order; however, a concurrent queue may dequeue additional tasks before any previous tasks finish. The actual number of tasks executed by a concurrent queue at any given moment is variable and can change dynamically as conditions in your application change. Many factors affect the number of tasks executed by the concurrent queues, including the number of available cores, the amount of work being done by other processes, and the number and priority of tasks in other serial dispatch queues.

也就是说:

全局队列的底层是一个线程池,向全局队列中提交的 block,都会被放到这个线程池中执行,如果线程池已满,后续再提交 block 就不会再重新创建线程。这就是为什么 Demo 会造成卡顿甚至冻屏的原因。

避免使用 GCD Global 队列创建 Runloop 常驻线程

在做网路请求时我们常常创建一个 Runloop 常驻线程用来接收、响应后续的服务端回执,比如NSURLConnection、AFNetworking等等,我们可以称这种线程为 Runloop 常驻线程。

正如上文所述,用 GCD Global 队列创建线程进行耗时操作是存在风险的。那么我们可以试想下,如果这个耗时操作变成了 runloop 常驻线程,会是什么结果?下面做一下分析:

先介绍下 Runloop 常驻线程的原理,在开发中一般有两种用法:

  • 单一 Runloop 常驻线程:在 APP 的生命周期中开启了唯一的常驻线程来进行网络请求,常用于网络库,或者有维持长连接需求的库,比如: AFNetworking 、 SocketRocket
  • 多个 Runloop 常驻线程:每进行一次网络请求就开启一条 Runloop 常驻线程,这条线程的生命周期的起点是网络请求开始,终点是网络请求结束,或者网络请求超时。

单一 Runloop 常驻线程

先说第一种用法:

以 AFNetworking 为例,AFURLConnectionOperation 这个类是基于 NSURLConnection 构建的,其希望能在后台线程接收 Delegate 回调。为此 AFNetworking 单独创建了一个线程,并在这个线程中启动了一个 RunLoop:

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"AFNetworking"];
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}
 
+ (NSThread *)networkRequestThread {
    static NSThread *_networkRequestThread = nil;
    static dispatch_once_t oncePredicate;
    dispatch_once(&oncePredicate, ^{
        _networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
        [_networkRequestThread start];
    });
    return _networkRequestThread;
}

多个 Runloop 常驻线程

第二种用法,我写了一个小 Demo 来模拟这种场景,

我们模拟了一个场景:假设所有的网络请求全部超时,或者服务端根本不响应,然后网络库超时检测机制的做法:

#import "Foo.h"

@interface Foo()  {
    NSRunLoop *_runloop;
    NSTimer *_timeoutTimer;
    NSTimeInterval _timeoutInterval;
    dispatch_semaphore_t _sem;
}
@end

@implementation Foo

- (instancetype)init {
    if (!(self = [super init])) {
        return nil;
    }
    _timeoutInterval = 1 ;
    _sem = dispatch_semaphore_create(0);
    // Do any additional setup after loading the view, typically from a nib.
    return self;
}

- (id)test {
    // 第一种方式:
    // NSThread *networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint0:) object:nil];
    // [networkRequestThread start];
    //第二种方式:
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
        [self networkRequestThreadEntryPoint0:nil];
    });
    dispatch_semaphore_wait(_sem, DISPATCH_TIME_FOREVER);
    return @(YES);
}

- (void)networkRequestThreadEntryPoint0:(id)__unused object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"CYLTest"];
        _runloop = [NSRunLoop currentRunLoop];
        [_runloop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        _timeoutTimer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(stopLoop) userInfo:nil repeats:NO];
        [_runloop addTimer:_timeoutTimer forMode:NSRunLoopCommonModes];
        [_runloop run];//在实际开发中最好使用这种方式来确保能runloop退出,做双重的保障[runloop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:(timeoutInterval+5)]];
    }
}

- (void)stopLoop {
    CFRunLoopStop([_runloop getCFRunLoop]);
    dispatch_semaphore_signal(_sem);
}

@end

如果

   for (int i = 0; i < 300 ; i++) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
            [[Foo new] test];
            NSLog(@"🔴类名与方法名:%@(在第%@行),描述:%@", @(__PRETTY_FUNCTION__), @(__LINE__), @"");
        });
    }

以上逻辑用真机测试会有卡死的几率,并非每次都会发生,但多尝试几次就会复现,伴随前后台切换,crash几率增大。

其中我们采用了 GCD 全局队列的方式来创建常驻线程,因为在创建时可能已经出现了全局队列的线程池满了的情况,所以 GCD 派发的任务,无法执行,而且我们把超时检测的逻辑放进了这个任务中,所以导致的情况就是,有很多任务的超时检测功能失效了。此时就只能依赖于服务端响应来结束该任务(服务端响应能结束该任务的逻辑在 Demo 中未给出),但是如果再加之服务端不响应,那么任务就永远不会结束。后续的网络请求也会就此 block 住,造成 crash。

如果我们把 GCD 全局队列换成 NSThread 的方式,那么就可以保证每次都会创建新的线程。

注意:文章中只演示的是超时 cancel runloop 的操作,实际项目中一定有其他主动 cancel runloop 的操作,就比如网络请求成功或失败后需要进行cancel操作。代码中没有展示网络请求成功或失败后的 cancel 操作。

Demo 的这种模拟可能比较极端,但是如果你维护的是一个像 AFNetworking 这样的一个网络库,你会放心把创建常驻线程这样的操作交给 GCD 全局队列吗?因为整个 APP 是在共享一个全局队列的线程池,那么如果 APP 把线程池沾满了,甚至线程池长时间占满且不结束,那么 AFNetworking 就自然不能再执行任务了,所以我们看到,即使是只会创建一条常驻线程, AFNetworking 依然采用了 NSThread 的方式而非 GCD 全局队列这种方式。

注释:以下方法存在于老版本AFN 2.x 中。

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"AFNetworking"];
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}
 
+ (NSThread *)networkRequestThread {
    static NSThread *_networkRequestThread = nil;
    static dispatch_once_t oncePredicate;
    dispatch_once(&oncePredicate, ^{
        _networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
        [_networkRequestThread start];
    });
    return _networkRequestThread;
}

正如你所看到的,没有任何一个库会用 GCD 全局队列来创建常驻线程,而你也应该

避免使用 GCD Global 队列来创建 Runloop 常驻线程。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容