Linux线程篇(中)

有了之前对线程的初步了解我们学习了什么是线程,线程的原理及其控制。这篇文章将继续讲解关于线程的内容以及重要的知识点。

线程的优缺点

线程的缺点

在这里我们来谈一谈线程健壮性

首先我们先思考一个问题,如果一个线程出现了问题,那么它会影响其他线程吗?

我们写代码验证一下:

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
using namespace std;


void* start_route(void* args)
{
    string name =static_cast<char*>(args);
    while(true)
    {
        cout<<"我是一个新线程,我的名字是:"<<name<<endl;
        sleep(1);

    }

}


int main()
{

    pthread_t id;
    pthread_create(&id,nullptr,start_route,(void*)"thread one");
    while(true)
    {
        cout<<"我是主线程!!!!"<<endl;
        sleep(1);

    }

    return 0;



}

代码运行的结果如下:

我们用ps命令进行查看:

pid和lwp的值相同的是主线程 ,不一样的是创建出来的新线程。我们继续修改代码,使其中的一个线程崩溃,看看会不会影响另一个进程:

结果如下:

当我们再用ps命令进行查找时发现两个线程不复存在,原因是:当一个线程对野指针进行访问时,操作系统会发送信号终止进程,而两个线程的pid相同,同属于一个进程,因此两个线程同时崩溃!

之前我们说过操作系统里面没有真正线程的概念,它只提供轻量级进程的使用接口,而创建进程或者线程底层调用的是clone:

这里我们了解一下底层使用的接口就行,我们还是使用平常用的fork和pthread_create创建。

如何看待线程库?(语言版)

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <thread>

void* thread_run()
{
    while(true)
    {
        std::cout<<"我是一个新线程!!"<<std::endl;
    }


}

int main()
{
    std::thread t1(thread_run);
    while(true)
    {
        std::cout<<"我是主线程!!!"<<std::endl;
    }
    t1.join();
}

当我们用c++11中的线程时,不引入线程库时,程序运行报错:

错误显示有未被定义的"pthread_create",由此可以看出在linux环境下,c++语言中的线程本质是对linux底下线程库的进一步封装

上面的代码也能在windows底下运行,因为语言帮我们解决了平台差异性问题,实现了跨平台!!而原生线程库的接口都是不可跨平台的,暴露和使用原生线程库都是自己决定的!

全局变量的安全性

现在我们写一个抢票的代码,抢票逻辑没有任何问题。但如果多个线程并发的执行就会出现bug:

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <memory>
#include "Thread.hpp"

int ticket = 10000;
void *getTicket(void *args)
{
    string username = static_cast<const char *>(args);
    while (true)
    {
        if (ticket > 0)
        {
            usleep(1234);
            std::cout << username << "正在抢票:" << ticket << std::endl;
            --ticket;
        }
        else{
            break;
        }
    }
}

int main()
{
    std::unique_ptr<Thread> thread1(new Thread(getTicket, (void *)"user1", 1));
    std::unique_ptr<Thread> thread2(new Thread(getTicket, (void *)"user2", 2));
    std::unique_ptr<Thread> thread3(new Thread(getTicket, (void *)"user3", 3));
    std::unique_ptr<Thread> thread4(new Thread(getTicket, (void *)"user4", 4));

    thread1->start();
    thread2->start();
    thread3->start();
    thread4->start();

    thread1->join();
    thread2->join();
    thread3->join();
    thread4->join();
    return 0;
}

运行结果:

这时我们发现票数竟然变成了负数,原因就是当我们进行usleep操作时线程被不停的切换。例如线程a刚进入判断语句相对票数进行减减时,线程a被切换,保存在寄存器的上下文也相应地被切走。这时线程b又来了,它和线程a就一起进入了判断语句对票数进行删减操作。当b进行了20次循环操作(假设)票数减了20次,但这时线程a又被切换回来时,cpu先读取线程a中的上下文,在进行减减操作,最后再将结果写回到内存中。这样线程b再回来的时候结果就变得翻天覆地!!

发生以上问题的原因主要是++、--操作不是原子性的,在汇编语句上至少是三条语句:

在这里我先补充一些概念:

临界资源 :多个执行流进行安全访问的共享资源

临界区 :多个执行流中,访问临界资源的代码。--往往是代码的很小一部分

互斥 :让多个执行流串行访问共享资源。

