1. 程式人生 > >iOS多執行緒——你要知道的NSThread都在這裡

iOS多執行緒——你要知道的NSThread都在這裡

你要知道的iOS多執行緒NSThread、GCD、NSOperation、RunLoop都在這裡

本系列文章主要講解iOS中多執行緒的使用,包括:NSThread、GCD、NSOperation以及RunLoop的使用方法詳解,本系列文章不涉及基礎的執行緒/程序、同步/非同步、阻塞/非阻塞、序列/並行,這些基礎概念,有不明白的讀者還請自行查閱。本系列文章將分以下幾篇文章進行講解,讀者可按需查閱。

組織架構說明

本系列文章是按照相關多執行緒類的抽象層次撰寫的,也就是說NSThreadFoundation框架提供的最基礎的多執行緒類,每一個NSThread類的物件即代表一個執行緒,接下來蘋果為開發者封裝了GCD(Grand Central Dispatch)

GCD相比於NSThread來說,提供了便捷的操作方法,開發者不需要再關注於管理執行緒的生命週期,也不需要自行管理一個執行緒池用於執行緒的複用,但GCD是以C函式對外提供介面,因此Foundation框架在GCD的基礎上進行了面向物件的封裝,提供了面向物件的多執行緒類NSOperationNSOperationQueue,抽象層次更高。

由於OCC語言的超集,開發者也可以選擇使用POSIX標準的執行緒pthreadpthreadNSThread都是對核心mach kernelmach 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物件產生的競爭條件,當執行同步程式碼塊時需要先獲取監聽器,如果獲取不到則執行緒會被阻塞,當同步程式碼塊執行完成則釋放監聽器,與javasynchronized同步程式碼塊一樣。

栗子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

備註

由於作者水平有限,難免出現紕漏,如有問題還請不吝賜教。