【C++】线程库

发布于:2024-07-03 ⋅ 阅读:(22) ⋅ 点赞:(0)

        在 C++11 问世之前,多线程基本和平台关系密切,不同的平台下多线程各有特点,使得 Windows 和 Linux 下必须拥有各自的系统调用接口,但同时也使得代码的可移植性较差。

        C++11中最重要的特性,就是支持了多线程编程,使 C++ 代码在并行编程时无须依赖第三方库,同时针对原子性操作引入了原子类的概念,大大提升了代码的跨平台性。

        本篇博客整理 C++ 线程库的概念和相关接口,旨在让读者了解并熟悉 C++ 下的多线程编程。

(欲知线程、线程控制和多线程的概念,请移步至:【Linux系统】线程与线程控制-CSDN博客 或 【Linux系统】多线程-CSDN博客

目录

一、线程库封装了不同平台的接口

二、线程类 thread

1)创建 thread 对象

.1- 无参构造

.2- 带参构造

.3- 移动构造

2)thread 类提供的非默认成员函数

.1- 获取线程TID

.2- 判定线程是否是有效

.3- 阻塞和分离

3)线程例程的传参问题

.1- 借助 std::ref()

.2- 地址的拷贝

.3- 借助 lambda 表达式

三、互斥量库 mutex

1)四种互斥量

.1- std::mutex

.2- std::recursive_mutex

.3- std::timed_mutex

.4- std::recursive_timed_mutex

2)RAII 锁

.1- 死锁问题

.2- lock_guard

.3- unique_lock

四、原子性操作库 atomic

1)线程安全问题

​编辑

2)解决方案

.1- 加锁

.2- 原子性操作类型

.补- 原子性操作的原理

五、条件变量库 condition_variable

1)wait 等待系列

2)notify 唤醒系列

补、实现两个线程交替打印数字 1 到 10

补、shared_ptr 的线程安全

补、懒汉模式的线程安全 


一、线程库封装了不同平台的接口

        由于在 Linux 操作系统中,线程的相关接口的遵循了 POSIX 标准(Portable Operating System Interface of UNIX,可移植操作系统接口),因此 Linux 中与线程有关的代码也只在具备POSIX 标准的操作系统之间具备可移植性。

//Linux 创建5个线程
#include<iostream>
#include<pthread.h>
#include<vector>
//线程例程
void* function(void* arg)
{
    int* tid = (int*)arg;
    printf("I am %d thread\n", *tid);
    delete tid;
    return nullptr;
}
const int num = 5;
int main()
{
    std::vector<pthread_t> tids(num);
    for(int i = 0; i < num; i++)
    {
        //创建线程
        pthread_create(&tids[i],nullptr,function,new int(i));
    }
    for(int i = 0; i < num; i++)
    {
        //回收线程
        pthread_join(tids[i],nullptr);
    }
    return 0;
}

        而在 Windows 操作系统中,线程的相关接口也是 Windows 独有的 API 接口(Application Programming Interface,程序间的接口),也遵循了 POSIX 标准,同样的, Windows 中与线程有关的代码也只在具备POSIX 标准的操作系统之间具备可移植性。

//Windows 创建5个线程
#include<iostream>
#include<Windows.h>
//线程例程
DWORD WINAPI ThreadFunction(LPVOID args)
{
	int* tid = (int*)args;
	printf("I am %d thread\n", *tid);
	delete tid;
	return 0;
}

int main()
{
	const int num = 5;
	HANDLE threads[num];
	for (int i = 0; i < num; i++)
	{   
        //创建线程
		threads[i] = CreateThread(nullptr, 0, ThreadFunction, new int(i), 0, nullptr);

		if (threads[i] == nullptr)
		{
			std::cout << "create thread fail" << std::endl;
			return 1;
		}
	}
	//回收线程
	WaitForMultipleObjects(num, threads, TRUE, INFINITE);

	for (int i = 0; i < num; i++)
	{
        //释放线程
		CloseHandle(threads[i]);
	}
	return 0;
}

        从以上代码可以看到,Linux 和 Windows 下的线程接口哪怕功能类似,它们的实现(名称、参数、返回值等)也各有特点。

        为了提升不同平台下代码的跨平台性,C++11 通过类似条件编译的形式,将不同平台的接口封装为一个线程库,从而实现了“一码多用”。

//C++11 创建5个线程
#include<thread>
#include<iostream>
#include<vector>
void function(int i)
{
	printf("I am %d thread.\n", i);
}
int main()
{
	const int num = 5;
	std::vector<std::thread> threads(num);
	for (int i = 0; i < num; i++)
	{
        //创建线程
		threads[i] = std::thread(function, i);
	}
    //回收线程
	for (auto& thd : threads)
	{
		thd.join();
	}
	return 0;
}

