Linux_16(多线程)信号量+基于环形队列的生成消费模型+自选锁+读写锁

1.信号量

之前在学习进程间通信的时候,简单地介绍过一下信号量,今天在这里进行详细的介绍。

cpp 复制代码
    void push(const T& in) // 生产者
    {
        lockGuard lockgrard(&_mtx); // 自动调用构造函数
        //pthread_mutex_lock(&_mtx);
 
        // pthread_cond_wait: 只要是一个函数,就可能调用失败,可能存在 伪唤醒 的情况,所以用while
        while(isQueueFull()) //1. 先检测当前的临界资源是否能够满足访问条件
        {
            pthread_cond_wait(&_Full, &_mtx); // 满的时候就在_Full这个条件变量下等待
            // 此时思考:我们是在临界区中,我是持有锁的,如果我去等待了,锁该怎么办呢?
            // 所以pthread_cond_wait第二个参数是一个锁,当成功调用wait之后,传入的锁,会被自动释放
            // 当我被唤醒时,我从哪里醒来呢?->从哪里阻塞挂起,就从哪里唤醒, 被唤醒的时候,我们还是在临界区被唤醒的
            // 当我们被唤醒的时候,pthread_cond_wait,会自动帮助我们线程获取锁
        }
 
        _bq.push(in); // 2. 队列不为空或者被唤醒 -> 访问临界资源,100%确定,资源是就绪的
 
        pthread_cond_signal(&_Empty); // 唤醒
        // pthread_mutex_unlock(&_mtx); // 解锁
    } // 出了代码块自动调用析构函数

这是之前写的基于阻塞队列的生产者消费者模型中向阻塞队列中push任务的代码。

不足之处:

一个线程在向阻塞队列中push任务的时候,必须满足临界资源不满的条件,否则就会被放入到条件变量的等待队列中去。

但是临界资源是否为满是不能直接得到答案的,需要先申请锁,然后进入临界区访问临界资源去判断它是否为满

在判断临界资源是否满足条件的过程中,必须先加锁,再检测,再操作,最后再解锁。

检测临界资源的本质也是在访问临界资源。

只要对临界资源整体加锁,就默认现场会对这个临界资源整体使用,但是实际情况可能存在:一份临界资源,划分为多个不同的区域,而且运行多个线程同时访问不同的区域

  • 在访问临界资源之前,无法得知临界资源的情况。
  • 多个线程不能同时访问临界资源的不同区域。

1.1 信号量和信号量操作的概念

信号量: 本质是一把计数器,用来衡量临界资源中资源数量多少。
申请信号量的本质:对临界资源 中特定的小块资源的预定机制

信号量也是一种互斥量,只要申请到信号量的线程,在未来一定能够拥有一份临界资源。

如上图所示,将一块临界资源划分为9个不同的区域,

现在想要让多个线程同时访问这9个不同的区域:

  • 创建一个信号量,它的值是9。
  • 每一个来访问临界资源的线程都先申请信号量,也就是将计数值减一。
  • 当计数值被减到0的时候,说明临界资源中的9个区域都在现场,然后再访问,其他想要访问临界资源的现场只能阻塞等待。

申请到信号量的现场就可以进入临界区去访问临界资源,当访问完毕以后,再将信号量加一。

每个线程访问临界资源中的哪块区域由程序员决定,但是必须保证一个区域只能有一个线程在访问。

通过信号量的方式就解决了之前代码的不足:

  • 线程不用访问临界资源就可以知道资源的使用情况。
  • 信号量只要申请成功就一定有资源使用,只要申请失败就说明条件不满足,只能阻塞等待。
  • 临界资源中的不同区域可以被多线程同时访问。

所有线程必须都能看到信号量才能申请,所以信号量是一个公共资源,公共资源就涉及到线程安全问题。

根据上面分析,信号量的基本操作就是对信号量进行加一和减一,所以这两个操作是原子的。

P操作:就是信号量减减(sem--),也就是在申请资源,而且该操作必须是原子的。
V操作:就是信号量加加(sem++),也就是在归还资源,同样也必须是原子的。


