用GCD构造线程安全的类

到目前为止,我们已经了解到,在多线程的程序中,数据读写访问的操作必须被某种同步机制保护着。我们使用GCD同步队列来确保这个过程。

我们也讨论了如何使用并发队列实现一个读写锁。为了让这篇文章更简单易懂,这里我继续使用串行队列。

一个始终存在的问题:不论是谁访问了你的数据都必须使用dispatch_sync()来确保线程安全,否则就会得到错误的结果。尤其是当你的代码被某个不熟悉你的用意的人使用时(比如,你的代码是某个框架的一部分),这个问题将尤为明显。

如果我们能够将访问数据的同步操作封装起来是不是很好?这样使用者就不必再担心同步操作的问题了。

封装是类所擅长的东西。我们必须创建线程安全的类,而不是要求使用者在他们的代码中写那些同步代码。

线程安全的类

什么样的类是线程安全的呢?简单来说,如果一个类允许程序在任何线程中去实例化它,销毁它,访问它的属性,调用它的方法而不用担心会出现多线程相关的错误,那这个类就是线程安全的。

不是每一个类都需要线程安全!实现线程安全会操作性能的损失,在很多情况下都是不必要的。在你的设计中,你应该选定正确的“同步点”,这些同步点之外的对象就不用再考虑线程安全了。

我们试着让下面这个类变得线程安全。

@interface Warrior: NSObject

@property (nonatomic, strong) NSString *leftHandEquippedItem;
@property (nonatomic, strong) NSString *rightHandEquippedItem;

- (void)swapLeftAndRightHandEquippedItems;
- (NSString *)juggleNewItem:(NSString *)item; // return dropped item

@end

字符串类型的属性应该被声明为copy而不是strong的,这里我只是为了容易理解。

swapLeftAndRightHandEquippedItems方法的一种线程不安全的实现方式可能如下:

@implementation Warrior
- (void)swapLeftAndRightHandEquippedItems {
    NSString *oldLeftHandEquippedItem = self.leftHandEquippedItem;
    self.leftHandEquippedItem = self.rightHandEquippedItem;
    self.rightHandEquippedItem = oldLeftHandEquippedItem;
}
@end

很明显这这样做并不是线程安全的。如果在当前线程正在交换2个item时,有另一个线程给rightHandEquippedItem重新赋了一个新值,错误就出现了。

用队列来补救

我们需要用队列来串行化访问属性的操作。鉴于GCD队列还是相对廉价的,所以我们给每一个Warrior实例创建一个队列。

@interface Warrior()
@property (nonatomic, strong) dispatch_queue_t memberQueue;
@end

@implementation Warrior
- (id)init {
    self = [super init];
    if (self) {
        _memberQueue = dispatch_queue_create("Queue", DISPATCH_QUEUE_SERIAL);
    }
    return self;
}

// ...

@end

这个匿名的分类(category)是一个声明私有属性和方法的好地方。它必须在.m文件中声明。

现在我们需要串行化访问leftHandEquippedItemrightHandEquippedItem属性的操作。我们可以重写它们的getter和setter方法来达到这个目的:

@implementation Warrior
- (NSString *)leftHandEquippedItem {
    __block NSString *retval;
    dispatch_sync(self.memberQueue, ^{
        retval = _leftHandEquippedItem;
    });
    return retval;
}

- (void)setLeftHandEquippedItem:(NSString *)item {
    dispatch_sync(self.memberQueue, ^{
        _leftHandEquippedItem = item;
    });
}
// Same for right hand...

你无法在block内给block外的变量重新赋值,除非它被__block修饰。

这解决了我们的同步问题,但是我再向前一步,为这些属性声明一个用队列名修饰的内部版本。

@interface Warrior()
@property (nonatomic, strong) NSString *memberQueueLeftHandEquippedItem;
@property (nonatomic, strong) NSString *memberQueueRightHandEquippedItem;
// ...
@end

@implementation Warrior
- (NSString *)leftHandEquippedItem {
    __block NSString *retval;
    dispatch_sync(self.memberQueue, ^{
        retval = self.memberQueueLeftHandEquippedItem;
    });
    return retval;
}

- (void)setLeftHandEquippedItem:(NSString *)item {
    dispatch_sync(self.memberQueue, ^{
        self.memberQueueLeftHandEquippedItem = item;
    });
}
// Same for right hand...
@end

为什么要费力做这些?首先,这让swapLeftAndRightHandEquippedItems方法实现起来更容易:

- (void)memberQueueSwapLeftAndRightHandEquippedItems {
    NSString *oldLeftHandEquippedItem = self.memberQueueLeftHandEquippedItem;
    self.memberQueueLeftHandEquippedItem = self.memberQueueRightHandEquippedItem;
    self.memberQueueRightHandEquippedItem = oldLeftHandEquippedItem;
}

