多线程 --- 竞争与互斥

2024-08-25 16:04
文章标签 互斥 多线程 竞争

本文主要是介绍多线程 --- 竞争与互斥,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

序言

 经过前面的学习,我们知道多个线程共享同一个进程地址空间的资源,所以免不了存在多个线程同时访问同一个资源的情况,这对我们的程序会产生什么影响呢?该怎么避免呢?


1. 多线程竞争

1.1 引出竞争问题

 为了更好地理解问题地来源,我们采用一段程序来引出今天的主题:

#include <iostream>
#include <pthread.h>
#include <vector>
#include <unistd.h>const int numThreads = 10;         // 线程数量
const int numIncrements = 100000; // 每个线程的增量次数
int counter = 0;                  // 全局计数器void *incrementCounter(void *)
{while(counter <= numIncrements){counter++;}return nullptr;
}int main()
{std::vector<pthread_t> threads(numThreads);// 创建线程for (int i = 0; i < numThreads; ++i){pthread_create(&threads[i], nullptr, incrementCounter, nullptr);}// 等待线程完成for (int i = 0; i < numThreads; ++i){pthread_join(threads[i], nullptr);}std::cout << "Final counter value: " << counter << std::endl;return 0;
}

整个程序的逻辑还是比较简单的,主要就是我们采取多线程的方式来对一个全局变量进行自增操作,当他增加到指定的值时,执行完毕,回收线程,整个程序退出。
 现在我们运行程序,看看结果:
在这里插入图片描述

运行了很多次,运行结果不是固定的,时而是 100001, 时而是 100002!为什么会出现这种情况呢?本应该到达 100000 时,程序就应该结束了呀。

1.2 从底层思考原因

 在进入底层之前,大家先要了解一个概念叫做 原子操作


知识点 — 原子操作

概念:原子操作指的是一种不可分割的操作,即这种操作一旦开始,就会一直运行到结束,中间不会被线程调度机制打断,也不会有任何的上下文切换到其他线程。在这里阐述的概念不易理解,大家可以简单的理解为 原子性操作的汇编指令只有一条
特性

  • 不可分割性:原子操作在执行过程中不会被其他任务或事件中断。
  • 完整性:操作要么全部完成,要么完全不执行,不会留下部分执行的结果。
  • 线程安全:在多线程环境中,原子操作能确保同一时间只有一个线程能执行该操作,从而避免数据竞争和不一致性。

示例:我们上述程序其中一个指令为 counter++;,这就是一条非原子性的操作,看着只有一句,但是在汇编层面它包含 读取数据,数据加一,写回数据 三个操作。


 好的现在言归正传,回到我们的正文话题来,为什么输出结果会超出预期呢?

  1. 某个时刻 counter = 9999A 线程经过判断后满足条件,执行 counter++
  2. 此时 B 线程跳出来了,因为 ++ 操作是非原子的,所以此时 B 线程在内存中读取的 counter = 9999
  3. 同样符合判断条件执行 counter++

所以总结一句话 是因为 counter 变量的递增操作没有在多线程环境中被正确地同步

1.3 竞争的危害以及解决方案

 通过实例,大家可以很明显的感觉到竞争会引起 数据不一致问题。解决线程竞争包含很多方法,这篇文章中,我们将介绍线程互斥的互斥锁方案。


2. 线程互斥 — 互斥锁

 在进入主题之前,请先记住三个概念:

  • 临界资源:多线程执行流共享的 资源 就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的 代码 ,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用

2.1 互斥锁的概念

 互斥锁是一种 保护共享资源不被多个线程同时访问的机制。它通过加锁和解锁操作来控制对共享资源的访问权限。确保在同一时间内,只有一个线程能够访问特定的资源或执行特定的代码段,从而保护共享数据的一致性和完整性。

