剑客
关注科技互联网

被大多数开发者忽略的线程安全问题,你是否了解呢?

这次和大家聊聊 iOS 开发中的多线程处理, 以及资源保护, 就是 Lock 的概念。

多线程无处不在

首先,我们要了解一个基本概念。 以 APP 程序来说,几乎所有的程序都是在多线程机制下运行的。 比如所有的 APP 都会有一个主线程,一般用于 UI 相关的处理。 如果你你需要请求一些网络数据,你就需要在另外一个线程中进行, 否则就会阻塞主线程的响应,结果就是用户界面明显的卡顿。 相信这个知识大家应该比较清楚了。

而我们在开发程序的时候,很容易习惯于用单线程的思维来进行开发。所以这也是我们这次要讨论的内容,在大多数情况下,这样的思维方式不会有太大的问题。 但一旦涉及到多个线程访问同一个资源的时候, 就有可能会发生一些预期之外的问题了。

共享资源问题

来说点儿具体的, 假设我们的 APP 中有这样一个文件 data.json:

{
	"favorites": 20
}

可以把它当做显示在 UI 上面的一个数据, 比如某个视频的访问数。 那么如果想增加这个访问数,我们一般需要通过一个异步线程来修改这个文件, 比如这样:

let queue1 = DispatchQueue(label: "operate favorite 1")
queue1.async {

    self.addFavorite(num: 1)
    print(self.readFavorite())
    
}

这里 addFavorite 方法用来写入文件。 readFavorite 用来读取文件中的内容。 假设我们这里文件中 favorites 的初始值是 20 ,那么这个异步操作完成后, print 语句就会输出 21。 因为我们调用 addFavorite 方法把它加 1 了。

从目前来看,一切都符合预期, 我们正确的将 favorites 的数值操作成功了。

但有一个问题可能会被很多人所忽略。 那就是 addFavorite 和 readFavorite 这两个方法操作的文件实际上是一个共享资源。 其他的线程一样可以读写这个文件。咱们再来看一个例子:

let queue1 = DispatchQueue(label: "operate favorite 1")
queue1.async {

    self.addFavorite(num: 1)
    print(self.readFavorite())
    
}

let queue2 = DispatchQueue(label: "operate favorite 2")
queue2.async {

    self.addFavorite(num: 1)
    print(self.readFavorite())
    
}

这次我们开启了两个异步操作, 他们分别都调用 addFavorite 方法, 然后再调用 readFavorite 输出文件操作后的内容。那么这样的程序你会认为最终会输出什么呢?

你可能会认为 queue1 先输出 21, 然后 queue2 再输出 22。 但实际上, 这段代码在很大概率下, 会输出两个 21。

也可以理解为,其中一个 addFavorite 的调用莫名其妙的丢失了。

那么原因是什么呢, 咱们先从 addFavorite 函数的定义说起:

func addFavorite(num: Int) {
    
    do {
     
        var fileURL = try FileManager.default.url(for: FileManager.SearchPathDirectory.documentDirectory, in: FileManager.SearchPathDomainMask.userDomainMask, appropriateFor: nil, create: true)
        fileURL = fileURL.appendingPathComponent("data.json")
        
        let jsonData = try Data(contentsOf: fileURL)
            
        if var jsonObj = try JSONSerialization.jsonObject(with: jsonData, options: JSONSerialization.ReadingOptions.allowFragments) as? [String: Any] {
            
            if let count = jsonObj["favorites"] as? NSNumber {
                
                jsonObj["favorites"] = NSNumber.init(value: count.intValue + num)
                
                let jsonData = try JSONSerialization.data(withJSONObject: jsonObj, options: JSONSerialization.WritingOptions.prettyPrinted)
                try jsonData.write(to: fileURL)
                
            }
            
        }
        
    } catch {
        
    }
    
}

