Linux多线程学习(2)--线程的同步与互斥及死锁问题(互斥量和条件变量)

版权声明:本文为博主原创文章,欢迎转载,转载请声明出处! https://blog.csdn.net/hansionz/article/details/84675536

一.互斥量

  • 大部分情况,线程使用的数据都属于局部变量局部变量存储在线程的栈帧中,这种变量属于单个线程,其他线程无法获得者种变量
  • 有些情况,一些变量需要在线程间共享,这样的变量称为共享变量(一般指全局变量),可以通过数据之间的共享来实现线程之间的交互
  • 多个线程并发的操作共享变量,一定会导致问题的,互斥量就是为了解决这种问题

1.名词理解

  • 临界资源:多线程执行共享的资源就叫做临界资源
  • 临界区:每个线程内部访问临界资源的代码叫做临界区
  • 互斥:任何时候,互斥保证有且只有一个执行流进入临界区,访问临界资源,互斥量通常是对临界资源起到保护作用
  • 同步:同步是在互斥的基础上,按照某种特定的次序去访问临界资源
  • 原子性:一个操作只有两种状态,要么完成,要么没有完成

2.什么是互斥量(mutex)

我们可以以一个多线程实现的简单售票系统来说明互斥量是什么:

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

using namespace std;

int ticket = 100;
void *route(void* arg)
{
  char* name = (char*)arg;
  while(1)
  {
    if(ticket > 0)
    {
      usleep(1000);
      //没有对临界资源加锁,会产生问题
      cout << name << " buy ticket:" << ticket << endl;
      --ticket;
    }
    else 
    {
      break;
    }
  }
}

int main()
{
  pthread_t t1,t2,t3,t4;

  pthread_create(&t1, NULL, route, (void*)"thread 1 ");
  pthread_create(&t2, NULL, route, (void*)"thread 2 ");
  pthread_create(&t3, NULL, route, (void*)"thread 3 ");
  pthread_create(&t4, NULL, route, (void*)"thread 4 ");
  
  pthread_join(t1, NULL);
  pthread_join(t2, NULL);
  pthread_join(t3, NULL);
  pthread_join(t4, NULL);

  return 0;
}

下边为运行结果:
在这里插入图片描述

可以发现票数竟然出现负数了,这个售票系统肯定是存在问题的,原因在与我们并没有将临界资源ticket保护起来,假设当ticket=1时,进程1判断条件成立进入if中,但是还没有执行--ticket的时候,它的时间片到了,OS切换到下一个线程,此时ticket依然等于1,该线程依然会进入到if条件中,此时就会导致问题了,本来上一个线程在运行时就没有票了,但是这个线程拿到却依然有票。这个问题我们就可以通过加上互斥量来解决,将临界资源保护起来。

出现错误的原因在于:

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • --ticket根本就不是一个原子性操作,站在汇编的角度去看这个操作,其实对于的是三条汇编:load(将ticket从内存加载到寄存器)、updata(更新寄存器中的值,执行-1操作)、store(将新值从寄存器写回共享变量ticket的内存地址中)

解决该错误的方法:

  • 执行--ticket这个非原子性的操作时必须要有互斥行为,当一个线程进入临界区时,不允许其他线程进入
  • 如果该线程没有在临界区中执行,那么该线程不能阻止其他线程进入临界区
  • 多个线程同时要执行临界区中的代码,并且临界期没有线程正在执行,那么只允许一个线程进入该临界区

上述的三点其实就是互斥量。互斥量的本质其实是一把锁,也叫做互斥锁,也可以理解为一个二元信号量。互斥量是最基本的同步形式,它用来保护临界区资源,以保证任何时刻只有一个线程在执行其中的代码

//上锁
pthread_mutex_lock()
...
//临界区(只允许有一个线程执行)
...
//解锁
pthread_mutex_unlock()

//非临界区,可以允许多个线程同时执行

