Linux多线程(线程互斥与线程锁)

举报
卖寂寞的小男孩 发表于 2022/10/21 08:38:53 2022/10/21
【摘要】 本文主要介绍线程锁的使用,以及线程互斥的原理

一、基本概念

因为多个线程时共享地址空间的,也就是很多资源是共享的。优点是线程间的通信非常方便,缺点是缺乏访问的控制。因为一个线程的操作问题,给其他线程造成了不可控或者引起崩溃异常等,这种现象称之为线程安全。
产生线程安全的原因是多个线程可以同时共享一些资源,比如堆等。想避免线程安全问题就需要对资源进行访问控制。
注意,线程有自己独立的栈结构,所以临时变量不需要控制线程安全。

二、互斥与同步

要保证线程安全,就需要线程之间是互斥和同步的。下面介绍几个概念来引出互斥和同步的概念。

1.临界资源:凡是被线程共享访问的资源都是临界资源(多线程,多进程都有临界资源。比如多个进程向显示器打印数据,显示器就是临界资源)
2.临界区:代码中访问临界资源部分的代码。因此,对临界区的保护本质上就是对临界资源的保护。(通过互斥和同步实现)
3.互斥:在任意时刻,只允许一个执行流访问某段代码(访问某部分资源),称之为互斥!
4.原子性:一段代码要么不执行,要么执行完毕。称这段代码具有元祖性
5.同步:一般而言,让访问临界资源的过程在安全的前提下(互斥并且原子的),让访问的资源具有一定的顺序性。

三、线程安全问题的底层原因

(1)抢票逻辑

使用一段模拟抢票的代码来验证实现线程同步和互斥的必要性。

#include<iostream>
#include<string>
#include<unistd.h>
#include<pthread.h>
using namespace std;
int tickets=1000;
void* ThreadRoutine(void* args)
{
   int id=*(int*)args;
   delete (int*)args;//接收线程的id
   while(true)
   {
      if(tickets>0)//对临界资源tickets进行操作
      {
         usleep(1000);
         cout<<"我是"<<id<<"我抢到的票是:"<<tickets<<endl;
         tickets--;
      }
      else
      {
         break;
      }
   }
}
int main()
{
   pthread_t tid[5];
   for(int i=0;i<5;i++)
   {
      int* id=new int(i);
      pthread_create(tid+i,nullptr,ThreadRoutine,(void*)id);//创建5个线程,这里传递id而不是i,因为毕竟传递的是地址,担心线程中有代码将i的值进行更改。
   }
   for(int i=0;i<5;i++)
   {
      pthread_join(tid[i],nullptr);//线程等待
   }
   return 0;
}

我们希望这五个线程将这1000张票抢购一空(tickets–至0)。但是我们发现运行结果并不是我们想的那样tickets–到0,甚至有的线程抢到了负数票。
在这里插入图片描述
此时我们发现tickets虽然是临界资源,但是并没有线程的安全性来保证。
那么为什么会出现这种情况呢?

(2)底层原理

tickets作为临界资源,所有的线程都要对它进行判断ticket是否大于0,以及ticket–的操作。用ticket–操作举例,虽然他看起来是一行C语言的代码,但是实际上它的底层汇编经历了三个阶段,分别是load命令,减法命令,以及store命令。
由于线程是不断在切换的,因此一个线程在执行完load命令之后,很可能还没来得及做减法或者写回操作,就被切走了。CPU开始执行下一个线程。
在这里插入图片描述
当线程A被切走的时候,它会抱着它的临时数据,也就是还没来得及进行–操作的1000。此时线程B进来,假设它执行了–操作,并成功将tickets–到了10,并写回到了内存中。
过了一会,A线程带着它的临时数据1000回来了,它认为tickets的值还是原来的1000,执行–操作,将值变成了999,此时写回内存中。写回的过程中,就将原来B写回的数据进行了覆盖。B的–白白进行执行了。
因此我们发现,如果多个线程同时执行的话,这是一个相当混乱的状态。
我们还可以分析一下,出现负数的情况,当一个线程进行判断操作后发现tickets是大于0的(此时还没将tickets放入CPU中),突然线程被切换走了。另一个线程来了,并将tickets–到了0,此时再将原来的线程切换回来,它认为自己已经判断完tickets的大小了。拿到ticktes的值后直接就放入CPU中进行了–操作,因此出现了负数。
为了解决这一问题,我们引入了线程锁的概念。

四、线程锁

1.锁的使用

对于上述的问题,我们只需要保证在一个线程对tickets的操作的时候,其他线程不会对tickets进行操作。(注意,不是保证线程不会被切走。)
使用线程锁,我们需要了解一个类型,以及四个线程锁有关的函数。

(1)初始化和销毁

在这里插入图片描述
其中参数中的pthread_mutex_t就是一个锁的类型,我们使用它来定义一把锁。
函数pthread_mutex_init是用来初始化的函数,第一个参数是一个指针指向要初始化的锁,第二个参数是锁的属性,我们置为NULL即可。
函数pthread_mutex_destroy是销毁锁的函数,它的参数指向要销毁的锁。

