在这里插入图片描述

个人主页:矢望
个人专栏:C++LinuxC语言数据结构Coze-AIMySQL

一、线程互斥

1.1 回顾相关概念

临界资源:多线程执行流被保护的共享的资源就叫做临界资源。
临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。

1.2 由并发竞争导致的数据不一致问题

现在有下面这段代码:

int tickets = 1000; // 共享资源,导致数据不一致问题

void GetTicket()
{
    char name[64];
    pthread_getname_np(pthread_self(), name, sizeof(name));

    while (1)
    {
        if (tickets > 0)
        {
            usleep(1000); // 模拟具体抢票花的时间
            printf("%s get ticket:%d\n", name, tickets);
            tickets--;
        }
        else
        {
            break;
        }
    }
}


int main()
{
    // 创建多线程
    ThreadModule::Thread t1(GetTicket);
    ThreadModule::Thread t2(GetTicket);
    ThreadModule::Thread t3(GetTicket);
    ThreadModule::Thread t4(GetTicket);

    t1.Start();
    t2.Start();
    t3.Start();
    t4.Start();

    t1.Join();
    t2.Join();
    t3.Join();
    t4.Join();

    return 0;
}

代码复用了之前对上期博客对线程的封装代码,代码定义了线程共享的全局变量tickets,并且让创建出的多个进程同时进行抢票的逻辑,预期的结果是票数被减到0就终止。

编译运行:
在这里插入图片描述
如上,结果不符合我们的预期,票数被减到了-2。原本只有1000张票,最终卖出1003张,这是怎么回事呢?

在这里插入图片描述
如上图,是票数被抢到1时的场景,此时票数为1。我们有多个线程同时在执行抢票逻辑,此时线程1过来了,它开始进行if判断从内存中拿值发现条件满足,然后它进入了if里面,然后刚要执行usleep代码时,不幸的是它的时间片到了,于是它被切走了,内存中的票数还是1。线程1同时带走了CPU寄存器中存储的它的上下文数据。这个时候线程2来了,它也读取票数,进行if判断进入if中,然后它的时间片也到了,它也被切走了。这个时候线程3也来了,如法炮制,直到线程n都同时进入了if条件
然后又过了一会儿,这n个线程一一再次被唤醒,等到它们醒来的时候,它们就在if内部,所以就很自然的,从内存中拿票数,然后对票数做减减,再将票数写回内存,就这样,票数变成了负数

也就是说线程在通过条件判断后被切走,醒来时不再重新判断,直接执行修改操作,导致多个线程同时买走了同一张票

tickets--是也不是原子的,对它做减减操作会涉及到3个步骤,将内存中的数据读取到CPU,在CPU中对数据做--操作,再将CPU中变化后的数据写回到内存中
在这里插入图片描述
如果有两个线程执行流A、B。线程A先将数据10读入到CPU中,然后刚要对它做--操作,线程A被切走了。此时线程B来了,线程B很幸运,线程B进行了多次修改操作,假设线程B将数据修改到了3,然后要继续--时,它被切走了。此时线程A来了,它继续执行之前的代码,它认为现在值是10,然后对它做--,然后再将9写入到内存中,此时我们发现数据由3变成9了!

所以全局变量的++--操作,对于多线程也是会导致数据不一致问题。从而引起线程安全问题。

在我们上面的代码例子中,引起线程安全问题的主要是if条件判断,因为--操作引发的只是会把票数变大。if条件判断会导致在票数为1的情况下有多个执行流进入if条件

注意:usleep 会让线程主动进入阻塞状态,进入等待队列,从而一定会触发线程切换

解决问题

在这里插入图片描述
如上图,我们代码中的票数属于共享资源,在我们的代码的场景下会产生有多个执行流同时进入if语句对票数进行修改的情况,所以就导致了数据不一致问题。

所以我们需要将共享资源转变成临界资源,我们的if语句就是临界区。我们需要对临界区的代码进行保护。多线程保护共享资源就是把访问临界资源的代码保护起来。
而保护临界资源的本质就是保护临界区,我们要对临界区进行互斥保护
。(互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。)

我们要对临界区进行加锁保护

Linux中:
在这里插入图片描述
如上,锁的类型是pthread_mutex_t类型,这是一个联合体。如果要定义全局或者静态的一把锁,可以使用这个进行初始化锁:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
在这里插入图片描述

