开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

【项目实战】MNIST 手写数字识别(上) 前言 配置环境

发表于 2022-10-30

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第31天,点击查看活动详情

前言

本文将介绍如何在 PyTorch 中构建一个简单的卷积神经网络,并训练它使用 MNIST 数据集识别手写数字,这将可以被看做是图像识别的 “Hello, World!”;

MNIST 包含 70,000 张手写数字图像:60,000 张用于训练,10,000 张用于测试。这些图像是灰度的,28x28 像素,居中以减少预处理并更快地开始。

配置环境

在本文中,我们将使用 PyTorch 训练卷积神经网络来识别 MNIST 的手写数字。 PyTorch 是一个非常流行的深度学习框架,如 Tensorflow、CNTK 和 Caffe2。但与这些其他框架不同,PyTorch 具有动态执行图,这意味着计算图是动态创建的。

1
2
py复制代码import torch
import torchvision

这里关于 PyTorch 的环境搭建就不再赘述了;

PyTorch 的官方文档链接:PyTorch documentation,在这里不仅有 API的说明还有一些经典的实例可供参考,中文文档点这!

准备数据集

完成环境导入之后,我们可以继续准备我们将使用的数据。

但在此之前,我们将定义我们将用于实验的超参数。在这里,epoch 的数量定义了我们将在整个训练数据集上循环多少次,而 learning_rate 和 momentum 是我们稍后将使用的优化器的超参数。

1
2
3
4
5
6
7
8
9
10
py复制代码n_epochs = 3
batch_size_train = 64
batch_size_test = 1000
learning_rate = 0.01
momentum = 0.5
log_interval = 10

random_seed = 1
torch.backends.cudnn.enabled = False
torch.manual_seed(random_seed)

image.png

对于可重复的实验,我们必须为任何使用随机数生成的值设置随机种子:numpy 和 random;

而且,由于 cuDNN 使用非确定性算法,可以通过设置 torch.backends.cudnn.enabled = False 禁用该算法。

现在我们还需要数据集 DataLoaders,这就是 TorchVision 发挥作用的地方。它让我们以方便的方式使用加载 MNIST 数据集。下面用于 Normalize() 转换的值 0.1307 和 0.3081 是 MNIST 数据集的全局平均值和标准差,我们将在此处将它们作为给定值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
py复制代码train_loader = torch.utils.data.DataLoader(
torchvision.datasets.MNIST(path, train=True, download=False,
transform=torchvision.transforms.Compose([
torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize(
(0.1307,), (0.3081,))
])),
batch_size=batch_size_train, shuffle=True)

test_loader = torch.utils.data.DataLoader(
torchvision.datasets.MNIST(path, train=False, download=False,
transform=torchvision.transforms.Compose([
torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize(
(0.1307,), (0.3081,))
])),
batch_size=batch_size_test, shuffle=True)
1
2
3
4
5
6
ruby复制代码Downloading http://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz
Downloading http://yann.lecun.com/exdb/mnist/train-labels-idx1-ubyte.gz
Downloading http://yann.lecun.com/exdb/mnist/t10k-images-idx3-ubyte.gz
Downloading http://yann.lecun.com/exdb/mnist/t10k-labels-idx1-ubyte.gz
Processing...
Done!

TIP: 如果你可以接受等待时间的话,可以改动 download=True,不然的话,就自己先下载,然后在设置路径;

PyTorch 的 DataLoader 包含一些有趣的选项,而不是数据集和批量大小。例如,我们可以使用 num_workers > 1 来使用子进程异步加载数据或使用固定 RAM(via pin_memory)来加速 RAM 到 GPU 的传输。

使用数据集

接下来使用一下 test_loader:

1
2
py复制代码examples = enumerate(test_loader)
batch_idx, (example_data, example_targets) = next(examples)

image.png

所以一个测试数据批次是一个形状张量:这意味着我们有 1000 个 28x28 像素的灰度示例(即没有 rgb 通道,因此只有一个)。可以使用 matplotlib 绘制其中的一些:

1
2
3
4
5
6
7
8
9
10
py复制代码import matplotlib.pyplot as plt

fig = plt.figure()
for i in range(6):
plt.subplot(2,3,i+1)
plt.tight_layout()
plt.imshow(example_data[i][0], cmap='gray', interpolation='none')
plt.title("Ground Truth: {}".format(example_targets[i]))
plt.xticks([])
plt.yticks([])

image.png

后记

当你完成上述工作后,且一切正常,那么你的准备工作就完成了!接下来,就是要构建一个简单的卷积神经网络,并训练它使用 MNIST 数据集识别手写数字;

📝 上篇精讲:【项目实战】—— SSM 图书管理系统

💖 我是 𝓼𝓲𝓭𝓲𝓸𝓽,期待你的关注;

👍 创作不易,请多多支持;

🔥 系列专栏: 项目实战 AI

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

计算机系统

发表于 2022-10-29

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

计算机系统是程序员的知识体系中最基础的理论知识,你越早掌握这些知识,你就能越早享受知识带来的 “复利效应”。

本文是计算机系统系列的第 12 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在前面的文章里,我们聊到了 CPU 的高速缓存机制。由于 CPU 和内存的速度差距太大,现代计算机会在两者之间插入一块高速缓存。

然而,CPU 缓存总能提高程序性能吗,有没有什么情况 CPU 缓存反而会成为程序的性能瓶颈?这就是我们今天要讨论的伪共享(False Sharing)。


学习路线图:


  1. 回顾 MESI 缓存一致性协议

由于 CPU 和内存的速度差距太大,为了拉平两者的速度差,现代计算机会在两者之间插入一块速度比内存更快的高速缓存,CPU 缓存是分级的,有 L1 / L2 / L3 三级缓存。

由于单核 CPU 的性能遇到瓶颈(主频与功耗的矛盾),芯片厂商开始在 CPU 芯片里集成多个 CPU 核心,每个核心有各自的 L1 / L2 缓存。其中 L1 / L2 缓存是核心独占的,而 L3 缓存是多核心共享的。为了保证同一份数据在内存和多个缓存副本中的一致性,现代 CPU 会使用 MESI 等缓存一致性协议保证系统的数据一致性。

缓存一致性问题

MESI 协议

现在,我们的问题是:CPU 缓存总能够提高程序性能吗?


  1. 什么是伪共享?

基于局部性原理的应用,CPU Cache 在读取内存数据时,每次不会只读一个字或一个字节,而是一块块地读取,每一小块数据也叫 CPU 缓存行(CPU Cache Line)。

在并行场景中,当多个处理器核心修改同一个缓存行变量时,有 2 种情况:

  • 情况 1 - 修改同一个变量: 两个处理器并行修改同一个变量的情况,CPU 会通过 MESI 机制维持两个核心的缓存中的数据一致性(Conherence)。简单来说,一个核心在修改数据时,需要先向所有核心广播 RFO 请求,将其它核心的 Cache Line 置为 “已失效”。其它核心在读取或写入 “已失效” 数据时,需要先将其它核心 “已修改” 的数据写回内存,再从内存读取;

事实上,多个核心修改同一个变量时,使用 MESI 机制维护数据一致性是必要且合理的。但是多个核心分别访问不同变量时,MESI 机制却会出现不符合预期的性能问题。

  • 情况 2 - 修改不同变量: 两个处理器并行修改不同变量的情况,从程序员的逻辑上看,两个核心没有数据依赖关系,因此每次写入操作并不需要把其他核心的 Cache Line 置为 “已失效”。但从 CPU 的缓存一致性机制上看,由于 CPU 缓存的颗粒度是一个个缓存行,而不是其中的一个个变量。当修改其中的一个变量后,缓存控制机制也必须把其它核心的整个 Cache Line 置为 “已失效”。

在高并发的场景下,核心的写入操作就会交替地把其它核心的 Cache Line 置为失效,强制对方刷新缓存数据,导致缓存行失去作用,甚至性能比串行计算还要低。

这个问题我们就称为伪共享问题。

出现伪共享问题时,有可能出现程序并行执行的耗时比串行执行的耗时还要长。耗时排序: 并行执行有伪共享 > 串行执行 > 并行执行无伪共享。

伪共享性能测试

—— 数据引用自 Github · falseSharing —— MJjainam 著


  1. 缓存行填充

那么,怎么解决伪共享问题呢?其实方法很简单 —— 缓存行填充:

  • 1、分组: 首先需要考虑哪些变量是独立变化的,哪些变量是协同变化的。协同变化的变量放在一组,而无关的变量分到不同组;
  • 2、填充: 在变量前后填充额外的占位变量,避免变量和其他分组的被填充到同一个缓存行中,从而规避伪共享问题。

下面,我们以 Java 为例介绍如何做缓存行填充,在不同 Java 版本上填充的实现方式不同:

  • Java 8 之前

通过填充 long 变量填充 Padding。 网上有的资料会将前置填充和后置填充放在同一个类中, 这是不对的。例如:

错误示例

1
2
3
4
5
java复制代码public class Data {
long a1,a2,a3,a4,a5,a6,a7; // 前置填充
volatile int value;
long b1,b2,b3,b4,b5,b6,b7; // 后置填充
}

在 《对象的内存分为哪几个部分?》 这篇文章中,我们分析 Java 对象的内存布局:其中我们提到:“其中,父类声明的实例字段会放在子类实例字段之前,而字段间的并不是按照源码中的声明顺序排列的,而是相同宽度的字段会分配在一起:引用类型 > long/double > int/float > short/char > byte/boolean。”

Java 对象内存布局

因此,上面的代码中,所有填充变量都变成前置填充了,并没有起到填充的效果:

实验验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bash复制代码# 使用 JOL 工具输出对象内存布局:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
# 填充无效
12 4 int Data.value 0
16 8 long Data.a1 0
24 8 long Data.a2 0
32 8 long Data.a3 0
40 8 long Data.a4 0
48 8 long Data.a5 0
56 8 long Data.a6 0
64 8 long Data.a7 0
72 8 long Data.b1 0
80 8 long Data.b2 0
88 8 long Data.b3 0
96 8 long Data.b4 0
104 8 long Data.b5 0
112 8 long Data.b6 0
120 8 long Data.b7 0
Instance size: 128 bytes

正确的做法是利用父子类继承来做缓存行填充:

正确示例

1
2
3
4
5
6
7
8
9
10
11
java复制代码public abstract class SuperPadding {
long a1,a2,a3,a4,a5,a6,a7; // 前置填充
}

public abstract class DataField extends SuperPadding {
volatile int value;
}

public class Data extends DataField {
long b1,b2,b3,b4,b5,b6,b7; // 后置填充
}

实验验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bash复制代码# 使用 JOL 工具输出对象内存布局:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) bf c1 00 f8 (10111111 11000001 00000000 11111000) (-134168129)
12 4 (alignment/padding gap)
16 8 long SuperPadding.a1 0
24 8 long SuperPadding.a2 0
32 8 long SuperPadding.a3 0
40 8 long SuperPadding.a4 0
48 8 long SuperPadding.a5 0
56 8 long SuperPadding.a6 0
64 8 long SuperPadding.a7 0
72 4 int DataField.value 0
76 4 (alignment/padding gap)
80 8 long Data.b1 0
88 8 long Data.b2 0
96 8 long Data.b3 0
104 8 long Data.b4 0
112 8 long Data.b5 0
120 8 long Data.b6 0
128 8 long Data.b7 0
Instance size: 136 bytes

缓存行填充

例如,Java 并发框架 Disruptor 就是使用继承的方式实现:

Disruptor · RingBuffer.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码abstract class RingBufferPad {
protected long p1, p2, p3, p4, p5, p6, p7;
}

abstract class RingBufferFields<E> extends RingBufferPad {
// 前置填充:父类的 7 个 long 变量
...
private final long indexMask;
private final Object[] entries;
protected final int bufferSize;
protected final Sequencer sequencer;
...
// 后置填充:子类的 7 个 long 变量
}

public final class RingBuffer<E> extends RingBufferFields<E> implements Cursored, EventSequencer<E>, EventSink<E> {
protected long p1, p2, p3, p4, p5, p6, p7;
...
}
  • Java 8 开始

@sun.misc.Contended 注解是 JDK 1.8 新增的注解。如果 JVM 开启字节填充功能 -XX:-RestrictContended ,在运行时就会在变量或类前后填充 Padding。
Java 8 Thread.java

1
2
3
4
5
6
7
8
9
10
11
java复制代码 /** The current seed for a ThreadLocalRandom */
@sun.misc.Contended("tlr")
long threadLocalRandomSeed;

/** Probe hash value; nonzero if threadLocalRandomSeed initialized */
@sun.misc.Contended("tlr")
int threadLocalRandomProbe;

/** Secondary seed isolated from public ThreadLocalRandom sequence */
@sun.misc.Contended("tlr")
int threadLocalRandomSecondarySeed;

Java 8 ConcurrentHashMap.java

1
2
3
4
java复制代码@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}

  1. 总结

  • 1、在并行场景中,当多个处理器核心修改同一个缓存行变量时,即使两个变量没有逻辑上的数据依赖性,CPU 缓存一致性机制也会使得两个核心中的缓存交替地失效,拉低程序的性能。这种现象叫伪共享问题;
  • 2、解决伪共享问题的方法是缓冲行填充:在变量前后填充额外的占位变量,避免变量和其他分组的被填充到同一个缓存行中,从而规避伪共享问题。

参考资料

  • 深入浅出计算机组成原理(第 37 讲) —— 徐文浩 著,极客时间 出品
  • 字节面:什么是伪共享? —— 小林 Coding 著
  • Be careful when trying to eliminate false sharing in Java —— nitsanw 著
  • False Sharing && Java 7 —— Martin Thompson 著
  • False sharing —— Wikepedia

推荐阅读

计算机系统系列完整目录如下(2023/07/11 更新):

  • #1 从图灵机到量子计算机,计算机可以解决所有问题吗?
  • #2 一套用了 70 多年的计算机架构 —— 冯·诺依曼架构
  • #3 为什么计算机中的负数要用补码表示?
  • #4 今天一次把 Unicode 和 UTF-8 说清楚
  • #5 为什么浮点数运算不精确?(阿里笔试)
  • #6 计算机的存储器金字塔长什么样?
  • #7 程序员学习 CPU 有什么用?
  • #8 我把 CPU 三级缓存的秘密,藏在这 8 张图里
  • #9 图解计算机内部的高速公路 —— 总线系统
  • #10 12 张图看懂 CPU 缓存一致性与 MESI 协议,真的一致吗?
  • #11 已经有 MESI 协议,为什么还需要 volatile 关键字?
  • #12 什么是伪共享,如何避免?

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

计算机系统

发表于 2022-10-27

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

计算机系统是程序员的知识体系中最基础的理论知识,你越早掌握这些知识,你就能越早享受知识带来的 “复利效应”。

本文是计算机系统系列的第 11 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在上一篇文章里,我们聊到了 CPU 的缓存一致性问题,分为纵向的 Cache 与内存的一致性问题以及横向的多个核心 Cache 的一致性问题。我们也讨论了 MESI 协议通过写传播和事务串行化实现缓存一致性。

不知道你是不是跟我一样,在学习 MESI 协议的时候,自然地产生了一个疑问:在不考虑写缓冲区和失效队列的影响下,在硬件层面已经实现了缓存一致性,那么在 Java 语言层面为什么还需要定义 volatile 关键字呢?是多此一举吗?今天我们将围绕这些问题展开。


学习路线图:


  1. 回顾 MESI 缓存一致性协议

由于 CPU 和内存的速度差距太大,为了拉平两者的速度差,现代计算机会在两者之间插入一块速度比内存更快的高速缓存,CPU 缓存是分级的,有 L1 / L2 / L3 三级缓存。其中 L1 / L2 缓存是核心独占的,而 L3 缓存是多核心共享的。

在 CPU Cache 的三级缓存中,会存在 2 个缓存一致性问题:

  • 纵向 - Cache 与内存的一致性问题:通过写直达或写回策略解决;
  • 横向 - 多核心 Cache 的一致性问题:通过 MESI 等缓存一致性协议解决。

MESI 协议能够满足写传播和事务串行化 2 点特性,通过 “已修改、独占、共享、已失效” 4 个状态实现了 CPU Cache 的一致性;

现代 CPU 为了提高并行度,会在增加写缓冲区 & 失效队列将 MESI 协议的请求异步化,这其实是一种处理器级别的指令重排,会破坏了 CPU Cache 的一致性。

Cache 不一致问题

MESI 协议在线模拟

网站地址:www.scss.tcd.ie/Jeremy.Jone…

现在,我们的问题是:既然 CPU 已经实现了 MESI 协议,为什么 Java 语言层面还需要定义 volatile 关键字呢?岂不是多此一举?你可能会说因为写缓冲区和失效队列破坏了 Cache 一致性。好,那不考虑这个因素的话,还需要定义 volatile 关键字吗?

其实,MESI 解决数据一致性(Data Conherence)问题,而 volatile 解决顺序一致性(Sequential Consistency)问题。 WC,这两个不一样吗?


  1. 数据一致性 vs 顺序一致性

2.1 数据一致性

数据一致性讨论的是同一份数据在多个副本之间的一致性问题, 你也可以理解为多个副本的状态一致性问题。例如内存与多核心 Cache 副本之间的一致性,或者数据在主从数据库之间的一致性。

当我们从 CPU 缓存一致性问题开始,逐渐讨论到 Cache 到内存的写直达和写回策略,再讨论到 MESI 等缓存一致性协议,从始至终我们讨论的都是 CPU 缓存的 “数据一致性” 问题,只是为了简便我们从没有刻意强调 “数据” 的概念。

数据一致性有强弱之分:

  • 强数据一致性: 保证在任意时刻任意副本上的同一份数据都是相同的,或者允许不同,但是每次使用前都要刷新确保数据一致,所以最终还是一致。
  • 弱数据一致性: 不保证在任意时刻任意副本上的同一份数据都是相同的,也不要求使用前刷新,但是随着时间的迁移,不同副本上的同一份数据总是向趋同的方向变化,最终还是趋向一致。

例如,MESI 协议就是强数据一致性的,但引入写缓冲区或失效队列后就变成弱数据一致性,随着缓冲区和失效队列被消费,各个核心 Cache 最终还是会趋向一致状态。

2.2 顺序一致性

顺序一致性讨论的是对多个数据的多次操作顺序在整个系统上的一致性。在并发编程中,存在 3 种指令顺序:

  • 编码顺序(Progrom Order): 指源码中指令的编写顺序,是程序员视角看到的指令顺序,不一定是实际执行的顺序;
  • 执行顺序(Memory Order): 指单个线程或处理器上实际执行的指令顺序;
  • 全局执行顺序(Global Memory Order): 每个线程或处理器上看到的系统整体的指令顺序,在弱顺序一致性模型下,每个线程看到的全局执行顺序可能是不同的。

顺序一致性模型是计算机科学家提出的一种理想参考模型,为程序员描述了一个极强的全局执行顺序一致性,由 2 个特性组成:

  • 特性 1 - 执行顺序与编码顺序一致: 保证每个线程中指令的执行顺序与编码顺序一致;
  • 特性 2 - 全局执行顺序一致: 保证每个指令的结果会同步到主内存和各个线程的工作内存上,使得每个线程上看到的全局执行顺序一致。

举个例子,线程 A 和线程 B 并发执行,线程 A 执行 A1 → A2 → A3,线程 B 执行 B1 → B2 → B3。那么,在顺序一致性内存模型下,虽然程序整体执行顺序是不确定的,但是线程 A 和线程 B 总会按照 1 → 2 → 3 编码顺序执行,而且两个线程总能看到相同的全局执行顺序。

顺序一致性内存模型

2.3 弱顺序一致性(一定要理解)

虽然顺序一致性模型对程序员非常友好,但是对编译器和处理器却不见得喜闻乐见。如果程序完全按照顺序一致性模型来实现,那么处理器和编译器的很多重排序优化都要被禁止,这对程序的 “并行度” 会有影响。例如:

  • 1、重排序问题: 编译器和处理器不能重排列没有依赖关系的指令;
  • 2、内存可见性问题: CPU 不能使用写回策略,也不能使用写缓冲区和失效队列机制。其实,从内存的视角看也是指令重排问题。

