多线程编程初探1:互斥量、互斥锁(mutex)和条件变量及异步编程

1.定义:

互斥锁是为了解决在多线程访问共享资源时,多个线程同时对共享资源操作产生的冲突而提出的一种解决方法。

在执行时,哪个线程持有互斥锁,并对共享资源成功加锁后,才能对共享资源进行操作,此时其它线程不能对共享资源进行操作。

只有在持有锁的线程将锁解锁释放后,其它线程才能进行抢锁加锁操作。

互斥锁的主要作用就是用来解决多线程对共享资源的竞争问题。

但,应注意:同一时刻,只能有一个线程持有该锁

当A线程对某个全局变量加锁访问,B在访问前尝试加锁,拿不到锁,B阻塞。(可以理解为对普通的mutex只能解锁后再加锁,没解锁时是没法再次加锁的)

C线程不去加锁,而直接访问该全局变量,依然能够访问,但会出现数据混乱

所以,互斥锁实质上是操作系统提供的一把“建议锁”(又称“协同锁”),建议程序中有多线程访问共享资源的时候使用该机制。但,并没有强制限定。
因此,即使有了mutex,如果有线程不按规则来访问数据,依然会造成数据混乱。:譬如没有按照规定的顺序访问等等

c++11之后提供了:

4种类型的互斥量
std::mutex:最基本的mutex类。
std::recursive_mutex:递归mutex类,能多次锁定而不死锁。
std::time_mutex:定时mutex类,可以锁定一定的时间。
std::recursive_timed_mutex:定时递归mutex类。

扫描二维码关注公众号,回复: 12419579 查看本文章

2.加锁的方式不同,虽然程序运行结果可能相同,但运行速度可能相差较大(这里说的加锁的方式主要指加锁的"粒度"):

因为频繁的加锁解锁带来的线程的切换、调度(需要保存上下文)会额外地占用cpu资源,消耗额外的时间

// test20201028.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//
#include<thread>
#include<mutex>
#include<iostream>
#include<ctime>

#define maxtimes 1000000
void add0(int *x,std::mutex * m)
{
	(*m).lock();
	for (int i = 0; i < maxtimes; i++)
		(*x)++;
	(*m).unlock();
}
void add1(int*x, std::mutex * m)
{
	
	for (int i = 0; i < maxtimes; i++)
	{
		(*m).lock();
		(*x)++;
		(*m).unlock();
	}
}
void f0()
{
	int x = 0;
	int start = clock();
	std::mutex m;
	std::thread t0(add0,&x,&m);
	std::thread t1(add0, &x, &m);
	t0.join();
	t1.join();
	int end = clock();
	std::cout << "初始值为0,循环外加锁计算结果为" << x << std::endl;
	std::cout << "方法1耗时:" << end - start<<std::endl;
}
void f1()
{
	int x = 0;
	int start = clock();
	std::mutex m;
	std::thread t0(add1, &x, &m);
	std::thread t1(add1, &x, &m);
	t0.join();
	t1.join();
	int end = clock();
	std::cout << "初始值为0,循环外加锁计算结果为" << x << std::endl;
	std::cout << "方法2耗时:" << end - start<<std::endl;
}
int main()
{
	f0();
	f1();
	return 0;
}


可以看出虽然结果相同,但耗时相差很大,加锁的方式或者说是加锁的位置,要根据实际情况考虑。

举个例子,互斥锁可以实现多线程时多个函数的按序执行或者说是先后执行,而不受调用次序影响

下图是没有加锁的结果,(基本是先创建的先输出)

但是如果按照一定规则加上锁,就能保证是1,2,3的顺序

需要注意的是在debug模式下会报错:因为加锁和解锁没有严格保证在同一个线程内。

官方文档的解释:

对于lock:

       Blocks the calling thread until the thread obtains ownership of the mutex.

       注意事项:If the calling thread already owns the mutex, the behavior is undefined.

对于unlock:

      Releases ownership of the mutex.

      注意事项:If the calling thread does not own the mutex, the behavior is undefined.

总结起来就是一句话:不能在同一线程中同时调用两次lock或两次unlock(没有配对的情况下);某个线程a执行了lock则必须执行unlock。

