关于RunLoop的那些事

前言

简书博客开篇,写些什么呢?想了好久,脑海中跳出了许多主题,Runtime、RunLoop、Swift、内存管理、性能优化、多线程、架构设计模式等,这些主题都很有意思也很有意义,想想都有一种心跳的感觉。如果有精力,真心想所有主题都写一篇。鲁迅说,一口吃不成大胖子(鲁迅:我没说过),所以还是现实一些,一篇一篇来吧。那么就先从RunLoop入手吧。这哥们挺有意思,好久前就想研究了。

切入点

RunLoop是什么呢?为了严谨起见,防止主观跑偏,我们非常有必要先看看苹果是怎么阐释他的:

A NSRunLoop object processes input for sources such as mouse and keyboard events from the window system, NSPort objects, and NSConnection objects. A NSRunLoop object also processes NSTimer events.

这段定义,不知道你看了什么感受,反正我第一次读他是有些云里雾里。苹果说他可以处理输入源,这些输入源可以是鼠标、键盘的输入事件,NSPort对象,NSConnection对象,也可以处理NSTimer事件。一片沉思,最怕空气突然安静...由于在实际开发中很少用到,所以研究起来好像一时想不出什么头绪。不要退缩,这里有个好消息,那就是苹果的RunLoop相关的代码是开源的,准确来说,是CFRunLoopRef的代码是开源的(CoreFoundation源码地址)。而NSRunLoop是对后者的一种封装,就如同NSString之于CFStringRef,NSData之于CFDataRef一般。所以我们直接研究CFRunLoopRef就行,看看他到底是个什么Boy。

CFRunLoopRef源码探究

我们下载好源码后(下载地址见上小节)打开工程文件,找到CFRunLoop文件,可以看到六千多行C代码,眼花缭乱,不知从哪个函数看起。还好我们从苹果那里获取了灵感:

Each NSThread object—including the application’s main thread—has an NSRunLoop object automatically created for it as needed.

他说每个线程包括主线程都有一个RunLoop对象,是在需要的时候系统自动创建的。我们由此推测,按照苹果说法,主线程的RunLoop会被自动创建,那么我们在程序启动时打断点看一下函数调用栈,肯定能看出什么端倪。果然,不出所料,CFRunLoop字眼赫然出现在堆栈信息中。由于最先调用的函数会被压在栈底,所以我们从CFRunLoopRunSpecific函数入手。

FuncStackInfo

CFRunLoopRunSpecific函数

这个函数,相当于在RunLoop的进入和退出流程做个切片,通知外部监听者。关于监听者,我们在后面会说。然后调用了核心函数__CFRunLoopRun。

SInt32 CFRunLoopRunSpecific(CFRunLoopRef rl, CFStringRef modeName, CFTimeInterval seconds, Boolean returnAfterSourceHandled) {     /* DOES CALLOUT */    // ...   
    int32_t result = kCFRunLoopRunFinished;   
    // 进入RunLoop的时候通知监听者
    if (currentMode->_observerMask & kCFRunLoopEntry ) __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopEntry);   
    // 调用__CFRunLoopRun, 进入RunLoop的核心逻辑
     result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode);   
    // 退出RunLoop的时候通知监听者
     if (currentMode->_observerMask & kCFRunLoopExit ) __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);   
    // ...   
    return result;
}

__CFRunLoopRun函数

这个函数比较复杂,我也不想通篇文章都是代码(PS:简书的代码渲染UI好差,丑到爆,对程序员太不友好了8),下面我只摘取一些核心部分来说明一下问题,在代码下面会有文字说明。

static int32_t __CFRunLoopRun(CFRunLoopRef rl, CFRunLoopModeRef rlm, CFTimeInterval seconds, Boolean stopAfterHandle, CFRunLoopModeRef previousMode) {
// ...
do {
if (rlm->_observerMask & kCFRunLoopBeforeTimers) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers);       
if (rlm->_observerMask & kCFRunLoopBeforeSources) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources);
 __CFRunLoopDoBlocks(rl, rlm);
Boolean sourceHandledThisLoop = __CFRunLoopDoSources0(rl, rlm, stopAfterHandle);       
    if (sourceHandledThisLoop) {           
        __CFRunLoopDoBlocks(rl, rlm);
 }
if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0, &voucherState, NULL)) {               
goto handle_msg;           
}
if (!poll && (rlm->_observerMask & kCFRunLoopBeforeWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting);

__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy);

if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting);

