【C++】C++11 线程库

2023-10-17 08:15:03 浏览数 (1)

一、thread 线程库

在 C 11 之前,由于 C 没有对各平台的线程接口进行封装,所以当涉及到多线程编程时,编写出来的代码都是和平台相关的,因为不同平台提供的线程相关接口是不同的;这就导致代码的可移植性比较差。C 11 一个很重要的改动就是对各平台的线程操作进行了封装,从而有了自己的线程库,同时还在原子操作中还引入了原子类的概念。

C 11 线程库定义在 <thread> 头文件下,我们可以查询相关文档进行学习:C 11线程库类

thread 类中主要提供了如下接口:

  • 构造函数:支持无参构造,即构造一个空线程对象,由于线程对象不会和任何外部线程关联,也没有关联的线程函数,因此不能直接开始执行线程,无参构造通常需要配合移动赋值来使用。 支持构造一个线程对象,并关联线程函数,构造函数中的可变参数是传递给线程函数的参数,这种线程对象一旦创建就会开始执行。同时支持移动构造,即使用一个将亡对象来构造一个新的线程对象。
  • 赋值重载:线程不允许两个非将亡对象之间的赋值,只运行将一个将亡对象赋值给另一个非将亡对象,即移动赋值,移动赋值的常见用法是构造一个匿名线程对象,然后将其赋值给一个空线程对象。
  • get_id:获取当前线程的 id,即线程的唯一标识 – bool joinable() const noexcept
  • joinable:用于检查当前线程对象是否与某个底层线程相关联,从而判断是否需要对线程对象进行 join() 或 detach() 操作 – bool joinable() const noexcept
  • join:由于线程是进程中的一个执行单元,同时线程的所有资源也是由进程分配的,所以主线程在结束前需要对其他从线程进行 join;即判断从线程是否全部指向完毕,如果指向完毕就回收从线程资源并继续向后执行;如果存在未指向完毕的从线程,主线程就会阻塞在 join 语句处等待从线程,直到所有从线程都执行完毕 – void join()
  • detach:将当前线程与主线程分离,分离后,主线程不能再 join 当前线程,当前线程的资源会被自动回收 – void detach()

使用 thread 类时有如下注意事项:

线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。当创建一个线程对象后,如果没有提供线程函数,则该对象实际没有对应任何线程。

当创建一个线程对象并且给定与线程关联的线程函数后,该线程就被启动,与主线程一起运行。线程函数一般情况下可按照以下三种方式提供:函数指针、lambda 表达式、函数对象。其中,lambda 表达式的本质其实是匿名的函数对象;除此之外,我们还可以使用包装器对象,其底层也是匿名的函数对象。

代码语言:javascript复制
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);

	t1.join();
	t2.join();
	t3.join();
	cout << "Main thread!" << endl;
	return 0;
}

可以看到,上面程序的输出结果是混乱的,这是因为我们在创建多个线程时,这些线程的执行顺序完全是由操作系统来进行调度的,所以 thread 1/2/3 的输出顺序也是不确定的,只有 main thread 语句是确定最后打印的,因为这条语句在打印前需要等待其他从线程执行完毕。

我们可以通过 jionable() 函数来判断线程是否有效;如果是以下任意情况,则线程无效:采用无参构造函数构造的线程对象、线程对象的状态已经转移给其他线程对象、线程已经调用 jion 或者 detach 结束。

线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。

代码语言:javascript复制
void ThreadFunc1(int& x) { x  = 10; }

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

int main()
{
	int a = 10;
	//在线程函数中对a修改,不会影响外部实参
	//并且这里会发生int与int&类型不匹配的报错
	//thread t1(ThreadFunc1, a);
	//t1.join();
	//cout << a << endl;

	//如果想要通过形参改变外部实参时,必须借助std::ref()函数
	thread t2(ThreadFunc1, std::ref(a));
	t2.join();
	cout << a << endl;

	//其实现原理类似于地址的拷贝
	thread t3(ThreadFunc2, &a);
	t3.join();
	cout << a << endl;

	return 0;
}

