Linux:详解多线程(线程安全互斥和死锁)

Posted It‘s so simple

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux:详解多线程(线程安全互斥和死锁)相关的知识,希望对你有一定的参考价值。


1. 线程安全(面试热点)

1.1 线程安全的定义

在多个执行流访问同一临界资源的时候,不会导致程序结果产生二义性,即就是线程安全。

在这里,我们首先需要知道以下几个名词的含义:

  • 执行流:这里的执行流可以理解为线程,即一个执行流就是一个线程的运行。
  • 访问:指的是对临界资源的操作。
  • 临界资源:多个线程都可以访问到得资源(例如:全局变量、某个结构体变量、某个类的实例化指针)。
  • 临界区:代码操作临界资源的代码区域称为临界区。
  • 二义性:程序结果会产生多个。

1.2 对正常变量进行操作的原理

根据冯诺伊曼体系结构,我们可以得知要对一个正常的变量进行操作的步骤是:

① 寄存器首先从内存中获取变量的值,
② 然后再将获取到的值传入CPU进行运算,
③ CPU对该变量进行相应的操作之后,再其结果返回给寄存器,
④ 再由寄存器将相应的结果返回给内存。

如图所示:
在这里插入图片描述

1.3 描述线程不安全的现象(重点)

① 假设场景

假设当前程序有一个线程A和一个线程B,以及全局变量 i ,并且 i 的值是10,现在线程A要对i进行++操作,线程B要对 i 进行--操作。

② 线程A和线程B的描述

首先是线程A,线程A先从内存中拿到i = 10的值,然后再将其交给寄存器准备传入CPU进行运算,假设当寄存器正准备将i的值传入CPU时,线程A的时间片耗尽了,需要进行线程的切换这时候线程A的PCB中上下文信息会保存寄存器中的值,即i = 10,程序计数器会保存下一条要运行的指令,然后线程A被切换出去。

当线程A被切换出去的时候,线程B开始运行,线程B也是先从内存中拿到i = 10的值,然后将其交给寄存器,并由寄存器将其传入CPU进行运算(--操作),CPU运算完成之后,会将结果返回寄存器,此时寄存器拿到i的值就为 9最终再写回内存中i的值就为9。我们假设在线程B运行的的时候,是没有发生线程切换的,对i的操作是在一个时间片内完成的。

当线程B运行完之后,线程A会再次被切换进来继续它的操作,此时线程A的PCB中的上下文信息会恢复线程A切换出去之前的现场,程序计数器会指明下一条要运行的指令,也就是说,此时Q 的寄存器并不会再次从内存中读值,而是直接从上下文信息中读取i的值,因此此时寄存器中i的值为10,交给CPU进行运算(++操作),CPU运算之后,再将结果返回给寄存器,寄存器再返回给内存,此时内存中i的值就为11。

最终的结果就是在经过线程A操作(++操作)和线程B操作(--操作)后,i的值变成了11;而并非是按正常逻辑来讲的对i进行++--操作后,i的值不变,依然为10,这就导致了程序结果产生了二义性,造成了线程不安全

③ 总结

造成这样的情况产生的根本原因就是多个执行流对同一临界资源进行操作,并且导致程序结果产生了二义性,发生了线程不安全的现象。

1.4 线程不安全的代码模拟(抢票系统的模拟)

现在我们来实现一个抢票程序的基本功能,抢票,我们可以创建出两个线程,并将票数设置为全局变量,在两个线程入口函数中分别对该票数进行减减操作(表示抢到票了),由此我们观察程序运行所产生的现象。

代码如下:

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

#define THREADNUM 2

int ticket = 100000;

void* threadEntry(void* arg)
{
    while(1)
    {
        if(ticket > 0)
        {
            printf("i am Thread %p, i have tecket ID is %d\\n",pthread_self(),ticket);
            --ticket;
        }
        else
        {
            pthread_exit(NULL);
        }
    }
    return NULL;
}

