【C++11】线程库

文章目录

  • thread 线程库
  • mutex 锁
  • atomic 原子性操作
  • condition_variable 条件变量
  • 实现两个线程交替打印1-100


thread 线程库

在C++11之前,涉及到多线程问题,都是和平台相关的,比如Windows和Linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含< thread >头文件。

【C++11】线程库_第1张图片

thread类中的接口如下:

构造函数:

【C++11】线程库_第2张图片

  • 支持无参构造,即构造一个空线程对象,由于该线程对象不会和任何外部线程关联,也没有关联的线程函数,因此不能直接开始执行线程,无参构造通常需要配合移动赋值来使用。
  • 支持构造一个线程对象,并关联线程函数,构造函数中的可变参数是传递给线程函数的参数,这种线程对象一旦创建就会开始执行。同时支持移动构造,即使用一个将亡对象来构造一个新的线程对象。

赋值重载:

在这里插入图片描述

  • 线程不允许两个非将亡对象之间的赋值,只允许将一个将亡对象赋值给另一个非将亡对象,即移动赋值,移动赋值的最常见用法是构造一个匿名线程对象,然后将其赋值给一个空线程对象。

其他相关接口:

【C++11】线程库_第3张图片

  • get_id: 获取当前线程的id,即线程的唯一标识——bool joinable() const noexcept。
  • joinable: 用于检查当前线程对象是否与某个底层线程相关联,从而判断是否需要对线程对象进行join() 或者 detach 操作——bool joinable() const noexcept。
  • join: 由于线程是进程中的一个执行单元,同时线程的所有资源也是有进程分配的,所以主线程在结束之前需要对其他从线程进行join。即判断从线程是否全部执行完毕,如果执行完毕,就回收从线程资源并继续向后执行。如果存在未执行完毕的从线程,主线程就会阻塞在join语句处等待从线程,直到所有从线程都执行完毕——void join()。
  • detach: 将当前线程与主线程分离,分离后主线程不能再join当前线程。当前线程的资源会被自动回收——void detach()。

thread使用注意事项:

  1. 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。
    【C++11】线程库_第4张图片
  2. 当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行。线程函数一般情况下可按照 函数指针、lambda表达式和函数对象 三种方式提供。在这里说明一下:lambda表达式的本质其实是匿名的函数对象。
    【C++11】线程库_第5张图片
    由于创建线程时,这些线程的执行顺序完全是由操作系统来进行调度的,因此thread 1/2/3 的输出顺序也是不确定的。
  3. 我们可以通过joinable() 函数来判断线程是否有效,如果是以下几种情况。则线程无效:采用无参构造函数构造的线程对象、线程对象的状态已经转移给其他线程对象、线程已经调用join或者detach结束。
  4. 线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此即使线程参数为引用类型,在线程中修改后也不能修改外部实参。因为实际引用的是线程栈中的拷贝,而不是外部的实参。
    【C++11】线程库_第6张图片
    【C++11】线程库_第7张图片
  5. 进程具有独立性,所以一个进程的退出并不会影响其他进程的正常运行。但是线程并不是独立的,一个进程下的多个线程共享进程的地址空间,所以一个线程的奔溃会导致整个进程奔溃。

this_thread 命名空间

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

yield 函数

【C++11】线程库_第8张图片

sleep_for 函数

【C++11】线程库_第9张图片

面试题:并行和并发的区别?

并发和并行都是指多个任务同时执行的情况,但是它们的含义有所不同。

  • 并发是指在同一个时间段内,多个任务交替地执行,这些任务可以在同一台计算机上运行,也可以在不同的计算机上运行,彼此之间通过网络或其他方式进行通信和同步。并发常常用来提高系统的吞吐量和响应性,以及实现资源共享和负载均衡等功能。

  • 而并行是指在同一个时间点上,多个任务同时执行,这些任务通常在多个处理器或多个计算机上运行,每个任务分配给不同的处理器或计算机进行处理。并行常常用来加速计算和处理速度,提高系统的性能。


mutex 锁

C++11 中引入了一个新的 mutex 类,mutex是一个可锁定对象,它用于在多个线程之间锁定共享资源以防止竞争条件。

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

【C++11】线程库_第10张图片

