☰
iOS多线程底层解析:NSThread从创建到常驻线程的完整实践
2026/10/16 8:22:37 网站建设 项目流程

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交互、数据安全这些基础知识的理解有多深。这一篇从创建方式讲到状态控制,从通信讲到常驻线程,从实现细节讲到排查技巧,希望你看完,能自己动手写一个带常驻线程的小工具试试,踩两个坑,就都通透了。

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

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

立即咨询