【Linux】线程安全的单例模式 && STL和智能指针的线程安全问题 && 其他常见的各种锁 && 读者写者模型(线程的周边话题)

👦个人主页:Weraphael

✍🏻作者简介:目前正在学习c++和算法

✈️专栏:Linux

🐋 希望大家多多支持,咱一起进步!😁

如果文章有啥瑕疵,希望大佬指点一二

如果文章对你有帮助的话

欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍


目录

  • 一、STL线程安全问题
  • 二、智能指针线程安全问题
  • 三、线程安全的单例模式
      • [3.1 什么是单例模式](#3.1 什么是单例模式)
      • [3.2 单例模式的简单实现](#3.2 单例模式的简单实现)
        • [3.2.1 如何创建单例对象](#3.2.1 如何创建单例对象)
        • [3.2.2 饿汉方式实现单例模式](#3.2.2 饿汉方式实现单例模式)
        • [3.2.3 懒汉方式实现单例模式](#3.2.3 懒汉方式实现单例模式)
        • [3.2.4 解决懒汉方式的线程安全问题](#3.2.4 解决懒汉方式的线程安全问题)
        • [3.2.5 C++11的懒汉单例模式](#3.2.5 C++11的懒汉单例模式)
        • [3.2.6 将线程池修改成懒汉单例模式](#3.2.6 将线程池修改成懒汉单例模式)
  • 四、其他常见的各种锁
  • [五、 读者写者模型](#五、 读者写者模型)
      • [5.1 321原则](#5.1 321原则)
      • [5.2 读写锁](#5.2 读写锁)
      • [5.3 读者写者模型的策略](#5.3 读者写者模型的策略)
      • [5.4 样例代码 仅供参考](#5.4 样例代码 仅供参考)
  • 六、相关代码

一、STL线程安全问题

STL库中的容器是否是线程安全的?

答案:不是

因为STL设计的初衷就是将性能挖掘到极致,而加锁和解锁操作势必会影响效率,因为它们引入了额外的开销,比如上下文切换和竞争条件。这些操作会导致线程等待锁释放,从而增加了等待时间和系统的整体开销。

因此STL中的容器并未考虑线程安全,在之前编写的生产者消费者模型和线程池中,使用了部分STL 容器,如vectorqueuestring等,这些都是需要我们自己去加锁和解锁,以确保多线程并发访问时的线程安全问题。

二、智能指针线程安全问题

C++标准提供的智能指针有三种:unique_ptrshared_ptrweak_ptr

  • unique_ptr: 它是一个独占的智能指针,只在当前代码块范围内生效,即unique_ptr的作用范围只限于当前作用域。因此在多线程环境下是线程安全的。
  • shared_ptr:多个对象需要共用一个引用计数变量,所以会存在线程安全问题,但是标准库实现的时候考虑到了这个问题,索性将shared_ptr对于引用计数的操作设计成了原子性(原子操作CAS),这意味着在多个线程中shared_ptr的引用计数操作是线程安全。
  • weak_ptr:这个就是shared_ptr的补充,名为弱引用智能指针,具体实现与shared_ptr紧密相关,因此在某些方面继承了shared_ptr的特性,即weak_ptr的引用计数操作也是线程安全。

三、线程安全的单例模式

3.1 什么是单例模式

单例模式是一种设计模式

什么是设计模式?

IT行业这么火,卷王特别多!俗话说林子大了啥鸟都有,大佬和菜鸡们两极分化的越来越严重.,为了让小菜鸡们快速成长,于是大佬们针对一些经典的常见的场景(模板),给定了一些对应的解决方案,这个就是设计模式。

回过头来,单例模式的主要目的是确保某个类只有一个实例即类只能有一个对象。这种模式常用于需要全局控制的场景,如配置管理、数据库连接等。

单例模式的核心特性某些类只应该具有一个对象(实例)

在很多服务器开发场景中,经常需要让服务器加载很多的数据 (上百GB) 到内存中进行高效处理和管理,此时此时我们就只用一个单例的类来管理这些数据就行了,因为数据只会加载一次,并且所有请求共享同一个数据实例,避免了重复加载和内存浪费。

3.2 单例模式的简单实现

3.2.1 如何创建单例对象

单例模式的核心特性某些类只应该具有一个对象(实例) 。因此,它们避免类被再次创建出对象的手段是一样的:私有化构造函数删除拷贝构造函数及赋值操作符

  • 构造函数私有化 :防止外部直接创建类的实例。将构造函数声明为私有private来实现
cpp 复制代码
class Singleton 
{
private:
    Singleton() {}  // 构造函数私有化

public:
    // 其他公共成员函数
};
  • 删除拷贝构造函数和赋值操作符 :避免通过复制已有实例来创建新的实例,从而进一步保证唯一性。
    • C++98/03你需要显式地声明拷贝构造函数和赋值操作符为private,并且没有定义它们
    • C++11及以上:使用delete关键字来显式地删除拷贝构造函数和赋值操作符。这是一种更简洁和现代的做法
    • 以上两种方式任选一种。我后面的代码全部用第二种。
cpp 复制代码
class Singleton 
{
private:
	// 构造函数私有化
    Singleton() {} 
    
    // ======  C++98/03 ========
    // 拷贝构造函数声明为 private 
    Singleton(const Singleton&);  
    
    // 赋值操作符声明为 private
    Singleton& operator=(const Singleton&);  
	
	// ===== C++11及以上 =======
	// 删除拷贝构造函数
	Singleton(const Singleton&) = delete;  

	// 删除赋值操作符
    Singleton& operator=(const Singleton&) = delete;

public:
};

只要外部无法访问构造函数,那么也就无法构建对象了

那么问题来了,外部无法访问构造函数了,我们应该如何创建一个单例对象呢?

既然外部受权限约束无法创建对象,但类内可以创建对象 。所以,只需要创建一个指向该类对象的静态指针或者一个静态对象的私有成员,又因为该成员变量不能在类的外部直接访问,那么可以通过静态成员函数间接访问和操作单例对象句柄(句柄就是只能使用接口来获取某些资源或对象的抽象标识符),将静态成员变量带出去给外部使用。

注意:静态成员函数不能调用非静态成员函数,也不能直接访问非静态成员变量,因为静态成员函数没有this指针。它们只能访问类的静态成员变量和静态成员函数

以下是 【代码模板】

cpp 复制代码
class Singleton 
{
private:
	// 构造函数私有化
    Singleton() {} 

	// 删除拷贝构造函数
	Singleton(const Singleton&) = delete;  
	// 删除赋值操作符
    Singleton& operator=(const Singleton&) = delete;	
	
public:
	// 获取单例对象的句柄
    static Singleton* getInstance()
   	{
   		if (_ptr == nullptr)
   		{
   			_ptr = new Singleton();
   		}
        return _ptr;
    }

private:

    // 指向单例对象的静态指针
    static Singleton* _ptr;
};
// 静态成员变量需要在类外初始化
Singleton *Singleton::_ptr = nullptr;

外部可以直接通过getInstance()获取单例对象的操作句柄,来调用类中的其他函数。

cpp 复制代码
int main()
{
    // Singleton::getInstance() -> 获取单例对象的操作句柄
    Singleton::getInstance()->run();

    return 0;
}

【程序结果】

除了创建静态单例对象指针外,也可以直接定义一个静态单例对象 。需要注意的是:getInstance()需要返回的也是该静态单例对象的地址,不能返回值。因为如果返回的是类对象值,会调用拷贝构造,但拷贝构造被删除了

cpp 复制代码
class Singleton
{
private:
    // 构造函数私有化
    Singleton() {}

    // 删除拷贝构造函数
    Singleton(const Singleton &) = delete;

    // 删除赋值操作符
    Singleton &operator=(const Singleton &) = delete;

public:
    // 获取单例对象的句柄
    static Singleton *getInstance()
    {
        return &_ptr;
    }

    void run()
    {
        cout << "void run()" << endl;
    }

private:
    // 静态单例对象
    static Singleton _ptr;
};
// 静态成员变量需要在类外初始化
Singleton Singleton::_ptr;

int main()
{
    Singleton *s1 = Singleton::getInstance();
    s1->run();

    return 0;
}

【程序结果】

而单例模式的实现方式有多种,但最常见的有两种:饿汉懒汉

3.2.2 饿汉方式实现单例模式

饿汉可以这样理解:吃完饭,立刻洗碗,这种就是饿汉方式。因为下一顿吃的时候可以立刻拿着碗就能吃饭。

回到计算机世界

饿汉模式:程序加载到内存或类加载时就立即自动创建单例对象,即不是依赖于主函数或其他代码显式地创建它

饿汉模式的单例对象本质就有点像全局变量,在程序加载时,对象就已经创建好了即饿汉模式的单例对象生命周期随进程。

我们可以用以下代码证明:

cpp 复制代码
#include <iostream>
#include <unistd.h>
using namespace std;

class Singleton
{
public:
    Singleton()
        : _a(1), _b(1)
    {
        cout << "a + b = " << _a + _b << endl;
    }

private:
    int _a;
    int _b;
};

Singleton s1;
Singleton s2;
Singleton s3;

int main()
{
    sleep(3);
    return 0;
}

【程序结果】

注意:在饿汉式单例模式中,单例对象是通过静态成员变量进行管理的 。这样做的特点是:单例对象在程序启动时就被创建,而不是在第一次访问时。这种方式保证了单例对象在程序的整个生命周期内都存在

另外,正因为饿汉单例对象在程序启动时被创建并初始化,这个阶段是在多线程执行之前完成的,因此不会出现多线程并发创建实例的问题即饿汉式单例模式被认为是线程安全的

饿汉模式是一个相对简单的单例实现方式,但它也会带来一定的弊端:延缓服务启动速度。由于单例对象在程序启动时创建,它会占用程序启动时的资源。对于较大的资源对象,这种方法可能导致程序启动时间增加。并且如果后续没有使用单例对象,那么在延缓服务启动的同时造成了一定的资源浪费。

综上,饿汉模式适合于服务规模较小或单例对象创建成本较低的场景

3.2.3 懒汉方式实现单例模式

懒汉可以这样理解:吃完饭,先把碗放下,然后下一顿饭用到这个碗了再洗碗,就是懒汉方式。

回到计算机世界

懒汉模式:只有在第一次使用时才创建实例,即按需创建,不会浪费资源。其最核心的思想是 "延时加载"。这样,程序启动时不需要立即创建单例对象,从而减少了启动时的资源消耗和初始化开销,从而能够优化程序的启动速度

延时加载这种机制就有点像写时拷贝,就堵你不会使用,从而节省资源开销。类似的还有进程地址空间,比方说平时我们申请空间new/malloc的时候,此时这个空间并不是立马在物理内存中给你开辟空间,它只是允许在进程地址空间上访问,当用户真正去访问时,操作系统才会做缺页中断,然后再内存开辟空间。本质就是当我们需要的时候才会给。

饿汉模式中出现的问题这里全都避免了!!!

注意:在懒汉式单例模式中,单例对象是通过静态指针进行管理的。这意味着单例对象的实例在第一次访问时才被创建,即一开始指针为空,调用getInstance()判断为空才会被创建,从而实现了延迟初始化

这样看来,懒汉模式确实优秀,但如果当前是多线程场景 ,问题就大了。因为懒汉模式是按需创建,那么就注定了可能存在大量的线程同时调用getInstance(),多个线程同时检查到单例对象尚未创建,于是它们同时尝试创建单例对象。这可能导致多个单例实例的创建,违反了单例模式的原则。

也就是说当前实现的懒汉模式存在严重的线程安全问题!!!

比如说以下代码:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;

class Singleton
{
private:
    // 私有构造函数
    Singleton() {}

    // 删除拷贝构造函数
    Singleton(const Singleton &) = delete;

    // 删除赋值操作符
    Singleton &operator=(const Singleton &) = delete;

public:
    // 获取单例对象的句柄
    static Singleton *getInstance()
    {
        // 第一次调用才创建
        if (_ptr == nullptr)
        {
            std::cout << "创建了一个单例对象" << std::endl;
            _ptr = new Singleton();
        }

        return _ptr;
    }

    void run()
    {
        cout << "void run()" << endl;
    }

private:
    static Singleton *_ptr;
};

// 静态成员变量需要在类外初始化
Singleton *Singleton::_ptr = nullptr;

void *threadFunc(void *)
{
    // 获取句柄
    Singleton::getInstance()->run();
    return nullptr;
}

int main()
{
    pthread_t p[10];

    for (int i = 0; i < 10; i++)
    {
        pthread_create(p + i, nullptr, threadFunc, nullptr);
    }
    // 线程等待
    for (int i = 0; i < 10; i++)
    {
        pthread_join(p[i], nullptr);
    }
    return 0;
}

【程序结果】

3.2.4 解决懒汉方式的线程安全问题

学到目前为止,解决多线程并发访问的利器是给临界资源加锁(互斥锁)。即要给单例对象加锁,确保在多线程环境中对单例对象的访问是安全的。

注意:要为类增加一个静态互斥锁。因为getInstance()是静态的,它不能访问非静态成员变量(包括非静态互斥锁)。静态方法只能操作静态成员,因此,保护静态成员的访问需要使用静态互斥锁

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;

class Singleton
{
private:
    // 私有构造函数
    Singleton() {}

    // 删除拷贝构造函数
    Singleton(const Singleton &) = delete;

    // 删除赋值操作符
    Singleton &operator=(const Singleton &) = delete;

public:
    // 获取单例对象的句柄
    static Singleton *getInstance()
    {
        pthread_mutex_lock(&_mtx);
        if (_ptr == nullptr)
        {
            std::cout << "创建了一个单例对象" << std::endl;
            _ptr = new Singleton();
        }
        pthread_mutex_unlock(&_mtx);
        return _ptr;
    }

    void run()
    {
        cout << "void run()" << endl;
    }

private:
    static Singleton *_ptr;
    static pthread_mutex_t _mtx;
};

// 静态成员变量需要在类外初始化
Singleton *Singleton::_ptr = nullptr;
pthread_mutex_t Singleton::_mtx = PTHREAD_MUTEX_INITIALIZER;

void *threadFunc(void *)
{
    // 获取句柄
    Singleton::getInstance()->run();
    return nullptr;
}

int main()
{
    pthread_t p[10];

    for (int i = 0; i < 10; i++)
    {
        pthread_create(p + i, nullptr, threadFunc, nullptr);
    }
    // 线程等待
    for (int i = 0; i < 10; i++)
    {
        pthread_join(p[i], nullptr);
    }
    return 0;
}

【程序结果】

但现在还面临最后一个问题:效率问题

当前代码确实能保证只会创建一个单例对象,即使后续线程使用这个函数就不会再创建单例对象,但这整个线程都需要经过加锁、判断、解锁 这个流程。而加锁和解锁操作势必会影响效率,所以以上代码还是不太完美~

解决方案是:双检查加锁

【关键代码】

cpp 复制代码
static Singleton *getInstance()
{
    if (_ptr == nullptr)
    {
        pthread_mutex_lock(&_mtx);
        if (_ptr == nullptr)
        {
            std::cout << "创建了一个单例对象" << std::endl;
            _ptr = new Singleton();
        }
        pthread_mutex_unlock(&_mtx);
    }
    return _ptr;
}

如何理解?

如果现在只有内层循环,当第一个线程调用getInstance()方法时,它会检查是否为nullptr。如果_ptr已经被初始化(即不为nullptr),说明单例对象已经创建,方法可以直接返回现有的实例。

而单例只允许创建一个实例,后序的线程进来判断一定不为空,但是在判断之前还都要竞争锁资源,那么,效率一定会降低。所以再给外层做一个判断,让后序线程没必要再申请锁来创建实例了。

3.2.5 C++11的懒汉单例模式

值得一提的是,懒汉模式还有一种非常简单的写法:调用getInstance()时创建一个静态单例对象并返回,因为静态单例对象只会初始化一次,所以是可行的,并且在C++11之后,可以保证静态变量初始化时的线程安全问题,也就不需要 双检查加锁 了,实现起来非常简单!

cpp 复制代码
static Singleton *getInstance()
{
    if (_ptr == nullptr)
    {
        pthread_mutex_lock(&_mtx);
        if (_ptr == nullptr)
        {
            std::cout << "创建了一个单例对象" << std::endl;
            _ptr = new Singleton();
        }
        pthread_mutex_unlock(&_mtx);
    }

    return _ptr;
}

因此,在 C++11 及之后的标准中,使用静态局部变量实现的懒汉单例模式不仅是线程安全的,而且实现简单、高效。

如果为了兼容性,也可以选择传统写法~

再次提醒: 静态变量创建时的线程安全问题,在C++11之前是不被保障的。

值得一提的是:new出来的单例对象不需要销毁吗?

进程结束时,操作系统会自动回收该进程的所有资源,包括内存和单例对象。因此,不需要额外的机制来销毁单例对象。单例对象的生命周期会随着进程的生命周期结束自动消失。

3.2.6 将线程池修改成懒汉单例模式

目的:将线程池改为 单例模式,只允许存在一个线程池对象。

往期线程池相关代码:点击跳转

这里选择 懒汉模式,因为比较优秀,并且为了确保兼容性,选择经典写法

【关键代码】

cpp 复制代码
#define defaultNum 5 // 线程池默认的线程个数

struct threadInfo
{
    pthread_t tid;
    std::string threadname;
};

template <class T>
class ThreadPool
{
private:
    // 默认构造函数
    ThreadPool(int num = defaultNum) // 默认在线程池创建5个线程
        : _threads(num)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_cond, nullptr);
    }

    // 析构函数
    ~ThreadPool()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_cond);
    }

public:
    // 线程池中线程的执行例程
    static void *HandlerTask(void *args)
    {
       // 略
    }

    // 启动线程(常见线程)
    void start()
    {
        // 略
    }

    // 向任务列表中塞任务 -- 主线程调用
    void push(const T &t)
    {
        // 略
    }

    // 去掉任务队列中的任务
    T pop()
    {
        // 略
    }
	
	// 获取线程名字
    std::string GetThreadName(pthread_t tid)
    {
        // 略
    }

    static ThreadPool<T> *getInstance(int num = defaultNum)
    {
        if (_tp == nullptr)
        {
            pthread_mutex_lock(&_mtx);
            if (_tp == nullptr)
            {
                _tp = new ThreadPool<T>(num);
            }
            pthread_mutex_unlock(&_mtx);
        }
        return _tp;
    }

private:
    std::vector<threadInfo>
        _threads;         // 将线程维护在数组中
    std::queue<T> _tasks; // 任务队列

    pthread_mutex_t _mutex;
    pthread_cond_t _cond;

    static ThreadPool<T> *_tp;
    static pthread_mutex_t _mtx;
};

template <class T>
ThreadPool<T> *ThreadPool<T>::_tp = nullptr;

template <class T>
pthread_mutex_t ThreadPool<T>::_mtx = PTHREAD_MUTEX_INITIALIZER;

修改部分:

  • 为了让该类成为单例模式,要将构造函数私有化,以及删除拷贝构造函数和赋值操作符。

  • 为成员变量增加一个指向该类对象的静态指针。

  • 为该类提供getInstance静态方法,将静态成员变量带出去给外部使用。

  • 加锁和解锁。

main.cc

cpp 复制代码
#include "ThreadPool.hpp"
#include "Task.hpp"
#include <ctime>

using namespace std;

int main()
{
    srand(time(nullptr));

    ThreadPool<Task> *tp = ThreadPool<Task>::getInstance(5); // 表示线程此有5个线程
    tp->start();

    while (true)
    {
        // 1. 构建任务
        int x = rand() % 10 + 1;
        usleep(10); // 防止x和y的值类似
        int y = rand() % 5;

        int len = oper.size();
        char op = oper[rand() % len];

        Task t(x, y, op);
        // 2.  交给线程池处理
        tp->push(t);
        cout << "main thread make a task: " << t.GetTask() << endl;

        sleep(1);
    }
    return 0;
}

【程序结果】

四、其他常见的各种锁

  • 悲观锁:在每次访问数据时,总是担心数据会被其他线程修改,于是在访问数据前,会先加锁,其他线程想访问时只能等待,之前使用的锁(互斥锁、信号量)都属于悲观锁。
  • 乐观锁:在每次访问数据时,总是乐观的认为数据不会被其他线程修改,因此不加锁。但是在更新数据前,会判断其他数据在更新前有没有被修改过(数据库会谈)。主要采用两种方式:版本号机制和CAS操作实现。
  • CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
  • 自旋锁:申请锁失败时,线程不会被挂起,而且不断尝试申请锁。我们以前学的锁都是挂起等待锁(互斥锁)。选择什么锁完全取决于线程在临界区执行的时间。不过大部分情况我们使用的都是互斥锁。

自旋本质上就是一个不断轮询的过程,即不断尝试申请锁,这种操作是十分消耗CPU资源的,因此推荐临界区中的操作时间较短时,使用 自旋锁 以提高效率;操作时间较长时,自旋锁会严重占用CPU资源。

Linux中自旋锁相关接口】

cpp 复制代码
#include <pthread.h>

// 自旋锁类型
pthread_spinlock_t; 

// 初始化自旋锁
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);

就这接口风格,跟互斥锁是亲兄弟,可以轻易上手!

  • 公平锁:它保证了锁的获取顺序与请求的顺序相同,即先请求锁的线程会先获得锁。公平锁通常采用队列机制来实现这种顺序性,以避免线程饥饿现象。(公平锁一般通过维护一个等待队列来实现,线程在获取锁时被放入队列,锁释放时会按照队列的顺序唤醒线程)
  • 非公平锁:通常使用信号量或自旋锁等机制。这些锁机制没有严格的按照请求的顺序来分配锁,而是以更高的性能为目标,允许一些线程或进程在较短时间内多次获取锁资源,从而减少了竞争开销。(非公平锁的实现通常比较简单,线程在尝试获取锁时如果发现锁已经被释放,就可以直接获取锁,而不必等待其他线程释放锁)

五、 读者写者模型

5.1 321原则

除了生产者消费者模型 外,还有一个读者写者模型。比方说:博客就是一个很典型的读者写者模型。看博客的人就是读者,而写博客的人就是写者。

读者写者模型也会存在一些并发问题:

  • 读者和读者 :多个读者可以同时读取共享数据而不会互相干扰。通常来说,如果只有读者在访问数据,读者之间不会产生冲突。因此,读者和读者之间的并发问题相对较少,即读者之间的访问可以并行进行。(无关系
  • 写者和写者 :多个写者同时修改共享数据会导致数据的不一致(互斥关系
  • 读者和写者 :当有读者在读取数据时,如果写者也在进行写操作,可能会导致数据的不完整(互斥关系)。当然了,写者写完了就应该发布,对应的读者就应该接收(同步关系)。

读者写者模型也遵循321原则

3 种关系

  • 读者和读者:无关系
  • 写者和写者:互斥
  • 读者和写者: 互斥、同步

2种角色

  • 读者
  • 写者

1 个交易场所

  • 队列或其他缓冲区
    为什么读者与读者之间甚至不存在互斥关系?

读者在读取共享数据时,不会改变数据的内容(不会拿走)。因此,多个读者可以同时进行读取操作,不会对数据的一致性产生影响。

允许多个读者同时读取共享资源可以显著提高系统的并发效率和吞吐量(系统在单位时间内处理的任务数量或数据量)。如果读者之间存在互斥关系,就会增加等待时间,降低整体性能。就好比说,我发布了这篇博客,不可能只有你一个人正在读吧,一定还有其他人在读。

因此,读者和读者之间不需要维持互斥关系。这也是和生产消费消费者模型最朴素的区别。

5.2 读写锁

在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较少的改写,它们读的机会反而高的多(多读少写)。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加互斥锁,会极大地降低我们程序的效率。

  • 为什么给多读少写加互斥锁会降低程序的效率?

互斥锁在一个线程持有锁时,其他线程必须等待,直到持有锁的线程释放它。对于多读少写的情况,大多数线程是读操作。使用互斥锁时,每个读操作都需要等待之前的读操作完成和任何写操作完成,这就会导致大量的读操作被阻塞,从而降低程序的并发度和整体效率。

  • 场景

库存管理系统中,查询库存状态(读操作)比实际更新库存(写操作)要频繁

那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁

  • 读锁(共享锁):允许多个读者同时持有读锁。这样可以提高读取操作的并发度,因为在没有写操作进行时,多个读者可以同时读取数据,而不需要互相等待(并发读取)。注意:多个线程可以同时持有读锁,但不能持有写锁。读锁之间是共享的(锁共享)。
  • 写锁(独占锁):当写者线程持有写锁时,其他线程不能持有读锁或写锁。写锁确保在写操作期间,数据不被其他线程读取或写入,从而保持数据的一致性和完整性(独占访问)。写锁是独占的,即在写操作进行时,所有的读操作和其他写操作都会被阻塞(锁排他)。

总结:

  • 读锁:允许多个读者同时读取共享资源,提高读取操作的并发度
  • 写锁:确保写操作的独占性,防止其他读者或写者同时访问共享资源

Linux中,原生线程库pthread中提供了读写锁相关接口允许多个读者并行访问共享资源(读者之间也要加锁),而在写操作时进行独占控制,从而提高系统的并发性能

cpp 复制代码
#include <pthread.h>

pthread_rwlock_t; // 读写锁类型

// 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *__restrict__ __rwlock
					, const pthread_rwlockattr_t *__restrict__ __attr); 

// 销毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *__rwlock) 

 // 读者,加锁
int pthread_rwlock_rdlock(pthread_rwlock_t *__rwlock); // 阻塞式
int pthread_rwlock_tryrdlock(pthread_rwlock_t *__rwlock); // 非阻塞式

// 写者,加锁
int pthread_rwlock_wrlock(pthread_rwlock_t *__rwlock); // 阻塞式 
int pthread_rwlock_trywrlock(pthread_rwlock_t *__rwlock); // 非阻塞式

// 解锁(读者锁、写者锁都可以解)
int pthread_rwlock_unlock(pthread_rwlock_t *__rwlock); 

// 设置读写优先
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 写者优先,但写者不能递归加锁
*/

注意: 读者和写者使用的加锁接口并不是同一个,但解锁是同一个

5.3 读者写者模型的策略

读者写者模型的使用场景:读多写少。在同一时刻下,读者之间要竞争读锁,写者之间要竞争写锁,那么读者的竞争能力要比写者强。毫无疑问,在现实中,读者数量大多数情况下都是多于写者的,所以势必会存在很多很多读者不断读取,导致写者根本申请不到锁资源。

这里可能会有人好奇:读者和写者不能一起竞争锁吗?读者竞争的是读锁,写者竞争的是写锁,不冲突啊?

这是因为写锁需要在没有读锁持有的情况下才能获得,也就是,当一个线程持有写锁时,其他线程(无论是读线程还是写线程)都不能获取任何锁。写锁是独占的,确保在写操作期间没有其他线程访问数据。

因此,这会导致写者饥饿或长时间等待,可能造成"死锁"现象,即写者无法获取锁来完成操作。这就是读者优先策略的体现。(以下伪代码就是读者优先策略)

cpp 复制代码
int reader_count = 0; // 读者个数
mutex_t rlock, wlock; // 读锁和写锁

读者
{
	lock(&rlock);
	reader_count++;
	if (reader_count == 1)
	{
		// 读者在读,写者就不要写了
		// 写者有两种情况:
		// 1. 在访问临界资源
		// 2. 还没申请到锁
		// 这都能让写者进行阻塞,保证读者在读
		lock(&wlock);
	}
	unlock(&rlock);
	
	//  读者进行读取操作
	// ...
	
	// 读者读完退出之前,人数-1
	lock(&rlock);
	reader_count--;
	if (reader_count == 0)
	{
		// 没有读者在读,写者就可以修改
		unlock(&wlock);
	}
	unlock(&rlock);
}

写者
{
	lock(&wlock);
	
	// 写入操作
	// ...
	
	unlock(wlock);
}

如果想要避免死锁,可以选择写者优先策略。即优先让写者先写,读者先等一等。

即在写者优先策略中,写者的请求会被优先处理,即使有读者在等待。这样可以确保写者能够及时获得锁进行写操作。但这种策略可能导致读者无法及时获取读锁,从而降低了读操作的并发度和吞吐量。

因此,无论是读者优先还是写者优先,都可能导致某一方(读者或写者)的饥饿问题。

如果想公平竞争的话,可以使用公平锁来有效解决读者或写者的饥饿问题。在并发编程中,公平锁(也称为公平读写锁)是一种锁策略,旨在确保所有线程都能以公平的方式获取锁,从而避免任何一个线程(无论是读者还是写者)因长期等待而无法执行。

5.4 样例代码 仅供参考

读多写少(读者优先策略)

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

class ReadWriteLock {
public:
    ReadWriteLock() : read_count(0), write_count(0) {}

    // 获取读锁
    void lock_read() {
        std::unique_lock<std::mutex> lock(mutex);
        // 等待直到没有写操作
        read_cond.wait(lock, [this]() { return write_count == 0; });
        ++read_count;
    }

    // 释放读锁
    void unlock_read() {
        std::unique_lock<std::mutex> lock(mutex);
        if (--read_count == 0) {
            write_cond.notify_one(); // 唤醒等待的写操作
        }
    }

    // 获取写锁
    void lock_write() {
        std::unique_lock<std::mutex> lock(mutex);
        // 等待直到没有读操作和写操作
        write_cond.wait(lock, [this]() { return read_count == 0 && write_count == 0; });
        ++write_count;
    }

    // 释放写锁
    void unlock_write() {
        std::unique_lock<std::mutex> lock(mutex);
        --write_count;
        if (write_count == 0) {
            read_cond.notify_all(); // 唤醒所有等待的读操作
        }
    }

private:
    std::mutex mutex;
    std::condition_variable read_cond;
    std::condition_variable write_cond;
    int read_count;
    int write_count;
};

ReadWriteLock rwlock;

void reader(int id) {
    std::this_thread::sleep_for(std::chrono::milliseconds(100 * id)); // 模拟工作负载
    rwlock.lock_read();
    std::cout << "Reader " << id << " is reading.\n";
    std::this_thread::sleep_for(std::chrono::milliseconds(1000)); // 模拟读操作
    rwlock.unlock_read();
}

void writer(int id) {
    std::this_thread::sleep_for(std::chrono::milliseconds(100 * id)); // 模拟工作负载
    rwlock.lock_write();
    std::cout << "Writer " << id << " is writing.\n";
    std::this_thread::sleep_for(std::chrono::milliseconds(1000)); // 模拟写操作
    rwlock.unlock_write();
}

int main() {
    std::thread readers[5];
    std::thread writers[2];

    // 创建读者线程
    for (int i = 0; i < 5; ++i) {
        readers[i] = std::thread(reader, i + 1);
    }

    // 创建写者线程
    for (int i = 0; i < 2; ++i) {
        writers[i] = std::thread(writer, i + 1);
    }

    // 等待所有线程完成
    for (auto &t : readers) {
        t.join();
    }
    for (auto &t : writers) {
        t.join();
    }

    return 0;
}

【代码解释】

六、相关代码

我的Gitee仓库链接:点击跳转

相关推荐
弗拉唐10 分钟前
springBoot,mp,ssm整合案例
java·spring boot·mybatis
sun00770011 分钟前
ubuntu dpkg 删除安装包
运维·服务器·ubuntu
Red Red13 分钟前
网安基础知识|IDS入侵检测系统|IPS入侵防御系统|堡垒机|VPN|EDR|CC防御|云安全-VDC/VPC|安全服务
网络·笔记·学习·安全·web安全
海岛日记13 分钟前
centos一键卸载docker脚本
linux·docker·centos
oi7742 分钟前
使用itextpdf进行pdf模版填充中文文本时部分字不显示问题
java·服务器
AttackingLin1 小时前
2024强网杯--babyheap house of apple2解法
linux·开发语言·python
2401_857610031 小时前
SpringBoot社团管理:安全与维护
spring boot·后端·安全