为什么线程需要同步和互斥的操作?
因为线程引入共享了进程的地址空间,导致了一个线程操作数据时候,极其容易影响到其他线程的情况;对其他线程造成不可控因素,或引起异常,逻辑结果不正确的情况;这也是线程不安全的原因!
如何创建一个线程安全函数?只要尽量不使用全局变量,stl,malloc,new 等操作即可;如果要使用,就要控制同步和互斥问题!
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
多个线程并发的操作共享变量,会带来一些问题。
用多线程来模拟抢票过程!票数定义为全局变量,为了看到票数出现多线程访问全局数据会出现逻辑问题的结果!
#include
#include
#include
#include
//线程切换的时机(1. 线程的时间片到了;2. 从内核到用户态)
//tickets就是临界资源; 卖票动作tickets--(这个操作不是原子的)
int tickets = 1000; // 票数
void* ThreadRun(void* argc)
{
int id = *(int*)argc;
while(true)
{
if(tickets > 0)
{
//抢票
usleep(10000); //休眠10毫秒s
tickets--;//虽然只有一行代码,当是这个操作并非原子的(汇编是有多行的)
printf("我是线程%d,我抢得票是第%d\n",id,tickets);
}
else
{
//没票了
break;
}
}
}
//5个线程抢票
int main()
{
pthread_t tid[5];
for(int i = 0;i < 5;i++){
int* id = new int(i);
pthread_create(tid+i,NULL,ThreadRun,(void*)id);
}
for(int i = 0; i< 5;i++){
pthread_join(tid[i],NULL);
}
return 0;
}
执行结果发现:抢票抢到最后,结果居然有负数。由于票数本身就是为0不可以抢了,但是还有负数,说明结果逻辑出现问题:这就是访问全局数据会导致逻辑问题的情况;
我们现在好奇的是:为什么多线程访问临界资源是不安全的?
首先我们知道:临界资源也是数据,也要保存在内存中,在这个抢票例子中:tickets就是全局数据,就是临界资源;当我们多线程访问临界资源,也就是对tickets执行了tickets- -操作,它并不是简单的减减操作而已;
在实际运算过程中,一个tickets–操作是要被分成三部分:1.从内存将数据加载到cpu寄存器,2.cpu运算做- -,3.计算结果返回加载到内存;
假若线程A,获得了CPU的时间片,那么就会开始执行自己的代码,访问临界资源,假设线程A把tickets抢了900张,那么它就会把计算结果返回到内存,内存的临界资源tickets就还剩100张;
假如此时线程B,获得CPU资源,那么线程A被切走就要把上下文数据保存起来,也就是它的计算信息,放到等待队列里等待下一次获得CPU执行;而线程B也对内存ticket访问100,当刚好把100这个只加载到cpu开始计算的过程,线程B就被切换走了,那么线程B就需要保存自己的上下文数据,也就是寄存器里的信息!
此时我们的线程A又获得CPU的时间片,开始执行,把刚刚的上下文数据进行恢复,一下子把票抢到了0,并且把该票数返回到了内存!
此时线程B又获得时间片的执行时间,开始恢复自己上下文信息,别忘记了,线程B的上下文信息,tickets就是100啊,线程B哗啦哗啦一通抢,抢到了50张,还剩40张返回到内存了!
我们注意就是这里,原来内存的票数被线程A抢到了0,你的线程B哗啦哗啦给抢票把内存的0给修改成了50;这很明显不符合逻辑!所以就是会出错!
为了学会如何加锁,我们先认识一些关于锁的接口!(这些都是原生线程库的接口)
#include
#include
#include
#include
class Tickets
{
private:
int tickets;
pthread_mutex_t mutex; //定义一把锁
public:
Tickets(int ticket):tickets(ticket)
{
pthread_mutex_init(&mutex,nullptr);
}
bool GetTickets()
{
bool flag = true; //表示有票
pthread_mutex_lock(&mutex);
if(tickets > 0)
{
//抢票
usleep(1000); //休眠1毫秒s
tickets--;
printf("我是线程[%lu],我抢得票是第%d\n",pthread_self(),tickets);
}
else
{
//没票了
printf("票被抢完了,亲!!!!\n");
flag = false;//无票
}
pthread_mutex_unlock(&mutex);
return flag;
}
~Tickets()
{
pthread_mutex_destroy(&mutex);
}
};
//5个线程抢票
void* ThreadRun(void* argc)
{
Tickets* t = (Tickets*) argc;
while(true)
{
if(!t->GetTickets())
{
break;
}
else
{
continue;
}
}
return (void*)0;
}
int main()
{
Tickets* t = new Tickets(1000); //创建对象1000张票
pthread_t tid[5];
for(int i = 0;i < 5;i++){
pthread_create(tid+i,NULL,ThreadRun,(void*)t);
}
for(int i = 0; i< 5;i++){
pthread_join(tid[i],NULL);
}
return 0;
}
互斥锁本身也是一个共享资源,多个线程也要对它进行访问,那么多线程访问共享资源就会出现一个问题:线程安全问题。而对线程安全问题的解决方案那就是保证互斥锁这个共享资源的原子性即可!
所以锁的基本原理也是要保证申请锁和释放锁的过程是原子性的!
如何保证原子性呢?
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下
当我们通过调用了锁的初始化函数,那么就是给锁赋值为1;这个值是保存在内存的!
然后上面汇编代码的意思是:
上锁(lock):把0赋值给寄存器%al;
同时通过xchgb汇编指令完成mutex(值为1)和%al(值为0)的交换数据,其实这个操作相当于mutex- - ;只不过 xchgb是原子操作而,直接mutex- - 不是原子操作;
然后判断%al的值,是1就表示获得锁成功,是0表示获得锁失败!
unlock:就是把1赋值给mutex;同时唤醒等待mutex的线程!
所以当一个线程A执行lock操作时候,执行了到if语句时候,时间片到了,被切换走了,同时保存自己上下文数据,此时该线程A的上下文数据是有锁的数据的;当线程B过来继续lock时候,就会因为没有获得锁而阻塞,这就完成了lock的原子性操作!
申请锁的操作是原子性的,那么我们临界区的资源是也是有可能被切换的呀!线程时间片到了,切换切换,但是也没要紧,申请到锁的线程A即便是在执行临界区代码时候别切换走,其他线程B也进不去临界区!因为申请不到锁!,该锁的资源还被上一个线程A拿着呢,线程B进都进不去!
对于没有申请到锁的线程来说:要么线程A没有申请成功锁!要么线程锁别释放了,其他线程才有机会进入临界区!