这是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介绍的最后一篇文章,读到这里,我希望你已经从中学到了一点东西,如果你喜欢这些文章,也可以把它们分享出去。