C++ - 多线程之线程同步

发布时间 2023-10-11 17:04:26作者: [BORUTO]

1.多线程的并发问题

线程间为什么需要同步?直接来看一个例子:

int a = 0;
void foo()
{
	for (int i = 0; i < 10000000; ++i)
	{
		a += 1;
	}
}
int main()
{
	clock_t start, end;
	start = clock();
	thread t1(foo);
	thread t2(foo);
	t1.join();
	t2.join();
	end = clock();
	cout << a << endl;
	cout << end - start << endl;
	return 0;
}

代码很简单,创建两个线程执行foo函数,foo函数的功能是对全局变量a进行自增,我们所预期的答案是20000000。但是实际运行结果却几乎不可能得到这个值,运行结果如下:

a的最终结果为10442732,共使用了58毫秒的时间。在两个线程对a进行自增的过程中可能会因为线程调度的问题使得最终结果并不正确。比如当前a的值为1,线程x现在将a的值读到寄存器中,而线程y也将a读到寄存器中,完成了自增并将新的值放入内存中,现在a的值为2,而线程x现在也对寄存器中的值进行自增,并将得到的结果放入内存中,a的值为2。可以看到两个线程都对a进行了自增,但是却得到的错误的结果。
这种情况便需要对线程间进行同步。

 

2.C++11的线程间同步方式

其实在APUE的学习中已经讲过了线程间的同步方式,共有五种,分别是互斥锁,自旋锁,读写锁,条件变量和屏障。
但是unix系统中的同步方式编写的代码并不能跨平台,都是C语言风格的结构,使用起来并不方便。所以在后来编写代码的过程中更喜欢使用C++11线程库和同步方式,不仅接口简单,而且也能在夸平台上使用。
 

2.1 互斥锁

mutex _mutex;
_mutex.lock();//加锁
_mutex.unlock();//解锁
_mutex.try_lock();//尝试加锁,成功返回bool,失败返回false不阻塞

包含mutex头文件后,就可以使用mutex类。相比起unix风格(接口名字复杂,且需要初始化互斥锁)要方便不少。

现在使用互斥锁来实现两个线程对同一变量自增的功能:

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

int a = 0;
mutex _mutex;
void foo()
{
	for (int i = 0; i < 10000000; ++i)
	{
		_mutex.lock();
		a += 1;
		_mutex.unlock();
	}
}
int main()
{
	clock_t start, end;
	start = clock();
	thread t1(foo);
	thread t2(foo);
	t1.join();
	t2.join();
	end = clock();
	cout << a << endl;
	cout << end - start << endl;
	return 0;
}

只有获得锁之后才能对数据a进行操作,运行结果如下:

 

可以看到这次得到了我们期望的正确答案,但是使用的时间却大大增加,使用了2661ms,是之前的40倍。造成这种现象的原因:
 
锁的争用造成了线程阻塞
互斥锁的获取需要陷入内核态,即每次上锁解锁,都需要从用户态进入内核态,再回到用户态。而foo函数本身执行自增操作只需要两条指令就能完成,而内核态的切换可能需要上百条指令。
要实现更加高效的同步就需要引入下一个内容,自旋锁。

 

2.2 自旋锁

自旋锁是一种忙等形式的锁,会再用户态不同的询问锁是否可以获取,不会陷入到内核态中,所以更加高效。缺点是可能会对CPU资源造成浪费。但是在C++11中并没有直接提供自旋锁的实现。但是在C++11中提供了原子操作的实现,可以借助原子操作实现简单的自旋锁。

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

atomic_flag flag;
int a = 0;

void foo()
{
	for (int i = 0; i < 10000000; ++i)
	{
		while (flag.test_and_set())
		{

		}//加锁
		a += 1;
		flag.clear();//解锁
	}
}

int main()
{
	flag.clear();//初始化为clear状态

	clock_t start, end;
	start = clock();
	thread t1(foo);
	thread t2(foo);
	t1.join();
	t2.join();
	end = clock();
	cout << a << endl;
	cout << end - start << endl;
	return 0;
}
atomic_flag是一个原子变量,共有set和clear两种状态。在clear状态下test_and_set会将其状态置于set并返回false,在set状态下test_and_set会返回true。可以看到自旋锁其实和CAS方式实现的乐观锁很相似,使用原子操作改变标志为的值,并不断地轮询标志位。
 
