Linux多线程_(线程池,单例模式,读者写者问题,自旋锁)
Posted 楠c
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux多线程_(线程池,单例模式,读者写者问题,自旋锁)相关的知识,希望对你有一定的参考价值。
目录
1. 线程池
1.1 是什么
一种线程使用模式。可以避免大面积请求引起的服务器宕机。
1.2 为什么
- 线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
- 降低资源消耗:通过重用已经创建的线程来降低线程创建和销毁的消耗
- 提高线程的可管理性:线程池可以统一管理、分配、调优和监控
1.3 怎么用
线程池的应用场景:
- 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技
术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个
Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。 - 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误.
1.5 线程池代码
这个线程池类,需要一个队列,来存取任务,创建5个线程,让main线程塞任务,这5个线程去执行任务。多线程所以他还要有个锁,main线程放任务,需要让多个线程需要同步,所以需要一个条件变量。
ThreadPool.hpp
#include<iostream>
#include<queue>
#include<math.h>
#include<unistd.h>
#include<stdlib.h>
using namespace std;
#define NUM 5
class Task
{
private:
int _a;
public:
Task(){}
Task(int a)
:_a(a)
{};
void run()
{
cout<<"pthread["<<pthread_self()<<"] : "<<_a <<":"<<pow(_a,2)<<endl;
}
};
class Pool
{
private:
queue<Task*> q;
int max_num;
pthread_mutex_t lock;
pthread_cond_t cond;
public:
Pool(int max=NUM)
:max_num(max)
{}
bool IsEmpty()
{
return q.size()==0;
}
void Threadwait()
{
pthread_cond_wait(&cond,&lock);
}
void ThreadWakeUp()
{
//一次唤醒一个
// pthread_cond_signal(&cond);
pthread_cond_broadcast(&cond);
}
//外部放任务
void put(Task& in)
{
LockQueue();
q.push(&in);
UnlockQueue();
//放任务就把你唤醒,一次唤醒一个
ThreadWakeUp();
}
//线程池线程拿任务
void Get(Task& out)
{
//调用的地方加了
Task *t=q.front();
q.pop();
out=*t;
}
static void* routine(void* args)
{
Pool* this_p=(Pool*)args;
this_p->LockQueue();
while(1)
{
//为空时不拿
pthread_detach(pthread_self());
while(this_p->IsEmpty())
{
this_p->Threadwait();
}
Task t;
this_p->Get(t);
this_p->UnlockQueue();
t.run();
}
}
void PoolInit()
{
pthread_mutex_init(&lock,NULL);
pthread_cond_init(&cond,NULL);
//不关心线程id,所以用一个变量当参数就行了
pthread_t tid;
for(int i=0;i<max_num;i++)
{
//因为成员函数routine中有两个参数,这里要传函数名和形参,形参只能传一个。用static
// 又因为静态函数没有this指针,而里面又要调用成员函数,所以传递this指针
pthread_create(&tid,NULL,routine,this);
}
}
void LockQueue()
{
pthread_mutex_lock(&lock);
}
void UnlockQueue()
{
pthread_mutex_unlock(&lock);
}
};
main.cc
#include"ThreadPool.hpp"
int main()
{
Pool p;
p.PoolInit();
sleep(2);
while(true)
{
int x=rand()%100+1;
Task t(x);
p.put(t);
sleep(1);
}
return 0;
}
1.6 实验现象
1.7 实验总结
-
可以看到由于我们是一次唤醒一个线程(signal),所以他是按顺序一次执行任务,假如队列为空然后等待被唤醒,这也正说明了条件变量cond中存在一个等待队列。任务执行完成。
-
假如一次唤起一群的话(broadcast),由于你只有一个任务执行,其他线程又会休眠,引起性能震荡,也叫做惊群效应。
-
线程池,耗费少量资源,但是程序健壮性不强。(一个线程异常,整个进程崩溃)
-
进程池,耗费较多资源,程序健壮性较强。(进程之间具有独立性,互不影响)
2. 单例模式
在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理这些数据。
- 吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭.
- 吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式.
2.1 饿汉方式
template <typename T>
class Singleton {
static T data;
public:
static T* GetInstance()
{
return &data;
}
};
由于他是一个静态对象,所以类加载的时候,他就会创建出来,用的时候通过这个类的成员方法,直接可以拿到地址使用。但是存在一个问题,假如程序中存在大量的不同单例,类加载的时候,启动会十分慢
2.2 懒汉方式
template <typename T>
class Singleton {
static T* inst;
public:
static T* GetInstance() {
if (inst == NULL)
{
inst = new T();
}
return inst;
}
};
他是一个静态指针,所以类加载的时候不会创建对象,当需要用这个对象时,调用方法才会创建出来供我们使用。所以它的核心思想是"延时加载",从而能够优化服务器的启动速度。
2.3 懒汉模式(线程安全)
饿汉是不存在线程安全的,因为类加载,对象已经创建地址唯一,即使多个线程进来也只是拿到他的地址。
而懒汉呢,当多个线程进来,对象还没有创建,假如同时判空,这样就创建了多个对象。那就不是单例模式了。所以需要加锁。
// 懒汉模式, 线程安全
template <typename T>
class Singleton {
volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
static std::mutex lock;
public:
static T* GetInstance() {
if (inst == NULL)
{ // 双重判定空指针, 降低锁冲突的概率, 提高性能.
lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new.
if (inst == NULL)
{
inst = new T();
}
lock.unlock();
}
return inst;
}
};
双重判空是因为,假如不同时进来,就没有必要加锁,所以在外面再加一个判定,假如另一个线程进来想创建对象就会自己返回,假如同时进来大不了我再加个锁。其实不同时进来的概率是更高的,同时进来我们的锁也可以处理,所以效率更高。
2.4 STL线程安全问题
STL是为有线程安全问题的,原因是, STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响.而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶).
因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全.
2.5 智能指针线程安全问题
对于 unique_ptr
, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题.
对于 shared_ptr
, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数.
3. 其他常见的各种锁
3.1 悲观锁
在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。所以之前用的到全部是悲观锁
3.2 乐观锁
每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,
会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
3.3 CAS操作
Compare and Swap
当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
3.4 读者写者问题(读写锁)
对于这个问题,也可以简单总结。
3种关系:
与生产者,消费者不同的是,消费者是会取走数据的。
3.4.1 初始化
3.4.2 销毁
3.4.3 加锁和解锁
有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁
因为应用场景,可以分为3类,读优先,写优先,公平。但是常见于读多写少的场景。
以读优先为例,他是怎么实现的呢?
当第一个读者进来,rc==1,所以对写者加锁,这样就实现了写者线程阻塞,读者线程优先。注意判断的时候由于可能多个线程同时进入判断,需要加锁。当读取完数据后,在进行判断如果读者线程为0,那么就对写者线程解锁。
3.5 自旋锁
之前,信号量,互斥锁,条件变量,申请不到就一直阻塞。
而自旋锁,spin,因为占有临界资源的线程,在临界区呆的时间特别短,无需挂起,让当前线程处于自旋状态,不断去检测锁的状态。自旋锁为我们提供了上述功能。锁在操作系统都是汇编实现的。而实际应用场景可以根据任务要求在临界区的时间长短,来区分使用哪种锁。
以上是关于Linux多线程_(线程池,单例模式,读者写者问题,自旋锁)的主要内容,如果未能解决你的问题,请参考以下文章
LINUX多线程(线程池,单例模式,线程安全,读者写者模型)