前言
在当今软件开发领域,多线程编程已成为日益重要的技能之一。然而,要确保多线程程序的正确性和性能,并非易事。本篇博客旨在探讨多线程编程实践中的关键技术,从基于环形队列的生产者消费者模型,到线程池的实现和线程安全的单例模式,再到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操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
- 自旋锁,公平锁,非公平锁…