Linux线程的加锁

线程库的封装

将系统中提供的线程库封装成C++的类,方便后续学习的使用。

#pragma once

#include<iostream>
#include<pthread.h>
#include<string>
#include<cassert>
#include<functional>

//因为如下的静态成员方法不能调用非静态成员方法和变量
//因此可以选择创建一个类,让类来调用方法后再让静态成员方法
//返回该类调用的结果

//声明下面的类
class Thread;
class Con{
public:
    Thread* _this;

    Con()
        :_this(nullptr)
    {}
};

class Thread{
public:
    typedef std::function<void*(void*)> func_;

    //构造函数
    Thread(func_ func, void* args, int number)
        :_func(func)
        ,_args(args)
    {
        _name = "thread-No.";
        _name += std::to_string(number);

        //创建辅助类对象,让其成员指向当前this
        //最后将其传入调用的函数中
        Con* con = new(Con);
        con->_this = this;

        int n = pthread_create(&_tid, nullptr, start_routine, con);
        assert(n == 0);
    }

    //线程调用方法
    //静态类内成员函数不含this指针
    static void* start_routine(void* args){
        //静态成员函数不能调用非静态成员方法和变量
        //因此使用辅助类,辅助类里的成员已经是当前this
        //直接调用即可
        Con* con = (Con*)args;
        void* res = con->_this->run();
        delete con;
        return res;
    }

    //调用函数
    void* run(){
        return _func(_args);
    }

    //线程等待
    void join(){
        int n = pthread_join(_tid, nullptr);
        assert(n == 0);
    }

    ~Thread(){

    }

private:
    std::string _name;
    pthread_t _tid;
    func_ _func;
    void* _args;
};

封装好之后,直接包含该头文件就可以以C++的类的形式去使用线程了

#include"MyThread.hpp"
#include<unistd.h>

void* thread_pp(void* args){
    char* s = (char*)args;
    while(1){
        std::cout << "I am new thread " << s << std::endl;
        sleep(1); 
    }
}

int main(){
    Thread thread1(thread_pp, (void*)"hello", 1);

    while(1){
        std::cout << "I am old thread" << std::endl;
        sleep(1);
    }

    thread1.join();

}

image-20230711193827538

多线程抢票场景

模拟一下四个线程同时抢票的场景,观察会出现什么问题

#include"MyThread.hpp"
#include<unistd.h>

//记录票数
int ticket = 10000;

void* getTicket(void* argc){
    char* res = (char*)argc;
    while(1){
        if(ticket > 0){
            usleep(1000);
            std::cout << res << " " << ticket << std::endl;
            --ticket;
        }
        else
            break;
    }
}

int main(){
    //创建多个线程
    Thread thread1(getTicket, (void*)"thread 1", 1);
    Thread thread2(getTicket, (void*)"thread 2", 2);
    Thread thread3(getTicket, (void*)"thread 3", 3);
    Thread thread4(getTicket, (void*)"thread 4", 4);

    thread1.join();
    thread2.join();
    thread3.join();
    thread4.join();

    return 0;
}

image-20230711194026687

可以看到票是四个线程同时在抢了,但是会出现0票和负票的情况,这是为什么呢?下面来分析一下这种情况

线程加锁

首先可以知道的是线程在被创建出来后并不能确定哪个线程先执行,即使是创建多个线程也不能确定谁先执行。那么在线程执行代码时,因为程序里有一个判断语句和一个usleep函数。

if(ticket > 0)
    	usleep(1000);

线程在遇到usleep函数时会休眠等待,那么假设现在的ticket为1,线程1率先到了usleep函数,那么它会休眠。因为CPU的速度是很快的,则有可能此时线程1在休眠的时候,其余的三个线程也依次过了判断语句(因为线程1在休眠,所以还没有执行到–ticket,也就是ticket仍然为1),那么当其余线程也都判断为真时,所有的线程都进入到了usleep函数。当其余线程到达usleep时,线程1这时候醒了,它就会继续往下执行,执行到了–ticket后,此时的ticket为0,线程1执行完后也就会退出了循环。但是由于其他的线程都已经在线程1执行–ticket前就判断为真进入到了if语句中,所以此时其余线程也会继续往下执行,这就会导致–ticket还会继续,也就会出现了0和负数的情况。

