Linux——线程互斥和同步

1.线程互斥

1.1 不加锁时,多线程访问全局变量出现的问题

观察代码

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

int ticket = 1000;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        if (ticket > 0) // 1. 判断
        {
            usleep(1000);                              
            ticket--;                                   
            printf("%s出票成功,剩余票数:%d\n", id, ticket); 
        }
        else
        {
            break;
        }
    }
    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_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
}

上述代码的运行结果如图所示 :当自定义方法内的 if 条件不满足时,可以看到线程仍然执行了 ticket-- 的代码,并将其减到了负数,这显然是不对的。

1.2 什么是原子性?

:在分析上述流程前,需要知道几点

①:在线程切换时,cpu会将当前线程寄存器中的数据临时备份一份,此时其他线程就可以直接覆盖当前线程的所有数据,当新线程执行完毕时,再将老线程的中的数据拷贝回寄存器中,重新开始执行。

②:逻辑运算和算数运算在cpu内部是会加载到不同寄存器当中,分别执行运算的。

③:原子性 ,只有执行完不执行两种状态,不存在第三种状态(例如当前代码执行一半,因为线程切换不执行了)
代码中将全局变量 ticket-- 的操作可以大致分为三步,如下图所示

假设开始时运行线程A,当执行完第②步时,此时 ticket 经过cpu的运算处理后变为 99,并且pc指针指向 0xFF04。正当CPU想要执行第③步时,即将寄存器中新的 ticket 值拷贝回内存时,假设此时当前线程的时间片运行完毕,发生了线程切换,那么新的线程B会从第①步重新开始,此时内存中的ticket值仍为100,假设线程B一直不断运行,将全局变量的ticket减到了1,此时又发生了线程切换执行线程A,执行线程A前,cpu会将先前存储的有关线程A的上下文先拷贝到cpu的寄存器当中(例如寄存器中的变量,pc指针),此时CPU会跟着上一次线程A结束的位置往后执行,即将ticket = 99 拷贝如内存中,这样一来,线程B所有努力都功亏一篑

注1 :上述过程证明了全局变量ticket,在减减时不具备原子性

注2:简单理解的话,一条汇编指令一定是有原子性的,而多条汇编指令没有。
:为什么ticket会被减到负数?

:我们再来分析这三条汇编代码

假设此时ticket为1,此时线程A从内存中取到ticket = 1,并进行逻辑运算发现满足if条件,于是开始执行,但是假设此时发生进程切换,线程B也同样会从 内存中吧取到 ticket = 1,执行相应语句,同时在发生线程切换转到运行线程C也是同样,那么此时一共就有三个线程

(A、B、C)在执行满足if条件时相应的代码。当A线程中的ticket--后,会拷贝回内存中,当执行线程B时,因为逻辑运算和算数运算是分开进行的,cpu在执行线程B的操作时,会重新将内存中的ticket拷贝到cpu的寄存器中执行算数运算,而此时内存中的ticket = 0,减减后再将-1拷贝回内存,同理线程C会将ticket的值变为-2,这就是为什么能够看到ticket变成负数的原因。

1.3 互斥锁/互斥量

为了解决上述问题,我们就需要引入锁的概念

初始化互斥锁/互斥量

法一:静态分配

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

法二
int pthread_mutex_init ( pthread_mutex_t * restrict mutex, const pthread_mutexattr_t * restrict attr);
参数
mutex : 要初始化的互斥量
attr : NULL
互斥量加锁和解锁
int pthread_mutex_lock ( pthread_mutex_t *mutex);
int pthread_mutex_unlock ( pthread_mutex_t *mutex);
返回值 : 成功返回 0 , 失败返回错误号
摧毁互斥量
int pthread_mutex_destroy ( pthread_mutex_t *mutex) ;
注1:使用 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量不需要销毁
注2 : 不要销毁⼀个已经加锁的互斥量
注3 : 已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁
引入互斥量改进上述代码

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

