深入理解 NSRunLoop

相信每个 Cocoa 程序员都为 NSRunLoop 头疼过,并且是常见的面试问题,本文介绍 NSRunLoop 的原理,并通过学习源码实现揭开 NSRunLoop 的神秘面纱。

NSRunLoop 原理介绍

什么是 NSRunLoop ?

曾听说过不少同学说 NSRunLoop 的概念比较复杂,我自己也是好几次学了又忘、忘了又学。事实上了解了 NSRunLoop 背后的原理,会发现整个机制十分简单。

因为我们日常开发中很少直接使用,才会觉得 NSRunLoop 复杂。通常来说苹果这些框架背后的原理都是十分清晰的,苹果之所以引入这些框架不是为了把我们搞晕,而是因为要解决通用性的问题,免除我们自己造轮子实现的时间。

总的来说,NSRunLoop 是线程处理各种系统输入事件的循环,它的作用是仅在需要的时候让线程执行任务而没有任务需要执行的时候让线程休眠。在运行时,它是在一个线程不停运行的一个 while 循环。

在不断循环的过程中,NSRunLoop 一旦观察到有事件源变化或者 timer 时间到了,就会通知外部处理事件或者触发 timer。

这里的机制其实跟服务端异步处理的网络请求机制是一样的。我曾经使用 select 函数实现一个简单的 http 服务器,系统的 select 接口通过观察 file descriptor 是否有新的输入,然后唤醒线程,从而实现服务器异步处理的机制。NSRunLoop 也是一样的机制,但他不仅仅观察 file descriptor(在苹果系统上其实是 mach port),还可以观察好几种输入源,包括 mach port sources, custom input sources, Cocoa perform selector sources 和 timer。

简单来说,这套机制的流程是:

服务器异步处理流程: while(0)循环 -> select 接口等待网络输入,线程休眠 -> 收到网络请求输入 -> 处理网络请求 -> 继续循环

NSRunLoop 流程: NSRunLoop 循环 -> 等待事件源,线程休眠 -> 收到事件源变化 -> 处理事件源 -> 继续循环

当然,上述只是 NSRunLoop 的核心介绍,实际实现有各种优化还有 NSRunLoop modes 需要考虑。

下面我们通过伪代码实现加深对 NSRunLoop 的理解。

NSRunLoop 的伪代码实现

Mike Ash 大神在 Friday Q&A 专栏的这篇文章[](https://mikeash.com/pyblog/friday-qa-2010-01-01-nsrunloop-internals.html)是学习源码的绝佳材料。这里插一句题外话,Friday Q&A 专栏文章都是精品,包含多种话题,有时间的话推荐阅读感兴趣的话题。

先来看一下官方文档介绍的 NSRunLoop 流程图:

图1 - 一个 run loop 的结构和输入源

image

结合这份流程图,我们来看一下伪代码实现。

 - (BOOL)runMode: (NSString *)mode beforeDate: (NSDate *)limitDate
{
    if(![self hasSourcesOrTimersForMode: mode])
        return NO;
    
    // with timer support, this code has to loop until an input
    // source fires
    BOOL didFireInputSource = NO;
    while(!didFireInputSource)
    {
        fd_set fdset;
        FD_ZERO(&fdset);
        
        for(inputSource in [_inputSources objectForKey: mode])
            FD_SET([inputSource fileDescriptor], &fdset);
        
        // the timeout needs to be set from the limitDate
        // and from the list of timers
        // start with the limitDate
        NSTimeInterval timeout = [limitDate timeIntervalSinceNow];
        
        // now run through the list of timers and set the
        // timeout to the smallest one found in them and
        // in the limitDate
        for(timer in [_timerSources objectForKey: mode])
            timeout = MIN(timeout, [[timer fireDate] timeIntervalSinceNow]);
        
        // now run select
        select(fdset, timeout);
        
        // process input sources first (this choice is arbitrary)
        for(inputSource in [[[_inputSources objectForKey: mode] copy] autorelease])
            if(FD_ISSET([inputSource fileDescrptor], &fdset))
            {
                didFireInputSource = YES;
                [inputSource fileDescriptorIsReady];
            }
        
        // now process timers
        // responsibility for updating fireDate for repeating timers
        // and for removing the timer from the runloop for non-repeating timers
        // rests in the timer class, not in the runloop
        for(timer in [[[_timerSources objectForKey: mode] copy] autorelease])
            if([[timer fireDate] timeIntervalSinceNow] <= 0)
                [timer fire];
        
        // see if we timed out, if so, abort!
        // this is checked at the end to ensure that timers and inputs are
        // always processed at least once before returning
        if([limitDate timeIntervalSinceNow] < 0)
            break;
    }
    return YES;
}
  1. runMode:UntilDate: 由线程调用,主线程默认启用,子线程需要自己调用启动 2. runMode:UntilDate: 方法内部主体是一个 while 循环
  2. 如果没有事件源或者 timer,不会进入 while 循环
  3. 通过 select 系统接口观察事件源回调,同时通过 timer 的时间决定 select 最多等待多久
  4. 如果有事件源输入,就通过 fileDescriptorIsReady 通知外部处理事件,并且退出 runMode:UntilDate: 循环
  5. 如果 timer 先到时间,就触发 timer,并且继续在 runMode:UntilDate: 内部执行循环。
  6. 注意 timer fire 后不会跳出循环,循环会一直执行,直到有事件源事件触发、或者设定的时间结束。
  7. 事件源事件触发、或者设定时间结束,跳出这个方法。

上述就是 NSRunLoop 的核心机制。

这里的伪代码还忽略了 run loop mode,通过 run loop mode 我们可以指定观察某些事件源而忽略某些事件源,在上述伪代码实现中,我们很容易拓展对某些事件源进行忽略的支持。

实战案例

实际工作中,我印象中有两个关于 NSRunLoop 印象比较深刻的例子。

第一个例子是在开发 Mac 程序的时候,发现拖拽过程中,主线程的某个方法始终没有被调用,从而按钮状态异常。最后发现是由于拖拽时主线程被设为 NSEventTrackingRunLoopMode,而performSelector:withObject是在 NSDefaultRunLoopMode 执行的,导致拖拽鼠标时这个方法始终未被触发。解决办法是使用 performSelector:withObject:afterDelay:inModes: 并将 NSEventTrackingRunLoopMode 加入其中。

另一个案例是为了优化程序启动速度,我们通过 CFRunLoopObserver 观察主线程是否处于空闲状态,只有主线程处于空闲状态的时候才执行某些低优先级的后台任务,从而避免 cpu 和内存资源被占用导致卡顿或者掉帧。

相信通过本文,你对 NSRunLoop 的运行原理以及代码实现有了更加清晰的了解。如果想全面了解 NSRunLoop,线上已经有不少优质的学习资源,推荐阅读后附链接文章。

(全文完)


推荐阅读

Blog:Friday Q&A: 一步一步用伪代码实现 NSRunLoop NSRunLoop 的源码实现: GNUstep 的 NSRunLoop 源代码 Blog:谜一样的 Runloop: 一篇详细的总结 Threading Programming Guide: Run Loops: 苹果官方教程

Posted 2018-04-28

More writing at jakehao.com