這是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:
,subtractMoney
和closeAccount
方法吧。實際上,我們打算每個方法寫兩種實現方式:一種假設沒有在隊列中, 一種假設在隊列中。如下:
// 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
隊列,所以接下來的工作相對簡單一些。
回顧一下那三個規則:
- 每一個方法或變量都應該被一個隊列序列化(即使其訪問操作串行),并且命名時需要加上隊列名作為前綴。
- 只在入隊到某個隊列的block中訪問有該隊列前綴的變量或者方法。
- 在有隊列名前綴修飾的方法中,只能用到被相同隊列前綴修飾的變量或者方法。
首先,在類的私有頭部里聲明帶隊列前綴的屬性和方法:
// 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_sync
或dispatch_async
去協調非變形方法的調用,使用dispatch_barrier_sync
和dispatch_barrier_async
去協調變形方法的調用。
對復雜嵌套的隊列說不
如果你發現你曾經往你的類中添加了不止一個同步隊列,那么你肯定會把你的設計搞砸的。
當程序的某個地方使用了“外層”的隊列(比如,Bank
類有一個自己的隊列),同時程序的另一個地方使用了“內層”的隊列(比如直接使用Account
類)時,你會發現你同時需要處理兩個隊列。對于方法-[Bank transferMoney:...]
,就必須串行化兩個隊列的操作,防止對Account
的直接修改導致出現線程問題。這很明顯是一個設計錯誤。
在我的經驗中,在一個功能模塊的同一個方法中使用復雜的多層隊列是不值得的。如果為了性能考慮,把串行隊列改為并發隊列通常來說是有效的做法。
讀者練習
-
-[Bank transferMoney:...]
方法有沒有做預防從一個關閉的賬戶中提現或者透支提現的操作。怎么調整公共和私有的接口來傳遞這個錯誤呢? - 使用
NSNotificationCenter
實現一個賬戶變化
的通知,怎么在避免死鎖風險的情況下實現它呢? - 假如銀行有數百萬個賬戶。重新以異步的方式實現
totalBalanceInAllAccounts
,并增加一個完成的回調block。會遇到哪些性能方面的挑戰呢?應該在哪個隊列上調用這個block來避免死鎖?
結語
我希望這個簡單的方法能夠幫你把你的代碼變得更加干凈整潔且具有可維護性,還能幫你遠離線程問題。因為它真的在這些方面幫到我了。
這是GCD介紹的最后一篇文章,讀到這里,我希望你已經從中學到了一點東西,如果你喜歡這些文章,也可以把它們分享出去。