【Linux】线程互斥与互斥量全解析:原理、实践与封装

文章目录


一、进程线程间的互斥相关背景概念

  • 临界资源:多线程执⾏流共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:用来保护临界区,任何时刻,互斥保证有且只有⼀个执⾏流进⼊临界区,访问临界资源,进而对临界资源起保护作⽤。
  • 当多个执行流向同一个共享资源做写入并且没有加保护,数据会发生写入错乱、覆盖等问题时就叫做数据写入不一致。(这也是多线程代码一定会面临的问题,因为多线程大部分资源都是共享的)
  • 原⼦性(后⾯讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。(原子性在多线程场景中并不是一种特性,而是一种结果,正是有了互斥的存在,故而有了访问资源的原子性)
    补充:

1、互斥保护资源的本质是把多线程访问资源由并发执行转化为串行执行,所以互斥是可能会降低程序运行效率的,

2、多线程共享资源不会发生数据不一致问题,多线程并发访问共享资源才会发生数据不一致问题。所以多线程并不是所有共享资源都需要被保护,被访问的共享资源才需要被保护。

二、互斥量mutex

为了实现互斥,所以需要引入一种新的数据类型:互斥量(互斥锁),它是完成互斥保护的数据对象和配套的方法。

我们头一次接触互斥量很可能不知所云,所以小编先提出一个不用互斥的异常现象,然后介绍一种解决方案,自然就会引出互斥量了。
我们看下面这个多线程并发访问一个全局变量的代码,它是模拟的一个抢票逻辑,每一个线程代表一个用户:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 100;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        if (ticket > 0)
        {
            usleep(1000);//模拟漫⻓业务的过程,期间可能有很多个线程会进⼊该代码段
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
        }
        else
        {
            break;
        }
    }
    return (void *)0;
}

int main()
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, route, (void *)"thread 1");
    pthread_create(&t2, NULL, route, (void *)"thread 2");
    pthread_create(&t3, NULL, route, (void *)"thread 3");
    pthread_create(&t4, NULL, route, (void *)"thread 4");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
}

运行结果:

我们可以看到结果最后成了负数,要理解这一现象发生的原理就需要先理解CPU硬件(计算机组成原理知识)和线程(操作系统知识)分别在这期间的行为:

ticket减到负数现象分析

计算机组成原理视角:

首先我们看访问共享变量ticket的核心代码,如上图红框框起来的部分,我们称之为临界区,理解区中右两个访问共享资源的地方:ticket > 0 和 ticket--,无论是ticket > 0这样判断真假的逻辑运算还是ticket--这样的算术运算都需要由CPU来完成。

(重要补充:同一进程的多个线程是共享进程地址空间和页表的,所以多线程访问ticket时本质都是访问的同一个在物理内存中的tickct变量)

所以对于代码中的逻辑运算和算术运算都需要经过三个步骤:

1、从物理内存中取数据、取汇编指令拷贝 到CPU的寄存器中。

2、分析指令,是算术运算还是逻辑运算,是加运算还是减运算...

3、CPU中的算逻运算器执行指令。

4、(部分情况有该步骤)将运算结果覆盖式拷贝回物理内存,针对要修改变量本身的指令,如++,--。

操作系统视角:

上面我们只是站在计算机组成原理的角度来分析问题,下面我们站在操作系统视角在分析一下整个过程,首先我们要知道CPU执行上面的取指令、分析指令、执行指令的过程都会以进程或线程为载体执行,也就是CPU取的指令,取的数据是线程的数据,CPU执行的这些操作本质是线程的代码要求CPU这样做的。

所以当CPU把物理内存中的数据拷贝到CPU的寄存器,本质就是把共享资源拷贝到线程硬件上下文,我们知道,线程的硬件上下文是线程私有的,所以CPU拷贝数据本质就是把共享数据变成线程的私有数据,这是数据不一致的基础,也就是单个线程在CPU中对共享数据做修改时是不会影响共享数据本身的。

当ticket被减到1后,多个线程同时进入该临界区,判断ticket > 0条件成立,多个线程同时进入if条件判断,然后多个线程串行执行if内部代码逻辑,第一个线程把物理内存中ticket位置的变量1拷贝到CPU中,完成ticket--后把结果0拷贝回物理内存的ticket位置,接着第一个线程被切走,然后把自己的硬件上下文带走,然后OS调度第二个线程执行if内部代码逻辑,首先它把物理内存中已经被改为0的ticket变量拷贝到CPU寄存器中,执行ticket--后把结果-1拷贝回物理内存的ticket位置,然后调用printf就打印出-1了。

所以票数被减到负数本质原因if判断导致的,就是多线程并发访问临界区并且多个线程进入if判断条件中。

ticket--代码分析

