
1. 线程保活到底在保什么从RunLoop的休眠机制说起很多人第一次接触线程保活脑子里冒出来的第一个念头是“让线程别退出”。这个理解对了一半但没说到点子上。线程保活真正要解决的问题是让一个线程在完成初始化任务之后依然能够随时响应后续派发给它的任务而不是执行完就走到生命周期终点被系统回收。在iOS和macOS开发里这件事几乎绕不开RunLoop。要理解保活先得理解RunLoop的本质。RunLoop直译过来叫“运行循环”但更准确的说法是“事件处理循环”。它做的事情可以类比成一个值班保安没有访客的时候他坐在椅子上打盹线程休眠一旦有人按门铃事件到达他立刻醒来处理处理完继续回去打盹。这个“打盹”状态非常关键它意味着线程并没有在空转消耗CPU而是真正进入了内核级别的休眠被唤醒时才占用资源。线程和RunLoop的关系是一对一的。主线程的RunLoop在应用启动时由系统自动创建并启动而子线程的RunLoop默认是不存在的只有当你第一次在子线程中获取它时才会被创建。这里有个容易被忽略的细节获取RunLoop这个动作本身会创建它但创建不等于启动。你拿到了RunLoop对象如果不调用run方法它依然不会进入循环线程该结束还是结束。那为什么子线程执行完任务就会退出因为线程的入口函数执行完毕线程的生命周期就走到了终点。这跟RunLoop没关系是线程本身的机制。所以保活的思路就清晰了想办法让线程的入口函数不返回同时让线程在无事可做时进入休眠而不是空转。RunLoop恰好提供了这个能力——只要RunLoop在运行run方法就不会返回线程的入口函数就卡在那一行线程自然就不会结束。但这里有个常见的认知误区很多人以为只要调用了run方法线程就保活了。实际上RunLoop的运行模式、事件源、定时器这些配置都会影响它是否能持续运行。如果RunLoop里没有任何事件源或定时器它会立刻返回线程照样结束。这就像保安发现值班室里没有任何通讯设备、没有任何巡逻任务他待着也没意义直接下班走人。所以保活的核心不只是“让RunLoop跑起来”而是“让RunLoop有理由一直跑下去”。从实际应用场景来看线程保活最常见的需求出现在需要长期驻留后台执行任务的模块中。比如一个负责日志上报的模块希望有一个常驻线程随时接收上报任务比如一个即时通讯的底层连接管理需要一个线程持续处理网络回调再比如一些需要串行执行且频繁调度的任务队列用常驻线程比反复创建销毁线程更高效。这些场景的共同点是任务不是一次性完成的而是随时可能到来且对响应速度有要求。理解了这些再去看RunLoop的保活方案就不会停留在“加个run就行”的层面而是会去思考这个RunLoop靠什么事件源维持运行线程在什么条件下会被唤醒保活期间如何优雅退出这些才是真正决定保活方案是否可靠的关键。2. 让RunLoop持续运转的几种事件源配置方案RunLoop要持续运行必须有“事情”让它等待。这些“事情”在RunLoop的术语里叫事件源Source和定时器Timer。没有它们RunLoop会认为无事可做直接返回。所以保活方案的核心就是给RunLoop配置一个能够长期存在的事件源。2.1 Port作为事件源的经典做法最经典也最稳定的方案是给RunLoop添加一个Mach Port。这个Port本身不传递任何实际消息它的作用就是让RunLoop有一个“永远等待”的对象。代码大致长这样- (void)threadEntryPoint { autoreleasepool { NSRunLoop *runLoop [NSRunLoop currentRunLoop]; // 添加一个空的Port仅用于维持RunLoop运行 [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; // 启动RunLoop [runLoop run]; } }这段代码里[NSMachPort port]创建了一个空的端口对象它不会被使用也不需要被使用。它的唯一使命就是让RunLoop的run方法认为“还有事情在等待”从而不会返回。这个方案的好处是极其稳定几乎不会出现意外退出的情况。缺点是它纯粹是为了保活而存在不承载任何业务逻辑看起来有点“为了保活而保活”的意味。为什么Port能起到这个作用因为RunLoop的底层实现是基于Mach内核的RunLoop本质上是在等待Mach Port上的消息。当你添加了一个PortRunLoop就会在这个Port上等待消息到来。由于这个Port永远不会收到消息RunLoop就会一直等下去线程也就一直休眠着。这比空转轮询优雅得多CPU占用几乎为零。2.2 用NSTimer维持RunLoop的可行性分析另一种常见的做法是添加一个NSTimer让定时器周期性触发从而让RunLoop有事情可做。代码类似这样- (void)threadEntryPoint { autoreleasepool { NSRunLoop *runLoop [NSRunLoop currentRunLoop]; // 添加一个重复触发的定时器 NSTimer *timer [NSTimer timerWithTimeInterval:1.0 target:self selector:selector(timerFired) userInfo:nil repeats:YES]; [runLoop addTimer:timer forMode:NSDefaultRunLoopMode]; [runLoop run]; } } - (void)timerFired { // 空实现仅用于维持RunLoop }这个方案也能实现保活但有几个问题需要注意。首先定时器会周期性唤醒线程虽然频率不高但相比Port方案还是多了一些无谓的唤醒。其次定时器的精度受RunLoop模式影响如果RunLoop切换到其他模式定时器可能不会触发。最重要的是如果定时器的target是self而self又持有这个线程或RunLoop容易形成循环引用导致内存无法释放。从实际经验来看如果保活线程本身就需要周期性执行某些任务比如每隔一段时间检查一次状态那用定时器是一举两得。但如果纯粹为了保活Port方案更干净。2.3 三种方案的对比与选型建议方案稳定性CPU开销实现复杂度适用场景Mach Port极高几乎为零低纯保活无周期性任务NSTimer高极低低保活周期性任务自定义Source中取决于实现高需要精细控制事件源选型的时候我的建议是如果只是单纯想让线程活着用Port方案简单可靠。如果线程本身有周期性任务用Timer方案顺便把保活做了。自定义Source一般不需要除非你有非常特殊的需求比如需要精确控制RunLoop的唤醒时机。还有一个细节值得注意添加事件源的时候要指定RunLoop Mode。默认用NSDefaultRunLoopMode就够了但如果你的线程需要处理UI相关的事件虽然子线程一般不需要可能需要考虑NSRunLoopCommonModes。对于保活场景NSDefaultRunLoopMode完全够用。3. 保活线程的优雅退出别让线程变成“僵尸”线程保活了接下来要面对的问题是什么时候让它退出怎么退出如果处理不好保活线程就会变成“僵尸线程”——一直活着但没有任何用处还占着资源。更糟糕的是如果线程持有某些资源不释放可能导致内存泄漏或其他问题。3.1 用标志位控制RunLoop的退出时机最直接的退出方式是在RunLoop运行之前设置一个标志位在需要退出的时候修改标志位并唤醒RunLoop。代码结构如下interface KeepAliveThread : NSObject property (nonatomic, assign) BOOL shouldKeepRunning; property (nonatomic, strong) NSThread *thread; end implementation KeepAliveThread - (void)start { self.shouldKeepRunning YES; self.thread [[NSThread alloc] initWithTarget:self selector:selector(threadEntryPoint) object:nil]; [self.thread start]; } - (void)threadEntryPoint { autoreleasepool { NSRunLoop *runLoop [NSRunLoop currentRunLoop]; [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; while (self.shouldKeepRunning) { // 运行RunLoop但设置一个超时时间避免永久阻塞 [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]; } } } - (void)stop { self.shouldKeepRunning NO; // 唤醒RunLoop让它有机会检查标志位 [self performSelector:selector(noop) onThread:self.thread withObject:nil waitUntilDone:NO]; } - (void)noop {} end这个方案的关键点在于不用run方法而是用runMode:beforeDate:并且设置一个超时时间。这样RunLoop每隔一秒会返回一次检查shouldKeepRunning标志位。当标志位变为NO时循环结束线程入口函数返回线程正常退出。为什么不用run方法因为run方法会一直阻塞没有机会检查标志位。虽然可以通过performSelector:onThread:来唤醒RunLoop但唤醒之后RunLoop处理完事件又会继续阻塞标志位的检查时机不好控制。用runMode:beforeDate:配合超时逻辑更清晰退出更可控。3.2 CFRunLoopStop的正确使用姿势另一种退出方式是调用CFRunLoopStop。这个函数会强制RunLoop停止运行run方法会返回。代码大致这样- (void)stop { CFRunLoopStop(CFRunLoopGetCurrent()); }但这里有个坑CFRunLoopStop必须在RunLoop所在的线程调用或者通过performSelector:onThread:派发到那个线程执行。如果在其他线程直接调用停的是当前线程的RunLoop而不是目标线程的。这一点非常容易搞错。另外CFRunLoopStop会立即停止RunLoop不管当前有没有正在处理的事件。如果线程正在执行某个任务这个任务会被中断。所以调用之前最好确认线程处于空闲状态或者能够接受任务被中断。3.3 退出时需要注意的资源清理问题线程退出之前需要确保该释放的资源都释放了。常见的需要清理的资源包括定时器如果添加了NSTimer需要调用invalidate否则定时器可能继续触发虽然线程都退了但定时器对象可能还被RunLoop持有。端口Mach Port一般不需要手动释放ARC会处理。观察者如果注册了RunLoop Observer需要移除。业务资源比如打开的文件、网络连接、数据库句柄等需要根据业务逻辑关闭。我见过不少项目保活线程退出时没有清理定时器导致定时器还在触发但target已经释放了直接crash。这种问题排查起来很费时间因为crash堆栈指向的是定时器回调但根因在线程退出时没有清理。提示在线程入口函数的autoreleasepool块结束之前确保所有需要手动释放的资源都已经处理完毕。ARC虽然能管理大部分内存但定时器、观察者这类需要手动移除的对象ARC是管不了的。4. 实际项目中的保活线程封装与踩坑记录理论说完了接下来聊聊实际项目中怎么封装一个可靠的保活线程以及我踩过的那些坑。4.1 一个可复用的保活线程封装在实际项目中我一般会把保活线程封装成一个独立的类对外提供启动、停止、执行任务三个接口。这样业务层不需要关心RunLoop的细节只需要往线程上派任务就行。interface PersistentThread : NSObject - (void)start; - (void)stop; - (void)executeTask:(void (^)(void))task; end implementation PersistentThread { NSThread *_thread; BOOL _isRunning; NSCondition *_condition; } - (instancetype)init { self [super init]; if (self) { _condition [[NSCondition alloc] init]; _isRunning NO; } return self; } - (void)start { [_condition lock]; if (_isRunning) { [_condition unlock]; return; } _isRunning YES; [_condition unlock]; _thread [[NSThread alloc] initWithTarget:self selector:selector(threadMain) object:nil]; _thread.name com.example.persistent; [_thread start]; } - (void)threadMain { autoreleasepool { NSRunLoop *runLoop [NSRunLoop currentRunLoop]; [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; while (_isRunning) { [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]; } } } - (void)executeTask:(void (^)(void))task { if (!task) return; [self performSelector:selector(runTask:) onThread:_thread withObject:task waitUntilDone:NO]; } - (void)runTask:(void (^)(void))task { task(); } - (void)stop { [_condition lock]; _isRunning NO; [_condition unlock]; [self performSelector:selector(noop) onThread:_thread withObject:nil waitUntilDone:NO]; } - (void)noop {} end这个封装有几个设计考虑。第一用NSCondition保护_isRunning标志位避免多线程竞争。第二线程的name属性设了一个有意义的名字方便调试时在Xcode的线程列表里识别。第三executeTask:方法用performSelector:onThread:把任务派发到保活线程执行保证任务在同一个线程上串行执行。4.2 踩坑记录performSelector的waitUntilDone陷阱performSelector:onThread:withObject:waitUntilDone:这个方法有个参数叫waitUntilDone。如果传YES当前线程会阻塞等待目标线程执行完任务如果传NO当前线程继续执行不等待。我踩过的坑是在保活线程上执行的任务里又调用了executeTask:往同一个线程派任务并且waitUntilDone传了YES。结果就是死锁——线程在等自己执行完任务但自己正忙着等永远等不到。这个问题的根因是performSelector:onThread:waitUntilDone:YES的本质是往目标线程的RunLoop里塞一个事件然后当前线程等待这个事件处理完。如果当前线程就是目标线程那就相当于自己等自己必然死锁。所以我的经验是往保活线程派任务时waitUntilDone一律传NO。如果确实需要等待任务执行结果用信号量或者回调不要用waitUntilDone:YES。4.3 踩坑记录RunLoop模式切换导致任务不执行另一个坑跟RunLoop的Mode有关。RunLoop可以运行在不同的模式下默认是NSDefaultRunLoopMode。如果保活线程的RunLoop运行在NSDefaultRunLoopMode但派发的任务被添加到了UITrackingRunLoopMode比如某些UI相关的事件那任务就不会被执行因为RunLoop当前不在那个模式下。这个坑在纯后台线程里不太常见但如果保活线程跟UI有交互就可能遇到。解决方案是统一使用NSDefaultRunLoopMode或者在添加事件源的时候用NSRunLoopCommonModes让RunLoop在多个模式下都能处理事件。注意NSRunLoopCommonModes不是一个真正的模式而是一个模式集合。把事件源添加到Common Modes相当于添加到所有被标记为Common的模式。这样无论RunLoop切换到哪个Common模式事件都能被处理。4.4 踩坑记录线程退出时的内存泄漏最后一个坑是关于内存泄漏的。保活线程如果持有某个对象而这个对象又持有线程就容易形成循环引用。比如self.thread [[NSThread alloc] initWithTarget:self selector:selector(threadMain) object:nil];这里self持有threadthread的target又是self如果self不释放thread也不会释放。虽然线程执行完会结束但NSThread对象本身可能还被self持有导致self也无法释放。解决方案是在stop方法里线程退出后将_thread置为nil打破引用链。或者用weak引用但NSThread的target不支持weak所以只能在逻辑上确保释放。5. 保活线程的替代方案与选型思考线程保活虽然有用但并不是所有场景都适合。有时候用其他方案会更简单、更可靠。这一章聊聊几种替代方案以及什么时候该用保活线程什么时候不该用。5.1 GCD队列能否替代保活线程GCD的串行队列本身就能保证任务串行执行而且队列的生命周期由系统管理不需要手动保活。那为什么还需要保活线程区别在于GCD队列是“任务驱动”的有任务就执行没任务就闲着队列本身不占用线程资源。而保活线程是“线程驱动”的线程一直存在随时可以接收任务。两者的差异体现在如果任务非常频繁保活线程省去了线程创建销毁的开销响应更快。如果任务稀疏GCD队列更省资源因为不需要维持一个常驻线程。如果任务需要访问线程绑定的资源比如线程局部存储保活线程更方便。我的经验是大多数场景下GCD串行队列已经够用了。只有在对响应延迟极其敏感、或者需要线程绑定的场景下才考虑保活线程。5.2 用信号量控制线程生命周期的思路还有一种折中方案用信号量控制线程的休眠和唤醒而不是依赖RunLoop。思路是线程在一个循环里等待信号量有任务时信号量加一线程被唤醒执行任务任务执行完继续等待。- (void)threadMain { autoreleasepool { while (_isRunning) { dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER); if (!_isRunning) break; // 执行任务 [self processTasks]; } } }这个方案的优点是逻辑简单不依赖RunLoop的复杂性。缺点是任务派发需要手动管理信号量而且无法利用RunLoop的其他特性比如定时器、事件源。适合任务模型非常简单的场景。5.3 不同场景下的选型对照表场景推荐方案理由任务频繁且需要低延迟RunLoop保活线程线程常驻响应最快任务稀疏且无实时性要求GCD串行队列系统管理省资源需要线程绑定资源RunLoop保活线程线程生命周期可控任务模型简单信号量控制线程实现简单逻辑清晰需要周期性执行RunLoop Timer一举两得选型的时候不要为了保活而保活。先问自己这个线程真的需要一直活着吗如果任务间隔超过几秒用GCD队列可能更合适。如果任务确实频繁到值得维持一个常驻线程再考虑RunLoop方案。6. 调试保活线程的实用技巧保活线程出问题的时候调试起来比普通线程麻烦因为它不执行完就退出你没法通过“线程结束”来判断它是否正常。这一章分享几个我常用的调试技巧。6.1 用Xcode线程列表确认线程状态Xcode的Debug Navigator里可以看到所有线程。保活线程如果正常运行会显示在列表里状态是“Running”或“Blocked”。如果线程已经退出就不会出现在列表里。通过这个可以快速判断线程是否还活着。另外给线程设置name属性非常重要。默认情况下线程名字是空的列表里只能看到一堆Thread 1、Thread 2根本分不清哪个是保活线程。设置名字之后一眼就能找到。6.2 通过日志判断RunLoop是否在运行在RunLoop的循环里加日志是最直接的调试方式while (_isRunning) { NSLog(RunLoop will run); [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]; NSLog(RunLoop did run); }如果只看到“will run”没有“did run”说明RunLoop阻塞了可能是没有事件源或者事件源配置有问题。如果两个日志都频繁出现说明RunLoop在空转可能是超时时间设得太短。6.3 常见异常现象与排查方向现象可能原因排查方向线程启动后立即退出RunLoop没有事件源检查是否添加了Port或Timer任务派发后不执行RunLoop模式不匹配检查RunLoop Mode和任务添加的Mode线程无法退出标志位未生效检查标志位是否被正确修改和读取CPU占用高RunLoop空转检查超时时间是否过短内存持续增长循环引用或资源未释放检查线程持有的对象和定时器这些现象我在实际项目中都遇到过大部分问题的根因都是RunLoop配置不当或者线程生命周期管理不清晰。调试的时候先确认RunLoop是否在运行再确认任务是否被正确派发最后检查资源释放基本能覆盖90%的问题。6.4 用Instruments观察线程活动Instruments的Time Profiler可以观察线程的CPU活动。保活线程在空闲时应该几乎没有CPU占用如果看到持续的CPU活动说明RunLoop可能在空转。另外Allocations工具可以观察线程相关的内存分配帮助发现内存泄漏。我一般会在保活线程上线之前用Instruments跑一遍确认空闲时CPU占用接近零内存没有持续增长。这两个指标正常基本可以放心。线程保活这件事说复杂也复杂说简单也简单。核心就是理解RunLoop的运行机制配置合适的事件源管理好线程的生命周期。实际项目中我见过太多因为RunLoop配置不当导致的诡异问题也见过为了保活而保活、最后把代码搞得一团糟的情况。希望这些经验能帮你少走点弯路。