所以,在 Java 虚拟机和处理器实现中,实际上使用的是弱顺序一致性模型:

  • 特性 1 - 不要求执行顺序与编码顺序一致: 不要求单线程的执行顺序与编码顺序一致,只要求执行结果与强顺序执行的结果一致,而指令是否真的按编码顺序执行并不关心。因为结果不变,从程序员的视角看程序就是按编码顺序执行的假象;
  • 特性 2 - 不要求全局执行顺序一致: 允许每个线程看到的全局执行顺序不一致,甚至允许看不到其他线程已执行指令的结果。

举个单线程的例子: 在这段计算圆面积的代码中,在弱顺序一致性模型下,指令 A 和 指令 B 可以不按编码顺序执行。因为 A 和 B 没有数据依赖,所以对最终的结果也没有影响。但是 C 对 A 和 B 都有数据依赖,所以 C 不能重排列到 A 或 B 的前面,否则会改变程序结果。

伪代码

1
2
3
java复制代码double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C(数据依赖于 A 和 B,不能重排列到前面执行)

指令重排

再举个多线程的例子: 我们在 ChangeThread 线程修改变量,在主线程观察变量的值。在弱顺序一致性模型下,允许 ChangeThread 线程 A 指令的执行结果不及时同步到主线程,在主线程看来就像没执行过 A 指令。

这个问题我们一般会理解为内存可见性问题,其实我们可以统一理解为顺序一致性问题。 主线程看不到 ChangeThread 线程 A 指令的执行结果,就好像两个线程看到的全局执行顺序不一致:ChangeThread 线程看到的全局执行顺序是:[B],而主线程看到的全局执行顺序是 []。

可见性示例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码public class VisibilityTest {
public static void main(String[] args) {
ChangeThread thread = new ChangeThread();
thread.start();
while (true) {
if (thread.flag) { // B
System.out.println("Finished");
return;
}
}
}

public static class ChangeThread extends Thread {
private boolean flag = false;

@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true; // A
System.out.println("Change flag = " + flag);
}
}
}

程序输出

1
2
bash复制代码Change flag = true
// 无限等待

前面你说到编译器和处理器的重排序,为什么指令可以重排序,为什么重排序可以提升性能,重排序不会出错吗?


  1. 什么是指令重排序?

3.1 重排序类型

从源码到指令执行一共有 3 种级别重排序:

  • 1、编译器重排序: 例如将循环内重复调用的操作提前到循环外执行;
  • 2、处理器系统重排序: 例如指令并行技术将多条指令重叠执行,或者使用分支预测技术提前执行分支的指令,并把计算结果放到重排列缓冲区(Reorder Buffer)的硬件缓存中,当程序真的进入分支后直接使用缓存中的结算结果;
  • 3、存储器系统重排序: 例如写缓冲区和失效队列机制,即是可见性问题,从内存的角度也是指令重排问题。

指令重排序类型

3.2 什么是数据依赖性?

编译器和处理器在重排序时,会遵循数据依赖性原则,不会试图改变存在数据依赖关系的指令顺序。如果两个操作都是访问同一个数据,并且其中一个是写操作,那么这两个操作就存在数据依赖性。此时一旦改变顺序,程序最终的执行结果一定会发生改变。

数据依赖性分为 3 种类型::

数据依赖性 描述 示例
写后读 写一个数据,再读这个数据 a = 1; // 写b = a; // 读
写后写 写一个数据,再写这个数据 a = 1; // 写a = 2; // 写
读后写 读一个数据,再写这个数据 b = a; // 读a = 1; // 写

3.3 指令重排序安全吗?

需要注意的是:数据依赖性原则只对单个处理器或单个线程有效,因此即使在单个线程或处理器上遵循数据依赖性原则,在多处理器或者多线程中依然有可能改变程序的执行结果。

举例说明吧。

例子 1 - 写缓冲区和失效队列的重排序: 如果是在一个处理器上执行 “写后读”,处理器不会重排这两个操作的顺序;但如果是在一个处理器上写,之后在另一个处理器上读,就有可能重排序。关于写缓冲区和失效队列引起的重排序问题,上一篇文章已经解释过,不再重复。

写缓冲区造成指令重排

例子 2 - 未同步的多线程程序中的指令重排: 在未同步的两个线程 A 和 线程 B 上分别执行这两段程序,程序的预期结果应该是 4,但实际的结果可能是 0。

线程 A

1
2
java复制代码a = 2; // A1
flag = true; // A2

线程 B

1
2
3
java复制代码while (flag) { // B1
return a * a; // B2
}

情况 1:由于 A1 和 A2 没有数据依赖性,所以编译器或处理器可能会重排序 A1 和 A2 的顺序。在 A2 将 flag 改为 true 后,B1 读取到 flag 条件为真,并且进入分支计算 B2 结果,但 A1 还未写入,计算结果是 0。此时,程序的运行结果就被重排列破坏了。

情况 2:另一种可能,由于 B1 和 B2 没有数据依赖性,CPU 可能用分支预测技术提前执行 B2,但 A1 还未写入,计算结果还是 0。此时,程序的运行结果就被重排列破坏了。

多线程的数据依赖性不被考虑

小结一下: 重排序在单线程程序下是安全的(与预期一致),但在多线程程序下是不安全的。


  1. 回答最初的问题

到这里,虽然我们的讨论还未结束,但已经足够回答标题的问题:“已经有 MESI 协议,为什么还需要 volatile 关键字?”

即使不考虑写缓冲区或失效队列,MESI 也只是解决数据一致性问题,并不能解决顺序一致性问题。在实际的计算机系统中,为了提高程序的性能,Java 虚拟机和处理器会使用弱顺序一致性模型。

在单线程程序下,弱顺序一致性与强顺序一致性的执行结果完全相同。但在多线程程序下,重排序问题和可见性问题会导致各个线程看到的全局执行顺序不一致,使得程序的执行结果与预期不一致。

为了纠正弱顺序一致性的影响,编译器和处理器都提供了 “内存屏障指令” 来保证程序关键节点的执行顺序能够与程序员的预期一致。在高级语言中,我们不会直接使用内存屏障,而是使用更高级的语法,即 synchronized、volatile、final、CAS 等语法。

那么,什么是内存屏障?synchronized、volatile、final、CAS 等语法和内存屏障有什么关联,这个问题我们在下一篇文章展开讨论,请关注。


参考资料

  • Java 并发编程的艺术(第 1、2、3 章) —— 方腾飞 魏鹏 程晓明 著
  • 深入理解 Android:Java 虚拟机 ART(第 12.4 节) —— 邓凡平 著
  • 深入理解 Java 虚拟机(第 5 部分) —— 周志明 著
  • 深入浅出计算机组成原理(第 55 讲) —— 徐文浩 著,极客时间 出品
  • CPU有缓存一致性协议(MESI),为何还需要 volatile —— 一角钱技术 著
  • 一文读懂 Java 内存模型(JMM)及 volatile 关键字 —— 一角钱技术 著
  • MESI protocol —— Wikipedia
  • Cache coherence —— Wikipedia
  • Sequential consistency —— Wikipedia
  • Out-of-order execution —— Wikipedia
  • std::memory_order —— cppreference.com

推荐阅读

计算机系统系列完整目录如下(2023/07/11 更新):

  • #1 从图灵机到量子计算机,计算机可以解决所有问题吗?
  • #2 一套用了 70 多年的计算机架构 —— 冯·诺依曼架构
  • #3 为什么计算机中的负数要用补码表示?
  • #4 今天一次把 Unicode 和 UTF-8 说清楚
  • #5 为什么浮点数运算不精确?(阿里笔试)
  • #6 计算机的存储器金字塔长什么样?
  • #7 程序员学习 CPU 有什么用?
  • #8 我把 CPU 三级缓存的秘密,藏在这 8 张图里
  • #9 图解计算机内部的高速公路 —— 总线系统
  • #10 12 张图看懂 CPU 缓存一致性与 MESI 协议,真的一致吗?
  • #11 已经有 MESI 协议,为什么还需要 volatile 关键字?
  • #12 什么是伪共享,如何避免?

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

计算机系统

发表于 2022-10-25

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

计算机系统是程序员的知识体系中最基础的理论知识,你越早掌握这些知识,你就能越早享受知识带来的 “复利效应”。

本文是计算机系统系列的第 10 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在上一篇文章里,我们聊到了 CPU 的三级缓存结构,提到 CPU 缓存就一定会聊到 CPU 的缓存一致性问题。那么,什么是缓存一致性问题,CPU Cache 的读取和写入过程是如何执行的,MESI 缓存一致性协议又是什么?今天我们将围绕这些问题展开。


学习路线图:


  1. 回顾 CPU 三级缓存结构

由于 CPU 和内存的速度差距太大,为了拉平两者的速度差,现代计算机会在两者之间插入一块速度比内存更快的高速缓存,CPU 缓存是分级的,有 L1 / L2 / L3 三级缓存。

由于单核 CPU 的性能遇到瓶颈(主频与功耗的矛盾),芯片厂商开始在 CPU 芯片里集成多个 CPU 核心,每个核心有各自的 L1 / L2 缓存。 其中 L1 / L2 缓存是核心独占的,而 L3 缓存是多核心共享的。

基于局部性原理的应用,CPU Cache 在读取内存数据时,每次不会只读一个字或一个字节,而是一块块地读取,每一小块数据也叫 CPU 缓存行(CPU Cache Line)。 为了标识 Cache 块中的数据是否已经从内存中读取,需要在 Cache 块上增加一个 有效位(Valid bit)。

无论对 Cache 数据检查、读取还是写入,CPU 都需要知道访问的内存数据映射在 Cache 上的哪个位置,这就是 Cache - 内存地址映射问题,映射方案有直接映射、全相联映射和组相联映射 3 种方案。当缓存块满或者内存块映射的缓存块位置被占用时,就需要使用 替换策略 将旧的 Cache 块换出腾出空闲位置。

Cache - 内存的直接映射方案

基于以上结构,就会存在缓存一致性问题。


  1. 什么是 CPU 缓存一致性问题?

CPU 缓存一致性(Cache Coherence)问题指 CPU Cache 与内存的不一致性问题。事实上, 在分析缓存一致性问题时,考虑 L1 / L2 / L3 的多级缓存没有意义, 所以我们提出缓存一致性抽象模型,只考虑核心独占的缓存。

CPU 三级缓存与抽象模型

在单核 CPU 中,只需要考虑 Cache 与内存的一致性。但是在多核 CPU 中,由于每个核心都有一份独占的 Cache,就会存在一个核心修改数据后,两个核心 Cache 数据不一致的问题。因此,我认为 CPU 的缓存一致性问题应该从 2 个维度理解:

  • 纵向:Cache 与内存的一致性问题: 在修改 Cache 数据后,如何同步回内存?
  • 横向:多核心 Cache 的一致性问题: 在一个核心修改 Cache 数据后,如何同步给其他核心 Cache?

接下来,我们将围绕这两个问题展开。


  1. 纵向:Cache 与内存的一致性问题

3.1 CPU Cache 的读取过程

这一节,我们先来讨论 Cache 的读取过程。事实上,Cache 的读取过程会受到 Cache 的写入策略影响,我们暂且用相对简单的 “写直达策略” 的读取过程:

  • 1、CPU 在访问内存地址时,会先检查该地址的数据是否已经加载到 Cache 中(Valid bit 是否为 1);
  • 2、如果数据在 Cache 中,则直接读取 Cache 块上的字到 CPU 中;
  • 3、如果数据不在 Cache 中:
+ 3.1 如果 Cache 已装满或者 Cache 块被占用,先执行替换策略,腾出空闲位置;
+ 3.2 访问内存地址,并将内存地址所处的整个内存块写入到映射的 Cache 块中;
+ 3.3 读取 Cache 块上的字到 CPU 中。

读取过程(以写直达策略)

但是,CPU 不仅会读取 Cache 数据,还会修改 Cache 数据,这就是第 1 个一致性问题 —— 在修改 Cache 数据后,如何同步回内存?有 2 种写入策略:

3.2 写直达策略(Write-Through)

写直达策略是解决 Cache 与内存一致性最简单直接的方式: 在每次写入操作中,同时修改 Cache 数据和内存数据,始终保持 Cache 数据和内存数据一致:

  • 1、如果数据不在 Cache 中,则直接将数据写入内存;
  • 2、如果数据已经加载到 Cache 中,则不仅要将数据写入 Cache,还要将数据写入内存。

写直达的优点和缺点都很明显:

  • 优点: 每次读取操作就是纯粹的读取,不涉及对内存的写入操作,读取速度更快;
  • 缺点: 每次写入操作都需要同时写入 Cache 和写入内存,在写入操作上失去了 CPU 高速缓存的价值,需要花费更多时间。

写直达策略

3.3 写回策略(Write-Back)

既然写直达策略在每次写入操作都会写内存,那么有没有什么办法可以减少写回内存的次数呢?这就是写回策略:

  • 1、写回策略会在每个 Cache 块上增加一个 “脏(Dirty)” 标记位 ,当一个 Cache 被标记为脏时,说明它的数据与内存数据是不一致的;
  • 2、在写入操作时,我们只需要修改 Cache 块并将其标记为脏,而不需要写入内存;
  • 3、那么,什么时候才将脏数据写回内存呢?—— 就发生在 Cache 块被替换出去的时候:
+ 3.1 在写入操作中,如果目标内存块不在 Cache 中,需要先将内存块数据读取到 Cache 中。如果替换策略换出的旧 Cache 块是脏的,就会触发一次写回内存操作;
+ 3.2 在读取操作中,如果目标内存块不在 Cache 中,且替换策略换出的旧 Cache 块是脏的,就会触发一次写回内存操作;

可以看到,写回策略只有当一个 Cache 数据将被替换出去时判断数据的状态,“清(未修改过,数据与内存一致)” 的 Cache 块不需要写回内存,“脏” 的 Cache 块才需要写回内存。这个策略能够减少写回内存的次数,性能会比写直达更高。当然,写回策略在读取的时候,有可能不是纯粹的读取了,因为还可能会触发一次脏 Cache 块的写入。

这里还有一个设计: 在目标内存块不在 Cache 中时,写直达策略会直接写入内存。而写回策略会先把数据读取到 Cache 中再修改 Cache 数据,这似乎有点多余?其实还是为了减少写回内存的次数。虽然在未命中时会增加一次读取操作,但后续重复的写入都能命中缓存。否则,只要一直不读取数据,写回策略的每次写入操作还是需要写入内存。

写回策略

通过写直达或写回策略,我们已经能够解决 “在修改 Cache 数据后,如何同步回内存” 的问题。接下来,我们来讨论第 2 个缓存一致性问题 —— 在一个核心修改 Cache 数据后,如何同步给其他核心 Cache?


  1. 横向:多核心 Cache 的一致性问题

在单核 CPU 中,我们通过写直达策略或写回策略保持了Cache 与内存的一致性。但是在多核 CPU 中,由于每个核心都有一份独占的 Cache,就会存在一个核心修改数据后,两个核心 Cache 不一致的问题。

举个例子:

  • 1、Core 1 和 Core 2 读取了同一个内存块的数据,在两个 Core 都缓存了一份内存块的副本。此时,Cache 和内存块是一致的;
  • 2、Core 1 执行内存写入操作:
+ 2.1 在写直达策略中,新数据会直接写回内存,此时,Cache 和内存块一致。但由于之前 Core 2 已经读过这块数据,所以 Core 2 缓存的数据还是旧的。此时,Core 1 和 Core 2 不一致;
+ 2.2 在写回策略中,新数据会延迟写回内存,此时 Cache 和内存块不一致。不管 Core 2 之前有没有读过这块数据,Core 2 的数据都是旧的。此时,Core 1 和 Core 2 不一致。
  • 3、由于 Core 2 无法感知到 Core 1 的写入操作,如果继续使用过时的数据,就会出现逻辑问题。

多核 Cache 不一致

可以看到:由于两个核心的工作是独立的,在一个核心上的修改行为不会被其它核心感知到,所以不管 CPU 使用写直达策略还是写回策略,都会出现缓存不一致问题。 所以,我们需要一种机制,将多个核心的工作联合起来,共同保证多个核心下的 Cache 一致性,这就是缓存一致性机制。

4.1 写传播 & 事务串行化

缓存一致性机制需要解决的问题就是 2 点:

  • 特性 1 - 写传播(Write Propagation): 每个 CPU 核心的写入操作,需要传播到其他 CPU 核心;
  • 特性 2 - 事务串行化(Transaction Serialization): 各个 CPU 核心所有写入操作的顺序,在所有 CPU 核心看起来是一致。

第 1 个特性解决了 “感知” 问题,如果一个核心修改了数据,就需要同步给其它核心,很好理解。但只做到同步还不够,如果各个核心收到的同步信号顺序不一致,那最终的同步结果也会不一致。

举个例子:假如 CPU 有 4 个核心,Core 1 将共享数据修改为 1000,随后 Core 2 将共享数据修改为 2000。在写传播下,“修改为 1000” 和 “修改为 2000” 两个事务会同步到 Core 3 和 Core 4。但是,如果没有事务串行化,不同核心收到的事务顺序可能是不同的,最终数据还是不一致。

非事务串行化

4.2 总线嗅探 & 总线仲裁

写传播和事务串行化在 CPU 中是如何实现的呢?—— 此处隆重请出计算机总线系统。

  • 写传播 - 总线嗅探: 总线除了能在一个主模块和一个从模块之间传输数据,还支持一个主模块对多个从模块写入数据,这种操作就是广播。要实现写传播,其实就是将所有的读写操作广播到所有 CPU 核心,而其它 CPU 核心时刻监听总线上的广播,再修改本地的数据;
  • 事务串行化 - 总线仲裁: 总线的独占性要求同一时刻最多只有一个主模块占用总线,天然地会将所有核心对内存的读写操作串行化。如果多个核心同时发起总线事务,此时总线仲裁单元会对竞争做出仲裁,未获胜的事务只能等待获胜的事务处理完成后才能执行。

提示: 写传播还有 “基于目录(Directory-base)” 的实现方案。

基于总线嗅探和总线仲裁,现代 CPU 逐渐形成了各种缓存一致性协议,例如 MESI 协议。

4.3 MESI 协议

MESI 协议其实是 CPU Cache 的有限状态机,一共有 4 个状态(MESI 就是状态的首字母):

  • M(Modified,已修改): 表明 Cache 块被修改过,但未同步回内存;
  • E(Exclusive,独占): 表明 Cache 块被当前核心独占,而其它核心的同一个 Cache 块会失效;
  • S(Shared,共享): 表明 Cache 块被多个核心持有且都是有效的;
  • I(Invalidated,已失效): 表明 Cache 块的数据是过时的。

在 “独占” 和 “共享” 状态下,Cache 块的数据是 “清” 的,任何读取操作可以直接使用 Cache 数据;

在 “已失效” 和 “已修改” 状态下,Cache 块的数据是 “脏” 的,它们和内存的数据都可能不一致。在读取或写入 “已失效” 数据时,需要先将其它核心 “已修改” 的数据写回内存,再从内存读取;

在 “共享” 和 “已失效” 状态,核心没有获得 Cache 块的独占权(锁)。在修改数据时不能直接修改,而是要先向所有核心广播 RFO(Request For Ownership)请求 ,将其它核心的 Cache 置为 “已失效”,等到获得回应 ACK 后才算获得 Cache 块的独占权。这个独占权这有点类似于开发语言层面的锁概念,在修改资源之前,需要先获取资源的锁;

在 “已修改” 和 “独占” 状态下,核心已经获得了 Cache 块的独占权(锁)。在修改数据时不需要向总线发送广播,能够减轻总线的通信压力。

事实上,完整的 MESI 协议更复杂,但我们没必要记得这么细。我们只需要记住最关键的 2 点:

  • 关键 1 - 阻止同时有多个核心修改的共享数据: 当一个 CPU 核心要求修改数据时,会先广播 RFO 请求获得 Cache 块的所有权,并将其它 CPU 核心中对应的 Cache 块置为已失效状态;
  • 关键 2 - 延迟回写: 只有在需要的时候才将数据写回内存,当一个 CPU 核心要求访问已失效状态的 Cache 块时,会先要求其它核心先将数据写回内存,再从内存读取。

提示: MESI 协议在 MSI 的基础上增加了 E(独占)状态,以减少只有一份缓存的写操作造成的总线通信。

MESI 协议有一个非常 nice 的在线体验网站,你可以对照文章内容,在网站上操作指令区,并观察内存和缓存的数据和状态变化。网站地址:www.scss.tcd.ie/Jeremy.Jone…

