线程安全之锁的原理

        欢迎来到小林的博客!!
      ️博客主页:✈️林 子
      ️博客专栏:✈️ Linux
      ️社区 :✈️ 进步学堂
      ️欢迎关注:点赞收藏✍️留言

这里写目录标题

  • 线程安全
  • 互斥锁的使用
    • 全局锁的使用
    • 局部锁的使用
  • 互斥锁的原理
    • 竞争锁的过程
    • 释放锁的过程

线程安全

在多线程情况下,如果有临界资源且没有保护临界资源的情况下。线程是不安全的。因为CPU的调度机制是随机的,而不是等你一个线程执行完才去执行另一个线程。可能在你这个线程执行到一半时,又切换到了另一个线程执行。

我们可以用下面这段代码来验证

#include
#include 
#include

#define THREAD_MAX_NUM 5

int tickets = 10000;

void* ThreadRun(void* args)
{
  int id = *(int*)args;
  delete (int*)args;

  //抢票逻辑
  while(1)
  {
    if(tickets > 0)
    {
      usleep(1000);//延迟1000微秒
      tickets--; 
      printf("thread %d 抢了一张票。还剩 %d 张票\n",id,tickets);
    }else break;
  }
  return nullptr;
}

int main()
{
  pthread_t tids[THREAD_MAX_NUM];
  for(int i = 0 ; i < THREAD_MAX_NUM ; i++)
  {
    int* id = new int(i+1);
    pthread_create(tids+i , nullptr,ThreadRun,(void*)id);
  }
  
  for(int i = 0 ;  i < THREAD_MAX_NUM ; i++)
  {
    pthread_join(tids[i],nullptr);
  }
  return 0;
}

这段代码的逻辑就是创建5个线程,主线程等待5个线程。然后派这5个线程去抢10000张票(全局变量tickets)。 当票没了的时候跳出循环退出线程。

我们来看看运行结果:

第一次测试:

线程安全之锁的原理_第1张图片

第二次测试:

线程安全之锁的原理_第2张图片

第三次测试:

线程安全之锁的原理_第3张图片

我们可以发现,三次测试结果。每次最后抢票都抢到了负数。这是非常危险的,如果在实际应用中,你只有100张票,却卖了105张。那么就会有5个用户没有座位,这影响是非常严重的。所以说,这个线程是不安全的。

为什么会这样呢?

我假设有2个线程,线程A和线程B。线程A先执行抢票,因为抢票的 tickets–并不是原子的。这条语句实际上是由三条汇编语句组成。 分别是: CPU加载tickets -> CPU对tickets进行减操作 -> CPU把tickets写回到内存。 而在这三个步骤中。如果在第二步完成,还没有走到第三步的时候。切换到了另一个线程B执行这段代码,线程B抢了5000票后,CPU又切到了线程A。并恢复线程A的上下文。可是在线程A的上下文中,tickets是9999。随后线程A又把9999写回到内存。 所以线程B明明已经抢了5000张票,又被线程A改回到了9999。这是非常非常不安全的。

线程安全之锁的原理_第4张图片

如何保证线程安全呢?

我们只要让临界资源每次只能被一个执行流访问即可。即使是CPU调度切换,那么也要把没有访问权限的线程卡在临界资源之外。直到有访问权限的线程访问完临界资源之后。其他线程才能重新争夺这个访问权限。而这个访问权限,我们称它为

互斥锁的使用

我们可以用互斥锁来保护一段区域,这段区域被称为临界区

临界区的资源每次只能被一个执行流访问!

而互斥锁是一个pthread_mutex_t 类型的变量。

通过pthread_mutex_init函数初始化,pthread_mutex_destroy销毁。

pthread_mutex_t mtx; //创建锁变量

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
              const pthread_mutexattr_t *restrict attr);//锁的初始化,返回0为成功。传入锁的地址和锁的属性

int pthread_mutex_destroy(pthread_mutex_t *mutex); //锁的销毁,返回0成功,传入锁的地址。 

当然,如果你想用一个全局的,或者静态的锁。你可以用PTHREAD_MUTEX_INITIALIZER这个宏来为锁初始化。
用法:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

