【Linux】多线程(下)

打印 上一主题 下一主题

主题 817|帖子 817|积分 2451

目录
一、生产者消耗者模子
1.1 概念
1.2 基于阻塞队列
1.3 POSIX信号量
初始化信号量
销毁信号量
等待信号量
发布信号量
1.4 基于环形队列和POSIX信号量
二、线程池
2.1 概念
2.2 代码
三、封装Linux线程库
四、单例模式
4.1 概念
4.2 单例模式的实现方式
4.2.1 饿汉模式
4.2.2 懒汉模式
(1)线程不安全的懒汉模式
(2)线程安全的懒汉模式
①使用静态局部变量
②使用互斥锁
五、其他常见的锁
5.1 定义
5.2 C++11Atomic和CAS利用
六、读者写者题目
6.1 概念
6.2 读写策略
(1)读者优先
(2)写者优先
(3)读写公平
阅读本篇文章前推荐优先阅读:
【Linux】多线程(中)-CSDN博客
https://blog.csdn.net/Eristic0618/article/details/143433347?spm=1001.2014.3001.5501

一、生产者消耗者模子

1.1 概念

生产者消耗者模子的产生是为了解决数据的生产者和消耗者的强耦合题目,方案是提供一个额外的容器让生产者和消耗者之间进行通讯。
其思路在于,生产者产出数据后不直接递交给消耗者进行处理,而是托管到容器中;消耗者也不会直接向生产者请求数据,而是从容器中获取。
在生活中,一个经典的生产者消耗者模子就是超市了,客户们就是消耗者,供货商们就是生产者,而超市就是容器。如果没有超市,客户只能和供货商直接对接,但单个客户的需求量对于供货商的生产筹划而言实在是太少了,生产和消耗的效率不平衡。超市就相当于一个大号的缓存,既可以大概负担供货商高效的生产效率,又可以大概接受客户琐屑的消耗需求,并且生产和消耗不必同时进行,做到了将生产和消耗动作很好的解耦,支持生产和消耗的忙闲不均
在程序中,生产者和消耗者就是一个个线程,数据存放到特定布局的内存空间中。于是这个内存空间一定会被多线程并发访问,是共享资源,以是我们的模子中一定要引入互斥锁包管其线程安全
生产者消耗者模子中的“321原则”,分为三种关系、两个角色和一个生意业务场所,必须服从
此中三种关系分为:


  • 生产者与生产者:互斥
  • 消耗者与消耗者:互斥
  • 生产者与消耗者:互斥和同步
两个角色:生产者和消耗者
一个生意业务场所:特定布局的内存空间

1.2 基于阻塞队列

阻塞队列(Blocking Queue)是一种常用于实现生产者消耗者模子的数据布局,其特点在于当队列为空时,消耗者从队列获取数据的动作将被阻塞,直到队列中有数据被放入;当队列已满时生产者向队列中存放数据的动作将被阻塞,直到队列中有空间
我们可以使用互斥锁和条件变量来实现基于阻塞队列的生产者消耗者模子
  1. //BlockQueue.hpp
  2. #pragma once
  3. #include <iostream>
  4. #include <queue>
  5. #include <sys/types.h>
  6. #include <unistd.h>
  7. #include <pthread.h>
  8. template <class T>
  9. class BlockQueue
  10. {
  11.     static const int NUM = 5;
  12. public:
  13.     BlockQueue(int cap = NUM)
  14.         :capacity_(cap)
  15.     {
  16.         pthread_mutex_init(&mutex_, nullptr);
  17.         pthread_cond_init(&c_, nullptr);
  18.         pthread_cond_init(&p_, nullptr);
  19.     }
  20.     const T& pop()
  21.     {
  22.         pthread_mutex_lock(&mutex_); //加锁
  23.         while(q_.size() == 0) //阻塞队列为空(为什么要用while而不是if?)
  24.         {
  25.             pthread_cond_wait(&c_, &mutex_); //消费者等待
  26.         }
  27.         T out = q_.front(); //消费
  28.         q_.pop();
  29.         pthread_cond_signal(&p_); //唤醒生产者
  30.         pthread_mutex_unlock(&mutex_); //解锁
  31.         return out;
  32.     }
  33.     void Push(const T& in)
  34.     {
  35.         pthread_mutex_lock(&mutex_); //加锁
  36.         while(q_.size() == capacity_) //阻塞队列已满
  37.         {
  38.             pthread_cond_wait(&p_, &mutex_); //生产者等待
  39.         }
  40.         q_.push(in); //生产
  41.         pthread_cond_signal(&c_); //唤醒消费者
  42.         pthread_mutex_unlock(&mutex_); //解锁
  43.     }
  44.     ~BlockQueue()
  45.     {
  46.         pthread_mutex_destroy(&mutex_);
  47.         pthread_cond_destroy(&c_);
  48.         pthread_cond_destroy(&p_);
  49.     }
  50. private:
  51.     std::queue<T> q_;
  52.     int capacity_; //阻塞队列容量
  53.     pthread_mutex_t mutex_; //互斥锁
  54.     pthread_cond_t c_;
  55.     pthread_cond_t p_;
  56. };