int ticket = 1000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        pthread_mutex_lock(&lock);
        if (ticket > 0) // 1. 判断
        {
            usleep(1000);                              
            ticket--;                                   
            printf("%s出票成功,剩余票数:%d\n", id, ticket); 
            pthread_mutex_unlock(&lock);
        }
        else
        {
            pthread_mutex_unlock(&lock);

            break;
        }
    }
    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_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
}

运行结果如下图所示


问题1:锁是用来保护临界资源的,而锁本身也是一种临界资源,那么由谁来保护锁?

:锁的申请过程是原子性的,锁申请成功就向后运行临界区代码和资源,失败则阻塞挂起申请执行流

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

答:允许,因为当前线程是加锁的,并没有释放锁,当前线程在持有锁的情况下被切换,那么其他线程也无法被执行,会重新进行进程切换,直至拥有锁的进程被执行完并解锁时。这也就是为什么加锁之后,运行时间会相应增加。

1.4 互斥锁/互斥量的原理

实现锁的方式

①:硬件级实现 → 关闭时钟中断

注:这显然是不可取的!

②:软件级实现 → 下方伪代码

1.4.1 分析软件级实现锁的原理

在分析原理前,先举一个不恰当的例子

假设学校里有一件超级自习室只供一个人使用,门口有一把钥匙,谁拥有这把钥匙,谁就有使用这个自习室的权利。假设此时某人带着钥匙进来,那么其他成员就都用不了。

而当这名成员如果带着钥匙吃饭去了,走之前把自习室锁上了,那么其他成员同样也用不了这件自习室。


现在再来分析软件级实现锁的原理

初始时,锁在内存中设置为1。

假设现在有一个线程A,开始时它指向语句①,将寄存器中的值初始化为0。

开始执行语句②,语句②的意思是:将寄存器中mutex的值与寄存器中al中的值进行互换

结果如下图所示:

如果此时进行线程切换,假设为线程B,切换前会将线程A中al的值拷贝一份,而线程B的值会直接覆盖当前寄存器中的值,那么当线程B来到第②步时,此时内存中mutex的值为0,交换后仍为0,那么线程B就会挂起等待。

在此后,无论在第③步,还是第④步,当发生进程切换时,其他线程只能在内存中拿到 mutex = 0,最终会执行挂起等到的操作。就好比:钥匙只有一把,mutex = 1 也只有一个,谁(哪个线程)拿到了,其他人就没有可以使用的权利。

:前面我们说了,申请锁是具有原子性的,上述xchgb &al mutex 只有一行汇编指令,因此具有原子性。

2. 线程同步

上述的代码存在一个问题:上述代码中一共有四个线程,当线程1在执行时,突然另一个线程2访问某个变量,而在线程1改变状态之前,线程2什么都做不了。

于是就会看到明明有四个线程存在,却只有线程1在执行。

:这种状态下所引起的就是线程饥饿的问题。为了避免线程饥饿的问题,于是引入了同步的概念
同步
在保证数据安全的前提下,让线程能够按照 某种特定的顺序访问临界资源,从而有效避免
饥饿问题,叫做同步
:通过引入条件变量来使得线程能够按照顺序访问临界资源
竞态条件
因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也
不难理解

2.1 条件变量函数

