Linux:线程 【线程池】
(一)线程池概念
- 所谓的 线程池 就是 提前创建一批线程,当任务来临时,线程直接从任务队列中获取任务执行,可以提高整体效率;同时一批线程会被合理维护,避免调度时造成额外开销。(创造线程也是需要资源的)。
- 像这种把未来会高频使用到,并且创建较为麻烦的资源提前申请好的技术称为 池化技术,池化技术 可以极大地提高性能。
- 池化技术 的本质:空间换时间
线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着
监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
1、线程池优点
- 线程池避免了在处理短时间任务时创建与销毁线程的代价
- 线程池不仅能够保证内核充分利用,还能防止过分调度。
线程会被合理调度,确保 任务与线程 间能做到负载均衡
2、线程池的应用场景
- 需要大量的线程来完成任务,且完成任务的时间比较短。
WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。 - 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。
突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。
(二)线程池的模拟实现
1、普通模式线程池
线程池类
线程池对外提供一个Push接口,用于让外部线程能够将任务Push到任务队列当中。
线程池中的多个线程负责从任务队列当中拿任务,并将拿到的任务进行处理。
具体代码如下:
#include <vector>
#include <queue>
#include <iostream>
#include <string>
#define NUM 5
// 描述一个线程信息的结构体
struct thread_data
{
pthread_t tid; // 线程 id
std::string threadname; // 线程的名字,这里是自己定义的
};
template <class T>
class ThreadPool
{
private:
std::vector<thread_data> _threads; // 存放线程的数组
std::queue<T> _task; // 任务队列
pthread_mutex_t _mutex; // 互斥锁
pthread_cond_t _cond; // 条件变量
public:
// 加锁
void Lock()
{
pthread_mutex_lock(&_mutex);
}
// 解锁
void Unlock()
{
pthread_mutex_unlock(&_mutex);
}
// 唤醒条件变量
void Wakeup()
{
pthread_cond_signal(&_cond);
}
// 等待条件变量
void ThreadSleep()
{
pthread_cond_wait(&_cond, &_mutex);
}
// 判断任务队列是否为空
bool IsQueueEmpty()
{
return _task.empty();
}
// 获取线程的名字
std::string GetThreadName(pthread_t tid)
{
// 遍历整个线程数组
for (const auto &ti : _threads)
{
if (ti.tid == tid)
return ti.threadname;
}
return "None";
}
public:
// 初始化锁和条件变量
ThreadPool(int num = NUM)
: _threads(NUM)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
// 销毁锁和条件变量
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
// 线程执行任务
static void *Headler(void *args)
{
ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
std::string name = tp->GetThreadName(pthread_self());
while (true)
{
// 线程之间会同步互斥的拿到对应的任务
tp->Lock();
while (tp->IsQueueEmpty())
{
tp->ThreadSleep();
}
T t = tp->Pop();
tp->Unlock();
// 处理任务的过程不加锁
t.run(); //这个是运行任务,是我自己在任务类中设计的函数, 具体的任务处理效果可以自己设置
std::cout << "thread name : " << name << " 结果等于 : " << t.GetResult() << std::endl;
}
}
// 创造线程
void CreatThreads()
{
int num = _threads.size();
for (int i = 0; i < num; i++)
{
_threads[i].threadname = "thread-" + std::to_string(i + 1);
pthread_create(&_threads[i].tid, nullptr, Headler, this);
}
}
//发送任务 到 任务队列
void Push(const T &in)
{
// 发布任务需要加锁,生产者保持互斥
Lock();
_task.push(in);
Wakeup();
Unlock();
}
// 线程拿到任务队列中的任务
T Pop()
{
T t = _task.front();
_task.pop();
return t;
}
};
注意:
- 在唤醒线程的时候我们要加上 while 循坏 ,因为线程可能造成伪唤醒。
如果是一下子唤醒所有线程,那么使得在被唤醒的若干线程中,只有个别线程能拿到任务。 - 线程执行例程需要是静态的。
因为默认的执行例程函数的参数是void *
,而类内函数第一个参数是 this 指针,所以我们需要将该函数设置成 static 的。
但是会出现一个问题:我们在静态成员函数内部无法调用非静态成员函数,而我们需要在Routine函数当中调用该类的某些非静态成员函数,比如Pop。因此我们在进行创建线程的时候,我们把this指针带上,此时我们就能够通过该this指针在Routine函数内部调用非静态成员函数了。
任务类
为了发送任务,我加上了一个任务类, Task.hpp代码如下:
#pragma once
#include <iostream>
#include <string>
std::string opers = "+-*/%";
enum // 规定错误码
{
DivZero = 1, // 当 除数为0时
ModZero, // 当 %的时候 ,xx % 0时
Unknown // 出现错误的符号时
};
class Task
{
private:
int data1_; //
int data2_;
char oper_;
int exitcode_; // 错误码,判断结果是否合理,默认为0
int result_; // 结果
public:
Task(int x, int y, char op) : data1_(x), data2_(y), oper_(op), result_(0), exitcode_(0)
{
}
~Task()
{
}
void run()
{
switch (oper_)
{
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;
case '%':
{
if (data2_ == 0)
exitcode_ = ModZero;
else
result_ = data1_ % data2_;
}
break;
default:
exitcode_ = Unknown;
break;
}
}
//两数运算 所指向的任务
std::string GetTask()
{
std::string r = std::to_string(data1_);
r += oper_;
r += std::to_string(data2_);
r += "= ";
return r;
}
//两数运算的结果
std::string GetResult()
{
std::string r = std::to_string(result_);
r += "[code: ";
r += std::to_string(exitcode_);
r += "]";
return r;
}
};
该类主要是实现两数之间的运算,使用该类时需要我们提供具体的 两个数字 和 运算符。
主函数
接下来准备工作已经完毕,我们看看如何运用线程池,main.cc代码如下:
#pragma Once
#include"Task.hpp"
#include"ThreadPool.hpp"
#include<unistd.h>
#include<ctime>
int main()
{
srand(time(nullptr));
//创造线程池
ThreadPool<Task>* tp = new ThreadPool<Task>(5);
tp->CreatThreads();
sleep(1);
while(true)
{
//1. 构建任务
int x = rand() % 10 + 1;
usleep(10);
int y = rand() % 5;
char op = opers[rand()%opers.size()];
Task t(x, y, op);
tp->Push(t);
//2. 交给线程池处理
std::cout << "main thread make task: " << t.GetTask() << std::endl;
sleep(1);
}
return 0;
}
运行效果如下:
因为是多线程并发执行,所以打印起来优点乱,但结果是符合预期的。
2、单例模式线程池
什么是单例模式:
- 一个类只能创建一个对象,即单例模式,该模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。
- 比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息,这种方式简化了在复杂环境下的配置管理。
线程池类
关于单例模式就不过多的描述了,我们直接实现一个懒汉模式下的线程池。
#include <vector>
#include <queue>
#include <iostream>
#include <string>
#define NUM 5
// 描述一个线程信息的结构体
struct thread_data
{
pthread_t tid; // 线程 id
std::string threadname; // 线程的名字,这里是自己定义的
};
template <class T>
class ThreadPool
{
private:
std::vector<thread_data> _threads; // 存放线程的数组
std::queue<T> _task; // 任务队列
pthread_mutex_t _mutex; // 互斥锁 ,保持发送任务和处理任务的互斥
pthread_cond_t _cond; // 条件变量
static ThreadPool<T> *tp_; // 单例模式, 提供一个静态的线程池
static pthread_mutex_t lock_; // 该锁保持 申请线程池的互斥 ,避免申请过多的线程池对象
public:
// 加锁
void Lock()
{
pthread_mutex_lock(&_mutex);
}
// 解锁
void Unlock()
{
pthread_mutex_unlock(&_mutex);
}
// 唤醒条件变量
void Wakeup()
{
pthread_cond_signal(&_cond);
}
// 等待条件变量
void ThreadSleep()
{
pthread_cond_wait(&_cond, &_mutex);
}
// 判断任务队列是否为空
bool IsQueueEmpty()
{
return _task.empty();
}
// 获取线程的名字
std::string GetThreadName(pthread_t tid)
{
// 遍历整个线程数组
for (const auto &ti : _threads)
{
if (ti.tid == tid)
return ti.threadname;
}
return "None";
}
private:
// 初始化锁和条件变量
ThreadPool(int num = NUM)
: _threads(NUM)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
// 销毁锁和条件变量
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
ThreadPool(const ThreadPool<T> &) = delete;
const ThreadPool<T> &operator=(const ThreadPool<T> &) = delete;
public:
// 线程执行任务
static void *Headler(void *args)
{
ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
std::string name = tp->GetThreadName(pthread_self());
while (true)
{
// 线程之间会同步互斥的拿到对应的任务
tp->Lock();
while (tp->IsQueueEmpty())
{
tp->ThreadSleep();
}
T t = tp->Pop();
tp->Unlock();
// 处理任务的过程不加锁
t.run();
std::cout << "thread name : " << name << " 结果等于 : " << t.GetResult() << std::endl;
}
}
// 创造线程
void CreatThreads()
{
int num = _threads.size();
for (int i = 0; i < num; i++)
{
_threads[i].threadname = "thread-" + std::to_string(i + 1);
pthread_create(&_threads[i].tid, nullptr, Headler, this);
}
}
//发送任务 到 任务队列
void Push(const T &in)
{
// 发布任务需要加锁,生产者保持互斥
Lock();
_task.push(in);
Wakeup();
Unlock();
}
// 线程拿到任务队列中的任务
T Pop()
{
T t = _task.front();
_task.pop();
return t;
}
//获取线程池对象
static ThreadPool<T> *GetInstance()
{
if (nullptr == tp_) // 申请锁也需要空间,所以当线程池对象已经存在了,就没必要再申请锁了。
{
pthread_mutex_lock(&lock_);
if (nullptr == tp_)
{
std::cout << "线程对象创造成功" << std::endl;
tp_ = new ThreadPool<T>();
}
pthread_mutex_unlock(&lock_);
}
return tp_;
}
};
template <class T>
ThreadPool<T> *ThreadPool<T>::tp_ = nullptr;
template <class T>
pthread_mutex_t ThreadPool<T>::lock_ = PTHREAD_MUTEX_INITIALIZER;
在原来版本的基础上多加了一个锁和 一个静态的线程池对象指针(饿汉模式)。
主函数
看看如何调用该线程池。
int main()
{
srand(time(nullptr));
// 创造线程池
ThreadPool<Task>::GetInstance()->CreatThreads();
sleep(1);
while (true)
{
// 1. 构建任务
int x = rand() % 10 + 1;
usleep(10);
int y = rand() % 5;
char op = opers[rand() % opers.size()];
Task t(x, y, op);
ThreadPool<Task>::GetInstance()->Push(t);
// 2. 交给线程池处理
std::cout << "main thread make task: " << t.GetTask() << std::endl;
sleep(1);
}
return 0;
}
运行效果如下:
(三)其它线程相关知识
1、STL线程安全
STL中的容器是否是线程安全的? 不是。
原因:
- STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响.
- 而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶).
- 因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全
2、智能指针线程安全
C++ 标准提供的智能指针有三种:unique_ptr
、shared_ptr
、weak_ptr
- 对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题
- 对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数.
- 至于 weak_ptr,名为弱引用智能指针,具体实现与 shared_ptr 一脉相承,因此它也是线程安全的
3、其他常见锁
- 悲观锁:
在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。 - 乐观锁:
每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作 - CAS操作:
当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。 - 公平锁:
一种用于同步多线程或多进程之间访问共享资源的机制,它通过使用互斥锁和相关的调度策略来确保资源的公平分配,以提高系统的性能和稳定性 - 非公平锁:
通常使用信号量(Semaphore)或自旋锁(Spinlock)等机制。这些锁机制没有严格的按照请求的顺序来分配锁,而是以更高的性能为目标,允许一些线程或进程在较短时间内多次获取锁资源,从而减少了竞争开销 - 自旋锁:
申请锁失败时,线程不会被挂起,而且不断尝试申请锁。
自旋 本质上就是一个不断 轮询 的过程,即不断尝试申请锁,这种操作是十分消耗 CPU 时间的,因此推荐临界区中的操作时间较短时,使用 自旋锁 以提高效率;操作时间较长时,自旋锁 会严重占用 CPU 时间
自旋锁相关接口:
#include <pthread.h>
pthread_spinlock_t lock; // 自旋锁类型
int pthread_spin_init(pthread_spinlock_t *lock, int pshared); // 初始化自旋锁
int pthread_spin_destroy(pthread_spinlock_t *lock); // 销毁自旋锁
// 自旋锁加锁
int pthread_spin_lock(pthread_spinlock_t *lock); // 失败就不断重试(阻塞式)
int pthread_spin_trylock(pthread_spinlock_t *lock); // 失败就继续向后运行(非阻塞式)
// 自旋锁解锁
int pthread_spin_unlock(pthread_spinlock_t *lock);
4、读者写者问题
- 在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。
- 通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁。
读者写者模型 321 原则
3 种关系:
- 读者 vs 读者 :无关系
- 写者 vs 写者 :互斥
- 读者 vs 写者 :互斥、同步
2 种角色:读者、写者
1 个交易场所:阻塞队列或其他缓冲区
读写锁 相关接口
#include <pthread.h>
pthread_rwlock_t; // 读写锁类型
// 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *__restrict__ __rwlock, const pthread_rwlockattr_t *__restrict__ __attr);
// 销毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *__rwlock)
// 读者,加锁
int pthread_rwlock_rdlock(pthread_rwlock_t *__rwlock); // 阻塞式
int pthread_rwlock_tryrdlock(pthread_rwlock_t *__rwlock); // 非阻塞式
// 写者,加锁
int pthread_rwlock_wrlock(pthread_rwlock_t *__rwlock); // 阻塞式
int pthread_rwlock_trywrlock(pthread_rwlock_t *__rwlock); // 非阻塞式
// 解锁(读者锁、写者锁都可以解)
int pthread_rwlock_unlock(pthread_rwlock_t *__rwlock);
//设置读写优先
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
/*
pref 共有 3 种选择
PTHREAD_RWLOCK_PREFER_READER_NP (默认设置) 读者优先,可能会导致写者饥饿情况
PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先,目前有 BUG,导致表现行为和
PTHREAD_RWLOCK_PREFER_READER_NP 一致
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递归加锁
*/
下面是一个伪代码理解 rwlock 的实现原理
注意:
- 读者是多于写者的,所以在申请锁的过程中 写者可能会一直申请不到锁,写者陷入死锁状态,这是读者优先模型的缺点。
- 若我们想改变这一状态,可以采用写者优先的方式。