注意我们下面讲的锁都是一种理念,而不是具体的锁,就相当于操作系统和 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_spinlock_t; 另外下面是它对应的接口,和我们学的互斥锁等都是类似的,这里就不细讲了!
#include <pthread.h>
int pthread_spin_init(pthread_spinlock_t *lock, int pshared); // 初始化自旋锁
int pthread_spin_destroy(pthread_spinlock_t *lock); // 销毁自旋锁
int pthread_spin_lock(pthread_spinlock_t *lock); // 获取自旋锁
int pthread_spin_trylock(pthread_spinlock_t *lock); // 尝试获取自旋锁,如果获取不到立即返回
int pthread_spin_unlock(pthread_spinlock_t *lock); // 释放自旋锁在 pthread_spin_init 函数的声明中,pshared 参数是指定自旋锁的作用域,它可以取两个值:
PTHREAD_PROCESS_PRIVATE:表示自旋锁是进程内部使用的,不能跨进程共享。PTHREAD_PROCESS_SHARED:表示自旋锁可以在多个进程之间共享,需要使用共享内存来实现。
当使用 PTHREAD_PROCESS_SHARED 时,需要先调用 shm_open 或 mmap 等函数创建一个共享内存区域,并将其映射到各个进程的虚拟地址空间中。然后,可以将 pthread_spin_init 的第二个参数传递给 pthread_spinlock_t 类型的指针,以便在各个进程之间共享自旋锁。注意,在使用共享内存时需要考虑同步问题,以避免竞态条件的出现。
Ⅱ. 读者写者问题
一、读者写者的概念
读者写者问题是一个经典的并发编程问题,涉及多个线程同时对共享资源进行读写操作的情况。一般来说,读操作之间不会产生冲突,而写操作会破坏共享资源的一致性,因此需要互斥访问。读者写者问题的目标是设计一种并发控制机制,使得在满足对共享资源的互斥访问的同时,尽可能地允许多个读者并发地访问共享资源。
在读者写者问题中,有两种类型的线程:读者和写者。它们共享一个资源,读者可以同时访问这个资源,而写者必须独占地访问这个资源。因此,当有写者正在访问资源时,不能有其他任何线程同时访问该资源,而当有读者正在访问资源时,其他读者可以同时访问该资源。同时,当有写者等待资源时,不能让读者获得资源,以免写者无限等待。
就比如我们以前在写黑板报的时候,写的时候基本都是只让一个人来写那一块区域,不允许说同时让多个人来写同一块区域,这个时候读者观看的时候其实没什么作用,就是一个写的时候独占的概念;而我们读者去观看黑板报的时候,是可以多个人去观看的,但是此时是不同意让写者去修改的!除此之外还有一个重要的点,就是写不是一直在写,而读是大部分的动作,也就是说读是占大部分时间的,这也是读者写者模型的一个特点!
简单地说就是:写独占,读共享,读锁优先级高。
传统的互斥锁方案无法满足这个需求,因为互斥锁会阻塞所有线程,而不仅仅是写入线程,这会导致读取线程也会被阻塞,从而影响了并发性能。因此,需要采用一些特殊的同步机制来解决读者写者问题。常见的解决读者写者问题的同步机制包括:
- 读写锁(
Read-Write Lock):读写锁允许多个线程同时读取资源,但在写入资源时需要进行互斥访问。读写锁分为共享锁和排他锁,当一个线程获得共享锁时,其他线程也可以获得共享锁,但是不能获得排他锁;当一个线程获得排他锁时,其他线程无法获得共享锁和排他锁。 - 信号量(
Semaphore):信号量是一种常用的同步机制,用于控制并发线程的数量。读取线程可以获得信号量,但是写入线程需要等待所有的读取线程释放信号量后才能获得信号量。 - 互斥量和条件变量(
Mutex and Condition Variable):互斥量和条件变量可以用于解决读者写者问题,其中互斥量用于互斥访问资源,条件变量用于控制读取和写入线程的执行。 - 读写自旋锁(
Read-Write Spin Lock):读写自旋锁和读写锁类似,也分为共享自旋锁和排他自旋锁。当一个线程获得共享自旋锁时,其他线程也可以获得共享自旋锁,但是不能获得排他自旋锁;当一个线程获得排他自旋锁时,其他线程无法获得共享自旋锁和排他自旋锁。
二、读写锁的概念
在对数据的读写应用中,更多的是读操作,而写操作较少,例如对数据库数据的读写应用。为了满足当前能够允许多个读出,但只允许一个写入的需求,线程提供了读写锁来实现。 线程读取锁允许多个线程执行读操作,而只允许一个线程执行写操作,同时,读写操作是互斥的。
读写锁(pthread_rwlock_t)是一种特殊的锁,可以同时支持多个读者或者一个写者访问某个共享资源,以提高并发访问效率。
读写锁一般有三种状态(如下图所示):
- 读模式:多个线程可以同时读同一个共享资源。
- 写模式:只允许一个线程写共享资源。
- 未加锁状态:未加锁状态允许多个线程同时获取读锁或写锁。

