☰
RunLoop线程保活:原理、实现与性能优化
2026/10/15 2:51:40 网站建设 项目流程

1. 线程保活到底在保什么

第一次接触 RunLoop 线程保活这个概念,是在做一个需要长期驻留后台的下载任务模块时。当时的需求很朴素:App 退到后台之后,已经排队的下载任务不能断,得有个东西一直活着把任务跑完。我一开始想得很简单,开个Thread,start一下,里面写个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 里加 Observer,RunLoop 还是会因为没有事件源而立刻返回。这个细节很多人不知道,我见过有人加了一堆 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 撑住 RunLoop
CPU 占用高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 的启动、唤醒、停止、任务投递都打上日志,出问题的时候日志比调试器还管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询