多线程杂谈:惊群现象、CAS、安全的单例

引言

本文是一篇杂谈,帮助大家了解多线程可能会出现的面试题。

目录

引言

惊群现象

结合条件变量

[CAS原子操作(cmp & swap)](#CAS原子操作(cmp & swap))

线程控制:两个线程交替打印奇偶数

智能指针线程安全

单例模式线程安全

最简单的单例(懒汉)模式


惊群现象

惊群效应(Thundering Herd Effect)是一个在计算机科学和网络领域中常见的现象,特别是在并发编程和分布式系统中。这个效应描述的是当多个进程或者线程几乎同时被唤醒或激活去处理一个任务或事件,但实际上只需要其中的一部分进程或线程来处理,导致资源的浪费和性能的下降。

下面详细解释一下惊群效应的几个关键点:

发生场景

  1. **网络服务中的请求处理 **:在处理网络请求时,如果有大量的请求同时到达,系统可能会唤醒所有的处理线程,但实际上只需要少数线程就能处理这些请求。

  2. **锁竞争 **:在多线程编程中,当多个线程试图获取同一个锁时,一旦锁被释放,所有的等待线程都可能被唤醒,但只有一个线程能够获得锁,其他线程将继续等待。

  3. **事件驱动系统**:在事件驱动的系统中,一个事件可能会使得多个处理者被唤醒,但实际上只需一个处理者处理该事件。

原因

  1. **同步机制**:系统中的同步机制(如信号量、锁等)可能会唤醒所有等待的进程或线程。

  2. **缺乏精细的调度**:调度器没有足够的信息来决定应该唤醒哪些进程或线程,因此默认唤醒所有等待者。

影响

  1. **性能下降**:不必要的进程或线程唤醒会导致上下文切换,增加CPU的负载,降低系统的响应速度和吞吐量。

  2. **资源浪费**:唤醒过多的进程或线程会占用内存和其他系统资源,而这些资源实际上并不需要立即使用。

解决方案

  1. **使用更精细的锁**:比如读写锁,可以允许多个读操作同时进行,而写操作则互斥。

  2. **改进调度算法**:调度器可以根据特定的策略只唤醒必要的进程或线程。

  3. **领导者选举**:在处理事件时,可以先选举一个领导者来处理事件,其他线程保持睡眠状态。

  4. **使用消息队列**:通过消息队列来分配任务,只有当任务到达时才唤醒处理线程。

惊群效应是系统设计时需要考虑的一个重要问题,通过合理的设计和优化,可以有效地避免或减轻这一效应带来的负面影响。

根据之前提到的惊群效应 及其影响,以下是一些具体的解决方案:

使用单线程或有限线程模型:

工作者线程(Worker Threads)模式:预先创建一定数量的工作者线程,每个线程从任务队列中获取并处理任务,避免同时唤醒过多线程。

线程池:通过线程池管理线程,可以限制同时运行的线程数量,避免创建过多的线程。

改进锁机制:

条件变量:使用条件变量来唤醒特定的线程,而不是所有等待的线程。

**读写锁(**Reader-Writer Lock):允许多个读操作同时进行,而写操作则互斥,减少锁竞争。

领导选举机制:

在处理特定任务时,通过选举机制选择一个线程作为领导者来处理任务,其他线程进入等待状态。

事件通知机制:

使用事件通知而不是轮询,只有当特定事件发生时才唤醒相关的线程。

消息队列:

通过消息队列来分配任务,线程可以根据队列中的消息来决定是否需要处理任务,减少不必要的唤醒。

负载均衡:

在分布式系统中,通过负载均衡技术将任务均匀分配到不同的服务器或线程上,避免某些节点过载。

细粒度锁:

使用细粒度锁代替粗粒度锁,减少锁的竞争范围,从而减少同时唤醒的线程数量。

限流和背压机制:

在系统层面实施限流策略,当系统负载过高时,通过背压机制告知上游减少请求发送,避免系统过载。

异步处理:

采用异步编程模型,任务提交后立即返回,实际处理在后台异步进行,减少线程等待和上下文切换。

通过上述解决方案,可以有效地减少惊群效应的发生,提高系统的性能和资源利用率。

结合条件变量

  1. **锁竞争 **:在多线程编程中,当多个线程试图获取同一个锁时,一旦锁被释放,所有的等待线程都可能被唤醒,但只有一个线程能够获得锁,其他线程将继续等待。

为什么把这一条添加进去呢?

这就要牵扯到wait与唤醒机制了。

唤醒时,一旦错误唤醒,就会出现恶性竞争。

CAS原子操作(cmp & swap)

整个处理流程中,假设内存中存在一个变量i,它在内存中对应的值是A(第一次读取),此时经过业务处理之后,要把它更新成B,那么在更新之前会再读取一下i现在的值C,如果在业务处理的过程中i的值并没有发生变化,也就是A和C相同,才会把i更新(交换)为新值B。如果A和C不相同,那说明在业务计算时,i的值发生了变化,则不更新(交换)成B。最后,CPU会将旧的数值返回。而上述的一系列操作由CPU指令来保证是原子的(来自程序新视界)。

在《Java并发编程实践》中对CAS进行了更加通俗的描述:我认为原有的值应该是什么,如果是,则将原有的值更新为新值,否则不做修改,并告诉我原来的值是多少。

线程控制:两个线程交替打印奇偶数

cpp 复制代码
// t1打印奇数
// t2打印偶数
// 交替打印
int main()
{
	mutex mtx;
	int x = 1;
	condition_variable cv;
	bool flag = false;


	// 如果保证t1先运行 condition_variable+flag
	// 交替运行
	thread t1([&]() {
		for (size_t i = 0; i < 10; i++)
		{
			unique_lock<mutex> lock(mtx);
			if (flag)
				cv.wait(lock);

			cout << this_thread::get_id() << ":" << x << endl;
			++x;

			flag = true;

			cv.notify_one(); // t1notify_one的时候 t2还没有wait
		}
		});


	thread t2([&]() {
		for (size_t i = 0; i < 10; i++)
		{
			unique_lock<mutex> lock(mtx);
			if (!flag)
				cv.wait(lock);

			cout << this_thread::get_id() << ":" << x << endl;
			++x;
			flag = false;

			cv.notify_one();
		}
		});

	t1.join();
	t2.join();

	return 0;
}

这是一道面试题,要求两个线程按顺序打印。

怎么保证线程一先运行呢?条件变量 + flag。(第一次不让线程一wait,而是线程2wait)

第一次的notify_one不起作用。

智能指针线程安全

cpp 复制代码
template<class T>
	class shared_ptr
	{
	public:
		// RAII
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			,_pcount(new atomic<int>(1))
		{}

		template<class D>
		shared_ptr(T* ptr, D del)
			:_ptr(ptr)
			, _pcount(new atomic<int>(1))
			, _del(del)
		{}

		// function<void(T*)> _del;

		void release()
		{
			if (--(*_pcount) == 0)
			{
				//cout << "delete->" << _ptr << endl;
				//delete _ptr;
				_del(_ptr);

				delete _pcount;
			}
		}

		~shared_ptr()
		{
			release();
		}

		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)
		{
			++(*_pcount);
		}

		// sp1 = sp3
		shared_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			if (_ptr != sp._ptr)
			{
				release();

				_ptr = sp._ptr;
				_pcount = sp._pcount;

				++(*_pcount);
			}

			return *this;
		}

		// 像指针一样
		T& operator*()
		{
			return *_ptr;
		}

		T* operator->()
		{
			return _ptr;
		}

		int use_count() const
		{
			return *_pcount;
		}

		T* get() const
		{
			return _ptr;
		}

	private:
		T* _ptr;
		//int* _pcount;
		atomic<int>* _pcount;

		function<void(T*)> _del = [](T* ptr) {delete ptr; };
	};

