前言

C++11的并发库的功能比较强大的,提供了很多的接口和方式,小编在这里也是主要学习C++11的并发库,想要分享一下心得。在使用的过程中,遇到不太熟练的接口的时候最佳的学习方法就是:查文档 + AI 辅助学习。

  • 官方文档。可读性有点不高,但是新!
  • 小编常用文档。这个文档的可读性较高,小编比较喜欢在这上面查阅一些C++的遗忘的语法知识。但是这个文档局限性较高,只有C++11及以下的语法,并没有C++14/C++17的语法。

1. mutex

C++11实现了对互斥量的封装

  • mutex 是封装的互斥锁的类,⽤于保护临界区的共享数据。mutex 主要提供 lockunlock 两个接⼝函数。

    mutex 提供排他性递归所有权语义:

    1. 调⽤⽅线程从它成功调用 locktry_lock 开始,到它调用 unlock 为止占有 mutex。
    2. 线程占有 mutex 时,其他线程如果试图要求 mutex 的所有权,那么就会阻塞(对于 lock 的调用), 对于 try_lock 就会返回 false

在这里插入图片描述
细节:mutex 只有默认构造,即在创建对象的时候就已经初始化了互斥量。同时不支持任何的拷贝构造和移动构造

通常在使用过程中还有很多的细节:

  • demo1:我们常常不会在全局声明一个互斥量。这会有头文件包含导致的变量重复定义等问题!下面这个 demo 是将互斥量作为一个局部变量供线程使用:
void Print(int count, int& x, std::mutex& m)
{
	m.lock();
	for (int i = 0; i < count; ++i)
	{
		++x;
	}
	m.unlock();
}

int main()
{
	int x = 0;
	std::mutex m;
	
	std::thread t1(Print, 1000000, std::ref(x), std::ref(m));
	std::thread t2(Print, 2000000, std::ref(x), std::ref(m));

	t1.join();
	t2.join();
	std::cout << x << std::endl;

	return 0;
}

这个程序的很有细节的:

  1. std::ref

    这里必须使用 std::ref() 来保持传入参数的引用属性。为什么?有点麻烦,下面我们以 Linux 系统的原生线程库接口为例:

    在这里插入图片描述
    std::ref的作用就是:保持传入参数的引用属性,在发生解析参数的时候,互斥量就不会发生拷贝。而是直接传递参数交给打包好的结构体!
    这是一个很重要的细节,也很隐晦。

  2. 加锁的区间

    上面我们实现一个简单的增加临界资源x的代码。我们采用的方式是:在循环外部加锁。为什么?这就涉及了加锁和解锁的资源消耗问题。频繁的加锁和解锁会造成线程不必要的切换,线程的切换是需要付出代价的:需要将 CPU 上寄存器上的资源都保持下来……所以像这样的场景,最后不要将加锁和解锁的逻辑写在循环内部。(看场景分需求)

但是一般而言,我们都不会向上面那样使用,更多的是,我们采用使用 lambda 表达式的捕捉的应用。这样我们传递参数的时候就不会传递引用了!这里就不做样例了。

同时:C++11还提供了很多类型的互斥量:

在这里插入图片描述

  1. std::mutex:最基本的互斥量。

  2. std::recursive_mutex :递归互斥量。

    特点:

    • 支持递归锁定:同一个线程可以多次锁定它而不会死锁。但是会记录一共进行了多数次加锁操作。

    • 锁定次数必须与解锁次数匹配,锁才会真正释放。配合第一点使用。

    • 不支持超时。

    使用场景:

    • 在递归函数中保护共享数据。

    • 当一个类的多个成员函数都需要加锁,且这些函数可能相互调用时。

  3. std::timed_mutex :定时互斥量。

    特点

    • 具备 std::mutex 的所有功能。

    • 支持超时操作:可以尝试在指定时间内获取锁。

    • 不支持递归锁定。

    使用场景

    • 需要避免无限期等待锁的情况。

    • 实现带超时的资源访问。

    主要成员函数:

    • try_lock():尝试立即获取锁,成功返回true,失败返回false。没有休眠时间。

    • try_lock_for(duration):在指定时间段内尝试获取锁。成功返回true,失败返回false。

    • try_lock_until(timepoint)尝试获取锁直到某个时间点。成功返回true,失败返回false。

1.1 RAII

但是上面的方式还是太挫了,需要我们手动的管理互斥量的加锁和释放。

C++11 根据 RAII 特性还封装了两个类模板

  1. lock_guard

  2. unique_lock

在这里插入图片描述

这两个类都是采用 RAII 的方式来对互斥量进行管理的。还是注意:小编的用词。

下面我们简单介绍一下:

1.1.1 lock_guard

这就是简单的实现一个 RAII 的功能。并没有提供更多的功能。

