27.线程互斥与同步(一)

发布于:2025-09-11 ⋅ 阅读:(21) ⋅ 点赞:(0)

线程共享地址空间 -> 线程会共享大部分资源 -> 公共资源 -> 数据不一致问题 -> 解决方案:互斥和同步

一、互斥

现象:多个线程执行抢票时,最终把票数抢到了负数。

为什么会抢到负数?

        ticket--操作不是原子的 -> C语言要翻译成汇编语言 -> 暂时认为一条汇编是原子的。

ticket--操作翻译成汇编分为三步:

0xFF00    载入ebx    ticket               将内存中的ticket值载入到寄存器

0xFF02    减少ebx    1                       做运算,寄存器中的值减一,姑且认为是这样

0xFF04    写回内存   ebx                   将寄存器中的值写回内存

        假设有一个线程A,进行这个ticket--操作,执行1次中已经把ebx的值减减了,正准备执行第三句汇编时,线程A被切换了,此时保存上下文数据,ebx值:99  下一条代码地址:0xFF04。线程B被切换进来了,执行ticket--操作,执行了99次,已经把内存中ticket的值减为1了。此时线程B被切换走了,线程A被切进来了,恢复上下文数据(寄存器的值),然后继续执行第三句汇编,把ebx值99写回到ticket中,ticket值由1->99,此时就出现了数据不一致问题。(抢到负数原理同上)

        因此,多线程如果在不保护可变的共享资源情况下,会出现数据不一致问题,且问题导致的结果是随机的。

        ticket--不是主要矛盾,判断是主要矛盾,原因:当ticket变为1时,此时有2个及以上线程根据ticket为1大于0的判断进入了代码块,然后减减,造成减到了负数。

        多线程中,制造更多的并发,更多的切换。切走的时间点:1.时间片耗尽 2.阻塞IO 3.sleep等。(陷入内核) 选择新的线程,从内核态回到用户态,进行检查。

互斥锁

互斥锁的使用方法:

1.全局互斥锁

pthread_mutex_t mutex = PTHREAD_MUTEX_INITALIZER

只需定义一个全局互斥锁类型的变量,用宏赋值即可。正常使用上锁和解锁方法

全局互斥锁不需要被释放,程序运行结束,会自动释放。

2.非全局互斥锁,自己申请

        pthread_mutex_t lock;

        pthread_mutex_init(&lock, nullptr);

        pthread_mutex_destroy(&lock);

需要进行初始化和手动销毁。

申请锁和释放锁:

        pthread_mutex_lock(&lock);

        pthread_mutex_unlock(&lock);

竞争申请锁,多线程都得看到同一个锁,锁本身就是共享资源!

因此,申请锁的过程必须是原子的!

        申请锁成功:继续向后运行,访问临界区代码,访问临界资源

        申请锁失败:阻塞挂起申请执行流

锁提供的能力的本质:执行临界区代码由并行变为串行

在我执行期间,不会被打扰,也是一种变相的原子性的表现。

进程间如何互斥?进程的共享内存,申请锁。

锁的封装

#pragma once

#include <pthread.h>

namespace quitesix
{
    class Mutex
    {
    public:
        Mutex()
        {
            pthread_mutex_init(&_lock, nullptr);
        }
        ~Mutex()
        {
            pthread_mutex_destroy(&_lock);
        }
        void Lock()
        {
            pthread_mutex_lock(&_lock);
        }
        void Unlock()
        {
            pthread_mutex_unlock(&_lock);
        }

    private:
        pthread_mutex_t _lock;
    };

    class MutexGuard
    {
    public:
        MutexGuard(Mutex *mutex) :_mutex(mutex)
        {
            _mutex->Lock();
        }
        ~MutexGuard()
        {
            _mutex->Unlock(); 
        }
    private:
        Mutex *_mutex;
    };
}

        RAII是Resource Acquisition Is Initialization的缩写,他是一种管理资源的类的设计思想,本质是一种利用对象生命周期来管理获取到的动态资源,避免资源泄漏,这里的资源可以是内存、文件指针、网络连接、互斥锁等等。

锁的理解

对临界资源保护:本质就是用锁,来对临界区进行保护

加锁之后,在临界区内部,允许进程切换吗?切换了会怎么样?

        允许进程切换。我当前线程,并没有释放锁,我是持有锁被切换的,即使我不在,其他线程也必须等我执行完临界区代码,释放锁后,其他线程才能进行锁的竞争,进入临界区。

        切换了不怎么样,其他线程得等我跑完。

实例理解:

        临界区是一个超级自习室,只允许一个人待在里面。需要竞争申请锁,拿到钥匙,一个人在自习室期间,没有人能进来。即使这个人去办别的事了,也是持有钥匙走的,别人还是进不去,直到钥匙放回去,别人才能重新竞争进去自习室。

锁的原理

1.硬件级实现:关闭时钟中断

2.软件级实现

lock:

        movb  $0, %al                 // 将寄存器%al的数据清0

        xchqb  %al, mutex          // 交换寄存器%al和内存中mutex值

        if(al寄存器的内容>0){       // 交换后如果al寄存器值大于0,说明申请锁成功 

                return 0;                    

        }else

                挂起等待;                  // 申请锁失败,挂起等待

        goto lock;