复制代码
可以看到在Pop和Push函数中,我们判断队列为空或已满时使用的是while循环而不是if,为什么?
答案:防止线程被伪叫醒的环境
假设队列容量已满,消耗者在消耗完毕后可能在叫醒的时候一次性叫醒了多个生产者,被叫醒的这几个生产者先对锁竞争,第一个竞争到锁的线程开始生产,生产完毕后释放锁。接着消耗者和剩余被叫醒的生产者共同竞争这把锁,如果下一个竞争到锁的照旧生产者,则可能导致生产了超出队列容量的数据,导致错误。
使用while循环,就可以做到在线程被伪叫醒的环境下重新将其加入条件变量中
在main.cc中调用BlockQueue.hpp,实现一个多生产多消耗的景象
  1. #include "BlockQueue.hpp"
  2. pthread_mutex_t mutex; //互斥锁
  3. void* Consumer(void* args) //消费者例程
  4. {
  5.     BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
  6.     while(true)
  7.     {
  8.         sleep(1); //可以让消费的速度慢一些
  9.         int data = bq->pop(); //消费数据
  10.         std::cout << "consuming a data: " << data << std::endl;
  11.     }
  12. }
  13. void* Productor(void* args) //生产者例程
  14. {
  15.     BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
  16.     static int data = 0; //模拟数据
  17.     while (true)
  18.     {
  19.         pthread_mutex_lock(&mutex);
  20.         data++; //临界资源
  21.         bq->Push(data); //生产数据
  22.         std::cout << "produce a data: " << data << std::endl;
  23.         pthread_mutex_unlock(&mutex);
  24.     }
  25. }
  26. int main()
  27. {
  28.     BlockQueue<int> *bq = new BlockQueue<int>();
  29.     pthread_t c[3], p[5];
  30.     for (int i = 0; i < 3; i++) //创建多个消费者线程
  31.     {
  32.         pthread_create(c + i, nullptr, Consumer, bq);
  33.     }
  34.     for (int i = 0; i < 5; i++) //创建多个生产者线程
  35.     {
  36.         pthread_create(p + i, nullptr, Productor, bq);
  37.     }
  38.     //线程等待
  39.     for (int i = 0;i < 3; i++)
  40.     {
  41.         pthread_join(c[i], nullptr);
  42.     }
  43.     for (int i = 0; i < 5; i++)
  44.     {
  45.         pthread_join(p[i], nullptr);
  46.     }
  47.     delete bq;
  48.     return 0;
  49. }
复制代码
程序运行结果如下:

因为我们的代码中选择让消耗者的速度慢一些,对生产者的速度不作限制,因此可以看到一旦消耗者消耗一个数据,生产者就立刻生产数据补上
在实际环境中,不一定只有队列为空才让消耗者等待,队列为满让生产者等待,而是可以添加一个水位线,限定命据存量的上限和下限
为何生产者消耗者模子是高效的?在业务中,生产者生产的数据往往需要耗费时间获取,消耗者在获取数据后也可能要对数据进行加工处理,也需要时间。生产者在获取数据时消耗者可能在消耗数据,生产者在生产数据时消耗者可能正在加工处理数据,此时一个访问临界区,一个访问非临界区,二者互不干扰。
固然生产和消耗时线程之间是互斥的,但多生产多消耗的意义在于让线程可以大概并发的完成数据的获取和后续数据的加工处理

1.3 POSIX信号量

在前面我们已经学习过了System V信号量
【Linux】历程间通信——System V消息队列和信号量_vos消息队列-CSDN博客
https://blog.csdn.net/Eristic0618/article/details/142635584?spm=1001.2014.3001.5501POSIX信号量和System V信号量作用类似,但POSIX信号量可以用于线程间同步。
之前提到过,信号量的本质就是一把计数器,我们只要让信号量的值与资源的数量保持同等,让线程竞争信号量,那么每一个竞争到信号量的线程就一定会分配到资源。也就是说,在申请信号量和释放信号量之间,无需再对资源是否就绪做判断了,因为持有信号量就意味着一定有一份资源属于该线程
这里不再赘述信号量相关的概念,只需要了解其API如何使用即可

初始化信号量

  1. #include <semaphore.h>
  2. int sem_init(sem_t *sem, int pshared, unsigned int value);
复制代码
此中sem是待初始化的POSIX信号量;pshared为0表现线程间共享,非0表现历程间共享;value为信号量初始值

销毁信号量

  1. #include <semaphore.h>
  2. int sem_destroy(sem_t *sem);
复制代码

等待信号量

  1. #include <semaphore.h>
  2. int sem_wait(sem_t *sem);
复制代码
线程调用sem_wait函数后会将信号量sem的值减1

发布信号量

  1. #include <semaphore.h>
  2. int sem_post(sem_t *sem);
复制代码
资源使用完毕后,线程可以调用sem_post函数将信号量sem的值加1,代表归还一份资源

1.4 基于环形队列和POSIX信号量

关于环形队列的性子我们简单提一嘴:
   环形队列通常采用数组模拟,通过模运算实现逻辑上的循环布局
  其缺点是起始状态下队头和队尾在同一位置,未便于判断队列为空照旧为满,因此通常空出一个位置来区分二者的状态。
但如今我们有POSIX信号量,通过信号量的值就能知道队列中资源的数量,从而进行判满或判空
信号量用来统计剩余资源的数量,但对于生产者和消耗者而言,二者需要的资源是同一种吗?


  • 生产者需要的资源:容器中剩余的空间
  • 消耗者需要的资源:容器中剩余的数据