Windows下 VS2022 编译后的运行结果:

Linux(CentOS 7.6版本)下 g++ 编译后的运行结果:

二、线程类 thread

        C++11将线程封装成了线程类 thread,并将不同平台下的线程接口封装成了线程类 thread的成员函数,由此,只需要实例化一个 thread 类,就可以通过实例化对象所暴露的外部接口对线程进行操作。

1)创建 thread 对象

【Tips】关于 thread 类提供的构造函数

  1. 线程是一个操作系统层面的概念,thread 对象可以从语言层面关联一个系统层面的线程,具体的方式是让 thread 对象与线程的例程建立关联(线程的执行函数)并以此控制线程以及获取线程的状态。
  2. 如果一个 thread 对象在被创建时,没有为其提供线程例程,那么这个 thread 对象实际没有关联任何线程;如果一个 thread 对象在被创建时,为其提供了线程例程,那么就会启动一个线程来执行这个例程,且这个启动的线程与主线程一起运行。
  3. thread 类是防拷贝的,不允许拷贝构造和拷贝赋值仅允许移动构造和移动赋值,因此,可以将一个 thread 对象与一个例程的关联状态转移给其他 thread 对象,且转移期间不会影响例程的执行。 

.1- 无参构造

        thread 类提供了无参的构造函数。由无参构造所实例化的 thread 对象没有关联任何线程例程。

thread t1;

        但这并不意味着无参构造没有任何意义,例如当后续需要该 thread 对象与线程例程建立关联时,仅需通过 thread 提供的移动构造和移动赋值创建一个匿名对象,同时将匿名对象的关联状态转移给该 thread 对象即可。 

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t1;

	//...

	t1 = thread(func, 10);

	//...

	return 0;
}

.2- 带参构造

        thread 类也提供了带参的构造函数,可以通过指定的线程例程来创建和关联一个 thread 对象。

//带参构造的定义:
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
//其中,
//fn是线程例程的模板(包括函数指针、仿函数、lambda表达式、经包装器处理过的可调用对象等)
//args... 是可变参数模板
//调用示例
void ThreadFunc(int a)
{
     cout << "Thread1" << a << endl;
}

class TF
{
public:
 void operator()()
 {
     cout << "Thread3" << endl;
 }
};

int main()
{
    // 线程例程为函数指针
     thread t1(ThreadFunc, 10); 
   
    // 线程例程为lambda表达式
     thread t2([]{cout << "Thread2" << endl; });   
 
    // 线程例程为函数对象
     TF tf;
     thread t3(tf);
    
    //...

     return 0;
}

.3- 移动构造

        thread 类提供了支持右值引用的移动构造函数,配合移动赋值,能够用一个作为右值的 thread 对象来实例化另一个新的 thread 对象。

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t3 = thread(func, 10);

	//...

	return 0;
}

2)thread 类提供的非默认成员函数

get_id 获取 thread 对象关联的线程TID
swap     交换将两个 thread 对象的关联状态
joinable 判断一个 thread 对象是否有效,是则返回 true,否则返回 false
join 阻塞等待一个线程
detach 分离一个线程

.1- 获取线程TID

         get_id() 可以获取当前 thread 对象所关联的线程的TID。

#include<thread>
#include<iostream>
#include<vector>
using namespace std;
void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t(func, 10);
	thread::id tid = t.get_id();
	cout << tid << endl;

	//...

	return 0;
}

        如果要在线程例程中获取线程id,就需要指定 this_thread 命名空间下的 get_id()。


void func()
{
	cout << this_thread::get_id() << endl; 
    //此时哪个线程执行这条语句就返回哪个线程的tid值
}
int main()
{
	thread t(func);

	//...
	return 0;
}

        这是因为,get_id() 其实是定义在 this_thread 命名空间中的,而该命名空间中的其他接口也是类似的用法,主要针对执行该接口的线程来使用。

【补】 this_thread 命名空间中的接口

  • yield():当前线程“放弃”执行,让 CPU 调度另一线程继续执行。
  • sleep_until():让当前线程休眠到一个具体时间点。
  • sleep_for():让当前线程休眠一个时间段,例如 1 秒。

.2- 判定线程是否是有效

        joinable() 可以判断一个 thread 对象是否有效,如果 thread 对象满足以下任意情况,则是无效的:

  • 通过无参构造创建,则这个 thread 对象没有关联任何线程。
  • 一个 thread 对象的关联状态已转移给另一个 thread 对象,则其原本关联的线程已被交给其他 thread 对象管理。
  • thread 对象已调用 join() 或 detach() 终止线程。

