linux:线程池

1.基础认识

(1)线程池是什么,池化的意义是什么?

线程池是一个基于生产者消费者模型的任务派发管理,我们先初始化一堆的线程,然后通过生产者端输入任务,再将任务分配给唤醒了的线程去执行

池化其实就是将多次的系统调用变为一次的系统调用,比如线程的创建,一次创建一个线程,需要多次调用系统调用,一次创建一堆的线程,只需要调用一次系统调用

(2)日志是什么?
计算机中的⽇志是记录系统和软件运⾏中发⽣事件的⽂件,主要作⽤是监控运⾏状态、记录异常信息,帮助快速定位问题并⽀持程序员进⾏问题修复
我们即将实现的日志的格式:

可读性很好的时间\] \[⽇志等级\] \[进程pid\] \[打印对应⽇志的⽂件名\]\[⾏号\] - 消息内容

2.日志代码编写

设计模式:日志与策略模式

主要实现功能:

1.确认刷新策略(显示器刷新,文件刷新)

2.获取日志格式所需要的所有内容并组合起来

2.1刷新功能实现

结构:

基类:LogStrategy

子类:FileLogStrategy/ConsoleLogStrategy

创建思路:因为有两种刷新策略,且两种策略具有共同点,所以可以定义一个基类,让具体刷新策略直接继承,然后对部分方法进行重写

(1) LogStrategy

cpp 复制代码
class LogStrategy // 刷新策略
{
public:
    virtual ~LogStrategy() = default;
    virtual void synclog(const std::string &logmessage) = 0;
};

析构函数:定义为普通虚函数,子类可以直接继承,不一定要重写,不重写视为直接使用基类的析构

synclog接口:是纯虚函数,所以子类必须重写

(2)ConsoleLogStrategy

cpp 复制代码
class 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

cpp 复制代码
const 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)

这个接口是和内部类的获取外部参数联动的,在内部类中解析


内部类单独解析:

cpp 复制代码
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;      // 提供刷新策略
    };

内部类的主要功能就是收集日志信息,然后根据对应策略进行打印

故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)线程池创建:构造函数

