mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-11 04:04:34 +08:00
del
This commit is contained in:
109
极客时间专栏/geek/深入浅出计算机组成原理/入门篇/01 | 冯·诺依曼体系结构:计算机组成的金字塔.md
Normal file
109
极客时间专栏/geek/深入浅出计算机组成原理/入门篇/01 | 冯·诺依曼体系结构:计算机组成的金字塔.md
Normal 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 Unit,ALU)和处理器寄存器(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)。那么图灵机和冯·诺依曼机是两种不同的计算机么?图灵机是一种什么样的计算机抽象呢?
|
||||
|
||||
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
110
极客时间专栏/geek/深入浅出计算机组成原理/入门篇/02 | 给你一张知识地图,计算机组成原理应该这么学.md
Normal file
110
极客时间专栏/geek/深入浅出计算机组成原理/入门篇/02 | 给你一张知识地图,计算机组成原理应该这么学.md
Normal 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程序,是怎么在计算机里面跑起来的。这里面,你既需要了解我们的程序是怎么通过编译器和汇编器,变成一条条机器指令这样的编译过程(如果把编译过程展开的话,可以变成一门完整的编译原理课程),还需要知道我们的操作系统是怎么链接、装载、执行这些程序的(这部分知识如果再深入学习,又可以变成一门操作系统课程)。而这一条条指令执行的控制过程,就是由计算机五大组件之一的**控制器**来控制的。
|
||||
|
||||
在计算机的计算部分,你要从二进制和编码开始,理解我们的数据在计算机里的表示,以及我们是怎么从数字电路层面,实现加法、乘法这些基本的运算功能的。实现这些运算功能的ALU(Arithmetic 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自发为你做的流水线、指令级并行和分支预测,还有对应访问到的硬盘、内存,以及加载到高速缓存中的数据,这些都对应着我们学习中的一个个知识点。建议你自己脑子里过一遍,最好是口头表述一遍或者写下来,这样对你彻底掌握这些知识点都会非常有帮助。
|
||||
|
||||
其次,**写一些示例程序来验证知识点。**计算机科学是一门实践的学科。计算机组成中的大量原理和设计,都对应着“性能”这个词。因此,通过把对应的知识点,变成一个个性能对比的示例代码程序记录下来,是把这些知识点融汇贯通的好方法。因为,相比于强记硬背知识点,一个有着明确性能对比的示例程序,会在你脑海里留下更深刻的印象。当你想要回顾这些知识点的时候,一个程序也更容易提示你把它从脑海深处里面找出来。
|
||||
|
||||
最后,**通过和计算机硬件发展的历史做对照**。计算机的发展并不是一蹴而就的。从第一台电子计算机ENIAC(Electronic 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,则是通过实际应用程序,来理解计算机组成原理中各个知识点的最好范例了。
|
||||
|
||||
《编码:隐匿在计算机软硬件背后的语言》和《程序员的自我修养:链接、装载和库》是理解计算机硬件和操作系统层面代码执行的优秀阅读材料。
|
||||
|
||||
## 总结延伸
|
||||
|
||||
学习不是死记硬背,学习材料也不是越多越好。到了这里,希望你不要因为我给出了太多可以学习的材料,结果成了“松鼠症”患者,光囤积材料,却没有花足够多的时间去学习这些知识。
|
||||
|
||||
我工作之后一直在持续学习,在这个过程中,我发现最有效的办法,**不是短时间冲刺,而是有节奏地坚持,希望你能够和专栏的发布节奏同步推进,做好思考题,并且多在留言区和其他朋友一起交流**,就更容易能够“积小步而至千里”,在程序员这个职业上有更长足的发展。
|
||||
|
||||
好了,对于学习资料的介绍就到这里了。希望在接下来的几个月里,你能和我一起走完这趟“计算机组成”之旅,从中收获到知识和成长。
|
||||
|
||||
## 课后思考
|
||||
|
||||
今天我为你梳理计算机组成的知识地图,也讲了我认为学习这个专栏的一些方法,听了这么多,那么你打算怎么学习这个专栏呢?
|
||||
|
||||
欢迎你在留言区写下你的学习目标和学习计划,和大家一起交流,也欢迎你把今天的文章分享给你的朋友,互相督促,共同成长。
|
||||
114
极客时间专栏/geek/深入浅出计算机组成原理/入门篇/03 | 通过你的CPU主频,我们来谈谈“性能”究竟是什么?.md
Normal file
114
极客时间专栏/geek/深入浅出计算机组成原理/入门篇/03 | 通过你的CPU主频,我们来谈谈“性能”究竟是什么?.md
Normal 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/30,Sandy 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设计,乃至指令设计层面,提升计算机的性能。
|
||||
|
||||
## 课后思考
|
||||
|
||||
每次有新手机发布的时候,总会有一些对于手机的跑分结果的议论。乃至于有“作弊”跑分或者“针对跑分优化”的说法。我们能针对“跑分”作弊么?怎么做到呢?“作弊”出来的分数对于手机性能还有参考意义么?
|
||||
|
||||
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
108
极客时间专栏/geek/深入浅出计算机组成原理/入门篇/04 | 穿越功耗墙,我们该从哪些方面提升“性能”?.md
Normal file
108
极客时间专栏/geek/深入浅出计算机组成原理/入门篇/04 | 穿越功耗墙,我们该从哪些方面提升“性能”?.md
Normal 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能够跑到40MHz,1989年的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 Integration,VLSI)。这些电路,实际上都是一个个晶体管组合而成的。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 i9,CPU的电压已经从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_0~W_3$, $W_4~W_7$, $W_8~W_{11}$, $W_{12}~W_{15}$这样四个部分的结果,再由一个人进行汇总,需要的时间就会缩短。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/9d/64d6957ecaa696edcf79dc1d5511269d.jpeg" alt="">
|
||||
|
||||
但是,并不是所有问题,都可以通过并行提高性能来解决。如果想要使用这种思想,需要满足这样几个条件。
|
||||
|
||||
第一,需要进行的计算,本身可以分解成几个可以并行的任务。好比上面的乘法和加法计算,几个人可以同时进行,不会影响最后的结果。
|
||||
|
||||
第二,需要能够分解好问题,并确保几个人的结果能够汇总到一起。
|
||||
|
||||
第三,在“汇总”这个阶段,是没有办法并行进行的,还是得顺序执行,一步一步来。
|
||||
|
||||
这就引出了我们在进行性能优化中,常常用到的一个经验定律,**阿姆达尔定律**(Amdahl’s 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节不容错过。
|
||||
|
||||
## 课后思考
|
||||
|
||||
我在这一讲里面,介绍了三种常见的性能提升思路,分别是,加速大概率事件、通过流水线提高性能和通过预测提高性能。请你想一下,除了在硬件和指令集的设计层面之外,你在软件开发层面,有用到过类似的思路来解决性能问题吗?
|
||||
|
||||
欢迎你在留言区写下你曾遇到的问题,和大家一起分享、探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
71
极客时间专栏/geek/深入浅出计算机组成原理/入门篇/开篇词 | 为什么你需要学习计算机组成原理?.md
Normal file
71
极客时间专栏/geek/深入浅出计算机组成原理/入门篇/开篇词 | 为什么你需要学习计算机组成原理?.md
Normal 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串起来。通过一个程序的执行过程进行逐层分解,让你能对整个系统有一个全貌的了解。
|
||||
|
||||
我希望这个专栏,不仅能够让你学好计算机组成原理的知识,更能够成为引领你进入更多底层知识的大门,让你有动力、有方法、更深入地去进一步学习体系结构、操作系统、编译原理这样的课程,成为真正的“内家高手”。
|
||||
|
||||
“人生如逆旅,我亦是行人”。学习总不会是一件太轻松的事情,希望在这个专栏里,你能和我多交流,坚持练完这一手内功。
|
||||
|
||||
下面,你可以讲一讲,你对于计算机组成原理的认识是怎样的?在之前工作中,哪些地方用到了计算机组成原理相关的知识呢?欢迎写在留言区,我们一起交流。
|
||||
|
||||
|
||||
97
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/17 | 建立数据通路(上):指令+运算=CPU.md
Normal file
97
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/17 | 建立数据通路(上):指令+运算=CPU.md
Normal 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.重复进行1~3的步骤。
|
||||
|
||||
这样的步骤,其实就是一个永不停歇的“**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闲置的状态呢?请你自己搜索研究一下这是为什么,并在留言区写下你的思考和答案。
|
||||
|
||||
欢迎你留言和我分享,你也可以把今天的文章分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
108
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/18 | 建立数据通路(中):指令+运算=CPU.md
Normal file
108
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/18 | 建立数据通路(中):指令+运算=CPU.md
Normal 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>
|
||||
在这个电路一开始,输入开关都是关闭的,所以或非门(NOR)A的输入是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反馈给到了A,A的输入变成了1和1,输出仍然是0。所以把A的开关合上之后,电路仍然是稳定的,不会像晶振那样振荡,但是整个电路的**输出Q**变成了1。
|
||||
</li>
|
||||
<li>
|
||||
这个时候,如果我们再把A前面的开关R打开,A的输入变成和1和0,输出还是0,对应的B的输入没有变化,输出也还是1。B的输出1反馈给到了A,A的输入变成了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信号是1,R和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了,但是实际上我们的晶振并不能提供这么高的频率,而是通过“外频+倍频“的方式来实现高频率的时钟信号。请你研究一下,倍频和分频的信号是通过什么样的电路实现的?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
96
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/19 | 建立数据通路(下):指令+运算=CPU.md
Normal file
96
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/19 | 建立数据通路(下):指令+运算=CPU.md
Normal 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,可以直接在控制器里面完成,你能说说这是为什么吗?
|
||||
|
||||
欢迎在留言区写下你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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的流水线设计之后,你是怎么理解这句话的呢?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
113
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/21 | 面向流水线的指令设计(下):奔腾4是怎么失败的?.md
Normal file
113
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/21 | 面向流水线的指令设计(下):奔腾4是怎么失败的?.md
Normal 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还在500~850MHz的频率下运行的时候,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="">
|
||||
|
||||
为了达到这个10GHz,Intel的工程师做出了一个重大的错误决策,就是在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 4,Intel更是把流水线深度增加到了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是在2000~2004年作为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执行计算机指令的吞吐率。我们一般用IPC(Instruction Per Cycle)来衡量CPU执行指令的效率。
|
||||
|
||||
IPC呢,其实就是我们之前在第3讲讲的CPI(Cycle 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的设计。
|
||||
|
||||
## 课后思考
|
||||
|
||||
除了我们这里提到的数据层面的依赖,你能找找我们在程序的执行过程中,其他的依赖情况么?这些依赖情况又属于我们说的哪一种冒险呢?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
193
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/22 | 冒险和预测(一):hazard是“危”也是“机”.md
Normal file
193
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/22 | 冒险和预测(一):hazard是“危”也是“机”.md
Normal 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 Write,RAW)、**先读后写**(Write After Read,WAR)和**写后再写**(Write After Write,WAW)。下面,我们分别看一下这几种情况。
|
||||
|
||||
### 先写后读(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.5~4.7章。
|
||||
|
||||
想要了解流水线冒险里面结构冒险的相关知识,你可以去看一看Coursera上普林斯顿大学的Computer Architecture的[Structure Hazard](https://zh.coursera.org/lecture/comparch/structural-hazard-lB2xV)部分。
|
||||
|
||||
## 课后思考
|
||||
|
||||
在采用流水线停顿的解决方案的时候,我们不仅要在当前指令里面,插入NOP操作,所有后续指令也要插入对应的NOP操作,这是为什么呢?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
96
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/23 | 冒险和预测(二):流水线里的接力赛.md
Normal file
96
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/23 | 冒险和预测(二):流水线里的接力赛.md
Normal 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.5~4.7章节。
|
||||
|
||||
## 课后思考
|
||||
|
||||
前面讲5级流水线指令的时候,我们说,STORE指令是没有数据写回阶段的,而ADD指令是没有访存阶段的。那像CMP或者JMP这样的比较和跳转指令,5个阶段都是全的么?还是说不需要哪些阶段呢?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
107
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/24 | 冒险和预测(三):CPU里的“线程池”.md
Normal file
107
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/24 | 冒险和预测(三):CPU里的“线程池”.md
Normal 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 Execution,OoOE)。乱序执行,最早来自于著名的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 Unit,FU),其实就是ALU,去执行了。我们有很多功能单元可以并行运行,但是不同的功能单元能够支持执行的指令并不相同。就和我们的铁轨一样,有些从上海北上,可以到北京和哈尔滨;有些是南下的,可以到广州和深圳。
|
||||
|
||||
5.指令执行的阶段完成之后,我们并不能立刻把结果写回到寄存器里面去,而是把结果再存放到一个叫作重排序缓冲区(Re-Order Buffer,ROB)的地方。
|
||||
|
||||
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 -> d -> x,而计算完成的顺序是 x -> a -> 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)。你能想一想,我们为什么要保障内存访问的顺序呢?在前后执行的指令没有相关数据依赖的情况下,为什么我们仍然要求这个顺序呢?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
158
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/25 | 冒险和预测(四):今天下雨了,明天还会下雨么?.md
Normal file
158
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/25 | 冒险和预测(四):今天下雨了,明天还会下雨么?.md
Normal 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 < 100; i++) {
|
||||
for (int j = 0; j <1000; j ++) {
|
||||
for (int k = 0; k < 10000; k++) {
|
||||
}
|
||||
}
|
||||
}
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("Time spent is " + (end - start));
|
||||
|
||||
start = System.currentTimeMillis();
|
||||
for (int i = 0; i < 10000; i++) {
|
||||
for (int j = 0; j <1000; j ++) {
|
||||
for (int k = 0; k < 100; k++) {
|
||||
}
|
||||
}
|
||||
}
|
||||
end = System.currentTimeMillis();
|
||||
System.out.println("Time spent is " + (end - start) + "ms");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这是一个简单的三重循环,里面没有任何逻辑代码。我们用两种不同的循环顺序各跑一次。第一次,最外重循环循环了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程序,验证了“分支预测”出错会对程序带来的性能影响。你可以用你自己惯用的语言来试一试,看一看是否会有同样的效果。如果没有的话,原因是什么呢?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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的倒数,又叫作IPC(Instruction 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 Word,VLIW)。这个设计呢,不仅想让编译器来优化指令数,还想直接通过编译器,来优化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里面是否有用到这些相关策略呢?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。
|
||||
105
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/27 | SIMD:如何加速矩阵乘法?.md
Normal file
105
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/27 | SIMD:如何加速矩阵乘法?.md
Normal 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
|
||||
>>> import numpy as np
|
||||
>>> import timeit
|
||||
>>> a = list(range(1000))
|
||||
>>> b = np.array(range(1000))
|
||||
>>> timeit.timeit("[i + 1 for i in a]", setup="from __main__ import a", number=1000000)
|
||||
32.82800309999993
|
||||
>>> timeit.timeit("np.add(1, b)", setup="from __main__ import np, b", number=1000000)
|
||||
0.9787889999997788
|
||||
>>>
|
||||
|
||||
```
|
||||
|
||||
从两段程序的输出结果来看,你会发现,两个功能相同的代码性能有着巨大的差异,足足差出了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章节。
|
||||
|
||||
## 课后思考
|
||||
|
||||
最后,给你留一道思考题。超线程这样的技术,在什么样的应用场景下最高效?你在自己开发系统的过程中,是否遇到超线程技术为程序带来性能提升的情况呢?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
111
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/28 | 异常和中断:程序出错了怎么办?.md
Normal file
111
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/28 | 异常和中断:程序出错了怎么办?.md
Normal 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进行的人为区分,你明白这个意思就好。
|
||||
|
||||
尽管,这里我把这些硬件和系统相关的异常,叫作“硬件异常”。但是,实际上,这些异常,既有来自硬件的,也有来自软件层面的。
|
||||
|
||||
比如,我们在硬件层面,当加法器进行两个数相加的时候,会遇到算术溢出;或者,你在玩游戏的时候,按下键盘发送了一个信号给到CPU,CPU要去执行一个现有流程之外的指令,这也是一个“异常”。
|
||||
|
||||
同样,来自软件层面的,比如我们的程序进行系统调用,发起一个读文件的请求。这样应用程序向系统调用发起请求的情况,一样是通过“异常”来实现的。
|
||||
|
||||
**关于异常,最有意思的一点就是,它其实是一个硬件和软件组合到一起的处理过程。异常的前半生,也就是异常的发生和捕捉,是在硬件层面完成的。但是异常的后半生,也就是说,异常的处理,其实是由软件来完成的。**
|
||||
|
||||
计算机会为每一种可能会发生的异常,分配一个异常代码(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章“异常控制流”部分,有非常深入和充分的讲解,推荐你认真阅读一下。
|
||||
|
||||
## 课后思考
|
||||
|
||||
很多教科书和网上的文章,会把中断分成软中断和硬中断。你能用自己的话说一说,什么是软中断,什么是硬中断吗?它们和我们今天说的中断、陷阱、故障以及中止又有什么关系呢?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
116
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/29 | CISC和RISC:为什么手机芯片都是ARM?.md
Normal file
116
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/29 | CISC和RISC:为什么手机芯片都是ARM?.md
Normal 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-V:CPU的现在与未来
|
||||
|
||||
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呢?
|
||||
|
||||
你可以搜索一下相关资料,在留言区写下你搜索到的内容。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
107
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/30 | GPU(上):为什么玩游戏需要使用GPU?.md
Normal file
107
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/30 | GPU(上):为什么玩游戏需要使用GPU?.md
Normal 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是随着我们开始在计算机里面需要渲染三维图形的出现,而发展起来的设备。图形渲染和设备的先驱,第一个要算是SGI(Silicon 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摆脱这些负担吗?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
121
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/31 | GPU(下):为什么深度学习需要使用GPU?.md
Normal file
121
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/31 | GPU(下):为什么深度学习需要使用GPU?.md
Normal 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 Instruction,Multiple 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个SM(Streaming Multiprocessor,流式处理器),这个SM相当于GPU里面的GPU Core,所以你可以认为这是一个46核的GPU,有46个取指令指令译码的渲染管线。每个SM里面有64个Cuda Core。你可以认为,这里的Cuda Core就是我们上面说的ALU的数量或者Pixel Shader的数量,46x64呢一共就有2944个Shader。然后,还有184个TMU,TMU就是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。
|
||||
|
||||
欢迎在留言区写下你的答案,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
110
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/32 | FPGA和ASIC:计算机体系结构的黄金时代.md
Normal file
110
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/32 | FPGA和ASIC:计算机体系结构的黄金时代.md
Normal 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年计算机工业界的中心都在软件上。似乎硬件对大家来说,慢慢变成了一个黑盒子。虽然必要,但却显得有点无关紧要。
|
||||
|
||||
不过,在上世纪70~80年代,计算机的世界可不是这样的。那个时候,计算机工业届最激动人心的,是层出不穷的硬件。无论是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这样的板子,可以在“现场”多次进行编程。它不像PAL(Programmable Array Logic,可编程阵列逻辑)这样更古老的硬件设备,只能“编程”一次,把预先写好的程序一次性烧录到硬件里面,之后就不能再修改了。
|
||||
|
||||
这么看来,其实“FPGA”这样的组合,基本上解决了我们前面说的想要设计硬件的问题。我们可以像软件一样对硬件编程,可以反复烧录,还有海量的门电路,可以组合实现复杂的芯片功能。
|
||||
|
||||
不过,相信你和我一样好奇,我们究竟怎么对硬件进行编程呢?我们之前说过,CPU其实就是通过晶体管,来实现各种组合逻辑或者时序逻辑。那么,我们怎么去“编程”连接这些线路呢?
|
||||
|
||||
FPGA的解决方案很精巧,我把它总结为这样三个步骤。
|
||||
|
||||
**第一,用存储换功能实现组合逻辑。**在实现CPU的功能的时候,我们需要完成各种各样的电路逻辑。在FPGA里,这些基本的电路逻辑,不是采用布线连接的方式进行的,而是预先根据我们在软件里面设计的逻辑电路,算出对应的真值表,然后直接存到一个叫作LUT(Look-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和寄存器的设备,也被叫做CLB(Configurable 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有一笔很高的NRE(Non-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,以节约成本。
|
||||
|
||||
相比FPGA,ASIC在“专用”上更进一步。它是针对特定的使用场景设计出来的芯片,比如,摄像头、音频、“挖矿”或者深度学习。虽然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之外,你最近关注到什么新的、有意思的硬件呢?
|
||||
|
||||
欢迎在留言区分享出来。让我们不只了解计算机“软件”,也能够看到更广阔的“硬件”世界。同时,如果你觉得今天的内容很有收获,你也可以把这篇文章分享给你的朋友。
|
||||
|
||||
|
||||
109
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/33 | 解读TPU:设计和拆解一块ASIC芯片.md
Normal file
109
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/33 | 解读TPU:设计和拆解一块ASIC芯片.md
Normal 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年解决计算机视觉问题开始,深度学习一下子进入了大爆发阶段,也一下子带火了GPU,NVidia的股价一飞冲天。我们在[第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在深度学习的推断任务上,要快15~30倍。而在能耗比上,更是好出30~80倍。另一方面,Google已经用TPU替换了自家数据中心里95%的推断任务,可谓是拿自己的实际业务做了一个明证。
|
||||
|
||||
## 总结延伸
|
||||
|
||||
这一讲,我从第一代TPU的设计目标讲起,为你解读了TPU的设计。你可以通过这篇文章,回顾我们过去32讲提到的各种知识点。
|
||||
|
||||
第一代TPU,是为了做各种深度学习的推断而设计出来的,并且希望能够尽早上线。这样,Google才能节约现有数据中心里面的大量计算资源。
|
||||
|
||||
从深度学习的推断角度来考虑,TPU并不需要太灵活的可编程能力,只要能够迭代完成常见的深度学习推断过程中一层的计算过程就好了。所以,TPU的硬件构造里面,把矩阵乘法、累加器和激活函数都做成了对应的专门的电路。
|
||||
|
||||
为了满足深度学习推断功能的响应时间短的需求,TPU设置了很大的使用SRAM的Unified Buffer(UB),就好像一个CPU里面的寄存器一样,能够快速响应对于这些数据的反复读取。
|
||||
|
||||
为了让TPU尽可能快地部署在数据中心里面,TPU采用了现有的PCI-E接口,可以和GPU一样直接插在主板上,并且采用了作为一个没有取指令功能的协处理器,就像387之于386一样,仅仅用来进行需要的各种运算。
|
||||
|
||||
在整个电路设计的细节层面,TPU也尽可能做到了优化。因为机器学习的推断功能,通常做了数值的归一化,所以对于矩阵乘法的计算精度要求有限,整个矩阵乘法的计算模块采用了8 Bits来表示浮点数,而不是像Intel CPU里那样用上了32 Bits。
|
||||
|
||||
最终,综合了种种硬件设计点之后的TPU,做到了在深度学习的推断层面更高的能效比。按照Google论文里面给出的官方数据,它可以比CPU、GPU快上15~30倍,能耗比更是可以高出30~80倍。而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 Google’s 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的设计之上做怎么样的修改呢?
|
||||
|
||||
欢迎留言和我分享你的想法。如果这篇文章对你有收获,你也可以把他分享给你的朋友。
|
||||
|
||||
|
||||
117
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/34 | 理解虚拟机:你在云上拿到的计算机是什么样的?.md
Normal file
117
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:处理器/34 | 理解虚拟机:你在云上拿到的计算机是什么样的?.md
Normal 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)。那么,我们怎么在一个操作系统上,再去跑多个完整的操作系统呢?答案就是,我们自己做软件开发中很常用的一个解决方案,就是加入一个中间层。在虚拟机技术里面,这个中间层就叫作**虚拟机监视器**,英文叫VMM(Virtual 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中也可以通用的呢?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解。如果有收获,你也可以把今天的文章分享给你朋友。
|
||||
|
||||
|
||||
@@ -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里面,一个比特的数据,需要6~8个晶体管。所以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),通过拖拉,查看1990~2020年随着硬件设备的进展,访问延时的变化。
|
||||
|
||||
<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,但也非常值得阅读。
|
||||
|
||||
## 课后思考
|
||||
|
||||
最后,给你留一道思考题。在上世纪80~90年代,3.5寸的磁盘大行其道。它的存储空间只有1.44MB,比起当时40MB的硬盘,它却被大家认为是“海量”存储的主要选择。你猜一猜这是为什么?
|
||||
|
||||
欢迎把你思考的结果写在留言区。如果觉得有收获,你也可以把这篇文章分享给你的朋友,和他一起讨论和学习。
|
||||
|
||||
|
||||
@@ -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只有256K,L2 Cache有个1MB,L3 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.1~5.2小节,是一个很好的阅读材料。
|
||||
|
||||
## 课后思考
|
||||
|
||||
我们今天拿了亚马逊的商品和用户访问数据做了例子。请你想一下,如果是拿商品数量更多的淘宝网来看,你可以估算一下,至少需要使用多少DRAM的内存,或者其他存储设备呢?
|
||||
|
||||
欢迎留言和我分享你的思考过程和最终答案。如果自己的力量无法解决,你也可以拉上你的朋友一起讨论。
|
||||
@@ -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 < arr.length; i++) arr[i] *= 3;
|
||||
|
||||
|
||||
// 循环2
|
||||
for (int i = 0; i < 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运算(求余运算)来实现。下面我举个例子帮你理解一下。
|
||||
|
||||
比如说,我们的主内存被分成0~31号这样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之外,我们常见的缓存放置策略还有全相连Cache(Fully Associative Cache)、组相连Cache(Set 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了,通常用的是组相连Cache(set associative cache),想要了解组相连Cache,你可以阅读《计算机组成与设计:硬件/软件接口》的5.4.1小节。
|
||||
|
||||
## 课后思考
|
||||
|
||||
对于二维数组的访问,按行迭代和按列迭代的访问性能是一样的吗?你可以写一个程序测试一下,并思考一下原因。
|
||||
|
||||
欢迎把你思考的结果写在留言区。如果觉得有收获,你也可以把这篇文章分享给你的朋友,和他一起讨论和学习。
|
||||
@@ -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内存模型(JMM,Java 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 < 5){
|
||||
if( threadValue!= COUNTER){
|
||||
System.out.println("Got Change for COUNTER : " + COUNTER + "");
|
||||
threadValue= COUNTER;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class ChangeMaker extends Thread{
|
||||
@Override
|
||||
public void run() {
|
||||
int threadValue = COUNTER;
|
||||
while (COUNTER <5){
|
||||
System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");
|
||||
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 < 5){
|
||||
if( threadValue!= COUNTER){
|
||||
System.out.println("Sleep 5ms, Got Change for COUNTER : " + COUNTER + "");
|
||||
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关键字,在数据写入的性能上会不会有差异,以及这个差异到底会有多大。
|
||||
|
||||
欢迎把你写的程序分享到留言区。如果有困难,你也可以把这个问题分享给你朋友,拉上他一起讨论完成,并在留言区写下你们讨论后的结果。
|
||||
|
||||
|
||||
@@ -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里面的数据。这个广播操作,一般叫作RFO(Request 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协议,究竟做了哪些优化?
|
||||
|
||||
欢迎把你研究的结果写在留言区和大家分享。如果有收获,也欢迎你把这篇文章分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 课后思考
|
||||
|
||||
在实际的虚拟内存地址到物理内存地址的地址转换的过程里,我们没有采用哈希表,而是采用了多级页表的解决方案。你能想一想,使用多级页表,对于哈希表有哪些优点,又有哪些缺点吗?
|
||||
|
||||
欢迎留言和我分享你的想法,如果觉得有收获,你也可以把这篇文章分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
146
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:存储与I|O系统/41 | 理解内存(下):解析TLB和内存保护.md
Normal file
146
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:存储与I|O系统/41 | 理解内存(下):解析TLB和内存保护.md
Normal 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芯片里面,我们封装了内存管理单元(MMU,Memory 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["param1"]);
|
||||
// 我们直接通过 eval 计算出来对应的参数公式的计算结果
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
script.php?param1=";%20echo%20exec('rm -rf ~/');%20//
|
||||
// 用户传入的参数里面藏了一个命令
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
$code = ""; echo exec('rm -rf ~/'); //";
|
||||
// 执行的结果就变成了删除服务器上的数据
|
||||
|
||||
```
|
||||
|
||||
还有一个例子就是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 = "goodmorning12345";
|
||||
// 我们的密码是明文存储的
|
||||
|
||||
$hashed_password = hash('sha256', password);
|
||||
// 对应的hash值是 054df97ac847f831f81b439415b2bad05694d16822635999880d7561ee1b77ac
|
||||
// 但是这个hash值里可以用彩虹表直接“猜出来”原始的密码就是goodmorning12345
|
||||
|
||||
$salt = "#21Pb$Hs&Xi923^)?";
|
||||
$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的高速缓存和虚拟机结合的层面。理解这个漏洞,可以让你看到,安全问题是如何出现各种让人难以想到的结果。这也是为什么我们需要可执行空间保护和地址空间布局,随机化这样的“防卫性”的安全策略。即使我们不知道漏洞可以从哪里来,即使漏洞可能已经发生了,这些策略也能够使得我们的系统更不容易被攻破。
|
||||
|
||||
## 课后思考
|
||||
|
||||
除了我们今天说的可执行空间保护和地址空间布局随机化之外,你还知道其他内存保护策略吗?你想到的这些内存保护策略,和你日常的开发工作中,是否也有类似思路的应用呢?
|
||||
|
||||
欢迎留言和我分享你日常开发中用到的内存保护策略,如果这篇文章对你有帮助,你也可以把它分享给你的朋友,和他一起讨论和学习。
|
||||
|
||||
|
||||
89
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:存储与I|O系统/42 | 总线:计算机内部的高速公路.md
Normal file
89
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:存储与I|O系统/42 | 总线:计算机内部的高速公路.md
Normal 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技术,你可以搜索和翻阅一下相关资料,了解一下它引入了什么新的设计理念。
|
||||
|
||||
欢迎在留言区分享你查阅到的资料,以及阅读之后的思考总结,和大家一起交流。如果有收获,你也可以把这篇文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -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设备发送命令、查询状态还是传输数据,都可以通过这样的方式。这种方式呢,叫作**内存映射**IO(Memory-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/O(Port-Mapped I/O,简称PMIO)或者也可以叫独立输入输出(Isolated I/O)。
|
||||
|
||||
其实PMIO的通信方式和MMIO差不多,核心的区别在于,PMIO里面访问的设备地址,不再是在内存地址空间里面,而是一个专门的端口(Port)。这个端口并不是指一个硬件上的插口,而是和CPU通信的一个抽象概念。
|
||||
|
||||
无论是PMIO还是MMIO,CPU都会传送一条二进制的数据,给到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和蓝牙鼠标这个输入设备之间的通信是怎样的吗?
|
||||
|
||||
你可以好好思考一下,然后在留言区写下你的想法。当然,你也可以把这个问题分享给你的朋友,拉上他一起学习。
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<audio id="audio" title="44 | 理解IO_WAIT:I/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和DTR(Data 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> 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> 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硬盘的性能是怎么样的。
|
||||
|
||||
在上面的性能指标上,我们已经讲解了Seq,4K以及Acc.Time这三个指标,那么4K-Thrd这个指标又是什么意思呢?测试这个指标对应的应用场景又是怎么样的呢?
|
||||
|
||||
请你研究一下,把你得到的答案写在留言区,和大家一起分享讨论吧。另外,如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -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能够提升多少呢?
|
||||
|
||||
你可以拿出纸和笔算一算,然后把你的答案写在留言区。如果觉得有帮助,你可以把这篇文章分享给你的朋友,和他一起学习。
|
||||
|
||||
|
||||
@@ -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这样的方式进一步提升IOPS,HDD硬盘已经满足不了我们的需求了。上面这些优化措施,无非就是,把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硬盘上吗?
|
||||
|
||||
欢迎在留言区写下你的思考。如果有收获,你也可以把这篇文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -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硬盘。
|
||||
|
||||
欢迎在留言区分享你了解到的信息,和大家一起交流。如果有收获,你可以把这篇文章分享给你的朋友。
|
||||
|
||||
|
||||
149
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:存储与I|O系统/48 | DMA:为什么Kafka这么快?.md
Normal file
149
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:存储与I|O系统/48 | DMA:为什么Kafka这么快?.md
Normal 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和零拷贝,是否真的可以大幅度提升性能。
|
||||
|
||||
|
||||
@@ -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的一种特殊情况吗?
|
||||
|
||||
欢迎把你阅读和分析的内容写在留言区,和大家一起分享。如果觉得有帮助,你也可以把今天的内容分享给你的朋友。
|
||||
|
||||
|
||||
105
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:存储与I|O系统/50 | 数据完整性(下):如何还原犯罪现场?.md
Normal file
105
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:存储与I|O系统/50 | 数据完整性(下):如何还原犯罪现场?.md
Normal 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。这些数,就是我们的校验码位,我们把他们记录做p1~p4。如果从二进制的角度看,它们是这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)到两个位的出错。也就是说,虽然我们不知道是哪两个比特错了,但是我们还是知道数据是错了的。为什么能够做到这一点呢?
|
||||
|
||||
你可以好好思考一下,然后在留言区写下你的答案。如果有收获,你也可以把这篇文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -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也就是30~50台日常使用的开发机的计算性能。而我们今天在互联网上遇到的问题,是每天数亿的访问量,靠30~50台个人电脑的计算能力想要支撑这样的计算需求,可谓是天方夜谭了。
|
||||
|
||||
然而,一旦开始采用水平扩展,我们就会面临在软件层面改造的问题了。也就是我们需要开始进行**分布式计算**了。我们需要引入**负载均衡**(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 Failure,SPOF)。我们这里的这个假设特别糟糕。我们假设这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秒钟,如果连续2~3次发现健康检测失败,负载均衡就会自动将这台服务器的流量切换到其他服务器上。于是,我们就自动地产生了一次故障转移。故障转移的自动化在大型系统里是很重要的,因为服务器越多,出现故障基本就是个必然发生的事情。而自动化的故障转移既能够减少运维的人手需求,也能够缩短从故障发现到问题解决的时间周期,提高可用性。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/02/0907c25d6f2fd80401ee3d9bf5d17802.png" alt="">
|
||||
|
||||
那么,让我们算一算,通过水平扩展相同功能的服务器来去掉单点故障,并且通过健康检查机制来触发自动的故障转移,这样的可用性会变成多少呢?你可以拿出纸和笔来试一下。
|
||||
|
||||
不知道你想明白应该怎么算了没有,在这种情况下,我们其实只要有任何一台服务器能够正常运转,就能正常提供服务。那么,我们的可用性就是:
|
||||
|
||||
可以看出,不能提供服务的时间就减少到了原来的万分之一。
|
||||
|
||||
当然,在实际情况中,可用性没法做到那么理想的地步。光从硬件的角度,从服务器到交换机,从网线连接到机房电力,从机房的整体散热到外部的光纤线路等等,可能出现问题的地方太多了。这也是为什么,我们需要从整个系统层面,去设计系统的高可用性。
|
||||
|
||||
## 总结延伸
|
||||
|
||||
讲到这里,相信你已经很清楚,为什么我们需要水平扩展了。对于怎么去设计整个硬件的部署,来保障高可用性,你应该也有了一个清晰的认识。这两点也是分布式计算在实践中非常重要的应用场景。
|
||||
|
||||
不过,光有这两点还是不够的。一旦系统里面有了很多台服务器。特别是,为了保障可用性,对于同样功能的、有状态的数据库进行了水平的扩展,我们就会面临一个新的挑战,那就是分区一致性问题。不过,这个问题更多的是一个软件设计问题,我把它留在后面的实战篇再进行讲解。
|
||||
|
||||
我们下面来回顾一下这一讲的内容。我们讲了通过升级硬件规格来提升服务能力的垂直扩展。除此之外,也可以通过增加服务器数量来提升服务能力。不过归根到底,我们一定要走上水平扩展的路径。
|
||||
|
||||
一方面是因为垂直扩展不可持续;另一方面,则是只有水平扩展才能保障高可用性。而通过水平扩展保障高可用性,则需要我们做三件事情。第一个是理解可用性是怎么计算的。服务器硬件的损坏只是可能导致可用性损失的因素之一,机房内的电力、散热、交换机、网络线路,都有可能导致可用性损失。而外部的光缆、自然灾害,也都有可能造成我们整个系统的不可用。
|
||||
|
||||
所以,在分析设计系统的时候,我们需要尽可能地排除单点故障。进一步地,对于硬件的故障,我们还要有自动化的故障转移策略。在这些策略都齐全之后,我们才能真的长舒一口气,在海量的负载和流量下安心睡个好觉。
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
今天的推荐阅读,不是读一篇具体的文章,我推荐你可以常常去浏览一下[http://highscalability.com/](http://highscalability.com/)这个网站,里面有不少有价值的、讲解怎么做到高扩展性的小文章。
|
||||
|
||||
## 课后思考
|
||||
|
||||
你可以想一想,你现在在学习和工作中开发的系统,是否考虑到高可用性呢?你能找找这些系统中,你们做了哪些高可用性的硬件层面的设计,是否还存在哪些单点故障,以及做了哪些故障转移的措施来保持可用性吗?
|
||||
|
||||
你可以写在留言区,和大家一起分享一下实际的应用经验,也可以看看其他同学的工作中做了什么样的设计和相关工作,和大家一起交流、分享。
|
||||
|
||||
|
||||
170
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:指令和运算/05 | 计算机指令:让我们试试用纸带编程.md
Normal file
170
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:指令和运算/05 | 计算机指令:让我们试试用纸带编程.md
Normal 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操作系统上跑起来,我们需要把整个程序翻译成一个**汇编语言**(ASM,Assembly 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 <main>:
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
这个时候你可能又要问了,我们实际在用GCC(GUC编译器套装,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是0,rs代表第一个寄存器s1的地址是17,rt代表第二个寄存器s2的地址是18,rd代表目标的临时寄存器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把这个的汇编代码和机器码打出来。
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
170
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:指令和运算/06 | 指令跳转:原来if...else就是goto.md
Normal file
170
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:指令和运算/06 | 指令跳转:原来if...else就是goto.md
Normal 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 <time.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
|
||||
int main()
|
||||
{
|
||||
srand(time(NULL));
|
||||
int r = rand() % 2;
|
||||
int a = 10;
|
||||
if (r == 0)
|
||||
{
|
||||
a = 1;
|
||||
} else {
|
||||
a = 2;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们用rand生成了一个随机数r,r要么是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 <main+0x4a>
|
||||
{
|
||||
a = 1;
|
||||
41: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1
|
||||
48: eb 07 jmp 51 <main+0x51>
|
||||
}
|
||||
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,就把**零标志条件码**(对应的条件码是ZF,Zero Flag)设置为1。除了零标志之外,Intel的CPU下还有**进位标志**(CF,Carry Flag)、**符号标志**(SF,Sign Flag)以及**溢出标志**(OF,Overflow 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 < 3; i++)
|
||||
{
|
||||
a += i;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们再看一段简单的利用for循环的程序。我们循环自增变量i三次,三次之后,i>=3,就会跳出循环。整个程序,对应的Intel汇编代码就是这样的:
|
||||
|
||||
```
|
||||
for (int i = 0; i <= 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语言程序,编译成汇编代码看一看。
|
||||
|
||||
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
@@ -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 <stdio.h>
|
||||
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 <main>:
|
||||
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 <add>
|
||||
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还可以调用函数B,B还能调用函数C。这一层又一层的调用并没有数量上的限制。在所有函数调用返回之前,每一次调用的返回地址都要记录下来,但是我们CPU里的寄存器数量并不多。像我们一般使用的Intel i7 CPU只有16个64位寄存器,调用的层数一多就存不下了。
|
||||
|
||||
最终,计算机科学家们想到了一个比单独记录跳转回来的地址更完善的办法。我们在内存里面开辟一段空间,用栈这个**后进先出**(LIFO,Last 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函数入口在0~1行,add函数结束之后在12~13行。
|
||||
|
||||
我们在调用第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 <stdio.h>
|
||||
#include <time.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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("u = %d\n", 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的指令集。
|
||||
|
||||
## 课后思考
|
||||
|
||||
在程序栈里面,除了我们跳转前的指令地址外,还需要保留哪些信息,才能在我们在函数调用完成之后,跳转回到指令地址的时候,继续执行完函数调用之后的指令呢?
|
||||
|
||||
你可以想一想,查一查,然后在留言区留下你的思考和答案,也欢迎你把今天的内容分享给你的朋友,和他一起思考和进步。
|
||||
|
||||
|
||||
@@ -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 <stdio.h>
|
||||
int main()
|
||||
{
|
||||
int a = 10;
|
||||
int b = 5;
|
||||
int c = add(a, b);
|
||||
printf("c = %d\n", 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 <add>:
|
||||
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 <main>:
|
||||
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 <main+0x2a>
|
||||
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 <main+0x39>
|
||||
39: b8 00 00 00 00 mov eax,0x0
|
||||
3e: e8 00 00 00 00 call 43 <main+0x43>
|
||||
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 <main>:
|
||||
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 <add>
|
||||
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 <_IO_stdin_used+0x4>
|
||||
6fd: b8 00 00 00 00 mov eax,0x0
|
||||
702: e8 59 fe ff ff call 560 <printf@plt>
|
||||
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格式,我推荐你阅读《程序员的自我修养——链接、装载和库》的1~4章。这是一本难得的讲解程序的链接、装载和运行的好书。
|
||||
|
||||
## 课后思考
|
||||
|
||||
你可以通过readelf读取出今天演示程序的符号表,看看符号表里都有哪些信息;然后通过objdump读取出今天演示程序的重定位表,看看里面又有哪些信息。
|
||||
|
||||
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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这样使用虚拟机的编程语言里面,我们写的程序是怎么装载到内存里面来的呢?它也和我们讲的一样,是通过内存分页和内存交换的方式加载到内存里面来的么?
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
179
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:指令和运算/10 | 动态链接:程序内部的“共享单车”.md
Normal file
179
极客时间专栏/geek/深入浅出计算机组成原理/原理篇:指令和运算/10 | 动态链接:程序内部的“共享单车”.md
Normal 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 Libary(DLL,动态链接库)。在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 <stdio.h>
|
||||
|
||||
|
||||
void show_me_the_money(int money)
|
||||
{
|
||||
printf("Show me USD %d from lib.c \n", money);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,show_me_poor.c 调用了 lib 里面的函数。
|
||||
|
||||
```
|
||||
// show_me_poor.c
|
||||
#include "lib.h"
|
||||
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 <show_me_the_money@plt-0x10>:
|
||||
400540: ff 35 12 05 20 00 push QWORD PTR [rip+0x200512] # 600a58 <_GLOBAL_OFFSET_TABLE_+0x8>
|
||||
400546: ff 25 14 05 20 00 jmp QWORD PTR [rip+0x200514] # 600a60 <_GLOBAL_OFFSET_TABLE_+0x10>
|
||||
40054c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
|
||||
|
||||
0000000000400550 <show_me_the_money@plt>:
|
||||
400550: ff 25 12 05 20 00 jmp QWORD PTR [rip+0x200512] # 600a68 <_GLOBAL_OFFSET_TABLE_+0x18>
|
||||
400556: 68 00 00 00 00 push 0x0
|
||||
40055b: e9 e0 ff ff ff jmp 400540 <_init+0x28>
|
||||
……
|
||||
0000000000400676 <main>:
|
||||
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 <show_me_the_money@plt>
|
||||
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 <show_me_the_money@plt>
|
||||
|
||||
```
|
||||
|
||||
这里后面有一个@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 <_GLOBAL_OFFSET_TABLE_+0x18>
|
||||
|
||||
```
|
||||
|
||||
在动态链接对应的共享库,我们在共享库的data section里面,保存了一张**全局偏移表**(GOT,Global 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的内存空间也会一下子就用出去了。这个问题在过去计算机的内存较少的时候更加显著。
|
||||
|
||||
通过动态链接这个方式,可以说彻底解决了这个问题。就像共享单车一样,如果仔细经营,是一个很有社会价值的事情,但是如果粗暴地把它变成无限制地复制生产,给每个人造一辆,只会在系统内制造大量无用的垃圾。
|
||||
|
||||
过去的05~09这五讲里,我们已经把程序怎么从源代码变成指令、数据,并装载到内存里面,由CPU一条条执行下去的过程讲完了。希望你能有所收获,对于一个程序是怎么跑起来的,有了一个初步的认识。
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
想要更加深入地了解动态链接,我推荐你可以读一读《程序员的自我修养:链接、装载和库》的第7章,里面深入地讲解了,动态链接里程序内的数据布局和对应数据的加载关系。
|
||||
|
||||
## 课后思考
|
||||
|
||||
像动态链接这样通过修改“地址数据”来进行间接跳转,去调用一开始不能确定位置代码的思路,你在应用开发中使用过吗?
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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之间有什么关系。理解了这些,相信以后遇到任何乱码问题,你都能手到擒来了。
|
||||
|
||||
## 理解二进制的“逢二进一”
|
||||
|
||||
二进制和我们平时用的十进制,其实并没有什么本质区别,只是平时我们是“逢十进一”,这里变成了“逢二进一”而已。每一位,相比于十进制下的0~9这十个数字,我们只能用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表示0,1000在这样的情况下表示-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,通过原码和补码是如何进行的?
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
@@ -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”的操作,其实就是来自于门电路,叫作组合逻辑电路。
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
《编码:隐匿在计算机软硬件背后的语言》的第6~11章,是一个很好的入门材料,可以帮助你深入理解数字电路,值得你花时间好好读一读。
|
||||
|
||||
## 课后思考
|
||||
|
||||
除了与、或、非之外,还有很多基础的门电路,比如“异或(XOR)门”。你可以想一想,试着搜索一些资料,设计一个异或门的电路。
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
@@ -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(通常是&符号)”“ 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 “加法器优化”一节,了解一下超前进位加法器的实现原理,以及我们为什么要使用它。
|
||||
|
||||
## 课后思考
|
||||
|
||||
这一讲,我给你详细讲解了无符号数的加法器是怎么通过电路搭建出来的。那么,如果是使用补码表示的有符号数,这个加法器是否可以实现正数加负数这样的运算呢?如果不行,我们应该怎么搭建对应的电路呢?
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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节。
|
||||
|
||||
## 课后思考
|
||||
|
||||
这一讲里,我为你讲解了乘法器是怎么实现的。那么,请你想一想,如果我们想要用电路实现一个除法器,应该怎么做呢?需要注意一下,除法器除了要计算除法的商之外,还要计算出对应的余数。
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
@@ -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”,然后看看你会得到一个什么样的结果。
|
||||
|
||||
```
|
||||
>>> 0.3 + 0.6
|
||||
0.8999999999999999
|
||||
|
||||
```
|
||||
|
||||
不知道你有没有大吃一惊,这么简单的一个加法,无论是在Python还是在JavaScript里面,算出来的结果居然不是准确的0.9,而是0.8999999999999999这么个结果。这是为什么呢?
|
||||
|
||||
在回答为什么之前,我们先来想一个更抽象的问题。通过前面的这么多讲,你应该知道我们现在用的计算机通常用16/32个比特(bit)来表示一个数。那我问你,我们用32个比特,能够表示所有实数吗?
|
||||
|
||||
答案很显然是不能。32个比特,只能表示2的32次方个不同的数,差不多是40亿个。如果表示的数要超过这个数,就会有两个不同的数的二进制表示是一样的。那计算机可就会一筹莫展,不知道这个数到底是多少。
|
||||
|
||||
40亿个数看似已经很多了,但是比起无限多的实数集合却只是沧海一粟。所以,这个时候,计算机的设计者们,就要面临一个问题了:我到底应该让这40亿个数映射到实数集合上的哪些数,在实际应用中才能最划得来呢?
|
||||
|
||||
## 定点数的表示
|
||||
|
||||
有一个很直观的想法,就是我们用4个比特来表示0~9的整数,那么32个比特就可以表示8个这样的整数。然后我们把最右边的2个0~9的整数,当成小数部分;把左边6个0~9的整数,当成整数部分。这样,我们就可以用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个比特能够表示的整数空间,就是0~255。我们在这里用1~254映射到-126~127这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应该是0,f应该是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=0,e = 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.1~0.9这9个数,其中只有0.5能够被精确地表示成二进制的浮点数,也就是s = 0、e = -1、f = 0这样的情况。
|
||||
|
||||
而0.3、0.6乃至我们希望的0.9,都只是一个近似的表达。这个也为我们带来了一个挑战,就是浮点数无论是表示还是计算其实都是近似计算。那么,在使用过程中,我们该怎么来使用浮点数,以及使用浮点数会遇到些什么问题呢?下一讲,我会用更多的实际代码案例,来带你看看浮点数计算中的各种“坑”。
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
如果对浮点数的表示还不是很清楚,你可以仔细阅读一下《计算机组成与设计:硬件/软件接口》的3.5.1节。
|
||||
|
||||
## 课后思考
|
||||
|
||||
对于BCD编码的定点数,如果我们用7个比特来表示连续两位十进制数,也就是00~99,是不是可以让32比特表示更大一点的数据范围?如果我们还需要表示负数,那么一个32比特的BCD编码,可以表示的数据范围是多大?
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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("c is " + c);
|
||||
float d = c - a;
|
||||
System.out.println("d is " + d);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对应的输出结果就是:
|
||||
|
||||
```
|
||||
c is 2.0E7
|
||||
d is 0.0
|
||||
|
||||
```
|
||||
|
||||
## Kahan Summation算法
|
||||
|
||||
那么,我们有没有什么办法来解决这个精度丢失问题呢?虽然我们在计算浮点数的时候,常常可以容忍一定的精度损失,但是像上面那样,如果我们连续加2000万个1,2000万的数值都会被精度损失丢掉了,就会影响我们的计算结果。
|
||||
|
||||
一个常见的应用场景是,在一些“积少成多”的计算过程中,比如在机器学习中,我们经常要计算海量样本计算出来的梯度或者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 < 20000000; i++) {
|
||||
float x = 1.0f;
|
||||
sum += x;
|
||||
}
|
||||
System.out.println("sum is " + 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 < 20000000; i++) {
|
||||
float x = 1.0f;
|
||||
float y = x - c;
|
||||
float t = sum + y;
|
||||
c = (t-sum)-y;
|
||||
sum = t;
|
||||
}
|
||||
System.out.println("sum is " + 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位浮点数的加法,两个数相差多少的情况后,较小的哪个数在加法过程中会完全丢失呢?
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问,和大家一起探讨。你也可以把今天的文章分享给你朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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. 低成本:我们还是从广告系统的角度来考虑。广告系统的收入通常用CPM(Cost 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硬盘,又会带来哪些好处和坏处呢?
|
||||
|
||||
请你仔细思考一下,也可以和周围的朋友分享讨论。如果你觉得有所收获,也请你把你的想法写在留言区,分享给其他的同学。
|
||||
119
极客时间专栏/geek/深入浅出计算机组成原理/应用篇/53 | 设计大型DMP系统(下):SSD拯救了所有的DBA.md
Normal file
119
极客时间专栏/geek/深入浅出计算机组成原理/应用篇/53 | 设计大型DMP系统(下):SSD拯救了所有的DBA.md
Normal 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过滤掉,而不需要访问硬盘。
|
||||
|
||||
这样,只有当数据在内存里面没有,并且在硬盘的某个特定文件上的时候,才会触发一次对于硬盘的读请求。
|
||||
|
||||
## SSD:DBA们的大救星
|
||||
|
||||
Cassandra是Facebook在2008年开源的。那个时候,SSD硬盘还没有那么普及。可以看到,它的读写设计充分考虑了硬件本身的特性。在写入数据进行持久化上,Cassandra没有任何的随机写请求,无论是Commit Log还是Dump,全部都是顺序写。
|
||||
|
||||
在数据读的请求上,最新写入的数据都会更新到内存。如果要读取这些数据,会优先从内存读到。这相当于是一个使用了LRU的缓存机制。只有在万般无奈的情况下,才会有对于硬盘的随机读请求。即使在这样的情况下,Cassandra也在文件之前加了一层BloomFilter,把本来因为Dump文件带来的需要多次读硬盘的问题,简化成多次内存读和一次硬盘读。
|
||||
|
||||
这些设计,使得Cassandra即使是在HDD硬盘上,也能有不错的访问性能。因为所有的写入都是顺序写或者写入到内存,所以,写入可以做到高并发。HDD硬盘的吞吐率还是很不错的,每秒可以写入100MB以上的数据,如果一条数据只有1KB,那么10万的WPS(Writes per seconds)也是能够做到的。这足够支撑我们DMP期望的写入压力了。
|
||||
|
||||
而对于数据的读,就有一些挑战了。如果数据读请求有很强的局部性,那我们的内存就能搞定DMP需要的访问量。
|
||||
|
||||
但是,问题就出在这个局部性上。DMP的数据访问分布,其实是缺少局部性的。你仔细想一想DMP的应用场景就明白了。DMP里面的Row Key都是用户的唯一标识符。普通用户的上网时长怎么会有局部性呢?每个人上网的时间和访问网页的次数就那么多。上网多的人,一天最多也就24小时。大部分用户一天也要上网2~3小时。我们没办法说,把这些用户的数据放在内存里面,那些用户不放。
|
||||
|
||||
<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的并发写入性能极高。
|
||||
|
||||
在数据读取层面,通过内存缓存和BloomFilter,Cassandra已经尽可能地减少了需要随机读取硬盘里面数据的情况。不过挑战在于,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这样的数据库里面的数据是怎么存储在硬盘上的。
|
||||
|
||||
欢迎把你研究的结果写在留言区,和大家一起分享、交流。如果觉得有帮助,你也可以把这篇文章分享给你的朋友,和他一起讨论、学习。
|
||||
@@ -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这个类里面定义了p1,p2一直到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<E> extends RingBufferPad
|
||||
{
|
||||
......
|
||||
private final long indexMask;
|
||||
private final Object[] entries;
|
||||
protected final int bufferSize;
|
||||
protected final Sequencer sequencer;
|
||||
......
|
||||
}
|
||||
|
||||
public final class RingBuffer<E> extends RingBufferFields<E> implements Cursored, EventSequencer<E>, EventSink<E>
|
||||
{
|
||||
......
|
||||
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),看看运行的结果是什么样的。
|
||||
|
||||
欢迎你把你的测试结果写在留言区,和大家一起讨论、分享。如果有收获,你也可以把这篇文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -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 < max) {
|
||||
counter++;
|
||||
}
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("Time spent is " + (end-start) + "ms without lock");
|
||||
}
|
||||
|
||||
|
||||
public static void runIncrementWithLock()
|
||||
{
|
||||
Lock lock = new ReentrantLock();
|
||||
long counter = 0;
|
||||
long max = 500000000L;
|
||||
long start = System.currentTimeMillis();
|
||||
while (counter < max) {
|
||||
if (lock.tryLock()){
|
||||
counter++;
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("Time spent is " + (end-start) + "ms with lock");
|
||||
}
|
||||
|
||||
|
||||
public static void main(String[] args) {
|
||||
runIncrement();
|
||||
runIncrementWithLock();
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
Time spent is 207ms without lock
|
||||
Time spent is 9603ms with lock
|
||||
|
||||
```
|
||||
|
||||
## 无锁的RingBuffer
|
||||
|
||||
加锁很慢,所以Disruptor的解决方案就是“无锁”。这个“无锁”指的是没有操作系统层面的锁。实际上,Disruptor还是利用了一个CPU硬件支持的指令,称之为CAS(Compare 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]< == [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() < max) {
|
||||
}
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("Time spent is " + (end-start) + "ms with cas");
|
||||
}
|
||||
|
||||
|
||||
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&rep=rep1&type=pdf)这篇论文。你可以更深入地学习一下,怎么实现一个无锁队列。
|
||||
|
||||
## 课后思考
|
||||
|
||||
最后,给你留一道思考题。这道题目有点儿难,不过也很有意思。
|
||||
|
||||
请你阅读一下Disruptor开源库里面的Sequence这个类的代码,看看它和一个普通的AtomicLong到底有什么区别,以及为什么它要这样实现。
|
||||
|
||||
欢迎在留言区写下你的思考和答案,和大家一起探讨应用层和硬件层之间的关联性。如果有收获,你也可以把这篇文章分享给你的朋友。
|
||||
|
||||
|
||||
55
极客时间专栏/geek/深入浅出计算机组成原理/应用篇/结束语 | 知也无涯,愿你也享受发现的乐趣.md
Normal file
55
极客时间专栏/geek/深入浅出计算机组成原理/应用篇/结束语 | 知也无涯,愿你也享受发现的乐趣.md
Normal 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)
|
||||
@@ -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. 坚持到底就是胜利✌️。把学习和成长变成一种习惯,这个习惯带来的惯性会让你更快地成长。
|
||||
|
||||
好了,到这里,我们第一期答疑就要结束了。这次我主要和你谈了谈“学习”这个话题,不知道你有什么感受呢?你还想听我和你聊什么专栏之外的话题呢?
|
||||
|
||||
欢迎积极留言给我。如果觉得这篇文章对你有帮助,也欢迎你收藏并分享给你的朋友。对了,看到这里的同学,记得在留言区给后面的同学招个手啊:)
|
||||
|
||||
|
||||
112
极客时间专栏/geek/深入浅出计算机组成原理/答疑与加餐/FAQ第二期 | 世界上第一个编程语言是怎么来的?.md
Normal file
112
极客时间专栏/geek/深入浅出计算机组成原理/答疑与加餐/FAQ第二期 | 世界上第一个编程语言是怎么来的?.md
Normal 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 >= 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 &
|
||||
|
||||
```
|
||||
|
||||
然后,我们利用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="">
|
||||
|
||||
@胖胖胖 同学说得很好。在专栏最开始几篇,或者到后面比较深入的文章,很多非科班的或者基础不太好的同学,会觉得读不下去,甚至很多地方看不懂。这些其实都是正常现象。
|
||||
|
||||
即便我在写的时候,已经尽可能考虑得比较完善,照顾大家的情况,但是肯定无法面面俱到。在我平时学习过程遇到拦路虎的时候,我一般有两种方法,这里跟你分享一下。
|
||||
|
||||
第一种,硬读。
|
||||
|
||||
你可能说了,这也叫方法吗?没错,事实就是这样。如果这个知识点,我必须要攻克,就想要搞明白,那我就会尽我所能,去看每一个字眼,把每个不理解的地方,都一点一点搞明白。不吝啬花费时间和精力。
|
||||
|
||||
当然这种情况适合我对这个内容完全不了解,或者已经基本了解,现在需要进一步提升的情况下。因为,在完全不了解一个知识的时候,这个壁垒是很高的。如果不想办法突破的话,那可能就没办法了解这个新的领域。而在已经基本了解某个领域或者某块知识的情况下,我去攻克一些更高难度的知识,很多时候也需要同样的方法,我会建立在兴趣的基础上去硬读,但是之后会非常非常有成就感。
|
||||
|
||||
第二种,先抓主要矛盾,再抓细节问题。
|
||||
|
||||
很多时候,大家在对一个知识不了解的时候,会感觉很“恐慌”。其实完全没必要,大家学任何东西都是从不会到会这么一个过程。就像@胖胖胖 同学说的那样,先找出这篇文章的主干,先对这些东西有个大致的概念。如果有需要,在之后的过程中,你还会碰到,你可以再重读,加深印象。
|
||||
|
||||
有时候,学习知识可以尝试“短期多次”。也就是说,看完一遍之后,如果不明白,先放下,过一段时间再看一遍,如果还不明白,再过一段时间再看。这样循环几次,在大脑中发酵几次,说不定就明白了,要给大脑一个缓冲的时间。
|
||||
|
||||
好了,今天的答疑到这里就结束了。不知道能否帮你解决了一些疑惑和问题呢?
|
||||
|
||||
我会持续不断地回复留言,并把比较好的问题精选出来,作为答疑。欢迎你继续在留言区留言,和大家一起交流学习。
|
||||
|
||||
|
||||
105
极客时间专栏/geek/深入浅出计算机组成原理/答疑与加餐/特别加餐 | 我在2019年F8大会的两日见闻录.md
Normal file
105
极客时间专栏/geek/深入浅出计算机组成原理/答疑与加餐/特别加餐 | 我在2019年F8大会的两日见闻录.md
Normal 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="">
|
||||
|
||||
于是,今年的F8,Facebook颇有些破釜沉舟、不破不立之感。扎克伯格的开场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 & 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/)
|
||||
|
||||
|
||||
41
极客时间专栏/geek/深入浅出计算机组成原理/答疑与加餐/特别加餐 | 我的一天怎么过?.md
Normal file
41
极客时间专栏/geek/深入浅出计算机组成原理/答疑与加餐/特别加餐 | 我的一天怎么过?.md
Normal 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点,除非已经是死线了。不然即使进度比想象中慢一点,我也会先去睡了,不然第二天效率更差。毕竟,明天我们又要开始创造明天么。
|
||||
|
||||
这就是我上周的一天,不知道和你想象中差别大吗?下次有机会,我会再写写我在海外出差的一天是什么过的。
|
||||
|
||||
最后,我想听你讲讲,你的一天是怎么过的呢?欢迎在留言区和同学们一起分享。
|
||||
@@ -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讲时写过,“**我工作之后一直在持续学习,在这个过程中,我发现最有效的方法,不是短时间冲刺,而是有节奏的坚持**。”我对这句话真是深有感触,所以一直记到现在,估计你也是吧?
|
||||
|
||||
## 总结
|
||||
|
||||
通过专栏的学习,我确实收获了很多,真的非常感谢徐文浩老师的付出,也感谢极客时间推出了这么多实用的基础课程。
|
||||
|
||||
其实,尽管毕业之后工作才半年左右,但是我的心里其实挺焦虑的,主要是担心自己在如此快速的技术变革中,跟不上变化,慢慢被淘汰。在这个过程中,我也思考了很多。通过学习专栏,我的焦虑情绪也化解了很多。在这里我也想说说我对于焦虑的看法。
|
||||
|
||||
我觉得人之所以会感到焦虑,是因为有上进心,说白了就是觉得自己不够好。这其实是一件好事,正是因为觉得自己不够好,我们才能产生变得更好的想法,进而找到变得更好的方法,所以我们要正确看待焦虑这种情绪。而**缓解焦虑的方式很简单,就是行动。担心自己长胖,那就去锻炼;担心自己被淘汰,那就去学习。**
|
||||
|
||||
我特别喜欢胡适说的一句话:“怕什么真理无穷,进一寸有一寸的欢喜”。
|
||||
|
||||
用这句话与各位共勉吧,希望我们都能抱着长线投资的心态,坚持下去,和时间做朋友,不要急躁。虽然学完这些基础知识,老板也看不到,短时间内也不会加薪升职,但我相信,它们会在未来的某个时间回馈你,让你知道现在的决定是正确的,现在的付出是值得的!
|
||||
|
||||
好了,我想要分享的内容就是这些,不知道你学习这个专栏的过程是怎样的呢?有没有什么独特的学习方法和心路历程呢?欢迎你写在留言区,我们一起分享,相互鼓励,共同进步!
|
||||
|
||||
|
||||
Reference in New Issue
Block a user