【Linux】日志与策略模式、线程池

在了解了线程的基本概念和线程互斥与同步之后,我们可以以此设计一个简单的线程池。【Linux】线程-CSDN博客

【Linux】线程同步与互斥-CSDN博客

线程池也是一种池化技术。提前申请一些线程,等待有任务时就直接让线程去执行,不用再收到任务之后再创建线程。

一.日志设计

以往,我们多线程在向显示器打印信息时,会出现信息混杂的现象。这是因为多线程向显示器打印信息时,显示器是一种临界资源。访问临界资源应该对其进行保护,否则就会出现数据不一致。为了解决该现象,我们可以设计处一个日志类,打印信息时都使用该类,所以,我们得保证该类打印信息是原子的。

1.策略模式

策略模式(Strategy Pattern)是一种行为设计模式,它使你能在运行时改变对象的行为。其主要思想是将算法或行为封装到独立的类中,这些类称为策略类。上下文类(Context)使用策略类来执行特定的算法或行为,而客户端可以根据需要选择不同的策略。

我们可以根据策略模式设计出不同的刷新策略,比如向显示器刷新,或者向指定路径的指定文件刷新。

而策略模式的具体实现方式就是先实现一个策略类,里面包含了一个虚函数,该虚函数是未来要执行的行为或者算法。

然后我们再通过继承的方式具体的实现某一个种策略。

cpp 复制代码
namespace MyLog
{
    using namespace MutexModule;

#define gap "\r\n"
    // 策略模式------刷新策略
    // 虚基类
    class logstrategy
    {
    public:
        ~logstrategy() = default;
        virtual void synclog(const std::string &message) = 0;
    };

    // 刷新策略1--->向显示器刷新
    class consolelogstrategy : public logstrategy
    {
    public:
        ~consolelogstrategy() {}
        void synclog(const std::string &message) override
        {
            // 向显示器刷新需要加锁
            mutexguard lock(_mutex);
            std::cout << message << gap;
        }

    private:
        Mutex _mutex;
    };

    // 刷新策略2--->向指定文件里刷新
    const std::string defaultPath = "./log";
    const std::string defaultName = "log.log";

    class filelogstrategy : public logstrategy
    {
    public:
        filelogstrategy(const std::string &path = defaultPath, const std::string &name = defaultName)
            : _path(path),
              _file(name)
        {
            // 指定路径存在,直接返回;不存在,创建路径
            if (std::filesystem::exists(_path))
            {
                return;
            }
            try
            {
                std::filesystem::create_directories(_path);
            }
            catch (const std::filesystem::filesystem_error &e)
            {
                std::cerr << e.what() << '\n';
            }
        }

        ~filelogstrategy() {}

        void synclog(const std::string &message) override
        {
            // 向指定文件里打印, 向指定文件里面打印也得是原子的,得加锁
            mutexguard lock(_mutex);

            // 拼接路径+文件名
            std::string filename = _path + (_path.back() == '/' ? "" : "/") + _file;

            // 向指定文件里面以追加方式写入
            std::ofstream out(filename, std::ios::app);
            out << message << gap;

            out.close();
        }

    private:
        std::string _path;
        std::string _file;
        Mutex _mutex;
    };
}

说明:我们实现了两种刷新策略,向显示器刷新、向指定路径的指定文件刷新。但不论哪种刷新方式,我们都得保证是原子的,即任意时刻只能有一个线程刷新,这样就不会产生数据混杂的情况。所以,这里我们实现原子性的方法是借助互斥锁。

2.日志类

有了刷新策略之后,下一步便是处理日志的具体内容了。这里我们期望打印出来的日志包含以下信息:

[时间][日志等级][进程pid][文件名][行号] - 日志正文

2025-5-4 10:05:48\]\[INFO\]\[828670\]\[thread.hpp\]\[38\]- create newthread-1 success \[2025-5-4 10:05:48\]\[INFO\]\[828670\]\[thread.hpp\]\[38\]- create newthread-2 success \[2025-5-4 10:05:48\]\[INFO\]\[828670\]\[thread.hpp\]\[38\]- create newthread-3 success \[2025-5-4 10:05:48\]\[INFO\]\[828670\]\[thread.hpp\]\[38\]- create newthread-4 success \[2025-5-4 10:05:48\]\[INFO\]\[828670\]\[thread.hpp\]\[38\]- create newthread-5 success

对于日志类来说,他首先得有自己的刷新策略,所以日志类包含一个成员那就是刷新策略,并且我们得指定默认的刷新策略:

cpp 复制代码
    class logger
    {
    public:
        logger()
        {
            // 默认使用显示器刷新策略
            UseConsoleStrategy();
        }
        ~logger() {}

        void UseConsoleStrategy() { _fflush_strategy = std::make_unique<consolelogstrategy>(); }
        void UseFileLogStrategy() { _fflush_strategy = std::make_unique<filelogstrategy>(); }

    private:
        std::unique_ptr<logstrategy> _fflush_strategy;
    }

有了刷新方式之后,我们下一步便是处理日志内容了。这里我们采取内部类的方式,实现日志内容的设计:

cpp 复制代码
    // 获取时间
    std::string GetTime()
    {
        // 1.获取当前的时间戳
        time_t cur_time = time(nullptr);

        // 2.将时间戳转化为年月日-时分秒
        struct tm format_time;
        localtime_r(&cur_time, &format_time);

        char time_buffer[128] = {0};
        snprintf(time_buffer, sizeof(time_buffer), "%d-%d-%d %d:%02d:%02d",
                 format_time.tm_year + 1900,
                 format_time.tm_mon + 1,
                 format_time.tm_mday,
                 format_time.tm_hour,
                 format_time.tm_min,
                 format_time.tm_sec);

        return time_buffer;
    }

    // 日志等级
    enum class loglevel
    {
        DEBUG,
        INFO,
        WARINING,
        ERROR,
        FATAL
    };

    // 获取日志等级
    std::string loglevelToString(loglevel level)
    {
        switch (level)
        {
        case loglevel::DEBUG:
            return "DEBUG";
        case loglevel::INFO:
            return "INFO";
        case loglevel::WARINING:
            return "WARNING";
        case loglevel::ERROR:
            return "ERROR";
        case loglevel::FATAL:
            return "FATAL";
        default:
            return "UNKNOEN";
        }
    } 



   // 内部类
        // 用来描述日志具体内容
        class logmessage
        {
        public:
            logmessage(loglevel &level, const std::string &name, int number, logger &logger)
                : _cur_time(GetTime()),
                  _log_level(level),
                  _file(name),
                  _line_number(number),
                  _pid(getpid()),
                  _logger(logger)
            {
                // 将格式化信息写入ss字符串流中
                std::stringstream ss;
                ss << "[" << _cur_time << "]"
                   << "[" << loglevelToString(_log_level) << "]"
                   << "[" << _pid << "]"
                   << "[" << _file << "]"
                   << "[" << _line_number << "]"
                   << "- ";

                // 从字符串中获取字符串
                _format_info = ss.str();
            }

            ~logmessage()
            {
                // 如果有刷新策略,在对象析构的时候进行刷新
                if (_logger._fflush_strategy)
                {
                    _logger._fflush_strategy->synclog(_format_info);
                }
            }

            // 日志的主要内容
            template <typename T>
            logmessage &operator<<(const T &message)
            {
                std::stringstream ss;
                ss << message;
                _format_info += ss.str();
                return *this;
            }

        private:
            std::string _cur_time;
            loglevel _log_level;
            pid_t _pid;
            std::string _file;
            int _line_number;
            std::string _format_info;
            logger &_logger;
        }; // end of logmessage 内部类,用来处理日志的格式化内容以及主要内容

有了以上内容,我们的日志类已经基本上实现了,但是我们还得再日志类中实现一个仿函数,该仿函数的返回值是内部类类型,有了内部类类型,我们就可以根据内部重载的<<运算符制作日志消息,最后在该内部类对象析构的时候进行刷新即可。所以我们在返回内部类对象时返回临时对象,并且不要接收,采取匿名的方式,这样它的声明周期就只有1行,该行结束就会自动刷新了。

cpp 复制代码
// logger类内成员
    public:
        logmessage operator()(loglevel level, const std::string &file, int line)
        {
            return logmessage(level, file, line, *this);
        }

为了方便使用,我们直接在命名空间中,定义一个全局的logger对象,使用日志类的时候,直接使用该全局对象。全局对象访问仿函数来实现日志的构成和打印。所以我们的调用方式就变为:

cpp 复制代码
Glogger(loglevel, filename, linenumber) << "xxx" << "xxx" << ...;

但是这样还是不太优雅,我们还得手动设置文件名和行号。我们可以使用宏来简化使用。

cpp 复制代码
#define LOG(level) Glogger(level, __FILE__, __LINE__)
#define USE_CONSOLE_STARATEGY Glogger.UseConsoleStrategy()
#define USE_FILE_LOG_STARATRGY Glogger.UseFileLogStrategy()

二.线程池

线程池作为一种池化技术,可以提前申请好资源,当数据或者任务到来时,直接去处理,不用在创建线程了。

设计方案:

  • 线程池要在创建的时候创建出多个线程,我们用数组将所有的线程管理起来。
  • 除了线程外,还得有任务,所以我们还得有一个任务队列。
  • 在处理任务时,和添加任务时都得是原子的,所以还得有互斥锁。
  • 当任务队列为空时,但线程池还没有结束,所以我们得让所有的线程等待,所以还得有条件变量。

在创建线程池的时候,直接在构造函数创建n个线程即可,因为创建线程需要指定执行的方法,所以我们实现一个handler方法,用来让创建出的线程去执行。

cpp 复制代码
ThreadPool(const int threads = defaultThreadSize)
            :_num(threads), _isRunning(true), _sleepernumber(0)
        {
            // 创建_num个线程
            for(int i=0; i<_num; i++){
                _threads.emplace_back(
                    [this](){
                        Handler();
                    }
                );
            }
        }

而对于handler方法来说,所有的线程都用从任务队列中获取任务,但任务队列作为临界资源,同一时刻只能有一个线程访问,所以我们必须得加锁。

但是还有一个问题,当线程池结束的时候,如果此时还有线程在等待,我们就应该叫醒它们,否则就会导致内存泄漏问题。所以,在判断线程需要等待时,需满足两个条件,线程池没有结束,并且没有任务,才需要等待,否则直接执行后面的代码。

在执行后面的代码时,我们需要判断线程池是否结束,如果结束了,并且没有任务,则直接让线程退出,否则执行完任务,在退出。

当线程拿到任务之后,就可以释放锁了,因为此时该任务已经属于该线程私有的了,如果再持有锁,就得等任务执行完才能获取下一个任务,导致效率底下。

cpp 复制代码
        void Handler()
        {
            // 获取线程名字
            char name[128] = { 0 };
            pthread_getname_np(pthread_self(), name, sizeof(name));

            // 从任务队列中获取任务
            while(true){
                T t;
                {
                    // 加锁访问任务队列,任意时刻只能有一个线程访问任务队列
                    mutexguard lock(_mutex);

                    // 当线程池终止了,但有可能还有线程再等待,此时已经没有任务,其他的线程都已经被回收了,这些线程会导致内存泄露
                    // 但是如果直接叫醒所有线程,它们不会退出循环,而是继续等待
                    // 所以在进行等待的时候,要判断线程池是否还在运行,如果已经结束,并且任务队列为空,则不需要等待
                    // 一个不满足,就必须等待
                    while(_isRunning && _taskManager.empty()){
                        // 任务队列为空,线程进行等待
                        _sleepernumber++;
                        _cond.Wait(_mutex);
                        _sleepernumber--;
                    }
                    // 当线程池已经终止了&&任务队列为空,就让线程结束
                    if(!_isRunning && _taskManager.empty()){
                        LOG(loglevel::INFO) << name << "退出";
                        break;
                    }

                    // 获取任务
                    t = _taskManager.front();
                    _taskManager.pop();
                }
                // 执行任务
                // 当一个线程加锁拿出任务后,这个任务已经从任务队列中消失了,只属于该线程私有,所以先解锁,再执行,提高效率。
                t();
            }
        }

添加任务也会访问临界资源任务队列,所以也得加锁,当然也得保证线程池还在运行,否则就不添加。并且,添加之后,就有任务了,我们判断此时是否有线程再等待,如果有,则唤醒,让其获取任务。

cpp 复制代码
// 向任务队列中新增任务
        bool emplace(const T& task)
        {
            // 任意时刻,都只允许只有一个线程插入任务
            mutexguard lock(_mutex);
            if(!_isRunning) return false;
            _taskManager.emplace(task);            
            if(_sleepernumber){
                WakeUpOne();
            }
            return true;
        }

        void WakeUpOne()
        {
            LOG(loglevel::INFO) << "唤醒一个线程";
            _cond.signal();
        }

我们还得有接口,让线程池停止。停止运行之后,如果还有任务就继续执行,没有任务了,就让线程退出。但因为有可能还有线程再等待,它们收不到任务了,如果还等待的化,就会导致内存泄露问题,所以,再停止线程池之后,我们需要唤醒所有的线程。

cpp 复制代码
        void WakeUpAll()
        {
            if(_sleepernumber){
                LOG(loglevel::INFO) << "唤醒所有线程";
                _cond.broadcast();
            }
        }

// 让线程池终止
        void Stop()
        {
            if(!_isRunning) return;
            _isRunning = false;
            LOG(loglevel::INFO) << "线程池已经被终止";

            //  线程池结束就让所有等待的线程苏醒,否则它们不会退出
            WakeUpAll();
        }

        // 回收线程
        void Join()
        {
            if(_isRunning) return;
            for(auto& thread : _threads){
                thread.Join();
            }
        }

有了以上接口,我们的线程池就可以运行起来了。但是,如果在内存中同时存在多个线程池的话,就会导致资源提前被申请,导致后面来的任务申请不到线程了。也有可能线程池很多,但处理的热任务很少,就会导致资源浪费问题。

所以,我们期望,线程池只能被实例化出一份,即内存中只允许有一个线程池。借此,我们来引出,单例模式线程池。

1.单例模式线程池

所谓单例模式,其实就是一个类只能实例化出一个对象。

而实现单例模式有两中方案:饿汉模式和懒汉模式。

  • 饿汉模式:在将代码加载到内存中时,就已经初始化了该对象
  • 懒汉模式:在代码加载到内存中时,只初始化一个该类对象的指针,并不具体实例化。当真正使用的时候,在进行实例化

在一个类比较大的时候,在加载的时候直接创建对象比较耗时

懒汉模式采用延时创建技术,就可以加快启动进程的时候

在内核中,我们使用malloc申请内存空间,其实就使用了懒汉模式,先给你虚拟地址空间,当你使用该虚拟地址空间的时候,再给你从内存中开辟,并构建映射关系

我们这里采取懒汉模式实现单例:

首先,单例模式只能实例化一个对象,所以我们不应该将构造、拷贝构造,赋值函数等暴露出来。我们在类内定义一个静态的该类对象的指针。因为静态对象是全局的,所以在代码加载到内存中时,他就已经被创建了,但因为我们创建的是指针,所以还没有真正意义上创建对象。

cpp 复制代码
static ThreadPool<T>* _inc;   // 未来实例化出的对象
static Mutex _sm;             // 用来实现单例模式

我们提供一个静态函数,用来初始化静态对象,初始化该静态对象一定得是原子的,要不然如果该函数被多线程同时访问,就有可能创建多个对象。

cpp 复制代码
        static ThreadPool<T>* Getinstance(int threadsize = defaultThreadSize)
        {
            LOG(loglevel::DEBUG) << "获取线程池单例...";
            if(!_inc){
                mutexguard lock(_sm);   
                if(!_inc){
                    LOG(loglevel::INFO) << "线程池单例创建....";
                    _inc = new ThreadPool<T>(threadsize);
                }
            }
            return _inc;
        }

我们这里采取双if判断,来提高获取单例的运行效率。如果没有外层的if,每一个线程都得先申请锁,然后再判断,申请锁的时候什么都做不了。就算我们单例创建好了,下一次还得申请锁,在判断。

所以我们额外添加一个if判断,单例还没有创建的时候确实没有变化,但对有已经有了单例来说,就可以让其他线程提前退出,获取到单例。

cpp 复制代码
#ifndef __ThreadPool__HPP__
#define __ThreadPool__HPP__

#include <iostream>
#include <queue>
#include "thread.hpp"
#include "log.hpp"
#include "mutex.hpp"
#include "cond.hpp"

namespace ThreadPoolModule
{
    using namespace MyThread;
    using namespace MutexModule;
    using namespace MyCond;
    using namespace MyLog;

    // 默认使用5个线程的线程池
    const int defaultThreadSize = 5;

    template <typename T>
    class ThreadPool
    {
    private:
        void WakeUpAll()
        {
            if(_sleepernumber){
                LOG(loglevel::INFO) << "唤醒所有线程";
                _cond.broadcast();
            }
        }

        void WakeUpOne()
        {
            LOG(loglevel::INFO) << "唤醒一个线程";
            _cond.signal();
        }
        
        // 同一时刻,内存中不需要存在多个线程池
        // 利用单例模式来控制该进程池只能实例化出一个对象:单例模型即一个类只能实例化一个对象
        // 单例模式有两种实现方式:饿汉模型和懒汉模式
        // 饿汉模式:在将代码加载到内存中时,就已经初始化了该对象
        // 懒汉模式:在代码加载到内存中时,只初始化一个该类对象的指针,并不具体实例化。当真正使用的时候,在进行实例化
        // 在一个类比较大的时候,在加载的时候直接创建对象比较耗时
        // 懒汉模式采用延时创建技术,就可以加快启动进程的时候
        // 在内核中,我们使用malloc申请内存空间,其实就使用了懒汉模式,先给你虚拟地址空间,当你使用该虚拟地址空间的时候,再给你从内存中开辟,并构建映射关系

        // 因为单例模式只能创建一个对象,所以不应该将类的构造,拷贝构造,赋值重载函数公开

        ThreadPool(const int threads = defaultThreadSize)
            :_num(threads), _isRunning(true), _sleepernumber(0)
        {
            // 创建_num个线程
            for(int i=0; i<_num; i++){
                _threads.emplace_back(
                    [this](){
                        Handler();
                    }
                );
            }
        }

        ThreadPool(const ThreadPool& tp) = delete;
        ThreadPool operator=(const ThreadPool& tp) = delete;
    public:
        // 有可能有多个执行流进入该函数,但是只能创建一个对象
        static ThreadPool<T>* Getinstance(int threadsize = defaultThreadSize)
        {
            LOG(loglevel::DEBUG) << "获取线程池单例...";
            if(!_inc){
                mutexguard lock(_sm);   
                if(!_inc){
                    LOG(loglevel::INFO) << "线程池单例创建....";
                    _inc = new ThreadPool<T>(threadsize);
                }
            }
            return _inc;
        }

        ~ThreadPool(){}

        void Handler()
        {
            // 获取线程名字
            char name[128] = { 0 };
            pthread_getname_np(pthread_self(), name, sizeof(name));

            // 从任务队列中获取任务
            while(true){
                T t;
                {
                    // 加锁访问任务队列,任意时刻只能有一个线程访问任务队列
                    mutexguard lock(_mutex);

                    // 当线程池终止了,但有可能还有线程再等待,此时已经没有任务,其他的线程都已经被回收了,这些线程会导致内存泄露
                    // 但是如果直接叫醒所有线程,它们不会退出循环,而是继续等待
                    // 所以在进行等待的时候,要判断线程池是否还在运行,如果已经结束,并且任务队列为空,则不需要等待
                    // 一个不满足,就必须等待
                    while(_isRunning && _taskManager.empty()){
                        // 任务队列为空,线程进行等待
                        _sleepernumber++;
                        _cond.Wait(_mutex);
                        _sleepernumber--;
                    }
                    // 当线程池已经终止了&&任务队列为空,就让线程结束
                    if(!_isRunning && _taskManager.empty()){
                        LOG(loglevel::INFO) << name << "退出";
                        break;
                    }

                    // 获取任务
                    t = _taskManager.front();
                    _taskManager.pop();
                }
                // 执行任务
                // 当一个线程加锁拿出任务后,这个任务已经从任务队列中消失了,只属于该线程私有,所以先解锁,再执行,提高效率。
                t();
            }
        }

        // 让线程池终止
        void Stop()
        {
            if(!_isRunning) return;
            _isRunning = false;
            LOG(loglevel::INFO) << "线程池已经被终止";

            //  线程池结束就让所有等待的线程苏醒,否则它们不会退出
            WakeUpAll();
        }

        // 回收线程
        void Join()
        {
            if(_isRunning) return;
            for(auto& thread : _threads){
                thread.Join();
            }
        }

        // 向任务队列中新增任务
        bool emplace(const T& task)
        {
            // 任意时刻,都只允许只有一个线程插入任务
            mutexguard lock(_mutex);
            if(!_isRunning) return false;
            _taskManager.emplace(task);            
            if(_sleepernumber){
                WakeUpOne();
            }
            return true;
        }

    private:
        std::vector<Thread> _threads; // 线程池
        int _num;                     // 线程个数
        std::queue<T> _taskManager;   // 任务队列
        Mutex _mutex;                 // 互斥锁
        cond _cond;                   // 信号量

        bool _isRunning;              // 线程池是否运行
        int _sleepernumber;           // 当前等待的线程个数

        static ThreadPool<T>* _inc;   // 未来实例化出的对象
        static Mutex _sm;             // 用来实现单例模式
    };

    // 初始化静态成员
    template <typename T>
    ThreadPool<T>* ThreadPool<T>::_inc = nullptr;

    template <typename T>
    Mutex ThreadPool<T>::_sm;
}

#endif

三.重入和线程安全

如果函数是可重入的,那么它就是线程安全的。

线程安全不一定是可重入的,而可重入的一定是线程安全的。

四.死锁

死锁是指一组线程中,都占有自己的资源,同时又向使用对方的资源,这样导致线程互相申请无法推进线程运行的现象就叫做死锁。

简单来说,线程A想要访问的资源必须同时持有锁1和锁2,线程B也一样。但此时线程A持有锁1,线程B持有锁2.而它们又同时访问对方的锁,这样就导致谁都申请不到锁,导致阻塞挂起。

1.死锁的四个必要条件

  • 0x1.互斥条件:一个临界资源只能被一个执行流访问
  • 0x2.请求与保持条件:一个执行流因为请求而导致阻塞时,对已有的资源不释放
  • 0x3.不剥夺条件:一个执行流已获得的资源,在未使用完前,不可被抢夺
  • 0x4.循环等待条件:若干个执行流,采取循环的申请对方的资源,导致了头尾衔接的等待资源关系。

2.避免死锁

死锁产生上面四种条件必须同时具有,所以我们只需要破坏其中的条件即可,死锁就不会成立!!!!

解决方案1:我们可以使用trylock来申请锁,在申请另一个锁时,发现申请失败,就可以释放掉当前的锁,来让其他人获取。

当然还有其他方法来避免死锁,可以自行了解。


以上,便是单例线程池的所有内容!

相关推荐
chao_7891 分钟前
PyQt5基本介绍
开发语言·qt
我命由我123453 分钟前
C++ - 数据容器之 forward_list(创建与初始化、元素访问、容量判断、元素遍历、添加元素、删除元素)
c语言·开发语言·c++·后端·visualstudio·visual studio·后端开发
Cxzzzzzzzzzz11 分钟前
go语言实现用户管理系统
开发语言·后端·golang
love530love15 分钟前
cuDNN 9.9.0 便捷安装-Windows
运维·windows·python
智者知已应修善业16 分钟前
【51单片机6位数码管显示时间与秒表】2022-5-8
c语言·c++·经验分享·笔记·单片机·算法·51单片机
心.c38 分钟前
最小单调子序列的长度+联通最小乘积
数据结构·c++·算法·leetcode
mahuifa38 分钟前
(40)VTK C++开发示例 ---集合
c++·vtk·cmake·3d开发
钢铁男儿1 小时前
Python变量作用域陷阱:为什么函数内赋值会引发_局部变量未定义
开发语言·python
尤物程序猿1 小时前
Java怎么实现一个敏感词过滤?有哪些方法?怎么优化?
java·开发语言·c#
天选之子1231 小时前
文本解析到大模型应用
服务器·开发语言