Linux学习之路 -- 线程 -- 死锁及线程安全相关问题

在上文中,我们已经介绍了线程池的编写,下面补充一下线程的相关知识。

目录

1、线程安全与可重入

概念

区别联系

常见线程不安全的情况

常见的不可重入情况

2、死锁问题

死锁概念

死锁四个必要条件

解决办法

检测与避免死锁

3、其他类型锁的介绍

悲观锁

乐观锁

 自旋锁

引例

概念及使用

4、读者写者问题


1、线程安全与可重入

<1>概念

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

重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。(一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数)

<2>区别联系

线程安全是线程在执行中的相互关系,而可重入是函数的一种特点。引起线程安全问题的方法有很多,不可重入是其中的一种。只要是不可重入的函数,一般就是线程安全的。

<3>常见线程不安全的情况

1、不保护共享变量的函数
2、函数状态随着被调用,状态发生变化的函数
3、返回指向静态变量指针的函数
4、调用线程不安全函数的函数

<4>常见的不可重入情况

1、调用了malloc/free函数,因为maloc函数是用全局链表来管理堆的
2、调用了标准I/0库函数,标准I/0库的很多实现都以不可重入的方式使用全局数据结构
3、可重入函数体内使用了静态的数据结构

2、死锁问题

<1>死锁概念

死锁其实就是当多线程在执行同一段代码时,对锁的不合理使用导致每个线程都无法获取锁,从而使得线程处于一种永久阻塞的状态。(如下图)

<2>死锁四个必要条件

1、互斥条件:一个资源每次只能被一个执行流使用
        这个条件简单来说就是要死锁,首先一定要有锁。
2、请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
        这个条件简单来说,就是一个线程持有了一个锁,还想要另一个锁,同时自己又不愿意放弃自己持有的锁。
3、不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺
        这个条件其实就是当一个线程持有锁时,没有用完前,不能强行剥夺锁。
4、循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
        这个条件会稍微抽象一点,不过也好理解。就是线程A持有锁1,线程B持有锁2,在此基础上线程A等待锁2,线程B等待锁1.

<3>解决办法

既然我们已经知道了死锁产生的四个必要条件,那么要避免死锁的产生,只要破坏其中一个条件即可。
        1、互斥条件:要破坏该条件其实就是尽量不使用锁,这样肯定就不会有死锁问题。
        2、请求与保持条件:要破坏该条件,可以让一个线程持有一把锁的情况下(线程需要两把锁),如果继续申请锁失败就直接释放原来的锁,重新取竞争两把锁,也就是放弃保持条件。
        3、不剥夺条件:该条件其实不建议破坏,因为一旦破坏可能造成许多问题。如果实在想要破坏,就在申请锁失败后,强行释放掉该锁
        4、循环等待条件:该条件的破坏其实是最容易的,我们只需要让不同线程在竞争锁时同步即可,也就是多个线程在一定时间段内只竞争同一把锁。

<4>检测与避免死锁

在linux中我们可以使用pstack命令对线程进行查看,该命令显示每个线程的栈跟踪信息(函数调用过程)。该命令的使用方式也很简单,只要pstack + pid即可。在检测死锁问题时,我们可以多次调用pstack命令,多次对比,看看有哪些线程是在多次等锁,而且一直没有什么变换,那么这大概率就是死锁问题导致的。

其中还有避免死锁的检测算法,比较著名的就是银行家算法,该算法在许多博客上都有介绍,这里就不再赘述,当然也还有其他的死锁避免的办法,这里就不详细地介绍了。

3、其他类型锁的介绍

<1>悲观锁

概念: 悲观锁假设在数据被访问的时候,极有可能有其他事务同时修改数据,因此在整个数据处理过程中,将数据锁定,直到事务完成。

特点:

  • 保守:它总是假设最坏的情况,认为每次访问数据时都会产生冲突。
  • 阻塞:如果一个事务持有了锁,其他尝试获取锁的事务将会被阻塞,直到锁被释放。
  • 重试:被阻塞的事务在锁释放后需要重新尝试获取锁。

<2>乐观锁

概念: 乐观锁假设在数据被访问的时候,不会有其他事务同时修改数据,因此在数据修改时并不进行锁定,而是在更新数据的时候检查是否有其他事务同时修改了数据。

特点:

  • 乐观:它假设最好的情况,认为在数据被访问期间不会发生冲突。
  • 非阻塞:事务在读取数据时不会锁定资源,只有在更新数据时才会检查冲突。
  • 冲突检测:在更新数据时,通过版本号、时间戳或者业务逻辑来检测是否有其他事务同时修改了数据。

