1.基础认识
(1)线程池是什么,池化的意义是什么?
线程池是一个基于生产者消费者模型的任务派发管理,我们先初始化一堆的线程,然后通过生产者端输入任务,再将任务分配给唤醒了的线程去执行
池化其实就是将多次的系统调用变为一次的系统调用,比如线程的创建,一次创建一个线程,需要多次调用系统调用,一次创建一堆的线程,只需要调用一次系统调用
(2)日志是什么?
计算机中的⽇志是记录系统和软件运⾏中发⽣事件的⽂件,主要作⽤是监控运⾏状态、记录异常信息,帮助快速定位问题并⽀持程序员进⾏问题修复
我们即将实现的日志的格式:可读性很好的时间\] \[⽇志等级\] \[进程pid\] \[打印对应⽇志的⽂件名\]\[⾏号\] - 消息内容
2.日志代码编写
设计模式:日志与策略模式
主要实现功能:
1.确认刷新策略(显示器刷新,文件刷新)
2.获取日志格式所需要的所有内容并组合起来
2.1刷新功能实现
结构:
基类:LogStrategy
子类:FileLogStrategy/ConsoleLogStrategy
创建思路:因为有两种刷新策略,且两种策略具有共同点,所以可以定义一个基类,让具体刷新策略直接继承,然后对部分方法进行重写
(1) LogStrategy
cppclass LogStrategy // 刷新策略 { public: virtual ~LogStrategy() = default; virtual void synclog(const std::string &logmessage) = 0; };析构函数:定义为普通虚函数,子类可以直接继承,不一定要重写,不重写视为直接使用基类的析构
synclog接口:是纯虚函数,所以子类必须重写
(2)ConsoleLogStrategy
cppclass ConsoleLogStrategy : public LogStrategy // 显示器刷新策略 { public: void synclog(const std::string &logmessage) override { MutexGuard lockguard(_lock); std::cout << logmessage << std::endl; } private: Mutex _lock; };**synclog接口重写:**这是打印接口,所以我们直接往显示器打印日志信息logmessage
不过由于显示器是一个整体使用的共享资源,所以我们直接使用锁进行资源保护
疑问:为什么这里不用写构造函数?Mutex会被正确创建吗?
如果我们不写构造函数,那么该类会直接使用基类的构造,若基类的构造也是默认构造,就直接使用默认构造,且会对所有成员变量调用他们各自的默认构造。故Mutex会调用它自己的默认构造,可以成功创建
(3)FileLogStrategy
cppconst std::string defaultdir = "."; const std::string defaultfilename = "test.log"; class FileLogStrategy : public LogStrategy { public: FileLogStrategy(const std::string &dir = defaultdir , const std::string &filename = defaultfilename) : _dir_path_name(dir), _filename(filename) { MutexGuard lockguard(_lock); // 加锁 if (std::filesystem::exists(_dir_path_name)) { return; } try { std::filesystem::create_directories(_dir_path_name); } catch (std::filesystem::filesystem_error &err) { std::cerr << err.what() << std::endl; } } void synclog(const std::string &logmessage) { // 文件名拼接 std::string target = _dir_path_name; target += "/"; target += _filename; // 打开文件流 std::ofstream out(target.c_str(), std::ios::app); if (!out.is_open()) { return; } out << logmessage << "\n"; out.close(); } private: std::string _dir_path_name; // 目录路径名 std::string _filename; // 文件名 Mutex _lock; };由于是往文件中进行日志打印,所以我们的属性中还需要添加目录路径,目标文件名等变量
**构造函数:**接收目录路径,文件名,并根据目录路径创建目录,且由于是在资源共享区域进行创建,构造函数的创建目录代码也需要加锁变为临界区
**synclog接口:**先对路径做结合操作,把文件绝对路径构建出来,然后根据文件绝对路径创建一个文件流out(使用起来和标准输入输出流一样),权限设置为app(追加)
将日志信息追加到out文件流中,追加完成再关闭文件流
2.2日志内容获取
可读性很好的时间\] \[⽇志等级\] \[进程pid\] \[打印对应⽇志的⽂件名\]\[⾏号\] - 消息内容 **(1)时间获取** ```cpp // 时间获取 std::string GetCurrentTime() { // 获取时间戳 time_t curtime = time(nullptr); // 将时间戳转换为年月日时分秒 struct tm currtm; localtime_r(&curtime, &currtm); // 使用可重入版本 // 将其转换为字符串形式 char timebuff[64]; snprintf(timebuff, sizeof(timebuff), "%4d-%02d-%02d %02d:%02d:%02d", currtm.tm_year + 1900 , currtm.tm_mon + 1 , currtm.tm_mday , currtm.tm_hour , currtm.tm_min , currtm.tm_sec); // 年份默认减去1900,所以要加上1900,月份默认是0-11,所以要加一 return timebuff; } ``` 时间获取最终的返回值是一个格式良好的记录年月日-时分秒的字符串 第一步:利用time获取时间戳 第二步:将时间戳转化为年月日时分秒,我们可以利用接口localtime_r,r表示函数可重入 (参数1为时间戳,参数2为时间戳转换后的tm结构体对象) 第三步:使用snprintf将内容格式化输出给指定字符数组timebuff,其中需要利用tm结构体的接口将年月日时分秒以整型类型从currtm中提取出来 **注意:** tm结构体接口中提供的年份是实际年份减去1900,月份是从0\~11的,故年份最终输出需要加上1900,月份则要加1 **(2)日志等级** ```cpp // 日志等级 enum class loglevel { DEBUG, INFO, WARNING, ERROR, FATAL }; std::string leveltostring(loglevel level) { switch (level) { case loglevel::DEBUG: return "Debug"; case loglevel::ERROR: return "ERROR"; case loglevel::FATAL: return "FATAL"; case loglevel::INFO: return "Info"; case loglevel::WARNING: return "Warning"; default: return "Unknow"; } } ``` 日志等级一共有五种,我们先将他们定义为枚举对象,然后再写一个日志等级转换字符串接口,具体的实现方法就是利用switch语句,根据枚举对象的值分别返回对应的日志等级字符串 **(3)剩余日志内容** 不需要特殊接口,直接可以获取 其中进程id使用getpid,当前文件名使用__FILE__预定义宏,当前文件行号用__LINE__ **(4)剩余内容拼接** 在日志类中实现
2.3日志类
cpp// 日志类:包含刷新与内容获取 class Logger { public: Logger() { } ~Logger() {} void EnableConsoleLogStrategy() { _strategy = std::make_unique<ConsoleLogStrategy>(); } void EnableFileLogStrategy() { _strategy = std::make_unique<FileLogStrategy>(); } class Logmessage // 负责内容获取 { public: Logmessage(loglevel level, std::string filename, int line, Logger &logger) : _currtime(GetCurrentTime()), _level(level) , _pid(getpid()), _filename(filename) , _line(line), _logger(logger) { std::stringstream ss; // 将日志内容写到字符串流中 ss << "[" << _currtime << "] " << "[" << leveltostring(_level) << "] " << "[" << _pid << "] " << "[" << _filename << "] " << "[" << _line << "] " << "- "; _loginfo = ss.str(); } // 重载输出操作,将后续语句拼接 template <typename T> Logmessage &operator<<(const T &info) { std::stringstream ss; ss << info; _loginfo += ss.str(); return *this; } ~Logmessage() { if (_logger._strategy) _logger._strategy->synclog(_loginfo); } private: std::string _currtime; loglevel _level; pid_t _pid; std::string _filename; int _line; std::string _loginfo; // 完整日志信息 Logger &_logger; // 提供刷新策略 }; // 构造出可以使用<<的临时对象 Logmessage operator()(loglevel level, std::string filename, int line) { return Logmessage(level, filename, line, *this); } private: std::unique_ptr<LogStrategy> _strategy; };结构:
外部类Logger负责调用对应刷新策略
内部类Logmessage负责将日志内容整合好,并以字符串形式打印
疑问:为什么要分成两个类?
因为这样可以让操作进行解耦,外部类可以不断设置各种刷新策略,对于不同的刷新策略,内部类不需要任何修改。如果合成一个类,那么每次增加一种刷新策略,整个类就要多修改一大段代码
外部类单独解析:
cpp// 日志类:包含刷新与内容获取 class Logger { public: Logger() { } ~Logger() {} void EnableConsoleLogStrategy() { _strategy = std::make_unique<ConsoleLogStrategy>(); } void EnableFileLogStrategy() { _strategy = std::make_unique<FileLogStrategy>(); } // 构造出可以使用<<的临时对象 Logmessage operator()(loglevel level, std::string filename, int line) { return Logmessage(level, filename, line, *this); } private: std::unique_ptr<LogStrategy> _strategy; };由于外部类只需要确定刷新策略,所以我们定义两个接口
接口1:EnableConsoleLogStrategy
显示器刷新策略,创建一个ConsoleLogStrategy类型对象,并将该对象的地址传递给智能指针_strategy
接口2:EnableFileLogStrategy
文件刷新策略,同样创建一个FileLogStrategy类型对象,并传递该对象地址给智能指针_strategy
接口3:Logmessage operator()(loglevel level, std::string filename, int line)
这个接口是和内部类的获取外部参数联动的,在内部类中解析
内部类单独解析:
cppclass Logmessage // 负责内容获取 { public: Logmessage(loglevel level, std::string filename, int line, Logger &logger) : _currtime(GetCurrentTime()), _level(level) , _pid(getpid()), _filename(filename) , _line(line), _logger(logger) { std::stringstream ss; // 将日志内容写到字符串流中 ss << "[" << _currtime << "] " << "[" << leveltostring(_level) << "] " << "[" << _pid << "] " << "[" << _filename << "] " << "[" << _line << "] " << "- "; _loginfo = ss.str(); } // 重载输出操作,将后续语句拼接 template <typename T> Logmessage &operator<<(const T &info) { std::stringstream ss; ss << info; _loginfo += ss.str(); return *this; } ~Logmessage()//打印 { if (_logger._strategy) _logger._strategy->synclog(_loginfo); } private: std::string _currtime; loglevel _level; pid_t _pid; std::string _filename; int _line; std::string _loginfo; // 完整日志信息 Logger &_logger; // 提供刷新策略 };内部类的主要功能就是收集日志信息,然后根据对应策略进行打印
故private成员变量:
(1)日志各部分的变量
(2)整合完成的日志信息
(3)外部类对象(利用确定的刷新策略刷新)
故public接口:
(1)构造函数:负责构造初级的日志信息(不包含用户自输入内容)根据我们前面讲解的方法进行日志信息相关成员变量初始化,然后创建字符串流ss,将日志信息按照对应格式输入到字符串流ss中,并利用ss给_loginfo进行赋值
(2)<<运算符重载:负责将用户自输入内容追加到日志信息字符串中
首先,由于用户输入的内容是类型未知的,所以我们需要使用模板类型
然后直接定义一个字符串流,将用户输入信息info输入字符串流中,输入完成将其追加到_loginfo
疑问1:虽然此时我们就完成了一次内容追加,可是如何将若干个内容都追加?
我们需要利用临时对象调用<<,因为临时对象的生命周期是整个语句结束的时候,所以我们可以每次执行完<<就返回对象的引用,那么他又会自动的调用下一个<<,直到最后的<<都被调用完成,此时临时对象才会销毁。
这又叫临时对象的流式编程
疑问2:那么我们如何保证对象是临时对象?
cpp// 构造出可以使用<<的临时对象 Logmessage operator()(loglevel level, std::string filename, int line) { return Logmessage(level, filename, line, *this); }这就需要用到前面提到的外部类中的()运算符重载,在该运算符重载函数中,我们直接将临时创建的Logmessage对象返回,那么第一个接触<<的就是Logmessage的临时对象了
(3)析构函数:负责根据指定刷新策略进行日志信息打印
先判断是否指定了策略,若策略已经确定,直接根据策略调用对应synclog接口进行日志信息打印
3.线程池代码编写
包含的自定义头文件:
1.mutex.hpp(锁)
2.cond.hpp(条件变量)
3.thread.hpp(线程)
4.logger.hpp(日志)
线程池功能模块:
1.线程池创建:构造函数
2.线程池启动:Start接口
3.线程池任务录入:Enqueue接口
4.线程池终止:Stop接口
5.线程池线程等待:Wait接口
6.线程任务分配与执行:Routine接口
线程池成员变量:
1.队列:_q
2.线程数组:_threads
3.线程数量:_threadnum
4.锁:_lock
5.条件变量:_cond
6.线程池启动状态:_isrunning
7.等待中线程个数:_threadwaitnum
执行逻辑:
创建线程池对象(完成线程池创建)->启动线程池
->数据(任务)录入->数据(任务)分配与执行->线程池终止->线程等待
(1)线程池创建:构造函数
cppThreadPool(int threadnum = defaultthreadnum) : _isrunning(false), _threadnum(threadnum) , _threadwaitnum(0) { for (int i = 1; i <= _threadnum; i++) { std::string threadname = "thread-" + std::to_string(i); // //方法1 // auto f = std::bind(&Routine,this); // thread t(f,threadname); // 方法2 // thread t([this](){ // this->sharedfunc(); // },threadname); // _threads.push_back(t); // 方法二优化 _threads.emplace_back([this](const std::string &name) { this->Routine(name); }, threadname); } LOG(loglevel::INFO) << "线程池创建成功"; }1.初始化列表:只初始化非结构体/类类型变量即可,结构体类型变量可以直接使用默认构造
**运行状态设置为false:**因为线程池的创建和运行是分离的,创建了不意味着就要运行
**线程数设置为传入的线程数/默认数:**线程池的线程数是有用户自定义的
**线程等待数设置为0:**因为线程池还没运行,不可能有线程在等待
2.根据线程池线程设置个数创建若干个线程,并将其放入线程数组中进行存储
**难点:**线程的参数是线程执行的函数Routine,可是Routine是一个定义在类内的接口(默认有一个参数为this指针),而我们的线程执行函数必须是一个参数为0的函数,怎么解决?
方法一:使用bind将this隐藏起来
cppauto f = std::bind(&Routine,this); thread t(f,threadname);bind的本质就是将原本带参数的函数封装起来,返回一个不带指定参数的函数
方法二:使用lambda表达式
cppthread t([this](){ this->Routine(); },threadname); _threads.push_back(t);线程t初始化使用的是无参无返回值的lambda,lambda使用参数捕捉this,然后在表达式内部调用线程实际执行的函数Routine,这样子就既可以满足传递要求,又可以完成Routine的使用
优化:
我们直接使用emplace_back就不用再额外创建一个线程对象了,直接传递线程的参数即可
(2)线程池启动:Start接口
cppvoid Start() // 启动线程池 { if (_isrunning) return; _isrunning = true; for (auto &e : _threads) // 启动各个线程 { e.Start(); } LOG(loglevel::INFO) << "线程池启动成功"; }首先对运行状态进行判断,若线程池处于运行状态就直接退出,否则就调用线程的启动接口,将所有线程启动(这里才真正的创建了线程,前面只是执行了线程的构造)
(3)线程池任务录入:Enqueue接口
cppvoid Enqueue(const T &t) { { MutexGuard lockguard(_lock); if(!_isrunning) return; _q.push(t); if (_threadwaitnum > 0) { _cond.notifyone(); } } }由于线程池的任务队列是一个整体使用的共享资源,所以我们需要使用锁来进行共享资源的保护,在确认线程池启动后,将传递进来的任务插入队列,然后对休眠线程进行唤醒
注意:线程池是一个基于生产者消费者模型的业务场景,它属于单生产者多消费者模型,且临界资源当成整体使用
而这里的队列可以自动扩容,所以生产者端无需添加信号量,消费者端也无需使用,只要使用条件变量进行休眠控制即可
逻辑如下:
消费者进行Routine执行前先判断队列是否为空,若为空进入休眠,直到被生产者唤醒。
生产者只要生产了任务就执行一次单线程唤醒
(4)任务分配与执行:Routine
cppbool Isempty() { return _q.empty(); } void Routine(const std::string &name)//任务分配接口 { // // 所有线程都要调用的方法 // LOG(loglevel::INFO) << name << "线程执行函数执行成功"; // sleep(1); while (true) { T t; { // 将任务从临界资源中转移为线程私有资源 MutexGuard lockguard(_lock); while (Isempty()&&_isrunning) { _threadwaitnum++; _cond.wait(_lock); _threadwaitnum--; } if(!_isrunning && Isempty()) { LOG(loglevel::INFO) << "线程池发出退出信号,且任务队列为空,线程" << name <<"退出"; break; } t = _q.front(); _q.pop(); } t(); LOG(loglevel::INFO) << name << "执行任务:" << t.Print(); } }首先一定要设置为死循环,以保证线程可以不断的进行执行任务->休眠->执行任务的周期
1.当任务队列为空且线程池并未发出退出信息
进行线程休眠,休眠前让_threadwaitnum++,唤醒后让_threadwaitnum--
疑问:为什么设置为while?
为了防止伪唤醒导致代码错误
2.当任务队列为空,且线程池发出退出信息
线程结束运行,直接退出循环
3.线程正常执行接口
将任务从队列中取出并执行
(5)线程池终止:Stop接口
cppvoid Stop() { if (!_isrunning) return; _isrunning = false; if(_threadwaitnum) _cond.notifyall(); }将线程池运行状态改为false,让所有线程都唤醒
由于线程池发出退出信号时,线程只有三种情况
1.被唤醒,任务队列为空:直接退出
2.被唤醒,任务队列不为空:执行完所有任务后退出
3.没休眠,在执行任务:执行完所有任务后退出
其中第二第三种情况最后都会转换成第一种情况
(6)线程池回收等待:Wait接口
cppvoid Wait() { for (auto &e : _threads) { e.Join(); } LOG(loglevel::INFO) << "线程池回收成功"; }直接将所有线程都Join回收即可
4.线程池(单例模式)
设计模式:单例模式
单例模式指的是一个类只能实例化一个对象的设计场景(比如一个钥匙只能对应一个锁)
饿汉方式实现:吃完饭马上洗完,后面吃饭的时候就可以快速吃
cpptemplate <typename T> class Singleton { static T data; public: static T* GetInstance() { return &data; } };定义了一个类内的static变量,所以虽然他是类内的,可是加载到虚拟地址空间的全局数据区,也就是说在对象还没创建使用的时候就已经初始化好了
懒汉方式实现:吃完饭先不洗,等到再要吃的时候再洗(优化程序启动速度)
cpptemplate <typename T> class Singleton { static T* inst; public: static T* GetInstance() { if (inst == NULL) { inst = new T(); } return inst; } };这里初始化的就不是一个静态变量,而是一个指向静态变量的指针,所以在程序加载的时候不用初始化变量,只有在运行的时候才会创建变量本身。这就是优化程序启动速度的原因
懒汉模式实现线程池单例模式:
1.将构造函数私有化,且禁用拷贝构造和赋值
做法:将public移动到构造函数后面,让构造函数是一个private函数,然后禁用拷贝构造和赋值
cppThreadPool<T> &operator=(const ThreadPool<T>&) = delete; ThreadPool(const ThreadPool<T>&) = delete;这是为了保证一个单例模式类只能有一个实例化对象
2.用户层面需要类内有一个static方法可以获取实例化对象
由于用户不可以自己创建对象,所以对象的创建必须由类内方法进行
而一个非static的类内方法,在没有对象来调用的前提下,是无法使用的,而static的类内方法可以直接通过类名访问(eg:classname::function())
且用户要获得类内创建的对象指针,该指针就必须是static的
故代码如下:
cppstatic ThreadPool<T>* Getinstance() { if(!_instance) { _instance = new ThreadPool<T>(); _instance->Start(); } return _instance; } ....... ....... static ThreadPool<T> *_instance; }; template<typename T> ThreadPool<T>* ThreadPool<T>::_instance = nullptr;但是还会有一个问题,如果由多个线程都要申请线程池怎么办?
此时会出现线程池并发问题,可能会同时申请出多个线程池,所以为了避免这种情况,我们需要给申请过程加锁
cppstatic ThreadPool<T> *Getinstance() { { MutexGuard lockguard(_singalton_lock); if (!_instance) { _instance = new ThreadPool<T>(); _instance->Start(); } } return _instance; }这样就保证了线程池是单例模式的,可是每次进入都要申请锁,对于已经申请过线程池的线程来说是否会麻烦了?
于是我们可以在加锁代码进入之前再添加一个是否有锁的判断
cppstatic ThreadPool<T> *Getinstance() { if (!_instance) { MutexGuard lockguard(_singalton_lock); if (!_instance) { _instance = new ThreadPool<T>(); _instance->Start(); } } return _instance; }
5.线程补充概念
线程安全与重入函数:
一个可重入函数对应的线程执行是安全的,一个不可重入函数对应的线程是不安全的
注意:线程安全的不一定可重入
eg:一个加了锁的临界资源函数,是线程安全的,但是如果他被打断去执行一个申请当前锁的函数,他就会被阻塞住,因为锁已经被自己之前申请过了,永远不会释放,这样就形成了单线程死锁
常见锁概念:
1.常见死锁情况:
一组线程中,线程皆占有一段不被释放的资源,但因互相申请线程不会释放的资源而导致的永久等待状态
eg:占用了锁a的线程A和占用了锁b的线程B想要互相申请对方的锁
四个必要条件:
(1)互斥条件
一个锁内资源一次只能被一个线程执行
(2)请求与保持条件一个线程既要保护自己所占用的资源,又要申请另一个线程的资源
(3)不剥夺条件
一个线程在执行完成前不会被强制剥夺资源
(4)循环等待条件
线程之间的请求是构成循环的,不会申请其他的线程的临界资源
解决方法:破坏任意个必要条条件
破坏1:不加锁就可以让互斥条件不满足
破坏2:在申请其他锁失败的时候,将当前持有的锁都释放掉,然后继续申请其他锁
破坏3:在申请失败的时候直接剥夺指定的锁
破坏4:一次性申请多个锁,直接避免出现循环等待问题
注意:
1.stl容器设计时为了保证最大效率,没有进行加锁,需要程序员自己进行共享资源保护
2.智能指针是线程安全的,对sharedptr进行了引用计数的原子化操作