在这里插入图片描述
如上图:简单提供了构造和析构函数。但是注意:这个类模板同样是不支持拷贝构造的。

demo:

void Print(int count, int& x, std::mutex& m)
{
	// 想要保护的临界区,我们采用局部域来保护
	{
		std::lock_guard<std::mutex> lock(m); //构造的时候自动加锁
		for (int i = 0; i < count; ++i)
		{
			++x;
		}
	}
	
	//不想保护的区域
	// TODO
	std::cout << "x: " << x << std::endl;
}

int main()
{
	int x = 0;
	std::mutex m;

	std::thread t1(Print, 1000000, std::ref(x), std::ref(m));
	std::thread t2(Print, 2000000, std::ref(x), std::ref(m));

	t1.join();
	t2.join();
	std::cout << x << std::endl;

	return 0;
}
  • 上面的 { } 局部域的使用,也是能很好地配合 lock_guard 使用的!
  • 同时自动加锁的时候,如果没有获取到互斥量资源就会阻塞。内部实现简单看来就是:在构造的时候调用互斥量的 lock;在析构的时候调用互斥量的 unlock

1.1.2 unique_lock

这个类模板支持的功能就比较多了:

在这里插入图片描述

这里主要需要介绍的就是:

  1. 几个构造的参数

    在这里插入图片描述
    红色标注的三者是一个标签(tag)。可以理解为跟不同的标签有不同的行为(类似枚举类型)。

    • try_to_lock_t: 尝试上锁。本来 unique_lock 的构造函数会选择 lock,阻塞式构造。但是选择这个标签之后,调用对应的构造函数时:调用的就是:try_lock

    • defer_lock_t:同理,这个标签是:不上锁。

    • adopt_lock_t:同时,这个标签是:当前线程已经持有锁了(但是这个构造内部不会判断,当前线程是否确实是持有锁了,有些场景需要注意),现在把管理权力交给构成的 unique_lock。通过这样的构造就避免的死锁,同时也能很好地保证 RAII 的管理机制。

    这是一种元编程技术

  2. unique_lock支持移动构造。

    上面的部分已经谈到了:这个类模板拥有空的构造(蓝色部分),所以天然地:这个类模板是支持移动构造/移动赋值的。这就诞生了一个问题:不是说好的互斥量不能任何拷贝/移动吗?

    上面小编就谈到了:注意用词的是管理(管理同一个互斥量的人只能有一个!)。从始至终,移动构造看到的互斥量资源只有一个,该类只是拥有了管理权。我们可以理解为:unique_lock 中的互斥量的存在形式是:指针。并不是引用(引用无法支持空的构造)。

  3. owns_lock/bool

    两者都是用来判断当前对象是否拥有互斥量资源的。判断是否现在这个线程持有锁。

  4. unique_lock 会自动上锁:

    所以,如果在后面的代码中,发生了 lock 这样的行为。这是未定义的。有可能会导致当前线程阻塞。

1.2 死锁

C++11还退出了两个能防止死锁的两个函数模板

在这里插入图片描述

1.2.1 try_lock

效果:等待多个互斥量资源就绪,立即返回。返回值为-1,表示所有锁资源获取成功;返回值>-1,表示对应的互斥量资源没有就绪,同时返回的时候会释放获取到的互斥量资源。

demo:

std::mutex foo,bar;

void task_a () {
  foo.lock();
  std::cout << "task a\n";
  bar.lock();
  // ...
  foo.unlock();
  bar.unlock();
}

void task_b () {
  int x = try_lock(bar,foo);
  if (x==-1) //等到两个互斥量资源成功
  {
    std::cout << "task b\n";
    // ...
    bar.unlock();
    foo.unlock();
  }
  else //等待失败
  {
    std::cout << "[task b failed: mutex " << (x?"foo":"bar") << " locked]\n";
  }
}

int main ()
{
  std::thread th1 (task_a);
  std::thread th2 (task_b);

  th1.join();
  th2.join();

  return 0;
}

1.2.2 lock

效果:等待多个互斥量资源就绪。如果互斥量资源没有就绪,释放获取到的互斥量资源,同时阻塞;否则,向下执行。

这两个接口可以有效地防止死锁问题,同时我们可以配合 unique_lock 实现 RAII 管理机。

demo:

void safe_transaction() {
    // 手动锁定多个互斥量(使用std::lock可以避免死锁)
    std::lock(mtx1, mtx2);  // 一次性锁定两个互斥量
    
    // 现在我们已经持有两个锁,但需要RAII来确保安全释放
    std::unique_lock<std::mutex> lock1(mtx1, std::adopt_lock);  // 接管mtx1
    std::unique_lock<std::mutex> lock2(mtx2, std::adopt_lock);  // 接管mtx2
    
    // 临界区开始 - 安全地使用共享资源
    std::cout << "安全地操作共享数据..." << std::endl;
    
    // 离开作用域时,lock1和lock2会自动解锁它们接管的互斥量
}