lock中有效影响是否申请锁的只有交换那一句汇编,原子的。

谁交换后值大于0了就代表申请成功了。

核心:交换不会增加资源,只是资源的转移。

        CPU寄存器只有一套,但CPU寄存器里的数据有多套 -> 把一个变量的内容交换到CPU寄存器内部,本质:把该变量的内容获取到执行流的硬件上下文 -> CPU寄存器数据是属于进程/线程私有的 -> swap,exchange内存中的变量,交换到CPU寄存器中,本质就是给当前线程/进程,获取锁,而且因为是交换,资源是转移的,并不会增加。

二、线程同步

理解:引入新的技术,必然引入新的问题,为了进一步解决问题,必须有新的技术引入。

线程互斥,本质没有错。但是不高效,不太公平。

        实例:超级自习室,一个人自习完了,把钥匙放回到原处了。开始纠结了,再自习一会呢,省得下次等很久,然后又拿着钥匙回去开锁自习了。

其他人得不到钥匙,其他线程 饥饿问题

        不公平的点:上个申请锁的线程“离”锁最近,下次申请更容易。(其他线程在等待队列要被唤醒,还要被调度)

解决:

1.不能立即申请第二次

2.外边的人进行排队,出来的人排最后面,进行二次申请。

        在保证自习室安全的情况下,让所有的执行流,访问临界资源,按照一定的顺序进行访问资源。(线程同步)

条件变量的理解

理解:有一个被蒙眼的人要执行放苹果的动作,其他蒙眼的人要去拿苹果。放苹果和拿苹果的操作要求是原子的(要加锁)。如果他们之间没有任何通知,那么放苹果的人要不断地执行放苹果这个动作(不知道盘子里苹果有没有被拿走),拿苹果的人也要不断执行拿苹果这个动作(不知道盘子里有没有放苹果)。

        于是有人提出了个想法,每次放苹果的人放完苹果之后就敲一下铃铛,等待拿苹果的队列里就知道苹果准备好了,可以拿了,就去拿苹果,拿完之后也敲一下铃铛。这时放苹果的人知道了盘子是空,要放苹果,就形成闭环了。

        当⼀个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。 例如⼀个线程访问队列时,发现队列为空,它只能等待,只到其它线程将⼀个节点添加到队列中。这种情况就需要用到条件变量。

条件变量接口的使用

局部: 

        pthread_cond_t cond;

        pthread_cond_destory(&cond);    //第一个参数cond指针
        pthread_cond_init(&cond, nullptr);        //第二个参数为属性,不管

全局或静态: pthread_cond_t cond  =  PTHREAD_COND_INITIALIZER;

等待:

pthread_cond_timewait(&cond, &mutex, abstime);  // 第三个参数为时间,按时间等

pthread_cond_wait(&cond, &mutex);  // 第一个参数为cond指针,第二个参数为锁

唤醒:

pthread_cond_broadcast(&cond);  // 唤醒指定条件变量下等待的所有线程

pthread_cond_signal(&cond);  // 唤醒在该条件变量下等待的一个线程

demo,用条件变量让线程等待,实现同步

#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <pthread.h>
#include <unistd.h>

#define THREAD_NUM 5
int cnt = 1000;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void *threadrun(void *arg)
{
    std::string name = static_cast<char *>(arg);
    delete[] (char *)arg;

    while (true)
    {
        // 申请锁
        pthread_mutex_lock(&mutex);
        // 直接等待,要唤醒才能访问
        pthread_cond_wait(&cond, &mutex);
        std::cout << name << " 当前cnt的值为: " << cnt << std::endl;
        cnt++;
        // 释放锁
        pthread_mutex_unlock(&mutex);
    }
}

int main()
{
    std::vector<pthread_t> tids;
    for (int i = 0; i < THREAD_NUM; i++)
    {
        pthread_t tid;
        char *name = new char[64];
        snprintf(name, 64, "thread-%d", i);
        pthread_create(&tid, nullptr, threadrun, (void *)name);
    }

    while (true)
    {
        // 每隔1秒唤醒一个线程
        sleep(1);
        pthread_cond_signal(&cond);
    }

    for (int i = 0; i < THREAD_NUM; i++)
    {
        pthread_join(tids[i], nullptr);
    }

    return 0;
}

        通过条件sleep,以及直接等待,让所有线程都进入cond的等待队列中,每搁一秒唤醒一次,此时整体就有了顺序性,实现了同步。

  • 等待是需要等,什么条件才会等呢?票数为0,等待之前,就要对资源的数量进行判定。
  • 判定本身就是访问临界资源!因此判断一定是在临界区内部的.
  • 判定结果,也一定在临界资源内部。所以,条件不满足要休眠,一定是在临界区内休眠的!
  • 证明一件事情:条件变量,可以允许线程等待。
  • 可以允许一个线程唤醒在cond等待的其他线程, 实现同步过程

生产者和消费者模型