.3- 阻塞和分离

        一个线程可以被创建后,也相应地需要被回收,否则会导致内存泄露等问题。

        thread 类中提供了阻塞和分离两种回收线程的方式,它们分别对应成员函数 join() 和 detach()。

  • 阻塞 join()

        join() 主要针对于关联了线程的 thread 对象,后续回收它所关联的线程资源。一个 thread 对象调用 join() 会使自身暂时处于不可关联的状态,并将原本关联的线程安全地销毁。

【ps】调用 join() 的注意事项

        一个 thread 对象在关联了一个线程后,只允许调用一次 join(),否则会导致程序崩溃。

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t(func, 20);
	t.join();
	t.join(); //程序崩溃
	return 0;
}

        除非一个已关联线程的 thread 对象在调用一次 join() 后,又经历了一次移动赋值,此时才可以又调用一次 join()。

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t(func, 20);
	t.join();

	t = thread(func, 30);//移动赋值使 t 又关联了一个线程
	t.join(); //关联了线程的 thread 对象可以安全地调用 join()
	return 0;
}

【补】RAII 方式回收线程

        调用 join() 可以回收线程资源并终止线程,但如果一个线程在执行 join() 所在的代码语句之前被中途切走或中断,导致无法执行 join() ,就会留下一些内存资源上的隐患。例如以下这样一个情景:

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
bool DoSomething()
{
	return false;
}
int main()
{
	thread t(func, 20);

	
	if (!DoSomething())
		return -1;
	

	t.join(); //不会被执行
	return 0;
}

        为了避免这个问题,可以采用 RAII 的方式对 thread 对象进行封装,利用对象的生命周期特性来控制线程资源的释放。

class myThread
{
public:
	myThread(thread& t)
		:_t(t)
	{}
	~myThread() 
	{
		if (_t.joinable())
			_t.join(); // 利用对象的生命周期特性来控制线程资源的释放
	}
	//防拷贝
	myThread(myThread const&) = delete;
	myThread& operator=(const myThread&) = delete;
private:
	thread& _t;
};
//1.每当创建一个thread对象后,就用一个类将其封装。
//2.当封装的类所实例化的对象,生命周期结束时,就会调用析构函数,
//  在析构中调用join()来回收线程。

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
bool DoSomething()
{
	return false;
}
int main()
{
	thread t(func, 20);
	myThread mt(t); //使用一个类将thread对象封装起来

	if (!DoSomething())
		return -1;

	//t.join(); //就算被切走/中断,join()也能够正常调用
	return 0;
}

  •   分离 detach()

         detach()可以将一个 thread 对象所关联的线程放在在后台运行,将线程的所有权和控制权交给C++运行库,让C++运行库去保证线程能够被正确地回收,使这个线程与主线程分离,能够被自动回收。

【ps】调用 detach() 的注意事项

        detach()的调用一般即刻发生在thread对象关联了一个线程后。如果 thread 对象因某些原因,在后续调用 detach() 之前被销毁,就会导致程序崩溃。

3)线程例程的传参问题

        thread 对象的线程例程的参数通过值拷贝的方式,被拷贝进其所关联的线程的栈空间中,因此,例程中的参数(形参)哪怕是引用类型,实际引用的也是线程栈空间中的拷贝,在例程中对参数进行修改操作,并不会影响到例程外部的实参。

#include<thread>
#include<iostream>
using namespace std;
void ThreadFunc1(int& x)
{
	x += 10;
}
int main()
{
	int a = 10;

	thread t(ThreadFunc1, a);
	t.join();
	cout << a << endl; //10
	return 0;
}

        如果希望通过线程例程的形参改变外部的实参,可以参考以下三种方式:

  1. 借助 std::ref()
  2. 地址的拷贝
  3. 借助 lambda 表达式

.1- 借助 std::ref()

        当线程例程的参数为引用类型时,std::ref() 可以做到在传入实参时保持对实参的引用,而非栈空间中的拷贝。

void ThreadFunc1(int& x)
{
	x += 10;
}
int main()
{
	int a = 10;

	thread t(ThreadFunc1, std::ref(a));
	t.join();
	cout << a << endl; //20
	return 0;
}

.2- 地址的拷贝

        当线程例程的参数为指针类型时,线程栈空间中拷贝的是一个地址,这个拷贝的地址和外部所传的地址是一致的,对这个拷贝地址的操作也等效于对外部所传地址的操作,因此,只要将实参的地址传入,就可以通过修改该地址上的变量进而影响到外部实参。

#include<thread>
#include<iostream>
using namespace std;