原子性:对一个资源进行访问时,要么不做,要么就一次性 做完。换句话来说执行的语句用一条汇编就能完成。

解决以上问题的手段:加锁!!!!!

锁的常用接口:

锁的初始化:

当你定义一个锁时,如果是全局锁就可以用以下方式定义:'

初始化以后就不需要对锁进行以下接口的调用:

但如果是一个局部的锁,就需要对锁进行以上的初始化和销毁的操作。下面定义的一个全局锁:

lock和unlock之前的代码区域就是临界区,临界区中访问的ticket就是临界资源,访问它们的方式都是安全的!!

现在我们使用一个局部的锁(全局的锁太简单):

cpp 复制代码
class ThreadData
{
public:
    ThreadData(const string&threadname,pthread_mutex_t* pmutex =nullptr)
    :_threadname(threadname)
    ,_pmutex(pmutex)
    {

    }

    ~ThreadData(){}

public:
    string _threadname;
    pthread_mutex_t* _pmutex;

};

int ticket = 10000;
void *getTicket(void *args)
{
    ThreadData* td =static_cast<ThreadData*>(args);
    while (true)
    {
        pthread_mutex_lock(td->_pmutex);
        if (ticket > 0)
        {
            usleep(1234);
            std::cout <<td->_threadname << "正在抢票:" << ticket << std::endl;
            --ticket;
            pthread_mutex_unlock(td->_pmutex);

        }
        else{
            pthread_mutex_unlock(td->_pmutex);
            break;
        }
        
    }
    return nullptr;
}

int main()
{
    #define NUM 4
    pthread_mutex_t lock;
    pthread_mutex_init(&lock,nullptr);
    vector<pthread_t> tids(NUM);
    for(int i=0;i<NUM;++i)
    {
        char buffer[64];
        snprintf(buffer,sizeof buffer,"thread %d",i+1);
        ThreadData* td =new ThreadData(buffer,&lock);
        pthread_create(&tids[i],nullptr,getTicket,td);
    }

    for(const auto& tid:tids)
    {
        pthread_join(tid,nullptr);
    }
    
    pthread_mutex_destroy(&lock);

 

    return 0;
}

当我们用了全局所以后发现几个现象:

1.抢票的速度变慢了!!!原因: 就是加锁和解锁的过程是多个线程串行执行的。

2.抢票的时候基本上都是一个线程抢了好多票,其他线程没有机会抢票。原因锁只是规定互斥访问,锁是多个执行流竞争的结果。因为我们抢票的逻辑还不完整导致一个线程释放锁以后,这个线程再次进入循环申请锁。抢完票以后不应该立即再次进入循环,而是出现抢票结果的响应,但我们没有写,简单的用usleep(最后一行)代替一下:

现在我们来谈一谈对锁的认识

1.锁是用来保护共享资源时其变得安全,但多个执行流申请锁致使锁也是个共享资源。因此申请锁和释放锁也是原子性的。

2.使用锁来保护共享资源实际上是一个执行流串行运行的结果。因此为了提高效率和速度,用锁保护的代码区域的粒度越小越好

3.如果一个线程申请锁成功,即使线程被切换,它是抱着锁被切换走的!!因此当另一个线程来申请锁的时候就必须挂起等待。我们所学的锁也称为挂起等待锁。

4.谁持有锁谁就进入临界区!!!!!

锁的原子性实现原理:

在理解原理之前我们必须要有两个共识:

1.cpu只有一套寄存器被所有执行流共享。

2.cpu内寄存器的内容是每个执行流私有的,是执行流的上下文。

现在我为大家展示底层原理的代码实现:

为了保证申请锁和释放锁的原子性,大多数体系结构都提供了swap或exchange指令,它们的作用就是将寄存器里的数据和内存里的数据进行交换,并且是一步到位。

保证申请锁为原子性的方式如下:

首先1代表有锁,0代表没有锁,先将0置于一个线程的寄存器中,使寄存器中的数值成为线程a的上下文:

然后使用exchange指令将寄存器中的0和内存中的1进行交换,这样线程a就持有了锁:

由于交换数值在汇编上只有一条指令,因此保证了申请锁的原子性,那么锁被申请到了,线程a能被随意的切走吗??答案是:当然可以!!!!因为线程a被切走时,它的上下文也随之被切走,因此当别的进程来申请锁时就申请不到内存中的锁了(内存中数值为0表示没有锁),因此被挂起等待。