初始化
int pthread_cond_init ( pthread_cond_t * restrict cond, const pthread_condattr_t
* restrict attr);
参数
cond:要初始化的条件变量
attr: NULL
销毁
int pthread_cond_destroy ( pthread_cond_t *cond)
:当条件变量设置为全局时,不需要销毁,若为局部的,则需要销毁,这点和互斥锁是相同的
条件等待
int pthread_cond_wait ( pthread_cond_t * restrict cond, pthread_mutex_t * restrict
mutex);
参数
cond:要在这个条件变量上等待
mutex:互斥量,后⾯详细解释
注1:条件等待的功能之一是将当前线程暂停,同时释放互斥锁
:为什么要释放互斥锁?
:程序设计的过程中,如果遇到条件等待说明此时线程已经不满足条件了,应当执行其他线程,通过上面对线程互斥的学习中,我们知道,多线程通信间只会存在一把锁,如果要执行其他线程,就需要将当前线程的锁释放,这样其他下线程才会运行。
注2:通过cond来区分是否是同一个条件
注3:如果被唤醒时,申请锁失败了,就会在锁上阻塞等待。
唤醒等待线程
int pthread_cond_broadcast ( pthread_cond_t *cond);
唤醒所有满足条件的线程
int pthread_cond_signal ( pthread_cond_t *cond);
唤醒所有满足条件的线程中的一个
:在唤醒前,即将唤醒的线程通过 pthread_cond_wai 重新取回互斥锁,继续执行 pthread_cond_wai 后面的代码。
总结:针对条件变量我们需要做以下几点认识:

①.不同条件变量名 意味着 条件不同,条件变量名尽可能的赋予实际意义(方便维护代码)

②.抛开条件变量初始化和销毁,其实只有等待和唤醒两种操作,很简单。但是简单的操作成就了多线程间的正常通信!

③.互斥锁成为了多线程间通信的关键,谁拿到了这把锁,谁就可以对临界资源进行访问

④.条件变量一定是在互斥锁之间的,因为条件判断本身也是对临界资源中的数据进行判断,判断的结果也是在临界资源内部的,当条件不满足线程进行休眠时,也是在临界资源内的。

3. 生产消费者模型

生产消费者模型满足"321原则"

**3 → 指三种关系:**消费者之间的互斥关系、生产者之间的互斥关系、生产者和消费者之间的互斥和同步关系

2 → 指两个角色:消费者角色和生产者角色,两者都由线程承担

1 → 一个交易场所:以特定结构构成的 "内存" 空间
生产消费者模型的优点

①. 生产过程和消费过程解耦

:当一个线程拿到锁,去访问临界资源时,其他线程可以执行非临界区属于自己的代码

②. 支持忙闲不均

:平日里,生产者可以加点加班生产货物,消费者也不去消费,所有的商品全部屯在超市里。逢年过节,工厂休息,消费者就涌进超时进行购物。

③.提高效率?

4. POSIX信号量

信号量:本质是一个计数器,对特定资源进行预定机制
多线程使用资源分两种:

①.将目标资源整体使用[mutex + 2元信号量]

②.将目标资源按照不同的"块",分批使用[信号量]

:阻塞队列就是对目标资源的整体使用

4.1 信号量相关的函数

#include <semaphore.h>

初始化信号量
int sem_init ( sem_t *sem, int pshared, unsigned int value);
参数
pshared: 0 表示线程间共享,非零表示进程间共享
value:信号量初始值
销毁信号量
int sem_destroy ( sem_t *sem);
等待信号量
int sem_wait ( sem_t *sem);
功能
等待信号量,会将信号量的值减 1,P 操作

若当前信号量的值大于0,则将信号量的值减1,并允许线程继续向后执行
若当前信号量的值为0,则当前线程将会被阻塞,直到其他线程执行 sem_post 增加信号量的值
发布信号量
int sem_post ( sem_t *sem);
功能
发布信号量,表示资源使⽤完毕,可以归还资源了。将信号量值加 1 , V操作

当调用此函数时,信号量的值增加 1,并且如果有线程因为信号量为 0 而被阻塞,它们可能会被唤醒

4.2 基于环形队列的生产消费模型

4.2.1 什么是环形队列?

如图所示:当队列为空时或者队列为满时,头和尾都指向同一位置处的队列称为环形队列。

此处可以通过数组模拟环形队列,当数组达到最后一个元素时,对数组大小取余就能够回到原来位置。

4.2.2 通过环形队列实现单对单的生产消费者模型

