2w 字 + 40 张图带你参透并发编程

并发历史

在计算机最早期的时候,没有操作系统,执行程序只需要一种方式,那就是从头到尾依次执行。任何资源都会为这个程序服务,在计算机使用某些资源时,其他资源就会空闲,就会存在 浪费资源 的情况。

>这里说的浪费资源指的是资源空闲,没有充分使用的情况。

操作系统的出现为我们的程序带来了 并发性,操作系统使我们的程序能够同时运行多个程序,一个程序就是一个进程,也就相当于同时运行多个进程。

操作系统是一个并发系统,并发性是操作系统非常重要的特征,操作系统具有同时处理和调度多个程序的能力,比如多个 I/O 设备同时在输入输出;设备 I/O 和 CPU 计算同时进行;内存中同时有多个系统和用户程序被启动交替、穿插地执行。操作系统在协调和分配进程的同时,操作系统也会为不同进程分配不同的资源。

操作系统实现多个程序同时运行解决了单个程序无法做到的问题,主要有下面三点

  • 资源利用率,我们上面说到,单个进程存在资源浪费的情况,举个例子,当你在为某个文件夹赋予权限的时候,输入程序无法接受外部的输入字符,只有等到权限赋予完毕后才能接受外部输入。总的来讲,就是在等待程序时无法执行其他工作。如果在等待程序时可以运行另一个程序,那么将会大大提高资源的利用率。(资源并不会觉得累)因为它不会划水~

  • 公平性,不同的用户和程序都能够使用计算机上的资源。一种高效的运行方式是为不同的程序划分时间片来使用资源,但是有一点需要注意,操作系统可以决定不同进程的优先级。虽然每个进程都有能够公平享有资源的权利,但是当有一个进程释放资源后的同时有一个优先级更高的进程抢夺资源,就会造成优先级低的进程无法获得资源,进而导致进程饥饿。

  • 便利性,单个进程是是不用通信的,通信的本质就是信息交换,及时进行信息交换能够避免信息孤岛,做重复性的工作;任何并发能做的事情,单进程也能够实现,只不过这种方式效率很低,它是一种顺序性的。

但是,顺序编程(也称为串行编程)也不是一无是处的,串行编程的优势在于其直观性和简单性,客观来讲,串行编程更适合我们人脑的思考方式,但是我们并不会满足于顺序编程,we want it more!!! 。资源利用率、公平性和便利性促使着进程出现的同时,也促使着线程的出现。

如果你还不是很理解进程和线程的区别的话,那么我就以我多年操作系统的经验(吹牛逼,实则半年)来为你解释一下:进程是一个应用程序,而线程是应用程序中的一条顺序流

进程中会有多个线程来完成一些任务,这些任务有可能相同有可能不同。每个线程都有自己的执行顺序。

每个线程都有自己的栈空间,这是线程私有的,还有一些其他线程内部的和线程共享的资源,如下所示。

>在计算机中,一般堆栈指的就是栈,而堆指的才是堆

线程会共享进程范围内的资源,例如内存和文件句柄,但是每个线程也有自己私有的内容,比如程序计数器、栈以及局部变量。下面汇总了进程和线程共享资源的区别

线程是一种轻量级的进程,轻量级体现在线程的创建和销毁要比进程的开销小很多。

>注意:任何比较都是相对的。

在大多数现代操作系统中,都以线程为基本的调度单位,所以我们的视角着重放在对线程的探究。

线程

什么是多线程

多线程意味着你能够在同一个应用程序中运行多个线程,我们知道,指令是在 CPU 中执行的,多线程应用程序就像是具有多个 CPU 在同时执行应用程序的代码。

 

其实这是一种假象,线程数量并不等于 CPU 数量,单个 CPU 将在多个线程之间共享 CPU 的时间片,在给定的时间片内执行每个线程之间的切换,每个线程也可以由不同的 CPU 执行,如下图所示

并发和并行的关系

并发意味着应用程序会执行多个的任务,但是如果计算机只有一个 CPU 的话,那么应用程序无法同时执行多个的任务,但是应用程序又需要执行多个任务,所以计算机在开始执行下一个任务之前,它并没有完成当前的任务,只是把状态暂存,进行任务切换,CPU 在多个任务之间进行切换,直到任务完成。如下图所示

并行是指应用程序将其任务分解为较小的子任务,这些子任务可以并行处理,例如在多个 CPU 上同时进行。

优势和劣势

合理使用线程是一门艺术,合理编写一道准确无误的多线程程序更是一门艺术,如果线程使用得当,能够有效的降低程序的开发和维护成本。

