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


    一、基本概念

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

    二、互斥与同步

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

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

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

    (1)抢票逻辑

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

    #include
    #include
    #include
    #include
    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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38

    我们希望这五个线程将这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
    #include
    #include
    #include
    #include
    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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70

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

    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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

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

    死锁检测算法
    银行家算法

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

  • 相关阅读:
    【PyQt】(自制类)处理鼠标点击逻辑
    CnOpenData学者长期科研服务陪伴计划启动!
    如何写一份好的吸引人的简历
    qt文件操作的一些技巧
    vue组件精讲
    【学习随笔】机器人感知-因子图在SLAM中的应用
    linux 安装中文字体
    Spring Boot项目启动速度优化
    ThreadLocal真会内存泄漏?
    【Spring 源码】AOP 的加载原理(一)
  • 原文地址:https://blog.csdn.net/qq_51492202/article/details/126002445