C++ 多线程 (mutex & conition_variable篇)

目录

引言

1. mutex  

std::mutex

    lock_guard     

    unique_lock

std::recursive_mutex

std::timed_mutex

std::recursive_timed_mutex

2. 条件变量

   condition_variable       

1. wait()

2. wait_for()

3. wait_until()

4. notify_all()

5. notify_one()

   condition_variable_any

3. atomic

4. future


引言

        前面有简单介绍了一下C++的thread,但是往往实际项目中我们面对的是多线程,这一节介绍一下多线程。

       一个进程中往往都会有多个线程,多线程中的一个重要问题就是并发,这些线程依赖于创建它的进程而存在,不独立地拥有资源。也就是说,同一进程中的多个线程之间共享相同的地址空间,可以访问进程中的大部分数据,指针和引用可以在线程间进行传递。这样的话,同一进程内的多个线程就能够很方便的进行数据共享以及通信。

       但是由于多线程并发缺少操作系统提供的保护机制,在多线程共享数据及通信时,就需要开发人员就要保证对共享数据段的操作是以预期的操作流程来进行的,否则就会得到不预期的结果。

      C++11 新标准中除了引入了<thread>外,还引入了如下这些头文件来支持多线程编程,它们分别是<atomic> ,<mutex>,<condition_variable>和<future>。

1. mutex  

        互斥是保护共享数据最基本的方式,在 <mutex>头文件主要声明了与互斥量(mutex)相关的类,包括 std::mutex 系列类,std::lock_guard, std::unique_lock, 以及其他的类型和函数。互斥量提供了互斥这一机制,是为了解决数据共享过程中可能存在的数据竞争冒险的问题,以保证数据的一致性访问。

       互斥大概的原理是:一段程序访问一个数据结构前,先锁住与数据相关的互斥,待访问结束后,再解锁互斥。C++线程库保证一旦一个线程锁住了某个互斥,若其他线程试图再给这个互斥加锁,则需要等待,要一直等到最初成功加锁的线程把该互斥解锁才可以。有了这个互斥机制,就可以做到一个线程在访问一份数据时,如果它先锁住了互斥量,其他任何线程在此期间也意图访问同一份数据的话,它们就会因为无法锁住互斥量而等待,一直等到前面的线程解锁互斥。

      mutex里提供了如下几种互斥量:

  • std::mutex

std::mutex 是普通互斥锁,它是C++11 中最基本的互斥量,std::mutex 对象提供了独占所有权的特性

       ① 构造函数。std::mutex不允许拷贝构造,也不允许 move 拷贝,最初产生的 mutex 对象是处于unlocked 状态的。

       ② lock()。调用线程将锁住该互斥量。线程调用该函数会发生下面 3 种情况:

         a. 如果该互斥量当前没有被其他线程锁住,则调用线程会将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁。
         b. 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住。
         c. 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。

       ③unlock(), 解锁,释放对互斥量的所有权。

       ④try_lock(),尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞。线程调用该函数也会出现下面 3 种情况:

         a. 如果当前互斥量没有被其他线程锁住,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量。
         b. 如果当前互斥量被其他线程锁住,则当前调用线程返回false,而并不会被阻塞掉。
         c. 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
 

       以如下代码为例子:有一个程序,程序里分别起两个线程对全局变量a进行多次的+1操作,但是下面的运行结果不是预期的20000,而是11891。这是因为两个线程在读写的过程中,可能会出现这种情况:假定某一时刻a=10,线程1读取到了a=10,线程2也同时读取到了a=10,然后分别执行加1的操作,线程1和线程2分别将自加1的结果写回a,不管写入的顺序如何,a都会是11;但是线程1和线程2的的确确分别对a各自执行了一次“+1”操作,但结果也确实不是我们所预期的结果->12

    从这个简单的例子可以看出来,所以如果不对公共资源进行保护的话,就有可能会得到不预期的结果。

#include <iostream>
#include <thread>

using namespace std;

int a = 0;

void incNumber(int num) {
	for (int i = 0; i < num; ++i)
		++a;
}