void ThreadFunc2(int* x)
{
	*x += 10;
}

int main()
{
	int a = 10;

	thread t2(ThreadFunc2, &a);
	t2.join();
	cout << a << endl; //20
	return 0;
}

.3- 借助 lambda 表达式

        将 lambda 表达式作为线程例程,就可以通过 lambda 的捕捉列表,以引用的方式捕捉外部实参,使 lambda 表达式中形参的改变也能影响到外部实参。

#include<thread>
#include<iostream>
using namespace std;

int main()
{
	int a = 10;

	thread t3([&a] {a+=10; });
	t3.join();
	cout << a << endl; //20
	return 0;
}

三、互斥量库 mutex

        与线程库类似,C++11也将线程互斥相关的互斥量、接口封装成了 mutex 类,以更好地支持线程互斥代码的可移植性。

1)四种互斥量

        mutex 类一共包含四种互斥量,它们分别是mutex、std::recursive_mutex、timed_mutex、recursive_timed_mutex。

.1- std::mutex

        mutex 是 C++11 提供的最基本的互斥量,mutex 对象之间不能进行拷贝和移动,常用的成员函数有 lock()、try_lock()、unlock()。

lock     对互斥量进行加锁
try_lock 尝试对互斥量进行加锁
unlock 对互斥量进行解锁,释放互斥量的所有权

【补】加锁的情况说明

        加锁和解锁操作一般在线程例程中完成。 

        当一个例程中调用 lock() 加锁时,可能会发生以下情况:

  • 如果要加锁的互斥量当下没有被其他线程锁住,则让当前线程锁住互斥量,在该线程调用 unlock() 解锁之前,该线程将一直持有该锁。
  • 如果要加锁的互斥量已经被其他线程锁住,则当前调用 lock() 的线程会被阻塞。
  • 如果要加锁的互斥量已经被其他线程锁住,且当前 lock() 调用成功,即该互斥量又被当前线程锁住,则会产生死锁。

        当一个例程中调用 try_lock() 加锁时,可能会发生以下情况:

  • 如果要加锁的互斥量当下没有被其他线程锁住,则让当前线程锁住互斥量,在该线程调用 unlock() 解锁之前,该线程将一直持有该锁。
  • 如果要加锁的互斥量已经被其他线程锁住,则 try_lock() 直接返回false,当前调用 try_lock() 的线程不会被阻塞。
  • 如果要加锁的互斥量已经被其他线程锁住,且当前 try_lock() 调用成功,即该互斥量又被当前线程锁住,则会产生死锁。
//示例:让两个线程加锁并各自输出1-10
#include<thread>
#include <mutex>
#include<iostream>
using namespace std;

void func(int n, mutex& mtx)
{
	mtx.lock(); //for循环体外加锁可以节省资源的申请开销
	for (int i = 1; i <= n; i++)
	{
		cout << i << endl;
	}
	mtx.unlock();
}
int main()
{
	mutex mtx;
	thread t1(func, 10, ref(mtx));
	thread t2(func, 10, ref(mtx));

	t1.join();
	t2.join();
	return 0;
}

//未加锁时的输出结果
#include<thread>
#include <mutex>
#include<iostream>
using namespace std;

void func(int n, mutex& mtx)
{
	//mtx.lock(); 
	for (int i = 1; i <= n; i++)
	{
		cout << i << endl;
	}
	//mtx.unlock();
}
int main()
{
	mutex mtx;
	thread t1(func, 10, ref(mtx));
	thread t2(func, 10, ref(mtx));

	t1.join();
	t2.join();
	return 0;
}

.2- std::recursive_mutex

        recursive_mutex 是一种递归互斥锁,专门针对递归的线程例程中的加锁,recursive_mutex也提供了 lock()、try_lock()、unlock(),其特性与 mutex 提供的大致相同。

        如果在递归例程中使用 mutex 互斥量进行加锁,那么在例程进行递归调用时,可能会因重复申请已申请的锁而导致死锁问题。recursive_mutex 允许同一个线程对互斥量多次上锁(即递归上锁),以获得互斥量对象的多层所有权,但在解锁时,需要调用与该锁层次深度相同的unlock()。

.3- std::timed_mutex

        timed_mutex 在 mutex 的基础上增加了计时功能,可以让一个线程定时申请锁。

        除了 lock()、try_lock()、unlock() 之外,timed_mutex 还提供了 try_lock_for()、try_lock_until()。

        try_lock_for() 的参数是一个时间范围,用于线程在锁释放后申请锁。一个线程在某一段时间之内,如果没有获得锁,则该线程会被阻塞住;如果在此期间其他线程释放了锁,则该线程可以进行加锁;如果超时(在这一段时间之内没有获得锁),则返回false。
        try_lock_untill() 的参数是一个时间点,也用于线程在锁释放后申请锁。一个线程在指定的时间点到来之前,如果没有获得锁,则该线程会被阻塞住;如果在此期间其他线程释放了锁,则该线程可以进行加锁;如果超时(在这一段时间之内没有获得锁),则返回false。