智能指针在拷贝构造的时候,内部的计数器++。万一两个线程都对智能指针调用拷贝构造,那么计数器就会错乱。

我们可以:1.上锁 2.atomic保护智能指针。

在目前的C++中,更新了如下的关于智能指针的安全性:

  • 引用计数的线程安全性std::shared_ptr对其内部的引用计数的操作(增加或减少)是线程安全 的。这意味着多个线程可以安全地共享和复制同一个 std::shared_ptr 实例,而无需额外的同步机制。例如,在不同线程中拷贝同一个 std::shared_ptr 实例不会导致数据竞争。

  • 对象内容的线程安全性std::shared_ptr 不会对其管理的对象的内容进行任何保护 ,如果多个线程同时读写由 std::shared_ptr 管理的对象,那么就需要手动确保对该对象的访问是线程安全的。--只是提供RAII封装

  • 实例本身的线程安全性 :对同一个 std::shared_ptr 实例的读写操作(例如,赋值和重置)是不安全的,需要额外的同步。

注意:shared_ptr(这个指针类)本身是线程安全的,但是他RAII指向的资源操作的时候不能保证线程安全。

我们可以理解为访问shared_ptr这个"壳子"的时候,是线程安全的,但是对"壳子"包含的对象不安全。

单例模式线程安全

