一 互斥锁
使用场景: 如果你能确定被锁住的代码执行时间很长,就不应该用互斥锁
加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。
互斥锁加锁失败后,线程会释放 CPU ,给其他线程,自身处于获取锁阻塞状态,然后从用户态切换到内核态由由内核帮助进行切换线程,当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行,着之间的过程会产生上下文切换
获取线程失败的线程会从Running(运行态) - > Sleep(睡眠态),然后把CPU切换给其他线程运行
当锁被释放后,线程会从Sleep(睡眠态) -> Ready(就绪)状态,然后内核把CPU切换给该进程
上下问切换的过程,虚拟内存是共享的,需要保存线程的私有数据,寄存器等不共享的数据。
二 自旋锁
使用场景: ,如果你能确定被锁住的代码执行时间很短,就使用自旋锁
自旋锁是通过CPU函数在用户态完成加锁和解锁操作,不产生上下文切换, 但是自旋锁会产生忙等待,自旋的线程会占用消耗CPU资源。此种加锁方式只适合于分时系统,不能工作在单CPU的硬件
- 第一步,查看所得状态,如果锁空闲,执行第二步
- 第二步,将锁设置为当前线程所持有
三 读写锁
读写锁适用于能明确区分读操作和写操作的场景
- 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
- 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞
- 因为读不影响资源,但是写入的时候会对资源和读的操作有影响,所以有读优先锁和写优先锁两种模式。
公平读写锁 : 比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。
四 乐观锁 和 悲观锁
前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。
悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
五 总结
开发过程中,最常见的就是互斥锁的了,互斥锁加锁失败时,会用「线程切换」来应对,当加锁失败的线程再次加锁成功后的这一过程,会有两次线程上下文切换的成本,性能损耗比较大。
如果我们明确知道被锁住的代码的执行时间很短,那我们应该选择开销比较小的自旋锁,因为自旋锁加锁失败时,并不会主动产生线程切换,而是一直忙等待,直到获取到锁,那么如果被锁住的代码执行时间很短,那这个忙等待的时间相对应也很短。
如果能区分读操作和写操作的场景,那读写锁就更合适了,它允许多个读线程可以同时持有读锁,提高了读的并发性。根据偏袒读方还是写方,可以分为读优先锁和写优先锁,读优先锁并发性很强,但是写线程会被饿死,而写优先锁会优先服务写线程,读线程也可能会被饿死,那为了避免饥饿的问题,于是就有了公平读写锁,它是用队列把请求锁的线程排队,并保证先入先出的原则来对线程加锁,这样便保证了某种线程不会被饿死,通用性也更好点。
另外,互斥锁、自旋锁、读写锁都属于悲观锁,悲观锁认为并发访问共享资源时,冲突概率可能非常高,所以在访问共享资源前,都需要先加锁。
相反的,如果并发访问共享资源时,冲突概率非常低的话,就可以使用乐观锁,它的工作方式是,在访问共享资源时,不用先加锁,修改完共享资源后,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。