.4- std::recursive_timed_mutex

        recursive_timed_mutex 相当于 recursive_mutex 和 timed_mutex 的结合版本,既支持在递归例程中加锁,又支持定时申请锁。

2)RAII 锁

.1- 死锁问题

        在使用一个互斥量时,如果临界区太大(加锁和解锁操作之间的代码),在临界区中又有线程例程的返回语句(在解锁前返回),或者临界区中抛了异常,那么后续申请这个互斥量的线程就会被阻塞住,进而导致死锁问题。

mutex mtx;
void func()
{
	mtx.lock();//加锁

	//...

	FILE* fout = fopen("data.txt", "r");
	if (fout == nullptr)
	{
		//...
		return; //中途返回(尚未解锁)
	}

	//...

	mtx.unlock();//解锁
}
int main()
{
	func();
	return 0;
}

        为了避免上述问题,C++11 采用了 RAII 方式将互斥量分别封装为了 lock_guard、unique_lock。 

        所谓 RAII,是一种设计思想,通过对象的生命周期的特性(定义时自动调用构造来创建,出作用域时自动调用析构来销毁),来更好地管理资源。

.2- lock_guard

        lock_guard 本质是一个模板类:

//lock_guard的定义
template <class Mutex>
class lock_guard;

        在需要加锁的位置,可以用互斥量实例化一个 lock_guard 对象,来代替对互斥量加锁的操作。

        在创建 lock_guard 对象时会自动调用构造函数,而在 lock_guard 类的构造函数中会调用 lock() 进行加锁;当 lock_guard 对象出作用域时会自动调用析构函数,而在 lock_guard 类中的析构函数中会调用 unlock() 进行解锁。

mutex mtx;
void func()
{
	lock_guard<mutex> lg(mtx); //自动调用构造加锁

	//...

	FILE* fout = fopen("data.txt", "r");
	if (fout == nullptr)
	{
		//...
		return; //自动调用析构解锁
	}

	//...

} //自动调用析构解锁

int main()
{
	func();
	return 0;
}

        如果想用 lock_guard 对指定的一部分代码进行保护,可以定义匿名的局部域来控制 lock_guard 对象的生命周期。

mutex mtx;
void func()
{
	//...

	//匿名局部域
	{
		lock_guard<mutex> lg(mtx); //自动调用构造加锁
		FILE* fout = fopen("data.txt", "r");
		if (fout == nullptr)
		{
			//...
			return; //自动调用析构解锁
		}
	} //自动调用析构解锁

	//...
}
int main()
{
	func();
	return 0;
}

【补】模拟实现 lock_guard 

//1.lock_guard类中包含一个互斥量(引用类型)
//2.在构造中加锁,在析构中解锁
//3.一定要防拷贝
namespace NeeEko
{
	template<class Mutex>
	class lock_guard
	{
	public:
        //构造
		lock_guard(Mutex& mtx)
			:_mtx(mtx)
		{
			mtx.lock(); //加锁
		}
        //析构
		~lock_guard()
		{
			mtx.unlock(); //解锁
		}
        //防拷贝
		lock_guard(const lock_guard&) = delete;
		lock_guard& operator=(const lock_guard&) = delete;
	private:
		Mutex& _mtx;
	};
}

.3- unique_lock

        虽然 lock_guard 能够避免死锁问题,但用法过于单一,用户无法直接控制互斥量,无法灵活地使用。对此,C++11 又提供了 unique_lock。

        与 lock_guard 类似的是,unique_lock 也可以在自动调用构造时加锁、在自动调用析构时解锁;但 与 lock_guard 不同的是,unique_lock 提供了更多的成员函数,可以更加灵活地控制互斥量。

【补】unique_lock 的成员函数:

  • 加锁/解锁:lock()、try_lock()、try_lock_for()、try_lock_until() 、unlock()。
  • 修改:移动赋值、swap()、release()(返回它所管理的互斥量对象的指针,并释放所有权)。
  • 获取属性:owns_lock()(返回当前对象是否上了锁)、operator bool()(与owns_lock()的功能相同)、mutex()(返回当前unique_lock所管理的互斥量的指针)。

