你要知道的iOS多线程NSThread、GCD、NSOperation、RunLoop都在这里
转载请注明出处 http://www.jianshu.com/p/973f0a5e0ec3
本系列文章主要讲解iOS中多线程的使用,包括:NSThread、GCD、NSOperation以及RunLoop的使用方法详解,本系列文章不涉及基础的线程/进程、同步/异步、阻塞/非阻塞、串行/并行,这些基础概念,有不明白的读者还请自行查阅。本系列文章将分以下几篇文章进行讲解,读者可按需查阅。
- iOS多线程——你要知道的NSThread都在这里
- iOS多线程——你要知道的GCD都在这里
- iOS多线程——你要知道的NSOperation都在这里
- iOS多线程——你要知道的RunLoop都在这里
- iOS多线程——RunLoop与GCD、AutoreleasePool
组织架构说明
本系列文章是按照相关多线程类的抽象层次撰写的,也就是说NSThread
是Foundation
框架提供的最基础的多线程类,每一个NSThread
类的对象即代表一个线程,接下来苹果为开发者封装了GCD(Grand Central Dispatch)
,GCD
相比于NSThread
来说,提供了便捷的操作方法,开发者不需要再关注于管理线程的生命周期,也不需要自行管理一个线程池用于线程的复用,但GCD
是以C函数
对外提供接口,因此Foundation
框架在GCD
的基础上进行了面向对象的封装,提供了面向对象的多线程类NSOperation
和NSOperationQueue
,抽象层次更高。
由于OC
是C语言
的超集,开发者也可以选择使用POSIX
标准的线程pthread
,pthread
和NSThread
都是对内核mach kernel
的mach thread
的封装,所以在开发时一般不会使用pthread
。
RunLoop
是与线程相关的一个基本组成,想要线程在执行完任务后不退出,在没有任务时睡眠以节省CPU资源都需要RunLoop
的实现,因此,正确的理解线程就需要深入理解RunLoop
相关知识。
NSThread的使用姿势全解
在组织架构说明
中讲到,NSThread
是对内核mach kernel
中的mach thread
的封装,所以,每一个NSThread
的对象其实就是一个线程,我们创建一个NSThread
对象也就意味着我们创建了一个新的线程。初始化创建NSThread
的方法有如下几种:
/*
使用target对象的selector作为线程的任务执行体,该selector方法最多可以接收一个参数,该参数即为argument
*/
- (instancetype)initWithTarget:(id)target selector:(SEL)selector object:(nullable id)argument API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
/*
使用block作为线程的任务执行体
*/
- (instancetype)initWithBlock:(void (^)(void))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
/*
类方法,返回值为void
使用一个block作为线程的执行体,并直接启动线程
上面的实例方法返回NSThread对象需要手动调用start方法来启动线程执行任务
*/
+ (void)detachNewThreadWithBlock:(void (^)(void))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
/*
类方法,返回值为void
使用target对象的selector作为线程的任务执行体,该selector方法最多接收一个参数,该参数即为argument
同样的,该方法创建完线程后会自动启动线程不需要手动触发
*/
+ (void)detachNewThreadSelector:(SEL)selector toTarget:(id)target withObject:(nullable id)argument;
下面分别举几个栗子:
/*
说明: 本文的栗子都是在单视图的工程中执行,防止主线程退出后,其他线程被退出,不方便实验。
*/
//线程的任务执行体并接收一个参数arg
- (void)firstThread:(id)arg
{
for (int i = 0; i < 10; i++)
{
NSLog(@"Task %@ %@", [NSThread currentThread], arg);
}
NSLog(@"Thread Task Complete");
}
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear: YES];
/*
创建一个线程,线程任务执行体为firstThread:方法
该方法可以接收参数@"Hello, World"
*/
NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(firstThread:) object:@"Hello, World"];
//设置线程的名字,方便查看
[thread setName:@"firstThread"];
//启动线程
[thread start];
}
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear: YES];
NSLog("ViewDidAppear");
}
上面的栗子没有什么实际意义,仅仅为了展示如何创建并启动线程,启动程序后就可以看到程序输出了10次
Task <NSThread: 0x1c446f780>{number = 4, name = firstThread} Hello, World
上面输出了线程的名称,还输出了我们传入的参数,通过很简单的代码就可以创建一个新的线程来执行任务,在开发中尽量将耗时的操作放在其他线程中执行,只将更新UI的操作放在主线程中执行。
一般情况下,通过上述方法创建的线程在执行完任务执行体后就会退出并销毁,可以在firstThread:
方法的第二个NSLog
方法和viewDidAppear:
方法的输出上打断点,然后运行程序查看线程信息,在第一个断点时即firstThread:
方法的断点中,程序中线程信息如下图:
从上图可以看到,现在程序中有一个线程名为firstThread
,该线程即为我们创建的NSThread
对象,而com.apple.main-thread(serial)
即为主线程的名称,其中serial
是指明主线程是串行的,这个内容会在GCD
中进行讲解,我们可以通过类方法[NSThread mainThread]
来获取主线程。接下来继续执行到第二个断点,程序中线程信息如下图:
从上图可以看到,firstThread
线程不见了,因为在执行完任务执行体后该线程就退出并被销毁了,
通过这个栗子也说明了,我们无法复用NSThread
,尽管线程的创建相比进程更加轻量级,但创建一个线程远比创建一个普通对象要消耗资源,而主线程和接收事件处理的线程仍然存在,这正是因为RunLoop
的作用,这个内容也会在RunLoop
部分进行讲解。
接下来继续讲解创建NSThread
的其他方法,具体栗子如下:
//栗子2:
/*
通过传入block的方式创建一个线程,线程执行体即为block的内容
但该方式创建线程无法传入参数
*/
NSThread *thread = [[NSThread alloc] initWithBlock:^{
for (int i = 0; i < 100; i++)
{
NSLog(@"Task %@", [NSThread currentThread]);
}
}];
//设置线程名称
[thread setName:@"firstThread"];
//启动线程
[thread start];
//栗子3:
/*
通过类方法创建并自动启动一个线程
该线程的执行体即为传入的block
*/
[NSThread detachNewThreadWithBlock:^{
for (int i = 0; i < 100; i++)
{
NSLog(@"Task %@", [NSThread currentThread]);
}
}];
//栗子4:
/*
通过类方法创建并自动启动一个线程
该线程的执行体为self的firstThread:方法,并传入相关参数
*/
[NSThread detachNewThreadSelector:@selector(firstThread:) toTarget:self withObject:@"Hello, World!"];
上述把所有NSThread
的创建方法都讲解了一遍,实例方法和类方法的区别就在于,实例方法会返回NSThread
对象,当需要启动线程时需要手动触发start
方法,而类方法没有返回值,创建线程后立即启动该线程。这里说的启动线程start
方法,仅仅是将线程的状态从新建转为就绪,何时执行该线程的任务需要系统自行调度。
接下来再看NSThread
中几个比较常用的属性和方法:
/*
类属性,用于获取当前线程
如果是在主线程调用则返回主线程对象
如果在其他线程调用则返回其他的当前线程
什么线程调用,就返回什么线程
*/
@property (class, readonly, strong) NSThread *currentThread;
//类属性,用于返回主线程,不论在什么线程调用都返回主线程
@property (class, readonly, strong) NSThread *mainThread;
/*
设置线程的优先级,范围为0-1的doule类型,数字越大优先级越高
我们知道,系统在进行线程调度时,优先级越高被选中到执行状态的可能性越大
但是我们不能仅仅依靠优先级来判断多线程的执行顺序,多线程的执行顺序无法预测
*/
@property double threadPriority;
//线程的名称,前面的栗子已经介绍过了
@property (nullable, copy) NSString *name
//判断线程是否正在执行
@property (readonly, getter=isExecuting) BOOL executing;
//判断线程是否结束
@property (readonly, getter=isFinished) BOOL finished;
//判断线程是否被取消
@property (readonly, getter=isCancelled) BOOL cancelled;
/*
让线程睡眠,立即让出当前时间片,让出CPU资源,进入阻塞状态
类方法,什么线程执行该方法,什么线程就会睡眠
*/
+ (void)sleepUntilDate:(NSDate *)date;
//同上,这里传入时间
+ (void)sleepForTimeInterval:(NSTimeInterval)ti;
//退出当前线程,什么线程执行,什么线程就退出
+ (void)exit;
/*
实例方法,取消线程
调用该方法会设置cancelled属性为YES,但并不退出线程
*/
- (void)cancel;
接下来再举一个栗子:
//按钮点击事件处理器
- (void)btnClicked
{
//取消线程
[self.thread cancel];
}
- (void)viewWillAppear:(BOOL)animated
{
self.thread = [[NSThread alloc] initWithBlock:^{
for (int i = 0; i < 100; i++)
{
//获取当前正在执行的线程,即self.thread
NSThread *currentThread = [NSThread currentThread];
//判断线程是否被取消
if ([currentThread isCancelled])
{
//如果被取消就退出当前正在执行的线程,即self.thread
[NSThread exit];
}
NSLog(@"Task %@", currentThread);
//循环内,每次循环睡1s
[NSThread sleepForTimeInterval:1];
}
}];
[self.thread setName:@"firstThread"];
//启动线程
[self.thread start];
}
上面的栗子也比较简单,在视图中加入了一个按钮,点击按钮就会让我们创建的线程执行退出方法,在viewWillAppear:
方法中创建并启动了一个线程,这个线程每次循环都会判断当前线程是否被取消,如果取消就退出当前线程,接下来线程就会被销毁,每次循环执行完后都会让当前线程睡眠一秒,这里可能很多人都会有误区,让线程睡眠会使得线程进入阻塞状态,当睡眠时间到后就会从阻塞状态进入就绪状态,被系统线程调度为执行状态后才能继续执行,所以这里睡1s并不是说精准的1s后再继续执行,只是1s后从阻塞态进入就绪态,之后何时执行由系统调度决定。还需要说明的是cancel
方法并不会让线程退出,仅仅是将cancelled
属性置为YES
,退出需要我们手动触发exit
方法。
所以执行上述代码后,每一秒多会输出一次,当我们点击按钮后该线程就会将cancelled
属性置为YES
,在线程下次执行时就会执行exit
方法退出线程,退出线程会立即终止当前执行的任务,也就是说exit
方法后的代码不会再执行了。
退出线程有如下三种情况:
- 任务执行体执行完成后正常退出
- 任务执行体执行过程中发生异常也会导致当前线程退出
- 执行NSThread类的exit方法退出当前线程
关于优先级的栗子就不再赘述了,可以自行实验,比如,启动两个线程,使用for循环来输出文本,并设置不同的优先级,可以发现,优先级高的线程获取到时间片即能够执行输出的机会高于优先级低的。
接下来举一个多线程下载图片的简单栗子:
- (void)viewWillAppear:(BOOL)animated
{
//创建一个线程用来下载图片
NSThread *thread = [[NSThread alloc] initWithBlock:^{
UIImage *image = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:@"https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1508398116220&di=ba2b7c9bf32d0ecef49de4fb19741edb&imgtype=0&src=http%3A%2F%2Fwscont2.apps.microsoft.com%2Fwinstore%2F1x%2Fea9a3c59-bb26-4086-b823-4a4869ffd9f2%2FScreenshot.398115.100000.jpg"]]];
//图片下载完成之后使用主线程来执行更新UI的操作
[self performSelectorOnMainThread:@selector(updateImage:) withObject:image waitUntilDone:NO];
}];
//启动线程
[thread start];
}
//主线程执行当前更新UI的方法
- (void)updateImage:(UIImage*)image
{
self.imageView.image = image;
}
上面使用了NSObject
提供的performSelectorOnMainThread:WithObject:watiUntilDone:
方法,该方法就是用于使用主线程执行相关方法,iOS
对于更新UI的操作有规定,必须放在主线程执行,否则会产生运行时警告,最重要的是,不在主线程执行无法预知什么时候才会进行更新操作,可能会产生各种意外。
NSThread 锁机制 经典的生产者消费者问题
提到多线程必然会考虑竞争条件
,OC
也为我们提供了同步的机制以及锁的机制,接下来举一个炒鸡经典的银行取钱的栗子:
//定义一个Account类
@interface Account: NSObject
//账号
@property (nonatomic, strong) NSString *accountNumber;
//余额
@property (nonatomic, assign) double balance;
//取钱操作
- (void)draw:(id)money;
@end
@implementation Account
@synthesize accountNumber = _accountNumber;
@synthesize balance = _balance;
- (void)draw:(id)money
{
double drawMoney = [money doubleValue];
//判断余额是否足够
if (self.balance >= drawMoney)
{
//当前线程睡1毫秒
//[NSThread sleepForTimeInterval:0.001];
self.balance -= drawMoney;
NSLog(@"%@ draw money %lf balance left %lf", [[NSThread currentThread] name], drawMoney, self.balance);
}
else
{
//余额不足,提示
NSLog(@"%@ Balance Not Enouth", [[NSThread currentThread] name]);
}
}
@end
//ViewController.m
- (void)viewWillAppear:(BOOL)animated
{
Account *account = [[Account alloc] init];
account.accountNumber = @"1603121434";
account.balance = 1500.0;
NSThread *thread1 = [[NSThread alloc] initWithTarget:account selector:@selector(draw:) object:@(1000)];
[thread1 setName:@"Thread1"];
NSThread *thread2 = [[NSThread alloc] initWithTarget:account selector:@selector(draw:) object:@(1000)];
[thread2 setName:@"Thread2"];
[thread1 start];
[thread2 start];
}
上面这个栗子很简单,定义了一个Account
类表示银行账户,然后定义了取钱的操作,在draw:
方法里,注释了[NSThread sleepForTimeInterval:0.001];
代码,然后在视图中创建了两个线程,都去取钱,运行上述程序我们发现线程1取到钱了,线程2提示余额不足,但这个结果不一定正确,我们提到过,多线程的执行顺序是无法预测的,哪怕线程2的优先级比线程1低,也有可能线程2先执行,所以我们把注释的一行去掉注释,来模拟第一个线程进入到取钱的判断条件体以后被系统线程调度切换,此时的输出结果为:
Thread1 draw money 1000.000000 balance left 500.000000
Thread2 draw money 1000.000000 balance left -500.000000
这就是竞争条件
,这里不再赘述什么是竞争条件
,线程1进入判断体后还没有进行取钱的操作就被切换到就绪态,系统切换线程2执行,由于线程1还没有进行取钱操作,所以余额是满足要求的,线程2也进入了判断体,这样两个线程都可以取到钱。
解决竞争条件
的方法很多,比如锁机制和同步代码块,接下来分别举两个栗子:
//栗子2:
- (void)draw:(id)money
{
@synchronized (self) {
double drawMoney = [money doubleValue];
if (self.balance >= drawMoney)
{
[NSThread sleepForTimeInterval:0.001];
self.balance -= drawMoney;
NSLog(@"%@ draw money %lf balance left %lf", [[NSThread currentThread] name], drawMoney, self.balance);
}
else
{
NSLog(@"%@ Balance Not Enouth", [[NSThread currentThread] name]);
}
}
}
//栗子3:
- (void)draw:(id)money
{
/*
self.lock在ViewController的初始化函数中进行初始化操作
self.lock = [[NSLock alloc] init];
*/
[self.lock lock];
double drawMoney = [money doubleValue];
if (self.balance >= drawMoney)
{
[NSThread sleepForTimeInterval:0.001];
self.balance -= drawMoney;
NSLog(@"%@ draw money %lf balance left %lf", [[NSThread currentThread] name], drawMoney, self.balance);
}
else
{
NSLog(@"%@ Balance Not Enouth", [[NSThread currentThread] name]);
}
[self.lock unlock];
}
在栗子2中,我们对draw:
方法添加了一个同步代码块,使用@synchronized
包围的代码即为同步代码块,同步代码块需要一个监听器,我们使用account
对象本身作为监听器,因为是account
对象产生的竞争条件
,当执行同步代码块时需要先获取监听器,如果获取不到则线程会被阻塞,当同步代码块执行完成则释放监听器,与java
的synchronized
同步代码块一样。
栗子3,我们使用锁机制,创建了一个NSLock
类的锁对象,lock
方法用于获取锁,如果锁被其他对象占用则线程被阻塞,unlock
方法用于释放锁,以便其他线程加锁。
线程的调度对于开发者来说是透明的,我们不能也无法预测线程执行的顺序,但有时我们需要线程按照一定条件来执行,这时就需要线程间进行通信,NSCondition
就提供了线程间通信的方法,查看一下NSCondition
的声明文件:
NS_CLASS_AVAILABLE(10_5, 2_0)
@interface NSCondition : NSObject <NSLocking> {
@private
void *_priv;
}
/*
调用NSCondition对象wait方法的线程会阻塞,直到其他线程调用该对象的signal方法或broadcast方法来唤醒
唤醒后该线程从阻塞态改为就绪态,交由系统进行线程调度
执行wait方法时内部会自动执行unlock方法释放锁,并阻塞线程
*/
- (void)wait;
//同上,只是该方法是在limit到达时唤醒线程
- (BOOL)waitUntilDate:(NSDate *)limit;
/*
唤醒在当前NSCondition对象上阻塞的一个线程
如果在该对象上wait的有多个线程则随机挑选一个,被挑选的线程则从阻塞态进入就绪态
*/
- (void)signal;
/*
同上,该方法会唤醒在当前NSCondition对象上阻塞的所有线程
*/
- (void)broadcast;
@property (nullable, copy) NSString *name API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
@end
NS_ASSUME_NONNULL_END
NSCondition
实现了NSLocking
协议,所以NSCondition
同样具有锁的功能,与NSLock
一样可以获取锁与释放锁的操作。了解了NSCondition
基本方法,就可以实现生产者消费者问题了:
@interface Account: NSObject
@property (nonatomic, strong) NSString *accountNumber;
@property (nonatomic, assign) double balance;
@property (nonatomic, strong) NSCondition *condition;
@property (nonatomic, assign) BOOL haveMoney;
- (void)deposite:(id)money;
- (void)draw:(id)money;
@end
@implementation Account
@synthesize accountNumber = _accountNumber;
@synthesize balance = _balance;
@synthesize condition = _condition;
@synthesize haveMoney = _haveMoney;
//NSCondition的getter,用于创建NSCondition对象
- (NSCondition*)condition
{
if (_condition == nil)
{
_condition = [[NSCondition alloc] init];
}
return _condition;
}
- (void)draw:(id)money
{
//设置消费者取钱20次
int count = 0;
while (count < 20)
{
//首先使用condition上锁,如果其他线程已经上锁则阻塞
[self.condition lock];
//判断是否有钱
if (self.haveMoney)
{
//有钱则进行取钱的操作,并设置haveMoney为NO
self.balance -= [money doubleValue];
self.haveMoney = NO;
count += 1;
NSLog(@"%@ draw money %lf %lf", [[NSThread currentThread] name], [money doubleValue], self.balance);
//取钱操作完成后唤醒其他在次condition上等待的线程
[self.condition broadcast];
}
else
{
//如果没有钱则在次condition上等待,并阻塞
[self.condition wait];
//如果阻塞的线程被唤醒后会继续执行代码
NSLog(@"%@ wake up", [[NSThread currentThread] name]);
}
//释放锁
[self.condition unlock];
}
}
- (void)deposite:(id)money
{
//创建了三个取钱线程,每个取钱20次,则存钱60次
int count = 0;
while (count < 60)
{
//上锁,如果其他线程上锁了则阻塞
[self.condition lock];
//判断如果没有钱则进行存钱操作
if (!self.haveMoney)
{
//进行存钱操作,并设置haveMoney为YES
self.balance += [money doubleValue];
self.haveMoney = YES;
count += 1;
NSLog(@"Deposite money %lf %lf", [money doubleValue], self.balance);
//唤醒其他所有在condition上等待的线程
[self.condition broadcast];
}
else
{
//如果有钱则等待
[self.condition wait];
NSLog(@"Deposite Thread wake up");
}
//释放锁
[self.condition unlock];
}
}
@end
- (void)viewWillAppear:(BOOL)animate
{
[super viewWillAppear:YES];
Account *account = [[Account alloc] init];
account.accountNumber = @"1603121434";
account.balance = 0;
//消费者线程1,每次取1000元
NSThread *thread = [[NSThread alloc] initWithTarget:account selector:@selector(draw:) object:@(1000)];
[thread setName:@"consumer1"];
//消费者线程2,每次取1000元
NSThread *thread2 = [[NSThread alloc] initWithTarget:account selector:@selector(draw:) object:@(1000)];
[thread2 setName:@"consumer2"];
//消费者线程3,每次取1000元
NSThread *thread3 = [[NSThread alloc] initWithTarget:account selector:@selector(draw:) object:@(1000)];
[thread3 setName:@"consumer3"];
//生产者线程,每次存1000元
NSThread *thread4 = [[NSThread alloc] initWithTarget:account selector:@selector(deposite:) object:@(1000)];
[thread4 setName:@"productor"];
[thread start];
[thread2 start];
[thread3 start];
[thread4 start];
}
上面这个栗子也比较简单,关于NSCondition
需要注意的就是它的wait
方法,在执行wait
方法前按照逻辑当然是要先获取锁,避免竞争条件
,执行wait
方法后会阻塞当前线程,直到其他线程调用这个condition
来唤醒被阻塞的线程,被阻塞的线程唤醒后进入就绪态,当被调度执行后会重新获取锁并在wait
方法下一行代码继续执行。还有一个要注意的地方就是是否有钱的haveMoney
这个flag,这个flag存在的意义就是,当线程被唤醒后进入就绪态,接下来系统线程调度具体调度哪个线程来执行开发者是不知道的,也就是说我们无法预知接下来执行的是生产者还是消费者,为了避免错误,加一个flag用于判断。
上面代码的写法是按照苹果官方文档的顺序写的,更多关于NSCondition
可查阅官方文档:Apple NSCondition
备注
由于作者水平有限,难免出现纰漏,如有问题还请不吝赐教。