iOS开发这套多线程系列写到第二篇,上一篇聊完线程的基本概念和进程的区别,这一篇我们把Thread(也就是NSThread)彻底扒开来讲。第一批用Thread入门的人很多,但真正能在生产环境里用得明白、不踩坑的其实不多,因为它在底层暴露得足够直接,很多细节要是理解不到位,代码写得越多,问题埋得越深。
Thread能干什么?简单说,它是iOS里最底层的线程对象,让你能手动创建线程、启动线程、控制线程状态,也能配合RunLoop做线程常驻。它不像GCD那样隐藏线程生命周期,也不像Operation那样自带依赖和取消逻辑,但它有一个不可替代的价值:让你真正搞清楚"线程到底是怎么跑起来的"。这一篇适合两类人,一类是想搞懂多线程底层逻辑、准备面试的开发者,另一类是项目中需要精细控制线程行为、处理线程间通信的进阶开发,如果你只想写业务不关心系统调度,那这篇可以帮你补足短板,未来排查崩溃和卡顿时会感谢今天的阅读。
1. 内容整体设计与思路拆解
1.1 为什么在多线程方案里还要单独学Thread
平时项目里大部分异步场景其实用GCD就够了,DispatchQueue一套下来,代码简洁、性能稳定,OperationQueue还能做依赖和取消。那为什么还要学Thread?
关键原因在于:Thread是iOS多线程能力的基层。你用了GCD,系统底层照样要创建线程,只不过线程的创建、调度、销毁都由系统接管了。你如果完全不懂线程本身的生命周期和状态,遇到GCD解决不了的场景会很懵,尤其是下面几种:
- 需要线程长时间驻留,反复接收某个回调或监听某个事件,而不是"执行完拉倒"。
- 需要线程自己传递参数、返回值,且要精确控制线程对象本身。
- 需要调试线程状态、分析
Thread的栈信息,排查死锁和卡顿。 - 面试里被问到"线程有哪些状态""线程间怎么通信""怎么让线程保活",都是围绕
Thread模型展开的。
说白了,Thread是基础功,GCD是高级封装。基础功不扎实,写出来的高级操作大概率是靠运气。
1.2 Thread在整套多线程体系中的定位与选型逻辑
咱们把iOS多线程的几个工具放在一张表里看,定位立刻清楚了。
| 工具 | 抽象层级 | 线程生命周期 | 适用场景 |
|---|---|---|---|
Thread/NSThread | 最底层,直接操作线程对象 | 开发者手动创建、启动、控制 | 线程常驻、精确线程控制、线程间通信、底层调试 |
GCD | 队列驱动,系统管理线程池 | 系统自动调度,无需关心 | 日常异步任务、批量并发、Barrier同步 |
OperationQueue | 任务封装,依赖关系 | 基于GCD封装,支持取消、依赖 | 复杂任务编排、可取消任务、任务依赖 |
这个定位告诉我们,别用Thread去做那种"提交一个异步任务,跑完就结束"的活儿,那是GCD的主场。Thread的主场是"线程作为一个长期存在的执行单元"出现的场景,比如后台的监听循环、常驻线程处理串行任务流。
1.3 写这篇内容前的几个关键思考
讲Thread不能只讲API,API就那几个,十分钟就能背完。真正有价值的是三个维度:
第一,创建线程的几种方式,每种的差异和对应场景。第二种和第三种看起来差不多,底层差别很大,好多人就在这里栽跟头。
第二,线程的状态机和生命周期。Executing、Finished、Cancelled什么时候变化,为什么要关心Finished,这是很多人写线程代码时完全忽略的。
第三,线程安全。Thread把线程给你了,线程安全问题就要自己负责,同一块资源多线程访问,不加锁就是事故现场。
这篇文章会按这个思路走,先讲清楚框架认知,再逐个拆API,中间穿插我自己实际项目里踩过的坑和验证过的做法。保证你读完能直接写代码,而不是光记住几个名词。
2. 核心API解析与使用要点
2.1 创建线程的几种方式与本质区别
NSThread提供了三种主流创建方式,这里逐一拆解。
第一种,detachNewThreadSelector:toTarget:withObject:。这是类方法,直接创建线程并自动启动,目标对象的方法会在子线程中执行。注意"自动启动"这四个字,你不需要拿到线程对象再调start,线程马上就跑起来了。它的局限在于,你没法拿到线程对象去执行后续控制,比如取消、设置优先级,都没办法,适合"一次性跑完就不管"的简单场景。
[NSThread detachNewThreadSelector:@selector(doWork:) toTarget:self withObject:param];第二种,initWithTarget:selector:object:。这是实例方法,创建线程对象之后,需要你手动调用start才会真正启动。这种方式最大的价值在于你保留了线程对象的引用,可以后续做线程状态查询、设置线程名字、设置优先级等操作。实际项目中我几乎都用这种方式,因为可控性比第一种强太多了。
NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(doWork:) object:param]; thread.name = @"com.example.worker"; [thread start];第三种,performSelectorInBackground:withObject:。它本质上是封装了detachNewThreadSelector,把代码写得更简洁,等价于在一个新线程里执行指定方法。它同样不返回线程对象,但好在它能在任意对象上直接调用,代码比较轻。
[self performSelectorInBackground:@selector(doWork:) withObject:param];这里要记住一个重要的底层逻辑:不管用哪种方式,最终都是pthread_create创建线程,NSThread只是把它包装成OC对象。所以你在子线程里看到NSThread对象,本质上就是操作系统线程的映射。
2.2 线程的核心属性:栈大小、名字、优先级和质量服务
Thread能设置的东西不算多,但每一样都可能坑你一下。
栈大小:stackSize。默认值是0,表示使用系统默认线程栈大小,通常对于主线程外的线程是512KB(不同系统版本、不同架构略有差异)。如果你开的线程每帧都做大量递归或者深调用栈,可以考虑显式设置栈大小,比如2MB,减少栈溢出的可能性,但别动不动就调大,内存是有成本的。
名字:name。强烈建议每个子线程都设置名字。这东西平时不起眼,等你奔溃了看Thread调用栈,或者用Xcode的Debug Navigator看线程列表时,会发现满屏的com.apple.main-thread和数字编号,根本分不清哪个是哪个。设了名字之后一眼就知道问题出在哪个线程。
优先级:threadPriority。取值范围0.0到1.0,默认0.5。注意,这里设置的是"调度优先级",不是绝对实时优先级,操作系统也只是把它作为参考。它跟qualityOfService是两套概念,后者描述的是线程工作的性质(用户交互、后台、实用工具等),系统会依据这个给线程分配资源。这两者要配合使用才合理。
thread.qualityOfService = NSQualityOfServiceUtility; thread.threadPriority = 0.8;2.3 获取当前线程与主线程判断
在子线程代码里,获取当前线程对象:
NSThread *current = [NSThread currentThread];判断是否主线程:
BOOL isMain = [NSThread isMainThread];这两个API看起来没啥,但在调试多线程崩溃的时候几乎是高频使用的。尤其是isMainThread这个判断,很多跟UI相关的代码必须确认在主线程执行,一旦在子线程里碰了UI,轻则UI更新异常,重则直接崩溃。
2.4 线程状态:Executing、Finished、Cancelled
Thread的状态是很多人学了但从来不用的知识,这里必须强化一下,因为后面排查问题全靠它。
一个线程从启动开始,经历的过程是:创建(init)→ 启动(start)→ 运行(executing)→ 完成(finished)。在任意时刻可以调用cancel请求取消线程。
这三个只读属性需要注意:
isExecuting:判断线程是否正在运行任务。isFinished:判断线程是否已经结束。isCancelled:判断线程是否收到了取消请求。
这里有个非常关键的点:cancel方法并不会强行终止线程,它只是设置一个标志位。你必须在你的任务代码里主动检查isCancelled,然后自行退出。如果你不检查,cancel了也白搭,线程照样跑完全部逻辑。这是很多新手以为cancel就是"杀掉线程",结果根本没用,然后在网上骂NSThread设计有问题的来源。
重要理解:
cancel不是强制杀死线程,那太危险了。它更像发给线程的一个"请求信号",配合你的业务代码决定如何响应。
2.5 线程对象的内存管理和线程的隐式保留
这里必须提醒一个特别的坑:线程对象在运行期间,会被系统以NSThread内部机制强引用,直到线程执行完并退出。也就是说,就算你把线程对象的强引用置为nil,只要它还在跑,系统也不会让它被释放掉。
反过来,如果你在线程对象上调用start之后,你没持有它,线程却不在了,那多半是线程已经执行完并自动退出了。想验证线程是否还在,用上面的状态判断即可。
在实际代码里,我见过不少人用__weak去存储线程对象然后检查是否释放,这种做法意义不大,线程是否活着应该用isExecuting和isFinished来判断,而不是依赖OC对象的内存管理。
3. 线程间通信与常驻线程的实现
3.1 线程间通信的常用套路
线程创建出来往往不是为了自己干活,而是干完活要把结果告诉别的线程,最常见的就是回主线程刷新UI。Thread体系下最常用的通信方式就是performSelector系列。
主线程更新UI的经典写法:
[self performSelectorOnMainThread:@selector(updateUIWithResult:) withObject:result waitUntilDone:NO];waitUntilDone这个参数值得说透。设为YES时,当前子线程会阻塞等待主线程执行完指定方法后才继续执行,这适合必须等UI更新完成才能继续处理的场景。设为NO则立即返回,子线程不等主线程,适合大多数异步回包更新UI的情况。实际开发里我基本都用NO,除非有明确的先后依赖,否则等YES会让子线程无意义地等主线程,增加卡顿风险。
跨线程传值还有别的思路:用NSLock加锁后修改共享的可变数据,或者用NSCondition做多个线程之间的状态通知,这套后面专门开一节讲锁的时候细说。
3.2 常驻线程和RunLoop的关系
Thread最典型的高级玩法是"线程常驻"。普通子线程执行完任务就自动退出了,但某些场景希望线程一直活着,随时能响应命令。这时候就需要给子线程加一个RunLoop,让它没有任务时休眠等待,有任务时醒来处理。
一套标准的常驻线程写法:
NSThread *thread = [[NSThread alloc] initWithBlock:^{ // 往当前RunLoop里添加一个Port源,保证RunLoop不会被立即退出 [[NSRunLoop currentRunLoop] addPort:[[NSPort alloc] init] forMode:NSDefaultRunLoopMode]; // 跑RunLoop,如果没有事件源就会立刻返回 [[NSRunLoop currentRunLoop] run]; }]; thread.name = @"com.demo.keepAliveThread"; [thread start];很多人照着这个写,发现线程还是退了。核心原因在于run方法在没有输入源或定时器等事件源时,RunLoop会直接返回,根本不会进入等待状态。所以一定要先加一个Port源或者定时器,RunLoop才能持续跑起来。
这里是第二个容易出问题的地方:这样创建的线程一旦跑起来,想再停止它怎么办?正确做法是往线程的RunLoop里发消息,或者设置一个退出标志,让RunLoop从自己的循环中退出。这里推荐写一个基于PerformBlock的封装,把任务都往线程RunLoop上投递,方便统一管理和退出。
- (void)executeTaskOnKeepAliveThread:(dispatch_block_t)task { if ([NSThread currentThread] == self.keepAliveThread) { task(); } else { [self performSelector:@selector(runTask:) onThread:self.keepAliveThread withObject:task waitUntilDone:NO]; } }用performSelector:onThread:withObject:waitUntilDone:往指定线程投递任务是常驻线程最常用的通信方式。这套机制底层依赖的就是线程的RunLoop,所以常驻线程必须在自己的RunLoop里待命,否则任务根本递不进去。
3.3 线程退出与资源清理
常驻线程如果永久不退出,资源就永久占着。现在很多做网络长连接、IM消息通道的老代码里都能看到常驻线程的身影,但新项目基本都用GCD的长生命周期队列代替了,因为更好控制。
真要退出一个常驻线程,一定要找对入口:往它的RunLoop发一个退出任务,让线程自己从RunLoop里跳出来,然后自然结束,不要在线程外面强行停。Thread没有合法的"强制杀掉另一个线程"的API,这是刻意设计,强制杀线程会导致资源泄漏、锁状态未知等一堆问题。
3.4 线程中共享数据的保护思路
用Thread最大的代价就是没有""自动安全""这回事,所有跨线程的共享数据都要自己想办法。我简单给三个阶段的保护思路:
一,尽量让线程只处理自己的局部变量,不共享,从最底层规避问题。
二,如果必须共享,使用NSLock、@synchronized或者信号量保护临界区。注意加锁代码必须成对,lock和unlock之间不要有return,最好用@synchronized这种自动配对的方式。
三,数据量大的读写场景用OSAtomic或dispatch_queue串行化,这个思路更现代,尽量别裸用大锁,锁的竞争会拖死性能。
3.5 线程传参的几种姿势对比
给线程方法传单个参数用withObject:就够了,但实际业务经常有多个参数,这里列一下主流方案。
| 传参方式 | 写法 | 适用场景 |
|---|---|---|
| 单个对象 | withObject: | 参数少,直接传 |
| 字典 | 把多个参数塞进NSDictionary,线程方法里取出 | 参数较多,结构化 |
| 模型对象 | 自定义Model包装参数 | 参数关联性强,可读性高 |
| Block捕获 | 用initWithBlock:创建线程 | 逻辑集中,避免拆方法 |
我自己用得最多的是最后一种,initWithBlock:代码紧凑,作用域内变量直接捕获,不用单独写方法名和target,读代码时上下文最连贯。而且它配合__weak修饰外部对象,能避免循环引用。
4. 实操全程:手写一个后台任务线程
4.1 需求描述与线程选择决策
纸上谈兵够了,直接进入一个踩过坑的实操场景。
需求:一个图片批量上传器,内部维护一个串行队列,逐张上传用户选择的图片。上传过程不能阻塞主线程,且上传完成之后要把结果回调到主线程。要求线程可以被安全取消。
这个需求乍一看可以用GCD的串行队列解决,但我想强调Thread在这个场景下的特殊价值:我们需要持有"当前正在执行上传的线程"对象,以便在极端情况下检查它是否还活着、是否响应,这是队列做不到的。虽然实际工程可以用OperationQueue做得更好,但学Thread就要拿这种场景练手。
4.2 初始化与启动上传线程
沿用第二种创建方式,把上传器类持有的线程对象初始化和启动:
@interface ImageUploader : NSObject @property (nonatomic, strong) NSThread *uploadThread; @property (nonatomic, assign, getter=isUploading) BOOL uploading; @end @implementation ImageUploader - (void)startUploadWithImages:(NSArray<UIImage *> *)images { if (self.isUploading) { return; } self.uploading = YES; self.uploadThread = [[NSThread alloc] initWithBlock:^{ [self processUploadImages:images]; }]; self.uploadThread.name = @"com.demo.imageUploader"; self.uploadThread.qualityOfService = NSQualityOfServiceUtility; [self.uploadThread start]; } @end这里有一个值得展开的坑:start之后线程对象被系统内部持有,所以你持不持有没有差别,但业务上你还是得存一下引用,毕竟后续要查询状态和投递任务。
另外我特意给线程设了名字,就是为了排查崩溃时能立刻定位"哦,这是上传线程的栈",而不是面对一串数字编号瞎猜。
4.3 任务执行内的取消检查机制
关键来了,线程的cancel必须配合内部主动退出,所以处理上传图片的循环里必须周期性检测。
我在原理部分说过,cancel只是表达一个意愿,真正让线程停下来的,是你代码里的检查动作。这个意识必须贯穿整个实现。
- (void)processUploadImages:(NSArray<UIImage *> *)images { for (NSInteger i = 0; i < images.count; i++) { if ([[NSThread currentThread] isCancelled]) { NSLog(@"上传线程被取消,停止处理"); break; } UIImage *image = images[i]; // 模拟上传过程 [self uploadImage:image completion:^(BOOL success) { // 线程内自己处理结果 }]; } [self performSelectorOnMainThread:@selector(uploadFinished) withObject:nil waitUntilDone:NO]; }这里有个很隐蔽的细节:如果线程在处理过程中被取消了,你还要不要回调主线程"上传完成"?从业务完备性上讲,应该区分是"正常完成"还是"被取消",所以我建议在循环结束后统一发一个带状态的结果对象,而不是简单发一个完成消息。我这里为了演示简洁,直接发完成,但实际项目里会把取消状态一起带回。
4.4 取消接口的实现与常见误区
与启动方法配套,取消接口应该是:
- (void)cancelUpload { if (self.uploadThread) { [self.uploadThread cancel]; } }有人会在这个方法里做"等待线程退出"的动作,希望能确认线程真正停了再返回。这就引出一个典型的坑,最好不要在主线程里使用waitUntilFinished之类的同步等待,除非你能接受阻塞界面。实测中,主线程等待子线程结束,要是子线程卡在某个网络请求上,界面就会卡死,用户过一会儿看到的就是"应用无响应"。
正确的做法是让"正在取消"的状态由线程自己上报,比如取消后回主线程通知"已取消",界面收到通知再更新状态。这样取消操作本身是异步的,界面不会卡住。
4.5 线程退出后的资源释放
线程执行完processUploadImages:之后,线程会自动结束,它内部的局部变量也会随之释放。这里几乎没有泄漏风险,因为有系统内部持有线程,执行完自动释放引用。
但如果你用了常驻线程,就得在线程退出前手动清理RunLoop里的源和定时器,否则会有一些未知的对象挂在RunLoop上,日积月累也是内存问题。实测中见过一个老项目里的常驻线程不断累加Timer不释放,内存涨到上百兆才被发现,就是因为没有在线程退出时做清理。
所以上线一个常驻线程,一定要想清楚"入"和"出"两条路,进得去还能出得来,这个线程才算用得干净。
5. 常见问题与排查技巧实录
5.1 常见问题速查表
用Thread解决问题的过程中,有些问题出现的频率极高,我整理成一个速查表,方便对号入座。
| 问题现象 | 根本原因 | 解决办法 |
|---|---|---|
| 子线程执行完,线程自动退出,再次执行任务失败 | 普通线程执行完任务即销毁,不能重复使用 | 使用常驻线程加RunLoop,或每次任务重新创建线程 |
cancel了但线程还在跑 | cancel只是设置标志位,任务代码没检查 | 在任务循环内检查isCancelled并主动break/return |
| 子线程访问UI导致崩溃或UI异常 | UIKit只能在主线程操作 | 回主线程更新UI,用performSelectorOnMainThread:或队列切回主线程 |
| 线程内改了共享集合,崩溃 | 多线程同时读写可变数据 | 加锁或用队列串行化访问 |
| 常驻线程创建后没执行任务就退出 | RunLoop没有事件源,直接返回 | 添加Port源或者Timer,再启动RunLoop |
| 同块代码有时在主线程有时在子线程,数据错乱 | 未做线程身份检查 | 用isMainThread判断分支处理 |
设置了threadPriority但效果不明显 | 系统调度以qualityOfService为主 | 优先设置qualityOfService,优先级作为补充 |
5.2 排查线程崩溃的日志定位技巧
排查线程相关崩溃时,第一步一定是看崩溃日志里的"线程列表"。
每一个线程条目都会有类似这样的信息:
- 线程名称(如果你设置过)
- 线程编号
- 对应线程的调用栈
我的经验是:崩溃日志里如果有你自己的线程名,定位问题快一半。所以再次强调,任何NSThread创建的线程,一定要name命名。命名要具体到功能语义,比如"上传队列-主处理",别起"thread1"这种没意义的名字。
其次是调用栈的顶部几个帧,确认是否在UIKit相关的函数里。如果是,又是在子线程里崩的,十有八九是UI更新跑到了子线程。
5.3 死锁问题排查思路
Thread配合锁使用时会遇到死锁。典型场景:主线程等待子线程返回,子线程又需要主线程处理某些事情才能返回。两边都在等对方,就死锁了。
排查死锁靠看"线程状态",Xcode的Debug Navigator或者Thread视图里,发抖的线状态会显示Stopped或者卡在某个线程的Lock等待处。从调用栈里找到带lock或wait的方法名,基本就是死锁点。
预防死锁有几条实操禁忌:
- 主线程里避免同步等待子线程结果。
- 多个锁的加锁顺序必须全局一致。
- 加锁范围尽量小,不要在锁内做耗时操作。
5.4 实测过的两个小建议
第一个建议:能用Thread但没必要扛太多复杂逻辑时,之间在任务方法里把结果准备好,统一回调主线程。别在线程内部又开子线程,层级一多,排查问题时的脑力成本指数级上升。
第二个建议:真要用常驻线程,优先考虑用dispatch_queue_t加dispatch_source_t或RunLoop的方案替代纯NSThread常驻。新代码我更推荐GCD队列,但如果你需要线程对象做精细控制,Thread仍然值得掌握。多学一种工具不是坏事,前提是你知道每个工具的边界。
6. Thread在项目中的影响范围分析
6.1 影响业务架构选型
一个团队里有没有真正懂Thread的开发者,会直接影响某些模块的架构选型。懂的人知道什么时候可以用常驻线程,什么时候必须切GCD;不懂的人往往把所有异步都塞给GCD,然后遇到"线程对象需要被持有"的场景就无从下手。
我做项目常常和团队定一条规矩:普通网络回调与UI刷新交给GCD,但涉及长生命周期单线程执行流(比如IM消息处理管道、图像批量处理队列)会全局评估一下,是用常驻线程还是串行队列。这种评估的底气,就来自对Thread模型的理解。
6.2 与人员能力成长的关系
对开发者个人来说,学Thread是一个"分水岭"式的知识节点。能把Thread讲清楚、用好,意味着你真正理解线程是什么,后面学RunLoop、GCD源码分析、锁机制都会事半功倍。反之,跳过Thread直接上手GCD,虽然短期内代码照样跑,但遇到疑难杂症时,缺少底层模型的支撑,排查起来会非常痛苦。
6.3 性能与体验层面的影响
讲个真事,早几年做某个数据上报模块,用常驻线程跑RunLoop,每5秒上报一次数据,线程内还加了一把大锁保护缓存数组。上线后发现CPU占用居高不下,卡顿上报明显增多。排查后发现是线程频繁切换加上锁竞争导致性能劣化。
后来把这个模块直接改成GCD定时器和串行队列,锁都省了,CPU占用骤降,体验立刻改善。这就是"工具选择影响性能"的实锤案例。
所以学Thread不只是为了写Thread代码,更是为了帮你建立一种判断力:什么时候该用它,什么时候不该用它。这种判断力,才是这篇文章最希望传达的核心。
回到Thread本身,它的角色很纯粹:把线程的控制权交到你手上。你能玩出多少花样,取决于你对线程的生命周期、RunLoop交互、数据安全这些基础知识的理解有多深。这一篇从创建方式讲到状态控制,从通信讲到常驻线程,从实现细节讲到排查技巧,希望你看完,能自己动手写一个带常驻线程的小工具试试,踩两个坑,就都通透了。