iOS 開發中的多執行緒

wxiubin發表於2017-07-20

執行緒、程式

什麼是執行緒、程式

  有的人說程式就像是人的腦袋,執行緒就是腦袋上的頭髮~~。其實這麼比方不算錯,但是更簡單的來說,用迅雷下載檔案,迅雷這個程式就是一個程式,下載的檔案就是一個執行緒,同時下載三個檔案就是多執行緒。一個程式可以只包含一個執行緒去處理事務,也可以有多個執行緒。

<!–more–>

多執行緒的優點和缺點

  多執行緒可以大大提高軟體的執行效率和資源(CPU、記憶體)利用率,因為CPU只可以處理一個執行緒(多核CPU另說),而多執行緒可以讓CPU同時處理多個任務(其實CPU同一時間還是隻處理一個執行緒,但是如果切換的夠快,就可以了認為同時處理多個任務)。但是多執行緒也有缺點:當執行緒過多,會消耗大量的CPU資源,而且,每開一條執行緒也是需要耗費資源的(iOS主執行緒佔用1M記憶體空間,子執行緒佔用512KB)。

iOS開發中的多執行緒

  iOS程式在啟動後會自動開啟一個執行緒,稱為 主執行緒 或者 UI執行緒 ,用來顯示、重新整理UI介面,處理點選、滾動等事件,所以耗費時間的事件(比如網路、磁碟操作)儘量不要放在主執行緒,否則會阻塞主執行緒造成介面卡頓。
iOS開發中的多執行緒實現方案有四種:

技術方案 簡介 語言 生命週期管理
pthread 一套通用的多執行緒API,適用於UnixLinuxWindows等系統,跨平臺可移植,使用難度大 C 程式設計師管理
NSThread 使用更加物件導向,簡單易用,可直接操作執行緒物件 Objective-C 程式設計師手動例項化
GCD 旨在替代NSThread等執行緒技術,充分利用裝置的多核 C 自動管理
NSOperation 基於GCD(底層是GCD),比GCD多了一些更簡單實用的功能,使用更加物件導向 Objective-C 自動管理

多執行緒中GCD我使用比較多,以GCD為例,多執行緒有兩個核心概念:

  1. 任務 (做什麼?)

  2. 佇列 (存放任務,怎麼做?)

任務就是你開闢多執行緒要來做什麼?而每個執行緒都是要加到一個佇列中去的,佇列決定任務用什麼方式來執行。

執行緒執行任務方式分為:

  1. 非同步執行

  2. 同步執行

同步執行只能在當前執行緒執行,不能開闢新的執行緒。而且是必須、立即執行。而非同步執行可以開闢新的執行緒。

佇列分為:

  1. 併發佇列

  2. 序列佇列

併發佇列可以讓多個執行緒同時執行(必須是非同步),序列佇列則是讓任務一個接一個的執行。打個比方說,序列佇列就是單車道,再多的車也得一個一個的跑(–:我倆車強行並著跑不行? –:來人,拖出去砍了!),而序列是多車道,可以幾輛車同時並著跑。那麼到底是幾車道?併發佇列有個最大併發數,一般可以手動設定。

那麼,執行緒加入到佇列中,到底會怎麼執行?

併發佇列 序列佇列(非主佇列) 主佇列(只有主執行緒,序列佇列)
同步 不開啟新的執行緒,序列 不開啟新的執行緒,序列 不開啟新的執行緒,序列
非同步 開啟新的執行緒,併發 開啟新的執行緒,序列 不開啟新的執行緒,序列

注意:

  1. 只用在併發佇列非同步執行才會開啟新的執行緒併發執行;

  2. 在當前序列佇列中開啟一個同步執行緒會造成 執行緒阻塞 ,因為上文說過,同步執行緒需要立即馬上執行,當在當前序列佇列中建立同步執行緒時需要在序列佇列立即執行任務,而此時執行緒還需要向下繼續執行任務,造成阻塞。

