![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/7a8f3c526a75606e2afe42351bc84191.jpeg#pic_center)
1.线程池
介绍
线程池是一种线程使用模式。
如果频繁地创建线程,会带来调度上的大量开销,进而影响缓存局部性和整体性能。
通过线程池一次性向系统申请多个线程进行维护,随后等待管理者分配可并发执行的任务。
线程池可避免在执行短时间任务时创建和销毁线程的高代价,防止过分调度,保证内核的充分利用。
可用线程的数量取决于可用的并发处理器,内存,网络sockets等的数量。
- 何处使用线程池?
-
需要依赖大量线程来并发完成任务,且每条任务的执行时间不长。
WEB服务器完成网页的请求的任务,使用线程池技术是很合适的,因为单个访问者即是一条线程,任务小的同时任务数量特别大。
-
对性能要求苛刻的应用,例如游戏服务器需要迅速对玩家的操作做出响应。
-
接受突发性的大量请求。
一个基于简单任务的线程池实现
我们使用类来封装线程池,使用队列存储任务对象。其中需要使用到互斥锁保证多线程间的互斥,需要条件变量保证生产者与消费者之间的同步。
所需工具:
- 互斥锁
- 条件变量
- 创建多个线程
- 队列
- 先给出线程池类
ThreadPool
的基本框架:
- 初始化锁和条件变量
- 销毁锁和变量
- 封装lock,unlock,wait,signal(之后讨论为什么一定要做封装)
//threadpool.hpp
#pragma once
#include <iostream>
#include <string>
#include <queue>
#include <unistd.h>
#include <pthread.h>
const int g_num=10;
template <class T>
class ThreadPool
{
public:
ThreadPool(int num=g_num):_num(num)
{
pthread_mutex_init(&_mtx,nullptr);
pthread_cond_init(&_cond,nullptr);
}
~ThreadPool()
{
pthread_mutex_destroy(&_mtx);
pthread_cond_destroy(&_cond);
}
void Lock()
{
pthread_mutex_lock(&_mtx);
}
void Unlock()
{
pthread_mutex_unlock(&_mtx);
}
void Wait()
{
pthread_cond_wait(&_cond,&_mtx);
}
void WakeUp()
{
pthread_cond_signal(&_cond);
}
bool IsEmpty()
{
return _task_queue.empty();
}
private:
int _num;//线程数
std::queue<T> _task_queue;
pthread_mutex_t _mtx;
pthread_cond_t _cond;
};
- 现在我们需要一个函数
ThreadInit()
来创建多个线程,
public:
void ThreadInit()
{
pthread_t tid;
for(int i=0;i<_num;++i)
{
pthread_create(&tid,nullptr,Routine,(void*)this);
}
}
- 线程函数
Routine()
要处理队列中的任务,事先写好从任务队列中提取数据的操作:PopTask()
,顺便写出存放数据的操作:PushTask()
。
public:
void PushTask(const T& in)
{
Lock();//队列为临界资源
_task_queue.push(in);
WakeUp();//填入数据后,唤醒线程开始处理
Unlock();
}
void PopTask(T* out)
{
*out=_task_queue.front();
_task_queue.pop();
}
- 显然还需建立一个线程函数
Routine()
来提取队列中的对象,但需要注意到,线程函数的参数只有一个参数,类型为void*:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
而如果作为成员函数,那么函数的第一个参数会默认为this指针,所以我们将 Routine
设置为静态成员函数。static修饰的函数,因为没有this指针,所以函数中无法调用成员变量,那显然互斥锁,条件变量,任务队列的API都需要做封装。
public:
static void *Routine(void *args)
{
pthread_detach(pthread_self());
ThreadPool<T> *tp = (ThreadPool<T> *)args;
while (true)
{
tp->Lock();
while(tp->IsEmpty())
{
tp->Wait();//如果队列为空,则等待
}
//该任务队列中一定有了任务
T t;
tp->PopTask(&t);
tp->Unlock();//静态变量没有this指针只能依赖传入的参数
//线程处理任务的工作不在临界区内,
//需先释放锁再执行相应的处理
t.Run();
}
}
那这个简单的线程池类就完成了
在队列中我们将存放一个任务类 Task
,届时生产者提供两个操作数和操作符,消费者执行 +-*/%
计算:
//Task.hpp
#pragma once
#include <iostream>
class Task
{
public:
Task(int _a=0,int _b=0,char _op=0):a(_a),b(_b),op(_op)
{}
std::string Show()
{
std::string message=std::to_string(a);
message+=op;
message+=std::to_string(b);
message+=" = ";
return message;
}
void Run()
{
int result=0;
switch(op)
{
case '+':
result=a+b;
break;
case '-':
result=a-b;
break;
case '*':
result=a*b;
break;
case '/':
if(b==0)
{
std::cout<<"div zero"<<std::endl;
result=-1;
}
else
{
result=a/b;
}
break;
case '%':
if (b == 0)
{
std::cout << "mod zero" << std::endl;
result = -1;
}
else
{
result = a % b;
}
break;
default:
break;
}
std::cout<<"thread("<<pthread_self()<<") 正在执行:"\
<<a<<op<<b<<" = "<<result<<std::endl;
}
~Task(){}
private:
int a;
int b;
char op;
};
最后我们做下测试,由主线程不断发出任务,主函数如下:
//main.cpp
#include <iostream>
#include <pthread.h>
#include<ctime>
#include "threadpool.hpp"
#include "Task.hpp"
int main()
{
ThreadPool<Task>* tp=new ThreadPool<Task>();
tp->ThreadInit();
srand((unsigned int)time(nullptr));
while(1)
{
int x=rand()%50+1;
int y=rand()%50+1;
Task t(x,y,"+-*/%"[rand()%5]);
tp->PushTask(t);
sleep(1);
}
return 0;
}
2.线程池与单例模式
上面所设计的线程池类,在工程中我们实例化一个对象就够了,这称为单例模式。
在很多服务器开发场景中,经常需要让服务器加载很多数据到内存中,此时往往需要一个单例的类来管理这些数据。如果允许这个类存在多个对象便会导致代码冗余。
饿汉模式与懒汉模式
吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭.
吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式.
- 饿汉方式实现单例模式
template <class T>
class Sigleton
{
private:
static T data;
public:
static T* GetInstance()
{
return &data;
}
};
饿汉模式会在进程创建伊始就实例化对象,缺点在于:起初如果这个对象用不到,但是会占据一部分内存,而且 创建进程的时间会更久。
- 懒汉方式实现单例模式
typename<class T>
class Singleton
{
private:
static T* data;
public:
static T* GetInstance()
{
if(data==nullptr)
{
data=new T();
}
return data;
}
};
T* Singleton::data=nullptr;
懒汉模式最核心的思想是延时加载,如写时拷贝,从而能够优化服务器的启动速度。
懒汉模式的线程池
现在我们用懒汉的单例模式尝试改写第一节中的线程池类:threadpool
首先需要注意的是,将构造函数改为private,以及禁用拷贝构造和赋值重载,防止用户实例化多个对象。
template <class T>
class ThreadPool
{
private:
//构造函数必须要实现,但是必须要私有化
ThreadPool(int num=g_num):_num(num)
{
pthread_mutex_init(&_mtx,nullptr);
pthread_cond_init(&_cond,nullptr);
}
ThreadPool(const ThreadPool<T> &tp)=delete;
ThreadPool<T>& operator=(ThreadPool<T> &tp)=delete;
public:
static ThreadPool<T>* GetInstance()
{
static pthread_mutex_t ins_mtx=PTHREAD_MUTEX_INITIALIZER;
if(instance==nullptr)//双判定
{
pthread_mutex_lock(&ins_mtx);
if(instance==nullptr)
{
instance=new ThreadPool();
instance->ThreadInit();
std::cout<<"首次加载"<<std::endl;
}
pthread_mutex_unlock(&ins_mtx);
}
return instance;
}
//其余的函数不变
//...
private:
int _num;
std::queue<T> _task_queue;
pthread_mutex_t _mtx;
pthread_cond_t _cond;
volatile static ThreadPool<T>* instance;//懒汉单例模式的指针,用时new
};
//静态成员变量须在类外初始化
template<class T>
ThreadPool<T>* ThreadPool<T>::instance=nullptr;
🚩说明:
-
GetInstance函数是静态成员函数,是跟随类编译时就创建的,只要我们需要实现单例时,调用这个静态成员函数即可。
-
GetInstance函数内我们写了两个if来判定instance指针是否为空,目的是为了保证线程安全并保证效率——
双判定
。首先需要明确的是,instance是一个需要保护的共享资源,如果线程很多,那么在就会有多个线程能通过
if(instance==nullptr)
的判断条件,从而创建多个对象。于是我们需要对此判断进行加锁,但是,如果单例对象已经创建,instance不为nullptr,那每个线程还要先去竞争锁再判断,是会造成性能损失的,所以我们在外边再套一层判断,从而在已创建一个对象后,让后面的线程可直接跳到return instance,而不用再去竞争锁。
所以一个线程安全的懒汉单例模式的模型应该是:
template <typename T>
class Singleton {
private:
volatile static T* instance; // 需要设置 volatile 关键字, 否则可能被编译器优化.
static std::mutex lock;
public:
static T* GetInstance()
{
if (instance == NULL)
{ // 双重判定空指针, 降低锁冲突的概率, 提高性能.
lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new.
if (instance == NULL)
{
instance = new T();
}
lock.unlock();
}
return instance;
}
};
主函数稍作修改:
//main.cpp
int main()
{
srand((unsigned int)time(nullptr));
while(1)
{
int x=rand()%50+1;
int y=rand()%50+1;
Task t(x,y,"+-*/%"[rand()%5]);
ThreadPool<Task>::GetInstance()->PushTask(t);
std::cout<<"单例对象:"<<ThreadPool<Task>::GetInstance()<<std::endl;
sleep(1);
}
return 0;
}
3.读写锁
在编写多线程的时候,有一种情况十分常见:有些公共数据修改的机会比较少,相比较改写数据,他们被读取的机会反而高得多。
通常而言,在读的过程中,常伴随查找操作,中间耗时很长,但是读者不会取走数据资源,给数据加锁会极大地降低程序的效率,所以我们需要重新划分各个线程的关系:
- 写者和写者:互斥关系
- 读者和写者:互斥+同步
- 读者和读者:没有关系(可以共享临界数据)
所以读者写者与生产者消费者的区别在于:读者不拿走数据,消费者会拿走数据。
有一种专用锁可以处理这种多读少写的情况,就是 读写锁 。
读写锁的行为
当前锁状态 | 读锁请求 | 写锁请求 |
---|---|---|
无锁 | 可以 | 阻塞 |
读锁 | 可以 | 阻塞 |
写锁 | 阻塞 | 阻塞 |
加写锁的时候判断是否有写锁,没有则说明已有写者持有写锁,要阻塞等其释放写锁;加写锁的时候判断reader是否大于0;如果大于0代表有人加读锁,将阻塞;
加读锁的时候判断是否有写者持有写锁;如果有人已加写锁,说明临界资源正在修改,读者将阻塞,否则reader计数器+1(所以可以存在多个reader)。
🚩总结:写独占,读共享
- 读者优先:读者和写者同时到来,让读者先进入访问
- 写者优先:读者和写者同时到来,比当前写者晚来的读者都不能进入临界区了,等到临界区的读者走完后,写者进入临界区。
读写锁接口
- 基本类型 :
pthread_rwlock_t(读写锁)
,pthread_rwlockattr_t(读写锁属性)
设置读者写者优先级
//初始化读写锁属性
pthread_rwlockattr_init(pthread_rwlockattr_t* attr);
//设置优先级
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t* attr,int pref);
//释放读写锁属性
pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
pref有3种选项
pthread_RWLOCK_PREFER_READER_NP //(默认设置),读者优先,可能会导致写者饥饿问题。
PTHREAD_RWLOCK_PREFER_WRITER_NP //写者优先,有 BUG,导致表现行为和PTHREAD_RWLOCK_PREFER_READER_NP 一致
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP //写者优先,注意写者不能递归加锁
读写锁API
- 初始化
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_wrlock(pthread_rwlock_t *rwlock);
//解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
- 返回值:成功返回0,失败返回错误码。
读写锁实验
现在我们设置一个写者和4个读者线程:
#include <iostream>
#include <string>
#include <time.h>
#include<unistd.h>
#include<stdlib.h>
#include<pthread.h>
using namespace std;
pthread_rwlock_t rwlock;
string* blackboard;
void* reader(void* arg)
{
cout<<"start read"<<endl;
while(true)
{
pthread_rwlock_rdlock(&rwlock);
cout<<pthread_self()<<"->read: "<<*blackboard<<endl;
sleep(1);
pthread_rwlock_unlock(&rwlock);
}
return nullptr;
}
void* writer(void* arg)
{
while(true)
{
pthread_rwlock_wrlock(&rwlock);
cout<<"start writing"<<endl;
//blackboard->clear();
*blackboard='A'+rand()%26;
pthread_rwlock_unlock(&rwlock);
cout<<"write over"<<endl;
sleep(3);
}
return nullptr;
}
int main()
{
srand(unsigned(time(nullptr)));
pthread_t rtid[4],wtid;
int i;
blackboard=new string("None");
pthread_rwlock_init(&rwlock,nullptr);
//创建4个读者线程
for(i=0;i<4;++i)
{
pthread_create(rtid+i,nullptr,reader,nullptr);
}
//创建写者线程
pthread_create(&wtid,nullptr,writer,nullptr);
//回收线程资源
pthread_join(wtid, NULL);
for(i = 0; i < 4; ++i)
{
pthread_join(rtid[1], NULL);
}
pthread_rwlock_destroy(&rwlock);
return 0;
}
结果发现由于默认读者优先加锁,导致写者一直无法得到读写锁也写不进内容,而读者也一直读的是空的黑板:
现在我们需要调整读写锁的属性,改为写者优先:
#include <iostream>
#include <string>
#include <time.h>
#include<unistd.h>
#include<stdlib.h>
#include<pthread.h>
using namespace std;
pthread_rwlock_t rwlock;
string* blackboard;
void* reader(void* arg)
{
cout<<"start read"<<endl;
while(true)
{
pthread_rwlock_rdlock(&rwlock);//读者的锁是可以累加的
cout<<pthread_self()<<"->read: "<<*blackboard<<endl;
pthread_rwlock_unlock(&rwlock);
sleep(1);
}
return nullptr;
}
void* writer(void* arg)
{
while(true)
{
pthread_rwlock_wrlock(&rwlock);
cout<<"------------------"<<endl;
cout<<"start writing"<<endl;
string tmp=*blackboard;
//blackboard->clear();
*blackboard='A'+rand()%26;
cout<<"modify blackboard from \""<<tmp<<"\" to \""<<*blackboard<<"\""<<endl;
pthread_rwlock_unlock(&rwlock);
cout<<"write over"<<endl;
cout<<"------------------"<<endl;
sleep(1);
}
return nullptr;
}
int main()
{
srand(unsigned(time(nullptr)));
//自己修改读写锁属性
pthread_rwlockattr_t attr;
//初始化读写锁属性
pthread_rwlockattr_init(&attr);
//属性修改为写者优先
pthread_rwlockattr_setkind_np(&attr,PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP);
pthread_t rtid[4],wtid;
int i;
blackboard=new string("None");
pthread_rwlock_init(&rwlock,&attr);
//创建4个读者线程
for(i=0;i<4;++i)
{
pthread_create(rtid+i,nullptr,reader,nullptr);
}
//创建写者线程
pthread_create(&wtid,nullptr,writer,nullptr);
//回收线程资源
pthread_join(wtid, NULL);
for(i = 0; i < 4; ++i)
{
pthread_join(rtid[1], NULL);
}
pthread_rwlockattr_destroy(&attr);
pthread_rwlock_destroy(&rwlock);
return 0;
}
4.自旋锁简介
自旋锁比较适用于锁使用者保持锁时间比较短的情况。
正是由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要的,自旋锁的效率远高于互斥锁。
信号量和读写信号量适合于保持时间较长的情况,它们会导致调用者睡眠,因此只能在进程上下文使用,而自旋锁适合于保持时间非常短的情况,它可以在任何上下文使用。
如果被保护的共享资源只在进程上下文访问,使用信号量保护该共享资源非常合适,如果对共享资源的访问时间非常短,自旋锁也可以。
但是如果被保护的共享资源需要在中断上下文访问(包括底半部即中断处理句柄和顶半部即软中断),就必须使用自旋锁。自旋锁保持期间是抢占失效的,而信号量和读写信号量保持期间是可以被抢占的。自旋锁只有在内核可抢占或SMP(多处理器)的情况下才真正需要,在单CPU且不可抢占的内核下,自旋锁的所有操作都是空操作。
总之,自旋锁是一种对多处理器相当有效的机制,而在单处理器非抢占式的系统中基本上没有作用。自旋锁在SMP系统中应用得相当普遍。
在许多SMP系统中,允许多个处理机同时执行目态程序,而一次只允许一个处理机执行操作系统代码,利用一个自旋锁可以很容易实现这种控制.一次只允许一个CPU执行核心代码并发性不够高,若期望核心程序在多CPU之间的并行执行,将核心分为若干相对独立的部分,不同的CPU可以同时进入和执行核心中的不同部分,实现时可以为每个相对独立的区域设置一个自旋锁.
- 接口函数
#include <pthread.h>
int pthread_spin_destroy(pthread_spinlock_t *lock);
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);
pshared的取值及其含义:
PTHREAD_PROCESS_SHARED
:该自旋锁可以在多个进程中的线程之间共享。PTHREAD_PROCESS_PRIVATE
: 仅初始化本自旋锁的线程所在的进程内的线程才能够使用该自旋锁。
自旋锁的基本形式如下:
spin_lock(&mr_lock);
//临界区
//访问时间较短
spin_unlock(&mr_lock);
— end —
青山不改 绿水长流