【多线程】九、常见的锁 && 读者写者问题


在这里插入图片描述

Ⅰ. 常见的锁

​ 注意我们下面讲的锁都是一种理念,而不是具体的锁,就相当于操作系统和 linux 系统的区别!

​ 之前我们讲过互斥锁、条件变量、信号量,下面来讲一讲其它的常见的锁。

一、悲观锁

​ 悲观锁是一种传统的线程同步方式,在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,会被阻塞挂起。

悲观锁的特点是会造成较高的锁竞争,因为每个线程都需要先获得锁才能访问共享资源,而只有一个线程能够获得锁

​ 悲观锁在实现上通常使用互斥锁(mutex)来保证临界区的互斥访问,因此也被称为 互斥锁

​ 悲观锁的优点是在并发访问中保证了数据的一致性,缺点是会降低并发性能,因为当一个线程持有锁时,其他线程只能等待。因此,如果共享资源的访问冲突比较少,悲观锁的效率可能会比较低。

读锁、写锁和行锁都是数据库系统中的概念,用于控制并发访问数据库时的数据一致性和并发性。具体的解释如下:

  1. 读锁(Shared Lock:允许多个事务同时访问同一数据对象,但是只能读取数据,不能修改数据。读锁不会阻塞其他的读锁,但是会阻塞写锁。读锁在事务需要读取数据而不需要修改数据时使用。
  2. 写锁(Exclusive Lock:在一个事务获得写锁之后,其他的事务就不能再访问同一数据对象。写锁既阻塞其他的写锁,也阻塞读锁。写锁用于在事务需要修改数据时使用。
  3. 行锁(Row Lock:在对数据库表中的某一行进行读或写操作时,对这一行加锁,而不是对整个表加锁。这种锁机制称为行锁,可以最大限度地提高并发性。行锁可以分为共享行锁和排它行锁两种。
    • 共享行锁:在一个事务获得共享行锁之后,其他的事务可以继续获得共享行锁,但是不能获得排它行锁。
    • 排它行锁:在一个事务获得排它行锁之后,其他的事务不能再获得共享行锁或排它行锁,直到该事务释放锁为止。

行锁适用于高并发访问同一表中不同数据行的场景,可以减少锁的竞争,提高并发性。

二、乐观锁

​ 乐观锁是一种非常常见的并发控制机制,它的基本思想是:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改,如果没有则进行更新操作,否则进行相应的补偿操作

​ 乐观锁的实现主要采用两种方式:版本号机制CAS操作

​ 每个数据项都会被赋予一个版本号,每次更新操作都会对版本号进行修改。当一个线程要对一个数据项进行更新时,它首先会读取该数据项的版本号,如果版本号与自己手头上的版本号相同,那么说明这个数据项还没有被其他线程修改过,此时它就可以进行更新操作,并将版本号加1。如果读取到的版本号与自己手头上的版本号不同,则说明这个数据项已经被其他线程修改过了,此时线程就需要进行相应的补偿操作,比如重试更新操作。

​ 乐观锁的优点是 并发度高,因为不需要加锁,所以不会出现因为线程竞争导致的阻塞现象。乐观锁相对于悲观锁来说,锁的使用更加轻量级,因此可以在高并发环境中提高系统的吞吐量。缺点是在高并发环境下,乐观锁的重试操作可能会比较频繁,导致性能下降。此外,乐观锁的实现需要对数据进行版本号的维护,这需要增加额外的开销。

CAS (Compare-and-Swap) 操作是一种原子操作,用于解决并发场景下的数据同步问题。它是一种乐观锁的实现方式。

CAS 操作可以分为以下几步:(伪代码可以查看POSIX信号量笔记,有介绍)

  1. 将目标内存地址的当前值与一个期望值进行比较。
  2. 如果两个值相等,那么就将目标内存地址的值更新为一个新的值。
  3. 如果两个值不相等,那么就不进行任何操作。

​ 在使用 CAS 操作时,需要传入一个期望值和一个新值,CAS 操作会将期望值与当前值进行比较,如果两个值相等,就将当前值更新为新值。CAS 操作是一种无锁操作,因此它不会阻塞线程。

版本号控制是另一种乐观锁的实现方式。它的基本思想是在数据结构中增加一个版本号,每次修改数据时都需要将版本号加 1,这样在并发场景下,每个线程修改数据时都会增加版本号,从而避免了数据冲突。

​ 版本号控制的实现需要对数据结构进行修改,因此相对于 CAS 操作来说,实现起来更加复杂。但是它的优点是可以保证所有线程都能够获得最新的数据,并且不会出现数据冲突的问题。

三、公平锁

​ 公平锁是指锁的获取按照请求的先后顺序进行,即 先请求锁的线程先获得锁,后请求锁的线程后获得锁,而不是随机选择一个等待锁的线程进行锁的分配。

​ 相比于非公平锁,公平锁能够保证线程获取锁的顺序,避免了饥饿和优先级反转等问题。但是,在实现上,公平锁的效率往往比非公平锁要低,因为线程在获取锁时需要进行等待和唤醒操作,而非公平锁则会让线程直接尝试获取锁,如果获取失败则进行自旋操作。

​ 💥需要注意的是,并非所有的锁都能够实现公平性,例如读写锁就无法实现公平性,因为读锁可以同时被多个线程获取,如果按照请求的先后顺序进行分配,可能会导致等待时间过长,影响系统的吞吐量。因此,读写锁通常都是非公平的实现

四、非公平锁

​ 非公平锁是一种 无需等待前一次持有锁的线程释放锁就可以立即争抢锁的锁实现。

​ 也就是说,当线程请求锁时,如果锁当前没有被持有,那么线程就可以立即获得锁;如果锁当前被持有,那么线程会一直自旋,不断尝试获取锁,直到获取到为止。

与公平锁相比,非公平锁可以在一定程度上提高系统的吞吐量,但是它可能导致某些线程长时间等待,无法及时获取到锁。因此,非公平锁的使用应该谨慎,需要根据具体的业务场景和需求进行权衡和选择。

五、原子操作(Atomic Operation)

原子操作(Atomic Operation是指在执行期间不会被中断的操作,可以作为一个不可分割的整体被执行。在多线程编程中,原子操作可以保证多个线程并发执行时的数据一致性。

​ 在计算机系统中,原子操作通常是由硬件提供支持,比如说 CPU 提供了一些指令,如上面讲过的 CAS,可以在执行期间保证不被中断。软件也可以通过加锁的方式来保证原子操作的实现。

​ 在 C++11 及以上的标准中,提供了 std::atomic 类,可以实现原子操作。该类提供了一些方法,如 load()store()exchange()compare_exchange_weak()compare_exchange_strong() 等,可以实现多种原子操作,如读取、写入、交换、比较并交换等。使用 std::atomic 类可以避免在多线程环境中发生数据竞争的问题。

六、自旋锁(Spin Lock)

​ 自旋锁是一种基于忙等待的锁,线程在获取锁时如果发现锁已经被占用,就会一直在循环中忙等待,直到获取到锁为止。在多核 CPU 上,自旋锁通常采用对锁变量进行自旋的方式,以避免线程切换的开销,提高锁的竞争效率。

​ 自旋锁 适用于锁保持时间短、竞争不激烈的情况,因为在锁保持时间长、竞争激烈的情况下,自旋锁会导致线程一直在忙等待,浪费 CPU 资源。

​ 自旋锁可以使用原子操作实现,比如 CAS 操作。当线程想要获取自旋锁时,它会将锁变量的值与期望的值进行比较,如果相等,则说明获取到了锁,否则就继续自旋等待。在多核 CPU 上,自旋锁的实现一般会使用类似于缓存行填充的技术,以避免多个线程在同一个缓存行上进行自旋锁的竞争。

它和挂起等待有什么区别呢❓❓❓

​ 自旋和挂起等待都是线程同步的一种方式,但是它们的实现方式和适用场景不同。

自旋是在获取不到锁的情况下,不断地尝试获取锁,直到获取成功或者超过一定次数之后再进行等待。这种方式适用于锁被持有的时间非常短的情况,例如在多处理器系统上使用自旋锁,因为在这种情况下,线程等待锁的时间很短,使用自旋锁可以避免线程切换的开销,提高性能。

挂起等待是在获取不到锁的情况下,==线程会进入睡眠状态,等待锁的持有者释放锁之后被唤醒==。这种方式适用于锁被持有的时间比较长的情况,例如在单处理器系统上使用互斥锁,因为在这种情况下,线程等待锁的时间比较长,使用自旋锁会浪费大量的 CPU 资源。

​ 综上所述,自旋和挂起等待都有其适用的场景,需要根据具体的应用情况进行选择。


​ 在 pthread 库中其实也提供了自旋锁类型 pthread_spinlock_t,其对应的声明如下:

typedef struct
{
   
    volatile int __spinlock; // 自旋锁的状态,0表示自旋锁未被持有,1表示已被持有
    int __owner_thread;      // 表示当前持有自旋锁的线程ID
} pthread_spinl
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

利刃大大

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值