ObjC 多執行緒簡析(一)-多執行緒簡述和執行緒鎖的基本應用

SoC發表於2019-02-28

在iOS開發中,經常會遇到將耗時操作放在子執行緒中執行的情況。

一般情況下我們會使用NSThread、NSOperation和GCD來實現多執行緒的相關操作。初次之外pthread也可以用於多執行緒的相關開發。

pthread提供了一套C語言的api,它是跨平臺的,需要開發人員自行管理執行緒的生命週期;NSThread提供了一套OC的api,使用更加簡單,但是執行緒的生命週期也是需要開發人員自己管理的;GCD也提供了C語言的api,它充分利用了CPU多核處理事件的能力,並且可以自己管理執行緒的生命週期;NSOperation是對GCD做了一層OC的封裝,更加物件導向,生命週期也由其自動管理。

本篇主要使用GCD來介紹iOS開發中的多執行緒情況,以及實現執行緒同步的些許方式。

GCD的基本使用

基本概念

GCD提供了同步和非同步處理事情的能力,分別呼叫dispatch_sync(<#dispatch_queue_t _Nonnull queue#>, <#^(void)block#>)dispatch_async(<#dispatch_queue_t _Nonnull queue#>, <#^(void)block#>)建立任務。同步只能在當前執行緒中執行,並不會建立一個新的執行緒。只有非同步才會建立新的執行緒,任務也是在新的執行緒中執行的。

GCD實現多執行緒通常需要依賴一個佇列,而GCD提供了序列和並行佇列。序列佇列是指任務一個接著一個執行,下一個任務的執行必須等待上一個任務執行結束。並行佇列則可以同時執行多個任務,但是併發任務的執行需要依賴於非同步函式(dispatch_async)。

任務和佇列

GCD的多執行緒技術需要往函式中新增一個佇列,那麼這四種情況排列組合將會出現什麼情況呢?可以使用下表進行表示:

GCD任務和佇列

當使用dispatch_sync的時候無論是併發佇列還是序列佇列或者主執行緒,全都不會開啟新的執行緒,並且都是序列執行任務。

當使用dispatch_async的時候,除了在主執行緒的情況下,全都會開啟新的執行緒,並且只有在併發佇列的時候才會並行執行任務。

佇列組

GCD提供了佇列組的api,可以實現在一個佇列組中控制佇列中任務的執行順序:

dispatch_group_t group = dispatch_group_create();
    dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_CONCURRENT);
    dispatch_group_async(group, queue, ^{
        NSLog(@"任務1");
    });
    dispatch_group_async(group, queue, ^{
        NSLog(@"任務2");
    });
    dispatch_group_notify(group, queue, ^{
        NSLog(@"任務3");
    });
複製程式碼

執行緒同步

儘管多執行緒提供了能充分利用執行緒處理事情的能力,比如多工下載、處理耗時操作等。但是當多條執行緒操作同一塊資源的時候就可能會出現不合理的現象(資料錯亂,資料安全),這是因為多執行緒執行的順序和時間是不確定的。所以當一條執行緒拿到資源進行操作的時候,下一條執行緒拿到可能還是之前的資源。所以執行緒同步就是讓多執行緒在同一時間只有一條執行緒在操作資源。

在實現執行緒同步的時候,我們首先想到的應該是給資源操作任務加鎖。那麼ObjC中提供了哪些執行緒鎖呢?

OSSpinLock

OSSpinLock是一種自旋鎖。自旋鎖在加鎖狀態下,等待鎖的執行緒會處於忙等的狀態,一直佔用著CPU的資源。OSSpinLock目前已經不再安全,api中也不再建議使用它,因為它可能出現優先順序反轉的問題。

優先順序反轉的問題就是,當優先順序比較高的執行緒在等待鎖,它需要繼續往下執行,所以優先順序低的佔用著鎖的執行緒就沒法將鎖釋放。

OSSpinLock存在於libkern/OAtomic.h中,通過定義我們可以看出它是一個int32_t型別的(定義:typedef int32_t OSSpinLock;)。使用OSSpinLock的時候需要對鎖進行初始化,然後再運算元據之前進行加鎖,運算元據之後進行解鎖。

// 初始化OSSpinLock
_osspinlock = OS_SPINLOCK_INIT;

