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", "[email protected]"), ("Luoyonghao", "[email protected]"), ("Yuchengdong", "[email protected]"), ("Goodguy", "[email protected]")]

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] [email protected]
Current person: [Goodguy] [email protected]
Current person: [Goodguy] [email protected]
Current person: [Goodguy] [email protected]
==> Final person: [Goodguy] [email protected]

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

  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] [email protected]
Current person: [Luoyonghao] [email protected]
Current person: [Yuchengdong] [email protected]
Current person: [Goodguy] [email protected]
==> Final person: [Goodguy] [email protected]

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

15236267451660.jpg

你可能感兴趣的:(iOS/Swift多线程之---如何避免数据竞争(Data race))