线程安全的单例模式与读者写者问题

发布于:2025-07-08 ⋅ 阅读:(16) ⋅ 点赞:(0)

什么是单例模式

单例模式是一种 "经典的, 常用的, 常考的" 设计模式.

什么是设计模式

大佬们针对一些经典的常见的场景, 给定了一些对应的解决方案, 这个就是设计模式

单例模式的特点

某些类, 只应该具有一个对象(实例), 就称之为单例. 例如一个男人只能有一个媳妇. 在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理这些数据.

饿汉实现方式和懒汉实现方式

吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭. 吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式.

懒汉方式最核心的思想是 "延时加载". 从而能够优化服务器的启动速度.

全局对象在加载的时候就有了,全局对象的生命周期随进程,如果全局对象定义太多就会拖慢饿汉模式加载速度

懒汉模式按需要加载,一切操作消耗不变但是将一整块加载时间分为多块

饿汉方式实现单例模式

template <typename T> 
class Singleton { 
 static T data; 
public: 
 static T* GetInstance() { 
 return &data; 
 } 
}; 

只要通过 Singleton 这个包装类来使用 T 对象, 则一个进程中只有一个 T 对象的实例.

懒汉方式实现单例模式

template <typename T> 
class Singleton { 
 static T* inst; 
public: 
 static T* GetInstance() { 
 if (inst == NULL) { 
 inst = new T(); 
 } 
 return inst; 
 } 
}; 

存在一个严重的问题, 线程不安全. 第一次调用 GetInstance 的时候, 如果两个线程同时调用, 可能会创建出两份 T 对象的实例. 但是后续再次调用, 就没有问题了.

单例模式线程池(懒汉)

防止串行执行,大量线程判断为空先进去竞争锁,竞争到的才能去创建tp_,tp_存在后其他线程就没必要再去竞争锁判断是否为空了,不需要加\释放锁

static ThreadPool<T> *GetInstance()
    {
        if (nullptr == tp_) // ???
        {
            pthread_mutex_lock(&lock_);
            if (nullptr == tp_)
            {
                std::cout << "log: singleton create done first!" << std::endl;
                tp_ = new ThreadPool<T>();
            }
            pthread_mutex_unlock(&lock_);
        }

        return tp_;
    }
#pragma once

#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <pthread.h>
#include <unistd.h>

struct ThreadInfo
{
    pthread_t tid;
    std::string name;
};

static const int defalutnum = 5;

template <class T>
class ThreadPool
{
public:
    void Lock()
    {
        pthread_mutex_lock(&mutex_);
    }
    void Unlock()
    {
        pthread_mutex_unlock(&mutex_);
    }
    void Wakeup()
    {
        pthread_cond_signal(&cond_);
    }
    void ThreadSleep()
    {
        pthread_cond_wait(&cond_, &mutex_);
    }
    bool IsQueueEmpty()
    {
        return tasks_.empty();
    }
    std::string GetThreadName(pthread_t tid)
    {
        for (const auto &ti : threads_)
        {
            if (ti.tid == tid)
                return ti.name;
        }
        return "None";
    }

public:
    static void *HandlerTask(void *args)
    {
        ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
        std::string name = tp->GetThreadName(pthread_self());
        while (true)
        {
            tp->Lock();

            while (tp->IsQueueEmpty())
            {
                tp->ThreadSleep();
            }
            T t = tp->Pop();
            tp->Unlock();

            t();
            std::cout << name << " run, "
                      << "result: " << t.GetResult() << std::endl;
        }
    }
    void Start()
    {
        int num = threads_.size();
        for (int i = 0; i < num; i++)
        {
            threads_[i].name = "thread-" + std::to_string(i + 1);
            pthread_create(&(threads_[i].tid), nullptr, HandlerTask, this);
        }
    }
    T Pop()
    {
        T t = tasks_.front();
        tasks_.pop();
        return t;
    }
    void Push(const T &t)
    {
        Lock();
        tasks_.push(t);
        Wakeup();
        Unlock();
    }
    static ThreadPool<T> *GetInstance()
    {
        if (nullptr == tp_) // ???
        {
            pthread_mutex_lock(&lock_);
            if (nullptr == tp_)
            {
                std::cout << "log: singleton create done first!" << std::endl;
                tp_ = new ThreadPool<T>();
            }
            pthread_mutex_unlock(&lock_);
        }

        return tp_;
    }

private:
    ThreadPool(int num = defalutnum) : threads_(num)
    {
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&cond_, nullptr);
    }
    ~ThreadPool()
    {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&cond_);
    }
    ThreadPool(const ThreadPool<T> &) = delete;
    const ThreadPool<T> &operator=(const ThreadPool<T> &) = delete; // a=b=c
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 lock_;
};

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

template <class T>
pthread_mutex_t ThreadPool<T>::lock_ = PTHREAD_MUTEX_INITIALIZER;
#include <iostream>
#include <ctime>
#include "ThreadPool.hpp"
#include "Task.hpp"


pthread_spinlock_t slock;


int main()
{
    // pthread_spin_init(&slock, 0);
    // pthread_spin_destroy(&slock);

    // 如果获取单例对象的时候,也是多线程获取的呢?
    std::cout << "process runn..." << std::endl;
    sleep(3);
    // ThreadPool<Task> *tp = new ThreadPool<Task>(5);
    ThreadPool<Task>::GetInstance()->Start();
    srand(time(nullptr) ^ getpid());

    while(true)
    {
        //1. 构建任务
        int x = rand() % 10 + 1;
        usleep(10);
        int y = rand() % 5;
        char op = opers[rand()%opers.size()];

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

        sleep(1);
    }
}

