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类。
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对象访问共享状态的点同步。