Linux线程篇(中)

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

线程的优缺点

 线程的缺点

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

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

我们写代码验证一下:

#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创建。

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

#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:

#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就是临界资源,访问它们的方式都是安全的!!

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

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:

 以下是代码的实现:

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。

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

猜你喜欢

转载自blog.csdn.net/m0_69005269/article/details/132407450
今日推荐