因此,使用环形队列和POSIX信号量实现生产消耗模子时,我们需要一个消耗信号量和一个生产信号量。消耗信号量用于统计队列中的数据个数,生产信号量用于统计队列中的剩余空间数量


  • 生产者进行一次生产:生产信号量-1(空间-1),消耗信号量+1(数据+1)
  • 消耗者进行一次消耗:消耗信号量-1(数据-1),空间信号量+1(空间+1)
因此,不论是生产者进行生产,照旧消耗者进行消耗,我们都要进行一次完备的PV利用,即请求自己的信号量(P)和释放对方的信号量(V)
环形队列代码如下:
  1. //RingQueue.hpp
  2. #pragma once
  3. #include <iostream>
  4. #include <vector>
  5. #include <unistd.h>
  6. #include <semaphore.h>
  7. #include <pthread.h>
  8. template <class T>
  9. class RingQueue
  10. {
  11.     static const int NUM = 5;
  12. private:
  13.     void P(sem_t &sem) //P操作:申请信号量
  14.     {
  15.         sem_wait(&sem);
  16.     }
  17.     void V(sem_t &sem) //V操作:归还信号量
  18.     {
  19.         sem_post(&sem);
  20.     }
  21.     void Lock(pthread_mutex_t &mutex) //上锁
  22.     {
  23.         pthread_mutex_lock(&mutex);
  24.     }
  25.     void Unlock(pthread_mutex_t &mutex) //解锁
  26.     {
  27.         pthread_mutex_unlock(&mutex);
  28.     }
  29. public:
  30.     RingQueue(int cap = NUM)
  31.         : ringqueue_(cap), capacity_(cap), Ci_(0), Pi_(0)
  32.     {
  33.         sem_init(&c_sem_, 0, 0);
  34.         sem_init(&p_sem_, 0, capacity_);
  35.         pthread_mutex_init(&c_mutex_, nullptr);
  36.         pthread_mutex_init(&p_mutex_, nullptr);
  37.     }
  38.     void Pop(T* out) //消费
  39.     {
  40.         P(c_sem_); //申请消费信号量
  41.         Lock(c_mutex_); //加锁
  42.         *out = ringqueue_[Ci_];
  43.         Ci_++;
  44.         Ci_ %= capacity_; //保持循环性质
  45.         Unlock(c_mutex_); //解锁
  46.         V(p_sem_); //归还生产信号量
  47.     }
  48.     void Push(T in) //生产
  49.     {
  50.         P(p_sem_); //申请生产信号量
  51.         Lock(p_mutex_); //加锁
  52.         ringqueue_[Pi_] = in;
  53.         Pi_++;
  54.         Pi_ %= capacity_; //保持循环性质
  55.         Unlock(p_mutex_); //解锁
  56.         V(c_sem_); // 归还消费信号量
  57.     }
  58.     ~RingQueue()
  59.     {
  60.         pthread_mutex_destroy(&c_mutex_);
  61.         pthread_mutex_destroy(&p_mutex_);
  62.         sem_destroy(&c_sem_);
  63.         sem_destroy(&p_sem_);
  64.     }
  65. private:
  66.     std::vector<T> ringqueue_; // 环形队列
  67.     int capacity_; // 队列容量
  68.     int Ci_; //可消费的数据所在下标
  69.     int Pi_; //可生产数据的空间下标
  70.     sem_t c_sem_; //消费信号量:剩余可消费的数据
  71.     sem_t p_sem_; //生产信号量:剩余可生产的空间
  72.     pthread_mutex_t c_mutex_; //消费者互斥锁
  73.     pthread_mutex_t p_mutex_; //生产者互斥锁
  74. };
复制代码
 在main.cc中调用RingQueue.hpp,实现一个多生产多消耗的景象
  1. #include "RingQueue.hpp"
  2. #include <time.h>
  3. #include <string>
  4. class ThreadData //线程属性
  5. {
  6. public:
  7.     RingQueue<int> *rq_; //环形队列
  8.     std::string ThreadName_; //线程名
  9. };
  10. void* Consumer(void* args) //消费者例程
  11. {
  12.     ThreadData *td = static_cast<ThreadData *>(args);
  13.     RingQueue<int> *rq = td->rq_;
  14.     while(true)
  15.     {
  16.         int data = 0;
  17.         rq->Pop(&data); //消费数据
  18.         std::cout << td->ThreadName_ << " consuming a data: " << data << std::endl;
  19.         sleep(1);
  20.     }
  21.     return nullptr;
  22. }
  23. void* Producer(void* args) //生产者例程
  24. {
  25.     ThreadData *td = static_cast<ThreadData *>(args);
  26.     RingQueue<int> *rq = td->rq_;
  27.     while(true)
  28.     {
  29.         int data = rand() % 10; //随机值模拟数据
  30.         rq->Push(data); //生产数据
  31.         std::cout << td->ThreadName_ << " produce a data: " << data << std::endl;
  32.         sleep(1);
  33.     }
  34.     return nullptr;
  35. }
  36. int main()
  37. {
  38.     srand(time(0));
  39.     RingQueue<int> *rq = new RingQueue<int>();
  40.     pthread_t c[5], p[3];
  41.     for (int i = 0; i < 5; i++)
  42.     {
  43.         ThreadData *td = new ThreadData();
  44.         td->rq_ = rq;
  45.         td->ThreadName_ = "Consumer " + std::to_string(i); //初始化线程属性
  46.         pthread_create(c + i, nullptr, Consumer, td); //创建消费者线程
  47.     }
  48.     for (int i = 0; i < 3; i++)
  49.     {
  50.         ThreadData *td = new ThreadData();
  51.         td->rq_ = rq;
  52.         td->ThreadName_ = "Producer " + std::to_string(i);
  53.         pthread_create(p + i, nullptr, Producer, td); //创建生产者线程
  54.     }
  55.     //线程等待
  56.     for (int i = 0; i < 5; i++)
  57.     {
  58.         pthread_join(c[i], nullptr);
  59.     }
  60.     for (int i = 0;i < 3; i++)
  61.     {
  62.         pthread_join(p[i], nullptr);
  63.     }
  64.     return 0;
  65. }
