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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349

推荐阅读更多精彩内容

  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,213评论 11 349
  • 从哪说起呢? 单纯讲多线程编程真的不知道从哪下嘴。。 不如我直接引用一个最简单的问题,以这个作为切入点好了 在ma...
    Mr_Baymax阅读 2,739评论 1 17
  • Java8张图 11、字符串不变性 12、equals()方法、hashCode()方法的区别 13、...
    Miley_MOJIE阅读 3,696评论 0 11
  • 曾经有个人告诉我-这件事,你要问你自己。 当时的我,对这个答案是不满意的,就像你问老师,这题怎么解,他给你的答案是...
    0be533f0d03f阅读 276评论 0 0
  • Callback methods和Entity Listeners是Hibernate特别有用的特性,有时候会带来...
    Devid阅读 1,953评论 2 3