mutex 的主要接口如下:

  • 构造: 互斥锁仅支持无参构造,不支持拷贝构造
    在这里插入图片描述
  • lock: 加锁函数。如果当前锁没有被任何线程持有,则当前线程持有锁并加锁,如果当前锁已经被其他线程持有,则当前线程阻塞直到持有锁的线程释放锁,如果当前互斥量被当前调用线程锁住,则会产生死锁。
  • try_lock: 尝试加锁函数。如果当前锁没有被任何线程持有,则当前线程持有锁并加锁。如果当前锁已经被其他线程持有,则加锁失败返回false,但当前线程并不会阻塞,而是跳过临界区代码继续向后执行。如果当前互斥量被当前调用线程锁住,则会产生死锁。
  • unlock: 解锁函数。当前线程执行完毕临界区中的代码后释放锁。如果存在其他线程正在申请当前锁,则它们其中一个将会持有锁并继续向后执行。当前锁也可能重新被当前线程竞争得到。
int x = 0;
mutex mtx;
void Func(int n)
{
	for (int i = 0; i < n; i++) {
		mtx.lock();
		++x;
		mtx.unlock();
	}
}

【C++11】线程库_第11张图片

在这个示例中,我们创建了两个线程 t1 和 t2,它们都要修改全局变量x。这里我们使用了一把全局互斥锁来保护共享变量x,保护x不会被多个线程同时访问。

关于 lock_guard

lock_guard 是一种用于管理互斥锁的 RAII(Resource Acquisition Is Initialization)类。它可以保证在作用域结束时自动释放互斥锁,以避免忘记手动释放锁所导致的问题。

使用 lock_guard 类可以避免手动管理互斥锁的问题,可以提高程序的可读性和可维护性。在创建 lock_guard 对象时,需要传入一个互斥锁对象,这个互斥锁对象会被 lock_guard 类包装,当 lock_guard 对象被销毁时,它会自动调用互斥锁对象的 unlock 函数,释放互斥锁。

lock_guard 类的使用非常简单,只需要在需要使用互斥锁的代码块中创建一个 lock_guard 对象即可,不需要手动加锁和解锁。当程序流程离开这个代码块时,lock_guard 对象会自动释放互斥锁。由于 lock_guard 对象的生命周期是由编译器控制的,因此无论代码流程中出现何种异常情况,lock_guard 对象都会在作用域结束时被自动销毁,从而保证了程序的正确性。

lock_guard的模拟实现

template <class Lock>
class LockGuard
{
public:
	LockGuard(Lock& lock)
		: _lock(lock)
	{
		_lock.lock();
	}

	~LockGuard()
	{
		_lock.unlock();
	}
	
	LockGuard(const LockGuard&) = delete;
	LockGuard& operator=(const LockGuard&) = delete;

private:
	Lock& _lock;
};

LockGuard的使用

int x = 0;
mutex mtx;
void Func(int n)
{
	for (int i = 0; i < n; i++)
	{
		try
		{
			LockGuard<mutex> lock(mtx);
			++x;

			// 抛异常
			if (rand() % 3 == 0)
			{
				throw exception("抛异常");
			}
		}
		catch (const std::exception& e)
		{
			cout << e.what() << endl;
		}
	}
}

【C++11】线程库_第12张图片

在 C++11 线程库中,线程执行例程的参数可以是引用也可以是拷贝,具体取决于用户如何传递参数。如果将参数作为值传递,则会进行拷贝构造,而如果将参数作为引用传递,则不会进行拷贝构造。

如果需要真正实现传递引用,可以使用 std::ref 函数将引用类型的参数包装成 std::reference_wrapper 类型的对象,然后将这个对象作为参数传递给线程的执行例程。 std::reference_wrapper 是一个模板类,它提供了一种引用的包装方式,可以像普通对象一样进行拷贝和赋值,同时可以通过调用其 get 函数获取其包装的引用。

如果有一个函数需要传递一个引用类型的参数,可以使用 ref 函数将这个引用包装成 reference_wrapper 类型的对象,并将其作为参数传递给线程的执行例程。

注:线程构造函数的参数包并不是直接传给执行例程的,而是先用参数包的参数去构造线程,然后再将这些参数传递给线程的执行例程。互斥锁没有被识别成引用传递的问题是出现在构造线程时参数包传递的过程。线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此:即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。

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 可以在任何时刻手动地释放和重新获取互斥锁,并且支持不同的互斥锁处理策略,例如延时加锁、尝试加锁等。


std::recursive_mutex

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

std::timed_mutex

比 std::mutex 多了两个成员函数,try_lock_fortry_lock_until

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

这里我们需要注意的是,在实际开发中try_lock_for() 和 try_lock_until()并不常用,其中对于时间的控制也比较复杂,因此这里我们只需要了解即可。

【C++11】线程库_第13张图片

这里我们可以看到,当前的这种情况下,在整个for循环的外面加锁的效率会更高,这是因为整个CPU的速度很快,如果我们对++x进行加锁,那么CPU就会频繁的在t1和t2两个线程之间切换,并且t1和t2也需要频繁的加锁和解锁,而这些操作都是要消耗资源的。

