二、互斥量库mutex
2.1 mutex的种类
在C++11中,mutex类中总共包了四种互斥量
std::muetx
mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动
常用成员函数如下:
线程函数调用lock时:
若该互斥量当前没有被其他线程获取,则调用线程获取互斥量,直到调用unlock()之前,该线程一直拥有该锁
若该互斥量已经被其他线程获取,则当前的调用线程会被阻塞,直至其他线程将互斥量释放
若当前线程已获取该互斥量,却又调用lock(),则会产生死锁(deadlock)
线程调用try_lock时:
若该互斥量当前没有被其他线程获取,则调用线程获取该互斥量,直到调用unlock()之前,该线程一直拥有该锁
若该互斥量已经被其他线程获取,则try_lock()调用返回false,调用线程不会被阻塞
若当前线程已获取该互斥量,却又调用try_lock(),则会产生死锁(deadlock)
std:recursive_mutex
recursive_mutex被称为递归互斥锁,该锁专门用于递归函数中的加锁操作
若在递归函数中使用mutex互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题
而recursive_mutex允许同一个线程对互斥量多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的unlock
recursive_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex大致相同
std::timed_mutex
timed_mutex中提供了以下两个成员函数:
try_lock_for:接受一个时间范围,表示在这一段时间范围之内线程若没有获得锁则被阻塞住,若在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,若超时(即在指定时间之内还是没有获得锁),则返回false
try_lock_untill:接受一个时间点作为参数,在指定时间点未到来之前线程若没有获得锁则被阻塞,若在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,若超时(即在指定时间点到来时还是没有获得锁),则返回false
timed_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex相同
std::recursive_timed_mutex
recursive_timed_mutex就是recursive_mutex和timed_mutex的结合,recursive_timed_mutex既支持在递归函数中进行加锁操作,也支持定时尝试申请锁
2.2 lock_guard与unique_lock
使用互斥锁时可能出现的问题
使用互斥锁时,若在加锁区域抛出异常,则后续的解锁代码则不会执行,导致此后所有申请该锁的线程都被阻塞。或者加锁的范围太大,那么极有可能在中途返回时忘记了解锁
因此C++11采用RAII的方式对锁进行了封装,于是就有了lock_guard和unique_lock
lock_guard
lock_guard是C++11中的一个模板类
template <class Mutex> class lock_guard;
lock_guard类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装
在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用lock()进行加锁
当lock_guard对象出作用域前会自动调用析构函数,而在lock_guard的析构函数中调用了unlock()自动解锁
从lock_guard对象定义到该对象析构,这段区域的代码都属于互斥锁的保护范围。若只想保护某一段代码,可以通过定义匿名的局部域来控制lock_guard对象的生命周期
#include <iostream> #include <mutex> using namespace std; mutex mtx; void func() { //匿名局部域 { lock_guard<mutex> lg(mtx); //调用构造函数加锁 if (true) { return; //调用析构函数解锁 } } //调用析构函数解锁 } int main() { func(); return 0; }
模拟实现lock_guard
lock_guard类中包含一个锁成员变量(引用类型),这个锁就是lock_guard对象管理的互斥锁
调用lock_guard的构造函数时需要传入一个被管理互斥锁,用该互斥锁来初始化锁成员变量后,调用互斥锁的lock函数进行加锁
lock_guard的析构函数中调用互斥锁的unlock进行解锁
需要删除lock_guard类的拷贝构造和拷贝赋值,lock_guard类本身也是不支持拷贝的
template<class Mutex> class lock_guard { public: lock_guard(Mutex& mtx) :_mtx(mtx) { mtx.lock(); //加锁 } ~lock_guard() { mtx.unlock(); //解锁 } private: lock_guard(const lock_guard&) = delete; lock_guard& operator=(const lock_guard&) = delete; private: Mutex& _mtx; };
unique_lock
由于lock_guard太单一,用户没有办法对锁进行控制,因此C++11又提供了unique_lock
unique_lock与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装。在创建unique_lock对象调用构造函数时也会调用lock进行加锁,在unique_lock对象销毁调用析构函数时也会调用unlock进行解锁
但lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:
加锁/解锁操作:lock()、try_lock()、try_lock_for()、try_lock_until()与unlock()
修改操作:移动赋值、swap、release(返回它所管理的互斥量对象的指针,并释放所有权)
获取属性:owns_lock(返回当前对象是否上了锁)、operator bool(与owns_lock的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)
如下场景就适合使用unique_lock:
要用互斥锁保护func1()的大部分代码,但是中间有一小块代码调用了func2(),而func2()不需要用func1()中的互斥锁进行保护,func2()内部的代码由其他互斥锁进行保护
因此在调用func2()之前需要对当前互斥锁进行解锁,当func()调用返回后再进行加锁,这样当调用func2()时其他线程调用func1()就能够获取到这个锁
三、原子性操作库atomic
线程安全问题
多线程最主要的问题是共享数据带来的问题(即线程安全)。若共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦
上述代码中分别让两个线程对同一个变量num进行了100000次++操作,理论上最终num的值应该是200000,但最终打印出n的值却是小于200000的
根本原因就是++操作并不是一个原子操作,该操作分为三步:
load:将共享变量n从内存加载到寄存器中
update:更新寄存器里面的值,执行+1操作
store:将新值从寄存器写回共享变量num的内存地址
++操作对应的汇编代码如下:
因此可能当线程1刚将num的值加载到寄存器中就被切走了,也就是只完成了++操作的第一步,而线程2可能顺利完成了一次完整的++操作才被切走,而这时线程1继续用之前加载到寄存器中的值完成剩余的两步操作,最终就会导致两个线程分别对共享变量num进行了一次++操作,但最终num的值却只被++了一次
加锁解决线程安全问题
#include <iostream> #include <thread> #include <mutex> using namespace std; void func(int& num, int count, mutex& mtx) { mtx.lock(); for (int i = 0; i < count; i++) { //mtx.lock(); ++num; //mtx.unlock(); } mtx.unlock(); } int main() { int num = 0; int count = 100000; //每个线程对num++的次数 mutex mtx; thread t1(func, ref(num), count, ref(mtx)); thread t2(func, ref(num), count, ref(mtx)); t1.join(); t2.join(); cout << num << endl; return 0; }
可以选择在for循环体里面进行加锁解锁,也可以选择在for循环体外进行加锁解锁。但效果终究是不尽人意的,在for循环体里面进行加锁解锁会导致线程的频繁进行加锁解锁操作,在for循环体外面进行加锁解锁会导致两个线程的执行逻辑变为串行
原子类解决线程安全问题
注意: 需要用大括号对原子类型的变量进行初始化
#include <iostream> #include <thread> #include <atomic> using namespace std; void func(atomic_int& num, int count) { for (int i = 0; i < count; i++) { ++num; } } int main() { atomic_int num = { 0 }; int count = 100000; //每个线程对n++的次数 thread t1(func, ref(num), count); thread t2(func, ref(num), count); t1.join(); t2.join(); cout << num << endl; //打印n的值 return 0; }
原子类型通常属于"资源类型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等
为了防止意外,标准库已经将atomic模板类中的拷贝构造、移动构造、operator=默认删除
原子类型不仅仅支持原子的++操作,还支持原子的--、+=、-=、与、或、异或操作
原子操作原理CAS
在对变量进行计算之前(如 ++ 操作),首先读取原变量值,称为 旧的预期值 A
然后在更新之前再获取当前内存中的值,称为 当前内存值 V
如果 A==V 则说明变量从未被其他线程修改过,此时将会写入新值 B
如果 A!=V 则说明变量已经被其他线程修改过,当前线程应当重新++
四、条件变量库condition_variable
wait系列成员函数
wait系列成员函数的作用就是让调用线程进行阻塞等待,包括wait()、wait_for()、wait_until()
下面以wait为例,wait()函数提供了两个不同的版本:
void wait(unique_lock<mutex>& lck); template<class Predicate> void wait(unique_lock<mutex>& lck, Predicate pred);
调用第一个版本的wait函数时只需要传入一个互斥锁,线程调用wait后会立即被阻塞,直到被唤醒
调用第二个版本的wait函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与第一个版本的wait不同的是,调用传入的可调用对象若返回值为false才会阻塞;被唤醒后也会调用该对象,若可调用对象的返回值为false,那么该线程还需要继续被阻塞
while (!pred()) wait(lck); == wait()版本二
为什么调用wait系列函数时需要传入一个互斥锁?
因为wait系列函数一般是在临界区中调用的,为了让当前线程调用wait()阻塞时其他线程能够获取到锁,因此调用wait系列函数时需要传入一个互斥锁,当线程被阻塞时这个互斥锁会被自动解锁,而当这个线程被唤醒时,又会自动获得这个互斥锁
因此wait系列函数实际上有两个功能,一个是让线程在条件不满足时进行阻塞等待,另一个是让线程将对应的互斥锁进行解锁
wait_for和wait_until函数的使用方式与wait函数类似:
wait_for函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒
wait_until函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,若超过这个时间点则线程被自动唤醒
线程调用wait_for或wait_until函数在阻塞等待期间,其他线程调用notify系列函数也可以将其唤醒。此外,若调用的是wait_for或wait_until函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,若可调用对象的返回值为false,那么当前线程还需要继续被阻塞
注意: 调用wait系列函数时,传入互斥锁的类型必须是unique_lock
notify系列成员函数
notify系列成员函数的作用就是唤醒等待的线程,包括notify_one和notify_all
notify_one:唤醒等待队列中的首个线程,若等待队列为空则什么也不做
notify_all:唤醒等待队列中的所有线程,若等待队列为空则什么也不做
注意: 条件变量下可能会有多个线程在进行阻塞等待,其会被放到一个等待队列中进行排队
五、实现两个线程交替打印1-100
尝试用两个线程交替打印1-100的数字,要求一个线程打印奇数,另一个线程打印偶数,并且打印数字从小到大依次递增
该题目主要考察的就是线程的同步和互斥
互斥:两个线程都在向控制台打印数据,为了保证两个线程的打印数据不会相互影响,因此需要对线程的打印过程进行加锁保护
同步:两个线程必须交替进行打印,因此需要用到条件变量让两个线程进行同步,当一个线程打印完再唤醒另一个线程进行打印
但只有同步和互斥是无法满足题目要求的,无法保证哪一个线程会先进行打印,不能说先创建的线程就一定先打印,后创建的线程先打印也是有可能的
#include <iostream> #include <thread> #include <atomic> #include <condition_variable> using namespace std; int main() { int i = 1; int n = 100; mutex mtx; condition_variable cv; size_t flag = 1;//1 or 2 代表哪个线程可以打印 //奇数 thread t1([&]() { while (i < n)//最大99 { unique_lock<mutex> lock(mtx); cv.wait(lock, [&flag]()->bool { return flag == 1; }); cout << this_thread::get_id() << ":" << i << endl; i += 1; flag = 2; cv.notify_one(); } }); //偶数 thread t2([&]() { while (i <= n)//最大100 { unique_lock<mutex> lock(mtx); cv.wait(lock, [&flag]()->bool { return flag == 2; }); cout << this_thread::get_id() << ":" << i << endl; i += 1; flag = 1; cv.notify_one(); } }); this_thread::sleep_for(chrono::seconds(1)); cout << "t1:" << t1.get_id() << endl; cout << "t2:" << t2.get_id() << endl; t1.join(); t2.join(); return 0; }