🕺作者: 主页
我的专栏 C语言从0到1 探秘C++ 数据结构从0到1 探秘Linux 菜鸟刷题集 😘欢迎关注:👍点赞🙌收藏✍️留言
🏇码字不易,你的👍点赞🙌收藏❤️关注对我真的很重要,有问题可在评论区提出,感谢阅读!!!
文章目录
- 前言
- 1 线程池
- 2 基于队列的线程池实现代码
- 3 线程安全的单例模式
- 3.1 相关概念
- 3.2 饿汉实现方式和懒汉实现方式
- 4 STL、智能指针和线程安全
- 5 其他常见的各种锁
前言
在当今软件开发领域,多线程编程已成为日益重要的技能之一。然而,要确保多线程程序的正确性和性能,并非易事。本篇博客旨在探讨多线程编程实践中的关键技术,从基于环形队列的生产者消费者模型,到线程池的实现和线程安全的单例模式,再到STL、智能指针和线程安全,以及其他常见的各种锁。
通过学习本文,读者将深入了解多线程编程的实际应用,掌握如何应对常见的并发编程挑战,并学会运用各种技术和方法来构建高效、稳定和可靠的多线程程序。让我们一同探索多线程编程的精髓,为未来的软件开发之路注入更多的智慧与创新。
1 线程池
- 什么是线程池?
简单来说,线程池就是有一堆已经创建好了的线程,初始它们都处于空闲等待状态,当有新任务需要处理的时候,就从这个池子里面取一个空闲等待的线程来处理该任务,当处理完成就再次把线程放回池中,以供后面的任务使用,当池子里面的线程都处于忙碌状态时,线程池中没有可用的空闲等待线程,此时,根据需要创建一个新的线程并置入池中,或通知任务线程池忙,稍后再试。
- 线程池存在的价值
- 有任务时立马有线程进行服务,省掉了线程创建的时间
- 可以有效防止服务器中线程过多导致系统过载的问题
- 线程池 vs 进程池
- 线程池占用的资源更少,但是健壮性不强
- 进程池占用的资源更多,但是健壮性很强
线程池是一种线程使用模式,线程过多会带来调度开销,进而影响缓存局部性和整体性能,而线程池维护着多个线程,等待着管理者分配可并发执行的任务。
这避免了在处理短时间任务时创建和销毁线程的代价。
线程池不仅可以保证内核的充分利用,还能防止过分调度。
可用的线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
- 线程池应用场景
- 要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。
- 线程池示例
- 创建固定数量线程池,循环从任务队列中获取任务对象。
- 获取到任务对象后,执行任务对象中的任务接口。
2 基于队列的线程池实现代码
makefile
main:main.cc
g++ -o $@ $^ -lpthread
.PHONY:clean
clean:
rm -f main
Thread_Pool.h
#pragma
#include<iostream>
#include<math.h>
#include<unistd.h>
#include<stdlib.h>
#include<pthread.h>
#include<queue>
#define NUM 5
class Task
{
private:
int _b;
public:
Task(){}
Task(int b)
:_b(b)
{}
~Task(){}
void run(){
std::cout<<"I am "<<pthread_self()<<" Task run ... bace:"<<\
_b<<" ^2 = "<<pow(_b,2)<<std::endl;
}
};
class Thread_Pool
{
private:
std::queue<Task*> q;
int _max_num;//线程总数
pthread_mutex_t lock;
pthread_cond_t cond;//只能让消费者操作
void LockQueue(){
pthread_mutex_lock(&lock);
}
void UnLockQueue(){
pthread_mutex_unlock(&lock);
}
bool IsEmpty(){
return q.size()==0;
}
bool IsFull(){
return q.size()==_max_num;
}
void ThreadWait()
{
pthread_cond_wait(&cond,&lock);
}
void ThreadWakeUp()
{
pthread_cond_signal(&cond);
}
public:
Thread_Pool(int max_num = NUM)
:_max_num(max_num)
{}
void Get(Task& out)//取数据
{
Task*t=q.front();
q.pop();
out=*t;
}
void Put(Task& in){//放置数据
LockQueue();
q.push(&in);
UnLockQueue();
ThreadWakeUp();
}
static void* Routine(void* arg){
while(1){
Thread_Pool* tp = (Thread_Pool*)arg;
while(tp->IsEmpty()){
tp->LockQueue();//静态成员方法不能访问非静态成员方法,所以传(void*)this过去
tp->ThreadWait();//为空挂起等待
}
Task t;
tp->Get(t);
tp->UnLockQueue();
t.run();
}
}
void ThreadPoolInit(){
pthread_mutex_init(&lock,NULL);
pthread_cond_init(&cond,NULL);
int i=0;
pthread_t t;
for(i=0;i<_max_num;++i){
pthread_create(&t,NULL,Routine,(void*)this);
}
}
~Thread_Pool(){
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&cond);
}
};
main.cc
#include"Thread_Pool.h"
using namespace std;
int main(){
Thread_Pool *tp = new Thread_Pool();
tp->ThreadPoolInit();
while(1){
int x=rand()%10 + 1;
Task t(x);
tp->Put(t);
sleep(1);
}
return 0;
}
结果:
3 线程安全的单例模式
3.1 相关概念
- 什么是单例模式
单例模式是一种 “经典的, 常用的, 常考的” 设计模式。
- 什么是设计模式
IT行业这么火, 涌入的人很多. 俗话说林子大了啥鸟都有. 大佬和菜鸡们两极分化的越来越严重. 为了让菜鸡们不太拖大佬的后腿, 于是大佬们针对一些经典的常见的场景, 给定了一些对应的解决方案, 这个就是 设计模式
- 单例模式的特点
某些类, 只应该具有一个对象(实例), 就称之为单例。
例如一个男人只能有一个媳妇.
在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理这些数据。
3.2 饿汉实现方式和懒汉实现方式
吃完饭, 立刻洗碗, 这种就是饿汉方式.。
因为下一顿吃的时候可以立刻拿着碗就能吃饭。
吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式。
懒汉方式最核心的思想是 “延时加载”. 从而能够优化服务器的启动速度。
- 饿汉方式实现单例模式
template <typename T>
class Singleton {
static T data; //定义静态的类对象,程序加载类就加载对象
public:
static T* GetInstance() {
return &data;
}
};
只要通过 Singleton 这个包装类来使用 T 对象,,则一个进程中只有一个 T 对象的实例。
- 懒汉方式实现单例模式
template <typename T>
class Singleton {
static T* inst; //定义静态的类对象,程序运行时才加载对象
public:
static T* GetInstance() {
if (inst == NULL) {
inst = new T();
}
return inst;
}
};
存在一个严重的问题, 线程不安全。
第一次调用 GetInstance 的时候, 如果两个线程同时调用, 可能会创建出两份 T 对象的实例。
但是后续再次调用, 就没有问题了。
- 懒汉方式实现单例模式(线程安全版本)
// 懒汉模式, 线程安全
template <typename T>
class Singleton {
volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
static std::mutex lock;
public:
static T* GetInstance()
{
if (inst == NULL) // 双重判定空指针, 降低锁冲突的概率, 提高性能. //判断两个线程不同时进去直接return
{
lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new. //两个线程同时进去加锁
if (inst == NULL)
{
inst = new T();
}
lock.unlock();
}
return inst;
}
};
注意事项:
- 加锁解锁的位置
- 双重 if 判定, 避免不必要的锁竞争
- volatile关键字防止过度优化
4 STL、智能指针和线程安全
- STL中的容器是否是线程安全的?
不是.
原因是:STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响。
而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶)。
因此 STL 默认不是线程安全.。
如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全。
- 智能指针是否是线程安全的?
对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题。
对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数。
5 其他常见的各种锁
- 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
- 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
- CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
- 自旋锁,公平锁,非公平锁…