1.2 信号量的基本使用接口

cpp 复制代码
#include <semaphore.h> // 信号量必须包含的头文件
 
sem_t sem; // 创建信号量

初始化信号量,man sem_init:

  • sem:信号量指针
  • pshared:0表示线程间共享,非0表示进程间共享。我们一般情况下写0就行。
  • value:信号量初始值,也就是计数器的值。
  • 返回值:成功返回0,失败返回-1,并且设置errno。

信号量销毁,man sem_destroy:

  • sem:信号量指针
  • 返回值:成功返回0,失败返回-1,并且设置errno。

申请信号量(P操作 -> 计数器减减),man sem_wait:

  • sem:信号量指针。
  • 返回值:成功返回0,失败返回-1,并且设置errno。
  • 功能:发布信号量,表示资源使用完毕,可以归还资源,将信号量值加1,也就是在归还信号量。

发布信号量(V操作 -> 计数器加加),man sem_post:

  • sem:信号量指针。
  • 返回值:成功返回0,失败返回-1,并且设置errno。
  • 功能:发布信号量,表示资源使用完毕,可以归还资源,将信号量值加1,也就是在归还信号量。

这些接口和前面mutex的接口非常类似,因为他们都是POSIX标准的,所以使用起来没有难度。(以前简单讲的是SystemV标准的信号量)


2.基于环形队列的生产者消费者模型

2.1环形队列分析

这里使用信号量来实现一个单生产单消费的环形队列模型。

  • 环形队列采用数组来模拟,用取模运算来模拟环状特性。

环形结构起始状态和结束状态都是一样的,不好判断为空或者为满。

  • 当环形队列为空时,头和尾都指向同一个位置。
  • 当环形队列为满时,头和尾也都指向同一个位置。
  • 可以通过加计数器或者标记位来判满或者空,也可以预留一个空的位置,作为满的状态。

但是我们现在有信号量这个计数器,就不需要用数据结构的方式来判空和判满了,能够很简单的进行多线程间的同步

单生产者和单消费者一共两个线程在访问环形队列这个公共资源,生产者向环形队列中生产数据,消费者从环形队列中消费数据。

生产者和消费者什么情况下会访问同一个位置? -> 环形队列为空和为满的时候

① 环形队列为空的时候,生产者和消费者会访问同一个位置。

当队列为空的时候,生产者访问队尾,向队列中生产数据,消费者访问对首消费数据,由于环形队列且为空,所以队首和队尾是同一个位置。


② 环形队列为满的时候,生产者和消费者会访问同一个位置。

当环形队列只有一个空位置的时候,生产者访问队尾生产数据,生产完毕后指向下一个位置,由于环形队列且为满,所以此时生产者又指向了队首,和消费者访问同一个位置。

其他任何时候,生产者和消费者访问的都是不同的区域。只要环形队列不满也不空,那么生产者和消费者之间都有数据,它们各自访问各自的区域。

为了完成环形队列的生产消费问题,必须要做的核心工作是什么?

① 消费者不能超过生产者。

消费者消费的是生产者生产的数据,生产者没有生产,消费者就无法消费。当消费者超过生产者后,消费者访问的区域并没有数据,所以没有任何意义。

消费者必须跟在生产者的后面,即使消费速度非常快(导致环形队列为空),此时消费者和生产者访问同一区域。


② 生产者不能把消费者套一圈以上

消费者消费的速度比较慢,环形队列满了以后,如果生产者继续生产,就会将消费者还没来得及消费的数据覆盖,消费者就无法消费到覆盖之前的数据了。

对于生产者而言,它在意的是环形队列中空闲的空间。

生产者只负责将数据生产到环形队列的空间中,当环形队列满了以后就不能生产了,所以它只关心环形队列中有多少空间可以用来生成数据。

对于消费者而言,它在意的是环形队列中 数据的个数。

消费者只负责从环形队列中消费数据,当环形队列为空时就停止消费,所以它只关心环形队列中有多少个数据可以用来消费。

  • 空间资源定义一个信号量。用来统计空闲空间的个数。
  • 数据资源定义一个信号量。用来统计数据个数。