- (void)swapLeftAndRightHandEquippedItems {
    dispatch_sync(self.memberQueue, ^{
        [self memberQueueSwapLeftAndRightHandEquippedItems];
    });
}

// 注意: swapLeftAndRightHandEquippedItems, 不可以使用async
// 因为外界可能在调用swapLeftAndRightHandEquippedItems后,立即获取左手或者右手的武器,
// 如果使用async,那么这个block还没有执行,获取到的武器就是错误的

看到了这种模式没有?如果你的类方法和属性变成更多,这种命名方法能够帮助你的类保持串行。没有它们,每当你要使用一个方法或者属性的时候,都会疑惑是否要将其dispatch_sync到一个队列。

这种命名规则目的很简单:以队列名开头的属性和方法能够确保它们已经被加入到这个队列中了。

除了在initdealloc,或者属性的getter和setter方法中,其他地方直接去访问一个属性(例如:_myProperty)是不好的做法。其他地方应该用 self.myProperty的形式来使得你的代码易读性更高。

这种命名方法可以使你没有疑虑得构建原子性的操作:

@implementation Warrior
- (NSString *)juggleNewItem:(NSString *)item {
    __block NSString *retval;
    dispatch_sync(self.memberQueue, ^{
        retval = self.memberQueueRightHandEquippedItem;
        self.memberQueueRightHandEquippedItem = item;
        [self memberQueueSwapLeftAndRightHandEquippedItems];
    });
    return retval;
}
// ...
@end

由于你已经在memberQueue队列中将block同步化了,所以使用这些以memberQueue开头的属性或方法是安全的。(实际上,你必须使用以memberQueue开头的属性或者方法,否则的话,有可能操作死锁!)

看看这种命名协议是怎么让操作串行的?

假如当item改变时你想打印出它的值:

@implementation Warrior
- (void)setMemberQueueLeftHandEquippedItem:(NSString *)item {
    NSLog(@"Left hand now holds %@", item);
    _memberQueueLeftHandEquippedItem = item;
}
// Same for right hand...

是不是很简单。

整体来看

类的全部实现如下:

// Header file

@interface Warrior: NSObject

@property (nonatomic, strong) NSString *leftHandEquippedItem;
@property (nonatomic, strong) NSString *rightHandEquippedItem;

- (void)swapLeftAndRightHandEquippedItems;
- (NSString *)juggleNewItem:(NSString *)item; // return dropped item

@end

// Implementation file

@interface Warrior()

@property (nonatomic, strong) dispatch_queue_t memberQueue;
@property (nonatomic, strong) NSString *memberQueueLeftHandEquippedItem;
@property (nonatomic, strong) NSString *memberQueueRightHandEquippedItem;

@end

@implementation Warrior

- (id)init {
    self = [super init];
    if (self) {
        _memberQueue = dispatch_queue_create("Queue", DISPATCH_QUEUE_SERIAL);
    }
    return self;
}

- (void)setMemberQueueLeftHandEquippedItem:(NSString *)item {
    NSLog(@"Left hand now holds %@", item);
    _memberQueueLeftHandEquippedItem = item;
}

- (void)setMemberQueueRightHandEquippedItem:(NSString *)item {
    NSLog(@"Right hand now holds %@", item);
    _memberQueueRightHandEquippedItem = item;
}

- (NSString *)leftHandEquippedItem {
    __block NSString *retval;
    dispatch_sync(self.memberQueue, ^{
        retval = self.memberQueueLeftHandEquippedItem;
    });
    return retval;
}

- (void)setLeftHandEquippedItem:(NSString *)item {
    dispatch_sync(self.memberQueue, ^{
        self.memberQueueLeftHandEquippedItem = item;
    });
}

- (NSString *)rightHandEquippedItem {
    __block NSString *retval;
    dispatch_sync(self.memberQueue, ^{
        retval = self.memberQueueRightHandEquippedItem;
    });
    return retval;
}

- (void)setRightHandEquippedItem:(NSString *)item {
    dispatch_sync(self.memberQueue, ^{
        self.memberQueueRightHandEquippedItem = item;
    });
}

- (void)memberQueueSwapLeftAndRightHandEquippedItems {
    NSString *oldLeftHandEquippedItem = self.memberQueueLeftHandEquippedItem;
    self.memberQueueLeftHandEquippedItem = self.memberQueueRightHandEquippedItem;
    self.memberQueueRightHandEquippedItem = oldLeftHandEquippedItem;
}

- (void)swapLeftAndRightHandEquippedItems {
    dispatch_sync(self.memberQueue, ^{
        [self memberQueueSwapLeftAndRightHandEquippedItems];
    });
}

- (NSString *)juggleNewItem:(NSString *)item {
    __block NSString *retval;
    dispatch_sync(self.memberQueue, ^{
        retval = self.memberQueueRightHandEquippedItem;
        self.memberQueueRightHandEquippedItem = item;
        [self memberQueueSwapLeftAndRightHandEquippedItems];
    });
    return retval;
}