MESI 协议在线模拟

4.4 写缓冲区 & 失效队列

MESI 协议保证了 Cache 的一致性,但完全地遵循协议会影响性能。 因此,现代的 CPU 会在增加写缓冲区和失效队列将 MESI 协议的请求异步化,以提高并行度:

  • 写缓冲区(Store Buffer)

由于在写入操作之前,CPU 核心 1 需要先广播 RFO 请求获得独占权,在其它核心回应 ACK 之前,当前核心只能空等待,这对 CPU 资源是一种浪费。因此,现代 CPU 会采用 “写缓冲区” 机制:写入指令放到写缓冲区后并发送 RFO 请求后,CPU 就可以去执行其它任务,等收到 ACK 后再将写入操作写到 Cache 上。

  • 失效队列(Invalidation Queue)

由于其他核心在收到 RFO 请求时,需要及时回应 ACK。但如果核心很忙不能及时回复,就会造成发送 RFO 请求的核心在等待 ACK。因此,现代 CPU 会采用 “失效队列” 机制:先把其它核心发过来的 RFO 请求放到失效队列,然后直接返回 ACK,等当前核心处理完任务后再去处理失效队列中的失效请求。

写缓冲区 & 失效队列

事实上,写缓冲区和失效队列破坏了 Cache 的一致性。 举个例子:初始状态变量 a 和变量 b 都是 0,现在 Core1 和 Core2 分别执行这两段指令,最终 x 和 y 的结果是什么?

Core1 指令

1
2
cpp复制代码a = 1; // A1
x = b; // A2

Core2 指令

1
2
cpp复制代码b = 2; // B1
y = a; // B2

我们知道在未同步的情况下,这段程序可能会有多种执行顺序。不管怎么执行,只要 2 号指令是在 1 号指令后执行的,至少 x 或 y 至少一个有值。但是在写缓冲区和失效队列的影响下,程序还有以意料之外的方式执行:

执行顺序(先不考虑 CPU 超前流水线控制) 结果
A1 → A2 → B1 → B2 x = 0, y = 1
A1 → B1 → A1 → B2 x = 2, y = 1
B1 → B2 → A1 → A2 x = 1, y = 0
B1 → A1 → B2 → A2 x = 2, y = 1
A2 → B1 → B2 → A1(A1 与 A2 重排) x = 0, y = 0
Core2 也会出现相同的情况,不再赘述 x = 0, y = 0

上图。

写缓冲区造成指令重排

可以看到:从内存的视角看,直到 Core1 执行 A3 来刷新写缓冲区,写操作 A1 才算真正执行了。虽然 Core 的执行顺序是 A1 → A2 → B1 → B2,但内存看到的顺序却是 A2 → B1 → B2 → A1,变量 a 写入没有同步给对变量 a 的读取,Cache 的一致性被破坏了。


  1. 总结

  • 1、在 CPU Cache 的三级缓存中,会存在 2 个缓存一致性问题:
+ **纵向 - Cache 与内存的一致性问题:** 在修改 Cache 数据后,如何同步回内存?
+ **横向 - 多核心 Cache 的一致性问题:** 在一个核心修改 Cache 数据后,如何同步给其他核心 Cache?
  • 2、Cache 与内存的一致性问题有 2 个策略:
+ **写直达策略:** 始终保持 Cache 数据和内存数据一致,在每次写入操作中都会写入内存;
+ **写回策略:** 只有在脏 Cache 块被替换出去的时候写回内存,减少写回内存的次数;
  • 3、多核心 Cache 一致性问题需要满足 2 点特性:
+ **写传播(总线嗅探):** 每个 CPU 核心的写入操作,需要传播到其他 CPU 核心;
+ **事务串行化(总线仲裁):** 各个 CPU 核心所有写入操作的顺序,在所有 CPU 核心看起来是一致。
  • 4、MESI 协议能够满足以上 2 点特性,通过 “已修改、独占、共享、已失效” 4 个状态实现了 CPU Cache 的一致性;
  • 5、现代 CPU 为了提高并行度,会在增加 写缓冲区 & 失效队列 将 MESI 协议的请求异步化, 从内存的视角看就是指令重排,破坏了 CPU Cache 的一致性。

今天,我们主要讨论了 CPU 的缓存一致性问题与对应的缓存一致性协议。这里有一个问题:既然 CPU 已经实现了 MESI 协议,已经在硬件层面实现了写传播和事务串行化,为什么 Java 语言层面还需要定义 volatile 关键字呢?岂不是多此一举?

你可能会说因为写缓冲区和失效队列破坏了 Cache 一致性。好,那不考虑这个因素的话,还需要定义 volatile 关键字吗?这个问题我们在 下一篇文章 展开讨论,请关注。


参考资料

  • 深入浅出计算机组成原理(第 37、38、39 讲) —— 徐文浩 著,极客时间 出品
  • 深入理解 Java 虚拟机(第 5 部分) —— 周志明 著
  • Java 并发编程的艺术(第 1、2、3 章) —— 方腾飞 魏鹏 程晓明 著
  • 计算机组成原理教程(第 7 章) —— 尹艳辉 王海文 邢军 著
  • 10 张图打开 CPU 缓存一致性的大门 —— 小林 Coding 著
  • CPU有缓存一致性协议(MESI),为何还需要 volatile —— 一角钱技术 著
  • Cache Miss – What It Is and How to Reduce It —— Linda D. 著
  • CPU cache —— Wikipedia
  • CPU caches —— LWN.net
  • Cache coherence —— Wikipedia
  • Directory-based cache coherence —— Wikipedia
  • MESI protocol —— Wikipedia

推荐阅读

计算机系统系列完整目录如下(2023/07/11 更新):

  • #1 从图灵机到量子计算机,计算机可以解决所有问题吗?
  • #2 一套用了 70 多年的计算机架构 —— 冯·诺依曼架构
  • #3 为什么计算机中的负数要用补码表示?
  • #4 今天一次把 Unicode 和 UTF-8 说清楚
  • #5 为什么浮点数运算不精确?(阿里笔试)
  • #6 计算机的存储器金字塔长什么样?
  • #7 程序员学习 CPU 有什么用?
  • #8 我把 CPU 三级缓存的秘密,藏在这 8 张图里
  • #9 图解计算机内部的高速公路 —— 总线系统
  • #10 12 张图看懂 CPU 缓存一致性与 MESI 协议,真的一致吗?
  • #11 已经有 MESI 协议,为什么还需要 volatile 关键字?
  • #12 什么是伪共享,如何避免?

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

vant 4 即将正式发布,支持暗黑主题,那么是如何实现的呢

发表于 2022-10-25

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

  1. 前言

大家好,我是若川。我倾力持续组织了一年每周大家一起学习200行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(4.1k+人)第一的专栏,写有20余篇源码文章。

我们开发业务时经常会使用到组件库,一般来说,很多时候我们不需要关心内部实现。但是如果希望学习和深究里面的原理,这时我们可以分析自己使用的组件库实现。有哪些优雅实现、最佳实践、前沿技术等都可以值得我们借鉴。

相比于原生 JS 等源码。我们或许更应该学习,正在使用的组件库的源码,因为有助于帮助我们写业务和写自己的组件。

如果是 Vue 技术栈,开发移动端的项目,大多会选用 vant 组件库,目前(2022-10-24) star 多达 20.3k。我们可以挑选 vant 组件库学习,我会写一个vant 组件库源码系列专栏,欢迎大家关注。

vant 组件库源码分析系列:

  • 1.vant 4 即将正式发布,支持暗黑主题,那么是如何实现的呢
  • 2.跟着 vant 4 源码学习如何用 vue3+ts 开发一个 loading 组件,仅88行代码
  • 3.分析 vant 4 源码,如何用 vue3 + ts 开发一个瀑布流滚动加载的列表组件?
  • 4.分析 vant 4 源码,学会用 vue3 + ts 开发毫秒级渲染的倒计时组件,真是妙啊
  • 5.vant 4.0 正式发布了,分析其源码学会用 vue3 写一个图片懒加载组件!

这次我们来分析 vant4 新增的暗黑主题是如何实现的。文章中的 vant4 的版本是 4.0.0-rc.6。vant 的核心开发者是@chenjiahan,一直在更新vant 。预计不久后就会发布 vant4 正式版。

暗黑主题如图所示:

暗黑主题

也可以打开官方文档链接,自行体验。

学完本文,你将学到:

1
2
3
4
5
6
bash复制代码1. 学会暗黑主题的原理和实现
2. 学会使用 vue-devtools 打开组件文件,并可以学会其原理
3. 学会 iframe postMessage 和 addEventListener 通信
4. 学会 ConfigProvider 组件 CSS 变量实现对主题的深度定制原理
5. 学会使用 @vue/babel-plugin-jsx 编写 jsx 组件
6. 等等
  1. 准备工作

看一个开源项目,第一步应该是先看 README.md 再看贡献文档 github/CONTRIBUTING.md。

不知道大家有没有发现,很多开源项目都是英文的 README.md,即使刚开始明显是为面向中国开发者。再给定一个中文的 README.md。主要原因是因为英文是世界通用的语言。想要非中文用户参与进来,英文是必备。也就是说你开源的项目能提供英文版就提供。

2.1 克隆源码

贡献文档中有要求:You will need Node.js >= 14 and pnpm.

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码# 推荐克隆我的项目
git clone https://github.com/lxchuan12/vant-analysis
cd vant-analysis/vant

# 或者克隆官方仓库
git clone git@github.com:vant-ui/vant.git
cd vant

# Install dependencies
pnpm i

# Start development
pnpm dev

我们先来看 pnpm dev 最终执行的什么命令。

vant 项目使用的是 monorepo 结构。查看根路径下的 package.json。

2.2 pnpm dev

1
2
3
4
5
6
7
8
json复制代码// vant/package.json
{
"private": true,
"scripts": {
"prepare": "husky install",
"dev": "pnpm --dir ./packages/vant dev",
},
}

再看 packages/vant/package.json。

1
2
3
4
5
6
7
8
json复制代码// vant/packages/vant/package.json
{
"name": "vant",
"version": "4.0.0-rc.6",
"scripts": {
"dev": "vant-cli dev",
},
}

pnpm dev 最终执行的是:vant-cli dev 启动了一个服务。本文主要是讲主题切换的实现,所以我们就不深入 vant-cli dev 命令了。

执行 pnpm dev 后,命令终端输入如图所示,可以发现是使用的是目前最新版本的 vite 3.1.8。

pnpm-dev-vite

这时我们打开 http://localhost:5173/#/zh-CN/config-provider。

  1. 文档网站

打开后,我们可以按 F12 和 vue-devtools 来查看vant 官方文档的结构。如果没有安装,我们可以访问vue-devtools 官网通过谷歌应用商店去安装。如果无法打开谷歌应用商店,可以通过这个极简插件链接 下载安装。

VanDocHeader 组件

mobile 端

VanDocSimulator 组件

3.1 通过 vue-devtools 打开组件文件

打开 VanDocSimulator 组件文件

如图所示,我们通过 vue-devtools 打开 VanDocSimulator 组件文件。

曾经在我的公众号@若川视野 发起投票 发现有很多人不知道这个功能。我也曾经写过文章《据说 99% 的人不知道 vue-devtools 还能直接打开对应组件文件?本文原理揭秘》分析这个功能的原理。感兴趣的小伙伴可以查看。

我们可以看到 vant/packages/vant-cli/site/desktop/components/Simulator.vue 文件,主要是 iframe 实现的,渲染的链接是 /mobile.html#/zh-CN。我们也可以直接打开 mobile 官网 验证下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
js复制代码// vant/packages/vant-cli/site/desktop/components/Simulator.vue
<template>
<div :class="['van-doc-simulator', { 'van-doc-simulator-fixed': isFixed }]">
<iframe ref="iframe" :src="src" :style="simulatorStyle" frameborder="0" />
</div>
</template>

<script>
export default {
name: 'VanDocSimulator',

props: {
src: String,
},
// 省略若干代码
}

3.2 desktop 端

和打开 VanDocSimulator 类似,我们通过 vue-devtools 打开 VanDocHeader 组件文件。

打开了文件后,我们也可以使用 Gitlens 插件。根据 git 提交记录 feat(@vant/cli): desktop site support dark mode,查看添加暗黑模式做了哪些改动。

接着我们来看 vant/packages/vant-cli/site/desktop/components/Header.vue 文件。找到切换主题的代码位置如下:

模板部分

1
2
3
4
5
6
7
8
9
10
11
12
13
html复制代码// vant/packages/vant-cli/site/desktop/components/Header.vue

<template>
<li v-if="darkModeClass" class="van-doc-header__top-nav-item">
<a
class="van-doc-header__link"
target="_blank"
@click="toggleTheme"
>
<img :src="themeImg" />
</a>
</li>
</template>

JS部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
js复制代码// vant/packages/vant-cli/site/desktop/components/Header.vue

<script>

import { getDefaultTheme, syncThemeToChild } from '../../common/iframe-sync';

export default {
name: 'VanDocHeader',
data() {
return {
currentTheme: getDefaultTheme(),
};
},
watch: {
// 监听主题变化,移除和添加样式 class
currentTheme: {
handler(newVal, oldVal) {
window.localStorage.setItem('vantTheme', newVal);
document.documentElement.classList.remove(`van-doc-theme-${oldVal}`);
document.documentElement.classList.add(`van-doc-theme-${newVal}`);
// 我们也可以在这里加上debugger自行调试。
debugger;
// 同步到 mobile 的组件中
syncThemeToChild(newVal);
},
immediate: true,
},
},

methods: {
// 切换主题
toggleTheme() {
this.currentTheme = this.currentTheme === 'light' ? 'dark' : 'light';
},
}
}

</script>

3.3 iframe 通信 iframe-sync

上文JS代码中,有 getDefaultTheme, syncThemeToChild 函数引自文件 vant/packages/vant-cli/site/common/iframe-sync.js

文件开头主要判断 iframe 渲染完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
js复制代码// vant/packages/vant-cli/site/common/iframe-sync.js

import { ref } from 'vue';
import { config } from 'site-desktop-shared';

let queue = [];
let isIframeReady = false;

function iframeReady(callback) {
if (isIframeReady) {
callback();
} else {
queue.push(callback);
}
}

if (window.top === window) {
window.addEventListener('message', (event) => {
if (event.data.type === 'iframeReady') {
isIframeReady = true;
queue.forEach((callback) => callback());
queue = [];
}
});
} else {
window.top.postMessage({ type: 'iframeReady' }, '*');
}

后半部分主要是三个函数 getDefaultTheme、syncThemeToChild、useCurrentTheme。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
js复制代码// 获取默认的主题
export function getDefaultTheme() {
const cache = window.localStorage.getItem('vantTheme');

if (cache) {
return cache;
}

const useDark =
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
return useDark ? 'dark' : 'light';
}

// 同步主题到 iframe 用 postMessage 通信
export function syncThemeToChild(theme) {
const iframe = document.querySelector('iframe');
if (iframe) {
iframeReady(() => {
iframe.contentWindow.postMessage(
{
type: 'updateTheme',
value: theme,
},
'*'
);
});
}
}

// 接收、使用主题色
export function useCurrentTheme() {
const theme = ref(getDefaultTheme());

// 接收到 updateTheme 值
window.addEventListener('message', (event) => {
if (event.data?.type !== 'updateTheme') {
return;
}

const newTheme = event.data?.value || '';
theme.value = newTheme;
});

return theme;
}

在项目中,我们可以可以搜索 useCurrentTheme 看在哪里使用的。很容易我们可以发现 vant/packages/vant-cli/site/mobile/App.vue 文件中有使用。

3.4 mobile 端

1
2
3
4
5
6
7
8
9
10
11
12
13
html复制代码// 模板部分
// vant/packages/vant-cli/site/mobile/App.vue

<template>
<demo-nav />
<router-view v-slot="{ Component }">
<keep-alive>
<demo-section>
<component :is="Component" />
</demo-section>
</keep-alive>
</router-view>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
js复制代码// js 部分
// vant/packages/vant-cli/site/mobile/App.vue
<script>
import { watch } from 'vue';
import DemoNav from './components/DemoNav.vue';
import { useCurrentTheme } from '../common/iframe-sync';
import { config } from 'site-mobile-shared';

export default {
components: { DemoNav },

setup() {
const theme = useCurrentTheme();

watch(
theme,
(newVal, oldVal) => {
document.documentElement.classList.remove(`van-doc-theme-${oldVal}`);
document.documentElement.classList.add(`van-doc-theme-${newVal}`);

const { darkModeClass, lightModeClass } = config.site;
if (darkModeClass) {
document.documentElement.classList.toggle(
darkModeClass,
newVal === 'dark'
);
}
if (lightModeClass) {
document.documentElement.classList.toggle(
lightModeClass,
newVal === 'light'
);
}
},
{ immediate: true }
);
},
};
</script>

<style lang="less">
@import '../common/style/base';

body {
min-width: 100vw;
background-color: inherit;
}

.van-doc-theme-light {
background-color: var(--van-doc-gray-1);
}

.van-doc-theme-dark {
background-color: var(--van-doc-black);
}

::-webkit-scrollbar {
width: 0;
background: transparent;
}
</style>

上文阐述了浅色主题和暗黑主题的实现原理,我们接着来看如何通过 ConfigProvider 组件实现主题的深度定制。

  1. ConfigProvider 组件,深度定制主题

这个组件的文档有说明,主要就是利用 CSS 变量
来实现的,具体可以查看这个链接学习。这里举个简单的例子。

1
2
3
4
5
6
7
js复制代码// html
<div id="app" style="--van-color: black;--van-background-color: pink;">hello world</div>
// css
#app {
color: var(--van-color);
background-color: var(--van-background-color);
}

可以预设写好若干变量,然后在 style 中修改相关变量,就能得到相应的样式,从而达到深度定制修改主题的能力。

比如:如果把 --van-color: black;,改成 --van-color: red; 则字体颜色是红色。
如果把 --van-background-color: pink; 改成 --van-background-color: white; 则背景色是白色。

vant 中有一次提交把之前所有的 less 变量,改成了原生 css 的 var 变量。breaking change: no longer support less vars

vant 中 ConfigProvider 组件其实就是利用了这个原理。

知晓了上面的原理,我们再来简单看下 ConfigProvider 具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
js复制代码// vant/packages/vant/src/config-provider/ConfigProvider.tsx
// 代码有省略
function mapThemeVarsToCSSVars(themeVars: Record<string, Numeric>) {
const cssVars: Record<string, Numeric> = {};
Object.keys(themeVars).forEach((key) => {
cssVars[`--van-${kebabCase(key)}`] = themeVars[key];
});
// 把 backgroundColor 最终生成类似这样的属性
// {--van-background-color: xxx}
return cssVars;
}

export default defineComponent({
name,

props: configProviderProps,

setup(props, { slots }) {
// 完全可以在你需要的地方打上 debugger 断点
debugger;
const style = computed<CSSProperties | undefined>(() =>
mapThemeVarsToCSSVars(
extend(
{},
props.themeVars,
props.theme === 'dark' ? props.themeVarsDark : props.themeVarsLight
)
)
);

// 主题变化添加和移除相应的样式类
if (inBrowser) {
const addTheme = () => {
document.documentElement.classList.add(`van-theme-${props.theme}`);
};
const removeTheme = (theme = props.theme) => {
document.documentElement.classList.remove(`van-theme-${theme}`);
};

watch(
() => props.theme,
(newVal, oldVal) => {
if (oldVal) {
removeTheme(oldVal);
}
addTheme();
},
{ immediate: true }
);

onActivated(addTheme);
onDeactivated(removeTheme);
onBeforeUnmount(removeTheme);
}

// 插槽
// 用于 style
// 把 backgroundColor 最终生成类似这样的属性
// {--van-background-color: xxx}

return () => (
<props.tag class={bem()} style={style.value}>
{slots.default?.()}
</props.tag>
);
},
});

有小伙伴可能注意到了,这感觉就是和 react 类似啊。其实 vue 也是支持 jsx。不过需要配置插件 @vue/babel-plugin-jsx。全局搜索这个插件,可以搜索到在 vant-cli 中配置了这个插件。

  1. 总结

我们通过查看 README.md 和贡献文档等,知道了项目使用的 monorepo,vite 等,pnpm i 安装依赖,pnpm dev 跑项目。

