【iOS】多线程学习
前言
在写项目时,笔者发现需要很多GCD的内容,因此笔者决定先学习一下多线程,这样有利于后续对GCD的理解。
线程与进程
关系
- 线程:
- 线程是进程的基本执行单元,一个进程的所有任务都在线程中执行。
- 进程想要执行任务必须要有线程,进程至少要有一条线程。
- 程序启动时会默认开启一条线程,即主线程或UI线程。
- 进程:
- 进程是指在系统中正在运行的一个应用程序。
- 每个进程之间是独立的,每个进程均运行在其专用的且受保护的内存空间内。
- 通过"活动监视器"可以查看mac系统内开启的线程。
总结一下,进程是线程的容器,线程是用来执行任务的。在iOS中是单进程开发,一个进程就是一个app,进程之间相互独立,这样的好处是,如微信崩不影响支付宝。
区别
进程与线程二者的关系主要在:
- 地址空间:
- 同一进程的线程共享进程的地址空间。
- 而进程之间则是独立的地址空间。
- 资源拥有:
- 同一个进程内线程共享本进程的资源,如内存、I/O、CPU等。
- 而进程之间资源是独立的。
- 多进程要比多线程健壮:
- 一个进程崩溃后,在保护模式下不会对其他进程产生影响。
- 而一个线程崩溃将影响整个进程,使整个进程都死掉。
- 频繁切换,并发操作:
- 进程切换时,消耗的资源大、效率高。因此涉及到频繁的切换时,使用线程好于进程。
- 同样如果要求同时进行并且共享某些变量的并发操作,只能用线程不能用进程。
- 执行过程:
- 每个独立的进程有一个程序运行的入口、顺序执行序列和程序入口。
- 而线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
- 线程是处理器调度的基本单位,而进程不是。也就是说,CPU不会直接调度进程,而是调度进程内的线程。进程只是"资源分配的容器",而线程才是CPU实际"干活"的对象。
- 线程没有地址空间,线程包含在进程地址空间中。
多线程
原理
- 对于单核CPU,同一时间,CPU只能处理一条线程,即只有一条线程在工作。
- iOS的多线程同时执行其实不是完全意义的同时。它的本质是CPU在多个任务之间进行快速的切换,由于CPU调度线程的时间足够快,就造成了多线程同时执行的效果。其中分配给每个线程的"CPU使用权时长"就是时间片。
优缺点
- 优点:
- 能适当提高程序的执行效率
- 能适当提高资源利用率,如CPU、内存
- 线程上的任务执行完成后,线程会自动销毁
- 缺点:
- 开启线程需要占用一定内存空间。默认情况下,每个线程占用512KB
- 如果开启大量线程,会占用大量的内存空间,降低程序性能
- 线程越多,CPU在调用线程上开销就越大
- 程序设计更加复杂,如多线程数据共享,线程间通信等
生命周期