int main() {

	thread th1(incNumber, 10000);
	thread th2(incNumber, 10000);
	th1.join();
	th2.join();

	cout << "a = "<< a << endl;

	system("pause");
	return 0;
}

       那所以上面这个问题的解决思路其实也跟着出来了:其中一个线程在对a做加1时,另一个线程就不能再同时去操作了,必须要等那个线程做完加1操作后,才能再去加1。

       所以在线程执行时加一把锁来保护共享变量,以保证一个线程在执行过中,另一个线程无法去操作a,以保证数据访问的一致性,修改后的代码以及运行结果如下:

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

int a = 0;
mutex mtx;

void incNumber(int num) {
	
	for (int i = 0; i < num; ++i) {
		mtx.lock();
		++a;
		mtx.unlock();
	}
	
}

int main() {

	thread th1(incNumber, 10000);
	thread th2(incNumber, 10000);
	th1.join();
	th2.join();

	cout << "a = " << a << endl;

	system("pause");
	return 0;
}

       虽然std::mutex可以对多线程编程中的共享变量提供保护,但其实在使用中不推荐直接去调用mutex的成员函数lock(),因为这样的话,我们要在函数以外的每条代码路径上都要调用unlock(),包括由于异常导致退出的路径。如果忘记unlock(),将导致锁无法释放,比如先lock()成功的线程在中途因为某些原因还没走到调用unlock()的位置,就中途退出了,而异常退出的路径上又忘记调用了unlock(),这就会发生死锁。

       因为C++有一个独特的功能叫“析构函数”,当一个对象被销毁时,析构函数就会被调用,不管是这个对象是因为发生了异常,还是正常的寿终正寝;如果把unlock()这个动作放在一个对象的析构函数里,系统就会保证unlock()在任何情况下都会被调用到,哪怕是遇到了异常。因此在往往会更建议使用lock_guard或者unique_lock,这能有效避免忘记unlock()这种问题,绝大多数情况下,lock_guard和unique_lock这两者之间是可以互相替代的。

      使用lock_gurad和unique_lock有利于遵守RAII的规范:在构造时给互斥加锁,在析构时解锁,从而保证了互斥总被正确解锁。

  •     lock_guard     

     std::lock_guard只有构造函数和析构函数。简单的来说:当调用构造函数时,会自动调用传入的对象的lock()函数;而当析构函数被调用时,会自动调用对象的unlock()函数

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

int a = 0;
mutex mtx;

void incNumber(int num) {

	//use lock_guard
	for (int i = 0; i < num; ++i) {
		lock_guard<mutex> lck(mtx);
		++a;
	}

}

int main() {

	thread th1(incNumber, 10000);
	thread th2(incNumber, 10000);
	th1.join();
	th2.join();

	cout << "a = " << a << endl;

	system("pause");
	return 0;
}

lock_guard的特性如下:

  • 创建即加锁,即lock_guard在创建lck对象的时候,就已经把mtx给锁住了,作用域结束自动析构并解锁,无需手工解锁
  •  不能中途解锁,只能等生命周期结束后才会自动解锁。
  •  不能复制
  • lock_guard 管理的是mutex对象lock()和unlock()调用时机,并不管理 std::mutex 对象本身的生命周期,也就是说在使用 lock_guard 的过程中,如果 std::mutex 的对象被释放了,那么在 lock_guard 析构的时候再进行unlock()就会出现空指针错误。

  •     unique_lock

       unique_lock和lock_guard一样,也可以实现自动加锁和解锁,但是unique_lock会更加灵活,同时也要付出更多的时间、性能成本。unique_lock的特性如下

  • 创建时可以选择:”创建即加锁“ 和 ”创建时可以不锁定(通过指定第二个参数为 std::defer_lock),而在需要时再锁定“。
  • 可以随时加锁解锁。unique_lock可以进行临时解锁和再上锁,比如在构造对象之后也可以使用unlock()进行解锁,再使用lock()进行上锁,而不必要等到析构之时再去自动unlock()。
  • 作用域规则同 lock_grard,析构时自动释放锁
  • 不可复制,可移动
  • 条件变量需要该类型的锁作为参数(此时必须使用 unique_lock)

下面是一个使用unique_lock的例子,就是将前面的lock_guard换成了unique_lock()

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

int a = 0;
mutex mtx;

void incNumber(int num) {

	//use unique_lock
	for (int i = 0; i < num; ++i) {
		unique_lock<mutex> lck(mtx);
		++a;
	}

}