运行结果如下:
可以看到得到了预期的正确答案,但是在时间性能上,并没有比互斥锁高出特别多。感觉很疑惑,查了一些资料,找到一个比较合理的解释:现代操作系统中的互斥锁是一种更加综合的锁,是互斥锁和自旋锁的结合,在无法获得锁时会先自旋一段时间,如果在这段时间中获得了锁便继续执行,如果没有获得便陷入内核阻塞进程。
 
自旋锁和互斥锁的优缺点和使用场景:
  1. 互斥锁不会浪费CPU资源,在无法获得锁时使线程阻塞,将CPU让给其他线程使用。比如多个线程使用打印机等公共资源时,应该使用互斥锁,因为等待时间较长,不能让CPU长时间的浪费。
  2. 自旋锁效率更高,但是长时间的自旋可能会使CPU得不到充分的应用。在临界区代码较少,执行速度快的时候应该使用自旋锁。比如多线程使用malloc申请内存时,内部可能使用的是自旋锁,因为内存分配是一个很快速的过程。
 

2.3 条件变量

条件变量在C++11中有现成的类可以使用,比unix风格的接口更加方便。用法和unix的条件变量类似,需要配合互斥锁使用。
如果不懂条件变量原理及使用的可以看看这篇博客:C++条件变量实现多线程顺序打印
 
现在我们使用C++11的条件变量完成三个线程顺序打印0,1,2:
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

condition_variable cond;
mutex _mutex;
int a = 0;

void first() {


	while (true)
	{
		unique_lock<mutex> lck(_mutex);
		while (a % 3 != 0)
			cond.wait(lck);
		a++;
		printf("%d\n", a % 3);
		cond.notify_all();
		lck.unlock();
	}
}
void second() {

	while (true) {
		unique_lock<mutex> lck(_mutex);
		while (a % 3 != 1)
			cond.wait(lck);
		a++;
		printf("%d\n", a % 3);
		cond.notify_all();
	}
}
void third() {

	while (a < 100) {
		unique_lock<mutex> lck(_mutex);
		while (a % 3 != 2)
			cond.wait(lck);
		a++;
		printf("%d\n", a % 3);
		cond.notify_all();
	}
}

int main()
{
	thread t1(first);
	thread t2(second);
	thread t3(third);
	getchar();
	return 0;
}

运行结果如下:

其中unique_lock是对mutex的一种RAII使用手法,来看看unique_lock的构造函数和析构函数:

explicit unique_lock(_Mutex& _Mtx)
	: _Pmtx(_STD addressof(_Mtx)), _Owns(false)
	{	// construct and lock
	_Pmtx->lock();
	_Owns = true;
	}
~unique_lock() noexcept
	{	// clean up
	if (_Owns)
		_Pmtx->unlock();
	}		
可以看到在unique_lock构造时自动加锁,析构时完成锁的释放,使用这种编程技法可以保证在推出临界区时锁一定会被释放。 
 
 

2.4 屏障

屏障(barrier)是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程都达到某一点,然后从该点继续执行。pthread_join函数就是一种屏障,允许一个线程等待,直到另一个线程退出。
 
但是屏障对象的概念更广,它们允许任意数量的线程等待,直到所有的线程完成处理工作,而线程不需要退出。所有线程达到屏障后可以接着工作。
 
不过屏障本身很少被使用,可以使用条件变量和互斥锁完成屏障的功能:
 
 
#include <iostream>
#include <thread>
#include <mutex>
#include <Windows.h>
using namespace std;

condition_variable cond;
mutex _mutex;
//unique_lock<mutex> lck(_mutex);
int _latch = 3;

void wait()
{
	cout << "wait..." << endl;
	unique_lock<mutex> lck(_mutex);
	while (_latch != 0)
		cond.wait(lck);
}
void CountDown()
{
	unique_lock<mutex> lck(_mutex);
	_latch -= 1;
	if (_latch == 0)
		cond.notify_all();
}
void thread1()
{
	Sleep(1000);
	CountDown();
	cout << "thread1 finish" << endl;
}
void thread2()
{
	Sleep(3000);
	CountDown();
	cout << "thread2 finish" << endl;
}
void thread3()
{
	Sleep(5000);
	CountDown();
	cout << "thread3 finish" << endl;
}
int main()
{
	//Sleep(5000);
	thread t1(thread1);
	thread t2(thread2);
	thread t3(thread3);
	wait();

	t1.join();
	t2.join();
	t3.join();
	cout << "all thread finish" << endl;

	return 0;
}

运行结果如下:

完成了屏障的功能,当线程123都执行完成后,main线程才会继续执行。

除去上述的同步方式之外,unix系统还提供读写锁用于线程同步,在C++11中也没有对应的接口,不过实现较为复杂