欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 财经 > 产业 > Linux 读写者问题和读写者锁

Linux 读写者问题和读写者锁

2024/10/23 17:19:22 来源:https://blog.csdn.net/m0_75256358/article/details/143062981  浏览:    关键词:Linux 读写者问题和读写者锁

前言

在计算机中,生产消费者问题是经典的并发控制问题之一,他描述的是多个生产执行流和多个消费执行流之间协调(同步与互斥)式的访问临界资源!与此类似,读写者问题也是一个重要的并发控制问题!本期我们将来介绍一下读写者问题和读写锁~!

目录

前言

一、读写者问题和读写者锁

1.1 什么是读写者问题

1.2 读写者的特点

1.3 为什么读者之间是并发关系?

1.4、理解读写者问题

1.5、读写锁先关的接口

• 初始化和销毁

• 申请读写锁

• 测试小Demo

1.6、读写锁的应用场景

1.7、读写者锁的优缺点

二、自旋锁

2.1 什么是自旋锁

2.2 自旋锁的原理

2.3 自旋锁的优缺点

2.4 自旋锁的使用场景

2.5 自旋锁的接口

• 加锁和解锁

• 初始化和销毁

2.6 测试小Demo


一、读写者问题和读写者锁

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_flagC++11提供的原子类型,它的结构如下:

typedef _Atomic struct
{#if __GCC_ATOMIC_TEST_AND_SET_TRUEVAL == 1_Bool __val;#elseunsigned 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我们下期再见~!

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com