handle_msg:;       
if (MACH_PORT_NULL == livePort) {            CFRUNLOOP_WAKEUP_FOR_NOTHING();                     
}
else if (rlm->_timerPort != MACH_PORT_NULL && livePort == rlm->_timerPort) {            CFRUNLOOP_WAKEUP_FOR_TIMER();       
}       
else if (livePort == dispatchPort) {           
CFRUNLOOP_WAKEUP_FOR_DISPATCH();       
}
else {           
CFRUNLOOP_WAKEUP_FOR_SOURCE();       

} while (0 == retVal);
//...
}

容我再吐槽一句,没想到简书对代码的支持那么差劲,简直了。好吧后面我能不粘代码就不粘代码了。(2016年补充:原来是我当时没找到MarkDown编辑器,为了更好补救代码体验,现截图成图片方便阅读)

__CFRunLoopRun

下面对__CFRunLoopRun这个函数的核心流程讲解一下:

1. 开启一个do-while循环来保活线程,下面的2-5步都是在这个循环里做的。

__CFRunLoopRun

2. RunLoop通知监听者:RunLoop即将处理timers回调、sources回调,紧接着处理了blocks的回调(blocks可以通过CFRunLoopPerformBlock添加回调事件)处理完sources0后处理了一遍Blocks。

__CFRunLoopRun

3. 在上一步你可能有疑问,那sources1是什么时候处理的呢?其实就在紧接着下面调用的。我们知道sources1事件源是基于machPort的。(注意,在这里你可能不太明白sources0或sources1,后面小节在讲RunLoop的构造的时候会重新解释一下。)这里我还想强调一下,代码中的goto handle_msg这句代码,这里的msg就是mach_msg,系统是通过mach_msg来完成内核态和用户态的相互切换的。这里要先有个印象,后面会不断提及这个概念,慢慢强化这点。

__CFRunLoopRun

4. 经过上面步骤,在本次RunLoop周期中处理了sources、blocks和timers(注意timer比较特殊,由于是定时源,所以不是马上处理的,当时间到了的时候通过mach_msg由内核态切换到用户态,然后再处理timer回调的,下面的handle_msg会详细说这点。)处理完这些事件之后,这就到了最关键的时候,如果你是系统设计者,这时候一定会思考,既然已经处理完了用户的操作,也处理完了定时任务,总不能让CPU一直这样空转着继续消耗资源吧。是的,苹果工程师也想到了这点,解决方案就是让线程进入休眠状态,在事件源、输入源到来的时候,再通过mach_msg让线程醒过来去完成这些新任务。

我们来看代码:先通过监听者即将进入休眠状态,然后执行__CFRunLoopServiceMachPort(waitSet)将线程休眠。上面已经多次提及,当处理完输入源、定时源事件的时候,系统会通过mach_msg将线程切换到内核态,在内核态中可以执行一些高级底层命令将线程休眠从而达到节省资源的目的。

__CFRunLoopRun

5. 那么问题来了,线程进入内核态的休眠状态的时候,如果这时候有了用户的新的操作或者定时器触发的时候,怎么去唤醒线程呢?
答案你或许能猜到了,就是通过mach_msg来完成的。mach_msg就像是用户态与内核态之间相互通信的桥梁。哪些事件能够唤醒线程呢?带着问题,我们继续读源码:

首先通知监听者,即将从休眠状态醒过来:

__CFRunLoopRun

然后就处理mach_msg信息,我们从处理消息的类型,就可以知道,哪些事件能够唤醒线程。
从代码中我们不难看出,有三种方式唤醒线程,分别是Timer事件、GCD事件、Source1事件。source1事件一般都是用户操作派发的事件,如点击了屏幕,会产生一个machPort,这个machPort就会转化为source1事件从而唤醒线程。

__CFRunLoopRun

6. 代码进行到这里,RunLoop如何处理事件,处理完事件后切换到内核态节省资源,如何通过mach_msg唤醒线程切换到用户态处理事情这些知识已经分析完了,这时候大家可能会问,“你刚刚不是说这些代码都是在while循环里面吗,那这个while循环什么时候退出呢?”。回答这个问题,我们得先看下while判断条件:retVal == 0。通过源码我们可以得知,正常状态下RunLoop是不会退出的。
但是,如果RunLoop中没有添加任何timers、sources、observers,那么这个RunLoop就会直接退出。而主线程的RunLoop默认已经添加了sources、observers和timers。你可以在主线程打个断点po一下[NSRunLoop currentLoop]来证实这点。所以,主线程的RunLoop在正常状态下是不会退出的。当然,把应用kill掉,RunLoop就随着应用进程的killed而退出。这时候你可能马上追问,子线程的RunLoop也像主线程的一样不会退出吗?这里先留个伏笔,后面讲到常驻线程的时候会给出答案。:)

__CFRunLoopRun
__CFRunLoopRun

RunLoop的数据结构