我们学会了利用 vue-devtools 快速找到我们不那么熟悉的项目中的文件,并打开相应的文件。

通过文档桌面端和移动端的主题切换,我们学到了原来是 iframe 渲染的移动(mobile)端,通过 iframe postMessage 和 addEventListener 通信切换主题。

学会了 ConfigProvider 组件是利用
CSS 变量 预设变量样式,来实现的定制主题。

也学会使用 @vue/babel-plugin-jsx 编写 jsx 组件,和写 react 类似。

相比于原生 JS 等源码。我们或许更应该学习,正在使用的组件库的源码,因为有助于帮助我们写业务和写自己的组件。开源项目通常有很多优雅实现、最佳实践、前沿技术等都可以值得我们借鉴。

如果是自己写开源项目相对耗时耗力,而且短时间很难有很大收益,很容易放弃。而刚开始可能也无法参与到开源项目中,这时我们可以先从看懂开源项目的源码做起。对于写源码来说,看懂源码相对容易。看懂源码后可以写文章分享回馈给社区,也算是对开源做出一种贡献。重要的是行动起来,学着学着就会发现很多都已经学会,锻炼了自己看源码的能力。

如果看完有收获,欢迎点赞、评论、分享支持。你的支持和肯定,是我写作的动力。

最后可以持续关注我@若川。这是 vant 第一篇文章。我会写一个组件库源码系列专栏,欢迎大家关注。

我倾力持续组织了一年每周大家一起学习200行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。

另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(4.1k+人)第一的专栏,写有20余篇源码文章。包含jQuery、underscore、lodash、vuex、sentry、axios、redux、koa、vue-devtools、vuex4、koa-compose、vue 3.2 发布、vue-this、create-vue、玩具vite、create-vite 等20余篇源码文章。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

计算机系统

发表于 2022-10-25

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

计算机系统是程序员的知识体系中最基础的理论知识,你越早掌握这些知识,你就能越早享受知识带来的 “复利效应”。

本文是计算机系统系列的第 9 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在之前的文章中,我们聊到了计算机的冯·诺依曼计算机架构,计算机由五大部件组成。那么,计算机的五大部件是如何连接成一个整体的呢?这就需要依赖总线系统。


思维导图:


  1. 认识计算机总线系统

1.1 什么是总线?

在冯·诺依曼计算机架构中,计算机由控制器、运算器、存储器、输入设备和输出设备五各部分组成,而这五个部分必须进行 “连接” 起来相互通信才能形成一个完整的整体。 总线就是连接多个计算机部件的数据通信规范。

PC 计算机主板

—— 图片引用自 Wikipedia

1.2 为什么要使用总线结构?

先解释一下为什么现代的计算机系统要采用总线结构:

  • 原因 1 - 降低复杂性: 这个设计思路跟软件开发中的中介者模式是相同的。总线结构将 N-N 网型拓扑结构简化为 N-1-N 总线型结构或星型+总线型拓扑结构,不仅整体的系统结构清晰许多,可以提高系统稳定性。而且需要使用的布线数目也减少了,制造成本也更低;
  • 原因 2 - 促进标准化: 总线结构提供了一个标准化的数据交换方式,各个硬件按照总线的标准实现接口,而无需考虑对方接口或总线的工作原理,有利于各个部件模块化设计。

网状拓扑和总线拓扑对比


  1. 总线的内部结构

总线本身的电路功能,又可以拆分成 3 部分:

  • 1、地址总线(Address Bus,AB): 地址总线传输的是地址信号。地址总线是单向的,地址信息只能从主设备发往从设备。地址总线宽度也决定了一个 CPU 的寻址能力,即多大可以访问多少数据空间。举个例子,32 位地址总线可以寻址 4GB 的数据空间;
  • 2、控制总线(Control Bus,CB): 控制总线传输控制或状态信号。控制总线是双向的,信号可以从主模块发往从模块,也可以从从模块发往主模块(例如 CPU 对存储器的读写控制信号,例如 I/O 设备对 CPU 中断请求信号);
  • 3、数据总线(Data Bus,DB): 数据总线传输的是实际的数据信息。数据总线是双向的,数据可以从主模块发往从模块(例如 CPU 向内存的写入操作),也可以从从模块发往主模块(例如 CPU 向内存的读取操作)。

举个例子,当 CPU 要从存储器读取数据时,三类总线的工作过程概要如下:

  • 1、CPU 通过地址总线发送要访问的存储单元的地址信息;
  • 2、CPU 通过控制总线发送读控制信号;
  • 3、存储器通过数据总线发送指定存储单元上的数据,从 CPU 的视角就是读取。

总线内部结构


  1. 总线系统的架构

理解了总线的概念后,我们先来看总线系统的整体架构,现代计算机中的总线大多采用分层次多总线架构。

3.1 单总线架构和多总线架构

在早期计算机中,会使用单一总线来连接计算机的各个部件,这种结构叫单总线架构。这种结构实现简单,但缺点有 2 个:

  • 缺点 1: 计算机不同组件之间的速度差较大,例如 CPU 与内存或 I/O 设备的速度差非常大,当传输数据量很大时,CPU 经常需要等待;
  • 缺点 2: 所有的信号都要经过同一个共享的总线,不允许两个以上的部件同时传输信号。

单总线架构

因此,单总线系统很容易形成系统的性能瓶颈,就算是增大总线的带宽也无法从根本上解决系统性缺陷。目前,单总线结构只出现在微型计算机中。大多数现代计算机都采用了分层次多总线结构,所有的设计思路都是围绕单总线架构存在的 2 个缺点展开的:

  • 应对缺点 1: 将高速部件和低速部件分为不同层级,不同层级之间使用独立的总线,减少高速部件对低速部件的等待;
  • 应对缺点 2: 增加多条总线,使得数据可以同时在多个部件之间传输。

3.2 双独立总线:片内 & 片外

现代 CPU 中通常会使用高速缓存,由于 “CPU-高速缓存” 和 “CPU - 内存” 的速度差非常大,计算机系统选择在 CPU 芯片内和 CPU 芯片外使用 双独立总线(Dual Independent Bus,DIB):

  • 前端总线(Front Side Bus,FSB): CPU 与外部连接的总线(即 CPU 连接北桥芯片的总线);
  • 后端总线(Back Side Bus,BSB): 也叫本地总线(Local Bus)或片内总线(On-chip Bus),是 CPU 芯片内部独立使用的总线。CPU 芯片内部一个或多个核心、Cache 之间的通信将不需要占用芯片外的系统总线。

提示: 前端总线和系统总线的概念容易混淆,不同资料的说法不一。我的理解是:前端总线是 “特指” 某些 Intel CPU 架构中,CPU 芯片与外部连接的这条总线,而系统总线 “泛指” 连接计算机各个部件的所有总线。小彭在后续专栏内容都会按照此理解讨论。

前端总线和后端总线

3.3 南北桥架构

南北桥架构是 Intel 提出的总线架构,也叫 Hub 架构 。它将计算机部件分为高速部件和低速部件两类,分为北桥芯片组合和南桥芯片组,中间用两颗桥芯片连接。使用南北桥设计有 2 个优点:

  • 1、缓冲功能: 南北桥芯片实现了两类总线信号速度缓冲;
  • 2、桥接功能: 南北桥芯片实现了两类总线信号的转换,有利于系统升级换代。例如在升级 CPU 时,只需要改动 CPU 和北桥芯片,其它南桥部分不需要改动。

南北桥架构

  • 北桥芯片(Northbridge): 北桥处理高速信号。北桥芯片连接的设备都是高速传输设备,包含 CPU、GPU、存储器与南桥的通信。北桥芯片也是 CPU 与外部连接的纽带;
  • 南桥芯片(Southbridge): 南桥处理低速信号。南桥芯片连接的大多是 I/O 设备,例如 PCI 总线、USB 适配器、显卡适配器、硬盘控制器;
  • 内存控制器(Memory Controller): 管理 CPU 和内存之间的总线数据传输,控制着存储器的读取和写入信号,并且定时刷新 DRAM 内的数据(DRAM 的存储单元包含电容,会自动漏电);
  • 内存总线(Memory Bus): 连接北桥芯片与存储器的总线;
  • DMI 总线(Direct Media Interface): 连接北桥芯片和南桥芯片的专用总线;
  • I/O 总线: 连接南桥芯片与 I/O 设备的总线;
    • PCI 局部总线: 连接高速 I/O 设备的标准;
    • ISA 局部总线: 连接低速 I/O 设备的标准。

3.4 前端总线瓶颈

前端总线是 CPU 连接外界的唯一通道,因此前端总线的数据传输能力对于计算机系统的整体性能影响非常大。 近年来随着 CPU 主频不断提升,前端总线频率却一直跟不上后端总线频率,从而出现性能瓶颈。

为了解决这个问题,传统的南北桥架构被重新设计,北桥芯片的功能几乎都移动到 CPU 内部变成 “片上北桥”。前端总线被淘汰,CPU / 片上北桥继续使用 DMI 连接南桥或 PCH 等外部设备。


  1. 总线仲裁

总线既有共享性又有独占性,听起来有点矛盾,其实是表现的时机不一样:

  • 共享性: 总线的共享性是指总线对所有连接的设备共享,主从模块能通过总线传输数据。
  • 独占性: 总线的独占性是指同一时刻,只允许一个部件占有总线的控制权,这个部件就是主模块,主模块可以与一个或多个从模块通信,但同一时刻只有一个主模块。

总线的独占性天然地将事务串行化: 如果多个部件同时向总线发出总线事务,总线仲裁(Bus Arbitration)单元会对竞争做出总裁,未获胜的事务只能等待获胜的事务处理完成后才能执行。当其中一个总线事务在执行时,其他总线事务都会被禁止。


  1. 总结

  • 1、总线就是连接多个计算机部件的数据通信规范;
  • 2、总线的电路结构由地址总线、控制总线和数据总线组成。举个例子,当 CPU 要从存储器读取数据时,三类总线的工作过程概要如下:
+ CPU 通过地址总线发送要访问的存储单元的地址信息;
+ CPU 通过控制总线发送读控制信号;
+ 存储器通过数据总线发送指定存储单元上的数据,从 CPU 的视角就是读取。
  • 3、现代计算机中的总线大多采用分层次多总线架构,由片内+片外双独立总线平衡高速缓存和内存的速度差,由南北桥架构平衡高速部件和低速部件的速度差;
  • 4、由于前端总线瓶颈和芯片集成度提高,南北桥架构逐渐被片上系统替代;
  • 5、总线具有共享性和独占性,当多个部件同时向总线发出总线事务,总线天然地将事务串行化;

参考资料

  • 深入浅出计算机组成原理(第 42 讲) —— 徐文浩 著,极客时间 出品
  • 计算机组成原理教程(第 3 章) —— 尹艳辉 王海文 邢军 著
  • 10分钟速成课 计算机科学 —— Carrie Anne 著
  • System Bus —— Wikipedia
  • Northbridge (computing) —— Wikipedia
  • Southbridge (computing) —— Wikipedia
  • HyperTransport —— Wikipedia
  • Intel QuickPath Interconnect —— Wikipedia
  • Arbiter (electronics) —— Wikipedia

推荐阅读

计算机系统系列完整目录如下(2023/07/11 更新):

  • #1 从图灵机到量子计算机,计算机可以解决所有问题吗?
  • #2 一套用了 70 多年的计算机架构 —— 冯·诺依曼架构
  • #3 为什么计算机中的负数要用补码表示?
  • #4 今天一次把 Unicode 和 UTF-8 说清楚
  • #5 为什么浮点数运算不精确?(阿里笔试)
  • #6 计算机的存储器金字塔长什么样?
  • #7 程序员学习 CPU 有什么用?
  • #8 我把 CPU 三级缓存的秘密,藏在这 8 张图里
  • #9 图解计算机内部的高速公路 —— 总线系统
  • #10 12 张图看懂 CPU 缓存一致性与 MESI 协议,真的一致吗?
  • #11 已经有 MESI 协议,为什么还需要 volatile 关键字?
  • #12 什么是伪共享,如何避免?

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Threejs 进阶之旅:Shader着色器入门

发表于 2022-10-24

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

摘要

本文内容主要介绍 Three.js 中的着色器知识,通过讲解什么是着色器、着色器的分类、GLSL 语言的核心语法要点、Three.js 中的两种着色器材质的 RawShaderMaterial 和 ShaderMaterial 的区别和用法等基本知识,深入理解着色器,并使用它创建出有趣的三维图形。

本文篇幅较长,涉及到的知识点也比较广,内容可能相对枯燥,有些地方需要耐心思考。我相信通过纵览全文,掌握全文的核心要点,一定会获益匪浅,着色器入门者建议收藏起来定期复习🤣。

效果

随着本文内容一步步深入,最终将使用着色器构建一个如下所示的波动旗帜 🚩 效果,通过滑动调整页面右上方的 dat.GUI 控制器,可以调整 x轴 和 y轴 上的波动幅度。

preview.gif

打开以下链接中的任意一个,在线预览效果,大屏访问效果更佳。

  • 👁‍🗨 在线预览地址1:dragonir.github.io/3d/#/flag
  • 👁‍🗨 在线预览地址2:3d-eosin.vercel.app/#/flag

本专栏系列代码托管在 Github 仓库【threejs-odessey】,后续所有目录也都将在此仓库中更新。

🔗 代码仓库地址:git@github.com:dragonir/threejs-ode…

码上掘金

正文

Shader着色器简介

着色器是 WebGL 的重要组件之一,它是一种使用 GLSL 语言编写的运行在 GPU 上的程序。顾名思义,着色器用于定位几何体的每个顶点,并为几何体的每个可见像素进行着色 🎨。着色器是屏幕上呈现画面之前的最后一步,用它可以实现对先前渲染结果进行修改,如颜色、位置等,也可以对先前渲染的结果做后处理,实现高级的渲染效果。

例如,对于相同场景、相同光照、相同模型等条件下,对这个模型分别使用不同的着色器,就会呈现出完全不同的渲染效果:使用 plastic shader 的模型渲染出塑料质感,而使用了 toon shader 的模型则看起来是二维卡通效果。

compare.png

为什么要使用着色器

虽然 Three.js 已经内置了非常多的材质,但是在实际开发中很难满足我们的需求,比如在数字孪生系统的开发中,我们经常需要添加一些炫酷的飞线效果、雷达效果等 ✨,它们是无法直接使用 Three.js 来生成,此时就需要我们创建自己的着色器。而且出于性能的考虑,我们也可以使用自己的着色器材质代替像 MeshStandardMaterial 这样的材质非常精细涉及大量代码和计算的材质,以便于提升页面性能。

着色器的类型

顶点着色器Vertex Shader

Vertex Shader 用于定位几何体的顶点,它的工作原理是发送顶点位置、网格变换(position、旋rotation和 scale 等)、摄像机信息(position、rotation、fov 等)。GPU 将按照 Vertex Shader 中的指令处理这些信息,然后将顶点投影到 2D 空间中渲染成 Canvas。

当使用 Vertex Shader 时,它的代码将作用于几何体的每个顶点。在每个顶点之间,有些数据会发生变化,这类数据称为 attribute;有些数据在顶点之间永远不会变化,称这种数据为 uniform。Vertex Shader 会首先触发,当顶点被放置,GPU 知道几何体的哪些像素可见,然后执行 Fragment Shader。

  • attribute:使用顶点数组封装每个顶点的数据,一般用于每个顶点都各不相同的变量,如顶点的位置。
  • uniform:顶点着色器使用的常量数据,不能被修改,一般用于对同一组顶点组成的单个 3D 物体中所有顶点都相同的变量,如当前光源的位置。

片元着色器Fragment Shader

Fragment Shader 在 Vertex Shader 之后执行,它的作用是为几何体的每个可见像素进行着色。我们可以通过uniforms 将数据发送给它,也可以将 Vertex Shader 中的数据发送给它,我们将这种从 Vertex Shader 发送到 Fragment Shader 的数据称为 varying。

Fragment Shader 中最直接的指令就是可以使用相同的颜色为所有像素进行着色。如果只设置了颜色属性,就相当于得到了与 MeshBasicMaterial 等价的材质。如果我们将光照的位置发送给 Fragment Shader,然后根据像素收到光照影响的多少来给像素上色,此时就能得到与 MeshPhongMaterial 效果等价的材质。

  • varying: 从顶点着色器发送到片元着色器中的插值计算数据

📌 以下内容示例流程翻译、并整理于《three.js journey》 shader 相关课程,如果对英文原版感兴趣可前往查看。

原始着色器材质RawShaderMaterial

在 Three.js 中可以渲染着色器的材质有两种:RawShaderMaterial 和 ShaderMaterial,它们之间的区别是 ShaderMaterial 会自动将一些初始化着色器的参数添加到代码中(内置 attributes 和 uniforms),而 RawShaderMaterial 则什么都不会添加。

我们先来看看如何使用 RawShaderMaterial 材质,首先我们创建一个平面,然后和创建其他材质一样,通过 new THREE.RawShaderMaterial 初始化原始着色器材质,并给它添加两个参数 vertexShader 和 fragmentShader 代表材质的顶点着色器和片元着色器。

1
2
3
4
js复制代码const material = new THREE.RawShaderMaterial({
vertexShader: '',
fragmentShader: ''
})

然后开始编写材质的顶点着色器和片元着色器,分别添加如下的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
js复制代码const material = new THREE.RawShaderMaterial({
vertexShader: `
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;

attribute vec3 position;

void main() {
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
precision mediump float;

void main(){
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`
});

此时可以得到一个红色的平面,说明我们编写的第一个着色器运行成功了 🎉。

step_01.png

分离两种着色器

在实际开发中,着色器比较复杂,代码量比较多,如果直接放在材质中的话会增加代码阅读困难量。我们可以将着色器代码单独拆分出来,分别存放在 vertex.glsl 和 fragment.glsl 文件中,然后在代码中像下面这样引入即可。这样做还有一个好处就是可以安装代码编辑器的 GLSL 高亮语法插件,提高编程效率。

1
2
3
4
5
6
7
js复制代码import testVertexShader from './shaders/test/vertex.glsl';
import testFragmentShader from './shaders/test/fragment.glsl';

const material = new THREE.RawShaderMaterial({
vertexShader: testVertexShader,
fragmentShader: testFragmentShader
});

此时查看页面,得到的结果还是一样的。

step_02.png

属性

材质的一些通用属性在 RawShaderMaterial 同样是适用的,比如 wireframe、side、transparent、flatShading 等都可以生效,对上面材质开启 wireframe 属性,可以得到如下图所示的平面的网格模型。

1
2
3
4
5
js复制代码const material = new THREE.RawShaderMaterial({
vertexShader: testVertexShader,
fragmentShader: testFragmentShader,
wireframe: true
});

step_03.png

📌 但是需要注意的是,像map、alphaMap、opacity、color等属性在着色器材质中会失效,我们需要在着色器代码中自己实现。

GLSL 语言

在 Three.js 中,需要使用 GLSL 语言来编写着色器,全称是 OpenGL Shading Language,意为 OpenGL 中的着色语言。它的语法类似于 C语言,在开始编写着色器之前,我们先了解一些它的基本语法。

  • 日志:由于着色器语言是针对每个顶点和每个片元执行的,日志记录是没有意义的,因此编写 GLSL 时没有控制台。
  • 缩进:代码缩进格式没有严格要求,只要易读美观就行。
  • 分号:和 C语言 一样,编写 GLSL 语言时,任何指令的结尾都必须添加分号,丢失分号就会导致代码无法运行。
  • 类型:和 C语言 一样, GLSL 是一种强类型语言,不同类型的变量不能混用,否则会报错。

变量

在 GLSL 中有很多变量类型,编写着色器时,我们需要根据需要选择合适类型的变量。

整型

用以定义整数。

1
2
glsl复制代码int foo = 123;
int bar = - 1;
浮点类型

浮点数就是小数,可以是正数也可以是负数,必须提供小数点 .。

1
2
glsl复制代码float foo = - 0.123;
float bar = 1.0;
布尔类型

用于表示值得真假。

1
2
glsl复制代码bool foo = true;
bool bar = false;
二维向量vec2

如果我们需要存储具有 x 和 y 属性这样具有2个坐标的值时,可以使用 vec2。需要注意的是,直接使用 vec2 foo = vec2() 这样未添加参数的空值会报错,应该像下面这样提供完整的参数:

