ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RunLoop底层原理与实战:从事件循环到线程保活和卡顿优化

RunLoop底层原理与实战:从事件循环到线程保活和卡顿优化 面试官如果问你“RunLoop底层原理”多数人第一反应是“啊不就是事件循环吗”然后就没然后了。我见过不少简历上写着“熟悉RunLoop”的候选人真到了白板环节能把Source0和Source1讲清楚的十个里面不超过三个。其实RunLoop是面试题里性价比极高的一块它不冷门、有源码可抄、又能串起线程、内存、性能、事件响应一整条链路。今天这篇就把RunLoop从数据结构到运行流程、从高频考题到实战优化完整拆一遍你要是认真看完下次面试再被问到不慌。1. RunLoop是什么为什么面试官总爱从它开始1.1 大厂面试官到底想从这道题里看什么我刚开始准备面试的时候把RunLoop理解成“一个不让线程退出的while循环”然后背了一堆概念结果面试官追问第一个问题就把我问住了那它和普通的while(1)有什么区别后来我才想明白面试官问RunLoop不只是想看你会不会背概念而是想通过这道题考察四层东西你有没有真正看过源码还是只刷了面经你懂不懂线程保活、事件响应、定时器这些日常开发里的底层机制你有没有处理过因为RunLoop导致的线上问题比如滑动卡顿、定时器失效、内存泄漏你能不能把一个抽象概念讲清楚同时聊出优化思路。换句话说RunLoop是一把钥匙它能打开iOS开发中非常多实际场景的门。如果你能把RunLoop讲透等于告诉面试官我对iOS的底层运行机制是有真实理解能力的。1.2 用一个餐厅等位的例子理解RunLoop的“转圈”RunLoop的官方名称叫“运行循环”我第一次接触的时候总觉得玄乎后来跟一个朋友用餐厅等位来类比一下子就通了。假设你是个火锅店门口的叫号员你的一天就是RunLoop有客人来取号的时候你处理取号任务有客人来问等位情况的时候你回答他没有客人的时候你不会一直站着喊“有没有人”而是坐下来休息但只要有人按了取号机的铃一个唤醒信号你就会立刻醒过来工作。线程也是一样。线程不是越忙越好也不应该一直在死循环里空转那样会一直占着CPU耗电、发热、卡顿。RunLoop做的事情就是在有任务的时候处理任务没有任务的时候让线程进入休眠状态等待系统内核来唤醒它。这就是事件循环的全部奥义。1.3 RunLoop考题能串起哪些iOS核心知识RunLoop之所以让很多面试者又爱又恨是因为它的“辐射面”特别广。聊到定时器可以引出NSTimer为什么在滑动时失效聊到线程可以引出子线程为什么默认没有RunLoop、线程保活怎么做聊到内存可以引出自动释放池什么时候释放聊到事件可以引出UI事件是怎么从点击屏幕一路传递到你的点击回调的。所以面试官只要把RunLoop当作引子就能顺藤摸瓜考察你一整条知识链。这也是为什么我建议所有准备iOS面试的人都把RunLoop作为重点复习对象——它不只是“背一道题”而是帮我们把整个iOS运行时机制重新梳理一遍。2. RunLoop的底层数据结构从NSRunLoop到CFRunLoop2.1 NSRunLoop和CFRunLoop的关系面试最容易翻车很多人会把NSRunLoop和CFRunLoop搞混面试时被问到“你说说NSRunLoop的底层结构”就开始含糊。其实两者是“封装”和“被封装”的关系NSRunLoop是Foundation框架里给OC开发者用的对象内部封装了CFRunLoopRefCFRunLoopRef是一套基于C语言的CoreFoundation接口是真正干活的那个平时我们开的NSRunLoop本质上就是对CFRunLoop的一层漂亮外衣。需要注意的一个大坑NSRunLoop不是线程安全的只能在当前线程上下文中调用。CFRunLoopRef虽然是C指针也不是所有操作都能跨线程执行。面试里如果要提到可以说“NSRunLoop是对CFRunLoop的OC封装底层核心是CFRunLoopRef两者在大部分场景下可以互相桥接但NSRunLoop不是线程安全的”。2.2 五个核心对象把RunLoop的地基打牢RunLoop底层有五个重要角色我建议你用下面这张表去记忆角色作用面试高频点CFRunLoopRef一个运行循环对象内部包含多个Mode线程和RunLoop一一对应CFRunLoopModeRef一种运行模式决定当前循环处理哪些事件Mode切换会导致定时器暂停CFRunLoopSourceRef事件源分Source0和Source1Source0需要手动处理Source1由内核唤醒CFRunLoopTimerRef定时器源底层是dispatch_source_t之类的定时器NSTimer往RunLoop里加CFRunLoopObserverRef观察者监听RunLoop各种状态卡顿监控、自动释放池释放时机都靠它这里面最关键的是CFRunLoopModeRef。一个RunLoop至少有两种ModekCFRunLoopDefaultMode默认模式和UITrackingRunLoopMode滚动跟踪模式。如果某个Timer只被添加到了DefaultMode那么当用户滑动ScrollView时RunLoop会切到TrackingMode这个Timer就不会被回调这就是“定时器在滑动时失效”的根本原因。2.3 Mode不是“并发”而是“切换”我见过不少人把Mode理解成RunLoop能同时处理多个模式的事件这是错的。RunLoop在任意一次循环里只能运行在一种Mode下它在一个时间段内只处理当前Mode下面注册的Source、Timer、Observer。这就相当于你现在人在餐厅你只能接待堂食的客人如果这时候外卖平台的订单来了你得先切换到外卖模式才能接单等处理完外卖单再切回堂食模式。切换模式不是并发执行而是“切换场景”。2.4 一个线程对应一个RunLoop这句话的完整含义每条线程都有一个唯一的RunLoop对象与之绑定主线程的RunLoop会由系统创建并默认启动而子线程的RunLoop默认是不存在的。只有当你在子线程里调用[NSRunLoop currentRunLoop]系统才会懒加载创建这个RunLoop。面试追问经常是为什么主线程能一直活着子线程执行完任务就退出答案就是主线程启动时创建并运行了RunLoop而子线程如果没有创建自己的RunLoop任务执行完线程就直接销毁了。注意RunLoop不能手动创建只能通过CFRunLoopGetMain()或CFRunLoopGetCurrent()获取。底层维护的是一个线程到RunLoop的哈希映射字典的key是线程指针value是RunLoop对象。3. 一次RunLoop循环源码层面到底发生了什么3.1 用伪代码拆开RunLoop的八个核心步骤看源码之前先看一段简化版的CFRunLoop运行逻辑真正的实现比这个复杂得多但主干是这个意思// CFRunLoop.c 简化版本 void CFRunLoopRun() { do { // 1. 通知Observer即将进入RunLoop __CFRunLoopDoObservers(KCFRunLoopEntry); // 2. 通知Observer即将处理Timer和Source0 __CFRunLoopDoObservers(KCFRunLoopBeforeTimers); // 3. 处理所有绑定在当前Mode上的Timer __CFRunLoopDoTimers(); // 4. 处理所有非基于端口的Source0事件 __CFRunLoopDoSources0(); // 5. 如果有Source1被内核唤醒跳去处理 __CFRunLoopDoSource1(); // 6. 通知Observer马上要进入休眠 __CFRunLoopDoObservers(KCFRunLoopBeforeWaiting); // 7. 调用mach_msg等待内核消息休眠 __CFRunLoopServiceMachPort(); // 8. 通知Observer被唤醒了 __CFRunLoopDoObservers(KCFRunLoopAfterWaiting); } while (keepRunning); }这八个步骤里最核心的其实就是两件事处理任务和睡觉。处理任务分为Timer、Source0、Source1三个入口睡觉则是通过内核的mach_msg机制来实现。3.2 Source0、Source1、Timer、Observer各自何时被触发很多人死记硬背“Source0是触摸事件Source1是端口事件”其实在底层源码里它们的划分是这样的Timer到了时间点就会被处理但前提是它所在Mode是当前Mode。NSTimer底层就是这个Timer源。Source0非基于端口的事件比如触摸事件的UIEvent它由系统内部先放入事件队列RunLoop在每次循环里去处理。注意Source0是需要手动调CFRunLoopSourceSignal来标记待处理并在合适时机被RunLoop处理的。Source1基于mach port的事件本质上是内核在偷偷“远程调用”你。比如一个网络包到了底层端口内核会通过Source1唤醒RunLoop让它来处理数据。Observer不负责具体业务只负责观察在某个状态变化时回调比如进入RunLoop、处理Timer前、即将休眠、被唤醒。3.3 从一次屏幕点击说起事件是怎么走完RunLoop的用一个最常见的场景来串联用户用手指点击了屏幕上的一个按钮。系统将这个物理点击交给SpringBoard进程再传递给当前AppApp进程的主线程RunLoop正处于mach_msg等待休眠内核通过Source1唤醒RunLoop并把这个点击事件封装成IOHIDEvent放入事件队列RunLoop回到AfterWaiting状态从队列里取出事件并交给Source0处理Source0处理时底层会把这个事件转成UIEvent再分发到UIWindow最终触发按钮的点击方法处理完所有事件后RunLoop再次进入休眠。所以面试官问“你点击屏幕后事件是怎么到你的action里的”你就可以用这条链路讲Source1唤醒、Source0处理、UIEvent分发。能讲到这里哪怕后面细节淡了前面这道题已经拿到印象分了。3.4 休眠与唤醒的本质mach_msg和内核端口RunLoop被设计得这么“省电”关键就在于休眠阶段是真正挂起线程的不是空转。这一步靠的是mach_msg。mach_msg是一个内核级别的消息发送/接收机制RunLoop会通过它向内核申请如果有消息到达我的端口再把我唤醒。这就好比你在电话旁边睡觉别人打你电话你才醒没人打电话你就一直睡。线程休眠期间不占用CPU资源等唤醒信号来了才回去处理事件。实操心得如果你做过iOS性能优化应该知道卡顿监控里经常会用到“主线程RunLoop阻塞时间”。它的原理就是注册一个Observer监听BeforeWaiting和AfterWaiting两个状态然后计算这两个状态的时间差。如果时间差超过阈值说明主线程在执行某段耗时代码卡了一顿没进入休眠这就是我们常说的“主线程卡顿”。4. 面试高频题逐题拆解不是背答案而是讲原理4.1 NSTimer在滑动页面时为什么停了怎么解决这道题几乎是RunLoop面试的必考题。现象大家都有体感当你快速滑动UITableView时页面上本来在跑的NSTimer好像停住了等滑动结束后又继续走。原因就是我前面提到的Mode切换。默认情况下NSTimer被添加到当前RunLoop的NSDefaultRunLoopMode里。当用户滑动ScrollView时主线程RunLoop会切换到UITrackingRunLoopMode在UITrackingMode下DefaultMode里的Timer不会被处理所以看起来像暂停了。解决方案我推荐三个按场景选使用[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]把Timer加到CommonModes里使用dispatch_source_t创建GCD定时器它不依赖RunLoop不受Mode切换影响如果是在主线程滚动过程中需要更新的UI优先用CADisplayLinkNSRunLoopCommonModes保证刷新和屏幕同步。一段典型代码NSTimer *timer [NSTimer timerWithTimeInterval:1.0 target:self selector:selector(doSomething) userInfo:nil repeats:YES]; [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];注意NSRunLoopCommonModes并不是一个真正的Mode它是一个模式集合代表一组可切换的Mode比如DefaultMode和TrackingMode都会被标记为common。所以把Timer加进去之后无论RunLoop切到Default还是TrackingTimer都能被处理。4.2 为什么在子线程调用performSelector:afterDelay:经常不执行很多新手在子线程里这样写dispatch_async(dispatch_get_global_queue(0, 0), ^{ [self performSelector:selector(task) afterDelay:2.0]; });然后发现task方法迟迟不执行甚至根本没执行。原因是performSelector:afterDelay:底层需要把Selector包装成一个Timer加到当前线程RunLoop的DefaultMode里但子线程默认没有运行RunLoop自然没人去调度这个Timer。解决办法有两个方向在子线程里手动启动RunLoop让线程不退出、能处理Timer改用GCD的dispatch_after它不依赖RunLoop。dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self task]; });面试时如果只答“因为子线程没有RunLoop”只能算及格。最好补一句“performSelector:afterDelay:本质上是在当前RunLoop注册一个Timer子线程默认没有创建RunLoop所以没有执行者。解决办法要么手动给子线程开RunLoop要么用GCD定时器。”4.3 RunLoop和GCD是什么关系dispatch_get_main_queue()为什么不依赖RunLoop这道题容易让人懵。我第一次听到的时候也愣了一下GCD和RunLoop不是同一个东西吗其实不是。RunLoop是线程内部的事件循环机制解决的是“线程什么时候处理事件、没事干时怎么休眠”的问题GCD是系统级的线程池调度框架解决的是“任务应该放到哪个线程、以什么优先级执行”的问题。dispatch_get_main_queue()本身不属于RunLoop管理它只是一个串行队列。App刚启动时系统会为主线程创建一个RunLoop并让它运行起来同时主线程还会去执行主队列里的block。换句话说主线程既在跑RunLoop也在从主队列里取任务执行。面试官比较喜欢问“RunLoop和GCD是不是互斥的”你可以回答它们各司其职但在实际跑起来的时候会协作。往主线程派发任务时GCD的任务最终也会在某个时机被主线程的RunLoop调度机制“带起来”执行只是GCD不依赖RunLoop来存续。4.4 AutoreleasePool的释放时机和RunLoop有什么关系这个问题在面试里出现的频率也很高。ARC虽然帮我们管理好了大部分内存但AutoreleasePool在什么时候销毁底层还是和RunLoop的Observer强相关。App启动后主线程的RunLoop里面注册了两个Observer一个Observer在kCFRunLoopEntry的时候创建一个自动释放池另一个Observer在kCFRunLoopBeforeWaiting即将休眠时把当前自动释放池释放并且重新创建一个新的自动释放池。这正好对应了“autorelease对象是在每次RunLoop循环进入休眠前被释放”的说法。所以当你写一个循环里创建大量临时变量时如果循环体里没有自己的autoreleasepool这些对象可能会一直累积到RunLoop休眠前才释放导致内存峰值变高。面试加分点可以说“如果想降低局部内存峰值可以在循环体内部手动加autoreleasepool让临时对象在一个小范围内及时释放而不是等RunLoop统一释放”。4.5 子线程保活为什么RunLoop能帮线程续命很多业务场景需要一个常驻后台的线程比如地图定位回调、音频后台播放如果直接开一个NSThread执行完就退出数据就丢了。保活的关键就是给这个子线程添加一个RunLoop并且让RunLoop一直转起来。最常见的做法是给RunLoop加一个NSPort- (void)startThread { NSThread *thread [[NSThread alloc] initWithTarget:self selector:selector(runLoopEntry) object:nil]; [thread start]; } - (void)runLoopEntry { autoreleasepool { NSRunLoop *runLoop [NSRunLoop currentRunLoop]; [runLoop addPort:[NSPort port] forMode:NSDefaultRunLoopMode]; [runLoop run]; // 这条是阻塞的线程不会退出 } }但这里有个坑如果把[runLoop run]放在主线程会死循环卡住所以一般只在子线程里用。另外[runLoop run]内部会不断运行当前RunLoop除非调用CFRunLoopStop或者线程被销毁。需要在合适时机停止时可以用CFRunLoopStop(CFRunLoopGetCurrent())来退出。4.6 CommonModes到底是什么怎么用才不会用错我一度以为CommonModes是一个“百搭Mode”往里加什么东西都能在所有场景生效。理解到一定程度才发现CommonModes更像是一种“白名单机制”。实操中我们经常需要把某个事件源加入到多个Mode里。比如MainRunLoop的kCFRunLoopDefaultMode和UITrackingRunLoopMode都在CommonModes集合里那你把Timer加到NSRunLoopCommonModes就等于让它在这几种模式切换时都能存活。经验之谈面试时如果被问“CommonModes自定义可以吗”可以提到底层通过CFRunLoopAddCommonMode往模式的CommonMode集合里加一个新的Mode名。但要提醒的是系统对常见Mode有特殊处理随便自定义Mode很坑因为你还得保证有事件源会在这个Mode下运行否则RunLoop可能空转。5. 实战RunLoop在项目里的三个高频应用5.1 线程保活NSPort方案和RunLoopBlock方案的取舍前面说了NSPort保活的基本写法但在真实项目里我更喜欢在子线程里用CFRunLoopRun()配合一个自定义的source或timer来保活因为NSPort方案偶尔会引出“port被释放”的问题。下面是我常用的一个带任务队列的线程保活封装思路interface MyThread : NSThread end implementation MyThread - (void)main { autoreleasepool { NSRunLoop *runLoop [NSRunLoop currentRunLoop]; [runLoop addPort:[NSPort port] forMode:NSDefaultRunLoopMode]; while (!self.isCancelled) { autoreleasepool { [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]; } } } } end这里的runMode:beforeDate:会阻塞当前线程直到有事件处理或超时。每次循环都开启一次RunLoop比直接[runLoop run]更好控制线程退出。5.2 用CFRunLoopObserver监控主线程卡顿这是面试里能直接展示你做过性能优化的点。原理非常简单主线程如果很流畅RunLoop会频繁经历BeforeWaiting和AfterWaiting之间的切换如果某一段代码执行时间过长这个间隔就会拉长。我给团队写过一个简化版卡顿监控static void runLoopObserverCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) { // 记录时间戳 static double lastTimestamp 0; if (activity kCFRunLoopBeforeSources) { lastTimestamp CFAbsoluteTimeGetCurrent(); } else if (activity kCFRunLoopBeforeWaiting) { double interval CFAbsoluteTimeGetCurrent() - lastTimestamp; if (interval 0.1) { NSLog(主线程可能卡顿耗时: %f, interval); } } }通过这些状态之间的耗时我们就能粗略定位到是哪段业务逻辑阻塞了主线程。注意这个方案只能定位“卡的时间点”不能精确到某一行代码要精确定位还要配合Profile或者更复杂的符号化工具但在面试里讲出这个思路已经很加分。5.3 利用RunLoop的空闲时机做预加载RunLoop还有一个很实用的玩法在空闲时处理低优先级任务。比如列表页展示时想在页面加载完成后再去做数据预解析、缓存清理等操作就可以把任务注册到观察者回调里或者在主线程RunLoop进入到BeforeWaiting时执行。伪代码思路- (void)scheduleIdleTask:(dispatch_block_t)task { CFRunLoopObserverRef observer CFRunLoopObserverCreateWithHandler( kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { task(); CFRunLoopRemoveObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes); }); CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes); }注意这里为了避免任务还被反复执行执行后要立刻移除Observer。用这个思路可以做一些“不敏感的预加载”既利用了主线程的空闲时间又不会拖垮页面首屏渲染。6. 避坑指南源码里没写的那些细节6.1 我踩过的三个真实RunLoop坑第一个坑是NSTimer的循环引用问题。很多人知道NSTimer加了target之后如果timer在RunLoop里不被停止timer会强持有target导致target无法释放。所以我一般建议如果使用timerWithTimeInterval:target:的写法一定要在合适时机调用invalidate。更好的办法是用block方式的timer从底层避免循环持有。第二个坑是子线程RunLoop“明明加了Timer却不触发”。这种情况十有八九是子线程跑完任务就结束了RunLoop还没真正开始运行或者你加Timer的时候RunLoop还没创建。我习惯的写法是先在子线程里获取[NSRunLoop currentRunLoop]再加入Port/Timer再启动RunLoop顺序不能反。第三个坑是使用runMode:beforeDate:后没有判断返回结果导致线程空转或提前退出。runMode:beforeDate:返回NO时代表RunLoop没有处理任何事件你要决定是否继续循环。处理不好线程保活逻辑就会出bug。6.2 怎么把RunLoop的答案讲得让面试官眼前一亮很多人的回答像是背课文“RunLoop是一种消息循环...”其实面试官每天听无数遍记不住。我建议采用“三层递进”的讲法第一层先说本质RunLoop是一个让线程在有事时做事、没事时休眠的机制核心目的是省资源、保证线程不退出。第二层讲底层结构NSRunLoop是CFRunLoop的OC封装核心数据结构是RunLoop、Mode、Source、Timer、ObserverMode决定当前循环处理哪些事件。到这里已经可以吊打一半候选人。第三层讲应用场景你要主动抛案例比如“NSTimer在滚动时失效是因为Mode切换”“主线程自动释放池是在BeforeWaiting时释放”“卡顿监控利用Observer观察两次状态之间的耗时”。面试官听到你把一个理论问题和真实业务场景串起来通常都会觉得你不仅懂原理还会用。这是最实用的面试技巧。6.3 如果你还想再深一层可以聊聊RunLoop和线程安全的边界有不少面试官会继续追问“RunLoop这么强能随便在子线程里创建并使用吗”这个问题其实是在考察你对线程安全的理解。CFRunLoop本身维护的是线程和RunLoop的一一对应关系你获取当前RunLoop时实际是从线程私有数据里取的所以你在哪个线程获取拿到的就是那个线程的RunLoop。但是你如果把主线程RunLoop拿给子线程去操作比如在子线程里CFRunLoopAddTimer(mainRunLoop, timer, mode)虽然不算直接崩溃但涉及多线程并发修改是很危险的操作。这个话题还能串到iOS开发里的“同步异步、串行并行”比如GCD串行队列和RunLoop的线程模型是两套体系。面试时如果能主动把“RunLoop是线程运行时的循环GCD是线程调度工具”这个区分讲清楚已经是很高的水平了。6.4 这些场景用得多了RunLoop会变成你的肌肉记忆我后来再看RunLoop再也不觉得它是个考试题了因为它每天就在你的App里跑着。主线程的UI事件、定时器、弱网请求的回调、KVO触发、DispatchOnce这些都会和RunLoop发生关系。如果你做iOS开发有段时间了建议不要只停在面试层面可以自己写几个小Demo一个是用RunLoop监听主线程卡顿的控件一个是在子线程里跑一个循环队列的worker一个是把Timer加到CommonModes后观察滑动页面它是否会跳。多写几次你对这个机制的理解就像骑自行车一样自然了。RunLoop这块内容说实话不难难的是很多人只背概念、不看场景。希望这篇整理能帮你在面试前把思路理清也帮你在实际开发里少走点弯路。如果你正准备去面大厂遇到RunLoop题就按“本质-结构-场景-优化”这个顺序讲下来大概率不会冷场。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进