0.关注博主有更多知识
目录
1.并发过程中的问题
我们知道,同一个进程中的多个线程共享绝大部分资源,这就意味着这些线程可以很轻易的访问进程当中的全局变量,那么这就说明在多线程并发执行的过程当中可以对这些全局变量做访问,那么就会产生数据不一致的问题。我们以一个模拟抢票的例子来说明这个问题:
#include <iostream>
#include <vector>
#include <string>
using namespace std;
#include <pthread.h>
#include <unistd.h>
/*定义一个全局变量,模拟票*/
int tickets = 10000;
struct threadData
{
pthread_t _tid;
string _name;
};
void *getTickets(void *args)
{
string name = (static_cast<threadData *>(args))->_name;
while(true)
{
if(tickets > 0)
{
/*因为线程出问题的情况很难模拟,所以我们要尽可能保证线程交叉运行
*而交叉运行的实现手段就是尽可能多的进行线程切换
*当线程执行usleep()时会让自己陷入阻塞,那么调度程序会调度另一个线程执行*/
usleep(1234);
cout << name << " get tickets:" << tickets-- << endl;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
vector<threadData *> vec;
#define NUM 4
for(int i=0;i<NUM;i++)
{
threadData *td = new threadData;
td->_name = "thread" + to_string(i+1);
pthread_create(&td->_tid,nullptr,getTickets,td);
vec.push_back(td);
}
for(auto& td:vec)
{
pthread_join(td->_tid,nullptr);
delete td;
}
return 0;
}
最后的输出结果对于我们来说是不正确的,因为抢票怎么可能抢到0和负数票呢?所以这里就产生了数据不一致问题。那么在代码的注释部分当中说到了要让线程尽可能的发生切换,那么切换的工作就是在线程由内核态返回用户态的时候做的,那么线程陷入内核的方式有很多,例如调用系统调用、请求I/O、发生异常等等,那么当线程的时间片结束之后,就会陷入内核,在内核态返回到用户态之前会检查需不需要切换该线程,如果确实需要,那么就切换该线程,否则返回用户空间执行用户代码。
那么数据不一致的问题是由于线程切换导致的,试想一下,如果一个线程从头到尾一直抢票不给其他线程任何机会,那么就不会产生数据不一致问题。大家可以将上面代码当中的"usleep(1234)"写到"cout..."语句的后面。我们用一个特殊的例子来模拟线程切换导致数据不一致的场景(单CPU):
在上述这个例子当中就会产生一个问题,即当tickets的值为1的时候,这就已经说明了只有一个线程能买票,但是不幸的是,当线程1做完判断之后,陷入阻塞,此时调度线程2运行,线程2也能做判断,然后陷入阻塞;当线程1醒来的时候,执行输出的操作,即将1输出到控制台上,然后执行--操作,--操作有三条汇编指令,三条汇编的意思按顺序依次为:将内存的值读入CPU寄存器,CPU对寄存器的值做--,将计算的结果写回内存。所以线程1执行完--之后,那么内存中的tickets的值就为0了;那么线程2醒来之后,内存当中的tickets的值虽然为0,但是线程2已经做过判断了,并且它的上下文中的tickets的值依然为1,此时线程2输出tickets(输出的是内存中的值),即将0输出到控制台上,然后做--操作,最后内存当中的值被写成-1。
那么从这个例子当中就可以得出一个结论,那就是并发执行的过程当中,当一个线程对数据做出了更改,但是另一个线程可能并没有及时更新其上下文的信息,从而导致的数据不一致问题。那么我们再以一个简单的例子来理解:
这个例子就很好的诠释了数据不一致问题。当线程1可能由于执行某些任务,在时间片快结束的附近执行了--操作,而--操作的最后一步没有执行完便发生了切换;此时线程2被投入运行,并一直执行--操作,那么内存当中的值由1000不断地被覆盖成999、998、997......当最后一次覆盖,即将200覆盖到内存之后,线程2被切换;此时线程1又投入运行,但是发生切换时线程1的上下文保存的值为999,那么需要恢复上下文,即将上下文的值写回寄存器,然后线程2继续执行上一次没有执行完的任务,即将寄存器的值写回内存,此时,线程1便将999写回内存,内存的值由200又回到了999。
那么在C/C++当中,++、--操作看似只有一条语句,实际上编译之后生成的汇编代码,++、--有三条汇编指令。
2.互斥
那么像上面抢票代码中,四个线程对一个全局变量做访问,那么这个全局变量就可以看是共享资源,又因为该共享资源没有任何保护机制,所以在并发执行的过程中会产生某些数据不一致问题。那么在多线程当中,有几个概念是比较关键的:
1.多个执行流访问同一份没有安全保护的资源,该资源称为共享资源
2.多个执行流访问同一份具有安全保护的资源,该资源称为临界资源
3.多个执行流当中,访问临界资源的代码称为临界区
4.临界区理论上来说是很小的一部分,因为线程当中的大部分语句都是在访问自己的私有资源
那么什么是具有安全保护的资源?那就是当某个执行流访问了这个资源,这个资源在同一时刻不能再被其他执行流访问,该资源就是具有安全保护的资源。那么用两个字来概括,就是互斥,互斥就是安全保护的一种机制。那么对于上面的抢票代码,解决数据不一致的问题手段便是让这些线程不能同时访问tickets全局变量,即只能串行访问,也就是达到互斥的效果。
那么互斥表现出来的效果就是当一个执行流访问一个资源时其他执行流不能访问该资源,那么这里就不得不提到原子性。原子性指的是任何事物只有两态,没有中间过程。例如互斥表现出来的效果,那么执行流要访问资源的时候,看到的资源就只有两种状态,即未被使用和被使用。
实际上数据不一致是一个问题,那么采用一种手段解决一个问题就会带出新的问题,由此往复问题是一直存在的。但是这并不意味着这些解决问题的手段不够好,而是因为这些问题根本无法解决,所以在不同的场景下就有不同的解决方案,这是一种特性。
2.1互斥锁
那么对于上面的抢票代码,我们可以使用一种互斥机制来让线程访问全局变量时做到串行访问的效果。那么这个机制可以是锁,可以是信号量等等,我们先介绍锁。
锁是一种安全保护的机制,保护的是执行流的共享资源的访问,也就是说,共享资源被锁保护起来之后,就变成了临