🌟 各位看官好,我是!****
🌍 Linux == Linux is not Unix !
🚀 今天来学习单例模式下的线程池,,线程安全与重入问题,死锁的概念及如何避免。
👍 如果觉得这篇文章有帮助,欢迎您一键三连,分享更多人哦!
目录
线程安全的单例模式
什么是单例模式
在上一章节中,我们对线程池进行了封装,但是实际上存在一个漏洞.我们一个对象会一次性创建出一批线程出来,那么如果有多个对象并且每个对象都申请线程呢?这不就会造成线程被滥用吗? --> 为了解决这种问题场景,我们设计出了单例模式!
特点
某些类, 只应该具有⼀个对象(实例), 就称之为单例.
在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要⽤⼀个单例的类来管理这些数据.
饿汉模式和懒汉模式的单例模式
饿汉?懒汉?这里举个例子方便大家进行理解:
吃完饭, ⽴刻洗碗, 这种就是饿汉⽅式. 因为下⼀顿吃的时候可以立刻拿着碗就能吃饭.
吃完饭, 先把碗放下, 然后下⼀顿饭⽤到这个碗了再洗碗, 就是懒汉方式.
饿汉模式实现
只要通过 Singleton 这个包装类来使⽤ T 对象, 则⼀个进程中只有⼀个 T 对象的实例.
bash
template <typename T>
class Singleton
{
static T data;
public:
static T* GetInstance()
{
return &data;
}
}
static变量将来被编译器编译,加载器加载,静态变量将来会被编译在进程地址空间的哪个区域?
在C++语言上中经常可以做类加载和创建类,在系统角度又是什么意思?
data变量一旦被定义,会在进程的全局数据区进行开辟.在系统角度上不就是编译器编译到全局变量去了,运行时该变量已经被加载了,已经存在了.不使用时变量就已经被开辟出来了(还没吃碗就立即洗了),这叫做进程加载时类对象直接被创建.
可是为什么是单例的呢?
- 全局变量,变量名不能冲突
- 只要是单例,构造拷贝不要创建
懒汉模式实现
bash
// 懒汉模式, 线程安全
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;
}
};
创建一个静态指针,当申请时才创建对象,是一种延迟创建对象(不着急洗完,当要吃饭才洗碗)
如何变成懒汉模式?
构造函数私有化,把拷贝构造,赋值拷贝禁用
懒汉方式最核⼼的思想是 "延时加载". 从⽽能够优化服务器的启动速度.
单例式线程池(懒汉模式)
构造函数私有化,必须得有构造函数,因为要有对象,且只能有一个.
bash
private:
ThreadPool(int threadnum = defaultthreadnum)
: _threadnum(threadnum), _is_running(false), _wait_thread_num(0)
{
for (int i = 0; i < _threadnum; i++)
{
std::string name = "thread-" + std::to_string(i + 1);
_threads.emplace_back([this](const std::string &name)
{ this->Routine(name); }, name);
}
LOG(LogLevel::INFO) << "thread pool obj create success";
}
禁用赋值重载、拷贝构造
bash
ThreadPool<T> &operator=(const ThreadPool<T> &) = delete;
ThreadPool(const ThreadPool<T> &) = delete;
单例中静态指针
bash
class ThreadPool
{
private:
// 单例中静态指针
static ThreadPool<T> *_instance;
};
template <class T>
ThreadPool<T> *ThreadPool<T>::_instance = nullptr;
获取单例
bash
void Start()
{
if (_is_running)
return;
_is_running = true;
for (auto &t : _threads)
{
t.Start();
}
LOG(LogLevel::INFO) << "thread pool running success";
}
// 获取单例
ThreadPool<T> *GetInstance()
{
if (!_instance)
{
_instance = new ThreadPool<T>();
LOG(LogLevel::DEBUG) << "线程池单例首次被使用,创建并初始化, addr: " << ToHex(_instance);
_instance->Start();
}
return _instance;
}
成员方法可以访问类内静态属性?可以,但是成员方法怎样才能访问?必须在外部有对象,可能在外部创建出对象吗?不可能,那访问不了啊?该咋做呢?加static,以类的方式访问GetInstance方法.
bash
// 获取单例
static ThreadPool<T> *GetInstance()
{
// ...
}
多线程分别使用单例,不就存在多份的情况?并不是线程安全的,该如何做呢?原子化,加锁。加判断,提高获取单例效率.
bash
template <class T>
class ThreadPool
{
public:
static ThreadPool<T> *GetInstance()
{
// A, B, C
{
// 线程安全,提高效率式的获取单例
if (!_instance)
{
LockGuard lockguard(&_singleton_lock);
if (!_instance)
{
_instance = new ThreadPool<T>();
LOG(LogLevel::DEBUG) << "线程池单例首次被使用,创建并初始化, addr: " << ToHex(_instance);
_instance->Start();
}
}
}
return _instance;
}
private:
static Mutex _singleton_lock;
};
template <class T>
Mutex ThreadPool<T>::_singleton_lock;
线程安全与重入问题
**线程安全:**多个线程在访问共享资源时,能够正确地执⾏,不会相互⼲扰或破坏彼此的执行结果。⼀般而言,多个线程并发同⼀段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进⾏操作,并且没有锁保护的情况下,容易出现该问题。
**重入:**同⼀个函数被不同的执行流调用,当前⼀个流程还没有执⾏完,就有其他的执⾏流再次进⼊,我们称之为重入。⼀个函数在重⼊的情况下,运⾏结果不会出现任何不同或者任何问题,则该函数被称为可重⼊函数,否则,是不可重入函数。
一个是线程视角,一个是函数视角;
线程是因,产生了 不安全和 不可重入 两个果!
即线程安全与不安全和函数重入与不可重入问题是一个硬币的两面
只要是多线程,最终都会调函数
重⼊其实可以分为两种情况:
- 多线程重⼊函数
- 信号导致⼀个执行重复进⼊函数
常见线程不安全情况
- 不保护共享变量的函数
- 函数状态随着被调⽤,状态发⽣变化的函数
- 返回指向静态变量指针的函数
- 调⽤线程不安全函数的函数
常见线程安全情况
- 每个线程对全局变量或者静态变量只有读取的权限,⽽没有写⼊的权限,⼀般来说这些线程是安全的
- 类或者接⼝对于线程来说都是原⼦操作
- 多个线程之间的切换不会导致该接⼝的执行结果存在二义性
常见不可重入情况
- 调⽤了malloc/free函数,因为malloc函数是⽤全局链表来管理堆的
- 调⽤了标准I/O库函数,标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构
- 可重⼊函数体内使⽤了静态的数据结构
常见可重入情况
- 不使⽤全局变量或静态变量
- 不使⽤⽤malloc或者new开辟出的空间
- 不调⽤不可重⼊函数
- 不返回静态或全局数据,所有数据都有函数的调⽤者提供
- 使⽤本地数据,或者通过制作全局数据的本地拷⻉来保护全局数据
可重入与线程安全联系与区别
联系
- 函数是可重入的,那就是线程安全的(其实知道这⼀句话就够了)
- 函数是不可重⼊的,那就不能由多个线程使⽤,有可能引发线程安全问题
- 如果⼀个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
区别
- 可重⼊函数是线程安全函数的⼀种
- 线程安全不⼀定是可重⼊的,而可重⼊函数则⼀定是线程安全的。
为什么说线程安全不一定是可重入的呢?
如果是因为信号导致进入了某个函数,此时这个函数有了加锁,如果还没进行解锁又再次被信号中断导致再次进入这个函数,此时这个执行流可以申请两次锁吗?会失败啊!把自己挂起了,就是典型的死锁问题!
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重⼊函数若锁还未释放则会产⽣死锁,因此是不可重⼊的。
- 如果不考虑 信号导致⼀个执⾏流重复进⼊函数 这种重⼊情况,线程安全和重⼊在安全角度不做区分
- 但是线程安全侧重说明线程访问公共资源的安全情况,表现的是并发线程的特点
- 可重⼊描述的是⼀个函数是否能被重复进⼊,表⽰的是函数的特点
常见锁概念
死锁
死锁是指在⼀组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站⽤不会释放的资源⽽处于的⼀种永久等待状态。
假设现在线程A,线程B必须同时持有锁1和锁2,才能进行后续资源的访问