如图所示,多线程的生命周期:新建-就绪-运行-阻塞-死亡。
逐个来分析:
- 新建:实例化线程对象,但还未开始执行。
- 就绪:线程对象通过调用start方法将线程对象加入可调度线程池,等待CPU调用。这里值得注意的是,start方法不会马上执行线程的任务,只是让线程进入就绪队列。需要等待一段时间,经CPU调度后才执行。
线程池原理:
-
运行:CPU选择执行就绪线程时,就绪线程进入运行状态。在线程执行完成前,线程可能被CPU暂停、切走、重新调度,在就绪和运行之间来回切换,开发者无法控制。iOS的调度完全由CPU+系统管理。
-
阻塞:阻塞即线程暂时无法继续执行。常见原因有:
- 休眠sleep。NSThread提供了两种sleep方式:
sleepForTimeInterval::指定休眠线程时长。sleepUntilDate::指定线程休眠到某个时间点。
当sleep到时后,线程会重新进入就状态。
- 同步锁阻塞。
objc@synchronized(self) { // 只有一个线程能进来 } - 休眠sleep。NSThread提供了两种sleep方式:
-
死亡:线程执行结束后进入死亡状态,不可再运行。**死亡后的线程对象不能再次start。**死亡一般分为两种情况:
-
正常死亡:线程任务正常执行完毕。
-
非正常死亡:即在线程内部主动终止执行。可能的情况有:
- 在线程内部调用exit()
exit():终止线程。一旦强行终止线程,后续代码都不会被执行。
cancel():取消当前线程。该方法不能取消正在执行的线程。
- 外部取消线程
- 异常崩溃等
-
时间片:简单来说,就是处于运行中的线程拥有的一段可以执行的时间。
- 如果时间片用尽,线程就会进入就绪状态队列。
- 如果时间片没有用尽,且需要开始等待某件事,就会进入阻塞状态队列。
- 等待事件发生后,线程又会重新进入就绪状态队列。
- 每当一个线程离开运行,即执行完毕或强制退出后,会重新从就绪状态队列中选择一个线程继续执行。
线程优先级
在早期NSThread中,我们可以通过threadPriority设置线程的"优先级"。
objc
NSThread *thread = [[NSThread alloc] initWithBlock:^{
NSLog(@"执行任务");
}];
thread.threadPriority = 0.8;//设置优先级,取值范围为0.0~1.0,默认值为0.5
[thread start];
后来,Apple引入了"服务质量"的概念,用于更智能地管理线程优先级。在NSthread、NSOperation、GCD中都支持这种机制。
objc
NSThread *thread = [[NSThread alloc] initWithBlock:^{
NSLog(@"执行任务");
}];
thread.qualityOfService = NSQualityOfServiceUserInitiated;//设置服务质量
[thread start];
看一下这一值的api:
objc
typedef NS_ENUM(NSInteger, NSQualityOfService) {
NSQualityOfServiceUserInteractive = 0x21,
NSQualityOfServiceUserInitiated = 0x19,
NSQualityOfServiceUtility = 0x11,
NSQualityOfServiceBackground = 0x09,
NSQualityOfServiceDefault = -1
} API_AVAILABLE(macos(10.10), ios(8.0), watchos(2.0), tvos(9.0));
- NSQualityOfServiceUserInteractive:最高优先级,适用于UI操作、动画、响应用户点击等需要立即完成的事件。
- NSQualityOfServiceUserInitiated:较高优先级,适用于用户发起、需要尽快完成的任务,例如打开文件。
- NSQualityOfServiceUtility:中等优先级,适用于不太急的后台任务,如下载、计算等。
- NSQualityOfServiceBackground:低优先级,适用于后台维护、同步、清理缓存等。
- NSQualityOfServiceDefault:默认值,没有明确指定时使用。
iOS多线程
iOS多线程的实现方式主要有以下四种:
- pthread:最底层C语言线程。完全由我们手动创建线程,手动管理参数、生命周期。不能直接写OC方法,使用繁琐。
objc
#include <pthread/pthread.h>
void *pthreadTest(void *para) {
NSString *name = (__bridge NSString*)(para);
NSLog(@"%@, %@", [NSThread currentThread], name);
return NULL;
}
objc
pthread_t threadID = NULL;
char *cString = "pthread";
int result = pthread_create(&threadID, NULL, pthreadTest, cString);
if (result == 0) {
NSLog(@"成功");
} else {
NSLog(@"失败");
}
- NSThread:比pthread简单,可以直接执行OC方法,但仍需要我们手动管理线程。
objc
[NSThread detachNewThreadSelector:@selector(threadTest) toTarget:self withObject:nil];
- GCD:性能最高,无需手动创建线程,线程池自动管理。
objc
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(globalQueue, ^{
[self threadTest];
});
- NSOperation:面向对象多线程,功能最强,自动管理线程。可取消暂停,支持依赖关系,可限制最大并发线程数,比GCD更可控。
objc
[[[NSOperationQueue alloc] init] addOperationWithBlock:^{
[self threadTest];
}];
objc
-(void)threadTest {
NSLog(@"开始");
for (int i = 0; i < 10; i++) {
NSInteger num = i;
NSString *name = @"iOS";
NSString *myName = [NSString stringWithFormat:@"%@ - %ld", name, (long)num];
NSLog(@"%@", myName);
}
NSLog(@"结束");
}
线程安全
当多个线程同时访问一块资源时,容易引发数据错乱和数据安全问题。有以下两种解决方法:
- 互斥锁
- 自旋锁
互斥锁
互斥锁就是让线程"睡觉"等锁。
- 用于保护临界区,确保同一时间,只有一个线程能进入临界区。
- 当新线程访问加了互斥锁的代码时,如果发现其他线程正在执行锁住的这部分代码,该新线程就会进入休眠(即阻塞)。
- 因为线程睡眠,互斥锁不会浪费CPU资源。
- 可长期锁定,适合锁住时间较长的任务。
使用时,还需注意:
- 互斥锁的锁定范围应该尽量小。锁定范围越大,效率越差。
- 锁对象一定要保证所有的线程都能被访问到。
自旋锁
自旋锁就是让线程"原地打转"不停尝试拿锁。
- 自旋锁与互斥锁不同,它不通过休眠使线程阻塞,而是在获取锁之前一直处于"疯狂试试试试"的阻塞状态(形象于原地打转,因此称为自旋)。
- 锁持有的时间短,适用于执行时间非常短的锁。
- 当新线程访问加了自旋锁的代码时,如果发现有其他线程正在执行锁住的这部分代码,新线程就会用死循环的方式一直等待锁定的代码执行完成,即不停的尝试执行代码,比较消耗性能。
总结一下,自旋锁和互斥锁的关系:
- 相同点:保证了在同一时间只有一条线程执行任务,即保证了相应同步的功能。
- 不同点:
- 互斥锁:发现其他线程执行,当前线程休眠(即就绪状态),进入等待执行。直到等到其他现线程打开后,停止休眠,唤醒执行。
- 自旋锁:发现其他线程执行,当前线程一直访问(即原地打转),处于忙等状态,耗费性能高。
- 运用场景:根据任务复杂度,区分使用不同的锁。更多情况下使用互斥锁去处理。执行时间短用自旋锁,执行时间长用互斥锁。自旋锁快但耗CPU,互斥锁慢但省CPU。
atomic&nonatomic
二者都主要用于属性的修饰。
- atomic:原子属性,为多线程开发准备的,是默认属性。Mac开发中常用。
- nonatomic:非原子属性。没有锁,性能高,iOS开发常用。
这里看一下atomic的底层实现:
atomic的底层最早是通过轻量级自旋锁OSSpinLock实现的,后来因存在优先级反转问题被弃用,现在内部已经改成使用os_unfair_lock。
objc
static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
if (offset == 0) {
object_setClass(self, newValue);
return;
}
id oldValue;
id *slot = (id*) ((char*)self + offset);
if (copy) {
newValue = [newValue copyWithZone:nil];
} else if (mutableCopy) {
newValue = [newValue mutableCopyWithZone:nil];
} else {
if (*slot == newValue) return;
newValue = objc_retain(newValue);
}
if (!atomic) {
oldValue = *slot;
*slot = newValue;
} else {
spinlock_t& slotlock = PropertyLocks[slot];
slotlock.lock();
oldValue = *slot;
*slot = newValue;
slotlock.unlock();
}
objc_release(oldValue);
}
从源码中我们可以看出,对于atomic修饰的属性进行了spinlock_t加锁处理。这里的spinlock_t在底层是通过os_unfair_lock替代了OSSpinLock实现的加锁。同时为了避免哈希冲突,还使用了加盐操作。
加盐:
objcPropertyLocks[slot]PropertyLocks是一个哈希表,key是属性的内存地址slot,value是该属性专属的锁。为了让哈希更分散,减少冲突,苹果会对key进行一次"扰动处理",即加盐,让哈希更均匀。这样避免了哈希冲突,就使得性能更加。
那么为什么要对属性指针加盐呢?
因为slot的值是对象地址+offset。对于同一类示例来说:offset固定,对象地址通常16字节对齐,导致slot很有规律,容易产生哈希冲突。因此,Runtime必须对slot进行"加盐"操作让它变随机,避免冲突。
那么为什么atomic属性要用哈希表呢?
每一个对象的每一个属性都可能被atomic访问,所以需要对每个属性位置加一把锁。如果不用哈希,生成锁的代价会很高。
总结一下,就是atomic属性底层需要为每个属性slot找一把锁,这个锁来自全局PropertyLocks哈希表。为了避免slot指针在哈希表中产生大量冲突,Runtime在计算hash时会对key,也就是slot指针做随机加盐,使其分布更随机高效。
总结一下atomic、nonatomic二者的区别:
- nonatomic:
- 非原子属性。
- setter/getter不加锁,速度最快。
- 线程不安全,多个线程可能同时读写。
- atomic:
- 原子属性。
- setter/getter由系统自动加锁。
- 同一时刻只允许一个线程写,多个线程可以同时读。
但是要注意,atomic修饰的属性不保证绝对安全。atomic只保证setter和getter方法的线程安全,并不能保证数据安全。
总结
笔者这篇文章简单总结了有关多线程的内容,后续更加深入学习将补充完善此篇博客。
