文章目录

Ⅰ. 常见的锁
注意我们下面讲的锁都是一种理念,而不是具体的锁,就相当于操作系统和 linux 系统的区别!
之前我们讲过互斥锁、条件变量、信号量,下面来讲一讲其它的常见的锁。
一、悲观锁
悲观锁是一种传统的线程同步方式,在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,会被阻塞挂起。
悲观锁的特点是会造成较高的锁竞争,因为每个线程都需要先获得锁才能访问共享资源,而只有一个线程能够获得锁。
悲观锁在实现上通常使用互斥锁(mutex
)来保证临界区的互斥访问,因此也被称为 互斥锁。
悲观锁的优点是在并发访问中保证了数据的一致性,缺点是会降低并发性能,因为当一个线程持有锁时,其他线程只能等待。因此,如果共享资源的访问冲突比较少,悲观锁的效率可能会比较低。
读锁、写锁和行锁都是数据库系统中的概念,用于控制并发访问数据库时的数据一致性和并发性。具体的解释如下:
- 读锁(
Shared Lock
):允许多个事务同时访问同一数据对象,但是只能读取数据,不能修改数据。读锁不会阻塞其他的读锁,但是会阻塞写锁。读锁在事务需要读取数据而不需要修改数据时使用。- 写锁(
Exclusive Lock
):在一个事务获得写锁之后,其他的事务就不能再访问同一数据对象。写锁既阻塞其他的写锁,也阻塞读锁。写锁用于在事务需要修改数据时使用。- 行锁(
Row Lock
):在对数据库表中的某一行进行读或写操作时,对这一行加锁,而不是对整个表加锁。这种锁机制称为行锁,可以最大限度地提高并发性。行锁可以分为共享行锁和排它行锁两种。
- 共享行锁:在一个事务获得共享行锁之后,其他的事务可以继续获得共享行锁,但是不能获得排它行锁。
- 排它行锁:在一个事务获得排它行锁之后,其他的事务不能再获得共享行锁或排它行锁,直到该事务释放锁为止。
行锁适用于高并发访问同一表中不同数据行的场景,可以减少锁的竞争,提高并发性。
二、乐观锁
乐观锁是一种非常常见的并发控制机制,它的基本思想是:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改,如果没有则进行更新操作,否则进行相应的补偿操作。
乐观锁的实现主要采用两种方式:版本号机制 和 CAS
操作。
每个数据项都会被赋予一个版本号,每次更新操作都会对版本号进行修改。当一个线程要对一个数据项进行更新时,它首先会读取该数据项的版本号,如果版本号与自己手头上的版本号相同,那么说明这个数据项还没有被其他线程修改过,此时它就可以进行更新操作,并将版本号加1。如果读取到的版本号与自己手头上的版本号不同,则说明这个数据项已经被其他线程修改过了,此时线程就需要进行相应的补偿操作,比如重试更新操作。
乐观锁的优点是 并发度高,因为不需要加锁,所以不会出现因为线程竞争导致的阻塞现象。乐观锁相对于悲观锁来说,锁的使用更加轻量级,因此可以在高并发环境中提高系统的吞吐量。缺点是在高并发环境下,乐观锁的重试操作可能会比较频繁,导致性能下降。此外,乐观锁的实现需要对数据进行版本号的维护,这需要增加额外的开销。
CAS
(Compare-and-Swap
) 操作是一种原子操作,用于解决并发场景下的数据同步问题。它是一种乐观锁的实现方式。
CAS
操作可以分为以下几步:(伪代码可以查看POSIX信号量笔记,有介绍)
- 将目标内存地址的当前值与一个期望值进行比较。
- 如果两个值相等,那么就将目标内存地址的值更新为一个新的值。
- 如果两个值不相等,那么就不进行任何操作。
在使用
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