1. 线程保活到底在保什么第一次接触 RunLoop 线程保活这个概念是在做一个需要长期驻留后台的下载任务模块时。当时的需求很朴素App 退到后台之后已经排队的下载任务不能断得有个东西一直活着把任务跑完。我一开始想得很简单开个Threadstart一下里面写个while(true)不就行了结果实测下来线程确实活着但 CPU 占用直接飙到 100%手机烫得能煎鸡蛋电量肉眼可见地往下掉。这就是典型的“活着但没活对”。后来才明白线程保活的核心不是让线程“忙起来”而是让线程“闲下来但不退出”。这两者的区别就是 RunLoop 存在的意义。RunLoop 本质上是一个事件循环它让线程在没有事件处理的时候进入休眠状态有事件来了再被唤醒。这样线程既不会退出也不会空转烧 CPU。所以“RunLoop 之线程保活”这个主题讲的就是如何利用 RunLoop 的这个特性让一个线程长期驻留随时待命处理任务。这个技术点适合谁看如果你正在做需要常驻后台的任务调度、长连接维护、定时器驱动、或者需要在子线程上持续接收事件响应的场景那这套东西你绕不开。反过来如果你只是偶尔开个线程跑个耗时操作然后让它自然结束那完全不需要保活别给自己找麻烦。我见过不少项目明明是一次性的网络请求非要搞个常驻线程结果线程管理复杂度上去了收益却几乎为零。在展开之前先把几个关键概念对齐一下。RunLoop 和线程是一一对应的关系每个线程都有一个对应的 RunLoop 对象但只有当你主动去获取的时候它才会被创建。主线程的 RunLoop 是系统自动创建并启动的子线程的 RunLoop 默认不创建也不启动。所以线程保活这件事本质上是在子线程上手动创建 RunLoop、往里面添加事件源、然后让它跑起来并且确保它不会因为“没有事件源”而自动退出。这里有个很多人踩过的坑RunLoop 如果没有任何 source、timer 或 observer它会立刻返回线程也就随之结束。所以保活的关键动作是至少往 RunLoop 里塞一个能持续存在的事件源让它有理由一直跑下去。这个事件源的选择直接决定了保活方案的稳定性和资源消耗后面会详细拆。2. 为什么不用 while 循环和 sleep 硬扛2.1 忙等待的代价我前面提到的while(true)方案属于典型的忙等待。线程在循环里不停地检查条件、执行代码CPU 时间片被持续占用。哪怕你什么都不做这个循环也在消耗调度资源。有人会说那我加个sleep不就行了比如while(true) { sleep(1); }。这确实能降低 CPU 占用但问题在于你的任务响应会有延迟最坏情况下要等一个 sleep 周期才能被处理而且 sleep 的时间设置很尴尬设短了还是费电设长了响应迟钝。更关键的是这种方案没有事件驱动机制。你没法优雅地往线程里投递任务只能靠共享变量加锁去轮询代码写出来又丑又容易出并发问题。我之前维护过一个老项目就是用 sleep 轮询的方式做后台任务队列结果每次加新任务类型都要改轮询逻辑锁的粒度也控制不好时不时就死锁。后来重构的时候全部换成了 RunLoop 驱动代码量少了三分之一稳定性还上去了。2.2 RunLoop 的休眠机制RunLoop 的休眠是操作系统级别的。当 RunLoop 没有事件需要处理时它会通过 mach port 进入内核态的等待此时线程真正被挂起不占用 CPU 时间片。一旦有事件源被触发比如 timer 到点、source 收到消息、或者手动唤醒RunLoop 会被唤醒并处理事件处理完再回到休眠。这个机制的好处是线程“活着”的成本极低几乎不消耗资源但响应速度又很快。用一个生活化的类比while 循环像是你雇了个人站在门口一直来回踱步等快递人累不说还占地方RunLoop 像是你给门铃装了个感应器人该干嘛干嘛去门铃响了再过来开门。显然后者更合理。2.3 保活方案选型的核心考量选保活方案的时候我一般会看三个维度资源占用、响应延迟、实现复杂度。while 循环资源占用高、响应快但不可控、实现简单sleep 轮询资源占用中等、响应有延迟、实现中等RunLoop 方案资源占用低、响应快、实现稍复杂但可控。综合下来RunLoop 是唯一能在三个维度上都拿到及格以上分数的方案这也是它成为主流做法的原因。不过 RunLoop 方案也不是没有代价。它的复杂度主要体现在你需要理解 source、timer、observer 的区别需要处理 RunLoop 的启动和停止还需要考虑线程退出时的清理逻辑。如果这些没处理好很容易出现线程退不掉、内存泄漏、或者 RunLoop 空转的问题。这些坑我在后面会一个个拆开讲。3. 核心机制拆解RunLoop 靠什么撑住线程3.1 Source、Timer、Observer 三者的角色RunLoop 要跑起来不退出必须有东西“撑住”它。能撑住 RunLoop 的东西分三类Source、Timer、Observer。它们的作用机制不一样保活效果也不一样。Source 分为 Source0 和 Source1。Source0 是手动触发的比如performSelector:onThread:就是往目标线程的 RunLoop 里投递一个 Source0 事件。Source1 是基于 mach port 的能接收系统层面的事件。Timer 就是定时器到点触发。Observer 是观察者用来监听 RunLoop 的各种状态变化比如即将进入休眠、即将退出等。关键点来了只有 Source 和 Timer 能阻止 RunLoop 退出Observer 不行。因为 Observer 只是监听状态它本身不产生事件。如果你只往 RunLoop 里加 ObserverRunLoop 还是会因为没有事件源而立刻返回。这个细节很多人不知道我见过有人加了一堆 Observer 然后纳闷为什么线程还是退出了。3.2 空 Source 的保活原理在实际保活场景里最常用的做法是往 RunLoop 里添加一个空的 Source或者一个空的 mach port。所谓“空”是指这个 Source 不实际处理任何业务事件它的唯一作用就是让 RunLoop 有一个持续存在的事件源从而不会退出。业务任务则通过performSelector:onThread:这种方式投递进来走的是 Source0 的路径。为什么用空 Source 而不是 Timer因为 Timer 会周期性触发哪怕你不需要它触发它也会按时唤醒 RunLoop造成不必要的唤醒开销。而空 Source 不会主动触发只在有事件投递时才唤醒资源占用更低。当然如果你的场景本身就需要定时任务那用 Timer 顺便保活也是可以的但要注意 Timer 的精度和功耗问题。3.3 RunLoop 的生命周期与线程退出RunLoop 的生命周期和线程绑定。当 RunLoop 退出时线程通常也就结束了。RunLoop 退出的条件是没有事件源、没有 timer、且被显式停止。所以保活的反面操作就是移除所有事件源并调用停止方法让 RunLoop 退出线程自然结束。这里有个容易忽略的点RunLoop 不能在线程外部被停止。你只能在 RunLoop 所在的线程内部调用停止方法或者通过投递一个特殊事件让线程自己停止。我一开始做的时候试图在主线程直接停掉子线程的 RunLoop结果发现根本停不掉后来才明白必须切到目标线程去操作。这个设计其实是为了线程安全避免跨线程操作 RunLoop 导致状态不一致。4. 手把手实现一个可复用的保活线程4.1 整体结构设计我一般会把保活线程封装成一个独立的对象对外暴露启动、停止、投递任务三个接口。内部维护一个 Thread 实例和一个 RunLoop 的引用。启动时创建线程并启动 RunLoop停止时往线程里投递一个停止事件投递任务时用performSelector把任务转到目标线程执行。这样设计的好处是调用方不需要关心 RunLoop 的细节只需要像用普通任务队列一样使用它。同时线程的生命周期被封装在对象内部避免了散落在各处的线程管理代码。下面我把关键实现拆开讲。4.2 启动线程与 RunLoop 的初始化启动逻辑的核心是创建线程在线程的入口方法里获取当前线程的 RunLoop添加一个空 Source 或 mach port然后调用 run 方法让 RunLoop 跑起来。这里要注意RunLoop 的获取和启动必须在同一个线程内完成不能跨线程。- (void)start { if (self.thread) { return; } self.thread [[NSThread alloc] initWithTarget:self selector:selector(threadEntry) object:nil]; [self.thread start]; } - (void)threadEntry { autoreleasepool { self.runLoop [NSRunLoop currentRunLoop]; // 添加一个空 port撑住 RunLoop [self.runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; // 用一个标志位控制 RunLoop 是否继续跑 self.shouldKeepRunning YES; while (self.shouldKeepRunning) { [self.runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]; } } }这段代码里beforeDate传的是distantFuture意思是如果没有事件RunLoop 会一直休眠到有事件为止不会因为超时而空转。shouldKeepRunning标志位用来控制退出当需要停止时把它置为 NO 并唤醒 RunLoop循环就会结束。4.3 任务投递与线程间通信任务投递用的是performSelector:onThread:withObject:waitUntilDone:。这个方法会把 selector 包装成一个 Source0 事件投递到目标线程的 RunLoop 里。目标线程的 RunLoop 被唤醒后会在该线程上执行这个 selector。- (void)executeTask:(void (^)(void))task { if (!self.thread || !task) { return; } [self performSelector:selector(runTask:) onThread:self.thread withObject:task waitUntilDone:NO]; } - (void)runTask:(void (^)(void))task { task(); }这里waitUntilDone传 NO表示异步投递调用方不阻塞。如果需要同步等待任务完成可以传 YES但要注意避免死锁——如果调用方本身就在目标线程上传 YES 会直接死锁。我一般默认用异步需要同步的场景单独处理。4.4 停止线程与资源清理停止线程的关键是在目标线程上把shouldKeepRunning置为 NO然后唤醒 RunLoop让它从休眠中返回并检查标志位从而退出循环。同时要移除之前添加的 port避免资源泄漏。- (void)stop { if (!self.thread) { return; } [self performSelector:selector(stopInThread) onThread:self.thread withObject:nil waitUntilDone:YES]; self.thread nil; self.runLoop nil; } - (void)stopInThread { self.shouldKeepRunning NO; // 移除所有事件源让 RunLoop 有机会退出 [self.runLoop removePort:... forMode:NSDefaultRunLoopMode]; // 唤醒 RunLoop CFRunLoopStop([self.runLoop getCFRunLoop]); }这里用CFRunLoopStop来强制唤醒并停止 RunLoop比单纯依赖标志位更可靠。因为 RunLoop 可能正处于深度休眠标志位改了它也不知道必须显式唤醒。这个细节我在第一次实现时没注意导致停止操作有时候不生效排查了半天才发现是 RunLoop 没被唤醒。5. 实操中绕不开的那些坑5.1 线程退不掉的几种情况线程退不掉是最常见的问题。我总结下来主要有三个原因一是 RunLoop 里还有未移除的事件源导致循环条件判断后仍然继续跑二是停止操作没有在目标线程上执行标志位改了但 RunLoop 不知道三是 RunLoop 处于休眠状态没有被唤醒标志位改了但循环没机会检查。对应的解决办法停止时先移除所有事件源再置标志位最后显式唤醒 RunLoop。三步缺一不可。我现在的习惯是把这三步写成一个原子操作封装在停止方法里避免遗漏。5.2 内存泄漏与循环引用保活线程对象如果被外部强引用而线程内部又持有 self很容易形成循环引用。比如self.thread [[NSThread alloc] initWithTarget:self ...]线程对象持有 target也就是 self如果 self 又持有 thread就循环了。解决办法是让线程对象不要强持有 target或者在停止时把 thread 置 nil 打断引用。另外RunLoop 里添加的 port、timer 等对象如果不在停止时移除也会造成泄漏。我一般会在stopInThread里把所有添加过的源都清理一遍确保 RunLoop 干净退出。5.3 任务执行顺序与线程安全通过performSelector投递的任务在 RunLoop 里是按投递顺序执行的但如果有多个来源同时投递顺序就不保证了。如果业务对顺序有要求需要自己在任务层面加序列号或者用串行队列来保证。另外任务执行在目标线程上如果任务内部访问了共享资源仍然需要考虑线程安全RunLoop 本身不提供锁机制。5.4 常见问题速查表问题现象可能原因排查方向解决办法线程启动后立刻退出RunLoop 没有事件源检查是否添加了 port 或 timer添加空 port 撑住 RunLoopCPU 占用高RunLoop 空转或忙等待检查 beforeDate 参数用 distantFuture 避免超时唤醒停止操作不生效RunLoop 处于休眠未被唤醒检查是否调用了唤醒方法用 CFRunLoopStop 显式唤醒内存持续增长事件源未移除或循环引用检查停止时的清理逻辑移除所有源并打断引用任务执行延迟大投递方式或 RunLoop 模式不对检查 mode 和 waitUntilDone用默认模式并异步投递这张表是我在实际项目中遇到问题后整理的基本上覆盖了八成以上的保活相关故障。遇到问题时按表排查能省不少时间。6. 进阶场景与性能优化6.1 多任务并发时的保活策略单个保活线程适合串行任务但如果任务量大且互不依赖单线程会成为瓶颈。这时候可以考虑保活多个线程组成一个线程池。每个线程各自维护自己的 RunLoop任务投递时轮询分配到不同线程。不过线程池的复杂度会高不少需要处理线程的动态增减、任务队列的同步等问题。我的建议是除非确实有高并发需求否则单保活线程加任务队列已经够用了。6.2 与定时任务的结合如果保活线程本身就需要周期性执行任务可以把空 port 换成 timer。但要注意 timer 的精度和功耗平衡。精度要求高的场景timer 间隔短唤醒频繁功耗上去了精度要求低的场景可以用较长的间隔减少唤醒次数。我一般会根据业务实际需求来定不会盲目追求高精度。6.3 性能监控与调优保活线程上线后需要监控几个指标线程存活状态、任务平均等待时间、CPU 占用率、内存增长趋势。如果发现任务等待时间变长可能是任务队列积压需要考虑加线程或优化任务执行效率。如果 CPU 占用异常检查是否有 RunLoop 空转或任务里有死循环。这些监控手段能帮你在问题恶化之前发现苗头。7. 一些个人体会这套 RunLoop 保活方案我在几个项目里都用过整体稳定性不错但每次都会根据具体场景做调整。比如有个项目对任务响应延迟极其敏感我就把 RunLoop 的模式从默认模式改成了 common modes确保在滚动等交互场景下任务也不会被延迟。另一个项目对功耗敏感我就把 timer 的间隔拉长并且在不必要时主动停止保活线程用完再启动。踩过的坑里印象最深的一次是线程停止后偶尔还会收到任务投递导致野指针崩溃。后来排查发现是停止操作和任务投递之间存在竞态停止过程中还有任务在往线程里投。解决办法是在停止前先关闭任务投递入口等所有在途任务处理完再真正停止线程。这个经验告诉我保活线程的生命周期管理不能只看启动和停止两个点中间的过渡状态同样需要仔细处理。如果你刚开始接触这块我的建议是先把单线程保活跑通理解 RunLoop 的休眠和唤醒机制然后再考虑多线程和性能优化。不要一上来就搞复杂方案容易把自己绕进去。另外多写日志把 RunLoop 的启动、唤醒、停止、任务投递都打上日志出问题的时候日志比调试器还管用。