1
glsl复制代码vec2 foo = vec2(1.0, 2.0);

创建 vec2 后修改属性值:

1
2
3
glsl复制代码vec2 foo = vec2(0.0);
foo.x = 1.0;
foo.y = 2.0;

进行浮点数与 vec2 相乘等操作运算时,结果将同时作用于 x 和 y:

1
2
glsl复制代码vec2 foo = vec2(1.0, 2.0);
foo *= 2.0;
三维向量vec3

与 vec2 类似,vec3 用于表示具有 x、y、z 三个坐标的值,可以用它非常方便的表示三维空间坐标。

1
2
3
glsl复制代码vec3 foo = vec3(0.0);
vec3 bar = vec3(1.0, 2.0, 3.0);
bar.z = 4.0;

RGB 颜色也同样适合使用 vec3 表示:

1
2
3
glsl复制代码vec3 color = vec3(0.0);
color.r = 0.5;
color.b = 1.0;

可以使用 vec2 来创建 vec3:

1
2
glsl复制代码vec2 foo = vec2(1.0, 2.0);
vec3 bar = vec3(foo, 3.0);

也可以使用 vec3 来创建 vec2,其中 bar 的值为 1.0, 2.0,baz 的 值为 2.0, 1.0:

1
2
3
glsl复制代码vec3 foo = vec3(1.0, 2.0, 3.0);
vec2 bar = foo.xy;
vec2 baz = foo.yx;
四维向量vec4

与前面几个类似,vec4 用于表示四维向量,四个值命名为 x, y, z, w 或 r, g, b, a,向量之间同样能进行相互转换:

1
2
glsl复制代码vec4 foo = vec4(1.0, 2.0, 3.0, 4.0);
vec4 bar = vec4(foo.zw, vec2(5.0, 6.0));

除上述之外,还有一些其它类型的变量,如 mat2、mat3、mat4、sampler2D 等将在后续学习中介绍。

  • 在着色器内,一般命名以 gl_ 开头的变量是着色器的内置变量。
  • webgl_ 和 _webgl 是着色器保留字,自定义变量不能以 webgl_ 或 _webgl 开头。
  • 变量声明一般包含 <存储限定符> <数据类型> <变量名称>,以 attribute vec4 a_Position 为例,attribute 表示存储限定符,vec 是数据类型,a_Position 为变量名。

函数

在 GLSL 中定义函数,必须以返回值的类型开头,如果没有返回值,则可以使用 void。定义函数的参数时,也必须提供参数类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
glsl复制代码// 有返回值
float loremIpsum() {
float a = 1.0;
float b = 2.0;
return a + b;
}
// 无返回值
void justDoingStuff() {
float a = 1.0;
float b = 2.0;
}
// 定义参数类型
float add(float a, float b) {
return a + b;
}
内置函数

GLSL 内置了很多使用的函数,下面列举了一些比较常用的:

  • 运算函数
    • abs(x):取 x 的绝对值
    • radians(x):角度转弧度
    • degrees(x):弧度转角度
    • sin(x):正弦函数,传入值为弧度。还有 cos 余弦函数、tan 正切函数、asin 反正弦、acos反余弦、atan 反正切等
    • pow(x,y):x^y
    • exp(x):e^x
    • exp2(x):2^x
    • log(x):logex
    • log2(x):log2x
    • sqrt(x):x√
    • inversesqr(x):1x√
    • sign(x):x>0 返回 1.0,x<0 返回 -1.0,否则返回 0.0
    • ceil(x):返回大于或者等于 x 的整数
    • floor(x):返回小于或者等于 x 的整数
    • fract(x):返回 x-floor(x) 的值
    • mod(x,y):取模求余数
    • min(x,y):获取 x、y 中小的那个
    • max(x,y):获取 x、y 中大的那个
    • mix(x,y,a):返回 x∗(1−a)+y∗a
    • step(x,a):x<a返回 0.0,否则返回 1.0。
    • smoothstep(x,y,a):a<x 返回 0.0,a>y 返回 1.0,否则返回 0.0-1.0 之间平滑的 Hermite 插值。
    • dFdx(p):p 在 x 方向上的偏导数
    • dFdy(p):p 在 y 方向上的偏导数
    • fwidth(p):p 在 x 和 y 方向上的偏导数的绝对值之和
  • 几何函数
    • length(x):计算向量 x 的长度
    • distance(x, y):返回向量 xy 之间的距离
    • dot(x,y):返回向量 xy 的点积
    • cross(x,y):返回向量 xy 的差积
    • normalize(x):返回与 x 向量方向相同,长度为 1 的向量
  • 矩阵函数
    • matrixCompMult(x,y):将矩阵相乘
    • lessThan(x,y):返回向量 xy 的各个分量执行 x<y 的结果
    • lessThanEqual(x,y):返回向量 xy 的各个分量执行 x<=y 的结果,类似的有类似的有 greaterThanEqual
    • any(bvec x):x 有一个元素为 true,则为 true
    • all(bvec x):x 所有元素为 true,则返回 true,否则返回 false
    • not(bvec x):x 所有分量执行逻辑非运算

🔗 如果想了解更多GLSL的内置函数,可以到这个网站查询:Kronos Group OpenGL reference pages

理解顶点着色器Vertex Shader

接下来讲解着色器里代码的具体内容。

顶点着色器的作用是将几何体的每个顶点放置在 2D 渲染空间上,即顶点着色器将 3D 顶点坐标转换为 2D canvas 坐标。

main函数

它将被自动调用,并且不会返回任何内容。

1
glsl复制代码void main() {}

gl_Position

gl_Position 是一个内置变量,我们只需要给它重新赋值就能使用,它将会包含屏幕上的顶点的位置。下面 main 函数中就是用于给它设置合适的值。执行这段指令后,将得到一个 vec4,意味着我们可以直接在 gl_Position 变量上使用其x、y、z 和 w 属性。

1
2
3
4
5
glsl复制代码void main() {
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
gl_Position.x += 0.5;
gl_Position.y += 0.5;
}

平面向右上角发生了位移,但是需要注意的是,我们并没有像在 Three.js 中一样将平面在三维空间中进行了移动,我们只是在二维空间中移动了平面的投影。就像你在桌子上画了一幅具有透视效果的画,然后把它向桌子右上角移动,但是你的画中的透视效果并没有发生变化。

gl_Position 的作用是在 2D 空间上定位 📍 顶点,既然是 2D 空间,为什么需要使用一个四维向量表示呢?实际上是这些坐标并不是精确的在 2D 空间,而是位于被称为 Clip Space 需要四个维度的裁切空间。裁切空间是指在 -1 到 +1 范围内所有 x、y、z 3个方向上的空间,第四个值 w 用于表示透视。就像把所有东西都放在 3D 盒子中一样,任何超出范围的内容都将被裁切。gl_Position 这些内容的这些内容都是自动完成的,我们只需明白其大概原理即可。

位置属性Position attributes

相同的代码将应用于几何体的每一个顶点,属性变量 attribute 是在顶点之间唯一会发生改变的变量。相同的顶点着色器 Vertex Shader 将应用于每一个顶点,position 属性将包含具体顶点的 x, y, z 坐标值。我们可以使用如下代码获取顶点位置:

1
glsl复制代码attribute vec3 position;

因为 gl_Position 是 vec4 类型,可以使用以下方法将 vec3 转化成 vec4:

1
glsl复制代码gl_Position = /* ... */ vec4(position, 1.0);

矩阵限定变量Matrices uniforms

每个矩阵将转换 position,直到我们获得最终的裁切空间坐标。下面是 3 个矩阵,因为在几何体所有顶点中它们的值都是相同的,我们可以通过 uniform 来获取它们。

1
2
3
glsl复制代码uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;

下面将对每个矩阵做出一些变换:

  • modelMatrix:将进行网格相关的变换,如缩放、旋转、移动等操作变换都将作用于 position。
  • viewMatrix:将进行相机相关的变换,如我们向左移动相机,顶点应该在右边、如果我们朝着网格方向移动相机,顶点会变大等。
  • projectionMatrix:会将我们的坐标转化为裁切空间坐标。

为了使用矩阵,我们需要将其相乘,如果想让一个 mat4 作为变量,则该变量类型必须是 vec4。我们也可以将多个矩阵相乘:

1
glsl复制代码gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);

实际上还可以使用更短的写法来让 viewMatrix 和 modelMatrix 组合成一个 projectionMatrix,虽然代码少了,但我们可控制的步骤也少了。

1
2
3
4
5
6
7
glsl复制代码uniform mat4 projectionMatrix;
uniform mat4 modelViewMatrix;
attribute vec3 position;

void main(){
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

实际中我们会选择更长的写法,以便于更好地理解及对 position 进行更多的控制。

1
2
3
4
5
6
7
8
9
10
11
glsl复制代码uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
attribute vec3 position;

void main(){
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectedPosition = projectionMatrix * viewPosition;~~~~
gl_Position = projectedPosition;
}

上面两种写法都是等价的,使用下面这种时,我们可以更方便地进行控制,比如可以通过调整 modelPosition 的值来对整个模型进行移动,通过以下代码,就能向上移动模型:

1
2
3
4
5
glsl复制代码void main() {
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
modelPosition.y += 1.0;
// ...
}

step_04.png

我们还可以做一些更有趣的操作,比如将平面变换为波浪形状:

1
2
3
4
5
glsl复制代码void main() {
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
modelPosition.z += sin(modelPosition.x * 10.0) * 0.1;
// ...
}

step_05.png

理解片元着色器Fragment Shader

片元着色器的代码将应用于几何体的每个可见像素,这就是片元着色器在顶点着色器之后运行的原因,它的代码比顶点着色器更易于管理。

主函数main

同样,片元着色器中也有一个主函数:

1
glsl复制代码void main() {}

精度Precision

在顶部有一条这样的指令,我们用它来决定浮点数的精度,有以下几种值供选择:

  • highp:会影响性能,在有些机器上可能无法运行;
  • mediump:常用的类型;
  • lowp:可能会由于精度问题产生错误。
1
glsl复制代码precision mediump float;

我们现在示例使用的是 RawShaderMaterial 原始着色器材质才需要设置精度,在着色器材质 ShaderMaterial中会自动处理。

在顶点着色器中也可以是指精度,但是这是非必须的。

gl_FragColor

gl_FragColor 和 gl_Position 类似,但它用于颜色。它也一样是已经被内置声明了的,我们只需要在main 函数中重新给它赋值。它是一个 vec4,前三个值是红色、绿色、蓝色通道 (r, g, b),第四个值是透明度 alpha (a)。gl_FragColor 的每个值的取值范围是 0.0 到 1.0,如果我们设置的值高于它们,也不会产生报错。

下面这段代码将生成一个紫色的几何体

1
glsl复制代码gl_FragColor = vec4(0.5, 0.0, 1.0, 1.0);

step_06.png

为了 alpha 透明度值可以生效,我们需要在材质中将 transparent 属性设置为 true:

1
2
3
4
5
js复制代码const material = new THREE.RawShaderMaterial({
vertexShader: testVertexShader,
fragmentShader: testFragmentShader,
transparent: true
})

属性Attributes

Attributes 是每个顶点之间变化的值,我们之前已经有一个命名为 position 的属性变量,它是每个顶点在坐标轴中的 vec3 值。我们将为每个顶点添加一个随机值,并根据这个值在 z 轴上移动该顶点。在 JavaScript 代码中我们可以像下面这个直接给 BufferGeometry 添加 attribute 属性。然后再创建一个 32位 的浮点类型数组 Float32Array,为了知道几何体中有多少个顶点,现在可以通过 attributes 属性获取。最后在 BufferAttribute 中使用该数组,并将它添加到几何体的属性中。

  • setAttribute:第一个参数是需要设置的 attribute 属性名称,然后在着色器中可以使用该名字,属性名命名时最好加一个 a 前缀方便区分。
  • BufferAttribute:第一个参数是数据数组;第二个参数表示组成一个属性的值的数量,如我们要发送一个 (x, y, z) 构成位置,则需要使用 3,示例中每个顶点的随机数只有 1个,因此这个参数使用 1。
1
2
3
4
5
6
7
8
9
js复制代码const geometry = new THREE.PlaneBufferGeometry(1, 1, 32, 32)
const count = geometry.attributes.position.count
const randoms = new Float32Array(count)
// 使用随机数填充数组
for(let i = 0; i < count; i++) {
randoms[i] = Math.random()
}
// 添加到几何体的属性中
geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1))

现在,我们可以在顶点着色器中获取该属性,并使用它移动顶点,可以得到一个如下图所示的一个由随机尖峰构成的平面。

1
2
3
4
5
6
7
glsl复制代码attribute float aRandom;

void main(){
// ...
modelPosition.z += aRandom * 0.1;
// ...
}

step_07.png

限定变量Varyings

现在我们若想在片元着色器中想使用 aRandom 属性给片元着色,是无法直接使用 attribute 属性变量的。此时,实现这个功能的方法就是将这个值从顶点着色器发送到片元着色器,称这种变量为 varying。我们需要在两种着色器中都做如下的操作:

在顶点着色器中,我们需要在 main 函数之前创建 varying,将其命名为以 v 作为前缀的变量名 vRandom,然后在 main 函数中给它赋值:

1
2
3
4
5
6
glsl复制代码varying float vRandom;

void main() {
// ...
vRandom = aRandom;
}

在片元着色器中,使用相同的方法声明,然后在 main 函数中使用它,可以得到如下的染色效果:

1
2
3
4
5
6
glsl复制代码precision mediump float;
varying float vRandom;

void main() {
gl_FragColor = vec4(0.5, vRandom, 1.0, 1.0);
}

step_08.png

📌 varying的一个有趣之处是,顶点之间的值是线性插值的,如GPU在两个顶点之间绘制一个片元,一个顶点的varying是1.0,另一个顶点的varying是0.0,则该片元值将为0.5。这个特性可以实现平滑的渐变效果。

现在我们先移除上面所有的效果,恢复到纯紫色的平面。

step_09.png

统一变量Uniforms

uniform 用于将数据从 JavaScript 发送到 着色器。如果我们使用同一个着色器但是参数不同时就可以使用 uniform,使用期间参数还可以改变。在顶点着色器 和 片元着色器 中都可以使用 uniform,它的值在每个顶点和每个片元中的数据都是相同的。实际上在我们的代码中已经有 projectionMatrix、viewMatrix、modelMatrix 等 uniform,Three.js 内置创建了它们。

现在,我们来创建自己的 uniform。为了将统一变量添加到材质中,需要使用 uniforms 属性。我们将创建一个波动的平面,并使用变量来控制波浪的频率。下面用于控制 频率 的变量命名为 uFrequency,特地加了一个 u 字符作为前缀来标识是 uniform 变量,方便在着色器中和其他参数区分开来,但这不是强制的。

1
2
3
4
5
6
7
js复制代码const material = new THREE.RawShaderMaterial({
vertexShader: testVertexShader,
fragmentShader: testFragmentShader,
uniforms: {
uFrequency: { value: 10 }
}
})

然后,可以在着色器代码中获取 uniform 值,并在 main 函数中使用它:

1
2
3
4
5
6
7
8
9
10
11
glsl复制代码uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
uniform float uFrequency;

attribute vec3 position;

void main() {
// ...
modelPosition.z += sin(modelPosition.x * uFrequency) * 0.1;
}

step_10.png

显示结果和前面的相同,但是现在我们可以在 JavaScript 来控制频率了。我们可以把频率 frequency 改成 vec2 来控制水平和垂直方向的波动,在 Three.js 中可以使用二维向量 THREE.Vector2:

1
2
3
4
5
6
7
js复制代码const material = new THREE.RawShaderMaterial({
vertexShader: testVertexShader,
fragmentShader: testFragmentShader,
uniforms: {
uFrequency: { value: new THREE.Vector2(10, 5) }
}
})

然后在着色器中,将 uFrequency 的类型从 float 改为 vec2 并在 z轴 同时应用 uFrequency 的 x值 和 y值,此时我们的模型网格就会同时产生在水平和垂直方向的波动:

1
2
3
4
5
6
7
8
9
glsl复制代码// ...
uniform vec2 uFrequency;

void main() {
// ...
modelPosition.z += sin(modelPosition.x * uFrequency.x) * 0.1;
modelPosition.z += sin(modelPosition.y * uFrequency.y) * 0.1;
// ...
}

step_11.png

《Three.js 进阶之旅:神奇的粒子系统-迷失太空 👨‍🚀》一文中我们已经了解过 dat.GUI 的用法,现在我们可以使用它动态修改 uFrequency 的值在页面上实时查看不同参数生成的波动效果。

1
2
js复制代码gui.add(material.uniforms.uFrequency.value, 'x').min(0).max(20).step(0.01).name('frequencyX');
gui.add(material.uniforms.uFrequency.value, 'y').min(0).max(20).step(0.01).name('frequencyY');

step_12.gif

让我们再新加一个 uniform 来让平面像在风中飘动的旗帜。我们将使用统一变量 uTime 向着色器发送一个时间值,然后在 sin(...) 函数中使用它:

1
2
3
4
5
6
7
8
js复制代码const material = new THREE.RawShaderMaterial({
vertexShader: testVertexShader,
fragmentShader: testFragmentShader,
uniforms: {
uFrequency: { value: new THREE.Vector2(10, 5) },
uTime: { value: 0 }
}
})

不要忘了在 tick 页面重绘函数中更新 uTime,我们使用 getElapsedTime 来获取已经花费了多少时间:

1
2
3
4
5
js复制代码const tick = () => {
const elapsedTime = clock.getElapsedTime();
material.uniforms.uTime.value = elapsedTime;
// ...
}

然后在着色器中获取 uTime 并在 sin(...) 函数中使用它,我们的平面就会看起来像一个在风中飘动的旗帜 🚩:

1
2
3
4
5
6
7
8
glsl复制代码// ...
uniform float uTime;

void main() {
modelPosition.z += sin(modelPosition.x * uFrequency.x + uTime) * 0.1;
modelPosition.z += sin(modelPosition.y * uFrequency.y + uTime) * 0.1;
// ...
}

step_13.gif

我们也可以将 uTime 之前的 + 改为 - 来修改波动的方向。

1
2
glsl复制代码modelPosition.z += sin(modelPosition.x * uFrequency.x - uTime) * 0.1;
modelPosition.z += sin(modelPosition.y * uFrequency.y - uTime) * 0.1;

step_14.gif

📌 注意,使用uTime时如果直接使用JavaScript的Date.now(),会发现不起作用,因为它的数值对于着色器而言太过庞大,我们不能发送太小或太大的统一变量值。

虽然现在网格模型具有波动效果,但是它仍然是一个平面网格构成,我们可以修改它的属性来使它看起来更像个旗子 🚩。我们可以修改它的大小比例:

1
2
js复制代码const mesh = new THREE.Mesh(geometry, material);
mesh.scale.y = 2 / 3;

step_15.gif

在片元着色器中也可以使用 uniform 统一变量,我们添加一个 uColor 作为颜色变量:

1
2
3
4
5
6
7
8
9
js复制代码const material = new THREE.RawShaderMaterial({
vertexShader: testVertexShader,
fragmentShader: testFragmentShader,
uniforms: {
uFrequency: { value: new THREE.Vector2(10, 5) },
uTime: { value: 0 },
uColor: { value: new THREE.Color('orange') }
}
})

然后在片元着色器中获取颜色变量,并将它作为 gl_FragColor 的值,你会看到平面将变成设定的颜色效果:

1
2
3
4
5
6
glsl复制代码precision mediump float;
uniform vec3 uColor;

void main() {
gl_FragColor = vec4(uColor, 1.0);
}

step_16.png

纹理Textures

Textures 知识比较复杂,在之前的文章中已经介绍过使用 THREE.TextureLoader 加载纹理,下面我们给着色器材质添加一个图片纹理,并使用 uTexture 统一变量传递给着色器:

1
2
3
4
5
6
7
js复制代码const material = new THREE.RawShaderMaterial({
// ...
uniforms: {
// ...
uTexture: { value: textureLoader.load('/textures/flag.png') }
}
})

然后在着色器中,为了使纹理的颜色应用于每个可见片元上,我们需要使用 texture2D(...),它接收两个参数,第一个是需要应用的纹理即 uTexture,第二个是纹理上拾取颜色的坐标系,这个坐标系其实就是前面讨论的 UV坐标,它的作用是将纹理坐标投射到几何体上。我们用于创建几何体的 PlaneBufferGeometry 会自动生成这个坐标,我们可以通过 geometry.attributes.uv 来查看它。texture2D(...) 的返回结果是一个由 r, g, b, a 构成的 vec4。

