>

中的各样锁,的一些事儿

- 编辑:www.bifa688.com -

中的各样锁,的一些事儿

图片 1

多线程在日常开发中会时不时遇到。首先APP会有一个主线程(UI线程),处理一些UI相关的逻辑。但是牵扯到网络、数据库等耗时的操作需要新开辟线程处理,避免“卡住”主线程,给用户留下不好的印象。多线程的好处不言而喻:幕后做事,不影响明面上的事儿。但是也有一些需要注意的地方,其中“资源抢夺”就是需要特别注意的一点。

在日常开发过程中,为了提升程序运行效率,以及用户体验,我们经常使用多线程。在使用多线程的过程中,难免会遇到资源竞争问题。我们采用锁的机制来确保线程安全。

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

资源抢夺

所谓资源抢夺就是多个线程同时操作一个数据。

下面这段代码很简单,就是往Preferences文件中存一个值,并读取出来输出

    override func viewDidLoad() {
        super.viewDidLoad()

        // 写
        saveData(key: identifier1, value: 1)
        // 读
        let result1 = readData(key: identifier1)
        print(" result1: (String(describing: result1))")

        // 写
        saveData(key: identifier2, value: 2)
        // 读
        print("result2: (String(describing: result1))")
    }

输出结果毫无疑问是
result1: 1
result2: 2

如果这么写

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        // 线程一操作
        let queue1 = DispatchQueue(label: "queue1");
        queue1.async {[weak self] in
            // 写
            self?.saveData(key: identifier, value: 1)
            // 读
            let result = self?.readData(key: identifier) ?? ""
            print("queue1 result: (String(describing: result))")
        }

        // 线程二操作
        let queue2 = DispatchQueue(label: "queue2");
        queue2.async {[weak self] in
            // 写
            self?.saveData(key: identifier, value: 2)
            // 读
            let result = self?.readData(key: identifier) ?? ""
            print("queue2 result: (String(describing: result))")
        }
    }

通常会认为 queue1 先输出 1, 然后 queue2 再输出 2。 但实际上...
循环打印的结果
queue1 result: 1
queue2 result: 2
queue2 result: 1
queue2 result: 2
queue1 result: 2
queue2 result: 2
queue2 result: 2
queue1 result: 1

刚才代码中的 queue1要读取并写入, 但很有可能 queue2 这时候也运行了, 它在 queue1 的写入操作没有完成之前就做了读取操作。 这时候他们两个读到值都是0, 就会造成两个都输出1。线程的调度是由操作系统来控制的,如果 queue2 调用的时, queue1 正好写入完成,这时就能得到正确的输出结果。 可如果 queue2 调起的时候 queue1 还没写入完成,那么就会出现输出同样结果的现象。 这一切都是由操作系统来控制。

线程安全

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

解决

当一个线程访问数据的时候,其他的线程不能对其进行访问,直到该线程访问完毕。即,同一时刻,对同一个数据操作的线程只有一个。只有确保了这样,才能使数据不会被其他线程污染。而线程不安全,则是在同一时刻可以有多个线程对该数据进行访问,从而得不到预期的结果。

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

1、NSLock

NSLock 是 iOS 提供给我们的一个 API 封装, 可以很好的解决资源抢夺问题。 NSLock 就是对线程加锁机制的一个封装
使用示例:

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let lock = NSLock()

        for _ in 0..<100 {
            // 线程一操作
            let queue1 = DispatchQueue(label: "queue1");
            queue1.async {[weak self] in
                lock.lock() // 锁起来
                // 写
                self?.saveData(key: identifier, value: 1)

                // 读
                let result = self?.readData(key: identifier) ?? ""
                lock.unlock()  // 解锁

                print("queue1 result: (String(describing: result))")
            }

            // 线程二操作
            let queue2 = DispatchQueue(label: "queue2");
            queue2.async {[weak self] in
                lock.lock() // 锁起来
                // 写
                self?.saveData(key: identifier, value: 2)

                // 读
                let result = self?.readData(key: identifier) ?? ""
                lock.unlock()  // 解锁

                print("queue2 result: (String(describing: result))")
            }
        }
    }

循环打印的结果
queue1 result: 1
queue2 result: 2
queue1 result: 1
queue2 result: 2
queue1 result: 2
queue2 result: 2
queue1 result: 1
queue2 result: 2