3.互斥量的接口

3.1 初始化信号量

Posix互斥锁被声明为pthread_t类型的变量。初始化互斥锁:

  • 静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER 
  • 动态分配在共享内存
 int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr _t *restrict attr);   
参数:       
   mutex:要初始化的互斥量        
   attr:属性,先设置为NULL

3.2 销毁信号量

int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:mutex销毁哪一个信号量

销毁信号量时要注意:

  • 使用PTHREAD_ MUTEX_ INITIALIZER初始化的互斥量不需要销毁
  • 不能销毁一个已经加锁互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁

3.3 互斥量加锁和解锁

  • 加锁
int pthread_mutex_lock(pthread_mutex_t *mutex); 
返回值:成功返回0,失败返回错误码

加锁时需要注意: 如果互斥量处于未锁状态,lock函数会将该互斥量锁定,同时返回成功。如果其他线程已经锁定互斥量,或者存在其他线程时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞,等待互斥量解锁

  • 解锁
//解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); 
返回值:成功返回0,失败返回错误码

我们可以根据上边学习到的互斥量对售票系统进行修改,在临界区加上互斥锁,以保护临界资源,不被多个线程重入

修改代码位于我的github:https://github.com/hansionz/Linux_Code/tree/master/pthread/ticket

二.条件变量

当一个线程互斥的访问某个变量时,它可能发现在其他线程改变状态之前,它什么也做不了。例如,一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中,这种情况就需要用到条件变量。

1.什么是条件变量

条件变量使我们可以睡眠等待某种条件出现,条件变量是利用线程间共享的全局变量进行同步的一种机制。主要包括两个动作:一个线程等待"条件变量的条件成立"而挂起;另一个线程使"条件成立"(给出条件成立信号)。为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起。

2.条件变量接口

2.1 初始化

条件变量其实是以pthread_cond_t为类型的变量,

int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *rest rict attr); 
参数:    
	cond:要初始化的条件变量    
	attr:NULL(属性)

2.2 销毁条件变量

只要初始化了条件变量,就必须得销毁

int pthread_cond_destroy(pthread_cond_t *cond)
参数:
	cond:表示要初始化的条件变量

2.3 等待条件满足

2.3.1 函数说明

此操作对应概念中的一个线程为等待条件变量的条件成立而挂起

int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mute x); 
参数:    
	cond:要在这个条件变量上等待    
	mutex:互斥量

2.3.2 为什么pthread_cond_wait需要互斥量

  • pthread_cond_wait的功能包括两步解锁和挂起等待既然涉及到解锁,那我们就必须要存在互斥量对其操作
  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据

对于pthread_cond_wait的两个操作是否是必须的呢?这两个操作是否可以分离开来操作:我们可以想到先上锁,发现条件不满足,解锁,然后在条件变量下等待,这样是否可行?

lock();
while(条件为假){
	unlock();
	///在解锁之后,等待之前,条件可能已经满⾜足,信号已经发出,但是该信号可能被错过
	wait();
	lock();
}
unlock();
  • 由于解锁和等待不是原子操作。调用解锁之后,pthread_ cond_ wait之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么pthread_ cond_ wait错过这个信号,可能会导致线程永远阻塞在这个pthread_ cond_ wait所以解锁和等待必须是一个原子操作。

2.4 唤醒等待

此操作对应概念中的一个线程使得条件成立(给出条件成立的信号)

//广播唤醒在该条件变量等待下的所有线程
int pthread_cond_broadcast(pthread_cond_t *cond);   
/唤醒在该条件变量下等待的一个线程(队列的第一个)
int pthread_cond_signal(pthread_cond_t *cond);

2.5 条件变量使用规范

  • 标准的等待条件代码应该这么写:
pthread_mutex_lock();
while(条件为假)
	pthread_cond_wait();
	//条件成立则返回
	//新的线程被唤醒会自动的重新申请锁
