前言
在计算机中,生产消费者问题是经典的并发控制问题之一,他描述的是多个生产执行流和多个消费执行流之间协调(同步与互斥)式的访问临界资源!与此类似,读写者问题也是一个重要的并发控制问题!本期我们将来介绍一下读写者问题和读写锁~!
目录
一、读写者问题和读写者锁
1.1 什么是读写者问题
读写者问题 是一个重要的并发控制问题,涉及多个读写的执行流,这些执行流需要并发式的访问共享的资源(文件、数据库、或某种数据结构)为了保证数据一致性和完整性,我们需要设计一种同步机制来协调这些执行流对共享资源的访问!
读写者问题中存在两种角色的执行流:
1、读者(Reader):只读取临界资源,不会拿走/修改临界资源
2、写者(Writer):修改共享资源
这就和我们平时的生活中的,栗子很相似。例如:小学/初中的时候,隔一段时间就需要画黑板,黑板报就是那个临界资源,负责画黑板报的那一批童鞋就是 写者;画完之后观看黑板报的那一群吃瓜群众就是 读者
1.2 读写者的特点
读写者问题的特点和生产消费者的特点基本一致,都可以用 321 原则总结:
1 表示:一个交易场所
2 表示:两种角色
3 表示:三种关系
其中三种关系是:
读者和读者:并发关系(没关系)
读者和写者:互斥 && 同步关系
写者和写者:互斥关系
这三种关系中后两个很好理解:
读者读的时候写者不能修改,写者写的时候读者也不允许读,即读者和写者是互斥和同步!同一个黑板报,我张三画画的时候你李四就等着我画完了你在写,即写者之间是互斥的!
1.3 为什么读者之间是并发关系?
我们上面介绍的时候也说了,读者只是负责读取并不会拿走/修改 共享资源,所以多个读者可以同时读取共享资源而不会发生冲突!
1.4、理解读写者问题
我们为了理解读写者问题,下面现将用一段伪代码介绍,然后完了之后再用一个栗子验证~!
// C++ -> Public
uint32_t reader_count = 0;
lock_t count_lock;
lock_t writer_lock;
// C++ -> Reader
// 加锁
lock(count_lock);
if(reader_count == 0)
lock(writer_lock);
++reader_count;
unlock(count_lock);
// read;
//解锁
lock(count_lock);
--reader_count;
if(reader_count == 0)
unlock(writer_lock);
unlock(count_lock);
// C++ -> Writer
lock(writer_lock);
// write
unlock(writer_lock);
为了保证读写者之间的互斥,读者第一次读的时候会先把写者的锁给占有了,这样即使读者读的时候,有写者来也不会访问到临界资源,而是写者在他的锁位置等待~!
同理,写者持有写者锁时,只能写者中的一个执行流写入操作,读者在第一次进入申请写者锁时,发现写者再用,于是直接在写者锁的那个位置等待!
读者读取
写者写入
这里有前面互斥同步以及生产消费者的理解应该是很容易理解的!
1.5、读写锁先关的接口
Linux操作系统提供了读写锁(Read-Write Lock)来实现读写者问题的同步控制。在pthread库提供的相关的接口实现。
• 初始化和销毁
#include <pthread.h>
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr);
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
注意:这里的初始化读写锁的第二个参数表示读写锁的属性,我们不用关心直接设置为nullptr 即可;另外这些函数的返回值都是一样的成功,返回0,失败返回错误码,后续不在介绍!
• 申请读写锁
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
这里读者加锁,还是两个版本,try版本是没有锁直接返回,try可以使用避免死锁!
#include <pthread.h>
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
OK,下面我们来实现一个简单的读写锁的Demo代码:
• 测试小Demo
我们创建一批线程,让几个去执行读者,几个去执行写者;
定义一个全局的整型变量,然后写者写就是想这个变量中写入,读者读取就是读取这个变量中的值!
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <ctime>
int share_count = 0;// 读写者共享
pthread_rwlock_t g_rwlock = PTHREAD_RWLOCK_INITIALIZER;// 定义一把全局的读写锁
void* Writer(void* args)
{
auto num = *(int*)args;
while (true)
{
// 加锁
pthread_rwlock_rdlock(&g_rwlock);
// 构造一个数据
share_count = rand() % 100 + 1;
std::cout << "写者_" << num << "写入了:" << share_count << std::endl;
sleep(2);
// 解锁
pthread_rwlock_unlock(&g_rwlock);
}
delete (int*)args;
return nullptr;
}
void* Reader(void* args)
{
auto num = *(int*)args;
while (true)
{
// 加锁
pthread_rwlock_rdlock(&g_rwlock);
// 读取
std::cout << "读者_" << num << "读取了:" << share_count << std::endl;
sleep(2);
// 解锁
pthread_rwlock_unlock(&g_rwlock);
}
delete (int*)args;
return nullptr;
}
int main()
{
srand(time(nullptr) ^ getpid());
// 初始化读写锁
pthread_rwlock_init(&g_rwlock, nullptr);
const int read = 2; // 读线程个数
const int write = 2;// 写线程个数
const int total = read + write; // 总个数
pthread_t tids[total]; // 管理线程的数组
// 创还能线程
for(int i = 0; i < read; i++)
{
int* num = new int(i);
pthread_create(tids+i, nullptr, Writer, num);
}
for(int i = read; i < total; i++)
{
int* num = new int(i);
pthread_create(tids+i, nullptr, Reader, num);
}
// 等待线程
for(int i = 0; i < total; i++)
{
pthread_join(tids[i], nullptr);
}
// 销销毁读写锁
pthread_rwlock_destroy(&g_rwlock);
return 0;
}
我们这里由于设置的时间问题,可以看到交替时的现象,有时候可能会出现一直是读者读/一直写者写的情况!这是正常的,因为读写锁有他的优先机制!
读者优先( Reader-Preference )在这种策略中,系统会尽可能多地允许多个读者同时访问资源(比如共享文件或数据),而不会优先考虑写者。这意味着当有读者正在读取时,新到达的读者会立即被允许进入读取区,而写者则会被阻塞,直到所有读者都离开读取区。读者优先策略可能会导致写者饥饿(即写者长时间无法获得写入权限),特别是当读者频繁到达时。写者优先( Writer-Preference )在这种策略中,系统会优先考虑写者。当写者请求写入权限时,系统会尽快地让写者进入写入区,即使此时有读者正在读取。这通常意味着一旦有写者到达,所有后续的读者都会被阻塞,直到写者完成写入并离开写入区。写者优先策略可以减少写者等待的时间,但可能会导致读者饥饿(即读者长时间无法获得读取权限),特别是当写者频繁到达时
默认是读锁优先,当然可以设置这些策略!
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
/*
pref 共有 3 种选择
PTHREAD_RWLOCK_PREFER_READER_NP (默认设置) 读者优先,可能会导致写者饥饿情况
PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先,目前有 BUG,导致表现行为和
PTHREAD_RWLOCK_PREFER_READER_NP 一致
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递归加锁
*/
1.6、读写锁的应用场景
读写锁在以下的场景中是很有用的:
1、数据库系统:在数据库系统中,读取操作通常远多于写入操作。使用读写锁可以提高并发性,允许多个读者同时读取数据,而写者在写入时独占资源。
2、缓存系统:缓存系统中的数据读取非常频繁,而写入(缓存失效或更新)相对较少。读写锁可以确保在读取时不会阻塞其他读者,同时保证写者在更新时能够独占资源。
3、配置文件:多个进程或线程可能需要读取配置文件,但修改配置文件的操作相对较少。使用读写锁可以确保配置文件在更新时不会影响大量读取操作。
1.7、读写者锁的优缺点
读写锁机制具有以下优点:
1、提高并发性:允许多个读者同时读取共享资源,提高了系统的并发性能。
2、简化同步控制:读写锁提供了简洁的API函数,使得同步控制更加容易实现。
然而,读写锁也存在一些潜在的缺点:
1、写者饥饿:在长时间运行的系统中,如果写操作频繁被读操作打断,可能会导致写者饥饿问题。这可以通过调整读写锁的策略(如写者优先)来解决。
2、死锁:虽然读写锁本身设计为防止死锁(因为它不允许多个线程同时持有写入锁),但在复杂的系统中,如果读写锁与其他同步机制(如互斥锁、条件变量等)结合使用时,仍然可能出现死锁。因此,需要通过仔细设计锁的顺序和避免嵌套锁等策略来预防。
二、自旋锁
2.1 什么是自旋锁
自旋锁 是一种多线程同步机制,用于并发时对共享资源的保护。在多个线程同时获取锁时,他们持续自旋(在一个循环中不断地检查所是否可用)而不是没有锁立即阻塞休眠等待。这种机制减少了线程的开销,适用于短时间内锁的竞争情况!但如果不合理使用,可能会造成CPU的浪费!
这里也不难理解,我们知道:多线程并发访问一个共享资源时,为了避免线程安全,就要对共享资源保护,被保护的这部分共享资源叫临界资源,访问临界资源的代码叫临界区,而所有访问临界资源的操作都是用代码访问的,所以对临界资源的保护本质就是对临界区的保护!
以前是加互斥锁/读写锁等保证,无论是互斥锁还是读写锁,他们都是当申请不成功时就去相应阻塞队列等待,即将对应的线程挂起等待,等到有锁了在唤醒!但是挂起和唤醒是需要时间的而且挂起务必伴随着线程切换!
如果是执行执行临界区代码的是将稍微长一点那还好,但如果执行临界区代码的时间较短就伴随着频繁的阻塞与唤醒,这也会导致性能受影响!所以这种情况下自旋锁就产生了重大的意义!
2.2 自旋锁的原理
自旋锁 通常使用一个共享的标志位(入一个布尔值)来表示锁的状态。当标志位为 true 时,表锁已经被某个线程占用;当标志位是 false 时,表示锁 可以用。当一个线程尝试获取自旋锁时,它的内部会不断地检查标志位:
标志位为 true :表示锁以被占用,线程会在一个循环中不断地自旋做检测,直到所释放!
标志位为 false : 表示锁可用,当线程申请到时会将标记为设为 true,表示当前已占用,并进入临界区!
我们下面用一段C++的伪代码来模拟实现一下自旋锁,主要是理解他的原理:
自旋锁的实现通常是使用原子操作来保证的,软件实现通常是CAS(Compaer-And-Swap)指令实现,我们这里使用C++11的原子性操作来简单介绍:
C++
#include <stdio.h>
#include <stdatomic.h>
#include <pthread.h>
#include <unistd.h>
// 使用原子标志来模拟自旋锁
atomic_flag spinlock = ATOMIC_FLAG_INIT; // ATOMIC_FLAG_INIT 是 0
// 尝试获取锁
void spinlock_lock()
{
while (atomic_flag_test_and_set(&spinlock))
{
// 如果锁被占用,则忙等待
}
}
// 释放锁
void spinlock_unlock()
{
atomic_flag_clear(&spinlock);
}
其中 atomic_flag 是C++11提供的原子类型,它的结构如下:
typedef _Atomic struct
{
#if __GCC_ATOMIC_TEST_AND_SET_TRUEVAL == 1
_Bool __val;
#else
unsigned char __val;
#endif
} atomic_flag;
atomic_flag_test_and_set 这个函数的作用有两个,1是检测自旋锁 2是设置互斥锁的标记位!
当检测完自旋锁没有被使用就是用,atomic_flag_test_and_set 会将标记为设计为 true 并返回原来的标记位即 false 所以死循环就结束,即自旋锁加锁成功!
当检测完是已被使用时,atomic_flag_test_and_set 会返回 true ,即一直循环的检测!
2.3 自旋锁的优缺点
优点
1、低延迟 : 自旋锁适用于短时间内的竞争情况,因为他不会让线程进入休眠状态,从而避免了线程切换到开销提高了所操作的效率!
2、减少系统的调度开销:等待线程不需要阻塞,不需要上下文的切换、从而减少了系统调度的开销!
缺点
1、CPU 资源浪费:如果持有自旋锁的线程长时间不释放,等待获取的线程会一直获取,导致CPU资源的浪费!
2、可能引起活锁:当多个线程同时自旋等待同一把锁时,如果因为某些原因,这把锁无法释放!这就会导致所有获取的线程都会在检测那里一直检测,而无法进入临界区,即形成活锁!
注意
在使用自旋锁时,需要确保锁被释放的时间尽可能的短,以避免CPU资源的浪费!
在多CPU情况下,自旋锁看不如其他锁高效,因为他可能让线程在不同的CPU上自旋等待!
2.4 自旋锁的使用场景
自旋锁在上层的应用中是非常的罕见的,可以说是几乎见不到!它的应用场景有:
1、短暂等待的情况:适用于锁占有时间很短的场景,如多线程对共享数据的简单读写操作
2、多线程锁的使用:通常用于OS的底层(非常常见),同步多个CPU对共享资源的访问
2.5 自旋锁的接口
• 加锁和解锁
// 加自旋锁,不成功,自旋检测
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_spinlock_t spin_lock;
// 初始化自旋锁
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
// 销毁自旋锁
int pthread_spin_destroy(pthread_spinlock_t *lock);
注意:自旋锁没有提供和互斥锁那样的全局的初始化宏的,所以得使用init初始化!
2.6 测试小Demo
因为上层对自旋锁的使用是非常的少的,所以我们找一个对共享的数据只是读写的例子,我们以前写的抢票就是一个不错的例子,这里可以使用自旋锁,就以他为例:
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 1000;
pthread_spinlock_t lock;
void *route(void *arg)
{
char *id = (char *)arg;
while (1)
{
pthread_spin_lock(&lock);
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
pthread_spin_unlock(&lock);
}
else
{
pthread_spin_unlock(&lock);
break;
}
}
return nullptr;
}
int main(void)
{
pthread_spin_init(&lock, PTHREAD_PROCESS_PRIVATE);
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void *)"thread 1");
pthread_create(&t2, NULL, route, (void *)"thread 2");
pthread_create(&t3, NULL, route, (void *)"thread 3");
pthread_create(&t4, NULL, route, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_spin_destroy(&lock);
return 0;
}
这里的结果也是符合我们的预期的~!
OK,,本期分享就到这里,好兄弟,我是cp我们下期再见~!