我们在上面,以官方文档为切入点一步一步往下探索,最后通过源码分析了RunLoop的执行逻辑,了解了RunLoop是怎么处理事件源与定时源的,处理完之后又通过mach_msg进入内核态休眠线程来节省系统资源,在休眠过程中系统可以通过mach_msg切换到用户态将线程唤醒来继续处理事情。
但是你可能会说,道理我现在懂了,但是总觉得对RunLoop的理解还是不够彻底。没错,那是因为没有去了解RunLoop的数据结构,认识一件事物的本质肯定得从事物的本身入手,否则也只是游离在本质的边缘进行黑盒猜测而已。下面我们就看下数据结构(我微调了下顺序,重点的属性都排在前面了):

__CFRunLoop结构
__CFRunLoopMode结构

从上面的结构中我们不得得出结论,CFRunLoop、CFRunLoopMode、CFRunLoopObserver、CFRunLoopTimer、CFRunLoopSource的关系如下图:

关系图

我们可以用RunLoop做什么?

前面小节中,我们一起了解了RunLoop的运行机制、结构组成等,收货颇多。但是故事到这里,就结束了吗?正如鲁迅教导我们的,“不以结婚为目的的恋爱都是耍流氓。”(鲁迅:我好累,摊~)开个玩笑,不落实到项目实践中的技术都是耍流氓。正如架构设计一样,不管看起来多牛X,什么高并发、高可用、高性能,脱离了业务实际,那就算不上是优秀的设计方案。
那么,我们可以用RunLoop解决什么问题呢?下面我们一起探究下一些好玩的事情。

1. 子线程中performSelector:after:,能执行这个SEL吗:

PerformSelctor

先自己思考一会。下面我来公布答案:不能执行。正如截图代码所示:test没有被调用。
其实你可能已经说对答案了,如果对我前面说到的知识有认真看的话。
调用performSelctor:after:,系统会在内部创建一个NSTimer事件,转换为CFRunLoopTimer定时源的方式添加到当前线程的RunLoop中。关键点就在于,子线程的RunLoop在默认状态下是没有被创建的,需要调用currentRunLoop来懒创建出RunLoop。并且调用Run方法将RunLoop跑起来。这里有个坑点,看下图,改成这样,你觉得会调用test方法吗?

PerformSelctor

答案就是,不会调用。至于原因,相信你只要认真看上面源码分析那个小节了就应该能明白。我再简单说明一下原因,调用run的时候由于没有侦测到任何timers、observer、sources事件,RunLoop循环会直接退出,后面添加的Timer也就无法被调用了。解决方案就是把run方法放到performSelector:after后面。

2. 如何实现一个常驻线程?

这个需求可能并不多见,毕竟常驻线程会消耗更多系统资源。AFNetworking 2.0 在内部就创建了一个常驻线程来接收NSURLConnectionDelegate回调方法。在这里我想稍稍解释一下,AFN 2.0在内部创建一个常驻线程其实是无奈之举,因为NSURLConnection发起请求后,所在线程需要一直存活以来接收Delegate方法回调。

那么,我们该如何实现一个常驻线程呢?我先梳理下需求,点击屏幕的时候要求在指定子线程调用`bgTask`方法。

BgThread

如果仅仅上面这样写的话,baTask方法是无法被调用的。因为_thread的RunLoop都没有开启,子线程根本无法存活。常驻线程的思路其实很简单,抓住上面提到的要点,其实不难实现。首先,将RunLoop创建开启,然后添加事件如sources、timers、observers等都可以,目的是让RunLoop忙起来,否则会直接退出循环。最后就是调用run方法,让RunLoop跑起来,这样这个线程就可以常驻在内存中。但是有个注意的点,就是要给常驻线程提供退出的机会。

常驻线程完整代码

有两个注意点,stopThread的时候需要调用CFRunLoopStop方法结束本次的loop循环。第二个注意点,while循环中如果写成`[[NSRunLoop currentRunLoop] run]`则不会退出。因为run方法相当于无限次调用runMode:beforDate:,根本都不给while控制条件机会退出。在苹果官方文档已给出解释:

run func doc

后记

文章到这里,关于RunLoop的那些事,基本上介绍的差不多了,剩下的如常见的Timer事件在多个Mode同步之类的,根据文中分析的运行机制和RunLoop数据结构,可以举一反三,很好理解。当然,借助RunLoop,我们除了上面提到的,还可以做一些其他酷炫的事情,如通过监听RunLoop状态来检测卡顿、根据RunLoop的Mode来处理耗时操作从而优化用户交互体验等等事情。

One more thing,感谢苹果大大开源的CF源码

【转载请标明出处,蟹蟹】

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容