这个是 addFavorite 函数的完整代码, 它做的事情并不复杂, 先从 data.json 中取得 favorites 的数值, 然后加上 num, 再将结果写回文件。 如果不存在并发操作, 这样的逻辑没有什么问题。 但如果考虑到多线程这个维度的话, 就会有问题了。

比如我们刚才代码中的 queue1, 它要读取并写入 data.json, 但很有可能 queue2 这时候也运行了, 它在 queue1 的写入操作没有完成之前就读取了 data.json。 这时候, 他们两个读到的 favorites 数值都将是初始值 20, 那么他们就都会把 20 加上 1. 也就造成了两个输出都是 21 的结果了。

这也是我前面为什么说,在很大概率下,会输出两个 21 的原因了。 线程的调度是由操作系统来控制的,如果 queue2 调起的时候, 正好 queue1 已经把文件写入完毕了,这时就能得到正确的输出结果。 反之,如果 queue2 调起的时候 queue1 还没写入完成,那么就会出现输出两个同样的 21 的情况了。 这一切都是由操作系统来控制。

关于多线程的更多基础背景知识,我们这里就不过多展开了, 这里给大家推荐一篇文章 https://computing.llnl.gov/tutorials/pthreads
, 有兴趣的朋友可以研习一下。

如何解决

前面说了这么多, 那么如何解决这个问题呢?其实业界前辈们已经给我们提供了很多的解决方了。 这里给大家介绍其中一个,就是 NSLock。 NSLock 是 iOS 提供给我们的一个 API 封装, 同时也是一个线程共享资源的通用解决方案 – 锁。 NSLock 就是对线程加锁机制的一个封装,我们再来看看刚才的例子如何用 NSLock 来处理:

var lock = NSLock()

let queue1 = DispatchQueue(label: "operate favorite 1")
queue1.async {

    lock.lock()
    self.addFavorite(num: 1)
    lock.unlock()
    print(self.readFavorite())
    
}

let queue2 = DispatchQueue(label: "operate favirite 2")
queue2.async {

    lock.lock()
    self.addFavorite(num: 1)
    lock.unlock()
    print(self.readFavorite())
    
}

这次我们对两个线程 addFavorite 调用的前后,都加上了 lock 和 unlock。 如果再次运行这个程序,得出的结果就是我们预期的了。 那么 NSLock 到底做了什么呢,这两个线程在执行 addFavorite 这个调用之前, 都试图获取这个锁,但这个锁在同一时间只能被一个线程获取。 另外那个没有获取成功的线程,就会被操作系统挂起。 直到这个锁被上一个线程解锁(unlock)。

举个具体的例子来说, 比如,操作系统先调度到 queue1, 然后它成功通过 lock() 方法取得了这个锁,开始读写文件的操作,当这些操作都完成后,再调用 unlock 释放这个锁。

那么如果在 queue1 还在读写文件这个过程中, queue2 也被调度了,并且执行了它的 lock 方法。 这时候由于 queue1 还在占用这个锁,操作系统就会让 queue2 暂时挂起, 直到 queue1 调用 unlock 将锁释放掉。 才能让 queue2 继续执行。

这样一个机制, 就保证了同一时间只能有一个线程来操作这个文件, 也就不会出现我们前面提到共享资源安全问题了。 同样, Lock 这个机制,也会带来一些性能损耗。 比如 queue2 会因为得不到 lock 而被暂时挂起。 但对于比较关键的资源来说,这个代价是值得付出的。

结尾

对于共享资源保护的考虑,应该很容易会被大家忽略。 毕竟在我们平时开发中,这种资源冲突的情况并不是总会发生。 但如果发生了,就一定会是很难调试并发现的问题。 所以我们更重要的是养成这样一个习惯, 在操作这些资源的时候,要考虑一下它会不会被多个线程同时操作,在写代码的时候提前做好处理。

如果你觉得这篇文章有帮助,还可以关注微信公众号 swift-cafe,会有更多我的原创内容分享给你~

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址