复制代码
程序运行结果如下:

通过观察可以发现,3个生产者每秒可以大概生产3份数据,但消耗者有5个,以是每秒只能有3个消耗者申请到信号量并消耗数据


二、线程池

2.1 概念

线程池是一种利用池化技术思想来实现的线程管理技术,主要是为了复用线程、便利地管理线程和使命、并将线程的创建和使命的执行解耦开来。线程池维护着多个线程,随时等待分配可并发执行的使命,可以大概包管内核的充实利用,克制了在处理短时间使命时创建和销毁线程的开销。
线程池的适用场景:


  • 需要大量线程完成使命且使命所需时长短的场景,比方web服务器完成网页请求。但对于长时间使命,线程池的优势就不明显了,因为使命所需时间比线程创建时间长
  • 对性能要求苛刻的应用
  • 可能产生突发性大量请求的应用。在这种环境下如果不采用线程池,短时间内将产生大量线程,占用系统内存

2.2 代码

下面是线程池的代码:
  1. //ThreadPool.hpp
  2. #pragma once
  3. #include <iostream>
  4. #include <vector>
  5. #include <string>
  6. #include <queue>
  7. #include <pthread.h>
  8. struct ThreadInfo
  9. {
  10.     pthread_t tid;
  11.     std::string threadname;
  12. };
  13. static const int ThreadNum = 5;
  14. template <class T>
  15. class ThreadPool
  16. {
  17. public:
  18.     void Lock() //加锁
  19.     {
  20.         pthread_mutex_lock(&mutex_);
  21.     }
  22.     void Unlock() //解锁
  23.     {
  24.         pthread_mutex_unlock(&mutex_);
  25.     }
  26.     void Wait() //线程等待
  27.     {
  28.         pthread_cond_wait(&cond_, &mutex_);
  29.     }
  30.     void Wakeup() //唤醒线程
  31.     {
  32.         pthread_cond_signal(&cond_);
  33.     }
  34.     std::string GetThreadName(pthread_t tid) //获取线程名
  35.     {
  36.         for(auto &e : threads_)
  37.         {
  38.             if(tid == e.tid)
  39.                 return e.threadname;
  40.         }
  41.         return "None";
  42.     }
  43. public:
  44.     ThreadPool(int num = ThreadNum)
  45.         :threads_(num)
  46.     {
  47.         pthread_mutex_init(&mutex_, nullptr);
  48.         pthread_cond_init(&cond_, nullptr);
  49.     }
  50.     static void* ThreadRoutine(void* args) //为何要加static?因为不加static的成员函数第一个参数是this指针,不符合线程例程函数的要求
  51.     {
  52.         ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
  53.         std::string threadname = tp->GetThreadName(pthread_self());
  54.         while(true)
  55.         {
  56.             tp->Lock(); //加锁
  57.             while(tp->datas_.size() == 0) //队列为空
  58.             {
  59.                 tp->Wait(); //线程进入条件变量等待
  60.             }
  61.             T data = tp->Pop(); //取出数据
  62.             tp->Unlock(); //解锁
  63.             std::cout << threadname << " get a data: " << data << std::endl;
  64.         }
  65.     }
  66.     void Start() //启动线程池
  67.     {
  68.         for (int i = 0; i < threads_.size(); i++)
  69.         {
  70.             threads_[i].threadname = "Thread-" + std::to_string(i + 1); //设置线程名
  71.             pthread_create(&(threads_[i].tid), nullptr, ThreadRoutine, this); //创建线程并传入This指针
  72.         }
  73.     }
  74.     T Pop() //从队列中取出数据
  75.     {
  76.         T out = datas_.front();
  77.         datas_.pop();
  78.         return out;
  79.     }
  80.     void Push(T &in) //向队列中放入数据
  81.     {
  82.         Lock(); //加锁
  83.         datas_.push(in);
  84.         Wakeup(); //唤醒线程
  85.         Unlock(); //解锁
  86.     }
  87.     ~ThreadPool()
  88.     {
  89.         pthread_mutex_destroy(&mutex_);
  90.         pthread_cond_destroy(&cond_);
  91.     }
  92. private:
  93.     std::vector<ThreadInfo> threads_; //线程信息数组
  94.     std::queue<T> datas_; //存放数据的队列(可以改为存放任务等)
  95.     pthread_mutex_t mutex_; //互斥锁
  96.     pthread_cond_t cond_; //条件变量
  97. };