因为 uv 是一个 attribute 属性,因此需要在顶点着色器中需要这样获取它,我们需要在片元着色器中使用它,因此还需要通过 varying 发送到片元着色器,并在 main 函数中更新它:

1
2
3
4
5
6
7
8
glsl复制代码// ...
attribute vec2 uv;
varying vec2 vUv;

void main() {
// ...
vUv = uv;
}

现在,我们可以在片元着色器中获取 vUv 变量,并在 texture2D(...)方法中使用它:

1
2
3
4
5
6
7
8
9
10
11
glsl复制代码precision mediump float;

uniform vec3 uColor;
uniform sampler2D uTexture;

varying vec2 vUv;

void main() {
vec4 textureColor = texture2D(uTexture, vUv);
gl_FragColor = textureColor;
}

step_17.png

颜色变化

现在虽然有了图片贴图,但是旗子 🚩 的明暗颜色变化还不太明显,下面我们将为它添加一些阴影变化。

首先在顶点着色器中,我们将把风的高程存储 elevation 变量中,然后通过 varying 发送到片元着色器:

1
2
3
4
5
6
7
8
9
10
glsl复制代码varying float vElevation;

void main() {
// ...
float elevation = sin(modelPosition.x * uFrequency.x - uTime) * 0.1;
elevation += sin(modelPosition.y * uFrequency.y - uTime) * 0.1;
modelPosition.z += elevation;
// ...
vElevation = elevation;
}

然后在片元着色器中获取 vElevation,用它来改变 textureColor 的 r, g, b属性:

1
2
3
4
5
6
7
8
glsl复制代码// ...
varying float vElevation;

void main() {
vec4 textureColor = texture2D(uTexture, vUv);
textureColor.rgb *= vElevation * 2.0 + 0.5;
gl_FragColor = textureColor;
}

step_18.gif

着色器材质ShaderMaterial

上面所有内容,为了深入理解着色器的原理,我们使用的是 RawShaderMaterial,接下来我们使用更简单的 ShaderMaterial 来重构上面完成的所有功能。 ShaderMaterial 和 RawShaderMaterial 的工作原理其实是一样的,只不过其内置 attributes 和 uniforms,精度 也会自动设置。我们只需按下面流程稍加修改代码即可。

在 JavaScript 代码中将材质换为 THREE.ShaderMaterial。

1
js复制代码const material = new THREE.ShaderMaterial({});

然后删除着色器中以下属性和定义:

  • uniform mat4 projectionMatrix;
  • uniform mat4 viewMatrix;
  • uniform mat4 modelMatrix;
  • attribute vec3 position;
  • attribute vec2 uv;
  • precision mediump float;

其他

  • 查错:因为着色器是对每个片元执行,因此没有日志记录,出错的话很难查找,如果我们忘写了分号,Three.js 会将整个着色器代码打印出来并会提示出错的行号;
  • 调试:调试数值的一种方法是可以在 gl_FragColor 中使用它,虽然不够精确,但是可以看到颜色变化;
  • GLSLify:一个 node module 模块,可以改对 glsl 文件的处理,通过 glslify 我们可以像模块一样导入和导出 glsl 代码。你可以使用 glslify-loader 并将其加到 webpack 配置中。
  • 拓展阅读:The Book of Shaders、ShaderToy

🔗 源码地址:github.com/dragonir/th…

总结

本文中主要包含的知识点包括:

  • 了解什么是着色器
  • 了解为什么要使用着色器
  • GLSL 语言的基本语法规则
  • 理解 Vertex Shader 顶点着色器
  • 理解 Fragment Shader 片元着色器
  • 掌握 Attributes、Varyings、Uniforms的区别和用法
  • 着色器在两种着色器材质 RawShaderMaterial 和 ShanderMaterial 中的使用方法
  • 使用着色器设置颜色和纹理等

想了解其他前端知识或其他未在本文中详细描述的Web 3D开发技术相关知识,可阅读我往期的文章。如果有疑问可以在评论中留言,如果觉得文章对你有帮助,不要忘了一键三连哦 👍。

附录

  • [1]. 🌴 Three.js 打造缤纷夏日3D梦中情岛
  • [2]. 🔥 Three.js 实现炫酷的赛博朋克风格3D数字地球大屏
  • [3]. 🐼 Three.js 实现2022冬奥主题3D趣味页面,含冰墩墩
  • [4]. 🦊 Three.js 实现3D开放世界小游戏:阿狸的多元宇宙
  • [5]. 🏆 掘金1000粉!使用Three.js实现一个创意纪念页面
  • ...
  • 【Three.js 进阶之旅】系列专栏访问 👈
  • 更多往期【3D】专栏访问 👈
  • 更多往期【前端】专栏访问 👈

参考

  • [1]. three.js journey
  • [2]. threejs.org
  • [3]. 《Three.js 开发指南——基于WebGL和HTML5在网页上渲染3D图形和动画》

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

计算机系统

发表于 2022-10-22

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

计算机系统是程序员的知识体系中最基础的理论知识,你越早掌握这些知识,你就能越早享受知识带来的 “复利效应”。

本文是计算机系统系列的第 8 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在上一篇文章里,我们聊到了计算机存储器系统的金字塔结构,其中在 CPU 和内存之间有一层高速缓存,就是我们今天要聊的 CPU 三级缓存。

那么,CPU Cache 的结构是怎样的,背后隐含着哪些设计思想,CPU Cache 和内存数据是如何关联起来的,今天我们将围绕这些问题展开。


思维导图:


  1. 认识 CPU 高速缓存

1.1 存储器的金字塔结构

现代计算机系统为了寻求容量、速度和价格最大的性价比会采用分层架构,从 “CPU 寄存器 - CPU 高速缓存 - 内存 - 硬盘”自上而下容量逐渐增大,速度逐渐减慢,单位价格也逐渐降低。

  • 1、CPU 寄存器: 存储 CPU 正在使用的数据或指令;
  • 2、CPU 高速缓存: 存储 CPU 近期要用到的数据和指令;
  • 3、内存: 存储正在运行或者将要运行的程序和数据;
  • 4、硬盘: 存储暂时不使用或者不能直接使用的程序和数据。

存储器金字塔

1.2 为什么在 CPU 和内存之间增加高速缓存?

我认为有 2 个原因:

  • 原因 1 - 弥补 CPU 和内存的速度差(主要): 由于 CPU 和内存的速度差距太大,为了拉平两者的速度差,现代计算机会在两者之间插入一块速度比内存更快的高速缓存。只要将近期 CPU 要用的信息调入缓存,CPU 便可以直接从缓存中获取信息,从而提高访问速度;
  • 原因 2 - 减少 CPU 与 I/O 设备争抢访存: 由于 CPU 和 I/O 设备会竞争同一条内存总线,有可能出现 CPU 等待 I/O 设备访存的情况。而如果 CPU 能直接从缓存中获取数据,就可以减少竞争,提高 CPU 的效率。

1.3 CPU 的三级缓存结构

在 CPU Cache 的概念刚出现时,CPU 和内存之间只有一个缓存,随着芯片集成密度的提高,现代的 CPU Cache 已经普遍采用 L1/L2/L3 多级缓存的结构来改善性能。自顶向下容量逐渐增大,访问速度也逐渐降低。当缓存未命中时,缓存系统会向更底层的层次搜索。

  • L1 Cache: 在 CPU 核心内部,分为指令缓存和数据缓存,分开存放 CPU 使用的指令和数据;
  • L2 Cache: 在 CPU 核心内部,尺寸比 L1 更大;
  • L3 Cache: 在 CPU 核心外部,所有 CPU 核心共享同一个 L3 缓存。

CPU 三级缓存


  1. 理解 CPU 三级缓存的设计思想

2.1 为什么 L1 要将指令缓存和数据缓存分开?

这个策略叫分离缓存,与之相对应的叫统一缓存:

  • 分离缓存: 指令和数据分别存放在不同缓存中:
    • 指令缓存(Instruction Cache,I-Cache)
    • 数据缓存(Data Cache,D-Cache)
  • 统一缓存: 指令和数据统一存放在一个缓存中。

那么,为什么 L1 缓存要把指令和数据分开呢?我认为有 2 个原因:

  • 原因 1 - 避免取指令单元和取数据单元争夺访缓存(主要): 在 CPU 内核中,取指令和取数据指令是由两个不同的单元完成的。如果使用统一缓存,当 CPU 使用超前控制或流水线控制(并行执行)的控制方式时,会存在取指令操作和取数据操作同时争用同一个缓存的情况,降低 CPU 运行效率;
  • 原因 2 - 内存中数据和指令是相对聚簇的,分离缓存能提高命中率: 在现代计算机系统中,内存中的指令和数据并不是随机分布的,而是相对聚集地分开存储的。因此,CPU Cache 中也采用分离缓存的策略更符合内存数据的现状;

2.2 为什么 L1 采用分离缓存而 L2 采用统一缓存?

我认为原因有 2 个:

  • 原因 1: L1 采用分离缓存后已经解决了取指令单元和取数据单元的争夺访缓存问题,所以 L2 是否使用分离缓存没有影响;
  • 原因 2: 当缓存容量较大时,分离缓存无法动态调节分离比例,不能最大化发挥缓存容量的利用率。例如数据缓存满了,但是指令缓存还有空闲,而 L2 使用统一缓存则能够保证最大化利用缓存空间。

2.3 L3 缓存是多核心共享的,放在芯片外有区别吗?

集成在芯片内部的缓存称为片内缓存,集成在芯片外部(主板)的缓存称为片外缓存。最初,由于受到芯片集成工艺的限制,片内缓存不可能很大,因此 L2 / L3 缓存都是设计在主板上,而不是在芯片内的。

后来,L2 / L3 才逐渐集成到 CPU 芯片内部后的。片内缓冲和片外缓存是有区别的,主要体现在 2 个方面:

  • 区别 1 - 片内缓存物理距离更短: 片内缓存与取指令单元和取数据单元的物理距离更短,速度更快;
  • 区别 2 - 片内缓存不占用系统总线: 片内缓存使用独立的 CPU 片内总线,可以减轻系统总线的负担。

  1. CPU Cache 的基本单位 —— Cache Line

CPU Cache 在读取内存数据时,每次不会只读一个字或一个字节,而是一块块地读取,这每一小块数据也叫 CPU 缓存行(CPU Cache Line)。这也是对局部性原理的应用,当一个指令或数据被访问过之后,与它相邻地址的数据有很大概率也会被访问,将更多可能被访问的数据存入缓存,可以提高缓存命中率。

当然,块长也不是越大越好(一般是取 4 到 8 个字长,如 64 字节):

  • 前期:当块长由小到大增长时,随着局部性原理的影响使得命中率逐渐提高;
  • 后期:但随着块长继续增大,导致缓存中承载的块个数减少,很可能内存块刚刚装入缓存就被新的内存块覆盖,命中率反而下降。而且,块长越长,追加的部分距离被访问的字越远,近期被访问的可能性也更低,无济于事。

区分几种容量单位:

  • 字节(Byte): 字节是计算机数据存储的基本单位,即使存储 1 个位也需要按 1 个字节存储;
  • 字(Word): 字长是 CPU 在单位时间内能够同时处理的二进制数据位数。多少位 CPU 就是指 CPU 的字长是多少位(比如 64 位 CPU 的字长就是 64 位);
  • 块(Block): 块是 CPU Cache 管理数据的基本单位,也叫 CPU 缓存行;
  • 段(Segmentation)/ 页(Page): 段 / 页是操作系统管理虚拟内存的基本单位。

事实上,CPU 在访问内存数据的时候,与计算机中对于 “缓存设计” 的一般性规律是相同的: 对于基于 Cache 的系统,对数据的读取和写入总会先访问 Cache,检查要访问的数据是否在 Cache 中。如果命中则直接使用 Cache 上的数据,否则先将底层的数据源加载到 Cache 中,再从 Cache 读取数据。

那么,CPU 怎么知道要访问的内存数据是否在 CPU Cache 中,在 CPU 中的哪个位置,以及是不是有效的呢?这就是下一节要讲的内存地址与 Cache 地址的映射问题。


  1. 内存地址与 Cache 地址的映射

无论对 Cache 数据检查、读取还是写入,CPU 都需要知道访问的内存数据对应于 Cache 上的哪个位置,这就是内存地址与 Cache 地址的映射问题。

事实上,因为内存块和缓存块的大小是相同的,所以在映射的过程中,我们只需要考虑 “内存块索引 - 缓存块索引” 之间的映射关系,而具体访问的是块内的哪一个字,则使用相同的偏移在块中寻找。举个例子:假设内存有 32 个内存块,CPU Cache 有 8 个缓存块,我们只需要考虑 紫色 部分标识的索引如何匹配即可。

目前,主要有 3 种映射方案:

  • 1、直接映射(Direct Mapped Cache): 固定的映射关系;
  • 2、全相联映射(Fully Associative Cache): 灵活的映射关系;
  • 3、组相联映射(N-way Set Associative Cache): 前两种方案的折中方法。

内存块索引 - 缓存块索

4.1 直接映射

直接映射是三种方式中最简单的映射方式,直接映射的策略是: 在内存块和缓存块之间建立起固定的映射关系,一个内存块总是映射到同一个缓存块上。

具体方式:

  • 1、将内存块索引对 Cache 块个数取模,得到固定的映射位置。例如 13 号内存块映射的位置就是 13 % 8 = 5,对应 5 号 Cache 块;
  • 2、由于取模后多个内存块会映射到同一个缓存块上,产生块冲突,所以需要在 Cache 块上增加一个 组标记(TAG),标记当前缓存块存储的是哪一个内存块的数据。其实,组标记就是内存块索引的高位,而 Cache 块索引就是内存块索引的低 4 位(8 个字块需要 4 位);
  • 3、由于初始状态 Cache 块中的数据是空的,也是无效的。为了标识 Cache 块中的数据是否已经从内存中读取,需要在 Cache 块上增加一个 有效位(Valid bit) 。如果有效位为 0,则 CPU 可以直接读取 Cache 块上的内容,否则需要先从内存读取内存块填入 Cache 块,再将有效位改为 1。

直接映射

4.2 全相联映射

理解了直接映射的方式后,我们发现直接映射存在 2 个问题:

  • 问题 1 - 缓存利用不充分: 每个内存块只能映射到固定的位置上,即使 Cache 上有空闲位置也不会使用;
  • 问题 2 - 块冲突率高: 直接映射会频繁出现块冲突,影响缓存命中率。

基于直接映射的缺点,全相联映射的策略是: 允许内存块映射到任何一个 Cache 块上。 这种方式能够充分利用 Cache 的空间,块冲突率也更低,但是所需要的电路结构物更复杂,成本更高。

具体方式:

  • 1、当 Cache 块上有空闲位置时,使用空闲位置;
  • 2、当 Cache 被占满时则替换出一个旧的块腾出空闲位置;
  • 3、由于一个 Cache 块会映射所有内存块,因此组标记 TAG 需要扩大到与主内存块索引相同的位数,而且映射的过程需要沿着 Cache 从头到尾匹配 Cache 块的 TAG 标记。

全相联映射

4.3 组相联映射

组相联映射是直接映射和全相联映射的折中方案,组相联映射的策略是: 将 Cache 分为多组,每个内存块固定映射到一个分组中,又允许映射到组内的任意 Cache 块。显然,组相联的分组为 1 时就等于全相联映射,而分组等于 Cache 块个数时就等于直接映射。

组相联映射


  1. Cache 块的替换策略

在使用直接映射的 Cache 中,由于每个主内存块都与某个 Cache 块有直接映射关系,因此不存在替换策略。而使用全相联映射或组相联映射的 Cache 中,主内存块与 Cache 块没有固定的映射关系,当新的内存块需要加载到 Cache 中时(且 Cache 块没有空闲位置),则需要替换到 Cache 块上的数据。此时就存在替换策略的问题。

常见替换策略:

  • 1、随机法: 使用一个随机数生成器随机地选择要被替换的 Cache 块,实现简单,缺点是没有利用 “局部性原理”,无法提高缓存命中率;
  • 2、FIFO 先进先出法: 记录各个 Cache 块的加载事件,最早调入的块最先被替换,缺点同样是没有利用 “局部性原理”,无法提高缓存命中率;
  • 3、LRU 最近最少使用法: 记录各个 Cache 块的使用情况,最近最少使用的块最先被替换。这种方法相对比较复杂,也有类似的简化方法,即记录各个块最近一次使用时间,最久未访问的最先被替换。与前 2 种策略相比,LRU 策略利用了 “局部性原理”,平均缓存命中率更高。

  1. 总结

  • 1、为了弥补 CPU 和内存的速度差和减少 CPU 与 I/O 设备争抢访存,计算机在 CPU 和内存之间增加高速缓存,一般存在 L1/L2/L3 多级缓存的结构;
  • 2、对于基于 Cache 的存储系统,对数据的读取和写入总会先访问 Cache,检查要访问的数据是否在 Cache 中。如果命中则直接使用 Cache 上的数据,否则先将底层的数据源加载到 Cache 中,再从 Cache 读取数据;
  • 3、CPU Cache 是一块块地从内存读取数据,一块数据就是缓存行;
  • 4、内存地址与 Cache 地址的映射有直接映射、全相联映射和组相联映射;
  • 5、使用全相联映射或组相联映射的 Cache 中,当新的内存块需要加载到 Cache 中时且 Cache 块没有空闲位置,则需要替换到 Cache 块上的数据。

今天,我们主要讨论了 CPU Cache 的基本设计思想以及 Cache 与内存的映射关系。具体 CPU Cache 是如何读取和写入的还没讲,另外有一个 CPU 缓存一致性问题 说的又是什么呢?下一篇文章我们详细展开讨论,请关注。


参考资料

  • 深入浅出计算机组成原理(第 37、38 讲) —— 徐文浩 著,极客时间 出品
  • 计算机组成原理教程(第 7 章) —— 尹艳辉 王海文 邢军 著
  • 面试官:如何写出让 CPU 跑得更快的代码? —— 小林 Coding 著
  • CPU cache —— Wikipedia
  • CPU caches —— LWN.net

推荐阅读

计算机系统系列完整目录如下(2023/07/11 更新):

  • #1 从图灵机到量子计算机,计算机可以解决所有问题吗?
  • #2 一套用了 70 多年的计算机架构 —— 冯·诺依曼架构
  • #3 为什么计算机中的负数要用补码表示?
  • #4 今天一次把 Unicode 和 UTF-8 说清楚
  • #5 为什么浮点数运算不精确?(阿里笔试)
  • #6 计算机的存储器金字塔长什么样?
  • #7 程序员学习 CPU 有什么用?
  • #8 我把 CPU 三级缓存的秘密,藏在这 8 张图里
  • #9 图解计算机内部的高速公路 —— 总线系统
  • #10 12 张图看懂 CPU 缓存一致性与 MESI 协议,真的一致吗?
  • #11 已经有 MESI 协议,为什么还需要 volatile 关键字?
  • #12 什么是伪共享,如何避免?

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

计算机系统

发表于 2022-10-22

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

计算机系统是程序员的知识体系中最基础的理论知识,你越早掌握这些知识,你就能越早享受知识带来的 “复利效应”。

本文是计算机系统系列的第 6 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在计算机组成原理中的众多概念中,开发者接触得最多的还是内存、硬盘、虚拟内存、CPU 缓存这些概念。这些概念有一个更为抽象的表示 —— 存储器,它是冯 · 诺依曼计算机体系中的五大组件之一,用于存储程序和数据。

在这个系列中,我将从存储器的金字塔结构展开,围绕 CPU 高速缓存、内存、硬盘、虚拟内存等内容逐步带你深入理解计算机中。


思维导图:


  1. 局部性原理

局部性原理是用于制定存储器系统数据管理策略的一个理论基础,我们可以分为 2 个维度来理解:

  • 1、时间局部性(Temporal Locality): 时间局部性表示一个指令或数据被访问过后,在短时间内有很大概率会再次访问。例如,在程序中的一些函数、循环语句或者变量往往会在短时间内被多次调用;
  • 2、空间局部性(Spatial Locality): 空间局部性表示一个指令或数据被访问过之后,与它相邻地址的数据有很大概率也会被访问。 例如,在程序中访问了数据的首项元素之后,往往也会访问继续后续的元素。