进程具有独立性,所以一个进程的退出并不会影响其他进程的正常执行;但是线程并不是独立的,一个进程下的多个线程共享进程的地址空间,所以一个线程的崩溃会导致整个进程崩溃。

this_thread 命名空间

C 11 thread 头文件中,除了有 thread 类,还存在一个 this_thread 命名空间,它其中保存了线程的一些相关属性信息,如下:


二、mutex 锁

多线程的线程安全问题

由于同一进程下的多个线程共享进程的地址空间,因此进程内的大部分资源都是共享的,比如内核区、堆区、共享区、数据区以及代码区。这虽然极大程度上缩小了进程间通信的成本,但同时也引发了共享资源的线程安全问题

线程安全问题是指多个线程并发/并行的访问/修改共享资源的数据,从而造成的数据混乱的问题。线程安全问题一般发生在全局变量上,因为全局变量保存在全局数据区,被所有线程共享;当然,局部变量也可能存在线程安全问题,只要能够以某种方式让其他线程访问到该变量即可,比如通过 lambda 表达式的引用捕捉。

代码语言:javascript复制
int g_val = 0;

void Func1(int n)
{
	for (size_t i = 0; i < n; i  )
	{
		  g_val;
	}
}

void Func2(int n)
{
	for (size_t i = 0; i < n; i  )
	{
		  g_val;
	}
}

可以看到,全局变量 g_val 的值和我们的预期值不一样,并且大家如果多次运行程序可以发现每次的运行结果也是不同的。造成这种结果的原因是C 语言层面的 操作一般都对应着三条汇编指令:

  1. 从内存中获取变量并存放到寄存器中。
  2. 对寄存器中的变量进行 操作。
  3. 将 之后的结果写回到内存中。

也就是说, g_val 这个操作不是一步就能完成的,换句话说, 操作不是原子的。所以,当多个线程并行/并发的访问 g_val 时,就可能会出现线程 A 已经完成了前两步正准备将 后的结果 temp 写回内存时,线程 A 的时间片到了的情况;此时其他线程继续对 g_val 进行操作;而最后线程 A 被唤醒时会继续完成之前的操作,即将 g_val 的值改为 temp;这样做的结果就是线程 A 被阻塞的这段时间中其他线程对 g_val 的 操作全部无效了。

C 11 mutex 类

为了解决上面的线程安全问题,C 11 提供了 mutex 类;mutex 是一个可锁定的对象,用于在代码的关键部分需要独占访问时发出信号,防止具有相同保护的其他线程同时执行并访问相同的内存位置。

具体来说,当我们对程序中的某一部分代码加锁之后,线程如果想要执行这部分代码 (即访问这部分数据),必须先申请锁;当访问完毕后再释放锁。同时,一把锁在同一时间只能被一个线程持有,当其他线程再来申请锁时,会直接申请失败,从而阻塞或不断重新申请,直到持有锁的线程将锁释放。通过以上策略,我们就可以保证多个线程只能串行的访问临界区中的代码/数据,从而保证了其安全性

C 11 对操作系统的互斥锁接口进行了封装,产生出了 <mutex> 类;其中 <mutex> 中又包含了四个种类的锁,下面我们先来学习最重要的 mutex:

mutex 的主要接口如下:

  • 构造:互斥锁仅支持无参构造,不支持拷贝构造。
  • lock:加锁函数。如果当前锁没有被任何线程持有,则当前线程持有锁并加锁;如果当前锁已经被其他线程持有,则当前线程阻塞直到持有锁的线程释放锁;如果当前互斥量被当前调用线程锁住,则会产生死锁。
  • try_lock:尝试加锁函数。如果当前锁没有被任何线程持有,则当前线程持有锁并加锁;如果当前锁已经被其他线程持有,则加锁失败返回 false,但当前线程并不会阻塞,而是跳过临界区代码继续向后执行;如果当前互斥量被当前调用线程锁住,则会产生死锁。
  • unlock:解锁函数。当前线程执行完临界区中的代码后释放锁,如果存在其他线程正在申请当前锁,则它们其中的一个将会持有锁并继续向后执行;当然,当前锁也可能重新被当前线程竞争得到。

recursive_mutex