所以生产者 每次在访问临界资源之前,需要先申请空间资源的信号量,申请成功就可以进行生产,否则就阻塞等待。

消费者 在访问临界资源之前,需要申请数据资源的信号量,申请成功就可以消费数据,否则就阻塞等待。

  • 空间资源信号量的申请(P操作)由生产者进行归还(V操作)由消费者进行,表示生产者可以生产数据。
  • 数据资源信号量的申请(P操作)由消费者进行归还(V操作)由生产者进行,表示消费者可以进行消费。

下面写写伪代码:

在信号量的初始化时,空间资源的信号量为环形队列的大小,因为没有生产任何数据。数据资源的信号量为0,因为没有任何数据可以消费。

通过信号量的方式同样维护了环形队列的核心操作,消费者消费速度快时,会将数据资源信号量全部申请完,但是此时生产者没有生产数据,也就没有归还数据资源的信号量所以消费者会阻塞等待,不会超过生产者。

生产者生产速度快时会将空间资源信号量全部申请完,但是此时消费者没有消费数据 ,也就没有归还空间资源的信号量,所以生产者会阻塞等待,不会超过套消费者一个圈。

生产者伪代码:

cpp 复制代码
productor_sem = 环形队列大小;
 
P(productor_sem);//申请空间资源信号量
//申请成功,继续向下运行。
//生气失败,阻塞在申请处。
 
.......//从事生产活动------把数据放入队列中
 
V(comsumer_sem);//归还数据资源信号量

消费者伪代码:

cpp 复制代码
comsumer_sem = 0;
 
P(comsumer_sem);//申请数据资源信号量
//申请成功,继续向下运行。
//生气失败,阻塞在申请处。
 
.......//从事消费活动------从队列中消费数据
 
V(proudctor_sem);//归还空间资源信号量

在环形队列中,大部分情况下单生产和单消费是可以并发执行的,只有在满或者空的时候,才会有同步和互斥问题,同步和互斥是通过信号量来实现的。

在生产者和消费者并发访问环形队列时,访问的位置其实就是队列的下标,而且是两个下标。

当空或者满的时候,两个下标相同。


2.2 代码分步实现

先把代码架构敲出来:(和上一篇架构是一样的,只是"交易场所"从阻塞队列变成了环形队列)

Makefile:

cpp 复制代码
ring_queue:testMain.cc
	g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
	rm -f ring_queue

ringQueue.hpp:

cpp 复制代码
#ifndef _Ring_QUEUE_HPP_
#define _Ring_QUEUE_HPP_
 
#include <iostream>
#include <vector>
 
const int g_default_num = 5; // 环形队列默认个数
 
template <class T>
class RingQueue
{
public:
    RingQueue(int default_num = g_default_num)
        : _ring_queue(default_num)
        , _num(default_num)
    {
    }
    ~RingQueue()
    {
    }
    void push(const T &in) // 生产者
    {
    }
    void pop(T *out) // 消费者
    {
    }
    void debug()
    {
        std::cerr << "size: " << _ring_queue.size() << " num: " << _num << std::endl;
    }
 
protected:
    std::vector<T> _ring_queue;
    int _num; // 环形队列的数据个数
};

testMain.cc

cpp 复制代码
#include "ringQueue.hpp"
 
void *consumer(void *args)
{
}
 
void *productor(void *args)
{
}
 
int main()
{
    RingQueue<int> *rq = new RingQueue<int>();
    rq->debug();
 
    // pthread_t c[3], p[2];
    // pthread_create(c, nullptr, consumer, (void *)rq);
    // pthread_create(c + 1, nullptr, consumer, (void *)rq);
    // pthread_create(c + 2, nullptr, consumer, (void *)rq);
 
    // pthread_create(p, nullptr, productor, (void *)rq);
    // pthread_create(p + 1, nullptr, productor, (void *)rq);
 
    // for (int i = 0; i < 3; i++)
    //     pthread_join(c[i], nullptr);
    // for (int i = 0; i < 2; i++)
    //     pthread_join(p[i], nullptr);
 
    return 0;
}

