iOS/Swift多线程之---如何避免数据竞争(Data race)

多线程编程中, 常见的问题有

  • 死锁Deadlock

    死锁指的是由于两个或多个执行单元之间相互等待对方结束而引起阻塞的情况。每个线程都拥有其他线程所需要的资源,同时又等待其他线程已经拥有的资源,并且每个线程在获取所有需要资源之前都不会释放自己已经拥有的资源。

  • 优先级翻转/倒置/逆转 Priority inversion

    当一个高优先级任务通过信号量机制访问共享资源时,该信号量已被一低优先级任务占有,而这个低优先级任务在访问共享资源时可能又被其它一些中等优先级任务抢先,因此造成高优先级任务被许多具有较低优先级任务阻塞,实时性难以得到保证。

  • 数据竞争Race condition
    Data Race是指多个线程在没有正确加锁的情况下,同时访问同一块数据,并且至少有一个线程是写操作,对数据的读取和修改产生了竞争,从而导致各种不可预计的问题。

这里我们重点讲讲iOS中的数据竞争问题以及如何解决/避免这类问题.

本文所用到的示例代码均可以在Github下载: https://github.com/zhihuitang/GCDExample

数据竞争 Data race

15236040231790.jpg

Data Race的问题非常难查,Data Race一旦发生,结果是不可预期的,也许直接就Crash了,也许导致执行流程错乱了,也许把内存破坏导致之后某个时刻突然Crash了。

在我们的产品中,经常会碰见这样的情况, 代码在我们开发测试阶段完美运行, 没有任何问题. 但产品上线后, 时不时有用户抱怨各种奇怪的问题, 后台日志跟踪(例如Fabric)也会出现一些异常的exception, 根据这些Exception的日志打印的堆栈也很难发现根本问题.


15236178780807.jpg

通常这些问题极有可能是多个线程同时访问内存中的同一段地址造成的。多线程问题是许多开发人员的噩梦, 它们难以跟踪重现,因为错误只发生在某些条件下,时间随机. 所以确定问题的根本原因可能是非常棘手的, 这就是我们所说的的“race condition”。

跟踪数据竞争在过去是一个绝对的噩梦,但幸运的是从Xcode8.0已经发布了一个新的调试工具,称为Thread Sanitizer(又叫TSan),可以帮助在运行时检测多线程中的数据竞争问题. 没了解过的小朋友可以参看官方视频 WWDC 2016 Session 412

例子

假设在你的App内, 有个联系人, 他是Person对象, 包含name和email, 定义如下:

class Person: NSObject {
    var name: String
    var email: String
    
    init(name: String, email: String) {
        self.name = name
        self.email = email
    }
    
    func setProperty(name: String, email: String) {
        self.name = name
        randomDelay(maxDuration:  0.5)
        
        randomDelay(maxDuration:  0.5)
        self.email = email
    }
    
    override var description: String {
        return "[\(name)] \(email)"
    }
}

由于业务需要, 这个Person可能会被多个线程访问(read & write), Person依次被修改为 Leijun, Luoyonghao, Yuchengdong, Goodguy.

let contacts = [("Leijun", "leijun@mi.com"), ("Luoyonghao", "luoyonghao@smartisan.com"), ("Yuchengdong", "yuchengdong@huawei.com"), ("Goodguy", "crafttang@gmail.com")]

private func updateContact(person: Person, contacts: [(String, String)]){
    for (name, email) in contacts {
        dispatchQueue.async(group: dispatchGroup) {
            person.setProperty(name: name, email: email)
            print("Current person: \(person)")
        }
    }
    
    dispatchGroup.notify(queue: DispatchQueue.global()) {
        print("==> Final person: \(person)")
    }
}

Person对象在被修改的过程中,我们同时打印出修改后的结果, 以及最终的结果. 如果一切运行正常的情况下, Person的最终结果应该为 Goodguy.

运行 GCDExample App, 点击 Test1 - Non-Thread-Safe 按钮, 对应代码如下:

let person = Person(name: "unknown", email: "unknown")
updateContact(person: person, contacts: contacts)

点击按钮后, 我们可以从Xcode的output窗口可以看见类似输出:

Current person: [Goodguy] crafttang@gmail.com
Current person: [Goodguy] yuchengdong@huawei.com
Current person: [Goodguy] luoyonghao@smartisan.com
Current person: [Goodguy] leijun@mi.com
==> Final person: [Goodguy] leijun@mi.com

上面的日志并不是我们预期的结果:

  1. 最终的Person, name倒是对的, 但Email错误;
  2. 中间结果每次输出的Person的name与email除第一个外, 均不对应.
15236244971288.jpg

造成上面name和email不对应的原因就是因为数据竞争(Data race), 多个线程试图修改同一块内存(person), 会导致修改数据的混乱, 严重的可能导致App崩溃.

利用Xcode的TSan,我们可以检测到这个问题, 在 Diagnostics页面, 选中Thread Sanitizer:

15236253730901.jpg

重新运行App, 点击Test1 - Non-Thread-Safe, 你会发现Xcode的output多了很多乱七八糟的输出:

15236255290028.jpg

同时, 在Xcode navigator面板, 你会发现检测到了Threading issues, thread9在read, thread10在write同一个变量,导致 Data race.

如何解决这种Data race问题呢? 将共享变量的 read和write放在同一个DispatchQueue中. 采用什么样的DispatchQueue, 这里有2种方法:

  1. 采用串行的DispatchQueue, 所有的read/write都是串行的, 所以不会出现Data race的问题; 但是效率比较低,即使所有的操作都是read, 也必须排队一个一个的读.
  2. 采用并行的DispatchQueue, 所有的read都可以并行进行, 所有的write都必须"独占"(barrier)的进行: 我write的时候, 任何人不允许read或者write.如下图:


    15238631755512.jpg

对于第1点, 由于效率较低, 也比较简单,这里我就不介绍.下面重点介绍如何采用barrier DispatchQueue才避免Data race的问题.
声明一个ThreadSafePerson的类, 继承于Person:

class ThreadSafePerson: Person {
    let isolationQueue = DispatchQueue(label: "com.crafttang.isolationQueue", attributes: .concurrent)
    override func setProperty(name: String, email: String) {
        isolationQueue.async(flags: .barrier) {
            super.setProperty(name: name, email: email)
        }
    }
    
    override var description: String {
        return isolationQueue.sync { super.description }
    }
}

在这个类中, 声明了一个并行.concurrent的DispatchQueue:

let isolationQueue = DispatchQueue(label: "com.crafttang.isolationQueue", attributes: .concurrent)

将所有的read和write操作都放在这个isolationQueue中.
读取person对象时:

override var description: String {
    return isolationQueue.sync { super.description }
}

修改person对象时, 将操作放在barrierisolationQueue中, 这样就保证了这个写操作是独占的:

override func setProperty(name: String, email: String) {
    isolationQueue.async(flags: .barrier) {
        super.setProperty(name: name, email: email)
    }
}
15238632049524.jpg

在ViewController添加第二个按钮Test2 - Thread-Safe, 对应代码如下:

@IBAction func button2Tapped(_ sender: UIButton) {
    let person = ThreadSafePerson(name: "unknown", email: "unknown")
    updateContact(person: person, contacts: contacts)
}

运行App, 点击第二个按钮Test2 - Thread-Safe, 在Xcode的output中你可能得到的输出如下:

Current person: [Luoyonghao] luoyonghao@smartisan.com
Current person: [Luoyonghao] luoyonghao@smartisan.com
Current person: [Yuchengdong] yuchengdong@huawei.com
Current person: [Goodguy] crafttang@gmail.com
==> Final person: [Goodguy] crafttang@gmail.com

所有的name和email均对应正确, 并且最后的 Final person也是正确的. 至此,完美解决Data race问题.

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

推荐阅读更多精彩内容