【补】unique_lock 的使用示例:

        如图,func2() 在 func1() 内部调用,其中,func1() 的大部分代码需要加锁保护,但调用func2() 的部分不需要用到保护 func1() 的锁,调用 func2() 的部分和 func2() 的内部则由其他锁来保护。

        因此,在调用 func2() 之前需要对保护 func1() 的锁进行解锁,当 func2() 的调用返回后再重新加锁,如此一来,在调用 func2() 时,如果存在其他线程调用 func1() ,就能使其他线程也可以持有这个保护 func1() 的锁。

四、原子性操作库 atomic

1)线程安全问题

        多线程之间能够共享一份数据,每个线程都可以对这份数据做访问活修改操作。如果共享数据都是只读的,多线程就只对数据做访问操作,不会涉及修改操作,因此所有线程都能够访问到同样的数据。但当多个线程要对共享数据做修改操作时,就可能引起数据不一致问题,即线程安全问题。

        例如下面这个示例:

#include<thread>
#include <mutex>
#include<iostream>
using namespace std;

//每个线程对n++的次数
void func(int& n, int times)
{
	for (int i = 0; i < times; i++)
	{
		n++;
	}
}
int main()
{
	int n = 0;
	int times = 100000;
    //创建两个线程
	thread t1(func, ref(n), times);
	thread t2(func, ref(n), times);

	t1.join();
	t2.join();
	cout << n << endl; //打印n的值
	return 0;
}

        按理来说,两个线程都对 n 进行了 ++ 操作,最终结果应该是 200000,可最终结果却不到预期的 200000。

         这其实是因为,++ 操作并不是一个原子操作,它本身可以转化成三句汇编语句,对应三步操作:

  1. load:将共享变量 n 从内存加载到寄存器中。
  2. updata:更新寄存器里面的值,执行 +1 操作。
  3. store:将新值从寄存器写回共享变量 n 的内存地址。

         多线程是并发运行的,当线程 1 刚完成 load 操作(++ 操作的第一步),就可能因 CPU 的调度而被切走,换上线程 2 继续执行;线程 2 可能顺利完成了 load 操作、updata 操作、store 操作(一次完整的 ++ 操作),然后被切走,换上线程 1 ,由线程 1 继续完成剩余的两步 updata 操作 和 store 操作。

        于是,虽然两个线程都对共享变量 n 执行了一次 ++ 操作,但整个过程中 n 的值却只被 ++ 了一次......循环往复,n 的值最后也不足预期的 200000 了。这就是线程安全问题。

2)解决方案

.1- 加锁

        为避免线程安全问题,可以对共享数据相关的临界区进行加锁保护。

void func(int& n, int times, mutex& mtx)
{
	mtx.lock();
    //在for循环体外进行加锁解锁,虽然可以节省申请锁的开销
    //但会使两个线程的执行逻辑从并行变为串行,
    //如果对锁的控制不得当,还很容易造成死锁
	for (int i = 0; i < times; i++)
	{
		//mtx.lock();

		n++;

		//mtx.unlock();
	}
	mtx.unlock();
}
int main()
{
	int n = 0;
	int times = 100000; 
	mutex mtx;
	thread t1(func, ref(n), times, ref(mtx));
	thread t2(func, ref(n), times, ref(mtx));

	t1.join();
	t2.join();
	cout << n << endl; 
	return 0;
}

.2- 原子性操作类型

        为了避免线程安全问题和更好地支持线程同步,C++11 引入了原子性操作类型和原子性操作库 atomic 中。

        除了可以使用已定义好的原子性操作类型,还可以使用 atomic 模板类自行定义任意的原子类型。

【ps】原子类型变量的初始化需用到 C++11 的 { } 初始化列表方式。

void func(atomic_int& n, int times)
{
	for (int i = 0; i < times; i++)
	{
		n++;
	}
}
int main()
{
	atomic_int n = { 0 };
	int times = 100000;
	thread t1(func, ref(n), times);
	thread t2(func, ref(n), times);

	t1.join();
	t2.join();
	cout << n << endl; 
	return 0;
}

【补】相关说明

  • 原子类型通常属于“资源类型”数据,多个线程只能访问单个原子类型的拷贝,因此在 C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及赋值重载等,为防止意外,标准库已经将 atomic 模板类中的拷贝构造、移动构造、赋值重载 delete 掉了。
  • 原子类型不仅仅支持原子的 ++ 操作,还支持原子的 --、加一个值、减一个值、与、或、异或操作等。
void func(atomic<int>& n, int times)
{
	for (int i = 0; i < times; i++)
	{
		n++;
	}
}
int main()
{
	atomic<int> n = 0;
    //int n = atomic(0); //也可以这样写
	int times = 100000;
	thread t1(func, ref(n), times);
	thread t2(func, ref(n), times);

	t1.join();
	t2.join();
	cout << n << endl;
	return 0;
}