// 加鎖
OSSpinLockLock(&_osspinlock);

// 運算元據
// ...

// 解鎖
OSSpinLockUnlock(&_osspinlock);
複製程式碼

os_unfair_lock

iOS10之後apple廢棄了OSSpinLock使用os/lock中定義的os_unfair_lock。通過彙編來看os_unfair_lock並不是一種自旋鎖,在加鎖狀態下,等待鎖的執行緒會處於休眠狀態,不佔用CPU資源。

同樣使用os_unfair_lock的時候也需要初始化。

// 初始化os_unfair_lock
_osunfairLock = OS_UNFAIR_LOCK_INIT;

// 加鎖
os_unfair_lock_lock(&(_osunfairLock));

// 運算元據
// ...

// 解鎖
os_unfair_lock_unlock(&(_osunfairLock));
複製程式碼

pthread_mutex

pthread_mutex是屬於pthreadapi中的,mutex屬於互斥鎖。在加鎖狀態下,等待鎖的執行緒會處於休眠狀態,不會佔用CPU的資源。

mutex初始化的時候需要傳入一個鎖的屬性(int pthread_mutex_init(pthread_mutex_t * __restrict,const pthread_mutexattr_t * _Nullable __restrict);),如果傳NULL就是預設狀態PTHREAD_MUTEX_DEFAULT也就是PTHREAD_MUTEX_NORMAL

pthread_mutex狀態pthread_mutexattr_t的定義:

/*
 * Mutex type attributes
 */
#define PTHREAD_MUTEX_NORMAL		0
#define PTHREAD_MUTEX_ERRORCHECK	1
#define PTHREAD_MUTEX_RECURSIVE		2
#define PTHREAD_MUTEX_DEFAULT		PTHREAD_MUTEX_NORMAL
複製程式碼

初始化mutex之後再需要加鎖的時候呼叫pthread_mutex_lock(),解鎖的時候呼叫pthread_mutex_unlock();

另外pthread_mutex中有一個銷燬鎖的方法int pthread_mutex_destroy(pthread_mutex_t *);在不需要鎖的時候通常需要呼叫一下,將mutex鎖的地址作為引數傳入。

pthread_mutex 遞迴鎖

在開發中時常遇到遞迴呼叫的情況,如果在一個函式中進行了加鎖和解鎖操作,然後在解鎖之前遞迴。那麼遞迴的時候執行緒會發現已經加鎖了,會一直在等待鎖被釋放。這樣遞迴就沒法繼續往下進行,鎖也永遠不會被釋放,就造成了死鎖的現象。

為了解決這個問題,pthread_mutex的屬性中提供了將pthread_mutex變為遞迴鎖的屬性。’

遞迴鎖就是同一條執行緒可以對一把鎖進行重複加鎖,而不同執行緒卻不可以。這樣每一次遞迴都會加一次鎖,所以互不衝突,當遞迴結束之後會從後往前以此解鎖。不同執行緒的時候,遞迴鎖會判斷這條執行緒正在等待的鎖與加鎖的不是一條執行緒,所以不會進行加鎖,而是在等待鎖被釋放。

建立遞迴鎖的時候需要初始化一個pthread_mutexattr_t屬性:

pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init(&_pthreadMutex, &attr);
pthread_mutexattr_destroy(&attr);
複製程式碼

屬性使用完也需要進行銷燬,呼叫pthread_mutexattr_destroy函式實現。

pthread_mutex 條件

當兩條執行緒在操作同一資源,但是一條執行緒的執行,需要依賴另一條執行緒的執行結果的時候,由於預設多執行緒的訪問時間和順序是不固定的,所以不容易實現。pthread_mutex提供了執行條件的額api,使用pthread_cond_init()初始化一個條件。

在需要等待的地方使用pthread_cond_wait();等待訊號的到來,此時執行緒會進入休眠狀態並且放開mutex鎖,等待訊號到來的時候會被喚醒並且對mutex加鎖。訊號傳送使用pthread_cond_signal()來告訴等待的執行緒,自己的執行緒處理完了,依賴的可以開始執行了,等待的執行緒就會往下繼續執行。也可以使用pthread_cond_broadcast()進行廣播,告訴所有等待的該條件的執行緒。條件也是需要銷燬的,使用pthread_cond_destroy()銷燬條件。