先论述单对单的生产消费者模型:实现一个生产者向数组存入数据,消费者从数组拿出数据的代码

以上述 数组/环形队列 为参照,做以下几点约定

①. 当数组元素为空时,生产者先运行

②. 当数组元素满时,消费者先运行

③. 生产者不能超消费者一圈

④. 消费者不能超过生产者

:有了上述约定后,消费者和生产者只可能在 环形队列(数组) 为满 or 为空 时才会处于相同位置(下标)

:这里的临界资源其实就是这个环形队列,如果代码设计合理,可以看到当生产者访问一部分临界资源时,消费者同时也在访问另一部分临界资源。今天只是简单插入一个int值,如果我将这个数组设置为vector,里面的元素设置为函数指针呢?是不是就可以分别执行不同任务了?

4.2.3 代码

**头文件Sem.hpp:**对信号量相关函数进行封装

复制代码
#pragma once
#include <iostream>
#include <semaphore.h>

int default_num = 5;

namespace SemModule
{

    class Sem
    {
    public:
        Sem(int value = default_num)
        {
            sem_init(&_sem,0,value);
        }

        void P()
        {
            sem_wait(&_sem);
        }

        void V()
        {
            sem_post(&_sem);
        }

        Sem()
        {
            sem_destroy(&_sem);
        }
    private:
        sem_t _sem;
    };
}

头文件RingQueue.hpp的封装

复制代码
#pragma once
#include "Sem.hpp"
#include "_Mutex.hpp"
#include <iostream>
#include <unistd.h>
#include <vector>

using namespace SemModule;
using namespace MutexModule;

template <class T>
class RingQueue
{
public:
    RingQueue(int cap = default_num)
        :_cap(cap),
        _v(cap),    //对于自定义类而言,通过参数化列表构造会自动调用其构造函数,这里就把vector的大小初始化好了
        _blank_sem(cap),//生产者初始信号量大小为5
        _P_step(0),//生产者数组下标
        _data_sem(0),//消费者初始信号量大小为0
        _C_step(0)//消费者数组下标
    {
    }
    void Equeue(const T& in)
    {
        //生产者申请信号量,本质是让它做--,因为生产者本身信号量初始值就为5
        _blank_sem.P();
        
        //生产,入数据
        _v[_P_step] = in;

        //更新数组下标
        _P_step++;
        _P_step %= _cap;//使之成环

        //消费者信号量++,这里就能唤醒等待的消费者线程,让其执行后续代码
        _data_sem.V();
    }
    
    void Pop(T* out)
    {
        //消费者申请信号量,因为消费者的初始信号量大小为0,所以会阻塞等待,直到生产者生产一个数据,并释放
        _data_sem.P();

        //保存数据,保存数据后当前下标就没有用了。
        *out = _v[_C_step];

        //更新下表
        _C_step++;
        _C_step %= _cap;
        //生产者信号量++
        _blank_sem.V();//当消费者拿完一个数据时,当前位置就空下来了,所以生产的信号量要++
    }

    ~RingQueue(){}

private:
    std::vector<T> _v;
    int _cap;

    //生产者
    Sem _blank_sem; //生产者信号量
    int _P_step; //生产者对应下标,为了不让消费者超过生产者

    //消费者
    Sem _data_sem; //消费者信号量
    int _C_step;
    
};

main.cc主函数

复制代码
#include "Task.hpp"
#include "RingQueue.hpp"

void* productor(void* args)
{
    RingQueue<int> * q = static_cast<RingQueue<int>*>(args);
    //int i = 0, j = 0;
    int data = 0;
    while(true)
    {
        q->Equeue(data);//传入一个数据
        data++;
    }
    return nullptr;
}

void* consumer(void* args)
{
    RingQueue<int > * q = static_cast<RingQueue<int>*>(args);
    while(true)
    {
        sleep(1);
        int t = 0;
        q->Pop(&t);//这里通过传入指针来修改原来的变量
        std::cout << "消费者消费了一个数据:" << t << std::endl;//取数据
    }
    return nullptr;
}