如果这是线程a又被切回来,它会带着它的1(它自己的上下文)回来。这时就保证了只有一个持有锁的进程能够访问临界资源!!!释放锁的原理和上面差不多这里就不多说了。这时有人会问:假如我让一个几个线程必须持有锁才能访问资源,让一个线程不需要锁进行访问,那不就不能保证只有一个线程访问了吗?? ---------------这里必须强调一下,加锁使程序员的工作,要访问就必须让所有线程持有锁访问,如果搞特殊的话这是你程序员自己代码上的失误喔!!!

锁的封装设计:(RAII)

首先我们来介绍一下什么是RAII:

以下是代码的实现:

cpp 复制代码
class Mutex
{
public:
    Mutex(pthread_mutex_t* pmutex =nullptr)
    :_pmutex(pmutex)
    {

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

    }

    void unlock()
    {
        if(_pmutex) pthread_mutex_unlock(_pmutex);

    }

    ~Mutex()
    {

    }

    pthread_mutex_t* _pmutex;
};


class Guard_Mutex
{
public:
    Guard_Mutex(pthread_mutex_t* pmutex):_mutex(pmutex)
    {
        _mutex.lock();   //构造函数中进行加锁

    }

    ~Guard_Mutex()
    {
        _mutex.unlock(); //析构函数中进行解锁
    }

private:
Mutex _mutex;

};

可重入和线程安全:

重入的概念:同一个函数被不同的执行流调用。一个执行流还没有调用完,其他执行流就再次进入这个函数。

可重入的概念:一个函数在重入的前提结果不会出现任何的问题,则称为可重入函数。

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

常见可重入的情况

前面讲了这么多相信大家听的云里雾里的,总结一句话就是:可重入是形容函数的,而线程安全表现的是整个程序运行的结果正不正确(例如有没有对全局变量的数据做保护、有没有对函数里的共享资源上锁等等)。线程安全可能调用函数,也可能不调用。因此可重入函数一定是线程安全的,而线程安全不一定是可重入的。

常见锁概念

死锁的概念:

多个线程在不释放自己锁资源的情况下,不断的请求对方的锁资源而导致永久等待的状态。

死锁产生的四个必要条件

1.互斥:这是锁的特性,每个资源每次只能被一个执行流使用。

2.请求与保持条件:对自己已经获得的资源不释放,并且不断请求对方的资源。

3.不剥夺:不强行获取对方的资源。

4.环路等待:若干执行流之间形成头尾相连的循环等待资源的关系。

破坏死锁的方法:

为了解决死锁问题,我们至少需要破坏死锁的必要条件中的一个。因为互斥是锁的基本特性,如果没有所那还谈什么死锁呢,所以互斥条件我们是没有办法解决的。因此我们根据后三条来解决:

不请求与保持:如果一个执行流申请锁失败时,可以先立即释放自己拥有的锁资源。

剥夺:提高某些执行流的优先级,我们当前执行流需要锁却申请不到,直接强行将锁给它。

破坏环路等待:如果有两把锁A、B,两个执行流依次以A、B的顺序申请和释放锁,而不是一个先申请A后申请B,一个先申请B再申请A。

到这里线程中篇就结束了,线程下篇持续更新,希望大家多多支持!

相关推荐
Nerd Nirvana1 小时前
软考—系统架构设计(案例 | 论文)
linux·系统架构·软件工程·软考·计算机基础
勤奋的凯尔森同学2 小时前
webmin配置终端显示样式,模仿UbuntuDesktop终端
linux·运维·服务器·ubuntu·webmin
软件黑马王子3 小时前
C#初级教程(4)——流程控制:从基础到实践
开发语言·c#
闲猫3 小时前
go orm GORM
开发语言·后端·golang
丁卯4043 小时前
Go语言中使用viper绑定结构体和yaml文件信息时,标签的使用
服务器·后端·golang
chengooooooo3 小时前
苍穹外卖day8 地址上传 用户下单 订单支付
java·服务器·数据库
李白同学4 小时前
【C语言】结构体内存对齐问题
c语言·开发语言
人间打气筒(Ada)5 小时前
MySQL主从架构
服务器·数据库·mysql
黑子哥呢?6 小时前
安装Bash completion解决tab不能补全问题
开发语言·bash
落笔画忧愁e6 小时前
FastGPT快速将消息发送至飞书
服务器·数据库·飞书