cpp 复制代码
 ThreadPool(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隐藏起来

cpp 复制代码
auto f = std::bind(&Routine,this);
thread t(f,threadname);

bind的本质就是将原本带参数的函数封装起来,返回一个不带指定参数的函数

方法二:使用lambda表达式

cpp 复制代码
thread t([this](){
this->Routine();
},threadname);
_threads.push_back(t);

线程t初始化使用的是无参无返回值的lambda,lambda使用参数捕捉this,然后在表达式内部调用线程实际执行的函数Routine,这样子就既可以满足传递要求,又可以完成Routine的使用

优化:

我们直接使用emplace_back就不用再额外创建一个线程对象了,直接传递线程的参数即可

(2)线程池启动:Start接口

cpp 复制代码
 void Start() // 启动线程池
    {
        if (_isrunning)
            return;
        _isrunning = true;
        for (auto &e : _threads) // 启动各个线程
        {
            e.Start();
        }
        LOG(loglevel::INFO) << "线程池启动成功";
    }

首先对运行状态进行判断,若线程池处于运行状态就直接退出,否则就调用线程的启动接口,将所有线程启动(这里才真正的创建了线程,前面只是执行了线程的构造)

(3)线程池任务录入:Enqueue接口

cpp 复制代码
 void Enqueue(const T &t)
    {
        {
            MutexGuard lockguard(_lock);
            if(!_isrunning)
            return;
            _q.push(t);
            if (_threadwaitnum > 0)
            {
                _cond.notifyone();
            }
        }
    }

由于线程池的任务队列是一个整体使用的共享资源,所以我们需要使用锁来进行共享资源的保护,在确认线程池启动后,将传递进来的任务插入队列,然后对休眠线程进行唤醒

注意:线程池是一个基于生产者消费者模型的业务场景,它属于单生产者多消费者模型,且临界资源当成整体使用

而这里的队列可以自动扩容,所以生产者端无需添加信号量,消费者端也无需使用,只要使用条件变量进行休眠控制即可

逻辑如下:

消费者进行Routine执行前先判断队列是否为空,若为空进入休眠,直到被生产者唤醒。

生产者只要生产了任务就执行一次单线程唤醒

(4)任务分配与执行:Routine

cpp 复制代码
 bool 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接口

cpp 复制代码
  void Stop()
    {
        if (!_isrunning)
            return;
        _isrunning = false;
        if(_threadwaitnum)
        _cond.notifyall();
    }

将线程池运行状态改为false,让所有线程都唤醒

由于线程池发出退出信号时,线程只有三种情况

1.被唤醒,任务队列为空:直接退出

2.被唤醒,任务队列不为空:执行完所有任务后退出

3.没休眠,在执行任务:执行完所有任务后退出

其中第二第三种情况最后都会转换成第一种情况

(6)线程池回收等待:Wait接口

cpp 复制代码
  void Wait()
    {
        for (auto &e : _threads)
        {
            e.Join();
        }
        LOG(loglevel::INFO) << "线程池回收成功";
    }

直接将所有线程都Join回收即可

4.线程池(单例模式)

设计模式:单例模式

单例模式指的是一个类只能实例化一个对象的设计场景(比如一个钥匙只能对应一个锁)

饿汉方式实现:吃完饭马上洗完,后面吃饭的时候就可以快速吃

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

定义了一个类内的static变量,所以虽然他是类内的,可是加载到虚拟地址空间的全局数据区,也就是说在对象还没创建使用的时候就已经初始化好了

懒汉方式实现:吃完饭先不洗,等到再要吃的时候再洗(优化程序启动速度)

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

这里初始化的就不是一个静态变量,而是一个指向静态变量的指针,所以在程序加载的时候不用初始化变量,只有在运行的时候才会创建变量本身。这就是优化程序启动速度的原因


懒汉模式实现线程池单例模式:

1.将构造函数私有化,且禁用拷贝构造和赋值

做法:将public移动到构造函数后面,让构造函数是一个private函数,然后禁用拷贝构造和赋值

cpp 复制代码
ThreadPool<T> &operator=(const ThreadPool<T>&) = delete;
    ThreadPool(const ThreadPool<T>&) = delete;

这是为了保证一个单例模式类只能有一个实例化对象

2.用户层面需要类内有一个static方法可以获取实例化对象

由于用户不可以自己创建对象,所以对象的创建必须由类内方法进行

而一个非static的类内方法,在没有对象来调用的前提下,是无法使用的,而static的类内方法可以直接通过类名访问(eg:classname::function())

且用户要获得类内创建的对象指针,该指针就必须是static的

故代码如下:

cpp 复制代码
static 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;

但是还会有一个问题,如果由多个线程都要申请线程池怎么办?

此时会出现线程池并发问题,可能会同时申请出多个线程池,所以为了避免这种情况,我们需要给申请过程加锁

cpp 复制代码
   static ThreadPool<T> *Getinstance()
    {
        {
            MutexGuard lockguard(_singalton_lock);
            if (!_instance)
            {
                _instance = new ThreadPool<T>();
                _instance->Start();
            }
        }
        return _instance;
    }

这样就保证了线程池是单例模式的,可是每次进入都要申请锁,对于已经申请过线程池的线程来说是否会麻烦了?

于是我们可以在加锁代码进入之前再添加一个是否有锁的判断

cpp 复制代码
static 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进行了引用计数的原子化操作

相关推荐
lsx2024062 小时前
Kotlin 继承
开发语言
虫小宝2 小时前
返利软件架构设计:多平台适配的抽象工厂模式实践
java·开发语言·抽象工厂模式
写代码的【黑咖啡】2 小时前
深入理解 Python 中的函数
开发语言·python
Studying 开龙wu2 小时前
Linux 系统中配置国内源下载时使用pip install 和conda install哪个快?
linux·conda·pip
想学后端的前端工程师2 小时前
【Java设计模式实战应用指南:23种设计模式详解】
java·开发语言·设计模式
呱呱巨基2 小时前
Linux 进程控制
linux·c++·笔记·学习
小白勇闯网安圈2 小时前
Java的集合
java·开发语言
渣渣盟3 小时前
网络命令大全:轻松解决网络故障
开发语言·php