int main()
{
    pthread_t tid[THREADNUM];

    for(int i = 0; i < THREADNUM; ++i)
    {
        int ret = pthread_create(&tid[i],NULL,threadEntry,NULL);
        if(ret < 0)
        {
            perror("pthread_create");
            return 0;
        }
    }

    for(int i = 0;i < THREADNUM; ++i)
    {
        int ret = pthread_join(tid[i],NULL);
        if(ret < 0)
        {
            perror("pthread_join");
            return 0;
        }
    }

    puts("thread join end.....");
    return 0;
}

部分运行结果如下:

由于打印结果过多,这里使用>符号将结果重定向到一个文件中。
在这里插入图片描述
在这里插入图片描述
不难发现,第38548张票被同时抢了2次。这就造成了我们上面所说的多个执行流对同一临界资源进行访问操作,导致了程序结果产生了二义性,造成了线程的不安全。

为了验证这里的抢票不仅仅是对同一张票抢了两次,而是又可能抢了多次,这里我们将该文件放入NotePad++中,对其进行计数。

在这里插入图片描述
发现第3356张票既然被同时抢了20次!!,想想如果放在现实生活中该是多么恐怖的事情。并且,当线程越多,CPU越多的时候,这种效果就越明显,因为线程在CPU中是并行的运行的。

那么,我们该如何解决这种情况的发生呢?如何保证以下几点:

  • 线程在获取票的时候,多个线程没有拿到同一张票的情况,并且没有线程拿到的负数的票。
  • 线程都能够合理的去获取到票。

接下来所说的互斥就能够解决这种情况的产生。

2. 互斥

2.1 互斥锁的原理

互斥锁的底层是一个互斥量,而互斥量的本质是一个计数器。计数器的取值只有两种,一种是1,一种是0。

  • 1:表示当前临界资源是可以被访问的
  • 0:表示当前临界资源是不可以被访问的

当我们的线程要访问临界资源时,需要先访问互斥锁,当互斥锁中的取值为1的时候,该线程就可对该临界资源进行访问,并将其互斥锁中的取值从1变为0,使其它线程不能够访问该临界资源,也就是所谓的加锁。

在这里插入图片描述

获取/释放互斥锁的逻辑:

  • 调用加锁接口,加锁接口内部判断计数器的值是否为1,如果为1,则表示能够访问,并且当加锁成功之后,计数器的值就会从1变为0,如果为0,则表示无法访问。
  • 调用解锁逻辑,计数器的值从1变为0,表示资源可用。

那么问题来了,加锁操作其实也是对临界资源进行了访问,那么它是如何保证加锁这个操作是原子性的呢?

答案是交换,是将我们寄存器中的值和内存中的值进行交换,不管寄存器现在的值为多少,先将寄存器的值初始化为0,再和内存进行交换。因此每当线程想要访问临界区资源的时候,都会先判断寄存器中的值是否为1,如果为1,则表示能够加锁,否则就不可以。这里的交换操作就是一条汇编指令,表明了它就是一个原子性的操作

对应汇编指令如下:
在这里插入图片描述
其中交换的操作就发生在xchgb一行。

2.2 互斥锁的接口

首先我们需要知道互斥锁的类型为pthread_mutex_t,我们可以在源码中查看它的定义。

在这里插入图片描述
它是一个联合体类型。

2.2.1 初始化互斥锁变量

初始化互斥锁变量分为静态初始化和动态初始化。
我们可以通过man pthread_mutex_init来查看相关的静态初始化和动态初始化

① 静态初始化:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITALIZER

这里的PTHREAD_MUTEX_INITALIZER是一个宏定义,如下:
在这里插入图片描述
静态初始化的好处就是用完之后不需要去主动的释放空间,操作系统会自动回收。

② 动态初始化:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, \\
const pthread_mutexattr_t *restrict attr);

参数

  • mutex:该参数为一个出参,由调用者传递一个互斥锁变量的地址,由pthread_mutex_init函数进行初始化。
  • attr:互斥锁的属性信息,一般设置为NULL,采用默认属性。

注意:

动态初始化互斥锁变量需要动态的销毁互斥锁,否则就会造成内存泄漏。

2.2.2 加锁接口

互斥锁的加锁接口有三个:阻塞加锁接口非阻塞加锁接口带有超时时间的加锁接口

① 阻塞加锁接口

int pthread_mutex_lock(pthread_mutex_t *mutex);

参数:

  • mutex:待要加锁的互斥锁变量

返回值:

  • 0 :加锁成功
  • <0:加锁失败

注意:

  • 如果互斥锁变量当中计数器的值为1,调用该接口,则加锁成功,该接口调用完毕,函数返回。
  • 如果互斥锁变量当中计数器的值为0,调用该接口,则调用该接口的执行流就会进入阻塞状态。

② 非阻塞加锁接口

int pthread_mutex_trylock(pthread_mutex_t *mutex);

参数:

  • mutex:待要加锁的互斥锁变量

返回值:

  • 0 :加锁成功
  • <0:加锁失败

注意:

不管有没有加锁成功,都会返回,所以需要对加锁结果进行判断,如果成功,则操作临界资源,反之则需要循环的获取互斥锁,直到拿到该互斥锁为止。

③ 带有超时时间的加锁接口

int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex, \\
const struct timespec *restrict abs_timeout);

参数:

  • mutex:待要加锁的互斥锁变量
  • abs_timeout:是一个struct timespec结构体的变量,可以用来记录当前线程的运行时间。

struct timespec结构体

typedef long time_t;
#ifndef _TIMESPEC
#define _TIMESPEC
struct timespec {
time_t tv_sec; // seconds
long tv_nsec; // and nanoseconds
};

struct timespec有两个成员,一个是秒,一个是纳秒, 所以最高精确度是纳秒。

返回值:

  • 0 :加锁成功
  • <0:加锁失败

注意:

  • 超时时间内,如果还没有获取到互斥锁,则返回
  • 超时时间内,如果获取到了互斥锁,也是直接返回

2.2.3 解锁接口

int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数:

  • mutex:待要解锁的互斥锁变量

返回值:

  • 0 :加锁成功
  • <0:加锁失败

注意:

不管是阻塞加锁接口、非阻塞加锁接口还是带有超时时间的加锁成功的互斥锁,都可以使用该接口。

2.2.4 销毁接口

int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数:

  • mutex:待要销毁的互斥锁变量

返回值:

  • 0 :加锁成功
  • <0:加锁失败

2.3 互斥锁的代码验证(解决线程不安全的实战)

下面我们就使用上面的互斥锁的接口来解决第一节中抢票程序所造成的线程不安全问题。

在写代码之前,我们需要首先明确以下几点:

① 什么时候要初始化互斥锁?

解答:要在创建工作线程之前

② 什么时候进行加锁?

解答:要在执行流访问临界资源之前进行加锁,需要注意的是,如果一个执行流加锁成功之后,再去获取该互斥锁,该执行流会依旧被阻塞掉;并且在加锁之后,一定要记得解锁,否则就会导致死锁。

③ 什么时候进行解锁?

解答:要在执行流所有可能退出的地方进行解锁

④ 什么时候释放互斥锁资源?

解答:在所有使用该互斥锁的线程退出之后,就可以释放该互斥锁了

我们现在再来整理一下这个代码的逻辑,我们现在只需要在访问全局变量ticket的时候,对其进行加锁操作,然后在使用完之后,再对该变量进行解锁操作即可。

代码如下:

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

#define THREADNUM 2

int ticket = 10;
//注意互斥锁变量的定义一定要在全局中定义
pthread_mutex_t mutex;