int main() {

	thread th1(incNumber, 10000);
	thread th2(incNumber, 10000);
	th1.join();
	th2.join();

	cout << "a = " << a << endl;

	system("pause");
	return 0;
}

  • std::recursive_mutex

        递归锁。递归锁也是一种锁类型,它允许同一个线程对同一个锁对象多次上锁,获得多层所有权。当解锁时,unlock函数调用的次数需要与lock调用的次数相同。

       前面mutex一节中,有说过调用线程在lock互斥量时,如果这个互斥量已经有被当前调用线程锁住,则会产生死锁(deadlock)。

        比如如下这段代码,主线程在调用incNumber时已经对mtx lock了一次,然后incNumber在调用printValue时,这里面又去lock mtx,这就会形成一个互相僵持住的局面:一方面,因为incNumber里已经lock住了mtx,printValue里无法lock mtx;另一方面,因为printValue卡住了,所以incNumber无法unlock mtx。简单地说就是:printValue说你incNumber要先把mtx unlock掉,我才能往下继续走;而incNumber说那你printValue要往下走,你做完后,我这边才有机会出作用域,来unlock mtx。所以这样就形成了一个互相等待而僵持住的局面,即形成了死锁。

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

class A {
public:
	A(int x=0) : val(x) {}

	void incNumber(int x) {
		std::lock_guard<mutex> lock(mtx);
		val += x;
		printValue();
	}

private:
	void printValue() {
		std::unique_lock<mutex> lock(mtx);
		cout << "val = " << val << endl;
	}

private:
	mutex mtx;
	int val;
};

int main(void) {
	A a(100);
	a.incNumber(50);

	system("pause");
	return 0;
}

       采用递归锁可以解决上面这个问题,递归锁允许同一个线程多次获取该互斥锁,可以用来解决同一线程需要多次获取互斥量时死锁的问题。将上面这段程序中所用的mutex替换成为recursive_mutex后就不会产生死锁了。

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

class A {
public:
	A(int x=0) : val(x) {}

	void incNumber(int x) {
		std::lock_guard<recursive_mutex> lock(mtx);
		val += x;
		printValue();
	}

private:
	void printValue() {
		std::unique_lock<recursive_mutex> lock(mtx);
		cout << "val = " << val << endl;
	}

private:
	recursive_mutex mtx;
	int val;
};

int main(void) {
	A a(100);
	a.incNumber(50);

	system("pause");
	return 0;
}

虽然递归锁能解决这种情况的死锁问题,但是尽量不要使用递归锁,主要原因如下:

  • 需要用到递归锁的多线程互斥处理本身就是可以简化的,允许递归很容易放纵复杂逻辑的产生,并且产生晦涩难懂的代码,当要使用递归锁的时候应该重新审视自己的代码是否一定要使用递归锁。
  • 递归锁比起非递归锁,效率会低。
  • 递归锁虽然允许同一个线程多次获得同一个互斥量,但可重复获得的最大次数并未具体说明,一旦超过一定的次数,再对lock进行调用就会抛出std::system错误。

  • std::timed_mutex

        定时mutex。实现有时限的锁定,在规定的等待时间内,没有获取锁,线程不会一直阻塞,代码会继续执行。

     std::time_mutex 比 std::mutex 多了两个成员函数,try_lock_for(),try_lock_until()。

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

using namespace std;

timed_mutex mtx;

void work() {
	while (true) {
		if (mtx.try_lock_for(chrono::milliseconds(10))) {
			std::cout << std::this_thread::get_id() << ": do work with the mutex" << std::endl;
			std::this_thread::sleep_for(chrono::milliseconds(100));
			mtx.unlock();
		}
		else {
			std::cout << std::this_thread::get_id() << ": do work without the mutex" << std::endl;
			td::this_thread::sleep_for(chrono::milliseconds(100));
		}
	}
}
int main(void) {
	std::thread t1(work);
	std::thread t2(work);
	t1.join();
	t2.join();
	std::cout << "main finish\n";
	return 0;
}

  • std::recursive_timed_mutex

       定时递归 Mutex 类。递归定时互斥将recursive_mutex的特性和timed_mutex的特性组合到一个类中:它既支持单个线程获取多次锁,也支持定时尝试锁请求。和 std:recursive_mutex 与 std::mutex 的关系一样,std::recursive_timed_mutex 的特性也可以从 std::timed_mutex 推导出来,这里就不展开讲了。

