线程池单例模式、线程安全与重入及死锁解析
线程池单例模式实现涉及饿汉与懒汉两种策略,重点在于延迟加载与线程安全控制。文章解析线程安全与可重入函数的联系与区别,指出信号中断可能导致死锁风险。针对死锁问题,说明互斥、请求保持、不剥夺及循环等待四个必要条件,并提供破坏循环等待条件的解决方案。此外,探讨 STL 容器非线程安全原因及 shared_ptr 引用计数的原子操作保障,最后对比悲观锁、乐观锁、CAS 及自旋锁等常见锁机制的应用场景。

线程池单例模式实现涉及饿汉与懒汉两种策略,重点在于延迟加载与线程安全控制。文章解析线程安全与可重入函数的联系与区别,指出信号中断可能导致死锁风险。针对死锁问题,说明互斥、请求保持、不剥夺及循环等待四个必要条件,并提供破坏循环等待条件的解决方案。此外,探讨 STL 容器非线程安全原因及 shared_ptr 引用计数的原子操作保障,最后对比悲观锁、乐观锁、CAS 及自旋锁等常见锁机制的应用场景。

在上一章节中,我们对线程池进行了封装,但是实际上存在一个漏洞。我们一个对象会一次性创建出一批线程出来,那么如果有多个对象并且每个对象都申请线程呢?这不就会造成线程被滥用吗?为了解决这种问题场景,我们设计出了单例模式!
某些类,只应该具有一个对象(实例),就称之为单例。 在很多服务器开发场景中,经常需要让服务器加载很多的数据(上百 G)到内存中。此时往往要用一个单例的类来管理这些数据。
饿汉?懒汉?这里举个例子方便大家进行理解:
吃完饭,立刻洗碗,这种就是饿汉方式。因为下一顿吃的时候可以立刻拿着碗就能吃饭。
吃完饭,先把碗放下,然后下一顿饭用到这个碗了再洗碗,就是懒汉方式。
只要通过 Singleton 这个包装类来使用 T 对象,则一个进程中只有一个 T 对象的实例。
template <typename T> class Singleton { static T data; public: static T* GetInstance() { return &data; } };
static 变量将来被编译器编译,加载器加载,静态变量将来会被编译在进程地址空间的哪个区域?
在 C++ 语言中经常可以做类加载和创建类,在系统角度又是什么意思?
data 变量一旦被定义,会在进程的全局数据区进行开辟。在系统角度上不就是编译器编译到全局变量去了,运行时该变量已经被加载了,已经存在了。不使用时变量就已经被开辟出来了(还没吃碗就立即洗了),这叫做进程加载时类对象直接被创建。
可是为什么是单例的呢?
// 懒汉模式,线程安全
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;
}
};
创建一个静态指针,当申请时才创建对象,是一种延迟创建对象(不着急洗完,当要吃饭才洗碗)
如何变成懒汉模式?
构造函数私有化,把拷贝构造,赋值拷贝禁用
懒汉方式最核心的思想是 "延时加载"。从而能够优化服务器的启动速度。
构造函数私有化,必须得有构造函数,因为要有对象,且只能有一个。
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";
}
禁用赋值重载、拷贝构造
ThreadPool<T> &operator=(const ThreadPool<T> &) = delete;
ThreadPool(const ThreadPool<T> &) = delete;
单例中静态指针
class ThreadPool {
private:
// 单例中静态指针
static ThreadPool<T> *_instance;
};
template <class T> ThreadPool<T> *ThreadPool<T>::_instance = nullptr;
获取单例
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 方法。
// 获取单例
static ThreadPool<T> *GetInstance() {
// ...
}
多线程分别使用单例,不就存在多份的情况?并不是线程安全的,该如何做呢?原子化,加锁。加判断,提高获取单例效率。
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,才能进行后续资源的访问
申请一把锁是原子的,但是申请两把锁就不一定了
造成的结果是



破坏死锁的四个必要条件这里只对最后一点进行破坏
破坏循环等待条件问题:资源一次性分配,使用超时机制、加锁顺序一致
// 定义两个共享资源(整数变量)和两个互斥锁
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 的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同的容器,加锁方式的不同,性能也可能不同 (例如 hash 表的锁表和锁桶)。
因此 STL 默认不是线程安全。若需要在多线程环境使用,往往需要调用者自行保证线程安全。
智能指针是否是线程安全的?
对于 unique_ptr,由于只是在当前代码块范围内生效,因此不涉及线程安全问题。
对于 shared_ptr,多个对象需要共用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作 (CAS) 的方式保证 shared_ptr 能够高效,原子的操作引用计数。
悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和 CAS 操作。
CAS 操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
自旋锁:不死不休地申请这个锁,常用于内核中。


微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online