复制代码
在main.cc中调用线程池并进行测试:
  1. #include <iostream>
  2. #include <ctime>
  3. #include <unistd.h>
  4. #include "ThreadPool.hpp"
  5. int main()
  6. {
  7.     ThreadPool<int> *tp = new ThreadPool<int>(10); //创建10个线程的线程池
  8.     tp->Start(); //启动线程池
  9.     srand(time(0));
  10.     while(true)
  11.     {
  12.         int data = rand() % 10;
  13.         tp->Push(data);
  14.         std::cout << "main thread make a data: " << data << std::endl;
  15.         sleep(1);
  16.     }
  17.     return 0;
  18. }
复制代码
程序运行结果如下:



三、封装Linux线程库

Linux线程库中,我们需要调用pthread_create函数才气让线程开始执行例程函数
但在C++11的尺度库中,我们可以在创建thread类对象时传入线程需要执行的函数,并且可以通过调用类成员函数完成线程等待之类的利用。我们可否通过对Linux线程库的封装来简单实现和C++thread类一样的效果呢?
  1. //Thread.hpp
  2. #pragma once
  3. #include <iostream>
  4. #include <string>
  5. #include <time.h>
  6. #include <pthread.h>
  7. typedef void (*callback_t)();
  8. static int num = 1;
  9. class Thread
  10. {
  11. public:
  12.     static void* ThreadRoutine(void* args)
  13.     {
  14.         Thread *thread = static_cast<Thread *>(args);
  15.         thread->entery();
  16.     }
  17. public:
  18.     Thread(callback_t cb)
  19.         :threadname_(""), start_timestamp_(0), isrunning_(false), cb_(cb)
  20.     {}
  21.     void run()
  22.     {
  23.         threadname_ = "Thread-" + std::to_string(num++);
  24.         start_timestamp_ = time(0);
  25.         isrunning_ = true;
  26.         pthread_create(&tid_, nullptr, ThreadRoutine, this);
  27.     }
  28.     void join()
  29.     {
  30.         pthread_join(tid_, nullptr);
  31.     }
  32.     void entery()
  33.     {
  34.         cb_();
  35.     }
  36.     std::string name()
  37.     {
  38.         return threadname_;
  39.     }
  40.     bool isrunning()
  41.     {
  42.         return isrunning_;
  43.     }
  44.     uint64_t starttime()
  45.     {
  46.         return start_timestamp_;
  47.     }
  48. private:
  49.     pthread_t tid_;
  50.     std::string threadname_;
  51.     uint64_t start_timestamp_;
  52.     bool isrunning_;
  53.     callback_t cb_;
  54. };
复制代码
注:此处没有实现创建线程时传入函数参数的功能,有爱好的同砚可以自己研究一下
在main.cc中调用封装后的线程
  1. #include <iostream>
  2. #include <unistd.h>
  3. #include "Thread.hpp"
  4. void Print()
  5. {
  6.     while(true)
  7.     {
  8.         std::cout << "I am an encapsulated thread" << std::endl;
  9.         sleep(1);
  10.     }
  11. }
  12. int main()
  13. {
  14.     Thread t(Print);
  15.     t.run();
  16.     std::cout << "isrunning: " << t.isrunning() << std::endl;
  17.     std::cout << "starttime: " << t.starttime() << std::endl;
  18.     std::cout << "threadname: " << t.name() << std::endl;
  19.     t.join();
  20.     return 0;
  21. }
复制代码
程序运行结果如下:



四、单例模式

4.1 概念

单例模式是一种经典的、常用常考的设计模式。采用这种模式的类只能由类本身创建一个全局唯一的实例化对象,即用户自己不能对这个类进行实例化,而是调用其接口来获取这个类已经实例化的对象。只能有一个实例化对象的类就称为单例
单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供了一个方法来获取该实例。
要点:


  • 单例类只能有一个实例
  • 单例类必须自己创建自己的唯一实例
  • 用户只能通过单例类提供的方法来获取该实例
当需要控制实例数量,节流系统资源时,我们就可以采用单例模式。单例模式可以解决频繁创建和销毁全局使用的类实例的题目。单例模式还可以克制资源的多重占用,比方当某些资源只能由一个对象进行管理时,这种环境可以采用单例模式
为了确保用户不会自行实例化单例类,我们需要将单例类的构造函数的属性设置为私有

4.2 单例模式的实现方式

4.2.1 饿汉模式

对于一个单例类,如果在程序一开始运行时就实例化自己的对象,在需要使用时可以直接获取,即为饿汉模式。这种模式本身就是线程安全的,不需要加锁
  1. #include <iostream>
  2. class Singleton
  3. {
  4. public:
  5.     static Singleton* GetInstance() //获取单例类对象
  6.     {
  7.         return instance;
  8.     }
  9.     static void DestroyInstance() //销毁单例类对象
  10.     {
  11.         if(instance)
  12.         {
  13.             delete instance;
  14.             instance = nullptr;
  15.         }
  16.     }
  17. private: //将构造、拷贝构造、赋值构造、析构设置为私有
  18.     Singleton()
  19.     {
  20.         std::cout << "Singleton()" << std::endl;
  21.     }
  22.     ~Singleton()
  23.     {
  24.         std::cout << "~Singleton()" << std::endl;
  25.     }
  26.     Singleton(const Singleton &sig);
  27.     const Singleton operator=(const Singleton &sig);
  28. private:
  29.     static Singleton *instance;
  30. };
  31. Singleton *Singleton::instance = new Singleton(); //全局唯一的单例类对象
