This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
<audio id="audio" title="01 | 冯·诺依曼体系结构:计算机组成的金字塔" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bf/f0/bfb4cbb06a226658acb06b2ca0ecddf0.mp3"></audio>
学习计算机组成原理,到底是在学些什么呢?这个事儿,一两句话还真说不清楚。不过没关系,我们先从“装电脑”这个看起来没有什么技术含量的事情说起,来弄清楚计算机到底是由什么组成的。
不知道你有没有自己搞过“装机”这回事儿。在2019年的今天大部分人用的计算机应该都已经是组装好的“品牌机”。如果我们把时钟拨回到上世纪八九十年代不少早期的电脑爱好者都是自己采购各种电脑配件来装一台自己的计算机的。
## 计算机的基本硬件组成
早年要自己组装一台计算机要先有三大件CPU、内存和主板。
在这三大件中,我们首先要说的是**CPU**它是计算机最重要的核心配件全名你肯定知道叫中央处理器Central Processing Unit。为什么说CPU是“最重要”的呢因为计算机的所有“计算”都是由CPU来进行的。自然CPU也是整台计算机中造价最昂贵的部分之一。
<img src="https://static001.geekbang.org/resource/image/a9/3c/a9af6307db5b3dde094c964e8940d83c.jpg" alt="">
CPU是一个超级精细的印刷电路板[图片来源](https://www.flickr.com/photos/130561288@N04/39836037882/in/photolist-23Gb7cm-25V6DAn-q421FW-qMvhAJ-7yVugk-qMvgHb-o3NoQV-qMwDkj-qMvgT1-7yVu7T-qMvgMj-7yVu5c-py3Fpg-8pZhf1-7yZhR5-7yVuax-ewr4C-7TQAKk-7SbTox-8pZh3b-fkLugb-HCGERb-231L6Mo-5SSUsD-28WhLvN-K2Tvk-98Cc4e-6ag8YH-7Sf6KS-aDGEYV-7yY2XT-b66LSc-r2oZqk-rPcasz-7TQ1dB-754sSu-qMwEzy-npvMDK-4BDkou-zrid4-a8X3jn-5uTaCd-7SbRFV-7TTeJh-6ag8zX-6akhEm-7ihCSj-8Whgmi-6j5iUJ-6ag8m8)
第二个重要的配件,就是**内存**Memory。你撰写的程序、打开的浏览器、运行的游戏都要加载到内存里才能运行。程序读取的数据、计算得到的结果也都要放在内存里。内存越大能加载的东西自然也就越多。
<img src="https://static001.geekbang.org/resource/image/aa/ad/aa20e3813fd7cb438bb0c13f43e09cad.jpg" alt="">
内存通常直接可以插在主板上,[图片来源](https://www.flickr.com/photos/dennissylvesterhurd/7633424314/in/photolist-cCxi73-4DT7ov-5SFN7f-22ptD6Q-5SEAjJ-5SMkhQ-qvfnJh-7TQ7bM-5SAgnX-jwzhXx-5SFTJY-7TQe2k-atvnG7-YGowK7-4w9tXh-5SEDih-dPcqJ1-5SAgFV-8EboSi-5SGJ9r-62Yv2h-5Tft1r-5Xz9Na-89gSAF-5SFFVy-5SMcvH-5KtAAz-eaehyJ-8kYkea-rEdcLj-b39Kug-EST98f-8tR3Vk-7ihCSj-dTYG6-YL543f-4dEEe-BJ8QZ-88ZMZg-6ZzkhW-8Z6NkM-5SBoXn-6JKJfA-7Zx3Su-5SFT2q-7TQkLk-75VyrS-5SGnr4-5SJnWV-5SBpq8)
存放在内存里的程序和数据需要被CPU读取CPU计算完之后还要把数据写回到内存。然而CPU不能直接插到内存上反之亦然。于是就带来了最后一个大件——**主板**Motherboard
主板是一个有着各种各样有时候多达数十乃至上百个插槽的配件。我们的CPU要插在主板上内存也要插在主板上。主板的**芯片组**Chipset和**总线**Bus解决了CPU和内存之间如何通信的问题。芯片组控制了数据传输的流转也就是数据从哪里到哪里的问题。总线则是实际数据传输的高速公路。因此**总线速度**Bus Speed决定了数据能传输得多快。
<img src="https://static001.geekbang.org/resource/image/16/b0/16bed40e3f1b1484e842cac3d6e596b0.jpg" alt="">
计算机主板上通常有着各种各样的插槽,[图片来源](https://www.flickr.com/photos/117150261@N02/12448712795/in/photolist-jY3UBe-7JggqE-DUWwNz-9GWzCa-bvGsRS-8m9cYn-e1BaEo-5SEAjJ-5SMkhQ-2eXVzdk-5SEDoU-dmvKB-5SAgnX-5SFTJY-e1vtir-5Pnxus-5SFFVy-63duyC-5SMcvH-jrTkcC-25V6DAn-imfxix-7VRFgR-inZF2N-io1oLM-zHB1BQ-C7aA66-dmE49-K6oVVQ-7VUTom-4pd9Jb-5SEDih-6LK87S-5SAgFV-5SGJ9r-22u9CTJ-7ihCSj-75VyrS-5PigdF-5SGnr4-5SJnWV-5SBpq8-5SNggT-jrTfcY-5SAjgT-5SSUsD-5SAgMi-4eqcQq-22cvYDk-5SAgSn)
有了三大件,只要配上**电源**供电计算机差不多就可以跑起来了。但是现在还缺少各类输入Input/输出Output设备也就是我们常说的**I/O设备**。如果你用的是自己的个人电脑,那显示器肯定必不可少,只有有了显示器我们才能看到计算机输出的各种图像、文字,这也就是所谓的**输出设备**。
同样的,鼠标和键盘也都是必不可少的配件。这样我才能输入文本,写下这篇文章。它们也就是所谓的**输入设备**。
最后,你自己配的个人计算机,还要配上一个硬盘。这样各种数据才能持久地保存下来。绝大部分人都会给自己的机器装上一个机箱,配上风扇,解决灰尘和散热的问题。不过机箱和风扇,算不上是计算机的必备硬件,我们拿个纸板或者外面放个电风扇,也一样能用。
说了这么多其实你应该有感觉了显示器、鼠标、键盘和硬盘这些东西并不是一台计算机必须的部分。你想一想我们其实只需要有I/O设备能让我们从计算机里输入和输出信息是不是就可以了答案当然是肯定的。
你肯定去过网吧吧不知道你注意到没有很多网吧的计算机就没有硬盘而是直接通过局域网读写远程网络硬盘里面的数据。我们日常用的各类云服务器只要让计算机能通过网络SSH远程登陆访问就好了因此也没必要配显示器、鼠标、键盘这些东西。这样不仅能够节约成本还更方便维护。
还有一个很特殊的设备,就是**显卡**Graphics Card。现在使用图形界面操作系统的计算机无论是Windows、Mac OS还是Linux显卡都是必不可少的。有人可能要说了我装机的时候没有买显卡计算机一样可以正常跑起来啊那是因为现在的主板都带了内置的显卡。如果你用计算机玩游戏做图形渲染或者跑深度学习应用你多半就需要买一张单独的显卡插在主板上。显卡之所以特殊是因为显卡里有除了CPU之外的另一个“处理器”也就是**GPU**Graphics Processing Unit图形处理器GPU一样可以做各种“计算”的工作。
鼠标、键盘以及硬盘这些都是插在主板上的。作为外部I/O设备它们是通过主板上的**南桥**SouthBridge芯片组来控制和CPU之间的通信的。“南桥”芯片的名字很直观一方面它在主板上的位置通常在主板的“南面”。另一方面它的作用就是作为“桥”来连接鼠标、键盘以及硬盘这些外部设备和CPU之间的通信。
有了南桥自然对应着也有“北桥”。是的以前的主板上通常也有“北桥”芯片用来作为“桥”连接CPU和内存、显卡之间的通信。不过随着时间的变迁现在的主板上的“北桥”芯片的工作已经被移到了CPU的内部所以你在主板上已经看不到北桥芯片了。
## 冯·诺依曼体系结构
刚才我们讲了一台计算机的硬件组成,这说的是我们平时用的个人电脑或者服务器。那我们平时最常用的智能手机的组成,也是这样吗?
我们手机里只有SD卡Secure Digital Memory Card这样类似硬盘功能的存储卡插槽并没有内存插槽、CPU插槽这些东西。没错因为手机尺寸的原因手机制造商们选择把CPU、内存、网络通信乃至摄像头芯片都封装到一个芯片然后再嵌入到手机主板上。这种方式叫**SoC**也就是System on a Chip系统芯片
这样看起来个人电脑和智能手机的硬件组成方式不太一样。可是我们写智能手机上的App和写个人电脑的客户端应用似乎没有什么差别都是通过“高级语言”这样的编程语言撰写、编译之后一样是把代码和数据加载到内存里来执行。这是为什么呢因为无论是个人电脑、服务器、智能手机还是Raspberry Pi这样的微型卡片机都遵循着同一个“计算机”的抽象概念。这是怎么样一个“计算机”呢这其实就是计算机祖师爷之一冯·诺依曼John von Neumann提出的**冯·诺依曼体系结构**Von Neumann architecture也叫**存储程序计算机**。
什么是存储程序计算机呢?这里面其实暗含了两个概念,一个是“**可编程**”计算机,一个是“**存储**”计算机。
说到“可编程”,估计你会有点懵,你可以先想想,什么是“不可编程”。计算机是由各种门电路组合而成的,然后通过组装出一个固定的电路板,来完成一个特定的计算程序。一旦需要修改功能,就要重新组装电路。这样的话,计算机就是“不可编程”的,因为程序在计算机硬件层面是“写死”的。最常见的就是老式计算器,电路板设好了加减乘除,做不了任何计算逻辑固定之外的事情。
<img src="https://static001.geekbang.org/resource/image/9b/6a/9bc9634431f627d3e684ce2f83cd946a.jpg" alt="">
计算器的本质是一个不可编程的计算机,[图片来源](https://www.flickr.com/photos/horiavarlan/4273218725/in/photolist-7vBn3V-3j7qrv-8iUqcs-biaK7a-qdmGPv-3jbGUN-6pFNS-3jbBa1-4MZAxs-292yK5p-2akim1j-26Bw8bE-qgskU-4EeDGe-NhdPhL-28gSRkC-292yLd6-4wVKuz-29iaje9-81BJ2h-27DSFgw-292yQkV-2akis1L-292yWRa-292yTqn-9sATYG-2akirG9-29ian6G-27DSDV5-9sAUCq-8EGHW5-29iaj49-2akigzf-29iarj1-MexNtE-292yUkt-LDNqXB-29jdR8d-4pyKYY-29nivE4-29iavZy-29iamfy-292yUMa-2akig6u-2akifN5-29jdQs5-29jdQhW-2akifUN-29jdRah-29jdQtN)
我们再来看“存储”计算机。这其实是说程序本身是存储在计算机的内存里可以通过加载不同的程序来解决不同的问题。有“存储程序计算机”自然也有不能存储程序的计算机。典型的就是早年的“Plugboard”这样的插线板式的计算机。整个计算机就是一个巨大的插线板通过在板子上不同的插头或者接口的位置插入线路来实现不同的功能。这样的计算机自然是“可编程”的但是编写好的程序不能存储下来供下一次加载使用不得不每次要用到和当前不同的“程序”的时候重新插板子重新“编程”。
<img src="https://static001.geekbang.org/resource/image/cb/9e/cbf639bab23f61d464aa80b4fd10019e.jpg" alt="">
著名的[Engima Machine](https://en.wikipedia.org/wiki/Enigma_machine)就用到了Plugboard来进行“编程”[图片来源](https://commons.wikimedia.org/wiki/File:Enigma-plugboard.jpg)
可以看到,无论是“不可编程”还是“不可存储”,都会让使用计算机的效率大大下降。而这个对于效率的追求,也就是“存储程序计算机”的由来。
于是我们的冯祖师爷基于当时在秘密开发的EDVAC写了一篇报告[**First Draft of a Report on the EDVAC**](https://en.wikipedia.org/wiki/First_Draft_of_a_Report_on_the_EDVAC),描述了他心目中的一台计算机应该长什么样。这篇报告在历史上有个很特殊的简称,叫**First Draft**,翻译成中文,其实就是《第一份草案》。这样,现代计算机的发展就从祖师爷写的一份草案开始了。
**First Draft**里面说了一台计算机应该有哪些部分组成,我们一起来看看。
首先是一个包含算术逻辑单元Arithmetic Logic UnitALU和处理器寄存器Processor Register的**处理器单元**Processing Unit用来完成各种算术和逻辑运算。因为它能够完成各种数据的处理或者计算工作因此也有人把这个叫作数据通路Datapath或者运算器。
然后是一个包含指令寄存器Instruction Register和程序计数器Program Counter的**控制器单元**Control Unit/CU用来控制程序的流程通常就是不同条件下的分支和跳转。在现在的计算机里上面的算术逻辑单元和这里的控制器单元共同组成了我们说的CPU。
接着是用来存储数据Data和指令Instruction的**内存**。以及更大容量的**外部存储**,在过去,可能是磁带、磁鼓这样的设备,现在通常就是硬盘。
最后就是各种**输入和输出设备**,以及对应的输入和输出机制。我们现在无论是使用什么样的计算机,其实都是和输入输出设备在打交道。个人电脑的鼠标键盘是输入设备,显示器是输出设备。我们用的智能手机,触摸屏既是输入设备,又是输出设备。而跑在各种云上的服务器,则是通过网络来进行输入和输出。这个时候,网卡既是输入设备又是输出设备。
任何一台计算机的任何一个部件都可以归到运算器、控制器、存储器、输入设备和输出设备中,而所有的现代计算机也都是基于这个基础架构来设计开发的。
而所有的计算机程序,也都可以抽象为从**输入设备**读取输入信息,通过**运算器**和**控制器**来执行存储在**存储器**里的程序,最终把结果输出到**输出设备**中。而我们所有撰写的无论高级还是低级语言的程序,也都是基于这样一个抽象框架来进行运作的。
<img src="https://static001.geekbang.org/resource/image/fa/2b/fa8e0e3c96a70cc07b4f0490bfe66f2b.jpeg" alt="">
冯·诺依曼体系结构示意图,[图片来源](https://en.wikipedia.org/wiki/Von_Neumann_architecture#/media/File:Von_Neumann_Architecture.svg)
## 总结延伸
可以说,冯·诺依曼体系结构确立了我们现在每天使用的计算机硬件的基础架构。因此,学习计算机组成原理,其实就是学习和拆解冯·诺依曼体系结构。
具体来说学习组成原理其实就是学习控制器、运算器的工作原理也就是CPU是怎么工作的以及为何这样设计学习内存的工作原理从最基本的电路到上层抽象给到CPU乃至应用程序的接口是怎样的学习CPU是怎么和输入设备、输出设备打交道的。
学习组成原理,就是在理解从控制器、运算器、存储器、输入设备以及输出设备,从电路这样的硬件,到最终开放给软件的接口,是怎么运作的,为什么要设计成这样,以及在软件开发层面怎么尽可能用好它。
好了,这一讲说到这儿就结束了。你应该已经理解了计算机的硬件是由哪些设备组成的,以及冯·诺依曼体系结构是什么样的了。下一讲,我会带你看一张地图,也是计算机组成原理的知识地图。我们一起来看一看怎么样才是学习组成原理的好方法。
## 推荐阅读
我一直认为,读读经典的论文,是从一个普通工程师迈向优秀工程师必经的一步。如果你有时间,不妨去读一读[**First Draft of a Report on the EDVAC**](https://en.wikipedia.org/wiki/First_Draft_of_a_Report_on_the_EDVAC)。对于工程师来说,直接读取英文论文的原文,既可以搞清楚、弄明白对应的设计及其背后的思路来源,还可以帮你破除对于论文或者核心技术的恐惧心理。
## 课后思考
计算机行业的两大祖师爷之一除了冯·诺依曼机之外还有一位就是著名的图灵Alan Mathison Turing。对应的我们现在的计算机也叫**图灵机**Turing Machine。那么图灵机和冯·诺依曼机是两种不同的计算机么图灵机是一种什么样的计算机抽象呢
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,110 @@
<audio id="audio" title="02 | 给你一张知识地图,计算机组成原理应该这么学" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3b/87/3b1729fc5eaa20f711b753f65d147d87.mp3"></audio>
了解了现代计算机的基本硬件组成和背后最基本的冯·诺依曼体系结构,我们就可以正式进入计算机组成原理的学习了。在学习一个一个零散的知识点之前,我整理了一份学习地图,好让你对将要学习的内容有一个总纲层面的了解。
<img src="https://static001.geekbang.org/resource/image/12/ff/12bc980053ea355a201e2b529048e2ff.jpg" alt="">
从这张图可以看出来,**整个计算机组成原理,就是围绕着计算机是如何组织运作展开的**。
## 计算机组成原理知识地图
计算机组成原理的英文叫Computer Organization。这里的Organization是“组织机构”的意思。计算机由很多个不同的部件放在一起变成了一个“组织机构”。这个组织机构最终能够进行各种计算、控制、读取输入进行输出达成各种强大的功能。
在这张图里面我们把整个计算机组成原理的知识点拆分成了四大部分分别是计算机的基本组成、计算机的指令和计算、处理器设计以及存储器和I/O设备。
首先,我们来看**计算机的基本组成**。
这一部分,你需要学习计算机是由哪些硬件组成的。这些硬件,又是怎么对应到经典的冯·诺依曼体系结构中的,也就是运算器、控制器、存储器、输入设备和输出设备这五大基本组件。除此之外,你还需要了解计算机的两个核心指标,性能和功耗。性能和功耗也是我们在应用和设计五大基本组件中需要重点考虑的因素。
了解了组成部分,接下来你需要掌握**计算机的指令和计算**。
在计算机指令部分你需要搞明白我们每天撰写的一行行C、Java、PHP程序是怎么在计算机里面跑起来的。这里面你既需要了解我们的程序是怎么通过编译器和汇编器变成一条条机器指令这样的编译过程如果把编译过程展开的话可以变成一门完整的编译原理课程还需要知道我们的操作系统是怎么链接、装载、执行这些程序的这部分知识如果再深入学习又可以变成一门操作系统课程。而这一条条指令执行的控制过程就是由计算机五大组件之一的**控制器**来控制的。
在计算机的计算部分你要从二进制和编码开始理解我们的数据在计算机里的表示以及我们是怎么从数字电路层面实现加法、乘法这些基本的运算功能的。实现这些运算功能的ALUArithmetic Logic Unit/ALU也就是算术逻辑单元其实就是我们计算机五大组件之一的**运算器**。
这里面有一个在今天看起来特别重要的知识点就是浮点数Floating Point。浮点数是我们在日常运用中非常容易用错的一种数据表示形式。掌握浮点数能让你对数据的编码、存储和计算能够有一个从表到里的深入理解。尤其在AI火热的今天浮点数是机器学习中重度使用的数据表示形式掌握它更是非常有必要。
明白计算机指令和计算是如何运转的,我们就可以深入到**CPU的设计**中去一探究竟了。
CPU时钟可以用来构造寄存器和内存的锁存器和触发器因此CPU时钟应该是我们学习CPU的前导知识。搞明白我们为什么需要CPU时钟CPU Clock以及寄存器和内存是用什么样的硬件组成的之后我们可以再来看看整个计算机的数据通路是如何构造出来的。
数据通路其实就是连接了整个运算器和控制器并最终组成了CPU。而出于对于性能和功耗的考虑你要进一步理解和掌握面向流水线设计的CPU、数据和控制冒险以及分支预测的相关技术。
既然CPU作为控制器要和输入输出设备通信那么我们就要知道异常和中断发生的机制。在CPU设计部分的最后我会讲一讲指令的并行执行看看如何直接在CPU层面通过SIMD来支持并行计算。
最后,我们需要看一看,计算机五大组成部分之一,**存储器的原理**。通过存储器的层次结构作为基础的框架引导你需要掌握从上到下的CPU高速缓存、内存、SSD硬盘和机械硬盘的工作原理它们之间的性能差异以及实际应用中利用这些设备会遇到的挑战。存储器其实很多时候又扮演了输入输出设备的角色所以你需要进一步了解CPU和这些存储器之间是如何进行通信的以及我们最重视的性能问题是怎么一回事理解什么是IO_WAIT如何通过DMA来提升程序性能。
对于存储器我们不仅需要它们能够正常工作还要确保里面的数据不能丢失。于是你要掌握我们是如何通过RAID、Erasure Code、ECC以及分布式HDFS这些不同的技术来确保数据的完整性和访问性能。
## 学习计算机组成原理,究竟有没有好办法?
相信这个学习地图,应该让你对计算机组成这门课要学些什么,有了一些了解。不过这个地图上的知识点繁多,应该也给你带来了不小的挑战。
我上一节也说过,相较于整个计算机科学中的其他科目,计算机组成原理更像是整个计算机学科里的“纲要”。这门课里任何一个知识点深入挖下去,都可以变成计算机科学里的一门核心课程。
比如说程序怎样从高级代码变成指令在计算机里面运行对应着“编译原理”和“操作系统”这两门课程计算实现背后则是“数字电路”如果要深入CPU和存储器系统的优化必然要深入了解“计算机体系结构”。
因此,为了帮你更快更好地学计算机组成,我为你总结了三个学习方法,帮你更好地掌握这些知识点,并且能够学为所用,让你在工作中能够用得上。
首先,**学会提问自己来串联知识点**。学完一个知识点之后,你可以从下面两个方面,问一下自己。
<li>
我写的程序,是怎样从输入的代码,变成运行的程序,并得到最终结果的?
</li>
<li>
整个过程中,计算器层面到底经历了哪些步骤,有哪些地方是可以优化的?
</li>
无论是程序的编译、链接、装载和执行以及计算时需要用到的逻辑电路、ALU乃至CPU自发为你做的流水线、指令级并行和分支预测还有对应访问到的硬盘、内存以及加载到高速缓存中的数据这些都对应着我们学习中的一个个知识点。建议你自己脑子里过一遍最好是口头表述一遍或者写下来这样对你彻底掌握这些知识点都会非常有帮助。
其次,**写一些示例程序来验证知识点。**计算机科学是一门实践的学科。计算机组成中的大量原理和设计,都对应着“性能”这个词。因此,通过把对应的知识点,变成一个个性能对比的示例代码程序记录下来,是把这些知识点融汇贯通的好方法。因为,相比于强记硬背知识点,一个有着明确性能对比的示例程序,会在你脑海里留下更深刻的印象。当你想要回顾这些知识点的时候,一个程序也更容易提示你把它从脑海深处里面找出来。
最后,**通过和计算机硬件发展的历史做对照**。计算机的发展并不是一蹴而就的。从第一台电子计算机ENIACElectronic Numerical Integrator And Computer电子数值积分计算机的发明到现在已经有70多年了。现代计算机用的各个技术都是跟随实际应用中遇到的挑战一个个发明、打磨最后保留下来的。这当中不仅仅有学术层面的碰撞更有大量商业层面的交锋。通过了解充满戏剧性和故事性的计算机硬件发展史让你更容易理解计算机组成中各种原理的由来。
比如说奔腾4和SPARC的失败以及ARM的成功能让我们记住CPU指令集的繁与简、权衡性能和功耗的重要性而现今高速发展的机器学习和边缘计算又给计算机硬件设计带来了新的挑战。
## 给松鼠症患者的学习资料
学习总是要花点笨功夫的。最有效的办法还是“读书百遍,其义自见”。对于不够明白的知识点,多搜索,多看不同来源的资料,多和朋友、同事、老师一起交流,一定能够帮你掌握好想要学习的知识点。
在这个专栏之前,计算机组成原理,已经有很多优秀的图书和课程珠玉在前了。为了覆盖更多知识点的细节,这些书通常都有点厚,课程都会有点长。不过作为专栏的补充阅读材料,却是最合适不过了。
因此,每一讲里,我都会留下一些“**补充阅读**”的材料。如果你想更进一步理解更多深入的计算机组成原理的知识,乃至更多相关的其他核心课程的知识,多用一些业余时间来看一看,读一读这些“补充阅读”也一定不会让你对花在上面的时间后悔的。
下面给你推荐一些我自己看过、读过的内容。我在之后的文章里推荐的“补充阅读”,大部分都是来自这些资料。你可以根据自己的情况来选择学习。
### 入门书籍
我知道,订阅这个专栏的同学,有很多是非计算机科班出身,我建议你先对计算机组成原理这门课有个基本概念。建立这个概念,有两种方法,第一,你可以把我上面那张地图的核心内容记下来,对这些内容之间的关系先有个大致的了解。
第二,我推荐你阅读两本书,准确地说,这其实是两本小册子,因为它们非常轻薄、好读,而且图文并茂,非常适合初学者和想要入门组成原理的同学。一本是《计算机是怎样跑起来的》,另一本是《程序是怎样跑起来的》。我要特别说一下后面这本,它可以说是一个入门微缩版本的“计算机组成原理”。
除此之外计算机组成中硬件层面的基础实现比如寄存器、ALU这些电路是怎么回事你可以去看一看Coursera上的北京大学免费公开课[](https://www.coursera.org/learn/jisuanji-zucheng)[Computer Organization](https://www.coursera.org/learn/jisuanji-zucheng)[](https://www.coursera.org/learn/jisuanji-zucheng)。这个视频课程的视频部分也就10多个小时。在学习专栏相应章节的前后去浏览一遍相信对你了解程序在电路层面会变成什么样子有所帮助。
### 深入学习书籍
对于想要深入掌握计算机组成的同学,我推荐你去读一读《计算机组成与设计:硬件/软件接口》和经典的《深入理解计算机系统》这两本书。后面这本被称为CSAPP的经典教材网上也有配套的视频课程。我在这里给你推荐两个不同版本的链接[B](https://www.bilibili.com/video/av24540152/)[ilibili版](https://www.bilibili.com/video/av24540152/)和[Y](https://www.youtube.com/playlist?list=PLmBgoRqEQCWy58EIwLSWwMPfkwLOLRM5R)[outube版](https://www.youtube.com/playlist?list=PLmBgoRqEQCWy58EIwLSWwMPfkwLOLRM5R) 。不过这两本都在500页以上坚持啃下来需要不少实践经验。
计算机组成原理还有一本的经典教材就是来自操作系统大神塔能鲍姆Andrew S. Tanenbaum的《计算机组成结构化方法》。这本书的组织结构和其他教材都不太一样适合作为一个辅助的参考书来使用。
如果在学习这个专栏的过程中,引发了你对于计算机体系结构的兴趣,你还可以深入读一读《计算机体系结构:量化研究方法》。
### 课外阅读
在上面这些教材之外对于资深程序员来说来自Redhat的**What Every Programmer Should Know About Memory**是写出高性能程序不可不读的经典材料。而LMAX开源的Disruptor则是通过实际应用程序来理解计算机组成原理中各个知识点的最好范例了。
《编码:隐匿在计算机软硬件背后的语言》和《程序员的自我修养:链接、装载和库》是理解计算机硬件和操作系统层面代码执行的优秀阅读材料。
## 总结延伸
学习不是死记硬背,学习材料也不是越多越好。到了这里,希望你不要因为我给出了太多可以学习的材料,结果成了“松鼠症”患者,光囤积材料,却没有花足够多的时间去学习这些知识。
我工作之后一直在持续学习,在这个过程中,我发现最有效的办法,**不是短时间冲刺,而是有节奏地坚持,希望你能够和专栏的发布节奏同步推进,做好思考题,并且多在留言区和其他朋友一起交流**,就更容易能够“积小步而至千里”,在程序员这个职业上有更长足的发展。
好了,对于学习资料的介绍就到这里了。希望在接下来的几个月里,你能和我一起走完这趟“计算机组成”之旅,从中收获到知识和成长。
## 课后思考
今天我为你梳理计算机组成的知识地图,也讲了我认为学习这个专栏的一些方法,听了这么多,那么你打算怎么学习这个专栏呢?
欢迎你在留言区写下你的学习目标和学习计划,和大家一起交流,也欢迎你把今天的文章分享给你的朋友,互相督促,共同成长。

View File

@@ -0,0 +1,114 @@
<audio id="audio" title="03 | 通过你的CPU主频我们来谈谈“性能”究竟是什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/eb/bd/eb4c772179b74302531d32aa6da224bd.mp3"></audio>
“性能”这个词,不管是在日常生活还是写程序的时候,都经常被提到。比方说,买新电脑的时候,我们会说“原来的电脑性能跟不上了”;写程序的时候,我们会说,“这个程序性能需要优化一下”。那么,你有没有想过,我们常常挂在嘴边的“性能”到底指的是什么呢?我们能不能给性能下一个明确的定义,然后来进行准确的比较呢?
在计算机组成原理乃至体系结构中,“性能”都是最重要的一个主题。我在前面说过,学习和研究计算机组成原理,就是在理解计算机是怎么运作的,以及为什么要这么运作。“为什么”所要解决的事情,很多时候就是提升“性能”。
## 什么是性能?时间的倒数
计算机的性能,其实和我们干体力劳动很像,好比是我们要搬东西。对于计算机的性能,我们需要有个标准来衡量。这个标准中主要有两个指标。
第一个是**响应时间**Response time或者叫执行时间Execution time。想要提升响应时间这个性能指标你可以理解为让计算机“跑得更快”。
<img src="https://static001.geekbang.org/resource/image/4c/96/4c87a1851aeb6857a323064859da6396.png" alt="">
第二个是**吞吐率**Throughput或者带宽Bandwidth想要提升这个指标你可以理解为让计算机“搬得更多”。
<img src="https://static001.geekbang.org/resource/image/27/27/27cab77c0eec95ec29792e6c3d093d27.png" alt="">
所以说,响应时间指的就是,我们执行一个程序,到底需要花多少时间。花的时间越少,自然性能就越好。
而吞吐率是指我们在一定的时间范围内,到底能处理多少事情。这里的“事情”,在计算机里就是处理的数据或者执行的程序指令。
和搬东西来做对比,如果我们的响应时间短,跑得快,我们可以来回多跑几趟多搬几趟。所以说,缩短程序的响应时间,一般来说都会提升吞吐率。
除了缩短响应时间我们还有别的方法吗当然有比如说我们还可以多找几个人一起来搬这就类似现代的服务器都是8核、16核的。人多力量大同时处理数据在单位时间内就可以处理更多数据吞吐率自然也就上去了。
提升吞吐率的办法有很多。大部分时候我们只要多加一些机器多堆一些硬件就好了。但是响应时间的提升却没有那么容易因为CPU的性能提升其实在10年前就处于“挤牙膏”的状态了所以我们得慎重地来分析对待。下面我们具体来看。
我们一般把性能,定义成响应时间的倒数,也就是:
这样一来响应时间越短性能的数值就越大。同样一个程序在Intel最新的CPU Coffee Lake上只需要30s就能运行完成而在5年前CPU Sandy Bridge上需要1min才能完成。那么我们自然可以算出来Coffee Lake的性能是1/30Sandy Bridge的性能是1/60两个的性能比为2。于是我们就可以说Coffee Lake的性能是Sandy Bridge的2倍。
过去几年流行的手机跑分软件就是把多个预设好的程序在手机上运行然后根据运行需要的时间算出一个分数来给出手机的性能评估。而在业界各大CPU和服务器厂商组织了一个叫作**SPEC**Standard Performance Evaluation Corporation的第三方机构专门用来指定各种“跑分”的规则。
<img src="https://static001.geekbang.org/resource/image/a5/22/a50a6cb9d3df027aeda5ee8e53b75422.png" alt="">
SPEC提供的CPU基准测试程序就好像CPU届的“高考”通过数十个不同的计算程序对于CPU的性能给出一个最终评分。这些程序丰富多彩有编译器、解释器、视频压缩、人工智能国际象棋等等涵盖了方方面面的应用场景。感兴趣的话你可以点击[这个链接](https://www.spec.org/cpu2017/results/cpu2017.html)看看。
## 计算机的计时单位CPU时钟
虽然时间是一个很自然的用来衡量性能的指标,但是用时间来衡量时,有两个问题。
**第一个就是时间不“准”**。如果用你自己随便写的一个程序来统计程序运行的时间每一次统计结果不会完全一样。有可能这一次花了45ms下一次变成了53ms。
为什么会不准呢这里面有好几个原因。首先我们统计时间是用类似于“掐秒表”一样记录程序运行结束的时间减去程序开始运行的时间。这个时间也叫Wall Clock Time或者Elapsed Time就是在运行程序期间挂在墙上的钟走掉的时间。
但是计算机可能同时运行着好多个程序CPU实际上不停地在各个程序之间进行切换。在这些走掉的时间里面很可能CPU切换去运行别的程序了。而且有些程序在运行的时候可能要从网络、硬盘去读取数据要等网络和硬盘把数据读出来给到内存和CPU。所以说**要想准确统计某个程序运行时间,进而去比较两个程序的实际性能,我们得把这些时间给刨除掉**。
那这件事怎么实现呢Linux下有一个叫time的命令可以帮我们统计出来同样的Wall Clock Time下程序实际在CPU上到底花了多少时间。
我们简单运行一下time命令。它会返回三个值第一个是**real time**也就是我们说的Wall Clock Time也就是运行程序整个过程中流逝掉的时间第二个是**user time**也就是CPU在运行你的程序在用户态运行指令的时间第三个是**sys time**是CPU在运行你的程序在操作系统内核里运行指令的时间。而**程序实际花费的CPU执行时间CPU Time就是user time加上sys time**。
```
$ time seq 1000000 | wc -l
1000000
real 0m0.101s
user 0m0.031s
sys 0m0.016s
```
在我给的这个例子里你可以看到实际上程序用了0.101s但是CPU time只有0.031+0.016 = 0.047s。运行程序的时间里,只有不到一半是实际花在这个程序上的。
<img src="https://static001.geekbang.org/resource/image/0b/00/0b340db019d7e389a2bde4c237ee4700.jpg" alt="">
**其次即使我们已经拿到了CPU时间我们也不一定可以直接“比较”出两个程序的性能差异**。即使在同一台计算机上CPU可能满载运行也可能降频运行降频运行的时候自然花的时间会多一些。
除了CPU之外时间这个性能指标还会受到主板、内存这些其他相关硬件的影响。所以我们需要对“时间”这个我们可以感知的指标进行拆解把程序的CPU执行时间变成 CPU时钟周期数CPU Cycles和 时钟周期时间Clock Cycle的乘积。
我们先来理解一下什么是时钟周期时间。你在买电脑的时候一定关注过CPU的主频。比如我手头的这台电脑就是Intel Core-i7-7700HQ 2.8GHz这里的2.8GHz就是电脑的主频Frequency/Clock Rate。这个2.8GHz我们可以先粗浅地认为CPU在1秒时间内可以执行的简单指令的数量是2.8G条。
如果想要更准确一点描述这个2.8GHz就代表我们CPU的一个“钟表”能够识别出来的最小的时间间隔。就像我们挂在墙上的挂钟都是“滴答滴答”一秒一秒地走所以通过墙上的挂钟能够识别出来的最小时间单位就是秒。
而在CPU内部和我们平时戴的电子石英表类似有一个叫晶体振荡器Oscillator Crystal的东西简称为晶振。我们把晶振当成CPU内部的电子表来使用。晶振带来的每一次“滴答”就是时钟周期时间。
在我这个2.8GHz的CPU上这个时钟周期时间就是1/2.8G。我们的CPU是按照这个“时钟”提示的时间来进行自己的操作。主频越高意味着这个表走得越快我们的CPU也就“被逼”着走得越快。
如果你自己组装过台式机的话可能听说过“超频”这个概念这说的其实就相当于把买回来的CPU内部的钟给调快了于是CPU的计算跟着这个时钟的节奏也就自然变快了。当然这个快不是没有代价的CPU跑得越快散热的压力也就越大。就和人一样超过生理极限CPU就会崩溃了。
我们现在回到上面程序CPU执行时间的公式。
最简单的提升性能方案自然缩短时钟周期时间也就是提升主频。换句话说就是换一块好一点的CPU。不过这个是我们这些软件工程师控制不了的事情所以我们就把目光挪到了乘法的另一个因子——CPU时钟周期数上。如果能够减少程序需要的CPU时钟周期数量一样能够提升程序性能。
对于CPU时钟周期数我们可以再做一个分解把它变成“指令数×**每条指令的平均时钟周期数**Cycles Per Instruction简称CPI”。不同的指令需要的Cycles是不同的加法和乘法都对应着一条CPU指令但是乘法需要的Cycles就比加法要多自然也就慢。在这样拆分了之后我们的程序的CPU执行时间就可以变成这样三个部分的乘积。
因此,如果我们想要解决性能问题,其实就是要优化这三者。
<li>
时钟周期时间,就是计算机主频,这个取决于计算机硬件。我们所熟知的[摩尔定律](https://zh.wikipedia.org/wiki/%E6%91%A9%E5%B0%94%E5%AE%9A%E5%BE%8B)就一直在不停地提高我们计算机的主频。比如说我最早使用的80386主频只有33MHz现在手头的笔记本电脑就有2.8GHz在主频层面就提升了将近100倍。
</li>
<li>
每条指令的平均时钟周期数CPI就是一条指令到底需要多少CPU Cycle。在后面讲解CPU结构的时候我们会看到现代的CPU通过流水线技术Pipeline让一条指令需要的CPU Cycle尽可能地少。因此对于CPI的优化也是计算机组成和体系结构中的重要一环。
</li>
<li>
指令数,代表执行我们的程序到底需要多少条指令、用哪些指令。这个很多时候就把挑战交给了编译器。同样的代码,编译成计算机指令时候,就有各种不同的表示方式。
</li>
我们可以把自己想象成一个CPU坐在那里写程序。计算机主频就好像是你的打字速度打字越快你自然可以多写一点程序。CPI相当于你在写程序的时候熟悉各种快捷键越是打同样的内容需要敲击键盘的次数就越少。指令数相当于你的程序设计得够合理同样的程序要写的代码行数就少。如果三者皆能实现你自然可以很快地写出一个优秀的程序你的“性能”从外面来看就是好的。
## 总结延伸
好了学完这一讲对“性能”这个名词你应该有了更清晰的认识。我主要对于“响应时间”这个性能指标进行抽丝剥茧拆解成了计算机时钟周期、CPI以及指令数这三个独立的指标的乘积并且为你指明了优化计算机性能的三条康庄大道。也就是提升计算机主频优化CPU设计使得在单个时钟周期内能够执行更多指令以及通过编译器来减少需要的指令数。
在后面的几讲里面我会为你讲解具体怎么在电路硬件、CPU设计乃至指令设计层面提升计算机的性能。
## 课后思考
每次有新手机发布的时候,总会有一些对于手机的跑分结果的议论。乃至于有“作弊”跑分或者“针对跑分优化”的说法。我们能针对“跑分”作弊么?怎么做到呢?“作弊”出来的分数对于手机性能还有参考意义么?
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,108 @@
<audio id="audio" title="04 | 穿越功耗墙,我们该从哪些方面提升“性能”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fd/b0/fd962c97feb010f816548f488750e7b0.mp3"></audio>
上一讲在讲CPU的性能时我们提到了这样一个公式
这么来看如果要提升计算机的性能我们可以从指令数、CPI以及CPU主频这三个地方入手。要搞定指令数或者CPI乍一看都不太容易。于是研发CPU的硬件工程师们从80年代开始就挑上了CPU这个“软柿子”。在CPU上多放一点晶体管不断提升CPU的时钟频率这样就能让CPU变得更快程序的执行时间就会缩短。
于是从1978年Intel发布的8086 CPU开始计算机的主频从5MHz开始不断提升。1980年代中期的80386能够跑到40MHz1989年的486能够跑到100MHz直到2000年的奔腾4处理器主频已经到达了1.4GHz。而消费者也在这20年里养成了“看主频”买电脑的习惯。当时已经基本垄断了桌面CPU市场的Intel更是夸下了海口表示奔腾4所使用的CPU结构可以做到10GHz颇有一点“大力出奇迹”的意思。
## 功耗CPU的“人体极限”
然而计算机科学界从来不相信“大力出奇迹”。奔腾4的CPU主频从来没有达到过10GHz最终它的主频上限定格在3.8GHz。这还不是最糟的更糟糕的事情是大家发现奔腾4的主频虽然高但是它的实际性能却配不上同样的主频。想要用在笔记本上的奔腾4 2.4GHz处理器其性能只和基于奔腾3架构的奔腾M 1.6GHz处理器差不多。
于是这一次的“大力出悲剧”不仅让Intel的对手AMD获得了喘息之机更是代表着“主频时代”的终结。后面几代Intel CPU主频不但没有上升反而下降了。到如今2019年的最高配置Intel i9 CPU主频也只不过是5GHz而已。相较于1978年到2000年这20年里300倍的主频提升从2000年到现在的这19年CPU的主频大概提高了3倍。
<img src="https://static001.geekbang.org/resource/image/18/80/1826102a89e4cdd31f7573db53dd9280.png" alt=""><br>
奔腾4的主频为什么没能超过3.8GHz的障碍呢?答案就是功耗问题。什么是功耗问题呢?我们先看一个直观的例子。
一个3.8GHz的奔腾4处理器满载功率是130瓦。这个130瓦是什么概念呢机场允许带上飞机的充电宝的容量上限是100瓦时。如果我们把这个CPU安在手机里面不考虑屏幕内存之类的耗电这个CPU满载运行45分钟充电宝里面就没电了。而iPhone X使用ARM架构的CPU功率则只有4.5瓦左右。
我们的CPU一般都被叫作**超大规模集成电路**Very-Large-Scale IntegrationVLSI。这些电路实际上都是一个个晶体管组合而成的。CPU在计算其实就是让晶体管里面的“开关”不断地去“打开”和“关闭”来组合完成各种运算和功能。
想要计算得快一方面我们要在CPU里同样的面积里面多放一些晶体管也就是**增加密度**;另一方面,我们要让晶体管“打开”和“关闭”得更快一点,也就是**提升主频**。而这两者,都会增加功耗,带来耗电和散热的问题。
这么说可能还是有点抽象我还是给你举一个例子。你可以把一个计算机CPU想象成一个巨大的工厂里面有很多工人相当于CPU上面的晶体管互相之间协同工作。
为了工作得快一点我们要在工厂里多塞一点人。你可能会问为什么不把工厂造得大一点呢这是因为人和人之间如果离得远了互相之间走过去需要花的时间就会变长这也会导致性能下降。这就好像如果CPU的面积大晶体管之间的距离变大电信号传输的时间就会变长运算速度自然就慢了。
除了多塞一点人我们还希望每个人的动作都快一点这样同样的时间里就可以多干一点活儿了。这就相当于提升CPU主频但是动作快每个人就要出汗散热。要是太热了对工厂里面的人来说会中暑生病对CPU来说就会崩溃出错。
我们会在CPU上面抹硅脂、装风扇乃至用上水冷或者其他更好的散热设备就好像在工厂里面装风扇、空调发冷饮一样。但是同样的空间下装上风扇空调能够带来的散热效果也是有极限的。
因此在CPU里面能够放下的晶体管数量和晶体管的“开关”频率也都是有限的。一个CPU的功率可以用这样一个公式来表示
那么为了要提升性能我们需要不断地增加晶体管数量。同样的面积下我们想要多放一点晶体管就要把晶体管造得小一点。这个就是平时我们所说的提升“制程”。从28nm到7nm相当于晶体管本身变成了原来的1/4大小。这个就相当于我们在工厂里同样的活儿我们要找瘦小一点的工人这样一个工厂里面就可以多一些人。我们还要提升主频让开关的频率变快也就是要找手脚更快的工人。
<img src="https://static001.geekbang.org/resource/image/f5/ed/f59f2f33e308000cb5d2ad017f2ff8ed.jpeg" alt="">
但是功耗增加太多就会导致CPU散热跟不上这时我们就需要降低电压。这里有一点非常关键在整个功耗的公式里面功耗和电压的平方是成正比的。这意味着电压下降到原来的1/5整个的功耗会变成原来的1/25。
事实上从5MHz主频的8086到5GHz主频的Intel i9CPU的电压已经从5V左右下降到了1V左右。这也是为什么我们CPU的主频提升了1000倍但是功耗只增长了40倍。比如说我写这篇文章用的是Surface Go在这样的轻薄笔记本上微软就是选择了把电压下降到0.25V的低电压CPU使得笔记本能有更长的续航时间。
## 并行优化,理解阿姆达尔定律
虽然制程的优化和电压的下降在过去的20年里让我们的CPU性能有所提升。但是从上世纪九十年代到本世纪初软件工程师们所用的“面向摩尔定律编程”的套路越来越用不下去了。“写程序不考虑性能等明年CPU性能提升一倍到时候性能自然就不成问题了”这种想法已经不可行了。
于是从奔腾4开始Intel意识到通过提升主频比较“难”去实现性能提升边开始推出Core Duo这样的多核CPU通过提升“吞吐率”而不是“响应时间”来达到目的。
提升响应时间就好比提升你用的交通工具的速度比如原本你是开汽车现在变成了火车乃至飞机。本来开车从上海到北京要20个小时换成飞机就只要2个小时了但是在此之上再想要提升速度就不太容易了。我们的CPU在奔腾4的年代就好比已经到了飞机这个速度极限。
那你可能要问了接下来该怎么办呢相比于给飞机提速工程师们又想到了新的办法可以一次同时开2架、4架乃至8架飞机这就好像我们现在用的2核、4核乃至8核的CPU。
虽然从上海到北京的时间没有变但是一次飞8架飞机能够运的东西自然就变多了也就是所谓的“吞吐率”变大了。所以不管你有没有需要现在CPU的性能就是提升了2倍乃至8倍、16倍。这也是一个最常见的提升性能的方式**通过并行提高性能**。
这个思想在很多地方都可以使用。举个例子,我们做机器学习程序的时候,需要计算向量的点积,比如向量$W = [W_0, W_1, W_2, …, W_{15}]$和向量 $X = [X_0, X_1, X_2, …, X_{15}]$$W·X = W_0 * X_0 + W_1 * X_1 +$ $W_2 * X_2 + … + W_{15} * X_{15}$。这些式子由16个乘法和1个连加组成。如果你自己一个人用笔来算的话需要一步一步算16次乘法和15次加法。如果这个时候我们把这个任务分配给4个人同时去算$W_0W_3$, $W_4W_7$, $W_8W_{11}$, $W_{12}W_{15}$这样四个部分的结果,再由一个人进行汇总,需要的时间就会缩短。
<img src="https://static001.geekbang.org/resource/image/64/9d/64d6957ecaa696edcf79dc1d5511269d.jpeg" alt="">
但是,并不是所有问题,都可以通过并行提高性能来解决。如果想要使用这种思想,需要满足这样几个条件。
第一,需要进行的计算,本身可以分解成几个可以并行的任务。好比上面的乘法和加法计算,几个人可以同时进行,不会影响最后的结果。
第二,需要能够分解好问题,并确保几个人的结果能够汇总到一起。
第三,在“汇总”这个阶段,是没有办法并行进行的,还是得顺序执行,一步一步来。
这就引出了我们在进行性能优化中,常常用到的一个经验定律,**阿姆达尔定律**Amdahls Law。这个定律说的就是对于一个程序进行优化之后处理器并行运算之后效率提升的情况。具体可以用这样一个公式来表示
在刚刚的向量点积例子里4个人同时计算向量的一小段点积就是通过并行提高了这部分的计算性能。但是这4个人的计算结果最终还是要在一个人那里进行汇总相加。这部分汇总相加的时间是不能通过并行来优化的也就是上面的公式里面**不受影响的执行时间**这一部分。
比如上面的各个向量的一小段的点积需要100ns加法需要20ns总共需要120ns。这里通过并行4个CPU有了4倍的加速度。那么最终优化后就有了100/4+20=45ns。即使我们增加更多的并行度来提供加速倍数比如有100个CPU整个时间也需要100/100+20=21ns。
<img src="https://static001.geekbang.org/resource/image/f1/e5/f1d05ec439e6377803df741bc07b09e5.jpeg" alt="">
## 总结延伸
我们可以看到无论是简单地通过提升主频还是增加更多的CPU核心数量通过并行来提升性能都会遇到相应的瓶颈。仅仅简单地通过“堆硬件”的方式在今天已经不能很好地满足我们对于程序性能的期望了。于是工程师们需要从其他方面开始下功夫了。
在“摩尔定律”和“并行计算”之外,在整个计算机组成层面,还有这样几个原则性的性能提升方法。
1.**加速大概率事件**。最典型的就是过去几年流行的深度学习整个计算过程中99%都是向量和矩阵计算于是工程师们通过用GPU替代CPU大幅度提升了深度学习的模型训练过程。本来一个CPU需要跑几小时甚至几天的程序GPU只需要几分钟就好了。Google更是不满足于GPU的性能进一步地推出了TPU。后面的文章我也会为你讲解GPU和TPU的基本构造和原理。
2.**通过流水线提高性能**。现代的工厂里的生产线叫“流水线”。我们可以把装配iPhone这样的任务拆分成一个个细分的任务让每个人都只需要处理一道工序最大化整个工厂的生产效率。类似的我们的CPU其实就是一个“运算工厂”。我们把CPU指令执行的过程进行拆分细化运行也是现代CPU在主频没有办法提升那么多的情况下性能仍然可以得到提升的重要原因之一。我们在后面也会讲到现代CPU里是如何通过流水线来提升性能的以及反面的过长的流水线会带来什么新的功耗和效率上的负面影响。
3.**通过预测提高性能**。通过预先猜测下一步该干什么而不是等上一步运行的结果提前进行运算也是让程序跑得更快一点的办法。典型的例子就是在一个循环访问数组的时候凭经验你也会猜到下一步我们会访问数组的下一项。后面要讲的“分支和冒险”、“局部性原理”这些CPU和存储系统设计方法其实都是在利用我们对于未来的“预测”提前进行相应的操作来提升我们的程序性能。
好了,到这里,我们讲完了计算机组成原理这门课的“前情提要”。一方面,整个组成乃至体系结构,都是基于冯·诺依曼架构组成的软硬件一体的解决方案。另一方面,你需要明白的就是,这里面的方方面面的设计和考虑,除了体系结构层面的抽象和通用性之外,核心需要考虑的是“性能”问题。
接下来,我们就要开始深入组成原理,从一个程序的运行讲起,开始我们的“机器指令”之旅。
## 补充阅读
如果你学有余力,关于本节内容,推荐你阅读下面两本书的对应章节,深入研读。
1.《计算机组成与设计:软/硬件接口》第5版的1.7和1.10节,也简单介绍了功耗墙和阿姆达尔定律,你可以拿来细细阅读。
2.如果你想对阿姆达尔定律有个更细致的了解《深入理解计算机系统》第3版的1.9节不容错过。
## 课后思考
我在这一讲里面,介绍了三种常见的性能提升思路,分别是,加速大概率事件、通过流水线提高性能和通过预测提高性能。请你想一下,除了在硬件和指令集的设计层面之外,你在软件开发层面,有用到过类似的思路来解决性能问题吗?
欢迎你在留言区写下你曾遇到的问题,和大家一起分享、探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,71 @@
<audio id="audio" title="开篇词 | 为什么你需要学习计算机组成原理?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2e/b8/2e1a27c3f76d6e4542b6aa5d998bcfb8.mp3"></audio>
你好,我是徐文浩,一个正在创业的工程师。目前主要是通过自然语言处理技术,为走向海外的中国企业提供英语的智能客服和社交网络营销服务。
2005年从上海交通大学计算机系毕业之后我一直以写代码为生。如果从7岁第一次在少年宫写程序开始算起到今天我的码龄快有30岁了。这些年里我在Trilogy Software写过各种大型企业软件在MediaV这样的广告科技公司从零开始搭建过支撑每天百亿流量的广告算法系统2015年我又加入了拼多多参与重写拼多多的交易系统。
这么多年一直在开发软件我深感软件这个行业变化太快了。语言上十年前流行Java这两年流行Go框架上前两年流行TensorFlow最近又流行PyTorch。我逐渐发现学习应用层的各种语言、框架好比在练拳法招式可以短期给予你回报而深入学习“底层知识”就是在练扎马步、核心肌肉力量是在提升你自己的“根骨”和“资质”。
正所谓“练拳不练功,到老一场空”。**如果越早去弄清楚计算机的底层原理,在你的知识体系中“储蓄”起这些知识,也就意味着你有越长的时间来收获学习知识的“利息”。虽然一开始可能不起眼,但是随着时间带来的复利效应,你的长线投资项目,就能让你在成长的过程中越走越快。**
## 计算机底层知识的“第一课”
如果找出各大学计算机系的培养计划,你会发现,它们都有差不多十来门核心课程。其中,“计算机组成原理”是入门和底层层面的第一课。
这是为什么呢我们直接用肉眼来看计算机是由CPU、内存、显示器这些设备组成的硬件但是计算机系的学生毕业之后大部分却都是从事各种软件开发工作。显然在硬件和软件之间需要一座桥梁而“计算机组成原理”就扮演了这样一个角色它既隔离了软件和硬件也提供了让软件无需关心硬件就能直接操作硬件的接口。
也就是说你只需要对硬件有原理性的理解就可以信赖硬件的可靠性安安心心用高级语言来写程序。无论是写操作系统和编译器这样的硬核代码还是写Web应用和手机App这样的应用层代码你都可以做到心里有底。
除此之外,组成原理是计算机其他核心课程的一个“导引”。学习组成原理之后,向下,你可以学习数字电路相关的课程,向上,你可以学习编译原理、操作系统这些核心课程。如果想要深入理解,甚至设计一台自己的计算机,体系结构是必不可少的一门课,而组成原理是计算机体系结构的一个入门版本。
<img src="https://static001.geekbang.org/resource/image/aa/73/aa5f644331319421eb7549d67d4f8773.jpeg" alt="">
所以说,无论你想要学习计算机的哪一门核心课程,之前你都应该先学习一下“计算机组成原理”,这样无论是对计算机的硬件原理,还是软件架构,你对计算机方方面面的知识都会有一个全局的了解。
学习这门“第一课”的过程会为你在整个软件开发领域中打开一扇扇窗和门让你看到更加广阔的天地。比如说明白了高级语言是如何对应着CPU能够处理的一条条指令能为你打开编译原理这扇门搞清楚程序是如何加载运行的能够让你对操作系统有更深入的理解。
因此学好计算机组成原理会让你对整个软件开发领域的全貌有一个系统了解也会给你带来更多的职业发展机会。像我自己的团队里有个小伙伴开始是做算法应用开发的因为有扎实的计算机基础知识后来就转去开发TVM这样的深度学习编译器了是不是很厉害
## 理论和实践相结合
说了这么多计算机组成原理的重要性,但到底该怎么学呢?接下来跟你分享我的心得。
我自己对计算机硬件的发展历史一直很感兴趣,所以,我读了市面上很多组成原理相关的资料。
互联网时代我们从来不缺少资料。无论是Coursera上北京大学的《计算机组成》开放课程还是图灵奖作者写的《计算机组成与设计硬件/软件接口》都珠玉在前是非常优秀的学习资料。不过“买书如山倒读书如抽丝”。从业这么多年周围想要好好学一学组成原理的工程师不少但是真的坚持下来学完、学好的却不多。大部分买来的书都是前面100页已经发黄了后面500页从来没有打开过更有不少非科班出身的程序员直接说“这些书根本看不懂”。
对这些问题,我都深有感触。从自己学习和工作的经验看,我找到了三个主要原因。
第一广。组成原理中的概念非常多每个概念的信息量也非常大。比如想要理解CPU中的算术逻辑单元也就是ALU是怎么实现加法的需要牵涉到如何把整数表示成二进制还需要了解这些表示背后的电路、逻辑门、CPU时钟、触发器等知识。
第二深。组成原理中的很多概念阐述开来就是计算机学科的另外一门核心课程。比如计算机的指令是怎么从你写的C、Java这样的高级语言变成计算机可以执行的机器码的如果我们展开并深入讲解这个问题就会变成《编译原理》这样一门核心课程。
第三,学不能致用。学东西是要拿来用的,但因为这门课本身的属性,很多人在学习时,常常沉溺于概念和理论中,无法和自己日常的开发工作联系起来,以此来解决工作中遇到的问题,所以,学习往往没有成就感,就很难有动力坚持下去。
考虑到这些,在这个专栏构思之初,我就给自己定了一个交付目标:**我要把这些知识点和日常工作、生活以及整个计算机行业的发展史联系起来,教你真正看懂、学会、记住组成原理的核心内容,教你更多地从“为什么”这个角度,去理解这些知识点,而不是只是去记忆“是什么”。**
对于这个专栏,具体我是这样设计的。
第一,我把组成原理里面的知识点,和我在应用开发和架构设计中遇到的实际案例,放到一起进行印证,通过代码和案例,让你消化理解。
比如为什么Disruptor这个高性能队列框架里要定义很多没有用的占位变量呢其实这是为了确保我们唯一关心的参数能够始终保留在CPU的高速缓存里面而高速缓存比我们的内存要快百倍以上。
第二,我会尽可能地多举一些我们日常生活里面的例子,让你理解计算机的各个组件是怎么运作的。在真实的开发中,我们会遇到什么问题,这些问题产生的根源是什么。让你从知识到应用,最终又回到知识,让学习和实践之间形成一道闭环。
计算机组成中很多组件的设计,都不是凭空发明出来,它们中的很多都来自现实生活中的想法和比喻。而底层很多硬件设计和开发的思路,其实也和你进行软件架构的开发设计和思路是一样的。
比如说在硬件上我们是通过最基本的与、或、非、异或门这些最基础的门电路组合形成了强大的CPU。而在面向对象和设计模式里我们也常常是通过定义基本的Command然后组合来完成更复杂的功能再比如说CPU里面的冒险和分支预测的策略就好像在接力赛跑里面后面几棒的选手早点起跑如果交接棒没有问题自然占了便宜但是如果没能交接上就会吃个大亏。
第三,在知识点和应用之外,我会多讲一些计算机硬件发展史上的成功和失败,让你明白很多设计的历史渊源,让你更容易记住“为什么”,更容易记住这些知识点。
比如说奔腾4的失败就是受限于超长流水线带来的散热和功耗问题而移动时代ARM的崛起则是因为Intel的芯片功耗太大不足以在小小的手机里放下足够支撑1天的电池。计算机芯片的兴盛和衰亡往往都是因为我们的计算机遇到了“功耗墙”这个散热和能耗上的挑战。而现代的云计算数据中心的设计到选址也是围绕功耗和散热的。理解了这些成功和失败背后的原因你自然记住了这些背后的知识点。
最后在这三种帮助你理解“为什么”的方法之上我会把整个的计算机组成原理通过指令、计算、CPU、存储系统和I/O串起来。通过一个程序的执行过程进行逐层分解让你能对整个系统有一个全貌的了解。
我希望这个专栏,不仅能够让你学好计算机组成原理的知识,更能够成为引领你进入更多底层知识的大门,让你有动力、有方法、更深入地去进一步学习体系结构、操作系统、编译原理这样的课程,成为真正的“内家高手”。
“人生如逆旅,我亦是行人”。学习总不会是一件太轻松的事情,希望在这个专栏里,你能和我多交流,坚持练完这一手内功。
下面,你可以讲一讲,你对于计算机组成原理的认识是怎样的?在之前工作中,哪些地方用到了计算机组成原理相关的知识呢?欢迎写在留言区,我们一起交流。

View File

@@ -0,0 +1,97 @@
<audio id="audio" title="17 | 建立数据通路(上):指令+运算=CPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ad/f7/ad5ffc922bb6c35898e9c99c014842f7.mp3"></audio>
前面几讲里我从两个不同的部分为你讲解了CPU的功能。
在“**指令**”部分,我为你讲解了计算机的“指令”是怎么运行的,也就是我们撰写的代码,是怎么变成一条条的机器能够理解的指令的,以及是按照什么样的顺序运行的。
在“**计算**”部分,我为你讲解了计算机的“计算”部分是怎么执行的,数据的二进制表示是怎么样的,我们执行的加法和乘法又是通过什么样的电路来实现的。
然而光知道这两部分还不能算是真正揭开了CPU的秘密只有把“指令”和“计算”这两部分功能连通起来我们才能构成一个真正完整的CPU。这一讲我们就在前面知识的基础上来看一个完整的CPU是怎么运转起来的。
## 指令周期Instruction Cycle
前面讲计算机机器码的时候我向你介绍过PC寄存器、指令寄存器还介绍过MIPS体系结构的计算机所用到的R、I、J类指令。如果我们仔细看一看可以发现计算机每执行一条指令的过程可以分解成这样几个步骤。
1.**Fetch****取得指令**也就是从PC寄存器里找到对应的指令地址根据指令地址从内存里把具体的指令加载到指令寄存器中然后把PC寄存器自增好在未来执行下一条指令。
2.**Decode****指令译码**也就是根据指令寄存器里面的指令解析成要进行什么样的操作是R、I、J中的哪一种指令具体要操作哪些寄存器、数据或者内存地址。
3.**Execute****执行指令**也就是实际运行对应的R、I、J这些特定的指令进行算术逻辑操作、数据传输或者直接的地址跳转。
4.重复进行13的步骤。
这样的步骤,其实就是一个永不停歇的“**Fetch - Decode - Execute**”的循环,我们把这个循环称之为**指令周期**Instruction Cycle
<img src="https://static001.geekbang.org/resource/image/18/a7/1840bead02cfbe5d8f70e2f0a7b962a7.jpg" alt="">
在这个循环过程中,不同部分其实是由计算机中的不同组件完成的。不知道你还记不记得,我们在专栏一开始讲的计算机组成的五大组件?
在取指令的阶段,我们的指令是放在**存储器**里的实际上通过PC寄存器和指令寄存器取出指令的过程是由**控制器**Control Unit操作的。指令的解码过程也是由**控制器**进行的。一旦到了执行指令阶段无论是进行算术操作、逻辑操作的R型指令还是进行数据传输、条件分支的I型指令都是由**算术逻辑单元**ALU操作的也就是由**运算器**处理的。不过,如果是一个简单的无条件地址跳转,那么我们可以直接在**控制器**里面完成,不需要用到运算器。
<img src="https://static001.geekbang.org/resource/image/bd/67/bde3548a4789ba49cab74c8c1ab02a67.jpeg" alt="">
除了Instruction Cycle这个指令周期在CPU里面我们还会提到另外两个常见的Cycle。一个叫**Machine Cycle****机器周期**或者**CPU周期**。CPU内部的操作速度很快但是访问内存的速度却要慢很多。每一条指令都需要从内存里面加载而来所以我们一般把从内存里面读取一条指令的最短时间称为CPU周期。
还有一个是我们之前提过的**Clock Cycle**,也就是**时钟周期**以及我们机器的主频。一个CPU周期通常会由几个时钟周期累积起来。一个CPU周期的时间就是这几个Clock Cycle的总和。
对于一个指令周期来说我们取出一条指令然后执行它至少需要两个CPU周期。取出指令至少需要一个CPU周期执行至少也需要一个CPU周期复杂的指令则需要更多的CPU周期。
<img src="https://static001.geekbang.org/resource/image/1a/48/1a7d2d6cf7cb78a8f48775268f452e48.jpeg" alt="">
所以我们说一个指令周期包含多个CPU周期而一个CPU周期包含多个时钟周期。
## 建立数据通路
在专栏一开始不少同学留言问到ALU就是运算器吗在讨论计算机五大组件的运算器的时候我们提到过好几个不同的相关名词比如ALU、运算器、处理器单元、数据通路它们之间到底是什么关系呢
名字是什么其实并不重要,一般来说,我们可以认为,数据通路就是我们的处理器单元。它通常由两类原件组成。
第一类叫**操作元件**也叫组合逻辑元件Combinational Element其实就是我们的ALU。在前面讲ALU的过程中可以看到它们的功能就是在特定的输入下根据下面的组合电路的逻辑生成特定的输出。
第二类叫**存储元件**也有叫状态元件State Element的。比如我们在计算过程中需要用到的寄存器无论是通用寄存器还是状态寄存器其实都是存储元件。
我们通过数据总线的方式,把它们连接起来,就可以完成数据的存储、处理和传输了,这就是所谓的**建立数据通路**了。
下面我们来说**控制器**。它的逻辑就没那么复杂了。我们可以把它看成只是机械地重复“Fetch - Decode - Execute“循环中的前两个步骤然后把最后一个步骤通过控制器产生的控制信号交给ALU去处理。
听起来是不是很简单?实际上,控制器的电路特别复杂。下面我给你详细解析一下。
一方面所有CPU支持的指令都会在控制器里面被解析成不同的输出信号。我们之前说过现在的Intel CPU支持2000个以上的指令。这意味着控制器输出的控制信号至少有2000种不同的组合。
运算器里的ALU和各种组合逻辑电路可以认为是一个固定功能的电路。控制器“翻译”出来的就是不同的控制信号。这些控制信号告诉ALU去做不同的计算。可以说正是控制器的存在让我们可以“编程”来实现功能能让我们的“存储程序型计算机”名副其实。
<img src="https://static001.geekbang.org/resource/image/46/6f/46087a894b4ac182fab83ac3786cad6f.jpeg" alt="">
## CPU所需要的硬件电路
那么要想搭建出来整个CPU我们需要在数字电路层面实现这样一些功能。
首先自然是我们之前已经讲解过的ALU了它实际就是一个没有状态的根据输入计算输出结果的第一个电路。
第二我们需要有一个能够进行状态读写的电路元件也就是我们的寄存器。我们需要有一个电路能够存储到上一次的计算结果。这个计算结果并不一定要立刻拿到电路的下游去使用但是可以在需要的时候拿出来用。常见的能够进行状态读写的电路就有锁存器Latch以及我们后面要讲的D触发器Data/Delay Flip-flop的电路。
第三我们需要有一个“自动”的电路按照固定的周期不停地实现PC寄存器自增自动地去执行“Fetch - Decode - Execute“的步骤。我们的程序执行并不是靠人去拨动开关来执行指令的。我们希望有一个“自动”的电路不停地去一条条执行指令。
我们看似写了各种复杂的高级程序进行各种函数调用、条件跳转。其实只是修改PC寄存器里面的地址。PC寄存器里面的地址一修改计算机就可以加载一条指令新指令往下运行。实际上PC寄存器还有一个名字就叫作程序计数器。顾名思义就是随着时间变化不断去数数。数的数字变大了就去执行一条新指令。所以我们需要的就是一个自动数数的电路。
第四我们需要有一个“译码”的电路。无论是对于指令进行decode还是对于拿到的内存地址去获取对应的数据或者指令我们都需要通过一个电路找到对应的数据。这个对应的自然就是“译码器”的电路了。
好了现在我们把这四类电路通过各种方式组合在一起就能最终组成功能强大的CPU了。但是要实现这四种电路中的中间两种我们还需要时钟电路的配合。下一节我们一起来看一看这些基础的电路功能是怎么实现的以及怎么把这些电路组合起来变成一个CPU。
## 总结延伸
好了到这里我们已经把CPU运转需要的数据通路和控制器介绍完了也找出了需要完成这些功能需要的4种基本电路。它们分别是ALU这样的组合逻辑电路、用来存储数据的锁存器和D触发器电路、用来实现PC寄存器的计数器电路以及用来解码和寻址的译码器电路。
虽然CPU已经是由几十亿个晶体管组成的及其复杂的电路但是它仍然是由这样一个个基本功能的电路组成的。只要搞清楚这些电路的运作原理你自然也就弄明白了CPU的工作原理。
## 推荐阅读
如果想要了解数据通路,可以参看《计算机组成与设计 硬件软件接口》的第5版的4.1到4.4节。专栏里的内容是从更高一层的抽象逻辑来解释这些问题,而教科书里包含了更多电路的技术细节。这两者结合起来学习,能够帮助你更深入地去理解数据通路。
## 课后思考
这一讲我们说CPU好像一个永不停歇的机器一直在不停地读取下一条指令去运行。那为什么CPU还会有满载运行和Idle闲置的状态呢请你自己搜索研究一下这是为什么并在留言区写下你的思考和答案。
欢迎你留言和我分享,你也可以把今天的文章分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,108 @@
<audio id="audio" title="18 | 建立数据通路(中):指令+运算=CPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/15/48/15cd5a49c4bc40891fcfc233e8035548.mp3"></audio>
上一讲我们看到要能够实现一个完整的CPU功能除了加法器这样的电路之外我们还需要实现其他功能的电路。其中有一些电路和我们实现过的加法器一样只需要给定输入就能得到固定的输出。这样的电路我们称之为**组合逻辑电路**Combinational Logic Circuit
但是光有组合逻辑电路是不够的。你可以想一下如果只有组合逻辑电路我们的CPU会是什么样的电路输入是确定的对应的输出自然也就确定了。那么我们要进行不同的计算就要去手动拨动各种开关来改变电路的开闭状态。这样的计算机不像我们现在每天用的功能强大的电子计算机反倒更像古老的计算尺或者机械计算机干不了太复杂的工作只能协助我们完成一些计算工作。
这样,我们就需要引入第二类的电路,也就是**时序逻辑电路**Sequential Logic Circuit。时序逻辑电路可以帮我们解决这样几个问题。
第一个就是**自动运行**的问题。时序电路接通之后可以不停地开启和关闭开关进入一个自动运行的状态。这个使得我们上一讲说的控制器不停地让PC寄存器自增读取下一条指令成为可能。
第二个是**存储**的问题。通过时序电路实现的触发器,能把计算结果存储在特定的电路里面,而不是像组合逻辑电路那样,一旦输入有任何改变,对应的输出也会改变。
第三个本质上解决了各个功能按照**时序协调**的问题。无论是程序实现的软件指令,还是到硬件层面,各种指令的操作都有先后的顺序要求。时序电路使得不同的事件按照时间顺序发生。
## 时钟信号的硬件实现
想要实现时序逻辑电路,第一步我们需要的就是一个**时钟**。我在[第3讲](https://time.geekbang.org/column/article/92215)说过CPU的主频是由一个晶体振荡器来实现的而这个晶体振荡器生成的电路信号就是我们的时钟信号。
实现这样一个电路,和我们之前讲的,通过电的磁效应产生开关信号的方法是一样的。只不过,这里的磁性开关,打开的不再是后续的线路,而是当前的线路。
在下面这张图里你可以看到我们在原先一般只放一个开关的信号输入端放上了两个开关。一个开关A一开始是断开的由我们手工控制另外一个开关B一开始是合上的磁性线圈对准一开始就合上的开关B。
于是一旦我们合上开关A磁性线圈就会通电产生磁性开关B就会从合上变成断开。一旦这个开关断开了电路就中断了磁性线圈就失去了磁性。于是开关B又会弹回到合上的状态。这样一来电路接通线圈又有了磁性。我们的电路就会来回不断地在开启、关闭这两个状态中切换。
<img src="https://static001.geekbang.org/resource/image/57/c0/57684c12e7bf8ef429220405b0e3bdc0.jpeg" alt="">
这个不断切换的过程对于下游电路来说就是不断地产生新的0和1这样的信号。如果你在下游的电路上接上一个灯泡就会发现这个灯泡在亮和暗之间不停切换。这个按照固定的周期不断在0和1之间切换的信号就是我们的**时钟信号**Clock Signal
一般这样产生的时钟信号就像你在各种教科书图例中看到的一样是一个振荡产生的0、1信号。
<img src="https://static001.geekbang.org/resource/image/6d/93/6dd534a167513c865dfe1921ebb6ae93.jpeg" alt="">
这种电路,其实就相当于把电路的输出信号作为输入信号,再回到当前电路。这样的电路构造方式呢,我们叫作**反馈电路**Feedback Circuit
接下来,我们还会看到更多的反馈电路。上面这个反馈电路一般可以用下面这个示意图来表示,其实就是一个输出结果接回输入的**反相器**Inverter也就是我们之前讲过的**非门**。
<img src="https://static001.geekbang.org/resource/image/d2/ca/d205493f6ff1aeba7a849575285bbeca.jpg" alt="">
## 通过D触发器实现存储功能
有了时钟信号我们的系统里就有了一个像“自动门”一样的开关。利用这个开关和相同的反馈电路我们就可以构造出一个有“记忆”功能的电路。这个有记忆功能的电路可以实现在CPU中用来存储计算结果的寄存器也可以用来实现计算机五大组成部分之一的存储器。
<img src="https://static001.geekbang.org/resource/image/dc/de/dc6dcce612b2fd51939d7ec44b3fe1de.jpeg" alt="">
我们先来看下面这个RS触发器电路。这个电路由两个或非门电路组成。我在图里面把它标成了A和B。
<img src="https://static001.geekbang.org/resource/image/7d/a9/7dd38282b8862cb6541ee82e76e1e0a9.jpg" alt="">
<li>
在这个电路一开始输入开关都是关闭的所以或非门NORA的输入是0和0。对应到我列的这个真值表输出就是1。而或非门B的输入是0和A的输出1对应输出就是0。B的输出0反馈到A和之前的输入没有变化A的输出仍然是1。而整个电路的**输出Q**也就是0。
</li>
<li>
当我们把A前面的开关R合上的时候A的输入变成了1和0输出就变成了0对应B的输入变成0和0输出就变成了1。B的输出1反馈给到了AA的输入变成了1和1输出仍然是0。所以把A的开关合上之后电路仍然是稳定的不会像晶振那样振荡但是整个电路的**输出Q**变成了1。
</li>
<li>
这个时候如果我们再把A前面的开关R打开A的输入变成和1和0输出还是0对应的B的输入没有变化输出也还是1。B的输出1反馈给到了AA的输入变成了1和0输出仍然是0。这个时候电路仍然稳定。**开关R和S的状态和上面的第一步是一样的但是最终的输出Q仍然是1**和第1步里Q状态是相反的。我们的输入和刚才第二步的开关状态不一样但是输出结果仍然保留在了第2步时的输出没有发生变化。
</li>
<li>
这个时候只有我们再去关闭下面的开关S才可以看到这个时候B有一个输入必然是1所以B的输出必然是0也就是电路的最终**输出Q**必然是0。
</li>
这样一个电路我们称之为触发器Flip-Flop。接通开关R输出变为1即使断开开关输出还是1不变。接通开关S输出变为0即使断开开关输出也还是0。也就是**当两个开关都断开的时候,最终的输出结果,取决于之前动作的输出结果,这个也就是我们说的记忆功能**。
这里的这个电路是最简单的RS触发器也就是所谓的复位置位触发器Reset-Set Flip Flop) 。对应的输出结果的真值表你可以看下面这个表格。可以看到当两个开关都是0的时候对应的输出不是1或者0而是和Q的上一个状态一致。
<img src="https://static001.geekbang.org/resource/image/ac/cb/acc43093e8f0da21b660b4cb5d3d05cb.jpg" alt="">
再往这个电路里加两个与门和一个小小的时钟信号我们就可以实现一个利用时钟信号来操作一个电路了。这个电路可以帮我们实现什么时候可以往Q里写入数据。
我们看看下面这个电路这个在我们的上面的R-S触发器基础之上在R和S开关之后加入了两个与门同时给这两个与门加入了一个**时钟信号CLK**作为电路输入。
这样当时钟信号CLK在低电平的时候与门的输入里有一个0两个实际的R和S后的与门的输出必然是0。也就是说无论我们怎么按R和S的开关根据R-S触发器的真值表对应的Q的输出都不会发生变化。
只有当时钟信号CLK在高电平的时候与门的一个输入是1输出结果完全取决于R和S的开关。我们可以在这个时候通过开关R和S来决定对应Q的输出。
<img src="https://static001.geekbang.org/resource/image/9e/d8/9e9bc411aa8c7bf2f080f306a0fb8bd8.jpeg" alt="">
如果这个时候我们让R和S的开关也用一个反相器连起来也就是通过同一个开关控制R和S。只要CLK信号是1R和S就可以设置输出Q。而当CLK信号是0的时候无论R和S怎么设置输出信号Q是不变的。这样这个电路就成了我们最常用的D型触发器。用来控制R和S这两个开关的信号呢我们视作一个输入的数据信号D也就是Data这就是D型触发器的由来。
<img src="https://static001.geekbang.org/resource/image/d7/bb/d749acce21756d89c35ee19545cfebbb.jpeg" alt="">
一个D型触发器只能控制1个比特的读写但是如果我们同时拿出多个D型触发器并列在一起并且把用同一个CLK信号控制作为所有D型触发器的开关这就变成了一个N位的D型触发器也就可以同时控制N位的读写。
CPU里面的寄存器可以直接通过D型触发器来构造。我们可以在D型触发器的基础上加上更多的开关来实现清0或者全部置为1这样的快捷操作。
## 总结延伸
好了,到了这里,我们可以顺一顺思路了。通过引入了时序电路,我们终于可以把数据“存储”下来了。我们通过反馈电路,创建了时钟信号,然后再利用这个时钟信号和门电路组合,实现了“状态记忆”的功能。
电路的输出信号不单单取决于当前的输入信号还要取决于输出信号之前的状态。最常见的这个电路就是我们的D触发器它也是我们实际在CPU内实现存储功能的寄存器的实现方式。
这也是现代计算机体系结构中的“冯·诺伊曼”机的一个关键,就是程序需要可以“存储”,而不是靠固定的线路连接或者手工拨动开关,来实现计算机的可存储和可编程的功能。
有了时钟信号和触发器之后,我们还差一个“自动”需求没有实现。我们的计算机还不能做到自动地不停地从内存里面读取指令去执行。这一部分,我们留在下一讲。下一讲里,我们看看怎么让程序自动运转起来。
## 推荐阅读
想要深入了解计算机里面的各种功能组件是怎么通过电路来实现的推荐你去阅读《编码隐匿在计算机软硬件背后的语言》这本书的第14章和16章。
如果对于数字电路和数字逻辑特别感兴趣,想要彻底弄清楚数字电路、时序逻辑电路,也可以看一看计算机学科的一本专业的教科书《数字逻辑应用与设计》。
## 课后思考
现在我们的CPU主频非常高了通常在几GHz了但是实际上我们的晶振并不能提供这么高的频率而是通过“外频+倍频“的方式来实现高频率的时钟信号。请你研究一下,倍频和分频的信号是通过什么样的电路实现的?
欢迎留言和我分享你的疑惑和见解,也欢迎你把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,96 @@
<audio id="audio" title="19 | 建立数据通路(下):指令+运算=CPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0b/6a/0b9668f7ad4510e1b3acea9a35214d6a.mp3"></audio>
上一讲,我们讲解了时钟信号是怎么实现的,以及怎么利用这个时钟信号,来控制数据的读写,可以使得我们能把需要的数据“存储”下来。那么,这一讲,我们要让计算机“自动”跑起来。
通过一个时钟信号我们可以实现计数器这个会成为我们的PC寄存器。然后我们还需要一个能够帮我们在内存里面寻找指定数据地址的译码器以及解析读取到的机器指令的译码器。这样我们就能把所有学习到的硬件组件串联起来变成一个CPU实现我们在计算机指令的执行部分的运行步骤。
## PC寄存器所需要的计数器
我们常说的PC寄存器还有个名字叫程序计数器。下面我们就来看看它为什么叫作程序计数器。
有了时钟信号我们可以提供定时的输入有了D型触发器我们可以在时钟信号控制的时间点写入数据。我们把这两个功能组合起来就可以实现一个自动的计数器了。
加法器的两个输入一个始终设置成1另外一个来自于一个D型触发器A。我们把加法器的输出结果写到这个D型触发器A里面。于是D型触发器里面的数据就会在固定的时钟信号为1的时候更新一次。
<img src="https://static001.geekbang.org/resource/image/1e/4c/1ed21092022057ed192a7d9aff76144c.jpg" alt="">
这样我们就有了一个每过一个时钟周期就能固定自增1的自动计数器了。这个自动计数器可以拿来当我们的PC寄存器。事实上PC寄存器的这个PC英文就是Program Counter也就是**程序计数器**的意思。
每次自增之后我们可以去对应的D型触发器里面取值这也是我们下一条需要运行指令的地址。前面第5讲我们讲过同一个程序的指令应该要顺序地存放在内存里面。这里就和前面对应上了顺序地存放指令就是为了让我们通过程序计数器就能定时地不断执行新指令。
加法计数、内存取值,乃至后面的命令执行,最终其实都是由我们一开始讲的时钟信号,来控制执行时间点和先后顺序的,这也是我们需要时序电路最核心的原因。
在最简单的情况下我们需要让每一条指令从程序计数到获取指令、执行指令都在一个时钟周期内完成。如果PC寄存器自增地太快程序就会出错。因为前一次的运算结果还没有写回到对应的寄存器里面的时候后面一条指令已经开始读取里面的数据来做下一次计算了。这个时候如果我们的指令使用同样的寄存器前一条指令的计算就会没有效果计算结果就错了。
在这种设计下我们需要在一个时钟周期里确保执行完一条最复杂的CPU指令也就是耗时最长的一条CPU指令。这样的CPU设计我们称之为**单指令周期处理器**Single Cycle Processor
很显然,这样的设计有点儿浪费。因为即便只调用一条非常简单的指令,我们也需要等待整个时钟周期的时间走完,才能执行下一条指令。在后面章节里我们会讲到,通过流水线技术进行性能优化,可以减少需要等待的时间,这里我们暂且说到这里。
## 读写数据所需要的译码器
现在我们的数据能够存储在D型触发器里了。如果我们把很多个D型触发器放在一起就可以形成一块很大的存储空间甚至可以当成一块内存来用。像我现在手头这台电脑有16G内存。那我们怎么才能知道写入和读取的数据是在这么大的内存的哪几个比特呢
于是,我们就需要有一个电路,来完成“寻址”的工作。这个“寻址”电路,就是我们接下来要讲的译码器。
在现在实际使用的计算机里面内存所使用的DRAM并不是通过上面的D型触发器来实现的而是使用了一种CMOS芯片来实现的。不过这并不影响我们从基础原理方面来理解译码器。在这里我们还是可以把内存芯片当成是很多个连在一起的D型触发器来实现的。
如果把“寻址”这件事情退化到最简单的情况,就是在两个地址中,去选择一个地址。这样的电路,我们叫作**2-1选择器**。我把它的电路实现画在了这里。
我们通过一个反相器、两个与门和一个或门就可以实现一个2-1选择器。通过控制反相器的输入是0还是1能够决定对应的输出信号是和地址A还是地址B的输入信号一致。
<img src="https://static001.geekbang.org/resource/image/38/a0/383bfbb085c1eeb9b9473ae6f18e97a0.jpeg" alt="">
一个反向器只能有0和1这样两个状态所以我们只能从两个地址中选择一个。如果输入的信号有三个不同的开关我们就能从$2^3$也就是8个地址中选择一个了。这样的电路我们就叫**3-8译码器**。现代的计算机如果CPU是64位的就意味着我们的寻址空间也是$2^{64}$那么我们就需要一个有64个开关的译码器。
<img src="https://static001.geekbang.org/resource/image/40/01/4002b5f8f60a913e655d5268348ee201.jpeg" alt="">
所以说其实译码器的本质就是从输入的多个位的信号中根据一定的开关和电路组合选择出自己想要的信号。除了能够进行“寻址”之外我们还可以把对应的需要运行的指令码同样通过译码器找出我们期望执行的指令也就是在之前我们讲到过的opcode以及后面对应的操作数或者寄存器地址。只是这样的“译码器”比起2-1选择器和3-8译码器要复杂的多。
## 建立数据通路构造一个最简单的CPU
D触发器、自动计数以及译码器再加上一个我们之前说过的ALU我们就凑齐了一个拼装一个CPU必须要的零件了。下面我们就来看一看怎么把这些零件组合起来才能实现指令执行和算术逻辑计算的CPU。
<img src="https://static001.geekbang.org/resource/image/68/71/6863e10fc635791878d1ecd57618b871.jpeg" alt="">
1. 首先我们有一个自动计数器。这个自动计数器会随着时钟主频不断地自增来作为我们的PC寄存器。
1. 在这个自动计数器的后面我们连上一个译码器。译码器还要同时连着我们通过大量的D触发器组成的内存。
1. 自动计数器会随着时钟主频不断自增从译码器当中找到对应的计数器所表示的内存地址然后读取出里面的CPU指令。
1. 读取出来的CPU指令会通过我们的CPU时钟的控制写入到一个由D触发器组成的寄存器也就是指令寄存器当中。
1. 在指令寄存器后面我们可以再跟一个译码器。这个译码器不再是用来寻址的了而是把我们拿到的指令解析成opcode和对应的操作数。
1. 当我们拿到对应的opcode和操作数对应的输出线路就要连接ALU开始进行各种算术和逻辑运算。对应的计算结果则会再写回到D触发器组成的寄存器或者内存当中。
这样的一个完整的通路也就完成了我们的CPU的一条指令的执行过程。在这个过程中你会发现这样几个有意思的问题。
第一个,是我们之前在[第6讲](https://time.geekbang.org/column/article/94075)讲过的程序跳转所使用的条件码寄存器。那时讲计算机的指令执行的时候我们说高级语言中的if…else其实是变成了一条cmp指令和一条jmp指令。cmp指令是在进行对应的比较比较的结果会更新到条件码寄存器当中。jmp指令则是根据条件码寄存器当中的标志位来决定是否进行跳转以及跳转到什么地址。
不知道你当时看到这个知识点的时候有没有一些疑惑为什么我们的if…else会变成这样两条指令而不是设计成一个复杂的电路变成一条指令到这里我们就可以解释了。这样分成两个指令实现完全匹配好了我们在电路层面“译码-执行-更新寄存器“这样的步骤。
cmp指令的执行结果放到了条件码寄存器里面我们的条件跳转指令也是在ALU层面执行的而不是在控制器里面执行的。这样的实现方式在电路层面非常直观我们不需要一个非常复杂的电路就能实现if…else的功能。
第二个,是关于我们在[](https://time.geekbang.org/column/article/98872)[17讲](https://time.geekbang.org/column/article/98872)里讲到的指令周期、CPU周期和时钟周期的差异。在上面的抽象的逻辑模型中你很容易发现我们执行一条指令其实可以不放在一个时钟周期里面可以直接拆分到多个时钟周期。
我们可以在一个时钟周期里面去自增PC寄存器的值也就是指令对应的内存地址。然后我们要根据这个地址从D触发器里面读取指令这个还是可以在刚才那个时钟周期内。但是对应的指令写入到指令寄存器我们可以放在一个新的时钟周期里面。指令译码给到ALU之后的计算结果要写回到寄存器又可以放到另一个新的时钟周期。所以执行一条计算机指令其实可以拆分到很多个时钟周期而不是必须使用单指令周期处理器的设计。
因为从内存里面读取指令时间很长所以如果使用单指令周期处理器就意味着我们的指令都要去等待一些慢速的操作。这些不同指令执行速度的差异也正是计算机指令有指令周期、CPU周期和时钟周期之分的原因。因此现代我们优化CPU的性能时用的CPU都不是单指令周期处理器而是通过流水线、分支预测等技术来实现在一个周期里同时执行多个指令。
## 总结延伸
好了今天我们讲完了怎么通过连接不同功能的电路实现出一个完整的CPU。
我们可以通过自动计数器的电路来实现一个PC寄存器不断生成下一条要执行的计算机指令的内存地址。然后通过译码器从内存里面读出对应的指令写入到D触发器实现的指令寄存器中。再通过另外一个译码器把它解析成我们需要执行的指令和操作数的地址。这些电路组成了我们计算机五大组成部分里面的控制器。
我们把opcode和对应的操作数发送给ALU进行计算得到计算结果再写回到寄存器以及内存里面来这个就是我们计算机五大组成部分里面的运算器。
我们的时钟信号,则提供了协调这样一条条指令的执行时间和先后顺序的机制。同样的,这也带来了一个挑战,那就是单指令周期处理器去执行一条指令的时间太长了。而这个挑战,也是我们接下来的几讲里要解答的问题。
## 推荐阅读
《编码隐匿在计算机软硬件背后的语言》的第17章用更多细节的流程来讲解了CPU的数据通路。《计算机组成与设计 硬件/软件接口》的4.1到4.4小节从另外一个层面和角度讲解了CPU的数据通路的建立推荐你阅读一下。
## 课后思考
CPU在执行无条件跳转的时候不需要通过运算器以及ALU可以直接在控制器里面完成你能说说这是为什么吗
欢迎在留言区写下你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,83 @@
<audio id="audio" title="20 | 面向流水线的指令设计一心多用的现代CPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7e/90/7e68e27c9ef990bfa090d9d7993c0290.mp3"></audio>
前面我们用了三讲用一个个的电路组合制作出了一个完整功能的CPU。这里面一下子给你引入了三个“周期”的概念分别是指令周期、机器周期或者CPU周期以及时钟周期。
你可能会有点摸不着头脑了为什么小小一个CPU有那么多的周期Cycle我们在专栏一开始不是把CPU的性能定义得非常清楚了吗我们说程序的性能是由三个因素相乘来衡量的我们还专门说过“指令数×CPI×时钟周期”这个公式。这里面和周期相关的只有一个时钟周期也就是我们CPU的主频倒数。当时讲的时候我们说一个CPU的时钟周期可以认为是可以完成一条最简单的计算机指令的时间。
那么为什么我们在构造CPU的时候一下子出来了那么多个周期呢这一讲我就来为你说道说道带你更深入地看看现代CPU是怎么一回事儿。
## 愿得一心人,白首不相离:单指令周期处理器
学过前面三讲你现在应该知道一条CPU指令的执行是由“取得指令Fetch-指令译码Decode-执行指令Execute ”这样三个步骤组成的。这个执行过程,至少需要花费一个时钟周期。因为在取指令的时候,我们需要通过时钟周期的信号,来决定计数器的自增。
那么很自然地我们希望能确保让这样一整条指令的执行在一个时钟周期内完成。这样我们一个时钟周期可以执行一条指令CPI也就是1看起来就比执行一条指令需要多个时钟周期性能要好。采用这种设计思路的处理器就叫作单指令周期处理器Single Cycle Processor也就是在一个时钟周期内处理器正好能处理一条指令。
不过,我们的时钟周期是固定的,但是指令的电路复杂程度是不同的,所以实际一条指令执行的时间是不同的。在[第13讲](https://time.geekbang.org/column/article/95883)和[第14讲](https://time.geekbang.org/column/article/97477)讲加法器和乘法器电路的时候,我给你看过,随着门电路层数的增加,由于门延迟的存在,位数多、计算复杂的指令需要的执行时间会更长。
不同指令的执行时间不同但是我们需要让所有指令都在一个时钟周期内完成那就只好把时钟周期和执行时间最长的那个指令设成一样。这就好比学校体育课1000米考试我们要给这场考试预留的时间肯定得和跑得最慢的那个同学一样。因为就算其他同学先跑完也要等最慢的同学跑完间我们才能进行下一项活动。
<img src="https://static001.geekbang.org/resource/image/6c/ee/6c85e2dd9b9988d8a458fb1200d96eee.jpeg" alt="">
所以在单指令周期处理器里面无论是执行一条用不到ALU的无条件跳转指令还是一条计算起来电路特别复杂的浮点数乘法运算我们都等要等满一个时钟周期。在这个情况下虽然CPI能够保持在1但是我们的时钟频率却没法太高。因为太高的话有些复杂指令没有办法在一个时钟周期内运行完成。那么在下一个时钟周期到来开始执行下一条指令的时候前一条指令的执行结果可能还没有写入到寄存器里面。那下一条指令读取的数据就是不准确的就会出现错误。
<img src="https://static001.geekbang.org/resource/image/30/9b/3097988ae8dfc82e33ab80234bd5a29b.jpeg" alt="">
到这里你会发现,这和我们之前[第3讲](https://time.geekbang.org/column/article/92215)和[第4讲](https://time.geekbang.org/column/article/93246)讲时钟频率时候的说法不太一样。当时我们说一个CPU时钟周期可以认为是完成一条简单指令的时间。为什么到了这里单指令周期处理器反而变成了执行一条最复杂的指令的时间呢
这是因为无论是PC上使用的Intel CPU还是手机上使用的ARM CPU都不是单指令周期处理器而是采用了一种叫作**指令流水线**Instruction Pipeline的技术。
## 无可奈何花落去,似曾相识燕归来:现代处理器的流水线设计
其实CPU执行一条指令的过程和我们开发软件功能的过程很像。
如果我们想开发一个手机App上的功能并不是找来一个工程师告诉他“你把这个功能开发出来”然后他就吭哧吭哧把功能开发出来。真实的情况是无论只有一个工程师还是有一个开发团队我们都需要先对开发功能的过程进行切分把这个过程变成“撰写需求文档、开发后台API、开发客户端App、测试、发布上线”这样多个独立的过程。每一个后面的步骤都要依赖前面的步骤。
我们的指令执行过程也是一样的它会拆分成“取指令、译码、执行”这样三大步骤。更细分一点的话执行的过程其实还包含从寄存器或者内存中读取数据通过ALU进行运算把结果写回到寄存器或者内存中。
如果我们有一个开发团队我们不会让后端工程师开发完API之后就歇着等待前台App的开发、测试乃至发布而是会在客户端App开发的同时着手下一个需求的后端API开发。那么同样的思路我们可以一样应用在CPU执行指令的过程中。
通过过去三讲你应该已经知道了CPU的指令执行过程其实也是由各个电路模块组成的。我们在取指令的时候需要一个译码器把数据从内存里面取出来写入到寄存器中在指令译码的时候我们需要另外一个译码器把指令解析成对应的控制信号、内存地址和数据到了指令执行的时候我们需要的则是一个完成计算工作的ALU。这些都是一个一个独立的组合逻辑电路我们可以把它们看作一个团队里面的产品经理、后端工程师和客户端工程师共同协作来完成任务。
<img src="https://static001.geekbang.org/resource/image/1e/ad/1e880fa8b1eab511583267e68f0541ad.jpeg" alt="">
这样一来,我们就不用把时钟周期设置成整条指令执行的时间,而是拆分成完成这样的一个一个小步骤需要的时间。同时,每一个阶段的电路在完成对应的任务之后,也不需要等待整个指令执行完成,而是可以直接执行下一条指令的对应阶段。
这就好像我们的后端程序员不需要等待功能上线就会从产品经理手中拿到下一个需求开始开发API。这样的协作模式就是我们所说的**指令流水线**。这里面每一个独立的步骤,我们就称之为**流水线阶段**或者流水线级Pipeline Stage
如果我们把一个指令拆分成“取指令-指令译码-执行指令”这样三个部分那这就是一个三级的流水线。如果我们进一步把“执行指令”拆分成“ALU计算指令执行-内存访问-数据写回”,那么它就会变成一个五级的流水线。
五级的流水线就表示我们在同一个时钟周期里面同时运行五条指令的不同阶段。这个时候虽然执行一条指令的时钟周期变成了5但是我们可以把CPU的主频提得更高了。**我们不需要确保最复杂的那条指令在时钟周期里面执行完成,而只要保障一个最复杂的流水线级的操作,在一个时钟周期内完成就好了。**
如果某一个操作步骤的时间太长我们就可以考虑把这个步骤拆分成更多的步骤让所有步骤需要执行的时间尽量都差不多长。这样也就可以解决我们在单指令周期处理器中遇到的性能瓶颈来自于最复杂的指令的问题。像我们现代的ARM或者Intel的CPU流水线级数都已经到了14级。
虽然我们不能通过流水线来减少单条指令执行的“延时”这个性能指标但是通过同时在执行多条指令的不同阶段我们提升了CPU的“吞吐率”。在外部看来我们的CPU好像是“一心多用”在同一时间同时执行5条不同指令的不同阶段。在CPU内部其实它就像生产线一样不同分工的组件不断处理上游传递下来的内容而不需要等待单件商品生产完成之后再启动下一件商品的生产过程。
## 超长流水线的性能瓶颈
既然流水线可以增加我们的吞吐率你可能要问了为什么我们不把流水线级数做得更深呢为什么不做成20级乃至40级呢这个其实有很多原因我在之后几讲里面会详细讲解。这里我先讲一个最基本的原因就是增加流水线深度其实是有性能成本的。
我们用来同步时钟周期的不再是指令级别的而是流水线阶段级别的。每一级流水线对应的输出都要放到流水线寄存器Pipeline Register里面然后在下一个时钟周期交给下一个流水线级去处理。所以每增加一级的流水线就要多一级写入到流水线寄存器的操作。虽然流水线寄存器非常快比如只有20皮秒ps$10^{-12}$秒)。
<img src="https://static001.geekbang.org/resource/image/d9/26/d9e141af3f2c5eedd5aed438388cfe26.jpeg" alt="">
但是如果我们不断加深流水线这些操作占整个指令的执行时间的比例就会不断增加。最后我们的性能瓶颈就会出现在这些overhead上。如果我们指令的执行有3纳秒也就是3000皮秒。我们需要20级的流水线那流水线寄存器的写入就需要花费400皮秒占了超过10%。如果我们需要50级流水线就要多花费1纳秒在流水线寄存器上占到25%。这也就意味着单纯地增加流水线级数不仅不能提升性能反而会有更多的overhead的开销。所以设计合理的流水线级数也是现代CPU中非常重要的一点。
## 总结延伸
讲到这里相信你已经能够理解为什么我们的CPU需要流水线设计了也能把每一个流水线阶段在干什么和上一讲的整个CPU的数据通路的连接过程对上了。
可以看到为了能够不浪费CPU的性能我们通过把指令的执行过程切分成一个一个流水线级来提升CPU的吞吐率。而我们本身的CPU的设计又是由一个个独立的组合逻辑电路串接起来形成的天然能够适合这样采用流水线“专业分工”的工作方式。
因为每一级的overhead一味地增加流水线深度并不能无限地提高性能。同样地因为指令的执行不再是顺序地一条条执行而是在上一条执行到一半的时候下一条就已经启动了所以也给我们的程序带来了很多挑战。这些挑战和对应的解决方案就要请你坚持关注后面的几讲我们一起来揭开答案了。
## 推荐阅读
想要了解CPU的流水线设计可以参看《深入理解计算机系统》的4.4章节,以及《计算机组成与设计 硬件/软件接口》的4.5章节。
## 课后思考
我们在前面讲过一个CPU的时钟周期可以认为是完成一条简单指令的时间。在这一讲之后你觉得这句话正确吗为什么在了解了CPU的流水线设计之后你是怎么理解这句话的呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,113 @@
<audio id="audio" title="21 | 面向流水线的指令设计奔腾4是怎么失败的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c2/12/c2145ebdd2b36b43a0a5915662144012.mp3"></audio>
上一讲我给你初步介绍了CPU的流水线技术。乍看起来流水线技术是一个提升性能的灵丹妙药。它通过把一条指令的操作切分成更细的多个步骤可以避免CPU“浪费”。每一个细分的流水线步骤都很简单所以我们的单个时钟周期的时间就可以设得更短。这也变相地让CPU的主频提升得很快。
这一系列的优点也引出了现代桌面CPU的最后一场大战也就是Intel的Pentium 4和AMD的Athlon之间的竞争。在技术上这场大战Intel可以说输得非常彻底Pentium 4系列以及后续Pentium D系列所使用的NetBurst架构被完全抛弃退出了历史舞台。但是在商业层面Intel却通过远超过AMD的财力、原本就更大的市场份额、无所不用的竞争手段以及最终壮士断腕般放弃整个NetBurst架构最终依靠新的酷睿品牌战胜了AMD。
在此之后整个CPU领域竞争的焦点不再是Intel和AMD之间的桌面CPU之战。在ARM架构通过智能手机的快速普及后来居上超越Intel之后移动时代的CPU之战变成了高通、华为麒麟和三星之间的“三国演义”。
## “主频战争”带来的超长流水线
我们在[第3讲](https://time.geekbang.org/column/article/92215)里讲过我们其实并不能简单地通过CPU的主频就来衡量CPU乃至计算机整机的性能。因为不同的CPU实际的体系架构和实现都不一样。同样的CPU主频实际的性能可能差别很大。所以在工业界更好的衡量方式通常是用SPEC这样的跑分程序从多个不同的实际应用场景来衡量计算机的性能。
但是跑分对于消费者来说还是太复杂了。在Pentium 4的CPU面世之前绝大部分消费者并不是根据跑分结果来判断CPU的性能的。大家判断一个CPU的性能通常只看CPU的主频。而CPU的厂商们也通过不停地提升主频把主频当成技术竞赛的核心指标。
Intel一向在“主频战争”中保持领先但是到了世纪之交的1999年到2000年情况发生了变化。
1999年AMD发布了基于K7架构的Athlon处理器其综合性能超越了当年的Pentium III。2000年在大部分CPU还在500850MHz的频率下运行的时候AMD推出了第一代Athlon 1000处理器成为第一款1GHz主频的消费级CPU。在2000年前后AMD的CPU不但性能和主频比Intel的要强价格还往往只有Intel的2/3。
在巨大的外部压力之下Intel在2001年推出了新一代的NetBurst架构CPU也就是Pentium 4和Pentium D。Pentium 4的CPU有个最大的特点就是高主频。2000年的Athlon 1000的主频在当时是最高的1GHz然而Pentium 4设计的目标最高主频是10GHz。
<img src="https://static001.geekbang.org/resource/image/9b/f8/9bcdc5d5c7c1342085d70fe50d5190f8.jpeg" alt="">
为了达到这个10GHzIntel的工程师做出了一个重大的错误决策就是在NetBurst架构上使用超长的流水线。这个超长流水线有多长呢我们拿在Pentium 4之前和之后的CPU的数字做个比较你就知道了。
Pentium 4之前的Pentium III CPU流水线的深度是11级也就是一条指令最多会拆分成11个更小的步骤来操作而CPU同时也最多会执行11条指令的不同Stage。随着技术发展到今天你日常用的手机ARM的CPU或者Intel i7服务器的CPU流水线的深度是14级。
可以看到差不多20年过去了通过技术进步现代CPU还是增加了一些流水线深度的。那2000年发布的Pentium 4的流水线深度是多少呢答案是20级比Pentium III差不多多了一倍而到了代号为Prescott的90纳米工艺处理器Pentium 4Intel更是把流水线深度增加到了31级。
要知道增加流水线深度在同主频下其实是降低了CPU的性能。因为一个Pipeline Stage就需要一个时钟周期。那么我们把任务拆分成31个阶段就需要31个时钟周期才能完成一个任务而把任务拆分成11个阶段就只需要11个时钟周期就能完成任务。在这种情况下31个Stage的3GHz主频的CPU其实和11个Stage的1GHz主频的CPU性能是差不多的。事实上因为每个Stage都需要有对应的Pipeline寄存器的开销这个时候更深的流水线性能可能还会更差一些。
我在上一讲也说过,流水线技术并不能缩短单条指令的**响应时间**这个性能指标,但是可以增加在运行很多条指令时候的**吞吐率**。因为不同的指令,实际执行需要的时间是不同的。我们可以看这样一个例子。我们顺序执行这样三条指令。
1. 一条整数的加法需要200ps。
1. 一条整数的乘法需要300ps。
1. 一条浮点数的乘法需要600ps。
如果我们是在单指令周期的CPU上运行最复杂的指令是一条浮点数乘法那就需要600ps。那这三条指令都需要600ps。三条指令的执行时间就需要1800ps。
如果我们采用的是6级流水线CPU每一个Pipeline的Stage都只需要100ps。那么在这三个指令的执行过程中在指令1的第一个100ps的Stage结束之后第二条指令就开始执行了。在第二条指令的第一个100ps的Stage结束之后第三条指令就开始执行了。这种情况下这三条指令顺序执行所需要的总时间就是800ps。那么在1800ps内使用流水线的CPU比单指令周期的CPU就可以多执行一倍以上的指令数。
虽然每一条指令从开始到结束拿到结果的时间并没有变化,也就是响应时间没有变化。但是同样时间内,完成的指令数增多了,也就是吞吐率上升了。
<img src="https://static001.geekbang.org/resource/image/b0/6a/b055676975e68a7d4014e46969058f6a.jpeg" alt="">
## 新的挑战:冒险和分支预测
那到这里可能你就要问了这样看起来不是很好么Intel的CPU支持的指令集很大我们之前说过有2000多条指令。有些指令很简单执行也很快比如无条件跳转指令不需要通过ALU进行任何计算只要更新一下PC寄存器里面的内容就好了。而有些指令很复杂比如浮点数的运算需要进行指数位比较、对齐然后对有效位进行移位然后再进行计算。两者的执行时间相差二三十倍也很正常。
既然这样Pentium 4的超长流水线看起来很合理呀为什么Pentium 4最终成为Intel在技术架构层面的大失败呢
**第一个自然是我们在第3讲里讲过的功耗问题**。提升流水线深度必须要和提升CPU主频同时进行。因为在单个Pipeline Stage能够执行的功能变简单了也就意味着单个时钟周期内能够完成的事情变少了。所以只有提升时钟周期CPU在指令的响应时间这个指标上才能保持和原来相同的性能。
同时,由于流水线深度的增加,我们需要的电路数量变多了,也就是我们所使用的晶体管也就变多了。
主频的提升和晶体管数量的增加都使得我们CPU的功耗变大了。这个问题导致了Pentium 4在整个生命周期里都成为了耗电和散热的大户。而Pentium 4是在20002004年作为Intel的主打CPU出现在市场上的。这个时间段正是笔记本电脑市场快速发展的时间。在笔记本电脑上功耗和散热比起台式机是一个更严重的问题了。即使性能更好别人的笔记本可以用上2小时你的只能用30分钟那谁也不爱买啊
更何况Pentium 4的性能还更差一些。**这个就要我们说到第二点了,就是上面说的流水线技术带来的性能提升,是一个理想情况。在实际的程序执行中,并不一定能够做得到**。
还回到我们刚才举的三条指令的例子。如果这三条指令,是下面这样的三条代码,会发生什么情况呢?
```
int a = 10 + 5; // 指令1
int b = a * 2; // 指令2
float c = b * 1.0f; // 指令3
```
我们会发现指令2不能在指令1的第一个Stage执行完成之后进行。因为指令2依赖指令1的计算结果。同样的指令3也要依赖指令2的计算结果。这样即使我们采用了流水线技术这三条指令执行完成的时间也是 200 + 300 + 600 = 1100 ps而不是之前说的 800ps。而如果指令1和2都是浮点数运算需要600ps。那这个依赖关系会导致我们需要的时间变成1800ps和单指令周期CPU所要花费的时间是一样的。
这个依赖问题,就是我们在计算机组成里面所说的**冒险**Hazard问题。这里我们只列举了在数据层面的依赖也就是数据冒险。在实际应用中还会有**结构冒险、控制冒险**等其他的依赖问题。
对应这些冒险问题,我们也有在**乱序执行**、**分支预测**等相应的解决方案。我们在后面的几讲里面,会详细讲解对应的知识。
但是我们的流水线越长这个冒险的问题就越难一解决。这是因为同一时间同时在运行的指令太多了。如果我们只有3级流水线我们可以把后面没有依赖关系的指令放到前面来执行。这个就是我们所说的乱序执行的技术。比方说我们可以扩展一下上面的3行代码再加上几行代码。
```
int a = 10 + 5; // 指令1
int b = a * 2; // 指令2
float c = b * 1.0f; // 指令3
int x = 10 + 5; // 指令4
int y = a * 2; // 指令5
float z = b * 1.0f; // 指令6
int o = 10 + 5; // 指令7
int p = a * 2; // 指令8
float q = b * 1.0f; // 指令9
```
我们可以不先执行1、2、3这三条指令而是在流水线里先执行1、4、7三条指令。这三条指令之间是没有依赖关系的。然后再执行2、5、8以及3、6、9。这样我们又能够充分利用CPU的计算能力了。
但是如果我们有20级流水线意味着我们要确保这20条指令之间没有依赖关系。这个挑战一下子就变大了很多。毕竟我们平时撰写程序通常前后的代码都是有一定的依赖关系的几十条没有依赖关系的指令可不好找。这也是为什么超长流水线的执行效率发而降低了的一个重要原因。
## 总结延伸
相信到这里你对CPU的流水线技术有了一个更加深入的了解。你会发现流水线技术和其他技术一样都讲究一个“折衷”Trade-Off。一个合理的流水线深度会提升我们CPU执行计算机指令的吞吐率。我们一般用IPCInstruction Per Cycle来衡量CPU执行指令的效率。
IPC呢其实就是我们之前在第3讲讲的CPICycle Per Instruction的倒数。也就是说 IPC = 3对应着CPI = 0.33。Pentium 4和Pentium D的IPC都远低于自己上一代的Pentium III以及竞争对手AMD的Athlon CPU。
过深的流水线不仅不能提升计算机指令的吞吐率更会加大计算的功耗和散热问题。Intel自己在笔记本电脑市场也很快放弃了Pentium 4而是主推了使用Pentium III架构的图拉丁CPU。
而流水线带来的吞吐率提升,只是一个理想情况下的理论值。在实践的应用过程中,还需要解决指令之间的依赖问题。这个使得我们的流水线,特别是超长的流水线的执行效率变得很低。要想解决好**冒险**的依赖关系问题,我们需要引入乱序执行、分支预测等技术,这也是我在后面几讲里面要详细讲解的内容。
## 推荐阅读
除了之前的教科书之外,我推荐你读一读[Modern Microprocessors, A 90-Minute Guide!](http://www.lighterra.com/papers/modernmicroprocessors/)这篇文章。这篇文章用比较浅显的方式介绍了现代CPU设计的多个方面很适合作为一个周末读物快速理解现代CPU的设计。
## 课后思考
除了我们这里提到的数据层面的依赖,你能找找我们在程序的执行过程中,其他的依赖情况么?这些依赖情况又属于我们说的哪一种冒险呢?
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,193 @@
<audio id="audio" title="22 | 冒险和预测hazard是“危”也是“机”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/23/f5/2308222ba20e143e4f33ab4c58d588f5.mp3"></audio>
过去两讲我为你讲解了流水线设计CPU所需要的基本概念。接下来我们一起来看看要想通过流水线设计来提升CPU的吞吐率我们需要冒哪些风险。
任何一本讲解CPU的流水线设计的教科书都会提到流水线设计需要解决的三大冒险分别是**结构冒险**Structural Hazard、**数据冒险**Data Hazard以及**控制冒险**Control Hazard
这三大冒险的名字很有意思,它们都叫作**hazard**冒险。喜欢玩游戏的话你应该知道一个著名的游戏生化危机英文名就叫Biohazard。的确hazard还有一个意思就是“危机”。那为什么在流水线设计里hazard没有翻译成“危机”而是要叫“冒险”呢
在CPU的流水线设计里固然我们会遇到各种“危险”情况使得流水线里的下一条指令不能正常运行。但是我们其实还是通过“抢跑”的方式“冒险”拿到了一个提升指令吞吐率的机会。流水线架构的CPU是我们主动进行的冒险选择。我们期望能够通过冒险带来更高的回报所以这不是无奈之下的应对之举自然也算不上什么危机了。
事实上,对于各种冒险可能造成的问题,我们其实都准备好了应对的方案。这一讲里,我们先从结构冒险和数据冒险说起,一起来看看这些冒险及其对应的应对方案。
## 结构冒险:为什么工程师都喜欢用机械键盘?
我们先来看一看结构冒险。结构冒险,本质上是一个硬件层面的资源竞争问题,也就是一个硬件电路层面的问题。
CPU在同一个时钟周期同时在运行两条计算机指令的不同阶段。但是这两个不同的阶段可能会用到同样的硬件电路。
最典型的例子就是内存的数据访问。请你看看下面这张示意图,其实就是[第20讲](https://time.geekbang.org/column/article/99523)里对应的5级流水线的示意图。
可以看到在第1条指令执行到访存MEM阶段的时候流水线里的第4条指令在执行取指令Fetch的操作。访存和取指令都要进行内存数据的读取。我们的内存只有一个地址译码器的作为地址输入那就只能在一个时钟周期里面读取一条数据没办法同时执行第1条指令的读取内存数据和第4条指令的读取指令代码。
<img src="https://static001.geekbang.org/resource/image/c2/4e/c2a4c0340cb835350ea954cdc520704e.jpeg" alt="">
类似的资源冲突,其实你在日常使用计算机的时候也会遇到。最常见的就是薄膜键盘的“锁键”问题。常用的最廉价的薄膜键盘,并不是每一个按键的背后都有一根独立的线路,而是多个键共用一个线路。如果我们在同一时间,按下两个共用一个线路的按键,这两个按键的信号就没办法都传输出去。
这也是为什么,重度键盘用户,都要买贵一点儿的机械键盘或者电容键盘。因为这些键盘的每个按键都有独立的传输线路,可以做到“全键无冲”,这样,无论你是要大量写文章、写程序,还是打游戏,都不会遇到按下了键却没生效的情况。
“全键无冲”这样的资源冲突解决方案,其实本质就是**增加资源**。同样的方案我们一样可以用在CPU的结构冒险里面。对于访问内存数据和取指令的冲突一个直观的解决方案就是把我们的内存分成两部分让它们各有各的地址译码器。这两部分分别是**存放指令的程序内存**和**存放数据的数据内存**。
这样把内存拆成两部分的解决方案,在计算机体系结构里叫作[哈佛架构](https://en.wikipedia.org/wiki/Harvard_architecture)Harvard Architecture来自哈佛大学设计[Mark I型计算机](https://en.wikipedia.org/wiki/Harvard_Mark_I)时候的设计。对应的我们之前说的冯·诺依曼体系结构又叫作普林斯顿架构Princeton Architecture。从这些名字里我们可以看到早年的计算机体系结构的设计其实产生于美国各个高校之间的竞争中。
不过我们今天使用的CPU仍然是冯·诺依曼体系结构的并没有把内存拆成程序内存和数据内存这两部分。因为如果那样拆的话对程序指令和数据需要的内存空间我们就没有办法根据实际的应用去动态分配了。虽然解决了资源冲突的问题但是也失去了灵活性。
<img src="https://static001.geekbang.org/resource/image/e7/91/e7508cb409d398380753b292b6df8391.jpeg" alt="">
不过借鉴了哈佛结构的思路现代的CPU虽然没有在内存层面进行对应的拆分却在CPU内部的高速缓存部分进行了区分把高速缓存分成了**指令缓存**Instruction Cache和**数据缓存**Data Cache两部分。
内存的访问速度远比CPU的速度要慢所以现代的CPU并不会直接读取主内存。它会从主内存把指令和数据加载到高速缓存中这样后续的访问都是访问高速缓存。而指令缓存和数据缓存的拆分使得我们的CPU在进行数据访问和取指令的时候不会再发生资源冲突的问题了。
## 数据冒险:三种不同的依赖关系
结构冒险是一个硬件层面的问题,我们可以靠增加硬件资源的方式来解决。然而还有很多冒险问题,是程序逻辑层面的事儿。其中,最常见的就是数据冒险。
数据冒险,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,我们可以分成三大类,分别是**先写后读**Read After WriteRAW、**先读后写**Write After ReadWAR和**写后再写**Write After WriteWAW。下面我们分别看一下这几种情况。
### 先写后读Read After Write
我们先来一起看看先写后读这种情况。这里有一段简单的C语言代码编译出来的汇编指令。这段代码简单地定义两个变量 a 和 b然后计算 a = a + 2。再根据计算出来的结果计算 b = a + 3。
```
int main() {
int a = 1;
int b = 2;
a = a + 2;
b = a + 3;
}
```
```
int main() {
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = a + 2;
12: 83 45 fc 02 add DWORD PTR [rbp-0x4],0x2
b = a + 3;
16: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
19: 83 c0 03 add eax,0x3
1c: 89 45 f8 mov DWORD PTR [rbp-0x8],eax
}
1f: 5d pop rbp
20: c3 ret
```
你可以看到在内存地址为12的机器码我们把0x2添加到 rbp-0x4 对应的内存地址里面。然后在紧接着的内存地址为16的机器码我们又要从rbp-0x4这个内存地址里面把数据写入到eax这个寄存器里面。
所以我们需要保证在内存地址为16的指令读取rbp-0x4里面的值之前内存地址12的指令写入到rbp-0x4的操作必须完成。这就是先写后读所面临的数据依赖。如果这个顺序保证不了我们的程序就会出错。
这个先写后读的依赖关系,我们一般被称之为**数据依赖**也就是Data Dependency。
### 先读后写Write After Read
我们还会面临的另外一种情况,先读后写。我们小小地修改一下代码,先计算 a = b + a然后再计算 b = a + b。
```
int main() {
int a = 1;
int b = 2;
a = b + a;
b = a + b;
}
```
```
int main() {
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = b + a;
12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
15: 01 45 fc add DWORD PTR [rbp-0x4],eax
b = a + b;
18: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1b: 01 45 f8 add DWORD PTR [rbp-0x8],eax
}
1e: 5d pop rbp
1f: c3 ret
```
我们同样看看对应生成的汇编代码。在内存地址为15的汇编指令里我们要把 eax 寄存器里面的值读出来,再加到 rbp-0x4 的内存地址里。接着在内存地址为18的汇编指令里我们要再写入更新 eax 寄存器里面。
如果我们在内存地址18的eax的写入先完成了在内存地址为15的代码里面取出 eax 才发生我们的程序计算就会出错。这里我们同样要保障对于eax的先读后写的操作顺序。
这个先读后写的依赖,一般被叫作**反依赖**也就是Anti-Dependency。
### 写后再写Write After Write
我们再次小小地改写上面的代码。这次,我们先设置变量 a = 1然后再设置变量 a = 2。
```
int main() {
int a = 1;
a = 2;
}
```
```
int main() {
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
a = 2;
b: c7 45 fc 02 00 00 00 mov DWORD PTR [rbp-0x4],0x2
}
```
在这个情况下你会看到内存地址4所在的指令和内存地址b所在的指令都是将对应的数据写入到 rbp-0x4 的内存地址里面。如果内存地址b的指令在内存地址4的指令之后写入。那么这些指令完成之后rbp-0x4 里的数据就是错误的。这就会导致后续需要使用这个内存地址里的数据指令没有办法拿到正确的值。所以我们也需要保障内存地址4的指令的写入在内存地址b的指令的写入之前完成。
这个写后再写的依赖,一般被叫作**输出依赖**也就是Output Dependency。
### 再等等:通过流水线停顿解决数据冒险
除了读之后再进行读,你会发现,对于同一个寄存器或者内存地址的操作,都有明确强制的顺序要求。而这个顺序操作的要求,也为我们使用流水线带来了很大的挑战。因为流水线架构的核心,就是在前一个指令还没有结束的时候,后面的指令就要开始执行。
所以,我们需要有解决这些数据冒险的办法。其中最简单的一个办法,不过也是最笨的一个办法,就是[流水线停顿](https://en.wikipedia.org/wiki/Pipeline_stall)Pipeline Stall或者叫流水线冒泡Pipeline Bubbling
流水线停顿的办法很容易理解。如果我们发现了后面执行的指令,会对前面执行的指令有数据层面的依赖关系,那最简单的办法就是“**再等等**”。我们在进行指令译码的时候,会拿到对应指令所需要访问的寄存器和内存地址。所以,在这个时候,我们能够判断出来,这个指令是否会触发数据冒险。如果会触发数据冒险,我们就可以决定,让整个流水线停顿一个或者多个周期。
<img src="https://static001.geekbang.org/resource/image/d1/c8/d1e24e4b18411a5391757a197de2bdc8.jpeg" alt="">
我在前面说过时钟信号会不停地在0和1之前自动切换。其实我们并没有办法真的停顿下来。流水线的每一个操作步骤必须要干点儿事情。所以在实践过程中我们并不是让流水线停下来而是在执行后面的操作步骤前面插入一个NOP操作也就是执行一个其实什么都不干的操作。
<img src="https://static001.geekbang.org/resource/image/0d/2a/0d762f2ce532d87cfe69c7b167af9c2a.jpeg" alt="">
这个插入的指令就好像一个水管Pipeline里面进了一个空的气泡。在水流经过的时候没有传送水到下一个步骤而是给了一个什么都没有的空气泡。这也是为什么我们的流水线停顿又被叫作流水线冒泡Pipeline Bubble的原因。
## 总结延伸
讲到这里,相信你已经弄明白了什么是结构冒险,什么是数据冒险,以及数据冒险所要保障的三种依赖,也就是数据依赖、反依赖以及输出依赖。
一方面我们可以通过增加资源来解决结构冒险问题。我们现代的CPU的体系结构其实也是在冯·诺依曼体系结构下借鉴哈佛结构的一个混合结构的解决方案。我们的内存虽然没有按照功能拆分但是在高速缓存层面进行了拆分也就是拆分成指令缓存和数据缓存这样的方式从硬件层面使得同一个时钟下对于相同资源的竞争不再发生。
另一方面我们也可以通过“等待”也就是插入无效的NOP操作的方式来解决冒险问题。这就是所谓的流水线停顿。不过流水线停顿这样的解决方案是以牺牲CPU性能为代价的。因为实际上在最差的情况下我们的流水线架构的CPU又会退化成单指令周期的CPU了。
所以,下一讲,我们进一步看看,其他更高级的解决数据冒险的方案,以及控制冒险的解决方案,也就是操作数前推、乱序执行和还有分支预测技术。
## 推荐阅读
想要进一步理解流水线冒险里数据冒险的相关知识,你可以仔细看一看《计算机组成与设计:硬件/软件接口》的第4.54.7章。
想要了解流水线冒险里面结构冒险的相关知识你可以去看一看Coursera上普林斯顿大学的Computer Architecture的[Structure Hazard](https://zh.coursera.org/lecture/comparch/structural-hazard-lB2xV)部分。
## 课后思考
在采用流水线停顿的解决方案的时候我们不仅要在当前指令里面插入NOP操作所有后续指令也要插入对应的NOP操作这是为什么呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,96 @@
<audio id="audio" title="23 | 冒险和预测(二):流水线里的接力赛" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/9e/a1158e59b928fa6c6104619fa22c9d9e.mp3"></audio>
上一讲我为你讲解了结构冒险和数据冒险以及应对这两种冒险的两个解决方案。一种方案是增加资源通过添加指令缓存和数据缓存让我们对于指令和数据的访问可以同时进行。这个办法帮助CPU解决了取指令和访问数据之间的资源冲突。另一种方案是直接进行等待。通过插入NOP这样的无效指令等待之前的指令完成。这样我们就能解决不同指令之间的数据依赖问题。
着急的人,看完上一讲的这两种方案,可能已经要跳起来问了:“这也能算解决方案么?”的确,这两种方案都有点儿笨。
第一种解决方案,好比是在软件开发的过程中,发现效率不够,于是研发负责人说:“我们需要双倍的人手和研发资源。”而第二种解决方案,好比你在提需求的时候,研发负责人告诉你说:“来不及做,你只能等我们需求排期。” 你应该很清楚地知道,“堆资源”和“等排期”这样的解决方案,并不会真的提高我们的效率,只是避免冲突的无奈之举。
那针对流水线冒险的问题,我们有没有更高级或者更高效的解决方案呢?既不用简单花钱加硬件电路这样“堆资源”,也不是纯粹等待之前的任务完成这样“等排期”。
答案当然是有的。这一讲,我们就来看看计算机组成原理中,一个更加精巧的解决方案,**操作数前推**。
## NOP操作和指令对齐
要想理解操作数前推技术,我们先来回顾一下,[第5讲](https://time.geekbang.org/column/article/93359)讲过的MIPS体系结构下的R、I、J三类指令以及[第20讲](https://time.geekbang.org/column/article/99523)里的五级流水线“取指令IF-指令译码ID-指令执行EX-内存访问MEM-数据写回WB ”。
我把对应的图片放进来了,你可以看一下。如果印象不深,建议你先回到这两节去复习一下,再来看今天的内容。
<img src="https://static001.geekbang.org/resource/image/b1/bf/b1ade5f8de67b172bf7b4ec9f63589bf.jpeg" alt=""><img src="https://static001.geekbang.org/resource/image/1e/ad/1e880fa8b1eab511583267e68f0541ad.jpeg" alt="">
在MIPS的体系结构下不同类型的指令会在流水线的不同阶段进行不同的操作。
我们以MIPS的LOAD这样从内存里读取数据到寄存器的指令为例来仔细看看它需要经历的5个完整的流水线。STORE这样从寄存器往内存里写数据的指令不需要有写回寄存器的操作也就是没有数据写回的流水线阶段。至于像ADD和SUB这样的加减法指令所有操作都在寄存器完成所以没有实际的内存访问MEM操作。
<img src="https://static001.geekbang.org/resource/image/b6/d4/b66ea9ca3300c7f71e91aaa6b6428fd4.jpg" alt="">
有些指令没有对应的流水线阶段但是我们并不能跳过对应的阶段直接执行下一阶段。不然如果我们先后执行一条LOAD指令和一条ADD指令就会发生LOAD指令的WB阶段和ADD指令的WB阶段在同一个时钟周期发生。这样相当于触发了一个结构冒险事件产生了资源竞争。
<img src="https://static001.geekbang.org/resource/image/9e/5f/9e62ab3b42e445d65accf0549badf45f.jpeg" alt="">
所以在实践当中各个指令不需要的阶段并不会直接跳过而是会运行一次NOP操作。通过插入一个NOP操作我们可以使后一条指令的每一个Stage一定不和前一条指令的同Stage在一个时钟周期执行。这样就不会发生先后两个指令在同一时钟周期竞争相同的资源产生结构冒险了。
<img src="https://static001.geekbang.org/resource/image/c1/42/c16643d83dd534d3d97d0d7ad8e30d42.jpg" alt="">
## 流水线里的接力赛:操作数前推
通过NOP操作进行对齐我们在流水线里就不会遇到资源竞争产生的结构冒险问题了。除了可以解决结构冒险之外这个NOP操作也是我们之前讲的流水线停顿插入的对应操作。
但是插入过多的NOP操作意味着我们的CPU总是在空转干吃饭不干活。那么我们有没有什么办法尽量少插入一些NOP操作呢不要着急下面我们就以两条先后发生的ADD指令作为例子看看能不能找到一些好的解决方案。
```
add $t0, $s2,$s1
add $s2, $s1,$t0
```
这两条指令很简单。
1. 第一条指令,把 s1 和 s2 寄存器里面的数据相加,存入到 t0 这个寄存器里面。
1. 第二条指令,把 s1 和 t0 寄存器里面的数据相加,存入到 s2 这个寄存器里面。
因为后一条的 add 指令,依赖寄存器 t0 里的值。而 t0 里面的值又来自于前一条指令的计算结果。所以后一条指令需要等待前一条指令的数据写回阶段完成之后才能执行。就像上一讲里讲的那样我们遇到了一个数据依赖类型的冒险。于是我们就不得不通过流水线停顿来解决这个冒险问题。我们要在第二条指令的译码阶段之后插入对应的NOP指令直到前一天指令的数据写回完成之后才能继续执行。
这样的方案虽然解决了数据冒险的问题但是也浪费了两个时钟周期。我们的第2条指令其实就是多花了2个时钟周期运行了两次空转的NOP操作。
<img src="https://static001.geekbang.org/resource/image/94/69/94dda2330b07c08530540ae11838c569.jpeg" alt="">
不过,其实我们第二条指令的执行,未必要等待第一条指令写回完成,才能进行。如果我们第一条指令的执行结果,能够直接传输给第二条指令的执行阶段,作为输入,那我们的第二条指令,就不用再从寄存器里面,把数据再单独读出来一次,才来执行代码。
我们完全可以在第一条指令的执行阶段完成之后直接将结果数据传输给到下一条指令的ALU。然后下一条指令不需要再插入两个NOP阶段就可以继续正常走到执行阶段。
<img src="https://static001.geekbang.org/resource/image/dc/27/dceadd35c334974d8270052b37d48c27.jpeg" alt="">
这样的解决方案,我们就叫作**操作数前推**Operand Forwarding或者操作数旁路Operand Bypassing。其实我觉得更合适的名字应该叫**操作数转发**。这里的Forward其实就是我们写Email时的“转发”Forward的意思。不过现有的经典教材的中文翻译一般都叫“前推”我们也就不去纠正这个说法了你明白这个意思就好。
转发,其实是这个技术的**逻辑含义**也就是在第1条指令的执行结果直接“转发”给了第2条指令的ALU作为输入。另外一个名字旁路Bypassing则是这个技术的**硬件含义**。为了能够实现这里的“转发”我们在CPU的硬件里面需要再单独拉一根信号传输的线路出来使得ALU的计算结果能够重新回到ALU的输入里来。这样的一条线路就是我们的“旁路”。它越过Bypass了写入寄存器再从寄存器读出的过程也为我们节省了2个时钟周期。
操作数前推的解决方案不但可以单独使用,还可以和流水线冒泡一起使用。有的时候,虽然我们可以把操作数转发到下一条指令,但是下一条指令仍然需要停顿一个时钟周期。
比如说我们先去执行一条LOAD指令再去执行ADD指令。LOAD指令在访存阶段才能把数据读取出来所以下一条指令的执行阶段需要在访存阶段完成之后才能进行。
<img src="https://static001.geekbang.org/resource/image/49/2d/49f3a9b1ae2972ac5c6cfca7731bf12d.jpeg" alt="">
总的来说,操作数前推的解决方案,比流水线停顿更进了一步。流水线停顿的方案,有点儿像游泳比赛的接力方式。下一名运动员,需要在前一个运动员游玩了全程之后,触碰到了游泳池壁才能出发。而操作数前推,就好像短跑接力赛。后一个运动员可以提前抢跑,而前一个运动员会多跑一段主动把交接棒传递给他。
## 总结延伸
这一讲,我给你介绍了一个更加高级,也更加复杂的解决数据冒险问题方案,就是操作数前推,或者叫操作数旁路。
操作数前推就是通过在硬件层面制造一条旁路让一条指令的计算结果可以直接传输给下一条指令而不再需要“指令1写回寄存器指令2再读取寄存器“这样多此一举的操作。这样直接传输带来的好处就是后面的指令可以减少甚至消除原本需要通过流水线停顿才能解决的数据冒险问题。
这个前推的解决方案,不仅可以单独使用,还可以和前面讲解过的流水线冒泡结合在一起使用。因为有些时候,我们的操作数前推并不能减少所有“冒泡”,只能去掉其中的一部分。我们仍然需要通过插入一些“气泡”来解决冒险问题。
通过操作数前推我们进一步提升了CPU的运行效率。那么我们是不是还能找到别的办法进一步地减少浪费呢毕竟看到现在我们仍然少不了要插入很多NOP的“气泡”。那就请你继续坚持学习下去。下一讲我们来看看CPU是怎么通过乱序执行进一步减少“气泡”的。
## 推荐阅读
想要深入了解操作数前推相关的内容,推荐你读一下《计算机组成与设计:硬件/软件接口》的4.54.7章节。
## 课后思考
前面讲5级流水线指令的时候我们说STORE指令是没有数据写回阶段的而ADD指令是没有访存阶段的。那像CMP或者JMP这样的比较和跳转指令5个阶段都是全的么还是说不需要哪些阶段呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,107 @@
<audio id="audio" title="24 | 冒险和预测CPU里的“线程池”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/98/a4dcdc1ec28e6d1a8ea9a34246742f98.mp3"></audio>
过去两讲,我为你讲解了通过增加资源、停顿等待以及主动转发数据的方式,来解决结构冒险和数据冒险问题。对于结构冒险,由于限制来自于同一时钟周期不同的指令,要访问相同的硬件资源,解决方案是增加资源。对于数据冒险,由于限制来自于数据之间的各种依赖,我们可以提前把数据转发到下一个指令。
但是即便综合运用这三种技术我们仍然会遇到不得不停下整个流水线等待前面的指令完成的情况也就是采用流水线停顿的解决方案。比如说上一讲里最后给你的例子即使我们进行了操作数前推因为第二条加法指令依赖于第一条指令从内存中获取的数据我们还是要插入一次NOP的操作。
<img src="https://static001.geekbang.org/resource/image/49/2d/49f3a9b1ae2972ac5c6cfca7731bf12d.jpeg" alt="">
那这个时候你就会想了,那我们能不能让后面没有数据依赖的指令,在前面指令停顿的时候先执行呢?
答案当然是可以的。毕竟,流水线停顿的时候,对应的电路闲着也是闲着。那我们完全可以先完成后面指令的执行阶段。
## 填上空闲的NOP上菜的顺序不必是点菜的顺序
之前我为你讲解的,无论是流水线停顿,还是操作数前推,归根到底,只要前面指令的特定阶段还没有执行完成,后面的指令就会被“阻塞”住。
但是这个“阻塞”很多时候是没有必要的。因为尽管你的代码生成的指令是顺序的,但是如果后面的指令不需要依赖前面指令的执行结果,完全可以不必等待前面的指令运算完成。
比如说,下面这三行代码。
```
a = b + c
d = a * e
x = y * z
```
计算里面的 x ,却要等待 a 和 d 都计算完成,实在没啥必要。所以我们完全可以在 d 的计算等待 a 的计算的过程中,先把 x 的结果给算出来。
在流水线里,后面的指令不依赖前面的指令,那就不用等待前面的指令执行,它完全可以先执行。
<img src="https://static001.geekbang.org/resource/image/37/ef/37ba6c453e530660cecbbfcf56a3ecef.jpeg" alt="">
可以看到,因为第三条指令并不依赖于前两条指令的计算结果,所以在第二条指令等待第一条指令的访存和写回阶段的时候,第三条指令就已经执行完成了。
这就好比你开了一家餐馆,顾客会排队来点菜。餐馆的厨房里会有洗菜、切菜、炒菜、上菜这样的各个步骤。后厨也是按照点菜的顺序开始做菜的。但是不同的菜需要花费的时间和工序可能都有差别。有些菜做起来特别麻烦,特别慢。比如做一道佛跳墙有好几道工序。我们没有必要非要等先点的佛跳墙上菜了,再开始做后面的炒鸡蛋。只要有厨子空出来了,就可以先动手做前面的简单菜,先给客户端上去。
这样的解决方案,在计算机组成里面,被称为**乱序执行**Out-of-Order ExecutionOoOE。乱序执行最早来自于著名的IBM 360。相信你一定听说过《人月神话》这本软件工程届的经典著作它讲的就是IBM 360开发过程中的“人生体会”。而IBM 360困难的开发过程也少不了第一次引入乱序执行这个新的CPU技术。
## CPU里的“线程池”理解乱序执行
那么我们的CPU怎样才能实现乱序执行呢是不是像玩俄罗斯方块一样把后面的指令找一个前面的坑填进去就行了事情并没有这么简单。其实从今天软件开发的维度来思考乱序执行好像是在指令的执行阶段引入了一个“线程池”。我们下面就来看一看在CPU里乱序执行的过程究竟是怎样的。
使用乱序执行技术后CPU里的流水线就和我之前给你看的5级流水线不太一样了。我们一起来看一看下面这张图。
<img src="https://static001.geekbang.org/resource/image/15/04/153f8d5e4a4363399133e1d7d9052804.jpeg" alt="">
1.在取指令和指令译码的时候乱序执行的CPU和其他使用流水线架构的CPU是一样的。它会一级一级顺序地进行取指令和指令译码的工作。
2.在指令译码完成之后就不一样了。CPU不会直接进行指令执行而是进行一次指令分发把指令发到一个叫作保留站Reservation Stations的地方。顾名思义这个保留站就像一个火车站一样。发送到车站的指令就像是一列列的火车。
3.这些指令不会立刻执行,而要等待它们所依赖的数据,传递给它们之后才会执行。这就好像一列列的火车都要等到乘客来齐了才能出发。
4.一旦指令依赖的数据来齐了指令就可以交到后面的功能单元Function UnitFU其实就是ALU去执行了。我们有很多功能单元可以并行运行但是不同的功能单元能够支持执行的指令并不相同。就和我们的铁轨一样有些从上海北上可以到北京和哈尔滨有些是南下的可以到广州和深圳。
5.指令执行的阶段完成之后我们并不能立刻把结果写回到寄存器里面去而是把结果再存放到一个叫作重排序缓冲区Re-Order BufferROB的地方。
6.在重排序缓冲区里我们的CPU会按照取指令的顺序对指令的计算结果重新排序。只有排在前面的指令都已经完成了才会提交指令完成整个指令的运算结果。
7.实际的指令的计算结果数据并不是直接写到内存或者高速缓存里而是先写入存储缓冲区Store Buffer面最终才会写入到高速缓存和内存里。
可以看到在乱序执行的情况下只有CPU内部指令的执行层面可能是“乱序”的。只要我们能在指令的译码阶段正确地分析出指令之间的数据依赖关系这个“乱序”就只会在互相没有影响的指令之间发生。
即便指令的执行过程中是乱序的,我们在最终指令的计算结果写入到寄存器和内存之前,依然会进行一次排序,以确保所有指令在外部看来仍然是有序完成的。
有了乱序执行我们重新去执行上面的3行代码。
```
a = b + c
d = a * e
x = y * z
```
里面的 d 依赖于 a 的计算结果,不会在 a 的计算完成之前执行。但是我们的CPU并不会闲着因为 x = y * z 的指令同样会被分发到保留站里。因为 x 所依赖的 y 和 z 的数据是准备好的, 这里的乘法运算不会等待计算 d而会先去计算 x 的值。
如果我们只有一个FU能够计算乘法那么这个FU并不会因为 d 要等待 a 的计算结果,而被闲置,而是会先被拿去计算 x。
在 x 计算完成之后d 也等来了 a 的计算结果。这个时候我们的FU就会去计算出 d 的结果。然后在重排序缓冲区里,把对应的计算结果的提交顺序,仍然设置成 a -&gt; d -&gt; x而计算完成的顺序是 x -&gt; a -&gt; d。
在这整个过程中整个计算乘法的FU都没有闲置这也意味着我们的CPU的吞吐率最大化了。
整个乱序执行技术,就好像在指令的执行阶段提供一个“线程池”。指令不再是顺序执行的,而是根据池里所拥有的资源,以及各个任务是否可以进行执行,进行动态调度。在执行完成之后,又重新把结果在一个队列里面,按照指令的分发顺序重新排序。即使内部是“乱序”的,但是在外部看起来,仍然是井井有条地顺序执行。
乱序执行极大地提高了CPU的运行效率。核心原因是现代CPU的运行速度比访问主内存的速度要快很多。如果完全采用顺序执行的方式很多时间都会浪费在前面指令等待获取内存数据的时间里。CPU不得不加入NOP操作进行空转。而现代CPU的流水线级数也已经相对比较深了到达了14级。这也意味着同一个时钟周期内并行执行的指令数是很多的。
而乱序执行以及我们后面要讲的高速缓存弥补了CPU和内存之间的性能差异。同样也充分利用了较深的流水行带来的并发性使得我们可以充分利用CPU的性能。
## 总结延伸
好了,总结一下。这一讲里,我为你介绍了乱序执行,这个解决流水线阻塞的技术方案。因为数据的依赖关系和指令先后执行的顺序问题,很多时候,流水线不得不“阻塞”在特定的指令上。即使后续别的指令,并不依赖正在执行的指令和阻塞的指令,也不能继续执行。
而乱序执行则是在指令执行的阶段通过一个类似线程池的保留站让系统自己去动态调度先执行哪些指令。这个动态调度巧妙地解决了流水线阻塞的问题。指令执行的先后顺序不再和它们在程序中的顺序有关。我们只要保证不破坏数据依赖就好了。CPU只要等到在指令结果的最终提交的阶段再通过重排序的方式确保指令“实际上”是顺序执行的。
## 推荐阅读
想要更深入地了解CPU的乱序执行的知识我们就不能局限于组成原理而要深入到体系结构中去了。你可以读一下《计算机体系结构量化研究方法》的3.4和3.5章节。
想要了解乱序执行为什么可行你可以看看Wikipedia上乱序执行所依赖的[Tomasulo算法](https://en.wikipedia.org/wiki/Tomasulo_algorithm)。这个算法也是在IBM 360时代引入的。
## 课后思考
在现代Intel的CPU的乱序执行的过程中只有指令的执行阶段是乱序的后面的内存访问和数据写回阶段都仍然是顺序的。这种保障内存数据访问顺序的模型叫作强内存模型Strong Memory Model。你能想一想我们为什么要保障内存访问的顺序呢在前后执行的指令没有相关数据依赖的情况下为什么我们仍然要求这个顺序呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,158 @@
<audio id="audio" title="25 | 冒险和预测(四):今天下雨了,明天还会下雨么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ff/47/ffc3801e7b380250aef34b2c8fd6e847.mp3"></audio>
过去三讲,我主要为你介绍了结构冒险和数据冒险,以及增加资源、流水线停顿、操作数前推、乱序执行,这些解决各种“冒险”的技术方案。
在结构冒险和数据冒险中,你会发现,所有的流水线停顿操作都要从**指令执行阶段**开始。流水线的前两个阶段也就是取指令IF和指令译码ID的阶段是不需要停顿的。CPU会在流水线里面直接去取下一条指令然后进行译码。
取指令和指令译码不会需要遇到任何停顿,这是基于一个假设。这个假设就是,所有的指令代码都是顺序加载执行的。不过这个假设,在执行的代码中,一旦遇到 if…else 这样的条件分支,或者 for/while 循环,就会不成立。
<img src="https://static001.geekbang.org/resource/image/b4/fa/b439cebb2d85496ad6eef2f61071aefa.jpeg" alt="">
我们先来回顾一下,[第6讲](https://time.geekbang.org/column/article/94075)里讲的cmp比较指令、jmp和jle这样的条件跳转指令。可以看到在jmp指令发生的时候CPU可能会跳转去执行其他指令。jmp后的那一条指令是否应该顺序加载执行在流水线里面进行取指令的时候我们没法知道。要等jmp指令执行完成去更新了PC寄存器之后我们才能知道是否执行下一条指令还是跳转到另外一个内存地址去取别的指令。
这种为了确保能取到正确的指令,而不得不进行等待延迟的情况,就是今天我们要讲的**控制冒险**Control Harzard。这也是流水线设计里最后一种冒险。
## 分支预测:今天下雨了,明天还会继续下雨么?
在遇到了控制冒险之后我们的CPU具体会怎么应对呢除了流水线停顿等待前面的jmp指令执行完成之后再去取最新的指令还有什么好办法吗当然是有的。我们一起来看一看。
### 缩短分支延迟
第一个办法,叫作**缩短分支延迟**。回想一下我们的条件跳转指令,条件跳转指令其实进行了两种电路操作。
第一种是进行条件比较。这个条件比较需要的输入是根据指令的opcode就能确认的条件码寄存器。
第二种是进行实际的跳转也就是把要跳转的地址信息写入到PC寄存器。无论是opcode还是对应的条件码寄存器还是我们跳转的地址都是在指令译码ID的阶段就能获得的。而对应的条件码比较的电路只要是简单的逻辑门电路就可以了并不需要一个完整而复杂的ALU。
所以我们可以将条件判断、地址跳转都提前到指令译码阶段进行而不需要放在指令执行阶段。对应的我们也要在CPU里面设计对应的旁路在指令译码阶段就提供对应的判断比较的电路。
这种方式,本质上和前面数据冒险的操作数前推的解决方案类似,就是在硬件电路层面,把一些计算结果更早地反馈到流水线中。这样反馈变得更快了,后面的指令需要等待的时间就变短了。
不过只是改造硬件,并不能彻底解决问题。跳转指令的比较结果,仍然要在指令执行的时候才能知道。在流水线里,第一条指令进行指令译码的时钟周期里,我们其实就要去取下一条指令了。这个时候,我们其实还没有开始指令执行阶段,自然也就不知道比较的结果。
### 分支预测
所以,这个时候,我们就引入了一个新的解决方案,叫作**分支预测**Branch Prediction技术也就是说让我们的CPU来猜一猜条件跳转后执行的指令应该是哪一条。
最简单的分支预测技术,叫作“**假装分支不发生**”。顾名思义自然就是仍然按照顺序把指令往下执行。其实就是CPU预测条件跳转一定不发生。这样的预测方法其实也是一种**静态预测**技术。就好像猜硬币的时候你一直猜正面会有50%的正确率。
如果分支预测是正确的我们自然赚到了。这个意味着我们节省下来本来需要停顿下来等待的时间。如果分支预测失败了呢那我们就把后面已经取出指令已经执行的部分给丢弃掉。这个丢弃的操作在流水线里面叫作Zap或者Flush。CPU不仅要执行后面的指令对于这些已经在流水线里面执行到一半的指令我们还需要做对应的清除操作。比如清空已经使用的寄存器里面的数据等等这些清除操作也有一定的开销。
所以CPU需要提供对应的丢弃指令的功能通过控制信号清除掉已经在流水线中执行的指令。只要对应的清除开销不要太大我们就是划得来的。
<img src="https://static001.geekbang.org/resource/image/39/c3/39d114b3e37fe7fbad98ef0322b876c3.jpeg" alt="">
### 动态分支预测
第三个办法,叫作**动态分支预测**。
上面的静态预测策略看起来比较简单预测的准确率也许有50%。但是如果运气不好,可能就会特别差。于是,工程师们就开始思考,我们有没有更好的办法呢?比如,根据之前条件跳转的比较结果来预测,是不是会更准一点?
我们日常生活里,最经常会遇到的预测就是天气预报。如果没有气象台给你天气预报,你想要猜一猜明天是不是下雨,你会怎么办?
有一个简单的策略,就是完全根据今天的天气来猜。如果今天下雨,我们就预测明天下雨。如果今天天晴,就预测明天也不会下雨。这是一个很符合我们日常生活经验的预测。因为一般下雨天,都是连着下几天,不断地间隔地发生“天晴-下雨-天晴-下雨”的情况并不多见。
那么把这样的实践拿到生活中来是不是有效呢我在这里给了一张2019年1月上海的天气情况的表格。
<img src="https://static001.geekbang.org/resource/image/2f/d8/2f83d82e417f1d37cb9ddb253a0b6cd8.png" alt="">
我们用前一天的是不是下雨直接来预测后一天会不会下雨。这个表格里一共有31天那我们就可以预测30次。你可以数一数按照这种预测方式我们可以预测正确23次正确率是76.7%比随机预测的50%要好上不少。
而同样的策略,我们一样可以放在分支预测上。这种策略,我们叫**一级分支预测**One Level Branch Prediction或者叫**1比特饱和计数**1-bit saturating counter。这个方法其实就是用一个比特去记录当前分支的比较情况直接用当前分支的比较情况来预测下一次分支时候的比较情况。
只用一天下雨,就预测第二天下雨,这个方法还是有些“草率”,我们可以用更多的信息,而不只是一次的分支信息来进行预测。于是,我们可以引入一个**状态机**State Machine来做这个事情。
如果连续发生下雨的情况,我们就认为更有可能下雨。之后如果只有一天放晴了,我们仍然认为会下雨。在连续下雨之后,要连续两天放晴,我们才会认为之后会放晴。整个状态机的流转,可以参考我在文稿里放的图。
<img src="https://static001.geekbang.org/resource/image/ea/5d/ea82f279b48c10ad95027c91ed62ab5d.jpeg" alt="">
这个状态机里我们一共有4个状态所以我们需要2个比特来记录对应的状态。这样这整个策略就可以叫作**2比特饱和计数**,或者叫**双模态预测器**Bimodal Predictor
好了现在你可以用这个策略再去对照一下上面的天气情况。如果天气的初始状态我们放在“多半放晴”的状态下我们预测的结果的正确率会是22次也就是73.3%的正确率。可以看到,并不是更复杂的算法,效果一定就更好。实际的预测效果,和实际执行的指令高度相关。
如果想对各种分支预测技术有所了解,[Wikipedia](https://en.wikipedia.org/wiki/Branch_predictor)里面有更详细的内容和更多的分支预测算法,你可以看看。
## 为什么循环嵌套的改变会影响性能?
说完了分支预测现在我们先来看一个Java程序。
```
public class BranchPrediction {
public static void main(String args[]) {
long start = System.currentTimeMillis();
for (int i = 0; i &lt; 100; i++) {
for (int j = 0; j &lt;1000; j ++) {
for (int k = 0; k &lt; 10000; k++) {
}
}
}
long end = System.currentTimeMillis();
System.out.println(&quot;Time spent is &quot; + (end - start));
start = System.currentTimeMillis();
for (int i = 0; i &lt; 10000; i++) {
for (int j = 0; j &lt;1000; j ++) {
for (int k = 0; k &lt; 100; k++) {
}
}
}
end = System.currentTimeMillis();
System.out.println(&quot;Time spent is &quot; + (end - start) + &quot;ms&quot;);
}
}
```
这是一个简单的三重循环里面没有任何逻辑代码。我们用两种不同的循环顺序各跑一次。第一次最外重循环循环了100次第二重循环1000次最内层的循环了10000次。第二次我们把顺序倒过来最外重循环10000次第二重还是1000次最内层100次。
事实上,这段代码在这个专栏一开始的几讲里面,就有同学来提问,想要弄明白这里面的关窍。
你可以先猜一猜,这样两次运行,花费的时间是一样的么?结果应该会让你大吃一惊。我们可以看看对应的命令行输出。
```
Time spent in first loop is 5ms
Time spent in second loop is 15ms
```
同样循环了十亿次第一段程序只花了5毫秒而第二段程序则花了15毫秒足足多了2倍。
这个差异就来自我们上面说的分支预测。我们在前面讲过循环其实也是利用cmp和jle这样先比较后跳转的指令来实现的。如果对for循环的汇编代码或者机器代码的实现不太清楚你可以回头去复习一下第6讲。
这里的代码每一次循环都有一个cmp和jle指令。每一个 jle 就意味着,要比较条件码寄存器的状态,决定是顺序执行代码,还是要跳转到另外一个地址。也就是说,在每一次循环发生的时候,都会有一次“分支”。
<img src="https://static001.geekbang.org/resource/image/69/a5/69c0cb32d5b7139e0f993855104e55a5.jpeg" alt="">
分支预测策略最简单的一个方式,自然是“**假定分支不发生**”。对应到上面的循环代码,就是循环始终会进行下去。在这样的情况下,上面的第一段循环,也就是内层 k 循环10000次的代码。每隔10000次才会发生一次预测上的错误。而这样的错误在第二层 j 的循环发生的次数是1000次。
最外层的 i 的循环是100次。每个外层循环一次里面都会发生1000次最内层 k 的循环的预测错误,所以一共会发生 100 × 1000 = 10万次预测错误。
上面的第二段循环也就是内存k的循环100次的代码则是每100次循环就会发生一次预测错误。这样的错误在第二层j的循环发生的次数还是1000次。最外层 i 的循环是10000次所以一共会发生 1000 × 10000 = 1000万次预测错误。
到这里,相信你能猜到为什么同样空转次数相同的循环代码,第一段代码运行的时间要少得多了。因为第一段代码发生“分支预测”错误的情况比较少,更多的计算机指令,在流水线里顺序运行下去了,而不需要把运行到一半的指令丢弃掉,再去重新加载新的指令执行。
## 总结延伸
好了,这一讲,我给你讲解了什么是控制冒险,以及应对控制冒险的三个方式。
第一种方案类似我们的操作数前推其实是在改造我们的CPU功能通过增加对应的电路的方式来缩短分支带来的延迟。另外两种解决方案无论是“假装分支不发生”还是“动态分支预测”其实都是在进行“分支预测”。只是“假装分支不发生”是一种简单的静态预测方案而已。
在动态分支预测技术里我给你介绍了一级分支预测或者叫1比特饱和计数的方法。其实就是认为预测结果和上一次的条件跳转是一致的。在此基础上我还介绍了利用更多信息的就是2比特饱和计数或者叫双模态预测器的方法。这个方法其实也只是通过一个状态机多看了一步过去的跳转比较结果。
这个方法虽然简单,但是却非常有效。在 SPEC 89 版本的测试当中使用这样的饱和计数方法预测的准确率能够高达93.5%。Intel的CPU一直到Pentium时代在还没有使用MMX指令集的时候用的就是这种分支预测方式。
这一讲的最后,我给你看了一个有意思的例子。通过交换内外循环的顺序,我们体验了一把控制冒险导致的性能差异。虽然执行的指令数是一样的,但是分支预测失败得多的程序,性能就要差上几倍。
## 推荐阅读
想要进一步了解控制冒险和分支预测技术,可以去读一读《计算机组成与设计:硬件/软件接口》的4.8章节。
如果想对各种分支预测技术有所了解,[Wikipedia](https://en.wikipedia.org/wiki/Branch_predictor)里面有更详细的内容和更多的分支预测算法。
## 课后思考
我在上面用一个三重循环的Java程序验证了“分支预测”出错会对程序带来的性能影响。你可以用你自己惯用的语言来试一试看一看是否会有同样的效果。如果没有的话原因是什么呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,109 @@
<audio id="audio" title="26 | Superscalar和VLIW如何让CPU的吞吐率超过1" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c4/00/c405ea4f9d4db9086eadac7b74e5c000.mp3"></audio>
到今天为止专栏已经过半了。过去的20多讲里我给你讲的内容很多都是围绕着怎么提升CPU的性能这个问题展开的。
我们先回顾一下[第4讲](https://time.geekbang.org/column/article/93246),不知道你是否还记得这个公式:
这个公式里有一个叫CPI的指标。我们知道CPI的倒数又叫作IPCInstruction Per Clock也就是一个时钟周期里面能够执行的指令数代表了CPU的吞吐率。那么这个指标放在我们前面几节反复优化流水线架构的CPU里能达到多少呢
答案是最佳情况下IPC也只能到1。因为无论做了哪些流水线层面的优化即使做到了指令执行层面的乱序执行CPU仍然只能在一个时钟周期里面取一条指令。
<img src="https://static001.geekbang.org/resource/image/dd/13/dd88d0dbf3a88b09d5e8fb6d9e3aea13.jpeg" alt="">
这说明无论指令后续能优化得多好一个时钟周期也只能执行完这样一条指令CPI只能是1。但是我们现在用的Intel CPU或者ARM的CPU一般的CPI都能做到2以上这是怎么做到的呢
今天我们就一起来看看现代CPU都使用了什么“黑科技”。
## 多发射与超标量:同一时间执行的两条指令
之前讲CPU的硬件组成的时候我们把所有算术和逻辑运算都抽象出来变成了一个ALU这样的“黑盒子”。你应该还记得第13讲到第16讲关于加法器、乘法器、乃至浮点数计算的部分其实整数的计算和浮点数的计算过程差异还是不小的。实际上整数和浮点数计算的电路在CPU层面也是分开的。
一直到80386我们的CPU都是没有专门的浮点数计算的电路的。当时的浮点数计算都是用软件进行模拟的。所以在80386时代Intel给386配了单独的387芯片专门用来做浮点数运算。那个时候你买386芯片的话会有386sx和386dx这两种芯片可以选择。386dx就是带了387浮点数计算芯片的而sx就是不带浮点数计算芯片的。
其实我们现在用的Intel CPU芯片也是一样的。虽然浮点数计算已经变成CPU里的一部分但并不是所有计算功能都在一个ALU里面真实的情况是我们会有多个ALU。这也是为什么在[第24讲](https://time.geekbang.org/column/article/101436)讲乱序执行的时候你会看到其实指令的执行阶段是由很多个功能单元FU并行Parallel进行的。
不过在指令乱序执行的过程中我们的取指令IF和指令译码ID部分并不是并行进行的。
既然指令的执行层面可以并行进行,为什么取指令和指令译码不行呢?如果想要实现并行,该怎么办呢?
其实只要我们把取指令和指令译码也一样通过增加硬件的方式并行进行就好了。我们可以一次性从内存里面取出多条指令然后分发给多个并行的指令译码器进行译码然后对应交给不同的功能单元去处理。这样我们在一个时钟周期里能够完成的指令就不只一条了。IPC也就能做到大于1了。
<img src="https://static001.geekbang.org/resource/image/85/32/85f15ec667d09fd2d368822904029b32.jpeg" alt="">
这种CPU设计我们叫作**多发射**Mulitple Issue和**超标量**Superscalar
什么叫多发射呢这个词听起来很抽象其实它意思就是说我们同一个时间可能会同时把多条指令发射Issue到不同的译码器或者后续处理的流水线中去。
在超标量的CPU里面有很多条并行的流水线而不是只有一条流水线。“超标量“这个词是说本来我们在一个时钟周期里面只能执行一个标量Scalar的运算。在多发射的情况下我们就能够超越这个限制同时进行多次计算。
<img src="https://static001.geekbang.org/resource/image/2e/d3/2e96fe0985a4ae3bd7a58c345def29d3.jpeg" alt="">
你可以看我画的这个超标量设计的流水线示意图。仔细看你应该能看到一个有意思的现象每一个功能单元的流水线的长度是不同的。事实上不同的功能单元的流水线长度本来就不一样。我们平时所说的14级流水线指的通常是进行整数计算指令的流水线长度。如果是浮点数运算实际的流水线长度则会更长一些。
## Intel的失败之作安腾的超长指令字设计
无论是之前几讲里讲的乱序执行还是现在更进一步的超标量技术在实际的硬件层面其实实施起来都挺麻烦的。这是因为在乱序执行和超标量的体系里面我们的CPU要解决依赖冲突的问题。这也就是前面几讲我们讲的冒险问题。
CPU需要在指令执行之前去判断指令之间是否有依赖关系。如果有对应的依赖关系指令就不能分发到执行阶段。因为这样上面我们所说的超标量CPU的多发射功能又被称为**动态多发射处理器**。这些对于依赖关系的检测都会使得我们的CPU电路变得更加复杂。
于是,计算机科学家和工程师们就又有了一个大胆的想法。我们能不能不把分析和解决依赖关系的事情,放在硬件里面,而是放到软件里面来干呢?
如果你还记得的话我在第4讲也讲过要想优化CPU的执行时间关键就是拆解这个公式
当时我们说过这个公式里面我们可以通过改进编译器来优化指令数这个指标。那接下来我们就来看看一个非常大胆的CPU设计想法叫作**超长指令字设计**Very Long Instruction WordVLIW。这个设计呢不仅想让编译器来优化指令数还想直接通过编译器来优化CPI。
围绕着这个设计的是Intel一个著名的“史诗级”失败也就是著名的IA-64架构的安腾Itanium处理器。只不过这一次责任不全在Intel还要拉上可以称之为硅谷起源的另一家公司也就是惠普。
之所以称为“史诗”级失败,这个说法来源于惠普最早给这个架构取的名字,**显式并发指令运算**Explicitly Parallel Instruction Computer这个名字的缩写**EPIC**,正好是“史诗”的意思。
好巧不巧安腾处理器和和我之前给你介绍过的Pentium 4一样在市场上是一个失败的产品。在经历了12年之久的设计研发之后安腾一代只卖出了几千套。而安腾二代在从2002年开始反复挣扎了16年之后最终在2018年被Intel宣告放弃退出了市场。自此世上再也没有这个“史诗”服务器了。
那么,我们就来看看,这个超长指令字的安腾处理器是怎么回事儿。
在乱序执行和超标量的CPU架构里指令的前后依赖关系是由CPU内部的硬件电路来检测的。而到了**超长指令字**的架构里面,这个工作交给了编译器这个软件。
<img src="https://static001.geekbang.org/resource/image/22/de/22b3f723ceee5950ac20a7b874dabbde.jpeg" alt="">
我从专栏第5讲开始就给你看了不少C代码到汇编代码和机器代码的对照。编译器在这个过程中其实也能够知道前后数据的依赖。于是我们可以让编译器把没有依赖关系的代码位置进行交换。然后再把多条连续的指令打包成一个指令包。安腾的CPU就是把3条指令变成一个指令包。
<img src="https://static001.geekbang.org/resource/image/f1/f6/f16a1ae443418caca0dc2fc3cec200f6.jpeg" alt="">
CPU在运行的时候不再是取一条指令而是取出一个指令包。然后译码解析整个指令包解析出3条指令直接并行运行。可以看到使用**超长指令字**架构的CPU同样是采用流水线架构的。也就是说一组Group指令仍然要经历多个时钟周期。同样的下一组指令并不是等上一组指令执行完成之后再执行而是在上一组指令的指令译码阶段就开始取指令了。
值得注意的一点是,流水线停顿这件事情在**超长指令字**里面,很多时候也是由编译器来做的。除了停下整个处理器流水线,**超长指令字**的CPU不能在某个时钟周期停顿一下等待前面依赖的操作执行完成。编译器需要在适当的位置插入NOP操作直接在编译出来的机器码里面就把流水线停顿这个事情在软件层面就安排妥当。
虽然安腾的设想很美好Intel也曾经希望能够让安腾架构成为替代x86的新一代架构但是最终安腾还是在前前后后折腾将近30年后失败了。2018年Intel宣告安腾9500会在2021年停止供货。
安腾失败的原因有很多,其中有一个重要的原因就是“向前兼容”。
一方面安腾处理器的指令集和x86是不同的。这就意味着原来x86上的所有程序是没有办法在安腾上运行的而需要通过编译器重新编译才行。
另一方面安腾处理器的VLIW架构决定了如果安腾需要提升并行度就需要增加一个指令包里包含的指令数量比方说从3个变成6个。一旦这么做了虽然同样是VLIW架构同样指令集的安腾CPU程序也需要重新编译。因为原来编译器判断的依赖关系是在3个指令以及由3个指令组成的指令包之间现在要变成6个指令和6个指令组成的指令包。编译器需要重新编译交换指令顺序以及NOP操作才能满足条件。甚至我们需要重新来写编译器才能让程序在新的CPU上跑起来。
于是安腾就变成了一个既不容易向前兼容又不容易向后兼容的CPU。那么它的失败也就不足为奇了。
可以看到技术思路上的先进想法在实际的业界应用上会遇到更多具体的实践考验。无论是指令集向前兼容性还是对应CPU未来的扩展在设计的时候都需要更多地去考虑实践因素。
## 总结延伸
这一讲里我和你一起向CPU的性能发起了一个新的挑战让CPU的吞吐率也就是IPC能够超过1。
我先是为你介绍了超标量也就是Superscalar这个方法。超标量可以让CPU不仅在指令执行阶段是并行的在取指令和指令译码的时候也是并行的。通过超标量技术可以使得你所使用的CPU的IPC超过1。
在Intel的x86的CPU里从Pentium时代第一次开始引入超标量技术整个CPU的性能上了一个台阶。对应的技术一直沿用到了现在。超标量技术和你之前看到的其他流水线技术一样依赖于在硬件层面能够检测到对应的指令的先后依赖关系解决“冒险”问题。所以它也使得CPU的电路变得更复杂了。
因为这些复杂性惠普和Intel又共同推出了著名的安腾处理器。通过在编译器层面直接分析出指令的前后依赖关系。于是硬件在代码编译之后就可以直接拿到调换好先后顺序的指令。并且这些指令中可以并行执行的部分会打包在一起组成一个指令包。安腾处理器在取指令和指令译码的时候拿到的不再是单个指令而是这样一个指令包。并且在指令执行阶段可以并行执行指令包里所有的指令。
虽然看起来VLIW在技术层面更具有颠覆性不仅仅只是一个硬件层面的改造而且利用了软件层面的编译器来组合解决提升CPU指令吞吐率的问题。然而最终VLIW却没有得到市场和业界的认可。
惠普和Intel强强联合开发的安腾处理器命运多舛。从1989开始研发直到2001年才发布了第一代安腾处理器。然而12年的开发过程后第一代安腾处理器最终只卖出了几千套。而2002年发布的安腾2处理器也没能拯救自己的命运。最终在2018年Intel宣布安腾退出市场。自此之后市面上再没有能够大规模商用的VLIW架构的处理器了。
## 推荐阅读
关于超标量和多发射的相关知识,你可以多看一看《计算机组成与设计:硬件/软件接口》的4.10部分。其中4.10.1和4.10.2的推测和静态多发射其实就是今天我们讲的超长指令字VLIW的知识点。4.10.2的动态多发射其实就是今天我们讲的超标量Superscalar的知识点。
## 课后思考
在超长指令字架构的CPU里面我之前给你讲到的各种应对流水线冒险的方案还是有效的么操作数前推、乱序执行分支预测能用在这样的体系架构下么安腾CPU里面是否有用到这些相关策略呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,105 @@
<audio id="audio" title="27 | SIMD如何加速矩阵乘法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/50/4d/50510dc79e2000f84bf57d941b75bc4d.mp3"></audio>
上一讲里呢我进一步为你讲解了CPU里的“黑科技”分别是超标量Superscalar技术和超长指令字VLIW技术。
超标量Superscalar技术能够让取指令以及指令译码也并行进行在编译的过程超长指令字VLIW技术可以搞定指令先后的依赖关系使得一次可以取一个指令包。
不过CPU里的各种神奇的优化我们还远远没有说完。这一讲里我就带你一起来看看专栏里最后两个提升CPU性能的架构设计。它们分别是你应该常常听说过的**超线程**Hyper-Threading技术以及可能没有那么熟悉的**单指令多数据流**SIMD技术。
## 超线程Intel多卖给你的那一倍CPU
不知道你是不是还记得,在[第21讲](https://time.geekbang.org/column/article/100554)我给你介绍了Intel是怎么在Pentium 4处理器上遭遇重大失败的。如果不太记得的话你可以回过头去回顾一下。
那时我和你说过Pentium 4失败的一个重要原因就是它的CPU的流水线级数太深了。早期的Pentium 4的流水线深度高达20级而后期的代号为Prescott的Pentium 4的流水线级数更是到了31级。超长的流水线使得之前我们讲的很多解决“冒险”、提升并发的方案都用不上。
因为这些解决“冒险”、提升并发的方案,本质上都是一种**指令级并行**Instruction-level parallelism简称IPL的技术方案。换句话说就是CPU想要在同一个时间去并行地执行两条指令。而这两条指令呢原本在我们的代码里是有先后顺序的。无论是我们在流水线里面讲到的流水线架构、分支预测以及乱序执行还是我们在上一讲说的超标量和超长指令字都是想要通过同一时间执行两条指令来提升CPU的吞吐率。
然而在Pentium 4这个CPU上这些方法都可能因为流水线太深而起不到效果。我之前讲过更深的流水线意味着同时在流水线里面的指令就多相互的依赖关系就多。于是很多时候我们不得不把流水线停顿下来插入很多NOP操作来解决这些依赖带来的“冒险”问题。
不知道是不是因为当时面临的竞争太激烈了为了让Pentium 4的CPU在性能上更有竞争力一点2002年底Intel在的3.06GHz主频的Pentium 4 CPU上第一次引入了**超线程**Hyper-Threading技术。
什么是超线程技术呢Intel想既然CPU同时运行那些在代码层面有前后依赖关系的指令会遇到各种冒险问题我们不如去找一些和这些指令完全独立没有依赖关系的指令来运行好了。那么这样的指令哪里来呢自然同时运行在另外一个程序里了。
你所用的计算机其实同一个时间可以运行很多个程序。比如我现在一边在浏览器里写这篇文章后台同样运行着一个Python脚本程序。而这两个程序是完全相互独立的。它们两个的指令完全并行运行而不会产生依赖问题带来的“冒险”。
然而这个时候你可能就会觉得奇怪了这么做似乎不需要什么新技术呀。现在我们用的CPU都是多核的本来就可以用多个不同的CPU核心去运行不同的任务。即使当时的Pentium 4是单核的我们的计算机本来也能同时运行多个进程或者多个线程。这个超线程技术有什么特别的用处呢
无论是上面说的多个CPU核心运行不同的程序还是在单个CPU核心里面切换运行不同线程的任务在同一时间点上一个物理的CPU核心只会运行一个线程的指令所以其实我们并没有真正地做到指令的并行运行。
<img src="https://static001.geekbang.org/resource/image/96/c8/96aa1220ff27776f55091c55c2eddbc8.jpeg" alt="">
超线程可不是这样。超线程的CPU其实是把一个物理层面CPU核心“伪装”成两个逻辑层面的CPU核心。这个CPU会在硬件层面增加很多电路使得我们可以在一个CPU核心内部维护两个不同线程的指令的状态信息。
比如在一个物理CPU核心内部会有双份的PC寄存器、指令寄存器乃至条件码寄存器。这样这个CPU核心就可以维护两条并行的指令的状态。在外面看起来似乎有两个逻辑层面的CPU在同时运行。所以超线程技术一般也被叫作**同时多线程**Simultaneous Multi-Threading简称SMT技术**。**
不过在CPU的其他功能组件上Intel可不会提供双份。无论是指令译码器还是ALU一个CPU核心仍然只有一份。因为超线程并不是真的去同时运行两个指令那就真的变成物理多核了。超线程的目的是在一个线程A的指令在流水线里停顿的时候让另外一个线程去执行指令。因为这个时候CPU的译码器和ALU就空出来了那么另外一个线程B就可以拿来干自己需要的事情。这个线程B可没有对于线程A里面指令的关联和依赖。
这样CPU通过很小的代价就能实现“同时”运行多个线程的效果。通常我们只要在CPU核心的添加10%左右的逻辑功能,增加可以忽略不计的晶体管数量,就能做到这一点。
不过,你也看到了,我们并没有增加真的功能单元。所以超线程只在特定的应用场景下效果比较好。一般是在那些各个线程“等待”时间比较长的应用场景下。比如,我们需要应对很多请求的数据库应用,就很适合使用超线程。各个指令都要等待访问内存数据,但是并不需要做太多计算。
于是我们就可以利用好超线程。我们的CPU计算并没有跑满但是往往当前的指令要停顿在流水线上等待内存里面的数据返回。这个时候让CPU里的各个功能单元去处理另外一个数据库连接的查询请求就是一个很好的应用案例。
<img src="https://static001.geekbang.org/resource/image/75/e5/759b55d8acdc6f69d0b711f4f62ad4e5.png" alt="">
我这里放了一张我的电脑里运行CPU-Z的截图。你可以看到在右下角里我的CPU的Cores被标明了是4而Threads则是8。这说明我手头的这个CPU只有4个物理的CPU核心也就是所谓的4核CPU。但是在逻辑层面它“装作”有8个CPU核心可以利用超线程技术来同时运行8条指令。如果你用的是Windows可以去下载安装一个[CPU-Z](https://www.cpuid.com/softwares/cpu-z.html)来看看你手头的CPU里面对应的参数。
## SIMD如何加速矩阵乘法
在上面的CPU信息的图里面你会看到中间有一组信息叫作Instructions里面写了有MMX、SSE等等。这些信息就是这个CPU所支持的指令集。这里的MMX和SSE的指令集也就引出了我要给你讲的最后一个提升CPU性能的技术方案**SIMD**,中文叫作**单指令多数据流**Single Instruction Multiple Data
我们先来体会一下SIMD的性能到底怎么样。下面是两段示例程序一段呢是通过循环的方式给一个list里面的每一个数加1。另一段呢是实现相同的功能但是直接调用NumPy这个库的add方法。在统计两段程序的性能的时候我直接调用了Python里面的timeit的库。
```
$ python
&gt;&gt;&gt; import numpy as np
&gt;&gt;&gt; import timeit
&gt;&gt;&gt; a = list(range(1000))
&gt;&gt;&gt; b = np.array(range(1000))
&gt;&gt;&gt; timeit.timeit(&quot;[i + 1 for i in a]&quot;, setup=&quot;from __main__ import a&quot;, number=1000000)
32.82800309999993
&gt;&gt;&gt; timeit.timeit(&quot;np.add(1, b)&quot;, setup=&quot;from __main__ import np, b&quot;, number=1000000)
0.9787889999997788
&gt;&gt;&gt;
```
从两段程序的输出结果来看你会发现两个功能相同的代码性能有着巨大的差异足足差出了30多倍。也难怪所有用Python讲解数据科学的教程里往往在一开始就告诉你不要使用循环而要把所有的计算都向量化Vectorize
有些同学可能会猜测是不是因为Python是一门解释性的语言所以这个性能差异会那么大。第一段程序的循环的每一次操作都需要Python解释器来执行而第二段的函数调用是一次调用编译好的原生代码所以才会那么快。如果你这么想不妨试试直接用C语言实现一下1000个元素的数组里面的每个数加1。你会发现即使是C语言编译出来的代码还是远远低于NumPy。原因就是NumPy直接用到了SIMD指令能够并行进行向量的操作。
而前面使用循环来一步一步计算的算法呢,一般被称为**SISD**,也就是**单指令单数据**Single Instruction Single Data的处理方式。如果你手头的是一个多核CPU呢那么它同时处理多个指令的方式可以叫作**MIMD**,也就是**多指令多数据**Multiple Instruction Multiple Dataa
为什么SIMD指令能快那么多呢这是因为SIMD在获取数据和执行指令的时候都做到了并行。一方面在从内存里面读取数据的时候SIMD是一次性读取多个数据。
就以我们上面的程序为例数组里面的每一项都是一个integer也就是需要 4 Bytes的内存空间。Intel在引入SSE指令集的时候在CPU里面添上了8个 128 Bits的寄存器。128 Bits也就是 16 Bytes ,也就是说,一个寄存器一次性可以加载 4 个整数。比起循环分别读取4次对应的数据时间就省下来了。
<img src="https://static001.geekbang.org/resource/image/48/a6/48ddcd5ac345091c1be5963d5ef7d7a6.jpeg" alt="">
在数据读取到了之后在指令的执行层面SIMD也是可以并行进行的。4个整数各自加1互相之前完全没有依赖也就没有冒险问题需要处理。只要CPU里有足够多的功能单元能够同时进行这些计算这个加法就是4路同时并行的自然也省下了时间。
所以对于那些在计算层面存在大量“数据并行”Data Parallelism的计算中使用SIMD是一个很划算的办法。在这个大量的“数据并行”其实通常就是实践当中的向量运算或者矩阵运算。在实际的程序开发过程中过去通常是在进行图片、视频、音频的处理。最近几年则通常是在进行各种机器学习算法的计算。
而基于SIMD的向量计算指令也正是在Intel发布Pentium处理器的时候被引入的指令集。当时的指令集叫作**MMX**也就是Matrix Math eXtensions的缩写中文名字就是**矩阵数学扩展**。而Pentium处理器也是CPU第一次有能力进行多媒体处理。这也正是拜SIMD和MMX所赐。
从Pentium时代开始我们能在电脑上听MP3、看VCD了而不用专门去买一块“声霸卡”或者“显霸卡”了。没错在那之前在电脑上看VCD是需要专门买能够解码VCD的硬件插到电脑上去的。而到了今天通过GPU快速发展起来的深度学习技术也一样受益于SIMD这样的指令级并行方案在后面讲解GPU的时候我们还会遇到它。
## 总结延伸
这一讲我们讲完了超线程和SIMD这两个CPU的“并行计算”方案。超线程其实是一个“线程级并行”的解决方案。它通过让一个物理CPU核心“装作”两个逻辑层面的CPU核心使得CPU可以同时运行两个不同线程的指令。虽然这样的运行仍然有着种种的限制很多场景下超线程并不一定能带来CPU的性能提升。但是Intel通过超线程让使用者有了“占到便宜”的感觉。同样的4核心的CPU在有些情况下能够发挥出8核心CPU的作用。而超线程在今天也已经成为Intel CPU的标配了。
而SIMD技术则是一种“指令级并行”的加速方案或者我们可以说它是一种“数据并行”的加速方案。在处理向量计算的情况下同一个向量的不同维度之间的计算是相互独立的。而我们的CPU里的寄存器又能放得下多条数据。于是我们可以一次性取出多条数据交给CPU并行计算。
正是SIMD技术的出现使得我们在Pentium时代的个人PC开始有了多媒体运算的能力。可以说Intel的MMX、SSE指令集和微软的Windows 95这样的图形界面操作系统推动了PC快速进入家庭的历史进程。
## 推荐阅读
如果你想看一看Intel CPU里面的SIMD指令具体长什么样可以去读一读《计算机组成与设计硬件/软件接口》的3.7章节。
## 课后思考
最后,给你留一道思考题。超线程这样的技术,在什么样的应用场景下最高效?你在自己开发系统的过程中,是否遇到超线程技术为程序带来性能提升的情况呢?
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,111 @@
<audio id="audio" title="28 | 异常和中断:程序出错了怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/cb/939f707f0c9e474f33ce053330a55ecb.mp3"></audio>
过去这么多讲,我们的程序都是自动运行且正常运行的。自动运行的意思是说,我们的程序和指令都是一条条顺序执行,你不需要通过键盘或者网络给这个程序任何输入。正常运行是说,我们的程序都是能够正常执行下去的,没有遇到计算溢出之类的程序错误。
不过现实的软件世界可没有这么简单。一方面程序不仅是简单的执行指令更多的还需要和外部的输入输出打交道。另一方面程序在执行过程中还会遇到各种异常情况比如除以0、溢出甚至我们自己也可以让程序抛出异常。
那这一讲,我就带你来看看,如果遇到这些情况,计算机是怎么运转的,也就是说,计算机究竟是如何处理异常的。
## 异常:硬件、系统和应用的组合拳
一提到计算机当中的**异常**Exception可能你的第一反应就是C++或者Java中的Exception。不过我们今天讲的并不是这些软件开发过程中遇到的“软件异常”而是和硬件、系统相关的“硬件异常”。
当然“软件异常”和“硬件异常”并不是实际业界使用的专有名词只是我为了方便给你说明和C++、Java中软件抛出的Exception进行的人为区分你明白这个意思就好。
尽管,这里我把这些硬件和系统相关的异常,叫作“硬件异常”。但是,实际上,这些异常,既有来自硬件的,也有来自软件层面的。
比如我们在硬件层面当加法器进行两个数相加的时候会遇到算术溢出或者你在玩游戏的时候按下键盘发送了一个信号给到CPUCPU要去执行一个现有流程之外的指令这也是一个“异常”。
同样,来自软件层面的,比如我们的程序进行系统调用,发起一个读文件的请求。这样应用程序向系统调用发起请求的情况,一样是通过“异常”来实现的。
**关于异常,最有意思的一点就是,它其实是一个硬件和软件组合到一起的处理过程。异常的前半生,也就是异常的发生和捕捉,是在硬件层面完成的。但是异常的后半生,也就是说,异常的处理,其实是由软件来完成的。**
计算机会为每一种可能会发生的异常分配一个异常代码Exception Number。有些教科书会把异常代码叫作中断向量Interrupt Vector。异常发生的时候通常是CPU检测到了一个特殊的信号。比如你按下键盘上的按键输入设备就会给CPU发一个信号。或者正在执行的指令发生了加法溢出同样我们可以有一个进位溢出的信号。这些信号呢在组成原理里面我们一般叫作发生了一个事件Event。CPU在检测到事件的时候其实也就拿到了对应的异常代码。
**这些异常代码里I/O发出的信号的异常代码是由操作系统来分配的也就是由软件来设定的。而像加法溢出这样的异常代码则是由CPU预先分配好的也就是由硬件来分配的。这又是另一个软件和硬件共同组合来处理异常的过程。**
拿到异常代码之后CPU就会触发异常处理的流程。计算机在内存里会保留一个异常表Exception Table。也有地方把这个表叫作中断向量表Interrupt Vector Table好和上面的中断向量对应起来。这个异常表有点儿像我们在[第10讲](https://time.geekbang.org/column/article/95244)里讲的GOT表存放的是不同的异常代码对应的异常处理程序Exception Handler所在的地址。
我们的CPU在拿到了异常码之后会先把当前的程序执行的现场保存到程序栈里面然后根据异常码查询找到对应的异常处理程序最后把后续指令执行的指挥权交给这个异常处理程序。
<img src="https://static001.geekbang.org/resource/image/e8/d6/e8a49f09d1bb50e4d42fccd14d743ad6.jpeg" alt="">
这样“检测异常,拿到异常码,再根据异常码进行查表处理”的模式,在日常开发的过程中是很常见的。
<img src="https://static001.geekbang.org/resource/image/27/f7/272b21cc50572c208b4db4b8ef8276f7.jpeg" alt="">
比如说现在我们日常进行的Web或者App开发通常都是前后端分离的。前端的应用会向后端发起HTTP的请求。当后端遇到了异常通常会给到前端一个对应的错误代码。前端的应用根据这个错误代码在应用层面去进行错误处理。在不能处理的时候它会根据错误代码向用户显示错误信息。
```
public class LastChanceHandler implements Thread.UncaughtExceptionHandler {
@Override
public void uncaughtException(Thread t, Throwable e) {
// do something here - log to file and upload to server/close resources/delete files...
}
}
Thread.setDefaultUncaughtExceptionHandler(new LastChanceHandler());
```
再比如说Java里面我们使用一个线程池去运行调度任务的时候可以指定一个异常处理程序。对于各个线程在执行任务出现的异常情况我们是通过异常处理程序进行处理而不是在实际的任务代码里处理。这样我们就把业务处理代码就和异常处理代码的流程分开了。
## 异常的分类:中断、陷阱、故障和中止
我在前面说了,异常可以由硬件触发,也可以由软件触发。那我们平时会碰到哪些异常呢?下面我们就一起来看看。
第一种异常叫**中断**Interrupt。顾名思义自然就是程序在执行到一半的时候被打断了。这个打断执行的信号来自于CPU外部的I/O设备。你在键盘上按下一个按键就会对应触发一个相应的信号到达CPU里面。CPU里面某个开关的值发生了变化也就触发了一个中断类型的异常。
第二种异常叫**陷阱**Trap。陷阱其实是我们程序员“故意“主动触发的异常。就好像你在程序里面打了一个断点这个断点就是设下的一个"陷阱"。当程序的指令执行到这个位置的时候,就掉到了这个陷阱当中。然后,对应的异常处理程序就会来处理这个"陷阱"当中的猎物。
最常见的一类陷阱,发生在我们的应用程序调用系统调用的时候,也就是从程序的用户态切换到内核态的时候。我们在[第3讲](https://time.geekbang.org/column/article/92215)讲CPU性能的时候说过可以用Linux下的time指令去查看一个程序运行实际花费的时间里面有在用户态花费的时间user time也有在内核态发生的时间system time
我们的应用程序通过系统调用去读取文件、创建进程,其实也是通过触发一次陷阱来进行的。这是因为,我们用户态的应用程序没有权限来做这些事情,需要把对应的流程转交给有权限的异常处理程序来进行。
第三种异常叫**故障**Fault。它和陷阱的区别在于陷阱是我们开发程序的时候刻意触发的异常而故障通常不是。比如我们在程序执行的过程中进行加法计算发生了溢出其实就是故障类型的异常。这个异常不是我们在开发的时候计划内的也一样需要有对应的异常处理程序去处理。
故障和陷阱、中断的一个重要区别是,故障在异常程序处理完成之后,仍然回来处理当前的指令,而不是去执行程序中的下一条指令。因为当前的指令因为故障的原因并没有成功执行完成。
最后一种异常叫**中止**Abort。与其说这是一种异常类型不如说这是故障的一种特殊情况。当CPU遇到了故障但是恢复不过来的时候程序就不得不中止了。
<img src="https://static001.geekbang.org/resource/image/da/a8/da0117e669ebd2bd06c19beaf12d0da8.jpeg" alt="">
在这四种异常里,中断异常的信号来自系统外部,而不是在程序自己执行的过程中,所以我们称之为“异步”类型的异常。而陷阱、故障以及中止类型的异常,是在程序执行的过程中发生的,所以我们称之为“同步“类型的异常。
在处理异常的过程当中,无论是异步的中断,还是同步的陷阱和故障,我们都是采用同一套处理流程,也就是上面所说的,“保存现场、异常代码查询、异常处理程序调用“。而中止类型的异常,其实是在故障类型异常的一种特殊情况。当故障发生,但是我们发现没有异常处理程序能够处理这种异常的情况下,程序就不得不进入中止状态,也就是最终会退出当前的程序执行。
## 异常的处理:上下文切换
在实际的异常处理程序执行之前CPU需要去做一次“保存现场”的操作。这个保存现场的操作和我在[第7讲](https://time.geekbang.org/column/article/94427)里讲解函数调用的过程非常相似。
因为切换到异常处理程序的时候,其实就好像是去调用一个异常处理函数。指令的控制权被切换到了另外一个"函数"里面,所以我们自然要把当前正在执行的指令去压栈。这样,我们才能在异常处理程序执行完成之后,重新回到当前的指令继续往下执行。
不过,切换到异常处理程序,比起函数调用,还是要更复杂一些。原因有下面几点。
第一点因为异常情况往往发生在程序正常执行的预期之外比如中断、故障发生的时候。所以除了本来程序压栈要做的事情之外我们还需要把CPU内当前运行程序用到的所有寄存器都放到栈里面。最典型的就是条件码寄存器里面的内容。
第二点,像陷阱这样的异常,涉及程序指令在用户态和内核态之间的切换。对应压栈的时候,对应的数据是压到内核栈里,而不是程序栈里。
第三点,像故障这样的异常,在异常处理程序执行完成之后。从栈里返回出来,继续执行的不是顺序的下一条指令,而是故障发生的当前指令。因为当前指令因为故障没有正常执行成功,必须重新去执行一次。
所以对于异常这样的处理流程不像是顺序执行的指令间的函数调用关系。而是更像两个不同的独立进程之间在CPU层面的切换所以这个过程我们称之为**上下文切换**Context Switch
## 总结延伸
这一讲我给你讲了计算机里的“异常”处理流程。这里的异常可以分成中断、陷阱、故障、中止这样四种情况。这四种异常分别对应着I/O设备的输入、程序主动触发的状态切换、异常情况下的程序出错以及出错之后无可挽回的退出程序。
当CPU遭遇了异常的时候计算机就需要有相应的应对措施。CPU会通过“查表法”来解决这个问题。在硬件层面和操作系统层面各自定义了所有CPU可能会遇到的异常代码并且通过这个异常代码在异常表里面查询相应的异常处理程序。在捕捉异常的时候我们的硬件CPU在进行相应的操作而在处理异常层面则是由作为软件的异常处理程序进行相应的操作。
而在实际处理异常之前计算机需要先去做一个“保留现场”的操作。有了这个操作我们才能在异常处理完成之后重新回到之前执行的指令序列里面来。这个保留现场的操作和我们之前讲解指令的函数调用很像。但是因为“异常”和函数调用有一个很大的不同那就是它的发生时间。函数调用的压栈操作我们在写程序的时候完全能够知道而“异常”发生的时间却很不确定。所以“异常”发生的时候我们称之为发生了一次“上下文切换”Context Switch。这个时候除了普通需要压栈的数据外计算机还需要把所有寄存器信息都存储到栈里面去。
## 推荐阅读
关于异常和中断《深入理解计算机系统》的第8章“异常控制流”部分有非常深入和充分的讲解推荐你认真阅读一下。
## 课后思考
很多教科书和网上的文章,会把中断分成软中断和硬中断。你能用自己的话说一说,什么是软中断,什么是硬中断吗?它们和我们今天说的中断、陷阱、故障以及中止又有什么关系呢?
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,116 @@
<audio id="audio" title="29 | CISC和RISC为什么手机芯片都是ARM" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d2/ba/d2cd1dacdd605eacafb15c91ea84a6ba.mp3"></audio>
我在[第5讲](https://time.geekbang.org/column/article/93359)讲计算机指令的时候给你看过MIPS体系结构计算机的机器指令格式。MIPS的指令都是固定的32位长度如果要用一个打孔卡来表示并不复杂。
<img src="https://static001.geekbang.org/resource/image/b1/bf/b1ade5f8de67b172bf7b4ec9f63589bf.jpeg" alt="">
[第6讲](https://time.geekbang.org/column/article/94075)的时候我带你编译了一些简单的C语言程序看了x86体系结构下的汇编代码。眼尖的话你应该能发现每一条机器码的长度是不一样的。
<img src="https://static001.geekbang.org/resource/image/ad/8a/ad91b005e97959d571bbd2a0fa30b48a.jpeg" alt="">
而CPU的指令集里的机器码是固定长度还是可变长度也就是**复杂指令集**Complex Instruction Set Computing简称CISC和**精简指令集**Reduced Instruction Set Computing简称RISC这两种风格的指令集一个最重要的差别。那今天我们就来看复杂指令集和精简指令集之间的对比、差异以及历史纠葛。
## CISC VS RISC历史的车轮不总是向前的
在计算机历史的早期其实没有什么CISC和RISC之分。或者说所有的CPU其实都是CISC。
虽然冯·诺依曼高屋建瓴地提出了存储程序型计算机的基础架构但是实际的计算机设计和制造还是严格受硬件层面的限制。当时的计算机很慢存储空间也很小。《人月神话》这本软件工程界的名著讲的是花了好几年设计IBM 360这台计算机的经验。IBM 360的最低配置每秒只能运行34500条指令只有8K的内存。为了让计算机能够做尽量多的工作每一个字节乃至每一个比特都特别重要。
所以CPU指令集的设计需要仔细考虑硬件限制。为了性能考虑很多功能都直接通过硬件电路来完成。为了少用内存指令的长度也是可变的。就像算法和数据结构里的[赫夫曼编码Huffman coding](https://en.wikipedia.org/wiki/Huffman_coding)一样,常用的指令要短一些,不常用的指令可以长一些。那个时候的计算机,想要用尽可能少的内存空间,存储尽量多的指令。
不过历史的车轮滚滚向前计算机的性能越来越好存储的空间也越来越大了。到了70年代末RISC开始登上了历史的舞台。当时[UC Berkeley](https://en.wikipedia.org/wiki/University_of_California,_Berkeley)的大卫·帕特森David Patterson教授发现实际在CPU运行的程序里80%的时间都是在使用20%的简单指令。于是他就提出了RISC的理念。自此之后RISC类型的CPU开始快速蓬勃发展。
我经常推荐的课后阅读材料,有不少是来自《计算机组成与设计:硬件/软件接口》和《计算机体系结构量化研究方法》这两本教科书。大卫·帕特森教授正是这两本书的作者。此外他还在2017年获得了图灵奖。
<img src="https://static001.geekbang.org/resource/image/d6/77/d69a1e753fa1523df054573f13516277.jpeg" alt="">
RISC架构的CPU究竟是什么样的呢为什么它能在这么短的时间内受到如此大的追捧
RISC架构的CPU的想法其实非常直观。既然我们80%的时间都在用20%的简单指令那我们能不能只要那20%的简单指令就好了呢?答案当然是可以的。因为指令数量多,计算机科学家们在软硬件两方面都受到了很多挑战。
在硬件层面我们要想支持更多的复杂指令CPU里面的电路就要更复杂设计起来也就更困难。更复杂的电路在散热和功耗层面也会带来更大的挑战。在软件层面支持更多的复杂指令编译器的优化就变得更困难。毕竟面向2000个指令来优化编译器和面向500个指令来优化编译器的困难是完全不同的。
于是在RISC架构里面CPU选择把指令“精简”到20%的简单指令。而原先的复杂指令则通过用简单指令组合起来来实现让软件来实现硬件的功能。这样CPU的整个硬件设计就会变得更简单了在硬件层面提升性能也会变得更容易了。
RISC的CPU里完成指令的电路变得简单了于是也就腾出了更多的空间。这个空间常常被拿来放通用寄存器。因为RISC完成同样的功能执行的指令数量要比CISC多所以如果需要反复从内存里面读取指令或者数据到寄存器里来那么很多时间就会花在访问内存上。于是RISC架构的CPU往往就有更多的通用寄存器。
除了寄存器这样的存储空间RISC的CPU也可以把更多的晶体管用来实现更好的分支预测等相关功能进一步去提升CPU实际的执行效率。
总的来说对于CISC和RISC的对比我们可以一起回到第4讲讲的程序运行时间的公式
CISC的架构其实就是通过优化**指令数**来减少CPU的执行时间。而RISC的架构其实是在优化CPI。因为指令比较简单需要的时钟周期就比较少。
因为RISC降低了CPU硬件的设计和开发难度所以从80年代开始大部分新的CPU都开始采用RISC架构。从IBM的PowerPC到SUN的SPARC都是RISC架构。所有人看到仍然采用CISC架构的Intel CPU都可以批评一句“Complex and messy”。但是为什么无论是在PC上还是服务器上仍然是Intel成为最后的赢家呢
## Intel的进化微指令架构的出现
面对这么多负面评价的Intel自然也不能无动于衷。更何况x86架构的问题并不能说明Intel的工程师不够厉害。事实上在整个CPU设计的领域Intel集中了大量优秀的人才。无论是成功的Pentium时代引入的超标量设计还是失败的Pentium 4时代引入的超线程技术都是异常精巧的工程实现。
而x86架构所面临的种种问题其实都来自于一个最重要的考量那就是指令集的向前兼容性。因为x86在商业上太成功了所以市场上有大量的Intel CPU。而围绕着这些CPU又有大量的操作系统、编译器。这些系统软件只支持x86的指令集就比如著名的Windows 95。而在这些系统软件上又有各种各样的应用软件。
如果Intel要放弃x86的架构和指令集开发一个RISC架构的CPU面临的第一个问题就是所有这些软件都是不兼容的。事实上Intel并非没有尝试过在x86之外另起炉灶这其实就是我在[](https://time.geekbang.org/column/article/102888)[26讲](https://time.geekbang.org/column/article/102888)介绍的安腾处理器。当时Intel想要在CPU进入64位的时代的时候丢掉x86的历史包袱所以推出了全新的IA-64的架构。但是却因为不兼容x86的指令集遭遇了重大的失败。
反而是AMD趁着Intel研发安腾的时候推出了兼容32位x86指令集的64位架构也就是AMD64。如果你现在在Linux下安装各种软件包一定经常会看到像下面这样带有AMD64字样的内容。这是因为x86下的64位的指令集x86-64并不是Intel发明的而是AMD发明的。
```
Get:1 http://archive.ubuntu.com/ubuntu bionic/main amd64 fontconfig amd64 2.12.6-0ubuntu2 [169 kB]
```
花开两朵各表一枝。Intel在开发安腾处理器的同时也在不断借鉴其他RISC处理器的设计思想。既然核心问题是要始终向前兼容x86的指令集那么我们能不能不修改指令集但是让CISC风格的指令集用RISC的形式在CPU里面运行呢
于是从Pentium Pro时代开始Intel就开始在处理器里引入了**微指令**Micro-Instructions/Micro-Ops**架构**。而微指令架构的引入也让CISC和RISC的分界变得模糊了。
<img src="https://static001.geekbang.org/resource/image/3c/76/3c4ceec254e765462b09f393153f4476.jpeg" alt="">
在微指令架构的CPU里面编译器编译出来的机器码和汇编代码并没有发生什么变化。但在指令译码的阶段指令译码器“翻译”出来的不再是某一条CPU指令。译码器会把一条机器码“**翻译**”成好几条“微指令”。这里的一条条微指令就不再是CISC风格的了而是变成了固定长度的RISC风格的了。
这些RISC风格的微指令会被放到一个微指令缓冲区里面然后再从缓冲区里面分发给到后面的超标量并且是乱序执行的流水线架构里面。不过这个流水线架构里面接受的就不是复杂的指令而是精简的指令了。在这个架构里我们的指令译码器相当于变成了设计模式里的一个“适配器”Adaptor。这个适配器填平了CISC和RISC之间的指令差异。
不过凡事有好处就有坏处。这样一个能够把CISC的指令译码成RISC指令的指令译码器比原来的指令译码器要复杂。这也就意味着更复杂的电路和更长的译码时间本来以为可以通过RISC提升的性能结果又有一部分浪费在了指令译码上。针对这个问题我们有没有更好的办法呢
我在前面说过之所以大家认为RISC优于CISC来自于一个数字统计那就是在实际的程序运行过程中有80%运行的代码用着20%的常用指令。这意味着CPU里执行的代码有很强的局部性。而对于有着很强局部性的问题常见的一个解决方案就是使用缓存。
所以Intel就在CPU里面加了一层L0 Cache。这个Cache保存的就是指令译码器把CISC的指令“翻译”成RISC的微指令的结果。于是在大部分情况下CPU都可以从Cache里面拿到译码结果而不需要让译码器去进行实际的译码操作。这样不仅优化了性能因为译码器的晶体管开关动作变少了还减少了功耗。
因为“微指令”架构的存在从Pentium Pro开始Intel处理器已经不是一个纯粹的CISC处理器了。它同样融合了大量RISC类型的处理器设计。不过由于Intel本身在CPU层面做的大量优化比如乱序执行、分支预测等相关工作x86的CPU始终在功耗上还是要远远超过RISC架构的ARM所以最终在智能手机崛起替代PC的时代落在了ARM后面。
## ARM和RISC-VCPU的现在与未来
2017年ARM公司的CEO Simon Segards宣布ARM累积销售的芯片数量超过了1000亿。作为一个从12个人起步在80年代想要获取Intel的80286架构授权来制造CPU的公司ARM是如何在移动端把自己的芯片塑造成了最终的霸主呢
ARM这个名字现在的含义是“Advanced RISC Machines”。你从名字就能够看出来ARM的芯片是基于RISC架构的。不过ARM能够在移动端战胜Intel并不是因为RISC架构。
到了21世纪的今天CISC和RISC架构的分界已经没有那么明显了。Intel和AMD的CPU也都是采用译码成RISC风格的微指令来运行。而ARM的芯片一条指令同样需要多个时钟周期有乱序执行和多发射。我甚至看到过这样的评价“ARM和RISC的关系只有在名字上”。
ARM真正能够战胜Intel我觉得主要是因为下面这两点原因。
第一点是功耗优先的设计。一个4核的Intel i7的CPU设计的时候功率就是130W。而一块ARM A8的单个核心的CPU设计功率只有2W。两者之间差出了100倍。在移动设备上功耗是一个远比性能更重要的指标毕竟我们不能随时在身上带个发电机。ARM的CPU主频更低晶体管更少高速缓存更小乱序执行的能力更弱。所有这些都是为了功耗所做的妥协。
第二点则是低价。ARM并没有自己垄断CPU的生产和制造只是进行CPU设计然后把对应的知识产权授权出去让其他的厂商来生产ARM架构的CPU。它甚至还允许这些厂商可以基于ARM的架构和指令集设计属于自己的CPU。像苹果、三星、华为它们都是拿到了基于ARM体系架构设计和制造CPU的授权。ARM自己只是收取对应的专利授权费用。多个厂商之间的竞争使得ARM的芯片在市场上价格很便宜。所以尽管ARM的芯片的出货量远大于Intel但是收入和利润却比不上Intel。
不过ARM并不是开源的。所以在ARM架构逐渐垄断移动端芯片市场的时候“开源硬件”也慢慢发展起来了。一方面MIPS在2019年宣布开源另一方面从UC Berkeley发起的[RISC-V](https://riscv.org/)项目也越来越受到大家的关注。而RISC概念的发明人图灵奖的得主大卫·帕特森教授从伯克利退休之后成了RISC-V国际开源实验室的负责人开始推动RISC-V这个“CPU届的Linux”的开发。可以想见未来的开源CPU也多半会像Linux一样逐渐成为一个业界的主流选择。如果想要“打造一个属于自己CPU”不可不关注这个项目。
## 总结延伸
这一讲我从RISC和CISC架构之前的差异说起讲到RISC的指令是固定长度的CISC的指令是可变长度的。RISC的指令集里的指令数少而且单个指令只完成简单的功能所以被称为“精简”。CISC里的指令数多为了节约内存直接在硬件层面能够完成复杂的功能所以被称为“复杂”。RISC的通过减少CPI来提升性能而CISC通过减少需要的指令数来提升性能。
然后我们进一步介绍了Intel的x86 CPU的“微指令”的设计思路。“微指令”使得我们在机器码层面保留了CISC风格的x86架构的指令集。但是通过指令译码器和L0缓存的组合使得这些指令可以快速翻译成RISC风格的微指令使得实际执行指令的流水线可以用RISC的架构来搭建。使用“微指令”设计思路的CPU不能再称之为CISC了而更像一个RISC和CISC融合的产物。
过去十年里Intel仍然把持着PC和服务器市场但是更多的市场上的CPU芯片来自基于ARM架构的智能手机了。而在ARM似乎已经垄断了移动CPU市场的时候开源的RISC-V出现了也给了计算机工程师们新的设计属于自己的CPU的机会。
## 推荐阅读
又到了推荐阅读的时间了,这次我们又要一起来读论文了。
想要了解x86和ARM之间的功耗和性能的差异以及这个差异到底从哪里来你可以读一读[《Power Struggles: Revisiting the RISC vs. CISC Debate on Contemporary ARM and x86 Architectures》](https://research.cs.wisc.edu/vertical/papers/2013/hpca13-isa-power-struggles.pdf)这篇论文。
这个12页的论文仔细研究了Intel和ARM的差异并且得出了一个结论。那就是ARM和x86之间的功耗差异并不是来自于CISC和RISC的指令集差异而是因为两类芯片的设计本就是针对不同的性能目标而进行的和指令集是CISC还是RISC并没有什么关系。
## 课后思考
Intel除了x86和安腾之外还推出过Atom这个面向移动设备的低功耗CPU。那Atom究竟是RISC还是CISC架构的CPU呢
你可以搜索一下相关资料,在留言区写下你搜索到的内容。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,107 @@
<audio id="audio" title="30 | GPU为什么玩游戏需要使用GPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/3c/0377d8ec851a248aacd498beba66c53c.mp3"></audio>
讲完了CPU我带你一起来看一看计算机里的另外一个处理器也就是被称之为GPU的图形处理器。过去几年里因为深度学习的大发展GPU一下子火起来了似乎GPU成了一个专为深度学习而设计的处理器。那GPU的架构究竟是怎么回事儿呢它最早是用来做什么而被设计出来的呢
想要理解GPU的设计我们就要从GPU的老本行图形处理说起。因为图形处理才是GPU设计用来做的事情。只有了解了图形处理的流程我们才能搞明白为什么GPU要设计成现在这样为什么在深度学习上GPU比起CPU有那么大的优势。
## GPU的历史进程
GPU是随着我们开始在计算机里面需要渲染三维图形的出现而发展起来的设备。图形渲染和设备的先驱第一个要算是SGISilicon Graphics Inc.这家公司。SGI的名字翻译成中文就是“硅谷图形公司”。这家公司从80年代起就开发了很多基于Unix操作系统的工作站。它的创始人Jim Clark是斯坦福的教授也是图形学的专家。
后来他也是网景公司Netscape的创始人之一。而Netscape就是那个曾经和IE大战300回合的浏览器公司虽然最终败在微软的Windows免费捆绑IE的策略下但是也留下了Firefox这个完全由开源基金会管理的浏览器。不过这个都是后话了。
到了90年代中期随着个人电脑的性能越来越好PC游戏玩家们开始有了“3D显卡”的需求。那个时代之前的3D游戏其实都是伪3D。比如大神卡马克开发的著名[Wolfenstein 3D](https://en.wikipedia.org/wiki/Wolfenstein_3D)德军总部3D从不同视角看到的是8幅不同的贴图实际上并不是通过图形学绘制渲染出来的多边形。
这样的情况下游戏玩家的视角旋转个10度看到的画面并没有变化。但是如果转了45度看到的画面就变成了另外一幅图片。而如果我们能实时渲染基于多边形的3D画面的话那么任何一点点的视角变化都会实时在画面里面体现出来就好像你在真实世界里面看到的一样。
而在90年代中期随着硬件和技术的进步我们终于可以在PC上用硬件直接实时渲染多边形了。“真3D”游戏开始登上历史舞台了。“古墓丽影”“最终幻想7”这些游戏都是在那个时代诞生的。当时很多国内的计算机爱好者梦寐以求的是一块Voodoo FX的显卡。
那为什么CPU的性能已经大幅度提升了但是我们还需要单独的GPU呢想要了解这个问题我们先来看一看三维图像实际通过计算机渲染出来的流程。
## 图形渲染的流程
现在我们电脑里面显示出来的3D的画面其实是通过多边形组合出来的。你可以看看下面这张图你在玩的各种游戏里面的人物的脸并不是那个相机或者摄像头拍出来的而是通过[多边形建模](https://en.wikipedia.org/wiki/Polygonal_modeling)Polygon Modeling创建出来的。
<img src="https://static001.geekbang.org/resource/image/07/22/0777aed6775051cfd83d0bb512de8722.png" alt="">
而实际这些人物在画面里面的移动、动作,乃至根据光线发生的变化,都是通过计算机根据图形学的各种计算,实时渲染出来的。
这个对于图像进行实时渲染的过程可以被分解成下面这样5个步骤
1. 顶点处理Vertex Processing
1. 图元处理Primitive Processing
1. 栅格化Rasterization
1. 片段处理Fragment Processing
1. 像素操作Pixel Operations
我们现在来一步一步看这5个步骤。
### 顶点处理
图形渲染的第一步是顶点处理。构成多边形建模的每一个多边形呢都有多个顶点Vertex。这些顶点都有一个在三维空间里的坐标。但是我们的屏幕是二维的所以在确定当前视角的时候我们需要把这些顶点在三维空间里面的位置转化到屏幕这个二维空间里面。这个转换的操作就被叫作顶点处理。
如果你稍微学过一点图形学的话,应该知道,这样的转化都是通过线性代数的计算来进行的。可以想见,我们的建模越精细,需要转换的顶点数量就越多,计算量就越大。**而且,这里面每一个顶点位置的转换,互相之间没有依赖,是可以并行独立计算的。**
<img src="https://static001.geekbang.org/resource/image/04/de/04c3da62c382e45b8f891cfa046169de.jpeg" alt="">
### 图元处理
在顶点处理完成之后呢我们需要开始进行第二步也就是图元处理。图元处理其实就是要把顶点处理完成之后的各个顶点连起来变成多边形。其实转化后的顶点仍然是在一个三维空间里只是第三维的Z轴是正对屏幕的“深度”。所以我们针对这些多边形需要做一个操作叫剔除和裁剪Cull and Clip也就是把不在屏幕里面或者一部分不在屏幕里面的内容给去掉减少接下来流程的工作量。
<img src="https://static001.geekbang.org/resource/image/4a/1d/4a20559c43f93177d7a99081a0cd0e1d.jpeg" alt="">
### 栅格化
在图元处理完成之后呢渲染还远远没有完成。我们的屏幕分辨率是有限的。它一般是通过一个个“像素Pixel”来显示出内容的。所以对于做完图元处理的多边形我们要开始进行第三步操作。这个操作就是把它们转换成屏幕里面的一个个像素点。这个操作呢就叫作栅格化。**这个栅格化操作,有一个特点和上面的顶点处理是一样的,就是每一个图元都可以并行独立地栅格化。**
<img src="https://static001.geekbang.org/resource/image/e6/a6/e60a58e632fc05dbf96eaa5cbb7fb2a6.jpeg" alt="">
### 片段处理
在栅格化变成了像素点之后,我们的图还是“黑白”的。我们还需要计算每一个像素的颜色、透明度等信息,给像素点上色。这步操作,就是片段处理。**这步操作,同样也可以每个片段并行、独立进行,和上面的顶点处理和栅格化一样。**
<img src="https://static001.geekbang.org/resource/image/49/08/490f298719e81beb1871c10566d56308.jpeg" alt="">
### 像素操作
最后一步呢我们就要把不同的多边形的像素点“混合Blending”到一起。可能前面的多边形可能是半透明的那么前后的颜色就要混合在一起变成一个新的颜色或者前面的多边形遮挡住了后面的多边形那么我们只要显示前面多边形的颜色就好了。最终输出到显示设备。
<img src="https://static001.geekbang.org/resource/image/31/1f/312b8e4730ac04d36c99ee7c56bbba1f.jpg" alt="">
经过这完整的5个步骤之后我们就完成了从三维空间里的数据的渲染变成屏幕上你可以看到的3D动画了。这样5个步骤的渲染流程呢一般也被称之为**图形流水线**Graphic Pipeline。这个名字和我们讲解CPU里面的流水线非常相似都叫**Pipeline**。
<img src="https://static001.geekbang.org/resource/image/bf/8f/bf6554dffdf501182ac45bc59d30648f.jpeg" alt="">
## 解放图形渲染的GPU
我们可以想一想如果用CPU来进行这个渲染过程需要花上多少资源呢我们可以通过一些数据来做个粗略的估算。
在上世纪90年代的时候屏幕的分辨率还没有现在那么高。一般的CRT显示器也就是640×480的分辨率。这意味着屏幕上有30万个像素需要渲染。为了让我们的眼睛看到画面不晕眩我们希望画面能有60帧。于是每秒我们就要重新渲染60次这个画面。也就是说每秒我们需要完成1800万次单个像素的渲染。从栅格化开始每个像素有3个流水线步骤即使每次步骤只有1个指令那我们也需要5400万条指令也就是54M条指令。
90年代的CPU的性能是多少呢93年出货的第一代Pentium处理器主频是60MHz后续逐步推出了66MHz、75MHz、100MHz的处理器。以这个性能来看用CPU来渲染3D图形基本上就要把CPU的性能用完了。因为实际的每一个渲染步骤可能不止一个指令我们的CPU可能根本就跑不动这样的三维图形渲染。
也就是在这个时候Voodoo FX这样的图形加速卡登上了历史舞台。既然图形渲染的流程是固定的那我们直接用硬件来处理这部分过程不用CPU来计算是不是就好了很显然这样的硬件会比制造有同样计算性能的CPU要便宜得多。因为整个计算流程是完全固定的不需要流水线停顿、乱序执行等等的各类导致CPU计算变得复杂的问题。我们也不需要有什么可编程能力只要让硬件按照写好的逻辑进行运算就好了。
那个时候整个顶点处理的过程还是都由CPU进行的不过后续所有到图元和像素级别的处理都是通过Voodoo FX或者TNT这样的显卡去处理的。也就是从这个时代开始我们能玩上“真3D”的游戏了。
<img src="https://static001.geekbang.org/resource/image/85/db/852288ae6b69b7e649c81f90c9fd7cdb.jpeg" alt="">
不过无论是Voodoo FX还是NVidia TNT。整个显卡的架构还不同于我们现代的显卡也没有现代显卡去进行各种加速深度学习的能力。这个能力要到NVidia提出Unified Shader Archicture才开始具备。这也是我们下一讲要讲的内容。
## 总结延伸
这一讲里我带你了解了一个基于多边形建模的三维图形的渲染过程。这个渲染过程需要经过顶点处理、图元处理、栅格化、片段处理以及像素操作这5个步骤。这5个步骤把存储在内存里面的多边形数据变成了渲染在屏幕上的画面。因为里面的很多步骤都需要渲染整个画面里面的每一个像素所以其实计算量是很大的。我们的CPU这个时候就有点跑不动了。
于是像3dfx和NVidia这样的厂商就推出了3D加速卡用硬件来完成图元处理开始的渲染流程。这些加速卡和现代的显卡还不太一样它们是用固定的处理流程来完成整个3D图形渲染的过程。不过因为不用像CPU那样考虑计算和处理能力的通用性。我们就可以用比起CPU芯片更低的成本更好地完成3D图形的渲染工作。而3D游戏的时代也是从这个时候开始的。
## 推荐阅读
想要了解GPU的设计构造一个有效的办法就是回头去看看GPU的历史。我建议你好好读一读Wikipedia里面关于GPU的条目。另外也可以看看Techspot上的[The History of the Mordern Graphics Processor](https://www.techspot.com/article/650-history-of-the-gpu/)的系列文章。
## 课后思考
我们上面说的图形加速卡可以加速3D图形的渲染。那么这些显卡对于传统的2D图形也能够进行加速让CPU摆脱这些负担吗
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,121 @@
<audio id="audio" title="31 | GPU为什么深度学习需要使用GPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/7d/6344f07eca4814953ae3a9d8866dd47d.mp3"></audio>
上一讲,我带你一起看了三维图形在计算机里的渲染过程。这个渲染过程,分成了顶点处理、图元处理、 栅格化、片段处理,以及最后的像素操作。这一连串的过程,也被称之为图形流水线或者渲染管线。
因为要实时计算渲染的像素特别地多图形加速卡登上了历史的舞台。通过3dFx的Voodoo或者NVidia的TNT这样的图形加速卡CPU就不需要再去处理一个个像素点的图元处理、栅格化和片段处理这些操作。而3D游戏也是从这个时代发展起来的。
你可以看这张图这是“古墓丽影”游戏的多边形建模的变化。这个变化则是从1996年到2016年这20年来显卡的进步带来的。
<img src="https://static001.geekbang.org/resource/image/1d/c3/1d098ce5b2c779392c8d3a33636673c3.png" alt="">
## Shader的诞生和可编程图形处理器
不知道你有没有发现在Voodoo和TNT显卡的渲染管线里面没有“顶点处理“这个步骤。在当时把多边形的顶点进行线性变化转化到我们的屏幕的坐标系的工作还是由CPU完成的。所以CPU的性能越好能够支持的多边形也就越多对应的多边形建模的效果自然也就越像真人。而3D游戏的多边形性能也受限于我们CPU的性能。无论你的显卡有多快如果CPU不行3D画面一样还是不行。
所以1999年NVidia推出的GeForce 256显卡就把顶点处理的计算能力也从CPU里挪到了显卡里。不过这对于想要做好3D游戏的程序员们还不够即使到了GeForce 256。整个图形渲染过程都是在硬件里面固定的管线来完成的。程序员们在加速卡上能做的事情呢只有改配置来实现不同的图形渲染效果。如果通过改配置做不到我们就没有什么办法了。
这个时候程序员希望我们的GPU也能有一定的可编程能力。这个编程能力不是像CPU那样有非常通用的指令可以进行任何你希望的操作而是在整个的**渲染管线**Graphics Pipeline的一些特别步骤能够自己去定义处理数据的算法或者操作。于是从2001年的Direct3D 8.0开始,微软第一次引入了**可编程管线**Programable Function Pipeline的概念。
<img src="https://static001.geekbang.org/resource/image/27/6d/2724f76ffa4222eae01521cd2dffd16d.jpeg" alt="">
一开始的可编程管线呢仅限于顶点处理Vertex Processing和片段处理Fragment Processing部分。比起原来只能通过显卡和Direct3D这样的图形接口提供的固定配置程序员们终于也可以开始在图形效果上开始大显身手了。
这些可以编程的接口,我们称之为**Shader**,中文名称就是**着色器**。之所以叫“着色器”,是因为一开始这些“可编程”的接口,只能修改顶点处理和片段处理部分的程序逻辑。我们用这些接口来做的,也主要是光照、亮度、颜色等等的处理,所以叫着色器。
这个时候的GPU有两类Shader也就是Vertex Shader和Fragment Shader。我们在上一讲看到在进行顶点处理的时候我们操作的是多边形的顶点在片段操作的时候我们操作的是屏幕上的像素点。对于顶点的操作通常比片段要复杂一些。所以一开始这两类Shader都是独立的硬件电路也各自有独立的编程接口。因为这么做硬件设计起来更加简单一块GPU上也能容纳下更多的Shader。
不过呢大家很快发现虽然我们在顶点处理和片段处理上的具体逻辑不太一样但是里面用到的指令集可以用同一套。而且虽然把Vertex Shader和Fragment Shader分开可以减少硬件设计的复杂程度但是也带来了一种浪费有一半Shader始终没有被使用。在整个渲染管线里Vertext Shader运行的时候Fragment Shader停在那里什么也没干。Fragment Shader在运行的时候Vertext Shader也停在那里发呆。
本来GPU就不便宜结果设计的电路有一半时间是闲着的。喜欢精打细算抠出每一分性能的硬件工程师当然受不了了。于是**统一着色器架构**Unified Shader Architecture就应运而生了。
既然大家用的指令集是一样的那不如就在GPU里面放很多个一样的Shader硬件电路然后通过统一调度把顶点处理、图元处理、片段处理这些任务都交给这些Shader去处理让整个GPU尽可能地忙起来。这样的设计就是我们现代GPU的设计就是统一着色器架构。
有意思的是这样的GPU并不是先在PC里面出现的而是来自于一台游戏机就是微软的XBox 360。后来这个架构才被用到ATI和NVidia的显卡里。这个时候的“着色器”的作用其实已经和它的名字关系不大了而是变成了一个通用的抽象计算模块的名字。
正是因为Shader变成一个“通用”的模块才有了把GPU拿来做各种通用计算的用法也就是**GPGPU**General-Purpose Computing on Graphics Processing Units通用图形处理器。而正是因为GPU可以拿来做各种通用的计算才有了过去10年深度学习的火热。
<img src="https://static001.geekbang.org/resource/image/da/93/dab4ed01f50995d82e6e5d970b54c693.jpeg" alt="">
## 现代GPU的三个核心创意
讲完了现代GPU的进化史那么接下来我们就来看看为什么现代的GPU在图形渲染、深度学习上能那么快。
### 芯片瘦身
我们先来回顾一下之前花了很多讲仔细讲解的现代CPU。现代CPU里的晶体管变得越来越多越来越复杂其实已经不是用来实现“计算”这个核心功能而是拿来实现处理乱序执行、进行分支预测以及我们之后要在存储器讲的高速缓存部分。
而在GPU里这些电路就显得有点多余了GPU的整个处理过程是一个[流式处理](https://en.wikipedia.org/wiki/Stream_processing)Stream Processing的过程。因为没有那么多分支条件或者复杂的依赖关系我们可以把GPU里这些对应的电路都可以去掉做一次小小的瘦身只留下取指令、指令译码、ALU以及执行这些计算需要的寄存器和缓存就好了。一般来说我们会把这些电路抽象成三个部分就是下面图里的取指令和指令译码、ALU和执行上下文。
<img src="https://static001.geekbang.org/resource/image/4c/9d/4c153ac45915fbf3985d24b092894b9d.jpeg" alt="">
### 多核并行和SIMT
这样一来我们的GPU电路就比CPU简单很多了。于是我们就可以在一个GPU里面塞很多个这样并行的GPU电路来实现计算就好像CPU里面的多核CPU一样。和CPU不同的是我们不需要单独去实现什么多线程的计算。因为GPU的运算是天然并行的。
<img src="https://static001.geekbang.org/resource/image/3d/ac/3d0859652adf9e3c0305e8e8517b47ac.jpeg" alt="">
我们在上一讲里面其实已经看到无论是对多边形里的顶点进行处理还是屏幕里面的每一个像素进行处理每个点的计算都是独立的。所以简单地添加多核的GPU就能做到并行加速。不过光这样加速还是不够工程师们觉得性能还有进一步被压榨的空间。
我们在[第27讲](https://time.geekbang.org/column/article/103433)里面讲过CPU里有一种叫作SIMD的处理技术。这个技术是说在做向量计算的时候我们要执行的指令是一样的只是同一个指令的数据有所不同而已。在GPU的渲染管线里这个技术可就大有用处了。
无论是顶点去进行线性变换还是屏幕上临近像素点的光照和上色都是在用相同的指令流程进行计算。所以GPU就借鉴了CPU里面的SIMD用了一种叫作[SIMT](https://en.wikipedia.org/wiki/Single_instruction,_multiple_threads)Single InstructionMultiple Threads的技术。SIMT呢比SIMD更加灵活。在SIMD里面CPU一次性取出了固定长度的多个数据放到寄存器里面用一个指令去执行。而SIMT可以把多条数据交给不同的线程去处理。
各个线程里面执行的指令流程是一样的但是可能根据数据的不同走到不同的条件分支。这样相同的代码和相同的流程可能执行不同的具体的指令。这个线程走到的是if的条件分支另外一个线程走到的就是else的条件分支了。
于是我们的GPU设计就可以进一步进化也就是在取指令和指令译码的阶段取出的指令可以给到后面多个不同的ALU并行进行运算。这样我们的一个GPU的核里就可以放下更多的ALU同时进行更多的并行运算了。
<img src="https://static001.geekbang.org/resource/image/3d/28/3d7ce9c053815f6a32a6fbf6f7fb9628.jpeg" alt="">
### GPU里的“超线程”
虽然GPU里面的主要以数值计算为主。不过既然已经是一个“通用计算”的架构了GPU里面也避免不了会有if…else这样的条件分支。但是在GPU里我们可没有CPU这样的分支预测的电路。这些电路在上面“芯片瘦身”的时候就已经被我们砍掉了。
所以GPU里的指令可能会遇到和CPU类似的“流水线停顿”问题。想到流水线停顿你应该就能记起我们之前在CPU里面讲过超线程技术。在GPU上我们一样可以做类似的事情也就是遇到停顿的时候调度一些别的计算任务给当前的ALU。
和超线程一样,既然要调度一个不同的任务过来,我们就需要针对这个任务,提供更多的**执行上下文**。所以一个Core里面的**执行上下文**的数量需要比ALU多。
<img src="https://static001.geekbang.org/resource/image/c9/b8/c971c34e0456dea9e4a87857880bb5b8.jpeg" alt="">
## GPU在深度学习上的性能差异
在通过芯片瘦身、SIMT以及更多的执行上下文我们就有了一个更擅长并行进行暴力运算的GPU。这样的芯片也正适合我们今天的深度学习的使用场景。
一方面GPU是一个可以进行“通用计算”的框架我们可以通过编程在GPU上实现不同的算法。另一方面现在的深度学习计算都是超大的向量和矩阵海量的训练样本的计算。整个计算过程中没有复杂的逻辑和分支非常适合GPU这样并行、计算能力强的架构。
我们去看NVidia 2080显卡的[技术规格](https://www.techpowerup.com/gpu-specs/geforce-rtx-2080.c3224),就可以算出,它到底有多大的计算能力。
2080一共有46个SMStreaming Multiprocessor流式处理器这个SM相当于GPU里面的GPU Core所以你可以认为这是一个46核的GPU有46个取指令指令译码的渲染管线。每个SM里面有64个Cuda Core。你可以认为这里的Cuda Core就是我们上面说的ALU的数量或者Pixel Shader的数量46x64呢一共就有2944个Shader。然后还有184个TMUTMU就是Texture Mapping Unit也就是用来做纹理映射的计算单元它也可以认为是另一种类型的Shader。
<img src="https://static001.geekbang.org/resource/image/14/e2/14d05a43f559cecff2b0813e8d5bdde2.png" alt="">
2080的主频是1515MHz如果自动超频Boost的话可以到1700MHz。而NVidia的显卡根据硬件架构的设计每个时钟周期可以执行两条指令。所以能做的浮点数运算的能力就是
对照一下官方的技术规格正好就是10.07TFLOPS。
那么最新的Intel i9 9900K的性能是多少呢不到1TFLOPS。而2080显卡和9900K的价格却是差不多的。所以在实际进行深度学习的过程中用GPU所花费的时间往往能减少一到两个数量级。而大型的深度学习模型计算往往又是多卡并行要花上几天乃至几个月。这个时候用CPU显然就不合适了。
今天随着GPGPU的推出GPU已经不只是一个图形计算设备更是一个用来做数值计算的好工具了。同样也是因为GPU的快速发展带来了过去10年深度学习的繁荣。
## 总结延伸
这一讲里面我们讲了GPU一开始是没有“可编程”能力的程序员们只能够通过配置来设计需要用到的图形渲染效果。随着“可编程管线”的出现程序员们可以在顶点处理和片段处理去实现自己的算法。为了进一步去提升GPU硬件里面的芯片利用率微软在XBox 360里面第一次引入了“统一着色器架构”使得GPU变成了一个有“通用计算”能力的架构。
接着我们从一个CPU的硬件电路出发去掉了对GPU没有什么用的分支预测和乱序执行电路来进行瘦身。之后基于渲染管线里面顶点处理和片段处理就是天然可以并行的了。我们在GPU里面可以加上很多个核。
又因为我们的渲染管线里面整个指令流程是相同的我们又引入了和CPU里的SIMD类似的SIMT架构。这个改动进一步增加了GPU里面的ALU的数量。最后为了能够让GPU不要遭遇流水线停顿我们又在同一个GPU的计算核里面加上了更多的执行上下文让GPU始终保持繁忙。
GPU里面的多核、多ALU加上多Context使得它的并行能力极强。同样架构的GPU如果光是做数值计算的话算力在同样价格的CPU的十倍以上。而这个强大计算能力以及“统一着色器架构”使得GPU非常适合进行深度学习的计算模式也就是海量计算容易并行并且没有太多的控制分支逻辑。
使用GPU进行深度学习往往能够把深度学习算法的训练时间缩短一个乃至两个数量级。而GPU现在也越来越多地用在各种科学计算和机器学习上而不仅仅是用在图形渲染上了。
## 推荐阅读
关于现代GPU的工作原理你可以仔细阅读一下 haifux.org 上的这个[PPT](http://haifux.org/lectures/267/Introduction-to-GPUs.pdf)里面图文并茂地解释了现代GPU的架构设计的思路。
## 课后思考
上面我给你算了NVidia 2080显卡的FLOPS你可以尝试算一下9900K CPU的FLOPS。
欢迎在留言区写下你的答案,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,110 @@
<audio id="audio" title="32 | FPGA和ASIC计算机体系结构的黄金时代" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a3/11/a3e54f953272942eb6b1082dbebc5311.mp3"></audio>
过去很长一段时间里大家在讲到高科技、互联网、信息技术的时候谈的其实都是“软件”。从1995年微软发布Windows 95开始高科技似乎就等同于软件业和互联网。著名的风险投资基金Andreessen Horowitz的合伙人Marc Andreessen在2011年发表了[一篇博客](https://a16z.com/2011/08/20/why-software-is-eating-the-world/)声称“Software is Eating the World”。Marc Andreessen不仅是投资人更是Netscape的创始人之一。他当时的搭档就是我们在前两讲提过的SGI创始人Jim Clark。
的确过去20年计算机工业界的中心都在软件上。似乎硬件对大家来说慢慢变成了一个黑盒子。虽然必要但却显得有点无关紧要。
不过在上世纪7080年代计算机的世界可不是这样的。那个时候计算机工业届最激动人心的是层出不穷的硬件。无论是Intel的8086还是摩托罗拉的68000这样用于个人电脑的CPU还是直到今天大家还会提起的Macintosh还有史上最畅销的计算机Commodore 64都是在那个时代被创造出来的。
<img src="https://static001.geekbang.org/resource/image/32/3e/325bfc1e0daa59c3451a6e5361dece3e.png" alt="">
不过,随着计算机主频提升越来越困难。这几年,计算机硬件又进入了一个新的、快速发展的时期。
从树莓派基金会这样的非盈利组织开发35美元的单片机到Google这样的巨头为了深度学习专门开发出来的TPU新的硬件层出不穷也无怪乎David Patterson老爷爷去年在拿图灵奖之后专门发表[讲话](https://eecs.berkeley.edu/turing-colloquium/schedule/patterson)说计算机体系结构又进入了一个黄金时代。那今天我就带你一起来看看FPGA和ASIC这两个最近比较时髦的硬件发展。
## FPGA
之前我们讲解CPU的硬件实现的时候说过其实CPU其实就是一些简单的门电路像搭积木一样搭出来的。从最简单的门电路搭建成半加器、全加器然后再搭建成完整功能的ALU。这些电路里呢有完成各种实际计算功能的组合逻辑电路也有用来控制数据访问创建出寄存器和内存的时序逻辑电路。如果你对这块儿内容印象不深可以回顾一下第12讲到第14讲的内容以及第17讲的内容。
好了那现在我问你一个问题在我们现代CPU里面有多少个晶体管这样的电路开关呢这个答案说出来有点儿吓人。一个四核i7的Intel CPU上面的晶体管数量差不多有20亿个。那接着问题就来了我们要想设计一个CPU就要想办法连接这20亿个晶体管。
这已经够难了后面还有更难的。就像我们写程序一样连接晶体管不是一次就能完事儿了的。设计更简单一点儿的专用于特定功能的芯片少不了要几个月。而设计一个CPU往往要以“年”来计。在这个过程中硬件工程师们要设计、验证各种各样的技术方案可能会遇到各种各样的Bug。如果我们每次验证一个方案都要单独设计生产一块芯片那这个代价也太高了。
我们有没有什么办法,不用单独制造一块专门的芯片来验证硬件设计呢?能不能设计一个硬件,通过不同的程序代码,来操作这个硬件之前的电路连线,通过“编程”让这个硬件变成我们设计的电路连线的芯片呢?
<img src="https://static001.geekbang.org/resource/image/88/7a/88becfbd1d82daad6d2bfebc92809f7a.png" alt="">
这个就是我们接下来要说的FPGA也就是**现场可编程门阵列**Field-Programmable Gate Array。看到这个名字你可能要说了这里面每个单词单独我都认识放到一起就不知道是什么意思了。
没关系我们就从FPGA里面的每一个字符一个一个来看看它到底是什么意思。
- P代表Programmable这个很容易理解。也就是说这是一个可以通过编程来控制的硬件。
- G代表Gate也很容易理解它就代表芯片里面的门电路。我们能够去进行编程组合的就是这样一个一个门电路。
- A代表的Array叫作阵列说的是在一块FPGA上密密麻麻列了大量Gate这样的门电路。
- 最后一个F不太容易理解。它其实是说一块FPGA这样的板子可以在“现场”多次进行编程。它不像PALProgrammable Array Logic可编程阵列逻辑这样更古老的硬件设备只能“编程”一次把预先写好的程序一次性烧录到硬件里面之后就不能再修改了。
这么看来其实“FPGA”这样的组合基本上解决了我们前面说的想要设计硬件的问题。我们可以像软件一样对硬件编程可以反复烧录还有海量的门电路可以组合实现复杂的芯片功能。
不过相信你和我一样好奇我们究竟怎么对硬件进行编程呢我们之前说过CPU其实就是通过晶体管来实现各种组合逻辑或者时序逻辑。那么我们怎么去“编程”连接这些线路呢
FPGA的解决方案很精巧我把它总结为这样三个步骤。
**第一,用存储换功能实现组合逻辑。**在实现CPU的功能的时候我们需要完成各种各样的电路逻辑。在FPGA里这些基本的电路逻辑不是采用布线连接的方式进行的而是预先根据我们在软件里面设计的逻辑电路算出对应的真值表然后直接存到一个叫作LUTLook-Up Table查找表的电路里面。这个LUT呢其实就是一块存储空间里面存储了“特定的输入信号下对应输出0还是1”。
<img src="https://static001.geekbang.org/resource/image/da/1e/da9a35820e7c1038c30ad54390888a1e.jpeg" alt="">
如果还没理解你可以想一下这个问题。假如现在我们要实现一个函数这个函数需要返回斐波那契数列的第N项并且限制这个N不会超过100。该怎么解决这个问题呢
斐波那契数列的通项公式是 f(N) = f(N-1) + f(N-2) 。所以我们的第一种办法自然是写一个程序从第1项开始算。但其实还有一种办法就是我们预先用程序算好斐波那契数量前100项然后把它预先放到一个数组里面。这个数组就像 [1, 1, 2, 3, 5…] 这样。当要计算第N项的时候呢我们并不是去计算得到结果而是直接查找这个数组里面的第N项。
这里面的关键就在于这个查表的办法不只能够提供斐波那契数列。如果我们要有一个获得N的5次方的函数一样可以先计算好放在表里面进行查询。这个“查表”的方法其实就是FPGA通过LUT来实现各种组合逻辑的办法。
**第二对于需要实现的时序逻辑电路我们可以在FPGA里面直接放上D触发器作为寄存器。**这个和CPU里的触发器没有什么本质不同。不过我们会把很多个LUT的电路和寄存器组合在一起变成一个叫作逻辑簇Logic Cluster的东西。在FPGA里这样组合了多个LUT和寄存器的设备也被叫做CLBConfigurable Logic Block可配置逻辑块
我们通过配置CLB实现的功能有点儿像我们前面讲过的全加器。它已经在最基础的门电路上做了组合能够提供更复杂一点的功能。更复杂的芯片功能我们不用再从门电路搭起可以通过CLB组合搭建出来。
<img src="https://static001.geekbang.org/resource/image/32/00/325f24043bf68651063dd5b907ab3300.jpeg" alt="">
**第三FPGA是通过可编程逻辑布线来连接各个不同的CLB最终实现我们想要实现的芯片功能。**这个可编程逻辑布线你可以把它当成我们的铁路网。整个铁路系统已经铺好了但是整个铁路网里面设计了很多个道岔。我们可以通过控制道岔来确定不同的列车线路。在可编程逻辑布线里面“编程”在做的就是拨动像道岔一样的各个电路开关最终实现不同CLB之间的连接完成我们想要的芯片功能。
于是通过LUT和寄存器我们能够组合出很多CLB而通过连接不同的CLB最终有了我们想要的芯片功能。最关键的是这个组合过程是可以“编程”控制的。而且这个编程出来的软件还可以后续改写重新写入到硬件里。让同一个硬件实现不同的芯片功能。从这个角度来说FPGA也是“软件吞噬世界”的一个很好的例子。
## ASIC
除了CPU、GPU以及刚刚的FPGA我们其实还需要用到很多其他芯片。比如现在手机里就有专门用在摄像头里的芯片录音笔里会有专门处理音频的芯片。尽管一个CPU能够处理好手机拍照的功能也能处理好录音的功能但是我们直接在手机或者录音笔里塞上一个Intel CPU显然比较浪费。
于是,我们就考虑为这些有专门用途的场景,单独设计一个芯片。这些专门设计的芯片呢,我们称之为**ASIC**Application-Specific Integrated Circuit也就是**专用集成电路**。事实上过去几年ASIC发展得特别快。因为ASIC是针对专门用途设计的所以它的电路更精简单片的制造成本也比CPU更低。而且因为电路精简所以通常能耗要比用来做通用计算的CPU更低。而我们上一讲所说的早期的图形加速卡其实就可以看作是一种ASIC。
因为ASIC的生产制造成本以及能耗上的优势过去几年里有不少公司设计和开发ASIC用来“挖矿”。这个“挖矿”说的其实就是设计专门的数值计算芯片用来“挖”比特币、ETH这样的数字货币。
那么我们能不能用刚才说的FPGA来做ASIC的事情呢当然是可以的。我们对FPGA进行“编程”其实就是把FPGA的电路变成了一个ASIC。这样的芯片往往在成本和功耗上优于需要做通用计算的CPU和GPU。
那你可能又要问了那为什么我们干脆不要用ASIC了全都用FPGA不就好了么你要知道其实FPGA一样有缺点那就是它的硬件上有点儿“浪费”。这个很容易理解我一说你就明白了。
每一个LUT电路其实都是一个小小的“浪费”。一个LUT电路设计出来之后既可以实现与门又可以实现或门自然用到的晶体管数量比单纯连死的与门或者或门的要多得多。同时因为用的晶体管多它的能耗也比单纯连死的电路要大单片FPGA的生产制造的成本也比ASIC要高不少。
当然有缺点就有优点FPGA的优点在于它没有硬件研发成本。ASIC的电路设计需要仿真、验证还需要经过流片Tape out变成一个印刷的电路版最终变成芯片。这整个从研发到上市的过程最低花费也要几万美元高的话会在几千万乃至数亿美元。更何况整个设计还有失败的可能。所以如果我们设计的专用芯片只是要制造几千片那买几千片现成的FPGA可能远比花上几百万美元来设计、制造ASIC要经济得多。
实际上到底使用ASIC这样的专用芯片还是采用FPGA这样可编程的通用硬件核心的决策因素还是成本。不过这个成本不只是单个芯片的生产制造成本还要考虑**总体拥有成本**Total Cost of Ownership也就是说除了生产成本之外我们要把研发成本也算进去。如果我们只制造了一片芯片那么成本就是“这枚芯片的成本+为了这枚芯片建的生产线的成本+芯片的研发成本”,而不只是“芯片的原材料沙子的成本+生产的电费”。
单个ASIC的生产制造成本比FPGA低ASIC的能耗也比能实现同样功能的FPGA要低。能耗低意味着长时间运行这些芯片所用的电力成本也更低。
但是ASIC有一笔很高的NRENon-Recuring Engineering Cost一次性工程费用成本。这个成本就是ASIC实际“研发”的成本。只有需要大量生产ASIC芯片的时候我们才能摊薄这份研发成本。
<img src="https://static001.geekbang.org/resource/image/4c/34/4cbada39aeedefd718eca8c329797834.jpeg" alt="">
其实,在我们的日常软件开发过程中,也需要做同样的决策。很多我们需要的功能,可能在市面上已经有开源的软件可以实现。我们可以在开源的软件之上做配置或者开发插件,也可以选择自己从头开始写代码。
在开源软件或者是买来的商业软件上启动往往能很快让产品上线。如果从头开始写代码往往会有一笔不地的NRE成本也就是研发成本。但是通常我们自己写的代码能够100%贴近我们的业务需求后续随着业务需求的改造成本会更低。如果要大规模部署很多服务器的话服务器的成本会更低。学会从TCO和NRE的成本去衡量做决策也是每一个架构师的必修课。
## 总结延伸
好了这一讲里我为你介绍了FPGA和ASIC这两种近年来非常时髦的芯片。
FPGA本质上是一个可以通过编程来控制硬件电路的芯片。我们通过用LUT这样的存储设备来代替需要的硬连线的电路有了可编程的逻辑门然后把很多LUT和寄存器放在一起变成一个更复杂的逻辑电路也就是CLB然后通过控制可编程布线中的很多开关最终设计出属于我们自己的芯片功能。FPGA常常被我们用来进行芯片的设计和验证工作也可以直接拿来当成专用的芯片替换掉CPU或者GPU以节约成本。
相比FPGAASIC在“专用”上更进一步。它是针对特定的使用场景设计出来的芯片比如摄像头、音频、“挖矿”或者深度学习。虽然ASIC的研发成本高昂但是生产制造成本和能耗都很低。所以对于有大量需求的专用芯片用ASIC是很划得来的。而在FPGA和ASIC之间进行取舍就要看两者的整体拥有成本哪一个更低了。
专用芯片的故事我们还没有讲完下一讲我们来看看Google开发的TPU这个近两年最知名的ASIC芯片的故事。
## 推荐阅读
既然用了David Patterson老爷爷的“黄金时代”作为这一讲的标题那么他的这篇讲话不可不读。我在文稿里留下了对应的[文章链接](https://cacm.acm.org/magazines/2019/2/234352-a-new-golden-age-for-computer-architecture/fulltext),你也可以在网络上看对应的[讲话视频和PPT。](https://eecs.berkeley.edu/turing-colloquium/schedule/patterson)
## 课后思考
最后给你留一道思考题。除了我今天讲到的FPGA、ASIC之外你最近关注到什么新的、有意思的硬件呢
欢迎在留言区分享出来。让我们不只了解计算机“软件”,也能够看到更广阔的“硬件”世界。同时,如果你觉得今天的内容很有收获,你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,109 @@
<audio id="audio" title="33 | 解读TPU设计和拆解一块ASIC芯片" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cd/3f/cd9de2b417f2eb039e7c79cc0f4f7a3f.mp3"></audio>
过去几年最知名、最具有实用价值的ASIC就是TPU了。各种解读TPU论文内容的文章网上也很多。不过这些文章更多地是从机器学习或者AI的角度来讲解TPU。
上一讲我为你讲解了FPGA和ASIC讲解了FPGA如何实现通过“软件”来控制“硬件”以及我们可以进一步把FPGA设计出来的电路变成一块ASIC芯片。
不过呢这些似乎距离我们真实的应用场景有点儿远。我们怎么能够设计出来一块有真实应用场景的ASIC呢如果要去设计一块ASIC我们应该如何思考和拆解问题呢今天我就带着你一起学习一下如何设计一块专用芯片。
## TPU V1想要解决什么问题
黑格尔说“世上没有无缘无故的爱也没有无缘无故的恨”。第一代TPU的设计并不是异想天开的创新而是来自于真实的需求。
从2012年解决计算机视觉问题开始深度学习一下子进入了大爆发阶段也一下子带火了GPUNVidia的股价一飞冲天。我们在[第31讲](https://time.geekbang.org/column/article/105401)讲过GPU天生适合进行海量、并行的矩阵数值计算于是它被大量用在深度学习的模型训练上。
不过你有没有想过,在深度学习热起来之后,计算量最大的是什么呢?并不是进行深度学习的训练,而是深度学习的推断部分。
所谓**推断部分**,是指我们在完成深度学习训练之后,把训练完成的模型存储下来。这个存储下来的模型,是许许多多个向量组成的参数。然后,我们根据这些参数,去计算输入的数据,最终得到一个计算结果。这个推断过程,可能是在互联网广告领域,去推测某一个用户是否会点击特定的广告;也可能是我们在经过高铁站的时候,扫一下身份证进行一次人脸识别,判断一下是不是你本人。
虽然训练一个深度学习的模型需要花的时间不少但是实际在推断上花的时间要更多。比如我们上面说的高铁去年2018年一年就有20亿人次坐了高铁这也就意味着至少进行了20亿次的人脸识别“推断“工作。
所以第一代的TPU首先优化的并不是深度学习的模型训练而是深度学习的模型推断。这个时候你可能要问了那模型的训练和推断有什么不同呢主要有三个点。
**第一点,深度学习的推断工作更简单,对灵活性的要求也就更低。**模型推断的过程我们只需要去计算一些矩阵的乘法、加法调用一些Sigmoid或者RELU这样的激活函数。这样的过程可能需要反复进行很多层但是也只是这些计算过程的简单组合。
**第二点,深度学习的推断的性能,首先要保障响应时间的指标。**我们在[第4讲](https://time.geekbang.org/column/article/93246)讲过计算机关注的性能指标有响应时间Response Time和吞吐率Throughput。我们在模型训练的时候只需要考虑吞吐率问题就行了。因为一个模型训练少则好几分钟多的话要几个月。而推断过程像互联网广告的点击预测我们往往希望能在几十毫秒乃至几毫秒之内就完成而人脸识别也不希望会超过几秒钟。很显然模型训练和推断对于性能的要求是截然不同的。
**第三点,深度学习的推断工作,希望在功耗上尽可能少一些**。深度学习的训练对功耗没有那么敏感只是希望训练速度能够尽可能快多费点电就多费点儿了。这是因为深度学习的推断要7×24h地跑在数据中心里面。而且对应的芯片要大规模地部署在数据中心。一块芯片减少5%的功耗,就能节省大量的电费。而深度学习的训练工作,大部分情况下只是少部分算法工程师用少量的机器进行。很多时候,只是做小规模的实验,尽快得到结果,节约人力成本。少数几台机器多花的电费,比起算法工程师的工资来说,只能算九牛一毛了。
这三点的差别也就带出了第一代TPU的设计目标。那就是在保障响应时间的情况下能够尽可能地提高**能效比**这个指标也就是进行同样多数量的推断工作花费的整体能源要显著低于CPU和GPU。
## 深入理解TPU V1
### 快速上线和向前兼容一个FPU的设计
如果你来设计TPU除了满足上面的深度学习的推断特性之外还有什么是你要重点考虑的呢你可以停下来思考一下然后再继续往下看。
不知道你的答案是什么我的第一反应是有两件事情必须要考虑第一个是TPU要有向前兼容性第二个是希望TPU能够尽早上线。我下面说说我考虑这两点的原因。
<img src="https://static001.geekbang.org/resource/image/f6/5e/f6637990792e8de1ef84891fadd11e5e.png" alt="">
第一点,向前兼容。在计算机产业界里,因为没有考虑向前兼容,惨遭失败的产品数不胜数。典型的有我在[第26讲](https://time.geekbang.org/column/article/102888)提过的安腾处理器。所以TPU并没有设计成一个独立的“CPU“而是设计成一块像显卡一样插在主板PCI-E接口上的板卡。更进一步地TPU甚至没有像我们之前说的现代GPU一样设计成自己有对应的取指令的电路而是通过CPU向TPU发送需要执行的指令。
这两个设计使得我们的TPU的硬件设计变得简单了我们只需要专心完成一个专用的“计算芯片”就好了。所以TPU整个芯片的设计上线时间也就缩短到了15个月。不过这样一个TPU其实是第26讲里我们提过的387浮点数计算芯片是一个像FPU浮点数处理器的协处理器Coprocessor而不是像CPU和GPU这样可以独立工作的Processor Unit。
### 专用电路和大量缓存,适应推断的工作流程
明确了TPU整体的设计思路之后我们可以来看一看TPU内部有哪些芯片和数据处理流程。我在文稿里面放了TPU的模块图和对应的芯片布局图你可以对照着看一下。
<img src="https://static001.geekbang.org/resource/image/6a/ae/6a14254b2bda4dd42adac6a2129e8bae.jpeg" alt="">
你可以看到在芯片模块图里面有单独的矩阵乘法单元Matrix Multiply Unit、累加器Accumulators模块、激活函数Activation模块和归一化/池化Normalization/Pool模块。而且这些模块是顺序串联在一起的。
这是因为一个深度学习的推断过程是由很多层的计算组成的。而每一个层Layer的计算过程就是先进行矩阵乘法再进行累加接着调用激活函数最后进行归一化和池化。这里的硬件设计呢就是把整个流程变成一套固定的硬件电路。这也是一个ASIC的典型设计思路其实就是把确定的程序指令流程变成固定的硬件电路。
接着我们再来看下面的芯片布局图其中控制电路Control只占了2%。这是因为TPU的计算过程基本上是一个固定的流程。不像我们之前讲的CPU那样有各种复杂的控制功能比如冒险、分支预测等等。
你可以看到超过一半的TPU的面积都被用来作为Local Unified Buffer本地统一缓冲区29%和矩阵乘法单元Matrix Mutliply Unit了。
相比于矩阵乘法单元,累加器、实现激活函数和后续的归一/池化功能的激活管线Activation Pipeline也用得不多。这是因为在深度学习推断的过程中矩阵乘法的计算量是最大的计算也更复杂所以比简单的累加器和激活函数要占用更多的晶体管。
而统一缓冲区Unified Buffer则由SRAM这样高速的存储设备组成。SRAM一般被直接拿来作为CPU的寄存器或者高速缓存。我们在后面的存储器部分会具体讲。SRAM比起内存使用的DRAM速度要快上很多但是因为电路密度小所以占用的空间要大很多。统一缓冲区之所以使用SRAM是因为在整个的推断过程中它会高频反复地被矩阵乘法单元读写来完成计算。
<img src="https://static001.geekbang.org/resource/image/08/2a/08e29a700898e5dabf60fbf0f026082a.jpeg" alt="">
可以看到整个TPU里面每一个组件的设计完全是为了深度学习的推断过程设计出来的。这也是我们设计开发ASIC的核心原因用特制的硬件最大化特定任务的运行效率。
### 细节优化使用8 Bits数据
除了整个TPU的模块设计和芯片布局之外TPU在各个细节上也充分考虑了自己的应用场景我们可以拿里面的矩阵乘法单元Matrix Multiply Unit来作为一个例子。
如果你仔细一点看的话会发现这个矩阵乘法单元没有用32 Bits来存放一个浮点数而是只用了一个8 Bits来存放浮点数。这是因为在实践的机器学习应用中会对数据做[归一化](https://en.wikipedia.org/wiki/Normalization)Normalization和[正则化](https://en.wikipedia.org/wiki/Regularization_(mathematics))Regularization的处理。咱们毕竟不是一个机器学习课所以我就不深入去讲什么是归一化和正则化了你只需要知道这两个操作呢会使得我们在深度学习里面操作的数据都不会变得太大。通常来说呢都能控制在-3到3这样一定的范围之内。
因为这个数值上的特征,我们需要的浮点数的精度也不需要太高了。我们在[第16讲](https://time.geekbang.org/column/article/98312)讲解浮点数的时候说过32位浮点数的精度差不多可以到1/1600万。如果我们用8位或者16位表示浮点数也能把精度放到2^6或者2^12也就是1/64或者1/4096。在深度学习里常常够用了。特别是在模型推断的时候要求的计算精度往往可以比模型训练低。所以8 Bits的矩阵乘法器就可以放下更多的计算量使得TPU的推断速度更快。
## 用数字说话TPU的应用效果
那么综合了这么多优秀设计点的TPU实际的使用效果怎么样呢不管设计得有多好最后还是要拿效果和数据说话。俗话说是骡子是马总要拿出来溜溜啊。
Google在TPU的论文里面给出了答案。一方面在性能上TPU比现在的CPU、GPU在深度学习的推断任务上要快1530倍。而在能耗比上更是好出3080倍。另一方面Google已经用TPU替换了自家数据中心里95%的推断任务,可谓是拿自己的实际业务做了一个明证。
## 总结延伸
这一讲我从第一代TPU的设计目标讲起为你解读了TPU的设计。你可以通过这篇文章回顾我们过去32讲提到的各种知识点。
第一代TPU是为了做各种深度学习的推断而设计出来的并且希望能够尽早上线。这样Google才能节约现有数据中心里面的大量计算资源。
从深度学习的推断角度来考虑TPU并不需要太灵活的可编程能力只要能够迭代完成常见的深度学习推断过程中一层的计算过程就好了。所以TPU的硬件构造里面把矩阵乘法、累加器和激活函数都做成了对应的专门的电路。
为了满足深度学习推断功能的响应时间短的需求TPU设置了很大的使用SRAM的Unified BufferUB就好像一个CPU里面的寄存器一样能够快速响应对于这些数据的反复读取。
为了让TPU尽可能快地部署在数据中心里面TPU采用了现有的PCI-E接口可以和GPU一样直接插在主板上并且采用了作为一个没有取指令功能的协处理器就像387之于386一样仅仅用来进行需要的各种运算。
在整个电路设计的细节层面TPU也尽可能做到了优化。因为机器学习的推断功能通常做了数值的归一化所以对于矩阵乘法的计算精度要求有限整个矩阵乘法的计算模块采用了8 Bits来表示浮点数而不是像Intel CPU里那样用上了32 Bits。
最终综合了种种硬件设计点之后的TPU做到了在深度学习的推断层面更高的能效比。按照Google论文里面给出的官方数据它可以比CPU、GPU快上1530倍能耗比更是可以高出3080倍。而TPU也最终替代了Google自己的数据中心里95%的深度学习推断任务。
## 推荐阅读
既然要深入了解TPU自然要读一读关于TPU的论文[In-Datacenter Performance Analysis of a Tensor Processing Unit](https://arxiv.org/ftp/arxiv/papers/1704/1704.04760.pdf)。
除了这篇论文之外你也可以读一读Google官方专门讲解TPU构造的博客文章 [An in-depth look at Googles first Tensor Processing Unit(TPU)](https://cloud.google.com/blog/products/gcp/an-in-depth-look-at-googles-first-tensor-processing-unit-tpu)。
## 课后思考
你能想一想如果我们想要做一个能够进行深度学习模型训练的TPU我们应该在第一代的TPU的设计之上做怎么样的修改呢
欢迎留言和我分享你的想法。如果这篇文章对你有收获,你也可以把他分享给你的朋友。

View File

@@ -0,0 +1,117 @@
<audio id="audio" title="34 | 理解虚拟机:你在云上拿到的计算机是什么样的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/1b/a13c711ce3e3bf389bffd10b90ddb81b.mp3"></audio>
上世纪60年代计算机还是异常昂贵的设备实际的计算机使用需求要面临两个挑战。第一计算机特别昂贵我们要尽可能地让计算机忙起来一直不断地去处理一些计算任务。第二很多工程师想要用上计算机但是没有能力自己花钱买一台所以呢我们要让很多人可以共用一台计算机。
## 缘起分时系统
为了应对这两个问题,[分时系统](https://en.wikipedia.org/wiki/Time-sharing)的计算机就应运而生了。
无论是个人用户还是一个小公司或者小机构你都不需要花大价钱自己去买一台电脑。你只需要买一个输入输出的终端就好像一套鼠标、键盘、显示器这样的设备然后通过电话线连到放在大公司机房里面的计算机就好了。这台计算机会自动给程序或任务分配计算时间。你只需要为你花费的“计算时间”和使用的电话线路付费就可以了。比方说比尔·盖茨中学时候用的学校的计算机就是GE的分时系统。
<img src="https://static001.geekbang.org/resource/image/d1/9d/d107e645e1f849ebcafab0e4d4b73a9d.png" alt="">
## 从“黑色星期五”到公有云
现代公有云上的系统级虚拟机能够快速发展,其实和分时系统的设计思路是一脉相承的,这其实就是来自于电商巨头亚马逊大量富余的计算能力。
和国内有“双十一”一样,美国会有感恩节的“[黑色星期五](https://en.wikipedia.org/wiki/Black_Friday_(shopping))Black Friday”和“[网络星期一](https://en.wikipedia.org/wiki/Cyber_Monday)Cyber Monday这样一年一度的大型电商促销活动。几天的活动期间会有大量的用户进入亚马逊这样的网站看商品、下订单、买东西。这个时候整个亚马逊需要的服务器计算资源可能是平时的数十倍。
于是亚马逊会按照“黑色星期五”和“网络星期一”的用户访问量来准备服务器资源。这个就带来了一个问题那就是在一年的365天里有360天这些服务器资源是大量空闲的。要知道这个空闲的服务器数量不是一台两台也不是几十几百台。根据媒体的估算亚马逊的云服务器AWS在2014年就已经超过了150万台到了2019年的今天估计已经有超过千万台的服务器。
平时有这么多闲着的服务器实在是太浪费了,所以,亚马逊就想把这些服务器给租出去。出租物理服务器当然是可行的,但是却不太容易自动化,也不太容易面向中小客户。
直接出租物理服务器意味着亚马逊只能进行服务器的“整租”这样大部分中小客户就不愿意了。为了节约数据中心的空间亚马逊实际用的物理服务器大部分多半是强劲的高端8核乃至12核的服务器。想要租用这些服务器的中小公司起步往往只需要1个CPU核心乃至更少资源的服务器。一次性要他们去租一整台服务器就好像刚毕业想要租个单间结果你非要整租个别墅给他。
这个“整租”的问题,还发生在“时间”层面。物理服务器里面装好的系统和应用,不租了而要再给其他人使用,就必须清空里面已经装好的程序和数据,得做一次“重装”。如果我们只是暂时不用这个服务器了,过一段时间又要租这个服务器,数据中心服务商就不得不先重装整个系统,然后租给别人。等别人不用了,再重装系统租给你,特别地麻烦。
其实,对于想要租用服务器的用户来说,最好的体验不是租房子,而是住酒店。我住一天,我就付一天的钱。这次是全家出门,一次多定几间酒店房间就好啦。
而这样的需求,用虚拟机技术来实现,再好不过了。虚拟机技术,使得我们可以在一台物理服务器上,同时运行多个虚拟服务器,并且可以动态去分配,每个虚拟服务器占用的资源。对于不运行的虚拟服务器,我们也可以把这个虚拟服务器“关闭”。这个“关闭”了的服务器,就和一个被关掉的物理服务器一样,它不会再占用实际的服务器资源。但是,当我们重新打开这个虚拟服务器的时候,里面的数据和应用都在,不需要再重新安装一次。
## 虚拟机的技术变迁
那虚拟机技术到底是怎么一回事呢?下面我带你具体来看一看,它的技术变迁过程,好让你能更加了解虚拟机,从而更好地使用它。
**虚拟机**Virtual Machine技术其实就是指在现有硬件的操作系统上能够**模拟**一个计算机系统的技术。而模拟一个计算机系统最简单的办法其实不能算是虚拟机技术而是一个模拟器Emulator
### 解释型虚拟机
要模拟一个计算机系统,最简单的办法,就是兼容这个计算机系统的指令集。我们可以开发一个应用程序,跑在我们的操作系统上。这个应用程序呢,可以识别我们想要模拟的、计算机系统的程序格式和指令,然后一条条去解释执行。
在这个过程中,我们把原先的操作系统叫作**宿主机**Host把能够有能力去模拟指令执行的软件叫作**模拟器**Emulator而实际运行在模拟器上被“虚拟”出来的系统呢我们叫**客户机**Guest VM
这个方式其实和运行Java程序的Java虚拟机很像。只不过Java虚拟机运行的是Java自己定义发明的中间代码而不是一个特定的计算机系统的指令。
这种解释执行另一个系统的方式有没有真实的应用案例呢当然是有的如果你是一个Android开发人员你在开发机上跑的Android模拟器其实就是这种方式。如果你喜欢玩一些老游戏可以注意研究一下很多能在Windows下运行的游戏机模拟器用的也是类似的方式。
**这种解释执行方式的最大的优势就是,模拟的系统可以跨硬件。**比如Android手机用的CPU是ARM的而我们的开发机用的是Intel X86的两边的CPU指令集都不一样但是一样可以正常运行。如果你想玩的街机游戏里面的硬件早就已经停产了那你自然只能选择MAME这样的模拟器。
<img src="https://static001.geekbang.org/resource/image/91/6b/912a6b83c639ae06b61a56327488916b.png" alt="">
不过这个方式也有两个明显的缺陷。第一个是我们做不到精确的“模拟”。很多的老旧的硬件的程序运行要依赖特定的电路乃至电路特有的时钟频率想要通过软件达到100%模拟是很难做到的。第二个缺陷就更麻烦了那就是这种解释执行的方式性能实在太差了。因为我们并不是直接把指令交给CPU去执行的而是要经过各种解释和翻译工作。
所以虽然模拟器这样的形式有它的实际用途。甚至为了解决性能问题也有类似于Java当中的JIT这样的“编译优化”的办法把本来解释执行的指令编译成Host可以直接运行的指令。但是这个性能还是不能让人满意。毕竟我们本来是想要把空余的计算资源租用出去的。如果我们空出来的计算能力算是个大平层结果经过模拟器之后能够租出去的计算能力就变成了一个格子间那我们就划不来了。
### Type-1和Type-2虚拟机的性能提升
所以我们希望我们的虚拟化技术能够克服上面的模拟器方式的两个缺陷。同时我们可以放弃掉模拟器方式能做到的跨硬件平台的这个能力。因为毕竟对于我们想要做的云服务里的“服务器租赁”业务来说中小客户想要租的也是一个x86的服务器。而另外一方面他们希望这个租用的服务器用起来和直接买一台或者租一台物理服务器没有区别。作为出租方的我们也希望服务器不要因为用了虚拟化技术而在中间损耗掉太多的性能。
所以首先我们需要一个“全虚拟化”的技术也就是说我们可以在现有的物理服务器的硬件和操作系统上去跑一个完整的、不需要做任何修改的客户机操作系统Guest OS。那么我们怎么在一个操作系统上再去跑多个完整的操作系统呢答案就是我们自己做软件开发中很常用的一个解决方案就是加入一个中间层。在虚拟机技术里面这个中间层就叫作**虚拟机监视器**英文叫VMMVirtual Machine Manager或者Hypervisor。
<img src="https://static001.geekbang.org/resource/image/e0/bf/e09b64e035a3b1bd664b0584a7b52fbf.jpeg" alt="">
如果说我们宿主机的OS是房东的话这个虚拟机监视器呢就好像一个二房东。我们运行的虚拟机都不是直接和房东打交道而是要和这个二房东打交道。我们跑在上面的虚拟机呢会把整个的硬件特征都映射到虚拟机环境里这包括整个完整的CPU指令集、I/O操作、中断等等。
既然要通过虚拟机监视器这个二房东我们实际的指令是怎么落到硬件上去实际执行的呢这里有两种办法也就是Type-1和Type-2这两种类型的虚拟机。
我们先来看Type-2类型的虚拟机。在Type-2虚拟机里我们上面说的虚拟机监视器好像一个运行在操作系统上的软件。你的客户机的操作系统呢把最终到硬件的所有指令都发送给虚拟机监视器。而虚拟机监视器又会把这些指令再交给宿主机的操作系统去执行。
那这时候你就会问了这和上面的模拟器看起来没有那么大分别啊看起来我们只是把在模拟器里的指令翻译工作挪到了虚拟机监视器里。没错Type-2型的虚拟机更多是用在我们日常的个人电脑里而不是用在数据中心里。
在数据中心里面用的虚拟机我们通常叫作Type-1型的虚拟机。这个时候客户机的指令交给虚拟机监视器之后呢不再需要通过宿主机的操作系统才能调用硬件而是可以直接由虚拟机监视器去调用硬件。
另外在数据中心里面我们并不需要在Intel x86上面去跑一个ARM的程序而是直接在x86上虚拟一个x86硬件的计算机和操作系统。所以我们的指令不需要做什么翻译工作可以直接往下传递执行就好了所以指令的执行效率也会很高。
所以在Type-1型的虚拟机里我们的虚拟机监视器其实并不是一个操作系统之上的应用层程序而是一个嵌入在操作系统内核里面的一部分。无论是KVM、XEN还是微软自家的Hyper-V其实都是系统级的程序。
<img src="https://static001.geekbang.org/resource/image/9c/8e/9c30f8d93270a9563154aa732b9c9f8e.jpeg" alt="">
因为虚拟机监视器需要直接和硬件打交道所以它也需要包含能够直接操作硬件的驱动程序。所以Type-1的虚拟机监视器更大一些同时兼容性也不能像Type-2型那么好。不过因为它一般都是部署在我们的数据中心里面硬件完全是统一可控的这倒不是一个问题了。
### Docker新时代的最佳选择
虽然Type-1型的虚拟机看起来已经没有什么硬件损耗。但是这里面还是有一个浪费的资源。在我们实际的物理机上我们可能同时运行了多个的虚拟机而这每一个虚拟机都运行了一个属于自己的单独的操作系统。
多运行一个操作系统意味着我们要多消耗一些资源在CPU、内存乃至磁盘空间上。那我们能不能不要多运行的这个操作系统呢
其实是可以的。因为我们想要的未必是一个完整的、独立的、全虚拟化的虚拟机。我们很多时候想要租用的不是“独立服务器”而是独立的计算资源。在服务器领域我们开发的程序都是跑在Linux上的。其实我们并不需要一个独立的操作系统只要一个能够进行资源和环境隔离的“独立空间”就好了。那么能够满足这个需求的解决方案就是过去几年特别火热的Docker技术。使用Docker来搭建微服务可以说是过去两年大型互联网公司的必经之路了。
<img src="https://static001.geekbang.org/resource/image/6c/35/6cbf5f5f4275bc053fabcd3480304a35.jpeg" alt="">
在实践的服务器端的开发中虽然我们的应用环境需要各种各样不同的依赖可能是不同的PHP或者Python的版本可能是操作系统里面不同的系统库但是通常来说我们其实都是跑在Linux内核上的。通过Docker我们不再需要在操作系统上再跑一个操作系统而只需要通过容器编排工具比如Kubernetes或者Docker Swarm能够进行各个应用之间的环境和资源隔离就好了。
这种隔离资源的方式呢也有人称之为“操作系统级虚拟机”好和上面的全虚拟化虚拟机对应起来。不过严格来说Docker并不能算是一种虚拟机技术而只能算是一种资源隔离的技术而已。
## 总结延伸
这一讲,我从最古老的分时系统讲起,介绍了虚拟机的相关技术。我们现在的云服务平台上,你能够租到的服务器其实都是虚拟机,而不是物理机。而正是虚拟机技术的出现,使得整个云服务生态得以出现。
虚拟机是模拟一个计算机系统的技术而其中最简单的办法叫模拟器。我们日常在PC上进行Android开发其实就是在使用这样的模拟器技术。不过模拟器技术在性能上实在不行所以我们才有了虚拟化这样的技术。
在宿主机的操作系统上运行一个虚拟机监视器然后再在虚拟机监视器上运行客户机的操作系统这就是现代的虚拟化技术。这里的虚拟化技术可以分成Type-1和Type-2这两种类型。
Type-1类型的虚拟化机实际的指令不需要再通过宿主机的操作系统而可以直接通过虚拟机监视器访问硬件所以性能比Type-2要好。而Type-2类型的虚拟机所有的指令需要经历客户机操作系统、虚拟机监视器、宿主机操作系统所以性能上要慢上不少。不过因为经历了宿主机操作系统的一次“翻译”过程它的硬件兼容性往往会更好一些。
今天即使是Type-1型的虚拟机技术我们也会觉得有一些性能浪费。我们常常在同一个物理机上跑上8个、10个的虚拟机。而且这些虚拟机的操作系统其实都是同一个Linux Kernel的版本。于是轻量级的Docker技术就进入了我们的视野。Docker也被很多人称之为“操作系统级”的虚拟机技术。不过Docker并没有再单独运行一个客户机的操作系统而是直接运行在宿主机操作系统的内核之上。所以Docker也是现在流行的微服务架构底层的基础设施。
## 推荐阅读
又到了阅读英文文章的时间了。想要更多了解虚拟机、Docker这些相关技术的概念和知识特别是进一步理解Docker的细节你可以去读一读FreeCodeCamp里的[A Beginner-Friendly Introduction to Containers, VMs and Docker](https://www.freecodecamp.org/news/a-beginner-friendly-introduction-to-containers-vms-and-docker-79a9e3e119b/)这篇文章。
## 课后思考
我们在程序开发过程中除了会用今天讲到的系统级虚拟机之外还会常常遇到Java虚拟机这样的进程级虚拟机。那么JVM这个进程级虚拟机是为了解决什么问题而出现的呢今天我们讲到的系统级虚拟机发展历程中的各种优化手段有哪些是JVM中也可以通用的呢
欢迎留言和我分享你的疑惑和见解。如果有收获,你也可以把今天的文章分享给你朋友。

View File

@@ -0,0 +1,114 @@
<audio id="audio" title="35 | 存储器层次结构全景:数据存储的大金字塔长什么样?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/50/a4e57ab2fac101456f3d8805c0a96c50.mp3"></audio>
今天开始,我们要进入到计算机另一个重要的组成部分,存储器。
如果你自己组装过PC机你肯定知道想要CPU我们只要买一个就好了但是存储器却有不同的设备要买。比方说我们要买内存还要买硬盘。买硬盘的时候不少人会买一块SSD硬盘作为系统盘还会买上一块大容量的HDD机械硬盘作为数据盘。内存和硬盘都是我们的存储设备。而且像硬盘这样的持久化存储设备同时也是一个I/O设备。
在实际的软件开发过程中我们常常会遇到服务端的请求响应时间长吞吐率不够的情况。在分析对应问题的时候相信你没少听过类似“主要瓶颈不在CPU而在I/O”的论断。可见存储在计算机中扮演着多么重要的角色。那接下来这一整个章节我会为你梳理和讲解整个存储器系统。
这一讲,我们先从存储器的层次结构说起,让你对各种存储器设备有一个整体的了解。
## 理解存储器的层次结构
在有计算机之前,我们通常把信息和数据存储在书、文件这样的物理介质里面。有了计算机之后,我们通常把数据存储在计算机的存储器里面。而存储器系统是一个通过各种不同的方法和设备,一层一层组合起来的系统。下面,我们把计算机的存储器层次结构和我们日常生活里处理信息、阅读书籍做个对照,好让你更容易理解、记忆存储器的层次结构。
我们常常把CPU比喻成计算机的“大脑”。我们思考的东西就好比CPU中的**寄存器**Register。寄存器与其说是存储器其实它更像是CPU本身的一部分只能存放极其有限的信息但是速度非常快和CPU同步。
而我们大脑中的记忆,就好比**CPU Cache**CPU高速缓存我们常常简称为“缓存”。CPU Cache用的是一种叫作**SRAM**Static Random-Access Memory静态随机存取存储器的芯片。
### SRAM
SRAM之所以被称为“静态”存储器是因为只要处在通电状态里面的数据就可以保持存在。而一旦断电里面的数据就会丢失了。在SRAM里面一个比特的数据需要68个晶体管。所以SRAM的存储密度不高。同样的物理空间下能够存储的数据有限。不过因为SRAM的电路简单所以访问速度非常快。
<img src="https://static001.geekbang.org/resource/image/25/99/25c619a683c161d3678c7339aa34d399.png" alt="">
在CPU里通常会有L1、L2、L3这样三层高速缓存。每个CPU核心都有一块属于自己的L1高速缓存通常分成**指令缓存**和**数据缓存**分开存放CPU使用的指令和数据。
不知道你还记不记得我们在[第22讲](https://time.geekbang.org/column/article/100569)讲过的哈佛架构这里的指令缓存和数据缓存其实就是来自于哈佛架构。L1的Cache往往就嵌在CPU核心的内部。
L2的Cache同样是每个CPU核心都有的不过它往往不在CPU核心的内部。所以L2 Cache的访问速度会比L1稍微慢一些。而L3 Cache则通常是多个CPU核心共用的尺寸会更大一些访问速度自然也就更慢一些。
你可以把CPU中的L1 Cache理解为我们的短期记忆把L2/L3 Cache理解成长期记忆把内存当成我们拥有的书架或者书桌。 当我们自己记忆中没有资料的时候可以从书桌或者书架上拿书来翻阅。这个过程中就相当于数据从内存中加载到CPU的寄存器和Cache中然后通过“大脑”也就是CPU进行处理和运算。
### DRAM
内存用的芯片和Cache有所不同它用的是一种叫作**DRAM**Dynamic Random Access Memory动态随机存取存储器的芯片比起SRAM来说它的密度更高有更大的容量而且它也比SRAM芯片便宜不少。
DRAM被称为“动态”存储器是因为DRAM需要靠不断地“刷新”才能保持数据被存储起来。DRAM的一个比特只需要一个晶体管和一个电容就能存储。所以DRAM在同样的物理空间下能够存储的数据也就更多也就是存储的“密度”更大。但是因为数据是存储在电容里的电容会不断漏电所以需要定时刷新充电才能保持数据不丢失。DRAM的数据访问电路和刷新电路都比SRAM更复杂所以访问延时也就更长。
<img src="https://static001.geekbang.org/resource/image/be/b8/befed615bf50df878b26455288eccbb8.png" alt="">
### 存储器的层级结构
整个存储器的层次结构其实都类似于SRAM和DRAM在性能和价格上的差异。SRAM更贵速度更快。DRAM更便宜容量更大。SRAM好像我们的大脑中的记忆而DRAM就好像属于我们自己的书桌。
大脑CPU中的记忆L1 Cache不仅受成本层面的限制更受物理层面的限制。这就好比L1 Cache不仅昂贵其访问速度和它到CPU的物理距离有关。芯片造得越大总有部分离CPU的距离会变远。电信号的传输速度又受物理原理的限制没法超过光速。所以想要快并不是靠多花钱就能解决的。
我们自己的书房和书桌(也就是内存)空间一般是有限的,没有办法放下所有书(也就是数据)。如果想要扩大空间的话,就相当于要多买几平方米的房子,成本就会很高。于是,想要放下更多的书,我们就要寻找更加廉价的解决方案。
没错,我们想到了公共图书馆。对于内存来说,**SSD**Solid-state drive或Solid-state disk固态硬盘、**HDD**Hard Disk Drive硬盘这些被称为**硬盘**的外部存储设备,就是公共图书馆。于是,我们就可以去家附近的图书馆借书了。图书馆有更多的空间(存储空间)和更多的书(数据)。
你应该也在自己的个人电脑上用过SSD硬盘。过去几年SSD这种基于NAND芯片的高速硬盘价格已经大幅度下降。
而HDD硬盘则是一种完全符合“磁盘”这个名字的传统硬件。“磁盘”的硬件结构决定了它的访问速度受限于它的物理结构是最慢的。
这些我们后面都会详细说,你可以对照下面这幅图了解一下,对存储器层次之间的作用和关联有个大致印象就可以了。
<img src="https://static001.geekbang.org/resource/image/ab/0a/ab345017c3f662b15e15e97e0ca1db0a.png" alt="">
从Cache、内存到SSD和HDD硬盘一台现代计算机中就用上了所有这些存储器设备。其中容量越小的设备速度越快而且CPU并不是直接和每一种存储器设备打交道而是每一种存储器设备只和它相邻的存储设备打交道。比如CPU Cache是从内存里加载而来的或者需要写回内存并不会直接写回数据到硬盘也不会直接从硬盘加载数据到CPU Cache中而是先加载到内存再从内存加载到Cache中。
**这样,各个存储器只和相邻的一层存储器打交道,并且随着一层层向下,存储器的容量逐层增大,访问速度逐层变慢,而单位存储成本也逐层下降,也就构成了我们日常所说的存储器层次结构。**
## 使用存储器的时候,该如何权衡价格和性能?
存储器在不同层级之间的性能差异和价格差异都至少在一个数量级以上。L1 Cache的访问延时是1纳秒ns而内存就已经是100纳秒了。在价格上这两者也差出了400倍。
我这里放了一张各种存储器成本的对比表格,你可以看看。你也可以在点击这个[链接](https://people.eecs.berkeley.edu/~rcs/research/interactive_latency.html)通过拖拉查看19902020年随着硬件设备的进展访问延时的变化。
<img src="https://static001.geekbang.org/resource/image/d3/a6/d39b0f2b3962d646133d450541fb75a6.png" alt="">
因为这个价格和性能的差异,你会看到,我们实际在进行电脑硬件配置的时候,会去组合配置各种存储设备。
我们可以找一台现在主流的笔记本电脑来看看比如一款入门级的惠普战66的笔记本电脑。今天在京东上的价格是4999人民币。它的配置是下面这样的。
1. Intle i5-8265U的CPU这是一块4核的CPU
- 这块CPU每个核有32KB一共128KB的L1指令Cache。
- 同样每个核还有32KB一共128KB的L1数据Cache指令Cache和数据Cache都是采用8路组相连的放置策略。
- 每个核有256KB一共1MB的L2 Cache。L2 Cache是用4路组相连的放置策略。
- 最后还有一块多个核心共用的12MB的L3 Cache采用的是12路组相连的放置策略。
1. 8GB的内存
1. 一块128G的SSD硬盘
1. 一块1T的HDD硬盘
你可以看到在一台实际的计算机里面越是速度快的设备容量就越小。这里一共十多兆的Cache成本只是几十美元。而8GB的内存、128G的SSD以及1T的HDD大概零售价格加在一起也就和我们的高速缓存的价格差不多。
## 总结延伸
这节的内容不知道你掌握了多少呢?为了帮助你记忆,我这里再带你复习一下本节的重点。
我们常常把CPU比喻成高速运转的大脑那么和大脑同步的寄存器Register就存放着我们当下正在思考和处理的数据。而L1-L3的CPU Cache好比存放在我们大脑中的短期到长期的记忆。我们需要小小花费一点时间就能调取并进行处理。
我们自己的书桌书架就好比计算机的内存能放下更多的书也就是数据但是找起来和看起来就要慢上不少。而图书馆更像硬盘这个外存能够放下更多的数据找起来也更费时间。从寄存器、CPU Cache到内存、硬盘这样一层层下来的存储器速度越来越慢空间越来越大价格也越来越便宜。
这三个“越来越”的特性,使得我们在组装计算机的时候,要组合使用各种存储设备。越是快且贵的设备,实际在一台计算机里面的存储空间往往就越小。而越是慢且便宜的设备,在实际组装的计算机里面的存储空间就会越大。
在后面的关于存储器的内容里,我会带着你进一步深入了解,各个层次的存储器是如何运作的,在不同类型的应用和性能要求下,是否可以靠人工添加一层缓存层来解决问题,以及在程序开发层面,如何利用好不同层次的存储器设备的访问原理和特性。
## 补充阅读
如果你学有余力,关于不同存储器的访问延时数据,有两篇文章推荐给你阅读。
第一个是Peter Novig的[**Teach Yourself Programming in Ten Years**](http://norvig.com/21-days.html#answers)。我推荐你在了解这些数据之后读一读这篇文章。这些数字随着摩尔定律的发展在不断缩小,但是在数量级上仍然有着很强的参考价值。
第二个是Jeff Dean的[**Build Software Systems at Google and Lessons Learned**](https://research.google.com/people/jeff/Stanford-DL-Nov-2010.pdf)。这份PPT中不仅总结了这些数字还有大量的硬件故障、高可用和系统架构的血泪经验。尽管这是一份10年前的PPT但也非常值得阅读。
## 课后思考
最后给你留一道思考题。在上世纪8090年代3.5寸的磁盘大行其道。它的存储空间只有1.44MB比起当时40MB的硬盘它却被大家认为是“海量”存储的主要选择。你猜一猜这是为什么
欢迎把你思考的结果写在留言区。如果觉得有收获,你也可以把这篇文章分享给你的朋友,和他一起讨论和学习。

View File

@@ -0,0 +1,83 @@
<audio id="audio" title="36 | 局部性原理:数据库性能跟不上,加个缓存就好了?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/04/e3/04f47d75c5910a87a46bf2aa880f57e3.mp3"></audio>
平时进行服务端软件开发的时候我们通常会把数据存储在数据库里。而服务端系统遇到的第一个性能瓶颈往往就发生在访问数据库的时候。这个时候大部分工程师和架构师会拿出一种叫作“缓存”的武器通过使用Redis或者Memcache这样的开源软件在数据库前面提供一层缓存的数据来缓解数据库面临的压力提升服务端的程序性能。
<img src="https://static001.geekbang.org/resource/image/67/89/675341b47057e483713395b55eef7089.png" alt="">
那么不知道你有没有想过这种添加缓存的策略一定是有效的吗或者说这种策略在什么情况下是有效的呢如果从理论角度去分析添加缓存一定是我们的最佳策略么进一步地如果我们对于访问性能的要求非常高希望数据在1毫秒乃至100微秒内完成处理我们还能用这个添加缓存的策略么
## 理解局部性原理
我们先来回顾一下,上一讲的这张不同存储器的性能和价目表。可以看到,不同的存储器设备之间,访问速度、价格和容量都有几十乃至上千倍的差异。
<img src="https://static001.geekbang.org/resource/image/d3/a6/d39b0f2b3962d646133d450541fb75a6.png" alt="">
以上一讲的Intel 8265U的CPU为例它的L1 Cache只有256KL2 Cache有个1MBL3 Cache有12MB。一共13MB的存储空间如果按照7美元/1MB的价格计算就要91美元。
我们的内存有8GB容量是CPU Cache的600多倍按照表上的价格差不多就是120美元。如果按照今天京东上的价格恐怕不到40美元。128G的SSD和1T的HDD现在的价格加起来也不会超过100美元。虽然容量是内存的16倍乃至128倍但是它们的访问速度却不到内存的1/1000。
性能和价格的巨大差异,给我们工程师带来了一个挑战:**我们能不能既享受CPU Cache的速度又享受内存、硬盘巨大的容量和低廉的价格呢**你可以停下来自己思考一下,或者点击文章右上方的“请朋友读”,邀请你的朋友一起来思考这个问题。然后,再一起听我的讲解。
好了,现在我公布答案。想要同时享受到这三点,前辈们已经探索出了答案,那就是,存储器中数据的**局部性原理**Principle of Locality。我们可以利用这个局部性原理来制定管理和访问数据的策略。这个局部性原理包括**时间局部性**temporal locality和**空间局部性**spatial locality这两种策略。
我们先来看**时间局部性**。这个策略是说,如果一个数据被访问了,那么它在短时间内还会被再次访问。这么看这个策略有点奇怪是吧?我用一个简单的例子给你解释下,你一下就能明白了。
比如说《哈利波特与魔法石》这本小说我今天读了一会儿没读完明天还会继续读。同理在一个电子商务型系统中如果一个用户打开了App看到了首屏。我们推断他应该很快还会再次访问网站的其他内容或者页面我们就将这个用户的个人信息从存储在硬盘的数据库读取到内存的缓存中来。这利用的就是时间局部性。
<img src="https://static001.geekbang.org/resource/image/53/d3/53cb2d05d9bc8e3131466e9802d2c6d3.png" alt="">
我们再来看**空间局部性**。这个策略是说,如果一个数据被访问了,那么和它相邻的数据也很快会被访问。
我们还拿刚才读《哈利波特与魔法石》的例子来说。我读完了这本书之后感觉这书不错所以就会借阅整套“哈利波特”。这就好比我们的程序在访问了数组的首项之后多半会循环访问它的下一项。因为在存储数据的时候数组内的多项数据会存储在相邻的位置。这就好比图书馆会把“哈利波特”系列放在一个书架上摆放在一起加载的时候也会一并加载。我们去图书馆借书往往会一次性把7本都借回来。
<img src="https://static001.geekbang.org/resource/image/1e/67/1ecca5bc07486a4d829263c8d78df667.png" alt="">
有了时间局部性和空间局部性我们不用再把所有数据都放在内存里也不用都放在HDD硬盘上而是把访问次数多的数据放在贵但是快一点的存储器里把访问次数少的数据放在慢但是大一点的存储器里。这样组合使用内存、SSD硬盘以及HDD硬盘使得我们可以用最低的成本提供实际所需要的数据存储、管理和访问的需求。
## 如何花最少的钱,装下亚马逊的所有商品?
了解了局部性原理,下面我用一些真实世界中的数据举个例子,带你做个小小的思维体操,来看一看通过局部性原理,利用不同层次存储器的组合,究竟会有什么样的好处。
我们现在要提供一个亚马逊这样的电商网站。我们假设里面有6亿件商品如果每件商品需要4MB的存储空间考虑到商品图片的话4MB已经是一个相对较小的估计了那么一共需要2400TB = 6亿 × 4MB的数据存储。
如果我们把数据都放在内存里面那就需要3600万美元 = 2400TB/1MB × 0.015美元 = 3600万美元。但是这6亿件商品中不是每一件商品都会被经常访问。比如说有Kindle电子书这样的热销商品也一定有基本无人问津的商品比如偏门的缅甸语词典。
如果我们只在内存里放前1%的热门商品也就是600万件热门商品而把剩下的商品放在机械式的HDD硬盘上那么我们需要的存储成本就下降到45.6万美元( = 3600 万美元 × 1% + 2400TB / 1MB × 0.00004 美元是原来成本的1.3%左右。
这里我们用的就是时间局部性。我们把有用户访问过的数据,加载到内存中,一旦内存里面放不下了,我们就把最长时间没有在内存中被访问过的数据,从内存中移走,这个其实就是我们常用的**LRU**Least Recently Used**缓存算法**。热门商品被访问得多就会始终被保留在内存里而冷门商品被访问得少就只存放在HDD硬盘上数据的读取也都是直接访问硬盘。即使加载到内存中也会很快被移除。越是热门的商品越容易在内存中找到也就更好地利用了内存的随机访问性能。
那么只放600万件商品真的可以满足我们实际的线上服务请求吗这个就要看LRU缓存策略的**缓存命中率**Hit Rate/Hit Ratio也就是访问的数据中可以在我们设置的内存缓存中找到的占有多大比例。
内存的随机访问请求需要100ns。这也就意味着在极限情况下内存可以支持1000万次随机访问。我们用了24TB内存如果8G一条的话意味着有3000条内存可以支持每秒300亿次 = 24TB/8GB × 1s/100ns访问。以亚马逊2017年3亿的用户数来看我们估算每天的活跃用户为1亿这1亿用户每人平均会访问100个商品那么平均每秒访问的商品数量就是12万次。
但是如果数据没有命中内存那么对应的数据请求就要访问到HDD磁盘了。刚才的图表中我写了一块HDD硬盘只能支撑每秒100次的随机访问2400TB的数据以4TB一块磁盘来计算有600块磁盘也就是能支撑每秒 6万次 = 2400TB/4TB × 1s/10ms )的随机访问。
这就意味着所有的商品访问请求都直接到了HDD磁盘HDD磁盘支撑不了这样的压力。我们至少要50%的缓存命中率HDD磁盘才能支撑对应的访问次数。不然的话我们要么选择添加更多数量的HDD硬盘做到每秒12万次的随机访问或者将HDD替换成SSD硬盘让单个硬盘可以支持更多的随机访问请求。
<img src="https://static001.geekbang.org/resource/image/fb/58/fb32dd8a5847745d07a1b17254c75158.png" alt="">
当然,这里我们只是一个简单的估算。在实际的应用程序中,查看一个商品的数据可能意味着不止一次的随机内存或者随机磁盘的访问。对应的数据存储空间也不止要考虑数据,还需要考虑维护数据结构的空间,而缓存的命中率和访问请求也要考虑均值和峰值的问题。
通过这个估算过程,你需要理解,如何进行存储器的硬件规划。你需要考虑硬件的成本、访问的数据量以及访问的数据分布,然后根据这些数据的估算,来组合不同的存储器,能用尽可能低的成本支撑所需要的服务器压力。而当你用上了数据访问的局部性原理,组合起了多种存储器,你也就理解了怎么基于存储器层次结构,来进行硬件规划了。
## 总结延伸
这一讲,我们讲解了计算机存储器层次结构中最重要的一个优化思路,就是局部性原理。
在实际的计算机日常的开发和应用中,我们对于数据的访问总是会存在一定的局部性。有时候,这个局部性是时间局部性,就是我们最近访问过的数据还会被反复访问。有时候,这个局部性是空间局部性,就是我们最近访问过数据附近的数据很快会被访问到。
而局部性的存在,使得我们可以在应用开发中使用缓存这个有利的武器。比如,通过将热点数据加载并保留在速度更快的存储设备里面,我们可以用更低的成本来支撑服务器。
通过亚马逊这个例子,我们可以看到,我们可以通过快速估算的方式,来判断这个添加缓存的策略是否能够满足我们的需求,以及在估算的服务器负载的情况下,需要规划多少硬件设备。这个“估算+规划”的能力,是每一个期望成长为架构师的工程师,必须掌握的能力。
最后回到这一讲的开头我问了你这样一个问题在遇到性能问题特别是访问存储器的性能问题的时候是否可以简单地添加一层数据缓存就能让问题迎刃而解呢今天这个亚马逊网站商品数据的例子似乎给了我们一个“Yes”的答案。那么这个答案是否放之四海皆准呢后面的几讲我们会深入各种应用场景进一步来回答这个问题。
## 推荐阅读
想要仔细了解各种存储器和局部性原理,你还是可以去读一读教科书。《计算机组成与设计:硬件/软件接口》的5.15.2小节,是一个很好的阅读材料。
## 课后思考
我们今天拿了亚马逊的商品和用户访问数据做了例子。请你想一下如果是拿商品数量更多的淘宝网来看你可以估算一下至少需要使用多少DRAM的内存或者其他存储设备呢
欢迎留言和我分享你的思考过程和最终答案。如果自己的力量无法解决,你也可以拉上你的朋友一起讨论。

View File

@@ -0,0 +1,119 @@
<audio id="audio" title="37 | 高速缓存“4毫秒”究竟值多少钱" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/c6/c1029221178435f1b6d678db4a0f2bc6.mp3"></audio>
在这一节内容开始之前我们先来看一个3行的小程序。你可以猜一猜这个程序里的循环1和循环2运行所花费的时间会差多少你可以先思考几分钟然后再看我下面的解释。
```
int[] arr = new int[64 * 1024 * 1024];
// 循环1
for (int i = 0; i &lt; arr.length; i++) arr[i] *= 3;
// 循环2
for (int i = 0; i &lt; arr.length; i += 16) arr[i] *= 3
```
在这段Java程序中我们首先构造了一个64×1024×1024大小的整型数组。在循环1里我们遍历整个数组将数组中每一项的值变成了原来的3倍在循环2里我们每隔16个索引访问一个数组元素将这一项的值变成了原来的3倍。
按道理来说循环2只访问循环1中1/16的数组元素只进行了循环1中1/16的乘法计算那循环2花费的时间应该是循环1的1/16左右。但是实际上循环1在我的电脑上运行需要50毫秒循环2只需要46毫秒。这两个循环花费时间之差在15%之内。
为什么会有这15%的差异呢这和我们今天要讲的CPU Cache有关。之前我们看到了内存和硬盘之间存在的巨大性能差异。在CPU眼里内存也慢得不行。于是聪明的工程师们就在CPU里面嵌入了CPU Cache高速缓存来解决这一问题。
## 我们为什么需要高速缓存?
按照[摩尔定律](https://zh.wikipedia.org/wiki/%E6%91%A9%E5%B0%94%E5%AE%9A%E5%BE%8B)CPU的访问速度每18个月便会翻一番相当于每年增长60%。内存的访问速度虽然也在不断增长却远没有这么快每年只增长7%左右。而这两个增长速度的差异使得CPU性能和内存访问性能的差距不断拉大。到今天来看一次内存的访问大约需要120个CPU Cycle这也意味着在今天CPU和内存的访问速度已经有了120倍的差距。
如果拿我们现实生活来打个比方的话CPU的速度好比风驰电掣的高铁每小时350公里然而它却只能等着旁边腿脚不太灵便的老太太也就是内存以每小时3公里的速度缓慢步行。因为CPU需要执行的指令、需要访问的数据都在这个速度不到自己1%的内存里。
<img src="https://static001.geekbang.org/resource/image/4f/4b/4fc459f42a67d3949402865a998bf34b.png" alt="">
为了弥补两者之间的性能差异我们能真实地把CPU的性能提升用起来而不是让它在那儿空转我们在现代CPU中引入了高速缓存。
从CPU Cache被加入到现有的CPU里开始内存中的指令、数据会被加载到L1-L3 Cache中而不是直接由CPU访问内存去拿。在95%的情况下CPU都只需要访问L1-L3 Cache从里面读取指令和数据而无需访问内存。要注意的是这里我们说的CPU Cache或者L1/L3 Cache不是一个单纯的、概念上的缓存比如之前我们说的拿内存作为硬盘的缓存而是指特定的由SRAM组成的物理芯片。
这里是一张Intel CPU的放大照片。这里面大片的长方形芯片就是这个CPU使用的20MB的L3 Cache。
<img src="https://static001.geekbang.org/resource/image/c1/15/c1dc0e3453f469fc4607557dab9d5215.jpg" alt="">
在这一讲一开始的程序里运行程序的时间主要花在了将对应的数据从内存中读取出来加载到CPU Cache里。CPU从内存中读取数据到CPU Cache的过程中是一小块一小块来读取数据的而不是按照单个数组元素来读取数据的。这样一小块一小块的数据在CPU Cache里面我们把它叫作Cache Line缓存块
在我们日常使用的Intel服务器或者PC里Cache Line的大小通常是64字节。而在上面的循环2里面我们每隔16个整型数计算一次16个整型数正好是64个字节。于是循环1和循环2需要把同样数量的Cache Line数据从内存中读取到CPU Cache中最终两个程序花费的时间就差别不大了。
知道了为什么需要CPU Cache接下来我们就来看一看CPU究竟是如何访问CPU Cache的以及CPU Cache是如何组织数据使得CPU可以找到自己想要访问的数据的。因为Cache作为“缓存”的意思在很多别的存储设备里面都会用到。为了避免你混淆在表示抽象的“缓存“概念时用中文的“缓存”如果是CPU Cache我会用“高速缓存“或者英文的“Cache”来表示。
## Cache的数据结构和读取过程是什么样的
现代CPU进行数据读取的时候无论数据是否已经存储在Cache中CPU始终会首先访问Cache。只有当CPU在Cache中找不到数据的时候才会去访问内存并将读取到的数据写入Cache之中。当时间局部性原理起作用后这个最近刚刚被访问的数据会很快再次被访问。而Cache的访问速度远远快于内存这样CPU花在等待内存访问上的时间就大大变短了。
<img src="https://static001.geekbang.org/resource/image/3a/cc/3a6fcfd1155e03f4f2781dbb6ddaf6cc.png" alt="">
这样的访问机制和我们自己在开发应用系统的时候“使用内存作为硬盘的缓存”的逻辑是一样的。在各类基准测试Benchmark和实际应用场景中CPU Cache的命中率通常能达到95%以上。
问题来了CPU如何知道要访问的内存数据存储在Cache的哪个位置呢接下来我就从最基本的**直接映射Cache**Direct Mapped Cache说起带你来看整个Cache的数据结构和访问逻辑。
在开头的3行小程序里我说过CPU访问内存数据是一小块一小块数据来读取的。对于读取内存中的数据我们首先拿到的是数据所在的**内存块**Block的地址。而直接映射Cache采用的策略就是确保任何一个内存块的地址始终映射到一个固定的CPU Cache地址Cache Line。而这个映射关系通常用mod运算求余运算来实现。下面我举个例子帮你理解一下。
比如说我们的主内存被分成031号这样32个块。我们一共有8个缓存块。用户想要访问第21号内存块。如果21号内存块内容在缓存块中的话它一定在5号缓存块21 mod 8 = 5中。
<img src="https://static001.geekbang.org/resource/image/52/22/522eade51bbfad19fd25eb4f3ce80f22.png" alt="">
实际计算中有一个小小的技巧通常我们会把缓存块的数量设置成2的N次方。这样在计算取模的时候可以直接取地址的低N位也就是二进制里面的后几位。比如这里的8个缓存块就是2的3次方。那么在对21取模的时候可以对21的2进制表示10101取地址的低三位也就是101对应的5就是对应的缓存块地址。
<img src="https://static001.geekbang.org/resource/image/ca/80/caadd2728b5cfcd2bd704103570f3a80.png" alt="">
取Block地址的低位就能得到对应的Cache Line地址除了21号内存块外13号、5号等很多内存块的数据都对应着5号缓存块中。既然如此假如现在CPU想要读取21号内存块在读取到5号缓存块的时候我们怎么知道里面的数据究竟是不是21号对应的数据呢同样建议你借助现有知识先自己思考一下然后再看我下面的分析这样会印象比较深刻。
这个时候,在对应的缓存块中,我们会存储一个**组标记**Tag。这个组标记会记录当前缓存块内存储的数据对应的内存块而缓存块本身的地址表示访问地址的低N位。就像上面的例子21的低3位101缓存块本身的地址已经涵盖了对应的信息、对应的组标记我们只需要记录21剩余的高2位的信息也就是10就可以了。
除了组标记信息之外,缓存块中还有两个数据。一个自然是从主内存中加载来的实际存放的数据,另一个是**有效位**valid bit。啥是有效位呢它其实就是用来标记对应的缓存块中的数据是否是有效的确保不是机器刚刚启动时候的空数据。如果有效位是0无论其中的组标记和Cache Line里的数据内容是什么CPU都不会管这些数据而要直接访问内存重新加载数据。
CPU在读取数据的时候并不是要读取一整个Block而是读取一个他需要的数据片段。这样的数据我们叫作CPU里的一个字Word。具体是哪个字就用这个字在整个Block里面的位置来决定。这个位置我们叫作偏移量Offset
总结一下,**一个内存的访问地址最终包括高位代表的组标记、低位代表的索引以及在对应的Data Block中定位对应字的位置偏移量。**
<img src="https://static001.geekbang.org/resource/image/13/d4/1313fe1e4eb3b5c949284c8b215af8d4.png" alt="">
而内存地址对应到Cache里的数据结构则多了一个有效位和对应的数据由“**索引 + 有效位** **+ 组标记 + 数据**”组成。如果内存中的数据已经在CPU Cache里了那一个内存地址的访问就会经历这样4个步骤
1. 根据内存地址的低位计算在Cache中的索引
1. 判断有效位确认Cache中的数据是有效的
1. 对比内存访问地址的高位和Cache中的组标记确认Cache中的数据就是我们要访问的内存数据从Cache Line中读取到对应的数据块Data Block
1. 根据内存地址的Offset位从Data Block中读取希望读取到的字。
如果在2、3这两个步骤中CPU发现Cache中的数据并不是要访问的内存地址的数据那CPU就会访问内存并把对应的Block Data更新到Cache Line中同时更新对应的有效位和组标记的数据。
好了讲到这里相信你明白现代CPU是如何通过直接映射Cache来定位一个内存访问地址在Cache中的位置了。其实除了直接映射Cache之外我们常见的缓存放置策略还有全相连CacheFully Associative Cache、组相连CacheSet Associative Cache。这几种策略的数据结构都是相似的理解了最简单的直接映射Cache其他的策略你很容易就能理解了。
## 减少4毫秒公司挣了多少钱?
刚才我花了很多篇幅讲了CPU和内存之间的性能差异以及我们如何通过CPU Cache来尽可能解决这两者之间的性能鸿沟。你可能要问了这样做的意义和价值究竟是什么毕竟一次内存的访问只不过需要100纳秒而已。1秒钟时间内足有1000万个100纳秒。别着急我们先来看一个故事。
2008年一家叫作Spread Networks的通信公司花费3亿美元做了一个光缆建设项目。目标是建设一条从芝加哥到新泽西总长1331公里的光缆线路。建设这条线路的目的其实是为了将两地之间原有的网络访问延时从17毫秒降低到13毫秒。
你可能会说仅仅缩短了4毫秒时间啊却花费3个亿真的值吗为这4毫秒时间买单的其实是一批高频交易公司。它们以5年1400万美元的价格使用这条线路。利用这短短的4毫秒的时间优势这些公司通过高性能的计算机程序在芝加哥和新泽西两地的交易所进行高频套利以获得每年以10亿美元计的利润。现在你还觉得这个不值得吗
其实只要350微秒的差异就足够高频交易公司用来进行无风险套利了。而350微秒如果用来进行100纳秒一次的内存访问大约只够进行3500次。而引入CPU Cache之后我们可以进行的数据访问次数提升了数十倍使得各种交易策略成为可能。
## 总结延伸
很多时候程序的性能瓶颈来自使用DRAM芯片的内存访问速度。
根据摩尔定律自上世纪80年代以来CPU和内存的性能鸿沟越拉越大。于是现代CPU的设计者们直接在CPU中嵌入了使用更高性能的SRAM芯片的Cache来弥补这一性能差异。通过巧妙地将内存地址拆分成“索引+组标记+偏移量”的方式使得我们可以将很大的内存地址映射到很小的CPU Cache地址里。而CPU Cache带来的毫秒乃至微秒级别的性能差异又能带来巨大的商业利益十多年前的高频交易行业就是最好的例子。
在搞清楚从内存加载数据到Cache以及从Cache里读取到想要的数据之后我们又要面临一个新的挑战了。CPU不仅要读数据还需要写数据我们不能只把数据写入到Cache里面就结束了。下一讲我们就来仔细讲讲CPU要写入数据的时候怎么既不牺牲性能又能保证数据的一致性。
## 推荐阅读
如果你学有余力,这里有两篇文章推荐给你阅读。
如果想深入了解CPU和内存之间的访问性能你可以阅读[What Every Programmer Should Know About Memory](https://people.freebsd.org/~lstewart/articles/cpumemory.pdf)。
现代CPU已经很少使用直接映射Cache了通常用的是组相连Cacheset associative cache想要了解组相连Cache你可以阅读《计算机组成与设计硬件/软件接口》的5.4.1小节。
## 课后思考
对于二维数组的访问,按行迭代和按列迭代的访问性能是一样的吗?你可以写一个程序测试一下,并思考一下原因。
欢迎把你思考的结果写在留言区。如果觉得有收获,你也可以把这篇文章分享给你的朋友,和他一起讨论和学习。

View File

@@ -0,0 +1,217 @@
<audio id="audio" title="38 | 高速缓存(下):你确定你的数据更新了么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/c8/a103021b2e107995dc95629e81ce80c8.mp3"></audio>
在我工作的十几年里写了很多Java的程序。同时我也面试过大量的Java工程师。对于一些表示自己深入了解和擅长多线程的同学我经常会问这样一个面试题“**volatile这个关键字有什么作用**”如果你或者你的朋友写过Java程序不妨来一起试着回答一下这个问题。
就我面试过的工程师而言即使是工作了多年的Java工程师也很少有人能准确说出volatile这个关键字的含义。这里面最常见的理解错误有两个一个是把volatile当成一种锁机制认为给变量加上了volatile就好像是给函数加了sychronized关键字一样不同的线程对于特定变量的访问会去加锁另一个是把volatile当成一种原子化的操作机制认为加了volatile之后对于一个变量的自增的操作就会变成原子性的了。
```
// 一种错误的理解是把volatile关键词当成是一个锁可以把long/double这样的数的操作自动加锁
private volatile long synchronizedValue = 0;
// 另一种错误的理解是把volatile关键词当成可以让整数自增的操作也变成原子性的
private volatile int atomicInt = 0;
amoticInt++;
```
事实上这两种理解都是完全错误的。很多工程师容易把volatile关键字当成和锁或者数据数据原子性相关的知识点。而实际上volatile关键字的最核心知识点要关系到Java内存模型JMMJava Memory Model上。
虽然JMM只是Java虚拟机这个进程级虚拟机里的一个内存模型但是这个内存模型和计算机组成里的CPU、高速缓存和主内存组合在一起的硬件体系非常相似。理解了JMM可以让你很容易理解计算机组成里CPU、高速缓存和主内存之间的关系。
## “隐身”的变量
我们先来一起看一段Java程序。这是一段经典的volatile代码来自知名的Java开发者网站[dzone.com](https://dzone.com/articles/java-volatile-keyword-0),后续我们会修改这段代码来进行各种小实验。
```
public class VolatileTest {
private static volatile int COUNTER = 0;
public static void main(String[] args) {
new ChangeListener().start();
new ChangeMaker().start();
}
static class ChangeListener extends Thread {
@Override
public void run() {
int threadValue = COUNTER;
while ( threadValue &lt; 5){
if( threadValue!= COUNTER){
System.out.println(&quot;Got Change for COUNTER : &quot; + COUNTER + &quot;&quot;);
threadValue= COUNTER;
}
}
}
}
static class ChangeMaker extends Thread{
@Override
public void run() {
int threadValue = COUNTER;
while (COUNTER &lt;5){
System.out.println(&quot;Incrementing COUNTER to : &quot; + (threadValue+1) + &quot;&quot;);
COUNTER = ++threadValue;
try {
Thread.sleep(500);
} catch (InterruptedException e) { e.printStackTrace(); }
}
}
}
}
```
我们先来看看这个程序做了什么。在这个程序里我们先定义了一个volatile的int类型的变量COUNTER。
然后我们分别启动了两个单独的线程一个线程我们叫ChangeListener。另一个线程我们叫ChangeMaker。
ChangeListener这个线程运行的任务很简单。它先取到COUNTER当前的值然后一直监听着这个COUNTER的值。一旦COUNTER的值发生了变化就把新的值通过println打印出来。直到COUNTER的值达到5为止。这个监听的过程通过一个永不停歇的while循环的忙等待来实现。
ChangeMaker这个线程运行的任务同样很简单。它同样是取到COUNTER的值在COUNTER小于5的时候每隔500毫秒就让COUNTER自增1。在自增之前通过println方法把自增后的值打印出来。
最后在main函数里我们分别启动这两个线程来看一看这个程序的执行情况。程序的输出结果并不让人意外。ChangeMaker函数会一次一次将COUNTER从0增加到5。因为这个自增是每500毫秒一次而ChangeListener去监听COUNTER是忙等待的所以每一次自增都会被ChangeListener监听到然后对应的结果就会被打印出来。
```
Incrementing COUNTER to : 1
Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Got Change for COUNTER : 5
```
这个时候我们就可以来做一个很有意思的实验。如果我们把上面的程序小小地修改一行代码把我们定义COUNTER这个变量的时候设置的volatile关键字给去掉会发生什么事情呢你可以自己先试一试看结果是否会让你大吃一惊。
```
private static int COUNTER = 0;
```
没错你会发现我们的ChangeMaker还是能正常工作的每隔500ms仍然能够对COUNTER自增1。但是奇怪的事情在ChangeListener上发生了我们的ChangeListener不再工作了。在ChangeListener眼里它似乎一直觉得COUNTER的值还是一开始的0。似乎COUNTER的变化对于我们的ChangeListener彻底“隐身”了。
```
Incrementing COUNTER to : 1
Incrementing COUNTER to : 2
Incrementing COUNTER to : 3
Incrementing COUNTER to : 4
Incrementing COUNTER to : 5
```
这个有意思的小程序还没有结束我们可以再对程序做一些小小的修改。我们不再让ChangeListener进行完全的忙等待而是在while循环里面小小地等待上5毫秒看看会发生什么情况。
```
static class ChangeListener extends Thread {
@Override
public void run() {
int threadValue = COUNTER;
while ( threadValue &lt; 5){
if( threadValue!= COUNTER){
System.out.println(&quot;Sleep 5ms, Got Change for COUNTER : &quot; + COUNTER + &quot;&quot;);
threadValue= COUNTER;
}
try {
Thread.sleep(5);
} catch (InterruptedException e) { e.printStackTrace(); }
}
}
}
```
好了不知道你有没有自己动手试一试呢又一个令人惊奇的现象要发生了。虽然我们的COUNTER变量仍然没有设置volatile这个关键字但是我们的ChangeListener似乎“睡醒了”。在通过Thread.sleep(5)在每个循环里“睡上“5毫秒之后ChangeListener又能够正常取到COUNTER的值了。
```
Incrementing COUNTER to : 1
Sleep 5ms, Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Sleep 5ms, Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Sleep 5ms, Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Sleep 5ms, Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Sleep 5ms, Got Change for COUNTER : 5
```
这些有意思的现象其实来自于我们的Java内存模型以及关键字volatile的含义。**那volatile关键字究竟代表什么含义呢它会确保我们对于这个变量的读取和写入都一定会同步到主内存里而不是从Cache里面读取。**该怎么理解这个解释呢?我们通过刚才的例子来进行分析。
刚刚第一个使用了volatile关键字的例子里因为所有数据的读和写都来自主内存。那么自然地我们的ChangeMaker和ChangeListener之间看到的COUNTER值就是一样的。
到了第二段进行小小修改的时候我们去掉了volatile关键字。这个时候ChangeListener又是一个忙等待的循环它尝试不停地获取COUNTER的值这样就会从当前线程的“Cache”里面获取。于是这个线程就没有时间从主内存里面同步更新后的COUNTER值。这样它就一直卡死在COUNTER=0的死循环上了。
而到了我们再次修改的第三段代码里面虽然还是没有使用volatile关键字但是短短5ms的Thead.Sleep给了这个线程喘息之机。既然这个线程没有这么忙了它也就有机会把最新的数据从主内存同步到自己的高速缓存里面了。于是ChangeListener在下一次查看COUNTER值的时候就能看到ChangeMaker造成的变化了。
虽然Java内存模型是一个隔离了硬件实现的虚拟机内的抽象模型但是它给了我们一个很好的“缓存同步”问题的示例。也就是说如果我们的数据在不同的线程或者CPU核里面去更新因为不同的线程或CPU核有着自己各自的缓存很有可能在A线程的更新到B线程里面是看不见的。
## CPU高速缓存的写入
事实上我们可以把Java内存模型和计算机组成里的CPU结构对照起来看。
我们现在用的Intel CPU通常都是多核的的。每一个CPU核里面都有独立属于自己的L1、L2的Cache然后再有多个CPU核共用的L3的Cache、主内存。
因为CPU Cache的访问速度要比主内存快很多而在CPU Cache里面L1/L2的Cache也要比L3的Cache快。所以上一讲我们可以看到CPU始终都是尽可能地从CPU Cache中去获取数据而不是每一次都要从主内存里面去读取数据。
<img src="https://static001.geekbang.org/resource/image/07/41/0723f72f3016fede96b545e2898c0541.jpeg" alt="">
这个层级结构就好像我们在Java内存模型里面每一个线程都有属于自己的线程栈。线程在读取COUNTER的数据的时候其实是从本地的线程栈的Cache副本里面读取数据而不是从主内存里面读取数据。如果我们对于数据仅仅只是读问题还不大。我们在上一讲里已经看到Cache Line的组成以及如何从内存里面把对应的数据加载到Cache里。
但是,对于数据,我们不光要读,还要去写入修改。这个时候,有两个问题来了。
**第一个问题是写入Cache的性能也比写入主内存要快那我们写入的数据到底应该写到Cache里还是主内存呢如果我们直接写入到主内存里Cache里的数据是否会失效呢**为了解决这些疑问,下面我要给你介绍两种写入策略。
### 写直达Write-Through
<img src="https://static001.geekbang.org/resource/image/8b/d3/8b9ad674953bf36680e815247de235d3.jpeg" alt="">
最简单的一种写入策略叫作写直达Write-Through。在这个策略里每一次数据都要写入到主内存里面。在写直达的策略里面写入前我们会先去判断数据是否已经在Cache里面了。如果数据已经在Cache里面了我们先把数据写入更新到Cache里面再写入到主内存里面如果数据不在Cache里我们就只更新主内存。
写直达的这个策略很直观但是问题也很明显那就是这个策略很慢。无论数据是不是在Cache里面我们都需要把数据写到主内存里面。这个方式就有点儿像我们上面用volatile关键字始终都要把数据同步到主内存里面。
### 写回Write-Back
<img src="https://static001.geekbang.org/resource/image/67/0d/67053624d6aa2a5c27c295e1fda4890d.jpeg" alt="">
这个时候我们就想了既然我们去读数据也是默认从Cache里面加载能否不用把所有的写入都同步到主内存里呢只写入CPU Cache里面是不是可以
当然是可以的。在CPU Cache的写入策略里还有一种策略就叫作写回Write-Back。这个策略里我们不再是每次都把数据写入到主内存而是只写到CPU Cache里。只有当CPU Cache里面的数据要被“替换”的时候我们才把数据写入到主内存里面去。
写回策略的过程是这样的如果发现我们要写入的数据就在CPU Cache里面那么我们就只是更新CPU Cache里面的数据。同时我们会标记CPU Cache里的这个Block是脏Dirty的。所谓脏的就是指这个时候我们的CPU Cache里面的这个Block的数据和主内存是不一致的。
如果我们发现我们要写入的数据所对应的Cache Block里放的是别的内存地址的数据那么我们就要看一看那个Cache Block里面的数据有没有被标记成脏的。如果是脏的话我们要先把这个Cache Block里面的数据写入到主内存里面。然后再把当前要写入的数据写入到Cache里同时把Cache Block标记成脏的。如果Block里面的数据没有被标记成脏的那么我们直接把数据写入到Cache里面然后再把Cache Block标记成脏的就好了。
在用了写回这个策略之后我们在加载内存数据到Cache里面的时候也要多出一步同步脏Cache的动作。如果加载内存里面的数据到Cache的时候发现Cache Block里面有脏标记我们也要先把Cache Block里的数据写回到主内存才能加载数据覆盖掉Cache。
可以看到,在写回这个策略里,如果我们大量的操作,都能够命中缓存。那么大部分时间里,我们都不需要读写主内存,自然性能会比写直达的效果好很多。
然而无论是写回还是写直达其实都还没有解决我们在上面volatile程序示例中遇到的问题也就是**多个线程或者是多个CPU核的缓存一致性的问题。这也就是我们在写入修改缓存后需要解决的第二个问题。**
要解决这个问题我们需要引入一个新的方法叫作MESI协议。这是一个维护缓存一致性协议。这个协议不仅可以用在CPU Cache之间也可以广泛用于各种需要使用缓存同时缓存之间需要同步的场景下。今天的内容差不多了我们放在下一讲仔细讲解缓存一致性问题。
## 总结延伸
最后我们一起来回顾一下这一讲的知识点。通过一个使用Java程序中使用volatile关键字程序我们可以看到在有缓存的情况下会遇到一致性问题。volatile这个关键字可以保障我们对于数据的读写都会到达主内存。
进一步地我们可以看到Java内存模型和CPU、CPU Cache以及主内存的组织结构非常相似。在CPU Cache里对于数据的写入我们也有写直达和写回这两种解决方案。写直达把所有的数据都直接写入到主内存里面简单直观但是性能就会受限于内存的访问速度。而写回则通常只更新缓存只有在需要把缓存里面的脏数据交换出去的时候才把数据同步到主内存里。在缓存经常会命中的情况下性能更好。
但是,除了采用读写都直接访问主内存的办法之外,如何解决缓存一致性的问题,我们还是没有解答。这个问题的解决方案,我们放到下一讲来详细解说。
## 推荐阅读
如果你是一个Java程序员我推荐你去读一读 [Fixing Java Memory Model](https://www.ibm.com/developerworks/java/library/j-jtp03304/index.html) 这篇文章。读完这些内容相信你会对Java里的内存模型和多线程原理有更深入的了解并且也能更好地和我们计算机底层的硬件架构联系起来。
对于计算机组成的CPU高速缓存的写操作处理你也可以读一读《计算机组成与设计硬件/软件接口》的5.3.3小节。
## 课后思考
最后给你留一道思考题。既然volatile关键字会让所有的数据写入都要到主内存。你可以试着写一个小的程序看看使用volatile关键字和不使用volatile关键字在数据写入的性能上会不会有差异以及这个差异到底会有多大。
欢迎把你写的程序分享到留言区。如果有困难,你也可以把这个问题分享给你朋友,拉上他一起讨论完成,并在留言区写下你们讨论后的结果。

View File

@@ -0,0 +1,104 @@
<audio id="audio" title="39 | MESI协议如何让多核CPU的高速缓存保持一致" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/67/a5/67084c8e497bd47cf7c1f46d2b799ea5.mp3"></audio>
你平时用的电脑应该都是多核的CPU。多核CPU有很多好处其中最重要的一个就是它使得我们在不能提升CPU的主频之后找到了另一种提升CPU吞吐率的办法。
不知道上一讲的内容你还记得多少上一节我们讲到多核CPU里的每一个CPU核都有独立的属于自己的L1 Cache和L2 Cache。多个CPU之间只是共用L3 Cache和主内存。
我们说CPU Cache解决的是内存访问速度和CPU的速度差距太大的问题。而多核CPU提供的是在主频难以提升的时候通过增加CPU核心来提升CPU的吞吐率的办法。我们把多核和CPU Cache两者一结合就给我们带来了一个新的挑战。因为CPU的每个核各有各的缓存互相之间的操作又是各自独立的就会带来[**缓存一致性**](https://en.wikipedia.org/wiki/Cache_coherence)Cache Coherence的问题。
<img src="https://static001.geekbang.org/resource/image/07/41/0723f72f3016fede96b545e2898c0541.jpeg" alt="">
## 缓存一致性问题
那什么是缓存一致性呢我们拿一个有两个核心的CPU来看一下。你可以看这里这张图我们结合图来说。
<img src="https://static001.geekbang.org/resource/image/a6/da/a6146ddd5c78f2cbc1af56b0ee3292da.jpeg" alt="">
在这两个CPU核心里1号核心要写一个数据到内存里。这个怎么理解呢我拿一个例子来给你解释。
比方说iPhone降价了我们要把iPhone最新的价格更新到内存里。为了性能问题它采用了上一讲我们说的写回策略先把数据写入到L2 Cache里面然后把Cache Block标记成脏的。这个时候数据其实并没有被同步到L3 Cache或者主内存里。1号核心希望在这个Cache Block要被交换出去的时候数据才写入到主内存里。
如果我们的CPU只有1号核心这一个CPU核那这其实是没有问题的。不过我们旁边还有一个2号核心呢这个时候2号核心尝试从内存里面去读取iPhone的价格结果读到的是一个错误的价格。这是因为iPhone的价格刚刚被1号核心更新过。但是这个更新的信息只出现在1号核心的L2 Cache里而没有出现在2号核心的L2 Cache或者主内存里面。**这个问题就是所谓的缓存一致性问题1号核心和2号核心的缓存在这个时候是不一致的。**
为了解决这个缓存不一致的问题,我们就需要有一种机制,来同步两个不同核心里面的缓存数据。那这样的机制需要满足什么条件呢?我觉得能够做到下面两点就是合理的。
第一点叫**写传播**Write Propagation。写传播是说在一个CPU核心里我们的Cache数据更新必须能够传播到其他的对应节点的Cache Line里。
第二点叫**事务的串行化**Transaction Serialization事务串行化是说我们在一个CPU核心里面的读取和写入在其他的节点看起来顺序是一样的。
第一点写传播很容易理解。既然我们数据写完了自然要同步到其他CPU核的Cache里。但是第二点事务的串行化可能没那么好理解我这里仔细解释一下。
我们还拿刚才修改iPhone的价格来解释。这一次我们找一个有4个核心的CPU。1号核心呢先把iPhone的价格改成了5000块。差不多在同一个时间2号核心把iPhone的价格改成了6000块。这里两个修改都会传播到3号核心和4号核心。
<img src="https://static001.geekbang.org/resource/image/fe/6c/fe0c449e71800c5ad2e4b84af0d6e46c.jpeg" alt="">
然而这里有个问题3号核心先收到了2号核心的写传播再收到1号核心的写传播。所以3号核心看到的iPhone价格是先变成了6000块再变成了5000块。而4号核心呢是反过来的先看到变成了5000块再变成6000块。虽然写传播是做到了但是各个Cache里面的数据是不一致的。
事实上我们需要的是从1号到4号核心都能看到相同顺序的数据变化。比如说都是先变成了5000块再变成了6000块。这样我们才能称之为实现了事务的串行化。
事务的串行化,不仅仅是缓存一致性中所必须的。比如,我们平时所用到的系统当中,最需要保障事务串行化的就是数据库。多个不同的连接去访问数据库的时候,我们必须保障事务的串行化,做不到事务的串行化的数据库,根本没法作为可靠的商业数据库来使用。
而在CPU Cache里做到事务串行化需要做到两点第一点是一个CPU核心对于数据的操作需要同步通信给到其他CPU核心。第二点是如果两个CPU核心里有同一个数据的Cache那么对于这个Cache数据的更新需要有一个“锁”的概念。只有拿到了对应Cache Block的“锁”之后才能进行对应的数据更新。接下来我们就看看实现了这两个机制的MESI协议。
## 总线嗅探机制和MESI协议
要解决缓存一致性问题首先要解决的是多个CPU核心之间的数据传播问题。最常见的一种解决方案呢叫作**总线嗅探**Bus Snooping。这个名字听起来你多半会很陌生但是其实特很好理解。
这个策略本质上就是把所有的读写请求都通过总线Bus广播给所有的CPU核心然后让各个核心去“嗅探”这些请求再根据本地的情况进行响应。
总线本身就是一个特别适合广播进行数据传输的机制所以总线嗅探这个办法也是我们日常使用的Intel CPU进行缓存一致性处理的解决方案。关于总线这个知识点我们会放在后面的I/O部分更深入地进行讲解这里你只需要了解就可以了。
基于总线嗅探机制其实还可以分成很多种不同的缓存一致性协议。不过其中最常用的就是今天我们要讲的MESI协议。和很多现代的CPU技术一样MESI协议也是在Pentium时代被引入到Intel CPU中的。
MESI协议是一种叫作**写失效**Write Invalidate的协议。在写失效协议里只有一个CPU核心负责写入数据其他的核心只是同步读取到这个写入。在这个CPU核心写入Cache之后它会去广播一个“失效”请求告诉所有其他的CPU核心。其他的CPU核心只是去判断自己是否也有一个“失效”版本的Cache Block然后把这个也标记成失效的就好了。
相对于写失效协议,还有一种叫作**写广播**Write Broadcast的协议。在那个协议里一个写入请求广播到所有的CPU核心同时更新各个核心里的Cache。
写广播在实现上自然很简单但是写广播需要占用更多的总线带宽。写失效只需要告诉其他的CPU核心哪一个内存地址的缓存失效了但是写广播还需要把对应的数据传输给其他CPU核心。
<img src="https://static001.geekbang.org/resource/image/4e/59/4ed6d05049cbbc8603346f617206cd59.jpeg" alt="">
MESI协议的由来呢来自于我们对Cache Line的四个不同的标记分别是
- M代表已修改Modified
- E代表独占Exclusive
- S代表共享Shared
- I代表已失效Invalidated
我们先来看看“已修改”和“已失效”这两个状态比较容易理解。所谓的“已修改”就是我们上一讲所说的“脏”的Cache Block。Cache Block里面的内容我们已经更新过了但是还没有写回到主内存里面。而所谓的“已失效“自然是这个Cache Block里面的数据已经失效了我们不可以相信这个Cache Block里面的数据。
然后我们再来看“独占”和“共享”这两个状态。这就是MESI协议的精华所在了。无论是独占状态还是共享状态缓存里面的数据都是“干净”的。这个“干净”自然对应的是前面所说的“脏”的也就是说这个时候Cache Block里面的数据和主内存里面的数据是一致的。
那么“独占”和“共享”这两个状态的差别在哪里呢这个差别就在于在独占状态下对应的Cache Line只加载到了当前CPU核所拥有的Cache里。其他的CPU核并没有加载对应的数据到自己的Cache里。这个时候如果要向独占的Cache Block写入数据我们可以自由地写入数据而不需要告知其他CPU核。
在独占状态下的数据如果收到了一个来自于总线的读取对应缓存的请求它就会变成共享状态。这个共享状态是因为这个时候另外一个CPU核心也把对应的Cache Block从内存里面加载到了自己的Cache里来。
而在共享状态下因为同样的数据在多个CPU核心的Cache里都有。所以当我们想要更新Cache里面的数据的时候不能直接修改而是要先向所有的其他CPU核心广播一个请求要求先把其他CPU核心里面的Cache都变成无效的状态然后再更新当前Cache里面的数据。这个广播操作一般叫作RFORequest For Ownership也就是获取当前对应Cache Block数据的所有权。
有没有觉得这个操作有点儿像我们在多线程里面用到的读写锁。在共享状态下,大家都可以并行去读对应的数据。但是如果要写,我们就需要通过一个锁,获取当前写入位置的所有权。
整个MESI的状态可以用一个有限状态机来表示它的状态流转。需要注意的是对于不同状态触发的事件操作可能来自于当前CPU核心也可能来自总线里其他CPU核心广播出来的信号。我把对应的状态机流转图放在了下面你可以对照着[Wikipedia里面MESI的内容](https://zh.wikipedia.org/wiki/MESI%E5%8D%8F%E8%AE%AE),仔细研读一下。
<img src="https://static001.geekbang.org/resource/image/fa/d1/fa98835c78c879ab69fd1f29193e54d1.jpeg" alt="">
## 总结延伸
好了关于CPU Cache的内容我们介绍到这里就结束了。我们来总结一下。这一节我们其实就讲了两块儿内容一个是缓存一致性另一个是MESI协议。
想要实现缓存一致性关键是要满足两点。第一个是写传播也就是在一个CPU核心写入的内容需要传播到其他CPU核心里。更重要的是第二点保障事务的串行化才能保障我们的数据是真正一致的我们的程序在各个不同的核心上运行的结果也是一致的。这个特性不仅在CPU的缓存层面很重要在数据库层面更加重要。
之后我介绍了基于总线嗅探机制的MESI协议。MESI协议是一种基于写失效的缓存一致性协议。写失效的协议的好处是我们不需要在总线上传输数据内容而只需要传输操作信号和地址信号就好了不会那么占总线带宽。
MESI协议是已修改、独占、共享以及已失效这四个缩写的合称。独占和共享状态就好像我们在多线程应用开发里面的读写锁机制确保了我们的缓存一致性。而整个MESI的状态变更则是根据来自自己CPU核心的请求以及来自其他CPU核心通过总线传输过来的操作信号和地址信息进行状态流转的一个有限状态机。
## 推荐阅读
大部分计算机组成或者体系结构的教科书都没有提到缓存一致性问题。不过最近有一本国人写的计算机底层原理的书《大话计算机》里面的6.9章节比较详细地讲解了多核CPU的访问存储数据的一致性问题很值得仔细读一读。
## 课后思考
今天我们所讲的MESI缓存一致性协议其实是对于MSI缓存一致性协议的一个优化。你可以通过搜索引擎研究一下什么是MSI协议以及MESI相对于MSI协议究竟做了哪些优化
欢迎把你研究的结果写在留言区和大家分享。如果有收获,也欢迎你把这篇文章分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,93 @@
<audio id="audio" title="40 | 理解内存(上):虚拟内存和内存保护是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ef/c8/ef862f267e7cbcebbd4cbcf2a8a6a2c8.mp3"></audio>
我们在专栏一开始说过计算机有五大组成部分分别是运算器、控制器、存储器、输入设备和输出设备。如果说计算机最重要的组件是承担了运算器和控制器作用的CPU那内存就是我们第二重要的组件了。内存是五大组成部分里面的存储器我们的指令和数据都需要先加载到内存里面才会被CPU拿去执行。
专栏[第9讲](https://time.geekbang.org/column/article/95223)我们讲了程序装载到内存的过程。可以知道在我们日常使用的Linux或者Windows操作系统下程序并不能直接访问物理内存。
<img src="https://static001.geekbang.org/resource/image/0c/f0/0cf2f08e1ceda473df71189334857cf0.png" alt="">
我们的内存需要被分成固定大小的页Page然后再通过虚拟内存地址Virtual Address到物理内存地址Physical Address的地址转换Address Translation才能到达实际存放数据的物理内存位置。而我们的程序看到的内存地址都是虚拟内存地址。
既然如此,这些虚拟内存地址究竟是怎么转换成物理内存地址的呢?这一讲里,我们就来看一看。
## 简单页表
想要把虚拟内存地址,映射到物理内存地址,最直观的办法,就是来建一张映射表。这个映射表,能够实现虚拟内存里面的页,到物理内存里面的页的一一映射。这个映射表,在计算机里面,就叫作**页表**Page Table
页表这个地址转换的办法,会把一个内存地址分成**页号**Directory和**偏移量**Offset两个部分。这么说太理论了我以一个32位的内存地址为例帮你理解这个概念。
其实前面的高位就是内存地址的页号。后面的低位就是内存地址里面的偏移量。做地址转换的页表只需要保留虚拟内存地址的页号和物理内存地址的页号之间的映射关系就可以了。同一个页里面的内存在物理层面是连续的。以一个页的大小是4K字节4KB为例我们需要20位的高位12位的低位。
<img src="https://static001.geekbang.org/resource/image/22/0f/22bb79129f6363ac26be47b35748500f.jpeg" alt="">
总结一下,对于一个内存地址转换,其实就是这样三个步骤:
1. 把虚拟内存地址,切分成页号和偏移量的组合;
1. 从页表里面,查询出虚拟页号,对应的物理页号;
1. 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
<img src="https://static001.geekbang.org/resource/image/07/dd/07cd4c3344690055240f215404a286dd.jpeg" alt="">
看起来这个逻辑似乎很简单很容易理解不过问题马上就来了。你能算一算这样一个页表需要多大的空间吗我们以32位的内存地址空间为例你可以暂停一下拿出纸笔算一算。
不知道你算出的数字是多少32位的内存地址空间页表一共需要记录2^20个到物理页号的映射关系。这个存储关系就好比一个2^20大小的数组。一个页号是完整的32位的4字节Byte这样一个页表就需要4MB的空间。听起来4MB的空间好像还不大啊毕竟我们现在的内存至少也有4GB服务器上有个几十GB的内存和很正常。
<img src="https://static001.geekbang.org/resource/image/81/c9/8190dc8a065b06786f26ece596a8e9c9.jpg" alt="">
不过这个空间可不是只占用一份哦。我们每一个进程都有属于自己独立的虚拟内存地址空间。这也就意味着每一个进程都需要这样一个页表。不管我们这个进程是个本身只有几KB大小的程序还是需要几GB的内存空间都需要这样一个页表。如果你用的是Windows你可以打开你自己电脑上的任务管理器看看现在你的计算机里同时在跑多少个进程用这样的方式页表需要占用多大的内存。
这还只是32位的内存地址空间现在大家用的内存多半已经超过了4GB也已经用上了64位的计算机和操作系统。这样的话用上面这个数组的数据结构来保存页面内存占用就更大了。那么我们有没有什么更好的解决办法呢你可以先仔细思考一下。
## 多级页表
仔细想一想我们其实没有必要存下这2^20个物理页表啊。大部分进程所占用的内存是有限的需要的页也自然是很有限的。我们只需要去存那些用到的页之间的映射关系就好了。如果你对数据结构比较熟悉你可能要说了那我们是不是应该用哈希表Hash Map这样的数据结构呢
很可惜你猜错了。在实践中我们其实采用的是一种叫作多级页表Multi-Level Page Table的解决方案。这是为什么呢为什么我们不用哈希表而用多级页表呢别着急听我慢慢跟你讲。
我们先来看一看,一个进程的内存地址空间是怎么分配的。在整个进程的内存地址空间,通常是“两头实、中间空”。在程序运行的时候,内存地址从顶部往下,不断分配占用的栈的空间。而堆的空间,内存地址则是从底部往上,是不断分配占用的。
所以,在一个实际的程序进程里面,虚拟内存占用的地址空间,通常是两段连续的空间。而不是完全散落的随机的内存地址。而多级页表,就特别适合这样的内存地址分布。
我们以一个4级的多级页表为例来看一下。同样一个虚拟内存地址偏移量的部分和上面简单页表一样不变但是原先的页号部分我们把它拆成四段从高到低分成4级到1级这样4个页表索引。
<img src="https://static001.geekbang.org/resource/image/61/76/614034116a840ef565feda078d73cb76.jpeg" alt="">
对应的一个进程会有一个4级页表。我们先通过4级页表索引找到4级页表里面对应的条目Entry。这个条目里存放的是一张3级页表所在的位置。4级页面里面的每一个条目都对应着一张3级页表所以我们可能有多张3级页表。
找到对应这张3级页表之后我们用3级索引去找到对应的3级索引的条目。3级索引的条目再会指向一个2级页表。同样的2级页表里我们可以用2级索引指向一个1级页表。
而最后一层的1级页表里面的条目对应的数据内容就是物理页号了。在拿到了物理页号之后我们同样可以用“页号+偏移量”的方式,来获取最终的物理内存地址。
我们可能有很多张1级页表、2级页表乃至3级页表。但是因为实际的虚拟内存空间通常是连续的我们很可能只需要很少的2级页表甚至只需要1张3级页表就够了。
事实上,多级页表就像一个多叉树的数据结构,所以我们常常称它为**页表树**Page Table Tree。因为虚拟内存地址分布的连续性树的第一层节点的指针很多就是空的也就不需要有对应的子树了。所谓不需要子树其实就是不需要对应的2级、3级的页表。找到最终的物理页号就好像通过一个特定的访问路径走到树最底层的叶子节点。
<img src="https://static001.geekbang.org/resource/image/5b/4e/5ba17a3ecf3f9ce4a65546de480fcc4e.jpeg" alt="">
以这样的分成4级的多级页表来看每一级如果都用5个比特表示。那么每一张某1级的页表只需要2^5=32个条目。如果每个条目还是4个字节那么一共需要128个字节。而一个1级索引表对应32个4KB的也就是128KB的大小。一个填满的2级索引表对应的就是32个1级索引表也就是4MB的大小。
我们可以一起来测算一下一个进程如果占用了8MB的内存空间分成了2个4MB的连续空间。那么它一共需要2个独立的、填满的2级索引表也就意味着64个1级索引表2个独立的3级索引表1个4级索引表。一共需要69个索引表每个128字节大概就是9KB的空间。比起4MB来说只有差不多1/500。
不过多级页表虽然节约了我们的存储空间却带来了时间上的开销所以它其实是一个“以时间换空间”的策略。原本我们进行一次地址转换只需要访问一次内存就能找到物理页号算出物理内存地址。但是用了4级页表我们就需要访问4次内存才能找到物理页号了。
我们在前面两讲讲过内存访问其实比Cache要慢很多。我们本来只是要做一个简单的地址转换反而是一下子要多访问好多次内存。对于这个时间层面的性能损失我们有没有什么更好的解决办法呢那请你一定要关注下一讲的内容哦
## 总结延伸
好了,这一讲的内容差不多了,我们来总结一下。
我们从最简单的进行虚拟页号一一映射的简单页表说起,仔细讲解了现在实际应用的多级页表。多级页表就像是一颗树。因为一个进程的内存地址相对集中和连续,所以采用这种页表树的方式,可以大大节省页表所需要的空间。而因为每个进程都需要一个独立的页表,这个空间的节省是非常可观的。
在优化页表的过程中,我们可以观察到,数组这样的紧凑的数据结构,以及树这样稀疏的数据结构,在时间复杂度和空间复杂度的差异。另外,纯粹理论软件的数据结构和硬件的设计也是高度相关的。
## 推荐阅读
对于虚拟内存的知识点,你可以再深入读一读《计算机组成与设计:硬件/软件接口》的第5.7章节。如果你觉得还不过瘾,可以进一步去读一读[《What Every Programmer Should Know About Memory》](https://people.freebsd.org/~lstewart/articles/cpumemory.pdf)的第4部分也就是Virtual Memory。
## 课后思考
在实际的虚拟内存地址到物理内存地址的地址转换的过程里,我们没有采用哈希表,而是采用了多级页表的解决方案。你能想一想,使用多级页表,对于哈希表有哪些优点,又有哪些缺点吗?
欢迎留言和我分享你的想法,如果觉得有收获,你也可以把这篇文章分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,146 @@
<audio id="audio" title="41 | 理解内存解析TLB和内存保护" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/c8/b137c580feb4446b71b7bd4f003050c8.mp3"></audio>
机器指令里面的内存地址都是虚拟内存地址。程序里面的每一个进程,都有一个属于自己的虚拟内存地址空间。我们可以通过地址转换来获得最终的实际物理地址。我们每一个指令都存放在内存里面,每一条数据都存放在内存里面。因此,“地址转换”是一个非常高频的动作,“地址转换”的性能就变得至关重要了。这就是我们今天要讲的**第一个问题**,也就是**性能问题**。
因为我们的指令、数据都存放在内存里面,这里就会遇到我们今天要谈的**第二个问题**,也就是**内存安全问题**。如果被人修改了内存里面的内容我们的CPU就可能会去执行我们计划之外的指令。这个指令可能是破坏我们服务器里面的数据也可能是被人获取到服务器里面的敏感信息。
现代的CPU和操作系统会通过什么样的方式来解决这两个问题呢别着急等讲完今天的内容你就知道答案了。
## 加速地址转换TLB
上一节我们说了,从虚拟内存地址到物理内存地址的转换,我们通过页表这个数据结构来处理。为了节约页表的内存存储空间,我们会使用多级页表数据结构。
不过多级页表虽然节约了我们的存储空间但是却带来了时间上的开销变成了一个“以时间换空间”的策略。原本我们进行一次地址转换只需要访问一次内存就能找到物理页号算出物理内存地址。但是用了4级页表我们就需要访问4次内存才能找到物理页号。
我们知道内存访问其实比Cache要慢很多。我们本来只是要做一个简单的地址转换现在反而要一下子多访问好多次内存。这种情况该怎么处理呢你是否还记得之前讲过的“加个缓存”的办法呢我们来试一试。
程序所需要使用的指令都顺序存放在虚拟内存里面。我们执行的指令也是一条条顺序执行下去的。也就是说我们对于指令地址的访问存在前面几讲所说的“空间局部性”和“时间局部性”而需要访问的数据也是一样的。我们连续执行了5条指令。因为内存地址都是连续的所以这5条指令通常都在同一个“虚拟页”里。
因此这连续5次的内存地址转换其实都来自于同一个虚拟页号转换的结果自然也就是同一个物理页号。那我们就可以用前面几讲说过的用一个“加个缓存”的办法。把之前的内存转换地址缓存下来使得我们不需要反复去访问内存来进行内存地址转换。
<img src="https://static001.geekbang.org/resource/image/ef/27/ef754d9b2c816acff1dad63875ffea27.jpeg" alt="">
于是计算机工程师们专门在CPU里放了一块缓存芯片。这块缓存芯片我们称之为**TLB**,全称是**地址变换高速缓冲**Translation-Lookaside Buffer。这块缓存存放了之前已经进行过地址转换的查询结果。这样当同样的虚拟地址需要进行地址转换的时候我们可以直接在TLB里面查询结果而不需要多次访问内存来完成一次转换。
TLB和我们前面讲的CPU的高速缓存类似可以分成指令的TLB和数据的TLB也就是**ITLB**和**DTLB**。同样的我们也可以根据大小对它进行分级变成L1、L2这样多层的TLB。
除此之外还有一点和CPU里的高速缓存也是一样的我们需要用脏标记这样的标记位来实现“写回”这样缓存管理策略。
<img src="https://static001.geekbang.org/resource/image/43/d9/432050446f68569a37c7699cccda75d9.jpeg" alt="">
为了性能我们整个内存转换过程也要由硬件来执行。在CPU芯片里面我们封装了内存管理单元MMUMemory Management Unit芯片用来完成地址转换。和TLB的访问和交互都是由这个MMU控制的。
## 安全性与内存保护
讲完了虚拟内存和物理内存的转换,我们来看看内存保护和安全性的问题。
进程的程序也好,数据也好,都要存放在内存里面。实际程序指令的执行,也是通过程序计数器里面的地址,去读取内存内的内容,然后运行对应的指令,使用相应的数据。
虽然我们现代的操作系统和CPU已经做了各种权限的管控。正常情况下我们已经通过虚拟内存地址和物理内存地址的区分隔离了各个进程。但是无论是CPU这样的硬件还是操作系统这样的软件都太复杂了难免还是会被黑客们找到各种各样的漏洞。
就像我们在软件开发过程中,常常会有一个“兜底”的错误处理方案一样,在对于内存的管理里面,计算机也有一些最底层的安全保护机制。这些机制统称为**内存保护**Memory Protection。我这里就为你简单介绍两个。
### 可执行空间保护
第一个常见的安全机制,叫**可执行空间保护**Executable Space Protection
这个机制是说我们对于一个进程使用的内存只把其中的指令部分设置成“可执行”的对于其他部分比如数据部分不给予“可执行”的权限。因为无论是指令还是数据在我们的CPU看来都是二进制的数据。我们直接把数据部分拿给CPU如果这些数据解码后也能变成一条合理的指令其实就是可执行的。
这个时候黑客们想到了一些搞破坏的办法。我们在程序的数据区里放入一些要执行的指令编码后的数据然后找到一个办法让CPU去把它们当成指令去加载那CPU就能执行我们想要执行的指令了。对于进程里内存空间的执行权限进行控制可以使得CPU只能执行指令区域的代码。对于数据区域的内容即使找到了其他漏洞想要加载成指令来执行也会因为没有权限而被阻挡掉。
其实,在实际的应用开发中,类似的策略也很常见。我下面给你举两个例子。
比如说在用PHP进行Web开发的时候我们通常会禁止PHP有eval函数的执行权限。这个其实就是害怕外部的用户所以没有把数据提交到服务器而是把一段想要执行的脚本提交到服务器。服务器里在拼装字符串执行命令的时候可能就会执行到预计之外被“注入”的破坏性脚本。这里我放了一个例子用这个办法可以去删除服务器上的数据。
```
script.php?param1=xxx
//我们的PHP接受一个传入的参数这个参数我们希望提供计算功能
```
```
$code = eval($_GET[&quot;param1&quot;]);
// 我们直接通过 eval 计算出来对应的参数公式的计算结果
```
```
script.php?param1=&quot;;%20echo%20exec('rm -rf ~/');%20//
// 用户传入的参数里面藏了一个命令
```
```
$code = &quot;&quot;; echo exec('rm -rf ~/'); //&quot;;
// 执行的结果就变成了删除服务器上的数据
```
还有一个例子就是SQL注入攻击。如果服务端执行的SQL脚本是通过字符串拼装出来的那么在Web请求里面传输的参数就可以藏下一些我们想要执行的SQL让服务器执行一些我们没有想到过的SQL语句。这样的结果就是或者破坏了数据库里的数据或者被人拖库泄露了数据。
### 地址空间布局随机化
第二个常见的安全机制,叫**地址空间布局随机化**Address Space Layout Randomization
内存层面的安全保护核心策略,是在可能有漏洞的情况下进行安全预防。上面的可执行空间保护就是一个很好的例子。但是,内存层面的漏洞还有其他的可能性。
这里的核心问题是,其他的人、进程、程序,会去修改掉特定进程的指令、数据,然后,让当前进程去执行这些指令和数据,造成破坏。要想修改这些指令和数据,我们需要知道这些指令和数据所在的位置才行。
原先我们一个进程的内存布局空间是固定的所以任何第三方很容易就能知道指令在哪里程序栈在哪里数据在哪里堆又在哪里。这个其实为想要搞破坏的人创造了很大的便利。而地址空间布局随机化这个机制就是让这些区域的位置不再固定在内存空间随机去分配这些进程里不同部分所在的内存空间地址让破坏者猜不出来。猜不出来呢自然就没法找到想要修改的内容的位置。如果只是随便做点修改程序只会crash掉而不会去执行计划之外的代码。
<img src="https://static001.geekbang.org/resource/image/db/b9/dbda1bd1d43d6fa9d7b552ca57d223b9.jpeg" alt="">
这样的“随机化”策略其实也是我们日常应用开发中一个常见的策略。一个大家都应该接触过的例子就是密码登陆功能。网站和App都会需要你设置用户名和密码之后用来登陆自己的账号。然后在服务器端我们会把用户名和密码保存下来在下一次用户登陆的时候使用这个用户名和密码验证。
我们的密码当然不能明文存储在数据库里,不然就会有安全问题。如果明文存储在数据库里,意味着能拿到数据库访问权限的人,都能看到用户的明文密码。这个可能是因为安全漏洞导致被人拖库,而且网站的管理员也能直接看到所有的用户名和密码信息。
比如前几年CSDN就发生过被人拖库的事件。虽然用户名和密码都是明文保存的别人如果只是拿到了CSDN网站的用户名密码用户的损失也不会太大。但是很多用户可能会在不同的网站使用相同的密码如果拿到这些用户名和密码的人能够成功登录用户的银行、支付、社交等等其他网站的话用户损失就大了去了。
于是大家会在数据库里存储密码的哈希值比如用现在常用的SHA256生成一一个验证的密码哈希值。但是这个往往还是不够的。因为同样的密码对应的哈希值都是相同的大部分用户的密码又常常比较简单。于是拖库成功的黑客可以通过[彩虹表](https://zh.wikipedia.org/wiki/%E5%BD%A9%E8%99%B9%E8%A1%A8)的方式,来推测出用户的密码。
这个时候,我们的“随机化策略”就可以用上了。我们可以在数据库里,给每一个用户名生成一个随机的、使用了各种特殊字符的**盐值**Salt。这样我们的哈希值就不再是仅仅使用密码来生成的了而是密码和盐值放在一起生成的对应的哈希值。哈希值的生成中包括了一些类似于“乱码”的随机字符串所以通过彩虹表碰撞来猜出密码的办法就用不了了。
```
$password = &quot;goodmorning12345&quot;;
// 我们的密码是明文存储的
$hashed_password = hash('sha256', password);
// 对应的hash值是 054df97ac847f831f81b439415b2bad05694d16822635999880d7561ee1b77ac
// 但是这个hash值里可以用彩虹表直接“猜出来”原始的密码就是goodmorning12345
$salt = &quot;#21Pb$Hs&amp;Xi923^)?&quot;;
$salt_password = $salt.$password;
$hashed_salt_password = hash('sha256', salt_password);
// 这个hash后的slat因为有部分随机的字符串不会在彩虹表里面出现。
// 261e42d94063b884701149e46eeb42c489c6a6b3d95312e25eee0d008706035f
```
可以看到,通过加入“随机”因素,我们有了一道最后防线。即使在出现安全漏洞的时候,我们也有了更多的时间和机会去补救这些问题。
虽然安全机制似乎在平时用不太到,但是在开发程序的时候,还是要有安全意识。毕竟谁也不想看到,被拖库的新闻里出现的是自己公司的名字,也不希望用户因为我们的错误遭受到损失。
## 总结延伸
为了节约页表所需要的内存空间我们采用了多级页表这样一个数据结构。但是多级页表虽然节省空间了却要花费更多的时间去多次访问内存。于是我们在实际进行地址转换的MMU旁边放上了TLB这个用于地址转换的缓存。TLB也像CPU Cache一样分成指令和数据部分也可以进行L1、L2这样的分层。
然后,我为你介绍了内存保护。无论是数据还是代码,我们都要存放在内存里面。为了防止因为各种漏洞,导致一个进程可以访问别的进程的数据或者代码,甚至是执行对应的代码,造成严重的安全问题,我们介绍了最常用的两个内存保护措施,可执行空间保护和地址空间布局随机化。
通过让数据空间里面的内容不能执行,可以避免了类似于“注入攻击”的攻击方式。通过随机化内存空间的分配,可以避免让一个进程的内存里面的代码,被推测出来,从而不容易被攻击。
## 推荐阅读
对于内存保护的相关知识,你可以通过[Wikipedia里面的相关条目](https://en.wikipedia.org/wiki/Memory_protection#Capability-based_addressing)来进一步了解相关的信息。
另外2017年暴露出来的[Spectre和Meltdown漏洞](https://zh.wikipedia.org/wiki/%E5%B9%BD%E7%81%B5%E6%BC%8F%E6%B4%9E)的相关原理你也可以在Wikipedia里面找到相关的信息来了解一下。
Spectre和Meltdown漏洞出现在CPU的高速缓存和虚拟机结合的层面。理解这个漏洞可以让你看到安全问题是如何出现各种让人难以想到的结果。这也是为什么我们需要可执行空间保护和地址空间布局随机化这样的“防卫性”的安全策略。即使我们不知道漏洞可以从哪里来即使漏洞可能已经发生了这些策略也能够使得我们的系统更不容易被攻破。
## 课后思考
除了我们今天说的可执行空间保护和地址空间布局随机化之外,你还知道其他内存保护策略吗?你想到的这些内存保护策略,和你日常的开发工作中,是否也有类似思路的应用呢?
欢迎留言和我分享你日常开发中用到的内存保护策略,如果这篇文章对你有帮助,你也可以把它分享给你的朋友,和他一起讨论和学习。

View File

@@ -0,0 +1,89 @@
<audio id="audio" title="42 | 总线:计算机内部的高速公路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/bd/9f43bd9f37fd1a10c96dfe8b25c765bd.mp3"></audio>
专栏讲到现在,如果我再问你,计算机五大组成部分是什么,应该没有人不知道了吧?我们这一节要讲的内容,依然要围绕这五大部分,控制器、运算器、存储器、输入设备和输出设备。
CPU所代表的控制器和运算器要和存储器也就是我们的主内存以及输入和输出设备进行通信。那问题来了CPU从我们的键盘、鼠标接收输入信号向显示器输出信号这之间究竟是怎么通信的呢换句话说计算机是用什么样的方式来完成CPU和内存、以及外部输入输出设备的通信呢
这个问题就是我们今天要讲的主题,也就是**总线**。之前很多同学留言问,我什么时候会讲一讲总线。那这一讲,你就要听仔细了。
## 降低复杂性:总线的设计思路来源
计算机里其实有很多不同的硬件设备除了CPU和内存之外我们还有大量的输入输出设备。可以说你计算机上的每一个接口键盘、鼠标、显示器、硬盘乃至通过USB接口连接的各种外部设备都对应了一个设备或者模块。
如果各个设备间的通信,都是互相之间单独进行的。如果我们有$N$个不同的设备,他们之间需要各自单独连接,那么系统复杂度就会变成$N^2$。每一个设备或者功能电路模块,都要和其他$N-1$个设备去通信。为了简化系统的复杂度,我们就引入了总线,把这个$N^2$的复杂度,变成一个$N$的复杂度。
那怎么降低复杂度呢与其让各个设备之间互相单独通信不如我们去设计一个公用的线路。CPU想要和什么设备通信通信的指令是什么对应的数据是什么都发送到这个线路上设备要向CPU发送什么信息呢也发送到这个线路上。这个线路就好像一个高速公路各个设备和其他设备之间不需要单独建公路只建一条小路通向这条高速公路就好了。
<img src="https://static001.geekbang.org/resource/image/af/58/afdf06aeb84a92a9dfe5e9d2299e6958.jpeg" alt="">
这个设计思路,就是我们今天要说的**总线**Bus
总线其实就是一组线路。我们的CPU、内存以及输入和输出设备都是通过这组线路进行相互间通信的。总线的英文叫作Bus就是一辆公交车。这个名字很好地描述了总线的含义。我们的“公交车”的各个站点就是各个接入设备。要想向一个设备传输数据我们只要把数据放上公交车在对应的车站下车就可以了。
其实,对应的设计思路,在软件开发中也是非常常见的。我们在做大型系统开发的过程中,经常会用到一种叫作[事件总线](https://dzone.com/articles/design-patterns-event-bus)Event Bus的设计模式。
进行大规模应用系统开发的时候,系统中的各个组件之间也需要相互通信。模块之间如果是两两之间单独去定义协议,这个软件系统一样会遇到一个复杂度变成了$N^2$的问题。所以常见的一个解决方案,就是事件总线这个设计模式。
在事件总线这个设计模式里各个模块触发对应的事件并把事件对象发送到总线上。也就是说每个模块都是一个发布者Publisher。而各个模块也会把自己注册到总线上去监听总线上的事件并根据事件的对象类型或者是对象内容来决定自己是否要进行特定的处理或者响应。
<img src="https://static001.geekbang.org/resource/image/1c/53/1c6002fabbb80407a34afec76cdb5f53.jpeg" alt="">
这样的设计下,注册在总线上的各个模块就是松耦合的。模块互相之间并没有依赖关系。无论代码的维护,还是未来的扩展,都会很方便。
## 理解总线:三种线路和多总线架构
理解了总线的设计概念,我们来看看,总线在实际的计算机硬件里面,到底是什么样。
现代的Intel CPU的体系结构里面通常有好几条总线。
首先CPU和内存以及高速缓存通信的总线这里面通常有两种总线。这种方式我们称之为**双独立总线**Dual Independent Bus缩写为DIB。CPU里有一个快速的**本地总线**Local Bus以及一个速度相对较慢的**前端总线**Front-side Bus
我们在前面几讲刚刚讲过现代的CPU里通常有专门的高速缓存芯片。这里的高速本地总线就是用来和高速缓存通信的。而前端总线则是用来和主内存以及输入输出设备通信的。有时候我们会把本地总线也叫作后端总线Back-side Bus和前面的前端总线对应起来。而前端总线也有很多其他名字比如处理器总线Processor Bus、内存总线Memory Bus
<img src="https://static001.geekbang.org/resource/image/4d/f9/4ddbb489ceaac5e7a2c8491178db1cf9.jpeg" alt="">
除了前端总线呢我们常常还会听到PCI总线、I/O总线或者系统总线System Bus。看到这么多总线的名字你是不是已经有点晕了。这些名词确实容易混为一谈。其实各种总线的命名一直都很混乱我们不如直接来看一看**CPU的硬件架构图**。对照图来看,一切问题就都清楚了。
CPU里面的北桥芯片把我们上面说的前端总线一分为二变成了三个总线。
我们的前端总线,其实就是**系统总线**。CPU里面的内存接口直接和系统总线通信然后系统总线再接入一个I/O桥接器I/O Bridge。这个I/O桥接器一边接入了我们的内存总线使得我们的CPU和内存通信另一边呢又接入了一个I/O总线用来连接I/O设备。
事实上真实的计算机里这个总线层面拆分得更细。根据不同的设备还会分成独立的PCI总线、ISA总线等等。
<img src="https://static001.geekbang.org/resource/image/f5/66/f58610f211422d71ff50eeeeb729d166.jpeg" alt="">
在物理层面,其实我们完全可以把总线看作一组“电线”。不过呢,这些电线之间也是有分工的,我们通常有三类线路。
1. 数据线Data Bus用来传输实际的数据信息也就是实际上了公交车的“人”。
1. 地址线Address Bus用来确定到底把数据传输到哪里去是内存的某个位置还是某一个I/O设备。这个其实就相当于拿了个纸条写下了上面的人要下车的站点。
1. 控制线Control Bus用来控制对于总线的访问。虽然我们把总线比喻成了一辆公交车。那么有人想要做公交车的时候需要告诉公交车司机这个就是我们的控制信号。
尽管总线减少了设备之间的耦合,也降低了系统设计的复杂度,但同时也带来了一个新问题,那就是总线不能**同时**给多个设备提供通信功能。
我们的总线是很多个设备公用的,那多个设备都想要用总线,我们就需要有一个机制,去决定这种情况下,到底把总线给哪一个设备用。这个机制,就叫作**总线裁决**Bus Arbitraction。总线裁决的机制有很多种不同的实现如果你对这个实现的细节感兴趣可以去看一看Wiki里面关于[裁决器](https://en.wikipedia.org/wiki/Arbiter_(electronics))的对应条目,这里我们就不多说了。
## 总结延伸
好了,你现在明白计算机里的总线、各种不同的总线到底是什么意思了吧?希望这一讲能够帮你厘清计算机总线的知识点。现在我们一起来总结梳理一下这节的内容。
这一讲,我为你讲解了计算机里各个不同的组件之间用来通信的渠道,也就是总线。总线的设计思路,核心是为了减少多个模块之间交互的复杂性和耦合度。实际上,总线这个设计思路在我们的软件开发过程中也经常会被用到。事件总线就是我们常见的一个设计模式,通常事件总线也会和订阅者发布者模式结合起来,成为大型系统的各个松耦合的模块之间交互的一种主要模式。
在实际的硬件层面总线其实就是一组连接电路的线路。因为不同设备之间的速度有差异所以一台计算机里面往往会有多个总线。常见的就有在CPU内部和高速缓存通信的本地总线以及和外部I/O设备以及内存通信的前端总线。
前端总线通常也被叫作系统总线。它可以通过一个I/O桥接器拆分成两个总线分别来和I/O设备以及内存通信。自然这样拆开的两个总线就叫作I/O总线和内存总线。总线本身的电路功能又可以拆分成用来传输数据的数据线、用来传输地址的地址线以及用来传输控制信号的控制线。
总线是一个各个接入的设备公用的线路,所以自然会在各个设备之间争夺总线所有权的情况。于是,我们需要一个机制来决定让谁来使用总线,这个决策机制就是总线裁决。
## 推荐阅读
总线是一个抽象的设计模式它不仅在我们计算机的硬件设计里出现。在日常的软件开发中也是一个常见的设计模式你可以去读一读Google开源的Java的一个常用的工具库Guava的[相关资料和代码](https://github.com/google/guava/wiki/EventBusExplained),进一步理解事件总线的设计模式,看看在软件层面怎么实现它。
对于计算机硬件层面的总线很多教科书里讲得都比较少你可以去读一读Wiki里面[总线](https://en.wikipedia.org/wiki/Bus_(computing))和[系统总线](https://en.wikipedia.org/wiki/System_bus)的相关条目。
## 课后思考
2008年之后我们的Intel CPU其实已经没有前端总线了。Intel发明了[快速通道互联](https://en.wikipedia.org/wiki/Intel_QuickPath_Interconnect)Intel Quick Path Interconnect简称为QPI技术替代了传统的前端总线。这个QPI技术你可以搜索和翻阅一下相关资料了解一下它引入了什么新的设计理念。
欢迎在留言区分享你查阅到的资料,以及阅读之后的思考总结,和大家一起交流。如果有收获,你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,103 @@
<audio id="audio" title="43 | 输入输出设备我们并不是只能用灯泡显示“0”和“1”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/ae/71254c57539bec271f9b7348eab61fae.mp3"></audio>
我们在前面的章节搭建了最简单的电路在这里面计算机的输入设备就是一个一个开关输出设备呢是一个一个灯泡。的确早期发展的时候计算机的核心是做“计算”。我们从“计算机”这个名字上也能看出这一点。不管是中文名字“计算机”还是英文名字“Computer”核心都是在”计算“这两个字上。不过到了今天这些“计算”的工作更多的是一个幕后工作。
我们无论是使用自己的PC还是智能手机大部分时间都是在和计算机进行各种“交互操作”。换句话说就是在和输入输出设备打交道。这些输入输出设备也不再是一个一个开关或者一个一个灯泡。你在键盘上直接敲击的都是字符而不是“0”和“1”你在显示器上看到的也是直接的图形或者文字的画面而不是一个一个闪亮或者关闭的灯泡。想要了解这其中的关窍那就请你和我一起来看一看计算机里面的输入输出设备。
## 接口和设备:经典的适配器模式
我们在前面讲解计算机的五大组成部分的时候,我看到这样几个留言。
一个同学问像蓝牙、WiFi无线网卡这样的设备也是输入输出设备吗还有一个同学问我们的输入输出设备的寄存器在哪里到底是在主板上还是在硬件设备上
这两个问题问得很好。其实你只要理解了这两个问题,也就理解输入输出设备是怎么回事儿了。
实际上,输入输出设备,并不只是一个设备。大部分的输入输出设备,都有两个组成部分。第一个是它的**接口**Interface第二个才是**实际的I/O设备**Actual I/O Device。我们的硬件设备并不是直接接入到总线上和CPU通信的而是通过接口用接口连接到总线上再通过总线和CPU通信。
<img src="https://static001.geekbang.org/resource/image/ce/bf/ce9d22a0dafa20b9574411b810c0aabf.jpg" alt="">
你平时听说的并行接口Parallel Interface、串行接口Serial Interface、USB接口都是计算机主板上内置的各个接口。我们的实际硬件设备比如使用并口的打印机、使用串口的老式鼠标或者使用USB接口的U盘都要插入到这些接口上才能和CPU工作以及通信的。
接口本身就是一块电路板。CPU其实不是和实际的硬件设备打交道而是和这个接口电路板打交道。我们平时说的设备里面有三类寄存器其实都在这个设备的接口电路上而不在实际的设备上。
那这三类寄存器是哪三类寄存器呢它们分别是状态寄存器Status Register、 命令寄存器Command Register以及数据寄存器Data Register
除了内置在主板上的接口之外,有些接口可以集成在设备上。你可能都没有见过老一点儿的硬盘,我来简单给你介绍一下。
上世纪90年代的时候大家用的硬盘都叫作**IDE硬盘**。这个IDE不是像IntelliJ或者WebStorm这样的软件开发集成环境Integrated Development Environment的IDE而是代表着集成设备电路Integrated Device Electronics。也就是说设备的接口电路直接在设备上而不在主板上。我们需要通过一个线缆把集成了接口的设备连接到主板上去。
<img src="https://static001.geekbang.org/resource/image/30/47/30c96ac2fd8a0deffcff86e7b66acf47.png" alt="">
把接口和实际设备分离,这个做法实际上来自于计算机走向[开放架构](https://en.wikipedia.org/wiki/Open_architecture)Open Architecture的时代。
当我们要对计算机升级我们不会扔掉旧的计算机直接买一台全新的计算机而是可以单独升级硬盘这样的设备。我们把老硬盘从接口上拿下来换一个新的上去就好了。各种输入输出设备的制造商也可以根据接口的控制协议来设计和制造硬盘、鼠标、键盘、打印机乃至其他种种外设。正是这样的分工协作带来了PC时代的繁荣。
其实在软件的设计模式里也有这样的思路。面向对象里的面向接口编程的接口就是Interface。如果你做iOS的开发Objective-C里面的Protocol其实也是这个意思。而Adaptor设计模式更是一个常见的、用来解决不同外部应用和系统“适配”问题的方案。可以看到计算机的软件和硬件在逻辑抽象上其实是相通的。
如果你用的是Windows操作系统你可以打开设备管理器里面有各种各种的Devices设备、Controllers控制器、Adaptors适配器。这些其实都是对于输入输出设备不同角度的描述。被叫作Devices看重的是实际的I/O设备本身。被叫作Controllers看重的是输入输出设备接口里面的控制电路。而被叫作Adaptors则是看重接口作为一个适配器后面可以插上不同的实际设备。
## CPU是如何控制I/O设备的
无论是内置在主板上的接口还是集成在设备上的接口除了三类寄存器之外还有对应的控制电路。正是通过这个控制电路CPU才能通过向这个接口电路板传输信号来控制实际的硬件。
我们先来看一看,硬件设备上的这些寄存器有什么用。这里,我拿我们平时用的打印机作为例子。
<img src="https://static001.geekbang.org/resource/image/fd/38/fd788de17028e8b1dbce58de5da31e38.jpeg" alt="">
1. 首先是数据寄存器Data Register。CPU向I/O设备写入需要传输的数据比如要打印的内容是“GeekTime”我们就要先发送一个“G”给到对应的I/O设备。
1. 然后是命令寄存器Command Register。CPU发送一个命令告诉打印机要进行打印工作。这个时候打印机里面的控制电路会做两个动作。第一个是去设置我们的状态寄存器里面的状态把状态设置成not-ready。第二个就是实际操作打印机进行打印。
1. 而状态寄存器Status Register就是告诉了我们的CPU现在设备已经在工作了所以这个时候CPU你再发送数据或者命令过来都是没有用的。直到前面的动作已经完成状态寄存器重新变成了ready状态我们的CPU才能发送下一个字符和命令。
当然在实际情况中打印机里通常不只有数据寄存器还会有数据缓冲区。我们的CPU也不是真的一个字符一个字符这样交给打印机去打印的而是一次性把整个文档传输到打印机的内存或者数据缓冲区里面一起打印的。不过通过上面这个例子相信你对CPU是怎么操作I/O设备的应该有所了解了。
## 信号和地址:发挥总线的价值
搞清楚了实际的I/O设备和接口之间的关系一个新的问题就来了。那就是我们的CPU到底要往总线上发送一个什么样的命令才能和I/O接口上的设备通信呢
CPU和I/O设备的通信一样是通过CPU支持的机器指令来执行的。
如果你回头去看一看[第5讲](https://time.geekbang.org/column/article/93359)MIPS的机器指令的分类你会发现我们并没有一种专门的和I/O设备通信的指令类型。那么MIPS的CPU到底是通过什么样的指令来和I/O设备来通信呢
答案就是和访问我们的主内存一样使用“内存地址”。为了让已经足够复杂的CPU尽可能简单计算机会把I/O设备的各个寄存器以及I/O设备内部的内存地址都映射到主内存地址空间里来。主内存的地址空间里会给不同的I/O设备预留一段一段的内存地址。CPU想要和这些I/O设备通信的时候呢就往这些地址发送数据。这些地址信息就是通过上一讲的地址线来发送的而对应的数据信息呢自然就是通过数据线来发送的了。
而我们的I/O设备呢就会监控地址线并且在CPU往自己地址发送数据的时候把对应的数据线里面传输过来的数据接入到对应的设备里面的寄存器和内存里面来。CPU无论是向I/O设备发送命令、查询状态还是传输数据都可以通过这样的方式。这种方式呢叫作**内存映射**IOMemory-Mapped I/O简称MMIO
<img src="https://static001.geekbang.org/resource/image/bb/22/bb8c1c007f7263bee41b7c649304c722.jpeg" alt="">
那么MMIO是不是唯一一种CPU和设备通信的方式呢答案是否定的。精简指令集MIPS的CPU特别简单所以这里只有MMIO。而我们有2000多个指令的Intel X86架构的计算机自然可以设计专门的和I/O设备通信的指令也就是 in 和 out 指令。
Intel CPU虽然也支持MMIO不过它还可以通过特定的指令来支持端口映射I/OPort-Mapped I/O简称PMIO或者也可以叫独立输入输出Isolated I/O
其实PMIO的通信方式和MMIO差不多核心的区别在于PMIO里面访问的设备地址不再是在内存地址空间里面而是一个专门的端口Port。这个端口并不是指一个硬件上的插口而是和CPU通信的一个抽象概念。
无论是PMIO还是MMIOCPU都会传送一条二进制的数据给到I/O设备的对应地址。设备自己本身的接口电路再去解码这个数据。解码之后的数据呢就会变成设备支持的一条指令再去通过控制电路去操作实际的硬件设备。对于CPU来说它并不需要关心设备本身能够支持哪些操作。它要做的只是在总线上传输一条条数据就好了。
这个其实也有点像我们在设计模式里面的Command模式。我们在总线上传输的是一个个数据对象然后各个接受这些对象的设备再去根据对象内容进行实际的解码和命令执行。
<img src="https://static001.geekbang.org/resource/image/4e/a7/4e66bafd713fed95a4957df71b3bd8a7.png" alt="">
这是一张我自己的显卡在设备管理器里面的资源Resource信息。你可以看到里面既有Memory Range这个就是设备对应映射到的内存地址也就是我们上面所说的MMIO的访问方式。同样的里面还有I/O Range这个就是我们上面所说的PMIO也就是通过端口来访问I/O设备的地址。最后里面还有一个IRQ也就是会来自于这个设备的中断信号了。
## 总结延伸
好了讲到这里不知道现在你是不是可以把CPU的指令、总线和I/O设备之间的关系彻底串联起来了呢我来带你回顾一下。
CPU并不是发送一个特定的操作指令来操作不同的I/O设备。因为如果是那样的话随着新的I/O设备的发明我们就要去扩展CPU的指令集了。
在计算机系统里面CPU和I/O设备之间的通信是这么来解决的。
首先在I/O设备这一侧我们把I/O设备拆分成能和CPU通信的接口电路以及实际的I/O设备本身。接口电路里面有对应的状态寄存器、命令寄存器、数据寄存器、数据缓冲区和设备内存等等。接口电路通过总线和CPU通信接收来自CPU的指令和数据。而接口电路中的控制电路再解码接收到的指令实际去操作对应的硬件设备。
而在CPU这一侧对CPU来说它看到的并不是一个个特定的设备而是一个个内存地址或者端口地址。CPU只是向这些地址传输数据或者读取数据。所需要的指令和操作内存地址的指令其实没有什么本质差别。通过软件层面对于传输的命令数据的定义而不是提供特殊的新的指令来实际操作对应的I/O硬件。
## 推荐阅读
想要进一步了解CPU和I/O设备交互的技术细节我推荐你去看一看北京大学在Coursera上的视频课程《计算机组成》[第10周的](https://www.coursera.org/learn/jisuanji-zucheng/home/week/10)[内容](https://www.coursera.org/learn/jisuanji-zucheng/home/week/10)。这个课程在Coursera上是中文的而且可以免费观看。相信这一个小时的视频课程对于你深入理解输入输出设备会很有帮助。
## 课后思考
我们还是回到这节开始的时候同学留言的问题。如果你买的是一个带无线接收器的蓝牙鼠标你需要把蓝牙接收器插在电脑的USB接口上然后你的鼠标会和这个蓝牙接收器进行通信。那么你能想一下我们的CPU和蓝牙鼠标这个输入设备之间的通信是怎样的吗
你可以好好思考一下,然后在留言区写下你的想法。当然,你也可以把这个问题分享给你的朋友,拉上他一起学习。

View File

@@ -0,0 +1,198 @@
<audio id="audio" title="44 | 理解IO_WAITI/O性能到底是怎么回事儿" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/32/af27bf0cc177f474bc67c2529813ae32.mp3"></audio>
在专栏一开始的时候我和你说过在计算机组成原理这门课里面很多设计的核心思路都来源于性能。在前面讲解CPU的时候相信你已经有了切身的感受了。
大部分程序员开发的都是应用系统。在开发应用系统的时候我们遇到的性能瓶颈大部分都在I/O上。在[第36讲](https://time.geekbang.org/column/article/107447)讲解局部性原理的时候,我们一起看了通过把内存当作是缓存,来提升系统的整体性能。在[第37讲](https://time.geekbang.org/column/article/107477)讲解CPU Cache的时候我们一起看了CPU Cache和主内存之间性能的巨大差异。
然而我们知道并不是所有问题都能靠利用内存或者CPU Cache做一层缓存来解决。特别是在这个“大数据”的时代。我们在硬盘上存储了越来越多的数据一个MySQL数据库的单表有个几千万条记录早已经不算是什么罕见现象了。这也就意味着用内存当缓存存储空间是不够用的。大部分时间我们的请求还是要打到硬盘上。那么这一讲我们就来看看硬盘I/O性能的事儿。
## IO性能、顺序访问和随机访问
如果去看硬盘厂商的性能报告,通常你会看到两个指标。一个是**响应时间**Response Time另一个叫作**数据传输率**Data Transfer Rate。没错这个和我们在专栏的一开始讲的CPU的性能一样前面那个就是响应时间后面那个就是吞吐率了。
我们先来看一看后面这个指标,数据传输率。
我们现在常用的硬盘有两种。一种是HDD硬盘也就是我们常说的机械硬盘。另一种是SSD硬盘一般也被叫作固态硬盘。现在的HDD硬盘用的是SATA 3.0的接口。而SSD硬盘呢通常会用两种接口一部分用的也是SATA 3.0的接口另一部分呢用的是PCI Express的接口。
现在我们常用的SATA 3.0的接口带宽是6Gb/s。这里的“b”是比特。这个带宽相当于每秒可以传输768MB的数据。而我们日常用的HDD硬盘的数据传输率差不多在200MB/s左右。
<img src="https://static001.geekbang.org/resource/image/a2/ff/a227f1a299a3774c4e1067436decf7ff.png" alt="">
当我们换成SSD的硬盘性能自然会好上不少。比如我最近刚把自己电脑的HDD硬盘换成了一块Crucial MX500的SSD硬盘。它的数据传输速率能到差不多500MB/s比HDD的硬盘快了一倍不止。不过SATA接口的硬盘差不多到这个速度性能也就到顶了。因为SATA接口的速度也就这么快。
<img src="https://static001.geekbang.org/resource/image/57/a4/57443821861f73e4d04ab4d64e6908a4.png" alt="">
不过实际SSD硬盘能够更快所以我们可以换用PCI Express的接口。我自己电脑的系统盘就是一块使用了PCI Express的三星SSD硬盘。它的数据传输率在读取的时候就能做到2GB/s左右差不多是HDD硬盘的10倍而在写入的时候也能有1.2GB/s。
除了数据传输率这个吞吐率指标另一个我们关心的指标响应时间其实也可以在AS SSD的测试结果里面看到就是这里面的Acc.Time指标。
这个指标其实就是程序发起一个硬盘的写入请求直到这个请求返回的时间。可以看到在上面的两块SSD硬盘上大概时间都是在几十微秒这个级别。如果你去测试一块HDD的硬盘通常会在几毫秒到十几毫秒这个级别。这个性能的差异就不是10倍了而是在几十倍乃至几百倍。
光看响应时间和吞吐率这两个指标似乎我们的硬盘性能很不错。即使是廉价的HDD硬盘接收一个来自CPU的请求也能够在几毫秒时间返回。一秒钟能够传输的数据也有200MB左右。你想一想我们平时往数据库里写入一条记录也就是1KB左右的大小。我们拿200MB去除以1KB那差不多每秒钟可以插入20万条数据呢。但是这个计算出来的数字似乎和我们日常的经验不符合啊这又是为什么呢
答案就来自于硬盘的读写。在**顺序读写**和**随机读写**的情况下,硬盘的性能是完全不同的。
我们回头看一下上面的AS SSD的性能指标。你会看到里面有一个“4K”的指标。这个指标是什么意思呢它其实就是我们的程序去随机读取磁盘上某一个4KB大小的数据一秒之内可以读取到多少数据。
你会发现在这个指标上我们使用SATA 3.0接口的硬盘和PCI Express接口的硬盘性能差异变得很小。这是因为在这个时候接口本身的速度已经不是我们硬盘访问速度的瓶颈了。更重要的是你会发现即使我们用PCI Express的接口在随机读写的时候数据传输率也只能到40MB/s左右是顺序读写情况下的几十分之一。
我们拿这个40MB/s和一次读取4KB的数据算一下。
也就是说一秒之内这块SSD硬盘可以随机读取1万次的4KB的数据。如果是写入的话呢会更多一些90MB /4KB 差不多是2万多次。
这个每秒读写的次数,我们称之为[IOPS](https://en.wikipedia.org/wiki/IOPS)也就是每秒输入输出操作的次数。事实上比起响应时间我们更关注IOPS这个性能指标。IOPS和DTRData Transfer Rate数据传输率才是输入输出性能的核心指标。
这是因为我们在实际的应用开发当中对于数据的访问更多的是随机读写而不是顺序读写。我们平时所说的服务器承受的“并发”其实是在说会有很多个不同的进程和请求来访问服务器。自然它们在硬盘上访问的数据是很难顺序放在一起的。这种情况下随机读写的IOPS才是服务器性能的核心指标。
好了回到我们引出IOPS这个问题的HDD硬盘。我现在要问你了那一块HDD硬盘能够承受的IOPS是多少呢其实我们应该已经在第36讲说过答案了。
HDD硬盘的IOPS通常也就在100左右而不是在20万次。在后面讲解机械硬盘的原理和性能优化的时候我们还会再来一起看一看这个100是怎么来的以及我们可以有哪些优化的手段。
## 如何定位IO_WAIT
我们看到即使是用上了PCI Express接口的SSD硬盘IOPS也就是在2万左右。而我们的CPU的主频通常在2GHz以上也就是每秒可以做20亿次操作。
即使CPU向硬盘发起一条读写指令需要很多个时钟周期一秒钟CPU能够执行的指令数和我们硬盘能够进行的操作数也有好几个数量级的差异。这也是为什么我们在应用开发的时候往往会说“性能瓶颈在I/O上”。因为很多时候CPU指令发出去之后不得不去“等”我们的I/O操作完成才能进行下一步的操作。
那么在实际遇到服务端程序的性能问题的时候我们怎么知道这个问题是不是来自于CPU等I/O来完成操作呢别着急我们接下来就通过top和iostat这些命令一起来看看CPU到底有没有在等待io操作。
```
# top
```
你一定在Linux下用过 top 命令。对于很多刚刚入门Linux的同学会用top去看服务的负载也就是load average。不过在top命令里面我们一样可以看到CPU是否在等待IO操作完成。
```
top - 06:26:30 up 4 days, 53 min, 1 user, load average: 0.79, 0.69, 0.65
Tasks: 204 total, 1 running, 203 sleeping, 0 stopped, 0 zombie
%Cpu(s): 20.0 us, 1.7 sy, 0.0 ni, 77.7 id, 0.0 wa, 0.0 hi, 0.7 si, 0.0 st
KiB Mem: 7679792 total, 6646248 used, 1033544 free, 251688 buffers
KiB Swap: 0 total, 0 used, 0 free. 4115536 cached Mem
```
在top命令的输出结果里面有一行是以%CPU开头的。这一行里有一个叫作wa的指标这个指标就代表着iowait也就是CPU等待IO完成操作花费的时间占CPU的百分比。下一次当你自己的服务器遇到性能瓶颈load很大的时候你就可以通过top看一看这个指标。
知道了iowait很大那么我们就要去看一看实际的I/O操作情况是什么样的。这个时候你就可以去用iostat这个命令了。我们输入“iostat”就能够看到实际的硬盘读写情况。
```
$ iostat
```
```
avg-cpu: %user %nice %system %iowait %steal %idle
17.02 0.01 2.18 0.04 0.00 80.76
Device: tps kB_read/s kB_wrtn/s kB_read kB_wrtn
sda 1.81 2.02 30.87 706768 10777408
```
你会看到这个命令里不仅有iowait这个CPU等待时间的百分比还有一些更加具体的指标了并且它还是按照你机器上安装的多块不同的硬盘划分的。
这里的tps指标其实就对应着我们上面所说的硬盘的IOPS性能。而kB_read/s和kB_wrtn/s指标就对应着我们的数据传输率的指标。
知道实际硬盘读写的tps、kB_read/s和kb_wrtn/s的指标我们基本上可以判断出机器的性能是不是卡在I/O上了。那么接下来我们就是要找出到底是哪一个进程是这些I/O读写的来源了。这个时候你需要“iotop”这个命令。
```
$ iotop
```
```
Total DISK READ : 0.00 B/s | Total DISK WRITE : 15.75 K/s
Actual DISK READ: 0.00 B/s | Actual DISK WRITE: 35.44 K/s
TID PRIO USER DISK READ DISK WRITE SWAPIN IO&gt; COMMAND
104 be/3 root 0.00 B/s 7.88 K/s 0.00 % 0.18 % [jbd2/sda1-8]
383 be/4 root 0.00 B/s 3.94 K/s 0.00 % 0.00 % rsyslogd -n [rs:main Q:Reg]
1514 be/4 www-data 0.00 B/s 3.94 K/s 0.00 % 0.00 % nginx: worker process
```
通过iotop这个命令你可以看到具体是哪一个进程实际占用了大量I/O那么你就可以有的放矢去优化对应的程序了。
上面的这些示例里不管是wa也好tps也好它们都很小。那么接下来我就给你用Linux下用stress命令来模拟一个高I/O复杂的情况来看看这个时候的iowait是怎么样的。
我在一台云平台上的单个CPU核心的机器上输入“stress -i 2”让stress这个程序模拟两个进程不停地从内存里往硬盘上写数据。
```
$ stress -i 2
```
```
$ top
```
你会看到在top的输出里面CPU就有大量的sy和wa也就是系统调用和iowait。
```
top - 06:56:02 up 3 days, 19:34, 2 users, load average: 5.99, 1.82, 0.63
Tasks: 88 total, 3 running, 85 sleeping, 0 stopped, 0 zombie
%Cpu(s): 3.0 us, 29.9 sy, 0.0 ni, 0.0 id, 67.2 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 1741304 total, 1004404 free, 307152 used, 429748 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 1245700 avail Mem
```
```
$ iostat 2 5
```
如果我们通过iostat查看硬盘的I/O你会看到里面的tps很快就到了4万左右占满了对应硬盘的IOPS。
```
avg-cpu: %user %nice %system %iowait %steal %idle
5.03 0.00 67.92 27.04 0.00 0.00
Device: tps kB_read/s kB_wrtn/s kB_read kB_wrtn
sda 39762.26 0.00 0.00 0 0
```
如果这个时候我们去看一看iotop你就会发现我们的I/O占用都来自于stress产生的两个进程了。
```
$ iotop
```
```
Total DISK READ : 0.00 B/s | Total DISK WRITE : 0.00 B/s
Actual DISK READ: 0.00 B/s | Actual DISK WRITE: 0.00 B/s
TID PRIO USER DISK READ DISK WRITE SWAPIN IO&gt; COMMAND
29161 be/4 xuwenhao 0.00 B/s 0.00 B/s 0.00 % 56.71 % stress -i 2
29162 be/4 xuwenhao 0.00 B/s 0.00 B/s 0.00 % 46.89 % stress -i 2
1 be/4 root 0.00 B/s 0.00 B/s 0.00 % 0.00 % init
```
相信到了这里你也应该学会了怎么通过top、iostat以及iotop一步一步快速定位服务器端的I/O带来的性能瓶颈了。你也可以自己通过Linux的man命令看一看这些命令还有哪些参数以及通过stress来模拟其他更多不同的性能压力看看我们的机器负载会发生什么变化。
## 总结延伸
这一讲里我们从硬盘的两个核心指标响应时间和数据传输率来理解和研究I/O的性能问题。你也自己可以通过as ssd这样的性能评测软件看一看自己的硬盘性能。
在顺序读取的情况下无论是HDD硬盘还是SSD硬盘性能看起来都是很不错的。不过等到进行随机读取测试的时候硬盘的性能才能见了真章。因为在大部分的应用开发场景下我们关心的并不是在顺序读写下的数据量而是每秒钟能够进行输入输出的操作次数也就是IOPS这个核心性能指标。
你会发现即使是使用PCI Express接口的SSD硬盘IOPS也就只是到了2万左右。这个性能和我们CPU的每秒20亿次操作的能力比起来可就差得远了。所以很多时候我们的程序对外响应慢其实都是CPU在等待I/O操作完成。
在Linux下我们可以通过top这样的命令来看整个服务器的整体负载。在应用响应慢的时候我们可以先通过这个指令来看CPU是否在等待I/O完成自己的操作。进一步地我们可以通过iostat这个命令来看到各个硬盘这个时候的读写情况。而 iotop 这个命令能够帮助我们定位到到底是哪一个进程在进行大量的I/O操作。
这些命令的组合可以快速帮你定位到是不是我们的程序遇到了I/O的瓶颈以及这些瓶颈来自于哪些程序你就可以根据定位的结果来优化你自己的程序了。
## 推荐阅读
关于IO_WAIT的文章在互联网上已经有不少了。你可以读一读这一篇[Understanding IOPS Latency and Storage Performance](https://louwrentius.com/understanding-iops-latency-and-storage-performance.html)进一步理解一下什么是IOPS和IO_WAIT。
## 课后思考
你能去下载一个AS SSD软件测试一下你自己硬盘的性能吗特别是如果你手上还有HDD硬盘的话可以尝试测试一下HDD硬盘的性能是怎么样的。
在上面的性能指标上我们已经讲解了Seq4K以及Acc.Time这三个指标那么4K-Thrd这个指标又是什么意思呢测试这个指标对应的应用场景又是怎么样的呢
请你研究一下,把你得到的答案写在留言区,和大家一起分享讨论吧。另外,如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,101 @@
<audio id="audio" title="45 | 机械硬盘Google早期用过的“黑科技”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/1a/4810c0eec6136b53c1e4774b8dfa0e1a.mp3"></audio>
在1991年我刚接触计算机的时候很多计算机还没有硬盘。整个操作系统都安装在5寸或者3.5寸的软盘里。不过,很快大部分计算机都开始用上了直接安装在主板上的机械硬盘。到了今天,更早的软盘早已经被淘汰了。在个人电脑和服务器里,更晚出现的光盘也已经很少用了。
机械硬盘的生命力仍然非常顽强。无论是作为个人电脑的数据盘,还是在数据中心里面用作海量数据的存储,机械硬盘仍然在被大量使用。不仅如此,随着成本的不断下降,机械硬盘还替代掉了很多传统的存储设备,比如,以前常常用来备份冷数据的磁带。
那这一讲里,我们就从机械硬盘的物理构造开始,从原理到应用剖析一下,看看我们可以怎么样用好机械硬盘。
## 拆解机械硬盘
上一讲里我们提到过机械硬盘的IOPS。我们说机械硬盘的IOPS大概只能做到每秒100次左右。那么这个100次究竟是怎么来的呢
我们把机械硬盘拆开来看一看看看它的物理构造是怎么样的你就自然知道为什么它的IOPS是100左右了。
我们之前看过整个硬盘的构造里面有接口有对应的控制电路版以及实际的I/O设备也就是我们的机械硬盘。这里我们就拆开机械硬盘部分来看一看。
<img src="https://static001.geekbang.org/resource/image/51/14/5146a2a881afb81b3a076e4974df8614.jpg" alt="">
一块机械硬盘是由盘面、磁头和悬臂三个部件组成的。下面我们一一来看每一个部件。
首先,自然是**盘面**Disk Platter。盘面其实就是我们实际存储数据的盘片。如果你剪开过软盘的外壳或者看过光盘DVD那你看到盘面应该很熟悉。盘面其实和它们长得差不多。
盘面本身通常是用的铝、玻璃或者陶瓷这样的材质做成的光滑盘片。然后,盘面上有一层磁性的涂层。我们的数据就存储在这个磁性的涂层上。盘面中间有一个受电机控制的转轴。这个转轴会控制我们的盘面去旋转。
我们平时买硬盘的时候经常会听到一个指标,叫作这个硬盘的**转速**。我们的硬盘有5400转的、7200转的乃至10000转的。这个多少多少转指的就是盘面中间电机控制的转轴的旋转速度英文单位叫**RPM**,也就是**每分钟的旋转圈数**Rotations Per Minute。所谓7200转其实更准确地说是7200RPM指的就是一旦电脑开机供电之后我们的硬盘就可以一直做到每分钟转上7200圈。如果折算到每一秒钟就是120圈。
说完了盘面,我们来看**磁头**Drive Head。我们的数据并不能直接从盘面传输到总线上而是通过磁头从盘面上读取到然后再通过电路信号传输给控制电路、接口再到总线上的。
通常,我们的一个盘面上会有两个磁头,分别在盘面的正反面。盘面在正反两面都有对应的磁性涂层来存储数据,而且一块硬盘也不是只有一个盘面,而是上下堆叠了很多个盘面,各个盘面之间是平行的。每个盘面的正反两面都有对应的磁头。
最后我们来看**悬臂**Actutor Arm。悬臂链接在磁头上并且在一定范围内会去把磁头定位到盘面的某个特定的磁道Track上。这个磁道是怎么来呢想要了解这个问题我们要先看一看我们的数据是怎么存放在盘面上的。
一个盘面通常是圆形的,由很多个同心圆组成,就好像是一个个大小不一样的“甜甜圈”嵌套在一起。每一个“甜甜圈”都是一个磁道。每个磁道都有自己的一个编号。悬臂其实只是控制,到底是读最里面那个“甜甜圈”的数据,还是最外面“甜甜圈”的数据。
<img src="https://static001.geekbang.org/resource/image/51/14/5146a2a881afb81b3a076e4974df8614.jpg" alt="">
知道了我们硬盘的物理构成,现在我们就可以看一看,这样的物理结构,到底是怎么来读取数据的。
我们刚才说的一个磁道会分成一个一个扇区Sector。上下平行的一个一个盘面的相同扇区呢我们叫作一个柱面Cylinder
读取数据,其实就是两个步骤。一个步骤,就是把盘面旋转到某一个位置。在这个位置上,我们的悬臂可以定位到整个盘面的某一个子区间。这个子区间的形状有点儿像一块披萨饼,我们一般把这个区间叫作**几何扇区**Geometrical Sector意思是在“几何位置上”所有这些扇区都可以被悬臂访问到。另一个步骤就是把我们的悬臂移动到特定磁道的特定扇区也就在这个“几何扇区”里面找到我们实际的扇区。找到之后我们的磁头会落下就可以读取到正对着扇区的数据。
<img src="https://static001.geekbang.org/resource/image/38/ca/384cf31520dc0d080490d627c3a209ca.jpg" alt="">
所以,我们进行一次硬盘上的随机访问,需要的时间由两个部分组成。
第一个部分,叫作**平均延时**Average Latency。这个时间其实就是把我们的盘面旋转把几何扇区对准悬臂位置的时间。这个时间很容易计算它其实就和我们机械硬盘的转速相关。随机情况下平均找到一个几何扇区我们需要旋转半圈盘面。上面7200转的硬盘那么一秒里面就可以旋转240个半圈。那么这个平均延时就是
第二个部分,叫作**平均寻道时间**Average Seek Time也就是在盘面选转之后我们的悬臂定位到扇区的的时间。我们现在用的HDD硬盘的平均寻道时间一般在4-10ms。
这样,我们就能够算出来,如果随机在整个硬盘上找一个数据,需要 8-14 ms。我们的硬盘是机械结构的只有一个电机转轴也只有一个悬臂所以我们没有办法并行地去定位或者读取数据。那一块7200转的硬盘我们一秒钟随机的IO访问次数也就是
现在你明白我们上一讲所说的HDD硬盘的IOPS每秒100次左右是怎么来的吧好了现在你再思考一个问题。如果我们不是去进行随机的数据访问而是进行顺序的数据读写我们应该怎么最大化读取效率呢
我们可以选择把顺序存放的数据尽可能地存放在同一个柱面上。这样我们只需要旋转一次盘面进行一次寻道就可以去写入或者读取同一个垂直空间上的多个盘面的数据。如果一个柱面上的数据不够我们也不要去动悬臂而是通过电机转动盘面这样就可以顺序读完一个磁道上的所有数据。所以其实对于HDD硬盘的顺序数据读写吞吐率还是很不错的可以达到200MB/s左右。
## Partial Stroking根据场景提升性能
只有100的IOPS其实很难满足现在互联网海量高并发的请求。所以今天的数据库都会把数据存储在SSD硬盘上。不过如果我们把时钟倒播20年那个时候我们可没有现在这么便宜的SSD硬盘。数据库里面的数据只能存放在HDD硬盘上。
今天即便是数据中心用的HDD硬盘一般也是7200转的因为如果要更快的随机访问速度我们会选择用SSD硬盘。但是在当时SSD硬盘价格非常昂贵还没有能够商业化。硬盘厂商们在不断地研发转得更快的硬盘。在数据中心里往往我们会用上10000转乃至15000转的硬盘。甚至直到2010年SSD硬盘已经开始逐步进入市场了西数还在尝试研发20000转的硬盘。转速更高、寻道时间更短的机械硬盘才能满足实际的数据库需求。
不过10000转乃至15000转的硬盘也更昂贵。如果你想要节约成本提高性价比那就得想点别的办法。你应该听说过Google早年用家用PC乃至二手的硬件通过软件层面的设计来解决可靠性和性能的问题。那么我们是不是也有什么办法能提高机械硬盘的IOPS呢
还真的有。这个方法,就叫作**Partial Stroking**或者**Short Stroking**。我没有看到过有中文资料给这个方法命名。在这里,我就暂时把它翻译成“**缩短行程**”技术。
其实这个方法的思路很容易理解,我一说你就明白了。既然我们访问一次数据的时间,是“平均延时+寻道时间”那么只要能缩短这两个之一不就可以提升IOPS了吗
一般情况下硬盘的寻道时间都比平均延时要长。那么我们自然就可以想一下有什么办法可以缩短平均的寻道时间。最极端的办法就是我们不需要寻道也就是说我们把所有数据都放在一个磁道上。比如我们始终把磁头放在最外道的磁道上。这样我们的寻道时间就基本为0访问时间就只有平均延时了。那样我们的IOPS就变成了
不过呢只用一个磁道我们能存的数据就比较有限了。这个时候可能我们还不如把这些数据直接都放到内存里面呢。所以实践当中我们可以只用1/2或者1/4的磁道也就是最外面1/4或者1/2的磁道。这样我们硬盘可以使用的容量可能变成了1/2或者1/4。但是呢我们的寻道时间也变成了1/4或者1/2因为悬臂需要移动的“行程”也变成了原来的1/2或者1/4我们的IOPS就能够大幅度提升了。
比如说我们一块7200转的硬盘正常情况下平均延时是4.17ms而寻道时间是9ms。那么它原本的IOPS就是
如果我们只用其中1/4的磁道那么它的IOPS就变成了
你看这个结果IOPS提升了一倍和一块15000转的硬盘的性能差不多了。不过这个情况下我们的硬盘能用的空间也只有原来的1/4了。不过要知道在当时同样容量的15000转的硬盘的价格可不止是7200转硬盘的4倍啊。所以这样通过软件去格式化硬盘只保留部分磁道让系统可用的情况可以大大提升硬件的性价比。
在2000-2010年这10年间正是这些奇思妙想让海量数据下的互联网蓬勃发展起来的。在没有SSD的硬盘的时候聪明的工程师们从硬件到软件设计了各种有意思的方案解决了我们遇到的各类性能问题。而对于计算机底层知识的深入了解也是能够找到这些解决办法的核心因素。
## 总结延伸
好了相信通过这一讲你对传统的HDD硬盘应该有了深入的了解。我们来总结一下。
机械硬盘的硬件,主要由盘面、磁头和悬臂三部分组成。我们的数据在盘面上的位置,可以通过磁道、扇区和柱面来定位。实际的一次对于硬盘的访问,需要把盘面旋转到某一个“几何扇区”,对准悬臂的位置。然后,悬臂通过寻道,把磁头放到我们实际要读取的扇区上。
受制于机械硬盘的结构我们对于随机数据的访问速度就要包含旋转盘面的平均延时和移动悬臂的寻道时间。通过这两个时间我们能计算出机械硬盘的IOPS。
7200转机械硬盘的IOPS只能做到100左右。在互联网时代的早期我们也没有SSD硬盘可以用所以工程师们就想出了Partial Stroking这个浪费存储空间但是可以缩短寻道时间来提升硬盘的IOPS的解决方案。这个解决方案也是一个典型的、在深入理解了硬件原理之后的软件优化方案。
## 推荐阅读
想要对机械硬盘的各种性能指标有更深入的理解你可以读一读Symantec写的Getting The Hang Of IOPS的白皮书以及后面的深入阅读内容对你应该会很有帮助。我把对应的[链接](https://www.symantec.com/connect/articles/getting-hang-iops-v13)放在这里,你可以看一看。
## 课后思考
如果是用更慢的5400转的硬盘使用Partial Stroking技术只使用一半的硬盘空间我们的IOPS能够提升多少呢
你可以拿出纸和笔算一算,然后把你的答案写在留言区。如果觉得有帮助,你可以把这篇文章分享给你的朋友,和他一起学习。

View File

@@ -0,0 +1,113 @@
<audio id="audio" title="46 | SSD硬盘如何完成性能优化的KPI" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/bf/d115f9c1f7af989a3a44e0fed9948dbf.mp3"></audio>
随着3D垂直封装技术和QLC技术的出现今年的“618”SSD硬盘的价格进一步大跳水趁着这个机会我把自己电脑上的仓库盘从HDD换成了SSD硬盘。我的个人电脑彻底摆脱了机械硬盘。
随着智能手机的出现互联网用户在2008年之后开始爆发性增长大家在网上花的时间也越来越多。这也就意味着隐藏在精美App和网页之后的服务端数据请求量呈数量级的上升。
无论是用10000转的企业级机械硬盘还是用Short Stroking这样的方式进一步提升IOPSHDD硬盘已经满足不了我们的需求了。上面这些优化措施无非就是把IOPS从100提升到300、500也就到头了。
于是SSD硬盘在2010年前后进入了主流的商业应用。我们在[第44讲](https://time.geekbang.org/column/article/113809)看过一块普通的SSD硬盘可以轻松支撑10000乃至20000的IOPS。那个时候不少互联网公司想要完成性能优化的KPI最后的解决方案都变成了换SSD的硬盘。如果这还不够那就换上使用PCI Express接口的SSD。
不过只是简单地换一下SSD硬盘真的最大限度地用好了SSD硬盘吗另外即便现在SSD硬盘很便宜了大部分公司的批量数据处理系统仍然在用传统的机械硬盘这又是为什么呢
那么接下来这两讲就请你和我一起来看一看SSD硬盘的工作原理以及怎么最大化利用SSD的工作原理使得访问的速度最快硬盘的使用寿命最长。
## SSD的读写原理
SSD没有像机械硬盘那样的寻道过程所以它的随机读写都更快。我在下面列了一个表格对比了一下SSD和机械硬盘的优缺点。
<img src="https://static001.geekbang.org/resource/image/a5/7c/a53e407311293609cb0753c7889a367c.jpeg" alt="">
你会发现不管是机械硬盘不擅长的随机读写还是它本身已经表现不错的顺序写入SSD在这些方面都要比HDD强。不过有一点机械硬盘要远强于SSD那就是耐用性。如果我们需要频繁地重复写入删除数据那么机械硬盘要比SSD性价比高很多。
要想知道为什么SSD的耐用性不太好我们先要理解SSD硬盘的存储和读写原理。我们之前说过CPU Cache用的SRAM是用一个电容来存放一个比特的数据。对于SSD硬盘我们也可以先简单地认为它是由一个电容加上一个电压计组合在一起记录了一个或者多个比特。
### SLC、MLC、TLC和QLC
能够记录一个比特很容易理解。给电容里面充上电有电压的时候就是1给电容放电里面没有电就是0。采用这样方式存储数据的SSD硬盘我们一般称之为**使用了SLC的颗粒**全称是Single-Level Cell也就是一个存储单元中只有一位数据。
<img src="https://static001.geekbang.org/resource/image/06/a7/0698c240459faa11254932905675dba7.jpeg" alt="">
但是这样的方式会遇到和CPU Cache类似的问题那就是同样的面积下能够存放下的元器件是有限的。如果只用SLC我们就会遇到存储容量上不去并且价格下不来的问题。于是呢硬件工程师们就陆续发明了**MLC**Multi-Level Cell、**TLC**Triple-Level Cell以及**QLC**Quad-Level Cell也就是能在一个电容里面存下2个、3个乃至4个比特。
<img src="https://static001.geekbang.org/resource/image/94/79/949106cb0ca5985a47388caef6925a79.jpeg" alt="">
只有一个电容我们怎么能够表示更多的比特呢别忘了这里我们还有一个电压计。4个比特一共可以从0000-1111表示16个不同的数。那么如果我们能往电容里面充电的时候充上15个不同的电压并且我们电压计能够区分出这15个不同的电压。加上电容被放空代表的0就能够代表从0000-1111这样4个比特了。
不过要想表示15个不同的电压充电和读取的时候对于精度的要求就会更高。这会导致充电和读取的时候都更慢所以QLC的SSD的读写速度要比SLC的慢上好几倍。如果你想要知道是什么样的物理原理导致这个QLC更慢可以去读一读这篇[文章](https://www.anandtech.com/show/5067/understanding-tlc-nand/2)。
### P/E擦写问题
如果我们去看一看SSD硬盘的硬件构造可以看到它大概是自顶向下是这么构成的。
<img src="https://static001.geekbang.org/resource/image/6a/5e/6ac3cfd51d39d3e3022effc7e4255e5e.jpeg" alt="">
首先自然和其他的I/O设备一样它有对应的**接口和控制电路**。现在的SSD硬盘用的是SATA或者PCI Express接口。在控制电路里有一个很重要的模块叫作**FTL**Flash-Translation Layer也就是**闪存转换层**。这个可以说是SSD硬盘的一个核心模块SSD硬盘性能的好坏很大程度上也取决于FTL的算法好不好。现在容我卖个关子我们晚一会儿仔细讲FTL的功能。
接下来是**实际I/O设备**它其实和机械硬盘很像。现在新的大容量SSD硬盘都是3D封装的了也就是说是由很多个裸片Die叠在一起的就好像我们的机械硬盘把很多个盘面Platter叠放再一起一样这样可以在同样的空间下放下更多的容量。
<img src="https://static001.geekbang.org/resource/image/0e/d3/0eee44535a925825b657bcac6afb72d3.jpeg" alt="">
接下来,一张裸片上可以放多个**平面**Plane一般一个平面上的存储容量大概在GB级别。一个平面上面会划分成很多个块Block一般一个块Block的存储大小 通常几百KB到几MB大小。一个块里面还会区分很多个页Page就和我们内存里面的页一样一个页的大小通常是4KB。
在这一层一层的结构里面,处在最下面的两层块和页非常重要。
对于SSD硬盘来说数据的**写入**叫作Program。写入不能像机械硬盘一样通过**覆写**Overwrite来进行的而是要先去**擦除**Erase然后再写入。
SSD的读取和写入的基本单位不是一个比特bit或者一个字节byte而是一个**页**Page。SSD的擦除单位就更夸张了我们不仅不能按照比特或者字节来擦除连按照**页**来擦除都不行,我们必须按照**块**来擦除。
而且你必须记住的一点是SSD的使用寿命其实是每一个块Block的擦除的次数。你可以把SSD硬盘的一个平面看成是一张白纸。我们在上面写入数据就好像用铅笔在白纸上写字。如果想要把已经写过字的地方写入新的数据我们先要用橡皮把已经写好的字擦掉。但是如果频繁擦同一个地方那这个地方就会破掉之后就没有办法再写字了。
我们上面说的SLC的芯片可以擦除的次数大概在10万次MLC就在1万次左右而TLC和QLC就只在几千次了。这也是为什么你去购买SSD硬盘会看到同样的容量的价格差别很大因为它们的芯片颗粒和寿命完全不一样。
### SSD读写的生命周期
下面我们来实际看一看一块SSD硬盘在日常是怎么被用起来的。
我用三种颜色分别来表示SSD硬盘里面的页的不同状态白色代表这个页从来没有写入过数据绿色代表里面写入的是有效的数据红色代表里面的数据在我们的操作系统看来已经是删除的了。
<img src="https://static001.geekbang.org/resource/image/96/81/966e51db8354922b533e1db236337e81.jpeg" alt="">
一开始,所有块的每一个页都是白色的。随着我们开始往里面写数据,里面的有些页就变成了绿色。
然后因为我们删除了硬盘上的一些文件所以有些页变成了红色。但是这些红色的页并不能再次写入数据。因为SSD硬盘不能单独擦除一个页必须一次性擦除整个块所以新的数据我们只能往后面的白色的页里面写。这些散落在各个绿色空间里面的红色空洞就好像硬盘碎片。
如果有哪一个块的数据一次性全部被标红了那我们就可以把整个块进行擦除。它就又会变成白色可以重新一页一页往里面写数据。这种情况其实也会经常发生。毕竟一个块不大也就在几百KB到几MB。你删除一个几MB的文件数据又是连续存储的自然会导致整个块可以被擦除。
随着硬盘里面的数据越来越多红色空洞占的地方也会越来越多。于是你会发现我们就要没有白色的空页去写入数据了。这个时候我们要做一次类似于Windows里面“磁盘碎片整理”或者Java里面的“内存垃圾回收”工作。找一个红色空洞最多的块把里面的绿色数据挪到另一个块里面去然后把整个块擦除变成白色可以重新写入数据。
不过这个“磁盘碎片整理”或者“内存垃圾回收”的工作我们不能太主动、太频繁地去做。因为SSD的擦除次数是有限的。如果动不动就搞个磁盘碎片整理那么我们的SSD硬盘很快就会报废了。
说到这里你可能要问了这是不是说我们的SSD硬盘的容量是用不满的因为我们总会遇到一些红色空洞
<img src="https://static001.geekbang.org/resource/image/e7/74/e7fcd994384145eefde614aaf3b45874.jpeg" alt="">
没错一块SSD的硬盘容量是没办法完全用满的。不过为了不得罪消费者生产SSD硬盘的厂商其实是预留了一部分空间专门用来做这个“磁盘碎片整理”工作的。一块标成240G的SSD硬盘往往实际有256G的硬盘空间。SSD硬盘通过我们的控制芯片电路把多出来的硬盘空间用来进行各种数据的闪转腾挪让你能够写满那240G的空间。这个多出来的16G空间叫作**预留空间**Over Provisioning一般SSD的硬盘的预留空间都在7%-15%左右。
## 总结延伸
到这里相信你对SSD硬盘的写入和擦除的原理已经清楚了也明白了SSD硬盘的使用寿命受限于可以擦除的次数。
仔细想一想你会发现SSD硬盘特别适合读多写少的应用。在日常应用里面我们的系统盘适合用SSD。但是如果我们用SSD做专门的下载盘一直下载各种影音数据然后刻盘备份就不太好了特别是现在QLC颗粒的SSD它只有几千次可擦写的寿命啊。
在数据中心里面SSD的应用场景也是适合读多写少的场景。我们拿SSD硬盘用来做数据库存放电商网站的商品信息很合适。但是用来作为Hadoop这样的Map-Reduce应用的数据盘就不行了。因为Map-Reduce任务会大量在任务中间向硬盘写入中间数据再删除掉这样用不了多久SSD硬盘的寿命就会到了。
好了,最后让我们总结一下。
这一讲我们从SSD的物理原理也就是“电容+电压计”的组合向你介绍了SSD硬盘存储数据的原理以及从SLC、MLC、TLC直到今天的QLC颗粒是怎么回事儿。
然后我们一起看了SSD硬盘的物理构造也就是裸片、平面、块、页的层次结构。我们对于数据的写入只能是一页一页的不能对页进行覆写。对于数据的擦除只能整块进行。所以我们需要用一个类似“磁盘碎片整理”或者“内存垃圾回收”这样的机制来清理块当中的数据空洞。而SSD硬盘也会保留一定的预留空间避免出现硬盘无法写满的情况。
到了这里我们SSD硬盘在硬件层面的写入机制就介绍完了。不过更有挑战的一个问题是在这样的机制下我们怎么尽可能延长SSD的使用寿命呢如果要开发一个跑在SSD硬盘上的数据库我们可以利用SSD的哪些特性呢想要知道这些请你一定要记得回来听下一讲。
## 推荐阅读
想要对于SSD的硬件实现原理有所了解我推荐你去读一读这一篇[Understand TLC NAND](https://www.anandtech.com/show/5067/understanding-tlc-nand)。
## 课后思考
现在大家使用的数据系统里往往会有日志系统。你觉得日志系统适合存放在SSD硬盘上吗
欢迎在留言区写下你的思考。如果有收获,你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="47 | SSD硬盘如何完成性能优化的KPI" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/41/9b/419a50a07dc8cbd56d479512ed3c9c9b.mp3"></audio>
如果你平时用的是Windows电脑你会发现用了SSD的系统盘就不能用磁盘碎片整理功能。这是因为一旦主动去运行磁盘碎片整理功能就会发生一次块的擦除对应块的寿命就少了一点点。这个SSD的擦除寿命的问题不仅会影响像磁盘碎片整理这样的功能其实也很影响我们的日常使用。
我们的操作系统上并没有SSD硬盘上各个块目前已经擦写的情况和寿命所以它对待SSD硬盘和普通的机械硬盘没有什么区别。
我们日常使用PC进行软件开发的时候会先在硬盘上装上操作系统和常用软件比如Office或者工程师们会装上VS Code、WebStorm这样的集成开发环境。这些软件所在的块写入一次之后就不太会擦除了所以就只有读的需求。
一旦开始开发我们就会不断添加新的代码文件还会不断修改已经有的代码文件。因为SSD硬盘没有覆写Override的功能所以这个过程中其实我们是在反复地写入新的文件然后再把原来的文件标记成逻辑上删除的状态。等SSD里面空的块少了我们会用“垃圾回收”的方式进行擦除。这样我们的擦除会反复发生在这些用来存放数据的地方。
<img src="https://static001.geekbang.org/resource/image/09/6e/09a9566eae60610b0f49d7e24ce4ee6e.jpeg" alt="">
有一天,这些块的擦除次数到了,变成了坏块。但是,我们安装操作系统和软件的地方还没有坏,而这块硬盘的可以用的容量却变小了。
## 磨损均衡、TRIM和写入放大效应
### FTL和磨损均衡
那么,我们有没有什么办法,不让这些坏块那么早就出现呢?我们能不能,匀出一些存放操作系统的块的擦写次数,给到这些存放数据的地方呢?
相信你一定想到了其实我们要的就是想一个办法让SSD硬盘各个块的擦除次数均匀分摊到各个块上。这个策略呢就叫作**磨损均衡**Wear-Leveling。实现这个技术的核心办法和我们前面讲过的虚拟内存一样就是添加一个间接层。这个间接层就是我们上一讲给你卖的那个关子就是FTL这个**闪存转换层**。
<img src="https://static001.geekbang.org/resource/image/6e/91/6e78f8da0320dc9b392b9d35ecf42091.jpeg" alt="">
就像在管理内存的时候我们通过一个页表映射虚拟内存页和物理页一样在FTL里面存放了**逻辑块地址**Logical Block Address简称LBA到**物理块地址**Physical Block Address简称PBA的映射。
操作系统访问的硬盘地址其实都是逻辑地址。只有通过FTL转换之后才会变成实际的物理地址找到对应的块进行访问。操作系统本身不需要去考虑块的磨损程度只要和操作机械硬盘一样来读写数据就好了。
操作系统所有对于SSD硬盘的读写请求都要经过FTL。FTL里面又有逻辑块对应的物理块所以FTL能够记录下来每个物理块被擦写的次数。如果一个物理块被擦写的次数多了FTL就可以将这个物理块挪到一个擦写次数少的物理块上。但是逻辑块不用变操作系统也不需要知道这个变化。
这也是我们在设计大型系统中的一个典型思路也就是各层之间是隔离的操作系统不需要考虑底层的硬件是什么完全交由硬件的控制电路里面的FTL来管理对于实际物理硬件的写入。
### TRIM指令的支持
不过操作系统不去关心实际底层的硬件是什么在SSD硬盘的使用上也会带来一个问题。这个问题就是操作系统的逻辑层和SSD的逻辑层里的块状态是不匹配的。
我们在操作系统里面去删除一个文件其实并没有真的在物理层面去删除这个文件只是在文件系统里面把对应的inode里面的元信息清理掉这代表这个inode还可以继续使用可以写入新的数据。这个时候实际物理层面的对应的存储空间在操作系统里面被标记成可以写入了。
所以,其实我们日常的文件删除,都只是一个操作系统层面的逻辑删除。这也是为什么,很多时候我们不小心删除了对应的文件,我们可以通过各种恢复软件,把数据找回来。同样的,这也是为什么,如果我们想要删除干净数据,需要用各种“文件粉碎”的功能才行。
这个删除的逻辑在机械硬盘层面没有问题因为文件被标记成可以写入后续的写入可以直接覆写这个位置。但是在SSD硬盘上就不一样了。我在这里放了一张详细的示意图。我们下面一起来看看具体是怎么回事儿。
<img src="https://static001.geekbang.org/resource/image/72/d7/72b3fc74ff567e7a0ec1f4071da946d7.jpeg" alt="">
一开始操作系统里面有好几个文件不同的文件我用不同的颜色标记出来了。下面的SSD的逻辑块里面占用的页我们也用同样的颜色标记出来文件占用的对应页。
当我们在操作系统里面,删除掉一个刚刚下载的文件,比如标记成黄色 openjdk.exe 这样一个jdk的安装文件在操作系统里面对应的inode里面就没有文件的元信息。
但是这个时候我们的SSD的逻辑块层面其实并不知道这个事情。所以在逻辑块层面openjdk.exe 仍然是占用了对应的空间。对应的物理页,也仍然被认为是被占用了的。
这个时候如果我们需要对SSD进行垃圾回收操作openjdk.exe 对应的物理页仍然要在这个过程中被搬运到其他的Block里面去。只有当操作系统再在刚才的inode里面写入数据的时候我们才会知道原来的些黄色的页其实都已经没有用了我们才会把它标记成废弃掉。
所以在使用SSD的硬盘情况下你会发现操作系统对于文件的删除SSD硬盘其实并不知道。这就导致我们为了磨损均衡很多时候在都在搬运很多已经删除了的数据。这就会产生很多不必要的数据读写和擦除既消耗了SSD的性能也缩短了SSD的使用寿命。
为了解决这个问题现在的操作系统和SSD的主控芯片都支持**TRIM命令。**这个命令可以在文件被删除的时候让操作系统去通知SSD硬盘对应的逻辑块已经标记成已删除了。现在的SSD硬盘都已经支持了TRIM命令。无论是Linux、Windows还是MacOS这些操作系统也都已经支持了TRIM命令了。
### 写入放大
其实TRIM命令的发明也反应了一个使用SSD硬盘的问题那就是SSD硬盘容易越用越慢。
当SSD硬盘的存储空间被占用得越来越多每一次写入新数据我们都可能没有足够的空白。我们可能不得不去进行垃圾回收合并一些块里面的页然后再擦除掉一些页才能匀出一些空间来。
这个时候从应用层或者操作系统层面来看我们可能只是写入了一个4KB或者4MB的数据。但是实际通过FTL之后我们可能要去搬运8MB、16MB甚至更多的数据。
我们通过“**实际的闪存写入的数据量 / 系统通过FTL写入的数据量 = 写入放大**”可以得到写入放大的倍数越多意味着实际的SSD性能也就越差会远远比不上实际SSD硬盘标称的指标。
而解决写入放大,需要我们在后台定时进行垃圾回收,在硬盘比较空闲的时候,就把搬运数据、擦除数据、留出空白的块的工作做完,而不是等实际数据写入的时候,再进行这样的操作。
## AeroSpike如何最大化SSD的使用效率
讲到这里相信你也发现了想要把SSD硬盘用好其实没有那么简单。如果我们只是简单地拿一块SSD硬盘替换掉原来的HDD硬盘而不是从应用层面考虑任何SSD硬盘特性的话我们多半还是没法获得想要的性能提升。
不过既然清楚了SSD硬盘的各种特性我们就可以依据这些特性来设计我们的应用。接下来我就带你一起看一看AeroSpike这个专门针对SSD硬盘特性设计的Key-Value数据库键值对数据库是怎么利用这些物理特性的。
首先AeroSpike操作SSD硬盘并没有通过操作系统的文件系统。而是直接操作SSD里面的块和页。因为操作系统里面的文件系统对于KV数据库来说只是让我们多了一层间接层只会降低性能对我们没有什么实际的作用。
其次AeroSpike在读写数据的时候做了两个优化。在写入数据的时候AeroSpike尽可能去写一个较大的数据块而不是频繁地去写很多小的数据块。这样硬盘就不太容易频繁出现磁盘碎片。并且一次性写入一个大的数据块也更容易利用好顺序写入的性能优势。AeroSpike写入的一个数据块是128KB远比一个页的4KB要大得多。
另外在读取数据的时候AeroSpike倒是可以读取512字节Bytes这样的小数据。因为SSD的随机读取性能很好也不像写入数据那样有擦除寿命问题。而且很多时候我们读取的数据是键值对里面的值的数据这些数据要在网络上传输。如果一次性必须读出比较大的数据就会导致我们的网络带宽不够用。
因为AeroSpike是一个对于响应时间要求很高的实时KV数据库如果出现了严重的写放大效应会导致写入数据的响应时间大幅度变长。所以AeroSpike做了这样几个动作
第一个是持续地进行磁盘碎片整理。AeroSpike用了所谓的高水位High Watermark算法。其实这个算法很简单就是一旦一个物理块里面的数据碎片超过50%,就把这个物理块搬运压缩,然后进行数据擦除,确保磁盘始终有足够的空间可以写入。
第二个是在AeroSpike给出的最佳实践中为了保障数据库的性能建议你只用到SSD硬盘标定容量的一半。也就是说我们人为地给SSD硬盘预留了50%的预留空间以确保SSD硬盘的写放大效应尽可能小不会影响数据库的访问性能。
<img src="https://static001.geekbang.org/resource/image/35/60/354d34d871dda3ef5a4792a1fe1fb860.jpeg" alt="">
正是因为做了这种种的优化在NoSQL数据库刚刚兴起的时候AeroSpike的性能把Cassandra、MongoDB这些数据库远远甩在身后和这些数据库之间的性能差距有时候会到达一个数量级。这也让AeroSpike成为了当时高性能KV数据库的标杆。你可以看一看InfoQ出的这个[Benchmark](https://www.infoq.com/news/2013/04/NoSQL-Benchmark/)里面有2013年的时候这几个NoSQL数据库巨大的性能差异。
## 总结延伸
好了,现在让我们一起来总结一下今天的内容。
因为SSD硬盘的使用寿命受限于块的擦除次数所以我们需要通过一个磨损均衡的策略来管理SSD硬盘的各个块的擦除次数。我们通过在逻辑块地址和物理块地址之间引入FTL这个映射层使得操作系统无需关心物理块的擦写次数而是由FTL里的软件算法来协调到底每一次写入应该磨损哪一块。
除了磨损均衡之外操作系统和SSD硬件的特性还有一个不匹配的地方。那就是操作系统在删除数据的时候并没有真的删除物理层面的数据而只是修改了inode里面的数据。这个“伪删除”使得SSD硬盘在逻辑和物理层面都没有意识到有些块其实已经被删除了。这就导致在垃圾回收的时候会浪费很多不必要的读写资源。
SSD这个需要进行垃圾回收的特性使得我们在写入数据的时候会遇到写入放大。明明我们只是写入了4MB的数据可能在SSD的硬件层面实际写入了8MB、16MB乃至更多的数据。
针对这些特性AeroSpike这个专门针对SSD硬盘特性的KV数据库设计了很多的优化点包括跳过文件系统直写硬盘、写大块读小块、用高水位算法持续进行磁盘碎片整理以及只使用SSD硬盘的一半空间。这些策略使得AeroSpike的性能在早年间远远超过了Cassandra等其他NoSQL数据库。
可以看到,针对硬件特性设计的软件,才能最大化发挥我们的硬件性能。
## 推荐阅读
如果你想要基于SSD硬盘本身的特性来设计开发你的系统我推荐你去读一读AeroSpike的这个[PPT](https://www.slideshare.net/AerospikeDB/getting-the-most-out-of-your-flashssds)。AeroSpike是市面上最优秀的KV数据库之一通过深入地利用了SSD本身的硬件特性最大化提升了作为一个KV数据库的性能。真正在进行系统软件开发的时候了解硬件是必不可少的一个环节。
## 课后思考
在SSD硬盘的价格大幅度下降了之后LFS也就是Log-Structured File System在业界出现了第二春。你可以去了解一下什么是LFS以及为什么LFS特别适合SSD硬盘。
欢迎在留言区分享你了解到的信息,和大家一起交流。如果有收获,你可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,149 @@
<audio id="audio" title="48 | DMA为什么Kafka这么快" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6f/a2/6f237ba836afd7ef908a8927408a8ea2.mp3"></audio>
过去几年里整个计算机产业界都在尝试不停地提升I/O设备的速度。把HDD硬盘换成SSD硬盘我们仍然觉得不够快用PCI Express接口的SSD硬盘替代SATA接口的SSD硬盘我们还是觉得不够快所以现在就有了傲腾Optane这样的技术。
但是无论I/O速度如何提升比起CPU总还是太慢。SSD硬盘的IOPS可以到2万、4万但是我们CPU的主频有2GHz以上也就意味着每秒会有20亿次的操作。
如果我们对于I/O的操作都是由CPU发出对应的指令然后等待I/O设备完成操作之后返回那CPU有大量的时间其实都是在等待I/O设备完成操作。
但是这个CPU的等待在很多时候其实并没有太多的实际意义。我们对于I/O设备的大量操作其实都只是把内存里面的数据传输到I/O设备而已。在这种情况下其实CPU只是在傻等而已。特别是当传输的数据量比较大的时候比如进行大文件复制如果所有数据都要经过CPU实在是有点儿太浪费时间了。
因此计算机工程师们就发明了DMA技术也就是**直接内存访问**Direct Memory Access技术来减少CPU等待的时间。
## 理解DMA一个协处理器
其实DMA技术很容易理解本质上DMA技术就是我们在主板上放一块独立的芯片。在进行内存和I/O设备的数据传输的时候我们不再通过CPU来控制数据传输而直接通过**DMA控制器**DMA Controller简称DMAC。这块芯片我们可以认为它其实就是一个**协处理器**Co-Processor
**DMAC最有价值的地方体现在当我们要传输的数据特别大、速度特别快或者传输的数据特别小、速度特别慢的时候。**
比如说我们用千兆网卡或者硬盘传输大量数据的时候如果都用CPU来搬运的话肯定忙不过来所以可以选择DMAC。而当数据传输很慢的时候DMAC可以等数据到齐了再发送信号给到CPU去处理而不是让CPU在那里忙等待。
好了现在你应该明白DMAC的价值知道了它适合用在什么情况下。那我们现在回过头来看。我们上面说DMAC是一块“协处理器芯片”这是为什么呢
注意这里面的“协”字。DMAC是在“协助”CPU完成对应的数据传输工作。在DMAC控制数据传输的过程中我们还是需要CPU的。
除此之外DMAC其实也是一个特殊的I/O设备它和CPU以及其他I/O设备一样通过连接到总线来进行实际的数据传输。总线上的设备呢其实有两种类型。一种我们称之为**主设备**Master另外一种我们称之为**从设备**Slave
想要主动发起数据传输必须要是一个主设备才可以CPU就是主设备。而我们从设备比如硬盘只能接受数据传输。所以如果通过CPU来传输数据要么是CPU从I/O设备读数据要么是CPU向I/O设备写数据。
这个时候你可能要问了那我们的I/O设备不能向主设备发起请求么可以是可以不过这个发送的不是数据内容而是控制信号。I/O设备可以告诉CPU我这里有数据要传输给你但是实际数据是CPU拉走的而不是I/O设备推给CPU的。
<img src="https://static001.geekbang.org/resource/image/99/bc/9998b67238044aad60d2aa0735b98ebc.jpeg" alt="">
不过DMAC就很有意思了它既是一个主设备又是一个从设备。对于CPU来说它是一个从设备对于硬盘这样的IO设备来说呢它又变成了一个主设备。那使用DMAC进行数据传输的过程究竟是什么样的呢下面我们来具体看看。
1.首先CPU还是作为一个主设备向DMAC设备发起请求。这个请求其实就是在DMAC里面修改配置寄存器。
2.CPU修改DMAC的配置的时候会告诉DMAC这样几个信息
<li>首先是**源地址的初始值以及传输时候的地址增减方式**。<br>
所谓源地址就是数据要从哪里传输过来。如果我们要从内存里面写入数据到硬盘上那么就是要读取的数据在内存里面的地址。如果是从硬盘读取数据到内存里那就是硬盘的I/O接口的地址。<br>
我们讲过总线的时候说过I/O的地址可以是一个内存地址也可以是一个端口地址。而地址的增减方式就是说数据是从大的地址向小的地址传输还是从小的地址往大的地址传输。</li>
- 其次是**目标地址初始值和传输时候的地址增减方式**。目标地址自然就是和源地址对应的设备,也就是我们数据传输的目的地。
- 第三个自然是**要传输的数据长度**,也就是我们一共要传输多少数据。
3.设置完这些信息之后DMAC就会变成一个空闲的状态Idle
4.如果我们要从硬盘上往内存里面加载数据这个时候硬盘就会向DMAC发起一个数据传输请求。这个请求并不是通过总线而是通过一个额外的连线。
5.然后我们的DMAC需要再通过一个额外的连线响应这个申请。
6.于是DMAC这个芯片就向硬盘的接口发起要总线读的传输请求。数据就从硬盘里面读到了DMAC的控制器里面。
7.然后DMAC再向我们的内存发起总线写的数据传输请求把数据写入到内存里面。
8.DMAC会反复进行上面第6、7步的操作直到DMAC的寄存器里面设置的数据长度传输完成。
9.数据传输完成之后DMAC重新回到第3步的空闲状态。
所以整个数据传输的过程中我们不是通过CPU来搬运数据而是由DMAC这个芯片来搬运数据。但是CPU在这个过程中也是必不可少的。因为传输什么数据从哪里传输到哪里其实还是由CPU来设置的。这也是为什么DMAC被叫作“协处理器”。
<img src="https://static001.geekbang.org/resource/image/c9/8e/c9ed34b47b0cd33867c581772d8eff8e.jpeg" alt="">
最早计算机里是没有DMAC的所有数据都是由CPU来搬运的。随着人们对于数据传输的需求越来越多先是出现了主板上独立的DMAC控制器。到了今天各种I/O设备越来越多数据传输的需求越来越复杂使用的场景各不相同。加之显示器、网卡、硬盘对于数据传输的需求都不一样所以各个设备里面都有自己的DMAC芯片了。
## 为什么那么快一起来看Kafka的实现原理
了解了DMAC是怎么回事儿那你可能要问了这和我们实际进行程序开发有什么关系呢有什么API我们直接调用一下就能加速数据传输减少CPU占用吗
你还别说过去几年的大数据浪潮里面还真有一个开源项目很好地利用了DMA的数据传输方式通过DMA的方式实现了非常大的性能提升。这个项目就是**Kafka**。下面我们就一起来看看它究竟是怎么利用DMA的。
Kafka是一个用来处理实时数据的管道我们常常用它来做一个消息队列或者用来收集和落地海量的日志。作为一个处理实时数据和日志的管道瓶颈自然也在I/O层面。
Kafka里面会有两种常见的海量数据传输的情况。一种是从网络中接收上游的数据然后需要落地到本地的磁盘上确保数据不丢失。另一种情况呢则是从本地磁盘上读取出来通过网络发送出去。
我们来看一看后一种情况从磁盘读数据发送到网络上去。如果我们自己写一个简单的程序最直观的办法自然是用一个文件读操作从磁盘上把数据读到内存里面来然后再用一个Socket把这些数据发送到网络上去。
```
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
```
在这个过程中数据一共发生了四次传输的过程。其中两次是DMA的传输另外两次则是通过CPU控制的传输。下面我们来具体看看这个过程。
第一次传输是从硬盘上读到操作系统内核的缓冲区里。这个传输是通过DMA搬运的。
第二次传输需要从内核缓冲区里面的数据复制到我们应用分配的内存里面。这个传输是通过CPU搬运的。
第三次传输要从我们应用的内存里面再写到操作系统的Socket的缓冲区里面去。这个传输还是由CPU搬运的。
最后一次传输需要再从Socket的缓冲区里面写到网卡的缓冲区里面去。这个传输又是通过DMA搬运的。
<img src="https://static001.geekbang.org/resource/image/e0/d5/e0e85505e793e804e3b396fc50871cd5.jpg" alt="">
这个时候你可以回过头看看这个过程。我们只是要“搬运”一份数据结果却整整搬运了四次。而且这里面从内核的读缓冲区传输到应用的内存里再从应用的内存里传输到Socket的缓冲区里其实都是把同一份数据在内存里面搬运来搬运去特别没有效率。
像Kafka这样的应用场景其实大部分最终利用到的硬件资源其实又都是在干这个搬运数据的事儿。所以我们就需要尽可能地减少数据搬运的需求。
事实上Kafka做的事情就是把这个数据搬运的次数从上面的四次变成了两次并且只有DMA来进行数据搬运而不需要CPU。
```
@Override
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}
```
Kafka的代码调用了Java NIO库具体是FileChannel里面的transferTo方法。我们的数据并没有读到中间的应用内存里面而是直接通过Channel写入到对应的网络设备里。并且对于Socket的操作也不是写入到Socket的Buffer里面而是直接根据描述符Descriptor写入到网卡的缓冲区里面。于是在这个过程之中我们只进行了两次数据传输。
<img src="https://static001.geekbang.org/resource/image/59/ab/596042d111ad9b871045d970a10464ab.jpg" alt="">
第一次是通过DMA从硬盘直接读到操作系统内核的读缓冲区里面。第二次则是根据Socket的描述符信息直接从读缓冲区里面写入到网卡的缓冲区里面。
这样我们同一份数据传输的次数从四次变成了两次并且没有通过CPU来进行数据搬运所有的数据都是通过DMA来进行传输的。
在这个方法里面我们没有在内存层面去“复制Copy”数据所以这个方法也被称之为**零拷贝**Zero-Copy
IBM Developer Works里面有一篇文章专门写过程序来测试过在同样的硬件下使用零拷贝能够带来的性能提升。我在这里放上这篇文章[链接](https://developer.ibm.com/articles/j-zerocopy/)。在这篇文章最后你可以看到无论传输数据量的大小传输同样的数据使用了零拷贝能够缩短65%的时间,大幅度提升了机器传输数据的吞吐量。想要深入了解零拷贝,建议你可以仔细读一读这篇文章。
## 总结延伸
讲到这里相信你对DMA的原理、作用和效果都有所理解了。那么我们一起来回顾总结一下。
如果我们始终让CPU来进行各种数据传输工作会特别浪费。一方面我们的数据传输工作用不到多少CPU核心的“计算”功能。另一方面CPU的运转速度也比I/O操作要快很多。所以我们希望能够给CPU“减负”。
于是工程师们就在主板上放上了DMAC这样一个协处理器芯片。通过这个芯片CPU只需要告诉DMAC我们要传输什么数据从哪里来到哪里去就可以放心离开了。后续的实际数据传输工作都会由DMAC来完成。随着现代计算机各种外设硬件越来越多光一个通用的DMAC芯片不够了我们在各个外设上都加上了DMAC芯片使得CPU很少再需要关心数据传输的工作了。
在我们实际的系统开发过程中利用好DMA的数据传输机制也可以大幅提升I/O的吞吐率。最典型的例子就是Kafka。
传统地从硬盘读取数据然后再通过网卡向外发送我们需要进行四次数据传输其中有两次是发生在内存里的缓冲区和对应的硬件设备之间我们没法节省掉。但是还有两次完全是通过CPU在内存里面进行数据复制。
在Kafka里通过Java的NIO里面FileChannel的transferTo方法调用我们可以不用把数据复制到我们应用程序的内存里面。通过DMA的方式我们可以把数据从内存缓冲区直接写到网卡的缓冲区里面。在使用了这样的零拷贝的方法之后呢我们传输同样数据的时间可以缩减为原来的1/3相当于提升了3倍的吞吐率。
这也是为什么Kafka是目前实时数据传输管道的标准解决方案。
## 推荐阅读
学完了这一讲之后我推荐你阅读一下Kafka的论文[Kakfa:a Distrubted Messaging System for Log Processing](http://notes.stephenholiday.com/Kafka.pdf)。Kafka的论文其实非常简单易懂是一个很好的让你了解系统、日志、分布式系统的入门材料。
如果你想要进一步去了解Kafka也可以订阅极客时间的专栏“[Kafka核心技术与实战](https://time.geekbang.org/column/intro/191)”。
## 课后思考
你可以自己尝试写一段使用零拷贝和不使用零拷贝传输数据的代码,然后看一看两者之间的性能差异。你可以看看,零拷贝能够带来多少吞吐量提升。
欢迎你把你运行程序的结果写在留言区和大家一起讨论、分享。你也可以把这个问题分享给你的朋友一起试一试看看DMA和零拷贝是否真的可以大幅度提升性能。

View File

@@ -0,0 +1,87 @@
<audio id="audio" title="49 | 数据完整性(上):硬件坏了怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cd/06/cde58524b1f00cf943e1f7ed2834a906.mp3"></audio>
2012年的时候我第一次在工作中遇到一个因为硬件的不可靠性引发的Bug。正是因为这个Bug让我开始逐步花很多的时间去复习回顾整个计算机系统里面的底层知识。
当时我正在MediaV带领一个20多人的团队负责公司的广告数据和机器学习算法。其中有一部分工作就是用Hadoop集群处理所有的数据和报表业务。当时我们的业务增长很快所以会频繁地往Hadoop集群里面添置机器。2012年的时候国内的云计算平台还不太成熟所以我们都是自己采购硬件放在托管的数据中心里面。
那个时候我们的Hadoop集群服务器在从100台服务器往1000台服务器走。我们觉得像Dell这样品牌厂商的服务器太贵了而且能够提供的硬件配置和我们的期望也有差异。于是运维的同学开始和OEM厂商合作自己定制服务器批量采购硬盘、内存。
那个时候大家都听过Google早期发展时为了降低成本买了很多二手的硬件来降低成本通过分布式的方式来保障系统的可靠性的办法。虽然我们还没有抠门到去买二手硬件不过当时我们选择购买了普通的机械硬盘而不是企业级的、用在数据中心的机械硬盘采购了普通的内存条而不是带ECC纠错的服务器内存条想着能省一点儿是一点儿。
## 单比特翻转:软件解决不了的硬件错误
忽然有一天,我们最大的、每小时执行一次的数据处理报表应用,完成时间变得比平时晚了不少。一开始,我们并没有太在意,毕竟当时数据量每天都在增长,慢一点就慢一点了。但是,接着糟糕的事情开始发生了。
一方面,我们发现,报表任务有时候在一个小时之内执行不完,接着,偶尔整个报表任务会执行失败。于是,我们不得不停下手头开发的工作,开始排查这个问题。
用过Hadoop的话你可能知道作为一个分布式的应用考虑到硬件的故障Hadoop本身会在特定节点计算出错的情况下重试整个计算过程。之前的报表跑得慢就是因为有些节点的计算任务失败过只是在重试之后又成功了。进一步分析我们发现程序的错误非常奇怪。有些数据计算的结果比如“34+23”结果应该是“57”但是却变成了一个美元符号“$”。
前前后后折腾了一周,我们发现,从日志上看,大部分出错的任务都在几个固定的硬件节点上。
另一方面我们发现问题出现在我们新的一批自己定制的硬件上架之后。于是和运维团队的同事沟通近期的硬件变更并且翻阅大量Hadoop社区的邮件组列表之后我们有了一个大胆的推测。
我们推测这个错误来自我们自己定制的硬件。定制的硬件没有使用ECC内存在大量的数据中内存中出现了**单比特翻转**Single-Bit Flip这个传说中的硬件错误。
那这个符号是怎么来的呢?是由于内存中的一个整数字符,遇到了一次单比特翻转转化而来的。 它的ASCII码二进制表示是0010 0100所以它完全可能来自 0011 0100 遇到一次在第4个比特的单比特翻转也就是从整数“4”变过来的。但是我们也只能**推测**是这个错误,而不能**确信**是这个错误。因为单比特翻转是一个随机现象,我们没法稳定复现这个问题。
<img src="https://static001.geekbang.org/resource/image/45/0f/45ad4eb91f48afd08c581148d5f6320f.jpeg" alt="">
**ECC内存**的全称是Error-Correcting Code memory中文名字叫作**纠错内存**。顾名思义,就是在内存里面出现错误的时候,能够自己纠正过来。
在和运维同学沟通之后我们把所有自己定制的服务器的内存替换成了ECC内存之后这个问题就消失了。这也使得我们基本确信问题的来源就是因为没有使用ECC内存。我们所有工程师的开发用机在2012年也换成了32G内存。是的换下来的内存没有别的去处都安装到了研发团队的开发机上。
## 奇偶校验和校验位:捕捉错误的好办法
其实,内存里面的单比特翻转或者错误,并不是一个特别罕见的现象。无论是因为内存的制造质量造成的漏电,还是外部的射线,都有一定的概率,会造成单比特错误。而内存层面的数据出错,软件工程师并不知道,而且这个出错很有可能是随机的。遇上随机出现难以重现的错误,大家肯定受不了。我们必须要有一个办法,避免这个问题。
其实在ECC内存发明之前工程师们已经开始通过**奇偶校验**的方式,来发现这些错误。
奇偶校验的思路很简单。我们把内存里面的N位比特当成是一组。常见的比如8位就是一个字节。然后用额外的一位去记录这8个比特里面有奇数个1还是偶数个1。如果是奇数个1那额外的一位就记录为1如果是偶数个1那额外的一位就记录成0。那额外的一位我们就称之为**校验码位**。
<img src="https://static001.geekbang.org/resource/image/e9/40/e94c642bdf41290d6a4e5eb2d6bb3c40.jpeg" alt="">
如果在这个字节里面,我们不幸发生了单比特翻转,那么数据位计算得到的校验码,就和实际校验位里面的数据不一样。我们的内存就知道出错了。
除此之外校验位有一个很大的优点就是计算非常快往往只需要遍历一遍需要校验的数据通过一个O(N)的时间复杂度的算法,就能把校验结果计算出来。
校验码的思路,在很多地方都会用到。
比方说我们下载一些软件的时候你会看到除了下载的包文件还会有对应的MD5这样的哈希值或者循环冗余编码CRC的校验文件。这样当我们把对应的软件下载下来之后我们可以计算一下对应软件的校验码和官方提供的校验码去做个比对看看是不是一样。
如果不一样,你就不能轻易去安装这个软件了。因为有可能,这个软件包是坏的。但是,还有一种更危险的情况,就是你下载的这个软件包,可能是被人植入了后门的。安装上了之后,你的计算机的安全性就没有保障了。
不过,使用奇偶校验,还是有两个比较大的缺陷。
第一个缺陷就是奇偶校验只能解决遇到单个位的错误或者说奇数个位的错误。如果出现2个位进行了翻转那么这个字节的校验位计算结果其实没有变我们的校验位自然也就不能发现这个错误。
第二个缺陷,是它只能发现错误,但是不能纠正错误。所以,即使在内存里面发现数据错误了,我们也只能中止程序,而不能让程序继续正常地运行下去。如果这个只是我们的个人电脑,做一些无关紧要的应用,这倒是无所谓了。
但是,你想一下,如果你在服务器上进行某个复杂的计算任务,这个计算已经跑了一周乃至一个月了,还有两三天就跑完了。这个时候,出现内存里面的错误,要再从头跑起,估计你内心是崩溃的。
所以我们需要一个比简单的校验码更好的解决方案一个能够发现更多位的错误并且能够把这些错误纠正过来的解决方案也就是工程师们发明的ECC内存所使用的解决方案。
我们不仅能捕捉到错误,还要能够纠正发生的错误。这个策略,我们通常叫作**纠错码**Error Correcting Code。它还有一个升级版本叫作**纠删码**Erasure Code不仅能够纠正错误还能够在错误不能纠正的时候直接把数据删除。无论是我们的ECC内存还是网络传输乃至硬盘的RAID其实都利用了纠错码和纠删码的相关技术。
想要看看我们怎么通过算法,怎么配置硬件,使得我们不仅能够发现单个位的错误,而能发现更多位的错误,你一定要记得跟上下一讲的内容。
## 总结延伸
好了,让我们一起来总结一下今天的内容。
我给你介绍了我自己亲身经历的一个硬件错误带来的Bug。由于没有采用ECC内存导致我们的数据处理中出现了大量的单比特数据翻转的错误。这些硬件带来的错误其实我们没有办法在软件层面解决。
如果对于硬件以及硬件本身的原理不够熟悉,恐怕这个问题的解决方案还是遥遥无期。如果你对计算机组成原理有所了解,并能够意识到,在硬件的存储层有着数据验证和纠错的需求,那你就能在有限的时间内定位到问题所在。
进一步地,我为你简单介绍了奇偶校验,也就是如何通过冗余的一位数据,发现在硬件层面出现的位错误。但是,奇偶校验以及其他的校验码,只能发现错误,没有办法纠正错误。所以,下一讲,我们一起来看看,怎么利用纠错码这样的方式,来解决问题。
## 推荐阅读
我推荐你去深入阅读一下Wikipedia里面关于[CRC](https://en.wikipedia.org/wiki/Cyclic_redundancy_check)的内容,了解一下,这样的校验码的详细算法。
## 课后思考
有人说奇偶校验只是循环冗余编码CRC的一种特殊情况。在读完推荐阅读里面的CRC算法的实现之后你能分析一下为什么奇偶校验只是CRC的一种特殊情况吗
欢迎把你阅读和分析的内容写在留言区,和大家一起分享。如果觉得有帮助,你也可以把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,105 @@
<audio id="audio" title="50 | 数据完整性(下):如何还原犯罪现场?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9b/5b/9bae72a19d39cbc44cc4887ea003515b.mp3"></audio>
讲完校验码之后你现在应该知道无论是奇偶校验码还是CRC这样的循环校验码都只能告诉我们一个事情就是你的数据出错了。所以校验码也被称为检错码Error Detecting Code
不管是校验码,还是检错码,在硬件出错的时候,只能告诉你“我错了”。但是,下一个问题,“错哪儿了”,它是回答不了的。这就导致,我们的处理方式只有一种,那就是当成“哪儿都错了”。如果是下载一个文件,发现校验码不匹配,我们只能重新去下载;如果是程序计算后放到内存里面的数据,我们只能再重新算一遍。
这样的效率实在是太低了,所以我们需要有一个办法,不仅告诉我们“我错了”,还能告诉我们“错哪儿了”。于是,计算机科学家们就发明了**纠错码**。纠错码需要更多的冗余信息,通过这些冗余信息,我们不仅可以知道哪里的数据错了,还能直接把数据给改对。这个是不是听起来很神奇?接下来就让我们一起来看一看。
## 海明码:我们需要多少信息冗余?
最知名的纠错码就是海明码。海明码Hamming Code是以他的发明人Richard Hamming理查德·海明的名字命名的。这个编码方式早在上世纪四十年代就被发明出来了。而直到今天我们上一讲所说到的ECC内存也还在使用海明码来纠错。
最基础的海明码叫**7-4海明码**。这里的“7”指的是实际有效的数据一共是7位Bit。而这里的“4”指的是我们额外存储了4位数据用来纠错。
首先你要明白一点纠错码的纠错能力是有限的。不是说不管错了多少位我们都能给纠正过来。不然我们就不需要那7个数据位只需要那4个校验位就好了这意味着我们可以不用数据位就能传输信息了。这就不科学了。事实上在7-4海明码里面我们只能纠正某1位的错误。这是怎么做到的呢我们一起来看看。
4位的校验码一共可以表示 2^4 = 16 个不同的数。根据数据位计算出来的校验值,一定是确定的。所以,如果数据位出错了,计算出来的校验码,一定和确定的那个校验码不同。那可能的值,就是在 2^4 - 1 = 15 那剩下的15个可能的校验值当中。
15个可能的校验值其实可以对应15个可能出错的位。这个时候你可能就会问了既然我们的数据位只有7位那为什么我们要用4位的校验码呢用3位不就够了吗2^3 - 1 = 7正好能够对上7个不同的数据位啊
你别忘了单比特翻转的错误不仅可能出现在数据位也有可能出现在校验位。校验位本身也是可能出错的。所以7位数据位和3位校验位如果只有单比特出错可能出错的位数就是10位2^3 - 1 = 7 种情况是不能帮我们找到具体是哪一位出错的。
事实上如果我们的数据位有K位校验位有N位。那么我们需要满足下面这个不等式才能确保我们能够对单比特翻转的数据纠错。这个不等式就是
在有7位数据位也就是K=7的情况下N的最小值就是4。4位校验位其实最多可以支持到11位数据位。我在下面列了一个简单的数据位数和校验位数的对照表你可以自己算一算理解一下上面的公式。
<img src="https://static001.geekbang.org/resource/image/ec/1d/ec8b6bff509e1abb7453caa36a4a711d.jpeg" alt="">
## 海明码的纠错原理
现在你应该搞清楚了,在数据位数确定的情况下,怎么计算需要的校验位。那接下来,我们就一起看看海明码的编码方式是怎么样的。
为了算起来简单一点,我们少用一些位数,来算一个**4-3海明码**也就是4位数据位3位校验位。我们把4位数据位分别记作d1、d2、d3、d4。这里的d取的是数据位data bits的首字母。我们把3位校验位分别记作p1、p2、p3。这里的p取的是校验位parity bits的首字母。
从4位的数据位里面我们拿走1位然后计算出一个对应的校验位。这个校验位的计算用之前讲过的奇偶校验就可以了。比如我们用d1、d2、d4来计算出一个校验位p1用d1、d3、d4计算出一个校验位p2用d2、d3、d4计算出一个校验位p3。就像下面这个对应的表格一样
<img src="https://static001.geekbang.org/resource/image/6d/bc/6d7cf44bb41df6361e82dcd4979dc4bc.jpeg" alt="">
这个时候你去想一想如果d1这一位的数据出错了会发生什么情况我们会发现p1和p2和校验的计算结果不一样。d2出错了是因为p1和p3的校验的计算结果不一样d3出错了则是因为p2和p3如果d4出错了则是p1、p2、p3都不一样。你会发现当数据码出错的时候至少会有2位校验码的计算是不一致的。
那我们倒过来如果是p1的校验码出错了会发生什么情况呢这个时候只有p1的校验结果出错。p2和p3的出错的结果也是一样的只有一个校验码的计算是不一致的。
所以校验码不一致,一共有 2^3-1=7种情况正好对应了7个不同的位数的错误。我把这个对应表格也放在下面了你可以理解一下。
<img src="https://static001.geekbang.org/resource/image/3e/21/3edee00788294bb96cde11dace2a7721.jpeg" alt="">
可以看到,海明码这样的纠错过程,有点儿像电影里面看到的推理探案的过程。通过出错现场的额外信息,一步一步条分缕析地找出,到底是哪一位的数据出错,还原出错时候的“犯罪现场”。
看到这里,相信你一方面会觉得海明码特别神奇,但是同时也会冒出一个新的疑问,我们怎么才能用一套程序或者规则来生成海明码呢?其实这个步骤并不复杂,接下来我们就一起来看一下。
首先我们先确定编码后要传输的数据是多少位。比如说我们这里的7-4海明码就是一共11位。
然后我们给这11位数据从左到右进行编号并且也把它们的二进制表示写出来。
接着我们先把这11个数据中的二进制的整数次幂找出来。在这个7-4海明码里面就是1、2、4、8。这些数就是我们的校验码位我们把他们记录做p1p4。如果从二进制的角度看它们是这11个数当中唯四的在4个比特里面只有一个比特是1的数值。
那么剩下的7个数就是我们d1-d7的数据码位了。
然后对于我们的校验码位我们还是用奇偶校验码。但是每一个校验码位不是用所有的7位数据来计算校验码。而是p1用3、5、7、9、11来计算。也就是在二进制表示下从右往左数的第一位比特是1的情况下用p1作为校验码。
剩下的p2我们用3、6、10、11来计算校验码也就是在二进制表示下从右往左数的第二位比特是1的情况下用p2。那么p3自然是从右往左数第三位比特是1的情况下的数字校验码。而p4则是第四位比特是1的情况下的校验码。
<img src="https://static001.geekbang.org/resource/image/a7/9d/a7d5e958f9d46938e494710e090f469d.jpeg" alt="">
这个时候,你会发现,任何一个数据码出错了,就至少会有对应的两个或者三个校验码对不上,这样我们就能反过来找到是哪一个数据码出错了。如果校验码出错了,那么只有校验码这一位对不上,我们就知道是这个校验码出错了。
上面这个方法,我们可以用一段确定的程序表示出来,意味着无论是几位的海明码,我们都不再需要人工去精巧地设计编码方案了。
## 海明距离:形象理解海明码的作用
其实,我们还可以换一个角度来理解海明码的作用。对于两个二进制表示的数据,他们之间有差异的位数,我们称之为海明距离。比如 1001 和 0001 的海明距离是1因为他们只有最左侧的第一位是不同的。而1001 和 0000 的海明距离是2因为他们最左侧和最右侧有两位是不同的。
<img src="https://static001.geekbang.org/resource/image/fb/78/fb388f965a7a7631925a32cc4610ff78.jpeg" alt="">
于是你很容易可以想到所谓的进行一位纠错也就是所有和我们要传输的数据的海明距离为1的数都能被纠正回来。
而任何两个实际我们想要传输的数据海明距离都至少要是3。你可能会问了为什么不能是2呢因为如果是2的话那么就会有一个出错的数到两个正确的数据的海明距离都是1。当我们看到这个出错的数的时候我们就不知道究竟应该纠正到那一个数了。
在引入了海明距离之后,我们就可以更形象地理解纠错码了。在没有纠错功能的情况下,我们看到的数据就好像是空间里面的一个一个点。这个时候,我们可以让数据之间的距离很紧凑,但是如果这些点的坐标稍稍有错,我们就可能搞错是哪一个点。
在有了1位纠错功能之后就好像我们把一个点变成了以这个点为中心半径为1的球。只要坐标在这个球的范围之内我们都知道实际要的数据就是球心的坐标。而各个数据球不能距离太近不同的数据球之间要有3个单位的距离。
<img src="https://static001.geekbang.org/resource/image/d6/34/d65bdde974ee99b6187eac90e4b5a234.jpeg" alt="">
## 总结延伸
好了纠错码的内容到这里就讲完了。你可不要小看这个看起来简单的海明码。虽然它在上世纪40年代早早地就诞生了不过直到今天的ECC内存里面我们还在使用这个技术方案。而海明也因为海明码获得了图灵奖。
通过在数据中添加多个冗余的校验码位海明码不仅能够检测到数据中的错误还能够在只有单个位的数据出错的时候把错误的一位纠正过来。在理解和计算海明码的过程中有一个很重要的点就是不仅原来的数据位可能出错。我们新添加的校验位一样可能会出现单比特翻转的错误。这也是为什么7位数据位用3位校验码位是不够的而需要4位校验码位。
实际的海明码编码的过程也并不复杂,我们通过用不同过的校验位,去匹配多个不同的数据组,确保任何一个数据位出错,都会产生一个多个校验码位出错的唯一组合。这样,在出错的时候,我们就可以反过来找到出错的数据位,并纠正过来。当只有一个校验码位出错的时候,我们就知道实际出错的是校验码位了。
## 推荐阅读
这一讲的推荐阅读,还是让我们回到教科书。我推荐你去读一读《计算机组成与设计:软件/硬件接口》的5.5章节,关于可信存储器的部分。
另外如果你想在纠错码上进一步深入你可以去了解一下纠删码也就是Erasure Code。最好的学习入口当然还是[Wikipedia](https://en.wikipedia.org/wiki/Erasure_code)。
## 课后思考
7明码除了可以进行单个位的纠错之外。还能做到可以检测Detection到两个位的出错。也就是说虽然我们不知道是哪两个比特错了但是我们还是知道数据是错了的。为什么能够做到这一点呢
你可以好好思考一下,然后在留言区写下你的答案。如果有收获,你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,119 @@
<audio id="audio" title="51 | 分布式计算:如果所有人的大脑都联网会怎样?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/b6/b15a52e0b604d7f65388f6f6b48436b6.mp3"></audio>
今天是原理篇的最后一篇。过去50讲我们一起看了抽象概念上的计算机指令看了这些指令怎么拆解成一个个简单的电路以及CPU是怎么通过一个一个的电路组成的。我们还一起看了高速缓存、内存、SSD硬盘和机械硬盘以及这些组件又是怎么通过总线和CPU连在一起相互通信的。
把计算机这一系列组件组合起来我们就拿到了一台完整的计算机。现在我们每天在用的个人PC、智能手机乃至云上的服务器都是这样一台计算机。
但是,一台计算机在数据中心里是不够的。因为如果只有一台计算机,我们会遇到三个核心问题。第一个核心问题,叫作**垂直扩展和水平扩展的选择问题**,第二问题叫作**如何保持高可用性**High Availability第三个问题叫作**一致性问题**Consistency
围绕这三个问题,其实就是我们今天要讲的主题,分布式计算。当然,短短的一讲肯定讲不完这么大一个主题。分布式计算拿出来单开一门专栏也绰绰有余。我们今天这一讲的目标,是让你能理解水平扩展、高可用性这两个核心问题。对于分布式系统带来的一致性问题,我们会留在我们的实战篇里面,再用案例来为大家分析。
## 从硬件升级到水平扩展
从技术开发的角度来讲想要在2019年创业真的很幸福。只要在AWS或者阿里云这样的云服务上注册一个账号一个月花上一两百块钱你就可以有一台在数据中心里面的服务器了。而且这台服务器可以直接提供给世界各国人民访问。如果你想要做海外市场你可以把这个服务器放在美国、欧洲、东南亚任何一个你想要去的市场的数据中心里然后把自己的网站部署在这台服务器里面就可以了。
<img src="https://static001.geekbang.org/resource/image/1d/6e/1d1cb606a2ee8261c45f99686fb7946e.png" alt="">
当然,这台服务器就是我们在[第34讲](https://time.geekbang.org/column/article/107183)里说的虚拟机。不过因为只是个业余时间的小项目一开始这台服务器的配置也不会太高。我以我现在公司所用的Google Cloud为例。最低的配置差不多是1个CPU核心、3.75G内存以及一块10G的SSD系统盘。这样一台服务器每个月的价格差不多是28美元。
幸运的是,你的网站很受大家欢迎,访问量也上来了。这个时候,这台单核心的服务器的性能有点不够用了。这个时候,你需要升级你的服务器。于是,你就会面临两个选择。
第一个选择是升级现在这台服务器的硬件变成2个CPU核心、7.5G内存。这样的选择我们称之为**垂直扩展**Scale Up。第二个选择则是我们再租用一台和之前一样的服务器。于是我们有了2台1个CPU核心、3.75G内存的服务器。这样的选择我们称之为**水平扩展**Scale Out
在这个阶段这两个选择从成本上看起来没有什么差异。2核心、7.5G内存的服务器成本是56.61美元而2台1核心、3.75G内存的服务器价格成本是57美元这之间的价格差异不到1%。
不过垂直扩展和水平扩展看似是两个不同的选择但是随着流量不断增长。到最后只会变成一个选择。那就是既会垂直扩展又会水平扩展并且最终依靠水平扩展来支撑Google、Facebook、阿里、腾讯这样体量的互联网服务。
垂直扩展背后的逻辑和优势都很简单。一般来说,垂直扩展通常不需要我们去改造程序,也就是说,我们**没有研发成本**。那为什么我们最终还是要用水平扩展呢?你可以先自己想一想。
原因其实很简单因为我们没有办法不停地去做垂直扩展。我们在Google Cloud上现在能够买到的性能最好的服务器是96个CPU核心、1.4TB的内存。如果我们的访问量逐渐增大一台96核心的服务器也支撑不了了那么我们就没有办法再去做垂直扩展了。这个时候我们就不得不采用水平扩展的方案了。
96个CPU核心看起来是个很强大的服务器但是你算一算就知道其实它的计算资源并没有多大。你现在多半在用一台4核心或者至少也是2核心的CPU。96个CPU也就是3050台日常使用的开发机的计算性能。而我们今天在互联网上遇到的问题是每天数亿的访问量靠3050台个人电脑的计算能力想要支撑这样的计算需求可谓是天方夜谭了。
然而,一旦开始采用水平扩展,我们就会面临在软件层面改造的问题了。也就是我们需要开始进行**分布式计算**了。我们需要引入**负载均衡**Load Balancer这样的组件来进行流量分配。我们需要拆分应用服务器和数据库服务器来进行垂直功能的切分。我们也需要不同的应用之间通过消息队列来进行异步任务的执行。
<img src="https://static001.geekbang.org/resource/image/03/91/034fdb2fcf4a371d1eeb331e86fa4491.jpeg" alt="">
所有这些软件层面的改造其实都是在做分布式计算的一个核心工作就是通过消息传递Message Passing而不是共享内存Shared Memory的方式让多台不同的计算机协作起来共同完成任务。
而因为我们最终必然要进行水平扩展,我们需要在系统设计的早期就基于消息传递而非共享内存来设计系统。即使这些消息只是在同一台服务器上进行传递。
事实上有不少增长迅猛的公司早期没有准备好通过水平扩展来支撑访问量的情况而一味通过提升硬件配置Scale Up来支撑更大的访问量最终影响了公司的存亡。最典型的例子就是败在Facebook手下的[MySpace](https://en.wikipedia.org/wiki/Myspace)。
## 理解高可用性和单点故障
尽管在1个CPU核心的服务器支撑不了我们的访问量的时候选择垂直扩展是一个最简单的办法。不过如果是我的话第一次扩展我会选择水平扩展。
选择水平扩展的一个很好的理由,自然是可以“强迫”从开发的角度,尽早地让系统能够支持水平扩展,避免在真的流量快速增长的时候,垂直扩展的解决方案跟不上趟。不过,其实还有一个更重要的理由,那就是系统的可用性问题。
上面的1核变2核的垂直扩展的方式扩展完之后我们还是只有1台服务器。如果这台服务器出现了一点硬件故障比如CPU坏了那我们的整个系统就坏了就**不可用**了。
如果采用了水平扩展即便有一台服务器的CPU坏了我们还有另外一台服务器仍然能够提供服务。负载均衡能够通过健康检测Health Check发现坏掉的服务器没有响应了就可以自动把所有的流量切换到第2台服务器上这个操作就叫作**故障转移**Failover我们的系统仍然是**可用**的。
系统的**可用性**Avaiability指的就是我们的系统可以正常服务的时间占比。无论是因为软硬件故障还是需要对系统进行停机升级都会让我们损失系统的可用性。可用性通常是用一个百分比的数字来表示比如99.99%。我们说系统每个月的可用性要保障在99.99%也就是意味着一个月里你的服务宕机的时间不能超过4.32分钟。
有些系统可用性的损失是在我们计划内的。比如上面说的停机升级这个就是所谓的计划内停机时间Scheduled Downtime。有些系统可用性的损失是在我们计划外的比如一台服务器的硬盘忽然坏了这个就是所谓的计划外停机时间Unscheduled Downtime
我们的系统是一定不可能做到100%可用的,特别是计划外的停机时间。从简单的硬件损坏,到机房停电、光缆被挖断,乃至于各种自然灾害,比如地震、洪水、海啸,都有可能使得我们的系统不可用。作为一个工程师和架构师,我们要做的就是尽可能低成本地提高系统的可用性。
咱们的专栏是要讲计算机组成原理,那我们先来看一看硬件服务器的可用性。
现在的服务器的可用性都已经很不错了通常都能保障99.99%的可用性了。如果我们有一个小小的三台服务器组成的小系统一台部署了Nginx来作为负载均衡和反向代理一台跑了PHP-FPM作为Web应用服务器一台用来作为MySQL数据库服务器。每台服务器的可用性都是99.99%。那么我们整个系统的可用性是多少呢?你可以先想一想。
答案是99.99% × 99.99% × 99.99% = 99.97%。在这个系统当中这个数字看起来似乎没有那么大区别。不过反过来看我们是从损失了0.01%的可用性变成了损失0.03%的可用性不可用的时间变成了原来的3倍。
如果我们有1000台服务器那么整个的可用性就会变成 99.99% ^ 1000 = 90.5%。也就是说,我们的服务一年里有超过一个月是不可用的。这可怎么办呀?
<img src="https://static001.geekbang.org/resource/image/e9/35/e9ef478712d744d07c82c54534564335.jpeg" alt="">
我们先来分析一下原因。之所以会出现这个问题,是因为在这个场景下,任何一台服务器出错了,整个系统就没法用了。这个问题就叫作**单点故障问题**Single Point of FailureSPOF。我们这里的这个假设特别糟糕。我们假设这1000台服务器每一个都存在单点故障问题。所以我们的服务也就特别脆弱随便哪台出现点风吹草动整个服务就挂了。
要解决单点故障问题,第一点就是要移除单点。其实移除单点最典型的场景,在我们水平扩展应用服务器的时候就已经看到了,那就是让两台服务器提供相同的功能,然后通过负载均衡把流量分发到两台不同的服务器去。即使一台服务器挂了,还有一台服务器可以正常提供服务。
不过光用两台服务器是不够的,单点故障其实在数据中心里面无处不在。我们现在用的是云上的两台虚拟机。如果这两台虚拟机是托管在同一台物理机上的,那这台物理机本身又成为了一个单点。那我们就需要把这两台虚拟机分到两台不同的物理机上。
不过这个还是不够。如果这两台物理机在同一个机架Rack那机架上的交换机Switch就成了一个单点。即使放到不同的机架上还是有可能出现整个数据中心遭遇意外故障的情况。
<img src="https://static001.geekbang.org/resource/image/ab/a4/ab09e9a2b9670d61c2391906189694a4.jpeg" alt="">
去年我自己就遇到过部署在Azure上的服务所在的数据中心因为散热问题触发了整个数据中心所有服务器被关闭的问题。面对这种情况我们就需要设计进行**异地多活**的系统设计和部署。所以在现代的云服务你在买服务器的时候可以选择服务器的area地区和zone区域而要不要把服务器放在不同的地区或者区域里也是避免单点故障的一个重要因素。
只是能够去除单点其实我们的可用性问题还没有解决。比如上面我们用负载均衡把流量均匀地分发到2台服务器上当一台应用服务器挂掉的时候我们的确还有一台服务器在提供服务。但是负载均衡会把一半的流量发到已经挂掉的服务器上所以这个时候只能算作一半可用。
想要让整个服务完全可用我们就需要有一套故障转移Failover机制。想要进行故障转移就首先要能发现故障。
以我们这里的PHP-FPM的Web应用为例负载均衡通常会定时去请求一个Web应用提供的健康检测Health Check的地址。这个时间间隔可能是5秒钟如果连续23次发现健康检测失败负载均衡就会自动将这台服务器的流量切换到其他服务器上。于是我们就自动地产生了一次故障转移。故障转移的自动化在大型系统里是很重要的因为服务器越多出现故障基本就是个必然发生的事情。而自动化的故障转移既能够减少运维的人手需求也能够缩短从故障发现到问题解决的时间周期提高可用性。
<img src="https://static001.geekbang.org/resource/image/09/02/0907c25d6f2fd80401ee3d9bf5d17802.png" alt="">
那么,让我们算一算,通过水平扩展相同功能的服务器来去掉单点故障,并且通过健康检查机制来触发自动的故障转移,这样的可用性会变成多少呢?你可以拿出纸和笔来试一下。
不知道你想明白应该怎么算了没有,在这种情况下,我们其实只要有任何一台服务器能够正常运转,就能正常提供服务。那么,我们的可用性就是:
可以看出,不能提供服务的时间就减少到了原来的万分之一。
当然,在实际情况中,可用性没法做到那么理想的地步。光从硬件的角度,从服务器到交换机,从网线连接到机房电力,从机房的整体散热到外部的光纤线路等等,可能出现问题的地方太多了。这也是为什么,我们需要从整个系统层面,去设计系统的高可用性。
## 总结延伸
讲到这里,相信你已经很清楚,为什么我们需要水平扩展了。对于怎么去设计整个硬件的部署,来保障高可用性,你应该也有了一个清晰的认识。这两点也是分布式计算在实践中非常重要的应用场景。
不过,光有这两点还是不够的。一旦系统里面有了很多台服务器。特别是,为了保障可用性,对于同样功能的、有状态的数据库进行了水平的扩展,我们就会面临一个新的挑战,那就是分区一致性问题。不过,这个问题更多的是一个软件设计问题,我把它留在后面的实战篇再进行讲解。
我们下面来回顾一下这一讲的内容。我们讲了通过升级硬件规格来提升服务能力的垂直扩展。除此之外,也可以通过增加服务器数量来提升服务能力。不过归根到底,我们一定要走上水平扩展的路径。
一方面是因为垂直扩展不可持续;另一方面,则是只有水平扩展才能保障高可用性。而通过水平扩展保障高可用性,则需要我们做三件事情。第一个是理解可用性是怎么计算的。服务器硬件的损坏只是可能导致可用性损失的因素之一,机房内的电力、散热、交换机、网络线路,都有可能导致可用性损失。而外部的光缆、自然灾害,也都有可能造成我们整个系统的不可用。
所以,在分析设计系统的时候,我们需要尽可能地排除单点故障。进一步地,对于硬件的故障,我们还要有自动化的故障转移策略。在这些策略都齐全之后,我们才能真的长舒一口气,在海量的负载和流量下安心睡个好觉。
## 推荐阅读
今天的推荐阅读,不是读一篇具体的文章,我推荐你可以常常去浏览一下[http://highscalability.com/](http://highscalability.com/)这个网站,里面有不少有价值的、讲解怎么做到高扩展性的小文章。
## 课后思考
你可以想一想,你现在在学习和工作中开发的系统,是否考虑到高可用性呢?你能找找这些系统中,你们做了哪些高可用性的硬件层面的设计,是否还存在哪些单点故障,以及做了哪些故障转移的措施来保持可用性吗?
你可以写在留言区,和大家一起分享一下实际的应用经验,也可以看看其他同学的工作中做了什么样的设计和相关工作,和大家一起交流、分享。

View File

@@ -0,0 +1,170 @@
<audio id="audio" title="05 | 计算机指令:让我们试试用纸带编程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/33/91/33098f112efdff4fa649d29450603091.mp3"></audio>
你在学写程序的时候,有没有想过,古老年代的计算机程序是怎么写出来的?
上大学的时候我们系里教C语言程序设计的老师说他们当年学写程序的时候不像现在这样都是用一种古老的物理设备叫作“打孔卡Punched Card”。用这种设备写程序可没法像今天这样掏出键盘就能打字而是要先在脑海里或者在纸上写出程序然后在纸带或者卡片上打洞。这样要写的程序、要处理的数据就变成一条条纸带或者一张张卡片之后再交给当时的计算机去处理。
<img src="https://static001.geekbang.org/resource/image/5d/d7/5d407c051e261902ad9a216c66de3fd7.jpg" alt="">
你看这个穿孔纸带是不是有点儿像我们现在考试用的答题卡那个时候人们在特定的位置上打洞或者不打洞来代表“0”或者“1”。
为什么早期的计算机程序要使用打孔卡而不能像我们现在一样用C或者Python这样的高级语言来写呢原因很简单因为计算机或者说CPU本身并没有能力理解这些高级语言。即使在2019年的今天我们使用的现代个人计算机仍然只能处理所谓的“机器码”也就是一连串的“0”和“1”这样的数字。
那么我们每天用高级语言的程序最终是怎么变成一串串“0”和“1”的这一串串“0”和“1”又是怎么在CPU中处理的今天我们就来仔细介绍一下“机器码”和“计算机指令”到底是怎么回事。
## 在软硬件接口中CPU帮我们做了什么事
我们常说CPU就是计算机的大脑。CPU的全称是Central Processing Unit中文是中央处理器。
我们上一节说了,从**硬件**的角度来看CPU就是一个超大规模集成电路通过电路实现了加法、乘法乃至各种各样的处理逻辑。
如果我们从**软件**工程师的角度来讲CPU就是一个执行各种**计算机指令**Instruction Code的逻辑机器。这里的计算机指令就好比一门CPU能够听得懂的语言我们也可以把它叫作**机器语言**Machine Language
不同的CPU能够听懂的语言不太一样。比如我们的个人电脑用的是Intel的CPU苹果手机用的是ARM的CPU。这两者能听懂的语言就不太一样。类似这样两种CPU各自支持的语言就是两组不同的**计算机指令集**英文叫Instruction Set。这里面的“Set”其实就是数学上的集合代表不同的单词、语法。
所以如果我们在自己电脑上写一个程序然后把这个程序复制一下装到自己的手机上肯定是没办法正常运行的因为这两者语言不通。而一台电脑上的程序简单复制一下到另外一台电脑上通常就能正常运行因为这两台CPU有着相同的指令集也就是说它们的语言相通的。
一个计算机程序不可能只有一条指令而是由成千上万条指令组成的。但是CPU里不能一直放着所有指令所以计算机程序平时是存储在存储器中的。这种程序指令存储在存储器里面的计算机我们就叫作**存储程序型计算机**Stored-program Computer
说到这里你可能要问了难道还有不是存储程序型的计算机么其实在没有现代计算机之前有着聪明才智的工程师们早就发明了一种叫Plugboard Computer的计算设备。我把它直译成“插线板计算机”。在一个布满了各种插口和插座的板子上工程师们用不同的电线来连接不同的插口和插座从而来完成各种计算任务。下面这个图就是一台IBM的Plugboard看起来是不是有一股满满的蒸汽朋克范儿
<img src="https://static001.geekbang.org/resource/image/99/51/99eb1ab1cdbdfa2d35fce456940ca651.jpg" alt="">
## 从编译到汇编,代码怎么变成机器码?
了解了计算机指令和计算机指令集接下来我们来看看平时编写的代码到底是怎么变成一条条计算机指令最后被CPU执行的呢我们拿一小段真实的C语言程序来看看。
```
// test.c
int main()
{
int a = 1;
int b = 2;
a = a + b;
}
```
这是一段再简单不过的C语言程序即便你不了解C语言应该也可以看懂。我们给两个变量 a、b分别赋值1、2然后再将a、b两个变量中的值加在一起重新赋值给了a这个变量。
要让这段程序在一个Linux操作系统上跑起来我们需要把整个程序翻译成一个**汇编语言**ASMAssembly Language的程序这个过程我们一般叫编译Compile成汇编代码。
针对汇编代码我们可以再用汇编器Assembler翻译成机器码Machine Code。这些机器码由“0”和“1”组成的机器语言表示。这一条条机器码就是一条条的**计算机指令**。这样一串串的16进制数字就是我们CPU能够真正认识的计算机指令。
在一个Linux操作系统上我们可以简单地使用gcc和objdump这样两条命令把对应的汇编代码和机器码都打印出来。
```
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o
```
可以看到左侧有一堆数字这些就是一条条机器码右边有一系列的push、mov、add、pop等这些就是对应的汇编代码。一行C语言代码有时候只对应一条机器码和汇编代码有时候则是对应两条机器码和汇编代码。汇编代码和机器码之间是一一对应的。
```
test.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 &lt;main&gt;:
int main()
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = a + b;
12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
15: 01 45 fc add DWORD PTR [rbp-0x4],eax
}
18: 5d pop rbp
19: c3 ret
```
这个时候你可能又要问了我们实际在用GCCGUC编译器套装GNU Compiler Collectipon编译器的时候可以直接把代码编译成机器码呀为什么还需要汇编代码呢原因很简单你看着那一串数字表示的机器码是不是摸不着头脑但是即使你没有学过汇编代码看的时候多少也能“猜”出一些这些代码的含义。
因为汇编代码其实就是“给程序员看的机器码”也正因为这样机器码和汇编代码是一一对应的。我们人类很容易记住add、mov这些用英文表示的指令而8b 45 f8这样的指令由于很难一下子看明白是在干什么所以会非常难以记忆。尽管早年互联网上到处流传大神程序员着拿小刀在光盘上刻出操作系统的梗但是要让你用打孔卡来写个程序估计浪费的卡片比用上的卡片要多得多。
<img src="https://static001.geekbang.org/resource/image/67/5b/67cf3c90ac9bde229352e1be0db24b5b.png" alt="">
从高级语言到汇编代码再到机器码就是一个日常开发程序最终变成了CPU可以执行的计算机指令的过程。
## 解析指令和机器码
了解了这个过程,下面我们放大局部,来看看这一行行的汇编代码和机器指令,到底是什么意思。
我们就从平时用的电脑、手机这些设备来说起。这些设备的CPU到底有哪些指令呢这个还真有不少我们日常用的Intel CPU有2000条左右的CPU指令实在是太多了所以我没法一一来给你讲解。不过一般来说常见的指令可以分成五大类。
第一类是**算术类指令**。我们的加减乘除在CPU层面都会变成一条条算术类指令。
第二类是**数据传输类指令**。给变量赋值、在内存里读写数据,用的都是数据传输类指令。
第三类是**逻辑类指令**。逻辑上的与或非,都是这一类指令。
第四类是**条件分支类指令**。日常我们写的“if/else”其实都是条件分支类指令。
最后一类是**无条件跳转指令**。写一些大一点的程序,我们常常需要写一些函数或者方法。在调用函数的时候,其实就是发起了一个无条件跳转指令。
你可能一下子记不住,或者对这些指令的含义还不能一下子掌握,这里我画了一个表格,给你举例子说明一下,帮你理解、记忆。
<img src="https://static001.geekbang.org/resource/image/eb/97/ebfd3bfe5dba764cdcf871e23b29f197.jpeg" alt="">
下面我们来看看,汇编器是怎么把对应的汇编代码,翻译成为机器码的。
我们说过不同的CPU有不同的指令集也就对应着不同的汇编语言和不同的机器码。为了方便你快速理解这个机器码的计算方式我们选用最简单的MIPS指令集来看看机器码是如何生成的。
MIPS是一组由MIPS技术公司在80年代中期设计出来的CPU指令集。就在最近MIPS公司把整个指令集和芯片架构都完全开源了。想要深入研究CPU和指令集的同学我这里推荐[一些资料](https://www.mips.com/mipsopen/),你可以自己了解下。
<img src="https://static001.geekbang.org/resource/image/b1/bf/b1ade5f8de67b172bf7b4ec9f63589bf.jpeg" alt="">
MIPS的指令是一个32位的整数高6位叫**操作码**Opcode也就是代表这条指令具体是一条什么样的指令剩下的26位有三种格式分别是R、I和J。
**R指令**是一般用来做算术和逻辑操作,里面有读取和写入数据的寄存器的地址。如果是逻辑位移操作,后面还有位移操作的位移量,而最后的功能码,则是在前面的操作码不够的时候,扩展操作码表示对应的具体指令的。
**I指令**,则通常是用在数据传输、条件分支,以及在运算的时候使用的并非变量还是常数的时候。这个时候,没有了位移量和操作码,也没有了第三个寄存器,而是把这三部分直接合并成了一个地址值或者一个常数。
**J指令**就是一个跳转指令高6位之外的26位都是一个跳转后的地址。
```
add $t0,$s2,$s1
```
我以一个简单的加法算术指令add $t0, $s1, $s2,为例,给你解释。为了方便,我们下面都用十进制来表示对应的代码。
对应的MIPS指令里opcode是0rs代表第一个寄存器s1的地址是17rt代表第二个寄存器s2的地址是18rd代表目标的临时寄存器t0的地址是8。因为不是位移操作所以位移量是0。把这些数字拼在一起就变成了一个MIPS的加法指令。
为了读起来方便我们一般把对应的二进制数用16进制表示出来。在这里也就是0X02324020。这个数字也就是这条指令对应的机器码。
<img src="https://static001.geekbang.org/resource/image/8f/1d/8fced6ff11d3405cdf941f6742b5081d.jpeg" alt="">
回到开头我们说的打孔带。如果我们用打孔代表1没有打孔代表0用4行8列代表一条指令来打一个穿孔纸带那么这条命令大概就长这样
<img src="https://static001.geekbang.org/resource/image/31/9c/31b430f9e4135f24a998b577cae8249c.png" alt="">
好了,恭喜你,读到这里,你应该学会了怎么作为人肉编译和汇编器,给纸带打孔编程了,不用再对那些用过打孔卡的前辈们顶礼膜拜了。
## 总结延伸
到这里,想必你也应该明白了,我们在这一讲的开头介绍的打孔卡,其实就是一种存储程序型计算机。
只是这整个程序的机器码不是通过计算机编译出来的而是由程序员用人脑“编译”成一张张卡片的。对应的程序也不是存储在设备里而是存储成一张打好孔的卡片。但是整个程序运行的逻辑和其他CPU的机器语言没有什么分别也是处理一串“0”和“1”组成的机器码而已。
这一讲里我们看到了一个C语言程序是怎么被编译成为汇编语言乃至通过汇编器再翻译成机器码的。
除了C这样的编译型的语言之外不管是Python这样的解释型语言还是Java这样使用虚拟机的语言其实最终都是由不同形式的程序把我们写好的代码转换成CPU能够理解的机器码来执行的。
只是解释型语言是通过解释器在程序运行的时候逐句翻译而Java这样使用虚拟机的语言则是由虚拟机对编译出来的中间代码进行解释或者即时编译成为机器码来最终执行。
然而单单理解一条指令是怎么变成机器码的肯定是不够的。接下来的几节我会深入讲解包含条件、循环、函数、递归这些语句的完整程序是怎么在CPU里面执行的。
## 推荐阅读
这一讲里我们用的是相对最简单的MIPS指令集作示例。想要对我们日常使用的Intel CPU的指令集有所了解可以参看《计算机组成与设计软/硬件接口》第5版的2.17小节。
## 课后思考
我们把一个数字在命令行里面打印出来背后对应的机器码是什么你可以试试通过GCC把这个的汇编代码和机器码打出来。
欢迎你在留言区写下你的思考和疑问,你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,170 @@
<audio id="audio" title="06 | 指令跳转原来if...else就是goto" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d3/42/d3ef84a47025fcbc8b9b9da742826142.mp3"></audio>
上一讲我们讲解了一行代码是怎么变成计算机指令的。你平时写的程序中肯定不只有int a = 1这样最最简单的代码或者指令。我们总是要用到if…else这样的条件判断语句、while和for这样的循环语句还有函数或者过程调用。
对应的CPU执行的也不只是一条指令一般一个程序包含很多条指令。因为有if…else、for这样的条件和循环存在这些指令也不会一路平铺直叙地执行下去。
今天我们就在上一节的基础上来看看,一个计算机程序是怎么被分解成一条条指令来执行的。
## CPU是如何执行指令的
拿我们用的Intel CPU来说里面差不多有几百亿个晶体管。实际上一条条计算机指令执行起来非常复杂。好在CPU在软件层面已经为我们做好了封装。对于我们这些做软件的程序员来说我们只要知道写好的代码变成了指令之后是一条一条**顺序**执行的就可以了。
我们先不管几百亿的晶体管的背后是怎么通过电路运转起来的逻辑上我们可以认为CPU其实就是由一堆寄存器组成的。而寄存器就是CPU内部由多个触发器Flip-Flop或者锁存器Latches组成的简单电路。
触发器和锁存器,其实就是两种不同原理的数字电路组成的逻辑门。这块内容并不是我们这节课的重点,所以你只要了解就好。如果想要深入学习的话,你可以学习数字电路的相关课程,这里我们不深入探讨。
好了现在我们接着前面说。N个触发器或者锁存器就可以组成一个N位Bit的寄存器能够保存N位的数据。比方说我们用的64位Intel服务器寄存器就是64位的。
<img src="https://static001.geekbang.org/resource/image/cd/6f/cdba5c17a04f0dd5ef05b70368b9a96f.jpg" alt="">
一个CPU里面会有很多种不同功能的寄存器。我这里给你介绍三种比较特殊的。
一个是**PC寄存器**Program Counter Register我们也叫**指令地址寄存器**Instruction Address Register。顾名思义它就是用来存放下一条需要执行的计算机指令的内存地址。
第二个是**指令寄存器**Instruction Register用来存放当前正在执行的指令。
第三个是**条件码寄存器**Status Register用里面的一个一个标记位Flag存放CPU进行算术或者逻辑计算的结果。
除了这些特殊的寄存器CPU里面还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。我们通常根据存放的数据内容来给它们取名字比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据又能存放地址我们就叫它通用寄存器。
<img src="https://static001.geekbang.org/resource/image/ad/8a/ad91b005e97959d571bbd2a0fa30b48a.jpeg" alt="">
实际上一个程序执行的时候CPU会根据PC寄存器里的地址从内存里面把需要执行的指令读取到指令寄存器里面执行然后根据指令长度自增开始顺序读取下一条指令。可以看到一个程序的一条条指令在内存里面是连续保存的也会一条条顺序加载。
而有些特殊指令比如上一讲我们讲到J类指令也就是跳转指令会修改PC寄存器里面的地址值。这样下一条要执行的指令就不是从内存里面顺序加载的了。事实上这些跳转指令的存在也是我们可以在写程序的时候使用if…else条件语句和while/for循环语句的原因。
## 从if…else来看程序的执行和跳转
我们现在就来看一个包含if…else的简单程序。
```
// test.c
#include &lt;time.h&gt;
#include &lt;stdlib.h&gt;
int main()
{
srand(time(NULL));
int r = rand() % 2;
int a = 10;
if (r == 0)
{
a = 1;
} else {
a = 2;
}
```
我们用rand生成了一个随机数rr要么是0要么是1。当r是0的时候我们把之前定义的变量a设成1不然就设成2。
```
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o
```
我们把这个程序编译成汇编代码。你可以忽略前后无关的代码只关注于这里的if…else条件判断语句。对应的汇编代码是这样的
```
if (r == 0)
3b: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0
3f: 75 09 jne 4a &lt;main+0x4a&gt;
{
a = 1;
41: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1
48: eb 07 jmp 51 &lt;main+0x51&gt;
}
else
{
a = 2;
4a: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
51: b8 00 00 00 00 mov eax,0x0
}
```
可以看到这里对于r == 0的条件判断被编译成了cmp和jne这两条指令。
cmp指令比较了前后两个操作数的值这里的DWORD PTR代表操作的数据类型是32位的整数而[rbp-0x4]则是一个寄存器的地址。所以第一个操作数就是从寄存器里拿到的变量r的值。第二个操作数0x0就是我们设定的常量0的16进制表示。cmp指令的比较结果会存入到**条件码寄存器**当中去。
在这里,如果比较的结果是 True也就是 r == 0就把**零标志条件码**对应的条件码是ZFZero Flag设置为1。除了零标志之外Intel的CPU下还有**进位标志**CFCarry Flag、**符号标志**SFSign Flag以及**溢出标志**OFOverflow Flag用在不同的判断条件下。
cmp指令执行完成之后PC寄存器会自动自增开始执行下一条jne的指令。
跟着的jne指令是jump if not equal的意思它会查看对应的零标志位。如果为0会跳转到后面跟着的操作数4a的位置。这个4a对应这里汇编代码的行号也就是上面设置的else条件里的第一条指令。当跳转发生的时候PC寄存器就不再是自增变成下一条指令的地址而是被直接设置成这里的4a这个地址。这个时候CPU再把4a地址里的指令加载到指令寄存器中来执行。
跳转到执行地址为4a的指令实际是一条mov指令第一个操作数和前面的cmp指令一样是另一个32位整型的寄存器地址以及对应的2的16进制值0x2。mov指令把2设置到对应的寄存器里去相当于一个赋值操作。然后PC寄存器里的值继续自增执行下一条mov指令。
这条mov指令的第一个操作数eax代表累加寄存器第二个操作数0x0则是16进制的0的表示。这条指令其实没有实际的作用它的作用是一个占位符。我们回过头去看前面的if条件如果满足的话在赋值的mov指令执行完成之后有一个jmp的无条件跳转指令。跳转的地址就是这一行的地址51。我们的main函数没有设定返回值而mov eax, 0x0 其实就是给main函数生成了一个默认的为0的返回值到累加器里面。if条件里面的内容执行完成之后也会跳转到这里和else里的内容结束之后的位置是一样的。
<img src="https://static001.geekbang.org/resource/image/b4/fa/b439cebb2d85496ad6eef2f61071aefa.jpeg" alt="">
上一讲我们讲打孔卡的时候说到读取打孔卡的机器会顺序地一段一段地读取指令然后执行。执行完一条指令它会自动地顺序读取下一条指令。如果执行的当前指令带有跳转的地址比如往后跳10个指令那么机器会自动将卡片带往后移动10个指令的位置再来执行指令。同样的机器也能向前移动去读取之前已经执行过的指令。这也就是我们的while/for循环实现的原理。
## 如何通过if…else和goto来实现循环
```
int main()
{
int a = 0;
for (int i = 0; i &lt; 3; i++)
{
a += i;
}
}
```
我们再看一段简单的利用for循环的程序。我们循环自增变量i三次三次之后i&gt;=3就会跳出循环。整个程序对应的Intel汇编代码就是这样的
```
for (int i = 0; i &lt;= 2; i++)
b: c7 45 f8 00 00 00 00 mov DWORD PTR [rbp-0x4],0x0
12: eb 0a jmp 1e
{
a += i;
14: 8b 45 f8 mov eax,DWORD PTR [rbp-0x4]
17: 01 45 fc add DWORD PTR [rbp-0x8],eax
1a: 83 45 f8 01 add DWORD PTR [rbp-0x4],0x1
1e: 83 7d f8 02 cmp DWORD PTR [rbp-0x4],0x2
22: 7e f0 jle 14
24: b8 00 00 00 00 mov eax,0x0
}
```
可以看到对应的循环也是用1e这个地址上的cmp比较指令和紧接着的jle条件跳转指令来实现的。主要的差别在于这里的jle跳转的地址在这条指令之前的地址14而非if…else编译出来的跳转指令之后。往前跳转使得条件满足的时候PC寄存器会把指令地址设置到之前执行过的指令位置重新执行之前执行过的指令直到条件不满足顺序往下执行jle之后的指令整个循环才结束。
<img src="https://static001.geekbang.org/resource/image/fb/17/fb50fe39181abb0f70fcfec53cf12317.jpg" alt="">
如果你看一长条打孔卡的话,就会看到卡片往后移动一段,执行了之后,又反向移动,去重新执行前面的指令。
其实你有没有觉得jle和jmp指令有点像程序语言里面的goto命令直接指定了一个特定条件下的跳转位置。虽然我们在用高级语言开发程序的时候反对使用goto但是实际在机器指令层面无论是if…else…也好还是for/while也好都是用和goto相同的跳转到特定指令位置的方式来实现的。
## 总结延伸
这一节我们在单条指令的基础上学习了程序里的多条指令究竟是怎么样一条一条被执行的。除了简单地通过PC寄存器自增的方式顺序执行外条件码寄存器会记录下当前执行指令的条件判断状态然后通过跳转指令读取对应的条件码修改PC寄存器内的下一条指令的地址最终实现if…else以及for/while这样的程序控制流程。
你会发现,虽然我们可以用高级语言,可以用不同的语法,比如 if…else 这样的条件分支,或者 while/for 这样的循环方式来实现不用的程序运行流程但是回归到计算机可以识别的机器指令级别其实都只是一个简单的地址跳转而已也就是一个类似于goto的语句。
想要在硬件层面实现这个goto语句除了本身需要用来保存下一条指令地址以及当前正要执行指令的PC寄存器、指令寄存器外我们只需要再增加一个条件码寄存器来保留条件判断的状态。这样简简单单的三个寄存器就可以实现条件判断和循环重复执行代码的功能。
下一节我们会进一步讲解如果程序中出现函数或者过程这样可以复用的代码模块对应的指令是怎么样执行的会和我们这里的if…else有什么不同。
## 推荐阅读
《深入理解计算机系统》的第3章详细讲解了C语言和Intel CPU的汇编语言以及指令的对应关系以及Intel CPU的各种寄存器和指令集。
Intel指令集相对于之前的MIPS指令集要复杂一些一方面所有的指令是变长的从1个字节到15个字节不等另一方面即使是汇编代码还有很多针对操作数据的长度不同有不同的后缀。我在这里没有详细解释各个指令的含义如果你对用C/C++做Linux系统层面开发感兴趣建议你一定好好读一读这一章节。
## 课后思考
除了if…else的条件语句和for/while的循环之外大部分编程语言还有switch…case这样的条件跳转语句。switch…case编译出来的汇编代码也是这样使用jne指令进行跳转吗对应的汇编代码的性能和写很多if…else有什么区别呢你可以试着写一个简单的C语言程序编译成汇编代码看一看。
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,215 @@
<audio id="audio" title="07 | 函数调用为什么会发生stack overflow" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8a/7d/8abffbbdfdc9930fb3578bdfb24d1f7d.mp3"></audio>
在开发软件的过程中我们经常会遇到错误如果你用Google搜过出错信息那你多少应该都访问过[Stack Overflow](https://stackoverflow.com/)这个网站。作为全球最大的程序员问答网站Stack Overflow的名字来自于一个常见的报错就是栈溢出stack overflow
今天,我们就从程序的函数调用开始,讲讲函数间的相互调用,在计算机指令层面是怎么实现的,以及什么情况下会发生栈溢出这个错误。
## 为什么我们需要程序栈?
和前面几讲一样我们还是从一个非常简单的C程序function_example.c看起。
```
// function_example.c
#include &lt;stdio.h&gt;
int static add(int a, int b)
{
return a+b;
}
int main()
{
int x = 5;
int y = 10;
int u = add(x, y);
}
```
这个程序定义了一个简单的函数add接受两个参数a和b返回值就是a+b。而main函数里则定义了两个变量x和y然后通过调用这个add函数来计算u=x+y最后把u的数值打印出来。
```
$ gcc -g -c function_example.c
$ objdump -d -M intel -S function_example.o
```
我们把这个程序编译之后objdump出来。我们来看一看对应的汇编代码。
```
int static add(int a, int b)
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
return a+b;
a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
10: 01 d0 add eax,edx
}
12: 5d pop rbp
13: c3 ret
0000000000000014 &lt;main&gt;:
int main()
{
14: 55 push rbp
15: 48 89 e5 mov rbp,rsp
18: 48 83 ec 10 sub rsp,0x10
int x = 5;
1c: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
int y = 10;
23: c7 45 f8 0a 00 00 00 mov DWORD PTR [rbp-0x8],0xa
int u = add(x, y);
2a: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
2d: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
30: 89 d6 mov esi,edx
32: 89 c7 mov edi,eax
34: e8 c7 ff ff ff call 0 &lt;add&gt;
39: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
3c: b8 00 00 00 00 mov eax,0x0
}
41: c9 leave
42: c3 ret
```
可以看出来在这段代码里main函数和上一节我们讲的的程序执行区别并不大它主要是把jump指令换成了函数调用的call指令。call指令后面跟着的仍然是跳转后的程序地址。
这些你理解起来应该不成问题。我们下面来看一个有意思的部分。
我们来看add函数。可以看到add函数编译之后代码先执行了一条push指令和一条mov指令在函数执行结束的时候又执行了一条pop和一条ret指令。这四条指令的执行其实就是在进行我们接下来要讲**压栈**Push和**出栈**Pop操作。
你有没有发现函数调用和上一节我们讲的if…else和for/while循环有点像。它们两个都是在原来顺序执行的指令过程里执行了一个内存地址的跳转指令让指令从原来顺序执行的过程里跳开从新的跳转后的位置开始执行。
但是这两个跳转有个区别if…else和for/while的跳转是跳转走了就不再回来了就在跳转后的新地址开始顺序地执行指令就好像徐志摩在《再别康桥》里面写的“我挥一挥衣袖不带走一片云彩”继续进行新的生活了。而函数调用的跳转在对应函数的指令执行完了之后还要再回到函数调用的地方继续执行call之后的指令就好像贺知章在《回乡偶书》里面写的那样“少小离家老大回乡音未改鬓毛衰”不管走多远最终还是要回来。
那我们有没有一个可以不跳转回到原来开始的地方来实现函数的调用呢直觉上似乎有这么一个解决办法。你可以把调用的函数指令直接插入在调用函数的地方替换掉对应的call指令然后在编译器编译代码的时候直接就把函数调用变成对应的指令替换掉。
不过仔细琢磨一下你会发现这个方法有些问题。如果函数A调用了函数B然后函数B再调用函数A我们就得面临在A里面插入B的指令然后在B里面插入A的指令这样就会产生无穷无尽地替换。就好像两面镜子面对面放在一块儿任何一面镜子里面都会看到无穷多面镜子。
<img src="https://static001.geekbang.org/resource/image/0b/06/0b4d9f07a7d15e5e25908bbf1532e706.jpg" alt="">
看来把被调用函数的指令直接插入在调用处的方法行不通。那我们就换一个思路能不能把后面要跳回来执行的指令地址给记录下来呢就像前面讲PC寄存器一样我们可以专门设立一个“程序调用寄存器”来存储接下来要跳转回来执行的指令地址。等到函数调用结束从这个寄存器里取出地址再跳转到这个记录的地址继续执行就好了。
但是在多层函数调用里简单只记录一个地址也是不够的。我们在调用函数A之后A还可以调用函数BB还能调用函数C。这一层又一层的调用并没有数量上的限制。在所有函数调用返回之前每一次调用的返回地址都要记录下来但是我们CPU里的寄存器数量并不多。像我们一般使用的Intel i7 CPU只有16个64位寄存器调用的层数一多就存不下了。
最终,计算机科学家们想到了一个比单独记录跳转回来的地址更完善的办法。我们在内存里面开辟一段空间,用栈这个**后进先出**LIFOLast In First Out的数据结构。栈就像一个乒乓球桶每次程序调用函数之前我们都把调用返回后的地址写在一个乒乓球上然后塞进这个球桶。这个操作其实就是我们常说的**压栈**。如果函数执行完了,我们就从球桶里取出最上面的那个乒乓球,很显然,这就是**出栈**。
拿到出栈的乒乓球找到上面的地址把程序跳转过去就返回到了函数调用后的下一条指令了。如果函数A在执行完成之前又调用了函数B那么在取出乒乓球之前我们需要往球桶里塞一个乒乓球。而我们从球桶最上面拿乒乓球的时候拿的也一定是最近一次的也就是最下面一层的函数调用完成后的地址。乒乓球桶的底部就是**栈底**,最上面的乒乓球所在的位置,就是**栈顶**。
<img src="https://static001.geekbang.org/resource/image/d0/be/d0c75219d3a528c920c2a593daaf77be.jpeg" alt="">
在真实的程序里压栈的不只有函数调用完成后的返回地址。比如函数A在调用B的时候需要传输一些参数数据这些参数数据在寄存器不够用的时候也会被压入栈中。整个函数A所占用的所有内存空间就是函数A的**栈帧**Stack Frame。Frame在中文里也有“相框”的意思所以每次到这里我都有种感觉整个函数A所需要的内存空间就像是被这么一个“相框”给框了起来放在了栈里面。
而实际的程序栈布局,顶和底与我们的乒乓球桶相比是倒过来的。底在最上面,顶在最下面,这样的布局是因为栈底的内存地址是在一开始就固定的。而一层层压栈之后,栈顶的内存地址是在逐渐变小而不是变大。
<img src="https://static001.geekbang.org/resource/image/23/d1/2361ecf8cf08f07c83377376a31869d1.jpeg" alt="">
对应上面函数add的汇编代码我们来仔细看看main函数调用add函数时add函数入口在01行add函数结束之后在1213行。
我们在调用第34行的call指令时会把当前的PC寄存器里的下一条指令的地址压栈保留函数调用结束后要执行的指令地址。而add函数的第0行push rbp这个指令就是在进行压栈。这里的rbp又叫栈帧指针Frame Pointer是一个存放了当前栈帧位置的寄存器。push rbp就把之前调用函数也就是main函数的栈帧的栈底地址压到栈顶。
接着第1行的一条命令mov rbp, rsp里则是把rsp这个栈指针Stack Pointer的值复制到rbp里而rsp始终会指向栈顶。这个命令意味着rbp这个栈帧指针指向的地址变成当前最新的栈顶也就是add函数的栈帧的栈底地址了。
而在函数add执行完成之后又会分别调用第12行的pop rbp来将当前的栈顶出栈这部分操作维护好了我们整个栈帧。然后我们可以调用第13行的ret指令这时候同时要把call调用的时候压入的PC寄存器里的下一条指令出栈更新到PC寄存器中将程序的控制权返回到出栈后的栈顶。
## 如何构造一个stack overflow
通过引入栈我们可以看到无论有多少层的函数调用或者在函数A里调用函数B再在函数B里调用A这样的递归调用我们都只需要通过维持rbp和rsp这两个维护栈顶所在地址的寄存器就能管理好不同函数之间的跳转。不过栈的大小也是有限的。如果函数调用层数太多我们往栈里压入它存不下的内容程序在执行的过程中就会遇到栈溢出的错误这就是大名鼎鼎的“stack overflow”。
要构造一个栈溢出的错误并不困难最简单的办法就是我们上面说的Infiinite Mirror Effect的方式让函数A调用自己并且不设任何终止条件。这样一个无限递归的程序在不断地压栈过程中将整个栈空间填满并最终遇上stack overflow。
```
int a()
{
return a();
}
int main()
{
a();
return 0;
}
```
除了无限递归递归层数过深在栈空间里面创建非常占内存的变量比如一个巨大的数组这些情况都很可能给你带来stack overflow。相信你理解了栈在程序运行的过程里面是怎么回事未来在遇到stackoverflow这个错误的时候不会完全没有方向了。
## 如何利用函数内联进行性能优化?
上面我们提到一个方法,把一个实际调用的函数产生的指令,直接插入到的位置,来替换对应的函数调用指令。尽管这个通用的函数调用方案,被我们否决了,但是如果被调用的函数里,没有调用其他函数,这个方法还是可以行得通的。
事实上,这就是一个常见的编译器进行自动优化的场景,我们通常叫**函数内联**Inline。我们只要在GCC编译的时候加上对应的一个让编译器自动优化的参数-O编译器就会在可行的情况下进行这样的指令替换。
我们来看一段代码。
```
#include &lt;stdio.h&gt;
#include &lt;time.h&gt;
#include &lt;stdlib.h&gt;
int static add(int a, int b)
{
return a+b;
}
int main()
{
srand(time(NULL));
int x = rand() % 5
int y = rand() % 10;
int u = add(x, y)
printf(&quot;u = %d\n&quot;, u)
}
```
为了避免编译器优化掉太多代码我小小修改了一下function_example.c让参数x和y都变成了通过随机数生成并在代码的最后加上将u通过printf打印出来的语句。
```
$ gcc -g -c -O function_example_inline.c
$ objdump -d -M intel -S function_example_inline.o
```
上面的function_example_inline.c的编译出来的汇编代码没有把add函数单独编译成一段指令顺序而是在调用u = add(x, y)的时候直接替换成了一个add指令。
```
return a+b;
4c: 01 de add esi,ebx
```
除了依靠编译器的自动优化你还可以在定义函数的地方加上inline的关键字来提示编译器对函数进行内联。
内联带来的优化是CPU需要执行的指令数变少了根据地址跳转的过程不需要了压栈和出栈的过程也不用了。
不过内联并不是没有代价,内联意味着,我们把可以复用的程序指令在调用它的地方完全展开了。如果一个函数在很多地方都被调用了,那么就会展开很多次,整个程序占用的空间就会变大了。
<img src="https://static001.geekbang.org/resource/image/dc/85/dca83475560147d4dd492ff283ae0c85.jpeg" alt="">
这样没有调用其他函数,只会被调用的函数,我们一般称之为**叶子函数(或叶子过程)**。
## 总结延伸
这一节我们讲了一个程序的函数间调用在CPU指令层面是怎么执行的。其中一定需要你牢记的就是**程序栈**这个新概念。
我们可以方便地通过压栈和出栈操作使得程序在不同的函数调用过程中进行转移。而函数内联和栈溢出一个是我们常常可以选择的优化方案另一个则是我们会常遇到的程序Bug。
通过加入了程序栈,我们相当于在指令跳转的过程种,加入了一个“记忆”的功能,能在跳转去运行新的指令之后,再回到跳出去的位置,能够实现更加丰富和灵活的指令执行流程。这个也为我们在程序开发的过程中,提供了“函数”这样一个抽象,使得我们在软件开发的过程中,可以复用代码和指令,而不是只能简单粗暴地复制、粘贴代码和指令。
## 推荐阅读
如果你觉得还不过瘾可以仔细读一下《深入理解计算机系统第三版》的3.7小节《过程》,进一步了解函数调用是怎么回事。
另外我推荐你花一点时间通过搜索引擎搞清楚function_example.c每一行汇编代码的含义这个能够帮你进一步深入了解程序栈、栈帧、寄存器以及Intel CPU的指令集。
## 课后思考
在程序栈里面,除了我们跳转前的指令地址外,还需要保留哪些信息,才能在我们在函数调用完成之后,跳转回到指令地址的时候,继续执行完函数调用之后的指令呢?
你可以想一想,查一查,然后在留言区留下你的思考和答案,也欢迎你把今天的内容分享给你的朋友,和他一起思考和进步。

View File

@@ -0,0 +1,218 @@
<audio id="audio" title="08 | ELF和静态链接为什么程序无法同时在Linux和Windows下运行" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/3c/943d00c868be98c76610f94799f8673c.mp3"></audio>
过去的三节你和我一起通过一些简单的代码看到了我们写的程序是怎么变成一条条计算机指令的if…else这样的条件跳转是怎么样执行的for/while这样的循环是怎么执行的函数间的相互调用是怎么发生的。
我记得以前我自己在了解完这些知识之后产生了一个非常大的疑问。那就是既然我们的程序最终都被变成了一条条机器码去执行那为什么同一个程序在同一台计算机上在Linux下可以运行而在Windows下却不行呢反过来Windows上的程序在Linux上也是一样不能执行的。可是我们的CPU并没有换掉它应该可以识别同样的指令呀
如果你和我有同样的疑问,那这一节,我们就一起来解开。
## 编译、链接和装载:拆解程序执行
[第5节](https://time.geekbang.org/column/article/93359)我们说过写好的C语言代码可以通过编译器编译成汇编代码然后汇编代码再通过汇编器变成CPU可以理解的机器码于是CPU就可以执行这些机器码了。你现在对这个过程应该不陌生了但是这个描述把过程大大简化了。下面我们一起具体来看C语言程序是如何变成一个可执行程序的。
不知道你注意到没有过去几节我们通过gcc生成的文件和objdump获取到的汇编指令都有些小小的问题。我们先把前面的add函数示例拆分成两个文件add_lib.c和link_example.c。
```
// add_lib.c
int add(int a, int b)
{
return a+b;
}
```
```
// link_example.c
#include &lt;stdio.h&gt;
int main()
{
int a = 10;
int b = 5;
int c = add(a, b);
printf(&quot;c = %d\n&quot;, c);
}
```
我们通过gcc来编译这两个文件然后通过objdump命令看看它们的汇编代码。
```
$ gcc -g -c add_lib.c link_example.c
$ objdump -d -M intel -S add_lib.o
$ objdump -d -M intel -S link_example.o
```
```
add_lib.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 &lt;add&gt;:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
10: 01 d0 add eax,edx
12: 5d pop rbp
13: c3 ret
```
```
link_example.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 &lt;main&gt;:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 48 83 ec 10 sub rsp,0x10
8: c7 45 fc 0a 00 00 00 mov DWORD PTR [rbp-0x4],0xa
f: c7 45 f8 05 00 00 00 mov DWORD PTR [rbp-0x8],0x5
16: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
19: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1c: 89 d6 mov esi,edx
1e: 89 c7 mov edi,eax
20: b8 00 00 00 00 mov eax,0x0
25: e8 00 00 00 00 call 2a &lt;main+0x2a&gt;
2a: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
2d: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc]
30: 89 c6 mov esi,eax
32: 48 8d 3d 00 00 00 00 lea rdi,[rip+0x0] # 39 &lt;main+0x39&gt;
39: b8 00 00 00 00 mov eax,0x0
3e: e8 00 00 00 00 call 43 &lt;main+0x43&gt;
43: b8 00 00 00 00 mov eax,0x0
48: c9 leave
49: c3 ret
```
既然代码已经被我们“编译”成了指令,我们不妨尝试运行一下 ./link_example.o。
不幸的是文件没有执行权限我们遇到一个Permission denied错误。即使通过chmod命令赋予link_example.o文件可执行的权限运行./link_example.o仍然只会得到一条cannot execute binary file: Exec format error的错误。
我们再仔细看一下objdump出来的两个文件的代码会发现两个程序的地址都是从0开始的。如果地址是一样的程序如果需要通过call指令调用函数的话它怎么知道应该跳转到哪一个文件里呢
这么说吧无论是这里的运行报错还是objdump出来的汇编代码里面的重复地址都是因为 add_lib.o 以及 link_example.o并不是一个**可执行文件**Executable Program而是**目标文件**Object File。只有通过链接器Linker把多个目标文件以及调用的各种函数库链接起来我们才能得到一个可执行文件。
我们通过gcc的-o参数可以生成对应的可执行文件对应执行之后就可以得到这个简单的加法调用函数的结果。
```
$ gcc -o link-example add_lib.o link_example.o
$ ./link_example
c = 15
```
实际上,“**C语言代码-汇编代码-机器码**” 这个过程,在我们的计算机上进行的时候是由两部分组成的。
第一个部分由编译Compile、汇编Assemble以及链接Link三个阶段组成。在这三个阶段完成之后我们就生成了一个可执行文件。
第二部分我们通过装载器Loader把可执行文件装载Load到内存中。CPU从内存中读取指令和数据来开始真正执行程序。
<img src="https://static001.geekbang.org/resource/image/99/a7/997341ed0fa9018561c7120c19cfa2a7.jpg" alt="">
## ELF格式和链接理解链接过程
程序最终是通过装载器变成指令和数据的所以其实我们生成的可执行代码也并不仅仅是一条条的指令。我们还是通过objdump指令把可执行文件的内容拿出来看看。
```
link_example: file format elf64-x86-64
Disassembly of section .init:
...
Disassembly of section .plt:
...
Disassembly of section .plt.got:
...
Disassembly of section .text:
...
6b0: 55 push rbp
6b1: 48 89 e5 mov rbp,rsp
6b4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
6b7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
6ba: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
6bd: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
6c0: 01 d0 add eax,edx
6c2: 5d pop rbp
6c3: c3 ret
00000000000006c4 &lt;main&gt;:
6c4: 55 push rbp
6c5: 48 89 e5 mov rbp,rsp
6c8: 48 83 ec 10 sub rsp,0x10
6cc: c7 45 fc 0a 00 00 00 mov DWORD PTR [rbp-0x4],0xa
6d3: c7 45 f8 05 00 00 00 mov DWORD PTR [rbp-0x8],0x5
6da: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
6dd: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
6e0: 89 d6 mov esi,edx
6e2: 89 c7 mov edi,eax
6e4: b8 00 00 00 00 mov eax,0x0
6e9: e8 c2 ff ff ff call 6b0 &lt;add&gt;
6ee: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
6f1: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc]
6f4: 89 c6 mov esi,eax
6f6: 48 8d 3d 97 00 00 00 lea rdi,[rip+0x97] # 794 &lt;_IO_stdin_used+0x4&gt;
6fd: b8 00 00 00 00 mov eax,0x0
702: e8 59 fe ff ff call 560 &lt;printf@plt&gt;
707: b8 00 00 00 00 mov eax,0x0
70c: c9 leave
70d: c3 ret
70e: 66 90 xchg ax,ax
...
Disassembly of section .fini:
...
```
你会发现可执行代码dump出来内容和之前的目标代码长得差不多但是长了很多。因为在Linux下可执行文件和目标文件所使用的都是一种叫**ELF**Execuatable and Linkable File Format的文件格式中文名字叫**可执行与可链接文件格式**,这里面不仅存放了编译成的汇编指令,还保留了很多别的数据。
比如我们过去所有objdump出来的代码里你都可以看到对应的函数名称像add、main等等乃至你自己定义的全局可以访问的变量名称都存放在这个ELF格式文件里。这些名字和它们对应的地址在ELF文件里面存储在一个叫作**符号表**Symbols Table的位置里。符号表相当于一个地址簿把名字和地址关联了起来。
我们先只关注和我们的add以及main函数相关的部分。你会发现这里面main函数里调用add的跳转地址不再是下一条指令的地址了而是add函数的入口地址了这就是EFL格式和链接器的功劳。
<img src="https://static001.geekbang.org/resource/image/27/b3/276a740d0eabf5f4be905fe7326d9fb3.jpg" alt="">
ELF文件格式把各种信息分成一个一个的Section保存起来。ELF有一个基本的文件头File Header用来表示这个文件的基本属性比如是否是可执行文件对应的CPU、操作系统等等。除了这些基本属性之外大部分程序还有这么一些Section
<li>
首先是.text Section也叫作**代码段**或者指令段Code Section用来保存程序的代码和指令
</li>
<li>
接着是.data Section也叫作**数据段**Data Section用来保存程序里面设置好的初始化数据信息
</li>
<li>
然后就是.rel.text Secion叫作**重定位表**Relocation Table。重定位表里保留的是当前的文件里面哪些跳转地址其实是我们不知道的。比如上面的 link_example.o 里面我们在main函数里面调用了 add 和 printf 这两个函数,但是在链接发生之前,我们并不知道该跳转到哪里,这些信息就会存储在重定位表里;
</li>
<li>
最后是.symtab Section叫作**符号表**Symbol Table。符号表保留了我们所说的当前文件里面定义的函数名称和对应地址的地址簿。
</li>
链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地址,进行一次修正。最后,把所有的目标文件的对应段进行一次合并,变成了最终的可执行代码。这也是为什么,可执行文件里面的函数调用的地址都是正确的。
<img src="https://static001.geekbang.org/resource/image/f6/12/f62da9b29aa53218f8907851df27f912.jpeg" alt="">
在链接器把程序变成可执行文件之后,要装载器去执行程序就容易多了。装载器不再需要考虑地址跳转的问题,只需要解析 ELF 文件把对应的指令和数据加载到内存里面供CPU执行就可以了。
## 总结延伸
讲到这里相信你已经猜到为什么同样一个程序在Linux下可以执行而在Windows下不能执行了。其中一个非常重要的原因就是两个操作系统下可执行文件的格式不一样。
我们今天讲的是Linux下的ELF文件格式而Windows的可执行文件格式是一种叫作**PE**Portable Executable Format的文件格式。Linux下的装载器只能解析ELF格式而不能解析PE格式。
如果我们有一个可以能够解析PE格式的装载器我们就有可能在Linux下运行Windows程序了。这样的程序真的存在吗没错Linux下著名的开源项目Wine就是通过兼容PE格式的装载器使得我们能直接在Linux下运行Windows程序的。而现在微软的Windows里面也提供了WSL也就是Windows Subsystem for Linux可以解析和加载ELF格式的文件。
我们去写可以用的程序,也不仅仅是把所有代码放在一个文件里来编译执行,而是可以拆分成不同的函数库,最后通过一个静态链接的机制,使得不同的文件之间既有分工,又能通过静态链接来“合作”,变成一个可执行的程序。
对于ELF格式的文件为了能够实现这样一个静态链接的机制里面不只是简单罗列了程序所需要执行的指令还会包括链接所需要的重定位表和符号表。
## 推荐阅读
想要更深入了解程序的链接过程和ELF格式我推荐你阅读《程序员的自我修养——链接、装载和库》的14章。这是一本难得的讲解程序的链接、装载和运行的好书。
## 课后思考
你可以通过readelf读取出今天演示程序的符号表看看符号表里都有哪些信息然后通过objdump读取出今天演示程序的重定位表看看里面又有哪些信息。
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,84 @@
<audio id="audio" title="09 | 程序装载“640K内存”真的不够用么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/c3/068aa199c89bf246db4978f9873abec3.mp3"></audio>
计算机这个行业的历史上有过很多成功的预言最著名的自然是“摩尔定律”。当然免不了的也有很多“失败”的预测其中一个最著名的就是比尔·盖茨在上世纪80年代说的“640K ought to be enough for anyone”也就是“640K内存对哪个人来说都够用了”。
那个年代微软开发的还是DOS操作系统程序员们还在绞尽脑汁想要用好这极为有限的640K内存。而现在我手头的开发机已经是16G内存了上升了一万倍还不止。那比尔·盖茨这句话在当时也是完全的无稽之谈么有没有哪怕一点点的道理呢这一讲里我就和你一起来看一看。
## 程序装载面临的挑战
上一讲我们看到了如何通过链接器把多个文件合并成一个最终可执行文件。在运行这些可执行文件的时候我们其实是通过一个装载器解析ELF或者PE格式的可执行文件。装载器会把对应的指令和数据加载到内存里面来让CPU去执行。
说起来只是装载到内存里面这一句话的事儿,实际上装载器需要满足两个要求。
**第一,可执行程序加载后占用的内存空间应该是连续的**。我们在[第6讲](https://time.geekbang.org/column/article/94075)讲过,执行指令的时候,程序计数器是顺序地一条一条指令执行下去。这也就意味着,这一条条指令需要连续地存储在一起。
**第二,我们需要同时加载很多个程序,并且不能让程序自己规定在内存中加载的位置。**虽然编译出来的指令里已经有了对应的各种各样的内存地址,但是实际加载的时候,我们其实没有办法确保,这个程序一定加载在哪一段内存地址上。因为我们现在的计算机通常会同时运行很多个程序,可能你想要的内存地址已经被其他加载了的程序占用了。
要满足这两个基本的要求,我们很容易想到一个办法。那就是我们可以在内存里面,找到一段连续的内存空间,然后分配给装载的程序,然后把这段连续的内存空间地址,和整个程序指令里指定的内存地址做一个映射。
我们把指令里用到的内存地址叫作**虚拟内存地址**Virtual Memory Address实际在内存硬件里面的空间地址我们叫**物理内存地址**Physical Memory Address**。**
程序里有指令和各种内存地址,我们只需要关心虚拟内存地址就行了。对于任何一个程序来说,它看到的都是同样的内存地址。我们维护一个虚拟内存到物理内存的映射表,这样实际程序指令执行的时候,会通过虚拟内存地址,找到对应的物理内存地址,然后执行。因为是连续的内存地址空间,所以我们只需要维护映射关系的起始地址和对应的空间大小就可以了。
## 内存分段
这种找出一段连续的物理内存和虚拟内存地址进行映射的方法,我们叫**分段**Segmentation**。**这里的段,就是指系统分配出来的那个连续的内存空间。
<img src="https://static001.geekbang.org/resource/image/24/18/24596e1e66d88c5d077b4c957d0d7f18.png" alt="">
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处,第一个就是**内存碎片**Memory Fragmentation的问题。
我们来看这样一个例子。我现在手头的这台电脑有1GB的内存。我们先启动一个图形渲染程序占用了512MB的内存接着启动一个Chrome浏览器占用了128MB内存再启动一个Python程序占用了256MB内存。这个时候我们关掉Chrome于是空闲内存还有1024 - 512 - 256 = 256MB。按理来说我们有足够的空间再去装载一个200MB的程序。但是这256MB的内存空间不是连续的而是被分成了两段128MB的内存。因此实际情况是我们的程序没办法加载进来。
<img src="https://static001.geekbang.org/resource/image/57/d1/57211af3053ed621aeb903433c6c10d1.png" alt="">
当然,这个我们也有办法解决。解决的办法叫**内存交换**Memory Swapping
我们可以把Python程序占用的那256MB内存写到硬盘上然后再从硬盘上读回来到内存里面。不过读回来的时候我们不再把它加载到原来的位置而是紧紧跟在那已经被占用了的512MB内存后面。这样我们就有了连续的256MB内存空间就可以去加载一个新的200MB的程序。如果你自己安装过Linux操作系统你应该遇到过分配一个swap硬盘分区的问题。这块分出来的磁盘空间其实就是专门给Linux操作系统进行内存交换用的。
虚拟内存、分段,再加上内存交换,看起来似乎已经解决了计算机同时装载运行很多个程序的问题。不过,你千万不要大意,这三者的组合仍然会遇到一个性能瓶颈。硬盘的访问速度要比内存慢很多,而每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个很占内存空间的程序,这样整个机器都会显得卡顿。
## 内存分页
既然问题出在内存碎片和内存交换的空间太大上,那么解决问题的办法就是,少出现一些内存碎片。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决这个问题。这个办法,在现在计算机的内存管理里面,就叫作**内存分页**Paging
**和分段这样分配一整段连续的空间给到程序相比,分页是把整个物理内存空间切成一段段固定尺寸的大小**。而对应的程序所需要占用的虚拟内存空间,也会同样切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫**页**Page。从虚拟内存到物理内存的映射不再是拿整段连续的内存的物理地址而是按照一个一个页来的。页的尺寸一般远远小于整个程序的大小。在Linux下我们通常只设置成4KB。你可以通过命令看看你手头的Linux系统设置的页的大小。
```
$ getconf PAGE_SIZE
```
由于内存空间都是预先划分好的也就没有了不能使用的碎片而只有被释放出来的很多4KB的页。即使内存空间不够需要让现有的、正在运行的其他程序通过内存交换释放出一些内存的页出来一次性写入磁盘的也只有少数的一个页或者几个页不会花太多时间让整个机器被内存交换的过程给卡住。
<img src="https://static001.geekbang.org/resource/image/0c/f0/0cf2f08e1ceda473df71189334857cf0.png" alt="">
更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
实际上我们的操作系统的确是这么做的。当要读取特定的页却发现数据并没有加载到物理内存里的时候就会触发一个来自于CPU的**缺页错误**Page Fault。我们的操作系统会捕捉到这个错误然后将对应的页从存放在硬盘上的虚拟内存里读取出来加载到物理内存里。这种方式使得我们可以运行那些远大于我们实际物理内存的程序。同时这样一来任何程序都不需要一次性加载完所有指令和数据只需要加载当前需要用到就行了。
通过虚拟内存、内存交换和内存分页这三个技术的组合,我们最终得到了一个让程序不需要考虑实际的物理内存地址、大小和当前分配空间的解决方案。这些技术和方法,对于我们程序的编写、编译和链接过程都是透明的。这也是我们在计算机的软硬件开发中常用的一种方法,就是**加入一个间接层**。
通过引入虚拟内存、页映射和内存交换,我们的程序本身,就不再需要考虑对应的真实的内存地址、程序加载、内存管理等问题了。任何一个程序,都只需要把内存当成是一块完整而连续的空间来直接使用。
## 总结延伸
现在回到开头我问你的问题我们的电脑只要640K内存就够了吗很显然现在来看比尔·盖茨的这个判断是不合理的那为什么他会这么认为呢因为他也是一个很优秀的程序员啊
在虚拟内存、内存交换和内存分页这三者结合之下你会发现其实要运行一个程序“必需”的内存是很少的。CPU只需要执行当前的指令极限情况下内存也只需要加载一页就好了。再大的程序也可以分成一页。每次只在需要用到对应的数据和指令的时候从硬盘上交换到内存里面来就好了。以我们现在4K内存一页的大小640K内存也能放下足足160页呢也无怪乎在比尔·盖茨会说出“640K ought to be enough for anyone”这样的话。
不过呢硬盘的访问速度比内存慢很多所以我们现在的计算机没有个几G的内存都不好意思和人打招呼。
那么,除了程序分页装载这种方式之外,我们还有其他优化内存使用的方式么?下一讲,我们就一起来看看“动态装载”,学习一下让两个不同的应用程序,共用一个共享程序库的办法。
## 推荐阅读
想要更深入地了解代码装载的详细过程推荐你阅读《程序员的自我修养——链接、装载和库》的第1章和第6章。
## 课后思考
请你想一想在Java这样使用虚拟机的编程语言里面我们写的程序是怎么装载到内存里面来的呢它也和我们讲的一样是通过内存分页和内存交换的方式加载到内存里面来的么
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,179 @@
<audio id="audio" title="10 | 动态链接:程序内部的“共享单车”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4c/f7/4cbe011e6435ea6f292bbd963e9bd6f7.mp3"></audio>
我们之前讲过,程序的链接,是把对应的不同文件内的代码段,合并到一起,成为最后的可执行文件。这个链接的方式,让我们在写代码的时候做到了“复用”。同样的功能代码只要写一次,然后提供给很多不同的程序进行链接就行了。
这么说来,“链接”其实有点儿像我们日常生活中的**标准化、模块化**生产。我们有一个可以生产标准螺帽的生产线,就可以生产很多个不同的螺帽。只要需要螺帽,我们都可以通过**链接**的方式,去**复制**一个出来,放到需要的地方去,大到汽车,小到信箱。
但是,如果我们有很多个程序都要通过装载器装载到内存里面,那里面链接好的同样的功能代码,也都需要再装载一遍,再占一遍内存空间。这就好比,假设每个人都有骑自行车的需要,那我们给每个人都生产一辆自行车带在身边,固然大家都有自行车用了,但是马路上肯定会特别拥挤。
<img src="https://static001.geekbang.org/resource/image/09/51/092dfd81e3cc45ea237bb85557bbfa51.jpg" alt="">
## 链接可以分动、静,共享运行省内存
我们上一节解决程序装载到内存的时候,讲了很多方法。说起来,最根本的问题其实就是**内存空间不够用**。如果我们能够让同样功能的代码,在不同的程序里面,不需要各占一份内存空间,那该有多好啊!就好比,现在马路上的共享单车,我们并不需要给每个人都造一辆自行车,只要马路上有这些单车,谁需要的时候,直接通过手机扫码,都可以解锁骑行。
这个思路就引入一种新的链接方法,叫作**动态链接**Dynamic Link。相应的我们之前说的合并代码段的方法就是**静态链接**Static Link
在动态链接的过程中,我们想要“链接”的,不是存储在硬盘上的目标文件代码,而是加载到内存中的**共享库**Shared Libraries。顾名思义这里的共享库重在“共享“这两个字。
这个加载到内存中的共享库会被很多个程序的指令调用到。在Windows下这些共享库文件就是.dll文件也就是Dynamic-Link LibaryDLL动态链接库。在Linux下这些共享库文件就是.so文件也就是Shared Object一般我们也称之为动态链接库。这两大操作系统下的文件名后缀一个用了“动态链接”的意思另一个用了“共享”的意思正好覆盖了两方面的含义。
<img src="https://static001.geekbang.org/resource/image/29/60/2980d241d3c7cbfa3724cb79b801d160.jpg" alt="">
## 地址无关很重要,相对地址解烦恼
不过,要想要在程序运行的时候共享代码,也有一定的要求,就是这些机器码必须是“**地址无关**”的。也就是说我们编译出来的共享库文件的指令代码是地址无关码Position-Independent Code。换句话说就是这段代码无论加载在哪个内存地址都能够正常执行。如果不是这样的代码就是地址相关的代码。
如果还不明白我给你举一个生活中的例子。如果我们有一个骑自行车的程序要“前进500米左转进入天安门广场再前进500米”。它在500米之后要到天安门广场了这就是地址相关的。如果程序是“前进500米左转再前进500米”无论你在哪里都可以骑车走这1000米没有具体地点的限制这就是地址无关的。
你可以想想,大部分函数库其实都可以做到地址无关,因为它们都接受特定的输入,进行确定的操作,然后给出返回结果就好了。无论是实现一个向量加法,还是实现一个打印的函数,这些代码逻辑和输入的数据在内存里面的位置并不重要。
而常见的地址相关的代码比如绝对地址代码Absolute Code、利用重定位表的代码等等都是地址相关的代码。你回想一下我们之前讲过的重定位表。在程序链接的时候我们就把函数调用后要跳转访问的地址确定下来了这意味着如果这个函数加载到一个不同的内存地址跳转就会失败。
<img src="https://static001.geekbang.org/resource/image/8c/4a/8cab516a92fd3d7e951887808597094a.jpg" alt="">
对于所有动态链接共享库的程序来讲,虽然我们的共享库用的都是同一段物理内存地址,但是在不同的应用程序里,它所在的虚拟内存地址是不同的。我们没办法、也不应该要求动态链接同一个共享库的不同程序,必须把这个共享库所使用的虚拟内存地址变成一致。如果这样的话,我们写的程序就必须明确地知道内部的内存地址分配。
那么问题来了,我们要怎么样才能做到,动态共享库编译出来的代码指令,都是地址无关码呢?
动态代码库内部的变量和函数调用都很容易解决,我们只需要使用**相对地址**Relative Address就好了。各种指令中使用到的内存地址给出的不是一个绝对的地址空间而是一个相对于当前指令偏移量的内存地址。因为整个共享库是放在一段连续的虚拟内存地址中的无论装载到哪一段地址不同指令之间的相对地址都是不变的。
## PLT和GOT动态链接的解决方案
要实现动态链接共享库,也并不困难,和前面的静态链接里的符号表和重定向表类似,还是和前面一样,我们还是拿出一小段代码来看一看。
首先lib.h 定义了动态链接库的一个函数 show_me_the_money。
```
// lib.h
#ifndef LIB_H
#define LIB_H
void show_me_the_money(int money);
#endif
```
lib.c包含了lib.h的实际实现。
```
// lib.c
#include &lt;stdio.h&gt;
void show_me_the_money(int money)
{
printf(&quot;Show me USD %d from lib.c \n&quot;, money);
}
```
然后show_me_poor.c 调用了 lib 里面的函数。
```
// show_me_poor.c
#include &quot;lib.h&quot;
int main()
{
int money = 5;
show_me_the_money(money);
}
```
最后,我们把 lib.c 编译成了一个动态链接库,也就是 .so 文件。
```
$ gcc lib.c -fPIC -shared -o lib.so
$ gcc -o show_me_poor show_me_poor.c ./lib.so
```
你可以看到,在编译的过程中,我们指定了一个 **-fPIC** 的参数。这个参数其实就是Position Independent Code的意思也就是我们要把这个编译成一个地址无关代码。
然后我们再通过gcc编译show_me_poor 动态链接了lib.so的可执行文件。在这些操作都完成了之后我们把show_me_poor这个文件通过objdump出来看一下。
```
$ objdump -d -M intel -S show_me_poor
```
```
……
0000000000400540 &lt;show_me_the_money@plt-0x10&gt;:
400540: ff 35 12 05 20 00 push QWORD PTR [rip+0x200512] # 600a58 &lt;_GLOBAL_OFFSET_TABLE_+0x8&gt;
400546: ff 25 14 05 20 00 jmp QWORD PTR [rip+0x200514] # 600a60 &lt;_GLOBAL_OFFSET_TABLE_+0x10&gt;
40054c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
0000000000400550 &lt;show_me_the_money@plt&gt;:
400550: ff 25 12 05 20 00 jmp QWORD PTR [rip+0x200512] # 600a68 &lt;_GLOBAL_OFFSET_TABLE_+0x18&gt;
400556: 68 00 00 00 00 push 0x0
40055b: e9 e0 ff ff ff jmp 400540 &lt;_init+0x28&gt;
……
0000000000400676 &lt;main&gt;:
400676: 55 push rbp
400677: 48 89 e5 mov rbp,rsp
40067a: 48 83 ec 10 sub rsp,0x10
40067e: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
400685: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
400688: 89 c7 mov edi,eax
40068a: e8 c1 fe ff ff call 400550 &lt;show_me_the_money@plt&gt;
40068f: c9 leave
400690: c3 ret
400691: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0]
400698: 00 00 00
40069b: 0f 1f 44 00 00 nop DWORD PTR [rax+rax*1+0x0]
……
```
我们还是只关心整个可执行文件中的一小部分内容。你应该可以看到在main函数调用show_me_the_money的函数的时候对应的代码是这样的
```
call 400550 &lt;show_me_the_money@plt&gt;
```
这里后面有一个@plt的关键字代表了我们需要从PLT也就是**程序链接表**Procedure Link Table里面找要调用的函数。对应的地址呢则是400550这个地址。
那当我们把目光挪到上面的 400550 这个地址你又会看到里面进行了一次跳转这个跳转指定的跳转地址你可以在后面的注释里面可以看到GLOBAL_OFFSET_TABLE+0x18。这里的GLOBAL_OFFSET_TABLE就是我接下来要说的全局偏移表。
```
400550: ff 25 12 05 20 00 jmp QWORD PTR [rip+0x200512] # 600a68 &lt;_GLOBAL_OFFSET_TABLE_+0x18&gt;
```
在动态链接对应的共享库我们在共享库的data section里面保存了一张**全局偏移表**GOTGlobal Offset Table。**虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。**所有需要引用当前共享库外部的地址的指令都会查询GOT来找到当前运行程序的虚拟内存里的对应位置。而GOT表里的数据则是在我们加载一个个共享库的时候写进去的。
不同的进程调用同样的lib.so各自GOT里面指向最终加载的动态链接库里面的虚拟内存地址是不同的。
这样虽然不同的程序调用的同样的动态库各自的内存地址是独立的调用的又都是同一个动态库但是不需要去修改动态库里面的代码所使用的地址而是各个程序各自维护好自己的GOT能够找到对应的动态库就好了。
<img src="https://static001.geekbang.org/resource/image/11/c8/1144d3a2d4f3f4f87c349a93429805c8.jpg" alt="">
我们的GOT表位于共享库自己的数据段里。GOT表在内存里和对应的代码段位置之间的偏移量始终是确定的。这样我们的共享库就是地址无关的代码对应的各个程序只需要在物理内存里面加载同一份代码。而我们又要通过各个可执行程序在加载时生成的各不相同的GOT表来找到它需要调用到的外部变量和函数的地址。
这是一个典型的、不修改代码,而是通过修改“**地址数据**”来进行关联的办法。它有点像我们在C语言里面用函数指针来调用对应的函数并不是通过预先已经确定好的函数名称来调用而是利用当时它在内存里面的动态地址来调用。
## 总结延伸
这一讲,我们终于在静态链接和程序装载之后,利用动态链接把我们的内存利用到了极致。同样功能的代码生成的共享库,我们只要在内存里面保留一份就好了。这样,我们不仅能够做到代码在开发阶段的复用,也能做到代码在运行阶段的复用。
实际上在进行Linux下的程序开发的时候我们一直会用到各种各样的动态链接库。C语言的标准库就在1MB以上。我们撰写任何一个程序可能都需要用到这个库常见的Linux服务器里/usr/bin下面就有上千个可执行文件。如果每一个都把标准库静态链接进来的几GB乃至几十GB的磁盘空间一下子就用出去了。如果我们服务端的多进程应用要开上千个进程几GB的内存空间也会一下子就用出去了。这个问题在过去计算机的内存较少的时候更加显著。
通过动态链接这个方式,可以说彻底解决了这个问题。就像共享单车一样,如果仔细经营,是一个很有社会价值的事情,但是如果粗暴地把它变成无限制地复制生产,给每个人造一辆,只会在系统内制造大量无用的垃圾。
过去的0509这五讲里我们已经把程序怎么从源代码变成指令、数据并装载到内存里面由CPU一条条执行下去的过程讲完了。希望你能有所收获对于一个程序是怎么跑起来的有了一个初步的认识。
## 推荐阅读
想要更加深入地了解动态链接我推荐你可以读一读《程序员的自我修养链接、装载和库》的第7章里面深入地讲解了动态链接里程序内的数据布局和对应数据的加载关系。
## 课后思考
像动态链接这样通过修改“地址数据”来进行间接跳转,去调用一开始不能确定位置代码的思路,你在应用开发中使用过吗?
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,93 @@
<audio id="audio" title="11 | 二进制编码:“手持两把锟斤拷,口中疾呼烫烫烫”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/42/13/428611743babe804048a3f5a38297013.mp3"></audio>
上算法和数据结构课的时候,老师们都会和你说,程序 = 算法 + 数据结构。如果对应到组成原理或者说硬件层面,算法就是我们前面讲的各种计算机指令,数据结构就对应我们接下来要讲的二进制数据。
众所周知现代计算机都是用0和1组成的二进制来表示所有的信息。前面几讲的程序指令用到的机器码也是使用二进制表示的我们存储在内存里面的字符串、整数、浮点数也都是用二进制表示的。万事万物在计算机里都是0和1所以呢搞清楚各种数据在二进制层面是怎么表示的是我们必备的一课。
大部分教科书都会详细地从整数的二进制表示讲起,相信你在各种地方都能看到对应的材料,所以我就不再啰啰嗦嗦地讲这个了,只会快速地浏览一遍整数的二进制表示。
然后呢我们重点来看一看大家在实际应用中最常遇到的问题也就是文本字符串是怎么表示成二进制的特别是我们会遇到的乱码究竟是怎么回事儿。我们平时在开发的时候所说的Unicode和UTF-8之间有什么关系。理解了这些相信以后遇到任何乱码问题你都能手到擒来了。
## 理解二进制的“逢二进一”
二进制和我们平时用的十进制其实并没有什么本质区别只是平时我们是“逢十进一”这里变成了“逢二进一”而已。每一位相比于十进制下的09这十个数字我们只能用0和1这两个数字。
任何一个十进制的整数都能通过二进制表示出来。把一个二进制数对应到十进制非常简单就是把从右到左的第N位乘上一个2的N次方然后加起来就变成了一个十进制数。当然既然二进制是一个面向程序员的“语言”这个从右到左的位置自然是从0开始的。
比如0011这个二进制数对应的十进制表示就是$0×2^3+0×2^2+1×2^1+1×2^0$<br>
$=3$代表十进制的3。
对应地,如果我们想要把一个十进制的数,转化成二进制,使用**短除法**就可以了。也就是把十进制数除以2的余数作为最右边的一位。然后用商继续除以2把对应的余数紧靠着刚才余数的右侧这样递归迭代直到商为0就可以了。
比如我们想把13这个十进制数用短除法转化成二进制需要经历以下几个步骤
<img src="https://static001.geekbang.org/resource/image/a2/d8/a2b6f2a92bcf99e9f96367bbb90383d8.jpg" alt="">
因此对应的二进制数就是1101。
刚才我们举的例子都是正数对于负数来说情况也是一样的吗我们可以把一个数最左侧的一位当成是对应的正负号比如0为正数1为负数这样来进行标记。
这样一个4位的二进制数 0011就表示为+3。而1011最左侧的第一位是1所以它就表示-3。这个其实就是整数的**原码表示法**。原码表示法有一个很直观的缺点就是0可以用两个不同的编码来表示1000代表0 0000也代表0。习惯万事一一对应的程序员看到这种情况必然会被“逼死”。
于是我们就有了另一种表示方法。我们仍然通过最左侧第一位的0和1来判断这个数的正负。但是我们不再把这一位当成单独的符号位在剩下几位计算出的十进制前加上正负号而是在计算整个二进制值的时候在左侧最高位前面加个负号。
比如一个4位的二进制补码数值1011转换成十进制就是$-1×2^3+0×2^2+1×2^1+1×2^0$<br>
$=-5$。如果最高位是1这个数必然是负数最高位是0必然是正数。并且只有0000表示01000在这样的情况下表示-8。一个4位的二进制数可以表示从-8到7这16个整数不会白白浪费一位。
当然更重要的一点是,用补码来表示负数,使得我们的整数相加变得很容易,不需要做任何特殊处理,只是把它当成普通的二进制相加,就能得到正确的结果。
我们简单一点拿一个4位的整数来算一下比如 -5 + 1 = -4-5 + 6 = 1。我们各自把它们转换成二进制来看一看。如果它们和无符号的二进制整数的加法用的是同样的计算方式这也就意味着它们是同样的电路。
<img src="https://static001.geekbang.org/resource/image/bf/ae/bf4cfd001308da2be317b08d1f40a7ae.jpg" alt="">
## 字符串的表示,从编码到数字
不仅数值可以用二进制表示,字符乃至更多的信息都能用二进制表示。最典型的例子就是**字符串**Character String。最早计算机只需要使用英文字符加上数字和一些特殊符号然后用8位的二进制就能表示我们日常需要的所有字符了这个就是我们常常说的**ASCII码**American Standard Code for Information Interchange美国信息交换标准代码
<img src="https://static001.geekbang.org/resource/image/be/05/bee81480de3f6e7181cb7bb5f55cc805.png" alt="">
ASCII码就好比一个字典用8位二进制中的128个不同的数映射到128个不同的字符里。比如小写字母a在ASCII里面就是第97个也就是二进制的0110 0001对应的十六进制表示就是 61。而大写字母 A就是第65个也就是二进制的0100 0001对应的十六进制表示就是41。
在ASCII码里面数字9不再像整数表示法里一样用0000 1001来表示而是用0011 1001 来表示。字符串15也不是用0000 1111 这8位来表示而是变成两个字符1和5连续放在一起也就是 0011 0001 和 0011 0101需要用两个8位来表示。
我们可以看到最大的32位整数就是2147483647。如果用整数表示法只需要32位就能表示了。但是如果用字符串来表示一共有10个字符每个字符用8位的话需要整整80位。比起整数表示法要多占很多空间。
这也是为什么很多时候我们在存储数据的时候要采用二进制序列化这样的方式而不是简单地把数据通过CSV或者JSON这样的文本格式存储来进行序列化。**不管是整数也好,浮点数也好,采用二进制序列化会比存储文本省下不少空间。**
ASCII码只表示了128个字符一开始倒也堪用毕竟计算机是在美国发明的。然而随着越来越多的不同国家的人都用上了计算机想要表示譬如中文这样的文字128个字符显然是不太够用的。于是计算机工程师们开始各显神通给自己国家的语言创建了对应的**字符集**Charset和**字符编码**Character Encoding
字符集表示的可以是字符的一个集合。比如“中文”就是一个字符集不过这样描述一个字符集并不准确。想要更精确一点我们可以说“第一版《新华字典》里面出现的所有汉字”这是一个字符集。这样我们才能明确知道一个字符在不在这个集合里面。比如我们日常说的Unicode其实就是一个字符集包含了150种语言的14万个不同的字符。
而字符编码则是对于字符集里的这些字符怎么一一用二进制表示出来的一个字典。我们上面说的Unicode就可以用UTF-8、UTF-16乃至UTF-32来进行编码存储成二进制。所以有了Unicode其实我们可以用不止UTF-8一种编码形式我们也可以自己发明一套 GT-32 编码比如就叫作Geek Time 32好了。只要别人知道这套编码规则就可以正常传输、显示这段代码。
<img src="https://static001.geekbang.org/resource/image/99/3e/9911c58d79e8a1f106d48a83457d193e.jpg" alt="">
同样的文本,采用不同的编码存储下来。如果另外一个程序,用一种不同的编码方式来进行解码和展示,就会出现乱码。这就好像两个军队用密语通信,如果用错了密码本,那看到的消息就会不知所云。在中文世界里,最典型的就是“手持两把锟斤拷,口中疾呼烫烫烫”的典故。
我曾经听说过这么一个笑话没有经验的同学在看到程序输出“烫烫烫”的时候以为是程序让CPU过热发出报警于是尝试给CPU降频来解决问题。
既然今天要彻底搞清楚编码知识,我们就来弄清楚“锟斤拷”和“烫烫烫”的来龙去脉。
<img src="https://static001.geekbang.org/resource/image/5c/fd/5c6e03705f50c250ccb5300849c281fd.png" alt="">
首先“锟斤拷”的来源是这样的。如果我们想要用Unicode编码记录一些文本特别是一些遗留的老字符集内的文本但是这些字符在Unicode中可能并不存在。于是Unicode会统一把这些字符记录为U+FFFD这个编码。如果用UTF-8的格式存储下来就是\xef\xbf\xbd。如果连续两个这样的字符放在一起\xef\xbf\xbd\xef\xbf\xbd这个时候如果程序把这个字符用GB2312的方式进行decode就会变成“锟斤拷”。这就好比我们用GB2312这本密码本去解密别人用UTF-8加密的信息自然没办法读出有用的信息。
而“烫烫烫”则是因为如果你用了Visual Studio的调试器默认使用MBCS字符集。“烫”在里面是由0xCCCC来表示的而0xCC又恰好是未初始化的内存的赋值。于是在读到没有赋值的内存地址或者变量的时候电脑就开始大叫“烫烫烫”了。
了解了这些原理,相信你未来在遇到中文的编码问题的时候,可以做到“手中有粮,心中不慌”了。
## 总结延伸
到这里,相信你发现,我们可以用二进制编码的方式,表示任意的信息。只要建立起字符集和字符编码,并且得到大家的认同,我们就可以在计算机里面表示这样的信息了。所以说,如果你有心,要发明一门自己的克林贡语并不是什么难事。
不过光是明白怎么把数值和字符在逻辑层面用二进制表示是不够的。我们在计算机组成里面关心的不只是数值和字符的逻辑表示更要弄明白在硬件层面这些数值和我们一直提的晶体管和电路有什么关系。下一讲我就会为你揭开神秘的面纱。我会从时钟和D触发器讲起最终让你明白计算机里的加法是如何通过电路来实现的。
## 推荐阅读
关于二进制和编码,我推荐你读一读《编码:隐匿在计算机软硬件背后的语言》。从电报机到计算机,这本书讲述了很多计算设备的历史故事,当然,也包含了二进制及其背后对应的电路原理。
## 课后思考
你肯定会计算十进制整数的加减法,二进制的加减法也是一样的。如果二进制的加法中,有数是负数的时候该怎么处理呢?我们今天讲了补码的表示形式,如果这个负数是原码表示的,又应该如何处理?如果是补码表示的呢?请你用二进制加法试着算一算,-5+4=-1通过原码和补码是如何进行的
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,87 @@
<audio id="audio" title="12 | 理解电路:从电报机到门电路,我们如何做到“千里传信”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c2/bb/c2e2074a9abdc9c0efff056f919555bb.mp3"></audio>
我们前面讲过机器指令你应该知道所有最终执行的程序其实都是使用“0”和“1”这样的二进制代码来表示的。上一讲里我也向你展示了对应的整数和字符串其实也是用“0”和“1”这样的二进制代码来表示的。
那么你可能要问了,我知道了这个有什么用呢?毕竟我们人用纸和笔来做运算,都是用十进制,直接用十进制和我们最熟悉的符号不是最简单么?为什么计算机里我们最终要选择二进制呢?
这一讲,我和你一起来看看,计算机在硬件层面究竟是怎么表示二进制的,以此你就会明白,为什么计算机会选择二进制。
## 从信使到电报,我们怎么做到“千里传书”?
马拉松的故事相信你听说过。公元前490年在雅典附近的马拉松海边发生了波斯和希腊之间的希波战争。雅典和斯巴达领导的希腊联军胜利之后雅典飞毛腿菲迪皮德斯跑了历史上第一个马拉松回雅典报喜。这个时候人们在远距离报信的时候采用的是派人跑腿传口信或者送信的方式。
但是,这样靠人传口信或者送信的方式,实在是太慢了。在军事用途中,信息能否更早更准确地传递出去经常是事关成败的大事。所以我们看到中国古代的军队有“击鼓进军”和“鸣金收兵”,通过打鼓和敲钲发出不同的声音,来传递军队的号令。
如果我们把军队当成一台计算机那“金”和“鼓”就是这台计算机的“1”和“0”。我们可以通过不同的编码方式来指挥这支军队前进、后退、转向、追击等等。
“金”和“鼓”比起跑腿传口信,固然效率更高了,但是能够传递的范围还是非常有限,超出个几公里恐怕就听不见了。于是,人们发明了更多能够往更远距离传信的方式,比如海上的灯塔、长城上的烽火台。因为光速比声速更快,传的距离也可以更远。
<img src="https://static001.geekbang.org/resource/image/48/f9/486201eca454fbda5b3a77ef29d27bf9.png" alt="">
但是这些传递信息的方式都面临一个问题就是受限于只有“1”和“0”这两种信号不能传递太复杂的信息那电报的发明就解决了这个问题。
从信息编码的角度来说,金、鼓、灯塔、烽火台类似电报的二进制编码。电报传输的信号有两种,一种是短促的**点信号**dot信号一种是长一点的**划信号**dash信号。我们把“点”当成“1”把“划”当成“0”。这样一来我们的电报信号就是另一种特殊的二进制编码了。电影里最常见的电报信号是“SOS”这个信号表示出来就是 “点点点划划划点点点”。
比起灯塔和烽火台这样的设备,电报信号有两个明显的优势。第一,信号的传输距离迅速增加。因为电报本质上是通过电信号来进行传播的,所以从输入信号到输出信号基本上没有延时。第二,输入信号的速度加快了很多。电报机只有一个按钮,按下就是输入信号,按的时间短一点,就是发出了一个“点”信号;按的时间长一些,就是一个“划”信号。只要一个手指,就能快速发送电报。
<img src="https://static001.geekbang.org/resource/image/5d/a4/5da409e31bd130129a5d669b143fa1a4.jpg" alt="">
而且,制造一台电报机也非常容易。电报机本质上就是一个“**蜂鸣器+长长的电线+按钮开关**”。蜂鸣器装在接收方手里,开关留在发送方手里。双方用长长的电线连在一起。当按钮开关按下的时候,电线的电路接通了,蜂鸣器就会响。短促地按下,就是一个短促的点信号;按的时间稍微长一些,就是一个稍长的划信号。
<img src="https://static001.geekbang.org/resource/image/28/12/283742f3a72eba22f6b4ae97e21c4112.jpg" alt="">
## 理解继电器,给跑不动的信号续一秒
有了电报机,只要铺设好电报线路,就可以传输我们需要的讯息了。但是这里面又出现了一个新的挑战,就是随着电线的线路越长,电线的电阻就越大。当电阻很大,而电压不够的时候,即使你按下开关,蜂鸣器也不会响。
你可能要说了,我们可以提高电压或者用更粗的电线,使得电阻更小,这样就可以让整个线路铺得更长一些。但是这个再长,也没办法从北京铺设到上海吧。要想从北京把电报发到上海,我们还得想些别的办法。
对于电报来说,电线太长了,使得线路接通也没有办法让蜂鸣器响起来。那么,我们就不要一次铺太长的线路,而把一小段距离当成一个线路。我们也可以跟驿站建立一个小电报站,在小电报站里面安排一个电报员。他听到上一个小电报站发来的信息,然后原样输入,发到下一个电报站去。这样,我们的信号就可以一段段传输下去,而不会因为距离太长,导致电阻太大,没有办法成功传输信号。为了能够实现这样**接力传输信号**,在电路里面,工程师们造了一个叫作**继电器**Relay的设备。
<img src="https://static001.geekbang.org/resource/image/11/ea/1186a10341202ea36df27cba95f1cbea.jpg" alt="">
事实上,这个过程中,我们需要在每一阶段**原样传输信号**,所以你可以想想,我们是不是可以设计一个设备来代替这个电报员?相比使用人工听蜂鸣器的声音,来重复输入信号,利用电磁效应和磁铁,来实现这个事情会更容易。
我们把原先用来输出声音的蜂鸣器,换成一段环形的螺旋线圈,让电路封闭通上电。因为电磁效应,这段螺旋线圈会产生一个带有磁性的电磁场。我们原本需要输入的按钮开关,就可以用一块磁力稍弱的磁铁把它设在“关”的状态。这样,按下上一个电报站的开关,螺旋线圈通电产生了磁场之后,磁力就会把开关“吸”下来,接通到下一个电报站的电路。
如果我们在中间所有小电报站都用这个“**螺旋线圈+磁性开关**”的方式,来替代蜂鸣器和普通开关,而只在电报的始发和终点用普通的开关和蜂鸣器,我们就有了一个拆成一段一段的电报线路,接力传输电报信号。这样,我们就不需要中间安排人力来听打电报内容,也不需要解决因为线缆太长导致的电阻太大或者电压不足的问题了。我们只要在终点站安排电报员,听写最终的电报内容就可以了。这样是不是比之前更省事了?
事实上,继电器还有一个名字就叫作**电驿**,这个“驿”就是驿站的驿,可以说非常形象了。这个接力的策略不仅可以用在电报中,在通信类的科技产品中其实都可以用到。
比如说你在家里用WiFi如果你的屋子比较大可能某些房间的信号就不好。你可以选用支持“中继”的WiFi路由器在信号衰减的地方增加一个WiFi设备接收原来的WiFi信号再重新从当前节点传输出去。这种中继对应的英文名词和继电器是一样的也叫Relay。
再比如说,我们现在互联网使用的光缆,是用光信号来传输数据。随着距离的增长、反射次数的增加,信号也会有所衰减,我们同样要每隔一段距离,来增加一个用来重新放大信号的中继。
有了继电器之后我们不仅有了一个能够接力传输信号的方式更重要的是和输入端通过开关的“开”和“关”来表示“1”和“0”一样我们在输出端也能表示“1”和“0”了。
输出端的作用,不仅仅是通过一个蜂鸣器或者灯泡,提供一个供人观察的输出信号,通过“螺旋线圈 + 磁性开关”使得我们有“开”和“关”这两种状态这个“开”和“关”表示的“1”和“0”还可以作为后续线路的输入信号让我们开始可以通过最简单的电路来组合形成我们需要的逻辑。
通过这些线圈和开关,我们也可以很容易地创建出 “与AND”“或OR”“非NOT”这样的逻辑。我们在输入端的电路上提供串联的两个开关只有两个开关都打开电路才接通输出的开关也才能接通这其实就是模拟了计算机里面的“与”操作。
我们在输入端的电路,提供两条独立的线路到输出端,两条线路上各有一个开关,那么任何一个开关打开了,到输出端的电路都是接通的,这其实就是模拟了计算机中的“或”操作。
当我们把输出端的“螺旋线圈+磁性开关”的组合,从默认关掉,只有通电有了磁场之后打开,换成默认是打开通电的,只有通电之后才关闭,我们就得到了一个计算机中的“非”操作。输出端开和关正好和输入端相反。这个在数字电路中,也叫作**反向器**Inverter
<img src="https://static001.geekbang.org/resource/image/97/5e/977b09f3a334304c2861c6b420217b5e.jpg" alt="">
与、或、非的电路都非常简单,要想做稍微复杂一点的工作,我们需要很多电路的组合。不过,这也彰显了现代计算机体系中一个重要的思想,就是通过分层和组合,逐步搭建起更加强大的功能。
回到我们前面看的电报机原型,虽然一个按钮开关的电报机很“容易”操作,但是却不“方便”操作。因为电报员要熟记每一个字母对应的摩尔斯电码,并且需要快速按键来进行输入,一旦输错很难纠正。但是,因为电路之间可以通过与、或、非组合完成更复杂的功能,我们完全可以设计一个和打字机一样的电报机,每按下一个字母按钮,就会接通一部分电路,然后把这个字母的摩尔斯电码输出出去。
虽然在电报机时代我们没有这么做但是在计算机时代我们其实就是这样做的。我们不再是给计算机“0”和“1”而是通过千万个晶体管组合在一起最终使得我们可以用“高级语言”指挥计算机去干什么。
## 总结延伸
可以说电报是现代计算机的一个最简单的原型。它和我们现在使用的现代计算机有很多相似之处。我们通过电路的“开”和“关”来表示“1”和“0”。就像晶体管在不同的情况下表现为导电的“1”和绝缘的“0”的状态。
我们通过电报机这个设备,看到了如何通过“螺旋线圈+开关”来构造基本的逻辑电路我们也叫门电路。一方面我们可以通过继电器或者中继进行长距离的信号传输。另一方面我们也可以通过设置不同的线路和开关状态实现更多不同的信号表示和处理方式这些线路的连接方式其实就是我们在数字电路中所说的门电路。而这些门电路也是我们创建CPU和内存的基本逻辑单元。我们的各种对于计算机二进制的“0”和“1”的操作其实就是来自于门电路叫作组合逻辑电路。
## 推荐阅读
《编码隐匿在计算机软硬件背后的语言》的第611章是一个很好的入门材料可以帮助你深入理解数字电路值得你花时间好好读一读。
## 课后思考
除了与、或、非之外还有很多基础的门电路比如“异或XOR门”。你可以想一想试着搜索一些资料设计一个异或门的电路。
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,89 @@
<audio id="audio" title="13 | 加法器:如何像搭乐高一样搭电路(上)?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f0/e4/f0c2ab7ba1ca54add23e734341a65ce4.mp3"></audio>
上一讲,我们看到了如何通过电路,在计算机硬件层面设计最基本的单元,门电路。我给你看的门电路非常简单,只能做简单的 “与AND”“或OR”“NOT”和“异或XOR这样最基本的单比特逻辑运算。下面这些门电路的标识你需要非常熟悉后续的电路都是由这些门电路组合起来的。
<img src="https://static001.geekbang.org/resource/image/94/f6/94194480bcfd3b5366e4649ee80de4f6.jpg" alt="">
这些基本的门电路是我们计算机硬件端的最基本的“积木”就好像乐高积木里面最简单的小方块。看似不起眼但是把它们组合起来最终可以搭出一个星球大战里面千年隼这样的大玩意儿。我们今天包含十亿级别晶体管的现代CPU都是由这样一个一个的门电路组合而成的。
<img src="https://static001.geekbang.org/resource/image/2f/b7/2f20b26b1ed7f9d26c5a0858ad6770b7.jpg" alt="">
## 异或门和半加器
我们看到的基础门电路输入都是两个单独的bit输出是一个单独的bit。如果我们要对2个8 位bit的数计算与、或、非这样的简单逻辑运算其实很容易。只要连续摆放8个开关来代表一个8位数。这样的两组开关从左到右上下单个的位开关之间都统一用“与门”或者“或门”连起来就是两个8位数的AND或者OR的运算了。
比起AND或者OR这样的电路外要想实现整数的加法就需要组建稍微复杂一点儿的电路了。
我们先回归一个最简单的8位的无符号整数的加法。这里的“无符号”表示我们并不需要使用补码来表示负数。无论高位是“0”还是“1”这个整数都是一个正数。
我们很直观就可以想到要表示一个8位数的整数简单地用8个bit也就是8个像上一讲的电路开关就好了。那2个8位整数的加法就是2排8个开关。加法得到的结果也是一个8位的整数所以又需要1排8位的开关。要想实现加法我们就要看一下通过什么样的门电路能够连接起加数和被加数得到最后期望的和。
<img src="https://static001.geekbang.org/resource/image/28/66/281879883d285478b7771f576f4b3066.jpg" alt="">
要做到这一点,我们先来看看,我们人在计算加法的时候一般会怎么操作。二进制的加法和十进制没什么区别,所以我们一样可以用**列竖式**来计算。我们仍然是从右到左一位一位进行计算只是把从逢10进1变成逢2进1。
<img src="https://static001.geekbang.org/resource/image/18/d1/1854b98fcac2c6bf4949ac5e2247d9d1.jpg" alt="">
你会发现其实计算一位数的加法很简单。我们先就看最简单的个位数。输入一共是4种组合00、01、10、11。得到的结果也不复杂。
一方面我们需要知道加法计算之后的个位是什么在输入的两位是00和11的情况下对应的输出都应该是0在输入的两位是10和01的情况下输出都是1。结果你会发现这个输入和输出的对应关系其实就是我在上一讲留给你的思考题里面的“异或门XOR”。
讲与、或、非门的时候我们很容易就能和程序里面的“AND通常是&amp;符号)”“ OR通常是 | 符号)”和“ NOT通常是 !符号”对应起来。可能你没有想过为什么我们会需要“异或XOR这样一个在逻辑运算里面没有出现的形式作为一个基本电路。**其实,异或门就是一个最简单的整数加法,所需要使用的基本门电路**。
算完个位的输出还不算完输入的两位都是11的时候我们还需要向更左侧的一位进行进位。那这个就对应一个与门也就是有且只有在加数和被加数都是1的时候我们的进位才会是1。
所以,通过一个异或门计算出个位,通过一个与门计算出是否进位,我们就通过电路算出了一个一位数的加法。于是,**我们把两个门电路打包,给它取一个名字,就叫作半加器**Half Adder
<img src="https://static001.geekbang.org/resource/image/58/1e/5860fd8c4ace079b40e66b9568d2b81e.jpg" alt="">
## 全加器
你肯定很奇怪为什么我们给这样的电路组合取名叫半加器Half Adder莫非还有一个全加器Full Adder你猜得没错。半加器可以解决个位的加法问题但是如果放到二位上来说就不够用了。我们这里的竖式是个二进制的加法所以如果从右往左数第二列不是十位我称之为“二位”。对应的再往左就应该分别是四位、八位。
二位用一个半加器不能计算完成的原因也很简单。因为二位除了一个加数和被加数之外还需要加上来自个位的进位信号一共需要三个数进行相加才能得到结果。但是我们目前用到的无论是最简单的门电路还是用两个门电路组合而成的半加器输入都只能是两个bit也就是两个开关。那我们该怎么办呢
实际上,解决方案也并不复杂。**我们用两个半加器和一个或门,就能组合成一个全加器**。第一个半加器我们用和个位的加法一样的方式得到是否进位X和对应的二个数加和后的结果Y这样两个输出。然后我们把这个加和后的结果Y和个位数相加后输出的进位信息U再连接到一个半加器上就会再拿到一个是否进位的信号V和对应的加和后的结果W。
<img src="https://static001.geekbang.org/resource/image/3f/2a/3f11f278ba8f24209a56fb3ee1ca9e2a.jpg" alt="">
这个W就是我们在二位上留下的结果。我们把两个半加器的进位输出作为一个或门的输入连接起来只要两次加法中任何一次需要进位那么在二位上我们就会向左侧的四位进一位。因为一共只有三个bit相加即使3个bit都是1也最多会进一位。
这样,通过两个半加器和一个或门,我们就得到了一个,能够接受进位信号、加数和被加数,这样三个数组成的加法。这就是我们需要的全加器。
有了全加器我们要进行对应的两个8 bit数的加法就很容易了。我们只要把8个全加器串联起来就好了。个位的全加器的进位信号作为二位全加器的输入信号二位全加器的进位信号再作为四位的全加器的进位信号。这样一层层串接八层我们就得到了一个支持8位数加法的算术单元。如果要扩展到16位、32位乃至64位都只需要多串联几个输入位和全加器就好了。
<img src="https://static001.geekbang.org/resource/image/68/a1/68cd38910f526c149d232720b82b6ca1.jpeg" alt="">
唯一需要注意的是对于这个全加器在个位我们只需要用一个半加器或者让全加器的进位输入始终是0。因为个位没有来自更右侧的进位。而最左侧的一位输出的进位信号表示的并不是再进一位而是表示我们的加法是否溢出了。
这也是很有意思的一点。以前我自己在了解二进制加法的时候一直有这么个疑问既然int这样的16位的整数加法结果也是16位数那我们怎么知道加法最终是否溢出了呢因为结果也只存得下加法结果的16位数。我们并没有留下一个第17位来记录这个加法的结果是否溢出。
看到全加器的电路设计,相信你应该明白,在整个加法器的结果中,我们其实有一个电路的信号,会标识出加法的结果是否溢出。我们可以把这个对应的信号,输出给到硬件中其他标志位里,让我们的计算机知道计算的结果是否溢出。而现代计算机也正是这样做的。这就是为什么你在撰写程序的时候,能够知道你的计算结果是否溢出在硬件层面得到的支持。
## 总结延伸
相信到这里,你应该已经体会到了,通过门电路来搭建算术计算的一个小功能,就好像搭乐高积木一样。
我们用两个门电路,搭出一个半加器,就好像我们拿两块乐高,叠在一起,变成一个长方形的乐高,这样我们就有了一个新的积木组件,柱子。我们再用两个柱子和一个长条的积木组合一下,就变成一个积木桥。然后几个积木桥串接在一起,又成了积木楼梯。
当我们想要搭建一个摩天大楼,我们需要很多很多楼梯。但是这个时候,我们已经不再关注最基础的一节楼梯是怎么用一块块积木搭建起来的。这其实就是计算机中,无论软件还是硬件中一个很重要的设计思想,**分层**。
<img src="https://static001.geekbang.org/resource/image/8a/94/8a7740f698236fda4e5f900d88fdf194.jpg" alt="">
从简单到复杂,我们一层层搭出了拥有更强能力的功能组件。在上面的一层,我们只需要考虑怎么用下一层的组件搭建出自己的功能,而不需要下沉到更低层的其他组件。就像你之前并没有深入学习过计算机组成原理,一样可以直接通过高级语言撰写代码,实现功能。
在硬件层面我们通过门电路、半加器、全加器一层层搭出了加法器这样的功能组件。我们把这些用来做算术逻辑计算的组件叫作ALU也就是算术逻辑单元。当进一步打造强大的CPU时我们不会再去关注最细颗粒的门电路只需要把门电路组合而成的ALU当成一个能够完成基础计算的黑盒子就可以了。
以此类推后面我们讲解CPU的设计和数据通路的时候我们以ALU为一个基础单元来解释问题也就够了。
## 补充阅读
出于性能考虑实际CPU里面使用的加法器比起我们今天讲解的电路还有些差别会更复杂一些。真实的加法器使用的是一种叫作**超前进位加法器**的东西。你可以找到北京大学在Coursera上开设的《计算机组成》课程中的Video-306 “加法器优化”一节,了解一下超前进位加法器的实现原理,以及我们为什么要使用它。
## 课后思考
这一讲,我给你详细讲解了无符号数的加法器是怎么通过电路搭建出来的。那么,如果是使用补码表示的有符号数,这个加法器是否可以实现正数加负数这样的运算呢?如果不行,我们应该怎么搭建对应的电路呢?
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,99 @@
<audio id="audio" title="14 | 乘法器:如何像搭乐高一样搭电路(下)?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/01/9f3414356b443bc6f0ef76a5b8a07f01.mp3"></audio>
和学习小学数学一样学完了加法之后我们自然而然就要来学习乘法。既然是退回到小学我们就把问题搞得简单一点先来看两个4位数的乘法。这里的4位数当然还是一个二进制数。我们是人类而不是电路自然还是用列竖式的方式来进行计算。
十进制中的13乘以9计算的结果应该是117。我们通过转换成二进制然后列竖式的办法来看看整个计算的过程是怎样的。
<img src="https://static001.geekbang.org/resource/image/49/4b/498fdfa2dc95631068d65e0ff5769c4b.jpg" alt="">
## 顺序乘法的实现过程
从列出竖式的过程中你会发现二进制的乘法有个很大的优点就是这个过程你不需要背九九乘法口诀表了。因为单个位置上乘数只能是0或者1所以实际的乘法就退化成了位移和加法。
在13×9这个例子里面被乘数13表示成二进制是1101乘数9在二进制里面是1001。最右边的个位是1所以个位乘以被乘数就是把被乘数1101复制下来。因为二位和四位都是0所以乘以被乘数都是0那么保留下来的都是0000。乘数的八位是1我们仍然需要把被乘数1101复制下来。不过这里和个位位置的单纯复制有一点小小的差别那就是要把复制好的结果向左侧移三位然后把四位单独进行乘法加位移的结果再加起来我们就得到了最终的计算结果。
对应到我们之前讲的数字电路和ALU你可以看到最后一步的加法我们可以用上一讲的加法器来实现。乘法因为只有“0”和“1”两种情况所以可以做成输入输出都是4个开关中间用1个开关同时来控制这8个开关的方式这就实现了二进制下的单位的乘法。
<img src="https://static001.geekbang.org/resource/image/02/9c/02ae32716bc3bf165d177dfe80d2c09c.jpg" alt="">
至于位移也不麻烦,我们只要不是直接连线,把正对着的开关之间进行接通,而是斜着错开位置去接就好了。如果要左移一位,就错开一位接线;如果要左移两位,就错开两位接线。
<img src="https://static001.geekbang.org/resource/image/e4/95/e4c7ddb75731030930d38adf967b2d95.jpg" alt="">
这样你会发现我们并不需要引入任何新的、更复杂的电路仍然用最基础的电路只要用不同的接线方式就能够实现一个“列竖式”的乘法。而且因为二进制下只有0和1也就是开关的开和闭这两种情况所以我们的计算机也不需要去“背诵”九九乘法口诀表不需要单独实现一个更复杂的电路就能够实现乘法。
为了节约一点开关也就是晶体管的数量。实际上像13×9这样两个四位数的乘法我们不需要把四次单位乘法的结果用四组独立的开关单独都记录下来然后再把这四个数加起来。因为这样做需要很多组开关如果我们计算一个32位的整数乘法就要32组开关太浪费晶体管了。如果我们顺序地来计算只需要一组开关就好了。
我们先拿乘数最右侧的个位乘以被乘数,然后把结果写入用来存放计算结果的开关里面,然后,把被乘数左移一位,把乘数右移一位,仍然用乘数去乘以被乘数,然后把结果加到刚才的结果上。反复重复这一步骤,直到不能再左移和右移位置。这样,乘数和被乘数就像两列相向而驶的列车,仅仅需要简单的加法器、一个可以左移一位的电路和一个右移一位的电路,就能完成整个乘法。
<img src="https://static001.geekbang.org/resource/image/cb/e9/cb809de19088d08767279715f07482e9.jpg" alt="">
你看这里画的乘法器硬件结构示意图。这里的控制测试其实就是通过一个时钟信号来控制左移、右移以及重新计算乘法和加法的时机。我们还是以计算13×9也就是二进制的1101×1001来具体看。
<img src="https://static001.geekbang.org/resource/image/06/71/0615e5e4406617ee6584adbb929f9571.jpeg" alt="">
这个计算方式虽然节约电路了,但是也有一个很大的缺点,那就是慢。
你应该很容易就能发现,在这个乘法器的实现过程里,我们其实就是把乘法展开,变成了“**加法+位移**”来实现。我们用的是4位数所以要进行4组“位移+加法”的操作。而且这4组操作还不能同时进行。因为**下一组的加法要依赖上一组的加法后的计算结果,下一组的位移也要依赖上一组的位移的结果。这样,整个算法是“顺序”的,每一组加法或者位移的运算都需要一定的时间**。
所以最终这个乘法的计算速度其实和我们要计算的数的位数有关。比如这里的4位就需要4次加法。而我们的现代CPU常常要用32位或者是64位来表示整数那么对应就需要32次或者64次加法。比起4位数要多花上8倍乃至16倍的时间。
换个我们在算法和数据结构中的术语来说就是,这样的一个顺序乘法器硬件进行计算的时间复杂度是 O(N)。这里的N就是乘法的数里面的**位数**。
## 并行加速方法
那么我们有没有办法把时间复杂度上降下来呢研究数据结构和算法的时候我们总是希望能够把O(N)的时间复杂度降低到O(logN)。办法还真的有。和软件开发里面改算法一样在涉及CPU和电路的时候我们可以改电路。
32位数虽然是32次加法但是我们可以让很多加法同时进行。回到这一讲开始我们把位移和乘法的计算结果加到中间结果里的方法32位整数的乘法其实就变成了32个整数相加。
前面顺序乘法器硬件的实现办法,就好像体育比赛里面的**单败淘汰赛**。只有一个擂台会存下最新的计算结果。每一场新的比赛就来一个新的选手实现一次加法实现完了剩下的还是原来那个守擂的直到其余31个选手都上来比过一场。如果一场比赛需要一天那么一共要比31场也就是31天。
<img src="https://static001.geekbang.org/resource/image/07/ef/07f7b0eedbf1a00fc72be7e2bd0d96ef.jpg" alt="">
加速的办法就是把比赛变成像世界杯足球赛那样的淘汰赛32个球队捉对厮杀同时开赛。这样一天一下子就淘汰了16支队也就是说32个数两两相加后你可以得到16个结果。后面的比赛也是一样同时开赛捉对厮杀。只需要5天也就是O(log<sub>2</sub>N)的时间就能得到计算的结果。但是这种方式要求我们得有16个球场。因为在淘汰赛的第一轮我们需要16场比赛同时进行。对应到我们CPU的硬件上就是需要更多的晶体管开关来放下中间计算结果。
<img src="https://static001.geekbang.org/resource/image/66/98/6646b90ea563c6b87dc20bbd81c54b98.jpeg" alt="">
## 电路并行
上面我们说的并行加速的办法,看起来还是有点儿笨。我们回头来做一个抽象的思考。之所以我们的计算会慢,核心原因其实是“顺序”计算,也就是说,要等前面的计算结果完成之后,我们才能得到后面的计算结果。
最典型的例子就是我们上一讲讲的加法器。每一个全加器,都要等待上一个全加器,把对应的进入输入结果算出来,才能算下一位的输出。位数越多,越往高位走,等待前面的步骤就越多,这个等待的时间有个专门的名词,叫作**门延迟**Gate Delay
每通过一个门电路我们就要等待门电路的计算结果就是一层的门电路延迟我们一般给它取一个“T”作为符号。一个全加器其实就已经有了3T的延迟进位需要经过3个门电路。而4位整数最高位的计算需要等待前面三个全加器的进位结果也就是要等9T的延迟。如果是64位整数那就要变成63×3=189T的延迟。这可不是个小数字啊
除了门延迟之外,还有一个问题就是**时钟频率**。在上面的顺序乘法计算里面,如果我们想要用更少的电路,计算的中间结果需要保存在寄存器里面,然后等待下一个时钟周期的到来,控制测试信号才能进行下一次移位和加法,这个延迟比上面的门延迟更可观。
那么,我们有什么办法可以解决这个问题呢?实际上,在我们进行加法的时候,如果相加的两个数是确定的,那高位是否会进位其实也是确定的。对于我们人来说,我们本身去做计算都是顺序执行的,所以要一步一步计算进位。但是,计算机是连结的各种线路。我们不用让计算机模拟人脑的思考方式,来连结线路。
那怎么才能把线路连结得复杂一点,让高位和低位的计算同时出结果呢?怎样才能让高位不需要等待低位的进位结果,而是把低位的所有输入信号都放进来,直接计算出高位的计算结果和进位结果呢?
我们只要把进位部分的电路完全展开就好了。我们的半加器到全加器,再到加法器,都是用最基础的门电路组合而成的。门电路的计算逻辑,可以像我们做数学里面的多项式乘法一样完全展开。在展开之后呢,我们可以把原来需要较少的,但是有较多层前后计算依赖关系的门电路,展开成需要较多的,但是依赖关系更少的门电路。
我在这里画了一个示意图,展示了一下我们加法器。如果我们完全展开电路,高位的进位和计算结果,可以和低位的计算结果同时获得。这个的核心原因是电路是天然并行的,一个输入信号,可以同时传播到所有接通的线路当中。
<img src="https://static001.geekbang.org/resource/image/0c/69/0c2c69f9bbd1d8eca36f560cbe092169.jpg" alt="">
如果一个4位整数最高位是否进位展开门电路图你会发现我们只需要3T的延迟就可以拿到是否进位的计算结果。而对于64位的整数也不会增加门延迟只是从上往下复制这个电路接入更多的信号而已。看到没我们通过把电路变复杂就解决了延迟的问题。
这个优化,本质上是利用了电路天然的并行性。电路只要接通,输入的信号自动传播到了所有接通的线路里面,这其实也是硬件和软件最大的不同。
无论是这里把对应的门电路逻辑进行完全展开以减少门延迟,还是上面的乘法通过并行计算多个位的乘法,都是把我们完成一个计算的电路变复杂了。而电路变复杂了,也就意味着晶体管变多了。
之前很多同学在我们讨论计算机的性能问题的时候,都提到,为什么晶体管的数量增加可以优化计算机的计算性能。实际上,这里的门电路展开和上面的并行计算乘法都是很好的例子。我们通过更多的晶体管,就可以拿到更低的门延迟,以及用更少的时钟周期完成一个计算指令。
## 总结延伸
讲到这里相信你已经发现我们通过之前两讲的ALU和门电路搭建出来了乘法器。如果愿意的话我们可以把很多在生活中不得不顺序执行的事情通过简单地连结一下线路就变成并行执行了。这是因为硬件电路有一个很大的特点那就是信号都是实时传输的。
我们也看到了通过精巧地设计电路用较少的门电路和寄存器就能够计算完成乘法这样相对复杂的运算。是用更少更简单的电路但是需要更长的门延迟和时钟周期还是用更复杂的电路但是更短的门延迟和时钟周期来计算一个复杂的指令这之间的权衡其实就是计算机体系结构中RISC和CISC的经典历史路线之争。
## 推荐阅读
如果还有什么细节你觉得还没有彻底弄明白,我推荐你看一看《计算机组成与设计:硬件/软件接口》的3.3节。
## 课后思考
这一讲里,我为你讲解了乘法器是怎么实现的。那么,请你想一想,如果我们想要用电路实现一个除法器,应该怎么做呢?需要注意一下,除法器除了要计算除法的商之外,还要计算出对应的余数。
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,101 @@
<audio id="audio" title="15 | 浮点数和定点数怎么用有限的Bit表示尽可能多的信息" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2a/95/2a8a2c58a1258d3ca597e5b75258d395.mp3"></audio>
在我们日常的程序开发中不只会用到整数。更多情况下我们用到的都是实数。比如我们开发一个电商App商品的价格常常会是9块9再比如现在流行的深度学习算法对应的机器学习里的模型里的各个权重也都是1.23这样的数。可以说,在实际的应用过程中,这些有零有整的实数,是和整数同样常用的数据类型,我们也需要考虑到。
## 浮点数的不精确性
那么,我们能不能用二进制表示所有的实数,然后在二进制下计算它的加减乘除呢?先不着急,我们从一个有意思的小案例来看。
你可以在Linux下打开Python的命令行Console也可以在Chrome浏览器里面通过开发者工具打开浏览器里的Console在里面输入“0.3 + 0.6”,然后看看你会得到一个什么样的结果。
```
&gt;&gt;&gt; 0.3 + 0.6
0.8999999999999999
```
不知道你有没有大吃一惊这么简单的一个加法无论是在Python还是在JavaScript里面算出来的结果居然不是准确的0.9而是0.8999999999999999这么个结果。这是为什么呢?
在回答为什么之前我们先来想一个更抽象的问题。通过前面的这么多讲你应该知道我们现在用的计算机通常用16/32个比特bit来表示一个数。那我问你我们用32个比特能够表示所有实数吗
答案很显然是不能。32个比特只能表示2的32次方个不同的数差不多是40亿个。如果表示的数要超过这个数就会有两个不同的数的二进制表示是一样的。那计算机可就会一筹莫展不知道这个数到底是多少。
40亿个数看似已经很多了但是比起无限多的实数集合却只是沧海一粟。所以这个时候计算机的设计者们就要面临一个问题了我到底应该让这40亿个数映射到实数集合上的哪些数在实际应用中才能最划得来呢
## 定点数的表示
有一个很直观的想法就是我们用4个比特来表示09的整数那么32个比特就可以表示8个这样的整数。然后我们把最右边的2个09的整数当成小数部分把左边6个09的整数当成整数部分。这样我们就可以用32个比特来表示从0到999999.99这样1亿个实数了。
<img src="https://static001.geekbang.org/resource/image/f5/b3/f5a0b0f2188ebe0d18f4424578a588b3.jpg" alt="">
这种用二进制来表示十进制的编码方式,叫作[**BCD编码**](https://zh.wikipedia.org/wiki/%E4%BA%8C%E9%80%B2%E7%A2%BC%E5%8D%81%E9%80%B2%E6%95%B8)Binary-Coded Decimal。其实它的运用非常广泛最常用的是在超市、银行这样需要用小数记录金额的情况里。在超市里面我们的小数最多也就到分。这样的表示方式比较直观清楚也满足了小数部分的计算。
不过,这样的表示方式也有几个缺点。
**第一,这样的表示方式有点“浪费”。**本来32个比特我们可以表示40亿个不同的数但是在BCD编码下只能表示1亿个数如果我们要精确到分的话那么能够表示的最大金额也就是到100万。如果我们的货币单位是人民币或者美元还好如果我们的货币单位变成了津巴布韦币这个数量就不太够用了。
**第二,这样的表示方式没办法同时表示很大的数字和很小的数字。**我们在写程序的时候实数的用途可能是多种多样的。有时候我们想要表示商品的金额关心的是9.99这样小的数字;有时候,我们又要进行物理学的运算,需要表示光速,也就是$3×10^8$这样很大的数字。那么,我们有没有一个办法,既能够表示很小的数,又能表示很大的数呢?
## 浮点数的表示
答案当然是有的,就是你可能经常听说过的**浮点数**Floating Point也就是**float类型**。
我们先来想一想。如果我们想在一张便签纸上用一行来写一个十进制数能够写下多大范围的数因为我们要让人能够看清楚所以字最小也有一个限制。你会发现一个和上面我们用BCD编码表示数一样的问题就是纸张的宽度限制了我们能够表示的数的大小。如果宽度只放得下8个数字那么我们还是只能写下最大到99999999这样的数字。
<img src="https://static001.geekbang.org/resource/image/c3/56/c321a0b9d95ba475439f9fbdff07bf56.png" alt="">
其实这里的纸张宽度就和我们32个比特一样是在空间层面的限制。那么在现实生活中我们是怎么表示一个很大的数的呢比如说我们想要在一本科普书里写一下宇宙内原子的数量莫非是用一页纸用好多行写下很多个0么
当然不是了,我们会用科学计数法来表示这个数字。宇宙内的原子的数量,大概在 10的82次方左右我们就用$1.0×10^82$这样的形式来表示这个数值不需要写下82个0。
在计算机里,我们也可以用一样的办法,用科学计数法来表示实数。浮点数的科学计数法的表示,有一个**IEEE**的标准它定义了两个基本的格式。一个是用32比特表示单精度的浮点数也就是我们常常说的float或者float32类型。另外一个是用64比特表示双精度的浮点数也就是我们平时说的double或者float64类型。
双精度类型和单精度类型差不多,这里,我们来看单精度类型,双精度你自然也就明白了。
<img src="https://static001.geekbang.org/resource/image/91/41/914b71bf1d85fb6ed76e1135f39b6941.jpg" alt="">
单精度的32个比特可以分成三部分。
第一部分是一个**符号位**,用来表示是正数还是负数。我们一般用**s**来表示。在浮点数里,我们不像正数分符号数还是无符号数,所有的浮点数都是有符号的。
接下来是一个8个比特组成的**指数位**。我们一般用**e**来表示。8个比特能够表示的整数空间就是0255。我们在这里用1254映射到-126127这254个有正有负的数上。因为我们的浮点数不仅仅想要表示很大的数还希望能够表示很小的数所以指数位也会有负数。
你发现没我们没有用到0和255。没错这里的 0也就是8个比特全部为0 和 255 也就是8个比特全部为1另有它用我们等一下再讲。
最后是一个23个比特组成的**有效数位**。我们用**f**来表示。综合科学计数法,我们的浮点数就可以表示成下面这样:
$(-1)^s×1.f×2^e$
你会发现这里的浮点数没有办法表示0。的确要表示0和一些特殊的数我们就要用上在e里面留下的0和255这两个表示这两个表示其实是两个标记位。在e为0且f为0的时候我们就把这个浮点数认为是0。至于其它的e是0或者255的特殊情况你可以看下面这个表格分别可以表示出无穷大、无穷小、NAN以及一个特殊的不规范数。
<img src="https://static001.geekbang.org/resource/image/f9/4c/f922249a89667c4d10239eb8840dc94c.jpg" alt="">
我们可以以0.5为例子。0.5的符号为s应该是0f应该是0而e应该是-1也就是
$0.5= (-1)^0×1.0×2^{-1}=0.5$对应的浮点数表示就是32个比特。
<img src="https://static001.geekbang.org/resource/image/51/50/5168fce3f313f4fc0b600ce5d1805c50.jpeg" alt="">
$s=0e = 2^{-1}$需要注意e表示从-126到127个-1是其中的第126个数这里的e如果用整数表示就是$2^6+2^5+2^4+2^3+2^2+2^1=126$$1.f=1.0$。
在这样的浮点数表示下,不考虑符号的话,浮点数能够表示的最小的数和最大的数,差不多是$1.17×10^{-38}$和$3.40×10^{38}$。比前面的BCD编码能够表示的范围大多了。
## 总结延伸
你会看到在这样的表示方式下浮点数能够表示的数据范围一下子大了很多。正是因为这个数对应的小数点的位置是“浮动”的它才被称为浮点数。随着指数位e的值的不同小数点的位置也在变动。对应的前面的BCD编码的实数就是小数点固定在某一位的方式我们也就把它称为**定点数**。
回到我们最开头为什么我们用0.3 + 0.6不能得到0.9呢这是因为浮点数没有办法精确表示0.3、0.6和0.9。事实上我们拿出0.10.9这9个数其中只有0.5能够被精确地表示成二进制的浮点数也就是s = 0、e = -1、f = 0这样的情况。
而0.3、0.6乃至我们希望的0.9,都只是一个近似的表达。这个也为我们带来了一个挑战,就是浮点数无论是表示还是计算其实都是近似计算。那么,在使用过程中,我们该怎么来使用浮点数,以及使用浮点数会遇到些什么问题呢?下一讲,我会用更多的实际代码案例,来带你看看浮点数计算中的各种“坑”。
## 推荐阅读
如果对浮点数的表示还不是很清楚,你可以仔细阅读一下《计算机组成与设计:硬件/软件接口》的3.5.1节。
## 课后思考
对于BCD编码的定点数如果我们用7个比特来表示连续两位十进制数也就是0099是不是可以让32比特表示更大一点的数据范围如果我们还需要表示负数那么一个32比特的BCD编码可以表示的数据范围是多大
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,172 @@
<audio id="audio" title="16 | 浮点数和定点数(下):深入理解浮点数到底有什么用?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c9/7f/c92842f221bcdca5e0571a540ac9807f.mp3"></audio>
上一讲,我们讲了用“浮点数”这样的数据形式,来表示一个不能确定大小的数据范围。浮点数可以大到$3.40×10^{38}$,也可以小到$1.17×10^{-38}$这样的数值。同时我们也发现其实我们平时写的0.1、0.2并不是精确的数值只是一个近似值。只有0.5这样,可以表示成$2^{-1}$这种形式的,才是一个精确的浮点数。
你是不是感到很疑惑,浮点数的近似值究竟是怎么算出来的?浮点数的加法计算又是怎么回事儿?在实践应用中,我们怎么才用好浮点数呢?这一节,我们就一起来看这几个问题。
## 浮点数的二进制转化
我们首先来看,十进制的浮点数怎么表示成二进制。
我们输入一个任意的十进制浮点数背后都会对应一个二进制表示。比方说我们输入了一个十进制浮点数9.1。那么按照之前的讲解,在二进制里面,我们应该把它变成一个“**符号位s+指数位e+有效位数f**”的组合。第一步,我们要做的,就是把这个数变成二进制。
首先我们把这个数的整数部分变成一个二进制。这个我们前面讲二进制的时候已经讲过了。这里的9换算之后就是1001。
接着我们把对应的小数部分也换算成二进制。小数怎么换成二进制呢我们先来定义一下小数的二进制表示是怎么回事。我们拿0.1001这样一个二进制小数来举例说明。和上面的整数相反我们把小数点后的每一位都表示对应的2的-N次方。那么0.1001,转化成十进制就是:
$1×2^{-1}+0×2^{-2}+0×2^{-3}+$<br>
$1×2^{-4}=0.5625$
和整数的二进制表示采用“除以2然后看余数”的方式相比小数部分转换成二进制是用一个相似的反方向操作就是乘以2然后看看是否超过1。如果超过1我们就记下1并把结果减去1进一步循环操作。在这里我们就会看到0.1其实变成了一个无限循环的二进制小数0.000110011。这里的“0011”会无限循环下去。
<img src="https://static001.geekbang.org/resource/image/f9/ae/f9213c43f5fa658a2192a68cd26435ae.jpg" alt="">
然后我们把整数部分和小数部分拼接在一起9.1这个十进制数就变成了1001.000110011…这样一个二进制表示。
上一讲我们讲过,浮点数其实是用二进制的科学计数法来表示的,所以我们可以把小数点左移三位,这个数就变成了:
$1.0010$$0011$$0011… × 2^3$
那这个二进制的科学计数法表示我们就可以对应到了浮点数的格式里了。这里的符号位s = 0对应的有效位f=0010**0011**0011…。因为f最长只有23位那这里“0011”无限循环最多到23位就截止了。于是f=0010**0011001100110011** **001**。最后的一个“0011”循环中的最后一个“1”会被截断掉。对应的指数为e代表的应该是3。因为指数位有正又有负所以指数位在127之前代表负数之后代表正数那3其实对应的是加上127的偏移量130转化成二进制就是130对应的就是指数位的二进制表示出来就是1000**0010**。
<img src="https://static001.geekbang.org/resource/image/9a/27/9ace5a7404d1790b03d07bd1b3cb5a27.jpeg" alt="">
然后我们把“s+e+f”拼在一起就可以得到浮点数9.1的二进制表示了。最终得到的二进制表示就变成了:
01000**0010** 0010 **0011001100110011** **001**
如果我们再把这个浮点数表示换算成十进制, 实际准确的值是9.09999942779541015625。相信你现在应该不会感觉奇怪了。
我在这里放一个[链接](https://www.h-schmidt.net/FloatConverter/IEEE754.html)这里提供了直接交互式地设置符号位、指数位和有效位数的操作。你可以直观地看到32位浮点数每一个bit的变化对应的有效位数、指数会变成什么样子以及最后的十进制的计算结果是怎样的。
这个也解释了为什么在上一讲一开始0.3+0.6=0.899999。因为0.3转化成浮点数之后和这里的9.1一样并不是精确的0.3了0.6和0.9也是一样的,最后的计算会出现精度问题。
## 浮点数的加法和精度损失
搞清楚了怎么把一个十进制的数值转化成IEEE-754标准下的浮点数表示我们现在来看一看浮点数的加法是怎么进行的。其实原理也很简单你记住六个字就行了那就是**先对齐、再计算**。
两个浮点数的指数位可能是不一样的,所以我们要把两个的指数位,变成一样的,然后只去计算有效位的加法就好了。
比如0.5,表示成浮点数,对应的指数位是-1有效位是00…后面全是0记住f前默认有一个1。0.125表示成浮点数,对应的指数位是-3有效位也还是00…后面全是0记住f前默认有一个1
那我们在计算0.5+0.125的浮点数运算的时候,首先要把两个的指数位对齐,也就是把指数位都统一成两个其中较大的-1。对应的有效位1.00…也要对应右移两位因为f前面有一个默认的1所以就会变成0.01。然后我们计算两者相加的有效位1.f就变成了有效位1.01,而指数位是-1这样就得到了我们想要的加法后的结果。
实现这样一个加法,也只需要位移。和整数加法类似的半加器和全加器的方法就能够实现,在电路层面,也并没有引入太多新的复杂性。
<img src="https://static001.geekbang.org/resource/image/d7/f0/d7a6e87da9c0d0b874980ca4306a55f0.jpg" alt="">
同样的,你可以用刚才那个链接来试试看,我们这个加法计算的浮点数的结果是不是正确。
回到浮点数的加法过程,你会发现,其中指数位较小的数,需要在有效位进行右移,在右移的过程中,最右侧的有效位就被丢弃掉了。这会导致对应的指数位较小的数,在加法发生之前,就**丢失精度**。两个相加数的指数位差的越大位移的位数越大可能丢失的精度也就越大。当然也有可能你的运气非常好右移丢失的有效位都是0。这种情况下对应的加法虽然丢失了需要加的数字的精度但是因为对应的值都是0实际的加法的数值结果不会有精度损失。
32位浮点数的有效位长度一共只有23位如果两个数的指数位差出23位较小的数右移24位之后所有的有效位就都丢失了。这也就意味着虽然浮点数可以表示上到$3.40×10^{38}$,下到$1.17×10^{-38}$这样的数值范围。但是在实际计算的时候,只要两个数,差出$2^{24}$也就是差不多1600万倍那这两个数相加之后结果完全不会变化。
你可以试一下我下面用一个简单的Java程序让一个值为2000万的32位浮点数和1相加你会发现+1这个过程因为精度损失被“完全抛弃”了。
```
public class FloatPrecision {
public static void main(String[] args) {
float a = 20000000.0f;
float b = 1.0f;
float c = a + b;
System.out.println(&quot;c is &quot; + c);
float d = c - a;
System.out.println(&quot;d is &quot; + d);
}
}
```
对应的输出结果就是:
```
c is 2.0E7
d is 0.0
```
## Kahan Summation算法
那么我们有没有什么办法来解决这个精度丢失问题呢虽然我们在计算浮点数的时候常常可以容忍一定的精度损失但是像上面那样如果我们连续加2000万个12000万的数值都会被精度损失丢掉了就会影响我们的计算结果。
一个常见的应用场景是在一些“积少成多”的计算过程中比如在机器学习中我们经常要计算海量样本计算出来的梯度或者loss于是会出现几亿个浮点数的相加。每个浮点数可能都差不多大但是随着累积值的越来越大就会出现“大数吃小数”的情况。
我们可以做一个简单的实验用一个循环相加2000万个1.0f最终的结果会是1600万左右而不是2000万。这是因为加到1600万之后的加法因为精度丢失都没有了。这个代码比起上面的使用2000万来加1.0更具有现实意义。
```
public class FloatPrecision {
public static void main(String[] args) {
float sum = 0.0f;
for (int i = 0; i &lt; 20000000; i++) {
float x = 1.0f;
sum += x;
}
System.out.println(&quot;sum is &quot; + sum);
}
}
```
对应的输出结果是:
```
sum is 1.6777216E7
```
面对这个问题,聪明的计算机科学家们也想出了具体的解决办法。他们发明了一种叫作[Kahan Summation](https://en.wikipedia.org/wiki/Kahan_summation_algorithm)的算法来解决这个问题。算法的对应代码我也放在文稿中了。从中你可以看到同样是2000万个1.0f相加用这种算法我们得到了准确的2000万的结果。
```
public class KahanSummation {
public static void main(String[] args) {
float sum = 0.0f;
float c = 0.0f;
for (int i = 0; i &lt; 20000000; i++) {
float x = 1.0f;
float y = x - c;
float t = sum + y;
c = (t-sum)-y;
sum = t;
}
System.out.println(&quot;sum is &quot; + sum);
}
}
```
对应的输出结果就是:
```
sum is 2.0E7
```
其实这个算法的原理其实并不复杂,就是在每次的计算过程中,都用一次减法,把当前加法计算中损失的精度记录下来,然后在后面的循环中,把这个精度损失放在要加的小数上,再做一次运算。
如果你对这个背后的数学原理特别感兴趣,可以去看一看[Wikipedia链接](https://en.wikipedia.org/wiki/Kahan_summation_algorithm)里面对应的数学证明,也可以生成一些数据试一试这个算法。这个方法在实际的数值计算中也是常用的,也是大量数据累加中,解决浮点数精度带来的“大数吃小数”问题的必备方案。
## 总结延伸
到这里,我们已经讲完了浮点数的表示、加法计算以及可能会遇到的精度损失问题。可以看到,虽然浮点数能够表示的数据范围变大了很多,但是在实际应用的时候,由于存在精度损失,会导致加法的结果和我们的预期不同,乃至于完全没有加上的情况。
所以,一般情况下,在实践应用中,对于需要精确数值的,比如银行存款、电商交易,我们都会使用定点数或者整数类型。
比方说你一定在MySQL里用过decimal(12,2)来表示订单金额。如果我们的银行存款用32位浮点数表示就会出现马云的账户里有2千万我的账户里只剩1块钱。结果银行一汇总总金额那1块钱在账上就“不翼而飞”了。
而浮点数呢则更适合我们不需要有一个非常精确的计算结果的情况。因为在真实的物理世界里很多数值本来就不是精确的我们只需要有限范围内的精度就好了。比如从我家到办公室的距离就不存在一个100%精确的值。我们可以精确到公里、米,甚至厘米,但是既没有必要、也没有可能去精确到微米乃至纳米。
对于浮点数加法中可能存在的精度损失特别是大量加法运算中累积产生的巨大精度损失我们可以用Kahan Summation这样的软件层面的算法来解决。
好了到了这里我已经把浮点数讲透了。希望你能从数据的表示、加法的实现乃至实践应用、数值算法层面能够体会到搞清楚一个计算机问题的基本原理其实能够帮助你理解它的实践应用乃至找到在特定问题下的可行解决方案。接下来我们要深入到CPU的构造去理解计算机组成原理。
## 推荐阅读
浮点数的加法我们讲完了。想要更深入地了解乘法乃至除法,可以参看《计算机组成与设计 硬件/软件接口》的3.5.2和3.5.3小节。
## 课后思考
这两节我讲的都是32位浮点数那么对于64位浮点数的加法两个数相差多少的情况后较小的哪个数在加法过程中会完全丢失呢
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。

View File

@@ -0,0 +1,97 @@
<audio id="audio" title="52 | 设计大型DMP系统MongoDB并不是什么灵丹妙药" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/00/e3/00daa3d6d41fa409d32ee3f6dee1dee3.mp3"></audio>
如果你一讲一讲跟到现在那首先要恭喜你马上就看到胜利的曙光了。过去的50多讲里我把计算机组成原理中的各个知识点一点一点和你拆解了。对于其中的很多知识点我也给了相应的代码示例和实际的应用案例。
不过呢相信你和我一样觉得只了解这样一个个零散的知识点和案例还不过瘾。那么从今天开始我们就进入应用篇。我会通过两个应用系统的案例串联起计算机组成原理的两大块知识点一个是我们的整个存储器系统另一个自然是我们的CPU和指令系统了。
我们今天就先从搭建一个大型的DMP系统开始利用组成原理里面学到的存储器知识来做选型判断从而更深入地理解计算机组成原理。
## DMP数据管理平台
我们先来看一下什么是DMP系统。DMP系统的全称叫作数据管理平台Data Management Platform目前广泛应用在互联网的广告定向Ad Targeting、个性化推荐Recommendation这些领域。
通常来说DMP系统会通过处理海量的互联网访问数据以及机器学习算法给一个用户标注上各种各样的标签。然后在我们做个性化推荐和广告投放的时候再利用这些这些标签去做实际的广告排序、推荐等工作。无论是Google的搜索广告、淘宝里千人千面的商品信息还是抖音里面的信息流推荐背后都会有一个DMP系统。
<img src="https://static001.geekbang.org/resource/image/17/ef/170004db8634a3a7f9dc47c4a4d5bfef.jpg" alt="">
那么一个DMP系统应该怎么搭建呢对于外部使用DMP的系统或者用户来说可以简单地把DMP看成是一个键-值对Key-Value数据库。我们的广告系统或者推荐系统可以通过一个客户端输入用户的唯一标识ID然后拿到这个用户的各种信息。
这些信息中有些是用户的人口属性信息Demographic比如性别、年龄有些是非常具体的行为Behavior比如用户最近看过的商品是什么用户的手机型号是什么有一些是我们通过算法系统计算出来的兴趣Interests比如用户喜欢健身、听音乐还有一些则是完全通过机器学习算法得出的用户向量给后面的推荐算法或者广告算法作为数据输入。
基于此对于这个KV数据库我们的期望也很清楚那就是**低响应时间**Low Response Time、**高可用性**High Availability、**高并发**High Concurrency、**海量数据**Big Data同时我们需要**付得起对应的成本**Affordable Cost。如果用数字来衡量这些指标那么我们的期望就会具体化成下面这样。
1. 低响应时间一般的广告系统留给整个广告投放决策的时间也就是10ms左右所以对于访问DMP获取用户数据预期的响应时间都在1ms之内。
1. 高可用性DMP常常用在广告系统里面。DMP系统出问题往往就意味着我们整个的广告收入在不可用的时间就没了所以我们对于可用性的追求可谓是没有上限的。Google 2018年的广告收入是1160亿美元折合到每一分钟的收入是22万美元。即使我们做到 99.99% 的可用性也意味着每个月我们都会损失100万美元。
1. 高并发还是以广告系统为例如果每天我们需要响应100亿次的广告请求那么我们每秒的并发请求数就在 100亿 / (86400) ~= 12K 次左右所以我们的DMP需要支持高并发。
1. 数据量如果我们的产品针对中国市场那么我们需要有10亿个Key对应的假设每个用户有500个标签标签有对应的分数。标签和分数都用一个4字节Bytes的整数来表示那么一共我们需要 10亿 x 500 x (4 + 4) Bytes = 4 TB 的数据了。
1. 低成本我们还是从广告系统的角度来考虑。广告系统的收入通常用CPMCost Per Mille也就是千次曝光来统计。如果千次曝光的利润是 0.10美元那么每天100亿次的曝光就是100万美元的利润。这个利润听起来非常高了。但是反过来算一下你会发现DMP每1000次的请求的成本不能超过 0.10美元。最好只有0.01美元,甚至更低,我们才能尽可能多赚到一点广告利润。
这五个因素一结合,听起来是不是就不那么简单了?不过,更复杂的还在后面呢。
虽然从外部看起来DMP特别简单就是一个KV数据库但是生成这个数据库需要做的事情更多。我们下面一起来看一看。
<img src="https://static001.geekbang.org/resource/image/b5/33/b5d8c56d840b9e824cfbc186c19d0733.jpg" alt="">
为了能够生成这个KV数据库我们需要有一个在客户端或者Web端的数据采集模块不断采集用户的行为向后端的服务器发送数据。服务器端接收到数据就要把这份数据放到一个**数据管道**Data Pipeline里面。数据管道的下游需要实际将数据落地到**数据仓库**Data Warehouse把所有的这些数据结构化地存储起来。后续我们就可以通过程序去分析这部分日志生成报表或者或者利用数据运行各种机器学习算法。
除了这个数据仓库之外我们还会有一个实时数据处理模块Realtime Data Processing也放在数据管道的下游。它同样会读取数据管道里面的数据去进行各种实时计算然后把需要的结果写入到DMP的KV数据库里面去。
## MongoDB真的万能吗
面对这里的KV数据库、数据管道以及数据仓库这三个不同的数据存储的需求最合理的技术方案是什么呢你可以先自己思考一下我这里先卖个关子。
我共事过的不少不错的Web程序员面对这个问题的时候常常会说“这有什么难的用MongoDB就好了呀”如果你也选择了MongoDB那最终的结果一定是一场灾难。我为什么这么说呢
MongoDB的设计听起来特别厉害不需要预先数据Schema访问速度很快还能够无限水平扩展。作为KV数据库我们可以把MongoDB当作DMP里面的KV数据库除此之外MongoDB还能水平扩展、跑MQL我们可以把它当作数据仓库来用。至于数据管道只要我们能够不断往MongoDB里面插入新的数据就好了。从运维的角度来说我们只需要维护一种数据库技术栈也变得简单了。看起来MongoDB这个选择真是相当完美
但是作为一个老程序员第一次听到MongoDB这样“万能”的解决方案我的第一反应是“天底下哪有这样的好事”。所有的软件系统都有它的适用场景想通过一种解决方案适用三个差异非常大的应用场景显然既不合理又不现实。接下来我们就来仔细看一下这个“不合理”“不现实”在什么地方。
上面我们已经讲过DMP的KV数据库期望的应用场景和性能要求了这里我们就来看一下**数据管道**和**数据仓库**的性能取舍。
对于数据管道来说我们需要的是高吞吐量它的并发量虽然和KV数据库差不多但是在响应时间上要求就没有那么严格了1-2秒甚至再多几秒的延时都是可以接受的。而且和KV数据库不太一样数据管道的数据读写都是顺序读写没有大量的随机读写的需求。
**数据仓库**就更不一样了数据仓库的数据读取的量要比管道大得多。管道的数据读取就是我们当时写入的数据一天有10TB日志数据管道只会写入10TB。下游的数据仓库存放数据和实时数据模块读取的数据再加上个2倍的10TB也就是20TB也就够了。
但是数据仓库的数据分析任务要读取的数据量就大多了。一方面我们可能要分析一周、一个月乃至一个季度的数据。这一次分析要读取的数据可不是10TB而是100TB乃至1PB。我们一天在数据仓库上跑的分析任务也不是1个而是成千上万个所以数据的读取量是巨大的。另一方面我们存储在数据仓库里面的数据也不像数据管道一样存放几个小时、最多一天的数据而是往往要存上3个月甚至是1年的数据。所以我们需要的是1PB乃至5PB这样的存储空间。
我把KV数据库、数据管道和数据仓库的应用场景总结成了一个表格放在这里。你可以对照着看一下想想为什么MongoDB在这三个应用场景都不合适。
<img src="https://static001.geekbang.org/resource/image/fe/41/fe9fc1ade3611ed2acf3ba3a23267f41.jpg" alt="">
在KV数据库的场景下需要支持高并发。那么MongoDB需要把更多的数据放在内存里面但是这样我们的存储成本就会特别高了。
在数据管道的场景下我们需要的是大量的顺序读写而MongoDB则是一个文档数据库系统并没有为顺序写入和吞吐量做过优化看起来也不太适用。
而在数据仓库的场景下主要的数据读取时顺序读取并且需要海量的存储。MongoDB这样的文档式数据库也没有为海量的顺序读做过优化仍然不是一个最佳的解决方案。而且文档数据库里总是会有很多冗余的字段的元数据还会浪费更多的存储空间。
那我们该选择什么样的解决方案呢?
拿着我们的应用场景去找方案其实并不难找。对于KV数据库最佳的选择方案自然是使用SSD硬盘选择AeroSpike这样的KV数据库。高并发的随机访问并不适合HDD的机械硬盘而400TB的数据如果用内存的话成本又会显得太高。
对于数据管道最佳选择自然是Kafka。因为我们追求的是吞吐率采用了Zero-Copy和DMA机制的Kafka最大化了作为数据管道的吞吐率。而且数据管道的读写都是顺序读写所以我们也不需要对随机读写提供支持用上HDD硬盘就好了。
到了数据仓库存放的数据量更大了。在硬件层面使用HDD硬盘成了一个必选项。否则我们的存储成本就会差上10倍。这么大量的数据在存储上我们需要定义清楚Schema使得每个字段都不需要额外存储元数据能够通过Avro/Thrift/ProtoBuffer这样的二进制序列化的方存储下来或者干脆直接使用Hive这样明确了字段定义的数据仓库产品。很明显MongoDB那样不限制Schema的数据结构在这个情况下并不好用。
2012年前后做广告系统的时候我们也曾经尝试使用MongoDB尽管只是用作DMP中的数据报表部分。事实证明即使是已经做了数据层面的汇总的报表MongoDB都无法很好地支撑我们需要的复杂需求。最终我们也不得不选择在整个DMP技术栈里面彻底废弃MongoDB而只在Web应用里面用用MongoDB。事实证明我最初的直觉是正确的并没有什么万能的解决方案。
## 总结延伸
好了,相信到这里,你应该对怎么从最基本的原理出发,来选择技术栈有些感觉了。你应该更多地从底层的存储系统的特性和原理去考虑问题。一旦能够从这个角度去考虑问题,那么你对各类新的技术项目和产品的公关稿,自然会有一定的免疫力了,而不会轻易根据商业公司的宣传来做技术选型了。
因为低延时、高并发、写少读多的DMP的KV数据库最适合用SSD硬盘并且采用专门的KV数据库是最合适的。我们可以选择之前文章里提过的AeroSpike也可以用开源的Cassandra来提供服务。
对于数据管道因为主要是顺序读和顺序写所以我们不一定要选用SSD硬盘而可以用HDD硬盘。不过对于最大化吞吐量的需求使用zero-copy和DMA是必不可少的所以现在的数据管道的标准解决方案就是Kafka了。
对于数据仓库我们通常是一次写入、多次读取。并且由于存储的数据量很大我们还要考虑成本问题。于是一方面我们会用HDD硬盘而不是SSD硬盘另一方面我们往往会预先给数据规定好Schema使得单条数据的序列化不需要像存JSON或者MongoDB的BSON那样存储冗余的字段名称这样的元数据。所以最常用的解决方案是用Hadoop这样的集群采用Hive这样的数据仓库系统或者采用Avro/Thrift/ProtoBuffer这样的二进制序列化方案。
在大型的DMP系统设计当中我们需要根据各个应用场景面临的实际情况选择不同的硬件和软件的组合来作为整个系统中的不同组件。
## 推荐阅读
如果通过这一讲的内容,能让你对大型数据系统的设计有了兴趣,那就再好不过了。我推荐你去读一读[《数据密集型应用系统设计》](https://book.douban.com/subject/30329536/)这本书,深入了解一下,设计数据系统需要关注的各个核心要点。
## 课后思考
这一讲里我们讲到了数据管道通常所使用的开源系统Kafka并且选择了使用机械硬盘。在Kafka的使用上我们有没有必要使用SSD硬盘呢如果用了SSD硬盘又会带来哪些好处和坏处呢
请你仔细思考一下,也可以和周围的朋友分享讨论。如果你觉得有所收获,也请你把你的想法写在留言区,分享给其他的同学。

View File

@@ -0,0 +1,119 @@
<audio id="audio" title="53 | 设计大型DMP系统SSD拯救了所有的DBA" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/70/11/7069b3fbeb47d8229c004335e6607b11.mp3"></audio>
上一讲里根据DMP系统的各个应用场景我们从抽象的原理层面选择了AeroSpike作为KV数据库Kafka作为数据管道Hadoop/Hive来作为数据仓库。
不过呢肯定有不信邪的工程师会问为什么MongoDB甚至是MySQL这样的文档数据库或者传统的关系型数据库不适用呢为什么不能通过优化SQL、添加缓存这样的调优手段解决这个问题呢
今天DMP的下半场我们就从数据库实现的原理一起来看一看这背后的原因。如果你能弄明白今天的这些更深入、更细节的原理对于什么场景使用什么数据库就会更加胸有成竹而不是只有跑了大量的性能测试才知道。下次做数据库选型的时候你就可以“以理服人”了。
## 关系型数据库:不得不做的随机读写
我们先来想一想,如果现在让你自己写一个最简单的关系型数据库,你的数据要怎么存放在硬盘上?
最简单最直观的想法是用一个CSV文件格式。一个文件就是一个数据表。文件里面的每一行就是这个表里面的一条记录。如果要修改数据库里面的某一条记录那么我们要先找到这一行然后直接去修改这一行的数据。读取数据也是一样的。
要找到这样数据最笨的办法自然是一行一行读也就是遍历整个CSV文件。不过这样的话相当于随便读取任何一条数据都要扫描全表太浪费硬盘的吞吐量了。那怎么办呢我们可以试试给这个CSV文件加一个索引。比如给数据的行号加一个索引。如果你学过数据库原理或者算法和数据结构那你应该知道通过B+树多半是可以来建立这样一个索引的。
索引里面没有一整行的数据,只有一个映射关系,这个映射关系可以让行号直接从硬盘的某个位置去读。所以,索引比起数据小很多。我们可以把索引加载到内存里面。即使不在内存里面,要找数据的时候快速遍历一下整个索引,也不需要读太多的数据。
加了索引之后我们要读取特定的数据就不用去扫描整个数据表文件了。直接从特定的硬盘位置就可以读到想要的行。索引不仅可以索引行号还可以索引某个字段。我们可以创建很多个不同的独立的索引。写SQL的时候where子句后面的查询条件可以用到这些索引。
不过,这样的话,写入数据的时候就会麻烦一些。我们不仅要在数据表里面写入数据,对于所有的索引也都需要进行更新。这个时候,写入一条数据就要触发好几个随机写入的更新。
<img src="https://static001.geekbang.org/resource/image/f3/3c/f3c01bc2de99dbb83ad17cef1fb38a3c.jpeg" alt="">
在这样一个数据模型下查询操作很灵活。无论是根据哪个字段查询只要有索引我们就可以通过一次随机读很快地读到对应的数据。但是这个灵活性也带来了一个很大的问题那就是无论干点什么都有大量的随机读写请求。而随机读写请求如果请求最终是要落到硬盘上特别是HDD硬盘的话我们就很难做到高并发了。毕竟HDD硬盘只有100左右的QPS。
而这个随时添加索引可以根据任意字段进行查询这样表现出的灵活性又是我们的DMP系统里面不太需要的。DMP的KV数据库主要的应用场景是根据主键的随机查询不需要根据其他字段进行筛选查询。数据管道的需求则只需要不断追加写入和顺序读取就好了。即使进行数据分析的数据仓库通常也不是根据字段进行数据筛选而是全量扫描数据进行分析汇总。
后面的两个场景还好说大不了我们让程序去扫描全表或者追加写入。但是在KV数据库这个需求上刚才这个最简单的关系型数据库的设计就会面临大量的随机写入和随机读取的挑战。
所以在实际的大型系统中大家都会使用专门的分布式KV数据库来满足这个需求。那么下面我们就一起来看一看Facebook开源的Cassandra的数据存储和读写是怎么做的这些设计是怎么解决高并发的随机读写问题的。
## Cassandra顺序写和随机读
### Cassandra的数据模型
作为一个分布式的KV数据库Cassandra的键一般被称为Row Key。其实就是一个16到36个字节的字符串。每一个Row Key对应的值其实是一个哈希表里面可以用键值对再存入很多你需要的数据。
Cassandra本身不像关系型数据库那样有严格的Schema在数据库创建的一开始就定义好了有哪些列Column。但是它设计了一个叫作列族Column Family的概念我们需要把经常放在一起使用的字段放在同一个列族里面。比如DMP里面的人口属性信息我们可以把它当成是一个列族。用户的兴趣信息可以是另外一个列族。这样既保持了不需要严格的Schema这样的灵活性也保留了可以把常常一起使用的数据存放在一起的空间局部性。
往Cassandra的里面读写数据其实特别简单就好像是在一个巨大的分布式的哈希表里面写数据。我们指定一个Row Key然后插入或者更新这个Row Key的数据就好了。
### Cassandra的写操作
<img src="https://static001.geekbang.org/resource/image/02/58/02d58b12403f7907975e00549a008c58.jpeg" alt="">
Cassandra解决随机写入数据的解决方案简单来说就叫作“不随机写只顺序写”。对于Cassandra数据库的写操作通常包含两个动作。第一个是往磁盘上写入一条提交日志Commit Log。另一个操作则是直接在内存的数据结构上去更新数据。后面这个往内存的数据结构里面的数据更新只有在提交日志写成功之后才会进行。每台机器上都有一个可靠的硬盘可以让我们去写入提交日志。写入提交日志都是顺序写Sequential Write而不是随机写Random Write这使得我们最大化了写入的吞吐量。
如果你不明白这是为什么,可以回到[第47讲](https://time.geekbang.org/column/article/118191)看看硬盘的性能评测。无论是HDD硬盘还是SSD硬盘顺序写入都比随机写入要快得多。
内存的空间比较有限一旦内存里面的数据量或者条目超过一定的限额Cassandra就会把内存里面的数据结构dump到硬盘上。这个Dump的操作也是顺序写而不是随机写所以性能也不会是一个问题。除了Dump的数据结构文件Cassandra还会根据row key来生成一个索引文件方便后续基于索引来进行快速查询。
随着硬盘上的Dump出来的文件越来越多Cassandra会在后台进行文件的对比合并。在很多别的KV数据库系统里面也有类似这种的合并动作比如AeroSpike或者Google的BigTable。这些操作我们一般称之为Compaction。合并动作同样是顺序读取多个文件在内存里面合并完成再Dump出来一个新的文件。整个操作过程中在硬盘层面仍然是顺序读写。
### Cassandra的读操作
<img src="https://static001.geekbang.org/resource/image/68/b0/68855c2861f07417bbc2eb64672d36b0.jpeg" alt="">
当我们要从Cassandra读数据的时候会从内存里面找数据再从硬盘读数据然后把两部分的数据合并成最终结果。这些硬盘上的文件在内存里面会有对应的Cache只有在Cache里面找不到我们才会去请求硬盘里面的数据。
如果不得不访问硬盘因为硬盘里面可能Dump了很多个不同时间点的内存数据的快照。所以找数据的时候我们也是按照时间从新的往旧的里面找。
这也就带来另外一个问题我们可能要查询很多个Dump文件才能找到我们想要的数据。所以Cassandra在这一点上又做了一个优化。那就是它会为每一个Dump的文件里面所有Row Key生成一个BloomFilter然后把这个BloomFilter放在内存里面。这样如果想要查询的Row Key在数据文件里面不存在那么99%以上的情况下它会被BloomFilter过滤掉而不需要访问硬盘。
这样,只有当数据在内存里面没有,并且在硬盘的某个特定文件上的时候,才会触发一次对于硬盘的读请求。
## SSDDBA们的大救星
Cassandra是Facebook在2008年开源的。那个时候SSD硬盘还没有那么普及。可以看到它的读写设计充分考虑了硬件本身的特性。在写入数据进行持久化上Cassandra没有任何的随机写请求无论是Commit Log还是Dump全部都是顺序写。
在数据读的请求上最新写入的数据都会更新到内存。如果要读取这些数据会优先从内存读到。这相当于是一个使用了LRU的缓存机制。只有在万般无奈的情况下才会有对于硬盘的随机读请求。即使在这样的情况下Cassandra也在文件之前加了一层BloomFilter把本来因为Dump文件带来的需要多次读硬盘的问题简化成多次内存读和一次硬盘读。
这些设计使得Cassandra即使是在HDD硬盘上也能有不错的访问性能。因为所有的写入都是顺序写或者写入到内存所以写入可以做到高并发。HDD硬盘的吞吐率还是很不错的每秒可以写入100MB以上的数据如果一条数据只有1KB那么10万的WPSWrites per seconds也是能够做到的。这足够支撑我们DMP期望的写入压力了。
而对于数据的读就有一些挑战了。如果数据读请求有很强的局部性那我们的内存就能搞定DMP需要的访问量。
但是问题就出在这个局部性上。DMP的数据访问分布其实是缺少局部性的。你仔细想一想DMP的应用场景就明白了。DMP里面的Row Key都是用户的唯一标识符。普通用户的上网时长怎么会有局部性呢每个人上网的时间和访问网页的次数就那么多。上网多的人一天最多也就24小时。大部分用户一天也要上网23小时。我们没办法说把这些用户的数据放在内存里面那些用户不放。
<img src="https://static001.geekbang.org/resource/image/cf/ca/cf55146f8cf79029af6d1f86f3de86ca.jpeg" alt="">
那么我们可不可能有一定的时间局部性呢如果是Facebook那样的全球社交网络那可能还有一定的时间局部性。毕竟不同国家的人的时区不一样。我们可以说在印度人民的白天把印度人民的数据加载到内存里面美国人民的数据就放在硬盘上。到了印度人民的晚上再把美国人民的数据换到内存里面来。
如果你的主要业务是在国内,那这个时间局部性就没有了。大家的上网高峰时段,都是在早上上班路上、中午休息的时候以及晚上下班之后的时间,没有什么区分度。
面临这个情况如果你们的CEO或者CTO问你是不是可以通过优化程序来解决这个问题如果你没有仔细从数据分布和原理的层面思考这个问题而直接一口答应下来那你可能之后要头疼了因为这个问题很有可能是搞不定的。
因为缺少了时间局部性我们内存的缓存能够起到的作用就很小了大部分请求最终还是要落到HDD硬盘的随机读上。但是HDD硬盘的随机读的性能太差了我们在[第45讲](https://time.geekbang.org/column/article/116104)看过也就是100QPS左右。而如果全都放内存那就太贵了成本在HDD硬盘100倍以上。
不过幸运的是从2010年开始SSD硬盘的大规模商用帮助我们解决了这个问题。它的价格在HDD硬盘的10倍但是随机读的访问能力在HDD硬盘的百倍以上。也就是说用上了SSD硬盘我们可以用1/10的成本获得和内存同样的QPS。同样的价格的SSD硬盘容量则是内存的几十倍也能够满足我们的需求用较低的成本存下整个互联网用户信息。
不夸张地说过去十年的“大数据”“高并发”“千人千面”有一半的功劳应该归在让SSD容量不断上升、价格不断下降的硬盘产业上。
回到我们看到的Cassandra的读写设计你会发现Cassandra的写入机制完美匹配了我们在第46和47讲所说的SSD硬盘的优缺点。
在数据写入层面Cassandra的数据写入都是Commit Log的顺序写入也就是不断地在硬盘上往后追加内容而不是去修改现有的文件内容。一旦内存里面的数据超过一定的阈值Cassandra又会完整地Dump一个新文件到文件系统上。这同样是一个追加写入。
数据的对比和紧凑化Compaction同样是读取现有的多个文件然后写一个新的文件出来。写入操作只追加不修改的特性正好天然地符合SSD硬盘只能按块进行擦除写入的操作。在这样的写入模式下Cassandra用到的SSD硬盘不需要频繁地进行后台的Compaction能够最大化SSD硬盘的使用寿命。这也是为什么Cassandra在SSD硬盘普及之后能够获得进一步快速发展。
## 总结延伸
好了关于DMP和存储器的内容讲到这里就差不多了。希望今天的这一讲能够让你从Cassandra的数据库实现的细节层面彻底理解怎么运用好存储器的性能特性和原理。
传统的关系型数据库我们把一条条数据存放在一个地方同时再把索引存放在另外一个地方。这样的存储方式其实很方便我们进行单次的随机读和随机写数据的存储也可以很紧凑。但是问题也在于此大部分的SQL请求都会带来大量的随机读写的请求。这使得传统的关系型数据库其实并不适合用在真的高并发的场景下。
我们的DMP需要的访问场景其实没有复杂的索引需求但是会有比较高的并发性。我带你一看了Facebook开源的Cassandra这个分布式KV数据库的读写设计。通过在追加写入Commit Log和更新内存Cassandra避开了随机写的问题。内存数据的Dump和后台的对比合并同样也都避开了随机写的问题使得Cassandra的并发写入性能极高。
在数据读取层面通过内存缓存和BloomFilterCassandra已经尽可能地减少了需要随机读取硬盘里面数据的情况。不过挑战在于DMP系统的局部性不强使得我们最终的随机读的请求还是要到硬盘上。幸运的是SSD硬盘在数据海量增长的那几年里价格不断下降使得我们最终通过SSD硬盘解决了这个问题。
而SSD硬盘本身的擦除后才能写入的机制正好非常适合Cassandra的数据读写模式最终使得Cassandra在SSD硬盘普及之后得到了更大的发展。
## 推荐阅读
今天的推荐阅读,是一篇相关的论文。我推荐你去读一读[Cassandra - A Decentralized Structured Storage System](https://www.cs.cornell.edu/projects/ladis2009/papers/lakshman-ladis2009.pdf)。读完这篇论文一方面你会对分布式KV数据库的设计原则有所了解了解怎么去做好数据分片、故障转移、数据复制这些机制另一方面你可以看到基于内存和硬盘的不同存储设备的特性Cassandra是怎么有针对性地设计数据读写和持久化的方式的。
## 课后思考
除了MySQL这样的关系型数据库还有Cassandra这样的分布式KV数据库。实际上在海量数据分析的过程中还有一种常见的数据库叫作列式存储的OLAP的数据库比如[Clickhouse](https://clickhouse.yandex/)。你可以研究一下Clickhouse这样的数据库里面的数据是怎么存储在硬盘上的。
欢迎把你研究的结果写在留言区,和大家一起分享、交流。如果觉得有帮助,你也可以把这篇文章分享给你的朋友,和他一起讨论、学习。

View File

@@ -0,0 +1,113 @@
<audio id="audio" title="54 | 理解Disruptor带你体会CPU高速缓存的风驰电掣" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b6/97/b6b22346c2a148eb3396dc52b8952f97.mp3"></audio>
坚持到底就是胜利终于我们一起来到了专栏的最后一个主题。让我一起带你来看一看CPU到底能有多快。在接下来的两讲里我会带你一起来看一个开源项目Disruptor。看看我们怎么利用CPU和高速缓存的硬件特性来设计一个对于性能有极限追求的系统。
不知道你还记不记得,在[第37讲](https://time.geekbang.org/column/article/107477)里为了优化4毫秒专门铺设光纤的故事。实际上最在意极限性能的并不是互联网公司而是高频交易公司。我们今天讲解的Disruptor就是由一家专门做高频交易的公司LMAX开源出来的。
有意思的是Disruptor的开发语言并不是很多人心目中最容易做到性能极限的C/C++而是性能受限于JVM的Java。这到底是怎么一回事呢那通过这一讲你就能体会到其实只要通晓硬件层面的原理即使是像Java这样的高级语言也能够把CPU的性能发挥到极限。
## Padding Cache Line体验高速缓存的威力
我们先来看看Disruptor里面一段神奇的代码。这段代码里Disruptor在RingBufferPad这个类里面定义了p1p2一直到p7 这样7个long类型的变量。
```
abstract class RingBufferPad
{
protected long p1, p2, p3, p4, p5, p6, p7;
}
```
我在看到这段代码的第一反应是变量名取得不规范p1-p7这样的变量名没有明确的意义啊。不过当我深入了解了Disruptor的设计和源代码才发现这些变量名取得恰如其分。因为这些变量就是没有实际意义只是帮助我们进行**缓存行填充**Padding Cache Line使得我们能够尽可能地用上CPU高速缓存CPU Cache。那么缓存行填充这个黑科技到底是什么样的呢我们接着往下看。
不知道你还记不记得,我们在[35讲](https://time.geekbang.org/column/article/107422)里面的这个表格。如果访问内置在CPU里的L1 Cache或者L2 Cache访问延时是内存的1/15乃至1/100。而内存的访问速度其实是远远慢于CPU的。想要追求极限性能需要我们尽可能地多从CPU Cache里面拿数据而不是从内存里面拿数据。
<img src="https://static001.geekbang.org/resource/image/d3/a6/d39b0f2b3962d646133d450541fb75a6.png" alt="">
CPU Cache装载内存里面的数据不是一个一个字段加载的而是加载一整个缓存行。举个例子如果我们定义了一个长度为64的long类型的数组。那么数据从内存加载到CPU Cache里面的时候不是一个一个数组元素加载的而是一次性加载固定长度的一个缓存行。
我们现在的64位Intel CPU的计算机缓存行通常是64个字节Bytes。一个long类型的数据需要8个字节所以我们一下子会加载8个long类型的数据。也就是说一次加载数组里面连续的8个数值。这样的加载方式使得我们遍历数组元素的时候会很快。因为后面连续7次的数据访问都会命中缓存不需要重新从内存里面去读取数据。这个性能层面的好处我在第37讲的第一个例子里面为你演示过印象不深的话可以返回去看看。
但是在我们不使用数组而是使用单独的变量的时候这里就会出现问题了。在Disruptor的RingBuffer环形缓冲区的代码里面定义了一个RingBufferFields类里面有indexMask和其他几个变量用来存放RingBuffer的内部状态信息。
<img src="https://static001.geekbang.org/resource/image/23/f6/23adbbc656243ce85fdb8c7fab42ecf6.jpeg" alt="">
CPU在加载数据的时候自然也会把这个数据从内存加载到高速缓存里面来。不过这个时候高速缓存里面除了这个数据还会加载这个数据前后定义的其他变量。这个时候问题就来了。Disruptor是一个多线程的服务器框架在这个数据前后定义的其他变量可能会被多个不同的线程去更新数据、读取数据。这些写入以及读取的请求会来自于不同的 CPU Core。于是为了保证数据的同步更新我们不得不把CPU Cache里面的数据重新写回到内存里面去或者重新从内存里面加载数据。
而我们刚刚说过这些CPU Cache的写回和加载都不是以一个变量作为单位的。这些动作都是以整个Cache Line作为单位的。所以当INITIAL_CURSOR_VALUE 前后的那些变量被写回到内存的时候,这个字段自己也写回到了内存,这个常量的缓存也就失效了。当我们要再次读取这个值的时候,要再重新从内存读取。这也就意味着,读取速度大大变慢了。
```
......
abstract class RingBufferPad
{
protected long p1, p2, p3, p4, p5, p6, p7;
}
abstract class RingBufferFields&lt;E&gt; extends RingBufferPad
{
......
private final long indexMask;
private final Object[] entries;
protected final int bufferSize;
protected final Sequencer sequencer;
......
}
public final class RingBuffer&lt;E&gt; extends RingBufferFields&lt;E&gt; implements Cursored, EventSequencer&lt;E&gt;, EventSink&lt;E&gt;
{
......
protected long p1, p2, p3, p4, p5, p6, p7;
......
}
```
<img src="https://static001.geekbang.org/resource/image/93/b1/9330b8fb1e8de3f62d34c6f85f268db1.jpeg" alt="">
面临这样一个情况Disruptor里发明了一个神奇的代码技巧这个技巧就是缓存行填充。Disruptor 在 RingBufferFields里面定义的变量的前后分别定义了7个long类型的变量。前面的7个来自继承的 RingBufferPad 类后面的7个则是直接定义在 RingBuffer 类里面。这14个变量没有任何实际的用途。我们既不会去读他们也不会去写他们。
而RingBufferFields里面定义的这些变量都是final的第一次写入之后不会再进行修改。所以一旦它被加载到CPU Cache之后只要被频繁地读取访问就不会再被换出Cache了。这也就意味着对于这个值的读取速度会是一直是CPU Cache的访问速度而不是内存的访问速度。
## 使用RingBuffer利用缓存和分支预测
其实这个利用CPU Cache的性能的思路贯穿了整个Disruptor。Disruptor整个框架其实就是一个高速的[生产者-消费者模型](https://en.wikipedia.org/wiki/Producer%E2%80%93consumer_problem)Producer-Consumer下的队列。生产者不停地往队列里面生产新的需要处理的任务而消费者不停地从队列里面处理掉这些任务。
<img src="https://static001.geekbang.org/resource/image/65/56/659082942118e7c69eb3807b00f5f556.jpeg" alt="">
如果你熟悉算法和数据结构,那你应该非常清楚,如果要实现一个队列,最合适的数据结构应该是链表。我们只要维护好链表的头和尾,就能很容易实现一个队列。生产者只要不断地往链表的尾部不断插入新的节点,而消费者只需要不断从头部取出最老的节点进行处理就好了。我们可以很容易实现生产者-消费者模型。实际上Java自己的基础库里面就有LinkedBlockingQueue这样的队列库可以直接用在生产者-消费者模式上。
<img src="https://static001.geekbang.org/resource/image/45/0e/45d4c7c8b0cb1f056684199e39660f0e.jpeg" alt="">
不过Disruptor里面并没有用LinkedBlockingQueue而是使用了一个RingBuffer这样的数据结构这个RingBuffer的底层实现则是一个固定长度的数组。比起链表形式的实现数组的数据在内存里面会存在空间局部性。
就像上面我们看到的数组的连续多个元素会一并加载到CPU Cache里面来所以访问遍历的速度会更快。而链表里面各个节点的数据多半不会出现在相邻的内存空间自然也就享受不到整个Cache Line加载后数据连续从高速缓存里面被访问到的优势。
除此之外数据的遍历访问还有一个很大的优势就是CPU层面的分支预测会很准确。这可以使得我们更有效地利用了CPU里面的多级流水线我们的程序就会跑得更快。这一部分的原理如果你已经不太记得了可以回过头去复习一下[第25讲](https://time.geekbang.org/column/article/102166)关于分支预测的内容。
## 总结延伸
好了不知道讲完这些你有没有体会到Disruptor这个框架的神奇之处呢
CPU从内存加载数据到CPU Cache里面的时候不是一个变量一个变量加载的而是加载固定长度的Cache Line。如果是加载数组里面的数据那么CPU就会加载到数组里面连续的多个数据。所以数组的遍历很容易享受到CPU Cache那风驰电掣的速度带来的红利。
对于类里面定义的单独的变量就不容易享受到CPU Cache红利了。因为这些字段虽然在内存层面会分配到一起但是实际应用的时候往往没有什么关联。于是就会出现多个CPU Core访问的情况下数据频繁在CPU Cache和内存里面来来回回的情况。而Disruptor很取巧地在需要频繁高速访问的变量也就是RingBufferFields里面的indexMask这些字段前后各定义了7个没有任何作用和读写请求的long类型的变量。
这样无论在内存的什么位置上这些变量所在的Cache Line都不会有任何写更新的请求。我们就可以始终在Cache Line里面读到它的值而不需要从内存里面去读取数据也就大大加速了Disruptor的性能。
这样的思路其实渗透在Disruptor这个开源框架的方方面面。作为一个生产者-消费者模型Disruptor并没有选择使用链表来实现一个队列而是使用了RingBuffer。RingBuffer底层的数据结构则是一个固定长度的数组。这个数组不仅让我们更容易用好CPU Cache对CPU执行过程中的分支预测也非常有利。更准确的分支预测可以使得我们更好地利用好CPU的流水线让代码跑得更快。
## 推荐阅读
今天讲的是Disruptor推荐的阅读内容自然是Disruptor的官方文档。作为一个开源项目Disruptor在自己[GitHub](https://github.com/LMAX-Exchange/disruptor/wiki/Introduction)上有很详细的设计文档,推荐你好好阅读一下。
这里面不仅包含了怎么用好Disruptor也包含了整个Disruptor框架的设计思路是一份很好的阅读学习材料。另外Disruptor的官方文档里还有很多文章、演讲详细介绍了这个框架很值得深入去看一看。Disruptor的源代码其实并不复杂很适合用来学习怎么阅读开源框架代码。
## 课后思考
今天我们讲解了缓存行填充你可以试试修改Disruptor的代码看看在没有缓存行填充和有缓存行填充的情况下的性能差异。你也可以尝试直接修改Disruptor的源码和[性能测试代码](https://github.com/LMAX-Exchange/disruptor/blob/master/src/perftest/java/com/lmax/disruptor/immutable/CustomPerformanceTest.java),看看运行的结果是什么样的。
欢迎你把你的测试结果写在留言区,和大家一起讨论、分享。如果有收获,你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,208 @@
<audio id="audio" title="55 | 理解Disruptor不需要换挡和踩刹车的CPU有多快" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/4c/5953ad076c9473cd3bfa3dd03be9064c.mp3"></audio>
上一讲我们学习了一个精妙的想法Disruptor通过缓存行填充来利用好CPU的高速缓存。不知道你做完课后思考题之后有没有体会到高速缓存在实践中带来的速度提升呢
不过利用CPU高速缓存只是Disruptor“快”的一个因素那今天我们就来看一看Disruptor快的另一个因素也就是“无锁”而尽可能发挥CPU本身的高速处理性能。
## 缓慢的锁
Disruptor作为一个高性能的生产者-消费者队列系统一个核心的设计就是通过RingBuffer实现一个无锁队列。
上一讲里我们讲过Java里面的基础库里就有像LinkedBlockingQueue这样的队列库。但是这个队列库比起Disruptor里用的RingBuffer要慢上很多。慢的第一个原因我们说过因为链表的数据在内存里面的布局对于高速缓存并不友好而RingBuffer所使用的数组则不然。
<img src="https://static001.geekbang.org/resource/image/9c/69/9ce732cb22c49a8a26e870dddde66b69.jpeg" alt="">
LinkedBlockingQueue慢有另外一个重要的因素那就是它对于锁的依赖。在生产者-消费者模式里,我们可能有多个消费者,同样也可能有多个生产者。多个生产者都要往队列的尾指针里面添加新的任务,就会产生多个线程的竞争。于是,在做这个事情的时候,生产者就需要拿到对于队列尾部的锁。同样地,在多个消费者去消费队列头的时候,也就产生竞争。同样消费者也要拿到锁。
那只有一个生产者,或者一个消费者,我们是不是就没有这个锁竞争的问题了呢?很遗憾,答案还是否定的。一般来说,在生产者-消费者模式下,消费者要比生产者快。不然的话,队列会产生积压,队列里面的任务会越堆越多。
一方面,你会发现越来越多的任务没有能够及时完成;另一方面,我们的内存也会放不下。虽然生产者-消费者模型下,我们都有一个队列来作为缓冲区,但是大部分情况下,这个缓冲区里面是空的。也就是说,即使只有一个生产者和一个消费者者,这个生产者指向的队列尾和消费者指向的队列头是同一个节点。于是,这两个生产者和消费者之间一样会产生锁竞争。
在LinkedBlockingQueue上这个锁机制是通过ReentrantLock这个Java 基础库来实现的。这个锁是一个用Java在JVM上直接实现的加锁机制这个锁机制需要由JVM来进行裁决。这个锁的争夺会把没有拿到锁的线程挂起等待也就需要经过一次上下文切换Context Switch
不知道你还记不记得,我们在[第28讲](https://time.geekbang.org/column/article/103717)讲过的异常和中断,这里的上下文切换要做的和异常和中断里的是一样的。上下文切换的过程,需要把当前执行线程的寄存器等等的信息,保存到线程栈里面。而这个过程也必然意味着,已经加载到高速缓存里面的指令或者数据,又回到了主内存里面,会进一步拖慢我们的性能。
我们可以按照Disruptor介绍资料里提到的Benchmark写一段代码来看看是不是真是这样的。这里我放了一段Java代码代码的逻辑很简单就是把一个long类型的counter从0自增到5亿。一种方式是没有任何锁另外一个方式是每次自增的时候都要去取一个锁。
你可以在自己的电脑上试试跑一下这个程序。在我这里两个方式执行所需要的时间分别是207毫秒和9603毫秒性能差出了将近50倍。
```
package com.xuwenhao.perf.jmm;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockBenchmark{
public static void runIncrement()
{
long counter = 0;
long max = 500000000L;
long start = System.currentTimeMillis();
while (counter &lt; max) {
counter++;
}
long end = System.currentTimeMillis();
System.out.println(&quot;Time spent is &quot; + (end-start) + &quot;ms without lock&quot;);
}
public static void runIncrementWithLock()
{
Lock lock = new ReentrantLock();
long counter = 0;
long max = 500000000L;
long start = System.currentTimeMillis();
while (counter &lt; max) {
if (lock.tryLock()){
counter++;
lock.unlock();
}
}
long end = System.currentTimeMillis();
System.out.println(&quot;Time spent is &quot; + (end-start) + &quot;ms with lock&quot;);
}
public static void main(String[] args) {
runIncrement();
runIncrementWithLock();
```
```
Time spent is 207ms without lock
Time spent is 9603ms with lock
```
## 无锁的RingBuffer
加锁很慢所以Disruptor的解决方案就是“无锁”。这个“无锁”指的是没有操作系统层面的锁。实际上Disruptor还是利用了一个CPU硬件支持的指令称之为CASCompare And Swap比较和交换。在Intel CPU里面这个对应的指令就是 cmpxchg。那么下面我们就一起从Disruptor的源码到具体的硬件指令来看看这是怎么一回事儿。
Disruptor的RingBuffer是这么设计的它和直接在链表的头和尾加锁不同。Disruptor的RingBuffer创建了一个Sequence对象用来指向当前的RingBuffer的头和尾。这个头和尾的标识呢不是通过一个指针来实现的而是通过一个**序号**。这也是为什么对应源码里面的类名叫Sequence。
<img src="https://static001.geekbang.org/resource/image/b6/ec/b64487a7b6b45393fdfa7e2d63e176ec.jpeg" alt="">
在这个RingBuffer当中进行生产者和消费者之间的资源协调采用的是对比序号的方式。当生产者想要往队列里加入新数据的时候它会把当前的生产者的Sequence的序号加上需要加入的新数据的数量然后和实际的消费者所在的位置进行对比看看队列里是不是有足够的空间加入这些数据而不会覆盖掉消费者还没有处理完的数据。
在Sequence的代码里面就是通过compareAndSet这个方法并且最终调用到了UNSAFE.compareAndSwapLong也就是直接使用了CAS指令。
```
public boolean compareAndSet(final long expectedValue, final long newValue)
{
return UNSAFE.compareAndSwapLong(this, VALUE_OFFSET, expectedValue, newValue);
}
public long addAndGet(final long increment)
{
long currentValue;
long newValue;
do
{
currentValue = get();
newValue = currentValue + increment;
}
while (!compareAndSet(currentValue, newValue));
return newValue;
```
这个CAS指令也就是比较和交换的操作并不是基础库里的一个函数。它也不是操作系统里面实现的一个系统调用而是**一个CPU硬件支持的机器指令**。在我们服务器所使用的Intel CPU上就是cmpxchg这个指令。
```
compxchg [ax] (隐式参数EAX累加器), [bx] (源操作数地址), [cx] (目标操作数地址)
```
cmpxchg指令一共有三个操作数第一个操作数不在指令里面出现是一个隐式的操作数也就是EAX累加寄存器里面的值。第二个操作数就是源操作数并且指令会对比这个操作数和上面的累加寄存器里面的值。
如果值是相同的那一方面CPU会把ZF也就是条件码寄存器里面零标志位的值设置为1然后再把第三个操作数也就是目标操作数设置到源操作数的地址上。如果不相等的话就会把源操作数里面的值设置到累加器寄存器里面。
我在这里放了这个逻辑对应的伪代码,你可以看一下。如果你对汇编指令、条件码寄存器这些知识点有点儿模糊了,可以回头去看看[第5](https://time.geekbang.org/column/article/93359)[](https://time.geekbang.org/column/article/93359)、[第6讲](https://time.geekbang.org/column/article/94075)关于汇编指令的部分。
```
IF [ax]&lt; == [bx] THEN [ZF] = 1, [bx] = [cx]
ELSE [ZF] = 0, [ax] = [bx]
```
单个指令是原子的这也就意味着在使用CAS操作的时候我们不再需要单独进行加锁直接调用就可以了。
没有了锁CPU这部高速跑车就像在赛道上行驶不会遇到需要上下文切换这样的红灯而停下来。虽然会遇到像CAS这样复杂的机器指令就好像赛道上会有U型弯一样不过不用完全停下来等待我们CPU运行起来仍然会快很多。
那么CAS操作到底会有多快呢我们还是用一段Java代码来看一下。
```
package com.xuwenhao.perf.jmm;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockBenchmark {
public static void runIncrementAtomic()
{
AtomicLong counter = new AtomicLong(0);
long max = 500000000L;
long start = System.currentTimeMillis();
while (counter.incrementAndGet() &lt; max) {
}
long end = System.currentTimeMillis();
System.out.println(&quot;Time spent is &quot; + (end-start) + &quot;ms with cas&quot;);
}
public static void main(String[] args) {
runIncrementAtomic();
}
```
```
Time spent is 3867ms with cas
```
和上面的counter自增一样只不过这一次自增我们采用了AtomicLong这个Java类。里面的incrementAndGet最终到了CPU指令层面在实现的时候用的就是CAS操作。可以看到它所花费的时间虽然要比没有任何锁的操作慢上一个数量级但是比起使用ReentrantLock这样的操作系统锁的机制还是减少了一半以上的时间。
## 总结延伸
好了咱们专栏的正文内容到今天就要结束了。今天最后一讲我带着你一起看了Disruptor代码的一个核心设计也就是它的RingBuffer是怎么做到无锁的。
Java基础库里面的BlockingQueue都需要通过显示地加锁来保障生产者之间、消费者之间乃至生产者和消费者之间不会发生锁冲突的问题。
但是加锁会大大拖慢我们的性能。在获取锁过程中CPU没有去执行计算的相关指令而要等待操作系统或者JVM来进行锁竞争的裁决。而那些没有拿到锁而被挂起等待的线程则需要进行上下文切换。这个上下文切换会把挂起线程的寄存器里的数据放到线程的程序栈里面去。这也意味着加载到高速缓存里面的数据也失效了程序就变得更慢了。
Disruptor里的RingBuffer采用了一个无锁的解决方案通过CAS这样的操作去进行序号的自增和对比使得CPU不需要获取操作系统的锁。而是能够继续顺序地执行CPU指令。没有上下文切换、没有操作系统锁自然程序就跑得快了。不过因为采用了CAS这样的忙等待Busy-Wait的方式会使得我们的CPU始终满负荷运转消耗更多的电算是一个小小的缺点。
程序里面的CAS调用映射到我们的CPU硬件层面就是一个机器指令这个指令就是cmpxchg。可以看到当想要追求最极致的性能的时候我们会从应用层、贯穿到操作系统乃至最后的CPU硬件搞清楚从高级语言到系统调用乃至最后的汇编指令这整个过程是怎么执行代码的。而这个也是学习组成原理这门专栏的意义所在。
## 推荐阅读
不知道上一讲说的Disruptor相关材料你有没有读完呢如果没有读完的话我建议你还是先去研读一下。
如果你已经读完了,这里再给你推荐一些额外的阅读材料,那就是著名的[Implement Lock-Free Queues](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.53.8674&amp;rep=rep1&amp;type=pdf)这篇论文。你可以更深入地学习一下,怎么实现一个无锁队列。
## 课后思考
最后,给你留一道思考题。这道题目有点儿难,不过也很有意思。
请你阅读一下Disruptor开源库里面的Sequence这个类的代码看看它和一个普通的AtomicLong到底有什么区别以及为什么它要这样实现。
欢迎在留言区写下你的思考和答案,和大家一起探讨应用层和硬件层之间的关联性。如果有收获,你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,55 @@
<audio id="audio" title="结束语 | 知也无涯,愿你也享受发现的乐趣" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/01/75ddc98274e0698bc47e39dc8eaf4b01.mp3"></audio>
你好,我是徐文浩。伴随着无数个不眠之夜,“深入浅出计算机组成原理”专栏终于来到了结束语。
去年11月份极客时间找到我我开始构思这个专栏。本以为今年4、5月份就能把专栏写完。结果一方面因为创业过程中时间总是不够用另一方面写出有价值内容的并不是一件容易的事情直到9月10号的凌晨我才写完这最后一篇结束语。原本计划的45讲也在这个过程中变成了近60讲。现在回过去看写这个“深入浅出计算机组成原理”专栏是一个远比想象中要困难的挑战但同时也是一个有趣的发现之旅。
## 完成比完美更好
Facebook的文化里面喜欢用各种小标语其中有一条我很喜欢“Done is better than perfect”。翻译成中文就是“完成比完美更好”。写这个专栏的时候我对这一点的体会特别深刻。在学习更多深入知识的时候我希望你也可以抱有这样的态度。
在初期构思专栏的时候,我期望写成一个完美的专栏。不过随着时间的推移,我发现其实并没有什么完美可言。
一方面,组成原理的知识点很多,如果每一个都写下来,没有个一两百讲怕是讲不完。更何况有那么多大师的教科书珠玉在前,只是做解读知识点、覆盖已有的知识点,我觉得价值不大。思来想去,我希望尽可能找到最重要、最核心的知识点,以及能和大多数工程师日常工作有结合的知识点,希望能够从应用中多给你一些启发。
另一方面写专栏和我们写程序一样都是有deadline的。无论是在系统发版之后的午夜里还是去美国出差的飞机上乃至偶尔忘带了录音笔的时候总是要打起精神想尽方法写出一篇让自己满意的文章来。同时也有不少同学给我挑出了错漏或者不准确的部分一起把这个专栏打磨地更“完美”。
不知道正在读结束语的你有没有在过去5个月里坚持学习这个专栏呢有没有认真阅读我每一节后的推荐阅读呢有没有尝试去做一做每一讲后面的思考题呢
如果你能够坚持下来,那首先要恭喜你,我相信能够学完的同学并不太多。如果你还没有学完,也不要紧,先跟着整个课程走一遍,有个大致印象。与其半途而费,不如先囫囵吞枣,硬着头皮看完再说。**新的知识第一遍没有百分百看懂,而随着时间的推移,慢慢领悟成长了,这才是人生的常态。而我所见到的优秀的工程师大都会经历这样的成长过程。**
我们这个行业,经常喜欢把软件开发和建筑放在一起类比,所以才会有经典的《设计模式》这样的书。甚至有不少人干脆从《建筑的永恒之道》里面去寻找灵感。然而,建筑能够在历史上留下长久的刻印,但是软件却完全不同。无论多么完美的代码都会不断迭代,就好像新陈代谢一样。几年过去之后,最初那些代码的踪影早已经没有了。软件工程师放弃了追求永恒,而是投身在创作的快乐之中。
希望在日后的学习过程中,你也能抱着“日拱一卒、不期速成”的心态坚持下去,不断地学习、反思、练习、再学习,这样的迭代才是最快的成长之路。
## 知也无涯,愿你享受发现的乐趣
说实话,从构思到写作这个专栏,这整个过程对我来说,还是有些忐忑的。组成原理是一门离大部分工程师的日常工作比较远的话题,却又是一个很多经典教材会讲的主题。“到底从什么角度去切入讲解”,我在构思文章的时候常常问自己。
组成原理其实是一门类似于“计算机科学101”的课程固然我可以在里面讲VHDL这样的硬件编程语言不过说实话这样的知识对于大部分的人意义并不大。我期望能够通过这个专栏让你体会到计算机科学知识是真的有用的能够让你把学专栏的过程变成一个发现之旅。
比如在学习HDD硬盘原理的时候你能知道为什么用它来记录日志很好但是拿来作为KV数据库就很糟糕在学习CPU Cache的时候你实际用代码体会一下它有多快为什么Disruptor里面的缓存行填充这样的小技巧能够把性能发挥到极致。
除此之外,撰写整个专栏的过程,也是我对自己的一个发现之旅。
虽然在过去开发大型系统的时候,已经体会到掌握各种计算机科学基础知识的重要性,但是,这个专栏还是给了我一个系统性地、对基础知识回顾和整理的机会,在忙碌的日常工作之外,在离开学校那么多年后,重新把基础的理论知识和实际的系统开发做了一一印证。
在这个过程中,对我自己是一个温故而知新的过程,我自己新学到不少过去不了解的知识点,也因此重新找到了很多新的技术兴奋点。乃至在专栏写了一半的时候,我特地在出差的空隙跑了一趟计算机历史博物馆,去感受创造新事物的那种激动人心的感觉。
不过,在这整个过程中,我也深深体会到了内容创作的难。
过去这10个月里持续地写稿、画图、写实验程序在编辑的反馈下再改稿和录音对我也是一个全新的体验。没有思路、时间不够、工作和写稿压力太大的时候抓狂、发脾气、骂人都发生过。如果没有编辑在背后一直督促着只靠自律我想我无论如何也不可能写完这样一个规模的专栏。
但是,我相信只有不断地逼迫自己走出习惯的舒适区,去尝试、体验新的挑战,才会进一步的成长。而很多未来的机会,也孕育在其间。就像史蒂夫·乔布斯说的,我们未来生活的可能性就是靠这些点点滴滴串联起来的。
也许你今天只是在学校写简单的课程管理系统可能会觉得有些无聊。抽一些时间出来去了解计算机科学的底层知识可能会让你找到求知的乐趣无形中这也为你去解决更有挑战的问题做好了铺垫。就像我自己在过去研究底层的数据系统、写技术博客的时候也没有想到会有机会写上这样一个20万字以上的专栏。
就像罗素说的那样,“对爱的渴望,对知识的追求,对人类苦难不可遏制的同情,是支配我一生的单纯而强烈的三种情感”。
我希望,在学习成长的过程中,你能够摆脱一些功利性,不用去回避遇到的痛苦和挫败感,多从这个过程中找到获得知识的快乐。
希望这个专栏能够给你带来发现的乐趣,也能够为你在未来的生活里铺垫上那小小的一步。相信这个专栏不是你学习的终点,也也不是我探索和发现新主题的终点。说不定,在不久的未来我们还会有缘再见。
对了我在文章末尾放了一个毕业调查问卷。在这5个月的学习过程中如果你对这个专栏或者我本人有什么建议可以通过这个问卷给我反馈我一定会认真查看每一封的内容。期待你的反馈
[<img src="https://static001.geekbang.org/resource/image/9a/9b/9a6d167383d98db7d72770ba07f8009b.jpg" alt="">](https://jinshuju.net/f/Nurf3L)

View File

@@ -0,0 +1,93 @@
<audio id="audio" title="FAQ第一期 | 学与不学,知识就在那里,不如就先学好了" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2a/ac/2a45be1da8f9df3257bd95e7b25418ac.mp3"></audio>
你好,我是徐文浩。专栏上线三个多月,我们已经进入后半段。
首先,恭喜跟到这里的同学,很快你就可以看到胜利的曙光了。如果你已经掉队了,不要紧,现在继续依然来得及。
其次,非常感谢同学们的积极留言,看到这么多人因为我的文章受到启发、产生思考,我也感到非常开心。因此,我特意把留言区中非常棒的、值得反复阅读和思考的内容,摘录出来,供你反复阅读学习。
有些内容你可能已经非常熟悉了,但是随着工作、学习经验的不同,相信你的理解也会不一样;有些内容可能刚好也是你的疑问,但是你还没发现,这里说不定就帮你解决了。
今天第一期,我们先来聊聊,“学习”这件事。我准备了五个问题,话不多说,一起来看看吧!
## Q1“要不要学”和“学不会怎么办”系列
<img src="https://static001.geekbang.org/resource/image/eb/86/eba537e71418f1af3ddedcd7940f4386.jpeg" alt="">
专栏已经更新三个多月了但是我估计很多人还是停留在前面3篇的学习上我相信一定有的
我观察了一下,其实很多人并不是真的学不会,而是“不敢学”,往往还没开始就被自己给吓到了。很多优秀的人,并非真的智商有多么高,而是他们敢于尝试,敢于突破自己的舒适区。
所以说,学习底层知识或者新知识的第一点,就是要“克服恐惧”,其实大部分东西上手了都不难,都很有意思。就像《冰与火之歌》里面,水舞者教导艾莉亚时的情况一样,“恐惧比利剑”更伤人。破除对于基础知识“难”的迷信,是迈向更高水平必经的一步。
“组成原理可以算是理解计算机运作机制的第一门入门课,这门课的交付目标就是让科班的同学们能够温故而知新,为非科班的同学们打开深入学习计算机核心课程的大门。”这是我在专栏刚上线的时候给一个同学的留言回复,现在拿过来再给你说一遍。
另外,大家在学校里学这些课程的时候,都会遇到一个问题,那就是理论和我们的编程应用实践离得比较远。在这个专栏里,我的目标是让大家能够更“实践”地去学习计算机组成原理。
所以,这门课我的目标就是尽量讲得“理论和实践相结合”,能和你的日常代码工作结合起来。让非科班的同学们也能学习到计算机组成原理的知识,所以在深入讲解知识点之外,我会尽量和你在开发过程中可能遇到的问题放到一块儿,只要跟着课程的节奏走,不会跟不上哦。**(跟到这里的同学可以在留言区冒个泡,给跟不上的同学招个手,让他们放心大胆看过来。)**
我自己在大学的时候也不是个“好学生”。现在回头看,我自己常常觉得大学的时候没有好好读书,浪费了很多时间。常常想,当时要是做了就好了。当时,不是就是现在么?学或者不学,知识就在那里,不如就先学好了啊。
## Q2“计算机组成原理”和“操作系统”到底有啥不一样
<img src="https://static001.geekbang.org/resource/image/d4/6b/d4a64cf654218cf992c6cf621356ee6b.png" alt="">
其实操作系统也是一个“软件”,而开发操作系统,只需要关注到“组成原理”或者“体系结构”就好了,不需要真的了解硬件。操作系统,其实是在“组成原理”所讲的“指令集”上做一层封装。
体系结构、操作系统、编译原理以及计算机网络,都可以认为是组成原理的后继课程。体系结构不是一个系统软件,它更多地是讲,如何量化地设计和研究体系结构和指令集。操作系统、编译原理和计算机网络都是基于体系结构之上的系统软件。
其实这几门基础学科,都是环环相扣,相互渗透的,每一门课都不可能独立存在。不知道你现在是否明白这几门基础学科的价值呢?
## Q3“图灵机”和“冯·诺依曼机”的区别
<img src="https://static001.geekbang.org/resource/image/ce/dd/ce241278076f71c81a78812de51114dd.jpg" alt="">
首先,先回答一下这道题本身。有些同学已经回答的不错。我把他们的答案贴在这里。你可以看看跟你想的是不是一样。
**Amanda 同学:**
>
两者有交叉但是不同,根据了解整理如下:
>
<p>图灵机是一种思想模型(计算机的基本理论基础),是一种有穷的、构造性的问题的求解思路,图灵认为凡是能用算法解决的问题也一定能用图灵机解决;<br>
冯·诺依曼提出了“存储程序”的计算机设计思想,并“参照”图灵模型设计了历史上第一台电子计算机,即冯·诺依曼机。</p>
图灵机其实是一个很有意思的话题。我上大学的时候,对应着图灵机也有一门课程,叫作“可计算性理论”,其实就是告诉我们什么样的问题是计算机解决得了的,什么样的问题是它解决不了的。
在我看来,图灵机就是一个抽象的“思维实验”,而冯·诺依曼机就是对应着这个“思维实验”的“物理实现”。如果我们把“图灵机”当成“灵魂”,代表计算机最抽象的本质,那么“冯诺伊曼机”就是“肉体”,代表了计算机最具体的本质。这两者之间颇有理论物理学家和实验物理学家的合作关系的意思,可谓是一个问题的两面。
冯·诺依曼体系结构距今已经几十年了目前我们还没有看到真正颠覆性的新的体系结构出现更多地是针对硬件变化和应用场景变化的优化。但是过去几年随着深度学习、IoT等的发展体系结构又有了一波新的大发展也许未来会有新的变化呢我们可以拭目以待。
## Q4工作多年如何保持对知识清晰、准确的认识
<img src="https://static001.geekbang.org/resource/image/fa/05/fafe82142456949c5e1e670efe102705.png" alt="">
我之前跟很多人聊过,发现工作很多年之后的工程师,在计算机科学的基础知识上,反而比不上很多应届的同学。我总结下来,大概有这么几个因素。
首先,很多工程师只是满足于工作的需求被满足了,没有真的深入去搞清楚一个问题的原理。从网络上搜索一段代码,复制粘贴到自己的程序里,只要能跑就认为问题解决了,并没有深入一行行看明白每行代码到底是做了什么,为什么要这么做。
比如说我们现在要提升RPC和序列化的性能很多人的做法是找一个教程用一下Thrift这样的开源框架解决眼下的问题就完事儿。至于Thrift是怎么序列化的每一种里面支持的RPC协议是怎么回事儿完全不清楚。其实这些开源代码并不复杂稍微花点时间搞清楚里面的实现细节和原理你对二进制存储、程序性能、网络性能就会有一个更深刻的认识之后遇到类似的问题你就不会再一问三不知久而久之你的能力就会得到提升。
其次,读书的时候我们认为一个东西掌握扎实了,有时候其实未必。很多人估计都有感受,像计算机这类实践性比较强的专业,书上所学和真正实践中所用完全是两码事。背出计算机的五大组成部分,似乎和我们的实际应用没有联系,但是在实际的系统开发过程中,无论是内存地址转换使用的页表树这样的数据结构,还是各个系统组件间通过总线进行通信的模式,其实都可以和我们自己的应用系统开发里的模式和思路联系起来。
至于究竟该怎么去掌握知识,其实没有什么特别好的方法。我就说说我一般会怎么做,一方面,遇到疑难问题、复杂的系统时,必须要用更底层更本质的理解计算机运作的方式,去处理问题,自然会去回头把这些基础知识捡起来;另一方面,时不时抽点时间回头看看一些“大部头”的教科书,对我自己而言,本身就很有自我满足感,而这种自我满足感也会促使我不断去读它们,从而形成一个良性循环。
## Q5六个最实用的、督促自己学习的办法
看到很多同学在留言里分享了自己学习方法,我看了也非常受益,我把这些方法筛选总结了一下,又结合我自己的学习经验,放在这里分享给你。
1. 好奇心是一个优秀程序员必然要有的特质。多去想想“为什么是这样的”,有助于你更深入地掌握这些知识点。
1. 先了解知识面,再寻找自己有兴趣的点深入,学习也是个反复迭代的过程。
1. 带着问题去学习是最快的成长方式之一。彻底搞清楚实际在开发过程中遇到的困难的问题,而不是只满足于功能问题被实现和解决,是提升自己的必经之路。
1. “教别人”是一种非常高效的学习方式,自己有没有弄清楚,在教别人的过程中,会体会得明明白白。
1. 每个月给自己投资100-200块在专业学习上面这样花了钱通过外部约束也是一个让自己坚持下去的好办法。
1. 坚持到底就是胜利✌️。把学习和成长变成一种习惯,这个习惯带来的惯性会让你更快地成长。
好了,到这里,我们第一期答疑就要结束了。这次我主要和你谈了谈“学习”这个话题,不知道你有什么感受呢?你还想听我和你聊什么专栏之外的话题呢?
欢迎积极留言给我。如果觉得这篇文章对你有帮助,也欢迎你收藏并分享给你的朋友。对了,看到这里的同学,记得在留言区给后面的同学招个手啊:)

View File

@@ -0,0 +1,112 @@
<audio id="audio" title="FAQ第二期 | 世界上第一个编程语言是怎么来的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/32/ed/323442a890f99b2a81a9e962eeeb57ed.mp3"></audio>
你好我是徐文浩今天是第二期FAQ我搜集了第3讲到第6讲大家在留言区问的比较多的问题来做一次集中解答。
有些问题,可能你已经知道了答案,不妨看看和我的理解是否一样;如果这些问题刚好你也有,那可要认真看啦!
希望今天的你,也同样有收获!
## Q1为什么user + sys运行出来会比real time多呢
<img src="https://static001.geekbang.org/resource/image/36/4c/3665db1602c971c2cad1932ee8d0804c.png" alt="">
我们知道实际的计算机运行的过程中CPU会在多个不同的进程里面切换分配不同的时间片去执行任务。所以运行一个程序在现实中走过的时间并不是实际CPU运行这个程序所花费的时间。前者在现实中走过的时间我们叫作real time。有时候叫作wall clock time也就是墙上挂着的钟走过的时间。
而实际CPU上所花费的时间又可以分成在操作系统的系统调用里面花的sys time和用户态的程序所花的user time。如果我们只有一个CPU的话那real time &gt;= sys time + user time 。所以,我当时在文章里给大家看了对应的示例。
不过有不少同学运行出来的结果不是这样的。这是因为现在大家都已经用上多核的CPU了。也就是同一时间有两个CPU可以同时运行任务。
你在一台多核或者多CPU的机器上运行seq和wc命令会分配到两个CPU上。虽然seq和wc这两个命令都是单线程运行的但是这两个命令在多核CPU运行的情况下会分别分配到两个不同的CPU。
于是user和sys的时间是两个CPU上运行的时间之和这就可能超过real的时间。而real只是现实时钟里走过的时间极端情况下user+sys可以到达real的两倍。
你可以运行下面这个命令,快速验证。让这个命令多跑一会儿,并且在后台运行。
```
time seq 100000000 | wc -l &amp;
```
然后我们利用top命令查看不同进程的CPU占用情况。你会在top的前几行里看到seq和wc的CPU占用都接近100实际上它们各被分配到了一个不同的CPU执行。
我写这篇文章的时候测试时只开了一个1u的最小的虚拟机只有一个CPU所以不会遇到这个问题。
## Q2时钟周期时间和指令执行耗时有直接关系吗
<img src="https://static001.geekbang.org/resource/image/f9/31/f9da13f81cc676645a224b8ea6744931.png" alt="">
这个问题提的得非常好,@易儿易 同学的学习和思考都很仔细、深入。
“晶振时间与CPU执行固定指令耗时成正比”这个说法更准确一点。我们为了理解可以暂且认为是晶振在触发一条一条电路变化指令。这就好比你拨算盘的节奏一样。算盘拨得快珠算就算得快。结果就是一条简单的指令需要的时间就和一个时钟周期一样。
当然实际上这个问题要比这样一句话复杂很多。你可以仔细去读一读专栏关于CPU的章节呢。
从最简单的单指令周期CPU来说其实时钟周期应该是放下最复杂的一条指令的时间长度。但是我们现在实际用的都没有单指令周期CPU了而是采用了流水线技术。采用了流水线技术之后单个时钟周期里面能够执行的就不是一个指令了。我们会把一条机器指令拆分成很多个小步骤。不同的指令的步骤数量可能还不一样。不同的步骤的执行时间也不一样。所以一个时钟周期里面能够放下的是最耗时间的某一个指令步骤。
这样的话单看一条指令其实一定需要很多个时钟周期。也就是说从响应时间的角度来看一个时钟周期一定是不够执行一条指令的。但是呢因为有流水线我们同时又会去执行很多个指令的不同步骤。再加上后面讲的像超线程技术等等从吞吐量的角度来看我们又能够做到平均一个时钟周期里面完成指令数可以超过1。
想要准确理解CPU的性能问题请你一定去仔细读一读专栏的整个CPU的部分啊。
## Q3为什么低压主频只有标压的2/3计算向量点积的时候怎么提高性能
<img src="https://static001.geekbang.org/resource/image/87/6b/87e8925f9d8b12906164e17dad86626b.png" alt="">
低压和低主频都是为了减少能耗。比如Surface Go的电池很小机器的尺寸也很小。如果用上高主频性能更好了但是耗电并没有下来。
另外低电压对于CPU的工艺有更高的要求因为太低的电压可能导致电路都不能导通要高主频一样对工艺有更高的要求。所以一般低压CPU都是通过和低主频配合用在对于移动性和续航要求比较高的机器上。
向量计算是可以通过让加法也并行来优化的不过真实的CPU里面其实是通过SIMD指令来优化向量计算的我在后面也会讲到SIMD指令。
## Q4世界上第一个编程语言是怎么来的
<img src="https://static001.geekbang.org/resource/image/8d/75/8d8e399dfef0d4b62c34910ccd4f4d75.png" alt="">
如果你去计算机历史博物馆看一下真机就会明白第一台通用计算机ENIAC它的各种输入都是一些旋钮可以认为是类似用机器码在编程后来才有了汇编语言、C语言这样越来越高级的语言。
编程语言是自举的,指的是说,我们能用自己写出来的程序编译自己。但是自举,并不要求这门语言的**第一个**编译器就是用自己写的。
比如这里说到的Go先是有了Go语言我们通过C++写了编译器A。然后呢我们就可以用这个编译器A来编译Go语言的程序。接着我们再用Go语言写一个编译器程序B然后用A去编译B就得到了Go语言写好的编译器的可执行文件了。
这个之后我们就可以一直用B来编译未来的Go语言程序这也就实现了所谓的自举了。所以即使是自举也通常是先有了别的语言写好的编译器然后再用自己来写自己语言的编译器。
更详细的关于鸡蛋问题可以直接看Wikipedia上[这个链接](https://en.wikipedia.org/wiki/Bootstrapping_(compilers)),里面讲了多种这个问题的解决方案。
## Q5不同指令集中汇编语言和机器码的关系怎么对应的
<img src="https://static001.geekbang.org/resource/image/26/bc/26dbff8c5bf1f6fa4c24516cf4d911bc.png" alt="">
不同指令集里对应的汇编代码会对应这个指令集的机器码呀。大家不要把“汇编语言”当成是像C一样的一门统一编程语言。
“汇编语言”其实可以理解成“机器码”的一种别名或者书写方式,不同的指令集和体系结构的机器会有不同的“机器码”。
高级语言在转换成为机器码的时候,是通过编译器进行的,需要编译器指定编译成哪种汇编/机器码。
物理机自己执行的时候只有机器码,并不认识汇编代码。
编译器如果支持编译成不同的体系结构的汇编/机器码就要维护很多不同的对应关系表但是这个表并不会太大。以最复杂的Intel X86的指令集为例也只有2000条不同的指令而已。
## Q6某篇文章大段大段读不懂怎么办
<img src="https://static001.geekbang.org/resource/image/3e/f6/3eeecdd9685196f4eb2e32012dc867f6.png" alt="">
@胖胖胖 同学说得很好。在专栏最开始几篇,或者到后面比较深入的文章,很多非科班的或者基础不太好的同学,会觉得读不下去,甚至很多地方看不懂。这些其实都是正常现象。
即便我在写的时候,已经尽可能考虑得比较完善,照顾大家的情况,但是肯定无法面面俱到。在我平时学习过程遇到拦路虎的时候,我一般有两种方法,这里跟你分享一下。
第一种,硬读。
你可能说了,这也叫方法吗?没错,事实就是这样。如果这个知识点,我必须要攻克,就想要搞明白,那我就会尽我所能,去看每一个字眼,把每个不理解的地方,都一点一点搞明白。不吝啬花费时间和精力。
当然这种情况适合我对这个内容完全不了解,或者已经基本了解,现在需要进一步提升的情况下。因为,在完全不了解一个知识的时候,这个壁垒是很高的。如果不想办法突破的话,那可能就没办法了解这个新的领域。而在已经基本了解某个领域或者某块知识的情况下,我去攻克一些更高难度的知识,很多时候也需要同样的方法,我会建立在兴趣的基础上去硬读,但是之后会非常非常有成就感。
第二种,先抓主要矛盾,再抓细节问题。
很多时候,大家在对一个知识不了解的时候,会感觉很“恐慌”。其实完全没必要,大家学任何东西都是从不会到会这么一个过程。就像@胖胖胖 同学说的那样,先找出这篇文章的主干,先对这些东西有个大致的概念。如果有需要,在之后的过程中,你还会碰到,你可以再重读,加深印象。
有时候,学习知识可以尝试“短期多次”。也就是说,看完一遍之后,如果不明白,先放下,过一段时间再看一遍,如果还不明白,再过一段时间再看。这样循环几次,在大脑中发酵几次,说不定就明白了,要给大脑一个缓冲的时间。
好了,今天的答疑到这里就结束了。不知道能否帮你解决了一些疑惑和问题呢?
我会持续不断地回复留言,并把比较好的问题精选出来,作为答疑。欢迎你继续在留言区留言,和大家一起交流学习。

View File

@@ -0,0 +1,105 @@
<audio id="audio" title="特别加餐 | 我在2019年F8大会的两日见闻录" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/36/6c/36013b6b3bb588b9ae87119328821b6c.mp3"></audio>
你好我是徐文浩。4月30日我在美国圣何塞参加了F8大会趁此机会和你分享一下我在大会上的一些见闻。下面是我参会这两天写的见闻录分享给你。希望可以看到更多技术人走出去抬头看看世界丰富自己的见识和经历。
## Day 1“The Future is Private”
今年是我连续第三年来F8了。如果说第一年是带着一点好奇和忐忑作为一个开发者来看看世界上最大的社交网络的开发者大会是怎么回事儿到了第二年作为一个Developer Partner看到自己公司的logo出现在首日的Keynote里就觉得格外兴奋那么今年第三年就有些轻车熟路了没有什么压力反而很想看一看每年一次的开发者大会还能办出什么新花样。
<img src="https://static001.geekbang.org/resource/image/e6/27/e6effd7050ec22d0284d3af592c56c27.jpg" alt="">
从旧金山国际机场出来一路Uber到了圣何塞住下不禁感慨互联网和智能手机的确改变了世界。一个中国人到美国拿着手机也能在这里生存下来了。
为了倒时差,我硬是熬到半夜睡了一觉。早早赶到圣何塞市中心的会场,发现已经有不少人在排队入场了。
去年的F8因为Facebook面临“剑桥门事件”主题的Keynote颇有些疲于应对的感觉。然而在过去的一年里Facebook推出的种种隐私保护的功能似乎并没有解决“隐私泄露”的问题反而给人一种此起彼伏、应接不暇的感觉。
<img src="https://static001.geekbang.org/resource/image/0f/e2/0f8bdbdcbe2c5cfba7926c79fb2c11e2.jpg" alt="">
于是今年的F8Facebook颇有些破釜沉舟、不破不立之感。扎克伯格的开场Keynote就表示Facebook要开始在整个公司的运营策略上做出重大改变打造一个“Privacy Focused Social Platform”接着更是亮出了“The Future is Private”的slogan。
<img src="https://static001.geekbang.org/resource/image/c5/2e/c583a5791cd27a0b9b0af67aa474162e.jpg" alt="">
紧接着扎克伯格介绍了Facebook这两年力推的产品Messenger。Messenger团队重写了整个手机客户端让整个客户端小于30MB冷启动时间少于1.3秒默认端到端加密并给它起了一个代号叫LightSpeed。
这几项指标都可以直接拿来和自家被认为简单易用的WhatsApp做对比而且明显胜出。为了服务更多Messenger的发达国家用户Facebook更是干脆开发了一个桌面版的客户端。要知道在这个移动端主宰一切的年代还会投入精力开发桌面客户端的公司可不多了。可以看出Facebook推动Messenger产品的决心。
<img src="https://static001.geekbang.org/resource/image/4d/c3/4d5274e5a721847f0331bb270befe5c3.jpg" alt="">
WhatsApp产品更新介绍的核心也还是在隐私上。他们能够通过Messenger直接和WhatsApp联系人通信这更是可以看出Facebook迈出了打通旗下所有产品的第一步。
<img src="https://static001.geekbang.org/resource/image/f7/5f/f7c163214d2de6fd6714287db764345f.jpg" alt="">
然而更重磅的还在后面。在介绍完Messenger和WhatsApp的产品更新之后会场的大屏幕上打出了白底蓝字的“FB5”的logo。
**作为Facebook最核心的产品也是自己公司名字的Facebook迎来了多年以来的第一次大型改版**App和Web端界面完全重写产品中心从原先的信息流转向以Group为核心。“Groups at the heart”传统蓝底白字的“f”字logo也变成了有背景动图的“f”字logo了。
<img src="https://static001.geekbang.org/resource/image/8b/77/8b46c1caf0c083c4d96cd2d16522b777.jpg" alt="">
如果说其他App上的改动还可以认为是Facebook的尝试或者探索作为其主要收入来源的Facebook改版恐怕是动真格的了。**从一个开放信息流式的产品变成一个以Group为核心的、有着私密性的产品怕是多年以来Facebook这个App的另一次重大转变了。**
之后的Instagram、Portal以及Spark VR的产品更新都没有引起太多关注。Keynote的下一个爆点自然是Oculus。
Facebook是目前市场上唯一还在大力投入VR的大型厂商。这一次让人尖叫的就是Oculus Quest。这第一个“无线”的Oculus的确引人注目。当现场宣布所有参加F8的人将人手派发一个Oculus Quest更是引来全场的掌声。大屏幕上看着卡马克头戴Oculus挥舞光剑更是让老程序员们回忆起在DOS上玩“Wolfenstein 3D”的旧时光。
<img src="https://static001.geekbang.org/resource/image/7c/f1/7c67b09702e8e26c47afa0b83cae4cf1.jpg" alt="">
早上的Keynote结束之后就是自由活动了。参会的工程师们可以选择去不同的会议室听各种开发和产品相关的小讲座也可以直接在主会场的各个“摊位”前和Facebook的工程师沟通交流。通常如果提问的话还会拿到背包、T恤、帽子这样的小奖品。
当然排长队去体验Oculus是每年最热门的项目。你也可以在会场里面转悠和其他开发者认识一下。免费的零食和饮料到处都是。**与其说这是一个开发者大会其实更像是一个Facebook生态圈的嘉年华。**
<img src="https://static001.geekbang.org/resource/image/95/50/95ca1c530901b73e48bc2452f2b2fe50.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/2a/71/2a57495f80f1dc4ab31ee77c5dd28671.jpg" alt="">
F8的第一天仍然是以Facebook自己的四大产品为核心的一个主题会议并没有介绍太多AI和VR的黑科技。按照惯例这些黑科技会在明天的Keynote呈现值得期待。我印象比较深的是今天在讲解Oculus Rift S的时候介绍了Oculus Insight Position Tracking不知道明天又会有什么新科技出现。
## Day 2科技改变世界
第二天的Keynote仍然是在圣何塞市中心的McEnery会议中心举办。虽然Keynote要到10点开始但是我住的公寓没有早餐我和同事们还是8点刚过就跑到会场去“蹭饭”吃。
<img src="https://static001.geekbang.org/resource/image/b2/84/b2dd1268cb0457941619328b72133a84.jpg" alt="">
女性在科技界始终是“少数派”所以Facebook特地在F8的第二天在会场旁边的万豪酒店举办了一个Women Breakfast的活动邀请所有参与F8的女性一起吃早餐相互交流。
我们的一位产品经理也早早地去了会场参与这个活动。我想起前一天的Keynote里介绍4个核心产品的演讲者中有3位都是女性这让这个充满“科技感”的活动平添了一分人文的色彩。
<img src="https://static001.geekbang.org/resource/image/d1/66/d10ff8ad0033d9a934dd603fc1a68e66.jpg" alt="">
免费早餐之后大家的焦点又转移到了主会场。第二天的主题Keynote不同于第一天以Facebook的产品为核心而是集中在“技术”这个词上。
一般来说第一天的Keynote关注的是最近这三五年来Facebook的产品发展方向那么第二天的Keynote的目标则放得更加长远关注的是Facebook未来十年会关注和投入的技术。今年也毫不例外。Connectivity、机器学习、AR和VR把整个会场带入了一个更有科技感的主题里去。
<img src="https://static001.geekbang.org/resource/image/83/3e/83d67089c4ff6de8a3d508ec0921f93e.jpg" alt="">
不过今年的Keynote和往年的还是有点不一样。过去几年里F8第二天的Keynote都显得更有“梦想”一些比如通过无人机为经济不发达区域提供网络接入研究怎么通过Reinforcement Learning让机器打《星际争霸》。
过去两年里我们常常能看到一些或许挑战很大但是却又容易让人憧憬的项目出现。而今年第二天的Keynote主题却和前一天环环相扣专注在了“Responsible Innovation”这样一个主题上。
<img src="https://static001.geekbang.org/resource/image/8d/a2/8d96967f3c1346392f4caca9ea7389a2.jpg" alt="">
如何通过机器学习找出虚假账号如何过滤仇恨言论乃至如何解决网络霸凌变成了一个个的机器学习案例反复出现在今天的Keynote里。似乎Facebook是想更坚定地传达这样一个信息“The Future is Private”这件事情我们是认真的。
在整个Keynote的过程里面也让大家看到了对于有害内容的过滤从简单的关键词匹配进化到应用计算机视觉直到今天使用的Nearest Neighbor Manifold Expansion &amp; Multi-modal Understanding这样更加复杂的机器学习技术一步一步是如何发展的。
<img src="https://static001.geekbang.org/resource/image/c9/fd/c90468d382b57a21706adce0f08c68fd.jpg" alt="">
Keynote结束之后第二天的其他内容都安排得更加紧凑了一些。大部分的小会场都在午餐时间同时进行内容也更加“硬核”。各个小会场里看到的不是产品更新而是各种机器学习问题在Facebook的实际解决方法和应用。更有不少小会场里面Facebook的工程师直接给大家展示了代码。有心想要了解一些特定问题的工程师可以从这里面学到不少有用的东西。
<img src="https://static001.geekbang.org/resource/image/e4/5a/e4650e28e113101b3b36ebaa5b0b835a.jpg" alt="">
除了“学习”之外参加F8很重要的一个方面是社交。第二天的有些小会场是以座谈会的形式邀请外部的开发者合作伙伴来分享他们的成功案例。开发者之间的相互交流也更多了起来。
在所有内容结束之后Facebook新加坡办公室的Partner Manager带着我们移师会议中心附近的餐馆开始了一个小小的After Party。我们一群来自五湖四海的华人就在美国一边吃着墨西哥菜一边交流。“微信”是Facebook的开发者大会上始终绕不开的话题Facebook自己的各类消息类产品其实也一直在从微信里面汲取养分。
<img src="https://static001.geekbang.org/resource/image/f3/f4/f3c98d9ce71e26ec1846215fc4de73f4.jpg" alt="">
晚餐过后今年的F8就算正式结束了。认识的新朋友重新见面的老朋友之后就又要各奔东西了。而我自己打算在回程之前跑一趟心心念念的计算机历史博物馆去看看里面收藏的从ENIAC到现代计算机的经典型号为今年的旅程画上一个完满的句号。
## 推荐阅读
[Facebook全球最大社交网络向未知转型](https://mp.weixin.qq.com/s/UMnm2U1qKEI4V5IQdXINTQ)
[F8 2019 Day 1 Keynote](https://developers.facebook.com/videos/f8-2019/day-1-keynote/)
[F8 2019 Day 2 Keynote](https://developers.facebook.com/videos/f8-2019/day-2-keynote/)

View File

@@ -0,0 +1,41 @@
<audio id="audio" title="特别加餐 | 我的一天怎么过?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d4/29/d4731b87cca6015b51b9860816fa1a29.mp3"></audio>
你好我是徐文浩。专栏更新到50多篇快要结束了。在进入实战篇之前我想先和你分享一个专栏之外的话题那就是我的一天是怎么过的。
为什么想写这篇文章呢?主要目的是“破除神话”。周围一些朋友说,你在创业很厉害;也有朋友说,你能写专栏很厉害。其实我觉得自己和大家一样,就是一个普普通通的工程师,每一天都是普通且忙碌的。同时,我也希望通过这篇文章,能够拉近和你的距离,在专栏快要完结之际,可以在未来和你有更多的交流。
作为一个工程师出身的创业者,很多人会好奇,我是不是还常常写代码?也有朋友看我一直出差,会问我现在主要精力是不是都在产品上了?还有,我究竟要花多少时间在写这个专栏上?
事实上,作为一个创业者,我很难给自己的工作划定个小小的范围,然后说,“看,这个就是我做的事情”。在公司里,我每天在做的,其实主要就是两件事情。一件事情,我称之为“让事情按次发生”,主要是规划和推动公司里想要做的事情,推动产品结合业务往前走。另一件事情,我称之为“面对问题,解决问题”,主要是给各种突发的、意料之外的问题找解决办法。
规划和推动产品的工作,往往时间安排上主动一些,我会尽可能找完整大块的时间来做。而解决问题的事情,往往就比较碎片化,只能时时响应处理。
很多学习专栏的同学,工作时间应该都不是非常久,还有不少属于自己的业余时间。对我来说,想有属于自己的时间,基本上是奢望了。特别是最近半年多时间,每天都要抽出时间来写专栏,睡眠时间都牺牲了不少。
当然,我和大部分同学以及其他专栏作者,在时间安排上,差异最大的一点是,我会比较频繁地去海外出差了。在国内的时候,我的时间安排通常还比较有规律,比如,下面是我最近在国内的一个周一。
1.周一一早9点刚到公司我先会看看我们用作视频会议设备是否都连上了。虽然其实公司人还不多但是因为主要是针对海外的业务所以有马尼拉、曼谷、杭州、深圳四个办公地点异地沟通成了一个很大的问题。通过发消息或者视频会议的方式沟通效率仍然很低所以我们干脆通过Facebook Portal群组聊天的方式8小时“直播”各个办公室的情况。需要找另外一个办公室的同事的时候对着视频会议的屏幕吼一声就是了。
2.9:45开始我连续参加了两个小团队的站会。站会有对应负责的同学来主持推动我主要是多听一听大家是否遇到什么问题以及需要什么样的支持。这里面的问题可能来自内部的其他团队也可能是需要问外部的客户、Facebook、合作方的各种问题。这一天很顺利事情团队自运转就继续正常推进了我们的产品进度。
3.因为是周一所以10:00开始我会和各个团队的负责人开一个非业务内容的周会。因为最近在推动公司内部做好跨团队职责的协同所以最近的重点是在做两件事情。一个是从后端的研发团队开始推进强流程的代码审核目标是提升代码质量和长期的迭代速度。第二个是培养整个系统里各个非功能模块的首要负责人主要是要把从云服务器管理、CDN、网络、监测等等非功能性的需求和职责划分给到更多不同的工程师让他们各自负责之后再做学习分享。这样可以让大家对整个系统的全貌有个了解而不是只是把这些问题放在一两个资深的技术同学身上。
这一天里,我发现代码审核进展很慢,主要是大家都还是觉得这样会影响进度,但是我内心深处知道不是这么回事儿,因为从开始要做这个事情已经两三周过去了。所以,我就不再是“建议”,而是“强迫”团队开始做代码审核了。各种非功能性的“负责人”的分配倒是相对比较顺利。
4.我们通常开会都很短三个会开完也就是10:30这样子。不过因为是周一所以接下来的主要时间还是在清理邮件。这里面既有来自外部客户和合作伙伴的问题也有系统自动生成的各种报告。能直接回复的都会直接回复掉不能直接回复的我会加到Microsoft TO-DO里面作为待办事项列表。
5.基本上把邮件清理完了,也就到了中午。我一般不叫外卖,而是和同事们一起出门觅食。因为大部分时间都是在办公室里坐着,运动也少,所以除非是暴雨天气,我一定是要出去走动走动的。和不同的同事吃饭,聊两句生活,互相之间的距离也能拉近不少。
6.吃完午饭,我自己的常备节目是去买杯瑞幸或者全家的咖啡。通常也有不少同事会一起过去,不管买还是不买,都要溜个弯儿。我自己最近有点睡得少,不靠咖啡下午就会犯困。
7.之后回到办公室想要开始写点代码。因为团队越来越大所以现在我已经不写任何“必须要写”的代码了避免自己的时间安排成为发布计划的瓶颈。不过我还是尽可能会抽一些时间来写一点效率提升的代码。这天要写的是答应了团队把自动化滚动部署Auto Rolling Update的脚本给写了。不过还没写多少我们的产品经理YC就来找我一起和团队过新的OMS订单管理系统的产品评审。虽然作为程序员被打断总是会觉得很头疼不过该过的事情还是要过。
8.等到产品评审走完终于又有了点儿时间重新开始写滚动部署的脚本。脚本写起来方便测试起来却是非常麻烦要频繁地开关虚拟机去做检查也没有什么太好的办法做单元测试或者自动化测试。前前后后几个小时下去终于把整个脚本调通。不过我又在JIRA里面记了一串新的想法主要是想要进一步把目前手动在云平台上创建负载均衡后端服务的手工工作都自动化掉。
9.抬头一看已经快晚上9点了其实已经过了饭点儿了。办公室里也空了大半于是干脆收拾好包出门吃饭回家。
10.回家刷了一会儿抖音重新打开电脑开始写专栏。专栏的工作量比想象中大不少基本上写到12点、1点除非已经是死线了。不然即使进度比想象中慢一点我也会先去睡了不然第二天效率更差。毕竟明天我们又要开始创造明天么。
这就是我上周的一天,不知道和你想象中差别大吗?下次有机会,我会再写写我在海外出差的一天是什么过的。
最后,我想听你讲讲,你的一天是怎么过的呢?欢迎在留言区和同学们一起分享。

View File

@@ -0,0 +1,83 @@
<audio id="audio" title="用户故事 | 赵文海:怕什么真理无穷,进一寸有一寸的欢喜" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/18/f0/1815daf3625f700d8fb75a72f6f5e0f0.mp3"></audio>
大家好我是赵文海一名Android开发仔坐标北京目前工作刚满一年在这里分享一下自己学习“深入浅出计算机组成原理”专栏的心得。
## 为什么要学计算机组成原理?
一直以来我心里都有一个念想,就是好好把计算机基础知识补一补,原因有两个。
第一,我不是计算机专业的,如果连基础知识都不熟悉,那怎么与科班出身的同事交流呢?虽然我目前的工作主要是在业务层进行开发,涉及基础知识的场景其实并不多,但是,既然我要在程序员这个行业长久地走下去,我觉得自己还是有必要补一下基础知识。
第二,虽然现在各种新框架、新技术层出不穷,但它们的根基其实还是那些基础知识。我们每个人的精力有限,整天追随这些“新”的东西,在我看来并不是一个很明智的选择。相反,正所谓“磨刀不误砍柴工”,如果我把先基础知识掌握好,那学习和了解那些应用层的框架应该会更容易一点。
所以我给自己设定了两个学习方向一是深入学习移动端开发相关技术比如学习Android 系统知识、深入了解一些框架、接触 Flutter 这类跨平台技术;二是学习计算机基础知识,然后再随着工作慢慢深入去学习移动端开发技术。
正好那时候极客时间出了很多基础课程,比如王争老师的“数据结构与算法之美”、刘超老师的“趣谈网络协议”等等。我是先从数据结构与算法开始学的,后面又学了一些网络协议的知识,然后才开始学习徐文浩老师的“深入浅出计算机组成原理”。
我记得徐老师在开篇词里写过这么一段话:
>
正所谓“练拳不练功,到老一场空”。如果越早去弄清楚计算机的底层原理,在你的知识体系中储备这些知识,也就意味着你有越长的时间来收获学习的“利息”。虽然一开始可能不起眼,但是随着时间带来的复利效应,你的长线投资项目,就能让你在成长的过程中越走越快。
这段话和我的想法不谋而合,也给了我极大的鼓舞,我学习基础知识的决心也更加坚定了 。
## 我是怎么学习专栏的?
刚开始看的时候,专栏已经更新了二十多讲,但我没有想要赶快跟上老师的步伐,就希望自己每天都能坚持看一讲。
我一般都是在晚上下班回家后看专栏,也有时候是早上到公司后,因为住的地方离公司比较近,还有时候一个人出去坐地铁也会拿出来看一会儿。
最开始由于我过于自信了,我想着只是把文章看了一遍就可以了。后来过了一周,我发现看完的东西,很快就没有印象了。我当时就想,这样可不行啊!不知道你是不是经常有这种感受,费很大劲搞懂的东西,结果因为只看了一遍,没有及时复习,很快就又忘记了。于是,后面每篇文章,我至少都会看两遍。第一遍认真阅读思考,第二遍、第三遍作为复习巩固。
另外,学习这个专栏时我没有做笔记,因为我觉得老师的文章不长,而且言语足够简练,没有必要自己再提炼一次。毕竟我每天都会打开极客时间,如果碰到哪里想不起来了,就直接再看一遍文章就好了,也花不了多长时间。
当然,记不记笔记是个人喜好,如果时间充裕,你也可以选择通过做笔记来加深印象。极客时间的划线笔记功能也是极好的,看到有问题或者非常好的地方,直接记录下来,方便以后查阅、学习。
就这样,保持一天一节的速度,慢慢我就赶上了老师更新的步伐,后面的文章基本就是更新当天就看完。
虽然进度跟上了,但是老师文章后面附的书籍我目前并没有去读。作为一个非科班出身的工作党,平时时间并不宽裕,掌握老师每一节的主要知识,已经挺不容易了。
这里特别说一下,徐老师每篇文章下的推荐阅读,是我个人最喜欢这个专栏的地方。徐老师每次都会在文章结尾列出相关书籍的对应章节、相关的博客或论文,这为我后面深入学习相关知识提供了很大的便利。
因此,关于这一块内容我是这么打算的。我准备学完第一遍之后,仔细去读一读老师推荐的书籍。这样有了第一遍的铺垫,读起老师推荐的书和论文,不至于那么困难和恐惧。读书的时候还可以结合书中内容再复习一遍专栏,到时候肯定会有新的收获。毕竟,基础知识的学习是一个**长期积累、慢慢参悟、螺旋上升**的过程,我已经做好了打持久战的准备。
## 学习专栏有什么收获?
徐老师的文章长度适中、图文并茂、言简意赅。在解释一些名词和概念的时候,徐老师经常拿生活中我们熟悉的事物来举例。我觉得这一点非常好。
比如,他把电路组装看成“搭乐高积木”、把动态链接比喻成程序内部的“共享单车”、把总线比喻成计算机里的“高速公路”,这很容易让我这种非科班的同学,对陌生概念迅速建立起一个初步印象。当然,能把概念解释地如此清晰和“接地气”,也反映了老师的深厚功底。
通过专栏的学习我对计算机的CPU、内存、I/O设备以及它们之间的通信有了初步的了解。
另外,像 GPU、TPU相关的章节也让我开拓了眼界比如 GPU 那一节老师就讲到了计算机图形渲染的流程,这些知识是我之前从未接触过的。
同时,专栏还有很多很实用的章节,比如讲 CPU 的高速缓存时,讲到了 Java 中的 volatile 关键字的作用,这些都可以直接运用到实际的工作或面试中。
不过,除了计算机组成原理的知识外,我还有其他的收获,在我看来这些收获甚至比那些知识还重要。
首先,就是克服了对于基础知识的恐惧。
我之前觉得基础知识是晦涩难懂的,像计算机组成原理、网络协议、操作系统,这些课听起来就觉得很难。开始学习之前,心里总是怕自己理解不了或者坚持不下来,但是通过学习专栏,我发现它们并没有想象中那么可怕,很多技术灵感其实就是源于我们的生活实践。先去学,然后慢慢就能发现其中有趣的地方。
比如CPU分支预测就和我们天气预测有相似之处。文章里穿插的历史知识也让我意识到这些知识虽然看似高深但也是无数前辈经历很长时间、很多次失败才慢慢积累下来的学习这些知识就是站在巨人的肩膀上体会他们思考和实践的过程。
还有就是,我意识到了持续学习的重要性。
徐老师在第2讲时写过“**我工作之后一直在持续学习,在这个过程中,我发现最有效的方法,不是短时间冲刺,而是有节奏的坚持**。”我对这句话真是深有感触,所以一直记到现在,估计你也是吧?
## 总结
通过专栏的学习,我确实收获了很多,真的非常感谢徐文浩老师的付出,也感谢极客时间推出了这么多实用的基础课程。
其实,尽管毕业之后工作才半年左右,但是我的心里其实挺焦虑的,主要是担心自己在如此快速的技术变革中,跟不上变化,慢慢被淘汰。在这个过程中,我也思考了很多。通过学习专栏,我的焦虑情绪也化解了很多。在这里我也想说说我对于焦虑的看法。
我觉得人之所以会感到焦虑,是因为有上进心,说白了就是觉得自己不够好。这其实是一件好事,正是因为觉得自己不够好,我们才能产生变得更好的想法,进而找到变得更好的方法,所以我们要正确看待焦虑这种情绪。而**缓解焦虑的方式很简单,就是行动。担心自己长胖,那就去锻炼;担心自己被淘汰,那就去学习。**
我特别喜欢胡适说的一句话:“怕什么真理无穷,进一寸有一寸的欢喜”。
用这句话与各位共勉吧,希望我们都能抱着长线投资的心态,坚持下去,和时间做朋友,不要急躁。虽然学完这些基础知识,老板也看不到,短时间内也不会加薪升职,但我相信,它们会在未来的某个时间回馈你,让你知道现在的决定是正确的,现在的付出是值得的!
好了,我想要分享的内容就是这些,不知道你学习这个专栏的过程是怎样的呢?有没有什么独特的学习方法和心路历程呢?欢迎你写在留言区,我们一起分享,相互鼓励,共同进步!