Java 很好的在用户空间实现了开发工具包,并在内核空间提供系统调用来支持多线程编程,Java 支持了丰富的类库 java.util.concurrent 和跨平台的内存模型,同时也提高了开发人员的门槛,并发一直以来是一个高阶的主题,但是现在,并发也成为了主流开发人员的必备素质。

虽然线程带来的好处很多,但是编写正确的多线程(并发)程序是一件极困难的事情,并发程序的 Bug 往往会诡异地出现又诡异的消失,在当你认为没有问题的时候它就出现了,难以定位 是并发程序的一个特征,所以在此基础上你需要有扎实的并发基本功。那么,并发为什么会出现呢?

并发为什么会出现

计算机世界的快速发展离不开 CPU、内存和 I/O 设备的高速发展,但是这三者一直存在速度差异性问题,我们可以从存储器的层次结构可以看出

CPU 内部是寄存器的构造,寄存器的访问速度要高于高速缓存,高速缓存的访问速度要高于内存,最慢的是磁盘访问。

程序是在内存中执行的,程序里大部分语句都要访问内存,有些还需要访问 I/O 设备,根据漏桶理论来说,程序整体的性能取决于最慢的操作也就是磁盘访问速度。

因为 CPU 速度太快了,所以为了发挥 CPU 的速度优势,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 使用缓存来中和和内存的访问速度差异

  • 操作系统提供进程和线程调度,让 CPU 在执行指令的同时分时复用线程,让内存和磁盘不断交互,不同的 CPU 时间片 能够执行不同的任务,从而均衡这三者的差异

  • 编译程序提供优化指令的执行顺序,让缓存能够合理的使用

我们在享受这些便利的同时,多线程也为我们带来了挑战,下面我们就来探讨一下并发问题为什么会出现以及多线程的源头是什么

线程带来的安全性问题

线程安全性是非常复杂的,在没有采用同步机制的情况下,多个线程中的执行操作往往是不可预测的,这也是多线程带来的挑战之一,下面我们给出一段代码,来看看安全性问题体现在哪

public class TSynchronized implements Runnable{
  
    static int i = 0;
    public void increase(){
          i++;    }

    @Override    public void run() {
          for(int i = 0;i < 1000;i++) {
              increase();        }    }
    public static void main(String[] args) throws InterruptedException {
  
        TSynchronized tSynchronized = new TSynchronized();        Thread aThread = new Thread(tSynchronized);        Thread bThread = new Thread(tSynchronized);        aThread.start();        bThread.start();        System.out.println("i = " + i);    }}

这段程序输出后会发现,i 的值每次都不一样,这不符合我们的预测,那么为什么会出现这种情况呢?我们先来分析一下程序的运行过程。

TSynchronized 实现了 Runnable 接口,并定义了一个静态变量 i,然后在 increase 方法中每次都增加 i 的值,在其实现的 run 方法中进行循环调用,共执行 1000 次。

可见性问题

在单核 CPU 时代,所有的线程共用一个 CPU,CPU 缓存和内存的一致性问题容易解决,CPU 和 内存之间

如果用图来表示的话我想会是下面这样

在多核时代,因为有多核的存在,每个核都能够独立的运行一个线程,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存

因为 i 是静态变量,没有经过任何线程安全措施的保护,多个线程会并发修改 i 的值,所以我们认为 i 不是线程安全的,导致这种结果的出现是由于 aThread 和 bThread 中读取的 i 值彼此不可见,所以这是由于 可见性 导致的线程安全问题。

####原子性问题

看起来很普通的一段程序却因为两个线程 aThreadbThread 交替执行产生了不同的结果。但是根源不是因为创建了两个线程导致的,多线程只是产生线程安全性的必要条件,最终的根源出现在 i++ 这个操作上。

这个操作怎么了?这不就是一个给 i 递增的操作吗?也就是 i++ => i = i + 1,这怎么就会产生问题了?

因为 i++ 不是一个 原子性 操作,仔细想一下,i++ 其实有三个步骤,读取 i 的值,执行 i + 1 操作,然后把 i + 1 得出的值重新赋给 i(将结果写入内存)。

当两个线程开始运行后,每个线程都会把 i 的值读入到 CPU 缓存中,然后执行 + 1 操作,再把 + 1 之后的值写入内存。因为线程间都有各自的虚拟机栈和程序计数器,他们彼此之间没有数据交换,所以当 aThread 执行 + 1 操作后,会把数据写入到内存,同时 bThread 执行 + 1 操作后,也会把数据写入到内存,因为 CPU 时间片的执行周期是不确定的,所以会出现当 aThread 还没有把数据写入内存时,bThread 就会读取内存中的数据,然后执行 + 1 操作,再写回内存,从而覆盖 i 的值,导致 aThread 所做的努力白费。