其他常见的各种锁

悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行 锁等),当其他线程想要访问数据时,被阻塞挂起。

乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前, 会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。

CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不 等则失败,失败则重试,一般是一个自旋的过程,即不断重试。

自旋锁:

自旋锁(Spinlock)是一种忙等待(Busy-Waiting)锁,适用于短期临界区多核环境。与互斥锁(Mutex)不同,线程在获取自旋锁失败时不会阻塞,而是循环检查锁状态,直到成功获取锁。

不会挂起,而是由进程周而复始的申请锁(进入临界区的线程时间非常短)

#include <pthread.h>

int pthread_spin_lock(pthread_spinlock_t *lock);

int pthread_spin_trylock(pthread_spinlock_t *lock);

int pthread_spin_unlock(pthread_spinlock_t *lock);

int pthread_spin_init(pthread_spinlock_t *lock, int pshared);

int pthread_spin_destroy(pthread_spinlock_t *lock);

自旋锁 vs. 互斥锁

特性 自旋锁(Spinlock) 互斥锁(Mutex)
等待方式 忙等待(CPU 空转) 阻塞(线程休眠,让出 CPU)
适用场景 临界区执行时间短(< CPU 切换时间) 临界区执行时间长
CPU 占用 高(占用 CPU 循环检查) 低(线程休眠)
实现复杂度 简单(通常用原子操作实现) 较复杂(依赖 OS 调度)
适用环境 多核 CPU 单核/多核均可

读者写者问题

读者写者问题具有以下特点:

  • 一个交易场所---写者写入数据,读者读数据
  • 两种角色---读者,写者
  • 三种关系
    • 读者和读者---并发
    • 写者和写者---互斥
    • 读者和写者---互斥 && 同步

生产者消费者模型中的消费者会将数据取走,而读者不会,这也是为什么读者之间不需要互斥,而是并发执行。

读多写少会导致读端竞争锁能力强,导致写端长时间竞争不到锁导致饥饿问题

 读者优先(伪代码)

核心思想

  • 只要有一个读者正在读,后续的读者可以直接进入,而写者必须等待所有读者完成。

  • 可能导致写者饥饿(如果一直有新的读者到来,写者可能永远无法执行)。

实现方式

  • 使用:

    • read_count(记录当前读者数量)

    • mutex(保护 read_count

    • rw_mutex(控制写者互斥访问)

// 全局变量
int read_count = 0;
semaphore rw_mutex = 1;  // 读写互斥锁
semaphore mutex = 1;     // 保护 read_count

// 读者
void reader() {
    wait(mutex);         // 保护 read_count
    read_count++;
    if (read_count == 1) // 第一个读者要锁住写者
        wait(rw_mutex);
    signal(mutex);

    // 执行读操作...

    wait(mutex);
    read_count--;
    if (read_count == 0) // 最后一个读者释放写锁
        signal(rw_mutex);
    signal(mutex);
}

// 写者
void writer() {
    wait(rw_mutex);      // 直接尝试获取写锁
    // 执行写操作...
    signal(rw_mutex);
}

写者两状态:未加锁、加锁,当没有加锁时即没有写入,读者竞争写锁,防止写端写入,当读者全部退出时释放写锁,当写端加锁即正在写入时,第一个读者阻塞等待写端的锁

特点

✅ 读者可以并发读,提高吞吐量
❌ 可能导致写者饥饿(如果读者源源不断,写者可能永远无法执行)

 写者优先

核心思想

  • 如果有写者在等待,新读者必须等待,直到所有写者完成。

  • 避免写者饥饿,但可能导致读者吞吐量下降

实现方式

  • 增加:

    • write_count(记录等待/正在写的写者数量)

    • read_try(阻止新读者在有写者等待时进入)

// 全局变量
int read_count = 0, write_count = 0;
semaphore rw_mutex = 1;  // 读写互斥锁
semaphore mutex = 1;     // 保护 read_count/write_count
semaphore read_try = 1;  // 控制读者尝试进入

// 读者
void reader() {
    wait(read_try);      // 尝试进入(可能被写者阻塞)
    wait(mutex);
    read_count++;
    if (read_count == 1)
        wait(rw_mutex);
    signal(mutex);
    signal(read_try);    // 允许其他读者尝试

    // 执行读操作...

    wait(mutex);
    read_count--;
    if (read_count == 0)
        signal(rw_mutex);
    signal(mutex);
}

// 写者
void writer() {
    wait(mutex);
    write_count++;
    if (write_count == 1)
        wait(read_try);  // 阻止新读者进入
    signal(mutex);

    wait(rw_mutex);
    // 执行写操作...
    signal(rw_mutex);

    wait(mutex);
    write_count--;
    if (write_count == 0)
        signal(read_try); // 允许新读者进入
    signal(mutex);
}

特点

✅ 避免写者饥饿
❌ 读者可能被延迟(如果有写者在等待)

读写锁

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

读写锁接口

设置读写优先

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

初始化

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_wrlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);  

读者优先的读写锁(伪代码)

lock(&rlock);
read_count++;
if(read_count==1)    lock(&wlock);
unlock(&rlock);

//进行读取

lock(&rlock);
read_count--;
if(read_count==0)    unlock(&wlock);
unlock(&rlock);

网站公告

今日签到

点亮在社区的每一天
去签到