面向猴子编程 并发

本篇文章主要讲述的是多线程并发的基础原理以及一些并发场景下如何解决并发带来的共享数据有误问题。

CPU、进程、线程的基础概念

CPU:计算机的核心是CPU,它承担了所有的计算任务,同时CPU也是执行指令的芯片。一颗CPU核心在同一时间只能做一件事情(执行一条指令)。

进程:进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。

线程:在早期的操作系统中并没有线程的概念,进程是拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片 轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。
随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就发明 了线程,线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。

进程和线程的区别

  1. 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
  2. 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线
  3. 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段,数据集,堆等)及一些进程级的资源(如打开文件和信号等),某进程内的线程在其他进程不可见;
  4. 调度和切换:线程上下文切换比进程上下文切换要快得多

多线程并发和并行

并发

从上文我们可以得知,单核CPU在同一时间只能做一件事情。那我们常说的多线程并发又是如何实现的呢?下面我们先来看一张图:

假设每个蓝色的框是一个单核CPU,单线程和多线程的运行状态就如图所示:

  • 单线程的情况下,线程在每个时间片上都是处于运行状态
  • 多线程的情况下,每个线程一直在运行状态和就绪状态来回切换

大部分操作系统的任务调度是采用时间片轮转的抢占式调度方式,也就是说一个任务执行一小段时间后强制暂停去执行下一个任务,每个任务轮流执行。任务执行的一小段时间叫做时间片,任务正在执行时的状态叫运行状态,任务执行一段时间后强制暂停去执行下一个任务,被暂停的任务就处于就绪状态,等待下一个属于它的时间片的到来。这样每个任务都能得到执行,由于CPU的执行效率非常高,时间片
非常短,在各个任务之间快速地切换,给人的感觉就是多个任务在“同时进行”,这也就是我们所说的并发。

注意,这里用的是任务而非线程是因为线程和进程都可用于实现并发。
在早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位,它相当于一个进程里只有一个线程,进程本身就是线程。所以线程有时被称为轻量级进程。

那我们可不可认为CPU和并发存在必然联系?答案是非也。
CPU的核数虽然会影响到多线程的执行情况,但是并不代表单核CPU的情况下线程一定是并发的。因为多线程同样在单核CPU下可以串行执行。那CPU的核数对多线程的运行有什么影响呢?那就是并行了。

并行

再回顾上文的那句话——单核CPU在同一时间只能做一件事情。也就是说,在单个时间片上,单核CPU只能有一个任务执行。
要让同一时间片上真正能达到有多个任务同时执行,那么最简单的方法就是增加CPU的核数,从物理上达到目的。而这种多个任务真正意义上的同时执行又被称为并行。

所以,并发和并行最大的区别在于,在同一时间片上是否能运行多个任务。

同样,多线程在多核下不一定都是并行的。因为多线程可能会集中于一个CPU核上运行。

并发问题

CPU切换线程导致的原子性问题

首先,让我们了解一下原子性和原子操作的概念:

  • 原子性指的是把一个操作或者多个操作视为一个整体,在执行的过程不能被中断的特性叫原子性。
  • 原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何线程切换。

然后让我看一个语句:

Num+1

这是一个简单的+1语句。看起来只有一条语句,但是在实际的执行过程中是分了几个步骤的:
无法复制加载中的内容

这是单线程情况下的执行情况,但是多线程的情况下情况就不一样了,可能出现以下情况:
在这里插入图片描述
(图中忽略了对CPU缓存的相关操作)
可以看到,由于线程调度的存在,线程A在获取内存中的num数据后并没有立即执行+1操作,而是切换到了线程B。线程B在完成一系列操作后将数据写入内存,此时num为1。之后线程调度切换回线程A执行,而由于寄存器中与A相关num的值为0,所以最后经过一系列操作后写入内存的num的值同样也为1。
从上我们可以发现,线程AB都对内存中同一个num进行操作,但是由于线程调度的原因,最终写入内存的num值是相同的,而不是理想中的线程A/B先对num+1后,另一个线程再对num+1,让最终num的结果为2。
这就是原子性问题——单个线程对内存中值的进行操作的过程并不是原子的,从而影响到其他线程对相同内存中的值的操作。

题外话:针对这种原子问题,各个语言都有自己的原子操作来解决这种问题。例如在GO语言中就有atomic.AddUint32()方法来原子的将数值递增。

多核下缓存导致的可见性问题

多核CPU的情况下,每个CPU都有着自己独立的缓存,它们各自之间是不可见的,这就会导致对应CPU读取的数据都是自己缓存的,无法看到别人对共享数据的修改,从而导致并发BUG。
下面我们看一段代码(多核CPU下执行)

public class TestCase {

    private  int number=0;
    
    public void addNumber(){
        for (int i=0;i<100000;i++){
            number=number+1;
        }
    }
 
