type
status
date
slug
summary
tags
category
icon
password
异步和并发

1 并发

并发其实很好理解,首先我们要稍微了解一下 CPU。
CPU 虽然很复杂,但是我们不需要学的那么深入,我们只需要知道一块 CPU 其实是由多个小核组成的。比如我有一块 CPU ,有 8 个内核,8 个逻辑处理器(线程),整个 CPU 可以同时执行 8 个任务。
其实 Intel 以前的 CPU 使用了超线程技术,可以让 1 个核心有 2 个 逻辑处理器,说是能提升 20% ~ 30% 的性能,但是可能导致过度的热密度,尤其是随着处理器架构的进步,增加物理核心的数量常常比启用超线程在对高性能要求的应用中来得更加有效。
虽然说一个 Java 进程可以创建多个线程,但是核心同一刻只能做一件事,并 不能真正 " 并行执行 " 两个繁重任务,而是 利用空闲周期交替调度两个线程共享同一核心资源。可以简单理解为:" 快速切换,让一个核心看起来像能同时做两件事 "。
关于并发和并行的两种概念:
  • 并发:两个及两个以上的作业在同一 时间段 内执行。
  • 并行:两个及两个以上的作业在同一 时刻 都在执行。
了解过网络开发的应该知道,每个请求都会独占一个线程,此时这些请求如果处于同一核心下,那就是并发执行的,虽然线程没有独立的地址空间,但是有自己的堆栈和局部变量,以此来确保线程内部数据不会被共享。如果请求量太大、请求处理速度太慢都会导致请求积压,进而影响用户体验,因此就需要更多的机器、更快的 CPU、更多的线程。

要想理解异步,我们首先要了解两种类型的任务:I/O 密集型CPU 密集型

2 异步:CPU 密集型

假设我们的 CPU 现在只有一个核心,同一刻只能执行一个任务,比如计算两个大质数是否为素数,每个计算都要耗 2 秒:
  1. 同步方式(顺序执行):
    1. 总耗时 4 秒
  1. 异步方式(单核心双线程异步执行):
    1. 如果底层只用了一个线程执行器(没有并发线程池),那么其实:
      • 第一个任务还是先跑完,再跑第二个
      • 总耗时还是差不多是 4 秒
我们会发现,在需要消耗 CPU 资源的时候,没有多线程的异步并不会减少耗时,本线程需要运行 1000 万次,那就不可能减少到 900 万次。
但有人可能会反驳,同一段代码他只用了两秒,那是因为操作系统把另一个线程分配到另一个核心上了,此时就是并行执行了。
如果说我们 8 核 的 8 个线程全部被 CPU 密集型的任务占满了,此时即使是多线程也都无济于事了。打个比方就是:砖头的数量把 8 个工人压得喘不过来气,即使是把一块砖劈成 8 个小块,需要搬的重量依旧不会少。

3 异步:I/O 密集型

我们用 JavaScript 来举例(JavaScript 正是 「单线程 + 异步」 处理 I/O 密集型任务的经典语言):请求两个接口,每个延迟 2 秒,返回数据。我们分别用 同步(模拟)异步 方式写,看看谁快。
  1. 同步(模拟,JS 实际不支持同步网络请求)
    1. 请求 1 完成
      请求 2 完成
      总耗时: 4.01s
  1. 异步(并发发起请求)
    1. 两个请求都完成
      总耗时: 2.01s
此时大家应该能理解什么是 I/O 密集型了,简单地说就是需要大量地从网络、硬盘等非内存设备中获取/读取一个数据,但是网络请求有延迟,硬盘读取耗时。虽然这点时间对人来说无关紧要,但是对 CPU 来讲已经够它计算几亿甚至十几亿次浮点运算了,CPU 资源是最珍贵的,从古至今所有程序员无一不是在为了增加 CPU 运行效率而努力。
举个例子,主频为 3.0GHZ 的 CPU,一个时钟周期大约是 0.3 纳秒,内存访问大约需要 120 纳秒,固态硬盘访问大约需要 50-150 微秒,机械硬盘访问大约需要 1-10 毫秒,最后网络访问最慢,得几十毫秒左右。 这个大家可能对时间不怎么敏感,那如果我们把 一个时钟周期如果按 1 秒算的话,内存访问大约就是 6 分钟 ,固态硬盘大约是 2-6 天 ,传统硬盘大约是 1-12 个月,网络访问就得几年了! 我们可以发现 CPU 的速度和内存等存储器的速度,完全不是一个量级上的,并且越差越大。