2.分类:

c++11之后提供了:

4种类型的互斥量
std::mutex:最基本的mutex类。
std::recursive_mutex:递归mutex类,能多次锁定而不死锁。
std::time_mutex:定时mutex类,可以锁定一定的时间。
std::recursive_timed_mutex:定时递归mutex类。

c++中提供使用锁的方式有:

1.简单直接的lock()、unlock(),缺点是必须一一对应,容易编程出错

2.通过lock_guard() //其在超出作用域范围时会主动解锁:原理:构造函数里lock(),析构函数unlock(),超出作用范围,自动析构

只用于解锁上锁,不提供对锁生命周期的管理,不支持尝试加锁

3.unique_lock() 提供了更好更灵活的机制,允许在生命周期内部手动加锁,解锁,支持尝试加锁解锁,很灵活,也可以自动按照作用域解锁

3.条件变量(condition variable):

使用mutx互斥锁时,最理想的状态是加锁后不阻塞,用完数据立刻解锁给别的线程用,把对并发和性能的影响降到最低。但是实际运用中会出现等待某个条件成立,再运行该线程的情况。

这个时候如果只是用循环判断该条件成立的话,如果循环间隔太短,就会占用cpu资源,循环间隔时间太长,可能会导致运行的延误,为了解决这个问题,就有了条件变量。

#include <iostream>
#include <thread>
#include <mutex>
#include <deque>
using namespace std;
deque<int> d;
mutex m;

void create()//生产
{
	int num = 10;
	while (num--)
	{
		unique_lock<mutex> l(m);
		d.push_front(num);
		cout << "生产数据" << num << endl;
		
		l.unlock();
		std::this_thread::sleep_for(std::chrono::seconds(2));
	}
}


void eat()//消费
{
	int data = 0;
	while (data!=1)
	{
		unique_lock<mutex> l(m);
		if (!d.empty())
		{
			
			data = d.front();
			d.pop_front();
			cout << "获取数据:"<<data << endl;
			l.unlock();
		}
		else
		{
			l.unlock();
			//std::this_thread::sleep_for(std::chrono::milliseconds(500));
		}
		//std::this_thread::sleep_for(std::chrono::seconds(1));
	}
}
int main()
{
	
	thread t2(create);thread t1(eat);
	t1.join();
	t2.join();
	return 0;
}

下图为上面代码的结果,可以看到没有使用条件变量,只是简单的循环判断吃了i5 8300H      24%的cpu资源

下图为给循环判断增加了500ms的间隔后(即取消这行的注释std::this_thread::sleep_for(std::chrono::seconds(1));),占用资源急剧减少,验证了上面的说法

条件变量是线程的另外一种有效同步机制。这些同步对象为线程提供了交互的场所(一个线程给另外的一个或者多个线程发送消息),我们指定在条件变量这个地方发生,一个线程用于修改这个变量使其满足其它线程继续往下执行的条件,其它线程则等待接收条件已经发生改变的信号。当条件变量同互斥锁一起使用时,条件变量允许线程以一种无竞争的方式等待任意条件的发生。

但前面也说了,困难之处在于如何确定这个延长时间(即轮询间隔周期),如果间隔太短会过多占用CPU资源,如果间隔太长会因无法及时响应造成延误。

这就引入了条件变量来解决该问题:条件变量使用“通知—唤醒”模型,生产者生产出一个数据后通知消费者使用,消费者在未接到通知前处于休眠状态节约CPU资源;当消费者收到通知后,赶紧从休眠状态被唤醒来处理数据,使用了事件驱动模型,在保证不误事儿的情况下尽可能减少无用功降低对资源的消耗。

条件变量的使用:

c++11后标准库中提供了condition_variable  可以用来一个线程唤醒其他在等待中的线程。

原则上,条件变量的运作如下:

  • 你必须同时包含< mutex >和< condition_variable >,并声明一个mutex和一个condition_variable变量;
  • 那个通知“条件已满足”的线程(或多个线程之一)必须调用notify_one()或notify_all(),以便条件满足时唤醒处于等待中的一个条件变量;
  • 那个等待"条件被满足"的线程必须调用wait(),可以让线程在条件未被满足时陷入休眠状态,当接收到通知时被唤醒去处理相应的任务;