.补- 原子性操作的原理

        原子性操作的原理是 CAS(compare and swap)。

        CAS包含三个操作数:内存位置的原值(V),预期原值(A)和新值(B),它们与 CPU 的关系如下:

  • 若 V 与 A 相等,则 CPU 会将 V 更新为 B。
  • 若 V 与 A 不相等,则 CPU 不会做任何操作。

        以两个线程 t1 和 t2 同时对变量 val 进行 ++ 操作为例, t1 和 t2 都会将 val 的值送入加法器 eax 中,同时将当前 val 在内存中的值设为 V ,在正式修改 val 值之前,CPU 会先判断 eax 中的 val 值与 V 是否相等,若相等(说明操作不是原子的了)则将 eax 中的值进行 ++ 操作得到新值 B,然后将 V 和 val 的值更新为 B ,若不相等(说明操作还是原子的)则不修改。

//伪代码过程:
while(1)
{
	eax = val;       // 将 val 值取到寄存器 eax 中
	if(eax == V)     // eax 中的值和 V 相同则可以修改
	{
		eax++;
		V = eax;     // 修改 V 为 B
		val = eax;   // 修改 val 的值为 B
		break;       // 访问结束,跳出循环
	}
}

        t1 和 t2 虽然是并行的,但从极小的时间粒度的角度去看,CPU 仍然是挨个在执行它们。首先,t1 将 val 值送到 eax 中,并对 V 赋值,判断的条件为真,于是修改 val 的值,并放回到 val 的地址中;然后, t2 被唤醒,将 val 值送到 eax 中后,发现 eax 中的值与最开始的 V 不同了,于是不对 val 的值做修改,继续循环,直到 eax 中的值和 V 相等才做修改。

        总得来说,虽然原子性操作能够保证线程安全,但另一个无法写的线程会因不停执行循环而陷入等待,就会占用一定的 CPU 资源。

五、条件变量库 condition_variable

        为了更好地支持线程同步,C++11将不同平台下的条件变量的相关接口封装成了条件变量库 condition_variable。

        condition_variable 中提供的成员函数,主要分为 wait 系列和 notify 系列。 

1)wait 等待系列

        wait 系列包括 wait()、wait_for() 、wait_until(),其作用就是让线程在一个条件变量下进行阻塞等待。

        其中最常用的是 wait()。

【Tips】wait() 的使用手册

        wait() 提供了两个不同版本的接口:

//版本一
void wait(unique_lock<mutex>& lck);
//版本二
template<class Predicate>
void wait(unique_lock<mutex>& lck, Predicate pred);

        调用版本一时,只需传入一个 unique_lock 类型的互斥锁,线程在调用后会立即被阻塞,等待被唤醒。需要传入一个互斥锁是因为,wait() 的调用一般发生在临界区中,为了让当前线程在阻塞时,其他线程也能够获取到锁,wait() 专门将一个互斥锁作为参数,使当前线程在被阻塞时,互斥锁能够自动解锁,而等该线程被唤醒时,又能自动持有这个互斥锁。由此,wait() 实际上有两个功能,一个是让线程在条件不满足时在一个条件变量下进行阻塞等待,另一个是让线程原本持有的互斥锁自动解锁或再次被线程持有。
        调用版本二时,除了需要传入一个 unique_lock 类型的互斥锁,还需要传入一个返回类型为 bool 的可调用对象。与版本一不同的是,当调用版本二阻塞的线程被唤醒后,还需要调用这个 bool 的可调用对象,如果可调用对象的返回值为 false,那么该线程还会继续被阻塞。

【补】wait_for() 和 wait_until() 的相关说明

        wait_for() 也提供了两个版本的接口,且这两个版本的接口都比 wait() 对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒。


        wait_until() 也提供了两个版本的接口,且这两个版本的接口都比 wait() 对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,如果超过这个时间点则线程被自动唤醒。
        线程调用wait_for() 或 wait_until() 在阻塞等待期间,其他线程调用 notify 系列也可以将其唤醒。

        此外,如果调用的是 wait_for() 或 wait_until() 的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么当前线程还会继续被阻塞。

2)notify 唤醒系列

        notify 系列包括 notify_one() 和 notify_all(),其作用是唤醒阻塞等待中的线程。

        一个条件变量下可能会有多个线程在进行阻塞等待,这些线程会被放到一个等待队列中进行排队——

  • notify_one():唤醒等待队列中的首个线程,如果等待队列为空则什么也不做。

  • notify_all():唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。

补、实现两个线程交替打印数字 1 到 10

