线程与数据的交互有多种方式。
只读数据:所有线程只能读取这些数据,所以是安全稳定的。
#include
#include
using namespace std;
static int share[1000];
void threadEntry(int threadCount)
{
cout << "线程入口函数执行,线程编号:" << threadCount << endl;
cout << "访问共享数据,结果:" << share[threadCount] << endl;
cout << "线程入口函数执行结束,线程编号:" << threadCount << endl;
}
int main()
{
vector<thread> threads;
for (int i = 0; i < 1000; i++)
{
share[i] = i;
}
for (int i = 0; i < 1000; i++)
{
threads.push_back(thread(threadEntry,i));
}
for (auto iter = threads.begin(); iter != threads.end(); iter++)
{
iter->join();
}
cout << "主线程结束" << endl;
return 0;
}
运行结果:
可以看到虽然杂乱无序,但确实正确运行了,前前后后共有1000个线程,也没有影响正确执行。所以只读数据是安全的。
有读有写:有些线程要修改数据,有的线程要读取这些数据。
由于计算机操作系统的调度机制,可能会发生各种诡异的事情,如前面我们遇到的同一行执行输出,结果却在不同行出现的现象。
对于同一个变量的多个操作,要保证每个操作能正常执行,不能发生数据已经被取出,却还不断的读取的现象。因此我们把所有对这些数据的操作请求扔到一个队列中,由队列来整理,让他们有序依次的访问数据。
#include
#include
using namespace std;
//队列遵循先入先出原则
class MsgQueue
{
public:
//插入消息队列
void InMsgQueue()
{
for (int i = 0; i < 10000; i++)
{
msgQueue.push_back(i);
cout << "新的请求入队,请求码:" << i << endl;
}
}
//从队列输出
void OutMsgQueue()
{
for (int i = 0; i < 10000; i++)
{
if (!msgQueue.empty())
{
cout << "请求出队,请求码:" << msgQueue.front() << endl;
msgQueue.pop_front();
}
else
{
//消息队列为空
cout << "无请求" << endl;
}
}
}
private:
//使用链表,头尾增删效率更高
list<int> msgQueue;
//这里的int就是消息的类型
};
int main()
{
MsgQueue queue;
thread InQueueObj(&MsgQueue::InMsgQueue, &queue);
thread OutQueueObj(&MsgQueue::OutMsgQueue, &queue);
InQueueObj.join();
OutQueueObj.join();
cout << "主线程结束" << endl;
return 0;
}
运行之后我们会发现异常,因为按照代码中的直接写变量是不安全的,当一个线程正在取出一个数据,而另一个线程又正在访问这个数据的时候,就会引发异常。这个时候,我们就需要保护读写数据的操作,使之能安全的读写。(有时候不报异常,可能是偶然,要知道STL是不支持线程安全的,有胜于无,涉及异步读写的操作一定要加锁,安全第一,否则出现问题很难排查)
扩充:消息队列的应用除了线程之间,还在进程间、系统间有广泛的应用,消息可以很简单,也可以很复杂,面对庞杂的数据业务时,消息队列是常用的思想,市面上有很多第三方软件封装好了消息队列供企业使用。
概念:
多个线程执行同一函数的时候,将函数中的某部分规定为:同一时刻,只允许一个线程执行这部分代码。
用来保证数据的安全性和稳定性。
思考:通过知道C++的内存模型我们知道同一个程序中代码只有一份,所有的线程都需要访问代码段来执行,那是不是执行到lock()的时候就会在这里触发某种判断机制?那lock和unlock以及PV操作的原理是不是基于此呢?
用法:
mutex my_mutex;
my_mutex.lock();
//读写操作
my_mutex.unlock();
要注意,lock和unlock必须成对使用,非对称的调用lock和unlock会导致异常,修改后类的代码如下:
class MsgQueue
{
public:
//插入消息队列
void InMsgQueue()
{
for (int i = 0; i < 10000; i++)
{
my_mutex.lock();
msgQueue.push_back(i);
my_mutex.unlock();
cout << "新的请求入队,请求码:" << i << endl;
}
}
void OutMsgQueue()
{
for (int i = 0; i < 10000; i++)
{
if (!msgQueue.empty())
{
my_mutex.lock();
cout << "请求出队,请求码:" << msgQueue.front() << endl;
msgQueue.pop_front();
my_mutex.unlock();
}
else
{
//消息队列为空
cout << "无请求" << endl;
}
}
}
private:
list<int> msgQueue;//使用链表,头尾增删效率更高
mutex my_mutex; //互斥量对象
};
所有线程执行到lock()的时候,会互斥的访问接下来的代码,在这段代码执行到unlock()之前,其他线程执行到lock()会进入阻塞直到unlock()后,进入就绪,等待操作系统调用。
需要注意的是,lock和unlock都会导致较大的计算机资源开销,尽量使需要互斥的代码简短快速。
由于lock之后必须unlock,这就带来和指针一样的弊端,忘记unlock,并且在实际中如果有忘记unlock的情况很难排除这个问题。C++提供了像智能指针一样的解决方法,使用lock_guard这个类,它的构造函数提供了lock的功能,它的析构函数提供了unlock()的功能,这样在lock_guard的作用域内,它又能起到互斥锁的作用,又能避免忘记unlock的弊端。将原函数改造如下:
void InMsgQueue()
{
for (int i = 0; i < 10000; i++)
{
lock_guard<mutex> my_guard(my_mutex);
msgQueue.push_back(i);
cout << "新的请求入队,请求码:" << i << endl;
}
}
输出函数以此类推。但要注意,使用lock_guard后,这段区域内就不能使用lock 和unlock了。
死锁的概念和进程间死锁的问题操作系统中有了详细的讲解,网上也有大量的理论和文章,这里主要讲C++中多线程的情况,但本质上是一样的,资源分配和互斥的时机的不合理等导致。
假设存在两个互斥锁,
mutex1,mutex2,
两个线程,
A和B
A需要先mutex1,后mutex2
B需要先mutex2,后mutex1
此时 死锁就产生了
除了互斥锁,系统资源(硬件、网卡、显卡、内存)也是导致死锁的重要原因,但在实际中少见,因为操作系统提供对设备管理的优化
死锁发生的时候,很难完美的解决问题。
预防和避免死锁的发生是解决死锁的主要方式
保证互斥锁的顺序正确
发生死锁的函数:
void InMsgQueue()
{
for (int i = 0; i < 10000; i++)
{
my_mutex1.lock();
my_mutex2.lock();
msgQueue.push_back(i);
my_mutex2.unlock();
my_mutex1.lock();
cout << "新的请求入队,请求码:" << i << endl;
}
}
void OutMsgQueue()
{
for (int i = 0; i < 100000; i++)
{
//顺序不同,此处发生死锁
my_mutex2.lock();
my_mutex1.lock();
if (!msgQueue.empty())
{
cout << "请求出队,请求码:" << msgQueue.front() << endl;
msgQueue.pop_front();
my_mutex1.unlock();
my_mutex2.unlock();
}
else
{
//消息队列为空
cout << "无请求" << endl;
my_mutex1.unlock();
my_mutex2.unlock();
}
}
}
执行不到几步就会停止运行。上面的例子很简单,在这几行内出现互斥锁顺序出错的情况概率不大,但在面对庞杂的业务和合作开发的时候,很有可能疏忽。
有一些项目对执行的完整性要求极高,不可随意重启的条件下,发生死锁的时候,可以采用执行回退的方法,这个需要特殊的硬件支持,多见军工和科研项目。
作用:一次锁住两个或者两个以上的互斥量(一般不会超过两个)
它不存在因为锁的顺序问题导致的死锁的风险。
如果一个没锁住,就会陷入阻塞,但不会占有能锁的锁,直到所有互斥锁都能同时锁住,才继续执行,保证要么两个互斥量都锁住,要么都没锁住。
用法:
std::lock(my_mutex1,my_mutex2);
adopt_lock是一个lock_guard的参数。
我们还可以将std::lock_guard和std::lock()一起使用。来避免使用std::lock时,unlock的疏忽。
std::adopt是一个结构体对象,起一个标记作用,用来表示这个互斥量已经被lock()了,不需要在构造函数中再lock()。(但必须要已经lock了才能使用)
使用方法:
using namespace std;
void InMsgQueue()
{
for (int i = 0; i < 10000; i++)
{
lock(my_mutex1,my_mutex2);
lock_guard<mutex> my_guard1(my_mutex1,adopt_lock);
lock_guard<mutex> my_guard2(my_mutex2,adopt_lock);
msgQueue.push_back(i);
cout << "新的请求入队,请求码:" << i << endl;
}
}
adopt_lock是适用于unique_lock的
unique_lock是一个类模板,工作中一般使用lock_guard已经足够了,unique_lock比lock_guard要灵活很多,有更多的功能,但效率上要差一点,内存也要占多一点
由上面的学习,我们知道lock_guard可以用第二个参数来标记已经上锁的mutex。
unique_lock 也可以带一样的标记adopt_lock,作用相同。
try_to_lock是一个unique_lock的参数。
正如其名,尝试去锁,锁定失败会立即返回,不会阻塞在此处。用try_to_lock的前提是不能先去lock。
用法:
unique_lock<std::mutex> my_unique(my_muteax,std::try_to_lock);
问题的来源是,程序中互斥访问的时候,没有进入临界区的线程就相当于闲了下来,如果互斥的时间很长,当有需要的时候,总要让它干点别的,不然会浪费CPU的资源,try_to_lock就提供了解决方案。
void InMsgQueue()
{
for (int i = 0; i < 10000; i++)
{
unique_lock<mutex> my_unique(my_mutex, try_to_lock);
if (my_unique.owns_lock())
{
msgQueue.push_back(i);
cout << "新的请求入队,请求码:" << i << endl;
}
else
{
doSomethingelse();
}
}
}
defer_lock是一个unique_lock的参数
它的作用是初始化一个没有加锁的mutex,有时候你不希望他锁着,只是想把mutex和unique_lock绑在一起。
unique_lock的成员函数有四个:
lock(),unlock(),try_lock(),release()
使用defer_lock后,unique_lock和mutex绑定在了一起,只需要调用成员函数就能实现和前面几种相同的功能,可能有人会说这不墨迹吗,都是一样的功能,还麻烦。确实是,但这种用法更能体现面向对象的思想,操作起来也更安全,灵活,会有很少的额外资源开销。这里只讲一下release()
release():返回它所管理的mutex对象指针,释放对它的绑定,使之不再和mutex有关系
注意:严格区分unlock和release的区别。释放没有unlock的mutex必须单独unlock。
release的返回值证明了unique_lock本质上一个指向mutex的指针,这也暗示unique_lock和mutex彼此之间是一一对应的。
unique_lock的成员函数使我们能更灵活的运用锁,不用再让锁的范围依赖于对象的作用域,能够提前解锁,这里提一个概念,就是锁的粒度。
前面提到过,使用锁会有较大的系统开销,并且锁住的代码越多,就意味着其他线程等待互斥的时间越长,程序的效率越低,那么这段被锁住的代码的规模,就称为锁的粒度。unique_lock让我们可以提前使用unlock来降低代码的粒度,对于粒度的取舍,是高级程序员能力的重要体现。
并不是粒度越粗效率就一定越低,粒度越细效率就越高,关于锁的粒度,这里有更详细的解释:https://www.ibm.com/support/knowledgecenter/zh/ssw_aix_71/performance/lock_granularity.html
std::unique_lock<std::mutex> my_unique1(my_mutex);
//else...
//...
std::unique_lock<std::mutex> my_unique2(std::move(my_unique1));
移动语义,本质上和智能指针(unique_ptr)的转移是一样的。和智能指针一样,可以用成员函数返回这个对象。
std::unique_lock<std::mutex> rtn_unique()
{
std::unique_lock<std::mutex> temp(my_mutex);
return temp;
}