上面提到執行緒會阻塞,那麼什麼是阻塞?除了阻塞之外執行緒還有其他什麼狀態?
一般來說,執行緒有五個狀態:

  • 新建狀態:執行緒剛剛被建立,還沒有呼叫 run 方法,這個時候的執行緒就是新建狀態;

  • 就緒狀態:在新建執行緒被建立之後呼叫了 run 方法,但是CPU並不是真正的同時執行多個任務,所以要等待CPU呼叫,這個時候執行緒處於就緒狀態,隨時可能進入下一個狀態;

  • 執行狀態:線上程執行過 run方法之後,CPU已經排程該執行緒即執行緒獲取了CPU時間;

  • 阻塞狀態:執行緒在執行時可能會進入阻塞狀態,比如執行緒睡眠(sleep);希望得到一個鎖,但是該鎖正被其他執行緒擁有。。

  • 死亡狀態:當執行緒執行完任務或者因為異常情況提前終止了執行緒

iOS開發中的多執行緒的使用

pthread的使用

使用下面程式碼可以建立一個執行緒:

int pthread_create(pthread_t * __restrict, const pthread_attr_t * __restrict,void *(*)(void *), void * __restrict)

可以看到這個方法有四個引數,主要引數有 pthread_t __restrict ,因為該方法是C語言,所以這個引數不是一個物件,而是一個 pthread_t 的地址,還有 void ()(void ) 是一個無返回值的函式指標。
使用程式碼:

void * run(void *param)
{
    NSLog(@"currentThread--%@", [NSThread currentThread]);
    return NULL;
}

- (void)createThread{
    pthread_t thread;
    pthread_create(&thread, NULL, run, NULL);
}

控制檯輸出:

currentThread--<NSThread: 0x7fff38602fb0>{number = 2, name = (null)}

number = 1 的執行緒是主執行緒,不為一的時候都是子執行緒。

NSThread的使用

NSThread建立執行緒一般有三種方式:

// equivalent to the first method with kCFRunLoopCommonModes
- (void)performSelectorInBackground:(SEL)aSelector withObject:(nullable id)arg;
//
+ (void)detachNewThreadSelector:(SEL)selector toTarget:(id)target withObject:(nullable id)argument;
//
- (instancetype)initWithTarget:(id)target selector:(SEL)selector object:(nullable id)argument
  1. 前兩種建立之後會自動執行,第三種方式建立後需要手動執行;

  2. 第一種建立方式是建立一個子執行緒,類似的 – (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString > )array 方法可以建立併發任務在主執行緒中執行,– (void)performSelector:(SEL)aSelector onThread:(NSThread )thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString > *)array 可以選擇在哪個執行緒中執行。

示例程式碼:

- (void)createThread{
    // 建立執行緒
    NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(run:) object:@"我是引數"];
    thread.name = @"我是執行緒名字啊";
    // 啟動執行緒
    [thread start];
    // 或者 [NSThread detachNewThreadSelector:@selector(run:) toTarget:self withObject:@"我是引數"];
    // 或者 [self performSelectorInBackground:@selector(run:) withObject:@"我是引數"];
}
- (void)run:(NSString *)param{
    NSLog(@"-----run-----%@--%@", param, [NSThread currentThread]);
}

控制檯輸出:

-----run-----我是引數--<NSThread: 0x7ff8a2f0c940>{number = 2, name = 我是執行緒名字啊}

GCD的使用

蘋果官方對GCD說:

開發者要做的只是定義執行的任務並追加到適當的 Dispatch Queue 中。

在GCD中我們要做的只是兩件事:定義任務;把任務加到佇列中。

dispatch_queue_create 獲取/建立佇列

GCD 的佇列有兩種:

Dispatch Queue 種類 說明
Serial Dispatch Queue 等待現在執行中處理結束(序列佇列)
Concurrent Dispatch Queue 不等待現在執行中處理結束(並行佇列)

GCD中的佇列都是 dispatch_queue_t 型別,獲取/建立方法:

// 1. 手動建立佇列
dispatch_queue_t dispatch_queue_create(const char *label, dispatch_queue_attr_t attr);
// 1.1 建立序列佇列
    dispatch_queue_t queue = dispatch_queue_create("com.sanyucz.queue", DISPATCH_QUEUE_SERIAL);
// 1.2 建立並行佇列
    dispatch_queue_t queue = dispatch_queue_create("com.sanyucz.queue", DISPATCH_QUEUE_CONCURRENT);
// 2. 獲取系統標準提供的 Dispatch Queue
// 2.1 獲取主佇列
dispatch_queue_t queue = dispatch_get_main_queue();
// 2.2 獲取全域性併發佇列
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

需要說明的是,手動建立佇列時候的兩個關鍵引數,const char *label 指定佇列名稱,最好起一個有意義的名字,當然如果你想除錯的時候刺激一下,也可以設定為 NULL,而 dispatch_queue_attr_t attr 引數文件有說明:

/*!
 * @const DISPATCH_QUEUE_SERIAL
 * @discussion A dispatch queue that invokes blocks serially in FIFO order.
 */
#define DISPATCH_QUEUE_SERIAL NULL
/*!
 * @const DISPATCH_QUEUE_CONCURRENT
 * @discussion A dispatch queue that may invoke blocks concurrently and supports
 * barrier blocks submitted with the dispatch barrier API.
 */
#define DISPATCH_QUEUE_CONCURRENT 
        DISPATCH_GLOBAL_OBJECT(dispatch_queue_attr_t, 
        _dispatch_queue_attr_concurrent)
  • DISPATCH_QUEUE_SERIAL 建立序列佇列按順序FIFO(First-In-First-On)先進先出;

  • DISPATCH_QUEUE_CONCURRENT 則會建立併發佇列

dispatch_async/dispatch_sync 建立任務

建立完佇列之後就是定義任務了,有兩種方式:

// 建立一個同步執行任務
void dispatch_sync(dispatch_queue_t queue, dispatch_block_t block);
// 建立一個非同步執行任務
void dispatch_async(dispatch_queue_t queue, dispatch_block_t block);

完整的示例程式碼:

dispatch_queue_t queue = dispatch_queue_create("com.sanyucz.queue.asyncSerial", DISPATCH_QUEUE_SERIAL);
dispatch_async(queue, ^{
   NSLog(@"非同步 + 序列 - %@",[NSThread currentThread]);
});

dispatch group 任務組

我們可能在實際開發中會遇到這樣的需求:在兩個任務完成後再執行某一任務。雖然這種情況可以用序列佇列來解決,但是我們有更加高效的方法。

直接上程式碼,在程式碼的註釋中講解:

// 獲取全域性併發佇列
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
// 建立任務組
// dispatch_group_t :A group of blocks submitted to queues for asynchronous invocation
dispatch_group_t group = dispatch_group_create();
// 在任務組中新增一個任務
dispatch_group_async(group, queue, ^{
    //
});
// 在任務組中新增另一個任務
dispatch_group_async(group, queue, ^{
    //
});
// 當任務組中的任務執行完畢之後再執行一下任務
dispatch_group_notify(group, queue, ^{
   //
});

dispatch_barrier_async

從字面意思就可以看出來這個變數的用處,即阻礙任務執行,它並不是阻礙某一個任務的執行,而是在程式碼中,在它之前定義的任務會比它先執行,在它之後定義的任務則會在它執行完之後在開始執行。就像一個欄柵。

使用程式碼:

dispatch_queue_t queue = dispatch_queue_create("com.gcd.barrier", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(queue, ^{
   NSLog(@"----1-----%@", [NSThread currentThread]);
});
dispatch_async(queue, ^{
   NSLog(@"----2-----%@", [NSThread currentThread]);
});
dispatch_barrier_async(queue, ^{
   NSLog(@"----barrier-----%@", [NSThread currentThread]);
}); 
dispatch_async(queue, ^{
   NSLog(@"----3-----%@", [NSThread currentThread]);
});
dispatch_async(queue, ^{
   NSLog(@"----4-----%@", [NSThread currentThread]);
});

控制檯輸出:

----1-----<NSThread: 0x7fdc60c0fd90>{number = 2, name = (null)}
----2-----<NSThread: 0x7fdc60c11500>{number = 3, name = (null)}
----barrier-----<NSThread: 0x7fdc60c11500>{number = 3, name = (null)}
----3-----<NSThread: 0x7fdc60c11500>{number = 3, name = (null)}
----4-----<NSThread: 0x7fdc60c0fd90>{number = 2, name = (null)}

dispatch_apply 遍歷執行任務

dispatch_apply 的用法類似於對陣列元素進行 for迴圈 遍歷,但是 dispatch_apply 的遍歷是無序的。

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    
NSMutableArray *array = [NSMutableArray array];
for (int i = 0; i < 10; i++) {
   [array addObject:@(i)];
}
// array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
NSLog(@"--------apply begin--------");
dispatch_apply(array.count, queue, ^(size_t index) {
   NSLog(@"%@---%zu", [NSThread currentThread], index);
});
NSLog(@"--------apply done --------");

控制檯輸出:

--------apply begin--------
<NSThread: 0x7ffa7bd05800>{number = 1, name = main}---0
<NSThread: 0x7ffa7bd05800>{number = 1, name = main}---4
<NSThread: 0x7ffa7bd05800>{number = 1, name = main}---5
<NSThread: 0x7ffa7bda77c0>{number = 2, name = (null)}---1
<NSThread: 0x7ffa7be1fd00>{number = 4, name = (null)}---3
<NSThread: 0x7ffa7bd05800>{number = 1, name = main}---6
<NSThread: 0x7ffa7be1a920>{number = 3, name = (null)}---2
<NSThread: 0x7ffa7bd05800>{number = 1, name = main}---8
<NSThread: 0x7ffa7be1fd00>{number = 4, name = (null)}---9
<NSThread: 0x7ffa7bda77c0>{number = 2, name = (null)}---7
--------apply done --------

可以看到,遍歷的時候自動開啟多執行緒,可以無序併發執行多個任務,但是有一點可以確定,就是 NSLog(@”——–apply done ——–“); 這段程式碼一定是在所有任務執行完之後才會去執行。

GCD 的其他用法

除了上面的那些,GCD還有其他的用法

  • dispatch_after 延期執行任務

  • dispatch_suspend / dispatch_resume 暫停/恢復某一任務

  • dispatch_once 保證程式碼只執行一次,而且執行緒安全

  • Dispatch I/O 可以以更小的粒度讀寫檔案

NSOperation的使用

NSOperation 及其子類

NSOperationNSOperationQueue 配合使用也能實現併發多執行緒,但是需要注意的是 NSOperation 是個抽象類,想要封裝操作需要使用其子類。
系統為我們提供了兩個子類:

  • NSInvocationOperation

  • NSBlockOperation

當然,我們也可以自定義其子類,只是需要重寫 main() 方法。

先看下系統提供兩個子類的初始化方法:

- (nullable instancetype)initWithTarget:(id)target selector:(SEL)sel object:(nullable id)arg;
+ (instancetype)blockOperationWithBlock:(void (^)(void))block;

兩個子類初始化方法不一樣的地方就是一個用 例項物件方法選擇器 來確定執行一個方法,另外一個是用block閉包儲存執行一段程式碼塊。
另外 NSBlockOperation 還有一個例項方法 – (void)addExecutionBlock:(void (^)(void))block; ,只要呼叫這個方法以至於封裝的運算元大於一個就會開啟新的執行緒非同步操作。
最後呼叫NSOperationstart方法啟動任務。

NSOperationQueue

NSOperation 預設是執行同步任務,但是我們可以把它加入到 NSOperationQueue 中程式設計非同步操作。

- (void)addOperation:(NSOperation *)op;
- (void)addOperations:(NSArray<NSOperation *> *)ops waitUntilFinished:(BOOL)wait;
- (void)addOperationWithBlock:(void (^)(void))block;

之前提到過多執行緒併發佇列可以設定最大併發數,以及佇列的取消、暫停、恢復操作:

// 建立佇列
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
    
// 設定最大併發運算元
queue.maxConcurrentOperationCount = 2; // 併發佇列
queue.maxConcurrentOperationCount = 1; // 序列佇列

 // 恢復佇列,繼續執行
queue.suspended = NO;
// 暫停(掛起)佇列,暫停執行
queue.suspended = YES;

// 取消佇列
[queue cancelAllOperations];

執行緒安全

多執行緒使用的時候,可能會多條執行緒同時訪問/賦值某一變數,如不加限制的話多相處同時訪問會出問題。具體情況可以搜尋一下相關資料,多執行緒的 買票問題 很是經典。
iOS執行緒安全解決方法一般有以下幾種:

  • @synchronized 關鍵字

  • NSLock 物件

  • NSRecursiveLock 遞迴鎖

  • GCD (dispatch_sync 或者 dispatch_barrier_async)

在iOS中執行緒安全問題一般是關鍵字 @synchronized 用加鎖來完成。
示例程式碼:

@synchronized(self) {
      // 這裡是安全的,同一時間只有一個執行緒能到這裡哦~~      
}

需要注意的是 synchronized 後面括號裡的 self 是個 token ,該 token 不能使用區域性變數,應該是全域性變數或者線上程併發期間一直存在的物件。因為執行緒判斷該加鎖的程式碼有沒有執行緒在訪問是通過該 token 來確定的。

首發於https://iosgg.cn/2016/05/22/multithreading_iOS/

相關文章