编译结果如下:

符合预期:


下面实现一下push和pop,我们可以给push和pop都定义一个下标,定义成成员变量,这样想看看环形队列的结构还可以在debug打印出来。还要封装下信号量,下面放的就是完整代码了:

sem.hpp
cpp 复制代码
#ifndef _SEM_HPP_
#define _SEM_HPP_
 
#include <iostream>
#include <semaphore.h>
 
class Sem
{
public:
    Sem(int value) // 传入的初始默认值
    {
        sem_init(&_sem, 0, value); // 0 -> 不需共享
    }
    void p() // P操作 -> 计数器减减 -> 申请信号量
    {
        sem_wait(&_sem);
    }
    void v() // V操作 -> 计数器加加 -> 发布信号量
    {
        sem_post(&_sem);
    }
    ~Sem() // 析构,直接销毁信号量
    {
        sem_destroy(&_sem); 
    }
protected:
    sem_t _sem; // 本质是计数器
};
 
#endif
ringQueue.hpp
cpp 复制代码
#ifndef _Ring_QUEUE_HPP_
#define _Ring_QUEUE_HPP_
 
#include <iostream>
#include <vector>
#include <pthread.h>
#include "sem.hpp"
 
const int g_default_num = 5; // 环形队列默认个数
 
template <class T>
class RingQueue
{
public:
    RingQueue(int default_num = g_default_num)
        : _ring_queue(default_num),
          _num(default_num),
          c_step(0),
          p_step(0),
          _space_sem(default_num),
          _data_sem(0)
    {
        pthread_mutex_init(&clock, nullptr);
        pthread_mutex_init(&plock, nullptr);
    }
    ~RingQueue()
    {
        pthread_mutex_destroy(&clock);
        pthread_mutex_destroy(&plock);
    }
    void push(const T &in) // 生产者:关注空间资源
    {
        _space_sem.p(); // 先申请信号量 -> P操作(这样不用访问临界资源就分配好资源了)
 
        pthread_mutex_lock(&plock); 
        // 临界区:一定是竞争成功的生产者线程 -> 就一个
        _ring_queue[p_step++] = in;
        p_step %= _num; // p_step永远是可以存放的位置
        pthread_mutex_unlock(&plock);
 
        _data_sem.v();
    }
    void pop(T *out) // 消费者:关注数据资源 -> 对比生产者
    {
        _data_sem.p();
 
        pthread_mutex_lock(&clock);
        *out = _ring_queue[c_step++];
        c_step %= _num;
        pthread_mutex_unlock(&clock);
 
        _space_sem.v();
    }
    void debug()
    {
        std::cerr << "size: " << _ring_queue.size() << " num: " << _num << std::endl;
    }
 
protected:
    std::vector<T> _ring_queue;
    int _num; // 环形队列的数据个数
    int c_step; // 消费下标
    int p_step; // 生产下标
    Sem _space_sem;
    Sem _data_sem;
    pthread_mutex_t clock;
    pthread_mutex_t plock;
};
 
#endif
testMain.cc
cpp 复制代码
#include "ringQueue.hpp"
#include <cstdlib>
#include <ctime>
#include <sys/types.h>
#include <unistd.h>
 
void *consumer(void *args)
{
    RingQueue<int> *rq = (RingQueue<int> *)args;
    while (true)
    {
        sleep(1);
        int x;
        rq->pop(&x); // 1. 从环形队列中获取任务或者数据
        // 2. 进行一定的处理 -- 不要忽略它的时间消耗问题
        std::cout << "消费: " << x << " [" << pthread_self() << "]" << std::endl;
    }
}
 
void *productor(void *args)
{
    RingQueue<int> *rq = (RingQueue<int> *)args;
    while (true)
    {
        // sleep(1);
        // 1. 构建数据或者任务对象 -- 一般是可以从外部来 -- 不要忽略它的时间消耗问题
        int x = rand() % 100 + 1;
        std::cout << "生产: " << x << " [" << pthread_self() << "]" << std::endl;
 
        // 2. 推送到环形队列中
        rq->push(x); // 完成生产的过程
    }
}
 
