[Linux]——Linux线程互斥

Linux线程互斥

上一篇博客中我们介绍了Linux下对线程控制的简单操作,其实就光单单论线程控制函数的使用来说控制线程并不是一件很难的事,但是线程控制真正抛给我们的难题恐怕并不是仅仅会使用函数而已,本篇博客就带领大家详细的探讨一下Linux下的线程互斥问题。

线程并发操作带来的一些问题

并发在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。戳这个搞笑视频或许能帮你加深理解
所以并发可能存在某一时刻会对共享资源进行同时访问,如果在对这些共享资源不遵循规则,那么就可能对这些共享资源造成破坏。多说无益,我们使用一个最简单的代码来阐述并发出现的问题。

[Linux]——Linux线程互斥_第1张图片
结果你认为是20000000,但是实际上令人大跌眼镜,这如果是钞票的话,笔者已经哭死在厕所
在这里插入图片描述
那么造成上面结果的原因是什么呢?来看下图,因为程序是并发运行的,所以不同的线程在执行时可能不同的寄存器同时取走了某一时刻的goal,对他们分别进行++后结果都变成了2,最后写回内存结果从预期的三变成了2,这里的根本原因是++这个操作并不是原子的,有的同学一脸震惊,这里不明摆着是一句代码吗,怎么就不是原子的了
[Linux]——Linux线程互斥_第2张图片
来看我们在vs2013下一段简单的反汇编代码:从图中你清楚的可以看到对于goal的++操作分解成了三步,所以这就是导致上面问题的罪魁祸首
在这里插入图片描述
为了下面更好的理解互斥问题,我们这里给大家提几个简单的概念:

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

如果++是一个原子性的操作的话,那么就不会出现上述的问题,所以现在要想解决上面的问题要么将上述代码实现原子性操作,要么实现互斥的机制,Linux中确实提供了原子的++操作(atomic)但是这仅仅解决了当前问题,所以我们这里重点介绍线程的互斥锁实现互斥。

互斥量mutex

为了解决上面的问题,我们需要做到3点:

  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区
  • 如果多个线程同时要执行临界区的代码,并且没有线程在执行,那么只允许一个线程进入该临界区
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区

要做到这三点,本质上需要一把锁,Linux下提供了一把锁叫做互斥量:

[Linux]——Linux线程互斥_第3张图片
接下来我们先学习互斥量的接口,然后再使用互斥量解决我们上述的问题

互斥量接口

互斥量的接口相对来说比较简单,我们将所有的接口一次性介绍完,用一个修改后的上文代码来做演示,这样便于读者们观察。

初始化互斥量的两种方式

  • 方法一:静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
  • 方法二:动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t*restrict attr);
//参数一为要初始化的互斥锁,参数二一般设置为NULL表示使用默认属性

互斥量的销毁

int pthread_mutex_destroy(pthread_mutex_t *mutex);

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

互斥量的加锁和解锁

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

调用pthread_ lock 时,可能会遇到以下情况:

互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁

现在我们来使用互斥量将上面程序修改成一个线程安全的程序:
[Linux]——Linux线程互斥_第4张图片
这个修改的程序当线程一要对goal进行++时,如果当前没有线程再临界区中,那么线程一拿到锁并加锁然后进行临界资源的访问,当线程二要访问临界资源时发现锁资源无法被申请,所以操作系统将线程二设置为非r状态并将其加入到阻塞队列中等待线程一释放锁资源。这样就可以保证在线程对临界区访问时同一个时间只有一个线程存在,也就是说我们现在可以认为我们的程序++是原子的。
但是有的同学提出了这样的一个问题,尽然互斥锁是用来解决原子性问题的,那么互斥锁本身是怎么保证加锁是原子的呢?

浅析互斥锁实现原理

互斥锁的实现原理实际上非常的简单,他并没有你想的那么复杂,这里我们必须要知道的前提是我们可以将汇编的一句指令认为是原子的,所以为了实现锁的互斥操作,大多数体系结构对于互斥锁的实现使用了swap或者exchange指令,该指令的作用是把寄存器和单元数据交换,该指令只有一句,所以是原子操作,因为就算是多线程,总线周期也有先后,所以利用此指令总能保证同一个时间只有一个线程进入临界区。

现在我们来看看如何使用swap或者exchange指令实现互斥锁原理,来看下面的一段伪代码:可以看到锁资源最开始拿到的值为1,然后使用swap或exchange指令将寄存器中的值和?拥有的值交换,如果寄存器中现在值为1,那么表示资源申请成功,如果寄存器中值为0,说明?资源中的1已经被其他线程交换走了,所以当前进程也就需要进入等待队列

lock:
		mov $0, %al
		xchgb %al, mutex
		if (al寄存器的内容 > 0){
			return 0;
		}
		else{
			挂起等待;
		}
		goto lock;
unlock:
		movb $1, mutex
		唤醒等待mutex的线程
		return 0;

我们保证mutex中的1在整个程序中只有一份,下图会更加加深你对互斥量实现原理的机制:
[Linux]——Linux线程互斥_第5张图片

重入

我们上面加加变量的小栗子有问题其实另一个原因是他本质是一个不可重入的函数,什么是重入?重入是指同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下不会出现不同或者任何问题,则我们称其为可重入函数,否则反之称为不可重入函数。

常见不可重入的情况:

  • 调用了malloc和free函数,因为malloc是使用全局链表来管理堆的
  • 调用了标准I/O库函数,I/O库很多函数的实现都使用了全局的数据结构
  • 函数体使用了静态全局变量

常见可重入的情况:

  • 不使用全局变量和静态变量
  • 不适用malloc或者new开辟出的空间
  • 不调用不可重入函数

线程安全问题

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

常见线程不安全的情况:

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

常见线程安全的情况:

  • 每个线程对于全局变量或者静态变量只有读取的权限,而没有写入的权限
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性

线程安全 VS 重入

  • 很经典的一句话,可重入函数一定是线程安全的,但是线程安全的不一定时可重入函数
  • 函数是不可重入的,那么就不能由多个线程使用,有可能引发线程安全的问题
  • 如果对临界资源访问加上锁,则这个函数线程安全,但是如果这个重入函数若锁没有释放则会产生死锁问题

总结

Linux线程互斥是一种非常重要的机制,同学们不仅要明白这种机制产生原因,并且需要学会解决线程安全的方案,而互斥锁的实现原理也需要大家牢记在心。

你可能感兴趣的:(Linux)