iOS DeveloperiOS学习

【译】用GCD保证线性操作(Keeping Things Str

2016-08-25  本文已影响114人  等开会

这是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的直接修改导致出现线程问题。这很明显是一个设计错误。

在我的经验中,在一个功能模块的同一个方法中使用复杂的多层队列是不值得的。如果为了性能考虑,把串行队列改为并发队列通常来说是有效的做法。

读者练习

结语

我希望这个简单的方法能够帮你把你的代码变得更加干净整洁且具有可维护性,还能帮你远离线程问题。因为它真的在这些方面帮到我了。

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

上一篇 下一篇

猜你喜欢

热点阅读