int main()
{
    srand((uint64_t)time(nullptr) ^ getpid());
 
    RingQueue<int> *rq = new RingQueue<int>();
    // rq->debug();
 
    pthread_t c[3], p[2];
    pthread_create(c, nullptr, consumer, (void *)rq);
    pthread_create(c + 1, nullptr, consumer, (void *)rq);
    pthread_create(c + 2, nullptr, consumer, (void *)rq);
 
    pthread_create(p, nullptr, productor, (void *)rq);
    pthread_create(p + 1, nullptr, productor, (void *)rq);
 
    for (int i = 0; i < 3; i++)
        pthread_join(c[i], nullptr);
    for (int i = 0; i < 2; i++)
        pthread_join(p[i], nullptr);
 
    return 0;
}

2.3 代码解析和在理解

环形队列的生产者消费者模型同样遵循123原则:

  • 1:一个交易场所,环形队列。
  • 2:两种角色,生产者和消费者。
  • 3:三种关系,生产者和生产者(互斥关系),消费者和消费者(互斥关系),生产者和消费者(在队列为空或者满时 -> 同步和互斥关系)。

上面的单生产单消费模型,维护的只是生产者和消费者之间的关系,要想实现多生产多消费,只需要将另外两种关系维护好即可。

  • 在RingQueue中增加两把互斥锁,一把生产者使用,一把消费者使用。
  • 在构造函数中将锁初始化,在析构函数中将锁摧毁。

push是向环形队列中生产任务,是生产者在调用,所以在生产之前需要加锁。pop是从环形队列中消费认为,是消费者在调用,所以在消费之前加锁。

互斥锁和申请信号量谁在前比较合适呢?

如果互斥锁在前,申请信号量在后:

  • 所有生产者线程或者是消费者线程都需要先竞争锁,然后再去申请信号量,信号量申请成功才能进入临界区。
  • 如果信号量申请失败就抱着锁阻塞,其他同类型线程就无法申请到锁。

这就好比去电影院买票,必须先排队进入放映厅才能买票。


如果申请信号量在前,互斥锁在后:

  • 所有生产者线程或者消费者线程先申请信号量,再去申请锁,然后进入临界区。
  • 如果信号量申请失败就不会再去申请锁。

同样是电影院,这就好比先买票,然后再排队进入放映厅,没买上票就没必要排队了。

对于线程来说,申请锁也是有代价的,将信号量申请放在前面可以减少申请锁的次数,所以申请信号量在互斥锁之前更合适

创建多个生产者线程和多个消费者线程,去执行生产计算任务和消费计算任务。

  • 生产任务的线程是不同的,可以根据tid值区别出来。
  • 消费认为的现场也是不同的,同样可以根据tid值区别。

此时就实现了基于环形队列的多生产多消费模型。

多生产多消费的意义在哪里?

不要狭隘的认为,把任务或者数据放在交易场所,就是生产和消费了。

将数据或者任务生产前和拿到之后处理,才是最耗费时间的。


生产的本质:私有的任务-> 公共空间中

消费的本质:公共空间中的任务-> 私有的


信号量本质是一把计数器-> 计数器的意义是什么?:

可以不用进入临界区,就可以得知资源情况,甚至可以减少临界区内部的判断。


申请锁 -> 判断与访问 -> 释放锁 -> 本质是我们并不清楚临界资源的情况

信号量可以提前预设资源的情况,而且在PV变化过程中,我们可以在外部就能知晓临界资源的情况


3.自旋锁和读写锁

之前使用的互斥锁就是挂起等待锁。多线程在竞争互斥锁时,申请到锁的线程进入临界区,而没有申请到锁的线程阻塞等待。

所谓的阻塞等待,其实是将该现场放入到操作系统维护的等待队列中,在合适的时候,操作系统再将其唤醒,放到运行队列中继续去申请锁。