@end

注意到相比于之前线程不安全的版本,类的公共接口并没有改变。这就说明你做的事是正确的:所有线程安全的代码都隐藏在类的内部,这就使得这个类的使用者可以完全不用知道关于线程同步的知识而去轻松的使用它。

读者练习:定义了个新的只读的NSArray属性,叫做bagOfCarrying。提供一个线程安全的方法去添加和删除这个数组里面的值。(提示:在内部声明一个bagOfCarrying类型的属性叫做memberQueueBagOfCarrying。)想想你该如何在避免线程问题的前提下,在bagOfCarrying的getter方法中返回正确的值。


有经验的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. 在有队列名前缀修饰的方法中,只能用到被相同队列前缀修饰的变量或者方法。

以上是对原文的直接翻译, 而且没有例子, 读起来怪怪的, 我来尝试总结一下:

  1. 对外提供的方法,属性, 都需要有一个内部的私有版本,比如addMoney  对应内部私有版本为 memberQueue_addMoney;
  2. 提供给外部调用的方法, 实现都使用统一格式, 
    1.调用同步方法+串行队列,
    2.在串行队列中,调用对应的私有方法
    - (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;
    }

  3. 内部的私有版本方法可以互相调用,
    公开的方法可以互相调用,
    私有方法绝不可以调用公开的方法; 公开方法调用私有方法必须通过串行队列

    1.memberQueue_fun1  可以调用任意带有前缀的方法, 比如 memberQueue_closeAccount
    2.memberQueue_fun1 绝对不可以调用不带前缀的方法版本, 比如 closeAccount,  这样必定会死锁.

多线程主要面临的问题, 数据竞争, 死锁, 太多线程切换导致效率降低, 那么看看上面的3个规则会不会导致问题.  

数据竞争: 由于我们都是在串行队列中执行方法,访问属性, 只有上一个任务执行完成, 才会取出下一个任务进行执行, 不存在同时多个线程调用同一个方法的情况, 所以不存在数据竞争的情况.

死锁: 都是采用sync+串行队列执行, 存在死锁的风险, 但是在看看第二条和第三条规则, 这2条规则保证了私有方法之间的互相调用肯定在队列中,  而外部方法的调用肯定会进入队列中, 不存在私有方法调用外部方法发送的死锁.

画一个简单的图大概类似这样:

 太多线程切换导致效率降低: 由于我们使用的是sync方法, 这样的方法不会创建线程, 所以即使有很多实例, 也不会发生太多线程切换导致的效率降低.

 知道了理论, 那么我们开始尝试改造把, 首先,在类的私有头部里声明带队列前缀的属性和方法:

// 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;
}

完成了。这个命名规则使得一切变得清晰明朗,很容易看出哪些操作是线程安全的,哪些不是。

写过一个LUR的缓存类, 按照此思路实现了线程安全的版本, 可以参考 https://github.com/guochaoshun/LRU

上面需要太多的私有方法,每新增一个对外的方法就需要一个内部的私有方法,可以采用下面的方式减少一些一些额外的私有方法。

// 定义好block,方法内部判断是否在串行队列中执行
- (void)hanldDataInQueue:(dispatch_block_t)block {
    if (dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL) == dispatch_queue_get_label(self.serialDataQueue)) {
        if (block) {
            block();
        }
    } else {
        dispatch_async(self.serialDataQueue, ^{
            if (block) {
                block();
            }
        });
    }
}

只用一个队列

这种命名方法对我帮助很大,但是它也有一定的局限性。一般情况下,只有一个队列就足够让一切完美运行。而且幸运的是,我几乎没发现多少情况下需要用到其他的队列。

避免过度优化,在一个功能模块中用一个串行队列开始写起,到以后如果遇到性能瓶颈,再去改变。

读写锁

为了支持并发的读写队列,你需要为你的每个方法实现带有两种不同的前缀的版本:memberQueue_memberQueueMutating_。非变形(Mutating)的方法只能对变量进行读操作不能进行写操作,而且只能调用其他的非变形的方法。变形(Mutating)方法可以对变量进行读写操作,而且可以调用其他变形或者非变形方法。使用dispatch_syncdispatch_async去协调非变形方法的调用,使用dispatch_barrier_syncdispatch_barrier_async去协调变形方法的调用。

对复杂嵌套的队列说不

如果你发现你曾经往你的类中添加了不止一个同步队列,那么你肯定会把你的设计搞砸的。

当程序的某个地方使用了“外层”的队列(比如,Bank类有一个自己的队列),同时程序的另一个地方使用了“内层”的队列(比如直接使用Account类)时,你会发现你同时需要处理两个队列。对于方法-[Bank transferMoney:...],就必须串行化两个队列的操作,防止对Account的直接修改导致出现线程问题。这很明显是一个设计错误。

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值