清楚了0和负数的情况后,那么问题又来了,此时的代码定义的ticket是个全局的变量,那么这个变量一定会安全的按照顺序一直–吗?事实上这种全局的变量在多线程的共享下是不能直接保证安全的。

首先要清楚,对于变量的++ – 在汇编中其实是至少三条语句的:

  1. 从内存读取数据到CPU的寄存器中
  2. 在寄存器张让CPU进行对应的运算逻辑
  3. 将新结果写回到该变量对应在内存中的位置

假如现在线程1率先执行,它将ticket从内存中读取到CPU的寄存器中(完成了第一条语句)紧接着在寄存器中完成了逻辑运算(完成了第二条语句),此时来了个线程2由于某些原因导致线程1不得不停止运行需要被切换掉,那么线程切换就需要将寄存器中对应的数据一并带走,所以线程1被切换后带着的数据为999。然后其余线程一直不断地执行着,而线程1继续等待。当ticket被-- 到了100后,此时线程1进场了,由于线程1被切换前已经完成了前两步,因此线程1会带着999这个数据执行第三步,也就是将结果写回到内存中,那么此时原本为100的ticket就会被线程1写成999。这就是全局变量并不一定安全的情况。

为了解决这个情况,可以采用加锁的方式,让线程串行执行

锁的调用

pthread_mutex_t XXX — 定义一个锁

如果定义的是全局的,可以直接 =PTHREAD_MUTEX_INITIALIZER 进行初始化

如果是局部的就要调用初始化函数

pthread_mutex_init

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
              const pthread_mutexattr_t *restrict attr);

参数一为锁的地址,参数二可以不关心设为nullptr

//全局定义锁并初始化
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

//定义锁并初始化
pthread_mutex_t lock;
pthread_mutex_init(&lock, nullptr);

pthread_mutex_destory – 销毁锁

 int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数为锁的地址

//删除锁
pthread_mutex_destroy(&lock);

定义成功一把锁后,接着就是申请使用这个锁和解开这个锁

int pthread_mutex_lock(pthread_mutex_t *mutex);//申请使用锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);//尝试申请锁,申请失败则立即报错返回
int pthread_mutex_unlock(pthread_mutex_t *mutex);//解锁

上述代码加锁

#include"MyThread.hpp"
#include<unistd.h>
#include<vector>

// //全局定义锁并初始化
// pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
//记录票数
int ticket = 10000;

//将线程属性封装成类,类里面包括锁
class ThreadDate{
public:
    std::string _name;
    pthread_mutex_t* _lockp;

    ThreadDate(const std::string name, pthread_mutex_t* lockp)
        :_name(name)
        ,_lockp(lockp)
    {}
};

void* getTicket(void* argc){
    ThreadDate* td = (ThreadDate*)argc;
    while(1){
        //使用锁
        pthread_mutex_lock(td->_lockp);
        if(ticket > 0){
            usleep(1000);
            std::cout << td->_name << " " << ticket << std::endl;
            --ticket;

            //循环退出前必须解锁
            pthread_mutex_unlock(td->_lockp);
        }
        else{
            //循环退出前必须解锁
            pthread_mutex_unlock(td->_lockp);
            break;
        }

        //模拟线程还会做其他事情,留点时间给其他的线程完成上面的代码
        usleep(1000);
    }
}

int main(){
    //定义锁并初始化
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, nullptr);
    std::vector<pthread_t> v(4);

    //创建多个线程
    for(int i = 0; i < 4; ++i){
        std::string name("thread ");
        name += std::to_string(i + 1);
        //创建线程属性的一个对象,所有线程共用一把锁
        ThreadDate* td = new ThreadDate(name, &lock);
        //创建线程,将线程id保存到数组中,参数传入属性对象
        pthread_create(&v[i], nullptr, getTicket, (void*)td);
    }

    //对所有线程进行等待
    for(int i = 0; i < 4; ++i)
        pthread_join(v[i], nullptr);

    //删除锁
    pthread_mutex_destroy(&lock);

    return 0;
}

image-20230712074933244

当这部分的代码用锁包起来后,可以看到就不会再出现0和负数了,接下来就来谈谈锁的原理。

锁的原理

那么在谈原理前,首先得清楚几个概念

临界资源:多个执行流进行安全访问的共享资源
临界区:我们把多个执行流中,访问临界资源的代码 – 往往是线程代码的很小的一部分
互斥:想让多个线程串行访问共享资源
原子性:对一个资源进行访问的时候,要么不做,要么做完

