读者写者问题与读写锁
何为读者写者问题
读者写者问题是并发编程中的一个经典问题,它描述了如何在多个线程(或进程)同时访问共享资源时,允许多个读者并发读取但要求写者独占访问的情况。也就是说,在没有写操作时,多个线程可以同时读取共享数据,而写操作则必须保证在写入期间没有其他读者或写者在访问共享资源
读者写者问题与生产消费模型
读者写者问题非常类似于前面的生产消费模型,其也存在「321」原则:
- 3种关系:读者与读者、写者与写者和读者与写者
- 2种角色:读者和写者
- 1个交易场所
其中写者和写者之间的的关系与读者和写者之间的关系与生产消费模型一致,分别是互斥与互斥和同步,唯一不同的就是读者和读者之间的关系并不同于生产消费模型中消费者和消费者之间的关系
在生产消费模型中,每一个消费者都会对交易场所存在的数据进行取出,如果不使用互斥,那么肯定会存在两个线程访问到同一个数据导致同一个数据被多次取出的问题,所以需要使用互斥来避免。但是在读者写者问题中,读者仅仅是读交易场所的数据,这个行为并不会影响已有数据的个数,所以可以允许多个读者并发访问共享资源。这就是生产消费模型和读者写者问题二者最主要的区别
读者和写者如何完成同步与互斥
读者和写者之间的配合可以按照下面的伪代码去理解:
=== “公共资源”
int reader_count = 0; // 读者计数器
lock_t count_lock; // 读者锁——用于保护更改计数器的临界区
lock_t writer_lock; // 写者锁
=== “读者”
// 加锁
lock(count_lock); // 修改读者计数器前先申请锁
// 只要有一个读者过来,那么此时写者就不可以再写
if(reader_count == 0)
lock(writer_lock); // 拿走写者的锁
++reader_count;
unlock(count_lock); // 释放读者锁
// 读取
//解锁
lock(count_lock);
--reader_count;
// 当最后一个读者离开时,释放写者锁,此时写者可以继续写
if(reader_count == 0)
unlock(writer_lock);
unlock(count_lock);
=== “写者”
// 写入之前先申请锁
lock(writer_lock);
// 写入
unlock(writer_lock);
读写锁
在编写多线程的时候,有一种情况是十分常见的:有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低当前程序的效率,所以此时就需要用到读者写者问题的逻辑,即读写锁。在读者写者问题中,读写锁有下面的几种行为:
当前锁状态 | 读锁请求 | 写锁请求 |
---|---|---|
无锁 | 可以 | 可以 |
读锁 | 可以 | 阻塞 |
写锁 | 阻塞 | 阻塞 |
在上面的表格中,如果没有锁,那么读者和写者就是正常的访问共享资源,此时肯定会涉及到线程安全问题;如果当前是读锁,那么根据读者写者问题的特点:读者可以并发访问,所以就算有多个读者,这些读者也不会因为有一个读者已经正在读而被阻塞在获取锁的部分;如果当前是写锁,那么为了保证同一个时刻只有一个线程可以写入,就必须保证一旦有一个线程持有写者锁,其他线程必须阻塞等待,这样才可以防止并发写入导致数据出现问题
在实际开发中,因为pthread
库本身已经封装了相关的接口,所以不需要程序员手动实现读写锁的逻辑,常见的接口如下:
设置读者和写者优先权
在读者写者问题中,如果写者先写,那么如果写者多,读者少,就会有极大概率出现读者饥饿问题。同样地,如果读者先读,那么如果读者多,写者少,就会有极大概率出现写者饥饿问题。可见,不论是那一方优先,总会有一方可能存在饥饿问题,所以读者写者问题中,「有一方可能存在饥饿问题」是读者写者问题的特性而不是一个明显问题
在Linux中想指定哪一方优先,就可以使用pthread_rwlockattr_setkind_np
接口,该接口原型如下:
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
在上面的接口中,第一个参数表示读写锁属性,第二个参数表示标记,有3种选择:
PTHREAD_RWLOCK_PREFER_READER_NP
:读者优先,也是Linux系统的默认值PTHREAD_RWLOCK_PREFER_WRITER_NP
:写者优先(但是可能存在问题,导致与第一种情况一样)PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP
:写者优先,但是写者不能递归加锁
初始化读写锁
在Linux中,初始化读写锁可以使用pthread_rwlock_init
,其原型如下:
int pthread_rwlock_init(pthread_rwlock_t * rwlock,
const pthread_rwlockattr_t * attr);
该接口第一个参数表示读写锁类型,第二个参数表示读写锁属性
读者加锁
在Linux中,使用pthread_rwlock_rdlock
接口对读者进行加锁,其原型如下:
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
写者加锁
在Linux中,使用pthread_rwlock_wrlock
接口对写者进行加锁,其原型如下:
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
解锁
虽然读者加锁和写者加锁是两个接口,但是解锁只有一个接口:pthread_rwlock_unlock
,其原型如下:
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
销毁读写锁
使用pthread_rwlock_destroy
接口对读写锁进行销毁,其原型如下:
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
使用示例
读写锁使用示例参考下面的代码:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <vector>
#include <cstdlib>
#include <ctime>
// 共享资源
int shared_data = 0;
// 读写锁
pthread_rwlock_t rwlock;
// 读者线程函数
void *Reader(void *arg)
{
//sleep(1); //读者优先,一旦读者进入&&读者很多,写者基本就很难进入了
int number = *(int *)arg;
while (true)
{
pthread_rwlock_rdlock(&rwlock); // 读者加锁
std::cout << "读者-" << number << " 正在读取数据, 数据是: " << shared_data << std::endl;
sleep(1); // 模拟读取操作
pthread_rwlock_unlock(&rwlock); // 解锁
}
delete (int*)arg;
return NULL;
}
// 写者线程函数
void *Writer(void *arg)
{
int number = *(int *)arg;
while (true)
{
pthread_rwlock_wrlock(&rwlock); // 写者加锁
shared_data = rand() % 100; // 修改共享数据
std::cout << "写者- " << number << " 正在写入. 新的数据是: " << shared_data << std::endl;
sleep(2); // 模拟写入操作
pthread_rwlock_unlock(&rwlock); // 解锁
}
delete (int*)arg;
return NULL;
}
int main()
{
srand(time(nullptr)^getpid());
pthread_rwlock_init(&rwlock, NULL); // 初始化读写锁
// 可以更改读写数量配比
const int reader_num = 2;
const int writer_num = 2;
const int total = reader_num + writer_num;
pthread_t threads[total]; // 假设读者和写者数量相等
// 创建读者线程
for (int i = 0; i < reader_num; ++i)
{
int *id = new int(i);
pthread_create(&threads[i], NULL, Reader, id);
}
// 创建写者线程
for (int i = reader_num; i < total; ++i)
{
int *id = new int(i - reader_num);
pthread_create(&threads[i], NULL, Writer, id);
}
// 等待所有线程完成
for (int i = 0; i < total; ++i)
{
pthread_join(threads[i], NULL);
}
pthread_rwlock_destroy(&rwlock); // 销毁读写锁
return 0;
}
上面的代码直接运行会发现读者一直在读,而写者一直没有机会写导致出现饥饿问题,这也符合Linux默认读者优先的特点
读者优先与写者优先
读者优先(Reader-Preference)
在这种策略中,系统会尽可能多地允许多个读者同时访问资源(比如共享文件或数据),而不会优先考虑写者。这意味着当有读者正在读取时,新到达的读者会立即被允许进入读取区,而写者则会被阻塞,直到所有读者都离开读取区。读者优先策略可能会导致写者饥饿(即写者长时间无法获得写入权限),特别是当读者频繁到达时
写者优先(Vriter-Preference)
在这种策略中,系统会优先考虑写者。当写者请求写入权限时,系统会尽快地让写者进入写入区,即使此时有读者正在读取。这通常意味着一旦有写者到达,所有后续的读者都会被阻塞,直到写者完成写入并离开写入区。写者优先策略可以减少写者等待的时间,但可能会导致读者饥饿(即读者长时间无法获得读取权限),特别是当写者频繁到达时
自旋锁
基本介绍
自旋锁是一种多线程同步机制,用于保护共享资源免受并发访问的影响。在多个线程尝试获取锁时,它们会持续自旋(即在一个循环中不断检查锁是否可用)而不是立即进入休眠状态等待锁的释放。这种机制减少了线程切换的开销,适用于短时间内锁的竞争情况。但是不合理的使用,可能会造成CPU的浪费
原理
自旋锁通常使用一个共享的标志位(如一个布尔值)来表示锁的状态。当标志位为true
时,表示锁已被某个线程占用;当标志位为false
时,表示锁可用。当一个线程尝试获取自旋锁时,它会不断检查标志位:
- 如果标志位为
false
表示锁可用,线程将设置标志位为true
,表示自己占用了锁,并进入临界区 - 如果标志位为
true
(即锁已被其他线程占用),线程会在一个循环中不断自旋等待,直到锁被释放
使用场景
- 短暂等待的情况:适用于锁被占用时间很短的场景,如多线程对共享数据进行简单的读写操作
- 多线程锁使用:通常用于系统底层,同步多个CPU对共享资源的访问
纯软件自旋锁类似的原理实现
自旋锁的实现通常使用原子操作来保证操作的原子性,常用的软件实现方式是通过CAS(Compare-And-Swap)指令实现。以下是一个简单的自旋锁实现示例(伪代码):
#include <stdio.h>
#include <stdatomic.h>
#include <pthread.h>
#include <unistd.h>
typedef _Atomic struct
{
#if __GCC_ATOMIC_TEST_AND_SET_TRUEVAL == 1
_Bool __val;
#else
unsigned char __val;
#endif
} atomic_flag;
// 使用原子标志来模拟自旋锁
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_test_and_set
函数检查atomic_flag
的当前状态。如果atomic_flag
之前没有被设置过(即其值为false
或「未设置」状态),则函数会将其设置为true
(或「设置」状态),并返回先前的值(在这种情况下为false
)。如果atomic_flag
之前已经被设置过(即其值为true
),则函数不会改变其状态,但会返回true
上面整个操作是原子的,意味着在多线程环境中,它保证了对atomic_flag
的读取和修改是不可分割的。当一个线程调用此函数时,其他线程无法看到这个操作的任何中间状态,这确保了操作的线程安全性
自旋锁接口
因为自旋锁和互斥锁非常类似,所以下面不对自旋锁的相关接口进行详细介绍,常见的接口如下:
// 加锁
int pthread_spin_lock(pthread_spinlock_t *lock);
// 解锁
int pthread_spin_unlock(pthread_spinlock_t *lock);
// 自旋锁初始化,第二个参数传递0(即PTHREAD_PROCESS_PRIVATE)
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
// 自旋锁销毁
int pthread_spin_destroy(pthread_spinlock_t *lock);
以前面的抢票代码为例演示自旋锁的使用:
// 操作共享变量会有问题的售票系统代码
#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;
}
运行上面的代码结果与互斥锁结果一致
自旋锁的优点与缺点
一般情况下,自旋锁有下面的优点:
- 低延迟:自旋锁适用于短时间内的锁竞争情况,因为它不会让线程进入休眠状态,从而避免了线程切换的开销,提高了锁操作的效率。
- 减少系统调度开销:等待锁的线程不会被阻塞,不需要上下文切换,从而减少了系统调度的开销
同时,自旋锁也有下面的缺点:
- CPU资源浪费:如果锁的持有时间较长,等待获取锁的线程会一直循环等待,导致CPU资源的浪费
- 可能引起活锁:当多个线程同时自旋等待同一个锁时,如果没有适当的退避策略,可能会导致所有线程都在不断检查锁状态而无法进入临界区,形成活锁
所以,自旋锁是一种适用于短时间内锁竞争情况的同步机制,它通过减少线程切换的开销来提高锁操作的效率。然而,它也存在CPU资源浪费和可能引起活锁等缺点。在使用自旋锁时,需要根据具体的应用场景进行选择,并确保锁被释放的时间尽可能短