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

目录

一、基本概念

二、互斥和同步

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

1.抢票逻辑

2.底层原理

四、线程锁

1.锁的使用

(1)初始化和销毁

(2)加锁和解锁

2.抢票逻辑

3.所得原理

五、死锁

1.概念

2.死锁的四个必要条件

3.如何避免死锁

4.避免死锁算法


一、基本概念

因为多个线程共享地址空间,也就是很多资源是共享的。优点是线程间的通信非常方便,缺点是缺乏访问的控制。因为一个线程的操作问题,给其他线程造成了不可控或者引起崩溃异常等,这种现象称之为线程安全。

产生线程安全的原因是多个线程可以同时共享一些资源,比如堆等。想避免线程安全问题就需要堆资源进行访问控制。

注意,线程有自己独立的栈结构,所以临时变量不需要控制安全。

二、互斥和同步

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

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作为临界资源,所有的线程都要对它进行判断是否大于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的大小了。拿到tickets的值后直接放入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++提供的函数来进行锁的操作,需要包含头文件<mutex>

mutex mymtx;//定义锁

mymtx.lock();//加锁

mymtx.unlock();解锁

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

static pthread_mutex_t mtx:PTHREAD_MUTEX_INITIALIZER

3.锁得原理

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

要理解这一过程,首先要理解上下文数据的概念。其实就是线程被切换后它的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.避免死锁算法

死锁检测算法

银行家算法

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值