现在我们把票数减到负的原理谈清楚了,现在还有一个问题,ticket--这个操作是线程安全的吗?答案是否定的,因为ticket--并不是原子的,它经过汇编之后会转化成三条指令,如下图所示:

假设当线程a第一次执行ticket--,刚刚执行到第二条汇编指令sub就因某种原因被切走了,这时操作系统会执行上下文保存,把线程 a 当前所有寄存器(包括存着1000的%eax)、程序计数器(PC/RIP)等状态,完整保存到该线程的线程控制块(TCB)中(实际是轻量级进程),然后OS调度线程b执行代码逻辑,假设又因为某种原因线程b一直执行代码把ticket减到0了,并把0写回了物理内存的ticket中,接着线程b被切走,线程a被切回,此时OS会将线程a被暂停时保存的寄存器状态恢复到CPU寄存器中,那么此时CPU不会去读取物理内存中ticket的最新值(0),而是直接恢复 "线程 a 暂停时的旧值1000",这时CPU在执行 ticket--就得到了999,并把999写到物理内存的ticket中。

通过上面的现象我们能得出一个结论:类似ticket这样的全局变量式的计数器不是线程安全的,会有并发问题。

解决思路

所以要解决上述问题,本质就需要对红框代码进行保护,形成临界区,需要我们用代码的形式来保护,因为我们既然能写访问共享资源的代码,就一定也能写保护共享资源的代码。

为了解决这一问题pthread库为我们提供了互斥锁的概念,并且它一定是可编程的,下面我们先见一见它的接口,后面再讲原理。

互斥量的接口

使用互斥量相关接口需要包含pthread头文件,pthread_mutex_t是pthread为我们提供的一种数据类型。

初始化互斥量

定义的互斥量是静态的或全局的:

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

在栈上、堆上动态开辟的互斥量:

cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
const pthread_mutexattr_t *restrict attr);

第二个参数是设置互斥锁属性,我们不关心,置为nullptr即可。

销毁互斥量
  • 使⽤ PTHREAD_ MUTEX_ INITIALIZER 初始化的全局互斥量不需要销毁。
  • 不要销毁⼀个已经加锁的互斥量。
  • 已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁。
cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

返回值:成功返回0,失败返回错误号

利用互斥量解决问题

衍生出的3个问题

1、gmutex本身不就是全局变量吗?它要保证别人是线程安全的,它自己怎么保证自己是线程安全的呢?
:让加锁和解锁操作本身具有原子性就可以保证互斥锁本身是线程安全的。

2、所有进入临界区线程都要加锁吗?可以有例外吗?
:不能有例外,只要进入是临界区的线程必须全部加锁!!线程申请锁成功后能继续向后执行代码,若线程申请锁失败就会在申请锁的位置被阻塞。

3、如果一个线程在申请了互斥锁之后进入临界区,当它在临界区执行代码时可以被OS切走吗?会因为切换导致其他线程进入临界区造成并发问题吗?
:线程可以被切走,但是切走后其他线程因申请该锁时会被阻塞进而无法进入临界区造成并发问题,因为线程是在持有锁的状态被切走的,它并没有释放锁,所以其他线程无法申请锁自然无法进入临界区。这也是为什么加锁后程序运行效率较慢的原因之一。

从软件层面的原子性理解互斥锁

对于一个申请了互斥锁的线程来讲,它对外本质就只有两种状态:未执行临界区代码和执行临界区代码完毕,因为在访问期间该线程是不可被打扰的,所以该线程访问临界区的过程就在逻辑上具有原子性。这里的原子性是在加锁之后产生的结果。这就是软件层面的原子性。

互斥锁使用的最佳实践

因为锁会降低程序效率,所以加锁和解锁时囊括的临界区尽量是最小集。

三、互斥的硬件/软件实现及原理

互斥本质就是要让线程的每个操作具有原子性,对于互斥的实现一般有硬件和软件两个层面的实现方案:

1、硬件实现:

互斥需要保证线程操作具有原子性,要解决这一问题就要在线程运行时避免外界因素中断/干扰线程的执行,除了线程调用如read系统调用阻塞等待时主动出让CPU资源以外,外界会频繁干扰线程的执行因素就是各种中断如时钟中断,外设中断,所以硬件实现互斥的方案之一就是关闭中断。

2、软件实现:

互斥锁的软件实现需要基于下面这个基本点:一条汇编语句本身是原子的。

为了实现互斥锁操作,⼤多数体系结构(如X86,arm等等)都提供了swap或exchange指令,该指令的作⽤是把寄存器和内存单元的数据相交换,由于只有⼀条指令,保证了原⼦性,下面是加锁和解锁操作的汇编指令:

虽然pthread库对互斥锁做了封装,我们先简单把互斥锁mutex当成一个整型变量,值为1,加锁第一步线程会先将cpu中的al寄存器中的值置为1,然后将al寄存器中的值和互斥锁mutex值做交换,这样1就换到了al寄存器中,内存中的mutex变量交换后就变成0了,我们要理解这里交换操作的本质:以非拷贝的形式把1这个数字由共享变为私有,因为这样1就会变成申请锁线程的硬件上下文。线程继续执行后面的if判断,判断成立返回0申请锁成功。

线程在申请锁的过程中是可能随时被切走的,但是不用担心会发生并发问题,因为如果一个线程已经将"1"交换进了al寄存器,当该线程被切走时会将"1"一并带走,其他线程被OS调度切回时也会执行申请锁操作,但此时的mutex值为0,即使交换后al寄存器的内容仍为0,if判断不成立就会被OS挂起等待,所以线程是否申请锁成功就在于它是否成功执行了exchange指令把"1"换进自己的硬件上下文。

数据的交换而非拷贝保证了"1"只有一份,所以我们可以把这个"1"看作是访问临界区的钥匙或者令牌。

四、互斥锁的封装

下面我们来对pthread的互斥锁做一下面向对象化的封装,类似于C++的互斥锁。

但是小编不会只做简单的封装,而是实现一个RAII分割的互斥锁。

cpp 复制代码
//Mutex.hpp
#pragma once

#include <pthread.h>

class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&_lock, nullptr);
    }

    void Lock()
    {
        pthread_mutex_lock(&_lock);
    }

    void Unlock()
    {
        pthread_mutex_unlock(&_lock);
    }

    ~Mutex()
    {
        pthread_mutex_destroy(&_lock);
    }

private:
    pthread_mutex_t _lock;
};


class LockGuard
{
public:
    LockGuard(Mutex *mutexp)
    :_mutexp(mutexp)
    {
        //构造的时候加锁
        _mutexp->Lock();
    }

    ~LockGuard()
    {
        //析构的时候解锁
        _mutexp->Unlock();
    }
private:
    Mutex *_mutexp;
};
cpp 复制代码
//testMutex.cc
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include "mutex/Mutex.hpp"

int ticket = 100;
Mutex lock;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        {
            //临界区
            LockGuard lockguard(&lock); //RAII风格的加锁!!
            if (ticket > 0)
            {
                usleep(1000);
                printf("%s sells ticket:%d\n", id, ticket);
                ticket--;
            }
            else
            {
                break;
            }
        }

        // other code....
    }
    return nullptr;
}

int main(void)
{
    pthread_t t1, t2, t3, t4;

    pthread_create(&t1, NULL, route, (void *)"thread 1");
    pthread_create(&t2, NULL, route, (void *)"thread 2");
    pthread_create(&t3, NULL, route, (void *)"thread 3");
    pthread_create(&t4, NULL, route, (void *)"thread 4");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
}

可以看到小编实现了一个LockGuard类,它的构造和析构的行为就是加锁和解锁,在测试代码中我们要加锁时只用定义一个LockGuard对象就完成了加锁操作,LockGuard对象会自动在进入临界区时加锁,在出临界区时解锁。背后的原理就是利用了局部变量的生命周期,因为局部变量的生命周期只在花括号内部,如果我们想更直观体会LockGuard的作用原理,还可以在while循环里将整个临界区用花括号包起来,如果while循环里还有其他非临界区代码也互不影响,并且可以提高效率。

RAII 封装的核心优势是'资源获取即初始化',可避免忘记解锁的问题;即便临界区发生异常,局部对象 LockGuard 析构时也会自动解锁,保证锁的安全释放。

以上就是小编分享的全部内容了,如果觉得不错还请留下免费的赞和收藏
如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~

相关推荐
hweiyu002 小时前
Linux命令:gzip
linux
老王熬夜敲代码2 小时前
IP和MAC的深入理解
linux·网络·笔记·网络协议
梁辰兴2 小时前
计算机网络基础:以太网的信道利用率
服务器·网络·计算机网络·计算机·以太网·信道利用率·梁辰兴
开开心心就好2 小时前
版本转换工具,支持Win双系统零售批量版
linux·运维·服务器·pdf·散列表·零售·1024程序员节
秋深枫叶红2 小时前
嵌入式第三十八篇——linux系统编程——IPC进程间通信
linux·服务器·网络·学习
MediaTea2 小时前
思考与练习(第十章 文件与数据格式化)
java·linux·服务器·前端·javascript
Dovis(誓平步青云)2 小时前
《Linux生态下HTTP协议解析+进阶HTTPS证书:抓包、拆解与问题排查实战》
linux·运维·http
QT 小鲜肉2 小时前
【Linux命令大全】001.文件管理之diff命令(实操篇)
linux·运维·chrome·笔记
Zeku2 小时前
20251127 - 韦东山Linux - 通用Makefile解析
linux·驱动开发·嵌入式软件·linux应用开发