复制代码
因为类对象指针是静态的,且只有静态成员函数能访问静态成员变量,以是函数也得是静态的 
在main.cc中调用这个饿汉式单例并进行测试:
  1. #include <iostream>
  2. #include "Singleton.hpp"
  3. int main()
  4. {
  5.     Singleton *ins = Singleton::GetInstance(); //获取单例类对象
  6.     ins->DestroyInstance(); //销毁单例类对象
  7.     return 0;
  8. }
复制代码
程序运行结果如下:

如果我们试图在源文件中自行实例化一个单例类对象呢?

可以看到因为构造函数已经私有化了,无法在外部调用

4.2.2 懒汉模式

懒汉模式与饿汉模式相对,其不会在程序一开始运行时就实例化类对象,而是等到用户需要使用该对象、第一次调用方法获取时才进行实例化。
懒汉模式的焦点思想在于“延时加载”,从而可以大概优化程序的启动速度,但其本身无法包管线程安全
(1)线程不安全的懒汉模式

  1. #include <iostream>
  2. class Singleton
  3. {
  4. public:
  5.     static Singleton* GetInstance() //获取单例类对象
  6.     {
  7.         if(instance == nullptr) //第一次调用:为空指针,进行实例化
  8.         {
  9.             instance = new Singleton();
  10.         }
  11.         return instance; //后续直接返回已实例化的对象
  12.     }
  13.     static void DestroyInstance() //销毁单例类对象
  14.     {
  15.         if(instance)
  16.         {
  17.             delete instance;
  18.             instance = nullptr;
  19.         }
  20.     }
  21. private: //将构造、拷贝构造、赋值构造、析构设置为私有
  22.     Singleton()
  23.     {
  24.         std::cout << "Singleton()" << std::endl;
  25.     }
  26.     ~Singleton()
  27.     {
  28.         std::cout << "~Singleton()" << std::endl;
  29.     }
  30.     Singleton(const Singleton &sig);
  31.     const Singleton operator=(const Singleton &sig);
  32. private:
  33.     static Singleton *instance;
  34. };
  35. Singleton *Singleton::instance = nullptr; //初始化为空指针
复制代码
可以看到,对于懒汉模式,单例类对象的指针初始是为空的,当用户第一次获取类对象时才会进行实例化
但是这种平凡的懒汉模式存在线程不安全的题目,即如果在第一次获取类对象时两个线程同时调用GetInstance方法,可能会创建出两份单例类的实例
因此我们可以通过静态局部变量或使用互斥锁来实现线程安全的懒汉模式
(2)线程安全的懒汉模式

①使用静态局部变量

使用静态局部变量实现懒汉模式的方式称为 Meyer’s Singleton
  1. #include <iostream>
  2. class Singleton
  3. {
  4. public:
  5.     static Singleton& GetInstance() //获取单例类对象
  6.     {
  7.         static Singleton instance;
  8.         return instance;
  9.     }
  10. private: //将构造、拷贝构造、赋值构造、析构设置为私有
  11.     Singleton()
  12.     {
  13.         std::cout << "Singleton()" << std::endl;
  14.     }
  15.     ~Singleton()
  16.     {
  17.         std::cout << "~Singleton()" << std::endl;
  18.     }
  19.     Singleton(const Singleton &sig);
  20.     const Singleton operator=(const Singleton &sig);
  21. };
复制代码
在C++11中,使用静态局部变量的方式天然就是线程安全的,具体详见:
c++ - Singleton instance declared as static variable of GetInstance method, is it thread-safe? - Stack Overflow
https://stackoverflow.com/questions/449436/singleton-instance-declared-as-static-variable-of-getinstance-method-is-it-thre这种方式实现的懒汉式单例,我们直接调用Singleton::GetInstance()即可获取其对象


②使用互斥锁

  1. #include <iostream>
  2. class Singleton
  3. {
  4. public:
  5.     static Singleton* GetInstance() //获取单例类对象
  6.     {
  7.         if(instance == nullptr) //第一次调用:为空指针,进行实例化
  8.         {
  9.             pthread_mutex_lock(&mutex); //加锁
  10.             if(instance == nullptr) //双重判断——双检锁:避免重复实例化
  11.             {
  12.                 instance = new Singleton();
  13.             }
  14.             pthread_mutex_unlock(&mutex); //解锁
  15.         }
  16.         return instance; //后续直接返回已实例化的对象
  17.     }
  18.     static void DestroyInstance() //销毁单例类对象
  19.     {
  20.         if(instance)
  21.         {
  22.             delete instance;
  23.             instance = nullptr;
  24.         }
  25.     }
  26. private: //将构造、拷贝构造、赋值构造、析构设置为私有
  27.     Singleton()
  28.     {
  29.         std::cout << "Singleton()" << std::endl;
  30.     }
  31.     ~Singleton()
  32.     {
  33.         std::cout << "~Singleton()" << std::endl;
  34.     }
  35.     Singleton(const Singleton &sig);
  36.     const Singleton operator=(const Singleton &sig);
  37. private:
  38.     static Singleton *instance;
  39.     static pthread_mutex_t mutex;
  40. };
  41. Singleton *Singleton::instance = nullptr; //初始化为空指针
  42. pthread_mutex_t Singleton::mutex;