修改条件
pthread_cond_unlock();

对于上边的条件判断为什么要使用while而不是if呢?

上边使用while而不是if的原因在与防止假唤醒假唤醒是指在多核处理器上,pthread_cond_signal函数不仅仅只会唤醒一个线程,而是可能会唤醒多个线程,在唤醒的这多个线程中,可能只有1个是满足条件的。所以我们需要在pthread_cond_wait函数返回后再次判断是否满足条件,如果使用if判断,不管第一个被唤醒的线程是否满足要求,就直接向下执行就会导致问题;如果采用while判断,如果第一个条件为假,则继续轮询判断,直到条件为,才跳出循环,继续执行后续代码。

  • 设置条件为真,并发送信号给在该条件变量下等待的线程
pthread_mutex_lock(&mutex);    
//设置条件为真    
pthread_cond_signal(cond);    
pthread_mutex_unlock(&mutex);

设置条件为真为什么要加锁: 因为条件变量是利用线程之间的全局变量进行同步的机制,要设置条件变量,就说明要对共享的全局变量进行改变,如果不加锁,可能会导致一些线程安全的问题。

三.死锁问题

1. 什么是死锁问题?

如果一个线程试图对同一个互斥量加锁两次,自身就会陷入死锁状态。当该线程第一次去向互斥量加锁时,由于该互斥量上并没有锁,所以可以加锁成功,但是该线程第二次去向互斥量加锁,由于该互斥量上已经加过锁了,所以会把自身挂起阻塞,直到该锁被释放,但是自己又被挂起了,所以不会有人去释放的,这就造成了死锁问题。

当一个线程去申请一个已经被持有,但是还没有释放的互斥量时,线程将会被阻塞,直到该互斥量被释放。如果该互斥量不被释放,该线程将会被一直阻塞。死锁就是,一个线程阻塞的等待一个永远不会为真的条件

2. 产生死锁的几个常见场景

  • 假设程序中现在有一个互斥量,然后一个线程对该互斥量已经加锁,但是在加锁和解锁的这段代码中,如果该区域代码又试图向该互斥量申请锁,那么就会造成自身挂起等待,从而导致死锁

  • 假设程序中使用两个互斥量线程A首先锁住一个互斥量,然后线程B也锁住另外一个互斥量,拥有第一个互斥量线程A又去试图锁住第二个互斥量,而拥有第二个互斥量线程B试图申请锁住第一个互斥量,这就会导致两个线程此时都在挂起堵塞中,两个线程都在相互请求另一个线程的资源导致两个线程都无法向前运行,于是产生了死锁问题

3.死锁产生的四个必要条件

上边的两种产生死锁的场景是在互斥量的条件下,但是这造成死锁的场景,并不局限于互斥量,只要满足产生死锁的条件,就会出现死锁。针对死锁的概念,大牛们总结出来了四条产生死锁的必要条件

  • 互斥条件

互斥条件与锁一样,要么能被申请,要么就只能等待。在任意时刻,某份资源只能被一个进程或线程使用。

  • 占有和等待条件

占有和等待条件是指某个线程或进程,在占有某份资源后还可以申请其他的资源。

  • 不可抢占条件

当某份资源被某一进程或线程占有时,不能被其他线程或进程强制性的抢占,只能被占有它的线程主动的释放。

  • 环路等待条件

死锁发生时,系统中一定有两个或两个以上的进程组成的一条环路,该环路中的每一个线程或进程都在等待下一个进程所占用的资源。

以上的四个条件必须同时满足,才会可能造成死锁。只要有一个条件不满足,就不会造成死锁死锁的产生并不仅会只有使用互斥量时会发生,只要满足以上四个条件也可能产生死锁。在系统中,有许多只能被互斥性访问的独占资源,如请求独占性的io设备,打印机等,在对其进行操作时,也有可能造成死锁