申请⼀把锁是原子的,但是申请两把锁就不⼀定了

造成的结果是

死锁四个必要条件
- 互斥条件:⼀个资源每次只能被⼀个执⾏流使⽤
- 请求与保持条件:⼀个执⾏流因请求资源⽽阻塞时,对已获得的资源保持不放

- 不剥夺条件:⼀个执⾏流已获得的资源,在末使⽤完之前,不能强⾏剥夺

- 循环等待条件:若干执⾏流之间形成⼀种头尾相接的循环等待资源的关系

避免死锁
破坏死锁的四个必要条件这里只对最后一点进行破坏
破坏循环等待条件问题:资源⼀次性分配, 使⽤超时机制、加锁顺序⼀致
bash
// 定义两个共享资源(整数变量)和两个互斥锁
int shared_resource1 = 0;
int shared_resource2 = 0;
std::mutex mtx1, mtx2;
// ⼀个函数,同时访问两个共享资源
void access_shared_resources()
{
// std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
// std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
// // 使⽤ std::lock 同时锁定两个互斥锁
// std::lock(lock1, lock2);
// 现在两个互斥锁都已锁定,可以安全地访问共享资源
int cnt = 10000;
while (cnt)
{
++shared_resource1;
++shared_resource2;
cnt--;
}
// 当离开 access_shared_resources 的作⽤域时,lock1 和 lock2 的析构函数会被⾃动调⽤
// 这会导致它们各⾃的互斥量被⾃动解锁
}
// 模拟多线程同时访问共享资源的场景
void simulate_concurrent_access()
{
std::vector<std::thread> threads;
// 创建多个线程来模拟并发访问
for (int i = 0; i < 10; ++i)
{
threads.emplace_back(access_shared_resources);
}
// 等待所有线程完成
for (auto &thread : threads)
{
thread.join();
}
// 输出共享资源的最终状态
std::cout << "Shared Resource 1: " << shared_resource1 << std::endl;
std::cout << "Shared Resource 2: " << shared_resource2 << std::endl;
}
int main()
{
simulate_concurrent_access();
return 0;
}
STL,智能指针和线程安全
STL中的容器不是线程安全的,为什么?
原因是, STL 的设计初衷是将性能挖掘到极致, ⽽⼀旦涉及到加锁保证线程安全, 会对性能造成巨⼤的影响.⽽且对于不同的容器, 加锁⽅式的不同, 性能可能也不同(例如hash表的锁表和锁桶).
因此 STL 默认不是线程安全. 若需要在多线程环境使用, 往往需要调⽤者自行保证线程安全.
智能指针是否是线程安全的?
对于 unique_ptr, 由于只是在当前代码块范围内⽣效, 因此不涉及线程安全问题.
对于 shared_ptr, 多个对象需要共⽤⼀个引⽤计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原⼦操作(CAS)的⽅式保证 shared_ptr 能够⾼效, 原⼦的操作引⽤计数.
其他常见锁
悲观锁:在每次取数据时,总是担⼼数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进⾏修改。主要采⽤两种⽅式:版本号机制和CAS操作。
CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则⽤新值更新。若不等则失败,失败则重试,⼀般是⼀个⾃旋的过程,即不断重试。
自旋锁:不死不休地申请这个锁,常用于内核中.

