C++11 线程库
- 一.thread类
- 1.介绍
- 1.框架
- 2.构造
- 3.赋值
- 4.join与joinable
- 5.id和get_id
- 6.this_thread命名空间
- 7.yield
- 8.演示
- 二.锁类
- 1.互斥锁
- 1.介绍
- 2.使用
- 1.配合lambda来使用
- 2.ref
- 2.递归锁和时间锁
- 1.递归锁介绍
- 2.例子
- 3.时间锁介绍
- 三.RAII管理锁类
- 1.lock_guard
- 1.介绍
- 2.使用
- 3.好处与不足
- 2.unique_lock
- 1.介绍
- 1.框架
- 2.构造,赋值和析构
- 3.release和mutex
- 4.swap与operator bool,owns_lock
- 3.lock_guard和unique_lock的区别与适用场景
- 四.条件变量类
- 1.框架
- 2.condition_variable
- 1.框架+构造+析构
- 2.wait和notify
- 3.使用
- 五.两个线程交替打印奇偶数,要求t1先打印
- 1.前提1 : 多个新线程之间谁先运行是不确定的
- 2.前提2 : 临界区当中有修改临界资源的代码,那么整个临界区当中无论是对临界资源的读和写都要加锁
- 1.如何保证t1先打印,t2后打印
- 2.如何交替打印[ 阻塞等待 ]呢
- 3.如何让一个线程不能连续打印呢?
- 4.验证
- 六.其他版本
- 七.原子类
- 1.互斥锁的缺点
- 2.原子类的引出
- 3.介绍
- 1.构造,赋值,is_lock_free
- 2.store,load和exchange
- 3.修改操作
- 4.使用
- 八.CAS与无锁化编程的介绍
- 1.CAS介绍
- 2.问题1
- 3.用一下之前先介绍接口
- 4.用一下
C++11当中的线程库对Linux下的pthread库进行了封装,把C写的pthread库由面向过程改为了面向对象
提供了thread类,锁类,RAII管理锁的类,条件变量类,原子类,下面我们逐一介绍并使用
一.thread类
1.介绍
1.框架
2.构造
含参构造创建线程,创建之后线程就会立刻启动
3.赋值
4.join与joinable
主线程创建并启动了一个新线程,必须要join它,否则主线程退出时就会报错,所以千万不要忘了join
当然,如果不想join,那么需要detach它
因此当多线程跟异常跟信号结合起来…
5.id和get_id
这个id函数是能由主线程去调用啊,因为新线程执行的函数里面没有thread类型的对象啊,我要是想在新线程当中打印新线程自己的id该怎么办呢?
下面介绍this_thread命名空间的时候,就解决这一问题了
正因如此,调用的函数一般都是直接用void作为返回值类型
6.this_thread命名空间
因此新线程直接调用this_thread::get_id()即可获取自己的线程id
7.yield
yield : 让步
8.演示
二.锁类
下面我们先介绍锁和锁的方法
1.互斥锁
1.介绍
这里的互斥锁跟pthread库当中的非常像,一看大家就懂
有些场景下,获取锁的线程仍需要重入该函数,且重入之前依旧不能释放锁
2.使用
1.配合lambda来使用
2.ref
ref可以在引用传参时保持引用属性
我们知道,可变参数模板的参数包是层层往下传的,传到最后一层的时候调用对应的构造函数来进行构造
又加上万能引用和完美转发之后,其中的过程非常复杂,我们可以暂时理解为(为了方便理解)参数包传递时,
由于万能引用+完美转发,导致传参时可能会生成引用对象的一个拷贝,因此我们必须要加ref来保持引用属性
否则就会编译报错
所以我们需要这么来写
或者这么写
建议以后用指针得了,C++11的报错真扛不住…
2.递归锁和时间锁
1.递归锁介绍
有了互斥锁为何还要有递归锁呢?
有些场景下,获取锁的线程仍需要重入该函数,且重入之前依旧不能释放锁,此时如果该锁是互斥锁,那么就会导致死锁
递归锁会记录加锁的次数,同一线程每次申请锁,计数+1,每次释放锁,计数减1
递归锁: 同一个线程可以多次申请同一把锁,而不会造成死锁,也叫做可重入锁
2.例子
一个线程执行线程函数的临界区时,调用了另一个函数,另一个函数当中在满足某些条件时还需要再调用线程函数,此时就可能会发生死锁问题
class A
{
public:
static void func()
{
mtx.lock();
task();
mtx.unlock();
}
static void task()
{
if(x++ == 0)
func();
}
static mutex mtx;
static int x;
};
mutex A::mtx;
int A::x = 0;
int main()
{
thread t1(&A::func);
t1.join();
return 0;
}
因此C++11线程库就搞出来递归锁
递归锁也是,禁掉了拷贝构造,移动构造,拷贝赋值,移动赋值
3.时间锁介绍
时间锁跟互斥锁差不多,只不过增加了try_lock_for和try_lock_until这两个函数
顾名思义,try_lock_for是尝试获取锁持续多长时间,try_lock_until是尝试获取锁到什么时候
递归时间锁就是递归锁+try_lock_for和try_lock_until
三.RAII管理锁类
1.lock_guard
1.介绍
lock_guard支持移动构造,但是没意义,
因为lock_guard就只是负责申请锁和释放锁的,而且lock_guard不支持默认构造,也就不能先构造lock_guard,
然后再让lock_guard管理某个对象(不支持默认构造可以看作是lock_guard的一个缺点)
lock_guard不支持拷贝赋值,也不支持移动赋值,因为lock_guard不支持默认构造,不能先构造lock_guard,然后再让lock_guard管理某个对象
2.使用
3.好处与不足
lock_guard的好处是:
- 利用RAII管理锁的申请与释放,无需我们手动申请释放锁了
- 使用起来简单,简洁
lock_guard的不足:
- 只支持管理互斥锁,无法管理递归锁,时间锁和递归时间锁
- 不支持手动释放锁,使用起来不灵活
- 不支持默认构造和移动赋值,因此无法将lock_guard的构造和对锁资源的管理分开,不够灵活
因此大佬又发明了一个unique_lock
2.unique_lock
1.介绍
1.框架
unique_lock可以管理各种类型的锁,互斥锁,递归锁,时间锁,递归时间锁
2.构造,赋值和析构
3.release和mutex
4.swap与operator bool,owns_lock
3.lock_guard和unique_lock的区别与适用场景
-
管理的锁的类型不同:
lock_guard只能管理互斥锁
而unique_lock能管理所有类型的锁, 使用起来更加通用 -
是否能像锁一样来使用的不同:
lock_guard无法像锁一样来使用,unqiue_lock能够像锁一样来使用,因此unqiue_lock支持手动申请释放锁,更加灵活 -
是否支持对象随时跟锁相绑定与解除绑定的不同 :
lock_guard跟锁是强绑定的,不支持随时进行绑定与解除绑定
unique_lock跟锁是不强行绑定的,支持随时绑定与解除绑定(默认构造+移动赋值进行随时绑定,通过release来解除绑定恢复为默认构造的状态) -
因为unique_lock能进行手动加锁与解锁,因此可以跟条件变量一起使用,而lock_guard无法进行手动加锁与解锁,因此无法跟条件变量一起使用
(注意: lock_guard和unique_lock都能跟信号量一起配合使用) -
总的来说,lock_guard更加简单且更易于控制,适用于管理互斥锁的比较简单的场景
unique_lock更加灵活且复杂,适用于各种类型的锁和比较复杂的场景
关于使用,我们跟条件变量一起配合使用,所以下面介绍条件变量
四.条件变量类
1.框架
2.condition_variable
1.框架+构造+析构
2.wait和notify
当一个线程调用wait的时候需要传入自己的锁,调用时会将自己的锁解开在进行阻塞,这么设计是为了避免发生死锁问题
当一个线程被notify唤醒之后需要重新竞争锁,竞争到锁的线程才能继续执行,没有竞争到的会阻塞在申请锁的位置
(小心: 此时如果用if,可能会导致伪唤醒)
3.使用
模拟一下多线程抢票,一开始就100张票,两个线程抢,抢完之后主线程加100张票,新线程继续抢,往复循环
其实可以不用条件变量,但是不用的话会导致多线程频繁申请锁,判断ticket是否大于0,
然后释放锁,妥妥的浪费资源,因此才要用条件变量来阻塞它们,不让他们浪费CPU资源
尽管条件变量设计的初衷是为了实现线程同步,避免竞争锁能力弱的线程出现饥饿问题的
但是这里条件变量可以提高我们的效率(减少浪费就是提高效率)
五.两个线程交替打印奇偶数,要求t1先打印
这是一个非常经典的多线程题目,下面我们一起来探讨一下该如何做
我们就统一规定t1和t2有一个共享资源num,初始值为1
t1和t2先打印num,后++num
1.前提1 : 多个新线程之间谁先运行是不确定的
首先我们要先明确一点 : 创建多线程时,多个新线程之间谁先运行是不确定的,由OS调度时它说了算
2.前提2 : 临界区当中有修改临界资源的代码,那么整个临界区当中无论是对临界资源的读和写都要加锁
因为如果只对写操作进行加锁,那么一个线程就可能会在另一个线程写入操作之前或者之后读取数据,就会导致数据不一致问题
因此需要加锁
1.如何保证t1先打印,t2后打印
因此我们一定要想出一个方法让t1能够先打印,t2后打印
但是因为t1和t2谁先运行我们决定不了,有这么两种情况:
- t2先运行,此时t2不能进行打印,等到t1打印完成之后,才能开始打印
- t1先运行,然后t2才开始运行,此时t2需要直接打印,不能在t1下一次打印之后才打印
因此我们要能够在t2运行之后判断t1是否运行了,
如果t1还没运行,我需要阻塞等待
如果t1已经运行了,我直接运行即可
那如何判断呢?
因为我们要保证t1第一次运行只能让1变成2,因此对于t2来说,如果t1运行了,那么num就是偶数
如果t1还没运行.那么num就是奇数
因此t2的框架一定是这样的,同理,我们切换到t1的视角
t2打印2之前t1是不能打印3的,因此t1在打印3之前也需要知道t2是否把num改成了3,如何知道呢?
判断num是不是奇数即可
因此t1的框架一定是这样的:
2.如何交替打印[ 阻塞等待 ]呢
我们知道 想要让多线程访问修改临界资源具有顺序性,就需要用到条件变量(🔔)
又因为任意时刻,num一定不是奇数就是偶数,因此任意时刻一定有一个线程正在运行/等待被调度,而另一个线程正在阻塞/等待被调度
因此我们可以搞一个条件变量,t1和t2阻塞等待时就等待那个条件变量
t1和t2打印并++num之后唤醒另一个线程
凡是使用条件变量进行唤醒就需要考虑一个问题:
此时该在唤醒队列当中的线程还没有进行等待的话,我就进行唤醒,会有什么后果吗?
我们先写出代码来,然后在分析一下
3.如何让一个线程不能连续打印呢?
如果t1/t2连续打印,那么必定会导致它们既打印奇数又打印偶数,因此这种情况我们也要避免
t1运行完之后,num一定是偶数,而t1的阻塞条件又恰好是偶数,所以t1本来就不能连续打印,
同理t2也是如此,因此我们之前的处理是一箭双雕
4.验证
至此,我们已经实现了:
- 让t1先打印
- 不让一个线程连续打印
- 实现交替打印/阻塞等待+通知
仔细想一下,所有的要求都已经满足了啊,没错,这个题已经做完了…此时我们直接运行
验证完毕
六.其他版本
刚才那个版本不具有普适性,因为如果我需求变了
t1和t2交替打印正负数怎么办?
while(num%2==0)这个就需要变,可是我不想变,怎么办?
我们可以搞一个bool类型的标记位,具体打印修改逻辑你肯定要变,但是我们能够保证甭管你打印啥
一定是t1先打印,然后t1,t2交替打印
int main()
{
int num = 1;
condition_variable cv;
mutex mtx;
bool flag = false;//false时: t2阻塞 true时: t1阻塞
auto callback_func = [&num]() {cout << this_thread::get_id() << " " << num++ << endl; };
thread t1([&]() {
while (true)
{
unique_lock<mutex> ulock(mtx);
while (flag == true)
{
//阻塞等待 TODO
cv.wait(ulock);
}
//等待成功 或者 压根无需等待
callback_func();
flag = true;//让我自己阻塞
cv.notify_one();
}
});
thread t2([&]() {
while (true)
{
unique_lock<mutex> ulock(mtx);
while (flag == false)
{
//阻塞等待 TODO
cv.wait(ulock);
}
//等待成功 或者 压根无需等待
callback_func();
flag = false;
cv.notify_one();
}
});
cout << "t1.get_id(): " << t1.get_id() << endl;
cout << "t2.get_id(): " << t2.get_id() << endl;
t1.join();
t2.join();
return 0;
}
七.原子类
多线程需要保护临界资源,本质是因为临界资源的修改操作不是原子的,如果临界资源修改的操作是原子的,也就不需要加锁了
1.互斥锁的缺点
因为加锁会导致多线程访问临界资源会由并发执行变为串行执行从而影响效率,
而且如果修改临界资源比较快的话,会导致多线程频繁被OS调度,往返于内核态和用户态之间,从而降低效率,浪费资源
因此有大佬提出了自旋锁,而且此时yield也可以用上了
但是加锁毕竟不好,影响多线程的并发,因此有大佬研究了CAS与无锁化编程
2.原子类的引出
发明了原子类,提供了对单个数据类型的原子操作,可以用于一些内置类型:整型家族,bool,指针,枚举
(注意: C++11不支持double等浮点数,之后的版本可能支持了,但是并不广泛和通用)
对于自定义类型,必须满足以下条件:
它的大小和内存布局是固定的,并且在所有平台上都是一致的
它的操作(如赋值、比较等)必须是原子的
因此原子类更常用于内置类型,特别是整型家族
3.介绍
1.构造,赋值,is_lock_free
2.store,load和exchange
3.修改操作
最常用的还是修改操作
4.使用
其实原子类就只是能够保证单个操作的原子性而已,因此只有在需要保证单个操作的原子性时,原子类才有比较好的用武之地
当涉及到多个需要一起执行的原子操作时,(比如: 判断+修改),还是要用锁的啊
单个操作: 无需加锁,用原子类即可
复合操作: 单纯的原子类的简单操作是无法保证的,还是要加锁
VS2019下验证半天看不出来,跑Linux下一跑就验证出来了
难道原子类真的只有这些用处???
atomic还有两个函数可以帮助我们抛弃锁,这两个函数底层采用的是CAS操作,用于实现无锁化编程
下面我们单独搞一个标题来介绍
八.CAS与无锁化编程的介绍
C++11的原子类当中有两个函数可以用于CAS操作
atomic_compare_exchange_weak和atomic_compare_exchange_strong
陈皓大佬的文章: 无锁队列的实现
我们就看着大佬的文章看一下CAS吧.至于无锁队列,陈皓大佬介绍的非常详细,大家感兴趣的话看一下就行了
我们重点在了解,学习,掌握基础的CAS使用
1.CAS介绍
如果失败,不做任何操作,整个比较并交换的操作是一个原子操作,不可被中断.
我们现在可能有两个问题:
- 为何这么做就能够解决数据不一致问题?
- 能否用一下呢?
2.问题1
而CAS就是将线程硬件上下文当中的值作为期望值/旧值 跟 内存当中的值进行比较
只有相等时才更新内存当中的值为修改后的新值,因此CAS “迫使” 线程必须重新读取对比内存当中的值,如果不一致,不允许修改
3.用一下之前先介绍接口
失败时利用内存当中的值更新旧值
因为一出错就返回false,因此我们可以配合do while循环,我们就用weak了,反正咱有while
do{}while(!CAS);
4.用一下
有了CAS做加持,原子类才能够挺起腰板来…
我们的抢票完全不需要锁了
atomic<int> ticket(10000);
void g()
{
bool ok = true;
while (ok)
{
int oldval = ticket.load(), newval = oldval - 1;//先取出旧值,搞一个新值
if (oldval > 0)
{
do
{
newval = oldval - 1;
if (oldval <= 0)
{
ok = false;
break;
}
//返回false会自动更新oldval,因此我只需要更新newval即可
}while (!atomic_compare_exchange_weak(&ticket, &oldval, newval));//旧值 和 新值
if(ok)
{
cout << this_thread::get_id() << "# ticket # " << oldval << endl;//打印旧值
}
}
else ok = false;
}
}
int main()
{
thread t1(g);
thread t2(g);
thread t3(g);
thread t4(g);
t1.join();
t2.join();
t4.join();
t3.join();
return 0;
}
以上就是C++11 线程库的全部内容,希望能对大家有所帮助!!