比如兩條執行緒操作一個陣列,a執行緒負責刪除陣列,b執行緒負責往陣列中新增元素。a執行緒刪除元素的條件是陣列中必須有元素存在。

程式碼如下:

#import "ViewController.h"
#import <pthread.h>

@interface ViewController ()

@property (nonatomic, assign) pthread_mutex_t pthreadMutex;
@property (nonatomic, strong) NSMutableArray *array;
@property (nonatomic, assign) pthread_cond_t cond;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    pthread_mutex_init(&_pthreadMutex, NULL);
    pthread_cond_init(&_cond, NULL);
    [self testCond];
}

- (void)testCond {
    [[[NSThread alloc] initWithTarget:self selector:@selector(removeObject) object:nil] start];
    [[[NSThread alloc] initWithTarget:self selector:@selector(addObject) object:nil] start];
}

- (void)addObject {
    pthread_mutex_lock(&_pthreadMutex);
    NSLog(@"end start %@", _array);
    [self.array addObject:@"test"];
    NSLog(@"end add %@", _array);
    pthread_cond_signal(&_cond);
    pthread_mutex_unlock(&_pthreadMutex);
}

- (void)removeObject {
    pthread_mutex_lock(&_pthreadMutex);
    pthread_cond_wait(&_cond, &_pthreadMutex);
    NSLog(@"start remove %@", _array);
    [self.array removeLastObject];
    NSLog(@"end remove %@", _array);
    pthread_mutex_unlock(&_pthreadMutex);
}

- (void)dealloc {
    pthread_cond_destroy(&_cond);
    pthread_mutex_destroy(&_pthreadMutex);
}
複製程式碼

NSLock和NSRecursiveLock

NSLock和NSRecursiveLock是對pthread_mutex普通鎖和遞迴鎖的OC封裝。更加物件導向,使用也比較簡單。它使用了NSLocking協議來生命加鎖和解鎖的方法。由於上面已經對pthread_mutex進行了簡單的介紹,NSLock和NSRecursiveLock的api都是OC的也比較簡單。這裡不再贅述,只是說明有這樣一種實現執行緒同步的方法。

NSCondition和NSConditionLock

NSCondition是對mutexcond的封裝,由於NSCondition也遵循了NSLocking協議,所以他也可以加鎖和加鎖。使用效果和pthread的cond一樣,在等待的時候呼叫wait,傳送訊號呼叫singal

#import "ViewController.h"

@interface ViewController ()
@property (nonatomic, strong) NSMutableArray *array;
@property (nonatomic, strong) NSCondition *cond;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.array = [NSMutableArray array];
    self.cond = [[NSCondition alloc] init];
    [self testCond];
}

- (void)testCond {
    [[[NSThread alloc] initWithTarget:self selector:@selector(removeObject) object:nil] start];
    [[[NSThread alloc] initWithTarget:self selector:@selector(addObject) object:nil] start];
}

- (void)addObject {
    [_cond1 lock];
    NSLog(@"end start %@", _array);
    [self.array addObject:@"test"];
    NSLog(@"end add %@", _array);
    [_cond1 signal];
    [_cond unlock];
}

- (void)removeObject {
    [_cond lock];
    [_cond wait];
    NSLog(@"start remove %@", _array);
    [self.array removeLastObject];
    NSLog(@"end remove %@", _array);
    [_cond unlock];
}
複製程式碼

NSConditionLock是對NSCondition的又一層封裝。NSConditionLock可以新增條件,通過- (instancetype)initWithCondition:(NSInteger)condition;初始化並新增一個條件,條件是NSInteger型別的。解鎖的時候是按照這個條件進行解鎖的。依然是上述例子:

#import "ViewController.h"

@interface ViewController ()
@property (nonatomic, strong) NSMutableArray *array;
@property (nonatomic, strong) NSConditionLock *lock2;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.array = [NSMutableArray array];
    self.lock2 = [[NSConditionLock alloc] initWithCondition:1];
    [self testCond];
}

- (void)testCond {
    [[[NSThread alloc] initWithTarget:self selector:@selector(removeObject) object:nil] start];
    [[[NSThread alloc] initWithTarget:self selector:@selector(addObject) object:nil] start];
}


