【譯】用GCD保證線性操作(Keeping Things Straight with GCD)

這是GCD介紹的第六篇文章,也是最后一篇。

有經驗的GCD使用者會告訴你:使用GCD時,你很容易就會忘記你當前在哪個隊列上,應不應該dispatch_sync一個隊列用來保護你的變量,或者我的調用者應不應該自己來考慮這些?

在這片文章中,我將介紹一種簡單的命名方法,這些年來一直對我幫助很大。遵守這個命名方法,你就不會再次陷入死鎖或者忘記同步化訪問屬性的操作了。

設計線程安全的庫

當談到設計線程安全的代碼,很容易就會有編寫一個線程安全的庫的想法。你需要區分外部公共接口和內部私有接口。外部接口寫在公開的頭文件中,而內部私有的接口寫在私有的頭文件中,且只給該庫的開發者使用。

理想的線程安全類的外部接口不應該暴露出與線程和隊列相關的東西(除非你的庫就是用來管理線程和隊列的)。當然最基本的是,使用你的庫時,不應該發生競態條件或者死鎖。讓我們來看一下這個典型的例子:

// Public header

#import <Foundation/Foundation.h>
// Thread-safe

@interface Account: NSObject
@property (nonatomic, readonly, getter=isClosed) BOOL closed;
@property (nonatomic, readonly) double balance;
- (void)addMoney:(double)amount;
- (void)subtractMoney:(double)amount;
- (double)closeAccount; // Returns balance
@end

@interface Bank: NSObject
@property (nonatomic, copy) NSArray<Account *> *accounts;
@property (nonatomic, readonly) double totalBalanceInAllAccounts;
- (void)transferMoneyAmount:(double)amount
                fromAccount:(Account *)fromAccount
                  toAccount:(Account *)toAccount;
@end

如果沒有注釋的話,你很難看出這個類是線程安全的。也就是說,你得把線程安全的實現隱藏起來。

三個簡單的規則

在實現文件里,定義一個串行隊列,用來串行化所有成員屬性的訪問操作。在我的經驗里,通常在一個功能模塊中定義一個串行隊列就足夠了,當然如果對性能要求較高的話,你也可以把這個隊列替換為并發隊列。

// Bank_Private.h
dispatch_queue_t memberQueue();
// Bank.m
#import "Bank_Private.h"
dispatch_queue_t memberQueue() {
    static dispatch_queue_t queue;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        queue = dispatch_queue_create("member queue", DISPATCH_QUEUE_SERIAL);
    });
    return queue;
}

現在,我來介紹第一個規則,也是一種命名方法:每一個方法或變量都應該被一個隊列序列化(使其訪問操作串行),并且命名時需要加上隊列名作為前綴。

比如Account類中的所有屬性都需要被序列化,所以它們的變量名需要增加隊列名前綴。一種方便的做法就是引入私有類擴展。

// Bank_Private.h
@interface Account()
@property (nonatomic, getter=memberQueue_isClosed) BOOL memberQueue_closed;
@property (nonatomic) double memberQueue_balance;
@end

這個類擴展應該放在類的私有頭部。

在類的私有頭部中,我們已經將balance改為了一個可讀可寫的屬性,所以在類的內部,我們可以輕易的改變它的值。

由于Objective-C會為所有的屬性自動生成成員變量和讀寫方法,我們現在碰到了兩種成員變量:一種是公開的屬性生成的,一種是私有的被隊列保護的屬性生成的。一種阻止公開屬性自動生成成員變量和讀寫方法的辦法就是在類的實現文件中將他們聲明為@dynamic。

// Bank.m

@implementation Account
@dynamic closed, balance;
@end

我們需要手動為這些公開屬性創建讀寫方法:

// Bank.m
@implementation Account
// ...
- (BOOL)isClosed {
    __block BOOL retval;
    dispatch_sync(memberQueue(), ^{
        retval = self.memberQueue_isClosed;
    });
    return retval;
}

- (double)balance {
    __block double retval;
    dispatch_sync(memberQueue(), ^{
        retval = self.memberQueue_balance;
    });
    return retval;
}
@end

你可以通過自己手動提供讀寫方法來阻止自動生成。但是我更傾向于使用@dynamic來明確指出,我并不需要自動為我的屬性生成成員變量和讀寫方法。調試階段由于一個未實現的方法導致的崩潰要比發布之后的代碼里存在潛在的奔潰風險要好很多。