注意:使用互斥时,不得向锁所在的作用域之外传递指向受保护的共享数据的指针和引用,无论是通过函数返回值将它们保存到对外可见的内存,还是将它们作为参数传递给使用者提供的函数。这个就需要开发者自己记得把所有访问共享数据的代码都标记成互斥,避免出现游离的指针或引用,不然互斥保护就形同虚设了

2. 条件变量

      <condition_variable>头文件里主要声明了与条件变量相关的类,包括 std::condition_variable 和 std::condition_variable_any。

       在多线程的使用场景中,除了涉及到共享数据读写的同步问题,还会遇到线程的执行顺序同步的情况,即:线程间需要按照预定的先后次序执行的行为------->线程同步。
      若等待的线程通过 while 循环不断的判断当前条件是否满足也可以达到目的,但 while 死循环是很耗CPU 资源的。
       我们想达到的效果是: “假如在等待的线程在条件不满足时就一直阻塞到那里,一旦条件满足后,再唤醒它继续执行代码” 。C++ 11中提供了条件变量来满足这个需求,条件变量可以理解为是一种信号通知机制,一个线程负责发送信号,其他线程等待该信号的触发,可用于阻止一个线程或同时阻止多个线程,直到另一个线程修改共享变量(condition),并通知condition_variable,才会继续执行。

       条件变量要和锁一起使用,锁提供了互斥这一机制,而条件变量在其基础上提供了同步的机制(同步是比互斥更严格的关系,互斥只要求线程间访问某一资源时不存在同时处理或者交替处理的可能,而对线程本身的调度顺序没有限制,也就是说谁先访问都行,但是得一个一个的来,这就是互斥。同步就是在互斥的基础上,再增加了一些控制条件:虽然线程之间的调度我们没办法控制,但我们可以原子的让某些线程在唤醒时检查某个条件,如果条件不满足就释放锁然后进入阻塞,通过这种方式达到控制不同线程按照某一种你设定的顺序访问资源)
 

   condition_variable       

     前面提到条件变量需要和互斥量一起来配合工作,但condition_variable 仅适用于 std::unique_lock<std::mutex>。

1. wait()

      当前线程调用wait()后将会被阻塞,当线程被阻塞时,该函数会自动调用std::mutex的unlock()将锁释放掉,使得其它被阻塞在锁竞争上的线程得以继续执行。一旦当前线程获得通知(notify,通常是另外某个线程调用notify_*唤醒了当前线程),wait()函数也是自动调用std::mutex的lock()。wait分为无条件被阻塞和带条件的被阻塞两种

void wait (unique_lock<mutex>& lck);会阻塞当前线程直至条件变量被通知,或虚假唤醒发生
template <class Predicate>
void wait (unique_lock<mutex>& lck, Predicate pred)

相对上面的wait()多了一个条件参数 Predicate,只有当 pred 条件为false 时调用 wait() 才会阻塞当前线程,并且在收到其他线程的通知后只有当 pred 为 true 时才会被解除阻塞

      有时候会出现一种情况,当A线程在发notify时,本应要wait的B线程可能恰好在做别的事情,没有在wait(),这也是有可能导致死锁的。

2. wait_for()

       作用与wait()类似,只是wait_for可以指定一个时间段,在当前线程收到通知或者指定的时间超时之前,该线程都会处于阻塞状态。而一旦超时或者收到了其它线程的通知,wait_for返回,剩下的步骤和wait类似。

3. wait_until()

       与wait_for类似,只是wait_until可以指定一个时间点,在当前线程收到通知或者指定的时间点超时之前,该线程都会处于阻塞状态。而一旦超时或者收到了其它线程的通知,wait_until返回,剩下的处理步骤和wait类似。

4. notify_all()

      唤醒所有的wait线程,如果当前没有等待线程,则该函数什么也不做。