读写锁的优点:允许多个读操作同时进行,提高了并发性,同时也保证了写操作的独占性,避免了写冲突,提高了效率。
读写锁的缺点:在读操作较多的情况下,写操作很有可能会受到饥饿,因为读操作一直持有读锁,而写操作需要等待读操作完成后才能获得写锁。
三、读写锁接口
① 读写锁类型定义
定义读写锁对象的代码如下:
pthread_rwlock_t rwlock; pthread_rwlock_t 类型声明如下(注意在 linux 2.6 内核中,编译带读写锁数据类型时需要加参数 -D_GNU_SOURCE,否则会编译错误):
/* Data structure for read-write lock variable handling. The structure of the attribute type is not exposed on purpose. */
typedef union
{
struct
{
int __lock; // 锁的状态
unsigned int __nr_readers; // 读者数量
unsigned int __readers_wakeup; // 读者的唤醒标志
unsigned int __writer_wakeup; // 写者的唤醒标志
unsigned int __nr_readers_queued; // 等待读者的数量
unsigned int __nr_writers_queued; // 等待写者的数量
/* FLAGS must stay at this position in the structure to maintain binary compatibility. */
unsigned int __flags; // 用于存储读写锁的标志位
int __writer; // 当前写者的ID
} __data;
char __size[__SIZEOF_PTHREAD_RWLOCK_T]; // 用于计算pthread_rwlock_t的大小
long int __align; // 用于内存对齐
} pthread_rwlock_t;其中上述的 __flags 字段用于存储读写锁的标志位,这些标志位包括以下几种:
PTHREAD_RWLOCK_PREFER_READER_NP:如果设置了这个标志位,当读写锁处于竞争状态时,更倾向于让读线程获取锁,这个标志位是不标准的,需要特殊设置才能使用。PTHREAD_RWLOCK_PREFER_WRITER_NP:如果设置了这个标志位,当读写锁处于竞争状态时,更倾向于让写线程获取锁,这个标志位是不标准的,需要特殊设置才能使用。PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP:如果设置了这个标志位,则写锁将不可递归,即同一个线程不能重复获取写锁,这个标志位是不标准的,需要特殊设置才能使用。PTHREAD_RWLOCK_INITIALIZER:这个标志位用于表示静态初始化,如果一个读写锁被声明为pthread_rwlock_t lock = PTHREAD_RWLOCK_INITIALIZER,则其内部所有字段都会被初始化为0。
不过在使用 pthread_rwlock_t 时,建议不要依赖于这些标志位的具体值,因为它们是平台特定的和不标准的,可能会因平台和实现的不同而有所变化。