2. condition_variable

下一个我们需要探讨的话题就是条件变量

在这里插入图片描述

  • wait 需要传递⼀个 unique_lock<mutex> 类型的互斥锁,wait 会阻塞当前线程直到被notify。在进入阻塞的⼀瞬间,会解开互斥锁,方便其他线程获取锁,访问条件变量。当被 notify 唤醒时,他会同时获取到锁,再继续往下运行。
  • notify_one 。这是一个轻量级的通知机制:不持有任何锁、不解锁任何互斥量、只是唤醒一个等待中的线程。

小编直接和大家上demo,然后简单解释场景:

现在需要实现两个线程,不管线程的先后启动顺序,都能够实现交替打印奇数和偶数,并且首先打印的是偶数:

int main()
{
	std::mutex m;
	std::condition_variable cv;
	bool flag = true; //关键
	auto threadfunc1 = [&m, &cv, &flag](int start) {
		for(int i = 0; i < 50; ++i) //打印50次
		{
			{//避免在通知的时候加锁了
				std::unique_lock<std::mutex> lock(m); //进入先加锁
				while (!flag)
				{
					//进行循环:flag == false
					cv.wait(lock); //在条件变量下等待
				}
				//循环 flag == true
				std::cout << start << std::endl;
				start += 2;

				flag = false; //关键
			}
			//唤醒条件变量下等待的线程
			cv.notify_one();
		}

		};
	auto threadfunc2 = [&m, &cv, &flag](int start) {
		for (int i = 0; i < 50; ++i) //打印50次
		{
			{ //避免在通知的时候加锁了
				std::unique_lock<std::mutex> lock(m); //进入先加锁
				while (flag)
				{
					//进行循环:flag == true
					cv.wait(lock); //在条件变量下等待
				}
				//循环 flag == false
				std::cout << start << std::endl;
				start += 2;

				flag = true; //关键
			}
			//唤醒条件变量下等待的线程
			cv.notify_one();
		}
		};

	std::thread t1(threadfunc1, 0);
	std::thread t2(threadfunc2, 1);

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

	return 0;
}
  1. 防止伪唤醒。我们一般在采用条件变量等待的时候都需要对判断条件进行循环判断。避免发生伪唤醒。同时为了轻量化,简易唤醒工作不要持有锁的时候通知

    同时:wait 传递参数的时候,可以传入一个可执行对象,这也是对于条件变量判断的包装:
    在这里插入图片描述
    函数对象返回 true,代表条件就绪;否则,代表条件未就绪,进入条件变量下等待唤醒。

  2. 上面实现两个线程交替打印的关键就是:对 flag 和 条件变量 的控制。分析上面程序还是比较麻烦,但是我们可以简单来说:

    • f l a g flag flag 的控制(保证交替性):flag 的值两个线程的交替性现在不管条件变量,简单来说:当 f l a g = t r u e flag = true flag=true 的时候我们就打印偶数;当 f l a g = f a l s e flag = false flag=false 就我们就打印奇数。为了交替打印,那么我们需要每次打印完数据之后都将 f l a g flag flag 进行改变。(简单场景)

      现在简单来说,就是规定: f l a g = t r u e flag = true flag=true 线程 t1 打印偶数; f l a g = f a l s e flag = false flag=false 线程t2 打印奇数。

      这保证了交替性

    • 条件变量的控制(保证有序性):现在我们要求 t1 线程先进来打印偶数。但是假设 t2 线程先进来了,想要打印奇数。t2 线程一看条件:“欸,我的打印的条件还没有满足啊,现在我不可以去打印,乖乖去条件变量下边等着,等我的条件满足了自然就通知我了。”同理,当 t2 线程条件满足的时候,t1线程就回去等待。这就满足了有序性

    • 两者一结合就能很好的配合了:

      举一个确切的例子(t2先于t1)。t2 开始就获取到互斥锁资源了。当上面的 t2 线程发现当前的 f l a g = t r u e flag = true flag=true。t2 线程就知道:自己现在不能打印奇数,所以就到了条件变量下等待,同时关键:将互斥锁资源释放出来。这个时候不管 t1 什么时候来,t1 线程都能得到互斥锁资源,同时 f l a g flag flag 条件也会被满足,t1线程都能打印偶数。然后,t1 线程更改了 f l a g flag flag 标记,唤醒 t2 线程。这个时候就是相反的场景了。

      (如果t1先于t2,t2就会在第一次获取互斥锁资源的时候阻塞)

    上面小编只是粗略的介绍了一下,具体还是要自己慢慢分析各种情况。

完。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