Linux线程的加锁

简介: 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();
}


17ca359da8db56a672027baf0db2c108.png

多线程抢票场景

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

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

34b1f774a01173fd45b01579c8c38cfd.png

可以看到票是四个线程同时在抢了,但是会出现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;
}

158d393e963074f27c0b2218ee9c0862.png

当这部分的代码用锁包起来后,可以看到就不会再出现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开辟出的空间

不调用不可重入函数

不返回静态或全局数据,所有数据都有函数的调用者提供

使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

二者之间的联系与区别

函数是可重入的,那就是线程安全的

函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题

如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

区别:

可重入函数是线程安全函数的一种

线程安全不一定是可重入的,而可重入函数则一定是线程安全的。

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


相关实践学习
CentOS 7迁移Anolis OS 7
龙蜥操作系统Anolis OS的体验。Anolis OS 7生态上和依赖管理上保持跟CentOS 7.x兼容,一键式迁移脚本centos2anolis.py。本文为您介绍如何通过AOMS迁移工具实现CentOS 7.x到Anolis OS 7的迁移。
目录
相关文章
|
3月前
|
安全 Java 编译器
线程安全问题和锁
本文详细介绍了线程的状态及其转换,包括新建、就绪、等待、超时等待、阻塞和终止状态,并通过示例说明了各状态的特点。接着,文章深入探讨了线程安全问题,分析了多线程环境下变量修改引发的数据异常,并通过使用 `synchronized` 关键字和 `volatile` 解决内存可见性问题。最后,文章讲解了锁的概念,包括同步代码块、同步方法以及 `Lock` 接口,并讨论了死锁现象及其产生的原因与解决方案。
81 10
线程安全问题和锁
|
3月前
|
安全 Java 调度
Java编程时多线程操作单核服务器可以不加锁吗?
Java编程时多线程操作单核服务器可以不加锁吗?
47 2
|
17天前
|
Linux 数据库
Linux内核中的锁机制:保障并发操作的数据一致性####
【10月更文挑战第29天】 在多线程编程中,确保数据一致性和防止竞争条件是至关重要的。本文将深入探讨Linux操作系统中实现的几种关键锁机制,包括自旋锁、互斥锁和读写锁等。通过分析这些锁的设计原理和使用场景,帮助读者理解如何在实际应用中选择合适的锁机制以优化系统性能和稳定性。 ####
35 6
|
2月前
|
资源调度 Linux 调度
Linux C/C++之线程基础
这篇文章详细介绍了Linux下C/C++线程的基本概念、创建和管理线程的方法,以及线程同步的各种机制,并通过实例代码展示了线程同步技术的应用。
30 0
Linux C/C++之线程基础
|
3月前
|
存储 缓存 安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
【Java面试题汇总】多线程、JUC、锁篇(2023版)
|
2月前
|
运维 API 计算机视觉
深度解密协程锁、信号量以及线程锁的实现原理
深度解密协程锁、信号量以及线程锁的实现原理
39 1
|
2月前
|
Java 应用服务中间件 测试技术
Java21虚拟线程:我的锁去哪儿了?
【10月更文挑战第8天】
41 0
|
2月前
|
安全 调度 数据安全/隐私保护
iOS线程锁
iOS线程锁
27 0
|
4月前
|
数据采集 存储 安全
如何确保Python Queue的线程和进程安全性:使用锁的技巧
本文探讨了在Python爬虫技术中使用锁来保障Queue(队列)的线程和进程安全性。通过分析`queue.Queue`及`multiprocessing.Queue`的基本线程与进程安全特性,文章指出在特定场景下使用锁的重要性。文中还提供了一个综合示例,该示例利用亿牛云爬虫代理服务、多线程技术和锁机制,实现了高效且安全的网页数据采集流程。示例涵盖了代理IP、User-Agent和Cookie的设置,以及如何使用BeautifulSoup解析HTML内容并将其保存为文档。通过这种方式,不仅提高了数据采集效率,还有效避免了并发环境下的数据竞争问题。
如何确保Python Queue的线程和进程安全性:使用锁的技巧
|
2月前
|
Java API
【多线程】乐观/悲观锁、重量级/轻量级锁、挂起等待/自旋锁、公平/非公锁、可重入/不可重入锁、读写锁
【多线程】乐观/悲观锁、重量级/轻量级锁、挂起等待/自旋锁、公平/非公锁、可重入/不可重入锁、读写锁
34 0
下一篇
无影云桌面