【C++11】线程库_第14张图片


atomic 原子性操作

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

在这里插入图片描述

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

【C++11】线程库_第15张图片

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 这么简单的,但作为一般程序员这样理解也就够了。感兴趣的可以看一下陈皓大佬的这一篇文章 无锁队列的实现

int main()
{
	int n = 100000;

	atomic<int> x = 0;
	thread t1([&, n](){
		for (int i = 0; i < n; i++)
		{
			++x;
		}
	});

	thread t2([&, n]() {
			for (int i = 0; i < n; i++)
			{
				++x;
			}
		});

	t1.join();
	t2.join();
	size_t end = clock();

	printf("%d\n", x.load());
	return 0;
}

【C++11】线程库_第16张图片


condition_variable 条件变量

C++11 中的 condition_variable 是用于 线程同步 的一种机制,它能够协调多个线程之间的操作,以便它们能够有效地进行通信和同步。

condition_variable 通常与互斥锁一起使用,用于实现生产者-消费者模型、读者-写者模型等线程间同步的场景。

condition_variable 提供了两个主要的操作:waitnotify_onenotify_all

  • wait 操作会使当前线程阻塞,并释放关联的互斥锁,直到另外一个线程调用了 notify_one 或 notify_all 方法,通知该线程可以继续执行了。

  • notify_one 操作会唤醒一个正在等待的线程,而notify_all 操作会唤醒所有正在等待的线程。如果没有线程处于等待状态,则这两个函数不会产生任何影响。

wait函数提供了两个不同版本的接口:

在这里插入图片描述

  • 调用第一个版本的wait函数时只需要传入一个互斥锁,线程调用wait后会立即被阻塞,直到被唤醒。

  • 调用第二个版本的wait函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与第一个版本的wait不同的是,当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么该线程还需要继续被阻塞。

为什么调用wait系列函数时需要传入一个互斥锁?

  1. 因为wait系列函数一般是在临界区中调用的,为了让当前线程调用wait阻塞时其他线程能够获取到锁,因此调用wait系列函数时需要传入一个互斥锁,当线程被阻塞时这个互斥锁会被自动解锁,而当这个线程被唤醒时,又会自动获得这个互斥锁。

  2. 因此wait系列函数实际上有两个功能,一个是让线程在条件不满足时进行阻塞等待,另一个是让线程将对应的互斥锁进行解锁。

注意:调用wait系列的函数时,传入互斥锁的类型必须是unique_lock。条件变量下,可能会有多个线程正在进行阻塞等待,这些线程会被放到一个等待队列中进行排队。


实现两个线程交替打印1-100

尝试用两个线程交替打印1-100的数字,要求一个线程打印奇数,一个线程打印偶数,并且打印数字从小到大依次递增。

该题目主要考察的就是线程的同步和互斥。

  • 互斥:两个线程都在向控制台打印数据,为了保证两个线程的打印数据不会相互影响,因此需要对线程的打印过程进行加锁保护。
  • 同步:两个线程必须交替进行打印,因此需要用到条件变量让两个线程进行同步,当一个线程打印完再唤醒另一个线程进行打印。

但如果只有同步和互斥是无法满足题目要求的。

  1. 首先,我们无法保证哪一个线程会先进行打印,不能说先创建的线程就一定先打印,后创建的线程先打印也是有可能的。
  2. 此外,有可能会出现某个线程连续多次打印的情况,比如线程1先创建并打印了一个数字,当线程1准备打印第二个数字的时候线程2可能还没有创建出来,或是线程2还没有在互斥锁上进行等待,这时线程1就会再次获取到锁进行打印。
int main()
{
	mutex mtx;
	condition_variable cv;

	int n = 100;
	int x = 1;

	thread t1([&, n]() {
		while (x < 100)
		{
			unique_lock<mutex> lock(mtx);
			if (x >= 100) break;
			if (x % 2 == 0) { // 偶数就阻塞
				cv.wait(lock);
			}
			cout << "线程" << this_thread::get_id() << ":" << x << endl;
			++x;
			cv.notify_one();
		}
		});

	thread t2([&, n]() {
		while (x < 100)
		{
			unique_lock<mutex> lock(mtx);
			if (x > 100) break;
			if (x % 2 != 0) { // 奇数就阻塞
				cv.wait(lock);
			}
			cout << "线程" << this_thread::get_id() << ":" << x << endl;
			++x;
			cv.notify_one();
		}
		});

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

	return 0;
}

【C++11】线程库_第17张图片


你可能感兴趣的:(C++,c++,开发语言)