复制代码
为了克制在第一次获取单例类对象时出现多线程同时调用GetInstance方法导致重复实例化的环境,我们需要使用互斥锁来包管线程安全。
但单纯使用互斥锁是不够的,在临界区内部还需要进行第二次判断,以克制解锁后其他线程继续申请锁进行二次实例化,这种进行双重判断的锁称为双检锁


五、其他常见的锁

5.1 定义



  • 公平锁:线程在申请锁前先进入队列列队,按照顺序获取锁
  • 非公平锁:多线程竞争式获取锁,获取不到才进入队列,可能导致线程饥饿题目
  • 自旋锁:获取不到锁的线程不会阻塞挂起,而是循环等待并不停判断是否可以大概获取锁
  • 悲观锁:每次获取数据时,总担心数据会被其他线程修改,因此会在访问数据前先加锁
  • 乐观锁:每次获取数据时,总是乐观的以为数据不会被其他线程修改,因此不上锁。但是会在更新数据前判断是否有其他线程对数据进行过修改。常见方式:版本号机制和CAS利用
自旋和非阻塞轮询比力相似,就是周而复始的去申请锁。过去我们学习使用的互斥锁属于挂起等待锁,即线程申请锁失败就被挂起,但将线程挂起和叫醒也是需要时间的,以是使用互斥锁和自旋锁完全取决于其他线程执行临界区时的时长。如果线程执行临界区需要花很长时间,那么就使用互斥锁,如果很短,那么就使用自旋锁
我们可以使用pthread_mutex_trylock函数和循环实现简单的自旋锁
  1. #include <pthread.h>
  2. int pthread_mutex_trylock(pthread_mutex_t *mutex);
复制代码
除此之外,还有Linux原生线程库提供的自旋锁
  1. #include <pthread.h>
  2. int pthread_spin_lock(pthread_spinlock_t *lock);
复制代码

5.2 C++11Atomic和CAS利用

为了包管线程安全,我们可以使用互斥锁,但不停加锁解锁的过程也是有性能消耗的。如果想要无锁实现线程安全,可使用C++11中新增的原子利用类Atomic中的CAS方法
首先我们需要知道什么是CAS利用
   Compare-and-Swap (CAS)是用于多线程以实现同步的原子指令,在修改数据前先判断其是否与预期值相等,如果相等才会将其修改为给定值
  通过在修改前先比力的方式,我们就能包管在修改数据的过程中,不会有其他线程额外对数据进行窜改,从而进行安全的写入,包管读写的同等性
关于Atomic类,可以查阅文档:
cplusplus.com/reference/atomic/atomic/
https://cplusplus.com/reference/atomic/atomic/atomic<T>提供的常用方法有:


  • store:原子写
  • load:原子读
  • exchange:原子的对两个数据进行交换
  • compare_exchange_weak和compare_exchange_strong:CAS利用
我们先看这两个CAS利用的声明:
  1. bool compare_exchange_weak (T& expected, T val, memory_order sync = memory_order_seq_cst) volatile noexcept;
  2. bool compare_exchange_weak (T& expected, T val, memory_order sync = memory_order_seq_cst) noexcept;
  3. bool compare_exchange_weak (T& expected, T val, memory_order success, memory_order failure) volatile noexcept;
  4. bool compare_exchange_weak (T& expected, T val, memory_order success, memory_order failure) noexcept;
复制代码
  1. bool compare_exchange_strong (T& expected, T val, memory_order sync = memory_order_seq_cst) volatile noexcept;
  2. bool compare_exchange_strong (T& expected, T val, memory_order sync = memory_order_seq_cst) noexcept;
  3. bool compare_exchange_strong (T& expected, T val, memory_order success, memory_order failure) volatile noexcept;
  4. bool compare_exchange_strong (T& expected, T val, memory_order success, memory_order failure) noexcept;
复制代码
主要参数为expected和val,它们的作用是,若this的值等于expected的值,则将this修改为val并返回true,否则返回false
此中,weak版本和strong的版本区别在于,weak版本的CAS利用允许出乎意料的false返回(比方this值和expected值一样时仍然返回false),比strong版本具有更高的性能
要创建一个原子变量,可以用这种方式:
  1. std::atomic<int> ai(1);
  2. int val = ai.load(); //原子的读取
  3. ai.store(2); //原子的写入
复制代码
我们可以看看C++文档中实现的线程安全的无锁链表
  1. #include <iostream>
  2. #include <atomic>
  3. #include <thread>
  4. #include <vector>
  5. struct Node
  6. {
  7.     int value;
  8.     Node *next;
  9. };
  10. std::atomic<Node *> list_head(nullptr);
  11. void append(int val)
  12. {
  13.     Node *oldHead = list_head;
  14.     Node *newNode = new Node{val, oldHead};
  15.     while (!list_head.compare_exchange_weak(oldHead, newNode))
  16.         newNode->next = oldHead;
  17. }
  18. int main()
  19. {
  20.     std::vector<std::thread> threads;
  21.     for (int i = 0; i < 10; ++i)
  22.         threads.push_back(std::thread(append, i));
  23.     for (auto &th : threads)
  24.         th.join();
  25.     for (Node *it = list_head; it != nullptr; it = it->next)
  26.         std::cout << ' ' << it->value;
  27.     std::cout << '\n';
  28.     Node *it;
  29.     while (it = list_head)
  30.     {
  31.         list_head = it->next;
  32.         delete it;
  33.     }
  34.     return 0;
  35. }