void* threadEntry(void* arg)
{
    while(1)
    {
        //在对临界资源进行操作之前进行加锁
        pthread_mutex_lock(&mutex);
        if(ticket > 0)
        {
            printf("i am Thread %p, i have tecket ID is %d\\n",pthread_self(),ticket);
            --ticket;
        }
        else
        {
            //注意一定要在线程所有可能退出的位置进行解锁
            //如果在该位置不解锁,那么就有可能该工作线程带着这把锁退出了,
            //而其他线程会一直处于等待拿锁的时刻,这样就造成了死锁
            pthread_mutex_unlock(&mutex);
            pthread_exit(NULL);
        }
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

int main()
{
    pthread_t tid[THREADNUM];
    //在工作线程之前动态的创建互斥锁
    pthread_mutex_init(&mutex,NULL);

    for(int i = 0; i < THREADNUM; ++i)
    {
        int ret = pthread_create(&tid[i],NULL,threadEntry,NULL);
        if(ret < 0)
        {
            perror("pthread_create");
            return 0;
        }
    }

    for(int i = 0;i < THREADNUM; ++i)
    {
        int ret = pthread_join(tid[i],NULL);
        if(ret < 0)
        {
            perror("pthread_join");
            return 0;
        }
    }

    //在所有线程都退出之后释放互斥锁
    pthread_mutex_destroy(&mutex);

    puts("thread join end.....");
    return 0;
}

运行结果:

在这里插入图片描述
先不说别的,就但从现象来说,至少是没有出现不同进程抢到同一张票的情况出现了。但是该程序还是有问题的,就从结果来看,所有的票数都是由一个进程抢到的,并没有实现我们所希望的各个进程并发的进行抢票,那这个就牵扯到同步的问题了,我会在下一篇进行详细的讲解。

2.4 扩展(使用gdb对多线程代码进行调试)

  • 当代码正在运行的时候,我们可以使用gdb attach [PID]的语句对其进行调试。
  • 查看当前程序所有的调用堆栈信息,使用b t命令。
  • 将线程应用到所有的调用堆栈当中,使用thread apply all bt
  • 跳转到某一个线程的编号,使用t [线程编号]命令。
  • 跳转到具体的某个堆栈当中,使用f [堆栈编号]命令。

扩展:libpthread.so.o是系统线程库,是release版本,是没有办法进行调试的。

3. 死锁

3.1 死锁的定义

简单的定义:

当一个执行流获取到互斥锁之后,并没有进行解锁,就会导致其他执行流由于获取不到锁的资源而进行阻塞,我们将这种现象称为死锁。

复杂的定义:

当线程A获取到互斥锁1,线程B获取到互斥锁2的时候,线程A和线程B还想获取对方手中的锁,即线程A还想获取互斥锁2,线程B还想获取互斥锁1,这样就会造成死锁。
如图:
在这里插入图片描述

3.2 死锁的必要条件

① 不可剥夺:执行流获取到互斥锁之后,除了自己主动释放锁,其他执行流不能解锁该互斥锁。

② 循环等待:就是上图中所显示的情况,线程A和线程B一直在循环的等待对方的锁解锁。

③ 互斥条件:一个互斥锁只能被一个执行流在同一时刻拥有。

④ 请求与保持:就是"吃着碗里的,看着锅里的"

需要注意的是,第①和③条件是不可修改的,这是实现互斥所必须拥有的条件

3.3 预防死锁

  • 破坏必要条件:即破坏上面所说的第②和④的情况即可。
  • 加锁顺序一致:就是线程A和线程B都要先对互斥锁1进行加锁,在对互斥锁2进行加锁。
  • 避免锁没有被释放:在书写代码时,一定要注意解锁的位置,避免发生锁没有被释放。
  • 资源一次性分配:就是说一旦进行加锁,则就将所有的资源全部分配给该线程。

3.4 避免死锁算法

3.4.1 死锁检测算法

在这里插入图片描述

本图节选自这篇文章,要是想查看具体的实践,可以去看该文章。

3.4.2 银行家算法

在这里插入图片描述
在这里插入图片描述

本图节选自这篇文章,要是想查看具体的实践及具体的算法,可以去看该文章。

以上是关于Linux:详解多线程(线程安全互斥和死锁)的主要内容,如果未能解决你的问题,请参考以下文章

[linux] linux多线程详解

线程同步与互斥详解

Linux多线程——互斥和同步

Linux多线程

linux--线程

Linux线程安全