👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍
一、池化技术(线程池)
不知道大家有没有听过xx池
(如内存池等),这些池其实统称池化技术。
以内存池为例,比方说有一个偏远的村庄,这个村庄离河边有一定距离,那么村民需要水的时候就跑去河边打水,可是每次需要水的时候就去打未免效率太低了。因此,村民可以提前打完一周所需要的用水。
因此,可以将村民看作是程序,而水则是内存。在没有内存池的情况下,程序每次需要内存时都需要向操作系统申请,而频繁进行系统调用是有成本的,就像村民每次需要水都要跑去河边打水一样,效率较低。而有了内存池,就像村民提前打好一周的水存放在家里一样,程序在启动时就预先分配了一定量的内存,并将其存放在内存池中。当程序需要内存时,就直接从内存池中获取,而不是每次都向系统请求,这样可以减少内存分配的开销和系统的负担,提高程序的运行效率。
因此,不管是xx池
,这些池化技术的共同特点是:通过提前分配一定数量的资源并在需要时复用这些资源,来提高系统的性能和效率。其本质就是空间换时间。
线程池是一种线程使用模式。就是提前创建一批线程,当任务来临时,线程直接从任务队列中获取任务执行,可以提高整体效率。同时一批线程会被合理维护,避免调度时造成额外开销。
如何合理维护?
- 线程重用: 线程池中的线程是重复使用的,不需要为每个新任务创建和销毁线程,从而减少了线程创建和销毁的开销。
- 任务调度: 线程池通常使用任务队列来管理待执行的任务,线程从队列中取任务并执行,避免了频繁的线程调度和任务分配开销。
- 线程数控制: 线程池会维护一个线程数量的上限,避免了过多线程带来的系统负担,同时也可以根据负载动态调整线程数量,以适应不同的工作负载需求。
- 资源管理: 通过合理配置线程池,可以有效控制资源使用,避免了线程过多导致的资源竞争和上下文切换开销。
二、实现线程池
线程池的两大核心:一批线程和任务队列。客户端发出请求,即任务队列新增任务,然后线程从队列获取任务,最后执行任务。
- 线程池中的多个线程相互竞争地从任务队列当中拿任务,并将拿到的任务进行处理。
- 线程池需要对外提供一个
push
接口,用于让外部线程能够将任务Push到任务队列当中。
以上的设计模式不就是生产者消费者模型吗?
这里我们选择在类内创建线程,主要是讲述几个常见的问题
ThreadPool.hpp
#pragma once
#include <iostream>
#include <pthread.h>
#include <vector>
#include <string>
#include <queue>
#include <unistd.h>
#include "Task.hpp"
#define defaultNum 5 // 线程池默认的线程个数
struct threadInfo
{
pthread_t tid;
std::string threadname;
};
template <class T>
class ThreadPool
{
public:
// 默认构造函数
ThreadPool(int num = defaultNum) // 默认在线程池创建5个线程
: _threads(num)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
// 析构函数
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
// 线程池中线程的执行例程
static void *HandlerTask(void *args)
{
// 线程分离
pthread_detach(pthread_self());
ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
std::string name = tp->GetThreadName(pthread_self());
// 不断从任务队列获取任务进行处理
while (true)
{
// 线程先检测任务队列有无任务
// 而任务队列是临界资源,那么需要加锁
pthread_mutex_lock(&(tp->_mutex));
while ((tp->_tasks).empty())
{
// 如果列表为空
// 线程直接去休眠, 即去条件变量的线程等待列表等待
pthread_cond_wait(&(tp->_cond), &(tp->_mutex));
}
// 如果有任务,则获取任务
T t = tp->pop();
pthread_mutex_unlock(&(tp->_mutex));
// 处理任务
t.run();
std::cout << name << " run, " << "result: " << t.GetRusult() << std::endl;
}
}
// 启动线程(常见线程)
void start()
{
for (int i = 0; i < _threads.size(); i++)
{
_threads[i].threadname = "thread-" + std::to_string(i + 1);
pthread_create(&(_threads[i].tid), nullptr, HandlerTask, this); // 注意参数传入this指针
}
}
// 向任务列表中塞任务 -- 主线程调用
void push(const T &t)
{
pthread_mutex_lock(&_mutex);
// 向任务队列里塞任务
_tasks.push(t);
// queue容器会自动扩容,不需要特判任务列表的容量是否够
// 接下来唤醒线程池中的线程
pthread_mutex_unlock(&_mutex);
pthread_cond_signal(&_cond);
}
// 去掉任务队列中的任务
T pop()
{
// 这个函数不需要对临界资源加锁
// 因为pop函数只在HandlerTask函数中被调用
// 而在HandlerTask函数中已经对该函数加锁了
T t = (_tasks).front();
_tasks.pop();
return t;
}
std::string GetThreadName(pthread_t tid)
{
for (const auto &e : _threads)
{
if (e.tid == tid)
{
return e.threadname;
}
}
return "None";
}
private:
std::vector<threadInfo> _threads; // 将线程维护在数组中
std::queue<T> _tasks; // 任务队列
pthread_mutex_t _mutex;
pthread_cond_t _cond;
};
为什么线程池中需要有互斥锁和条件变量?
-
线程池中的任务队列是会被多个执行流同时访问的临界资源,因此我们需要引入互斥锁对任务队列进行保护,保证数据的一致性。
-
线程池当中的线程要从任务队列里拿任务,前提条件是任务队列中必须要有任务,因此线程池当中的线程在拿任务之前,需要先判断任务队列当中是否有任务,若此时任务队列为空,那么该线程应该进行等待,直到任务队列中有任务时再将其唤醒,因此我们需要引入条件变量。
-
另外,当外部线程向任务队列中
push
一个任务后,此时可能有线程正处于等待状态,因此在新增任务后需要唤醒在条件变量下等待的线程。
为什么线程池中的线程执行例程需要设置为静态方法?
使用pthread_create
函数创建线程时,需要为创建的线程传入一个HandlerTask
,该HandlerTask
只有一个参数类型为void*
的参数,以及返回类型为void*
的返回值。
而此时HandlerTask
作为类的成员函数,类成员函数的第一个参数都是隐藏的this
指针,因此这里的HandlerTask
函数,虽然看起来只有一个参数,而实际上它有两个参数,即void* HandlerTask(ThreadPool<T> *this, void *args)
,而HandlerTask
函数只能有一个参数,此时直接将该函数作为创建线程时的执行例程是不行的,会导致无法通过编译。
静态成员函数属于整个类,而不属于某个对象,也就是说静态成员函数是没有this
指针的,因此我们需要将HandlerTask
设置为静态方法,此时HandlerTask
函数才真正只有一个参数类型为void*的参数。
另外,在静态成员函数内部无法调用非静态成员函数,包括类中的成员变量,而我们需要在HandlerTask函数当中调用该类的某些非静态成员函数,以及使用成员变量。因此我们需要在创建线程时,向HandlerTask
函数传入的当前对象的this
指针,此时我们就能够通过该this
指针在HandlerTask
函数内部可以直接调用非静态成员函数和成员变量了。
几个注意的点
- 当某线程被唤醒时,其可能是伪唤醒,或者是一些广播类的唤醒线程操作而导致所有线程被唤醒,使得在被唤醒的若干线程中,只有个别线程能拿到任务。此时应该让被唤醒的线程再次判断是否满足被唤醒条件,所以在判断任务队列是否为空时,应该使用
while
进行判断,而不是if
pthread_cond_broadcast
函数的作用是唤醒条件变量下的所有线程,而外部可能只push
了一个任务,我们却把全部在等待的线程都唤醒了,此时这些线程就都会去任务队列获取任务,但最终只有一个线程能得到任务。一瞬间唤醒大量的线程可能会导致系统震荡,这叫做惊群效应。因此在唤醒线程时最好使用pthread_cond_signal
函数唤醒正在等待的一个线程即可。- 当线程从任务队列中拿到任务后,该任务就已经属于当前线程了,与其他线程已经没有关系了,因此应该在解锁之后再进行处理任务,而不是在解锁之前进行。因为处理任务的过程可能会耗费一定的时间,所以我们不要将其放到临界区当中。
- 如果将处理任务的过程放到临界区当中,那么当某一线程从任务队列拿到任务后,其他线程还需要等待该线程将任务处理完后,才有机会进入临界区。此时虽然是线程池,但最终我们可能并没有让多线程并行的执行起来。
任务类设计
我们将线程池进行了模板化,因此线程池当中存储的任务类型可以是任意的,但无论该任务是什么类型的,在该任务类当中都必须包含一个run
方法,当我们处理该类型的任务时只需调用该run
方法即可。
#pragma once
#include <iostream>
std::string oper = "+-*/";
enum
{
DivZero = 1,
Unknow
};
class Task
{
public:
Task()
{
}
Task(int data1, int data2, char op)
: _data1(data1), _data2(data2), _op(op), _result(0), _exitcode(0)
{
}
void run()
{
switch (_op)
{
case '+':
_result = _data1 + _data2;
break;
case '-':
_result = _data1 - _data2;
break;
case '*':
_result = _data1 * _data2;
break;
case '/':
if (_data2 == 0)
{
_exitcode = DivZero;
}
else
{
_result = _data1 / _data2;
}
break;
default:
_exitcode = Unknow;
break;
}
}
std::string GetRusult()
{
std::string r = std::to_string(_data1);
r += _op;
r += std::to_string(_data2);
r += "=";
r += std::to_string(_result);
r += "[code: ";
r += std::to_string(_exitcode);
r += "]";
return r;
}
std::string GetTask()
{
std::string r = std::to_string(_data1);
r += _op;
r += std::to_string(_data2);
r += "= ?";
return r;
}
private:
int _data1;
int _data2;
char _op; // 运算符
int _result; // 运算结果
int _exitcode; // 运算结果是否正确,0表示正确,1表示不正确
};
主线程
主线程负责不断向任务队列当中push
任务就行了,此后线程池当中的线程会从任务队列当中获取到这些任务并进行处理。
#include "ThreadPool.hpp"
#include "Task.hpp"
#include <ctime>
using namespace std;
int main()
{
srand(time(nullptr));
ThreadPool<Task> *tp = new ThreadPool<Task>(5); // 表示线程此有5个线程
tp->start();
while (true)
{
// 1. 构建任务
int x = rand() % 10 + 1;
usleep(10); // 防止x和y的值类似
int y = rand() % 5;
int len = oper.size();
char op = oper[rand() % len];
Task t(x, y, op);
// 2. 交给线程池处理
tp->push(t);
cout << "main thread make a task: " << t.GetTask() << endl;
sleep(1);
}
return 0;
}
【程序结果】
我们会发现这五个线程在处理时会呈现出一定的顺序性,这是因为主线程是每秒push
一个任务,这五个线程只会有一个线程获取到该任务,其他线程都会在等待队列中进行等待,当该线程处理完任务后就会因为任务队列为空而排到等待队列的最后,当主线程再次push
一个任务后会唤醒等待队列首部的一个线程,这个线程处理完任务后又会排到等待队列的最后,因此这五个线程在处理任务时会呈现出一定的顺序性。这不就是同步嘛!这不就是PC
模型嘛!
而我们以前说的进程池,它其实也是PC
模型!只不过当时所写的进程池是拿父进程和每一个子进程之间建立了一个管道,管道自带同步和互斥,而管道不就是今天所讲的任务队列嘛!
三、线程封装
在Linux
中,C++11
的线程库的实现往往还是依赖于系统原生的线程库pthread
,在编译时,链接时需要确保链接了pthread
库。
【程序结果】
当然我们也可以通过原生线程库pthread
,自己封装实现的线程库Thread.hpp
,支持对线程做出更多操作。
Thread.hpp
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include <ctime>
#include <functional>
// 参数、返回值为 void 的函数类型
typedef void (*callback_t)();
class Thread
{
static int num;
public:
static void *Routine(void *args)
{
Thread *thread = static_cast<Thread *>(args);
thread->Entery();
return nullptr;
}
public:
Thread(callback_t cb = nullptr)
: _tid(0), _threadname(""), _Start_timestamp(0), _status(false), _cb(cb)
{
}
~Thread()
{
}
// 启动线程
void Run()
{
_threadname = "thread-" + std::to_string(num++);
_Start_timestamp = time(nullptr);
_status = true;
pthread_create(&_tid, nullptr, Routine, this);
}
// 线程等待
void Join()
{
pthread_join(_tid, nullptr);
_status = false;
}
// 获取线程名
std::string GetName()
{
return _threadname;
}
// 获取线程启动的时间戳
uint64_t GetStartTime()
{
return _Start_timestamp;
}
// 获取线程状态
bool Status()
{
return _status;
}
void Entery()
{
_cb();
}
private:
pthread_t _tid; // 线程tid
std::string _threadname; // 线程名字
uint16_t _Start_timestamp; // 时间戳(线程什么时候启动的)
bool _status; // 线程状态
callback_t _cb; // 线程回调函数(表示线程要执行的任务)
};
// 静态成员变量初始化需要在类外
int Thread::num = 1;
main.cc
#include <iostream>
#include "Thread.hpp"
#include <unistd.h>
using namespace std;
void Print()
{
while (true)
{
cout << "我是一个封装的线程" << endl;
sleep(1);
}
}
int main()
{
Thread t(Print); // 创建线程
t.Run(); // 启动线程
/* 查看线程属性 */
cout << "是否启动成功: " << t.Status() << endl;
cout << "启动成功的时间戳: " << t.GetStartTime() << endl;
cout << "线程的名字: " << t.GetName() << endl;
t.Join(); // 线程等待
return 0;
}
【程序结果】
四、线程池的优点
- 线程池避免了在处理短时间任务时创建与销毁线程的代价。
- 线程池不仅能够保证内核充分利用,还能防止过分调度。
- 保证内核充分利用: 线程池可以使得线程在内核级别得到充分利用,因为它们是预先创建好的,可以直接从任务队列中取任务并执行,从而减少了线程创建的开销。
- 防止过分调度: 线程池限制了活跃线程的数量,避免了过多线程同时竞争系统资源的问题,减少了上下文切换和调度开销。这样,可以避免过度的线程切换和资源竞争,从而提高了整体系统的效率。
注意: 线程池中可用线程的数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets
等的数量。
五、线程池的应用场景
- 需要大量的线程来完成任务,且完成任务的时间比较短。
web
服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet
连接请求(用于通过网络远程访问另一台计算机的服务),线程池的优点就不明显了。因为Telnet
会话时间比线程的创建时间大多了。 - 对性能要求苛刻的应用。比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。
六、相关代码
我的Gitee
仓库:点击跳转