(2)加锁和解锁

在这里插入图片描述
函数pthread_mutex_lock是加锁函数,pthread_mutex_unlock是解锁函数。
我们只需要在访问临界资源的区域(临界区),前进行加锁,在访问后进行解锁即可以保证在某个线程访问临界资源的时候,其他线程无法访问该资源。

2.抢票逻辑

#include<iostream>
#include<string>
#include<unistd.h>
#include<pthread.h>
#include<stdio.h>
using namespace std;
class Ticket
{
private:
   pthread_mutex_t mtx;//定义一把锁
   int tickets=1000;//定义票数
public:
   Ticket():tickets(1000)
   {
       pthread_mutex_init(&mtx,nullptr);//构造函数中,对锁进行初始化
   }
   bool GetTicket()
   {
      bool res=true;
      pthread_mutex_lock(&mtx);//访问临界资源tickets要进行加锁
      if(tickets>0)
      {
         usleep(1000);
         cout<<"我是"<<pthread_self()<<"我抢到的票是:"<<tickets<<endl;
         tickets--;
      }
      else
      {
         res=false;
         cout<<"票被抢光了"<<endl;
         printf("");
      }
      pthread_mutex_unlock(&mtx);//访问结束,进行解锁
      return res;
   }
   ~Ticket()
   {
      pthread_mutex_destroy(&mtx);//析构函数中对锁进行销毁
   }   
};
void* ThreadRoutine(void* args)
{
   Ticket* t=(Ticket*)args;
   cout<<"我是线程"<<pthread_self()<<endl;
   while(true)
   {
      if(t->GetTicket())
      {
         continue;
      }
      else
      {
         break;
      }
   }
}
int main()
{
   Ticket* t=new Ticket();
   pthread_t tid[5];
   for(int i=0;i<5;i++)
   {
      pthread_create(tid+i,nullptr,ThreadRoutine,(void*)t);
   }
   for(int i=0;i<5;i++)
   {
      pthread_join(tid[i],nullptr);
   }
   return 0;
}

为了实现这一过程,可以定义一个Ticket类,在其中定义ticket和一把锁。使用访问函数来帮助线程对锁进行访问,在临界区处进行加锁,解锁的操作。
此时再运行代码,我们发现是我们期望的抢票结果。
在这里插入图片描述
我们也可以使用C++提供的函数来进行锁的操作,需要包含头文件<mutx>

mutex mymtx//定义锁
mymtx.lock();//加锁
mymtx.unlock;//解锁

我们还可以定义静态锁,此时不再需要调用初始化函数和销毁函数:

static pthread_mutex_t mtx:PTHREAD_MUTEX_INITIALIZER

3.锁的原理

我们发现,tickets是所有线程都可以看到的资源,因此是临界资源。而锁也是所有线程都可以看到的资源,那么为什么它不会造成线程安全问题呢?这是因为加锁和解锁的过程是原子性的。它的关键在于:只使用一条汇编,就将内存中的数据和CPU寄存器中的数据进行交换了。
它的汇编代码如下:

lock:
      movb  $0,%al  #将0值赋给寄存器
      xchgb %al ,mutex #将内存中的mutex值和寄存器中的数据进行交换
      if(al寄存器中的内容>0)
      {
        return 0;
      }
      else
      {
       挂起等待;
      }
      goto lock;
unlock:
      movb $1 mutex
      唤醒等待Mutex的线程;
      return 0

要理解这一过程,首先要理解上下文数据的概念。其实就是线程被切换后它的PCB中保存着执行的数据。
在这里插入图片描述
当线程A到来时,假设它要抢锁,目前其他线程没有它来的快。它的寄存器al的数据现在是0,执行交换操作,将内存中mutex的值交换给A的寄存器al中,此时线程A的al值为1,内存中mutex的值为0。
当线程A被切换走时(是带着上下文数据1一起被切走的),线程B到来,它的al寄存器中的值为0(线程设置的是自己的上下文数据,互相不冲突),进行交换mutex的值和al寄存器的值(0和0交换),最终B拿到的值是0,发生挂起等待。此时就可以根据每个线程的al中寄存器的值判断是哪一个线程抢到了锁,从而只允许该线程访问临时资源。当A访问完资源后,释放锁,唤醒等待的线程,并将mutex的值置为1。
注意,拿到锁的A在访问临时资源的时候还是会被切换的,只不过其他线程此时无法访问临时资源。
站在其他线程的视角,要么A没有申请锁不能访问临界资源,要么A申请锁了访问了临界资源。因此线程A访问临界资源的动作具有原子性。

五、死锁

1.概念

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源,而处于的一种永久等待状态。

2.死锁的四个必要条件

互斥条件:一个资源只能被一个执行流使用。
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
循环等待条件:若干执行流之间形参一种首尾相接的循环等待资源的关系。

3.如何避免死锁

破坏死锁的四个必要条件。
加锁顺序一致。
避免加锁未释放的场景。
资源一次性分配。

4.避免死锁算法

死锁检测算法
银行家算法

这里有一个小结论:线程安全不一定是可重入问题,可重入问题会导致线程安全。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。