Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
ChenYilong opened this issue Jun 11, 2017 · 7 comments
Open

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

ChenYilong opened this issue Jun 11, 2017 · 7 comments
Labels

Comments

@ChenYilong
Copy link
Owner

ChenYilong commented Jun 11, 2017

避免使用 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 全局队列的方式来创建常驻线程,因为在创建时可能已经出现了全局队列的线程池满了的情况,毕竟 libdispatch 的最大线程数量非常高(具体数值请参考 StackOverflow - Number of threads created by 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 常驻线程。

有个朋友担心“NSThread创建太多,应该也会引起线程调度频繁,资源竞争大吧,global 队列创建太多常驻线程,也可能使得系统在global 队列运行的任务迟迟得不到执行吧”,这个担心是有道理的,这个需要在最外层限制并发数量。无限制也会导致线程泄露。

所以也可以说:

避面长期占用GCD全局队列进行耗时操作


Posted by Posted by 微博@iOS程序犭袁 & 公众号@iTeaTime技术清谈
原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

@ChenYilong
Copy link
Owner Author

有个朋友担心“NSThread创建太多,应该也会引起线程调度频繁,资源竞争大吧,global 队列创建太多常驻线程,也可能使得系统在global 队列运行的任务迟迟得不到执行吧”,这个担心是有道理的,这个需要在最外层限制并发数量。无限制也会导致线程泄露。

@YueRuo
Copy link

YueRuo commented Aug 22, 2017

分析不错,结论是不是应该改成:避面长期占用GCD全局队列进行耗时操作

@ChenYilong
Copy link
Owner Author

是的,数据库和网络请求我上面都给例子了。

@jate
Copy link

jate commented Sep 1, 2017

使用NSThread或者GCD serial queue做一些异步请求任务的timeout判断,一般选择哪一个比较好呢?
另外要求一旦有返回,可以立刻结束任务。

@ChenYilong
Copy link
Owner Author

ChenYilong commented Aug 7, 2019

iTeaTime(技术清谈)@SAGESSE-深圳-某不知名小作坊: 问题来了,如果一个gcd任务在runloop,还能加入任务吗"


iTeaTime(技术清谈)@ChenYilong
gcd任务在runloop,这个是什么意思,是说用gcd任务手动启用了一个runloop吗?

看gcd队列类型吧,串行队列还是并行队列吧。
串行队列,要关runloop,才能继续处理下一个。
并行队列,仅仅是占用一条线程,其他线程一样可以继续处理后来加进来的任务。


iTeaTime(技术清谈)@林小达-鹅厂-iOS :串行类型的话处理不了,除非强制用CFRunloopPerformBlock处理


iTeaTime(技术清谈)@独醉年华-云图iOS -北京 :

Q: 弱弱的问下,runloop不是会休眠的么,有事干事没事睡眠

iTeaTime(技术清谈)@ChenYilong :
A:就跟等茅坑一样,别人在里面睡觉,你也进不去。:

@ChenYilong
Copy link
Owner Author

Q: 龙哥你这个例子,#9 我怎么一直不崩。切了很多次,就卡死,一直没崩,终于崩了。。。插着电脑调试一直不崩,不插就容易 崩。

A:插着电脑,用的是电脑的CPU,不能比。

@ChenYilong
Copy link
Owner Author

ChenYilong commented Oct 20, 2021

这个bad case还有一个细节没有被注意到:

可以引申总结为:

什么情况下, 异步线程会导致watchdog?
异步线程是否会有watchdog相关的crash?

@iteatimeteam (技术清谈) [2021-10-11 21:17:41]

这里的case看似是异步的场景 , 实际crash的更本原因还是主线程, 因为watchdog必定是主线程引起的, 这里的代码跟主线程有什么关系? 主线程只做了一件事情, 那就是派发队列, 反证法也能证明crash在派发队列这里.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants