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进行了引用计数的原子化操作

相关推荐
张小姐的猫9 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
qq_452396239 小时前
第二篇:《Go 开发环境搭建:SDK、IDE、Module 与 Hello World》
开发语言·ide·golang
栩栩云生9 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
天青色等烟雨..9 小时前
全流程ArcGISPro空间分析、三维建模、可视化及Python融合应用技术
开发语言·python
丰锋ff9 小时前
1.2 Qt常用基础类型
开发语言·qt
冰暮流星10 小时前
javascript之String方法介绍
开发语言·javascript·ecmascript
imc.1110 小时前
linux基础IO
linux·运维·服务器
nianniannnn11 小时前
Qt QMessageBox知识点
开发语言·数据库·qt
故乡de云11 小时前
AWS 添加付款方式失败排查清单:卡片、银行风控与账号状态逐项定位
开发语言·php
J_yyy11 小时前
【Qt核心组件类框架】
开发语言·qt