其他常见的各种锁

悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起
乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前, 会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
自旋锁,公平锁,非公平锁。
读写锁。(自旋锁和读写锁在下面简单介绍。)

原子性原语double CAS (DCAS) 运行子两个随机排序内存单元上。若当前值与预期值一致,可改变这两个内存单元的值。


所谓原语的原子性操作是指一个操作中的所有动作,要么成功完成,要么全不做。也就是说,原语操作是一个不可分割的整体。为了保证原语操作的正确性,必须保证原语具有原子性。在单机环境下,操作的原子性一般是通过关闭中断来实现的。由于中断是计算机与外设通信的重要手段,关闭中断会对系统产生很大的影响,所以在实现时一定要避免原语操作花费时间过长,绝对不允许原语中出现死循环。


这里简单的介绍下自旋锁和读写锁。

3.1 自旋锁的概念和接口

自旋锁也是互斥锁它的作用也是保护共享资源的安全 。多线程在竞争自旋锁时,申请到锁的线程进入临界区,而没有申请到锁的线程不会挂起等待。

没有申请到锁的线程会不停的继续去申请锁,直到申请锁成功进入临界资源,自旋和进程等待中的轮询非常的相似。

自旋锁和挂起等待锁的区别 就在于:没有申请到锁时,自旋锁仍然继续申请,挂起等待锁则进入等待队列等待,在被唤醒后继续申请锁。


是什么决定着线程的等待方式呢?是需要等待的时长。

当访问临界资源的时间较短 的时候,可以使用自旋锁,因为进入临界区的线程会非常快地出来,处于自旋状态的线程也可以很快进入临界区。

此时申请自旋锁的线程,免去了被挂起等待和唤醒的过程,一定程度上提高了效率。
当访问临界资源的时间较长 的时候,就要使用挂起等待锁,因为进入临界区的线程不会很快出来。

此时将申请锁失败的线程挂起,就将CPU资源空闲了出来,如果不挂起而处于自旋状态,则CPU就一直被占用。

那么需要等待的时间长短是如何定义的呢?

像前面写的多线程抢票的代码, 对于票tickets的访问就可以使用自旋锁。

对于需要进行复杂运算,高IO,以及等待某些软件标志就位的情况 就是用挂起等待锁。


等待时间的长短并没有明确的定义,使用自旋锁还是挂起等待锁根据具体情况来觉得。最好的方式是分别测试两种锁,哪种效率高就用哪种。


看看自旋锁的基本使用接口:(和挂起等待锁的使用基本一样)

cpp 复制代码
#include <pthread.h>//使用自旋锁要包含的头文件
 
pthread_spinlock_t lock;//创建自旋锁

man pthread_spin_init:

初始化自旋锁:

cpp 复制代码
int pthread_spin_init(pthread_spinlock_t* lock, int shared);
  • lock:自旋锁指针
  • shared:0表示线程间共享,非0表示进程间共享,和信号量初始化中的shared一样。
  • 返回值:成功返回0,失败返回-1。

销毁自旋锁:

cpp 复制代码
int pthread_spin_destroy(pthread_spinlock_t* lock);

加锁和解锁:

  • lock:都是自旋锁指针
  • 返回值:都是成功返回0,失败返回-1。

这些接口和之前学习的挂起等待锁以及信号量的使用非常相似,只是换个函数名而已,因为它们遵循POSIX标准。


3.2 读写锁的概念和接口

读写锁主要使用在读者写者模型中,读者写者模型和生产者消费者模型很类似,也遵循123原则:

  • 1:一个交易场所,任意类型的数据结构。
  • 2:两种角色,读者和写者。
  • 3:三种关系,写者和写者(互斥),读者和写者(同步和互斥),读者和读者(没有关系)。

读者线程和写者线程并发访问一块临界资源:

  • 写者向临界资源中写数据。
  • 读者从临界资源中读数据。

读者和写者之间是互斥关系

写者在写数据时,读者无法访问临界资源,因为如果在读取的时候,写者还没有写完,那么读者读到的数据就不全。
读者和写者之间也是同步关系