- (void)addObject {
    [_lock2 lock];
    NSLog(@"end start %@", _array);
    [self.array addObject:@"test"];
    NSLog(@"end add %@", _array);
    [_lock2 unlockWithCondition:2];
}

- (void)removeObject {
    [_lock2 lockWhenCondition:2];
    NSLog(@"start remove %@", _array);
    [self.array removeLastObject];
    [_lock2 unlock];
}
複製程式碼

dispatch_semaphore_t

GCD提供了一個訊號量的方式也可以解決執行緒同步的問題。

使用dispatch_semaphore_create();建立一個訊號量,使用dispatch_semaphore_wait()等待訊號的到來,使用dispatch_semaphore_signal()傳送一個訊號。

dispatch_semaphore_wait()會根據第二個引數dispatch_time_t timeout判斷超時時間,一般我們會設定為DISPATCH_TIME_FOREVER一直等待訊號的到來。如果此時訊號量的值大於0,那麼就讓訊號量的值減1,然後繼續往下執行程式碼,而如果訊號量的值小於等於0,那麼就會休眠等待,直到訊號量的值變成大於0,再就讓訊號量的值減1,然後繼續往下執行程式碼。dispatch_semaphore_signal()傳送一個訊號,並且讓訊號量加1。

經典的買票例子:

#import "ViewController.h"

@interface ViewController ()
@property (strong, nonatomic) dispatch_semaphore_t ticketSemaphore;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.ticketSemaphore = dispatch_semaphore_create(1);
    [self ticket];
}

- (void)saleTicket {
    dispatch_semaphore_wait(_ticketSemaphore, DISPATCH_TIME_FOREVER);
    int oldTicketCount = _ticketCount;
    sleep(.2);
    oldTicketCount --;
    _ticketCount = oldTicketCount;
    NSLog(@"剩餘的票數%d",_ticketCount);
    dispatch_semaphore_signal(_ticketSemaphore);
}

- (void)ticket {
    self.ticketCount = 20;
    
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self saleTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self saleTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self saleTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self saleTicket];
        }
    });
}

複製程式碼

序列佇列

使用GCD的序列佇列實現執行緒同步,原理是因為序列佇列必須一個接著一個執行,只有在執行完上一個任務的情況下,下一個任務才會繼續執行。

使用dispatch_queue_create("queue", DISPATCH_QUEUE_SERIAL);建立一條序列佇列,將多執行緒任務都放到這條序列佇列當中執行。

#import "ViewController.h"

@interface ViewController ()
@property (strong, nonatomic) dispatch_semaphore_t ticketSemaphore;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.ticketSemaphore = dispatch_semaphore_create(1);
    [self ticket];
}

- (void)saleTicket {
    dispatch_semaphore_wait(_ticketSemaphore, DISPATCH_TIME_FOREVER);
    int oldTicketCount = _ticketCount;
    sleep(.2);
    oldTicketCount --;
    _ticketCount = oldTicketCount;
    NSLog(@"剩餘的票數%d",_ticketCount);
    dispatch_semaphore_signal(_ticketSemaphore);
}

- (void)ticket {
    self.ticketCount = 20;
    
    dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_SERIAL);
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self saleTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self saleTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self saleTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self saleTicket];
        }
    });
}
複製程式碼

@synchronized

@synchronized是對mutex遞迴鎖的封裝。需要傳遞一個obj,@synchronized(obj)內部會生成obj對應的遞迴鎖,然後進行加鎖、解鎖操作。

使用:

// 建立一個初始化一次的obj
- (NSObject *)lock {
    static NSObject *lock;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        lock = [[NSObject alloc] init];
    });
    return lock;
}

// 加鎖買票經典案例
- (void)saleTicket {
    @synchronized([self lock]) {
        int oldTicketCount = _ticketCount;
        sleep(.2);
        oldTicketCount --;
        _ticketCount = oldTicketCount;
        NSLog(@"剩餘的票數%d",_ticketCount);
    }
}

- (void)ticket {
    self.ticketCount = 20;
    
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self saleTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self saleTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self saleTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i < 5; i++) {
            [self saleTicket];
        }
    });
}

// 遞迴鎖
- (void)test {
    @synchronized ([self lock]) {
        NSLog(@"%s",__func__);
        [self test];
    }
}
複製程式碼

