1.2 使用NSThread实现多线程
前面已经简单介绍过,NSThread类是实现多线程的一种方案,也是实现多线程的最简单方式。本节将针对NSThread相关的内容进行详细的介绍。
1.2.1 线程的创建和启动
在iOS开发中,通过创建一个NSThread类的实例作为一个线程,一个线程就是一个NSThread对象。要想使用NSThread类创建线程,有3种方法,具体如下所示:
// 1.创建新的线程 - (instancetype)initWithTarget:(id)target selector:(SEL)selector object:(id)argument // 2.创建线程后自动启动线程 + (void)detachNewThreadSelector:(SEL)selector toTarget:(id)target withObject:(id)argument; // 3.隐式创建线程 - (void)performSelectorInBackground:(SEL)aSelector withObject:(id)arg;
在上述代码中,这3种方法都是将target对象或者其所在对象的selector方法转化为线程的执行者。其中,selector方法最多可以接收一个参数,而object后面对应的就是它接收的参数。
这3种方法中,第1种方法是对象方法,返回一个NSThread对象,并可以对该对象进行详细的设置,必须通过调用start方法来启动线程;第2种方法是类方法,创建对象成功之后就会直接启动线程,前两个方法没有本质的区别;第3种创建方式属于隐式创建,主要在后台创建线程。
除了以上3种方法,NSThread类还提供了两个方法用于获取当前线程和主线程,具体的定义格式如下:
// 获取当前线程 + (NSThread *)currentThread; // 获得主线程 + (NSThread *)mainThread;
为了大家能够更好地理解,接下来通过一个示例讲解如何运用以上3种方法创建并启动线程,具体步骤如下所示。
(1)新建一个Single View Application应用,名称为01-NSThreadDemo。
(2)进入Main.StoryBoard,从对象库添加一个Button和一个Text View。其中,Button用于响应用户单击事件,而Text View用于测试线程的阻塞,设计好的界面如图1-8所示。
图1-8 设计完成的界面
(3)将StoryBoard上面的Button通过拖曳的方式,在ViewController.m中进行声明以响应btnClick:消息。通过3种创建线程的方法创建线程,这3种方法分别被封装在threadCreate1、threadCreate2、threadCreate3三个方法中,之后依次在btnClick:中被调用,代码如例1-1所示。
【例1-1】ViewController.m
1 #import "ViewController.h" 2 @interface ViewController () 3 // 按钮被单击 4 - (IBAction)btnClick:(id)sender; 5 @end 6 @implementation ViewController 7 - (IBAction)btnClick:(UIButton *)sender { 8 // 获取当前线程 9 NSThread *current = [NSThread currentThread]; 10 NSLog(@"btnClick--%@--current", current); 11 // 获取主线程 12 NSThread *main = [NSThread mainThread]; 13 NSLog(@"btnClick--%@--main", main); 14 [self threadCreate1]; 15 } 16 - (void)run:(NSString *)param 17 { 18 // 获取当前线程 19 NSThread *current = [NSThread currentThread]; 20 for (int i = 0; i<10; i++) { 21 NSLog(@"%@----run---%@", current, param); 22 } 23 } 24 // 第1种创建方式 25 - (void)threadCreate1{ 26 NSThread *threadA = [[NSThread alloc] initWithTarget:self 27 selector:@selector(run:) object:@"哈哈"]; 28 threadA.name = @"线程A"; 29 // 开启线程A 30 [threadA start]; 31 NSThread *threadB = [[NSThread alloc] initWithTarget:self 32 selector:@selector(run:) object:@"哈哈"]; 33 threadB.name = @"线程B"; 34 // 开启线程B 35 [threadB start]; 36 } 37 // 第2种创建方式 38 - (void)threadCreate2{ 39 [NSThread detachNewThreadSelector:@selector(run:) 40 toTarget:selfwithObject:@"我是参数"]; 41 } 42 //隐式创建线程且启动,在后台线程中执行,也就是在子线程中执行 43 - (void)threadCreate3{ 44 [self performSelectorInBackground:@selector(run:) withObject:@" 参数3"]; 45 } 46 // 测试阻塞线程 47 - (void)test{ 48 NSLog(@"%@",[NSThread currentThread]); 49 for (int i = 0; i<10000; i++) { 50 NSLog(@"---------%d", i); 51 } 52 } 53 @end
在例1-1中,第14行代码调用了threadCreate1方法,选择第1种方式创建并启动线程。第25~36行代码创建了threadA和threadB两条线程,并调用start方法开启了线程。线程一旦启动,就会在线程thread中执行self的run:方法,并且将文字“哈哈”作为参数传递给run:方法。程序的运行结果如图1-9所示。
图1-9 第1种方式的运行结果
从图1-9中可以看出,主线程的number值为1,且btnClick操作的当前线程的number值也为1,说明按钮单击事件被系统自动放在主线程中。然后可以看到线程A和线程B并发执行的效果,它们的number值分别为2和3,属于不同子线程。
(4)修改第14行代码为“[self threadCreate2];”,选择第2种方式。第38~41行代码创建了一个线程,线程一旦启动,就会在线程thread中执行self的run:方法,并且将文字“我是参数”作为参数传递给run:方法,程序的运行结果如图1-10所示。
从图1-10中可以看出,创建了一个number值为2的子线程,并且run:方法获取到了“我是参数”这个参数。
图1-10 第2种方式的运行结果
(5)修改第14行代码为“[self threadCreate3];”,隐式创建一个线程。第43~45行代码创建了一个线程,线程一旦启动,就会在线程thread中执行self的run:方法,并且将“参数3”作为参数传递给run:方法,程序的运行结果如图1-11所示。
图1-11 第3种方式的运行结果
从图1-11中可以看出,创建了一个number值为2的子线程,并且run:方法获取到了“参数3”这个参数。
(6)修改第14行代码为“[self test];”,用于测试线程的阻塞情况。重新运行程序,单击按钮后,发现Debug输出栏一直在打印输出,说明线程仍被占用。这时,拖曳屏幕中的文本视图,发现该文本视图没有任何响应。待输出栏停止输出的时候,将输出栏的滚动条滑至顶部,程序的运行结果如图1-12所示。
图1-12 test阻塞运行结果
从图1-12可以看出,test方法执行时所处的线程为主线程,如果把大量耗时的操作放在主线程当中,就会阻塞主线程,影响主线程的其他操作正常响应。
1.2.2 线程的状态
当线程被创建并启动之后,它既不是一启动就进入了执行状态,也不是一直处于执行状态,即便线程开始运行以后,它也不可能一直占用着CPU独自运行。由于CPU需要在多个线程之间进行切换,造成了线程的状态也会在多次运行、就绪之间进行切换,如图1-13所示。
图1-13 线程状态的切换
由图1-13可知,线程主要有5个状态,并按照相应的逻辑顺利地在这几个状态中切换。这些状态的具体介绍如下。
1. 新建(New)
当程序新建了一个线程之后,该线程就处于新建状态。这时,它和其他对象一样,仅仅是由系统分配了内存,并初始化了其内部成员变量的值,此时的线程没有任何动态特征。
2. 就绪(Runable)
当线程对象调用了start方法后,该线程就处于就绪状态,系统会为其创建方法调用的栈和程序计数器,处于这种状态中的线程并没有开始运行,只是代表该线程可以运行了,但到底何时开始运行,由系统来进行控制。
3. 运行(Running)
当CPU调度当前线程的时候,将其他线程挂起,当前线程变成运行状态;当CPU调度其他线程的时候,当前线程处于就绪状态。要测试某个线程是否正在运行,可以调用isExecuting方法,若返回YES,则表示该线程处于运行状态。
4. 终止(Exit)
当线程遇到以下3种情况时,线程会由运行状态切换到终止状态,具体如下。
(1)线程执行方法执行完成,线程正常结束。
(2)线程执行的过程中出现了异常,线程崩溃结束。
(3)直接调用NSThread类的exit方法来终止当前正在执行的线程。
若要测试某个线程是否结束,可以调用isFinished方法判断,若返回YES,则表示该线程已经终止。
5. 阻塞(Blocked)
如果当前正在执行的线程需要暂停一段时间,并进入阻塞状态,可以通过NSThread类提供的两个类方法来完成,具体定义格式如下:
// 让当前正在执行的线程暂停到date参数代表的时间,并且进入阻塞状态 + (void)sleepUntilDate:(NSDate *)date; // 让正在执行的线程暂停ti秒,并且进入阻塞状态。 + (void)sleepForTimeInterval:(NSTimeInterval)ti;
需要注意的是,当线程进入阻塞状态之后,在其睡眠的时间内,该线程不会获得执行的机会,即便系统中没有其他可执行的线程,处于阻塞状态的线程也不会执行。
1.2.3 线程间的安全隐患
进程中的一块资源可能会被多个线程共享,也就是多个线程可能会访问同一块资源,这里的资源包括对象、变量、文件等。当多个线程同时访问同一块资源时,会造成资源抢夺,很容易引发数据错乱和数据安全问题。
这里有一个很经典的卖火车票的例子,假设有1000张火车票,同时开启两个窗口执行卖票的动作,每出售一张票后就返回当前的剩余票数,由于两个线程共同抢夺1000张的票数资源,容易造成剩余票数混乱,具体如图1-14所示。
图1-14 卖火车票案例
在图1-14所示案例中,两个线程同时读取当前票数是1000,然后线程1的卖票窗口1售出1张票,使票数减1变成999,同时线程2也售出1张票,使票数减1变成999。结果是售出了2张票,但是剩余票数是999,这就造成了数据的错误。为了解决这个问题,实现数据的安全访问,可以使用线程间加锁。针对加锁前后线程A和线程B的变化,分别可用图1-15和图1-16表示。
图1-15 加锁前的示意图
图1-16 加锁后的示意图
图1-16所示是加锁后的示意图,线程中首先对Thread A加了一把锁,第一时间段只有Thread A能够访问资源。当Thread A进行write后解锁,这时,Thread B加了一把锁,第二时间段只有Thread B能够访问资源。这样就能够保证在某一个时刻只能有一个线程访问资源,其他线程无法抢夺资源,既保证了线程间的合理秩序,又避免了线程间抢夺资源造成的混乱。
为了大家更好地理解线程安全的问题,这里引入一个卖票的案例,同时设置3个窗口卖票,模拟为每个窗口开启一个线程,共同访问票数资源。新建一个Single View Application应用,名称为02-ThreadSafeDemo,具体代码如例1-2所示。
【例1-2】ViewController.m
1 #import "ViewController.h" 2 @interface ViewController () 3 @property (nonatomic, assign) int leftTicketCount; // 剩余票数 4 @end 5 @implementation ViewController 6 // 卖票 7 - (void)saleTickets 8 { 9 while (true) { 10 // 模拟延时 11 [NSThread sleepForTimeInterval:1.0]; 12 // 判断是否有票 13 if (self.leftTicketCount > 0) { 14 // 如果有,卖一张 15 self.leftTicketCount--; 16 // 提示余额 17 NSLog(@"%@卖了一张票, 剩余%d张票", [NSThread currentThread].name, 18 self.leftTicketCount); 19 } else { // 如果没有,提示用户 20 NSLog(@"没有余票"); 21 return; 22 } 23 } 24 } 25 - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 26 { 27 // 总票数 28 self.leftTicketCount = 50; 29 // 创建3个线程,启动后执行saleTickets方法卖票 30 NSThread *t1 = [[NSThread alloc] initWithTarget:self 31 selector:@selector(saleTickets) object:nil]; 32 t1.name = @"1号窗口"; 33 [t1 start]; 34 NSThread *t2 = [[NSThread alloc] initWithTarget:self 35 selector:@selector(saleTickets) object:nil]; 36 t2.name = @"2号窗口"; 37 [t2 start]; 38 NSThread *t3 = [[NSThread alloc] initWithTarget:self 39 selector:@selector(saleTickets) object:nil]; 40 t3.name = @"3号窗口"; 41 [t3 start]; 42 } 43 @end
在例1-2中,该段代码总共创建了3个线程,每个线程都使用saleTickets方法来访问同一个资源,并通过while循环不断减少票数,然后打印剩余票数。当程序运行成功后,单击模拟器的屏幕,控制台的输入如图1-17所示。
图1-17 程序的运行结果
从图1-17中可以看出,由于开启了3个线程执行并发操作,在同一时刻同时抢夺一个资源leftTicketCount,造成了剩余票数统计的混乱。
为了解决这个问题,Objective-C的多线程引入了同步锁的概念,使用@synchronized关键字来修饰代码块,这个代码块可简称为同步代码块,同步代码块的基本格式如下:
@synchronized (obj) { // 插入被修饰的代码块 }
在上述语法格式中,obj就是锁对象,添加了锁对象之后,锁对象就实现了对多线程的监控,保证同一时刻只有一个线程执行,当同步代码块执行完成后,锁对象就会释放对同步监视器的锁定。
需要注意的是,虽然Objective-C允许使用任何对象作为同步锁,但是考虑到同步锁存在的意义是阻止多个线程对同一个共享资源的并发访问,因此,同步锁只要一个就可以了。并且同步锁要监视所有线程的整个运行状态,考虑到同步锁的生命周期,通常推荐使用当前的线程所在的控制器作为同步锁。
对例1-2中的saleTickets方法进行修改,修改后的代码如下:
1 - (void)saleTickets{ 2 while (true) { 3 // 模拟延时 4 [NSThread sleepForTimeInterval:1.0]; 5 // 判断是否有票 6 @synchronized(self) { 7 if (self.leftTicketCount > 0) { 8 // 如果有,卖一张 9 self.leftTicketCount--; 10 // 提示余额 11 NSLog(@"%@卖了一张票, 剩余%d张票", [NSThread 12 currentThread].name, 13 self.leftTicketCount); 14 } else { // 如果没有,提示用户 15 NSLog(@"没有余票"); 16 return; 17 } 18 } 19 } 20 }
在上述代码中,第6行代码添加了一个同步锁,它用于将第7~17行执行的代码锁住,执行到第18行代码解锁。其中,第4行代码实现每卖出一张票后让卖票的线程休眠1秒。当程序运行成功后,单击模拟器的屏幕,控制台的运行结果如图1-18所示。
图1-18 程序的运行结果
从图1-18中可以看出,通过给线程加同步锁,成功地实现了线程的同步运行,也就是说,使多条线程按顺序地执行任务。需要注意的是,同步锁会消耗大量的CPU资源,一般的初学者很难把握好性能与功能的平衡,所以在开发中不推荐使用同步锁。
注意:
使用同步锁的时候,要尽量让同步代码块包围的代码范围最小,而且要锁定共享资源的全部读写部分的代码。
1.2.4 线程间的通信
在一个进程中,线程往往不是孤立存在的,多个线程之间要经常进行通信,称为线程间通信。线程间的通信主要体现在,一个线程执行完特定任务后,转到另一个线程去执行任务,在转换任务的同时,将数据也传递给另外一个线程。
NSThread类提供了两个比较常用的方法,用于实现线程间的通信,这两个方法的定义格式如下:
// 在主线程执行方法 - (void)performSelectorOnMainThread:(SEL)aSelector withObject: (id)arg waitUntilDone:(BOOL)wait; // 在子线程中执行方法 - (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject: (id)arg waitUntilDone:(BOOL)wait;
在上述定义的格式中,第1个方法是将指定的方法放在主线程中运行。其中,aSelector就是在主线程中运行的方法,参数arg是当前执行方法所在的线程传递给主线程的参数,参数waitUntilDone是一个布尔值,用来指定当前线程是否阻塞,当为YES的时候会阻塞当前线程,直到主线程执行完毕后才执行当前线程;当为NO的时候,则不阻塞这个线程。第2个方法是创建一个子线程,将指定的方法放在子线程中运行。
为了大家更好地理解,接下来,通过一个使用多线程下载网络图片的案例,讲解如何实现线程间的通信,具体步骤如下。
(1)新建一个Single View Application应用,名称为03-ThreadContact。
(2)进入Main.StoryBoard,从对象库拖曳一个Image View到程序界面,用于显示要下载的图片,设置Mode的模式为Center,最后给Image View设置一个背景颜色。
(3)通过拖曳的方式,将Image View在viewController.m文件的类扩展中进行属性的声明。
(4)单击模拟器的屏幕,开始下载图片,并将下载完成的图片显示到Image View上,这个过程如图1-19所示。
图1-19展示了下载图片过程中的执行顺序,程序如果直接在主线程中访问网络数据,由于网络速度的不稳定性,一旦网络传输速度较慢时,容易造成主线程的阻塞,从而导致应用程序失去响应。因此,需要将网络下载图片这样耗时的操作放到子线程中,等下载完成后,通知主线程刷新视图,代码如例1-3所示。
图1-19 下载图片执行顺序图
【例1-3】ViewController.m
1 #import "HMViewController.h" 2 @interface HMViewController () 3 @property (weak, nonatomic) IBOutlet UIImageView *imageView; 4 @end 5 @implementation HMViewController 6 // 单击屏幕 7 - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 8 { 9 [self performSelectorInBackground:@selector(download) withObject:nil]; 10 } 11 // 下载图片 12 - (void)download 13 { 14 NSLog(@"download---%@", [NSThread currentThread]); 15 // 1.图片地址 16 NSString *urlStr = @"http://www.itcast.cn/images/logo.png"; 17 NSURL *url = [NSURL URLWithString:urlStr]; 18 // 2.根据地址下载图片的二进制数据(这句代码最耗时) 19 NSData *data = [NSData dataWithContentsOfURL:url]; 20 // 3.设置图片 21 UIImage *image = [UIImage imageWithData:data]; 22 // 4.回到主线程,刷新UI界面(为了线程安全) 23 if(image!=nil){ 24 [self performSelectorOnMainThread:@selector(downloadFinished:) 25 withObject:image waitUntilDone:NO]; 26 } else{ 27 NSLog(@"图片下载出现错误"); 28 } 29 } 30 // 下载完成 31 - (void)downloadFinished:(UIImage *)image 32 { 33 self.imageView.image = image; 34 NSLog(@"downloadFinished---%@", [NSThread currentThread]); 35 } 36 @end
在例1-3中,第9行代码调用performSelectorInBackground:withObject:方法创建子线程,并指定了download方法来下载图片,第24行代码调用performSelectorOnMainThread:with Object:waitUntilDone:来到主线程,在主线程中刷新视图。运行程序,程序运行成功后,单击屏幕,就成功下载了图片,如图1-20所示。
图1-20 网络下载图片
同时,控制台的运行结果如图1-21所示。
从图1-21中可以看出,程序执行的download方法是在子线程中执行的,而执行downloadFinished:方法来刷新界面是在主线程中进行的。
图1-21 控制台输出结果