4 单线程 + 异步

有的人在看上面两个例子的时候可能会产生一种疑惑:单线程是怎么实现异步的?异步难道不就是并发 (多线程) 吗?
实际上 异步 ≠ 并发,这是个很多人刚开始会混淆的点:
概念
解释
异步
不阻塞地启动任务,先登记,然后 " 等你忙完了告诉我 "
并发
同时真正地跑多个任务,需要多个线程/CPU
所以说:
🚫 异步 ≠ 多线程
🚫 异步 ≠ 加快任务速度
✅ 异步 = 不等待、不阻塞

4.1 🧠 先解释一下原理:单线程异步是怎么做到的?

这其实涉及到 CPU 的两种 " 运行权限模式 " 了,用户态(User Mode)内核态(Kernel Mode) 。一个 CPU 核心在 同一时刻只能处于一种模式
我们知道 CPU 其实就是一块块能够运算与或的芯片,怎么让任务通过一次次的与或运算实现你想要的结果,这就离不开 操作系统 的功劳。这部分就比较复杂了,我就简单说一下,我们的所有应用、程序、代码都是在操作系统(Windows、Linux、MacOS)这个软件上运行的,操作系统为了防止自身的奔溃,出现无法处理的异常情况,出于安全考虑,不允许用户直接操作硬件或访问关键资源(比如磁盘、内存管理、网卡、CPU 调度),需要将核心转为内核态通过 "系统调用" 来执行。也因为每个操作系统的 " 系统调用 " 都不相同,所以应用多平台的移植不是一件轻松的事情。就连大家所熟知的跨平台的 Java 语言,其编译后的字节码也是需要运行在 JVM 虚拟机上的,而 JVM 是不跨平台的。
我们在文章开头就已经知道了,1 个核心上的 2 个线程 并非真正的并发,而是两个任务快速切换,让一个核心看起来像能同时做两件事。在此基础上我们举个例子:这两个线程在运行时其中一个发起了 异步 IO(比如 aio_read()epolllibuv、Java NIO 等),此时 CPU 做了什么?
时间点
CPU 核心在干嘛
T0
核心运行线程 A,发起异步 IO
T1 核心
核心登记 IO 请求,交给驱动/设备处理,核心回到用户态继续执行,不必等待设备完成事件
T2
核心可能切换去运行线程 B,也可能继续执行线程 A 的后续内容
T3(IO 完成)
核心通过中断或事件唤醒线程 A,准备继续执行

4.2 ⏳ 那为什么说它比 " 同步 " 快?

有的人可能又会产生疑问了:读取硬盘数据难道不也是核心在干活吗?怎么会凭空出现一个线程既能读取磁盘数据,又能运行线程 A 、还能运行线程 B 呢?
IO 操作本身(例如磁盘读取、网卡接收)大多数由 硬件 + 内核 完成,不怎么消耗 用户态的 CPU。说白了,核心在转为内核态后用驱动(硬件的软件,就好比 CPU 的软件是操作系统)给硬件发送了一个命令,之后硬件就自己去执行了,不需要 CPU 主动参与,此时发起 IO 的线程可以不选择阻塞,继续执行后续任务。
总结:
  • CPU 只是在开始时切入内核态发起 IO 请求,然后就可以干别的了。
  • 硬件磁盘控制器 会负责处理 IO 命令、调度读写、准备数据。
  • 最终通过 中断机制 告诉 CPU " 我好了 ",CPU 再处理后续(唤醒线程、复制数据等)。
线程池和虚拟线程缓存穿透、击穿、雪崩
Loading...