懒汉模式的线程安全:由于即用即取,万一两个线程并发进行懒汉申请,那么就会出现线程安全,加锁就可以。

cpp 复制代码
//2、提供获取单例对象的接口函数
static Singleton& GetInstance(){
if(_pslnst==nullptr)
{
//t1 t2
unique_lock<mutex>lock(_mtX);
if(_psinst==nullptr)
{
//第一次调用Getlnstance的时候创建单例对象
_psinst=newSingleton;
}
}
return*_psinst;
}

当后续存在单例之后,就需要重复的申请锁,减少了资源消耗。

同时双重判断也提供了保险机制。

最简单的单例(懒汉)模式

cpp 复制代码
//懒汉
class Singleton {
public:
    // 2、提供获取单例对象的接口函数
    static Singleton* GetInstance() {
        // 局部的静态对象,是在第一次调用时初始化
        static Singleton inst;
        return &inst;
    }
private:
    // 1、构造函数私有
    Singleton() {
        cout << "Singleton()" << endl;
    }
};

私有构造--静态方法获取static实例。

注意:这是线程安全的!--静态局部对象只初始化一次!

局部的静态对象,是在第一次调用时初始化

C++11之前,他不是,也就说,C++11之前的编译器,那么这个代码不安全的

C++11之后可以保证局部静态对象的初始化是线程安全的,只初始化一次(不会获得两个实例)

相关推荐
汤姆和杰瑞在瑞士吃糯米粑粑17 分钟前
【C++学习篇】滑动窗口--结合例题讲解思路
开发语言·数据结构·c++·算法·散列表
小王努力学编程23 分钟前
【C++篇】红黑树封装 实现map和set
java·开发语言·c++
我们的五年34 分钟前
【Linux课程学习】:锁封装(Mutex)线程封装(Thread),this指针
linux·服务器·c语言·c++·学习
字节高级特工1 小时前
【优选算法】3----快乐数
c++·算法
奇树谦1 小时前
C++|开源日志库log4cpp和glog
开发语言·c++·开源
dot to one1 小时前
C++入门 详细版
c语言·开发语言·汇编·c++
mljy.2 小时前
C++《AVL树》
数据结构·c++
香菇滑稽之谈2 小时前
关于回调函数(callback)
c++·回调函数·函数指针
不玩return的马可乐2 小时前
蓝桥杯 单词重排
开发语言·数据结构·c++·算法·leetcode·职场和发展·蓝桥杯
被二进制支配的打工人2 小时前
【STL】list 双向循环链表的使用介绍
数据结构·c++·链表·stl·list