4. 处理死锁的四种策略

  • 忽略死锁问题: 将死锁忽略,不注意死锁。有的死锁产生的时间并不确定。而且死锁发生的频度,造成问题的严重性不同。假如对于一个死锁每隔几个月或者每几年出现一次,而且每次造成的问题并不严重,那么此时,工程师可能并不会以损失可用性或性能损失的代价去防止死锁。此种情况下就属于忽略死锁的问题。

  • 检测死锁并恢复:当出现死锁时,通过检测死锁的技术,检测到出现的死锁,对于找到的死锁进行恢复

  • 仔细对资源分配,动态的避免死锁

  • 通过破坏引起死锁的四个必要条件之一,以此来避免死锁

5.避免死锁的常见方法

  • 设置加锁顺序

多个线程需要相同的一些锁,但是按照不同的顺序加锁死锁就很容易发生。如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。如果一个线程(线程3)需要一些锁,那么它必须按照确定的顺序获取锁。它只有获得了从顺序上排在前面的锁之后,才能获取后面的锁例如,线程2和线程3只有在获取了锁A之后才能尝试获取锁。因为线程1已经拥有了锁A,所以线程2和3需要一直等到锁A被释放。然后在它们尝试对B或C加锁之前,必须成功地对A加了锁。

Thread 1:
  lock A 
  lock B

Thread 2:
   wait for A
   lock C //此时,要锁C,必须先将释放的A锁锁住

Thread 3:
   wait for A
   wait for B
   wait for C

缺点: 按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁,但总有些时候是无法预知的。

  • 设置加锁时限(超时检测)

获取锁的时候尝试加一个获取锁的时限超过时限不需要再获取锁,放弃操作(对锁的请求)。若一个线程在一定的时间里没有成功的获取到锁,则会进行回退并释放之前获取到的锁,然后等待一段时间后进行重试。在这段等待时间中其他线程有机会尝试获取相同的锁,这样就能保证在没有获取锁的时候继续执行自己的事情。

缺点: 由于存在锁的超时,通过设置时限并不能确定出现了死锁,每种方法总是有缺陷的。有时为了执行某个任务,某个线程花了很长的时间去执行任务,如果在其他线程看来,可能这个时间已经超过了等待的时限,可能出现了死锁。在大量线程去操作相同的资源的时候,这个情况又是一个不可避免的事情。例如,现在只有两个线程,一个线程执行的时候,超过了等待的时间,下一个线程会尝试获取相同的锁,避免出现死锁。但是这时候不是两个线程了,可能是几百个线程同时去执行,让事件出现的概率变大,假如线程还是等待那么长时间,但是多个线程的等待时间就有可能重叠,因此又会出现竞争超时,由于他们的超时发生时间正好赶在了一起,而超时等待的时间又是一致的,那么他们下一次又会竞争,等待,这就又出现了死锁

  • 死锁检测

当一个线程获取锁的时候,会在相应的数据结构中记录下来,如果有线程请求锁,也会在相应的结构中记录下来。当一个线程请求失败时,需要遍历一下这个数据结构检查是否有死锁产生。例如:线程A请求锁住一个方法1,但是现在这个方法是线程B所有的,这时候线程A可以检查一下线程B是否已经请求了线程A当前所持有的锁,像是一个环,线程A拥有锁1,请求锁2,线程B拥有锁2,请求锁1。当遍历这个存储结构的时候,如果发现了死锁,一个可行的办法就是释放所有的锁,回退,并且等待一段时间后再次尝试。

缺点: 这个这个方法和上面的超时重试的策略是一样的。但是在大量线程的时候问题还是会出现和设置加锁时限相同的问题。每次线程之间发生竞争。 还有一种解决方法是设置线程优先级,这样其中几个线程回退,其余的线程继续保持着他们获取的锁,也可以尝试随机设置优先级,这样保证线程的执行

猜你喜欢

转载自blog.csdn.net/hansionz/article/details/84675536