atomic

在OC中定義屬性通常會指定屬性的原子性也就是使用nonatomic關鍵字定義非原子性的屬性,而其預設為atomic原子性

atomic用於保證屬性setter、getter的原子性操作,相當於在getter和setter內部加了執行緒同步的鎖,但是它並不能保證使用屬性的過程是執行緒安全的。

讀寫安全

當我們多項城操作一個檔案的時候,如果同時進行讀寫的話,會造成讀的內容不完全等問題。所以我們經常會在多執行緒讀寫檔案的時候,實現多讀單寫的方案。即在同一時間可以有多條執行緒在讀取檔案內容,但是隻能有一條執行緒執行寫檔案的操作。

下面通過模擬對檔案的讀寫操作並且通過pthread_rwlock_tdispatch_barrier_async來實現檔案讀寫的執行緒安全。

pthread_rwlock_t

使用pthread_rwlock_t的時候,需要呼叫pthread_rwlock_init()進行初始化。然後在讀的時候呼叫pthread_rwlock_rdlock()對讀操作進行加鎖。在寫的時候呼叫pthread_rwlock_wrlock()對讀進行加鎖。使用pthread_rwlock_unlock()進行解鎖。在用不到鎖的時候使用pthread_rwlock_destroy()對鎖進行銷燬。

#import "SecondViewController.h"
#import <pthread.h>

@interface SecondViewController ()

@property (nonatomic, assign) pthread_rwlock_t lock;

@end

@implementation SecondViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    pthread_rwlock_init(&_lock, NULL);
    
    for (int i = 0; i < 10; i++) {
        [[[NSThread alloc] initWithTarget:self selector:@selector(read) object:nil] start];
        [[[NSThread alloc] initWithTarget:self selector:@selector(write) object:nil] start];
    }

}

- (void)read {
    
    pthread_rwlock_rdlock(&_lock);
    sleep(1);
    NSLog(@"%s",__func__);
    pthread_rwlock_unlock(&_lock);
    
}

- (void)write {
    
    pthread_rwlock_wrlock(&_lock);
    sleep(1);
    NSLog(@"%s",__func__);
    pthread_rwlock_unlock(&_lock);
    
}

- (void)dealloc {
    pthread_rwlock_destroy(&_lock);
}
複製程式碼

通過列印結果的時間我們可以發現,沒有同一時間執行讀寫的操作,只有同一時間讀,這樣就保證了讀寫的執行緒安全。

列印結果如下:

列印結果

dispatch_barrier_async

GCD提供了一個非同步柵欄函式,這個函式要求傳入的併發佇列必須是自己通過dispatch_queue_cretate建立的。

它的原理就是當執行到dispatch_barrier_async的時候就相當於建立了一個柵欄將執行緒的讀寫操作隔離開,這個時候只能有一個執行緒來執行dispatch_barrier_async裡面的任務。

當我們使用它來處理讀寫安全的操作的時候,使用dispatch_barrier_async來隔離寫的操作,就能保證同一時間只能有一條執行緒對檔案執行寫的操作。

程式碼如下:

#import "SecondViewController.h"

@interface SecondViewController ()

@end

@implementation SecondViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_CONCURRENT);
    for (int i = 0; i < 5; i++) {
        dispatch_async(queue, ^{
            [self read];
        });
        dispatch_async(queue, ^{
            [self read];
        });
        dispatch_async(queue, ^{
            [self read];
        });
        dispatch_barrier_async(queue, ^{
            [self write];
        });
    }
}

- (void)read {
    sleep(1);
    NSLog(@"%s",__func__);
}

- (void)write {
    sleep(1);
    NSLog(@"%s",__func__);
}
複製程式碼

依然通過列印結果的時間分析是否實現了檔案的讀寫安全。下面是列印結果,明顯看出檔案的讀寫是安全的。

列印結果:

列印結果

總結

本篇主要介紹了ObjC和iOS開發中常用的多執行緒方案,並通過賣票的經典案例介紹了多執行緒操作統一資源造成的隱患以及通過執行緒同步方案解決隱患的幾種方法。另外還介紹了檔案讀寫鎖以及GCD提供的柵欄非同步函式處理多執行緒檔案讀寫安全的兩種用法。

相關文章