iOS多线程编程(三) NSThread

1,182 阅读10分钟

多线程系列篇章计划内容:
iOS多线程编程(一) 多线程基础
iOS多线程编程(二) Pthread
iOS多线程编程(三) NSThread
iOS多线程编程(四) GCD
iOS多线程编程(五) GCD的底层原理
iOS多线程编程(六) NSOperation
iOS多线程编程(七) 同步机制与锁
iOS多线程编程(八) RunLoop

NSThread 是苹果提供的一种面向对象的轻量级多线程解决方案,一个 NSThread 对象代表一个线程,使用比较简单,但是需要手动管理线程的生命周期、处理线程同步等问题。

1. 创建、启动NSThread线程

  • 创建一个NSThread线程有类方法和实例方法。

类方法创建:

+ (void)detachNewThreadWithBlock:(void (^)(void))block ;
+ (void)detachNewThreadSelector:(SEL)selector toTarget:(id)target withObject:(nullable id)argument;

实例方法创建:

- (instancetype)initWithBlock:(void (^)(void))block;
- (instancetype)initWithTarget:(id)target selector:(SEL)selector object:(nullable id)argument;

使用实例方法创建线程返回线程对象,可以根据需要设置相应属性参数。

需要注意:block形式的创建方式 需在iOS10之后使用

  • 创建完毕后不要忘记开启线程!

线程的状态

线程创建完毕后对应线程状态的新建态,我们需要调用 start方法启动线程(使用类方法创建的线程隐式的启动了线程),否则线程是不会执行的。

但是使用类方法创建或者使用实例方法创建并且调用start方法之后,线程并不会立即执行,只是将线程加入可调度线程池,进入就绪状态,具体何时执行需要等待CPU的调度。(关于线程状态可以参阅 多线程基础 中的线程生命周期)

2. NSThread线程属性

  • name 属性:设置线程的名字
NSThread *thread = [[NSThread alloc] initWithBlock:^{
    NSLog(@"线程:%@ start",[NSThread currentThread]);
 }];
thread.name = @"测试线程";
[thread start];

打印结果如下:

线程:<NSThread: 0x600001227200>{number = 6, name = 测试线程} start
  • qualityOfService属性:设置线程优先级

原本线程优先级threadPriority属性,是一个double类型,取值范围为0.0~1.0,值越大,优先级越高。不过,该属性已被qualityOfService 取代。qualityOfService 是一个枚举值。定义如下:

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 优先级最高,从上到下依次降低,NSQualityOfServiceDefault 为默认优先级。

使用如下:

    NSThread *thread1 = [[NSThread alloc] initWithBlock:^{
        NSLog(@"\n 线程:%@ start",[NSThread currentThread]);
    }];
    thread1.name = @"测试线程 1 ";
    [thread1 start];
    
    NSThread *thread2 = [[NSThread alloc] initWithBlock:^{
        NSLog(@"\n 线程:%@ start",[NSThread currentThread]);
    }];
    thread2.qualityOfService = NSQualityOfServiceUserInteractive;
    thread2.name = @"测试线程 2 ";
    [thread2 start];

虽然 thread1 先于 thread2 start,但thread1优先级为默认,而thread2优先级为NSQualityOfServiceUserInteractive,在执行时,thread2 先于 thread1执行。

线程:<NSThread: 0x600001e557c0>{number = 7, name = 测试线程 2 } start
线程:<NSThread: 0x600001e55700>{number = 6, name = 测试线程 1 } start
  • callStackReturnAddresses callStackSymbols 属性:

callStackReturnAddresses 属性定义如下:

@property (class, readonly, copy) NSArray<NSNumber *> *callStackReturnAddresses

线程的调用会有函数的调用,该属性返回的就是 该线程中函数调用的虚拟地址数组。

callStackSymbols 属性定义如下:

@property (class, readonly, copy) NSArray<NSString *> *callStackSymbols

该属性以符号的形式返回该线程调用函数。

