Linux知识点 -- Linux多线程(二)

Linux知识点 – Linux多线程(二)

文章目录

  • Linux知识点 -- Linux多线程(二)
  • 一、线程互斥
    • 1.背景概念
    • 2.多线程访问同一个全局变量
    • 3.加锁保护
    • 4.问题
    • 5.锁的实现
  • 二、线程安全
    • 1.可重入与线程安全
    • 2.常见情况
    • 3.可重入与线程安全的联系
  • 三、死锁
    • 1.死锁概念
    • 2.死锁的条件
    • 3.避免死锁的方法


一、线程互斥

1.背景概念

  • 临界资源:多线程执行流共享的资源就叫做临界资源;
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区;
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区, 访问临界资源,通常对临界资源起保护作用;
  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成;

2.多线程访问同一个全局变量

下面实现一个抢票代码,多线程共同抢票,都访问同一个全局变量tickets,每次访问都 - -tickets:

#include 
#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

int tickets = 1000;


void *getTickets(void *args)
{
    (void)args;
    while (true)
    {
        if(tickets > 0)
        {
            usleep(1000);
            printf("%p: %d\n", pthread_self(), tickets);
            tickets--;
        }
        else
        {
            break;
        }
    }
}

int main()
{
    pthread_t t1, t2, t3;
    pthread_create(&t1, nullptr, getTickets, nullptr);
    pthread_create(&t2, nullptr, getTickets, nullptr);
    pthread_create(&t3, nullptr, getTickets, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

    return 0;
}

运行结果:
Linux知识点 -- Linux多线程(二)_第1张图片
最终将tickets的数量减到了-1,但我们发现判断条件是tickets > 0才执行 - -操作;
并发访问的时候,导致了数据不一致的问题;

  • 解释:
    tickets - -这个操作翻译成汇编语句,一共有三步操作:
    (1)读取内存数据到cpu的寄存器中;
    (2)cpu内部进行计算 - -;
    (3)将结果写回内存中
    把数据读取到寄存器,就是将数据读取到执行流的上下文数据;

    因为这个tickets - - 的运算过程不是原子的,线程在运行的任何时候都有可能被切换出去,因此会发生以下的情况:
    当线程1执行完第二步的时候,被切走了,由线程2继续执行这个- - 操作;
    线程2执行完后将数据写回内存,当线程2一直执行一定时间后,将最后结果(5000)写入内存;
    此时切回了进程1,从第三步继续执行,将结果写入内存,内存中的结果又被写成了9999;
    这样就引发了因为切换问题导致的数据不一致;

3.加锁保护

为了解决多线程引发的数据不一致问题,可以为临界区代码加锁:

  • 锁的初始化:
    Linux知识点 -- Linux多线程(二)_第2张图片
    自行初始化:
    在这里插入图片描述
    全局内定义的静态锁,使用宏初始化:
    在这里插入图片描述
  • 加锁与解锁:
    Linux知识点 -- Linux多线程(二)_第3张图片

在临界区加锁:加锁的意义在于,在一个时刻,只允许一个执行流访问加锁的代码,将这段代码变成串行运行的;
任何一个时刻,只允许一个线程获得这把锁,其他线程都在等待;
直到拿到锁的线程最终释放掉,其他线程才可以拿到;
相当于加锁和解锁之间的代码只可以串行通信,其他代码都可以并行;

Linux知识点 -- Linux多线程(二)_第4张图片

全局静态的锁:

//全局静态的锁,使用宏初始化
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;//pthread_mutex_t就是原生线程库提供的一个数据类型

void *getTickets(void *args)
{
    (void)args;
    while (true)
    {
        pthread_mutex_lock(&mtx);//为临界区代码加锁
        if(tickets > 0)
        {
            usleep(1000);
            printf("%p: %d\n", pthread_self(), tickets);
            tickets--;
        }
        else
        {
            break;
        }
    }
}

解锁:
Linux知识点 -- Linux多线程(二)_第5张图片
不能在这里解锁,因为如果线程执行完break之后,就不会执行解锁代码,而这把锁是全局的,还处于被该线程修改的状态,其他线程就无法拿到锁了;
应该在下面的地方解锁:

//全局静态的锁,使用宏初始化
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;//pthread_mutex_t就是原生线程库提供的一个数据类型

void *getTickets(void *args)
{
    (void)args;
    while (true)
    {
        pthread_mutex_lock(&mtx);//为临界区代码加锁
        if(tickets > 0)
        {
            usleep(1000);
            printf("%p: %d\n", pthread_self(), tickets);
            tickets--;
            pthread_mutex_unlock(&mtx);//解锁
        }
        else
        {
        	//如果线程加锁后直接运行到这里,在这里也可以解锁
            pthread_mutex_unlock(&mtx);//解锁
            break;
        }
    }
}

加锁和解锁之间的代码叫做临界区;

运行:
固定休眠时间可能会导致只有一个线程在跑,可以随即休眠时间;

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

int tickets = 1000;

//全局静态的锁,使用宏初始化
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;//pthread_mutex_t就是原生线程库提供的一个数据类型

void *getTickets(void *args)
{
    (void)args;
    while (true)
    {
        pthread_mutex_lock(&mtx);//为临界区代码加锁
        if(tickets > 0)
        {
            usleep(rand()%1500);
            printf("%s: %d\n", (char*)args, tickets);
            tickets--;
            pthread_mutex_unlock(&mtx);//解锁
        }
        else
        {
            //如果线程加锁后直接运行到这里,在这里也可以解锁
            pthread_mutex_unlock(&mtx);//解锁
            break;
        }
        usleep(rand()%2000);
    }
}

int main()
{
    srand((unsigned long)time(nullptr) ^ getpid());
    pthread_t t1, t2, t3;
    pthread_create(&t1, nullptr, getTickets, (void*)"thread one");
    pthread_create(&t2, nullptr, getTickets, (void*)"thread two");
    pthread_create(&t3, nullptr, getTickets, (void*)"thread three");

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

    return 0;
}

结果:
Linux知识点 -- Linux多线程(二)_第6张图片
注:加锁的时候,一定要保证加锁的粒度越小越好,因为加锁会导致进程互斥,造成临界区代码只能串行访问,影响效率;

局部的锁:
在这里插入图片描述
第一个参数是锁的地址,第二个是锁的属性;

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

int tickets = 1000;

//全局静态的锁,使用宏初始化
//pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;//pthread_mutex_t就是原生线程库提供的一个数据类型

#define THREAD_NUM 5

class ThreadData
{
public:
    ThreadData(const string &n, pthread_mutex_t *pm)
        : tname(n),
          pmtx(pm)
    {}

public:
    string tname;//线程名
    pthread_mutex_t *pmtx;//锁
};

void *getTickets(void *args)
{
    ThreadData* td = (ThreadData*)args;//接收对象
    while (true)
    {
        pthread_mutex_lock(td->pmtx);//为临界区代码加锁
        if(tickets > 0)
        {
            usleep(rand()%1500);
            printf("%s: %d\n", td->tname.c_str(), tickets);
            tickets--;
            pthread_mutex_unlock(td->pmtx);//解锁
        }
        else
        {
            //如果线程加锁后直接运行到这里,在这里也可以解锁
            pthread_mutex_unlock(td->pmtx);//解锁
            break;
        }
        usleep(rand()%2000);
    }
    delete td;
    return nullptr;
}

int main()
{
    pthread_mutex_t mtx;
    pthread_mutex_init(&mtx, nullptr);//局部锁初始化
    srand((unsigned long)time(nullptr) ^ getpid());
    pthread_t t[THREAD_NUM];
    //多线程抢票逻辑
    for(int i = 0; i < THREAD_NUM; i++)
    {
        string name = "thread ";
        name += to_string(i + 1);

        ThreadData *td = new ThreadData(name, &mtx);//创建对象
        pthread_create(t + i, nullptr, getTickets, (void*)td);//创建线程的时候,穿的参数也可以是对象指针
    }

    for(int i = 0; i < THREAD_NUM; i++)
    {
        pthread_join(t[i], nullptr);
    }


    pthread_mutex_destroy(&mtx);//局部锁的销毁

    return 0;
}

在上面的代码中,创建了一个类保存线程的信息和锁的指针,创建线程的时候,给回调函数传的参数也可以传这个对象,这样就把线程属性和局部锁的指针都传进去了,在回调函数中就可以进行使用;

运行结果:
Linux知识点 -- Linux多线程(二)_第7张图片

4.问题

  • 加锁之后,线程在临界区中是否会切换?
    会被切换,但是不会出问题;因为该线程是持有锁被切换的,所以其他抢票线程要执行临界区代码,也必须先申请锁,但是锁已经被该线程申请了,其他线程就无法申请成功,因此,就不会让其他线程进入临界区,保证了临界区中数据的一致性;

  • 一个线程,不申请锁,就是单纯的访问临界资源,这是错误的编码方式;

  • 当一个线程持有锁,在其他线程看来,该线程就是原子的;

  • 所本身就是一种共享资源,那么谁来保证锁的安全呢?
    为了保证锁的安全,申请和释放锁,必须是原子的;

5.锁的实现

  • exchange或swap汇编指令:
    以一条汇编指令的方式,将内存和CPU寄存器的数据进行交换;站在汇编的角度,只有一条汇编语句,就认为该语句的执行是原子的;

  • 在执行流视角,是如何看待COU上面的寄存器的?
    CPU内部的寄存器,本质叫做当前执行流的上下文,这些寄存器的空间是被所有执行流共享的,但是寄存器的内容,是被每一个执行流私有的,当执行流切换的时候,会将寄存器内的数据(上下文数据)一并带走;

  • 加锁和解锁的汇编代码:(伪代码)
    Linux知识点 -- Linux多线程(二)_第8张图片
    核心的语句就是下面这句:
    在这里插入图片描述
    将寄存器的内容和锁的内容交换,这是一行汇编代码,是原子的;
    多线程申请锁的可能的情况:
    Linux知识点 -- Linux多线程(二)_第9张图片
    内存mutex中的1只能被一个线程交换,如果A线程已经执行了这一条指令,将al寄存器的值(0)和mutex的值(1)交换;
    交换完成后,mutex的值就变为了0,相当于锁已经被A线程拿走了,此时线程A被切换了,连带着寄存器al中的值一起带走;
    当另一个线程B来的时候,内存mutex中这个1已经被上一个线程交换了;
    现在mutex中的值是0,第二个线程交换完后将0交换到了寄存器al中,因此只能等待;

    释放锁就是再将线程寄存器al的内容和内存mutex的内容再交换回来;

二、线程安全

1.可重入与线程安全

  • 可重入:
    在这里插入图片描述
    可重入是针对函数而言的;
  • 线程安全:
    在这里插入图片描述
    线程安全是用来描述线程的;

2.常见情况

  • 线程不安全:
    在这里插入图片描述

  • 线程安全:
    在这里插入图片描述

  • 不可重入:
    在这里插入图片描述

  • 可重入:
    Linux知识点 -- Linux多线程(二)_第10张图片

3.可重入与线程安全的联系

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

三、死锁

1.死锁概念

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

  • 两个线程同时申请对方已有的锁,形成互相申请对方资源的一种环路情况;

同一个线程反复申请同一把锁,也会造成死锁:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include

using namespace std;

int tickets = 1000;

#define THREAD_NUM 5

class ThreadData
{
public:
    ThreadData(const string &n, pthread_mutex_t *pm)
        : tname(n),
          pmtx(pm)
    {}

public:
    string tname;//线程名
    pthread_mutex_t *pmtx;//锁
};

void *getTickets(void *args)
{
    ThreadData* td = (ThreadData*)args;//接收对象
    while (true)
    {
        int n = pthread_mutex_lock(td->pmtx);//为临界区代码加锁
        assert(n == 0);
        if(tickets > 0)
        {
            usleep(rand()%1500);
            printf("%s: %d\n", td->tname.c_str(), tickets);
            tickets--;
            int n = pthread_mutex_lock(td->pmtx);//听一个进程反复申请同一把锁,也会造成死锁
            assert(n == 0);
        }
        else
        {
            int n = pthread_mutex_lock(td->pmtx);
            assert(n == 0);
            break;
        }
        usleep(rand()%2000);
    }
    delete td;
    return nullptr;
}

int main()
{
    pthread_mutex_t mtx;
    pthread_mutex_init(&mtx, nullptr);//局部锁初始化
    srand((unsigned long)time(nullptr) ^ getpid());
    pthread_t t[THREAD_NUM];
    //多线程抢票逻辑
    for(int i = 0; i < THREAD_NUM; i++)
    {
        string name = "thread ";
        name += to_string(i + 1);

        ThreadData *td = new ThreadData(name, &mtx);//创建对象
        pthread_create(t + i, nullptr, getTickets, (void*)td);//创建线程的时候,穿的参数也可以是对象指针
    }

    for(int i = 0; i < THREAD_NUM; i++)
    {
        pthread_join(t[i], nullptr);
    }

    pthread_mutex_destroy(&mtx);//局部锁的销毁

    return 0;
}

运行结果:
Linux知识点 -- Linux多线程(二)_第11张图片
该线程运行了一次就卡住不动了,进入了死锁状态;

2.死锁的条件

  • 死锁的四个必要条件(全部满足即造成死锁):
    (1)互斥条件:一个资源每次只能被一个执行流使用;
    (2)请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放;
    (3)不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺;
    (4)循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系;

3.避免死锁的方法

(1)破坏死锁的四个必要条件的其中一个;

  • 互斥:可不可以不加锁;
  • 请求与保持:申请锁时可以使用trylock,如果锁被占有,就返回错误码,连续申请若干次,不成功,就把自己的锁释放掉,不会导致线程阻塞;

(2)加锁顺序一致;
(3)避免锁未释放的场景;
(4)资源一次性分配;

你可能感兴趣的:(Linux,linux,运维,服务器)