我们一起聊聊从操作系统层面理解多线程冲突

开发 前端
同一进程内的线程是共享同一内存空间的,所以在多个线程的进程里,线程是可以同时操作这个进程空间的数据的,线程之间可以共享进程的资源:比如代码段、堆空间、数据段、打开的文件等资源,但每个线程也有自己独立的栈空间。

前言

今天来从操作系统层面理解一下多线程冲突的问题,话不多说,开始~~

什么是多线程的冲突

同一进程内的线程是共享同一内存空间的,所以在多个线程的进程里,线程是可以同时操作这个进程空间的数据的,线程之间可以共享进程的资源:比如代码段、堆空间、数据段、打开的文件等资源,但每个线程也有自己独立的栈空间。如果多个线程如果竞争共享资源,如果不采取有效的措施,则会造成共享数据的混乱。

举个小栗子:一个房子里(代表一个进程),只有一个厕所(代表共享资源)。屋子里面有两个人A和B(代表两个线程),共用这个厕所。一天A去上厕所了,不过厕所门的锁坏了,就没法锁门。这是B也想去上厕所,直接就开门进去了,然后发现A在里面。

上面这个故事说明,对于共享资源,如果没有上锁,在多线程的环境里,那么就可能会发生翻车现场。

竞争与协作

做个小实验,创建五个线程,它们分别对共享变量 i 自增 1 执行 1000 次

package com.atguigu.juc.atomics;

public class Station{


    int i = 0;

    public void add() {
        for(int m = 0; m < 1000; m++){
            try {
                //使用sleep()模拟业务时间,如果不加,大概率不会出现并发问题
                Thread.sleep(1);
                i += 1;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }

    public static void main(String[] args) throws InterruptedException {
        //实例化站台对象,并为每一个站台取名字
        Station station = new Station();
        new Thread(station::add,"线程1").start();
        new Thread(station::add,"线程2").start();
        new Thread(station::add,"线程3").start();
        new Thread(station::add,"线程4").start();
        new Thread(station::add,"线程5").start();
        Thread.sleep(20000);
        // 让每一个站台对象各自开始工作
        System.out.println(station.i);
        Thread.sleep(5000);
        System.out.println(station.i);
    }
}

运行了几次发现,每次运行得到不同的结果。在计算机里是不能容忍的,虽然是小概率出现的错误,但是小概率事件它一定是会发生的。

为什么会出现这样的问题呢?

为了理解为什么会发生这种情况,我们必须了解编译器为更新计数器 i 变量生成的代码序列,也就是要了解汇编指令的执行顺序。

在这个例子中,我们只是想给 i 加上数字 1,那么它对应的汇编指令执行过程是这样的:

可以发现,只是单纯给 i 加上数字 1,在 CPU 运行的时候,实际上要执行 3 条指令。

设想我们的线程 1 进入这个代码区域,它将 i 的值(假设此时是 50 )从内存加载到它的寄存器中,然后它向寄存器加 1,此时在寄存器中的 i 值是 51。

现在,一件不幸的事情发生了:当前线程被挂起了,线程 2 被调度运行,并进入同一段代码。它也执行了第一条指令,从内存获取 i 值并将其放入到寄存器中,此时内存中 i 的值仍为 50,因此线程 2 寄存器中的 i 值也是 50。假设线程 2 执行接下来的两条指令,将寄存器中的 i 值 + 1,然后将寄存器中的 i 值保存到内存中,于是此时全局变量 i 值是 51。

最后,又发生一次上下文切换,线程 1 恢复执行。还记得它已经执行了两条汇编指令,现在准备执行最后一条指令。回忆一下, 线程 1 寄存器中的 i 值是51,因此,执行最后一条指令后,将值保存到内存,全局变量 i 的值再次被设置为 51。

简单来说,增加 i (值为 50 )的代码被运行两次,按理来说,最后的 i 值应该是 52,但是由于不可控的调度,导致最后 i 值却是 51。

针对上面线程 1 和线程 2 的执行过程,我画了一张流程图,会更明确一些:

互斥的概念

上面展示的情况称为竞争条件(race condition),当多线程相互竞争操作共享变量时,由于运气不好,即在执行过程中发生了上下文切换,我们得到了错误的结果,事实上,每次运行都可能得到不同的结果,因此输出的结果存在不确定性(indeterminate)。

由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行。

我们希望这段代码是互斥(mutualexclusion)的,也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区,说白了,就是这段代码执行过程中,最多只能出现一个线程。

另外,说一下互斥也并不是只针对多线程。在多进程竞争共享资源的时候,也同样是可以使用互斥的方式来避免资源竞争造成的资源混乱。

同步的概念

所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。

上面那个栗子:一A去上厕所了,发现厕所门的锁坏了,告诉B一声。这是B想去上厕所的话,就会先问一下A是不是还在里面,然后再开门进去了。这也是互通消息的方式,如果锁没有坏,A直接把门锁上,这就是相互等待的方式。

怎么解决多线程冲突?

为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种:

  • 锁:加锁、解锁操作;
  • 信号量:P、V 操作;

这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强一些,它还可以方便地实现进程/线程同步。

使用加锁操作和解锁操作可以解决并发线程/进程的互斥问题。

任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。

信号量

信号量是操作系统提供的一种协调共享资源访问的方法。通常信号量表示资源的数量,对应的变量是一个整型(sem)变量。

另外,还有两个原子操作的系统调用函数来控制信号量的,分别是:

  • P 操作:将 sem 减 1,相减后,如果 sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;
  • V 操作:将 sem 加 1,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;

举个类比,2 个资源的信号量,相当于 2 条火车轨道,PV 操作如下图过程:


责任编辑:武晓燕 来源: 今日头条
相关推荐

2022-09-22 08:06:29

计算机平板微信

2023-06-09 08:06:14

操作系统调度器LLM

2024-02-28 08:41:51

Maven冲突版本

2023-07-11 08:34:25

参数流程类型

2012-02-22 10:48:23

操作系统

2023-08-02 08:35:54

文件操作数据源

2021-12-10 07:45:48

字节音频视频

2021-08-27 07:06:10

IOJava抽象

2024-02-20 21:34:16

循环GolangGo

2021-10-26 09:55:52

CAP理论分布式

2023-10-31 09:04:21

CPU调度Java

2021-12-14 07:40:07

多线程面试CPU

2012-06-06 10:38:32

Windows操作系统

2023-08-10 08:28:46

网络编程通信

2023-08-04 08:20:56

DockerfileDocker工具

2022-05-24 08:21:16

数据安全API

2023-06-30 08:18:51

敏捷开发模式

2023-09-10 21:42:31

2022-11-12 12:33:38

CSS预处理器Sass

2022-10-28 07:27:17

Netty异步Future
点赞
收藏

51CTO技术栈公众号