#include<thread>
#include <mutex>
#include<iostream>
using namespace std;
int main()
{
	int val = 0;
	int n = 10;              // 打印的范围
	mutex mtx;               // 创建互斥锁
	condition_variable cond; // 创建条件变量
    //t1打印奇数
	thread t1([&](){
		while (val < n)
		{
			unique_lock<mutex> lock(mtx); // 加锁
			while (val % 2 == 0)// 判断是否是偶数
			{
				// 是偶数则放入等待队列中等待
				cond.wait(lock);
			}
			// 是奇数时打印
			cout << "thread1:" << this_thread::get_id() << "->" << val++ << endl;
			cond.notify_one(); // 唤醒等待队列中的一个线程去打印偶数
		}
	});
 
	this_thread::sleep_for(chrono::microseconds(100));

    //t2打印偶数
	thread t2([&](){
		while (val < n)
		{
			unique_lock<mutex> lock(mtx);
			while (val % 2 == 1)
			{
				cond.wait(lock);
			}
			cout << "thread2:" << this_thread::get_id() << "->" << val++ << endl;
			cond.notify_one();//唤醒等待队列中的一个线程去打印奇数
		}
	});
 
	t1.join();
	t2.join();
 
	return 0;
}

补、shared_ptr 的线程安全

(关于 shared_ptr 的更多说明,请见【C++】智能指针-CSDN博客

namespace CVE
{
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			: _ptr(ptr)
			, _pCount(new int(1))
			,_pMtx(new mutex)
		{}
 
		shared_ptr(const shared_ptr<T>& sp)
			: _ptr(sp._ptr)
			, _pCount(sp._pCount)
			, _pMtx(sp._pMtx)
		{
			_pMtx->lock();
			(*_pCount)++;
			_pMtx->unlock();
		}
 
		shared_ptr<T>& operator=(const shared_ptr<T>& sp)
		{

			//if (this != &sp)
			if (_ptr != sp._ptr) // 防止自己给自己赋值,注意不能比较this,类似s1 = s2; 再来一次s1 = s2;
			{                    
				Release(); // 防止内存泄漏
 
				_ptr = sp._ptr;
				_pCount = sp._pCount;
				_pMtx->lock();
				(*_pCount)++;
				_pMtx->unlock();
			}
			return *this;
		}
 
		void Release() // 防止内存泄漏
		{
			bool flag = false;
 
			_pMtx->lock();
			if (--(*_pCount) == 0)
			{
				delete _ptr;
				delete _pCount;
 
				flag = true;
			}
			_pMtx->unlock();
 
			if (flag)
			{
				delete _pMtx; // new出来的,引用计数为0时要delete
			}
		}
		~shared_ptr()
		{
			Release();
		}
 
		T& operator*()
		{
			return *_ptr;
		}
		T* operator->()
		{
			return _ptr;
		}
		int use_count()
		{
			return *_pCount;
		}
	protected:
		T* _ptr;
		int* _pCount;// 引用计数
		mutex* _pMtx;// 互斥锁
	};
}

补、懒汉模式的线程安全 

(关于懒汉模式的更多说明,请见:【C++】特殊类的设计-CSDN博客

//写法一
class Singleton
{
public:
	static Singleton* GetInstance()
	{
		// 双检查加锁
		if (m_pInstance == nullptr) // 保护第一次后,后续不需要加锁
		{
			unique_lock<mutex> lock(_mtx); // 加锁,防止new抛异常就用unique_lock
			if (m_pInstance == nullptr) // 保护第一次时,线程安全
			{
				m_pInstance = new Singleton;
			}
		}
 
		return m_pInstance;
	}
 
private:
	Singleton() // 构造函数私有
	{}
	Singleton(const Singleton& s) = delete; // 禁止拷贝
	Singleton& operator=(const Singleton& s) = delete; // 禁止赋值
 
	// 静态单例对象指针
	static Singleton* m_pInstance; // 单例对象指针
	static mutex _mtx;
};
//初始化
Singleton* Singleton::m_pInstance = nullptr; 
mutex Singleton::_mtx;
 
//写法二
class Singleton
{
public:
	static Singleton* GetInstance()
	{
		static Singleton _s; // 局部的静态对象,第一次调用时才初始化
		// 在C++11之前是不能保证线程安全的,
		// C++11之前局部静态对象的构造函数调用初始化并不能保证线程安全的原子性。
		// 而 C++11 修复了这个问题,这种写法也只能在支持C++11以后的编译器上使用

		return &_s;
	}
 
private:
	// 构造函数私有
	Singleton()
	{};
 
	Singleton(Singleton const&) = delete;
	Singleton& operator=(Singleton const&) = delete;
};