callStackReturnAddresscallStackSymbols这两个函数可以同NSLog联合使用来跟踪线程的函数调用情况,是编程调试的重要手段。

  • threadDictionary 属性:

每个线程有自己的堆栈空间,线程内维护了一个键-值的字典,它可以在线程里面的任何地方被访问。 你可以使用该字典来保存一些信息,这些信息在整个线程的执行过程中都保持不变。 比如,你可以使用它来存储在你的整个线程过程中 Run loop 里面多次迭代的状态信息。

  • 其他属性
@property (class, readonly, strong) NSThread *mainThread; // 获取主线程
@property (class, readonly, strong) NSThread *currentThread;// 获取当前线程
@property NSUInteger stackSize; // 线程使用堆栈大小,默认512k
@property (readonly) BOOL isMainThread; // 是否是主线程
@property (class, readonly) BOOL isMainThread ; // reports whether current thread is main
@property (readonly, getter=isExecuting) BOOL executing ; // 线程是否正在执行
@property (readonly, getter=isFinished) BOOL finished ;  // 线程是否执行完毕
@property (readonly, getter=isCancelled) BOOL cancelled;  // 线程是否取消

3. NSThread线程的阻塞

NSThread提供了2个类方法,

+ (void)sleepUntilDate:(NSDate *)date; // 休眠到指定日期
+ (void)sleepForTimeInterval:(NSTimeInterval)ti;// 休眠执行时常

对于上面设置线程优先级的示例代码,我们稍做些更改。

    NSThread *thread1 = [[NSThread alloc] initWithBlock:^{
        NSLog(@"\n 线程:%@ start",[NSThread currentThread]);
    }];
    thread1.name = @"测试线程 1 ";
    [thread1 start];
    // 加入休眠函数
    [NSThread sleepForTimeInterval:1];
    NSThread *thread2 = [[NSThread alloc] initWithBlock:^{
        NSLog(@"\n 线程:%@ start",[NSThread currentThread]);
    }];
    thread2.qualityOfService = NSQualityOfServiceUserInteractive;
    thread2.name = @"测试线程 2 ";
    [thread2 start];

在 thread1 与 thread2 之间加入 [NSThread sleepForTimeInterval:1]; 让主线程阻塞1秒,那么 thread1 将 先于 thread2 执行,即使thread2 的优先级是高于thread1。

这是因为,thread1先start进入就绪状态,此时,主线程休眠,在CPU时间到来之时,可调度线程池中只有thread1,thread1 被调度执行,此时主线程休眠时间结束,thread2 进入就绪态,并在下一次CPU时间时被调度执行。

4. NSThread的终止

  • 取消线程
- (void)cancel ;

对于已被调度的线程是无法通过cancel取消的。

  • 退出线程
+ (void)exit;

强制退出线程,使线程进入死亡态。

5. 线程的通信

在开发中,我们有时需要在子线程进行耗时操作,操作结束后切换到主线程进行刷新UI。这就涉及到线程间的通信,NSThread线程提供了对NSObject的拓展函数。

5.1 NSObject方式

// 在主线程上执行操作 wait表示是否阻塞该方法,等待主线程空闲再运行,modes表示运行模式kCFRunLoopCommonModes
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(id)arg waitUntilDone:(BOOL)wait;
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(id)arg waitUntilDone:(BOOL)wait modes:(NSArray<NSString *> *)array;
// equivalent to the first method with kCFRunLoopCommonModes

// 在指定线程上执行操作
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(id)arg waitUntilDone:(BOOL)wait modes:(NSArray *)array;
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(id)arg waitUntilDone:(BOOL)wait;
// equivalent to the first method with kCFRunLoopCommonModes

// 隐式创建一个线程并执行
- (void)performSelectorInBackground:(SEL)aSelector withObject:(nullable id)arg;

// NSObject函数: 在当前线程上执行操作,调用 NSObject 的 performSelector:相关方法
- (id)performSelector:(SEL)aSelector;
- (id)performSelector:(SEL)aSelector withObject:(id)object;
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;

举个例子,我们来模拟子线程下载图片回到线程刷新 UI 的实现