5. notify_one()

       唤醒某个wait线程,如果当前没有等待线程,则该函数什么也不做;如果同时存在多个等待线程,则唤醒哪个线程是不确定的。

       下面我们使用一个经典的生产者-消费者场景来阐述对于condition_variable的使用,生产者-消费者问题,也称有限缓冲问题,是一个多线程同步问题的经典案例。该问题描述了共享固定大小缓冲区的两个线程——即所谓的“生产者”和“消费者”,在实际运行时会发生的问题。

       生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,费者也在缓冲区消耗这些数据。该问题的关键就是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区为空时消耗数据。

       要解决该问题,就必须让生产者在缓冲区满时休眠(要么干脆就放弃数据),等到下次消费者消耗缓冲区中的数据的时候,生产者才能被唤醒,开始往缓冲区添加数据。同样,也可以让消费者在缓冲区空时进入休眠,等到生产者往缓冲区添加数据之后,再唤醒消费者。
 

#include <iostream>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>

using namespace std;

queue<int> dataQueue;
mutex g_mutex;
condition_variable g_cv;

const int MAX_DATAQUEUE_NUM = 20;
const int PRODUCER_NUM = 3;
const int CONSUMER_NUM = 2;

int Data = 0;

void producerThread(int tid) {
	while (1) {
        this_thread::sleep_for(std::chrono::milliseconds(1000));
        
        unique_lock<mutex> lck(g_mutex);
        g_cv.wait(lck, [] {return dataQueue.size() < MAX_DATAQUEUE_NUM; });
        dataQueue.push(Data);
        cout << "+ Prorducer " << tid << " : push data(" << Data << ") to queue. \n";
        ++Data;
        
        g_cv.notify_all();
   }
}

void consumerThread(int tid) {
	while (1) {
		this_thread::sleep_for(std::chrono::milliseconds(500));

		unique_lock<mutex> lck(g_mutex);
		g_cv.wait(lck, [] {return !dataQueue.empty(); });
		cout << "- Consumer  " << tid << " : deque data(" << dataQueue.front() << ") from queue. \n";
		dataQueue.pop();

		g_cv.notify_all();
	}
}

int main() {

	thread producers[PRODUCER_NUM];
	thread consumers[CONSUMER_NUM];

	for (int i = 0; i < PRODUCER_NUM; ++i)
		producers[i] = thread(producerThread,i);

	for (int i = 0; i < CONSUMER_NUM; ++i)
		consumers[i] = thread(consumerThread,i);


	for (int i = 0; i < PRODUCER_NUM; ++i)
		producers[i].join();
		 
	for (int i = 0; i < CONSUMER_NUM; ++i)
		consumers[i].join();

	return 0;
}

   condition_variable_any

        condition_variable_any与 std::condition_variable类似,只不过而 std::condition_variable只能接受 std::mutex类型的参数,而std::condition_variable_any可以和任意带有 lock()、unlock() 语义的 mutex 搭配使用,也就是说它还可以搭配mutex、recursive_mutex、time_mutex和recursive_timed_mutex等这些锁。除此以外,condition_variable_any和std::condition_variable几乎完全一样。
 

3. atomic

        前面章节介绍的st::mutex可以保证多线程之间数据访问的互斥性,但是C++11还提供了一种原子类型,它提供了多线程间的原子操作,它是一种不需要用到mutex技术的多线程并发编程方式,一个操作一旦开始,这个操作就不能被处理器拆分处理,能够确保所有其他线程都不在同一时间访问该资源,不会存在数据竞争的问题。

         所以对于原子操作来讲,是不可能看到原子操作只完成了部分这种情况的, 它要么是做了,要么就是没做,只有这两种可能。虽然mutex也可以提供共享资源的访问的保护,但是原子操作更加接近底层,因而效率一般比互斥对象更高,可以说,原子操作轻松地化解了互斥访问共享数据的难题。

      这里就只简单描述一下atomic,这 将在后面单独起一篇来介绍atomic。


4. future

        C++11中提供了std::thread,可以异步地运行任务,但却无法获取任务执行的结果,一般都是依靠全局对象来达到目的,但是全局对象在多线程下是很不安全的。为此标准库提供了std::future类模板,它提供了一种用于访问异步操作结果的机制,解决了 std::thread 无法返回值的问题

       当我们在多线程编程中使用异步任务时,std::future可以帮助我们在需要的时候获取任务的执行结果。std::future的一个重要特性是能够阻塞当前线程,直到异步操作完成,从而确保我们在获取结果时不会遇到未完成的操作。

       这里就只简单描述一下future,这 将在后面单独起一篇来介绍future。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值