0.关注博主有更多知识
操作系统入门知识合集
目录
1.单例设计模式
1.1将线程池设计为懒汉方式实现的单例模式
1.2线程安全版本的懒汉方式
2.STL、智能指针与线程安全
3.自旋锁
4.读者写者问题
1.单例设计模式
在一些软件设计场景当中,要求某些类只能具有一个对象,这样的模式我们就称为单例。例如服务器类,程序加载运行后,我们希望服务器类只实例化出一个服务器对象。
单例模式分为饿汉实现和懒汉实现。以一个通俗的例子来说明饿汉和懒汉:
1.饿汉说的就是吃完饭,立刻洗碗,这样做就可以在下一次吃饭的时候不用洗碗
2.懒汉指的是吃完饭,先不洗碗,直到下一次吃饭时再洗碗
那么放在程序设计的角度,就可这么理解饿汉和懒汉:
1.饿汉指的是程序还没有开始运行就实例化一个对象。这种对象通常是静态变量或者全局变量,因为在C++程序中,main()函数是程序的入口,在执行main()函数之前,全局变量已经被定义好了。
2.懒汉指的是程序需要使用对象时才实例化对象。
事实上,懒汉实现的核心思想就是"延迟加载",能够优化程序的启动速度。为什么懒汉能够优化程序的启动速度?因为饿汉的对象在程序运行之前就要创建,如果该对象的创建需要使用大量的资源和时间,那么就会延迟程序进入main()函数;那么懒汉的对象是在我们需要使用对象时才实例化出对象,这就意味着程序相较于饿汉模式能够提前一些时间进入main()函数。
事实上我们调用的new或malloc()也是一种延迟加载的思想,当我们new或malloc()成功申请一块空间时,我们就真的拥有这块空间了吗?当然未必,操作系统可能只会将地址空间的地址范围稍微扩大一些,让我们的进程看到的虚拟内存又扩大了一些,从而让我们感觉到空间已经申请到了。实际上真实的物理内存空间并未属于我们,只有我们真正向申请的空间进行操作的时候,操作系统才会执行缺页中断、重新建立页表映射等等操作,最后将物理内存空间映射到我们的地址空间当中。我们试着逆向思考一下,如果我们使用new或malloc()申请了一块空间之后立马获得该空间,但是我们不去使用它,这不就是一种"占着茅坑不拉屎"的行为吗?这不就是一种浪费系统的资源的做法吗?
所以我们主要讨论单例模式中的懒汉实现。
1.1将线程池设计为懒汉方式实现的单例模式
以上一篇博客实现的线程池为例,假设在某个应用程序当中需要使用线程池,并且希望它是单例的,那么我们可以这么实现线程池:
template <class T>
class threadPool;
template <class T>
class threadData /*传递给线程例程的参数*/
{
public:
threadPool<T> *_this;
string _name;
};
template <class T>
class threadPool
{
private:
#define INIT_THREAD_NUM 10
static void *start_routine(void *args);
private:
void queueLock() { pthread_mutex_lock(&_mutex); }
void queueUnlock() { pthread_mutex_unlock(&_mutex); }
bool isQueueEmpty() { return _taskQueue.empty(); }
void queueWait() { pthread_cond_wait(&_cond, &_mutex); }
T taskPop();
pthread_mutex_t *mutex() { return &_mutex; }
private:
/*将构造函数作为私有,并且禁用拷贝构造和赋值重载
*目的就是为了不允许在类外定义对象*/
threadPool(int num = INIT_THREAD_NUM);
threadPool(const threadPool &tp) = delete;
threadPool &operator=(const threadPool &tp) = delete;
public:
~threadPool();
void start();
void push(const T &in);
/*给定一个静态方法,用来获取单例对象的指针
*静态方法可以不用对象访问*/
static threadPool<T> *getInstance()
{
/*如果单例指针为空,说明对象还没有被实例化过*/
if(_singleton == nullptr)
{
_singleton = new threadPool<T>();
}
return _singleton;
}
private:
vector<Thread *> _threadVec;
queue<T> _taskQueue;
pthread_mutex_t _mutex;
pthread_cond_t _cond;
/*单例指针*/
static threadPool<T> *_singleton;
};
template <class T>
threadPool<T> *threadPool<T>::_singleton = nullptr;
// 测试用例
#include "singletonPthreadPool.hpp"
using namespace threadpool;
#include "Task.hpp"
#include <iostream>
#include <memory>
using namespace std;
#include <unistd.h>
int main()
{
threadPool<culculateTask<int>>::getInstance()->start();
printf("0x%x\n", threadPool<culculateTask<int>>::getInstance());
int left = 0, right = 0;
char oper;
while (true)
{
cout << "Input left number# ";
cin >> left;
cout << "Input right number# ";
cin >> right;
cout << "Input oper# ";
cin >> oper;
culculateTask<int> task(left, right, oper);
threadPool<culculateTask<int>>::getInstance()->push(task);
printf("0x%x\n", threadPool<culculateTask<int>>::getInstance());
usleep(1234);
}
return 0;
}
我们在测试用力当中使用了两句printf()语句,其目的就是证明对象永远最多只有一个:
1.2线程安全版本的懒汉方式
上面的代码已经实现单例了,但是我们并不满足,因为我们知道,多个执行流之间可能同时创建对象,如果我们不加安全保护,就会带来数据不一致问题(多创建对象)。那么线程安全的懒汉实现方式为:
template <class T>
class threadPool;
template <class T>
class threadData /*传递给线程例程的参数*/
{
public:
threadPool<T> *_this;
string _name;
};
template <class T>
class threadPool
{
private:
#define INIT_THREAD_NUM 10
static void *start_routine(void *args);
private:
void queueLock() { pthread_mutex_lock(&_mutex); }
void queueUnlock() { pthread_mutex_unlock(&_mutex); }
bool isQueueEmpty() { return _taskQueue.empty(); }
void queueWait() { pthread_cond_wait(&_cond, &_mutex); }
T taskPop();
pthread_mutex_t *mutex() { return &_mutex; }
private:
/*将构造函数作为私有,并且禁用拷贝构造和赋值重载
*目的就是为了不允许在类外定义对象*/
threadPool(int num = INIT_THREAD_NUM);
threadPool(const threadPool &tp) = delete;
threadPool &operator=(const threadPool &tp) = delete;
public:
~threadPool();
void start();
void push(const T &in);
/*给定一个静态方法,用来获取单例对象的指针
*静态方法可以不用对象访问*/
static threadPool<T> *getInstance()
{
/*如果没有最外层的这条判断语句,那么每个线程要创建对象时都要加锁
*如果在加锁之前告诉线程对象已经存在了,线程就不用再加锁了
*从而减少加锁和解锁所带来的开销*/
if (_singleton == nullptr)
{
/*如果单例指针为空,说明对象还没有被实例化过*/
pthread_mutex_lock(&_singletonMutex);
if (_singleton == nullptr)
{
_singleton = new threadPool<T>();
}
pthread_mutex_unlock(&_singletonMutex);
}
return _singleton;
}
private:
vector<Thread *> _threadVec;
queue<T> _taskQueue;
pthread_mutex_t _mutex;
pthread_cond_t _cond;
/*单例指针和多线程创建对象时的锁*/
static threadPool<T> *_singleton;
pthread_mutex_t _singletonMutex;
};
template <class T>
threadPool<T> *threadPool<T>::_singleton = nullptr;
2.STL、智能指针与线程安全
实际上STL中的容器不是线程安全的,这就是为什么我们在写生产消费模型、线程池时,使用了容器还需要主动加锁的原因。因为STL的设计初衷是将性能挖掘到机制,而一旦涉及到线程安全,就会对性能造成巨大影响,所以在使用STL容器时,需要程序员自己控制STL容器的线程安全问题。
对于unique_ptr来说,它只在当前代码块范围内生效,因此不涉及线程安全问题。但是对于shared_ptr,多个对象需要共用一个引用计数变量,所以就会存在线程安全问题,但是标准库实现的时候考虑到了这个问题,因此使用了原子操作的方式操作引用计数,从而保证shared_ptr的高效,这种原子操作实际上就是CAS操作。
3.自旋锁
我们之前介绍的互斥锁是一种悲观锁,悲观锁的特点就是持有锁的执行流访问临界资源时,阻塞其他执行流对临界资源的访问。很显然,悲观锁降低了并发性能,但是却保证了数据一致和线程安全。
自旋锁是一种乐观锁,当持有自旋锁的执行流访问临界资源时,其他执行流不会被阻塞,而是一直在尝试申请锁,直到成功为止。因此,自旋锁适用于访问临界资源消耗的时间较少的场景,能够提高并发性能,减少线程的阻塞时间。
我们可以以一幅逻辑图来理解互斥锁与自旋锁的区别:
也就是说,正在被访问的临界资源决定了其他线程需要等待,而访问临界的时间长短决定了线程的等待方式,即可以使用阻塞挂起等待和持续申请锁两种方式,这两种方式分别对应了互斥锁和自旋锁。需要注意的是,加锁和解锁的行为是程序员行为,所以选用互斥锁还是自旋锁完全由程序员决定,也就是说程序员有义务去预估访问临界资源所花费的时间再去选择合适的锁。当然,最笨的方法就是分别测试两个锁的时间,哪个消耗的时间短就用哪个锁。
在Linux当中,pthread原生线程库确实提供了有关自旋锁的接口,pthread_spin_t为自旋锁的类型:
pthread_spin_init()和pthread_spin_destroy()负责自旋锁的初始化和析构。
该接口与pthread_mutex_lock()非常相似,就不做过多介绍了,只需要知道申请自旋锁失败的线程不会陷入阻塞即可。
至于自旋锁的使用方法,我只能说与互斥锁一摸一样,我们仅需要记住自旋锁和互斥锁的差别就可以了。当然,使用自旋锁的时候一定要小心,因为当访问临界资源的时间过长、出现死锁等问题的时候,申请自旋锁的线程不会挂起等待,而是一直在申请自旋锁,这就是会造成CPU资源浪费,如果情况较为极端,那么使用自旋锁是十分危险的动作。
4.读者写者问题
在多线程编程当中,有一种情况是十分常见的,即某些公共资源的被修改次数很少。这就意味着负责写入的线程竞争到公共资源的机会比较少,更多的是负责读取的线程竞争到公共资源的机会比较多。这样我们又可以讨论一个新的问题,即读者写者问题。
读者写者问题实际上可以归类为生产消费模型,但是读者与消费者的区别在于:消费者要从缓冲区拿走数据,读者将缓冲区的数据拷贝一份,即不拿走缓冲区的数据。读者写者的场景大多发生于发布某种数据、资源,该资源很长一段时间内不会被修改,更多的时间是在被读取。那么既然读者写者问题也可以归类为生产消费模型,那么它也需要遵循三二一原则:
1.保持三种关系:读者与写者之间互斥,即读者与写者不能同时操作"同一本书";写者与写者之间互斥,即统一时刻只允许一个写者"写书";读者与读者之间没有任何关系,读者想要"读书"时,不需要顾及其他读者的感受,因为它们不会把"书本"占为己有(读者不会从缓冲区拿走数据)。
2.保持两种身份:即保证有读者和写者两种身份。
3.保持一个缓冲区:这个缓冲区就是供读者读取的资源,写者要操作的资源。
综上所述,不难推断出读者写者问题是一种公共资源被多读少写的情况。如果使用互斥锁来控制共享资源的访问,就会导致读操作和写操作之间相互阻塞,从而降低并发性能;并且使用互斥锁很容易发生饥饿问题。那么在操作系统当中就有了读写锁这样的概念,读写锁就是为了解决多读少写的并发问题。当然,Linux的pthread原生线程库也提供了读写锁,pthread_rwlock_t就是读写锁的类型:
这里的pthread_rwlock_rdlock()指的是申请读锁。
pthread_rwlock_wrlock()指的是申请写锁。
读锁、写锁统一使用pthread_rwlock_unlock()解锁。
此时就会产生一个问题,那就是读者与写者之间存在互斥、写者与写者之间存在互斥,那么它们两者之间直接竞争一把锁不久行了吗?为什么需要读、写两把锁?此时我们便要从伪代码的角度去分析读写锁的功能:
可以看到读锁的作用就是为了保护里面的一个计数器,这个计数器用来表示当前有多少读者正在读取数据,这个计数器被多个读者之间共享、操作,所以它需要被保护;当读者申请到读锁的时候,证明当前读者可以读取数据,那么就让计数器++,如果当前读者是第一个读者,那么此时写者就不能写数据,这就是读者与写者之间的互斥关系;当读者读取数据之间释放了读锁,这就说明读者与读者之间不存在互斥关系;最后,当解锁读锁时,计数器会--,直到最后一个读者退出,此时写者可以写数据。
上面的伪代码是读者优先模式,读写锁的默认方式就是读者优先,因为读者优先,就有可能导致写者的饥饿问题(至少有一个读者永不退出)。事实上,写者优先模式不可能实现,但是Linux还是支持了这种模式的设置,但是会存在其他问题:
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 写者优先,但写者不能递归加锁
*/
CPP 复制 全屏
既然这篇博客作为补充章节去进行介绍,所以有关读者写者的代码就不展示。事实上也没什么展示的,因为它的实验现象并不是很明显,我们只需要记住读者写者的使用的场景就行了。