理解:生产者为各种工厂,中间是超市,消费者是用户。

        工厂生产商品交给超市;超市存放着工厂生产的商品,不是单一的商品,而是来自各个工厂,且品类不同的商品;用户需要从超市买商品。

        工厂把商品给超市(工厂->超市)和 消费者买商品(超市->用户这两个过程是互斥加锁的,串行执行的。因为有前提条件,超市货架不满工厂才能放,超市有东西用户才能买,且工厂放的时候用户不能买

        核心:工厂生产商品的时候不影响消费者使用商品,消费者使用商品不影响工厂生产商品。(生产和消费解耦合)

生产者消费者模型:

        3种要素,生产者,消费者,一个交易场所(临界资源)

                生产者之间:互斥关系

                消费者之间:互斥关系

                生产者和消费者之间: 互斥 和 同步

        2种角色:生产者角色和消费者角色(线程承担)

        1个交易场所:以特定结构构成的一块“内存”空间

记忆:321原则

为什么要有生产者消费者模型?

好处:

1.生产过程和消费过程解耦合

2.支持忙闲不均

3.提高效率

        提高效率不是体现在入交易场所和出交易场所上,而是未来获取任务和处理具体任务,是并发的。真正耗时间的是生产(制造)和消费(使用)的过程,入交易场所和出交易场所占比很少,真正耗时间的(生产和消费)解耦,并发执行,就能提高效率。

编写基于blockqueue的生产消费模型

阻塞队列:

#pragma once

#include <pthread.h>
#include <iostream>
#include <queue>

const int max_cap = 5;

template <typename T>
class BlockQueue
{
private:
    bool IsFull() { return _q.size() >= _cap; }
    bool IsEmpty() { return _q.empty(); }

public:
    BlockQueue(int cap = max_cap)
        : _cap(cap), _csleep_num(0), _psleep_num(0)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_empty_cond, nullptr);
        pthread_cond_init(&_full_cond, nullptr);
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_empty_cond);
        pthread_cond_destroy(&_full_cond);
    }
    void Push(const T &data)
    {
        pthread_mutex_lock(&_mutex);
        // 判满,如果满了就等待,生产者
        while (IsFull())
        {
            _psleep_num++;
            pthread_cond_wait(&_full_cond, &_mutex);
            _psleep_num--;
        }
        _q.push(data);
        // 插入后,现在队列里一定不为空,如果消费者在等待,唤醒
        if (_csleep_num > 0)
            pthread_cond_signal(&_empty_cond);
        pthread_mutex_unlock(&_mutex);
    }
    T Pop()
    {
        pthread_mutex_lock(&_mutex);
        // 判空,如果空了就等待,消费者
        while (IsEmpty())
        {
            _csleep_num++;
            pthread_cond_wait(&_empty_cond, &_mutex);
            _csleep_num--;
        }
        T data = _q.front();
        _q.pop();
        // 删除后,现在队列里一定有空余,如果生产者在等待,唤醒
        if (_psleep_num > 0)
            pthread_cond_signal(&_full_cond);
        pthread_mutex_unlock(&_mutex);
        return data;
    }

private:
    int _cap;               // 阻塞队列的容量
    std::queue<T> _q;       // 临界资源
    pthread_mutex_t _mutex; // 锁

    pthread_cond_t _empty_cond; // 空了,消费者等待的条件
    pthread_cond_t _full_cond;  // 满了,生产者等待的条件

    int _csleep_num; // 消费者休眠的个数
    int _psleep_num; // 生产者休眠的个数
};

1.关于pthread_cond_wait函数:

重点1:pthread_cond_wait调用成功,挂起当前线程之前,要先自动释放锁!!

重点2:当线程被唤醒的时候,默认就在临界区内唤醒!要从pthread_cond_wait

成功返回,需要当前线程,重新申请_mutex锁!!!

重点3:如果我被唤醒,但是申请锁失败了??我就会在锁上阻塞等待!!!

2.关于判空和判满那为什么要用循环判断而不是if:

问题1: pthread_cond_wait是函数吗?有没有可能失败?pthread_cond_wait立即返回了

问题2:pthread_cond_wait可能会因为,条件其实不满足,pthread_cond_wait 伪唤醒

        举例:多线程情况,队列里只有一个数据,两个线程都被唤醒了(pthread_cond_boardcast),第一个线程申请到了锁,成功将数据拿到,此时队列没有数据了,释放锁。第二个线程紧接着申请到了锁,但此时队列中没有数据了,但是因为是if直接就出去了,也去拿数据,然后pop,就报错了。

在释放锁前唤醒和释放锁后唤醒都可以:

        第一种:唤醒后,因为没有释放锁,消费者会在申请锁处阻塞,后面释放锁,正常往后执行,消费者被唤醒

        第二种:唤醒后,锁已经被释放了,消费者可以申请到锁,消费者被唤醒

对于多消费者,多生产者的情况,阻塞队列的代码同样适用:

        原因:多消费者,多生产者相比于单生产者单消费者无非就是要使消费者间互斥,生产者间互斥,锁可以保证这两个条件;单生产者单消费者:消费者和生产者间的同步(条件变量)和互斥(锁)已经保证了。


网站公告

今日签到

点亮在社区的每一天
去签到