    public static void main(String[] args) throws Exception {
        TestCase testCase=new TestCase();
        Thread threadA=new Thread(new Runnable() {
            @Override
            public void run() {
                testCase.addNumber();
            }
        });
 
         Thread threadB=new Thread(new Runnable() {
              @Override
              public void run() {
                  testCase.addNumber();
              }
         });
         threadA.start();
         threadB.start();
         threadA.join();
         threadB.join();
        System.out.println("number="+testCase.number);
    }
}

这是并发下对同一个number参数进行递增操作。
从代码中我们看出,分别起了两个线程对number执行了递增操作。理想情况下,应该最终number的值应该是200000。然而,情况真的如所想的一样吗?

代码执行完后,所打印的值为“number=139769”,与我们的猜想的值相去甚远。

这是为什么呢?让我来看下下面一个图
无法复制加载中的内容

在多核情况下,A/B线程可能分别执行在不同的CPU上。A/B线程从内存中将值读到各自的缓存后再对值进行+1操作,最后写入内存中。在这过程中,由于A/B线程各自使用自己的缓存区,他们只能查看和更新自己CPU缓存里的变量值,线程各自的执行结果对于别的线程来说是不可见的。从而引发可见性问题——一个线程对共享变量的修改,另外一个线程无法立刻看到。

并发问题的解决

可见性问题

首先,让我们看看可见性问题。对于可见性问题,又该如何解决?总不能使用单核CPU来达到所有的线程共用一个缓存的目的吧?

其实对于可见性的问题,可以通过禁止CPU使用缓存来达到目的。例如在JAVA中,JMM已经提供了一套解决缓存可见性问题的产品,它们分别就是volatile、synchronized、final, 而这几个关键字的原理也就是通过内存屏障指令来禁用缓存,当我们对一个变量使用volatile后,那么其实就是告诉我们的程序,在修改变量时强制把最新的数据同步到内存中,读取变量值时强制从内存中读取。而synchronized 也是一样,在线程解锁前,必须把共享变量的值刷新到内存中,而线程加锁前,清空缓存区的共享变量值,这样当读取共享变量时候就必须从内存中读取。

对于可见性问题其实还可以通过缓存一致性协议MESI来解决。此协议简单来讲就是为CPU缓存中的数据添加状态,分别为Modified 、Exclusive 、Share 、Invalid。

  • 当数据只有在单个CPU缓存中存在的时候,数据状态为Exclusive。
  • 当数据在所有CPU缓存中都存在且并未被进行修改的时候,数据状态为Share。
  • 当数据在CPU缓存中被修改后,数据状态为Modified。
  • 当数据在其他CPU缓存中被修改后,当前CPU缓存中数据状态为Invalid。
    CPU可以通过缓存数据的状态来判断何时把缓存的数据同步到主存,何时可以从缓存读取数据,何时又必须从主存读取数据。
    这里有一篇文章把整个MESI过程写的十分详细了,可以参考https://zhuanlan.zhihu.com/p/84500221

原子性问题

对于原子性问题,是否可以通过禁止线程切换来保证其原子性?
答案肯定是否定的。因为这种方式只有在单核CPU的情况下行得通,但是多核CPU的话就行不通了。
因为多核CPU的情况下,同一个语句的指令可能同时被多个CPU在不同的线程上执行,你只能禁用当前CPU切换线程执行,而没办法阻止其他CPU执行。

其实想要保证操作的原子性,我们可以使用总线锁来达到目的。
所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存

GO语言的原子操作CAS就是基于总线锁实现的,让我先看下其底层汇编代码

TEXT runtime∕internal∕atomic·Cas64(SB), NOSPLIT, $0-25
    MOVQ    ptr+0(FP), BX
    MOVQ    old+8(FP), AX
    MOVQ    new+16(FP), CX
    LOCK
    CMPXCHGQ    CX, 0(BX)
    SETEQ    ret+24(FP)
    RET

可以看到汇编相关的指令有 LOCK 和 CMPXCHGQ 及其它几个指令。
这里的LOCK 称为 LOCK指令前缀,是用来设置CPU的 LOCK# 信号的。这个信号会使总线锁定,阻止其他处理器接管总线访问内存,直到使用LOCK前缀的指令执行结束。这一套操作会使后面的指令的执行变为原子操作。在多处理器环境下,设置 LOCK# 信号能保证某个处理器对共享内存的独占使用。当操作变成了原子操作后,使用CMPXCHGQ就能放心的进行比较和更新了。

后记

以上都是从底层原理的角度出发,讲述了何为多线程并发。以及在多线程并发下如何保证共享数据的正确性。
其实,从业务角度还有许许多多的并发问题,这里就暂时不讲了。之后会专门写一篇文章来讲讲业务角度下,并发是怎么产生的,以及如何解决并发问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值