函数 功能 核心行为
pthread_mutex_init 初始化锁 动态初始化,设置锁属性
pthread_mutex_lock 加锁 锁被占用则阻塞等待
pthread_mutex_unlock 解锁 释放锁,唤醒等待线程
pthread_mutex_destroy 销毁锁 释放锁资源

先对我们之前出现的问题进行解决。

int tickets = 1000; // 共享资源,导致数据不一致问题
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 静态初始化全局锁

void GetTicket()
{
    char name[64];
    pthread_getname_np(pthread_self(), name, sizeof(name));

    while (1)
    {
        pthread_mutex_lock(&mutex); // 加锁
        if (tickets > 0)
        {
            usleep(1000); // 模拟具体抢票花的时间
            printf("%s get ticket:%d\n", name, tickets);
            tickets--;
            pthread_mutex_unlock(&mutex); // 解锁,让其他线程有机会进入临界区
        }
        else
        {
            pthread_mutex_unlock(&mutex); // 解锁
            break;
        }
    }
}


int main()
{
    // 创建多线程
    ThreadModule::Thread t1(GetTicket);
    ThreadModule::Thread t2(GetTicket);
    ThreadModule::Thread t3(GetTicket);
    ThreadModule::Thread t4(GetTicket);

    t1.Start();
    t2.Start();
    t3.Start();
    t4.Start();

    t1.Join();
    t2.Join();
    t3.Join();
    t4.Join();

    pthread_mutex_destroy(&mutex);   // 销毁锁

    return 0;
}

编译运行:
在这里插入图片描述
如上,票数不会再减到负数了,有多少票就卖出多少张票。

细节1:加锁的原则性问题? 加锁会导致代码的执行效率降低,因为之前是多线程并行执行临界区的代码,现在是多线程串行执行临界区的代码,所以效率下降了。因此加锁的粒度越小越好,也就是加锁的代码范围越小越好。保护的代码范围越小,性能损失越小

细节2:mutex在我们上面的代码中也是共享资源,这个锁来保护临界区,那么谁来保护锁呢?它被原子性规则保护,lockunlock被设计成了原子的!(原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。)

细节3:对临界资源进行保护加锁的过程是所有相关线程的共识!访问临界资源,所有线程都必须遵守加锁和解锁的规则,不能有例外

细节4:申请加锁不成功的线程就必须在锁上阻塞等待。所以加锁成功的进程,在访问临界区的时候,其它任何的线程都不可能打扰到它,因此加锁可以保证原子性!

细节5:加锁的线程,在访问临界区的时候是可能被切换的,并且切换并不会影响执行,不影响互斥保护的有效性

注意:不建议在临界区内执行IO操作,很影响效率

局部锁

局部锁需要动态初始化pthread_mutex_init(&mutex, NULL);

代码:

int tickets = 1000; // 共享资源,导致数据不一致问题

class ThreadData
{
public:
    ThreadData(const std::string& name, pthread_mutex_t *mutex)
        : _name(name)
        , _mutex(mutex)
    {}
public:
    std::string _name;
    pthread_mutex_t *_mutex;
};

void *route(void *arg)
{
    ThreadData *td = static_cast<ThreadData*>(arg);
    while(true) 
    {
        pthread_mutex_lock(td->_mutex); // 加锁
        if (tickets > 0) 
        {
            usleep(1000);
            printf("%s sells ticket: %d\n", td->_name.c_str(), tickets);
            tickets--;
            pthread_mutex_unlock(td->_mutex); // 解锁
        } else {
            pthread_mutex_unlock(td->_mutex); // 解锁
            break;
        }
    }

    return nullptr;
}

int main( void )
{
    // 局部锁
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, nullptr); // 动态初始化


    pthread_t t1, t2, t3, t4;
    ThreadData td1("thread 1", &mutex);
    ThreadData td2("thread 2", &mutex);
    ThreadData td3("thread 3", &mutex);
    ThreadData td4("thread 4", &mutex);

    pthread_create(&t1, nullptr, route, (void*)&td1);
    pthread_create(&t2, nullptr, route, (void*)&td2);
    pthread_create(&t3, nullptr, route, (void*)&td3);
    pthread_create(&t4, nullptr, route, (void*)&td4);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);

    pthread_mutex_destroy(&mutex); // 销毁锁

    return 0;
}

编译运行:
在这里插入图片描述

1.3 锁的原理