乐观锁与悲观锁涉及数据库的知识,这里就不详细介绍,简单了解即可。

 <3>自旋锁

在了解自旋锁之前,我们需要先理解自旋的概念,下面用一个例子来帮助理解自旋。

<1>引例

现在有一对好朋友,他们分别叫张三和李四,其中张三是个学渣,而李四是个学霸。到了期末前一天,张三找到李四复习高数。当张三来到李四的宿舍楼楼下,给李四打了个电话,叫李四下来。李四回复说:“我也还在复习,估计还要一两个小时,等我复习完就下来找你”。张三一听李四还要那么久,就决定先到网吧玩一阵子再回来。一个多小时后,李四告诉张三可以过去找他了,此时张三就离开网吧去找李四复习了。

考完高数后,张三又打听到明天要考离散,于是又去找李四复习。当张三来到李四的宿舍楼楼下,给李四打了个电话,叫李四下来。李四回复说:”离散我复习完了,我马上就下来找你“。张三一听李四马上就下来,张三也就并没有去网吧玩了,而是等待李四下来。在李四下来之前,张三反复给李四打电话,确认李四到哪里了。过了一会后,李四下楼后找到了张三,李四和张三一起学习去了。

在上面两个例子中,其实张三就是线程,而李四就是一把锁(资源),网吧就类似于条件变量。在第二个例子中,张三不断打电话向李四确定的过程就是自旋。而在这个过程中造成张三等待方式的差异的原因,其实就是李四(资源)准备就绪的时间。

其实在线程等待锁的过程就是像上述的过程一样,如果时间比较长,线程会被阻塞挂起,等待临界区执行完并释放锁后,再被唤醒去竞争锁。而如果等待的时间比较短,那么线程就不必阻塞挂起,而是一直检测锁是否就绪。

<2>概念及使用

概念:自旋锁是一种让线程在获取锁时不断循环检查锁状态的锁机制,而不是进入休眠状态。

接口:

头文件均为pthread.h 

使用方式和pthread_mutex_t 基本是一致的,区别不大。不过这里需要介绍一下pthread_spin_init接口的pshared参数,这个参数主要用来表示自旋锁是否共享,一般又以下两个选项

  1. PTHREAD_PROCESS_PRIVATE:

    • 当 pshared 设置为 PTHREAD_PROCESS_PRIVATE 时,锁只能被创建它的进程中的线程访问。
    • 这意味着锁是私有的,不能在进程间共享。
    • 这是 pshared 参数的默认值。
  2. PTHREAD_PROCESS_SHARED:

    • 当 pshared 设置为 PTHREAD_PROCESS_SHARED 时,锁可以被多个进程中的线程访问,只要这些进程可以访问到锁所在的内存区域。
    • 这通常意味着锁存储在共享内存区域中。
    • 使用这个值时,需要确保所有访问共享锁的进程都有正确的访问权限。

4、读者写者问题

相较于生产者消费者模型,这个模型在我们日常生活中反而是最常见的,比如我们写文章,发视频等。

这个模型也和生产消费模型一样,具有两个对象,一个交易场所和三种关系。其中两个对象就是读者和写着,三种关系就是写者与读者,写者与写者,读者与读者的关系。写者和写者之间需要保持互斥的关系,读者和写者之间需要保证同步与互斥的关系,读者和读者之间没有关系。因为读者仅仅只是读数据,不对数据进行修改,所以读者之间没有什么关系,这也是和生产消费模型不同的点。

加锁逻辑(伪代码)

相关的接口

上述的上锁逻辑,OS已经帮助我们实现了,我们只需要使用上述的接口,就可以达到上述伪代码的效果。上述各种接口的逻辑和我们之前介绍的互斥锁几乎一致,所以这里就不详细介绍了。需要注意的是,rdlock对应的就是上述伪代码中的读者逻辑,wrlock对应的就是上述伪代码中写者逻辑。

读者优先

如果当前我们读者和写者一起来的时候,并且读者不断到来,读者也没有退完。此时,肯定是让读者先访问共享的资源。

写者优先

读者在写者来临之前可以正常访问资源,一旦写者来了,后续的读者就不能进入了。这个做法比较复杂,不过也是可以通过读写锁实现的,这里不作介绍。

以上就是所有内容,如果想要深入了解文中的内容,可以依据文中介绍的点自行搜索。文中如有不对支持,还望各位大佬指正,谢谢!!!

上一篇:【Java】—— 泛型:泛型的理解及其在集合(List,Set)、比较器(Comparator)中的使用