int main()
{
    RingQueue<int >* bq = new RingQueue<int>();//pthread_create 规定第四个参数为指针,所以new一个自定义类出来,通过传参强制类型转换进行自定义类的访问
    pthread_t td1,td2;

    //创建线程1
    pthread_create(&td1,nullptr,productor,bq);

    //创建线程2
    pthread_create(&td1,nullptr,consumer,bq);
    
    //线程等待
    pthread_join(td1,nullptr);
    pthread_join(td2,nullptr);
    return 0;
}

4.2.4 通过环形队列实现多对多的生产消费者模型

对上述代码中 头文件RingQueue.hpp中的部分代码 进行修改,如下图**:**

复制代码
void Equeue(const T& in)
    {
        //生产者申请信号量,本质是让它做--,因为生产者本身信号量初始值就为5
        _blank_sem.P();
        _plock.Lock();//加锁,这部分同样是对互斥锁进行了封装在调用
        
        
        //生产,入数据
        _v[_P_step] = in;

        //更新数组下标
        _P_step++;
        _P_step %= _cap;//使之成环

        _plock.UnLock();

        //消费者信号量++,这里就能唤醒等待的消费者线程,让其执行后续代码
        _data_sem.V();
    }
    
    void Pop(T* out)
    {
        //消费者申请信号量,因为消费者的初始信号量大小为0,所以会阻塞等待,直到生产者生产一个数据,并释放
        _data_sem.P();
        _clock.Lock();
        //保存数据,保存数据后当前下标就没有用了。
        *out = _v[_C_step];

        //更新下表
        _C_step++;
        _C_step %= _cap;
        //生产者信号量++
        _clock.UnLock();
        _blank_sem.V();//当消费者拿完一个数据时,当前位置就空下来了,所以生产的信号量要++
    }

增加了消费者间以及生产者间的互斥关系。

:先申请锁?还是先申请信号量?

:都可以,但是先申请信号量的效率比先申请锁的效率高!如何理解?假设现在有三个生产者,他们先对资源进行预定,本质就是信号量 -= 3,当他们申请好了之后,三个线程再去争这把锁,谁抢到了,谁就先执行,其他线程阻塞等待。当前线程执行完毕时,释放锁,先前没抢到锁的线程就再依次执行。

:当某个线程成功申请到锁时,其他线程可以进行申请信号量的操作;当前线程执行完毕时,可以先把锁释放了交给其他线程去执行,自己再去执行其他信号量操作

举一个简单的例子:去看电影,是先排队然后买票?还是先买票再排队?肯定是后者。买票好比申请信号量,排队就好比申请锁。

相关推荐
小安运维日记4 小时前
CKS认证 | Day3 K8s容器运行环境安全加固
运维·网络·安全·云原生·kubernetes·云计算
我是唐青枫4 小时前
Linux ar 命令使用详解
linux·运维·服务器
mljy.5 小时前
Linux《进程概念(上)》
linux
IEVEl5 小时前
Centos7 开放端口号
linux·网络·centos
我要升天!6 小时前
Linux中《环境变量》详细介绍
linux·运维·chrome
MobiCetus6 小时前
有关pip与conda的介绍
linux·windows·python·ubuntu·金融·conda·pip
Wnq100727 小时前
DEEPSEEK创业项目推荐:
运维·计算机视觉·智能硬件·ai创业·deepseek
weixin_428498497 小时前
Linux系统perf命令使用介绍,如何用此命令进行程序热点诊断和性能优化
linux·运维·性能优化
盛满暮色 风止何安8 小时前
VLAN的高级特性
运维·服务器·开发语言·网络·网络协议·网络安全·php
lemon3106249 小时前
dockerfile制作镜像
linux·运维·服务器·学习