② 初始化 && 销毁
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
// 返回值:成功返回0,失败返回一个错误编号
// 参数:
// rwlock:要初始化或者销毁的读写锁指针
// attr:要初始化的读写锁的属性,一般设为nullptr即可③ 加锁和解锁
对于读锁的加锁:
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
// 返回值:成功返回0,失败返回一个错误编号
// 参数:rwlock是要加锁的读写锁指针其中两个函数的区别如下:
pthread_rwlock_rdlock是阻塞式的,如果当前有写锁或者有写锁请求正在等待,那么调用pthread_rwlock_rdlock的线程将被阻塞,直到获取读锁成功为止。pthread_rwlock_tryrdlock是非阻塞式的,如果当前没有写锁,那么调用pthread_rwlock_tryrdlock的线程将立即获取读锁,否则会立即返回一个错误码EBUSY,表示读锁获取失败。
因此,pthread_rwlock_tryrdlock 可以用来实现一些非阻塞式的读操作,如果获取不到读锁,可以采取其他的措施,而 pthread_rwlock_rdlock 则适用于需要长时间占用读锁的场景,保证了读锁的相对优先性。
对于写锁的加锁:
#include <pthread.h>
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
// 返回值:成功返回0,失败返回一个错误编号
// 参数:rwlock是要加锁的读写锁指针两个的区别在于当锁已经被占用时的行为不同:
pthread_rwlock_wrlock:尝试获取写锁,如果锁已经被占用,则当前线程会被阻塞,直到锁被释放并且当前线程成功获得锁为止。pthread_rwlock_trywrlock:尝试获取写锁,如果锁已经被占用,则函数会立即返回,不会阻塞当前线程。
因此,pthread_rwlock_wrlock 在获取写锁时可能会阻塞当前线程,而 pthread_rwlock_trywrlock 不会阻塞当前线程,只是简单地返回一个错误码提示当前线程无法获取锁。
读写锁的解锁:
#include <pthread.h>
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
// 返回值:成功返回0,失败返回一个错误编号
// 参数:rwlock是要解锁的读写锁指针 值得注意的是,pthread_rwlock_unlock 和 pthread_rwlock_destroy 都是对读写锁进行操作的函数,但是它们的作用是不同的。
pthread_rwlock_unlock 函数用于释放读写锁的占用,即解锁。如果当前线程占用了读写锁,那么该函数会释放锁并使锁处于可用状态。如果当前线程没有占用读写锁,该函数的行为是未定义的。
pthread_rwlock_destroy 函数用于销毁读写锁。该函数释放了读写锁的内部数据结构和资源,并使该锁变为不可用状态。一旦读写锁被销毁,就不能再次使用,除非重新初始化。
需要注意的是,在调用 pthread_rwlock_destroy 之前必须确保没有任何线程正在使用该读写锁。否则,该函数的行为将是未定义的。因此,在使用 pthread_rwlock_destroy 销毁读写锁之前,通常需要先使用其他线程同步机制等待所有线程都不再使用该锁。
代码测试
#include <iostream>
#include <pthread.h>
#include <unistd.h>
pthread_rwlock_t rwlock; // 读写锁
void *read_thread(void *arg)
{
while (true)
{
pthread_rwlock_rdlock(&rwlock);
std::cout << "Read thread " << arg << " get read lock." << std::endl;
// 读取共享资源
pthread_rwlock_unlock(&rwlock);
std::cout << "Read thread " << arg << " release read lock." << std::endl;
usleep(10000);
}
}
void *write_thread(void *arg)
{
while (true)
{
pthread_rwlock_wrlock(&rwlock);
std::cout << "Write thread " << arg << " get write lock." << std::endl;
// 写入共享资源
pthread_rwlock_unlock(&rwlock);
std::cout << "Write thread " << arg << " release write lock." << std::endl;
usleep(10000);
}
}
int main()
{
pthread_t read_tid[3], write_tid[2];
pthread_rwlock_init(&rwlock, nullptr); // 初始化读写锁
// 创建线程
for (int i = 0; i < 3; i++)
pthread_create(&read_tid[i], nullptr, read_thread, (void *)i);
for (int i = 0; i < 2; i++)
pthread_create(&write_tid[i], nullptr, write_thread, (void *)i);
// 等待线程
for (int i = 0; i < 3; i++)
pthread_join(read_tid[i], nullptr);
for (int i = 0; i < 2; i++) {
pthread_join(write_tid[i], nullptr);
}
// 销毁读写锁
pthread_rwlock_destroy(&rwlock);
return 0;
}
// 运行结果
Write thread 0 get write lock.
Write thread 0 release write lock.
Read thread 0x2 get read lock.
Read thread 0x2 release read lock.
Write thread 0x1 get write lock.
Write thread 0x1 release write lock.
Read thread 0x1 get read lock.
Read thread 0x1 release read lock.
Read thread 0 get read lock.
Read thread 0 release read lock.
Write thread 0 get write lock.
Write thread 0 release write lock.
Read thread 0x2 get read lock.
Read thread 0x2 release read lock.
Write thread 0x1 get write lock.
Write thread 0x1 release write lock.
Read thread 0x1 get read lock.
Read thread 0x1 release read lock.
Read thread 0 get read lock.
Read thread 0 release read lock.
....... 上述示例代码创建了 3 个读线程和 2 个写线程,它们都在不断地获取锁、操作共享资源、释放锁的过程中循环执行。在读线程中使用 pthread_rwlock_rdlock() 获取读锁,然后读取共享资源,最后释放读锁;在写线程中使用 pthread_rwlock_wrlock() 获取写锁,然后写入共享资源,最后释放写锁。
需要注意的是,这里为了简化代码没有进行任何错误处理,实际中应该对返回值进行判断并根据不同的错误进行相应的处理。