2.2 互斥锁的使用

 锁包含全局的,局部的,在这里我们使用全局的锁,他的初始化更为方便,并且不需要手动释放资源,局部的锁需要我们手动释放资源。
 锁的使用步骤:

  1. 初始化锁:pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; 在这里我们初始化了一把全局的锁
  2. 临界区 上锁, pthread_mutex_lock(&mtx); ,保证同一时间只能有一个线程访问临界资源,
  3. 访问完毕,对 临界区 解锁, pthread_mutex_unlock(&mtx); ,保证下一个线程可以访问资源

 改动的部分很少,现在就只展示有改动的地方:

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; // 互斥锁初始化void *incrementCounter(void *)
{while (true){pthread_mutex_lock(&mtx); // 锁定互斥锁if(counter <= numIncrements){counter++;pthread_mutex_unlock(&mtx); // 解锁互斥锁}else{break;pthread_mutex_unlock(&mtx); // 解锁互斥锁}}return nullptr;
}

 再次运行我们的程序:
在这里插入图片描述
不管运行多少次,我们程序的输出值都符合我们的预期!

 在这里使用互斥锁一定要注意一点,尽可能将互斥锁的持有时间缩小到必要的最小范围内。例如,只在需要保护共享资源的代码段中持有锁,其他代码不应在持锁的情况下执行,这样才能有更好的并发性能! 就比如,如果我在这里稍微扩大一点锁的范围:

void *incrementCounter(void *)
{pthread_mutex_lock(&mtx); // 锁定互斥锁while (true){if(counter <= numIncrements){counter++;pthread_mutex_unlock(&mtx); // 解锁互斥锁}else{break;pthread_mutex_unlock(&mtx); // 解锁互斥锁}}return nullptr;
}

那这里完全就是一个线程揽下了所有的活,其他线程就在外面一直阻塞,丢失了并发性!

2.3 互斥锁的底层实现

 我们首先查看该锁结构体的定义:
在这里插入图片描述
我们发现,他的成员变量的一个结构体中包含一个变量叫做 __lock 该变量是关键!
 在我们申请锁使用锁的时候,所有线程都是使用一把锁(同一个结构体变量)!有了这些知识铺垫,现在我们可以开始正式介绍怎么上锁了,解锁了。

 我们需要理解一下这段代码逻辑:
在这里插入图片描述

上锁的过程
  1. 首先将寄存器 %al 的值置为 0
  2. 将锁结构体中的 __lock%al 的值做交换(该操作为原子操作,不会被中断
  3. 如果交换后 %al 的值为 1 ,则上锁成功,退出该函数,执行临界区的代码
  4. 反之,则被阻塞,等待唤醒

所以同一时间,只有一个线程可以访问临界区的代码!

解锁的过程
  1. 首先将寄存器 __lock 的值置为 1
  2. 唤醒所有被阻塞的进程
  3. 返回退出
模拟互斥过程

 如果大家看完还有一点懵的话,我们可以使用两个线程 AB模拟一下。现在两个线程都想要访问临界区的代码,AB 快一丢丢(具体谁更快一点是不确定的),接触到 lock 函数,首先将寄存器 %al 的值置为 0,再将 __lock%al 的值做了交换,现在 %al = 1, __lock = 0A 美滋滋的打开了去临界区的门,并把门关上了。B 也把 __lock%al 的值做了交换,但是此时 %al = 0, __lock = 0,好了 B 就被一直阻塞(门口排队),等待 A 开门(执行完毕退出,并且放回了钥匙 1)。


3. 总结

 在这篇文章中,我们介绍了多线程竞争以及解决的其中一个方案 — 互斥锁,还讲解了互斥锁的实现原理,希望大家有所收获!

这篇关于多线程 --- 竞争与互斥的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1106010

相关文章

多线程解析报表

假如有这样一个需求,当我们需要解析一个Excel里多个sheet的数据时,可以考虑使用多线程,每个线程解析一个sheet里的数据,等到所有的sheet都解析完之后,程序需要提示解析完成。 Way1 join import java.time.LocalTime;public class Main {public static void main(String[] args) thro

Java 多线程概述

多线程技术概述   1.线程与进程 进程:内存中运行的应用程序,每个进程都拥有一个独立的内存空间。线程:是进程中的一个执行路径,共享一个内存空间,线程之间可以自由切换、并发执行,一个进程最少有一个线程,线程实际数是在进程基础之上的进一步划分,一个进程启动之后,进程之中的若干执行路径又可以划分成若干个线程 2.线程的调度 分时调度:所有线程轮流使用CPU的使用权,平均分配时间抢占式调度

Java 多线程的基本方式

Java 多线程的基本方式 基础实现两种方式: 通过实现Callable 接口方式(可得到返回值):

java线程深度解析(二)——线程互斥技术与线程间通信

http://blog.csdn.net/daybreak1209/article/details/51307679      在java多线程——线程同步问题中,对于多线程下程序启动时出现的线程安全问题的背景和初步解决方案已经有了详细的介绍。本文将再度深入解析对线程代码块和方法的同步控制和多线程间通信的实例。 一、再现多线程下安全问题 先看开启两条线程,分别按序打印字符串的

JAVA- 多线程

一,多线程的概念 1.并行与并发 并行:多个任务在同一时刻在cpu 上同时执行并发:多个任务在同一时刻在cpu 上交替执行 2.进程与线程 进程:就是操作系统中正在运行的一个应用程序。所以进程也就是“正在进行的程序”。(Windows系统中,我们可以在任务管理器中看 到进程) 线程:是程序运行的基本执行单元。当操作系统执行一个程序时, 会在系统中建立一个进程,该进程必须至少建立一个线

多线程篇(阻塞队列- LinkedBlockingDeque)(持续更新迭代)

目录 一、LinkedBlockingDeque是什么 二、核心属性详解 三、核心方法详解 addFirst(E e) offerFirst(E e) putFirst(E e) removeFirst() pollFirst() takeFirst() 其他 四、总结 一、LinkedBlockingDeque是什么 首先queue是一种数据结构,一个集合中

多线程篇(阻塞队列- LinkedBlockingQueue)(持续更新迭代)

目录 一、基本概要 1. 构造函数 2. 内部成员 二、非阻塞式添加元素:add、offer方法原理 offer的实现 enqueue入队操作 signalNotEmpty唤醒 删除线程(如消费者线程) 为什么要判断if (c == 0)时才去唤醒消费线程呢? 三、阻塞式添加元素:put 方法原理 图解:put线程的阻塞过程 四、非阻塞式移除:poll方法原理 dequ

Ajax 解决回调竞争

回调的竞争,即多次快速点击同一按钮导致多个异步的AJAX请求同时返回,导致数据更新顺序混乱。这种情况在异步编程中很常见,特别是前端开发时,AJAX请求的回调并不保证按顺序执行。 $.ajaxSetup() 可以设置全局的 beforeSend 和 complete 回调函数,这样每个 AJAX 请求在发送前和完成后都可以执行相应的逻辑。 let isRequestPending = false

spring笔记 多线程的支持

spring的工作机制 136  属性编辑器 140 spring事件的体系结构 168 Bean间的关系 109 继承 依赖 引用     Bean的继承          1 为了简化初始化的属性注入;          2 子Bean和父Bean相同的属性值,使用子Bean的     Bean的依赖 Srping控制相互依赖的Bean之间,属性注入的顺序,防止出错  depend-on

【编程底层思考】详解Java的JUC多线程并发编程底层组件AQS的作用及原理

Java中的AbstractQueuedSynchronizer(简称AQS)是位于java.util.concurrent.locks包中的一个核心组件,用于构建锁和其他同步器。AQS为实现依赖于FIFO(先进先出)等待队列的阻塞锁和相关同步器提供了一套高效、可扩展的框架。 一、AQS的作用 统一同步状态管理:AQS提供了一个int类型的成员变量state,用于表示同步状态。子类可以根据自己