如果写者写好数据,读者不去读,那么写者写的数据就没有意义,所以写者写好数据后必须有读者来读。

反之,如果所有读者都已经读取过临界区的数据了,再读就是重复的旧数据,此时读取也没有意义,所以读者读完数据以后,写者必须来写入新的数据。
写者和写者之间是互斥关系

如果一个写者正在写数据,另一个写者也来写,假设他们写的是同一块公共资源,就有可能发生覆盖。
读者和读者之间没有关系

读者只从临界区中读取数据,并不拿走,所以读者之间并不会产生影响。

读者写者模型使用场景:一次发布,很长时间不做修改,大部分时间都是在被读取,比如这里写的博客。


读者写者模型和生产者消费者模型的本质区别是:消费者会拿走临界资源中的数据,而读者不会。

有些共享资源的数据修改的机会比较少,相比较改写,它们读的机会反而高的多。

在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。

读写锁就是专门用于读者写者模型中的一种锁,可以给读者加锁,也可以给写者加锁,可以维护读者写者的123原则。


|--------|------|------|
| 临界区的状态 | 读者请求 | 写者请求 |
| 无锁 | 可以 | 可以 |
| 读锁 | 可以 | 阻塞 |
| 写锁 | 阻塞 | 可以 |

持有写锁的线程独占临界资源,持有读锁的线程,读者之间共享临界资源。


读写锁基本接口:(还是和以前用的差不多)

cpp 复制代码
#include <pthread.h>//读写锁必须包含的头文件
 
pthread_rwlock_t rwlock;//创建读写锁

初始化读写锁:man pthread_rwlock_init

cpp 复制代码
int pthread_rwlock_init(pthread_rwlock_t* rwlock, const pthread_rwlockattrt_t* attr);
  • rwlock:读写锁指针
  • attr:读写锁属性结构体指针,一般设置成nullptr即可。
  • 返回值:成功返回0,失败返回-1

销毁读写锁:

cpp 复制代码
int pthread_rwlock_destroy(pthread_rwlock_t* rwlock);

加读锁:

cpp 复制代码
int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);

加写锁:

cpp 复制代码
int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);

解锁:

cpp 复制代码
int pthread_rwlock_unlock(pthread_rwlock_t* rwlock);
  • 读锁和写锁都通过这个接口去解锁。

同样是POSIX标准,所以返回值,参数等风格和前面的挂起等待锁,信号量,以及自旋锁一样,这里就不详细解释了。


3.3 读写锁的原理和优先级

读写锁:在任何时刻,只允许一个写者写入,但是允许多个读者并发读取(写者阻塞)。

是不是感觉非常奇怪?上面的接口中明明只有一把锁,但是可以给读者和写者分别加锁,而且对于读者和写者的效果还不同?

下面看一段伪代码来解释一下:

读写锁的类型是一个结构体,它里面封装的也是互斥锁,而且针对读者有一把,针对写者有一把,只是机制不一样而已。

读加锁伪代码: pthread_rwlock_rdlock(pthread_rwlock_t* rwlock)

cpp 复制代码
pthread_mutex_t rdlock;//创建读锁
int reader_count = 0;//读者计数
------------------------------------------------------------
lock(&rdlock);//读加锁
reader_count++;//读者数量加一
if(reader_count == 1)
{
	//只要有读者在访问临界资源,就将写锁也申请走
	lock(&rwlock);//写加锁
}
unlock(&rdlock);//解读锁
------------------------------------------------------------
//读取数据....
------------------------------------------------------------
lock(&rdlock);//再次读加锁
read_count--;//读者数量减一
if(reader_count == 0)
{
	//读者全部读完以后,释放写锁
	unlock(&rwlock);//写解锁
}
unlock(&rdlock);//读解锁

加读锁时,有一个计数器,该计数器所有读者线程共享,是一份共享资源,用来统计访问公共资源的读者数量。