比如写文件和读文件,当一个线程在写文件的时候,理论上来说,如果这个时候另一个线程来直接读取的话,那么得到将是不可预期的结果。

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

互斥锁(pthread_mutex_lock)

互斥锁属于忙等(sleep-waiting)类型,例如在一个多核的机器上有两个线程p1和p2,分别运行在Core1和 Core2上。假设线程p1想要通过pthread_mutex_lock操作去得到一个临界区(Critical Section)的锁,而此时这个锁正被线程p2所持有,那么线程p1就会被阻塞 (blocking),Core1 会在此时进行上下文切换(Context Switch)将线程p1置于等待队列中,此时Core1就可以运行其他的任务(例如另一个线程p3),而不必进行忙等待。

为了线程安全,我们可以使用锁的机制来确保,同一时刻只有同一个线程来对同一个数据源进行访问。在开发过程中我们通常使用以下几种锁。

{ "favorites": 20}

自旋锁(Spin lock)

先插个话题:在OC中定义属性时,很多人会认为如果属性具备 nonatomic 特质,则不使用 “同步锁”。其实在属性设置方法中使用的是自旋锁。

旋锁与互斥锁有点类似,只是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是 否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。其作用是为了解决某项资源的互斥使用。因为自旋锁不会引起调用者睡眠,所以自旋锁的效率远 高于互斥锁。

虽然它的效率比互斥锁高,但是它也有些不足之处:

1、自旋锁一直占用CPU,他在未获得锁的情况下,一直运行--自旋,所以占用着CPU,如果不能在很短的时 间内获得锁,这无疑会使CPU效率降低。
2、在用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁,调用有些其他函数也可能造成死锁,如 copy_to_user()、copy_from_user()、kmalloc()等。
因此我们要慎重使用自旋锁,自旋锁只有在内核可抢占式或SMP的情况下才真正需要,在单CPU且不可抢占式的内核下,自旋锁的操作为空操作。自旋锁适用于锁使用者保持锁时间比较短的情况下。

NSLock

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

总结

这里贴一张ibireme做的测试图,介绍了一些iOS 中的锁的API,及其效率

图片 2

674591-176434d65ad6f5b6.png

挑几个我们常用且熟悉的啰嗦几句

NSRecursiveLock