recursive_mutex 和 mutex 大体相同,只是 recursive_mutex 允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,释放互斥量时需要调用与该锁层次深度相同次数的 unlock()。

timed_mutex

相较于 mutex,timed_mutex 增加了两个两个成员函数 try_lock_for() 和 try_lock_until():

  • try_lock_for():接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与std::mutex 的 try_lock() 不同,try_lock 如果被调用时没有获得锁则直接返回false),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。
  • try_lock_until():接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。

需要注意的是,在实际开发中 try_lock_for() 和 try_lock_until() 并不常用,其中对于时间的控制也比较复杂,所以这里我们只需要了解即可,如果将来用到了,再去查阅 相关文档 也完全没问题。

现在,我们可以使用互斥锁 mutex 对之前的代码进行改进:

代码语言:javascript复制
int g_val = 0;
mutex mtx;

void Func1(int n)
{
	mtx.lock();
	for (size_t i = 0; i < n; i  )
	{
		  g_val;
	}
	mtx.unlock();
}

void Func2(int n)
{
	mtx.lock();
	for (size_t i = 0; i < n; i  )
	{
		  g_val;
	}
	mtx.unlock();
}

注意:有的同学可能会疑惑既然对 g_val 的 操作不是线程安全的,那为什么不只保护 g_val。其实只保护 操作是可以达到相同目的的,但是在当前场景下保护整个 for 循环程序的效率会更高 – 因为CPU的速度很快,如果我们对 g_val语句进行加锁,那么CPU就需要频繁的在 t1 和 t2 两个线程之间切换,并且 t1 和 t2 也需要频繁的加锁解锁,而这些操作都是要消耗资源的。


三、atomic 原子性操作

C 11 atomic 类