复制代码


六、读者写者题目

6.1 概念

在多线程编程中,有一种环境非经常见,即有些共享数据修改的时机较少,读取的次数相比写入的次数要多得多。
比方存在一个文件,允许多个读者同时读取这个文件,但是不允许在读的过程中对文件进行写入,文件在进行写入时也不允许读取或有其他人同时写入
读者写者题目也有“321原则”,但与生产者消耗者模子差别之处在于,读者与读者之间是共享关系,而不是互斥关系,因为读者不会像消耗者一样拿走数据
在Linux原生线程库中也有读者写者题目需要用到的锁:读写锁
  1. #include <pthread.h>
  2. int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
  3. int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
  4.               const pthread_rwlockattr_t *restrict attr);
  5. //读者申请读写锁
  6. int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
  7. //写者申请读写锁
  8. int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
  9. //读者写者释放读写锁
  10. int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
复制代码

6.2 读写策略

在读者多、写者少的环境下,读者竞争锁的本事更强,可能导致写者的饥饿题目,因此我们有时可能需要对读写接纳特定的策略
(1)读者优先

读者优先的伪代码:
  1. mutex_t rlock, wlock;
  2. int reader_count = 0; //读者个数
  3. void reader() //读者
  4. {
  5.         lock(rlock); //加锁准备修改reader_count
  6.         if(reader_count == 0)
  7.         lock(wlock); //第一个读者申请写者锁,写者被阻塞
  8.         reader_count++;
  9.         unlock(rlock); //解锁
  10.        
  11.         read(); //读者读
  12.         lock(rlock); //加锁准备修改reader_count
  13.         reader_count--;
  14.         if(reader_count == 0)
  15.         unlock(wlock);//最后一个读者读取完才释放写者锁
  16.         unlock(rlock); //解锁
  17. }
  18. void writer() //写者
  19. {
  20.     lock(wlock); //加锁进行写入
  21.         write();
  22.         unlock(wlock);
  23. }
复制代码
第一个读者开始读取时,首先申请写者锁,让写者阻塞无法进行写入。读取的动作不需要互斥,因此不消加锁。
当reader_count为0时说明全部读者读取完毕,此时释放写者锁,让写者可以大概申请锁进行写入
而写者只需要简单的申请和释放锁,构成写者之间的互斥即可

(2)写者优先

写者优先的伪代码:
  1. mutex_t S;
  2. mutex_t rlock,wlock, wclock;
  3. int readcount = 0, writecount = 0;
  4. void reader() //读者
  5. {
  6.            lock(S); //读者对S加锁准备申请读取
  7.         lock(rlock);//加锁准备修改readcount
  8.         if(readcount == 0)
  9.         lock(wlock); //第一个读者阻止后面的写者写入
  10.         readcount++;
  11.         unlock(rlock);
  12.         unlock(S);       
  13.         read(); //读者读
  14.        
  15.         lock(rlock); //加锁准备修改readcount
  16.         readcount--;
  17.         if(readcount == 0)
  18.         unlock(wlock);//最后一个读者读取完毕
  19.         unlock(rlock);
  20. }
  21. void writer() //写者
  22. {
  23.         lock(wclock); //加锁准备修改writecount
  24.         if(writecount == 0)
  25.         lock(S); //第一个写者申请锁S,读者被阻塞
  26.         writecount++;
  27.         unlock(wclock);
  28.         lock(wlock);
  29.         write(); //写者写
  30.         unlock(wlock);       
  31.         lock(wclock); //加锁准备修改writecount
  32.         writecount--;
  33.         if(writecount == 0)
  34.         unlock(S); //最后一个写者写入完毕释放S,读者才能读取
  35.         unlock(wclock);
  36. }
复制代码
在没有写者进行写入的环境下,读者会申请wlock让自己在读取的同时写者无法进行写入。
如果此时有写者想要进行写入,就会先申请S让后续读者阻塞,等待当前全部读者读取完毕后进行写入
上面的mutex_t换成二元信号量也是一样的效果,伪代码主要方便进行原理的说明

(3)读写公平

读写公平的伪代码:
  1. mutex_t rlock, wlock, S;
  2. int readcount = 0, writecount = 0;
  3. void reader() //读者
  4. {
  5.           lock(S);               
  6.         lock(rlock);//加锁准备修改readcount
  7.         if(readcount == 0)
  8.         lock(wlock);//第一个读者申请wlock,写者阻塞
  9.         readcount++;
  10.         unlock(rlock);
  11.         unlock(S);               
  12.        
  13.         read(); //读者读
  14.        
  15.         lock(rlock);
  16.         readcount--;
  17.         if(readcount == 0)
  18.         unlock(wlock);
  19.         unlock(rlock);
  20. }
  21. void writer() //写者
  22. {
  23.         lock(S);
  24.         lock(wlock);
  25.         write(); //写者写
  26.         unlock(wlock);
  27.         unlock(S);
  28. }
复制代码
读者跟写者一起公平竞争S,申请到S的线程才有资格访问共享资源
完.

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

河曲智叟

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表