下图为使用条件变量后的效果,对cpu占用率小很多,并且不用考虑循环间隔设置的问题了。


#include <iostream>
#include <thread>
#include <mutex>
#include <deque>
using namespace std;
deque<int> d;
mutex m;
std::condition_variable cond;//条件变量
void create()//生产
{
	int num = 10;
	while (num--)
	{
		unique_lock<mutex> l(m);
		d.push_front(num);
		cout << "生产数据" << num << endl;
		
		l.unlock();
		cond.notify_one();              // 向一个等待线程发出“条件已满足”的通知
		std::this_thread::sleep_for(std::chrono::seconds(1));
	}
}


void eat()//消费
{
	int data = 0;
	while (data!=1)
	{
		unique_lock<mutex> l(m);
		while (d.empty())        //判断队列是否为空
			cond.wait(l); // 解锁互斥量并陷入休眠以等待通知被唤醒,被唤醒后加锁以保护共享数据
		if (!d.empty())
		{
			
			data = d.front();
			d.pop_front();
			cout << "获取数据:"<<data << endl;
			l.unlock();
		}
		else
		{
			l.unlock();
		//	std::this_thread::sleep_for(std::chrono::milliseconds(500));
		}
		//std::this_thread::sleep_for(std::chrono::seconds(1));
	}
}
int main()
{
	
	thread t2(create);thread t1(eat);
	t1.join();
	t2.join();
	return 0;
}

需要注意的是:

  • 所有通知(notification)都会被自动同步化,所以并发调用notify_one()和notify_all()不会带来麻烦;
  • 所有等待某个条件变量(condition variable)的线程都必须使用相同的mutex,当wait()家族的某个成员被调用时该mutex必须被unique_lock锁定,否则会发生不明确的行为;
  • wait()函数会执行“解锁互斥量–>陷入休眠等待–>被通知唤醒–>再次锁定互斥量–>检查条件判断式是否为真”几个步骤,这意味着传给wait函数的判断式总是在锁定情况下被调用的,可以安全的处理受互斥量保护的对象;但在"解锁互斥量–>陷入休眠等待"过程之间产生的通知(notification)会被遗失。

线程同步保证了多个线程对共享数据的有序访问,目前我们了解到的多线程间传递数据主要是通过共享数据(全局变量)实现的,全局共享变量的使用容易增加不同任务或线程间的耦合度,也增加了引入bug的风险,所以全局共享变量应尽可能少用。很多时候我们只需要传递某个线程或任务的执行结果,以便参与后续的运算,但我们又不想阻塞等待该线程或任务执行完毕,而是继续执行暂时不需要该线程或任务执行结果参与的运算,当需要该线程执行结果时直接获得,才能更充分发挥多线程并发的效率优势。

这就意味着需要异步编程:

异步编程:

如果细心观察不难发现,前面提到的线程同步主要是为了解决对共享数据的竞争访问问题,所以线程同步主要是对共享数据的访问同步化(按照既定的先后次序,一个访问需要阻塞等待前一个访问完成后才能开始)。这篇文章谈到的异步编程主要是针对任务或线程的执行顺序,也即一个任务不需要阻塞等待上一个任务执行完成后再开始执行,程序的执行顺序与任务的排列顺序是不一致的。下面从任务执行顺序的角度解释下同步与异步的区别:

  • 同步:就是在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由调用者主动等待这个调用的结果。
  • 异步:调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
  • åæ­¥ä¸å¼æ­¥è°ç¨

通常情况下,线程调用者需要获得线程的执行结果或执行状态,以便后续任务的执行。那么,通过什么方式获得被调用者的执行结果或状态呢?

1:使用全局变量、条件变量来传递结果

2.c++11后使用future与promise

< future >头文件功能允许对特定提供者设置的值进行异步访问,可能在不同的线程中。
这些提供程序(要么是promise 对象,要么是packaged_task对象,或者是对异步的调用async)与future对象共享共享状态:提供者使共享状态就绪的点与future对象访问共享状态的点同步。

 

猜你喜欢

转载自blog.csdn.net/weixin_42067304/article/details/109330575