锁创建和初始化之后,我们可以用pthread_mutex_lock 函数来加锁(本质是竞争锁,因为多个线程只能有一个线程持有锁)。 pthread_mutex_unlock函数来解锁。

加锁到解锁中间的区域,就是临界区。临界区只能同时被一个执行流访问!

int pthread_mutex_lock(pthread_mutex_t *mutex); //竞争锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); //解锁

全局锁的使用

那么我们就用 PTHREAD_MUTEX_INITIALIZER 来初始化全局锁演示一下。

加锁后的代码:

#include
#include 
#include

#define THREAD_MAX_NUM 5

int tickets = 1000;

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;  //创建全局锁

void* ThreadRun(void* args)
{
  int id = *(int*)args;
  delete (int*)args;

  //抢票逻辑
  while(1)
  {
    //抢票这段资源为临界资源,我们为其加锁
    pthread_mutex_lock(&mtx);
    if(tickets > 0)
    {
      usleep(1000);//延迟1000微秒
      tickets--; 
      printf("thread %d 抢了一张票。还剩 %d 张票\n",id,tickets);
      pthread_mutex_unlock(&mtx); //解锁 
    }else 
    {
      //这里也必须解锁。如果上面条件不成立,那么就要在这里进行解锁。
      pthread_mutex_unlock(&mtx); //解锁 
      break; 
    }
  }
  return nullptr;
}

int main()
{
  pthread_t tids[THREAD_MAX_NUM];
  for(int i = 0 ; i < THREAD_MAX_NUM ; i++)
  {
    int* id = new int(i+1);
    pthread_create(tids+i , nullptr,ThreadRun,(void*)id);
  }
  
  for(int i = 0 ;  i < THREAD_MAX_NUM ; i++)
  {
    pthread_join(tids[i],nullptr);
  }
  return 0;
}

我们为这个线程加上了互斥锁。那么我们来运行看看这段代码。

第一次测试:

第二次测试:

第三次测试:

加锁之后我们可以明显的感觉到,票数不会再被买到负数了,这是能够保证线程安全的。但同样的,速度也降低了很多。没加锁时的购票速度是很快的,而加锁后的速度却变慢了很多。所以加锁也是有消耗的(主要在于临界区只能同时被单个执行流访问)。

局部锁的使用

局部互斥锁的函数上面已经介绍过了,我们直接使用即可。但是要注意的是,如果要把锁和id同时传给线程的话。我们最好指定一个ThreadData类。用来存储线程的数据。随后把这个类对象传给线程。

代码演示:

#include
#include 
#include

#define THREAD_MAX_NUM 5

int tickets = 10000;

class ThreadData
{
  public:
    ThreadData(int uid,pthread_mutex_t* mtx):_uid(uid),_mtx(mtx){}
  public:
    int _uid;
    pthread_mutex_t* _mtx;
};

void* ThreadRun(void* args)
{
  ThreadData* data = (ThreadData*)args;

  //抢票逻辑
  while(1)
  {
    //抢票这段资源为临界资源,我们为其加锁
    pthread_mutex_lock(data->_mtx);
    if(tickets > 0)
    {
      usleep(1000);//延迟1000微秒
      tickets--; 
      printf("thread %d 抢了一张票。还剩 %d 张票\n",data->_uid,tickets);
      pthread_mutex_unlock(data->_mtx); //解锁 
    }else 
    {
      //这里也必须解锁。如果上面条件不成立,那么就要在这里进行解锁。
      pthread_mutex_unlock(data->_mtx); //解锁 
      break; 
    }
  }
  return nullptr;
}

int main()
{
  //创建局部锁 
  pthread_mutex_t mtx; 
  //初始化局部锁 
  pthread_mutex_init(&mtx,nullptr);
  pthread_t tids[THREAD_MAX_NUM];
  for(int i = 0 ; i < THREAD_MAX_NUM ; i++)
  {
    ThreadData* data = new ThreadData(i+1,&mtx);
    pthread_create(tids+i , nullptr,ThreadRun,(void*)data);
  }
  
  for(int i = 0 ;  i < THREAD_MAX_NUM ; i++)
  {
    pthread_join(tids[i],nullptr);
  }
  //销毁锁
  pthread_mutex_destroy(&mtx);
  return 0;
}