硬件实现

我们上面出现数据不一致问题,本质上不是因为共享资源的存在,而是因为线程在执行的过程中被切换,导致操作被打断,从而导致的数据不一致问题。所以只要让线程在执行临界区时不被切换,这个时候,临界区的执行就是原子的,也就没有数据不一致问题了

使用硬件是可以实现的,可以让线程在执行临界区时将时钟中断关掉,这样就不会发生线程切换的状况了

软件实现

为了实现互斥锁操作,大多数体系结构都提供了swapexchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期
在这里插入图片描述
如上图,mutex在内存当中的值是1CPU中存在al寄存器,这个寄存器中的值是0CPU在调度线程,以线程为载体进行加锁逻辑,加锁时就执行xchgb %al, mutex指令,这条指令是原子的,含义是交换内存中mutex和寄存器al中的值。本质就是把线程共享数据mutex变成某个线程的私有数据。这个时候如果很幸运,那么这个线程就会拿到这个1,此时这个线程在寄存器中的值就是1。如果它被切换了,寄存器中硬件上下文的数据都是这个线程的,就会被一并切走,也就是这个线程会拿着这个1离开。而其它的线程执行加锁逻辑时,只会执行成else语句,被挂起等待

所以在上文中,锁就是那个1exchange的方式没有拷贝,所以自始至终只有一个1,谁持有这个1,谁就持有锁!

上面是加锁逻辑,解锁逻辑就更简单了:
在这里插入图片描述
谁持有锁,等到执行完临界区后,谁就会执行解锁逻辑,它会将al寄存器中的1再次交换回内存中的mutex中。

二、C++封装锁

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;
};

如上就是对锁进行的初始化锁、加锁、解锁、销毁锁的接口所做的封装,这样,我们的代码就可以使用自己封装的接口了。

int tickets = 1000; // 共享资源,导致数据不一致问题

Mutex mutex;

void GetTicket()
{
    char name[64];
    pthread_getname_np(pthread_self(), name, sizeof(name));

    while (1)
    {
        mutex.Lock(); // 加锁
        if (tickets > 0)
        {
            usleep(1000); // 模拟具体抢票花的时间
            printf("%s get ticket:%d\n", name, tickets);
            tickets--;
            mutex.UnLock(); // 解锁,让其他线程有机会进入临界区
        }
        else
        {
            mutex.UnLock(); // 解锁
            break;
        }
    }
}


int main()
{
    // 创建多线程
    ThreadModule::Thread t1(GetTicket);
    ThreadModule::Thread t2(GetTicket);
    ThreadModule::Thread t3(GetTicket);
    ThreadModule::Thread t4(GetTicket);

    t1.Start();
    t2.Start();
    t3.Start();
    t4.Start();

    t1.Join();
    t2.Join();
    t3.Join();
    t4.Join();

    // 销毁锁, 自动会调用析构函数

    return 0;
}

编译运行:
在这里插入图片描述

RAII 风格代码

既然我们对锁进行了封装,那么我们可以在使用Mutex,再实现一个RAII锁管理类/锁守护类。

class LockGuard // RAII 风格代码
{
public:
    LockGuard(Mutex &lock)
        :_lockref(lock)
    {
        _lockref.Lock();
    }

    ~LockGuard()
    {
        _lockref.UnLock();
    }
private:
    Mutex &_lockref;
};

构造时,自动获取资源(加锁);析构时,自动释放资源(解锁);使用时,无需手动调用 lock/unlock

代码:

int tickets = 1000; // 共享资源,导致数据不一致问题

Mutex mutex;

void GetTicket()
{
    char name[64];
    pthread_getname_np(pthread_self(), name, sizeof(name));

    while (1)
    {
        // 临界区
        {
            LockGuard lockgurad(mutex);
            if (tickets > 0)
            {
                usleep(1000); // 模拟具体抢票花的时间
                printf("%s get ticket:%d\n", name, tickets);
                tickets--;
            }
            else
            {
                break;
            }
        }
    }
}

编译运行:
在这里插入图片描述
如上,进入{,执行LockGuard lockgurad(mutex);之后自动加锁,遇到}时,lockguard调用析构函数自动解锁。并且这样写代码的可读性很高,而且很优雅
LockGuard 的作用域等于花括号限定的代码块,进入时自动加锁,离开时自动解锁。将临界区用 {} 单独包裹

总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