在计算机组成原理中,很多策略中都会体现到局部性原理,我们在学习中可以有意识地关联起来。例如在管理 CPU 高速缓存时,除了可以将当前正在访问的内存数据加到到缓存中,还可以把相邻内存的数据一并缓存起来(即 CPU 缓存行),也能够提高缓存命中率。


  1. 认识存储器系统

2.1 为什么存储器系统要分层?

小伙伴们应该都知道内存和硬盘都是存储器设备。其实,在 CPU 内部中的寄存器和 CPU L1/L2/L3 缓存也同样是存储设备,而且它们的访问速度比内存和硬盘快几个数量级。那么,为什么要使用内存和硬盘,直接扩大 CPU 的存储能力不行吗?这就要提到存储器的 3 个主要的性能指标: 速度 + 容量 + 每位价格。

一般来说,存储器的容量越大则速度越低,速度越高则价格越高。想要获得一个同时满足容量大、速度高且价格低的 “神奇存储器” 是很难实现的。因此,现代计算机系统会采用分层架构,以满足整个系统在容量、速度和价格上最大的性价比。

2.2 存储器的层次结构

在现代计算机系统中,一般采用 “CPU 寄存器 - CPU 高速缓存 - 内存 - 硬盘” 四级存储器结构。自上而下容量逐渐增大,速度逐渐减慢,单位价格也逐渐降低。

  • 1、CPU 寄存器: 存储 CPU 正在使用的数据或指令。寄存器是最靠近 CPU 控制器和运算器的存储器,它的速度最快;
  • 2、CPU 高速缓存: 存储 CPU 近期要用到的数据和指令。CPU 高速缓存是位于 CPU 和内存中间的一层缓存。缓存和内存之间的数据调动是由硬件自动完成的,对上层是完全透明的。
  • 3、内存: 存储正在运行或者将要运行的程序和数据;
  • 4、硬盘: 存储暂时不使用或者不能直接使用的程序和数据。硬盘主要解决存储器系统容量不足的问题,硬盘的速度虽然比内存慢,但硬盘的容量可以比内存大很多,而且断电不丢失数据。

存储器金字塔

在此基础上,对各个层级上进行局部优化,就形成了完整的存储器系统:

  • 优化 1 - CPU 三级缓存: 在 CPU Cache 的概念刚出现时,CPU 和内存之间只有一个缓存,随着芯片集成密度的提高,现代的 CPU Cache 已经普遍采用 L1/L2/L3 多级缓存的结构来改善性能;
  • 优化 2 - 虚拟内存: 程序不能直接访问物理地址,而是访问虚拟地址,虚拟地址需要经过地址变换(Address Translation)才能映射到存放数据的物理地址;
  • 优化 3 - 页缓存: 为了提高读写效率和保护磁盘,操作系统在文件系统中使用了页缓存机制。

2.3 为什么在 CPU 和内存之间增加高速缓存?

我认为有 2 个原因:

  • 原因 1 - 弥补 CPU 和内存的速度差(主要): 由于 CPU 和内存的速度差距太大,为了拉平两者的速度差,现代计算机会在两者之间插入一块速度比内存更快的高速缓存。只要将近期 CPU 要用的信息调入缓存,CPU 便可以直接从缓存中获取信息,从而提高访问速度;
  • 原因 2 - 减少 CPU 与 I/O 设备争抢访存: 由于 CPU 和 I/O 设备会竞争同一条内存总线,有可能出现 CPU 等待 I/O 设备访存的情况。而如果 CPU 能直接从缓存中获取数据,就可以减少竞争,提高 CPU 的效率。

关于 CPU 三级高速缓存的更多内容,请关注专栏文章。

CPU 三级缓存

2.4 为什么要使用虚拟内存访问内存?

为了满足系统的多进程需求和大内存需求,操作系统在内存这一层级使用了虚拟内存管理。当物理内存资源不足时,操作系统会按照一定的算法将最近不常用的内存换出(Swap Out)到硬盘上,再把要访问数据从硬盘换入(Swap In)到物理内存上。 至于操作系统如何管理虚拟地址和内存地址之间的关系(段式、页式、段页式),对上层应用完全透明。

关于虚拟内存的更多内容,请关注专栏文章。

虚拟内存


  1. 存储器类型

这一节,我们来梳理常见的存储器类型。

3.1 按存储材质划分

  • 1、磁表面存储器: 在金属或塑料表面涂抹一层磁性材料作为记录介质,用磁头在磁层上进行读写操作。例如磁盘、磁带、软盘等,已经逐渐淘汰。
  • 2、光盘存储器: 在金属或塑料表面涂抹一层磁光材料作为记录介质,用激光在磁层上进行读写操作。例如 VCD、DVD 等,已经逐渐淘汰。
  • 3、半导体存储器: 由半导体器件组成的存储器,现代的半导体存储器都会用超大规模集成电路技术将存储器制成芯片,具有体积小、功耗低、存取速度快的优点,是目前主流的存储器技术。

提示: 由于磁盘和光盘已经逐渐从历史中淡去,以后我们讨论的存储器都默认表示半导体存储器。

3.2 按芯片类型划分

半导体存储器按照存取方式划分可以分为 2 种:

  • 1、RAM(Random-Access Memory 随机存取存储器): 指可以通过指令对任意存储单元进行读写访问的存储器,在断电后会丢失全部信息。RAM 的容量没有 ROM 大,但速度比 ROM 快很多,通常用作计算机主存。
  • 2、ROM(Read-Only Memory 只读存储器): 指只能进行读取操作的存储器,断电后信息不丢失。随着半导体技术的发展,在 ROM 的基础上又发展出 EEPROM(电可擦除只读存储器)等技术,它们并不符合 ROM 只读的命名,但由于是在 ROM 上衍生的技术,才沿用了原来的叫法。现在我们更熟悉的 HDD(机械硬盘)和 SSD(固态硬盘) 都是 ROM 的衍生技术。

RAM 又分为 SRAM 和 DRAM 两种实现类型:

  • 1、SRAM(静态 RAM): SRAM 只要在保持通电状态下,内部存储的数据就不会丢失,因此称为 “静态” RAM。
    • 优点: 访问速度非常快,通常用作 CPU 的高速缓存;
    • 缺点: 在 SRAM 中,仅实现 1 比特容量就需要 6~8 个晶体管组成,所以 SRAM 的存储密度不高。

6 个晶体管组成的 1 比特 SRAM

—— 图片引用自 Wikipedia

  • 2、DRAM(动态 RAM): DRAM 在保持通电状态下,还需要定时刷新,才能保证内部存储的数据不会丢失,因此称为 “动态” RAM。
    • 优点: 实现 1 比特容量只需要 1 个晶体管和 1 个电容组成,所以 DRAM 的存储密度、功耗和价格指标都比 SRAM 优秀的多;
    • 缺点: 电容会自然放电,为避免某些长期得不到访问的存储单元丢失数据,必须采用定时刷新的策略。这就导致 DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,访问时延也更长,因此,DRAM 一般用作计算机主存。

1 个晶体管和 1 个电容组成的 1 比特 DRAM

—— 图片引用自 计算机组成原理教程

3.3 为什么内存的访问速度比 CPU 差这么多?

内存的访问速度受制于 DRAM 的性能瓶颈。

在目前的计算机系统中,计算机内存采用的是基于 DRAM (动态随机存取存储器)芯片的存储器,它的基本单元由一个晶体管 + 一个电容组成,在存储密度、功耗和价格等方面表现优秀。但电容会自然放电,需要定时刷新来保证信息不丢失,因此访问速度受损。而高速缓存是基于 SRAM (静态随机存取存储器)芯片的存储器,它的基本单元由 6~8 个晶体管组成。结构更简单,因此访问速度更快,但存储密度不高。


  1. 总结

  • 1、局部性原理是计算机存储器系统的基本原理,分为时间局部性和空间局部性;
  • 2、现代计算机系统为了寻求容量、速度和价格上最大的性价比会采用分层架构,从 “CPU 寄存器 - CPU 高速缓存 - 内存 - 硬盘”自上而下容量逐渐增大,速度逐渐减慢,单位价格也逐渐降低;
  • 3、为了弥补 CPU 和内存的速度差和减少 CPU 与 I/O 设备争抢访存,计算机在 CPU 和内存之间增加高速缓存,一般存在 L1/L2/L3 多级缓存的结构;
  • 4、为了满足系统的多进程需求和大内存需求,操作系统在内存这一层级使用了虚拟内存管理;
  • 5、内存的访问速度受制于 DRAM 的性能瓶颈。

今天,我们简单提到了 CPU 的三级缓存,下一篇文章我们详细展开讨论,敬请期待。


参考资料

  • 深入浅出计算机组成原理(第 35 讲) —— 徐文浩 著,极客时间 出品
  • 计算机组成原理教程(第 7 章) —— 尹艳辉 王海文 邢军 著
  • 10分钟速成课 计算机科学 —— Carrie Anne 著
  • principle of locality —— Wikipedia
  • Static random-access memory —— Wikipedia
  • Dynamic random-access memory —— Wikipedia

推荐阅读

计算机系统系列完整目录如下(2023/07/11 更新):

  • #1 从图灵机到量子计算机,计算机可以解决所有问题吗?
  • #2 一套用了 70 多年的计算机架构 —— 冯·诺依曼架构
  • #3 为什么计算机中的负数要用补码表示?
  • #4 今天一次把 Unicode 和 UTF-8 说清楚
  • #5 为什么浮点数运算不精确?(阿里笔试)
  • #6 计算机的存储器金字塔长什么样?
  • #7 程序员学习 CPU 有什么用?
  • #8 我把 CPU 三级缓存的秘密,藏在这 8 张图里
  • #9 图解计算机内部的高速公路 —— 总线系统
  • #10 12 张图看懂 CPU 缓存一致性与 MESI 协议,真的一致吗?
  • #11 已经有 MESI 协议,为什么还需要 volatile 关键字?
  • #12 什么是伪共享,如何避免?

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Threejs 进阶之旅:神奇的粒子系统-迷失太空 👨‍🚀

发表于 2022-10-17

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

摘要

粒子 particle 和 精灵 sprite 是在三维开发中经常用到的网格模型,在 Three.js 中使用 THREE.Points 可以非常容易地创建很多细小的物体,可以用来模拟星辰、雨滴、雪花、烟雾、火焰和其他有趣的效果。本文将讨论关于 Three.js 中各种创建粒子的方式和以及如何优化粒子的样式和使用粒子,最终将结合本文所讲的粒子知识,制作一个充满趣味和创意的 3D 粒子页面——迷失太空。

通过阅读本文及配套对应代码,你将学到的内容包括:使用 THREE.Sprite 创建粒子集合、使用 THREE.Points 创建粒子集合、如何创建样式化的粒子、使用 dat.GUI 动态控制页面参数、使用 Canvas 样式化粒子、使用纹理贴图样式化粒子、从高级几何体创建粒子、给场景添加 Fog 和 FogExp2 雾化效果、使用 正余弦函数 给模型添加动画效果、鼠标移动动画等。

效果

本文代码中包含7个粒子效果示例,解开代码中相应的注释即可查看每种粒子效果。迷失太空是本文中最后综合应用粒子知识实现的一个创意页面,下图就是它的实现效果。整个页面类似科幻电影《地心引力》的海报,宇航员不慎迷失太空,逃逸向无边无际的宇宙深处 🌌。在屏幕上使用鼠标移动,宇航员 👨‍🚀 和 星辰 ✨ 都会根据鼠标的移动产生相反方向的位移动画。

preview.gif

打开以下链接中的任意一个,在线预览效果,大屏访问效果更佳。

  • 👁‍🗨 在线预览地址1:dragonir.github.io/3d/#/gravit…
  • 👁‍🗨 在线预览地址2:3d-eosin.vercel.app/#/gravity

本专栏系列代码托管在 Github 仓库【threejs-odessey】,后续所有目录也都将在此仓库中更新。

🔗 代码仓库地址:git@github.com:dragonir/threejs-ode…

《地心引力》海报

gravity.png

码上掘金

实现

本文中有7个关于 Three.js 粒子效果的示例,为了方便,全都写在一个文件中,下述步骤中资源引入和场景初始化都是通用的,其他步骤 0~6 都是一个个单独的示例。在代码中,只要解开每个示例的注释,即可查看对应粒子效果。

资源引入

本示例中,除了引入样式表、Three.js、镜头轨道控制器 OrbitControls、模型加载器 GLTFLoader 之外,还额外引入了 dat.GUI,它是可以通过在页面上添加一个可视化控制器来修改代码参数的库,方便动态查看和调试页面在各种参数下的渲染效果,具体用法在本文后续内容中有详细的描述。

1
2
3
4
5
js复制代码import './style.css';
import * as THREE from 'three';
import * as dat from 'dat.gui';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

场景初始化

正式开发粒子效果之前,需要经过初始化渲染器、场景、相机、缩放事件监听、页面重绘动画调用等必备步骤。关于它们的具体原理本文不再赘述,需要了解的可前往本专栏前两章查看《Three.js 进阶之旅:基础入门(上)》、《Three.js 进阶之旅:基础入门(下)》。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
js复制代码// 初始化渲染器
const canvas = document.querySelector('canvas.webgl');
const renderer = new THREE.WebGLRenderer({ canvas: canvas });
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

// 初始化场景
const scene = new THREE.Scene();

// 初始化相机
const camera = new THREE.PerspectiveCamera(45, sizes.width / sizes.height, 0.1, 1000)
camera.position.z = 120
camera.lookAt(new THREE.Vector3(0, 0, 0))
scene.add(camera);

// 镜头控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

// 页面缩放事件监听
window.addEventListener('resize', () => {
sizes.width = window.innerWidth;
sizes.height = window.innerHeight;
// 更新渲染
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
// 更新相机
camera.aspect = sizes.width / sizes.height;
camera.updateProjectionMatrix();
});

// 页面重绘动画
const tick = () => {
controls && controls.update();
// 更新渲染器
renderer.render(scene, camera);
// 页面重绘时调用自身
window.requestAnimationFrame(tick);
}
tick();

〇 使用THREE.Sprite创建粒子

Three.js 提供多种方法创建粒子,首先我们使用 THREE.Sprite 来通过如下的方式创建一个 20 x 30 的粒子系统。通过 new THREE.Sprite() 构造方法来创建粒子,给它传入唯一的参数材质,此时可选的材质类型只能是 THREE.SpriteMaterial 或 THREE.SpriteCanvasMaterial。创建材质时将它的 color 属性值设置成了随机色。由于THREE.Sprite 对象继承于 THREE.Object3D,它的大多数属性和方法都可以直接使用。示例中使用了 position 方法对粒子进行定位设置。还可以使用 scale 属性进行缩放、使用 translate 属性进行位移设置等。

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码const createParticlesBySprite = () => {
for (let x = -15; x < 15; x++) {
for(let y = -10; y < 10; y++) {
let material = new THREE.SpriteMaterial({
color: Math.random() * 0xffffff
});
let sprite = new THREE.Sprite(material);
sprite.position.set(x * 4, y * 4, 0);
scene.add(sprite);
}
}
}

将创建的粒子系统添加到场景中,就能看到如下图所示的粒子方块点阵。它们是一个个的彩色方块构成的网格,如果使用鼠标或触控板在场景中移动,你会发现,无论从哪个角度观察,阵列中的彩色方块看起来都没有变化,每一个粒子都是永远面向摄像机的二维平面,如果在创建粒子的时候没有指定任何属性,那么它们将会被渲染成二维的白色小方块 □。

method_0.gif

知识点 💡 精灵材质 THREE.SpriteMaterial

THREE.SpriteMatrial 对象的一些可修改属性及其说明。

  • color:粒子的颜色。
  • map:粒子所用的纹理,可以是一组 sprite sheet。
  • sizeAttenuation:如果该属性设置为 false,那么距离摄像机的远近不影响粒子的大小,默认值为 true。
  • opacity:该属性设置粒子的不透明度。默认值为 1,不透明。
  • blending:该属性指定渲染粒子时所用的融合模式。
  • fog:该属性决定粒子是否受场景中雾化效果影响。默认值为 true。

① 使用THREE.Points创建粒子

通过 THREE.Sprite 你可以非常容易地创建一组对象并在场景中移动它们。当你使用少量的对象时,这会很有效,但是如果需要创建大量的粒子,如果这时候还是使用 THREE.Sprite 的话,就会产生性能问题,因为每个对象需要分别由 Three.js 进行管理。

Three.js 提供了另一种方式来处理大量的粒子,就是使用 THREE.Points,通过 Three.Points,Three.js 不需要管理大量 THREE.Sprite 对象,而只需要管理 THREE.Points 实例。使用这种方法创建粒子系统时,首先要创建粒子的网格 THREE.BufferGeometry,然后创建粒子的材质 THREE.PointsMaterial。然后创建两个数组 veticsFloat32Array 和 veticsColors,用来管理粒子系统中每个粒子的位置和颜色,通过 THREE.Float32BufferAttribute 将它们设置为网格属性。最后使用 THREE.Points 将创建的网格和材质变为粒子系统添加到场景中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
js复制代码const createParticlesByPoints = () => {
const geom = new THREE.BufferGeometry();
const material = new THREE.PointsMaterial({
size: 3,
vertexColors: true,
color: 0xffffff
});
let veticsFloat32Array = []
let veticsColors = []
for (let x = -15; x < 15; x++) {
for (let y = -10; y < 10; y++) {
veticsFloat32Array.push(x * 4, y * 4, 0);
const randomColor = new THREE.Color(Math.random() * 0xffffff);
veticsColors.push(randomColor.r, randomColor.g, randomColor.b);
}
}
const vertices = new THREE.Float32BufferAttribute(veticsFloat32Array, 3);
const colors = new THREE.Float32BufferAttribute(veticsColors, 3);
geom.attributes.position = vertices;
geom.attributes.color = colors;
const particles = new THREE.Points(geom, material);
scene.add(particles);
}

实现效果如下图所示,使用 THREE.Points 可以得到和使用 THREE.Sprite 相同的结果。

method_1.png

知识点 💡 点材质 THREE.PointsMaterial

上述例子中使用 THREE.PointsMaterial 来样式化粒子,它是 THREE.Points 使用的默认材质,下面列举了 THREE.PointsMaterial 中所有可设置属性及其说明。

  • color: 粒子系统中所有粒子的颜色。将 vertexColors 属性设置为 true,并且通过颜色属性指定了几何体的颜色来覆盖该属性。默认值为 0xFFFFFF。
  • map: 通过这个属性可以在粒子材质,比如可以使用 canvas、贴图等。
  • size:该属性指定粒子的大小,默认值为 1。
  • sizeAnnutation: 如果该属性设置为 false,那么所有的粒子都将拥有相同的尺寸,无论它们距离相机有多远。如果设置为 true,粒子的大小取决于其距离摄像机的距离的远近,默认值为true。
  • vertexColors:通常 THREE.Points 中所有的粒子都拥有相同的颜色,如果该属性设置为 THREE.VertexColors,并且几何体的颜色数组也有值,那就会使用颜色数组中的值,默认值为 THREE.NoColors。
  • opacity:该属性与 transparent 属性一起使用,用来设置粒子的不透明度。默认值为 1(完全不透明)。
  • transparent:如果该属性设置为 true,那么粒子在渲染时会根据 opacity 属性的值来确定其透明度,默认值为 false。
  • blending:该属性指定渲染粒子时的融合模式。
  • fog:该属性决定粒子是否受场景中雾化效果影响,默认值为 true。

② 创建样式化的粒子