看到這種模式了嗎?這就引出了第二個規則:只在入隊到某個隊列的block中訪問有該隊列前綴的變量或者方法。

現在,讓我們來實現addMoney:subtractMoneycloseAccount方法吧。實際上,我們打算每個方法寫兩種實現方式:一種假設沒有在隊列中, 一種假設在隊列中。如下:

// Bank.m
@implementation Account
//...
- (void)addMoney:(double)amount {
    dispatch_sync(memberQueue(), ^{
        [self memberQueue_addMoney:amount];
    });
}
- (void)memberQueue_addMoney:(double)amount {
    self.memberQueue_balance += amount;
}

- (void)subtractMoney:(double)amount {
    dispatch_sync(memberQueue(), ^{
        [self memberQueue_subtractMoney:amount];
    });
}
- (void)memberQueue_subtractMoney:(double)amount {
    self.memberQueue_balance -= amount;
}

- (double)closeAccount {
    __block double retval;
    dispatch_sync(memberQueue(), ^{
        retval = [self memberQueue_closeAccount];
    });
    return retval;
}
- (double)memberQueue_closeAccount {
    self.memberQueue_closed = YES;
    double balance = self.memberQueue_balance;
    self.memberQueue_balance = 0.0;
    return balance;
}

@end

我們仍然把這些帶隊列名前綴的方法放在我們的私有頭部里:

// Bank_Private.h
@interface Account()
//...
- (void)memberQueue_addMoney:(double)amount;
- (void)memberQueue_subtractMoney:(double)amount;
- (double)memberQueue_closeAccount;

然后是第三個規則:在有隊列名前綴修飾的方法中,只能用到被相同隊列前綴修飾的變量或者方法。

這三個規則可以使我們保持清醒:你可以準確的知道你現在在那個隊列上(如果有的話)。并且只要你在這個隊列上,你只能訪問相同隊列上的方法和變量。

注意到,在memberQueue_closeAccount方法中,知道該方法只有在memberQueue隊列上才會被調用時,我們是如何原子性的修改memberQueue_closed``memberQueue_balance了吧。memberQueue_addMoney:memberQueue_subtractMoney:方法中的加減操作也是如此,可以不用擔心競態條件執行線程安全的操作。

再來一次

現在我們可以在任何線程中使用Account類的對象了。接下來讓我們把Bank類也變得線程安全吧。因為在Bank類和Account類中,我們用的是同一個memberQueue隊列,所以接下來的工作相對簡單一些。

回顧一下那三個規則:

  1. 每一個方法或變量都應該被一個隊列序列化(即使其訪問操作串行),并且命名時需要加上隊列名作為前綴。
  2. 只在入隊到某個隊列的block中訪問有該隊列前綴的變量或者方法。
  3. 在有隊列名前綴修飾的方法中,只能用到被相同隊列前綴修飾的變量或者方法。

首先,在類的私有頭部里聲明帶隊列前綴的屬性和方法:

// Bank_Private.h
@interface Bank()
@property (nonatomic, copy) NSArray<Account *> *memberQueue_accounts;
@property (nonatomic, readonly) double memberQueue_totalBalanceInAllAccounts;
- (void)memberQueue_transferMoneyAmount:(double)amount
                            fromAccount:(Account *)fromAccount
                              toAccount:(Account *)toAccount;
@end

然后用@dynamic來阻止自動生成成員變量和讀寫方法:

// Bank.m
@implementation Bank
@dynamic accounts, totalBalanceInAllAccounts;
@end

實現我們定義的方法:

// Bank.m
@implementation Bank
@dynamic accounts, totalBalanceInAllAccounts;
@end
We define our member functions:

// Bank.m
@implementation Bank
//...
- (NSArray<Account *> *)accounts {
    __block NSArray<Account *> *retval;
    dispatch_sync(memberQueue(), ^{
        retval = self.memberQueue_accounts;
    });
    return retval;
}
- (void)setAccounts:(NSArray<Account *> *)accounts {
    dispatch_sync(memberQueue(), ^{
        self.memberQueue_accounts = accounts;
    });
}

- (double)totalBalanceInAllAccounts {
    __block double retval;
    dispatch_sync(memberQueue(), ^{
        retval = self.memberQueue_totalBalanceInAllAccounts;
    });
    return retval;
}
- (double)memberQueue_totalBalanceInAllAccounts {
    __block double retval = 0.0;
    for (Account *account in self.memberQueue_accounts) {
        retval += account.memberQueue_balance;
    }
    return retval;
}