伪代码解释:

  • 每个读者访问公共资源的时候,都需要将计数值加1,考虑到线程安全,所以计数值要加锁。
  • 当第一个读者到来后,它先申请了读锁,然后又申请了写锁,此时写者线程就无法访问临界资源了,因为写锁在读者手里。之后的读者线程仅将计数值加一即可。
  • 当读者线程访问完计数值以后就将读锁解锁,然后去公共资源中读数据(仅读取,不拿走)。
  • 读者读完数据以后,继续线程安全的访问计数值,将值减一,当值被减到0时,说明没有读者再来读数据了,此时将申请的写锁解锁,好方便写者访问公共资源。

过这样的方式就实现了读者和写者之前的互斥,读者和读者之间没有关系。

互斥访问读者计数值非常的快,读者真正访问公共资源的时候是没有任何关系的(不存在加锁)。


写加锁伪代码: pthread_rwlock_wrlock(pthread_rwlock_t* lock)

cpp 复制代码
pthread_mutex_t wrlock;//创建写锁
------------------------------------------------------------
lock(&wrlock);//写加锁
//向临界资源中写入数据
unlock(&wrlock);//写解锁

写者的加锁解锁,实现了写者之间的互斥关系。

伪代码解释:

  • 写者线程在访问临界资源的时候会先申请锁,申请成功的进入临界区,失败的阻塞等待。
  • 如果写者申请写锁成功,那么第一个读者在申请写锁的时候同样会阻塞,直到写者释放锁。
  • 如果第一个读者申请写锁成功,那么写者在申请写锁的时候也会阻塞,直到读者释放锁。

写锁的原理非常简单,正是由于读者会申请写锁,写者也会申请写锁,所以才能实现写者和读者的互斥。


上面讲解的读写锁是读者优先的,前提是有读者已经在访问公共资源。

已经有读者在访问公共资源的时候,写锁已经被读者申请走了。

当后面写者和读者同时到来的时候,写者会因为无法申请锁而阻塞,而读者可以访问公共资源。

如果没有读者在访问公共资源,第一个读者和写者同时到来时,它两就不存在优先关系,谁的竞争能力强谁申请到写锁,进入临界资源。

试想,读者非常多,那么写者就始终无法进入临界区访问临界资源,所以就会导致写者饥饿问题,但是读写锁就是应用在这种场景下,写者数量少执行少,读者数量多执行多。

读写锁是可以设置成写者优先的。

即使已经有读者在访问公共资源,并且写锁已经被申请走了。

当后面的写者和读者同时到来的时候,将写者后面的所有读者阻塞,不让它们访问公共资源。

当进入临界区的读者出来以后,并且归还了写锁,此时写者直接申请写锁并进入临界区访问临界资源。

大概的道理是这样,具体如何阻塞写者之后的读者策略可以在代码层面进行设计。

cpp 复制代码
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t* attr, int pref);
  • attr:属性设置
  • pref:有三种选择
  • PTHREAD_RWLOCK_PREFER_READER_NP:(默认设置)读者优先,可能会导致写者饥饿情况。
  • PTHREAD_RWLOCK_PREFER_WRITER_NP:写者优先
  • PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP:写者优先,但写者不能递归加锁。

本章完

相关推荐
行初心1 小时前
uos基础 systemctl 查看unit的详细配置
运维
eyuiomvtywn1 小时前
阿里云DNS解析Vercel部署项目的域名
运维·服务器·阿里云
4t4run1 小时前
25、Linux 特殊权限
linux·运维
S***y3961 小时前
DevOps监控告警体系
运维·devops
氵文大师2 小时前
A机通过 python -m http.server 下载B机的文件
linux·开发语言·python·http
HUT_Tyne2652 小时前
Linux 快速入门
linux·运维·服务器
leoufung2 小时前
逆波兰表达式 LeetCode 题解及相关思路笔记
linux·笔记·leetcode
鸠摩智首席音效师2 小时前
如何在 Linux 中使用 dd 命令 ?
linux·运维·服务器
一夜空中最亮的星一3 小时前
【Linux】ubuntu24.04 安装docker
linux·docker·eureka