let queue1 = DispatchQueue(label: "operate favorite 1")queue1.async { self.addFavorite print(self.readFavorite }
@synchronized (属:互斥锁)

显然,这是我们最熟悉的加锁方式,因为这是OC层面的为我们封装的,使用起来简单粗暴。使用时 @synchronized 后面需要紧跟一个 OC 对象,它实际上是把这个对象当做锁来使用。这是通过一个哈希表来实现的,OC 在底层使用了一个互斥锁的数组(也就是锁池),通过对象的哈希值来得到对应的互斥锁。

-(void)criticalMethod  
{  
    @synchronized(self)  
    {  
        //关键代码;  
    }  
}  

NSCondition

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

NSLock(属:互斥锁)

NSLock 是OC 以对象的形式暴露给开发者的一种锁,它的实现非常简单,通过宏,定义了 lock 方法:
#define MLOCK - (void) lock{ int err = pthread_mutex_lock(&_mutex); // 错误处理 ……}

NSLock只是在内部封装了一个pthread_mutex,属性为PTHREAD_MUTEX_ERRORCHECK,它会损失一定性能换来错误提示。这里使用宏定义的原因是,OC 内部还有其他几种锁,他们的 lock 方法都是一模一样,仅仅是内部pthread_mutex互斥锁的类型不同。通过宏定义,可以简化方法的定义。NSLock比pthread_mutex略慢的原因在于它需要经过方法调用,同时由于缓存的存在,多次方法调用不会对性能产生太大的影响。

NSConditionLock

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

atomic原子操作(属:自旋锁)

即不可分割开的操作;该操作一定是在同一个cpu时间片中完成,这样即使线程被切换,多个线程也不会看到同一块内存中不完整的数据。如果属性具备 atomic 特质,则在属性设置方法中使用的是“自旋锁”。

pthread_mutex

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

什么情况下用什么锁?

1、总的来看,推荐pthread_mutex作为实际项目的首选方案;
2、对于耗时较大又易冲突的读操作,可以使用读写锁代替pthread_mutex;
3、如果确认仅有set/get的访问操作,可以选用原子操作属性;
4、对于性能要求苛刻,可以考虑使用OSSpinLock,需要确保加锁片段的耗时足够小;
5、条件锁基本上使用面向对象的NSCondition和NSConditionLock即可;
6、@synchronized则适用于低频场景如初始化或者紧急修复使用;

pthread_rwlock

let queue1 = DispatchQueue(label: "operate favorite 1")queue1.async { self.addFavorite print(self.readFavorite }let queue2 = DispatchQueue(label: "operate favorite 2")queue2.async { self.addFavorite print(self.readFavorite }

POSIX Conditions

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

OSSpinLock

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

os_unfair_lock

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

dispatch_semaphore

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

@synchronized

func addFavorite { 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 的情况了。 这一切都是由操作系统来控制。

在这个停车场系统中,车位是公共资源,每辆车好比一个线程,看门人起的就是信号量的作用。

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

互斥锁

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

一种用来防止多个线程同一时刻对共享资源进行访问的信号量,它的原子性确保了如果一个线程锁定了一个互斥量,将没有其他线程在同一时间可以锁定这个互斥量。它的唯一性确保了只有它解锁了这个互斥量,其他线程才可以对其进行锁定。当一个线程锁定一个资源的时候,其他对该资源进行访问的线程将会被挂起,直到该线程解锁了互斥量,其他线程才会被唤醒,进一步才能锁定该资源进行操作。

var lock = NSLock()let queue1 = DispatchQueue(label: "operate favorite 1")queue1.async { lock.lock() self.addFavorite lock.unlock() print(self.readFavorite }let queue2 = DispatchQueue(label: "operate favirite 2")queue2.async { lock.lock() self.addFavorite lock.unlock() print(self.readFavorite }

优点:能有效防止因多线程抢夺资源造成的数据安全问题;

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

缺点:需要消耗大量的CPU资源;

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

NSLock

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

NSLock实现了最基本的互斥锁,遵循了 NSLocking 协议,通过 lock 和 unlock 来进行锁定和解锁。其使用也非常简单

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

图片 3

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

由于是互斥锁,当一个线程进行访问的时候,该线程获得锁,其他线程进行访问的时候,将被操作系统挂起,直到该线程释放锁,其他线程才能对其进行访问,从而却确保了线程安全。但是如果连续锁定两次,则会造成死锁问题。那如果想在递归中使用锁,那要怎么办呢,这就用到了 NSRecursiveLock 递归锁。

更多精彩内容可关注微信公众号:swift-cafe

图片 4

NSRecursiveLock

递归锁,顾名思义,可以被一个线程多次获得,而不会引起死锁。它记录了成功获得锁的次数,每一次成功的获得锁,必须有一个配套的释放锁和其对应,这样才不会引起死锁。只有当所有的锁被释放之后,其他线程才可以获得锁

图片 5

NSCondition

NSCondition 是一种特殊类型的锁,通过它可以实现不同线程的调度。一个线程被某一个条件所阻塞,直到另一个线程满足该条件从而发送信号给该线程使得该线程可以正确的执行。比如说,你可以开启一个线程下载图片,一个线程处理图片。这样的话,需要处理图片的线程由于没有图片会阻塞,当下载线程下载完成之后,则满足了需要处理图片的线程的需求,这样可以给定一个信号,让处理图片的线程恢复运行。

图片 6

NSConditionLock

NSConditionLock 对象所定义的互斥锁可以在使得在某个条件下进行锁定和解锁。它和 NSCondition 很像,但实现方式是不同的。

当两个线程需要特定顺序执行的时候,例如生产者消费者模型,则可以使用 NSConditionLock 。当生产者执行执行的时候,消费者可以通过特定的条件获得锁,当生产者完成执行的时候,它将解锁该锁,然后把锁的条件设置成唤醒消费者线程的条件。锁定和解锁的调用可以随意组合,lock 和 unlockWithCondition: 配合使用 lockWhenCondition: 和 unlock 配合使用。

图片 7

当生产者释放锁的时候,把条件设置成了1。这样消费者可以获得该锁,进而执行程序,如果消费者获得锁的条件和生产者释放锁时给定的条件不一致,则消费者永远无法获得锁,也不能执行程序。同样,如果消费者释放锁给定的条件和生产者获得锁给定的条件不一致的话,则生产者也无法获得锁,程序也不能执行。

pthread_mutex

POSIX 互斥锁是一种超级易用的互斥锁,使用的时候,只需要初始化一个 pthread_mutex_t 用 pthread_mutex_lock 来锁定 pthread_mutex_unlock 来解锁,当使用完成后,记得调用 pthread_mutex_destroy 来销毁锁。

图片 8

pthread_rwlock

读写锁,在对文件进行操作的时候,写操作是排他的,一旦有多个线程对同一个文件进行写操作,后果不可估量,但读是可以的,多个线程读取时没有问题的。

当读写锁被一个线程以读模式占用的时候,写操作的其他线程会被阻塞,读操作的其他线程还可以继续进行。

当读写锁被一个线程以写模式占用的时候,写操作的其他线程会被阻塞,读操作的其他线程也被阻塞。

图片 9

图片 10

POSIX Conditions

POSIX 条件锁需要互斥锁和条件两项来实现,虽然看起来没什么关系,但在运行时中,互斥锁将会与条件结合起来。线程将被一个互斥和条件结合的信号来唤醒。

首先初始化条件和互斥锁,当 ready_to_go 为 flase 的时候,进入循环,然后线程将会被挂起,直到另一个线程将 ready_to_go 设置为 true 的时候,并且发送信号的时候,该线程会才被唤醒。

图片 11

OSSpinLock

自旋锁,和互斥锁类似,都是为了保证线程安全的锁。但二者的区别是不一样的,对于互斥锁,当一个线程获得这个锁之后,其他想要获得此锁的线程将会被阻塞,直到该锁被释放。但自选锁不一样,当一个线程获得锁之后,其他线程将会一直循环在哪里查看是否该锁被释放。所以,此锁比较适用于锁的持有者保存时间较短的情况下。

图片 12

然而,YYKit 作者 @ibireme 的文章也有说这个自旋锁存在优先级反转问题,具体文章可以戳 不再安全的 OSSpinLock。

os_unfair_lock

自旋锁已经不在安全,然后苹果又整出来个 os_unfair_lock_t (╯‵□′)╯︵┻━┻

这个锁解决了优先级反转问题。

图片 13

@synchronized

一个便捷的创建互斥锁的方式,它做了其他互斥锁所做的所有的事情。

图片 14

如果你在不同的线程中传过去的是一样的标识符,先获得锁的会锁定代码块,另一个线程将被阻塞,如果传递的是不同的标识符,则不会造成线程阻塞。

总结

应当针对不同的操作使用不同的锁,而不能一概而论那种锁的加锁解锁速度快。

当进行文件读写的时候,使用 pthread_rwlock 较好,文件读写通常会消耗大量资源,而使用互斥锁同时读文件的时候会阻塞其他读文件线程,而 pthread_rwlock 不会。

当性能要求较高时候,可以使用 pthread_mutex 或者 dispath_semaphore,由于 OSSpinLock 不能很好的保证线程安全,而在只有在 iOS10 中才有 os_unfair_lock ,所以,前两个是比较好的选择。既可以保证速度,又可以保证线程安全。

对于 NSLock 及其子类,速度来说 NSLock < NSCondition < NSRecursiveLock < NSConditionLock 。

- (void)viewDidLoad

{

          [super viewDidLoad];

          NSLog(@"1");

          dispatch_sync(dispatch_get_main_queue(), ^{

                   NSLog(@"2");

          });

          NSLog(@"3");

}    输出 1 后 会造成死锁。

原因  dispatch_sync  是同步线程执行代码 要阻塞当前线程,如果当前线程是主线程,首先被阻塞,然后执行输出,问题是代码的参数是dispatch_get_main_queue() 它会获取主线程然后在主线程执行输出 主线程本来就被阻塞 所以造成了互锁。(如果当前调用dispatch_sync的是主队列的话会死锁,否则不会。因为dispatch_sync会阻塞当前线程等待block执行完毕然后返回,但是当前的block加入到了当前的队列等待dispatch_sync的执行完毕,那么这么互相依赖显然死锁)

本文由必发88手机版发布,转载请注明来源:中的各样锁,的一些事儿