- (void)transferMoneyAmount:(double)amount
                fromAccount:(Account *)fromAccount
                  toAccount:(Account *)toAccount {
    dispatch_sync(memberQueue(), ^{
        [self memberQueue_transferMoneyAmount:amount
                                  fromAccount:fromAccount
                                    toAccount:toAccount];
    });
}
- (void)memberQueue_transferMoneyAmount:(double)amount
                            fromAccount:(Account *)fromAccount
                              toAccount:(Account *)toAccount {
    fromAccount.memberQueue_balance -= amount;
    toAccount.memberQueue_balance += amount;
}

完成了。這個命名規則使得一切變得清晰明朗,很容易看出哪些操作是線程安全的,哪些不是。

只用一個隊列

這種命名方法對我幫助很大,但是它也有一定的局限性。一般情況下,只有一個隊列就足夠讓一切完美運行。而且幸運的是,我幾乎沒發現多少情況下需要用到其他的隊列。

避免過度優化,在一個功能模塊中用一個串行隊列開始寫起,到以后如果遇到性能瓶頸,再去改變。

讀寫鎖

為了支持并發的讀寫隊列,你需要為你的每個方法實現帶有兩種不同的前綴的版本:memberQueue_memberQueueMutating_。非變形(Mutating)的方法只能對變量進行讀操作不能進行寫操作,而且只能調用其他的非變形的方法。變形(Mutating)方法可以對變量進行讀寫操作,而且可以調用其他變形或者非變形方法。使用dispatch_syncdispatch_async去協調非變形方法的調用,使用dispatch_barrier_syncdispatch_barrier_async去協調變形方法的調用。

對復雜嵌套的隊列說不

如果你發現你曾經往你的類中添加了不止一個同步隊列,那么你肯定會把你的設計搞砸的。

當程序的某個地方使用了“外層”的隊列(比如,Bank類有一個自己的隊列),同時程序的另一個地方使用了“內層”的隊列(比如直接使用Account類)時,你會發現你同時需要處理兩個隊列。對于方法-[Bank transferMoney:...],就必須串行化兩個隊列的操作,防止對Account的直接修改導致出現線程問題。這很明顯是一個設計錯誤。

在我的經驗中,在一個功能模塊的同一個方法中使用復雜的多層隊列是不值得的。如果為了性能考慮,把串行隊列改為并發隊列通常來說是有效的做法。

讀者練習

  • -[Bank transferMoney:...]方法有沒有做預防從一個關閉的賬戶中提現或者透支提現的操作。怎么調整公共和私有的接口來傳遞這個錯誤呢?
  • 使用NSNotificationCenter實現一個賬戶變化的通知,怎么在避免死鎖風險的情況下實現它呢?
  • 假如銀行有數百萬個賬戶。重新以異步的方式實現totalBalanceInAllAccounts,并增加一個完成的回調block。會遇到哪些性能方面的挑戰呢?應該在哪個隊列上調用這個block來避免死鎖?

結語

我希望這個簡單的方法能夠幫你把你的代碼變得更加干凈整潔且具有可維護性,還能幫你遠離線程問題。因為它真的在這些方面幫到我了。

這是GCD介紹的最后一篇文章,讀到這里,我希望你已經從中學到了一點東西,如果你喜歡這些文章,也可以把它們分享出去。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 從三月份找實習到現在,面了一些公司,掛了不少,但最終還是拿到小米、百度、阿里、京東、新浪、CVTE、樂視家的研發崗...
    時芥藍閱讀 42,366評論 11 349
  • 從哪說起呢? 單純講多線程編程真的不知道從哪下嘴。。 不如我直接引用一個最簡單的問題,以這個作為切入點好了 在ma...
    Mr_Baymax閱讀 2,831評論 1 17
  • Java8張圖 11、字符串不變性 12、equals()方法、hashCode()方法的區別 13、...
    Miley_MOJIE閱讀 3,731評論 0 11
  • 曾經有個人告訴我-這件事,你要問你自己。 當時的我,對這個答案是不滿意的,就像你問老師,這題怎么解,他給你的答案是...
    0be533f0d03f閱讀 291評論 0 0
  • Callback methods和Entity Listeners是Hibernate特別有用的特性,有時候會帶來...
    Devid閱讀 1,994評論 2 3