目录
引言
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 新标准中除了引入了
互斥是保护共享数据最基本的方式,在
互斥大概的原理是:一段程序访问一个数据结构前,先锁住与数据相关的互斥,待访问结束后,再解锁互斥。C++线程库保证一旦由线程锁住了某个互斥,若其他线程试图再给这个互斥加锁,则需要等待,要一直等到最初成功加锁的线程把该互斥解锁才可以。有了这个互斥机制,就可以做到一个线程在访问一份数据时,如果它先锁住了互斥量,其他任何线程在此期间也意图访问同一份数据的话,它们就会因为无法锁住互斥量而等待,一直到前面的线程完事儿。
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也是确实分别执行了一次自加1操作,但结果也确实不是我们所期望的结果->12
所以如果不对公共资源进行保护的话,就有可能会得到不预期的结果。
#include
#include
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
#include
#include
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(),因为这样的话,我们要在函数以外的每条代码路径上都要调用unloock(),包括由于一场导致退出的路径。如果忘记unlock(),将导致锁无法释放,比如先lock()成功的线程中途因为某些原因还没走到调用unlock()的位置,就中途退出了,而异常退出的路径上又忘记调用了unlock(),这就会发生死锁。因此在往往会更建议使用lock_guard或者unique_lock,这能有效避免忘记unlock()这种问题,绝大多数情况下,lock_guard和unique_lock这两者之间是可以互相替代的。
使用lock_gurad和unique_lock有利于遵守RAII的规范:在构造时给互斥加锁,在析构时解锁,从而保证了互斥总被正确解锁。
std::lock_guard只有构造函数和析构函数。简单的来说:当调用构造函数时,会自动调用传入的对象的lock()函数;而当析构函数被调用时,会自动调用对象的unlock()函数。
#include
#include
#include
using namespace std;
int a = 0;
mutex mtx;
void incNumber(int num) {
//use lock_guard
for (int i = 0; i < num; ++i) {
lock_guard 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的特性如下:
unique_lock和lock_guard一样,也可以实现自动加锁和解锁,但是unique_lock会更加灵活,同时也要付出更多的时间、性能成本。unique_lock的特性如下
下面是一个使用unique_lock的例子,就是将前面的lock_guard换成了unique_lock()
#include
#include
#include
using namespace std;
int a = 0;
mutex mtx;
void incNumber(int num) {
//use unique_lock
for (int i = 0; i < num; ++i) {
unique_lock 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;
}
递归锁。递归锁也是一种锁类型,它允许同一个线程对同一个锁对象多次上锁,获得多层所有权。当解锁时,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
#include
#include
using namespace std;
class A {
public:
A(int x=0) : val(x) {}
void incNumber(int x) {
std::lock_guard lock(mtx);
val += x;
printValue();
}
private:
void printValue() {
std::unique_lock 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
#include
#include
using namespace std;
class A {
public:
A(int x=0) : val(x) {}
void incNumber(int x) {
std::lock_guard lock(mtx);
val += x;
printValue();
}
private:
void printValue() {
std::unique_lock 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;
}
虽然递归锁能解决这种情况的死锁问题,但是尽量不要使用递归锁,主要原因如下:
定时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
#include
#include
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;
}
定时递归 Mutex 类。递归定时互斥将recursive_mutex的特性和timed_mutex的特性组合到一个类中:它既支持单个线程获取多次锁,也支持定时尝试锁请求。和 std:recursive_mutex 与 std::mutex 的关系一样,std::recursive_timed_mutex 的特性也可以从 std::timed_mutex 推导出来,这里就不展开讲了。
注意:使用互斥时,不得向锁所在的作用域之外传递指向受保护的共享数据的指针和引用,无论是通过函数返回值将它们保存到对外可见的内存,还是将它们作为参数传递给使用者提供的函数。这个就需要开发者自己记得把所有访问共享数据的代码都标记成互斥,避免出现游离的指针或引用,不然互斥保护就形同虚设了
在多线程的使用场景中,除了涉及到共享数据读写的同步问题,还会遇到线程的执行顺序同步的情况,即:线程间需要按照预定的先后次序执行的行为------->线程同步。
若等待的线程通过 while 循环不断的判断当前条件是否满足也可以达到目的,但 while 死循环是很耗CPU 资源的。
我们想达到的效果是: “假如在等待的线程在条件不满足时就一直阻塞到那里,一旦条件满足后,再唤醒它继续执行代码” 。C++ 11中提供了条件变量来满足这个需求,条件变量可以理解为是一种信号通知机制,一个线程负责发送信号,其他线程等待该信号的触发,可用于阻止一个线程或同时阻止多个线程,直到另一个线程修改共享变量(condition),并通知condition_variable,才会继续执行。
条件变量要和锁一起使用,锁提供了互斥这一机制,而条件变量在其基础上提供了同步的机制(同步是比互斥更严格的关系,互斥只要求线程间访问某一资源时不存在同时处理或者交替处理的可能,而对线程本身的调度顺序没有限制,也就是说谁先访问都行但你们一个个来,这就是互斥。同步就是在互斥的基础上,虽然线程之间的调度我们没办法控制,但我们可以原子的让某些线程在唤醒时检查某个条件,如果条件不满足就释放锁然后进入阻塞,通过这种方式达到控制不同线程按照某一种你设定的顺序访问资源)
前面提到条件变量需要和互斥量一起来配合工作,但condition_variable 仅适用于 std::unique_lock
当前线程调用wait()后将会被阻塞,当线程被阻塞时,该函数会自动调用std::mutex的unlock()将锁释放掉,使得其它被阻塞在锁竞争上的线程得以继续执行。一旦当前线程获得通知(notify,通常是另外某个线程调用notify_*唤醒了当前线程),wait()函数也是自动调用std::mutex的lock()。wait分为无条件被阻塞和带条件的被阻塞两种
void wait (unique_lock |
会阻塞当前线程直至条件变量被通知,或虚假唤醒发生 |
template void wait (unique_lock |
相对上面的wait()多了一个条件参数 |
有时候会出现一种情况,当A线程在发notify时,本应要wait的B线程可能恰好在做别的事情,没有在wait(),这也是有可能导致死锁的。
作用与wait()类似,只是wait_for可以指定一个时间段,在当前线程收到通知或者指定的时间超时之前,该线程都会处于阻塞状态。而一旦超时或者收到了其它线程的通知,wait_for返回,剩下的步骤和wait类似。
与wait_for类似,只是wait_until可以指定一个时间点,在当前线程收到通知或者指定的时间点超时之前,该线程都会处于阻塞状态。而一旦超时或者收到了其它线程的通知,wait_until返回,剩下的处理步骤和wait类似。
唤醒所有的wait线程,如果当前没有等待线程,则该函数什么也不做。
唤醒某个wait线程,如果当前没有等待线程,则该函数什么也不做;如果同时存在多个等待线程,则唤醒某个线程是不确定的。
下面我们使用一个经典的生产者-消费者场景来阐述对于condition_variable的使用,生产者-消费者问题,也称有限缓冲问题,是一个多线程同步问题的经典案例。该问题描述了共享固定大小缓冲区的两个线程——即所谓的“生产者”和“消费者”,在实际运行时会发生的问题。
生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,费者也在缓冲区消耗这些数据。该问题的关键就是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区中空时消耗数据。
要解决该问题,就必须让生产者在缓冲区满时休眠(要么干脆就放弃数据),等到下次消费者消耗缓冲区中的数据的时候,生产者才能被唤醒,开始往缓冲区添加数据。同样,也可以让消费者在缓冲区空时进入休眠,等到生产者往缓冲区添加数据之后,再唤醒消费者。
#include
#include
#include
#include
#include
using namespace std;
queue 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 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 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与 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几乎完全一样。
前面章节介绍的st::mutex可以保证多线程之间数据访问的互斥性,但是C++11还提供了一种原子类型,它提供了多线程间的原子操作,它是一种不需要用到mutex技术的多线程并发编程方式,一个操作一旦开始,这个操作就不能被处理器拆分处理,能够确保所有其他线程都不在同一时间访问该资源,不会存在数据竞争的问题。
所以对于原子操作来讲,时不可能看到原子操作只完成了部分这种情况的, 它要么是做了,要么就是没做,只有这两种可能。虽然mutex也可以提供共享资源的访问的保护,但是原子操作更加接近底层,因而效率一般比互斥对象更高,可以说,原子操作轻松地化解了互斥访问共享数据的难题。
这里就只简单描述一下atomic,这 将在后面单独起一篇来介绍atomic。
C++11中提供了std::thread,可以异步地运行任务,但却无法获取任务执行的结果,一般都是依靠全局对象来达到目的,但是全局对象在多线程下是很不安全的。为此标准库提供了std::future类模板,它提供了一种用于访问异步操作结果的机制,解决了 std::thread 无法返回值的问题
当我们在多线程编程中使用异步任务时,std::future可以帮助我们在需要的时候获取任务的执行结果。std::future的一个重要特性是能够阻塞当前线程,直到异步操作完成,从而确保我们在获取结果时不会遇到未完成的操作。
这里就只简单描述一下future,这 将在后面单独起一篇来介绍future。