多线程 --- 竞争与互斥

序言

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


1. 多线程竞争

1.1 引出竞争问题

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

cpp 复制代码
#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); ,保证下一个线程可以访问资源

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

cpp 复制代码
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;
}

再次运行我们的程序:

不管运行多少次,我们程序的输出值都符合我们的预期!

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

cpp 复制代码
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. 总结

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

相关推荐
远游客071324 分钟前
centos stream 8下载安装遇到的坑
linux·服务器·centos
马甲是掉不了一点的<.<24 分钟前
本地电脑使用命令行上传文件至远程服务器
linux·scp·cmd·远程文件上传
jingyu飞鸟25 分钟前
centos-stream9系统安装docker
linux·docker·centos
唐诺29 分钟前
几种广泛使用的 C++ 编译器
c++·编译器
超爱吃士力架1 小时前
邀请逻辑
java·linux·后端
冷眼看人间恩怨1 小时前
【Qt笔记】QDockWidget控件详解
c++·笔记·qt·qdockwidget
红龙创客2 小时前
某狐畅游24校招-C++开发岗笔试(单选题)
开发语言·c++
Lenyiin2 小时前
第146场双周赛:统计符合条件长度为3的子数组数目、统计异或值为给定值的路径数目、判断网格图能否被切割成块、唯一中间众数子序列 Ⅰ
c++·算法·leetcode·周赛·lenyiin
fantasy_arch2 小时前
CPU性能优化-磁盘空间和解析时间
网络·性能优化
yuanbenshidiaos3 小时前
c++---------数据类型
java·jvm·c++