// 开辟子线程模拟网络请求
- (void)downloadImage { 
   [NSThread detachNewThreadWithBlock:^{
   // 1. 获取图片 imageUrl
   NSURL *imageUrl = [NSURL URLWithString:@"https://xxxxx.jpg"];
   // 2. 从 imageUrl 中读取数据(下载图片) -- 耗时操作
   NSData *imageData = [NSData dataWithContentsOfURL:imageUrl];
   // 通过二进制 data 创建 image
   UIImage *image = [UIImage imageWithData:imageData];

   // 主线程刷新UI
   [self performSelectorOnMainThread:@selector(mainThreadRefreshUI) withObject:image waitUntilDone:YES];
    }];
}

// 主线程刷新 UI 调用方法
- (void)mainThreadRefreshUI:(UIImage *)image {
    self.imageView.image = image;
}

5.2 端口通信方式

端口通信需要使用 NSPortNSPort 是一个抽象类,具体使用的时候可以使用其子类NSMachPort

通过下面方法传递将要在线程间通信的信息数据。

- (BOOL)sendBeforeDate:(NSDate *)limitDate components:(nullable NSMutableArray *)components from:(nullable NSPort *) receivePort reserved:(NSUInteger)headerSpaceReserved;
- (BOOL)sendBeforeDate:(NSDate *)limitDate msgid:(NSUInteger)msgID components:(nullable NSMutableArray *)components from:(nullable NSPort *)receivePort reserved:(NSUInteger)headerSpaceReserved;

实现NSPortDelegate 的方法,接受端口传递过来的数据。

- (void)handlePortMessage:(NSPortMessage *)message

注意:在使用端口的时候,需要注意将端口将入当前Runloop,否则消息无法传递

[[NSRunLoop currentRunLoop] addPort:self.myPort forMode:NSDefaultRunLoopMode];

6. NSThread通知

NSWillBecomeMultiThreadedNotification:由当前线程派生出第一个其他线程时发送,一般一个线程只发送一次
NSDidBecomeSingleThreadedNotification:这个通知目前没有实际意义,可以忽略
NSThreadWillExitNotification线程退出之前发送这个通知

7. NSThread 线程安全案例

只要涉及到多线程就有可能存在非线程安全的情况。根本原因就是多条线程同时操作一片临界区,导致临界区资源错乱。

我们来模拟多线程经典的售票案例:两个售票窗口同时售卖50张车票

- (void)initTicketStatusNotSave {
    // 1. 设置剩余火车票为 50
    self.ticketSurplusCount = 50;

    // 2. 模拟窗口1售票的线程
    self.ticketSaleWindow1 = [[NSThread alloc]initWithTarget:self selector:@selector(saleTicketNotSafe) object:nil];
    self.ticketSaleWindow1.name = @"售票窗口1";

    // 3. 模拟窗口2售票的线程
    self.ticketSaleWindow2 = [[NSThread alloc]initWithTarget:self selector:@selector(saleTicketNotSafe) object:nil];
    self.ticketSaleWindow2.name = @"售票窗口2";

    // 4. 开始售卖火车票
    [self.ticketSaleWindow1 start];
    [self.ticketSaleWindow2 start];
}

/**
* 售卖火车票(非线程安全)
*/
- (void)saleTicketNotSafe {
    while (1) {
    //如果还有票,继续售卖
    if (self.ticketSurplusCount > 0) {
        self.ticketSurplusCount --;
        NSLog(@"%@", [NSString stringWithFormat:@"剩余票数:%ld 窗口:%@", self.ticketSurplusCount, [NSThread currentThread].name]);
       [NSThread sleepForTimeInterval:0.2];
    }
    //如果已卖完,关闭售票窗口
    else {
        NSLog(@"所有火车票均已售完");
        break;
    }
  }
}

截取部分结果如下:

2020-11-19 15:55:53.222575+0800 pthread[5018:211393] 剩余票数:49 窗口:售票窗口1
2020-11-19 15:55:53.222589+0800 pthread[5018:211394] 剩余票数:48 窗口:售票窗口2
2020-11-19 15:55:53.426619+0800 pthread[5018:211394] 剩余票数:46 窗口:售票窗口2
2020-11-19 15:55:53.426626+0800 pthread[5018:211393] 剩余票数:47 窗口:售票窗口1
2020-11-19 15:55:53.630102+0800 pthread[5018:211394] 剩余票数:45 窗口:售票窗口2
2020-11-19 15:55:53.630144+0800 pthread[5018:211393] 剩余票数:44 窗口:售票窗口1
2020-11-19 15:55:53.832564+0800 pthread[5018:211393] 剩余票数:43 窗口:售票窗口1
2020-11-19 15:55:53.832649+0800 pthread[5018:211394] 剩余票数:42 窗口:售票窗口2
2020-11-19 15:55:54.033279+0800 pthread[5018:211393] 剩余票数:41 窗口:售票窗口1
2020-11-19 15:55:54.033360+0800 pthread[5018:211394] 剩余票数:40 窗口:售票窗口2
2020-11-19 15:55:54.237370+0800 pthread[5018:211393] 剩余票数:39 窗口:售票窗口1
2020-11-19 15:55:54.237370+0800 pthread[5018:211394] 剩余票数:39 窗口:售票窗口2
2020-11-19 15:55:54.440124+0800 pthread[5018:211393] 剩余票数:38 窗口:售票窗口1
2020-11-19 15:55:54.440200+0800 pthread[5018:211394] 剩余票数:37 窗口:售票窗口2
2020-11-19 15:55:54.643881+0800 pthread[5018:211393] 剩余票数:35 窗口:售票窗口1
2020-11-19 15:55:54.643889+0800 pthread[5018:211394] 剩余票数:36 窗口:售票窗口2
2020-11-19 15:55:54.845543+0800 pthread[5018:211393] 剩余票数:33 窗口:售票窗口1
......

这就是多线程同时操作同一片临界区的结果,得到的票数是错乱的,这是不符合我们的预期的。

线程安全的解决方案,就是线程同步机制。比较常用的是使用【锁】。在一个线程占用临界区的时候,不允许其他线程进入。

iOS 实现线程加锁有很多种方式。@synchronized、 NSLock、NSRecursiveLock、NSCondition、NSConditionLock、pthread_mutex、dispatch_semaphore、OSSpinLock、atomic等等。更详细的锁相关知识参见iOS多线程编程(七)-锁。这里我们使用@synchronized对此案例进行线程安全优化。

- (void)initTicketStatusNotSave {
    // 1. 设置剩余火车票为 50
    self.ticketSurplusCount = 50;

    // 2. 模拟窗口1售票的线程
    self.ticketSaleWindow1 = [[NSThread alloc]initWithTarget:self selector:@selector(saleTicketNotSafe) object:nil];
    self.ticketSaleWindow1.name = @"售票窗口1";

    // 3. 模拟窗口2售票的线程
    self.ticketSaleWindow2 = [[NSThread alloc]initWithTarget:self selector:@selector(saleTicketNotSafe) object:nil];
    self.ticketSaleWindow2.name = @"售票窗口2";

    // 4. 开始售卖火车票
    [self.ticketSaleWindow1 start];
    [self.ticketSaleWindow2 start];
}

/**
* 售卖火车票(线程安全)
*/
- (void)saleTicketNotSafe {
    while (1) {
       // 互斥锁
       @synchronized (self) {
           //如果还有票,继续售卖
           if (self.ticketSurplusCount > 0) {
              self.ticketSurplusCount --;
              NSLog(@"%@", [NSString stringWithFormat:@"剩余票数:%ld 窗口:%@", self.ticketSurplusCount, [NSThread currentThread].name]);
             [NSThread sleepForTimeInterval:0.2];
           }
           //如果已卖完,关闭售票窗口
           else {
              NSLog(@"所有火车票均已售完");
              break;
           }
        }
    }
}

运行后结果是正常的,这里就不贴了。