那么如果去看待锁呢?

加锁的过程本质就是原子的,因此在一个线程加了锁之后它就一定要把这部分代码执行完否则其他线程不能访问这部分代码

如果线程申请锁成功,就继续向后执行,如果申请暂时没有成功,执行流会阻塞!

根据这个就可以清楚上述的抢票代码,当线程1申请锁成功后就会去执行下面的代码,在它解锁前其他的线程都不可以访问这部分代码而是会阻塞等待。总结得出:

当一个线程申请锁成功进入临界资源并正在访问临界资源时,其他线程在阻塞等待。需要注意:持有锁的线程也是可以被切换的,但是即便持有锁的线程被切换,其他线程依旧是无法申请锁成功的便也无法向后执行,直到持有锁的线程解锁才可以,对于其他线程而言持有锁的线程就是原子性的。

锁的设计实现

那么对于锁而言,因为必须保证其原子性所以在Linux中实现锁的方案就可以有将汇编语句设计成一条的方法。

为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期

也就是说之前的++ - - 操作就可以由三个步骤直接变为一个步骤,从而控制原子性

lock:
		movb $0, %al
		xchgb %al, mutex
		if(al寄存器的内容 > 0)
			return 0;
		else
			挂起等待;
		goto lock;
unlock:
		movb $1, mutex
		唤醒等待mutex的线程;
		return 0;

根据上面代码来分析:首先线程1访问执行,将0放入到al寄存器中,接着直接将内存中对应位置的变量值(假设为1)与寄存器中的值进行交换,此时的寄存器里的值为1,内存对应位置的值为0。如果此时线程1被切换了,线程2进场。因为线程切换是要带走寄存器中的上下文的,所以寄存器中的1被线程1带走了。线程2来了之后同样先把0放到寄存器中,再将寄存器的值和内存中的值交换,注意此时内存中的值因为被线程1交换后还没有进行下一步操作,所以为0。线程2交换后,寄存器中的值和内存中的值都为0,接着线程2继续往下执行,判断if不能通过来到else后阻塞。当线程1回来时带回来上下文数值1放到寄存器中继续往下执行,判断if为真因此返回0,这就是申请锁成功了

这就是锁实现的一种方法,最主要的就是确保原子性,所以将汇编变成一条语句去执行。

解锁的过程就需要将寄存器中的值设为非0,以确保其他线程申请锁时判断if为真。

对锁的C++封装

#pragma once

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

//锁的对象类
class Mutex{
public:
    Mutex(pthread_mutex_t* lockp = nullptr)
        :_lockp(lockp)
    {}

    void lock(){
        if(_lockp)
            pthread_mutex_lock(_lockp);
    }

    void unlock(){
        if(_lockp)
            pthread_mutex_unlock(_lockp);
    }

    ~Mutex(){}

private:
    pthread_mutex_t* _lockp;
};

//锁的调用类
class LockGuard{
public:
    LockGuard(Mutex* lock)
        :_lock(lock)
    {
        //在构造中直接调用
        _lock->lock();
    }

    ~LockGuard(){
        //析构中直接解锁
        _lock->unlock();
    }
private:
    Mutex* _lock;
};

封装好后以后的使用就可以跟C++一样创建类对象,不用再去频繁的编写复杂的系统调用接口

死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。

可以理解为拥有不同锁的线程去申请别的已经申请好锁的线程的锁,还有一种情况为一个线程已经申请好锁后再去申请同样的锁

死锁的条件

互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

避免死锁

避免死锁的情况可以:

破坏死锁的四个必要条件
加锁顺序一致
避免锁未释放的场景
资源一次性分配

可重入和线程安全

线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。

重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数

常见的线程不安全情况:

不保护共享变量的函数
函数状态随着被调用,状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数

常见的线程安全情况:

每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
类或者接口对于线程来说都是原子操作
多个线程之间的切换不会导致该接口的执行结果存在二义性

常见的不可重入情况:

调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构

常见的可重入情况

不使用全局变量或静态变量
不使用用malloc或者new开辟出的空间
不调用不可重入函数
不返回静态或全局数据,所有数据都有函数的调用者提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

二者之间的联系与区别

函数是可重入的,那就是线程安全的
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

区别:

可重入函数是线程安全函数的一种
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的

  • 14
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 11
    评论
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

CHJBL

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值