我们上文已经提到,多线程最主要的问题是共享数据带来的问题 (即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦,比如数据混乱。

虽然我们可以通过加锁来对共享资源进行保护,但加锁存在一定缺陷,比如多个线程只能串行访问被锁包含的资源,会导致程序运行效率降低;同时,加锁如果控制不当还可能会造成死锁等问题。因此 C 11 引入了原子操作,原子操作即不可被中断的一个或一系列操作;C 11通过引入原子操作类型,使得线程间数据的同步变得更加高效。

C 11 原子操作包含在 <atomic> 头文件中:

由于原子类型通常属于 “资源型” 数据,多个线程只能访问单个原子类型的拷贝,因此在C 11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及赋值重载等。

atomic 类主要支持原子性的 、–、 、-、按位或、按位与 以及 按位异或操作:

atomic 类能够支持这些原子性操作本质是因为其底层对 CAS 操作进行了封装,可以简单理解为,atomic = CAS while

CAS 操作

CAS (compare and swap) 是 CPU 硬件同步原语,它是支持并发的第一个处理器提供原子的测试并设置操作。CAS 操作包含三个操作数 – 内存位置(V)、预期原值(A)和新值 (B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则处理器不做任何操作。

我们还是以 g_val 操作为例,和一般的 操作不同,CAS 在会额外使用一个寄存器来保存讲寄存器中 g_val 修改之前的值 (预期原值),并且在将修改之后的值 (新值) 写回到内存时会重新取出内存中 g_val 的值与预期原值进行比较,如果二者相等,则将新值写入内存;如果二者不等,则放弃写入。

这样当线程 A 将新值写入到内存之前,如果有其他线程对 g_val 的值进行了修改,则内存中 g_val 的值就会与预期原值不等,此时操作系统就会放弃写入来保证整个 操作的原子性。

但单纯的放弃写入会导致可能当前 操作执行了但是 g_val 的值并不变;所以 C 对 CAS 操作进行了封装,即在 CAS 外面套了一层 while 循环,当新值成功写入时跳出循环,当新值写入失败时重新执行之前的取数据、修改数据、写回数据的操作,直到新值写入成功。这样做的优点是即实现了语言层面上 操作的原子性,解决了其线程安全问题;缺点是有一些 操作可能要重复执行多次才能成功,一定程度上影响程序效率,但还是比加锁解锁的效率要高。

注:上面只是对 atomic 底层原理的简单理解,atomic 底层逻辑控制肯定不是单纯的 CAS while 这么简单的,但作为一般程序员这样理解也就够了;如果对 CAS 特别感兴趣的同学,这里我推荐一篇陈皓大佬关于 CAS 的文章 (RIP):无锁队列的实现 – coolshell.com

下面是基于 atomic 原子性操作改进的代码:

代码语言:javascript复制
#include <atomic>

atomic<int> aval = 0;

void Func1(int n)
{
	for (size_t i = 0; i < n; i  )
	{
		  aval;
	}
}

void Func2(int n)
{
	for (size_t i = 0; i < n; i  )
	{
		  aval;
	}
}

四、RAII 管理锁资源

在多线程环境下,如果想要保证某个变量的安全性,只要将其设置成对应的原子类型即可,即高效又不容易出现死锁问题。但是有些情况下,我们可能需要保证一段代码的安全性,那么就只能通过锁的方式来进行控制。

但是锁控制不好时,可能会造成死锁,最常见的比如在锁中间代码返回,或者在锁的范围内抛异常等,我们前面 特殊类设计 中懒汉模式 new 单例对象时就提到过这个问题。为了更好的管理锁,C 11采用 RAII 的方式对锁进行了封装,并提供了 lock_guard 和 unique_lock 两个类

lock_guard

lock_guard 是 C 11 中定义的模板类,如下:

代码语言:javascript复制
template<class _Mutex>
class lock_guard
{
public:
	// 在构造lock_gard时,_Mtx还没有被上锁
	explicit lock_guard(_Mutex& _Mtx)
		: _MyMutex(_Mtx)
	{
		_MyMutex.lock();
	}
	// 在构造lock_gard时,_Mtx已经被上锁,此处不需要再上锁
	lock_guard(_Mutex& _Mtx, adopt_lock_t)
		: _MyMutex(_Mtx)
	{}
	~lock_guard() _NOEXCEPT
	{
		_MyMutex.unlock();
	}
	lock_guard(const lock_guard&) = delete;
	lock_guard& operator=(const lock_guard&) = delete;
private:
	_Mutex& _MyMutex;
};

可以看到,lock_guard 类模板通过 RAII 的方式对其管理的互斥量进行了封装;因此在需要加锁的地方,只需要实例化一个 lock_guard 对象,然后将锁交给该对象管理即可。lock_guard 对象调用构造函数成功上锁,出作用域前,lock_guard 对象要被销毁,调用析构函数自动解锁,从而有效避免死锁问题。

lock_guard 的缺陷是太单一,用户没有办法对该锁进行控制,因此C 11又提供了 unique_lock。

unique_lock

与 lock_guard 类似,unique_lock 类模板也是采用 RAII 的方式对锁进行了封装,并且也是以独占所有权的方式来管理 mutex 对象的上锁和解锁操作,即其对象之间不能发生拷贝。

与 lock_guard 不同的是,unique_lock 更加的灵活,提供了更多的成员函数:

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

unique_lock 和 lock_guard 最大的区别在于 lock_guard 无法手动释放和重新获取互斥锁,只能在创建时 lock,析构时 unlock,这在某些复杂的多线程编程场景中可能会受到一些限制。而 unique_lock 则提供了更加灵活和精细的互斥锁控制,unique_lock 可以在任何时刻手动地释放和重新获取互斥锁,并且支持不同的互斥锁处理策略,例如延时加锁、尝试加锁等

下面是基于互斥锁和 lock_guard 的代码:

代码语言:javascript复制
#include <mutex>

int g_val = 0;
mutex mtx;

void Func1(int n)
{
    //构造时自动lock,出局部作用域析构时自动unlock
	lock_guard<mutex> lg(mtx);
	for (size_t i = 0; i < n; i  )
	{
		  g_val;
	}
}

void Func2(int n)
{
	lock_guard<mutex> lg(mtx);
	for (size_t i = 0; i < n; i  )
	{
		  g_val;
	}
}

五、condition_variable 条件变量

我们以一道面试题来引入条件变量:写一个程序,支持两个线程从 0 到 100 交替打印,一个打印奇数,一个打印偶数。

代码实现如下:

代码语言:javascript复制
int main()
{
	int val = 0;
	//假设t1线程打印偶数,t2线程打印奇数
	thread t1([&]()
		{
			while (val <= 100)
			{
				if (val % 2 == 0)
				{
					cout << "t1:" << this_thread::get_id() << "->" << val << endl;
					  val;
				}
			}
		});

	thread t2([&]()
		{
			while (val <= 100)
			{
				if (val % 2 == 1)
				{
					cout << "t2:" << this_thread::get_id() << "->" << val << endl;
					  val;
				}
			}
		});

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

	return 0;
}

可以看到,上面的程序是存在问题的,可能出现下面这种情况 – val 的值刚好为100,并且 t1 线程在 val 之前 t2 线程被调度,由于 val 为 100 所以 while 条件满足,而当 t2 执行 if 条件判断时 t1 已经完成了 val 的 操作,此时 val 为 101,if 条件成立,打印。

虽然我们可以通过将 t2 的 while 条件改为 while(val < 100)来避免上面的情况,但这仍然存在资源浪费的问题 – 例如下面这种场景:当 t1 满足条件正在运行并且在 val 之前其时间片到了被中断,此时 t2 被调度,但由于 t2 条件不满足,所以 t2 就会一直进行 while if 判断,直到时间片到被中断;这就导致 CPU 资源被浪费,那么能不能让线程在不满足条件时主动让出 CPU 资源,在条件满足时再被唤醒呢?这就需要依靠条件变量来实现了。

条件变量

条件变量 condition_variable 是 C 11 引入的同步机制之一,用于实现线程间的协作。它能够在多个线程之间传递信号,实现线程的等待和唤醒。通过使用 condition_variable,一个线程可以通知其它线程某个特定的事件已经发生,然后其它线程也可以相应地执行各自的操作。这种机制可以避免线程一直忙等待某个事件的发生,从而提高了应用程序的效率。

具体来说,condition_variable 主要由以下两个成员函数组成:

  • void wait(std::unique_lock<std::mutex>& lock): 该函数会使当前线程阻塞,直到另一个线程调用 notify_one()notify_all() 成功唤醒该线程为止。调用该函数时需要传递一个已经加锁的 unique_lock<std::mutex> 对象,函数内部会自动释放锁。当该函数返回时,锁会再次被该线程持有。
  • void notify_one() / void notify_all(): 这两个函数用于唤醒一个或全部等待中的线程。这些被唤醒的线程会尝试重新获得锁,并继续执行相应的操作。如果没有线程处于等待状态,则这两个函数不会产生任何影响。

注意:由于 condition_variable 本身并不持有锁,因此在使用时通常需要与 mutex 配合使用。具体来说,一般会创建一个 mutex 对象和一个 condition_variable 对象,并在等待某个条件时使用 unique_lock<std::mutex> 对象进行加锁和解锁。这样可以确保线程在等待时不会占用 CPU 资源。

如下,通过条件变量,当 t1/t2 线程条件不满足时,线程就会直接阻塞,让出 CPU 资源,直到被通知唤醒。下面是基于 condition_variable 改进的代码:

代码语言:javascript复制
#include <mutex>
#include <condition_variable>
int main()
{
	int val = 0;
	mutex mtx;
	condition_variable cv;
	//假设t1线程打印偶数,t2线程打印奇数
	thread t1([&]()
		{
			while (val <= 100)
			{
				unique_lock<mutex> lg(mtx);
				//如果val是奇数,则阻塞当前线程
				while (val % 2 == 1)
				{
					cv.wait(lg);
				}
				cout << "t1:" << this_thread::get_id() << "->" << val << endl;
				  val;
                //  之后唤醒在当前条件下的另一个线程
				cv.notify_one();
			}
		});

	thread t2([&]()
		{
			while (val < 100)
			{
				unique_lock<mutex> lg(mtx);
				//如果val是偶数,则阻塞当前线程
				while (val % 2 == 0)
				{
					cv.wait(lg);
				}
				cout << "t2:" << this_thread::get_id() << "->" << val << endl;
				  val;
				cv.notify_one();
			}
		});

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

	return 0;
}

0 人点赞