为什么上面的线程切换会出现问题呢?

我们先来考虑一下正常情况下(即不会出现线程安全性问题的情况下)两条线程的执行顺序

可以看到,当 aThread 在执行完整个 i++ 的操作后,操作系统对线程进行切换,由 aThread -> bThread,这是最理想的操作,一旦操作系统在任意 读取/增加/写入 阶段产生线程切换,都会产生线程安全问题。例如如下图所示

最开始的时候,内存中 i = 0,aThread 读取内存中的值并把它读取到自己的寄存器中,执行 +1 操作,此时发生线程切换,bThread 开始执行,读取内存中的值并把它读取到自己的寄存器中,此时发生线程切换,线程切换至 aThread 开始运行,aThread 把自己寄存器的值写回到内存中,此时又发生线程切换,由 aThread -> bThread,线程 bThread 把自己寄存器的值 +1 然后写回内存,写完后内存中的值不是 2 ,而是 1, 内存中的 i 值被覆盖了。

我们上面提到 原子性 这个概念,那么什么是原子性呢?

并发编程的原子性操作是完全独立于任何其他进程运行的操作,原子操作多用于现代操作系统和并行处理系统中。

>

原子操作通常在内核中使用,因为内核是操作系统的主要组件。但是,大多数计算机硬件,编译器和库也提供原子性操作。

>

在加载和存储中,计算机硬件对存储器字进行读取和写入。为了对值进行匹配、增加或者减小操作,一般通过原子操作进行。在原子操作期间,处理器可以在同一数据传输期间完成读取和写入。 这样,其他输入/输出机制或处理器无法执行存储器读取或写入任务,直到原子操作完成为止。

简单来讲,就是原子操作要么全部执行,要么全部不执行。数据库事务的原子性也是基于这个概念演进的。

有序性问题

在并发编程中还有带来让人非常头疼的 有序性 问题,有序性顾名思义就是顺序性,在计算机中指的就是指令的先后执行顺序。一个非常显而易见的例子就是 JVM 中的类加载

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Docker是一种流行的容器化技术,通过轻量级、隔离性强的容器来运行应用程序。下面我将通过十张图你深入理解Docker容器和镜像。 1. 第一张图展示了Docker容器和镜像的关系。镜像是Docker的基础组件,它是一个只读的模板,包含了运行应用程序所需的所有文件和配置。容器是从镜像创建的实例,它具有自己的文件系统、网络和进程空间。 2. 第二张图展示了Docker容器的隔离性。每个容器都有自己的文件系统,这意味着容器之间的文件互不干扰。此外,每个容器还有自己的网络和进程空间,使得容器之间的网络和进程相互隔离。 3. 第三张图展示了Docker镜像和容器的可移植性。镜像可以在不同的主机上运行,只需在目标主机上安装Docker引擎即可。容器也可以很容易地在不同的主机上迁移,只需将镜像传输到目标主机并在其上创建容器。 4. 第四张图展示了Docker容器的快速启动。由于Docker容器与主机共享操作系统内核,启动容器只需几秒钟的时间。这使得快速部署和扩展应用程序成为可能。 5. 第五张图展示了Docker容器的可重复性。通过使用Dockerfile定义镜像构建规则,可以确保每次构建的镜像都是相同的。这样,可以消除由于环境差异导致的应用程序运行问题。 6. 第六张图展示了Docker容器的资源隔离性。Docker引擎可以为每个容器分配一定数量的CPU、内存和磁盘空间,确保容器之间的资源不会互相干扰。 7. 第七张图展示了Docker容器的可扩展性。通过使用Docker Swarm或Kubernetes等容器编排工具,可以在多个主机上运行和管理大规模的容器群集。 8. 第八张图展示了Docker镜像的分层结构。镜像由多个只读层组成,每个层都包含一个或多个文件。这种分层结构使得镜像的存储和传输变得高效。 9. 第九张图展示了Docker容器的生命周期。容器可以通过创建、启动、停止和销毁等命令来管理。这使得容器的维护和管理变得简单。 10. 第十张图展示了Docker容器的应用场景。Docker容器广泛应用于开发、测试、部署和运维等领域。它可以提供一致的开发和运行环境,简化了应用程序的管理和交付过程。 通过这十张图,希望能让大家更深入地理解Docker容器和镜像的概念、特性和应用。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值