代码的运行结果和上面的全局互斥锁是一样的。

线程安全之锁的原理_第5张图片

互斥锁的原理

我们都知道锁可以保证临界资源的安全。但是,锁也是被所有线程共享的。锁也是临界资源!!既然锁也是临界资源,那么锁如何保证自己是安全的?

就好比说一个1.5米的小瘦子说要保护一个2.0米的大胖子一样,小瘦子凭什么说能保证大胖子的安全?这时候小瘦子掏出了一把AK-47…大胖子才相信他能保护自己的安全…

这里有一份pthread_mutex_lock函数和pthread_mutex_unlock函数的伪代码

lock:
	movb $0, %al
	xchgb %al, mutex 
	if(al寄存器的内容 > 0)
	{
		return 0;
	}else 
		挂起等待;
	goto lock;
unlock: 
	movb $1, mutex 
	唤醒等待mutex的线程;
	return 0;

xchgb是一条汇编指令,意思是交换两个数的值。

而互斥锁的实现原理就是用一条汇编指令,将%al寄存器的内容与mutex的内容进行交换。

接下来我将图文演示申请锁到释放锁的整个过程。

竞争锁的过程

线程安全之锁的原理_第6张图片

首先,看哪个线程先调用pthread_mutex_lock,假设线程B先调用。

线程安全之锁的原理_第7张图片

这时候已经在线程B中调用了pthread_mutex_lock函数。执行第一句代码 movb $0, %al ,将0写入到寄存al中。

线程安全之锁的原理_第8张图片

随后执行第二条指令xchgb %al, mutex 把al寄存器与mutex的内容进行交换。

线程安全之锁的原理_第9张图片

突然,这时候CPU要调度线程A了。那么CPU把当前线程B运行的数据保存到线程B的上下文。也就是al寄存器的内容也会被保存到线程B的上下文。随后调度线程A。

线程安全之锁的原理_第10张图片

调度线程A之后,在A调用了pthread_mutex_lock函数之后,执行第一条汇编语句 movb $0, %al,把0写入到al寄存器中。

线程安全之锁的原理_第11张图片

随后调用第二条汇编语句 xchgb %al, mutex 把寄存器的值与mutex的值进行交换。

线程安全之锁的原理_第12张图片

随后线程A继续往后执行if(al寄存器的内容 > 0) ,不满足条件,保存了自己的上下文之后被CPU挂起等待。

此时CPU又切换到了线程B。

线程安全之锁的原理_第13张图片

CPU切换到线程B后,线程B把保存的上下文交给了CPU,所以此时al寄存器的内容被线程B替换为了1。

线程安全之锁的原理_第14张图片

随后线程B继续往后执行,if(al寄存器的内容 > 0) 条件为真,最后pthread_mutex_lock返回0。返回后执行的就是临界区的代码,也就是我们写的抢票逻辑。在这期间不管哪个线程被调度,当xchgb %al, mutex这条指令被执行时。al的结果都不可能为1。因为mutex在第一次交换之后 ~ 锁释放之前会一直为0。所以,第一个 执行xchgb %al, mutex 这条与mutex进行交换的汇编指令的线程将会获得临界区的访问权限,也就是竞争锁成功!而竞争锁成功后,在释放锁之前,这段时间临界区只有竞争锁成功的线程可以访问。所以这就保证了线程安全。

因为 xchgb %al, mutex 的操作是原子的。所以锁也是原子的,因为只有这一句指令才是真正的竞争锁。即使在调用了 movb $0, %al这条汇编之后,线程被切换。也无法影响什么,因为锁只有一个,只能让一个人换走。

释放锁的过程

释放锁的过程很简单,先把mutex的值恢复即可。

线程安全之锁的原理_第15张图片

然后再把所有挂起的线程唤醒

线程安全之锁的原理_第16张图片

注意!线程B在退出pthread_mutex_lock函数的时候,对应保存的al寄存器的上下文就已经不存在了!!不要认为线程B在执行临界资源代码时,上下文还保存着 al = 1这个字段。这个字段在退出pthread_mutex_lock函数时就已经不存在了。是保存的上下文没了,不是al寄存器没了!!!切记切记不要弄混。

你可能感兴趣的:(Linux之路,安全)