在上个例子的基础上,我们改造一下创建粒子的方法,通过给 THREE.PointsMaterial 动态传入参数的方式来修改粒子的样式。为了能够实时修改参数并同时能够在页面上查看到参数改变之后的效果,我们可以使用 dat.GUI 库来实现这一功能。首先,通过 new dat.GUI() 进行初始化,然后通过 .add() 及 .addColor() 等方法为它添加控制选项,并在控制选项发生改变时在 .onChange() 中调用我们预先写好的回调函数来更新粒子样式。回调函数 ctrls 也很简单,就是通过 scene.getObjectByName("particles") 找到场景中已经创建好的粒子将它删除,然后使用新的参数再次调用 createStyledParticlesByPoints 来创建新的粒子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
js复制代码const createStyledParticlesByPoints = (ctrls) => {
const geom = new THREE.BufferGeometry();
const material = new THREE.PointsMaterial({
size: ctrls.size,
transparent: ctrls.transparent,
opacity: ctrls.opacity,
color: new THREE.Color(ctrls.color),
vertexColors: ctrls.vertexColors,
sizeAttenuation: ctrls.sizeAttenuation
});
let veticsFloat32Array = []
let veticsColors = []
for (let x = -15; x < 15; x++) {
for (let y = -10; y < 10; y++) {
veticsFloat32Array.push(x * 4, y * 4, 0)
const randomColor = new THREE.Color(Math.random() * ctrls.vertexColor)
veticsColors.push(randomColor.r, randomColor.g, randomColor.b)
}
}
const vertices = new THREE.Float32BufferAttribute(veticsFloat32Array, 3)
const colors = new THREE.Float32BufferAttribute(veticsColors, 3)
geom.attributes.position = vertices;
geom.attributes.color = colors;
const particles = new THREE.Points(geom, material);
particles.name = 'particles';
scene.add(particles)
}
// 创建属性控制器
const ctrls = new function () {
this.size = 5;
this.transparent = true;
this.opacity = 0.6;
this.vertexColors = true;
this.color = 0xffffff;
this.vertexColor = 0x00ff00;
this.sizeAttenuation = true;
this.rotate = true;
this.redraw = function () {
if (scene.getObjectByName("particles")) {
scene.remove(scene.getObjectByName("particles"));
}
createStyledParticlesByPoints({
size: ctrls.size,
transparent: ctrls.transparent,
opacity: ctrls.opacity,
vertexColors: ctrls.vertexColors,
sizeAttenuation: ctrls.sizeAttenuation,
color: ctrls.color,
vertexColor: ctrls.vertexColor
});
};
}
const gui = new dat.GUI();
gui.add(ctrls, 'size', 0, 10).onChange(ctrls.redraw);
gui.add(ctrls, 'transparent').onChange(ctrls.redraw);
gui.add(ctrls, 'opacity', 0, 1).onChange(ctrls.redraw);
gui.add(ctrls, 'vertexColors').onChange(ctrls.redraw);
gui.addColor(ctrls, 'color').onChange(ctrls.redraw);
gui.addColor(ctrls, 'vertexColor').onChange(ctrls.redraw);
gui.add(ctrls, 'sizeAttenuation').onChange(ctrls.redraw);

添加 dat.GUI 后,页面顶部就会出现一个对应参数可视化的控制器 🎮,用鼠标在上面下拉或滑动修改参数,就能实时更新到页面中了。赶快动手试试,看看修改不同参数对粒子的影响有何不同吧 🤩。

method_2.png

知识点 💡 dat.GUI

dat.GUI 是一个轻量级的 JavaScript 图形用户界面控制库,它可以轻松地即时操作变量和触发函数,通过设定好的控制器去快捷的修改设定的变量。下面是它的一些基本使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
js复制代码// 初始化
const gui = new dat.GUI({ name: 'name'});
// 初始化控件属性
const ctrls = {
name: 'acqui',
speed: 0.5,
color1: '#FF0000',
color2: [0, 128, 255],
color3: [0, 128, 255, 0.3],
color4: { h: 350, s: 0.9, v: 0.3 },
test: ',
test2: ',
cb: () => {},
gender:true
};
// gui.add(控件对象变量名,对象属性名,其它参数),控制字符类型或数字
gui.add(ctrls, 'name');
// 缩放区间[0,100],变化步长10
gui.add(ctrls, 'speed', 0, 100, 10);
// 创建一个下拉选择
gui.add(ctrls, 'test', { 低速: 0.005, 中速: 0.01, 高速: 0.1 }).name('转速');
gui.add(ctrls, 'test2', ['低速', '中速', '高速']).name('转速2');
// 创建按钮
gui.add(ctrls, 'cb').name('按钮');
gui.add(ctrls, 'gender').name('性别');
// 控制颜色属性
gui.addColor(ctrls, 'color1');
// 通过name可设置别名
gui.addColor(ctrls, 'color2').name('颜色2');
// 创建一个Folder
const folder = gui.addFolder('颜色组');
folder.addColor(ctrls, 'color3');
folder.addColor(ctrls, 'color4');
//可以通过onChange方法来监听改变的值,从而修改样式
gui.addColor(ctrls, 'color2').onChange(callback);

📌 dat.GUI不仅能用到Three.js开发中,在其他需要实时修改参数查看效果的场景下都能用哦。
官网地址:github.com/dataarts/da…。

③ 使用Canvas样式化粒子

THREE.js 提供了将 HTML5 画布 Canvas 转化为纹理的功能,利用这一特性,我们就可以创建个性化的 Canvas 来美化我们的粒子效果。在本文示例 createParticlesByCanvas 方法中,首先提供了一个 createCanvasTexture 的方法用来生成 Canvas 纹理,在该方法中,我们创建一个 Canvas 画布,然后在上面绘制一个彩色渐变圆环,最后使用 THREE.CanvasTexture 方法将其转化为可以在 Three.js 中渲染的纹理。然后使用该纹理,通过 map 属性将其传递给粒子材质 THREE.PointsMaterial。这样粒子就具有了如下图所示的 Canvas 纹理效果了。

注意 🔺 ,如果此时用鼠标旋转粒子时会发现粒子四周本该透明的地方是黑色的,而且前后粒子之间还存在穿透问题。此时需要给粒子材质设置 depthTest: true、depthWrite: false 设置这两个属性,以解决粒子显示正常的透明效果以及前后叠加层级问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
js复制代码const createParticlesByCanvas = () => {
// 使用canvas创建纹理
const createCanvasTexture = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = 300
canvas.height = 300
ctx.lineWidth = 10;
ctx.beginPath();
ctx.moveTo(170, 120);
var grd = ctx.createLinearGradient(0, 0, 170, 0);
grd.addColorStop('0', 'black');
grd.addColorStop('0.3', 'magenta');
grd.addColorStop('0.5', 'blue');
grd.addColorStop('0.6', 'green');
grd.addColorStop('0.8', 'yellow');
grd.addColorStop(1, 'red');
ctx.strokeStyle = grd;
ctx.arc(120, 120, 50, 0, Math.PI * 2);
ctx.stroke();
const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true
return texture
}
// 创建粒子系统
const createParticles = (size, transparent, opacity, sizeAttenuation, color) => {
const geom = new THREE.BufferGeometry()
const material = new THREE.PointsMaterial({
size: size,
transparent: transparent,
opacity: opacity,
map: texture,
sizeAttenuation: sizeAttenuation,
color: color,
depthTest: true,
depthWrite: false
})
let veticsFloat32Array = []
const range = 500
for (let i = 0; i < 400; i++) {
const particle = new THREE.Vector3(Math.random() * range - range / 2, Math.random() * range - range / 2, Math.random() * range - range / 2)
veticsFloat32Array.push(particle.x, particle.y, particle.z)
}
const vertices = new THREE.Float32BufferAttribute(veticsFloat32Array, 3)
geom.attributes.position = vertices
const particles = new THREE.Points(geom, material)
scene.add(particles)
}
createParticles(40, true, 1, true, 0xffffff)
}

method_3.gif

知识点 💡 Canvas纹理CanvasTexture

用于从 Canvas 元素中创建纹理贴图。

构造函数:

1
js复制代码CanvasTexture(canvas: HTMLElement, mapping: Constant, wrapS: Constant, wrapT: Constant, magFilter: Constant, minFilter: Constant, format: Constant, type: Constant, anisotropy: Number )
  • canvas:将会被用于加载纹理贴图的 Canvas 元素。
  • mapping:纹理贴图将被如何应用到物体上。
  • wrapS:默认值是 THREE.ClampToEdgeWrapping。
  • wrapT:默认值是 THREE.ClampToEdgeWrapping。
  • magFilter:当一个纹素覆盖大于一个像素时贴图将如何采样,默认值为 THREE.LinearFilter。
  • minFilter:当一个纹素覆盖小于一个像素时贴图将如何采样,默认值为 THREE.LinearMipmapLinearFilter。
  • format:在纹理贴图中使用的格式。
  • type:默认值是 THREE.UnsignedByteType。
  • anisotropy:沿着轴,通过具有最高纹素密度的像素的样本数。 默认情况下,这个值为 1。设置一个较高的值将会产生比基本的 mipmap 更清晰的效果,代价是需要使用更多纹理样本。

属性和方法:

  • .isCanvasTexture[Boolean]:检查是否是 CanvasTexture 类型纹理的只读属性。
  • .needsUpdate[Boolean]:是否需要更新,默认值为 true,以便使得 Canvas中的数据能够载入。
  • 其他属性和方法继承于 Texture。

④ 使用纹理贴图样式化粒子

自然,Three.js 中粒子材质也可以直接使用 THREE.TextureLoader 加载图片作为纹理进行粒子样式个性化设置。createParticlesByTexture 方法和 createParticlesByCanvas 除了在给 THREE.PointsMaterial 设置 map 属性之处有区别之外,其他地方完全相同,因此下面代码只保留了关键内容,详情可查看源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
js复制代码const createParticlesByTexture = () => {
const createParticles = (size, transparent, opacity, sizeAttenuation, color) => {
// ...
const material = new THREE.PointsMaterial({
'size': size,
'transparent': transparent,
'opacity': opacity,
// 加载自定义图片作为粒子纹理
'map': new THREE.TextureLoader().load('/images/heart.png'),
'sizeAttenuation': sizeAttenuation,
'color': color,
depthTest: true,
depthWrite: false
})
// ...
}
}

本文示例中采用了一张霓虹心形爱心 💖 图片作为粒子纹理,效果如下图所示:

method_4.gif

⑤ 从高级几何体创建粒子

THREE.Points 是基于几何体的顶点来渲染每个粒子的,利用这一特性我们就可以从高级几何体来创建几何体形状的粒子。下面示例中我们利用 THREE.SphereGeometry 来创建一个球形的粒子系统。为了营造出好看视觉效果效果,我们可以使用 Canvas 的渐变方法 createRadialGradient 创建出一种类似发光特效来作为粒子的纹理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
js复制代码const createParticlesByGeometry = () => {
// 创建发光canvas纹理
const generateSprite = () => {
const canvas = document.createElement('canvas');
canvas.width = 16;
canvas.height = 16;
const context = canvas.getContext('2d');
const gradient = context.createRadialGradient(canvas.width / 2, canvas.height / 2, 0, canvas.width / 2, canvas.height / 2, canvas.width / 2);
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
gradient.addColorStop(0.2, 'rgba(0, 255, 0, 1)');
gradient.addColorStop(0.4, 'rgba(0, 120, 20, 1)');
gradient.addColorStop(1, 'rgba(0, 0, 0, 1)');
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.Texture(canvas);
texture.needsUpdate = true;
return texture;
}
// 创建立方体
const sphereGeometry = new THREE.SphereGeometry(15, 32, 16);
// 创建粒子材质
const material = new THREE.PointsMaterial({
color: 0xffffff,
size: 3,
transparent: true,
blending: THREE.AdditiveBlending,
map: generateSprite(),
depthWrite: false
})
const particles = new THREE.Points(sphereGeometry, material)
scene.add(particles)
}

method_5.png

⑥ 迷失太空

最后,我们利用上述汇总介绍的的粒子系统知识,并结合本专栏前几期的内容进行实践,打造一个趣味的 3D 页面,在无边无际的宇宙,宇航员跌向无尽深渊。

创建宇航员 👨‍🚀

首先使用 GLTFLoader 模型加载器加载宇航员 👨‍🚀 模型到场景中,并调整好模型在场景中的初始大小和位置。

1
2
3
4
5
6
7
8
js复制代码const loader = new GLTFLoader();
loader.load('/models/astronaut.glb', mesh => {
astronaut = mesh.scene;
astronaut.material = new THREE.MeshLambertMaterial();
astronaut.scale.set(.0005, .0005, .0005);
astronaut.position.z = -10;
scene.add(astronaut);
});

astronaut.png

创建宇宙粒子 🌌

使用上述创建粒子系统的方法,创建 1000 个粒子作为宇宙星系,并为它们设置限定范围内随机的位置,星系粒子纹理采用了一张径向渐变的贴图,将其添加到场景中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
js复制代码const geom = new THREE.BufferGeometry();
const material = new THREE.PointsMaterial({
color: 0xffffff,
size: 10,
alphaTest: .8,
map: new THREE.TextureLoader().load('/images/particle.png')
});
let veticsFloat32Array = []
let veticsColors = []
for (let p = 0; p < 1000; p++) {
veticsFloat32Array.push(
rand(20, 30) * Math.cos(p),
rand(20, 30) * Math.sin(p),
rand(-1500, 0)
);
const randomColor = new THREE.Color(Math.random() * 0xffffff);
veticsColors.push(randomColor.r, randomColor.g, randomColor.b);
}
const vertices = new THREE.Float32BufferAttribute(veticsFloat32Array, 3);
const colors = new THREE.Float32BufferAttribute(veticsColors, 3);
geom.attributes.position = vertices;
geom.attributes.color = colors;
const particleSystem = new THREE.Points(geom, material);
scene.add(particleSystem);

场景优化 ✨

根据场景内摄像机的位置以及宇宙星系粒子在Z轴方向的变化,添加合适参数的黑色雾化效果来营造出由近到远星系由亮到暗的变化效果,增强画面的真实性。接着,为了宇航员显示在场景中,根据实际参数增加一些光照效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
js复制代码// 雾化效果
scene.fog = new THREE.FogExp2(0x000000, 0.005);
// 设置光照
let light = new THREE.PointLight(0xFFFFFF, 0.5);
light.position.x = -50;
light.position.y = -50;
light.position.z = 75;
scene.add(light);
light = new THREE.PointLight(0xFFFFFF, 0.5);
light.position.x = 50;
light.position.y = 50;
light.position.z = 75;
scene.add(light);
light = new THREE.PointLight(0xFFFFFF, 0.3);
light.position.x = 25;
light.position.y = 50;
light.position.z = 200;
scene.add(light);
light = new THREE.AmbientLight(0xFFFFFF, 0.02);
scene.add(light);

知识点 💡 雾化效果Fog和FogExp2

为了增强场景的真实性,示例中使用了 FogExp2 雾化效果,那么 THREE.Fog 和 THREE.FogExp2 有什么不同呢?

  • 雾Fog
    • 定义:表示线性雾,雾的密度是随着距离线性增大的,即场景中物体雾化效果随着随距离线性变化。
    • 构造函数:Fog(color, near, far)
      • .color:表示雾的颜色,场景中远处物体为黑色,场景中最近处距离物体是自身颜色,最远和最近之间的物体颜色是物体本身颜色和雾颜色的混合效果。
      • .near:表示应用雾化效果的最小距离,距离活动摄像机长度小于 .near 的物体将不会被雾所影响。
      • .far: 表示应用雾化效果的最大距离,距离活动摄像机长度大于 .far 的物体将不会被雾所影响。
  • 指数雾FogExp2
    • 定义:表示指数雾,即雾的密度随着距离指数而增大。
    • 构造函数:FogExp2(color, density)
      • .color:表示雾的颜色。
      • .density:表示雾的密度的增速,默认值为 0.00025。

添加动画效果 🎦

整个场景的动画分为以下三个部分:

  • 粒子系统动画由以下两部分构成:
    • 粒子系统旋转动画:使用余弦函数 Math.cos(t) 改变粒子系统的 position 来创建旋转运动轨迹,其中 t 指定了旋转速度。
    • 粒子系统由近到远动画:通过遍历更改构成粒子的向量数组中表示每个粒子的 Z轴 方向的值来更新粒子系统实现。
  • 宇航员模型动画:
    • 宇航员旋转动画:通过修改宇航员在 x、y、z 三个坐标轴上的位置参数实现。
    • 宇航员由近到远动画:使用正弦函数 Math.sin(t) 来生成宇航员远近循环动画。
  • 渲染器和相机的页面重绘更新动画。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
js复制代码const updateParticles = () => {
// 粒子系统旋转动画
particleSystem.position.x = 0.2 * Math.cos(t);
particleSystem.position.y = 0.2 * Math.cos(t);
particleSystem.rotation.z += 0.015;
camera.lookAt(particleSystem.position);
// 粒子系统由近到远动画
for (let i = 0; i < veticsFloat32Array.length; i++) {
// 如果是Z轴值,则修改数值
if ((i + 1) % 3 === 0) {
const dist = veticsFloat32Array[i] - camera.position.z;
if (dist >= 0) veticsFloat32Array[i] = rand(-1000, -500);
veticsFloat32Array[i] += 2.5;
const _vertices = new THREE.Float32BufferAttribute(veticsFloat32Array, 3);
geom.attributes.position = _vertices;
}
}
particleSystem.geometry.verticesNeedUpdate = true;
}
const updateMeshes = () => {
if (astronaut) {
// 宇航员由近到远动画
astronaut.position.z = 0.08 * Math.sin(t) + (camera.position.z - 0.2);
// 宇航员旋转动画
astronaut.rotation.x += 0.015;
astronaut.rotation.y += 0.015;
astronaut.rotation.z += 0.01;
}
}
// 场景和相机更新
const updateRenderer = () => {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const needResize = canvas.width !== width || canvas.height !== height;
if (needResize) {
renderer.setSize(width, height, false);
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
}
}
const tick = () => {
updateParticles();
updateMeshes();
updateRenderer();
renderer.render(scene, camera);
requestAnimationFrame(tick);
t += 0.01;
}

其中 rand 方法用于生成两数之间的随机数。

1
js复制代码const rand = (min, max) => min + Math.random() * (max - min);

鼠标移动交互 🖱

为了增强页面的可交互性,可以添加一些鼠标效果。在 迷失太空 这个示例中,当鼠标 🖱 在页面上进行移动时,场景中的宇航员 👨‍🚀 以及相机 📷 的位置会根据鼠标的相反方向发生部分偏移。

1
2
3
4
5
6
7
8
9
10
js复制代码window.addEventListener('mousemove', e => {
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
const dx = -1 * ((cx - e.clientX) / cx);
const dy = -1 * ((cy - e.clientY) / cy);
camera.position.x = dx * 5;
camera.position.y = dy * 5;
astronaut.position.x = dx * 5;
astronaut.position.y = dy * 5;
});

CSS样式优化 💥

最后,使用 CSS 在页面上添加一些装饰性文案和图片,其中的 GRAVITY 字样使用了一种免费的字体,大家可以选择自己喜欢的样式风格进行页面修饰,提升页面的整体视觉效果。

lost_in_space.png

到此,本文涉及 Three.js 粒子系统的全部内容就结束了,完整代码可访问以下地址查看。

源码地址:github.com/dragonir/th…

总结

本文中主要包含的知识点包括:

  • 使用 THREE.Sprite 创建粒子。
  • 使用 THREE.Points 创建粒子。
  • 创建样式化的粒子。
  • 使用 dat.GUI 动态控制页面参数。
  • 使用 Canvas 样式化粒子。
  • 使用纹理贴图样式化粒子。
  • 从高级几何体创建粒子。
  • 给场景添加 Fog 和 FogExp2 雾化效果。
  • 使用正弦余弦函数添加模型动画、鼠标移动动画等。

想了解其他前端知识或其他未在本文中详细描述的Web 3D开发技术相关知识,可阅读我往期的文章。如果有疑问可以在评论中留言,如果觉得文章对你有帮助,不要忘了一键三连哦 👍。

附录

  • [1]. 🌴 Three.js 打造缤纷夏日3D梦中情岛
  • [2]. 🔥 Three.js 实现炫酷的赛博朋克风格3D数字地球大屏
  • [3]. 🐼 Three.js 实现2022冬奥主题3D趣味页面,含冰墩墩
  • [4]. 🦊 Three.js 实现3D开放世界小游戏:阿狸的多元宇宙
  • [5]. 🏆 掘金1000粉!使用Three.js实现一个创意纪念页面
  • ...
  • 【Three.js 进阶之旅】系列专栏访问 👈
  • 更多往期【3D】专栏访问 👈
  • 更多往期【前端】专栏访问 👈

参考

  • [1]. 《Three.js 开发指南——基于WebGL和HTML5在网页上渲染3D图形和动画》
  • [2]. https://threejs.org

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…858687…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%