This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
<audio id="audio" title="01 | 架构设计的宏观视角" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/e3/59862f0b0bc3d08aac4d3b6d8147e4e3.mp3"></audio>
你好,我是七牛云许式伟。今天我们来谈谈架构设计的宏观视角。
在信息科技高度发展的今天我们每个人随时随地都可以接触到由程序驱动的智能电子设备包括手机如iPhone、oppo拍照手机、平板电脑如iPad、手表如iWatch、小天才智能手表、音箱如天猫精灵、汽车如特斯拉等等。
这些东西背后是怎么工作的?单就其中的软件系统而言,这些小小的设备上往往运行着成千上万个软件模块,这些模块是如何如此精密地一起协作的?
对此,我过去接触过很多的软件开发工程师,或者架构师,很多人对这些原理也是一知半解,虽然“知其然”,但却“不知其所以然”。甚至有些朋友可能觉得,学这些有什么用处呢,在我看来,这部分内容恰恰是我们成为架构师很重要的一门基础课。
## 为什么需要建立宏观视角?
如同造房子有建筑工人(负责搬砖)和建筑师(负责架构设计)一样,软件系统的开发过程同样需要有程序员(负责搬“砖”)和架构师(负责架构设计)。作为架构师,我们需要的第一个能力是宏观的全局掌控能力。
如果把应用程序比作一座大厦,那么我们作为大厦的架构师,需要把大厦的结构搭建好,让程序员可以把砖填充进去,我们都知道,一个大厦的结构建得是否稳固,与地基密不可分。
所以,我们首先就需要从大厦的地基开始,熟悉这座大厦。毕竟,你对所依赖的基础架构了解得越全面,做业务架构设计就会越发从容。
介绍基础架构的知识点并不是让你真的去实现它们。但你仍然需要懂得它们的核心思想是什么,知道有哪些信息是你必须深刻理解的,以便可以更好地驾驭它们。
**我们的整个专栏内容也会从基础架构开始讲起,最后逐步过渡到业务架构,到最终完成一个完整应用程序的设计过程。**
那么,在今天的开篇第一篇,我们需要站在宏观视角,从基础架构开始,逐渐来解剖一个应用程序的整体构成,我希望,通过今天的文章,可以让你对于一个程序的全貌,形成完整的认识。
我们从头开始。
## 应用程序的基础架构
我们想学习一个程序的基础架构,其实就是弄清楚电脑的工作原理,以及程序的运行原理。
无论是什么样的智能电子设备,手机也好,汽车也罢,它们都可以称为“电脑”。所有的电脑都可以统一看作由“**中央处理器+存储+一系列的输入输出设备**”构成。
中央处理器也就是我们平常说的CPU负责按指令执行命令存储负责保存数据包括我们要执行的命令也是以数据形式保存在存储中的。
每次在打开电脑的电源后,中央处理器都会从存储的某个固定位置处开始读入数据(也就是指令),并且按指令执行命令,执行完一条指令就会继续执行下一条指令。电脑就这样开始工作了。
你可能会说,就这么简单?是的,就是这么简单。
**那这么简单的话,为何电脑能够完成这么多复杂而多样化的工作?**
这整个过程,在我看来主要依赖两点。
**第一是可编程性。**大体来说中央处理器CPU的指令分为如下这几类。
- 计算类也就是支持我们大家都熟知的各类数学运算如加减乘除、sin/cos等等。
- I/O类从存储读写数据从输入输出设备读数据、写数据。
- 指令跳转类,在满足特定条件下跳转到新的当前程序执行位置。
虽然, CPU 指令是一个很有限的指令集但是CPU 执行的指令序列(或者叫“程序”)并不是固定的,而是依赖保存在存储中的数据—— 由软件工程师(或者叫“程序员”)编写的软件来决定。指令序列的可能性是无穷的,这也就意味着电脑能够做的事情的可能性也是无穷的。
**第二是开放设计的外部设备支持。**虽然我们电脑可以连接非常非常多种类的外部设备比如键盘、打印机、屏幕、汽车马达等等但CPU 并不理解这些设备具体有什么样的能力,它只和这些设备交换数据。它能够做的是从某个编号的设备(通常这个设备编号被称为“端口”)读入一段数据,或者向设备的端口写入一段数据。
例如当你在键盘上按下了A的时候CPU 可以从键盘连接的端口读到一段数据通过这段数据来表达你按了“A”可能CPU 会向打印机连接的端口发送一段数据来驱动打印机打印特定的文本还有可能CPU 会向汽车马达所在的端口发送数据,来驱动马达转动,从而让汽车按照预期来行驶。
值得注意的是CPU 知道的是如何和这些设备交换数据但是并不理解数据代表什么含义。这些外部设备的厂商在提供设备硬件的同时往往也需要提供和硬件匹配的软件来完成和CPU 的协作,让软件工程师可以轻松使用这些设备。
从上面可以看出,**电脑的 CPU 是一个非常简洁的模型,它只读入和写出数据,对数据进行计算。**这也是为什么我们往往把电脑也叫作“计算机”,这是因为 CPU 这个计算机的大脑的确只会做“计算”。
这个基础的设计体系我们很多人都知道这就是冯·诺依曼计算机体系。1945年6月冯·诺依曼以“关于EDVAC的报告草案”为题起草的长达101页的总结报告定义了“冯·诺依曼体系结构”他现在也被称为计算机之父。我想看到这里你应该不难理解他的伟大之处了吧
有了这个基础的计算机体系之后,我们就可以编写软件了。
当然我们遇到的第一个问题是**直接用机器指令编写软件太累,而且这些机器指令像天书一样没人看得懂,没法维护。**
所以,**编程语言+编译器**就出现了。编译器负责把我们人类容易理解的语言,转换为机器可以理解的机器指令,这样一来就大大解放了编写软件的门槛。
在编写软件不是问题时,我们遇到的第二个问题,就是**多个软件在同一个电脑上怎么共处。多个软件大家往同一个存储地址写数据冲突怎么办?一起往打印机去发送打印指令怎么办?有的软件可能偷偷搞破坏怎么办?**
于是,**操作系统**就出现了。
**它首先要解决的是软件治理的问题。**它要建立安全保护机制,确保你的电脑免受恶意软件侵害。同时,它也要建立软件之间的协作秩序,让大家按照期望的方式进行协作。比如存储你写到这里,那么我就要写到别处;使用打印机要排队,你打完了,我才能接着去打印。
操作系统**其次解决的是基础编程接口问题。**这些编程接口一方面简化了软件开发,另一方面提供了多软件共存(多任务)的环境,实现了软件治理。
例如,对于屏幕设备,操作系统需要提供多任务窗口系统,以避免屏幕被多个软件画得乱七八糟;对于键盘输入设备,操作系统引入焦点窗口,以确定键盘输入的事件被正确发送到正确的软件程序。
你会发现,今天的我们开发软件的时候,已经处于一些基础的架构设计之中。像冯·诺依曼计算机体系,像操作系统和编程语言,这些都是我们开发一个应用程序所依赖的基础架构。
基础架构解决的是与业务无关的一些通用性的问题,这些问题往往无论你具体要做什么样的应用都需要面对。而且,基础架构通常以独立的软件存在,所以也称为基础软件。
例如我们熟知的Linux、Nginx、MySQL、PHP 等这些软件都属于基础软件,这些基础软件极大地降低了应用开发的难度。在今天软件服务化的大趋势下,很多基础软件最终以互联网服务的方式提供,这就是所谓的“云计算”。
## 完整的程序架构是怎样的?
讲完了程序的地基,让我们来总览一下程序的完整架构。
在越强大的基础架构支撑下,应用程序开发需要关注的问题就越收敛,我们的开发效率就越高。**在我们只需要关注应用程序本身的业务问题如何构建时,我们说自己是在设计应用程序的业务架构(或者叫“应用架构”)。**
业务架构虽然会因为应用的领域不同而有很大的差异,但不同业务架构之间,仍然会有许多共通的东西。它们不只遵循相同的架构原则,还可以遵循相同的设计范式。
一些设计范式被人们以应用程序框架的方式固化下来。例如在用户交互领域有著名的MVC 框架如JavaScript 语言的AngularPHP 语言的ZendPython 语言的 Django在游戏开发领域有各种游戏引擎如JavaScript 语言的 PhaserC# 语言的 Unity3D等等。
对于一个服务端应用程序来说,其完整的架构体系大体如下:
<img src="https://static001.geekbang.org/resource/image/55/37/5553453858eb86bf88a5623255f20037.png" alt="">
对于客户端应用程序来说,和服务端的情况会有非常大的差别。客户端首先面临的是多样性的挑战。
单就操作系统来说PC 就有Windows、Mac、Linux 等数十种手机也有Android、iOSWindows Mobile 等等。而设备种类而言就更多了,不只有笔记本、平板电脑,还有手机、手表、汽车,未来只会更加多样化。
第一个想消除客户端的多样性,并且跨平台提供统一编程接口的,是浏览器。
可能在很多人看来,浏览器主要改变的是软件分发的方式,让软件可以即取即用,无需安装。但从技术角度来说,底层操作系统对软件的支持同样可以做到即取即用。
这方面苹果在iOS 上已经在尝试大家可能已经留意到如果你一个软件很久没有用iPhone 就会把这个软件从本地清理出去,而在你下一次使用它时又自动安装回来。
假如软件包足够小,那么这种行为和 Web 应用就毫无区别。不同之处只在于Web 应用基于的指令不是机器码,而是更高阶的 JavaScript 脚本。
JavaScript 因为指令更高阶,所以程序的尺寸比机器码会有优势。但另一方面来说 JavaScript 是文本指令,表达效率又要比机器码低。
但这一点也在发生变化,近年来 WebAssembly 技术开始蓬勃发展JavaScript 作为浏览器的机器码的地位会被逐步改变,我们前端开发会面临更多的可能性。
浏览器的地位非常特殊,我们可以看作操作系统之上的操作系统。一旦某种浏览器流行起来,开发人员都在浏览器上做应用,那么必然会导致底层操作系统管道化,这是操作系统厂商所不愿意看到的。
而如果浏览器用户量比较少,那么通过它能够触达的用户量就太少,消除不同底层操作系统差异的价值就不存在,开发人员也就不乐意在上面开发应用。
我们知道PC 的浏览器之战打到今天基本上就剩下Chrome、Internet Explorer、Safari、Firefox 等。
有趣的是,移动浏览器的战场似乎是从中国开始打起的,这就是微信引发的小程序之战,它本质上是一场浏览器的战争。
浏览器是一个基础软件,它能够解决多大的问题,依赖于它的市场占有率。但是基于同样的浏览器技术核心也可以构建出跨平台的应用框架。我们看到 React Native 就是沿着这个思路走的。当然这不是唯一的一条路,还有人会基于类似 QT 这样的传统跨平台方案。
整体来说,对于一个客户端应用程序来说,其完整的架构体系大体如下:
<img src="https://static001.geekbang.org/resource/image/3a/c7/3af7a4830566a5b3e1058f409422b7c7.png" alt="">
对于架构师来说,不仅仅只是想清楚业务应该怎么去做好分解,整个应用从底到最顶层的上层建筑,每一层都需要进行各种决策。先做 iOS 版本,还是先做小程序?是选择 Java 还是 Go 语言?这些都是架构的一部分。
## 结语
今天,我们从“计算机是如何工作”开始,一起登高鸟瞰,总览了程序完整的架构体系。
**可能有人看到今天的内容心里会有些担心:“原来架构师要学这么多东西,看来我离成为架构师好远。”**
好消息是:我们就是来打消这个担心的。如果我们把写代码的能力比作武功招式,那么架构能力就好比内功。内功修炼好了,武功招式的运用才能得心应手。
**而架构能力的提升,本质上是对你的知识脉络(全身经络)的反复梳理与融会贯通的过程。**具备架构思维并不难,而且极有必要。不管今天的你是不是团队里的一位架构师,对任何一位程序员来说,具备架构思维将会成为让你脱颖而出的关键。
这就像你没有从事云计算行业,但是你仍然需要理解云计算的本质,需要驾驭云计算。你也不必去做出一个浏览器,但是你需要理解它们的思考方式,因为你在深度依赖于它们。
接下来我们将进一步展开来谈这个程序架构体系里面的每一个环节。你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,182 @@
<audio id="audio" title="02 | 大厦基石:无生有,有生万物" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/eb/7e/ebf1e26f5139f87199ae7879c80c267e.mp3"></audio>
你好,我是七牛云许式伟。
在上一讲中,我们把“构建一个应用程序”类比成“构建一座大厦”,并从宏观全局的视角剖析了应用程序这座大厦的构成。今天,我们将更加近距离地去解剖这座大厦的地基:冯·诺依曼体系结构。
## 解剖架构的关键点是什么?
在解剖之前,我想和你先谈谈“解剖学”:**我们应该如何去分析架构设计中涉及的每一个零部件。换一句话说,当我们设计或分析一个零部件时,我们会关心哪些问题。**
**第一个问题,是需求。**这个零部件的作用是什么?它能被用来做哪些事情?(某种意义上来说更重要的是)它不会被用来做哪些事情?
你可能会说,呀,这个问题很简单,既然我设计了这个零部件,自然知道它是用来干嘛的。但实质上这里真正艰难的是“为什么”:为何这个零件被设计成用来干这些事情的,而不是多干一点事情,或者为什么不是少干某些事情?
**第二个问题,是规格。**这个零部件接口是什么样的?它如何与其他零件连接在一起的?
规格是零部件的连接需求的抽象。符合规格的零部件可以有非常多种可能的实现方案,但是,一旦规格中某个条件不能满足了,它就无法正常完成与其他零件的连接,以达到预期的需求目标。
规格的约束条件会非常多样化,可能是外观(比如形状和颜色),可能是交互方式(比如用键盘、鼠标,或者语音和触摸屏),也可能是质量(比如硬度、耐热性等等)。
那么,冯·诺依曼体系结构的需求和规格又是什么样的呢?
## 为“解决一切的问题”而生
冯·诺依曼体系结构不但是应用程序这座大厦的地基,同时也是整个信息科技的地基。
**当我们去审视整个信息科技时,仅把它形容为一座大厦显得如此不贴切,甚至你也不能用“一个城市”去形容它,事实上,它更像是一个无中生有的全新世界:在其中,有个体、有族群、有生态,还有喜怒哀乐。**
冯·诺依曼体系结构的迷人之处在于,从需求来说,它想解决一切问题。解决一切可以用“计算”来解决的问题。
“计算”的边界在哪里?今天我们还没有人能够真正说得清。计算能不能解决“智能”的问题?通过计算能力,计算机是否终有一天可以获得和人类一样的智能?
今天人工智能热潮的兴起,证明对于这个问题我们很乐观:计算终将解决智能的问题。尽管我们不能确定什么时候能够达到,但是让人欣慰的是,我们一直在进步 —— 如果人类智能无法完成进一步的进化,那么我们就一直一直在前进,最终无限逼近甚至超越人类智能。
甚至有科幻小说家设想例如在Google的“AlphaGo”大热后霍炬和西乔创作的漫画“BetaCat”计算机演进出超过人类的智能是生物进化的一个自然演进路径它将取代人类成为新的食物链顶端并最终基于其悠久的生命力去完成人类有限生命无法实现的星际航行之路。
## 冯·诺依曼体系的规格
为了实现“解决一切可以用‘计算’来解决的问题”这个目标,冯·诺依曼引入了三类基础零部件:
- 中央处理器;
- 存储;
- 输入输出设备。
首先我们来看看存储。它负责存放计算涉及的相关数据,作为计算的输入参数和输出结果。
我们日常见到的存储设备非常的多样化。比如中央处理器自己内置的寄存器、内存、传统机械硬盘、USB固态硬盘、光盘等等。
从中央处理器的角度存储可简单分为两类一类是内置支持的存储通过常规的处理器指令可直接访问比如寄存器、内存、计算机主板的ROM。一类是外置存储它们属于输入输出设备。中央处理器本身并不能直接读写其中的数据。
冯·诺依曼体系中涉及的“存储”,指的是中央处理器内置支持的存储。
我们再来看看输入输出设备。它是计算机开放性的体现,大大拓展了计算机的能力。每个设备通过一个端口与中央处理器连接。通过这个端口地址,中央处理器可以和设备进行数据交换。数据交换涉及的数据格式由设备定义,中央处理器并不理解。
但这并不影响设备的接入。设备数据交换的发起方(设备使用方)通常理解并可以解释所接收的数据含义。为了方便使用,设备厂商或操作系统厂商通常会提供设备相关的驱动程序,把设备数据交换的细节隐藏起来,设备的使用方只需要调用相关的接口函数就可以操作设备。
最后我们来看看中央处理器。它负责程序(指令序列)的执行。指令序列在哪里?也存放在存储里面。计算机加电启动后,中央处理器从一个固定的存储地址开始执行。
中央处理器支持的指令大体如下(我们在第一篇文章中也曾提到过):
- 计算类也就是支持我们大家都熟知的各类数学运算如加减乘除、sin/cos等等
- I/O类从存储读写数据从输入输出设备读数据、写数据
- 指令跳转类,在满足特定条件下跳转到新的当前程序执行位置、调用自定义的函数。
和“解决一切可以用‘计算’来解决的问题”这个伟大的目标相比,冯·诺依曼体系的三类零部件的规格设计显得如此精简。
为什么这么简洁的规格设计,居然可以解决这么复杂的需求?
## 需求是怎么被满足的?
我们来设想一下:假如今天让我们从零开始设计一个叫电脑的东西,我们的目标是“解决一切可以用‘计算’来解决的问题”。
对于这么含糊的需求,如果你是“电脑”这个产品的主架构师,你会如何应对?
让我们来分析一下。
一方面,需求的变化点在于,要解决的问题是五花八门包罗万象的。如何以某种稳定但可扩展的架构来支持这样的变化?而另一方面,需求的稳定之处在于,电脑的核心能力是固定的,怎么表达电脑的核心能力?
电脑的核心能力是“计算”。什么是计算?计算就是对一个数据(输入)进行变换,变为另一个数据(输出)。在数学中我们把它叫“函数”。如下:
>
y = F(x)
这里 x、y 是数据。它们可能只是一个简单的数值,也可能是文本、图片、视频,各种我们对现实问题进行参数化建模后的测量值,当然也可能是多个输入数据。但无论它的逻辑含义为何,物理上都可以以一段连续的字节内容来表达。用 Go 的语法表达就是:
```
func F(x []byte) (y []byte)
```
那么 x、y 物理上在哪里?思路推理到这里,“存储” 这个概念自然就产生了:存储,就是存放计算所要操作的数据的所在。
下面的问题是:一个具体的计算(也就是 F 函数)怎么表达?
这里的难点在于F 对于电脑的架构师来说是未知的。那么,怎么设计一种系统架构让用户可以表达任意复杂的计算(函数)?
逻辑上来看,无论多复杂的自定义函数,都可以通过下面这些元素的组合来定义:
- 内置函数比如整数或小数运算加减乘除、sin/cos等
- 循环和条件分支;
- 子函数(也是自定义函数)。
这样一来,对于任意的一个具体的计算(自定义函数)来说,都可以用一组指令序列来表达。
那么函数 F 物理上在哪里?以指令序列形式存放在存储里面。所以,存储不只存放计算所要操作的数据,也存放“计算”本身。
只是存储里面存放的“计算”只是数据需要有人理解并执行这些数据背后的计算行为才变成真正意义的“计算”。这个执行者就是中央处理器CPU。它支持很多计算指令包括执行内置函数、循环和条件分支、执行子函数等。
所以,有了中央处理器+存储,就可以支持任意复杂的“计算”了。
<img src="https://static001.geekbang.org/resource/image/cf/37/cf77b8fbe8a559cecbb264c390bc7337.png" alt="">
只是如果电脑只有“中央处理器+存储”,那它就如同一个人只有头脑而没有四肢五官,尽管很可能很聪明,但是这种聪明无法展现出来,因为它没法和现实世界发生交互。
交互,抽象来看就是输入和输出。对人来说,输入靠的是五官:眼睛看、耳朵听、鼻子闻、舌头尝,以及肌肤接触产生的触觉。输出靠语言(说话)和各种动作,如微笑、眨眼、皱眉、手势等等。
对于电脑来说,输入输出的需求就更多了,不只是四肢五官,而可能是千肢万官。
从输入需求来说可能采集静态图像、声音、视频也可能采集结构化数据如GPS位置、脉搏、心电图、温度、湿度等还可能是用户控制指令如键盘按键、鼠标、触摸屏动作等。
从输出需求来说,可能是向屏幕输出信息;也可能是播放声音;还可能是执行某项动作,如交通灯开关、汽车马达转动、打印机打印等。
但不管是什么样交互用途的器官(设备),我们要做的只是定义好统一的数据交换协议。这个数据交换机制,和网络上两台电脑通过互联网,需要通过某种数据交换协议进行通讯,需求上没有实质性的差别。
也就是说除了纯正的“计算”能力外中央处理器还要有“数据交换”能力或者叫IO能力。最终**电脑可以被看做由 “中央处理器+存储+一系列的输入输出设备”** 构成。如下图:
<img src="https://static001.geekbang.org/resource/image/28/a9/28ef9c0241c5c34abb85148453379fa9.png" alt="">
尽管输入输出设备引入的最初灵感可能是来自于“交互”,但是当我们去审视输入输出设备到底是什么的时候,我们很自然发现,它能够做的不单单是交互。
比如常见的外置存储如机械硬盘、光盘等,它们也是输入输出设备,但并不是用于交互,而是显著提升了电脑处理的数据体量。
输入输出设备从根本上解决的问题是什么?
是电脑无限可能的扩展能力。
最重要的一点,输入输出设备和电脑是完全异构的。输入输出设备对电脑来说就只是实现了某项能力的黑盒子。
这个黑盒子内部如何没有规定。它可以只是一个原始的数字化的元器件也可以是另一台冯·诺依曼架构的电脑还可以是完全不同架构的电脑比如GPU电脑、量子计算机。
你可以发现,引入了输入输出设备的电脑,不再只能做狭义上的“计算”(也就是数学意义上的计算),如果我们把交互能力也看做一种计算能力的话,电脑理论上能够解决的“计算”问题变得无所不包。
## 架构思维上我们学习到什么?
架构的第一步是需求分析。从需求分析角度来说,关键要抓住需求的稳定点和变化点。需求的稳定点,往往是系统的核心价值点;而需求的变化点,则往往需要相应去做开放性设计。
对于“电脑”这个产品而言,需求的稳定点是电脑的“计算”能力。需求的变化点,一是用户“计算”需求的多样性,二是用户交互方式的多样性。
电脑的“计算”能力,最终体现为中央处理器的指令集,这是需求相对稳定的部分。
用户“计算”需求的多样性,最终是通过在存储中的指令序列实现。计算机加电启动后,中央处理器并不是按自己固有的“计算”过程进行,而是从一个固定的存储地址加载指令序列执行。
通常这个固定的存储地址指向计算机主板的ROM上的一段启动程序BIOS。这段启动程序通常包含以下这些内容。
- 存储设备的驱动程序用以识别常规的外置存储设备比如硬盘、光驱、U盘。
- 基础外部设备的驱动程序,比如键盘、鼠标、显示器(显卡)。
- 设备和启动配置的基础管理能力。
- 在外置存储上执行程序的能力(中央处理器只支持在内存上执行程序,当然它也为在外置存储执行程序提供了一些支持,比如内存页缺失的中断处理)。
- 将执行权转移到外置存储(第一次安装操作系统的时候可能是光驱甚至是网络存储,平常通常是硬盘)上的操作系统启动程序。这样,操作系统就开始干活了。
这样一来“计算”需求的多样性只需要通过调整计算机主板上的BIOS程序乃至外置存储中的操作系统启动程序就可以实现而不必去修改中央处理器本身。
用户交互方式的多样性,则通过定义外部设备与中央处理器的数据交换协议实现。
当我们把所有的变化点从电脑的最核心部件中央处理器剥离后,中央处理器的需求变得极其稳定,可独立作为产品进行其核心价值的演进。
## 结语
总结一下,今天,我们近距离地去解剖了整个信息世界地基:冯·诺依曼体系结构。
冯·诺依曼体系结构的不凡之处在于,它想“解决一切可以用‘计算’来解决的问题”。
为了实现这个目标,冯·诺依曼引入了三类基础零部件:中央处理器、存储、输入输出设备。所有计算机都可以看做由 “中央处理器+存储+一系列的输入输出设备” 构成。
为了方便理解,我在尝试用 Go 语言模拟来实现冯·诺依曼架构体系的电脑:
- [https://github.com/qiniu/arch/tree/master/von](https://github.com/qiniu/arch/tree/master/von)
如果你对此感兴趣,欢迎 fork 并对其进行修改迭代。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,112 @@
<audio id="audio" title="03 | 汇编:编程语言的诞生" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/7a/94bf6a870c9492ef4cc327807ea0587a.mp3"></audio>
你好,我是七牛云许式伟。
在上一讲中,我们一起解剖了架构大厦的地基:冯·诺依曼体系。接下来,我们就开始沿着这座大厦攀登,一起来聊聊编程语言。
对于现代计算机来说,虽然 CPU 指令是一个很有限的指令集但是CPU 执行的指令序列(或者叫“程序”)并不是固定的,它依赖于保存在存储中的数据,由软件工程师(或者叫“程序员”)编写的软件决定。
从上一讲中我们可以知道计算机的程序可能被保存在计算机主板的ROM上这段程序也叫计算机的启动程序也可能被保存在外置的存储设备比如硬盘并在合适的时机加载执行。
程序称得上是计算机的灵魂。指令序列的可能性是无穷的,程序的可能性就是无穷的。今天计算机创造的世界如此多姿多彩,正是得益于程序无穷的可能性。
那么,软件工程师是怎么编写程序的?
## 编程的史前时代
在第一门面向程序员的编程语言出现前人们只能通过理解CPU指令的二进制表示将程序以二进制数据方式刻录到存储比如ROM或硬盘上。
这个时期的编程无疑是痛苦的,效率是极其低下的:且不说我们怎么去修改和迭代我们的程序,光将我们的想法表达出来就极其困难。
我们首先要把表达的执行指令翻译成二进制的比特数据,然后再把这些数据刻录到存储上。
这个时候软件和硬件的边界还非常模糊,并不存在所谓软件工程师(或者叫“程序员”)这样的职业。写程序也并不是一个纯软件的行为,把程序刻录到存储上往往还涉及了硬件的电气操作。
为了解决编程效率的问题汇编语言和解释它的编译器诞生了。汇编语言的编译器将汇编语言写的程序编译成为CPU指令序列并将其保存到外置的存储设备比如硬盘上。
汇编语言非常接近计算机的CPU 指令一条汇编指令基本上和CPU指令一一对应。
## 与机器对话
汇编语言的出现,让写程序(编程)成为一个纯软件行为(出现“程序员”这个分工的标志),人们可以反复修改程序,然后通过汇编编译器将其翻译成机器语言,并写入到外置的存储设备(比如硬盘)。并且,程序员可以按需执行该程序。
在表达能力上,汇编语言主要做了如下效率优化。
<li>
用文本符号symbol表达机器指令例如 add 表示加法运算,而不用记忆对应的 CPU 指令的二进制表示。
</li>
<li>
用文本符号symbol表达要操作的内存地址并支持内存地址的自动分配。比如我们在程序中使用了“Hello” 这样一段文本,那么汇编编译器将为程序开辟一段静态存储区(通常我们叫“数据段”)来存放这段文本,并用一个文本符号(也就是“变量名-variable”指向它。用变量名去表达一段内存数据这样我们就不用去关注内存的物理地址而把精力放在程序的逻辑表达上。
</li>
<li>
用文本符号symbol表达要调用的函数function也叫“过程-procedure”地址。对 CPU 指令来说,函数只有地址没有名字。但从编程的角度,函数是机器指令的扩展,和机器指令需要用文本符号来助记一样,函数的名称也需要用文本符号来助记。
</li>
<li>
用文本符号symbol表达要跳转的目标地址。高级语言里面流程控制的语法有很多比如 goto、if .. else、for、while、until 等等。但是从汇编角度来说只有两种基本的跳转指令无条件跳转jmp和条件跳转(je、jne)。同样,跳转的目标地址用文本符号(也就是“标签-label”有助于程序逻辑的表达而不是让人把精力放在具体的指令跳转地址上。
</li>
总结来说,汇编从指令能力上来说,和机器指令并无二致,它只不过把人们从物理硬件地址中解脱出来,以便专注于程序逻辑的表达。
但是这一步所解放的生产力是惊人的毕竟如果有选择的话没有人会愿意用0101这样的东西来表达自己的思想。
## 可自我迭代的计算机
从探究历史的角度,你可能会期望了解最真实的历史发展过程。比如:怎么产生了现代计算机(以键盘作为输入,显示器作为输出)?怎么产生了汇编语言?怎么产生了操作系统?
不过本专栏是以架构设计为目的我们目的并不是还原最真实的历史。架构的意义在于创造。我们甚至可以设想一个有趣的场景假设今天我们的信息科技的一切尚不存在那么从架构设计角度我们从工程上来说如何更高效地完成从0到1的信息科技的构建
>
最早的输入输出设备并不是键盘和显示器而是打孔卡和打印机。用打孔卡来作为机器指令的输入早在18世纪初就被用在织布机上了。早期的数字计算机就是用打孔卡来表达程序指令和输入的数据。
下图是 IBM 制造的打孔卡:
<img src="https://static001.geekbang.org/resource/image/90/99/907ef3466d15146c8aa1b2ea2a7dbd99.png" alt="">
我们可以想象一下,第一台以键盘+显示器为标准输入输出的现代计算机出现后一个最小功能集的计算机主板的ROM上应该刻上什么样的启动程序换句话说这个现代计算机具备的最基本功能是什么
从高效的角度(不代表真实的历史,真实历史可能经历过很多曲折的发展过程),我想,它最好具备下面的这些能力。
- 键盘和显示器的驱动程序。
- 当时最主流的外置存储设备(不一定是现代的硬盘)的驱动程序。
- 一个汇编程序编辑器。可从存储中读取汇编程序代码,修改并保存到存储中。
- 一个汇编编译器。可将汇编程序代码编译成机器代码程序,并保存到存储中。
- 可以执行一段保存在外置存储设备中的机器代码程序。
本质上,我们是要实现一个最小化的计算能力可自我迭代的计算机。
这个时期还没有操作系统当然把ROM上的启动程序BIOS看做一种最小化的操作系统我觉得也可以但毕竟不是现实中我们说的操作系统
汇编语言的出现要早于操作系统。操作系统的核心目标是软件治理,只有在计算机需要管理很多的任务时,才需要有操作系统。
所以在没有操作系统之前BIOS 包含的内容很可能是下面这样的:
- 外置存储设备的驱动程序;
- 基础外部设备的驱动程序,比如键盘、显示器;
- 汇编语言的编辑器、编译器;
- 把程序的源代码写入磁盘,从磁盘读入的能力。
最早期的计算机毫无疑问是单任务的,计算的职能也多于存储的职能。每次做完任务,计算机的状态重新归零(回到初始状态)都没有关系。
但是,有了上面这样一个 BIOS 程序后,计算机就开始发展起它存储的能力:程序的源代码可以进行迭代演进了。
这一步非常非常重要。计算机的存储能力的重要性如同人类发明了纸。纸让人类存储了知识,一代代传递下去并不断演进,不断发扬光大。
而同样有了存储能力的计算机,我们的软件程序就会不断被传承,不断演进发扬光大,并最终演进出今天越来越多姿多彩的信息科技的世界。
## 结语
今天我们一起回到了编程的史前时代,共同回溯了编程语言诞生的历史。
为了不再用“0101”表达自己的思想人们创造了汇编语言这一步让编程成为一个纯软件行为程序员这一个分工也由此诞生。
为了进一步支持程序员这个职业我们设计了MVP版最小化可行产品的可自我迭代的计算机。有了这个计算机我们就可以不断演进并最终演进出今天越来越多姿多彩的信息科技的世界。
## 架构上的思考题
在上一讲中,我们谈架构思维时提到,我们在需求分析时,要区分需求的变化点和稳定点。稳定点往往是系统的核心能力,而变化点则需要对应地去考虑扩展性上的设计。
今天,我们假设要实现一个最小化的计算能力可自我迭代的计算机,需求如上所述。
那么,它的变化点和稳定点分别是什么?为此,你会怎么设计出哪些子系统,每个子系统的规格是什么?扩展性上有哪些考虑?
欢迎把你的想法告诉我,我们一起讨论。感谢你的收听,再见。

View File

@@ -0,0 +1,129 @@
<audio id="audio" title="04 | 编程语言的进化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1e/a4/1ec4d6ab4e2049979a1df9287f82aaa4.mp3"></audio>
你好,我是七牛云许式伟。今天我们继续来聊聊编程语言。
编程语言的出现,诞生了软件工程师(也叫程序员)这样一个职业,而汇编语言则可以看做是软件工程师这个分工出现的标志。
通过编程语言,软件工程师和计算机可以进行交互,表达自己的思想。但是,如果我们把掌握编程语言看做是软件工程师的专业技能,其实又低估了编程语言带来的革命性变化。
编程语言在信息科技发展中的位置,如同人类文明中语言所在的位置。而编程语言写出来的软件(及其源代码),如同人类文明中不断被传承下来的图书典籍。
## 软件是活的书籍
我个人一直有一个观点:软件是活的书籍,是我们人类知识传承能力的一次伟大进化。书籍能够通过文字来记载事件、传递情感、揭示规律、传承技术。书籍能够让人们进行远程的沟通(飞鸽传书),也能够让我们了解古人的生活习性,与古人沟通(虽然是单向的)。
这些事情软件都可以做到,而且做得更好。为什么我说软件是活的书籍,有两方面的原因。
**其一,表达方式的多样性。**书籍只能通过文字描述来进行表达,这种表达方式依赖于人们对文字的理解,以及人的想象能力对场景进行还原。软件除了能够通过文字,还能够通过超链接、声音、动画、视频、实时的交互反馈等方式来还原场景。
**其二,对技术的现场还原。**书籍只能通过文字来描述技术,但是因为人与人对同样的文字理解不同,领悟能力不同,这些都可能导致技术的传承会出现偏差,如果文字的记载不够详尽,可能就会出现“谁也看不懂,学不会”的情况,从而导致技术的失传。
但是,软件对技术的还原可以是精确的,甚至软件本身可以是技术的一部分。当软件是技术的一部分的时候,技术传承就是精确的,失传的概率就大大降低(除非技术本身适应不了潮流,退出了历史舞台)。
信息科技发展到今天,已经影响人类活动的方方面面。无论你从事什么职业,不管你是否会从事软件开发的工作,你都无法和信息科技脱节。如果希望能够站在职业发展的至高点,你就需要理解和计算机沟通的语言,也就需要理解软件工程师们的语言。
不仅如此,如果你把编程语言升华为人类知识传承能力的进化,你就更能够清晰地预判到这样的未来:每一个小孩的基础教育中一定会有编程教育,就如同每一个小孩都需要学习物理和数学一样。
## 编程范式的进化
编程语言从汇编开始,到今天还只有六十多年的历史,但是迭代之迅速,远超自然语言的迭代速度。从思想表达的角度来说,我们通常会听到以下这些编程范式。
**其一是过程式。过程式就是以一条条命令的方式,让计算机按我们的意愿来执行。**今天计算机的机器语言本身就是一条条指令构成,本身也是过程式的。所以过程式最为常见,每个语言都有一定过程式的影子。过程式语言的代表是 Fortran、C/C++、JavaScript、Go 等等。
过程式编程中最核心的两个概念是结构体(自定义的类型)和过程(也叫函数)。通过结构体对数据进行组合,可以构建出任意复杂的自定义数据结构。通过过程可以抽象出任意复杂的自定义指令,复用以前的成果,简化意图的表达。
**其二是函数式。函数式本质上是过程式编程的一种约束,它最核心的主张就是变量不可变,函数尽可能没有副作用**(对于通用语言来说,所有函数都没副作用是不可能的,内部有 IO 行为的函数就有副作用)。
既然变量不可变,函数没有副作用,自然人们犯错的机会也就更少,代码质量就会更高。函数式语言的代表是 Haskell、Erlang 等等。大部分语言会比较难以彻底实施函数式的编程思想,但在思想上会有所借鉴。
函数式编程相对小众。因为这样写代码质量虽然高,但是学习门槛也高。举一个最简单的例子:在过程式编程中,数组是一个最常规的数据结构,但是在函数式中因为变量不可变,对某个下标的数组元素的修改,就需要复制整个数组(因为数组作为一个变量它不可变),非常低效。
所以,函数式编程里面,需要通过一种复杂的平衡二叉树来实现一个使用界面(接口)上和过程式语言数组一致的“数组”。这个简单的例子表明,如果你想用函数式编程,你需要重修数据结构这门课程,大学里面学的数据结构是不顶用了。
**其三是面向对象。面向对象在过程式的基础上,引入了对象(类)和对象方法(类成员函数),它主张尽可能把方法(其实就是过程)归纳到合适的对象(类)上,不主张全局函数(过程)。面向对象语言的代表是 Java、C#、C++、Go 等等。**
## 从“面向对象”到“面向连接”
面向对象的核心思想是引入契约,基于对象这样一个概念对代码的使用界面进行抽象和封装。
它有两个显著的优点。
**其一是清晰的使用界面**,某种类型的对象有哪些方法一目了然,而不像过程式编程,数据结构和过程的关系是非常松散的。
**其二是信息的封装。**面向对象不主张绕过对象的使用接口侵入到对象的内部实现细节。因为这样做破坏了信息的封装,降低了类的可复用性,有一天对象的内部实现方式改变了,依赖该对象的相关代码也需要跟着调整。
面向对象还有一个至关重要的概念是接口。通过接口,我们可以优雅地实现过程式编程中很费劲才能做到的一个能力:**多态**。
由于对象和对象方法的强关联,我们可以引入接口来抽象不同对象相同的行为(比如鸟和猪是不同的对象,但是它们有相同的方法,比如移动和吃东西)。这样不同对象就可以用相同的代码来实现类似的复杂行为,这就是多态了。
多数面向对象语言往往还会引入一个叫**继承**的概念。大家对这个概念褒贬不一。虽然继承带来了编码上的便捷性,但也带来了不必要的心智负担:本来复合对象的唯一构造方法是组合,现在多了一个选择,继承。
究竟什么时候应该用继承,什么时候应该用组合?这着实会让人纠结。不过,这件事情最完美的答案是 Go 语言给出来的:放弃继承,全面强化组合能力(要了解 Go 语言强大的组合能力,参阅[我的演讲](http://open.qiniu.us/go-next-c.pdf))。
不同编程范式并不是互斥的。虽然有些编程语言会有明确的编程范式主张,比如 Java 是纯正的面向对象语言,它反对全局过程。但是,也有一些语言明确主张说自己是多范式的,典型代表是 C++。
当然,可能 C++ 不是一个好例子,因为它太复杂了,让人觉得多范式会大大增加语言的复杂性,虽然其实 C++ 的复杂性和多范式并没有什么关系。
可能 Go 语言是多范式更好的例子。它没有声称自己是多范式的,但是实际上每一种编程范式它都保留了精华部分。这并没有使得 Go 语言变得很复杂,整个语言的特性极其精简。
Go 语言之所以没有像 C++ 那样声称是多范式的,是因为 Go 官方认为 Go 是一门面向连接的语言。
**什么是面向连接的语言?**在此之前,你可能并没有听过这样的编程范式,这应该算 Go 自己发明出来的范式名称。在我看来,所谓面向连接就是朴素的组合思想。研究连接,就是研究人与人如何组合,研究代码与代码之间怎么组合。
面向对象创造性地把契约的重要性提高到了非常重要的高度,但这还远远不够。这是因为,并不是只有对象需要契约,语言设计的方方面面都需要契约。
比如,代码规范约束了人的行为,是人与人的连接契约。如果面对同一种语言,大家写代码的方式很不一样,语言就可能存在很多种方言,这对达成共识十分不利。所以 Go 语言直接从语言设计上就消灭掉那些最容易发生口水的地方,让大家专注于意图的表达。
再比如,消息传递约束了进程(这里的进程是抽象意义上的,在 Go 语言中叫 goroutine的行为是进程与进程的连接契约。**消息传递是多核背景下流行起来的一种编程思想,其核心主张是:尽可能用消息传递来取代共享内存,从而尽可能避免显式的锁,降低编程负担。**
Go 语言不只是提供了语言内建的消息传递机制channel同时它的消息传递是类型安全的。这种类型安全的消息传递契约机制大大降低了犯错的机会。
## 其他方面的进化
除了编程范式,编程语言的进化还体现在工程化能力的完善上。工程化能力主要体现在如下这些方面。
-package即代码的发布单元。
- 版本version即包的依赖管理。
- 文档生成doc
- 单元测试test
从语言的执行器的行为看,出现了这样三种分类的语言。
- 编译的目标文件为可执行程序。典型代表是 Fortran、C/C++、Go 等。
- 生成跨平台的虚拟机字节码,有独立的执行器(虚拟机)执行字节码 。典型代表为 Java、Erlang 等。
- 直接解释执行。典型代表是 JavaScript。当然现在纯解释执行的语言已经不多。大多数语言也只是看起来直接执行内部还是会有基于字节码的虚拟机以提升性能。
## 语言对架构的影响是什么?
我们思考一个问题:从架构设计角度来看,编程语言的选择对架构的影响是什么?
我们在第一篇“架构设计的宏观视角”中,介绍了服务端程序和客户端程序的整体架构图。细心的读者可能留意到了,在架构图中我用了三种不同的颜色来表示不同层次的依赖。
无论服务端,还是客户端,我们可以统一将其架构图简化为下图所示。
<img src="https://static001.geekbang.org/resource/image/60/5f/604930da3b45b73189a924f8b172655f.png" alt="">
图中淡紫色是硬件层次的依赖,是我们程序工作的物理基础。淡绿色的是软件层次的依赖,是我们程序工作的生态环境。桔色的是库或源代码层次的依赖,是我们程序本身的组成部分。细分的话它又可以分两部分:一部分是业务无关的框架和基础库,还有一部分是业务架构。
从软件的业务架构来说,本身应该怎么拆分模块,每个模块具体做什么样的事情(业务边界是什么),这是业务需求本身决定的,和编程语言并没有关系。但在我们描述每个模块的规格时,采用的规格描述语言会面临如下两种选择:
- 选择某种语言无关的接口表示;
- 选择团队开发时采用的语言来描述接口。
两种选择的差异并不是实质性的。只要团队内有共识,选哪一种都无所谓。本着“如无必要勿增实体”的原则,我个人会倾向于后者,用开发语言来做接口表示。在七牛云的话自然就是选 Go 了。
站在唯技术论的角度,业务架构与语言无关,影响的只是模块规格的描述语法。但语言的选择在实践中对业务架构决策的影响仍然极其关键。
**原因之一是开发效率。**抛开语言本身的开发效率差异不谈,不同语言会有不同的社区资源。语言长期以来的演进,社区所沉淀下来的框架和基础库,还有你所在的企业长期发展形成的框架和基础库,都会导致巨大的开发效率上的差异。
**原因之二是后期维护。**语言的历史通常都很悠久,很难实质性地消亡。但是语言的确有它的生命周期,语言也会走向衰落。选择公司现在更熟悉的语言,还是选择一个面向未来更优的语言,对架构师来说也是一个两难选择。
## 结语
今天我们抛开具体的语言发展史,而从编程范式演进的角度来谈编程语言的进化。过程式、函数式、面向对象,这些都是大家熟悉的编程范式;所以我们把重点放在了介绍由 Go 语言带来的面向连接的编程思想,并将其与面向对象做了详细的对比。
未来编程语言还将出现什么样的新思想,我们不得而知。但可以预见,出现新的创造性思维的挑战将越来越大。历史的发展是曲折螺旋上升的。
要想有所突破,需要建立批判性思维。一种新思潮的兴起过程很容易用力过猛。面向对象是一个很好的例子。面向对象是非常重要的进步,但是继承是一个过度设计,不只让软件工程师在组合与继承中纠结,也产生了复杂的对象继承树。我们只有在实践中不断总结与反思,才有可能产生新的突破。
你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,161 @@
<audio id="audio" title="05 | 思考题解读:如何实现可自我迭代的计算机?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d5/5e/d5562e89cfe70a9bf535daa6c9df705e.mp3"></audio>
你好,我是七牛云许式伟。
在“[03 | 汇编:编程语言的诞生](https://time.geekbang.org/column/article/91425)”中,我给出了一个架构思考题:
**第一台以键盘+显示器为标准输入输出的现代计算机出现后,一个最小功能集,但计算能力可自我迭代的计算机应该是什么样的?**
从需求上来说,我们期望它有如下能力。
- 键盘和显示器的驱动程序。
- 当时最主流的外置存储设备(不一定是现代的硬盘)的驱动程序。
- 一个汇编程序编辑器。可从存储中读取汇编程序代码,修改并保存到存储中。
- 一个汇编编译器。可将汇编程序代码编译成机器代码程序,并保存到存储中。
- 支持执行一段保存在外置存储设备中的机器代码程序。
那么,它的变化点和稳定点分别是什么?为此,你会怎么设计,设计出哪些子系统,每个子系统的规格是什么?扩展性上有哪些考虑?
## 需求分析
我们前面谈架构思维时提到:**做架构,第一件事情要学会做需求分析**。
需求分析的重要性怎么形容都不过分。准确的需求分析是做出良好架构设计的基础。我个人认为,架构师在整个架构的过程中,至少应该花费三分之一的精力在需求分析上。
这也是为什么很多非常优秀的架构师换到一个新领域后,一上来并不能保证一定能够设计出良好的架构,而是往往需要经过几次迭代才趋于稳定,原因就在于:领域的需求理解是需要一个过程的,对客户需求的理解不可能一蹴而就。
所以,一个优秀的架构师除了需要“在心里对需求反复推敲”的严谨态度外,对客户反馈的尊重之心也至关重要。只有心里装着客户,才能理解好需求,做好架构。
前面我们也强调过:在需求分析时,要区分需求的变化点和稳定点。稳定点往往是系统的核心能力,而变化点则需要对应地去考虑扩展性上的设计。
那么今天我们来实战一番,要实现一个最小化的计算能力可自我迭代的计算机,我们怎么做需求分析。
## 怎么实现可自我迭代的计算机?
通过前面对计算机工作原理的分析,我们已经知道,计算机分为三大类的零部件:
- 中央处理器;
- 存储;
- 输入输出设备。
中央处理器作为“计算”能力的核心,我们已经对它的工作范畴解剖清晰,这里不提。
存储,一方面作为“计算”的输入输出,另一方面作为“计算”本身的承载(也就是程序),主要的变数在后者。存储上的程序主要是:
- 计算机主板ROM上的启动程序BIOS
- 外置存储上的软件。
接下来我们要考虑清楚的是BIOS 负责做什么,外置存储上的软件负责做什么。这里我们先不展开。
输入输出设备,除了键盘和显示器外,还有外置存储。键盘和显示器我们只需要准备好对应的驱动程序,并没有特别需要考虑的内容。主要的变数在外置存储上。
外置存储在我们为它准备好了驱动程序后,就可以对它进行数据的读写了,但是我们接着需要考虑的问题是:我们准备把外置存储的数据格式设计成什么样?
回答这个问题前,先回顾下我们要做什么。目前我们已知的功能需求有如下这些。
- 键盘和显示器的驱动程序。
- 外置存储设备的驱动程序。
- 汇编程序编辑器。可从外置存储中读取汇编程序代码,修改并保存到外置存储中。
- 汇编编译器。可将汇编程序代码编译成机器代码程序,并保存到外置存储中。
- 支持执行一段保存在外置存储设备中的机器代码程序。
我们可以看到,外置存储需要保存的内容有:
<li>
汇编程序的源代码;
</li>
<li>
汇编编译器编译出来的可执行程序。
</li>
可见,外置存储它不应该只能保存一个文件,而是应该是多个。既然是多个,就需要组织这些文件。那么,怎么组织呢?
今天我们当然知道操作系统的设计者们设计了文件系统这样的东西来组织这些文件。虽然文件系统的种类有很多比如FAT32、NTFS、EXT3、EXT4 等等),但是它们有统一的抽象:文件系统是一颗树;节点要么是目录,要么是文件;文件必然是叶节点;根节点是目录,目录可以有子节点。
但是文件系统File System是否是唯一的可能性当然不是。键值存储Key-Value 存储)也挺好,尤其是早期外置存储容量很可能极其有限的情况下。可以做这样统一的抽象:
- 每个文件都有一个名字Key通过名字Key可以唯一定位该文件以进行文件内容的读写
- 为了方便管理文件可以对文件名做模糊查询List查询List操作支持通配符比如我们现在习惯用的`*``?`
- 未来外置存储的空间有可能很大需要考虑文件管理的延展性问题可以考虑允许每个文件设定额外的元数据Meta例如创建时间、编辑时间、最后访问时间、以及其他用户自定义的元数据。通过元数据我们也可以检索Search到我们感兴趣的文件。
聊完了外置存储,让我们再回来看看 BIOS 和外置存储的软件怎么分工。
首先BIOS 和外置存储上的软件分工的标准是什么BIOS 是刻在计算机主板ROM上的启动程序它的变更非常麻烦。所以 BIOS 负责的事情最好越少越好,只做最稳定不变的事情。
我们一一来看当前已知的需求。
**首先是外部设备的驱动程序**:键盘和显示器的驱动程序、外置存储设备的驱动程序。一方面,只要键盘、显示器、外置存储没有大的演进,驱动程序就不变,所以这块是稳定的;另一方面,它们是 BIOS 干其他业务的基础。所以,这个事情 BIOS 必然会做。
**其次是汇编程序编辑器。**编辑器的需求是模糊的,虽然我们知道它支持用户来编写程序,但是整个编辑器的操作范式是什么样的,没有规定。所以它不像是给键盘写一个驱动程序那样,是一个确定性的需求,而有很多额外的交互细节,需要去进一步明确。
你可以留意下自己日常使用的编辑器,去试着列一下它们的功能列表。你会发现小小的编辑器,功能远比你接触的大部分常规软件要多得多。
**再次是汇编编译器。**汇编编译器从输入输出来看,似乎需求相对确定。输入的是汇编源代码,输出的是可执行程序。但认真分析你会发现,它实际上也有很大的不确定性。
其一CPU 会增加指令这时候汇编指令也会相应地增加。对于大部分应用程序CPU 新增的指令如果自己用不到,可以当它不存在。但是汇编语言及编译器需要完整呈现 CPU 的能力,因此需要及时跟进。
其二,虽然汇编指令基本上和机器指令一一对应,但是它毕竟是面向程序员的生产力工具,所以汇编语言还是会演进出一些高阶的语法,比如宏汇编指令。
所谓宏汇编指令就是用一个命令去取代一小段汇编指令序列它和C语言里面的宏非常类似。所以汇编语言并不是稳定的东西它和其他高级语言类似也会迭代变化。这就意味着汇编编译器也需要相应地迭代变化。
**最后,执行一段保存在外置存储设备中的机器代码程序。**这个需求看似比较明确,但是实际上需求也需要进一步细化。它究竟是基于外置存储的物理地址来执行程序,还是基于文件系统中的文件(文件内容逻辑上连续,但是物理上很可能不连续)来执行程序?
实现上,这两者有很大的不同。前者只需要依赖外置存储的驱动程序就可以完成,后者则还需要额外理解文件系统的格式才能做到。
那么BIOS 到底怎么把执行控制权交到外置存储呢?
在学冯·诺依曼结构的时候我们提到过CPU 加电启动时,它会从存储的一个固定地址开始执行指令,这个固定地址指向的正是 BIOS 程序。
类似的,我们的 BIOS 也可以认定一个外置存储的固定地址来加载程序并执行,而无需关心磁盘的数据格式是什么样的。这个固定地址所在的数据区域,我们可以把它叫做引导区。
引导区的存在非常重要,它实际上是 BIOS 与操作系统的边界。
对于 BIOS 来说执行外置存储上的程序能力肯定是需要具备的否则它没有办法把执行权交给外置存储。但是这个能力可以是非常简约的。BIOS 只需要执行引导区的程序,这个程序并不长,完全可以直接读入到内存中,然后再执行。
我们是否需要基于文件系统中的文件来执行程序的能力?答案是需要。因为汇编编译器编译后的程序在外置存储中,需要有人能够去执行它。
综上,我们确认 BIOS 需要负责的事情是:
- 键盘和显示器的驱动程序;
- 外置存储设备的驱动程序;
- 支持执行外置存储中引导区的机器代码程序;
- 跳转到外置存储的固定地址,把执行权交给该地址上的引导程序。
而汇编程序编辑器、汇编编译器 ,以及支持执行文件系统中的程序,则不应该由 BIOS 来负责。
那么,外置存储上的引导程序拿到执行权后干什么呢?
我们再来总结下当前我们遇到的需求。
- 需要有人负责支持外置存储的数据格式提供统一的功能给其他程序使用。无论它是文件系统还是Key-Value存储系统。
- 需要有人提供管理外置存储的基础能力比如查询List一下外置存储里面都有些什么文件。它可以实现为一个独立的程序比如我们命名为 ls。
- 需要有人执行外置存储上的可执行程序。它可以实现为一个独立的程序,比如我们命名为 sh。
- 汇编程序编辑器。其实这个程序和汇编语言没什么关系,就是一个纯正的文本编辑器。我们可以把这个程序命名为 vi。
- 汇编编译器。它可以实现为一个独立的程序,比如我们命名为 asm。
引导程序拿到执行权后,我们不管它额外做了哪些事情,最终它要把执行权交给 sh 程序。因为sh 程序算得上是可自我迭代的计算机扩展性的体现:通过 sh 程序来执行外置存储上的任意程序,这也相当于在扩展 CPU 的指令集。
## 结语
我们来回顾一下今天的内容。一个最小功能集、计算能力可自我迭代的计算机,它的变化点和稳定点分别是什么?为此,你会怎么设计,设计出哪些子系统,每个子系统的规格是什么?扩展性上有哪些考虑?
需求的变化点在于下面这几点。
- 外置存储的数据格式。对此我们设计文件系统或Key-Value存储子系统来负责这件事情。另外我们也提供了 ls 程序来管理外置存储中的文件。
- 用户最终拿到这个计算机后,会迭代出什么能力。对此,我们设计了 sh 程序,让它支持在外置存储上执行任何应用程序。
- 编辑器的交互范式。对此,我们设计了 vi 程序,让它迭代编辑器的能力。
- 汇编语言的使用范式。对此,我们设计了 asm 程序,让它响应 CPU 指令集的迭代,以及汇编语言进化的迭代。
最终,我们设计出来的“可自我迭代的计算机”,它的系统架构看起来是这样的:
<img src="https://static001.geekbang.org/resource/image/95/47/95183755588918ff21a76b747a96b247.png" alt="">
你的需求分析和系统设计跟上面的架构一致吗?
不一致非常正常,架构并无标准答案。但通过对比别人的方案与自己的不同之处,可以加深你对架构设计在决策上的体会。
另外,在 “可自我迭代的计算机” 这样相对模糊需求的细化过程中,也会很自然出现不太一样的理解,这些都是正常的,这也是需求分析的重要性所在,它本身就是一个需求从模糊到细化并最终清晰定义的过程。
如果你觉得系统过于复杂,不知道如何下手,也不要紧,设计“一个可自我迭代的计算机” 的确是一个复杂的系统,它并不是一个非常适合架构新手的任务。但是我仍然希望通过这样一个例子的剖析,你对需求分析中稳定点和变化点的判断有所感悟。
如果你有什么样的想法和疑问,欢迎你给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,151 @@
<audio id="audio" title="06 | 操作系统进场" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cb/ff/cb0e901c862c0466d7305be1afe5d2ff.mp3"></audio>
你好,我是七牛云许式伟。
在编程语言出现后,软件生产效率得到了大幅度的提升。随着越来越多软件的出现,自然而然就诞生了多个软件如何共处,也就是软件治理的需求。比如下面的这些需求场景。
- 多个软件如何同时运行(多任务的需求)?
- 多个软件如何共同使用计算机上的存储空间(内存管理、文件系统的需求)?
- 多个软件如何共同使用同一个外部设备(设备管理的需求)?
- 多个软件如何相互通讯,如何进行数据交换(进程间通讯、共享内存的需求)?
- 病毒、恶意软件如何治理(安全管理的需求)?
如果没有一个中间的协调方,软件与软件之间并不知道彼此的存在,你不难想象出,这种没有统一规则约束下的场面,会有多么凌乱。
于是,操作系统就出现了。对于软件而言,它像一个大法官,制定规则并据此约束大家的行为。
## 操作系统的启动过程
操作系统是怎么获得执行权的?
这是计算机主板ROM上的启动程序BIOS交给它的。
计算机加电启动后中央处理器CPU会从一个固定的存储地址加载指令序列执行。通常这个固定的存储地址指向计算机主板的ROM上的一段启动程序BIOS。这段启动程序通常包含以下这些内容。
- 存储设备的驱动程序用以识别常规的外置存储设备比如硬盘、光驱、U盘。
- 基础外部设备的驱动程序,比如键盘、鼠标、显示器。
- 设备和启动配置的基础管理能力。
- 支持执行外置存储中引导区的机器代码程序。
- 跳转到外置存储引导区的固定地址,把执行权交给该地址上的引导程序。
引导区的引导程序有长度限制(关于这一点我在上一讲已经介绍过),只能做非常少的事情。在常规情况下,它只是简单地跳转到真正的操作系统的启动程序,但有时计算机上安装了多个操作系统,此时引导程序会提供菜单让你选择要运行的操作系统。
这样,操作系统就开始干活了。
## 操作系统的需求演进
那么,操作系统是做什么的?前面我们说的“软件治理”是否可以涵盖它完整的目标?
让我们从操作系统的发展历程说起。
最早期的计算机是大型机。这个时期的计算机笨重、昂贵,并且操作困难,主要使用人群是搞科研性质的科学家或其他高端人群。
虽然这个时期催生了 IBM 这样的硬件巨头,但大多数人根本就意识不到,这玩意儿对后世人们的生活能够产生如此翻天覆地的变化。
这个时期的计算机还是单任务的,以计算为主,软件为操作硬件服务。如果我们认为“软件治理”是操作系统的根源需求的话,那么可以认为这个时期还不存在操作系统。但的确会有一些辅助工具库来简化用户使用计算机的负担,我们可以把它看做操作系统的萌芽。
从这个意义来说,提供计算机的“基础编程接口”,降低软件开发的负担,是操作系统更为原始的需求。
此后小型机和个人计算机PC的崛起分别诞生了 UNIX 和 DOS 这两个影响深远的操作系统。 UNIX 就不用说了,它几乎算得上今天所有现代操作系统的鼻祖。
DOS 的历史非常有趣。首先是 IBM 没把操作系统当回事儿,把这个活儿包给了微软。然后是微软只花了 5 万美元向西雅图公司购买了 86-DOS 操作系统的版权,更名为 MS-DOS。
那么 86-DOS 是怎么来的西雅图公司的一个24岁小伙叫蒂姆·帕特森Tim Paterson单枪匹马花了4个月时间写出来的。
可以看到这个时期人们对操作系统并没有太深刻的认知多数人只把它看做硬件的附属品。IBM 不把它当回事,西雅图公司也没把它当回事,几万就把它卖了。只有微软认认真真地把它当做生意做了起来(在此之前微软的生意是卖 BASIC 语言的解析器起家,所以微软一直对 BASIC 语言情有独钟,直到很久以后微软搞出了 C# 语言后,情况才有所改变)。
等到 IBM 意识到操作系统是个金蛋,改由自己做 PC-DOS 操作系统的时候,微软已经通过推动 PC 兼容机的发展,让操作系统不再依赖特定的硬件设备,微软也就因此脱离 IBM 的臂膀,自己一飞冲天了。
回到问题。要回答操作系统在做什么,我们可以从客户价值和商业价值两个维度来看。
客户价值来说,**操作系统首先要解决的是软件治理的问题**,大体可分为以下六个子系统:进程管理、存储管理、输入设备管理、输出设备管理、网络管理、安全管理等。
<img src="https://static001.geekbang.org/resource/image/e9/0e/e9084e205547f2874910985d54b64d0e.jpg" alt="">
**操作系统其次解决的是基础编程接口问题。**这些编程接口一方面简化了软件开发,另一方面提供了多软件共同运行的环境,实现了软件治理。
商业价值来说,操作系统是**基础的刚需软件**。计算机离开了操作系统就是一堆废铜烂铁。随着个人计算机采购需求的急速增加,光靠软件 License 的费用就让操作系统厂商赚翻了。
虽然第一个广为人知的操作系统是 UNIX但从商业上来说最成功的操作系统则是 DOS/Windows成就了微软的霸主地位。
为什么是 DOS/Windows 赢得了市场这无关技术优劣关键在于两者的商业路线差异UNIX 走的是企业市场,而 DOS/Windows 选择了更为巨大的市场个人计算机PC市场。
操作系统也是**核心的流量入口**。占领了操作系统,就占有了用户,想推什么内容给用户都很容易。微软对这一点显然心知肚明。
这也是为什么当年网景推 Netscape 浏览器的时候,微软很紧张。因为浏览器是另一个软件治理的入口,本质上是操作系统之上的操作系统。如果软件都运行在浏览器上,那么本地操作系统就沦为和硬件一般无二的管道了。
虽然早期操作系统没有应用市场AppStore但是通过操作系统预装软件的方式向软件厂商收租这是一直以来都有的盈利方式。国内盗版的番茄花园 Windows 发行版就是通过在 Windows 系统上预装软件来盈利。
当然预装软件只是一种可能性,流量变现的方式还有很多。苹果的 iOS 操作系统开启了新的玩法它构建了新的商业闭环账号Account、支付Pay、应用市场AppStore
我们把这个商业模式叫收税模式。帐号(注意是互联网账号,不是过去用于权限管理的本地账号)是前提。没有帐号,就没有支付系统,也没有办法判断用户是否购买过某个软件。
应用市场实现了应用的分发,既解决了系统能力的无限扩展问题(客户价值),也解决了预装软件的软件个数总归有限的问题(商业价值)。支付则是收税模式的承载体,无论是下载应用收费,还是应用内购买内容收费,都可以通过这个关卡去收税。
无论是本地操作系统 iOS 和 Android还是 Web 操作系统(浏览器)如微信小程序,都实现了“帐号-支付-应用市场”这样的商业闭环。这类操作系统,我们不妨把它叫做现代操作系统。
<img src="https://static001.geekbang.org/resource/image/8e/e8/8e47a58d0786d245ddf3e192cae730e8.jpg" alt="">
## 操作系统的边界在哪里?
架构的第一步是需求分析。上一讲我提到了在架构设计过程中,需求分析至少应该花费三分之一的精力。通过这一节我们对操作系统演进过程的回顾,你可能更容易体会到这一点。
当我们说要做一个操作系统的时候,实际上我们自己对这句话的理解也是非常模糊的。尤其是我们正准备去做的事情是一个新生事物时,我们对其理解往往更加粗浅。
在本专栏[开篇词](https://time.geekbang.org/column/article/89668)中我也提过,架构也关乎用户需求,作为架构师我们不只是要知道当前的用户需求是什么,我们还要预测需求未来可能的变化,预判什么会发生,而什么一定不会发生。
我们可以问一下自己我是否能够预料到有一天支付Pay系统会成为操作系统的核心子系统如果不能那么怎么才能做到
操作系统的边界到底在哪里?
要回答这个问题,我们需要看清楚这样三个角色的关系:
- 硬件(个人计算机、手机或其他);
- 操作系统;
- 浏览器。
首先我们来看操作系统与硬件的关系。如果操作系统厂商不做硬件会怎样我们知道个人计算机PC市场就是如此。微软虽然占据了 PC 操作系统DOS/Windows绝大部分江山但是它自身并不生产硬件。这里面PC 兼容机的发展对 DOS/Windows 的发展有着至关重要的支撑意义。它让操作系统厂商有了独立的生存空间。
到了移动时代Google 收购 Android 后,通过免费策略占领移动操作系统的大半江山,一定程度上复制了微软的过程,但实际上并没有那么理想。
首先Android 是免费的Google 并没有从中收取软件 License 费用,而是借助 Android 的市场占有率来推动 Google 的服务例如搜索、Gmail 等等),通过 Google 服务来获取商业回报。
其次iOS 操作系统引入的 “账号-支付-应用市场” 的收税模式受益方是硬件手机厂商而非操作系统厂商。其中最关键的一点几乎所有手机厂商都不接受把支付Pay这个核心系统交给 Google。
最后,不止支付系统,一旦手机厂商长大立足 Google 服务也会被逐步替换。所以 Google 和 Android 手机厂商之间的联盟并不可靠,养肥的手机厂商会不断试探 Google 的底线,而 Google 也会尝试去收紧政策,双方在博弈中达到平衡。
之所以会这样,我觉得原因有这么几个:
其一历史是不可复制的人们对操作系统的重要性认知已经非常充分。所以大部分手机厂商都不会放弃操作系统的核心子系统的主控权。Android 系统的开源策略无法完全达到预期的目标,这也是 Google 最终还是免不了要自己做手机的原因。
其二,手机是个性化产品,硬件上并没有 PC 那么标准化。所以个人计算机有兼容机,而手机并没有所谓的标准化硬件。
分析完操作系统和硬件的关系,我们再来看它和浏览器的关系。在 PC 时期,操作系统和浏览器看起来至少需求上是有差异化的:操作系统,是以管理本地软件和内容为主(对内)。浏览器,是以管理互联网内容为主(对外)。
但,这个边界必然会越来越模糊。
操作系统不涉足互联网内容这是不可能的。应用市场AppStore其实就是典型的互联网内容而另一方面在浏览器的生态里也有一些特殊角色网址导航、搜索引擎、Web 应用市场,它们共同构成了探索互联网世界的“地图”。
问题在于:
- 操作系统、浏览器和(互联网)搜索引擎的关系是什么;
- 移动时代的浏览器会是什么样的;它和操作系统的关系又如何相处?
欢迎把你对这几个问题的想法告诉我。
## 结语
让我们简单回顾下今天我所讲到的内容。
从客户需求来说,操作系统的核心价值在于:
- **实现软件治理,让多个软件和谐共处;**
- **提供基础的编程接口,降低软件开发难度。**
从商业价值来说,操作系统是刚性需求,核心的流量入口,兵家必争之地。所以,围绕它的核心能力,操作系统必然会不断演化出新的形态。
我们把引入了 “账号-支付-应用市场” 商业闭环的收税模式的操作系统,称为现代操作系统。
操作系统的边界到底在什么地方?我们通过分析硬件、操作系统、浏览器三者的关系,也做了定性的分析。这样的分析将有助于你对需求发展做出预判。
最后,你可以在留言区给我留言,分享你对于操作系统技术、商业的看法,让我们一起交流。

View File

@@ -0,0 +1,141 @@
<audio id="audio" title="07 | 软件运行机制及内存管理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/68/82/686d3ac3ae32eb9284f8c8c56bfa4f82.mp3"></audio>
你好,我是七牛云许式伟。
操作系统的核心职能是软件治理,而软件治理的一个很重要的部分,就是让多个软件可以共同合理使用计算机的资源,不至于出现争抢的局面。
内存作为计算机最基础的硬件资源有着非常特殊的位置。我们知道CPU 可以直接访问的存储资源非常少只有寄存器、内存RAM、主板上的 ROM。
寄存器的访问速度非常非常快,但是数量很少,大部分程序员不直接打交道,而是由编程语言的编译器根据需要自动选择寄存器来优化程序的运行性能。
主板上的 ROM 是非易失的只读的存储。所谓非易失是计算机重新启动后它里面的数据仍然会存在。这不像内存RAM计算机重新启动后它上面的数据就丢失了。ROM 非易失和只读的特点决定了它非常适合存储计算机的启动程序BIOS
所以你可以看到,内存的地位非常特殊,它是唯一的 CPU 内置支持,且和程序员直接会打交道的基础资源。
内存有什么用?前面我们在 “[02 | 大厦基石:无生有,有生万物](https://time.geekbang.org/column/article/91007)” 一讲中介绍冯·诺依曼结构的时候,画过一个图:
<img src="https://static001.geekbang.org/resource/image/cf/37/cf77b8fbe8a559cecbb264c390bc7337.png" alt="">
从图中可以看出,存储的作用有两个:一个是作为 “计算” 的操作对象,输入和输出数据存放的所在;另一个是存放 “计算” 本身,也就是程序员写的程序。
这里说的存储,主要指的就是内存。
## 计算机运行全过程
当然,这是从 CPU 角度看到的视图:对于 CPU 来说,“计算” 过程从计算机加电启动,执行 BIOS 程序的第一条指令开始,到最后计算机关机,整个就是一个完整的 “计算” 过程。这个过程有多少个“子的 ‘计算’过程”,它并不关心。
但是从操作系统的视角来看,计算机从开机到关机,整个 “计算” 过程,由很多软件,也就是子 “计算” 过程,共同完成。从时序来说,计算机完整的 “计算” 过程如下:
<img src="https://static001.geekbang.org/resource/image/5a/02/5a44dba21554d921c480cd2785874202.png" alt="">
整个 “计算” 过程的每个子过程都有其明确的考量。
首先BIOS 程序没有固化在 CPU 中,而是独立放到主板的 ROM 上,是因为不同历史时期的计算机输入输出设备很不一样,有键盘+鼠标+显示器的,有触摸屏的,也有纯语音交互的,外置存储则有软盘,硬盘,闪存,这些变化我们通过调整 BIOS 程序就可以应对,而不需要修改 CPU。
引导区引导程序则是程序从内置存储ROM转到外置存储的边界。引导区引导程序很短BIOS 只需要把它加载到内存执行就可以,但是这样系统的控制权就很巧妙地转到外置存储了。
引导区引导程序不固化在 BIOS 中,而是写在外置存储的引导区,是为了避免 BIOS 程序需要经常性修改。毕竟 BIOS 还是硬件,而引导区引导程序已经属于软件范畴了,修改起来会方便很多。
OS 引导程序,则是外置存储接手计算机控制权的真正开始。这里 OS 是操作系统Operating System的缩写。操作系统从这里开始干活了。这个过程发生了很多很多事情这里我们先略过。但是最终所有的初始化工作完成后操作系统会把执行权交给 OS Shell 程序。
OS Shell 程序负责操作系统与用户的交互。最早的时候计算机的交互界面是字符界面OS Shell 程序是一个命令行程序。DOS 中叫 command.com而在 Linux 下则叫 sh 或者 bash 之类。这里的 sh 就是 shell 的缩写。
这个时期启动一个软件的方式就是在 Shell 程序中输入一个命令行Shell 负责解释命令行理解用户的意图,然后启动相应的软件。到了图形界面时期,在 Shell 中启动软件就变成点点鼠标,或者动动手指(触摸屏)就行了,交互范式简化了很多。
在了解了计算机从开机到关机的整个过程后,你可能很快会发现,这里面有一个很关键的细节没有交代:计算机是如何运行外置存储上的软件的?
这和内存管理有关。
结合内存的作用,我们谈内存管理,只需要谈清楚两个问题:
- 如何分配内存(给运行中的软件,避免它们发生资源争抢);
- 如何运行外置存储(比如硬盘)上的软件?
在回答这两个问题之前我们先了解一个背景知识CPU 的实模式和保护模式。这两个模式 CPU 对内存的操作方式完全不同。在实模式下CPU 直接通过物理地址访问内存。在保护模式下CPU 通过一个地址映射表把虚拟的内存地址转为物理的内存地址,然后再去读取数据。
相应的,工作在实模式下的操作系统,我们叫实模式操作系统;工作在保护模式下的操作系统,我们叫保护模式操作系统。
## 实模式下的内存管理
先看实模式操作系统。
在实模式操作系统下,所有软件包括操作系统本身,都在同一个物理地址空间下。在 CPU 看来,它们是同一个程序。操作系统如何分配内存?至少有两种可行的方法。
其一,把操作系统内存管理相关的函数地址,放到一个大家公认的地方(比如 0x10000 处),每个软件要想申请内存就到这个地方取得内存管理函数并调用它。
其二,把内存管理功能设计为一个中断请求。所谓中断,是 CPU 响应硬件设备事件的一个机制。当某个输入输出设备发生了一件需要 CPU 来处理的事情,它就会触发一个中断。
内存的全局有一个中断向量表,本质上就是在一个大家公认的地方放了一堆函数地址。比如键盘按了一个键,它会触发 9 号中断。在 CPU 收到中断请求时,它会先停下手头的活来响应中断请求(到中断向量表找到第 9 项对应的函数地址并去执行它),完成后再回去干原来的活。
中断机制设计之初本来为响应硬件事件之用,但是 CPU 也提供了指令允许软件触发一个中断,我们把它叫软中断。比如我们约定 77 号中断为内存管理中断,操作系统在初始化时把自己的内存管理函数写到中断向量表的第 77 项。
所以,上面两种方法实质上是同一个方法,只是机制细节有所不同而已。中断机制远不止是函数向量表那么简单。比如中断会有优先级,高优先级中断可以打断低优先级中断,反之则不能。
那么,在实模式下,操作系统如何运行外置存储(比如硬盘)上的软件?
很简单,就是把软件完整从外置存储读入到内存然后执行它。不过,在执行前它干了一件事情,把浮动地址固定下来。为什么会有浮动地址?因为软件还没有加载到内存的时候并不知道自己会在哪里,所以有很多涉及数据的地址、函数的地址都没法固定下来,要在操作系统把它加载到内存时来确定。
整体来说,实模式内存管理的机制是非常容易理解的。因为它毕竟实质上是一个程序被拆分为很多个软件(程序代码片段),实现了程序代码片段的动态加载而已。
## 保护模式下的内存管理
但实模式有两个问题。
其一是安全性。操作系统以及所有软件都运行在一起,相互之间可以随意修改对方的数据甚至程序指令,这样搞破坏就非常容易。
其二是支持的软件复杂性低,同时可运行的软件数量少。
一方面,软件越复杂,它的程序代码量就越多,需要的存储空间越大,甚至可能出现单个软件的大小超过计算机的可用内存,这时在实模式下就没法执行它。
另一方面,哪怕单个软件可运行,但是一旦我们同时运行的软件多几个,操作系统对内存的需求量就会急剧增加。相比这么多软件加起来的内存需求量,内存的存储空间往往仍然是不足的。
但是为什么平常我们可以毫无顾忌地不断打开新的软件,从来不曾担心过内存会不足呢?
这就是保护模式的作用了。保护模式下,内存访问不再是直接通过物理内存,而是基于虚拟内存。虚拟内存模式下,整个内存空间被分成很多个连续的内存页。每个内存页大小是固定的,比如 64K。
这样,每次 CPU 访问某个虚拟内存地址中的数据,它都会先计算出这是要访问哪个内存页,然后 CPU 再通过一个地址映射表,把虚拟的内存地址转为物理的内存地址,然后到这个物理内存地址去读取数据。地址映射表是一个数组,下标是内存页页号,值是该内存页对应的物理内存首地址。
当然,也有可能某一个内存页对应的物理内存地址还不存在,这种情况叫缺页,没法读取数据,这时 CPU 就会发起一个缺页的中断请求。
<img src="https://static001.geekbang.org/resource/image/ae/85/ae0a79ee0dabba34bca6a5de97d7af85.png" alt="">
这个缺页的中断请求会被操作系统接管。发生缺页时,操作系统会为这个内存页分配物理的内存,并恢复这个内存页的数据。如果没有空闲的物理内存可以分配,它就会选择一个最久没有被访问的内存页进行淘汰。
当然,淘汰前会把这个内存页的数据保存起来,因为下次 CPU 访问这个被淘汰的内存页时一样会发生缺页中断请求,那时操作系统还要去恢复数据。
通过这个虚拟内存的机制,操作系统并不需要一上来就把整个软件装进内存中,而是通过缺页中断按需加载对应的程序代码片段。多个软件同时运行的问题也解决了,内存不够用的时候,就把最久没有用过的内存页淘汰掉,腾出物理内存出来。
运行软件的问题解决了。那么,操作系统如何分配内存给运行中的软件?
其实,内存分配的问题也解决了,并不需要任何额外的机制。反正内存地址空间是虚拟的,操作系统可以一上来就给要运行的软件分配超级大的内存,你想怎么用随你。软件如果不用某个内存页,什么都不发生。软件一旦用了某个内存页,通过缺页中断,操作系统就分配真正的物理内存给它。
通过引入虚拟内存及其缺页机制CPU 很好地解决了操作系统和软件的配合关系。
每个运行中的软件,我们把它叫进程,都有自己的地址映射表。也就是说,虚拟地址并不是全局的,而是每个进程有一个自己独立的虚拟地址空间。
在保护模式下,计算机的基础架构体系和操作系统共同在努力做的一件事情,就是让每个软件“感觉”自己在独占整个计算机的资源。独立的虚拟地址空间很好地伪装了这一点:看起来我独自在享用所有内存资源。在实模式下的浮动地址的问题也解决了,软件可以假设自己代码加载的绝对地址是什么,不需要在加载的时候重新调整 CPU 指令操作的地址。
这和实模式很不一样。在实模式下,所有进程都在同在物理内存的地址空间里,它们相互可以访问对方的数据,修改甚至破坏对方的数据,进而导致其他进程(包括操作系统本身的进程)崩溃。内存是进程运行的基础资源,保持进程基础资源的独立性,是软件治理的最基础的要求。这也是保护模式之所以叫“保护”模式的原因。
## 架构思维上我们学到什么?
虚拟内存它本质上要解决这样两个很核心的需求。
其一,软件越来越大,我们需要考虑在外置存储上执行指令,而不是完整加载到内存中。但是外置存储一方面它的数据 CPU 并不知道怎么读;另一方面就算知道怎么读,也不知道它的数据格式是什么样的,这依赖文件系统的设计。让 CPU 理解外置存储的实现细节?这并不是一个好的设计。
其二要同时运行的软件越来越多计算机内存的供给与软件运行的内存需求相比捉襟见肘。怎么才能把有限的内存的使用效率最大化一个很容易想到的思路是把不经常使用的内存数据交换到外置存储。但是问题仍然是CPU 并不了解外置存储的实现细节,怎么才能把内存按需交换出去?
通过把虚拟内存地址分页,引入缺页中断,我们非常巧妙地解决了这个问题。缺页中断很像是 CPU 留给操作系统的回调函数,通过它对变化点实现了很好的开放性设计。
## 结语
总结一下。我们今天先概要地阐述了计算机运行的全过程,并对每一步的核心意义做了简单的介绍。然后我们把话题转到我们这一节的重心:内存管理。
谈内存管理,需要谈清楚两个核心问题:
- 如何分配内存(给运行中的软件,避免它们发生资源争抢);
- 如何运行外置存储(比如硬盘)上的软件?
我们分别就在实模式下和保护模式下的内存管理进行了讨论。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,157 @@
<audio id="audio" title="08 | 操作系统内核与编程接口" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b4/2f/b44ee6f9bb35fbf9e4f3a4bc4e7ae42f.mp3"></audio>
你好,我是七牛云许式伟。
今天我们在开发软件的时候,已经处于一些基础的架构设计之中,像冯·诺依曼计算机体系,像操作系统和编程语言,这些都是我们开发一个应用程序所依赖的基础架构。
在上一讲“[07 | 软件运行机制及内存管理](https://time.geekbang.org/column/article/93802)” 中,我们着重介绍了软件是如何被运行起来的。**今天,我们着重聊一聊软件如何利用它所依赖的基础架构。**
**首先是冯·诺依曼计算机体系**,它由 “中央处理器+存储+一系列的输入输出设备” 构成。这一层提供了编程接口的是中央处理器CPU编程接口是 CPU 指令,但 CPU 指令非常难用。
为此,人们发明了编程语言来降低 CPU 指令的使用门槛。编程语言面向人类CPU 指令面向机器,编译器负责将人类容易理解和掌握的编程语言的程序,翻译成机器能够理解的 CPU 指令序列。
**其次是编程语言**。虽然编程语言出现的起因是降低 CPU 指令的使用门槛,第一门编程语言汇编语言的能力也很接近 CPU 指令,但是语言的自然演化会越来越脱离 CPU 所限制的条条框框,大部分语言都会演化出很多基础的算法库。
比如字符串算法库有字符串连接concat、字符串子串substring字符串比较compare、字符串长度length等等。
## 系统调用
**最后就是操作系统了**
操作系统和前两者非常不同。软件都是某种编程语言写成的,而 CPU 和编程语言的能力,统一以语言的语法或者库体现。
操作系统则属于基础软件,它和我们编写的软件并不在同一个进程(进程是软件的一个运行后产生的实例,同一个软件可以运行多次得到多个进程)中。
如果是实模式下的操作系统,大家都在同一个地址空间下,那么只需要知道操作系统的接口函数地址,理论上就可以直接访问。但是今天主流的操作系统都是保护模式的,操作系统和软件不在同一个进程,软件怎么才能使用操作系统的能力呢?
你可能想说,那就用进程与进程之间的通信机制?
的确,操作系统提供了很多进程与进程之间通讯的机制,后面我们也会涉及。但是今天我们讲的操作系统的编程接口是更为基础的机制,它是所有软件进程使用操作系统能力的基础,包括进程与进程之间通讯的机制,也是建立在这个基础之上。
它应该是一种成本非常非常低的方案,性能上要接近函数调用,否则我们为保护模式付出的成本就太高了。
有这样的机制么?有,就是上一讲我们已经提到过的“中断”。
中断的设计初衷是 CPU 响应硬件设备事件的一个机制。当某个输入输出设备发生了一件需要 CPU 来处理的事情,它就会触发一个中断;但是 CPU 也提供了指令允许软件触发一个中断,我们把它叫软中断。
大部分情况下,操作系统的能力通过软中断向我们写的软件开放,为此还专门引入了一个术语叫 “系统调用syscall”。
**系统调用是怎么工作的?**
我们需要先理解下 CPU 的代码执行权限等级。
在保护模式下CPU 引入了 “保护环Protection Rings” 的概念。说白了,代码有执行权限等级的,如果权限不够,有一些 CPU 指令就不能执行。
这一点比较容易理解:上一讲我们介绍过,从内存管理的角度,虚拟内存机制让软件运行在一个沙盒中,这个沙盒让软件感觉自己在独享系统的内存。但如果不对软件的执行权限进行约束,它就可以打破沙盒,了解到真实的世界。
我们通常说的操作系统是很泛的概念。完整的操作系统非常庞大。根据与应用的关系,我们可以把操作系统分为内核与外围。
所谓操作系统内核,其实就是指那些会向我们写的应用程序提供系统服务的子系统的集合,它们管理着计算机的所有硬件资源,也管理着所有运行中的应用软件(进程)。
操作系统内核的执行权限等级,和我们常规的软件进程不同。像 Intel CPU 通常把代码执行权限分为 Ring 0-3 四个等级。
操作系统内核通常运行在 Ring 0而常规的软件进程运行在 Ring 3当然近年来虚拟化机制流行为了更好地提升虚拟化的效率Intel CPU 又引入了 Ring -1 级别的指令,这些指令只允许虚拟机所在的宿主操作系统才能调用)。
系统调用所基于的软中断,它很像一次间接的“函数调用”,但是又颇有不同。在实模式下,这种区别并不强烈。但是在保护模式下,这种差异会十分明显。
原因在于,我们的应用程序运行在 Ring 3我们通常叫用户态而操作系统内核运行在 Ring 0我们通常叫内核态。所以一次中断调用不只是“函数调用”更重要的是改变了执行权限从用户态跃迁到了内核态。
但是这似乎不够。我们之前说了,操作系统与我们编写的软件并不同属一个进程,两边的内存地址空间都是独立的,我们系统调用请求是过去了,但是我们传给操作系统的内存地址,对它真的有意义吗?
答案在于,从虚拟内存机制的视角,操作系统内核和所有进程都在同一个地址空间,也就是,操作系统内核,它是所有进程共享的内存。示意如下:
<img src="https://static001.geekbang.org/resource/image/2b/b3/2b0adde3eca6262ae674a97f478c15b3.png" alt="">
这非常有趣。操作系统内核的代码和数据,不只为所有进程所共享,而且在所有进程中拥有相同的地址。这样无论哪个进程请求过来,对内核来说看起来都是一次本进程内的请求。
从单个进程的视角,中断向量表的地址,以及操作系统内核的地址空间是一个契约。有了中断向量表的地址约定,用户态函数就可以发起一次系统调用(软中断)。
当然你可能要问:**既然操作系统内核和我同属一个地址空间,我是否可以跳过中断,直接访问调用内核函数?**
这不单单是执行权限的问题。你可能会说,也许某个内核函数里面没有调用任何特权指令,我是否可以调用?
当然不能。这涉及虚拟内存中的内存页保护机制。内存页可以设置 “可读、可写、可执行” 三个标记位。操作系统内核虽然和用户进程同属一个地址空间,但是被设置为“不可读、不可写、不可执行”。虽然这段地址空间是有内容的,但是对于用户来说是个黑洞。
## 编程接口
理解了操作系统内核,以及它的调用方法 “系统调用”,我们来聊一聊操作系统的编程接口。
自然,最原始的调用方式,是用软中断指令。在汇编语言里面通常是:
```
int &lt;中断号&gt; ; // 对每个操作系统来说中断号是固定的,比如 Linux 是 0x80
```
这里的 int 不是整数integer的缩写而是中断interrupt的缩写。
当然用汇编语言来写软件并不是一个好主意。大部分高级语言都实现了操作系统编程接口的封装。
前面我们说,操作系统(内核)有六大子系统:存储管理、输入设备管理、输出设备管理、进程管理、网络管理、安全管理。除了安全管理是一个“润物细无声”的能力外,其他子系统都会有所包装。
我们以 C 语言和 Go 语言为例给一个简表,方便大家索引:
<img src="https://static001.geekbang.org/resource/image/37/11/372f60e314a3ec386844d4cd1db74411.jpg" alt="">
这些标准库的能力,大部分与操作系统能力相关,但或多或少进行了适度的包装。
例如HTTP 是应用层协议,和操作系统内核关联性并不大,基于 TCP 的编程接口可以自己实现,但由于 HTTP 协议细节非常多,这个网络协议又是互联网世界最为广泛应用的应用层协议,故此 Go 语言提供了对应的标准库。
进程内通讯最为复杂。虽然操作系统往往引入了 thread 这样的概念,但 Go 语言自己搞了一套goroutine 这样的东西,原因是什么,我们在后面讨论 “进程管理” 相关的内容时,再做详细讨论。
## 动态库
从操作系统的角度来说,它仅仅提供最原始的系统调用是不够的,有很多业务逻辑的封装,在用户态来做更合适。但是,它也无法去穷举所有的编程语言,然后一一为它们开发各种语言的基础库。那怎么办?
聪明的操作系统设计者们想了一个好办法:动态库。几乎所有主流操作系统都有自己的动态库设计,包括:
- Windows 的 dllDynamic Link Library
- Linux/Android 的 soshared object
- Mac/iOS 的 dylibMach-O Dynamic Library
动态库本质上是实现了一个语言无关的代码复用机制。它是二进制级别的复用,而不是代码级别的。这很有用,大大降低了编程语言标准库的工作量。
动态库的原理其实很简单,核心考虑两个东西。
- 浮动地址。动态库本质上是在一个进程地址空间中动态加载程序片段,这个程序片段的地址显然在编译阶段是没法确定的,需要在加载动态库的过程把浮动地址固定下来。这块的技术非常成熟,我们在实模式下加载进程就已经在使用这样的技术了。
- 导出函数表。动态库需要记录有哪些函数被导出export这样用户就可以通过函数的名字来取得对应的函数地址。
有了动态库,编程语言的设计者实现其标准库来说就多了一个选择:直接调用动态库的函数并进行适度的语义包装。大部分语言会选择这条路,而不是直接用系统调用。
## 操作系统与编程语言
我们这个专栏从计算机硬件结构讲起,然后再到编程语言,到现在开始介绍操作系统,有些同学可能会觉得话题有那么一些跳跃。虽然每一节的开头,我其实对话题的脉络有所交代,但是,今天我还是有必要去做一个梳理。
编程语言和操作系统是两个非常独立的演化方向,却又彼此交融,它们有点像是某种“孪生关系”。虽然操作系统的诞生离不开编程语言,但是操作系统和 CPU 一样,是编程语言背后所依赖的基础设施。
和这个话题相关的,有这么一些有趣的问题:
- 先有编程语言,还是先有操作系统;
- 编程语言怎么做到自举的比如用C语言来实现C语言编译器
- 操作系统开发的环境是什么样的,能够做到操作系统自身迭代本操作系统(自举)么?
对于**第一个问题:先有编程语言,还是先有操作系统?**这个问题的答案比较简单,先有编程语言。之所以有这个疑问,是因为两点:
其一,大部分人习惯认为运行软件是操作系统的责任。少了责任方,软件是怎么跑起来的?但实际上软件跑起来是很容易的,看 BIOS 程序把控制权交给哪个软件。
其二,大部分常见的应用程序都直接或间接依赖操作系统的系统调用。这样来看,编程语言编译出来的程序是无法脱离操作系统而存在的。但是实际上常见的系统级语言(比如 C 语言)都是可以编写出不依赖任何内核的程序的。
对于**第二个问题:编程语言怎么做到自举的?**
从鸡生蛋的角度,编译器的进化史应该是这样的:先用机器码直接写第一个汇编语言的编译器,然后汇编语言编译器编出第一个 C 语言编译器。有了 C 语言编译器后,可以反过来用 C 语言重写汇编语言编译器和 C 语言编译器,做更多的功能增强。
这个过程理论上每出现一种新 CPU 指令集、新操作系统,就需要重新来一遍。但是人是聪明的。所以交叉编译这样的东西产生了。所谓交叉编译就是在一种 “CPU +操作系统” 架构下,生成另一种 “CPU +操作系统” 架构下的软件。这就避免了需要把整个编译器进化史重新演绎一遍。
对于**第三个问题:操作系统能够做到自身迭代本操作系统(自举)么?**
当然可以。通常一门新的操作系统开发之初,会用上面提到的交叉编译技术先干出来,然后等到新操作系统稳定到一定程度后再实现自举,也就是用本操作系统自己来做操作系统的后续迭代开发。
## 结语
这一节我们介绍了我们的基础架构中央处理器CPU、编程语言、操作系统这三者对应用软件开放的编程接口。总结来看就是下面这样一幅图
<img src="https://static001.geekbang.org/resource/image/b2/e0/b2393a109f849bd91c991b1e750cb3e0.png" alt="">
其中,我们着重介绍的是操作系统的系统调用背后的实现机理。通过系统调用这个机制,我们很好地实现了操作系统和应用软件的隔离性和安全性,同时仍然保证了极好的执行性能。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="09 | 外存管理与文件系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/95/19/9567a7f19496063d1cac75ae0c1e7519.mp3"></audio>
你好,我是七牛云许式伟。
在 “[07 | 软件运行机制及内存管理](https://time.geekbang.org/column/article/93802)”中,我们已经聊了内存管理,这一讲我们聊聊外置存储设备的管理。
## 外存的分类
计算机有非常多样化的外置存储设备比如磁带、光盘、硬盘、U盘、SSD 等等。外置存储设备的种类是不可穷尽的。随着科技的发展,新的存储设备会不断涌现,有着更低的单位能耗(存储量/每日能源消耗成本),更低的单位存储成本(存储量/可存储的时间/设备价格),或者更高的访问性能。
但不管这些存储设备内部如何存储数据的原理怎么变,改变的主要是质量,而不是它的功能。对操作系统来说,管理它们的方式是非常一致的。这些外置存储设备依据其功能特性不同,简单可以分为如下三类。
- 顺序读写型。如:磁带。
- 随机只读型。更准确说是单次完整写入多次读取型,也就是每次写数据都是整个存储介质一次性完整写入数据。如:光盘(含可擦写光盘)。
- 随机读写型。如软盘、硬盘、U盘、SSD 等等。
顺序读写型的外置存储(如磁带)我们日常并不常见,它的主要应用场景是归档,也就是数据备份。今天我们略过不提。
随机只读型的外置存储如光盘我们日常有较多应用常见的应用场景是资料分发和归档。资料发布的内容很广泛比如软件、娱乐媒体包括电影、MTV、音乐等等。
随机读写型的外置存储最为常见我们今天在所有“能够称得上叫电脑”的设备上无论是PC、笔记本、手机还是手表、汽车随处可见它们的身影。
## 外存的数据格式
外置存储和内存最大的区别是什么?
毫无疑问,外置存储是持久存储,它的目的是用来存储资料的。而内存是临时存储,虽然是存储,但是它实质上是为 CPU 的计算服务的。
那么,怎么让很多的软件进程同时使用这些外置存储设备,而不会乱呢?直接基于物理的存储地址进行读写肯定是行不通的,过上几个月你自己可能都不记得什么数据写到哪里了。
所以和内存管理不同,外部存储的管理,我们希望这些写到存储中的数据是“自描述”的某种数据格式,我们可以随时查看之前写了哪些内容,都什么时候写的。
这就是文件系统的来源。
文件系统把存储设备中的数据组织成为了一棵树。节点可以是目录(也叫“文件夹”),也可以是文件。
树的根节点为目录,我们叫根目录。如果是目录,那么它还可以有子节点,子节点同样可以是子目录或文件。文件则是叶节点,保存我们希望存储的资料。
每个节点,无论是目录还是文件,都有自己的名字、创建时间、最后编辑时间、最后访问时间等信息。有些文件系统还会提供最近一段时间的操作日志。这些信息有助于提醒我们有什么内容,以前都做过什么。
尽管几乎所有文件系统的接口是非常一致的,但文件系统的实现却有很多。对于随机只读型的外置存储(如光盘),常见的文件系统有如下这些。
<img src="https://static001.geekbang.org/resource/image/3c/17/3cc295f0d1c92dbc8252c528d9139e17.jpg" alt="">
由于这类存储设备的写特征是批量写,一次把所有的数据写完,所以它的数据格式通常偏向于读优化(存储系统一般都有读写操作,所谓读优化是指在数据结构和算法设计时尽可能考虑让读操作更高效)。整个文件系统的元数据和文件数据都会非常紧凑,比如文件数据不必支持分块等等。
对于随机读写型的存储(如硬盘),常见的文件系统有如下这些。
<img src="https://static001.geekbang.org/resource/image/07/29/0795b3e4c850d2201269be0412c45c29.jpg" alt="">
从文件系统格式的设计角度来说,它和架构关联性不大,更多的是数据结构与算法的问题;而且,不是基于内存的数据结构,而是基于外存的数据结构,这两者非常不同。
尽管文件系统的种类非常多但是它们的设计思路其实基本相似。大部分现代文件系统都基于日志journal来改善文件系统的防灾难能力比如突然断电或不正常的 unmount 行为),基于 B 树或 B+ 树组织元数据。
古老的 DOS 引入的 FAT 文件系统(典型代表为 FAT32是个例外它直接把目录当作一个特殊的文件里面依次列出了这个目录里的所有子节点的元信息。
这个结构简单是简单了,但是缺点非常明显,如果目录树深、目录里的子节点数量多,都会大幅降低文件系统的性能。
对于随机读写型的存储设备,操作系统往往还支持对其进行分区,尤其是在这个存储设备的容量非常大的情况下。分区是一个非常简单而容易理解的行为,本质上只是把一个存储设备模拟成多个存储设备来使用而已。
一般来说,拿到一块存储设备,我们往往**第一步是对其进行分区**(当然也可以省略这一步,把整个设备看做一个分区)。
**第二步是对每个分区进行格式化。**所谓格式化就是给这个分区生成文件系统的初始状态。格式化最重要的是标记分区的文件系统格式(用来告诉别人这个分区是数据是怎么组织的),并且生成文件系统的根目录。
**第三步是把该分区挂载mount到操作系统管理的文件系统名字空间中。**完成挂载后,该分区的文件系统管理程序就工作起来了,我们可以对这个文件系统进行目录和文件的读取、创建、删除、修改等操作。
## 外存的使用接口
怎么使用这些外置存储设备?
最简单的方式是用操作系统提供的命令行工具。例如:
- 目录相关ls, mkdir, mv, cp, rmdir 等。
- 文件相关cat, vi, mv, cp, rm 等。
当然,最原始的方式还是我们上一讲介绍的 “系统调用”。但大部分编程语言对此都有相应的封装,例如 Go 语言中的相关功能如下所示。
- 目录相关os.Mkdir, os.Rename, os.Remove 等。
- 文件相关os.Open/Create/OpenFile, os.Rename, os.Remove 等。
有意思的是,在早期,操作系统试图将所有的输入输出设备的接口都统一以 “文件” 来抽象它。
最典型的代表就是标准输入stdin和标准输出stdout这两个虚拟的文件分别代表了键盘和显示器。在 UNIX 系里面有个 “一切皆文件” 的口号,便由此而来。
但事实证明 UNIX 错了。输入输出设备太多样化了,所谓的 “一切皆文件” 不过是象牙塔式的理想。就拿键盘和显示器来说,图形界面时代到来,所谓标准输入和标准输出就被推翻了,编程接口产生颠覆性的变化。
有了文件系统的使用接口,进程就可以互不影响地去使用这些外置存储设备。除非这些进程要操作的文件或目录的路径产生冲突(所谓路径,是指从根目录到该节点的访问序列。例如路径 /a/b/c 是从根目录访问子目录a再访问子子目录b最后访问节点c一般情况下它们并不需要感知到其他进程的存在。
路径冲突是可以避免的,只要我们对路径取名进行一些基础的名字空间约定,但有时候也会故意利用这种路径的冲突,来实现进程间的通讯。
操作系统提供了一些冲突检查的机制。例如 “检查文件是否存在,不存在就创建它”,这个语义在保证原子性的前提下,就可以用于做进程间的互斥。例如,我们希望一个软件不要运行多个进程实例,就可以基于这个机制来实现。
## 虚拟内存的支持
前面我们在 “[07 | 软件运行机制及内存管理](https://time.geekbang.org/column/article/93802)” 一讲中提到,在物理内存不足的时候,操作系统会利用外存把一些很久没有使用的内存页的数据,保存到外存以进行淘汰。
在 UNIX 系的操作系统中,操作系统为此分配了一个磁盘分区叫 swap 分区,专门用于内存页的保存和恢复。在 Windows 操作系统中则通过一个具有隐藏属性的 .swp 文件来实现。
在缺页发生比较频繁时,内存页的数据经常性发生保存和恢复,这会发生大量的磁盘 IO 操作,非常占用 CPU 时间,这时候我们通常能够非常明显感觉到计算机变得很慢。
在计算机变慢,并且计算机的硬盘灯不停闪烁的时候,我们基本可以确定是物理内存严重不足,不能满足运行中的软件的内存需要。
## 结语
回顾一下我们今天的内容。整体来说,外存管理从架构角度来说比较简单,复杂性主要集中在外存数据格式,也就是文件系统的设计上。
文件系统的实现非常多。如果你希望进一步研究某个文件系统的具体实现细节,我这里推荐一个由七牛云开源的 BPL 语言Binary Processing Language二进制处理语言。地址如下
- [https://github.com/qiniu/bpl](https://github.com/qiniu/bpl)
顾名思义BPL 语言主要用于分析二进制数据格式。应用场景包括:文件格式分析(含磁盘分区格式,因为一个磁盘分区可以把它理解为一个大文件)、网络协议分析。
我们在后面的介绍文本处理相关的章节,还会专门拿出 BPL 语言进行讨论。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,132 @@
<audio id="audio" title="10 | 输入和输出设备:交互的演进" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8c/c5/8c1d9263267db0f8eaa5b1ec2e3d42c5.mp3"></audio>
你好,我是七牛云许式伟。
前面我们已经介绍了操作系统的存储管理:内存和外存。今天,让我们来聊一聊操作系统是如何管理输入和输出设备的。
输入和输出设备有非常非常多。例如输入设备除了你常见的键盘、鼠标、触摸屏外还有一些采集信息的传感器如GPS位置、脉搏、心电图、温度、湿度等。我们会把关注点收敛在人机交互相关的设备管理上。
## 交互的演进
在计算机外部设备的演进上,人机交互设备的演进毫无疑问是最为剧烈的部分。
计算机刚出现的时候,人们使用“**打孔卡+打印机**”作为人机交互方式。这个时期还没有操作系统,表达意图非常痛苦,只能在打孔卡上打孔来发送指令。
但很快,人们找到了 “**键盘+显示器**”, 这对最为经典的人机交互设备。而随着计算机使用人群越来越多,以及使用场景的变化,人机交互的方式也相应地发生了一次又一次的改变。
<img src="https://static001.geekbang.org/resource/image/b6/49/b6df127839174d6a1d524a2efa243049.jpg" alt="">
总结这些改变,我们会发现,人机交互在往越来越自然的方向发展。所谓自然,就是越来越接近于两个人直接的面对面沟通。
那么,这些人机交互的改变,对于操作系统来说又意味着什么呢?
## 输入设备
我们先看输入设备:键盘、鼠标、麦克风、摄像头。
### 键盘
键盘输入的管理机制和窗口系统的设计密切相关。为了让用户清楚键盘输入的目的地,窗口系统往往会有一个焦点窗口。
在窗口系统里面,窗口间还有父子关系,焦点窗口还会有父窗口,还有父窗口的父窗口,这些窗口属于活动窗口。
大部分情况下,键盘输入的事件会先发给焦点窗口,焦点窗口不处理则发给其父窗口,按此传递,直到有人处理了该按键事件,或者直到顶层窗口。
键盘从功能上来说,**有两个不同的能力:其一是输入文本,其二是触发命令。**从输入文本的角度来说要有一个输入光标在Windows里面叫Caret来指示输入的目的窗口。目的窗口也必然是焦点窗口否则就会显得很不自然。
这个交互的呈现方式非常稳定,从 DOS到Windows/Mac到iOS/Android 都是如此。但是从触发命令的角度来说,命令的响应并不一定是在焦点窗口,甚至不一定在活跃窗口。
比如Windows下就有热键HotKey的概念能够让非活跃窗口Inactive Window也获得响应键盘命令的机会。一个常见的例子是截屏软件往往需要一个热键来触发截屏。
到了移动时代,键盘不再是交互主体,但是,键盘作为输入文本的能力很难被替代(虽然有语音输入法),于是它便自然而然地保留下来。
不过移动设备不太会有人会基于键盘来触发命令,只有常见的热键需求比如截屏、调大或调小音量、拍照等等,被设计为系统功能(对应的,这些功能的热键也被设计为系统按键)保留下来。
### 鼠标
鼠标输入的管理机制和键盘一样,和窗口系统的设计密切相关。但鼠标因为有位置,确定鼠标事件的目的地相比键盘事件要简单的多,大部分情况下,鼠标事件总是交给鼠标位置所属的窗口来处理,但也会有一些例外的场景,比如拖放。
为了支持拖放Windows操作系统引入了鼠标捕获Mouse Capture的概念一旦鼠标被某个窗口捕获哪怕鼠标已经移出该窗口鼠标事件仍然会继续发往该窗口。
到了移动时代鼠标已经完全消失虽然在智能手机之前还是出现过WinCE这样的支持鼠标的移动操作系统取而代之的是触摸屏。窗口系统也和PC时期完全不同在屏幕可见范围内只有单个应用程序占满整个屏幕这让交互的目的地确认不再是个问题。
### 麦克风
麦克风是一个非常有潜力的下一代输入设备。今天 IoT 领域如汽车、智能音箱都是很好的发展语音交互能力的场景。包括今天大行其道的手机,语音交互也是一个很好的补充。
交互方式不管怎么变化,其核心需要实现的都是这样的两大能力:输入文本和触发命令,这一点是不变的。
语音交互今天仍然还很不成熟,究其原因,语音交互在 IoT 领域还停留在触发命令为主,且哪怕是触发命令这一件事情,也还有重重关卡需要去突破。
在手机软件中,语音输入文本在部分软件中已经有较多应用,但是主要优势还在日常用语和长文本,在个性化场景如“输入人名之类”,较难达到好的结果。
从更本源的角度看,语音交互今天仍然在相对封闭的应用技术场景里面发展为主,而作为操作系统的主体交互手段,其能力必须是开放的。因为操作系统是开放的,场景是开放的。
### 摄像头
摄像头作为交互设备,除了引入语音,也引入了手势、表情。从表达能力来说,这是最为丰富也是最为自然的一种表达方法。但是技术所限,这种交互方式还只在萌芽阶段。微软的 Kinect 是一个非常经典的案例,它能够让玩家通过语音和手势发指令来玩游戏。
### 输出设备
输出设备主要负责向用户反馈信息。比如:显示器(显卡)、音箱(声卡)、打印机。输出设备的演化并不大,最主要的输出设备还是以显示器为主。
### 显示器
显示器虽然经历了 CRT 到液晶屏多代更新,但也只是支持的色彩更多(从黑白到彩色到真彩色),分辨率越来越高。实际上,从操作系统的软件治理角度来看,显示器并没有发生过实质性的变化。
为了让不同软件可以在同一显示器屏幕上呈现操作系统引入了窗口系统的概念。每个软件有一个或多个窗口Window有时候也叫视图即View。在 PC 操作系统中不同窗口还可以层叠Cascade或平铺Tile
通过引入窗口,操作系统在逻辑上把显示器屏幕这个有限的设备资源,分配给了多个软件。和 PC 不同的是,移动设备由于屏幕过小,所以操作系统选择了让软件的顶层窗口全屏占据整个屏幕。这让显示器屏幕的管理变得更为简单。
除了窗口系统,显示设备管理的另一大挑战是绘制子系统。窗口里面的内容是什么,呈现成什么样子,完全是软件来决定的,这就意味着软件需要绘制能力。
绘制能力牵涉面非常之广在操作系统里面往往有一个独立的子系统通常叫GDI与之对应。这里我们简单罗列一下GDI子系统会涉及哪些东西。
- **2D图形相关。**包含 Path(路径)、Brush(画刷)、Pen(画笔) 等概念。
- **3D图形相关。**包含 Model(模型)、Material(材质)、Lighting(光照) 等概念。
- **文本相关。**包含 Font(字体) 等概念。而字体又分点阵字体和 TrueType 字体。TrueType 字体的优势是可以自由缩放。今天我们见到的大部分字体都是 TrueType 字体。
- **图像处理相关。**包含 Bitmap(位图) 对象及常见图像格式的编解码器(Encoder/Decoder)。
窗口系统结合输入设备对应的事件管理系统、绘制(GDI)系统,我们就可以随心所欲地实现各类用户体验非常友好的视窗软件了。
但是,为了进一步简化开发过程,操作系统往往还提供了一些通用的界面元素,通常我们称之为控件(Control)。常见的控件有如下这些:
- 静态文本 (Label)
- 按钮 (Button)
- 单选框 (RadioBox)
- 复选框 (CheckBox)
- 输入框 (Input也叫EditBox/EditText)
- 进度条 (ProgressBar)
- ……
不同操作系统提供的基础控件大同小异。不过一些处理细节上的差异往往会成为跨平台开发的坑,如果你希望一份代码多平台使用,在这方面就需要谨慎处理。
### 音箱
相比显示器的管理,音箱的设备管理要简单得多。我们很容易做到多个软件同时操作设备,而有合理的结果。
例如,调整音量我们遵循覆盖原则即可,谁后设置音量就听谁的。而声音的播放则可进行混音处理,多个软件播放的声音同时播放出来,让人听起来像是同时有多个人在说话。
当然,特定情况下要允许某个软件禁止其他软件播放出来的声音,比如接听电话的软件,需要在电话接通的时候屏蔽掉所有其他软件播放的声音。
### 打印机
打印机的管理方式又很不一样,软件使用打印机的过程基本上是互斥的。一个软件在打印文档的时候,其他的软件只能等待它打印完毕后,才能进行打印。
打印机的使用是以文档为互斥的单位。为了避免软件之间出现长时间的相互等待,操作系统往往在打印机的管理程序中引入很大的打印缓冲。
软件操作打印机的时候,并不是等待打印机真把内容打印出来,而是把文档打印到打印缓冲中就完成打印。这样,在大部分情况下多个软件不需要因为使用打印机而出现相互等待。
## 结语
后面我们在谈“桌面开发”一章中,还会涉及人机交互的更多细节,这一章侧重点在于领域无关的通用操作系统相关的问题域,相关的内容这里仅做概要性的阐述。
但是,仅通过简单对比所有输入和输出设备的管理方式,我们就可以看出,不同输入和输出设备的管理方法差异非常大,没有太大的共性可言。
尽管对 CPU 而言,所有外部设备有着相同的抽象,但这些设备的业务逻辑却如此不同,并不能统一抽象它们。正是因为有了操作系统这样的基础软件,这些设备业务逻辑的复杂性才从我们的软件开发过程中解放出来。
人机交互演化的核心变化是输入设备的变化。我们看到,输入手段的变化是非常剧烈的,且每一次演变都是颠覆性的变化。
事实上输入意图的理解越来越难了,**因为交互在朝着自然Nature和智能Intelligence的方向发展。**我们不可能让每一个软件都自己去做输入意图的理解(今天的现状是每个软件自己做),**在未来,必然将由操作系统来实现智能交互的基础架构。**
今天的内容就到这里。你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,190 @@
<audio id="audio" title="11 | 多任务:进程、线程与协程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8a/09/8ae61d45283989b0dcb36171bc946609.mp3"></audio>
你好,我是七牛云许式伟。
到现在为止,我们已经介绍了操作系统的存储管理:内存与外存;也已经介绍了输入与输出设备的管理。
当然,考虑到输入与输出设备属于人机交互范畴,我们主要会留到下一章 “桌面软件开发” 去详细介绍,这一章,我们仅概要地回顾输入与输出设备的需求演进过程。
**CPU + 存储 + 输入与输出,软件开发最基础的内容基本上就都覆盖到了。** 今天开始,我们就来聊一聊多任务。
## 多任务与执行体
多任务的需求是随处可见的。常见的场景,比如我们想边工作边听音乐;又或者我们需要跑一个后台监控程序,以报告随时可能发生的异常。
那么,怎么才能做到多任务?
我们先从物理层面看。最早期的 CPU 基本上都是单核的,也就是同一时间只能执行一条指令。尽管如此,大家可能都听过 “摩尔定律”,简单地说就是,每隔一年半到两年,同样的钱能买到的计算力能够翻一倍。
这当然不是什么严谨的物理学定律,更多的是一定历史时期下的经验之谈。早期 CPU 工艺的发展,基本上是通过提高电子元器件的密集程度实现的;但是电子元器件大小总归有个极限,不可能无限小下去。
那么怎么办?不能更小的话,那就横向多铺几个,一颗 CPU 多加几颗核心。这样多核技术就出现了。多核的意思是说,单核速度我提不上去了,多给你几个,价格一样。
所以**物理层面的多任务,有两个方法:一个是多颗 CPU一个是单颗 CPU 多个核心。**
在桌面端,大多数情况用的是后者,因为桌面端的产品(个人计算机、手机、手表等)还是很在意产品的体积如何尽可能做得更小;而服务器领域,通常同时使用两者,它更多关注的是如何尽可能提升单台计算机的计算力密度。
但如果我们实际就只有一个单核的 CPU是否就没办法实现多任务呢
当然可以。方法是把 CPU 的时间切成一段段时间片,每个时间片只运行某一个软件。这个时间片给软件 A下一个时间片给软件 B。因为时间片很小我们会感觉这些软件同时都在运行。这种分时间片实现的多任务系统我们把它叫分时系统。
分时系统的原理说起来比较简单,把当前任务状态先保存起来,把另一个任务的状态恢复,并把执行权交给它即可。这里面涉及的问题有:
- 任务是什么,怎么抽象任务这样一个概念;
- 任务的状态都有什么?怎么保存与恢复;
- 什么时机会发生任务切换?
从今天的现实看,任务的抽象并不是唯一的。大部分操作系统提供了两套:进程和线程。有的操作系统还会提供第三套叫协程(也叫纤程)。
我个人喜欢统一用来 “**执行体**” 一词来统称它们。所谓**执行体**,是指可被 CPU 赋予执行权的对象,它至少包含下一个执行位置(获得执行权后会从这里开始执行)以及其他的运行状态。
任务的状态都有什么?
从 CPU 的角度执行程序主要依赖的是内置存储寄存器和内存RAM它们构成执行体的上下文。
**先看寄存器**。寄存器的数量很少且可枚举,我们直接通过寄存器名进行数据的存取。
在我们把 CPU 的执行权从软件 A 切换到软件 B 的时候,要把软件 A 所有用到的寄存器先保存起来(以便后续轮到软件 A 执行的时候恢复),并且把寄存器的值恢复到软件 B 上一次执行时的值,然后才把执行权交给软件 B。
这样,在软件 A 和 B 的视角看来,它们好像一直都是独自在使用 CPU从未受到过其他软件的打扰。
**我们再看内存RAM**。CPU 在实模式和保护模式下的内存访问机制完全不同,我们分别进行讨论。在实模式下,多个执行体同在一个内存地址空间,相互并无干扰(非恶意情况下)。
在保护模式下,不同任务可以有不同的地址空间,它主要通过不同的地址映射表来体现。怎么切换地址映射表?也是寄存器。
所以,总结就一句话:**执行体的上下文,就是一堆寄存器的值。要切换执行体,只需要保存和恢复一堆寄存器的值即可。**无论是进程、线程还是协程,都是如此。
## 进程与线程
那么,不同的执行体究竟有何不同?为何会出现不同种类的执行体?
<img src="https://static001.geekbang.org/resource/image/bf/8a/bf0720da6789e599daf672e1db04058a.jpg" alt="">
进程是操作系统从安全角度来说的隔离单位,不同进程之间基于最低授权的原则。
在创建一个进程这个事情上UNIX 偷了一次懒,用的是 fork分叉语义。所谓 fork就是先 clone 然后再分支,父子进程各干各的。
这样创建进程很讨巧,不用传递一堆的参数,使用上非常便利。但我认为从架构设计的角度,这是 UNIX 操作系统设计中最糟糕的 API没有之一。而更不幸的是 Linux 把这一点继承下来了。
为什么进程 fork 是糟糕的?**这是因为:进程是操作系统最基本的隔离单元,我们怕的就是摘不清楚,但是 fork 偏偏要藕断丝连。**
这一点 Windows 要清晰很多,哪些文件句柄在子进程中还要用到,一一明确点名,而不是 fork 一下糊里糊涂就继承过去了。
事实上我个人那么多年工程经验表明,除了会接管子进程的标准输入和标准输出,我们几乎从来不会通过向子进程传递文件句柄来通讯。
所以 fork 这种传递进程上下文的方式,是彻头彻尾的一次过度设计。甚至严重一点说,是设计事故。
线程的出现,则是因为操作系统发现同一个软件内还是会有多任务的需求,这些任务处在相同的地址空间,彼此之间相互可以信任。
从线程角度去理解 UNIX 的 fork能够稍微理解一些设计者们当年的考量。
早期操作系统中没有线程的概念,也不会有人想到要搞两套执行体。所以进程实际上承担了一部分来自线程的需求:我需要父进程的环境。
## 协程与goroutine
协程并不是操作系统内核提供的,它有时候也被称为用户态线程。这是因为协程是在用户态下实现的。如果你感兴趣,也可以自己实现一个。
但为什么会出现协程呢?看起来它要应对的需求与线程一样,但是功能比线程弱很多?
答案是因为实现高性能的网络服务器的需要。对于常规的桌面程序来说,**进程+线程绰绰有余。** 但对于一个网络服务器,我们可以用下面这个简单的模型看它:
<img src="https://static001.geekbang.org/resource/image/76/06/767fa0814f026410827a6185218c9c06.png" alt="">
对网络服务器来说,大量的来自客户端的请求包和服务器的返回包,都是网络 IO在响应请求的过程中往往需要访问存储来保存和读取自身的状态这也涉及本地或网络 IO。
如果这个网络服务器有很多客户,那么整个服务器就充斥着大量并行的 IO 请求。
操作系统提供的标准网络 IO 有以下这些成本:
- 系统调用机制产生的开销;
- 数据多次拷贝的开销(数据总是先写到操作系统缓存再到用户传入的内存);
- 因为没有数据而阻塞,产生调度重新获得执行权,产生的时间成本;
- 线程的空间成本和时间成本(标准 IO 请求都是同步调用,要想 IO 请求并行只能使用更多线程)。
在一些人心目中会有一个误区:操作系统的系统调用很慢。这句话很容易被错误地理解为系统调用机制产生的开销很大。
但这是很大的误解。系统调用虽然比函数调用多做了一点点事情,比如查询了中断向量表(这类似编程语言中的虚函数),比如改变 CPU 的执行权限(从用户态跃迁到内核态再回到用户态)。
但是注意这里并没有发生过调度行为,所以归根结底还是一次函数调用的成本。怎么理解操作系统内核我们示意如下:
<img src="https://static001.geekbang.org/resource/image/35/cb/35e748fa03b0f5a0a28ed5dafd9644cb.png" alt="">
从操作系统内核的主线程来说,内核是独立进程,但是从系统调用的角度来说,操作系统内核更像是一个多线程的程序,每个系统调用是来自某个线程的函数调用。
为了改进网络服务器的吞吐能力,现在主流的做法是用 epollLinux或 IOCPWindows机制这两个机制颇为类似都是在需要 IO 时登记一个 IO 请求,然后统一在某个线程查询谁的 IO 先完成了,谁先完成了就让谁处理。
从系统调用次数的角度epoll 或 IOCP 都是产生了更多次数的系统调用。从内存拷贝来说也没有减少。所以真正最有意义的事情是:减少了线程的数量。
既然不希望用太多的线程,网络服务器就不能用标准的同步 IOread/write来写程序。知名的异步 IO 网络库 libevent 就是对 epoll 和 IOCP 这些机制包装了一套跨平台的异步 IO 编程模型。
Node.js 一炮而红,也是因为把 JavaScript 的低门槛和 libevent 的高性能结合起来,给了前端程序员一个“我也能搞高性能服务器”的梦想。
但是异步 IO 编程真的很反人类,它让程序逻辑因为 IO 异步回调函数而碎片化。我们开始怀念写同步 IO 的那些日子了。
让我们再回头来看:我们为什么希望减少线程数量?因为线程的成本高?我们分析一下。
首先,我们看下时间成本。它可以拆解为:
- 执行体切换本身的开销,它主要是寄存器保存和恢复的成本,可腾挪的余地非常有限;
- 执行体的调度开销,它主要是如何在大量已准备好的执行体中选出谁获得执行权;
- 执行体之间的同步与互斥成本。
我们再看线程的空间成本。它可以拆解为:
- 执行体的执行状态;
- TLS线程局部存储
- 执行体的堆栈。
空间成本是第一根稻草。默认情况下 Linux 线程在数 MB 左右,其中最大的成本是堆栈(虽然,线程的堆栈大小是可以设置的,但是出于线程执行安全性的考虑,线程的堆栈不能太小)。
我们可以算一下,如果一个线程 1MB那么有 1000 个线程就已经到 GB 级别了,消耗太快。
执行体的调度开销,以及执行体之间的同步与互斥成本,也是一个不可忽略的成本。虽然单位成本看起来还好,但是盖不住次数实在太多。
我们想象一下:系统中有大量的 IO 请求,大部分的 IO 请求并未命中而发生调度。另外,网络服务器的存储是个共享状态,也必然伴随着大量的同步与互斥操作。
综上,协程就是为了这样两个目的而来:
- 回归到同步 IO 的编程模式;
- 降低执行体的空间成本和时间成本。
但是,大部分你看到的协程(纤程)库只是一个半吊子。它们都只实现了协程的创建和执行权的切换,缺了非常多的内容。包括:
- 协程的调度;
- 协程的同步、互斥与通讯;
- 协程的系统调用包装,尤其是网络 IO 请求的包装。
这包含太多的东西,基本上你看到的服务端操作系统所需的东西都要包装一遍。而且,大部分协程库,连协程的基础功能也是半吊子的。这里面最难搞的是堆栈。
为什么协程的堆栈是个难题?因为,协程的堆栈如果太小则可能不够用;而如果太大则协程的空间成本过高,影响能够处理的网络请求的并发数。理想情况下,堆栈大小需要能够自动适应需要。
所以,一个完备的协程库你可以把它理解为用户态的操作系统,而协程就是用户态操作系统里面的 “进程”。
这世界上有完备的协程库么有。有两个语言干了这事儿Erlang 和 Go 语言。Erlang 语言它基于虚拟机但是道理上是一致的。Go 语言里面的用户态 “进程” 叫 goroutine。它有这样一些重要设计
- 堆栈开始很小(只有 4K但可按需自动增长
- 坚决干掉了 “线程局部存储TLS” 特性的支持,让执行体更加精简;
- 提供了同步、互斥和其他常规执行体间的通讯手段,包括大家非常喜欢的 channel
- 提供了几乎所有重要的系统调用(尤其是 IO 请求)的包装。
## 架构师的批判性思维
多任务的需求非常复杂。
为了满足需要,人们不只发明了三套执行体:进程、线程和协程,还发明了各种五花八门的执行体间的通讯机制(可以参考 “[08 | 操作系统内核与编程接口](https://time.geekbang.org/column/article/94486)” 中我们给出的表格)。有一些执行体间的通讯机制在逐渐消亡,退出历史舞台。
操作系统内核之中,不乏无数精妙的设计思想。但是,前辈们也并非圣贤,也可能会出现一些决策上失误,留下了诸多后遗症。
这非常正常。操作系统内核是非常庞大而复杂的基础软件。它并不像计算机基础体系结构,简洁优雅。
对 CPU 而言,统一的、接口一致的输入输出设备,到了操作系统这里,它需要依据每一种设备的需求特性,抽象出对应的更加用户友好的使用接口。这个工作既繁重,又需要极强的预见性。
而作为后辈的我们,在体会这些精妙的设计思想的同时,也要批判性去吸收。日常我们天天依赖于这些基础架构,受到它们的影响与约束,这些实在是最佳的学习材料。
## 结语
今天我们重点介绍了多任务,以及多任务带来的复杂需求,由此介绍了进程、线程和协程等三套执行体的设计。后面我们还会分进程内和进程间来介绍进程的通讯机制。
执行体的设计有非常多值得反思的地方。UNIX 的 fork API 是否是一个好的设计?线程的设计是否成功?如果线程的设计是优良的,是不是就不再有 Go 语言这种在用户态重造执行体和 IO 子系统的必要性?
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,395 @@
<audio id="audio" title="12 | 进程内协同:同步、互斥与通讯" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ea/27/ea61be65d49f84d8166f3e618c7bb327.mp3"></audio>
你好,我是七牛云许式伟。
上一讲开始我们进入了多任务的世界,我们详细介绍了三类执行体:进程、线程和协程,并且介绍了每一种执行体的特点。
既然启动了多个执行体,它们就需要相互协同,今天我们先讨论进程内的执行体协同。
考虑到进程内的执行体有两类:用户态的协程(以 Go 语言的 goroutine 为代表)、操作系统的线程,我们对这两类执行体的协同机制做个概要。如下:
<img src="https://static001.geekbang.org/resource/image/57/1b/575d31c0ebf3f4a6148a211387bdae1b.jpg" alt="">
让我们逐一详细分析一下它们。
## 原子操作
首先让我们看一下原子操作。需要注意的是,原子操作是 CPU 提供的能力,与操作系统无关。这里列上只是为了让你能够看到进程内通讯的全貌。
顾名思义,原子操作的每一个操作都是原子的,不会中途被人打断,这个原子性是 CPU 保证的,与执行体的种类无关,无论 goroutine 还是操作系统线程都适用。
从语义上来说,原子操作可以用互斥体来实现,只不过原子操作要快得多。
例如:
```
var val int32
...
newval = atomic.AddInt32(&amp;val, delta)
```
等价于:
```
var val int32
var mutex sync.Mutex
...
mutex.Lock()
val += delta
newval = val
mutex.Unlock()
```
## 执行体的互斥
互斥体也叫锁。锁用于多个执行体之间的互斥访问,避免多个执行体同时操作一组数据产生竞争。其使用界面上大概是这样的:
```
func (m *Mutex) Lock()
func (m *Mutex) Unlock()
```
锁的使用范式比较简单:在操作需要互斥的数据前,先调用 Lock操作完成后就调用 Unlock。但总是存在一些不求甚解的人对锁存在各种误解。
有的人会说锁很慢。甚至我曾看到有 Go 程序员用 channel 来模拟锁理由就是锁太慢了尽量不要用锁。产生“锁慢channel 快”这种错觉的一个原因,可能是人们经常看到这样的忠告:
>
**不要通过共享内存来通信要通过通信channel来共享内存。**
不明就里的人们看到这话后可能就有了这样的印象锁是坏的锁是性能杀手channel 是好的,是 Go 发明的先进武器,应该尽可能用 channel而不要用锁。
快慢是相对而言的。锁的确会导致代码串行执行,所以在某段代码并发度非常高的情况下,串行执行的确会导致性能的显著降低。但平心而论,相比其他的进程内通讯的原语来说,锁并不慢。从进程内通讯来说,比锁快的东西,只有原子操作。
例如 channel作为进程内执行体间传递数据的设施来说它本身是共享变量所以 channel 的每个操作必然是有锁的。事实上channel 的每个操作都比较耗时。关于这一点,在下文解释 channel 背后的工作机理后,你就会清楚知道。
那么锁的问题在哪里?锁的最大问题在于不容易控制。锁 Lock 了但是忘记 Unlock 后是灾难性的,因为相当于服务器挂了,所有和该锁相关的代码都不能被执行。
比如:
```
mutex.Lock()
doSth()
mutex.Unlock()
```
在考虑异常的情况下,这段代码是不安全的,如果 doSth 抛出了异常,那么服务器就会出现问题。
为此 Go 语言还专门发明了一个 defer 语法来保证配对:
```
mutex.Lock()
defer mutex.Unlock()
doSth()
```
这样可以保证即使 doSth 发生异常mutex.Unlock 仍然会被正确地执行。这类在异常情况下也能够正常工作的代码,我们称之为 “对异常安全的代码”。如果语言不支持 defer而是支持 try .. catch那么代码可能是这样的
```
mutex.Lock()
try {
doSth()
} catch (e Exception) {
mutex.Unlock()
throw e
}
mutex.Unlock()
```
锁不容易控制的另一个表现是锁粒度的问题。例如上面 doSth 函数里面如果调用了网络 IO 请求,而网络 IO 请求在少数特殊情况下可能会出现慢请求,要好几秒才返回。那么这几秒对服务器来说就好像挂了,无法处理请求。
对服务器来说这是极为致命的。对后端程序员来说,有一句箴言要牢记:
>
**不要在锁里面执行费时操作。**
这里 “锁里面” 是指在`mutex.Lock``mutex.Unlock`之间的代码。
在锁的最佳编程实践中,如果明确一组数据的并发访问符合 “绝大部分情况下是读操作,少量情况有写操作” ,这种 “读多写少” 特征,那么应该用读写锁。
所谓读写锁,是把锁里面的操作分为读操作和写操作两种,对应调用不同的互斥操作。
如果是读操作,代码如下:
```
mutex.RLock()
defer mutex.RUnlock()
doReadOnlyThings
```
如果是锁里面是写操作,代码就和普通锁一样,如下:
```
mutex.Lock()
defer mutex.Unlock()
doWriteThings
```
为什么在 “读多写少” 的情况下,这样的使用范式能够优化性能?
因为从需求上来说,如果当前我们正在执行某个读操作,那么再来一个新的读操作,是不应该挡在外面的,大家都不修改数据,可以安全地并发执行。但如果来的是写操作,就应该挡在外面,等待读操作执行完。整体来说,读写锁的特性就是:
>
<p>**读操作不阻止读操作,阻止写操作;**<br>
**写操作阻止一切,不管读操作还是写操作。**</p>
## 执行体的同步
聊完了执行体的互斥,我们再来看下执行体之间的同步。
同步的一个最常见的场景是把一个大任务分解为n个小任务分配给n个执行体并行去做等待它们一起做完。这种同步机制我们叫 “等待组”。
其使用界面上大概是这样的:
```
func (wg *WaitGroup) Add(n int)
func (wg *WaitGroup) Done()
func (wg *WaitGroup) Wait()
```
用法上大概是这样的:
```
var wg WaitGroup
...
wg.Add(n)
for 循环n次 {
go func() {
defer wg.Done()
doTaski // 执行第i个任务
}()
}
wg.Wait()
```
简而言之,在每个任务开始的时候调用 wg.Add(1),结束的时候调用 wg.Done(),然后在主执行体调用 wg.Wait() 等待这些任务结束。
需要注意的是wg.Add(1) 是要在任务的 goroutine 还没有开始就先调用,否则可能出现某个任务还没有开始执行就被认为结束了。
条件变量Condition Variable是一个更通用的同步原语设计精巧又极为强大。强大到什么程度像 channel 这样的通讯机制都可以用它来实现。
条件变量的使用界面上大概是这样的:
```
func NewCond(l Locker) *Cond
func (c *Cond) Broadcast()
func (c *Cond) Signal()
func (c *Cond) Wait()
```
那么,怎么用条件变量?
我们先看下初始化。条件变量初始化的时候需要传入一个互斥体它可以是普通锁Mutex)也可以是读写锁RWMutex。如下
```
var mutex sync.Mutex // 也可以是 sync.RWMutex
var cond = sync.NewCond(&amp;mutex)
...
```
为什么创建条件变量需要传入锁?因为 cond.Wait() 的需要。Wait 内部实现逻辑是:
```
把自己加入到挂起队列
mutex.Unlock()
等待被唤醒 // 挂起的执行体会被后续的 cond.Broadcast 或 cond.Signal() 唤醒
mutex.Lock()
```
初始化了条件变量后,我们再来看看它的使用方式。条件变量的用法有一个标准化的模板,看起来大概是这样的:
```
mutex.Lock()
defer mutex.Unlock()
for conditionNotMetToDo {
cond.Wait()
}
doSomething
if conditionNeedNotify {
cond.Broadcast()
// 有时可以优化为 cond.Signal()
}
```
看起来有些复杂,让我们来解释一下。加锁后,先用一个 for 循环判断当前是否能够做我们想做的事情,如果做不了就调用 cond.Wait() 进行等待。
这里很重要的一个细节是注意用的是 for 循环,而不是 if 语句。这是因为 cond.Wait() 得到了执行权后不代表我们想做的事情就一定能够干了,所以要再重新判断一次条件是否满足。
确定能够做事情了,于是 doSomething。在做的过程中间如果我们判断可能挂起队列中的部分执行体满足了重新执行的条件就用 cond.Broadcast 或 cond.Signal 唤醒它们。
cond.Broadcast 比较粗暴,它唤醒了所有在这个条件变量挂起的执行体,而 cond.Signal 则只唤醒其中的一个。
什么情况下应该用 cond.Broadcast什么情况下应该用 cond.Signal最偷懒的方式当然是不管三七二十一用 cond.Broadcast 一定没问题。但是本着经济的角度,我们还是要交代清楚 cond.Signal 的适用范围:
- 挂起在这个条件变量上的执行体,它们等待的条件是一致的;
- 本次 doSomething 操作完成后,所释放的资源只够一个执行体来做事情。
Cond 原语虽然叫条件变量,但是实际上它既没有明白说变量具体是什么样的,也没有说条件具体是什么样的。变量是指 “一组要在多个执行体之间协同的数据”。条件是指做任务前 Wait 的 “前置条件”,和做任务时需要唤醒其它人的 “唤醒条件”。
这样的介绍相当的抽象。我们拿 Go 语言的 channel 开刀,自己实现一个。代码如下:
```
type Channel struct {
mutex sync.Mutex
cond *sync.Cond
queue *Queue
n int
}
func NewChannel(n int) *Channel {
if n &lt; 1 {
panic(&quot;todo: support unbuffered channel&quot;)
}
c := new(Channel)
c.cond = sync.NewCond(&amp;c.mutex)
c.queue = NewQueue()
// 这里 NewQueue 得到一个普通的队列
// 代码从略
c.n = n
return c
}
func (c *Channel) Push(v interface{}) {
c.mutex.Lock()
defer c.mutex.Unlock()
for c.queue.Len() == c.n { // 等待队列不满
c.cond.Wait()
}
if c.queue.Len() == 0 { // 原来队列是空的,可能有人等待数据,通知它们
c.cond.Broadcast()
}
c.queue.Push(v)
}
func (c *Channel) Pop() (v interface{}) {
c.mutex.Lock()
defer c.mutex.Unlock()
for c.queue.Len() == 0 { // 等待队列不空
c.cond.Wait()
}
if c.queue.Len() == c.n { // 原来队列是满的,可能有人等着写数据,通知它们
c.cond.Broadcast()
}
return c.queue.Pop()
}
func (c *Channel) TryPop() (v interface{}, ok bool) {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.queue.Len() == 0 { // 如果队列为空,直接返回
return
}
if c.queue.Len() == c.n { // 原来队列是满的,可能有人等着写数据,通知它们
c.cond.Broadcast()
}
return c.queue.Pop(), true
}
func (c *Channel) TryPush(v interface{}) (ok bool) {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.queue.Len() == c.n { // 如果队列满,直接返回
return
}
if c.queue.Len() == 0 { // 原来队列是空的,可能有人等待数据,通知它们
c.cond.Broadcast()
}
c.queue.Push(v)
return true
}
```
对着这个 Channel 的实现,你是否对条件变量有感觉很多?顺便提醒一点,这个 Channel 的实现不支持无缓冲 channel也就是不支持 NewChannel(0) 的情况。如果你感兴趣,可以改改这个问题。
## 执行体的通讯
聊完同步与互斥,我们接着聊执行体的通讯:怎么在执行体间收发消息。
管道是大家都很熟知的执行体间的通讯机制。规格如下:
```
func Pipe() (pr *PipeReader, pw PipeWriter)
```
用法上,先调用`pr, pw := io.Pipe()`得到管道的写入端和读出端,分别传给两个并行执行的 goroutine其他语言也类似然后一个 goroutine 读,一个 goroutine 写就好了。
管道用处很多。一个比较常见的用法是做读写转换,例如,假设我手头有一个算法:
```
func Foo(w io.Writer) error
```
这个算法生成的数据流,需要作为另一个函数的输入,但是这个函数的输入是 io.Reader原型如下
```
func Bar(r io.Reader)
```
那么怎么把它们串起来呢?用管道我们很容易实现这样的变换:
```
func FooReader() io.ReadCloser {
pr, pw := io.Pipe()
go func() {
err := Foo(pw)
pw.CloseWithError(err)
}()
return pr
}
```
这个 FooReader 函数几句话就把 Foo 变成了一个符合 io.Reader 接口的对象,它就可以很方便的和 Bar 函数结合了。
其实 Go 语言中引入的 channel 也是管道,只不过它是类型安全的管道。具体用法如下:
```
c := make(chan Type, n) // 创建一个能够传递 Type 类型数据的管道,缓冲大小为 n
...
go func() {
val := &lt;-c // 从管道读入
}()
...
go func() {
c &lt;- val // 向管道写入
}()
```
我们后面在 “服务端开发” 一章,我们还会比较详细讨论 channel今天先了解一个大体的语义。
## 结语
总结一下,我们今天主要聊了执行体间的协同机制:原子操作、同步、互斥与通讯。我们重点聊了锁和同步原语 “条件变量”。
锁在一些人心中是有误解的,但实际上锁在服务端编程中的比重并不低,我们可能经常需要和它打交道,建议多花精力理解它们。
条件变量是最复杂的同步原语,功能强大。虽然平常我们直接使用条件变量的机会不是太多,大部分常见的场景往往有更高阶的原语(例如 channel可以取代。但是它的设计精巧而高效值得细细体会。
你会发现,操作系统课本上的信号量这样的同步原语,我们这里没有交代,这是因为它被更强大而且性能更好的同步原语 “条件变量” 所取代了。
上面我们为了介绍条件变量的用法,我们实作了一个 channel你也可以考虑用信号量这样的东西来实现一遍然后分析一下为什么我们说基于 “条件变量” 的版本是更优的。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。在下期,我们将讨论进程与进程之间的协同:进程间的同步互斥、资源共享与通讯。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,184 @@
<audio id="audio" title="13 | 进程间的同步互斥、资源共享与通讯" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2e/09/2e2ce47c738a8a35fae2359c85a4cd09.mp3"></audio>
你好,我是七牛云许式伟。
在上一讲,我们介绍了进程内执行体之间的协同机制。今天我们接着聊进程与进程之间的协同。
这些协同机制大体可分为:互斥、同步、资源共享以及通讯等原语。对于这些协同机制,我们对比了 Linux、Windows、iOS 这三大操作系统的支持情况,整理内容如下:
<img src="https://static001.geekbang.org/resource/image/27/e5/276e3874bc64364c10c52371f6b11ce5.png" alt="">
在逐一详细分析它们之前,我们先讨论一个问题:从需求角度来讲,进程内协同与进程间协同有何不同?
在早期,操作系统还只有进程这个唯一的执行体。而今天,进程内的执行体(线程与协程)被发明出来并蓬勃发展,事情发生了怎样的变化?
请先思考一下这个问题。我们在这一讲最后总结的时候一起聊聊。
## 启动进程
在讨论进程间的协同前,我们先看下怎么在一个进程中启动另一个进程。这通常有两种方法:
- 创建子进程;
- 让Shell配合执行某个动作。
前面在 “[11 | 多任务:进程、线程与协程](https://time.geekbang.org/column/article/96324)” 一讲中我们已经提到过,创建子进程 UNIX 系的操作系统都用了 fork API它使用上很简洁但是从架构角度来说是一个糟糕的设计。Windows 中我们用 CreateProcess这个函数有很多的参数。
iOS 很有意思,它并不支持创建子进程。在进程启动这件事情上,它做了两个很重要的变化:
- 软件不再创建多个进程实例,永远是单例的;
- 一个进程要调用另一个进程的能力,不是去创建它,而是基于 URL Scheme 去打开它。
什么是 URL Scheme ?我们平常看到一个 URL 地址。比如:
- [https://www.qiniu.com/](https://www.qiniu.com/)
- [ftp://example.com/hello.doc](ftp://example.com/hello.doc)
这里面的 https 和 ftp 就是 URL Scheme它代表了某种协议规范。在 iOS 下,一个软件可以声明自己实现了某种 URL Scheme比如微信可能注册了“weixin”这个 URL Scheme那么调用
```
UIApplication.openURL(&quot;weixin://...&quot;)
```
都会跳转到微信。通过这个机制,我们实现了支付宝和微信支付能力的对接。
URL Scheme 机制并不是 iOS 的发明它应该是浏览器出现后形成的一种扩展机制。Windows 和 Linux 的桌面也支持类似的能力,在 Windows 下调用的是 ShellExecute 函数。
## 同步与互斥
聊完进程的启动,我们正式开始谈进程间的协同。
首先我们来看一下同步和互斥体。从上一讲 “[12 | 进程内协同:同步、互斥与通讯](https://time.geekbang.org/column/article/96994)”看,同步互斥相关的内容有:
-Mutex
- 读写锁RWMutex
- 信号量Semaphore
- 等待组WaitGroup
- 条件变量Cond
进程间协同来说主流操作系统支持了锁Mutex和信号量Semaphore。Windows 还额外支持了事件Event同步原语这里我们略过不提。
进程间的锁Mutex语义上和进程内没有什么区别只不过标识互斥资源的方法不同。Windows 最简单用名称Name标识资源iOS 用路径PathLinux 则用共享内存。
从使用接口看Windows 和 iOS 更为合理,虽然大家背后实现上可能都是基于共享内存(对用户进程来说,操作系统内核对象都是共享的),但是没必要把实现机理暴露给用户。
我们再看信号量。
信号量Semaphore概念是 Dijkstra学过数据结构可能会立刻回忆起图的最短路径算法对的就是他发明的提出来的。信号量本身是一个整型数值代表着某种共享资源的数量简记为 S。信号量的操作界面为 PV 操作。
P 操作意味着请求或等待资源。执行 P 操作 P(S) 时S 的值减 1如果 S &lt; 0说明没有资源可用等待其他执行体释放资源。
V 操作意味着释放资源并唤醒执行体。执行 V 操作 V(S) 时S 的值加 1如果 S &lt;= 0则意味着有其他执行体在等待中唤醒其中的一个。
看到这里,你可能敏锐地意识到,条件变量的设计灵感实际上是从信号量的 PV 操作进一步抽象而来,只不过信号量中的变量是确定的,条件也是确定的。
进程间的同步与互斥原语并没有进程内那么丰富(比如没有 WaitGroup也没有 Cond甚至没那么牢靠。
为什么?因为进程可能会异常挂掉,这会导致同步和互斥的状态发生异常。比如,进程获得了锁,但是在做任务的时候异常挂掉,这会导致锁没有得到正常的释放,那么另一个等待该锁的进程可能就会永远饥饿。
信号量同样有类似的问题甚至更麻烦。对锁来说进程挂掉还可能可以把释放锁的责任交给操作系统内核。但是信号量做不到这一点操作系统并不清楚信号量的值S应该是多少才是合理的。
## 资源共享
两个进程再怎么被隔离,只要有共同的中间人,就可以相互对话(通讯)。中间人可以是谁?共享资源。进程之间都有哪些共享的存储型资源?比较典型的是:
- 文件系统;
- 剪贴板。
文件系统本身是因存储设备的管理而来。但因为存储设备本身天然是共享资源,某个进程在存储设备上创建一个文件或目录,其他进程自然可以访问到。
因此,文件系统天然是一个进程间通讯的中间人。而且,在很多操作系统里面,文件的概念被抽象化,“一切皆文件”。比如,命名管道就只是一种特殊的 “文件” 而已。
和文件系统相关的进程间协同机制有:
- 文件;
- 文件锁;
- 管道(包括匿名管道和命名管道);
- 共享内存。
这里我们重点介绍一下共享内存。
共享内存其实是虚拟内存机制的自然结果。关于虚拟内存的详细介绍,可以参阅 “[07 | 软件运行机制及内存管理](https://time.geekbang.org/column/article/93802)” 一讲。虚拟内存本来就需要在内存页与磁盘文件之间进行数据的保存与恢复。
将虚拟内存的内存页和磁盘文件的内容建立映射关系,在虚拟内存管理机制中原本就存在。
只需要让两个进程的内存页关联到同一个文件句柄,即可完成进程间的数据共享。这可能是性能最高的进程间数据通讯手段了。
Linux 的共享内存的使用界面大体是这样的:
```
func Map(addr unsafe.Pointer, len int64, prot, flags int, fd int, off int64) unsafe.Pointer
func Unmap(addr unsafe.Pointer, len int64)
```
其中Map 是将文件 fd 中的`[off, off+len)`区间的数据,映射到`[addr, addr+len)` 这段虚拟内存地址上去。
addr 可以传入 nil 表示选择一段空闲的虚拟内存地址空间来进行映射。Unmap 则是将`[addr, addr+len)`这段虚拟内存地址对应的内存页取消映射,此后如果代码中还对这段内存地址进行访问,就会发生缺页异常。
在 Windows 下共享内存的使用界面和 Linux 略有不同,但语义上大同小异,这里略过不提。
真正值得注意的是 iOS你会发现基于文件系统的进程间通讯机制一律不支持。为什么因为 iOS 操作系统做了一个极大的改变软件被装到了一个沙箱Sandbox里面不同进程间的存储完全隔离。
存储分为内存和外存。内存通过虚拟内存机制实现跨进程的隔离,这个之前我们已经谈到过。现在 iOS 更进一步,外存的文件系统也相互独立。软件 A 创建的文件,软件 B 默认情况下并不能访问。在一个个软件进程看来,自己在独享着整个外存的文件系统。
文件系统之外,进程间共享的存储型资源,就剩下剪贴板了。
但剪贴板并不是一个常规的进程间通讯方式。从进程间通讯角度来说它有很大的限制:剪贴板只有一个,有人共享数据上去,就会把别人存放的数据覆盖掉。
实践中,剪贴板通常作为一种用户实现跨进程交互的手段,而不太会被用来作为进程间的通讯。相反它更可能被恶意程序所利用。比如,写个木马程序来监听剪贴板,以此来窃取其他程序使用过程中留下的痕迹。
## 收发消息
那么,不用文件系统和剪贴板这样的共享资源,还有其他的通讯机制么?
**有,基于网络。很重要的一个事实是:这些进程同在一台机器上,同在一个局域网中。**
套接字作为网络通讯的抽象,本身就是最强大的通讯方式,没有之一。进程间基于套接字来进行通讯,也是极其自然的一个选择。
况且UNIX 还发明了一个专门用于本地通讯的套接字UNIX 域。UNIX 域不同于常规套接字的是,它通过一个 name 来作为访问地址,而不是用`ip:port`来作为访问地址。
Windows 平台并不支持 UNIX 域。但是有趣的是Windows 的命名管道NamedPipe也不是一个常规意义上的管道那么简单它更像是一个管道服务器PipeServer一个客户端连上来可以分配一个独立的管道给服务器和客户端进行通讯。从这个事实看Windows 的命名管道和 UNIX 域在能力上是等价的。
关于套接字更详细的内容,后文在讨论网络设备管理时我们会进一步介绍。
## 架构思维上我们学习到什么?
对比不同操作系统的进程间协同机制,差异无疑是非常巨大的。
总结来说进程间协同的机制真的很多了五花八门我们这里不见得就列全了。但是有趣的是iOS 把其中绝大部分的协同机制给堵死了。
创新性的系统往往有其颠覆性,带着批判吸收的精神而来,做的是大大的减法。
iOS 就是这样的一个操作系统。它告诉我们:
- 软件不需要启动多份实例。一个软件只需启动一个进程实例。
- 大部分进程间的协同机制都是多余的。你只需要能够调用其他软件的能力URL Scheme、能够互斥、能够收发消息就够了。
这的确是一个让人五体投地的决策。虽然从进程间协同机制的角度,看起来 iOS 少了很多能力。但这恰恰也给了我们一个启示:这么多的进程通讯机制,是否都是必需的?
至少从桌面操作系统的视角看,进程间协同的机制,大部分都属于过度设计。当然,后面在 “服务端开发” 一章中,我们也会继续站在服务端开发视角来谈论这个话题。
并不是早期操作系统的设计者们喜欢过度设计。实际上这是因为有了线程和协程这样的进程内多任务设施之后,进程的边界已经发生了极大的变化。
前面我们讨论架构思维的时候说过,架构的第一步是做需求分析。那么需求分析之后呢?是概要设计。概要设计做什么?是做子系统的划分。它包括这样一些内容:
- 子系统职责范围的定义;
- 子系统的规格(接口),子系统与子系统之间的边界;
- 需求分解与组合的过程,系统如何满足需求、需求适用性(变化点)的应对策略。
从架构角度来看,进程至少应该是子系统级别的边界。子系统和子系统应该尽可能是规格级别的协同,而不是某种实现框架级别的协同。规格强调的是自然体现需求,所以规格是稳定的,是子系统的契约。而实现框架是技巧,是不稳定的,也许下次重构的时候实现框架就改变了。
所以站在架构视角,站在子系统的边界看进程边界,我们就很清楚,进程间协同只需要有另一个进程能力的调用,而无需有复杂的高频协作、高度耦合的配合需求。
不过,为什么 iOS 会如此大刀阔斧地做出改变,除了这些机制的确多余之外,还有一个极其核心的原因,那就是安全。关于这一点,我们在后面探讨操作系统的安全管理时,会进一步进行分析。
## 结语
今天我们从进程启动开始入手,介绍了同步与互斥、资源共享、收发消息等进程间的协同机制。通过对比不同操作系统,我们会发现以 “剧烈变动” 来形容进程间协同的需求演进一点也不过分。
我认为 iOS 是对的。大刀阔斧干掉很多惯例要支持的功能后,进程这个执行体,相比线程和协程就有了更为清晰的分工。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。到这一讲为止,我们单机软件相关的内容就介绍完了。从下一讲开始我们将进入多姿多彩的互联网世界。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,214 @@
<audio id="audio" title="14 | IP 网络:连接世界的桥梁" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/31/26/31dc68f289a0843671565e983607be26.mp3"></audio>
你好,我是七牛云许式伟。
到目前为止,我们介绍了操作系统的六大子系统中的四项:进程、存储、输入、输出。当你理解了这些东西背后的道理,基本上做一款单机软件就游刃有余了。
但是,如果仅仅局限于单机,一台计算机并不见得比计算器高明太多,网络对整个信息科技的重要性不言而喻。它让计算机连接在了一起,这一连接就发生了巨大的变化。
没有了网络,我们只能用 Office 软件,玩玩扫雷。没有网络,就没有 QQ 和微信,不会有淘宝和支付宝,也不会有 BAT。
网络连接一切。它连接了人(个人和企业)、服务(由软件系统构建的服务接口)和物(大自然产物和智能终端),构建了多姿多彩的互联网。
它让地球上的任何两个人都可以随时随地进行沟通,远程做生意。在互联网出现之前,旧的商业文明我们可以一言以蔽之:一手交钱,一手交货。而建立在互联网之上的新商业文明,我们一手下单付款,一手收钱发货,足不出户,货物就通过便捷的物流服务送到了你手上。
这是多么巨大的效率变革,但这一切是怎么做到的呢?
## 数据的封包过程
网络和其他所有的输入输出设备一样,只能交换数据。无论你要对方做什么,你首先需要发送对方理解得了的数据给它。所以双方要就沟通的语言达成共识,这就是网络协议。
网络协议是计算机与计算机远程沟通的数据格式。它包含很多信息。这些信息不同部分的内容,有不同的职责,关心它的人也各有不同。
网络是传递数据的,是数字内容的物流。作为类比,我们可以看看实物快递的物流协议是什么样的。下面这张快递面单大家应该都很熟悉:
<img src="https://static001.geekbang.org/resource/image/d2/18/d2206dbdaf528ef1f1fcb26869b05018.png" alt="">
这个快递面单包含很多内容,其中最重要的当然是寄件人信息和收件人信息。有了收件人信息,物流系统才能够知道怎么把要邮寄的物品进行一站站中转,并最终到达目的地。
有了寄件人信息,收件人收到信息才知道是谁寄过来的,如果回复的话应该邮寄往何方。而在物流过程如果出错的话,物流系统也知道如何与寄件人沟通协调错误的处置方法。
寄件人关心什么?他很可能关心物流订单号。这是他掌握物流状态的唯一凭据。另外,为了传输过程的便捷,物流系统可能还会给我们要邮寄的物品用信封袋或者包装箱进行封装。
所有这些寄件人信息、收件人信息、物流订单号、信封袋、包装箱,都不是寄件人要邮寄的内容,而是物流系统对物流协议所产生的需求。
为了支持整个物流系统的不间断运转,我们会有很多不同的部门。有负责最后一公里的快递员,也有负责骨干线路的航空运输部门,或者火车货运部门等等。
同样的数字物流系统也有很多不同的部门有的部门负责局域网LAN内最后一公里的也有人负责广域网WAN骨干线路的运输的。网络协议作为数字物流的载体会收到来自这些不同部门的需求。
我们平常可能经常听人提及,网络协议有 OSI 模型,它把网络协议分成了七层结构,从上到下分别是:应用层、表示层、会话层、传输层、网络层、数据链路层、物理层。但这样描述有点过于抽象,我们不妨用下图来理解网络协议的分层结构。
<img src="https://static001.geekbang.org/resource/image/60/2a/6059e45af9f2ff757fa64df2ec48212a.png" alt="">
让我们设想:我们要传输一部电影,它就是我们的 “物品”,或者用技术术语叫 “应用层数据”,怎么传?
**第一步,分批次。**数字物流系统单次能够传递的数据大小是有限的。如果数据太大就必须分开多次来传输。从物理网络视角看数据并不是流stream而是一个个大小有明确限制的数据包。
**第二步,套上信封,写好寄件单位的部门(源端口号)、收件单位的部门(目标端口号)、批次编号。**为什么要写部门(端口号)?方便知道由谁(哪个应用程序软件)来负责收件。为什么要写批次编号?是为了防止寄丢了,寄丢了就得重新传一份该批次编号的东西。
这层我们叫传输层。它主要是为了解决传输可靠性的问题。当然传输层有两套协议(两种信封),一套是 TCP 协议,另一套是 UDP 协议。UDP 协议不保证对方一定收到,信封上就没写批次编号。
**第三步,再套上一层信封,上面写上寄件单位地址(源 IP 地址)、收件单位地址(目标 IP 地址)。**有了地址这封信在广域网WAN上流转就知道自己应该去往何方了。这一层叫做网络层它定义的信封格式叫 IP 协议。
互联网的复杂性是在于,它不是一家数字物流公司的事。信寄到某处,可能就换一家物流公司了。它是所有的数字物流公司通力协作的结果。
所以 IP 协议最核心的意义是标准化,解决跨物流公司传输的问题。为什么我们要这样一层信封套一层信封?因为越往外层的信封,内容越和具体的物流公司相关。但无论你外层套的信封如何各有不同,拆到这一层,信的格式就是标准化的。
**第四步,再套上一层信封,这层我们叫它数据链路层。**具体信封上写什么,完全是具体负责这段路程的物流公司说了算。当信件从一个物流公司转到另一个物流公司做交接的时候,这一层信封拆掉,重新换上新的信封。
数据链路层的信封格式网络协议非常多样化。局域网LAN现在最流行的是以太网Ethernet协议广域网WAN现在常见的有 HDLC、PPP、Frame-Relay 等网络协议。
无论如何,写完了特定物流公司所需要的信息,信件就可以进入数字物流系统(物理层)去流转了。
## 网络协议
上面那一层套一层的信件(网络协议)放的是用户要邮寄的东西,比如一部电影。但为了支持整个邮寄过程的顺利进行,获得更好的用户体验,还会有一些辅助用途的信件(网络协议)在网络上传递,有的是面向用户的,有的是面向数字网络系统内部的。
完整来说,在整个数字物流系统中,与数据传输这件事本身有关的网络协议,我们整理如下:
<img src="https://static001.geekbang.org/resource/image/8d/23/8d3d2147685359357e78c8715e5edf23.png" alt="">
在这个图中链路层协议最为复杂MAC+LLC、PPP、HDLC、Frame-Replay这些是目前最为常见的。未来也必然会出来很多新的网络通讯技术用的是全新的协议。链路层之上IP -&gt; TCP/UDP这些协议我们最为耳熟能详上面我们也已经介绍过了。
其他都还有些什么?除了 ICMP 和 IGMP 协议,这些协议都和网络地址的解析有关。所以,在谈协议用途前,我们先聊一聊网络地址。
要通讯,首先要有地址。数字物流世界的地址有三层。最底下的是链路层地址。不同链路层协议的地址表示非常不同。
比如局域网所采纳的以太网Ethernet协议用的是 MAC 地址。一台计算机有一个或多个网卡,每个网卡会有自己的唯一标识即 MAC 地址。这个标识跟随网卡设备存在和网络环境无关。你把计算机从北京搬到上海MAC 地址保持不变。
链路层的网络地址我们平常接触并不多,常规我们理解的网络地址是位于第二层的 IP 地址。
IP 地址类似于门牌号你家住在哪个城市哪条路几号。它决定了网络路由怎么走信息如何到达你的计算机网卡。IP 地址已经发展了两代,分别为 IPv4 和 IPv6。升级的原因是 IPv4 地址空间太小,只有 4G即40多亿个地址。
就像我们通常会更喜欢用 “我要去金茂大厦” 而不是 “我要去上海市浦东新区世纪大道88号” 一样IP地址并不容易记忆所以就有了第三层的网络地址域名。
比如,我们会用 [www.qiniu.com](http://www.qiniu.com) 这个地址来找到七牛云的官网,而不是记住枯燥的 IP 地址。
理解了这三类网络地址,我们一一介绍下这些协议的用途。
**首先是 DNS 协议。**这个协议就像是个地址簿,主要负责 “域名” =&gt; “IP地址” 的查询。每次我们要邮寄信件之前都要拿出来查一查。
**其次是 DHCP 协议。**DHCP 全称叫动态主机配置协议Dynamic Host Configuration Protocol主要负责计算机接入网络时的初始化。计算机刚开始就只有网卡的 MAC 地址,通过 DHCP 可以给它分配 IP 地址,并得到默认网关地址(这很重要,不知道网关就上不了网)和 DNS 服务器的地址。有了这些东西,这台计算机就可以和外界通讯了。
**然后是 ARP 协议。**ARP 全称叫地址解析协议Address Resolution Protocol它服务于现在局域网中最流行的以太网协议。在以太网中ARP 协议负责解析远程主机 IP 地址对应的 MAC 地址。之所以需要 ARP 协议,是因为我们平常应用程序连接目标计算机进行网络通讯时,都是提供了域名或 IP 地址。但对以太网来说,要想发信件出去,它要的是对方的 MAC 地址。
**然后是 RARP 协议。**RARP 全称叫反向地址转换协议Reverse Address Resolution Protocol。顾名思义它和 ARP 协议相反,负责的是 MAC 地址到 IP 地址的转换。RARP 协议已经被上面的 DHCP 协议所取代,平常用不太到了。
**然后是 ICMP 协议。**ICMP 全称叫互联网控制报文协议Internet Control Message Protocol它能够检测网路的连线状况以保证连线的有效性。基于这个协议实现的常见程序有两个ping 和 traceroute它们可以用来判断和定位网络问题。
**最后是 IGMP 协议。**IGMP 全称叫互联网组管理协议Internet Group Management Protocol它负责 IP 组播Multicast成员管理。本文略过这块的内容。
## 数据传输过程
了解了数据包的结构,也了解了数据传输相关的网络协议,接下来我们聊一聊数据传输的过程。为了方便理解,我们画了一幅数据传输的示意图:
<img src="https://static001.geekbang.org/resource/image/b6/d5/b6dd426fa5fffa0c38b69118c20732d5.png" alt="">
简化理解来说,我们可以认为,在需要传输数据的源主机和目标主机之间,它们通过若干路由器或交换机连接。我们分以下几种情况来分析:
**情形一,源主机和目标主机在同一个局域网内,中间通过交换机连接,采用了最常见的以太网协议。**
通讯开始的时候,源主机只有目标主机的 IP 地址,并没有 MAC 地址。但以太网通讯要的是 MAC 地址,所以源主机会发起一个 ARP 请求去获得目标 IP 对应的 MAC 地址。
当然,源主机会缓存这个对应关系。第二次继续给相同 IP 发信息的时候,就不需要重新发起 ARP 请求了。
无论是 ARP 请求还是普通的数据包都会先到达交换机。ARP 是一个广播请求,所以交换机会转发给所有其他主机,目标主机发现这个 IP 地址是自己的,于是返回自己的 MAC 地址。
有了目标主机的 MAC 地址,源主机就可以发数据了。同样的,所有数据包都发给了交换机。
交换机是性能极高的网络数据交换设备。它通常工作在网络协议的第二层,也就是数据链路层。这一层只认 MAC 地址,不认 IP 地址。MAC 地址本身是个唯一身份标识,就像我们的身份证号,并没有可寻址的作用。那么交换机怎么做到这么高的数据传输的效率?
交换机在工作的过程中会不断地收集资料去创建一个地址映射表MAC 地址 =&gt; 交换机端口。这个表很简单它记录了某个MAC 地址是在哪个端口上被发现的。
交换机收到一个数据包后,首先会进行学习,把源 MAC 地址和收到数据包的交换机端口对应起来。然后交换机查看数据包的目标 MAC 地址,并在地址映射表中找,如果找到对应的端口,那么就往这个端口转发数据包。
如果没找到,交换机可能会把这个数据包 “扩散” 出去,就好像收到广播数据包一样。这时如果目标主机收到广播过来的数据包后,回复了这个数据包,那么它的 MAC 地址和交换机端口的映射关系就也会被学习到。
当交换机初次加入网络时,由于地址映射表是空的,所以,所有的数据包将发往局域网内的全部端口,直到交换机 “学习” 到各个MAC 地址为止。这样看来,交换机刚刚启动时与传统的共享式集线器类似,直到地址映射表比较完整地建立起来后,它才真正发挥它的高性能。
我们总结一下,当一台交换机安装配置好之后,其工作过程如下。
- 收到某端口设为AMAC 地址为 X 的计算机发给 MAC 地址为 Y 的计算机的数据包。交换机从而记下了 MAC 地址 X 在端口 A。这称为学习learning
- 交换机还不知道 MAC 地址 Y 在哪个端口上,于是向除了 A 以外的所有端口转发该数据包。这称为泛洪flooding
- MAC 地址 Y 的计算机收到该数据包,向 MAC 地址 X 发出确认包。交换机收到该包后,从而记录下 MAC 地址 Y 所在的端口。
- 交换机向 MAC 地址 X 转发确认包。这称为转发forwarding
- 交换机收到一个数据包查表后发现该数据包的来源地址与目的地址属于同一端口。交换机将不处理该数据包。这称为过滤filtering
- 交换机内部的 “MAC 地址 =&gt; 端口” 查询表的每条记录采用时间戳记录最后一次访问的时间。早于某个阈值用户可配置的记录被清除。这称为老化aging
**情形二,源主机和目标主机都有公网 IP 地址,它们中间经过若干交换机和路由器相连。**
路由器和交换机不太一样,交换机因为没有门牌号,通讯基本靠吼。好的一点是,圈子比较小,吼上一段时间后,路都记住了,闭着眼睛都不会走错。
但广域网太大了,靠吼没几个人听得见。所以路由器工作在网络协议的第三层,也就是网络层。网络层看到的是 IP 协议,能够知道数据传输的源 IP 地址和目标 IP 地址。
有了 IP 地址,就相当于有了门牌号,开启导航按图索骥就可以把东西带过去了。这也是路由器为什么叫路由器的原因,它有导航(路由)功能,知道哪些目标 IP 地址的数据包应该往哪条路走的。
路由器可以拥有一部分交换机的能力,比如,如果发现请求是局域网内的话,也可以引入类似交换机那样的基于 MAC 地址的映射表实现高速通讯。但总体来说,路由器要考虑的问题复杂很多,因为涉及 “最佳路由路径” 的问题。
简单说,所谓 “最佳路由路径” 是指,到达目标主机的路有很多种可能性,我应该选择哪一条。大家在大学可能都学过带权的有向图,路由器面临的正是这种情况。而且情况可能更复杂的是,每一小段路径的权重都是动态的,因为网络状况一直在变。
如果你对路由算法感兴趣,可以在维基百科查找 “[路由](https://zh.wikipedia.org/wiki/%E8%B7%AF%E7%94%B1)” ,进一步研究。
路由器除了解决路由问题,它往往还要解决异构网络的封包转换问题。作为局域网的接入方,它可能走的是固网或 WiFi 网络。
作为 Internet 的接入方,它可能走的是光纤宽带。所以它需要把局域网的数据链路层的封包解开并重组,以适应广域网数据链路协议的需求。
理解了以上两点,我们回到话题:广域网的两台具备公网 IP 的主机之间如何完成数据传输?
大体来说,整个过程如下。
- 首先,源主机发送的数据包,经由交换机(可选),到达本局域网的公网网关(路由器)。这个过程属于局域网内通讯,同情形一。
- 路由器收到了数据包,发现目标主机是 Internet 上的某个远端的目标主机,于是对数据包进行拆包重组,形成新的数据包。
- 循着自身的路由表,把这个新数据包层层转发,最后到达目标主机对应的公网网关(路由器)上。
- 路由器发现是发给本局域网内的目标主机,于是再拆包重组,形成新的数据包。
- 新数据包转到局域网内,经由交换机(可选),并最终到达目标主机。如此,整个数据传输过程就结束了。
**情形三,源主机和目标主机至少有一方在局域网内且只有私有 IP 地址,它们中间经过若干交换机和路由器相连。**
解释一下私有 IP 地址。在 IPv4 地址区间中,有一些区段,比如 10.0.0.0 ~ 10.255.255.255、172.16.0.0 ~ 172.31.255.255、192.168.0.0 ~ 192.168.255.255 ,这几个 IP 地址区间都是私有 IP 地址,只用于局域网内通讯。
常规来说,只有私有 IP 而没有公网 IP 的主机只能和局域网内的主机通讯,而无法和 Internet 上的其他主机相互通讯。
但这一点又和我们日常的感受不符:比如家庭用户往往网络结构是一个 WiFi 路由器连接公网,所有的家庭设备如手机、平板、笔记本,都以 WiFi 路由器为网关构成一个局域网。那么我们的这些设备是怎么上网的呢?
答案是 NATNetwork Address Translation网络地址转换技术。它的原理比较简单假设我们现在源主机用的IP+端口为 iAddr:port1经过 NAT 网关后NAT 将源主机的 IP 换成自己的公网 IP比如 eAddr端口随机分配一个比如 port2。
也就是从目标主机看来,这个数据包看起来是来自于 eAddr:port2。然后目标主机把数据包回复到 eAddr:port2NAT 网关再把它转发给 iAddr:port1。
也就是说NAT 网关临时建立了一个双向的映射表 iAddr:port1 &lt;=&gt; eAddr:port2一旦完成映射关系的建立在映射关系删除前eAddr:port2 就变成了 iAddr:port1 的 “替身”。这样,内网主机也就能够上网了。
NAT 网关并不一定是公网网关(路由器),它可以由局域网内任何一台有公网 IP 的主机担当。但显然如果公网网关担当 NAT 网关,链路的效率会高一点。
我们家用的 WiFi 路由器,就充当了 NAT 网关的作用,这也是我们能够上网的原因。
那么,最极端的情形,源主机和目标主机在不同的局域网内,且都没有公网 IP它们是否可以通讯呢
答案是不确定。
首先,在这种情况下,源主机和目标主机没法直接通讯,需要中间人去帮忙搭建通讯的链路。怎么做呢?找一个有公网 IP 的主机作为中间人服务器,目标主机向它发包,这样,在目标主机的 NAT 网关就形成了一对双向的映射表:
- iDestAddr:portDest1 &lt;=&gt; eDestAddr:portDest2
然后,中间人服务器再把 eDestAddr:portDest2 告诉源主机。这样源主机就可以通过向 eDestAddr:portDest2 发送数据包来和目标主机 iDestAddr:portDest1 通讯了。
我们不少 P2P 软件就利用了这个技术实现 NAT 穿透,让两台不同内网的计算机相互能够直接通讯。
那么,答案为什么是不确定?因为上面这个机制只有在目标主机的 NAT 网关是 Full cone NAT即一对一one-to-oneNAT 网关时才成立。
什么是 Full cone NAT它是指 NAT 网关临时建立了 iAddr:port1 &lt;=&gt; eAddr:port2 双向映射后,任何主机给 eAddr:port2 发送数据包,都会被转给 iAddr:port1并不局限于构建这个映射时数据包发送的目标主机是谁。
但在其他类型的 NAT 网关下,一般都对回包的主机 IP 地址有约束。也就是说NAT 网关形成的双向映射表是因为哪个目标主机建立的,那么回包也必须来自哪台主机。
这种情况下,中间人服务器就没办法来搭桥让它们直接通讯了,数据包需要由中间人服务器来中转。
## 结语
总结一下,今天我们介绍了数据封包过程、与 IP 数据包传输相关的网络协议,并对数据传输过程做了整体的描述。
我们主要介绍的重点是 IP 协议之下的网络工作过程。我们不讨论如何进行数据重传,更不关心数据到达应用层我们收到数据包后,如何去处理它们。
互联网背后的世界,和互联网一样精彩。精妙之处,值得细细体会。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将进一步来探讨一下网络世界的编程接口。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,363 @@
<audio id="audio" title="15 | 可编程的互联网世界" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/d4/0611cb647b32957c5c4300862a33b2d4.mp3"></audio>
你好,我是七牛云许式伟。
前面我们讨论架构思维的时候说过,架构的第一步是做需求分析。需求分析之后呢?是概要设计。概要设计做什么?是做子系统的划分。它包括这样一些内容:
- 子系统职责范围的定义;
- 子系统的规格(接口),子系统与子系统之间的边界;
- 需求分解与组合的过程,系统如何满足需求、需求适用性(变化点)的应对策略。
对于我们理解这个精彩的互联网世界来说,理解它的子系统的划分思路是非常非常重要的。
## 网络应用程序的全视图
在上一讲 “[14 | IP 网络:连接世界的桥梁](https://time.geekbang.org/column/article/98406)” 中我们介绍了 IP 网络的工作原理。我们还画了一幅与数据传输这件事本身有关的网络协议图,如下:
<img src="https://static001.geekbang.org/resource/image/8d/23/8d3d2147685359357e78c8715e5edf23.png" alt="">
那么,从一个典型的网络应用程序角度来说,它的完整视图又是什么样子的呢?
<img src="https://static001.geekbang.org/resource/image/27/35/272a1a5319c226fc6472bb4f5f256c35.png" alt="">
上图是我给出的答案。当然,它并不代表所有的网络应用程序,但这不影响我们借它的结构来解释网络世界是怎么划分子系统的,每个子系统都负责了些什么。
**第一层是物理层。**你可以理解为网络设备的原生能力,它定义了硬件层次来看的基础网络协议。
**第二层是数据链路层。**它负责解决的是局部网络世界的数据传输能力。网络数据传输技术会层出不穷今天主流有固网、WiFi、3G/4G明天有 5G/6G未来也必然还会出现更快速的网络新技术。
这些网络技术虽然都有自己独特的链路层协议,但都可以很自然融入整个互联网世界。原因在于什么?在于 IP 网络。
**所以第三层是 IP 网络层,它负责的是互联网世界的一体化,彼此包容与协作。**如果拿单机的应用程序的全视图来类比的话IP 网络类似于单机体系中的操作系统。
在单机体系操作系统是一台计算机真正可编程的开始。同样地互联网世界的体系中IP 网络是互联网 “操作系统” 的核心,是互联网世界可编程的开始。
**第四层是 TCP/UDP 传输层。**它也是互联网 “操作系统” 的重要组成部分,和 IP 网络一起构成互联网 “操作系统” 的内核。IP 网络解决的是网如何通的问题,而传输层解决的是如何让互联网通讯可信赖的问题,从而大幅降低互联网应用程序开发的负担。
互联网并不是世界上的第一张网。但是只有拥有了 TCP/IP 这一层 “操作系统”,这才真正实现了网络价值的最大化:连接一切。
有了操作系统,应用软件才得以蓬勃发展。上图我们列出的应用层协议,仅仅只是沧海一粟。但是,要说当前最主流的应用层协议,无疑当属 HTTP 协议超文本传输协议HyperText Transfer Protocol和 SMTP/POP3 协议了。
HTTP 协议是因为万维网World Wide Web简称 WWW这个应用场景而诞生冲着传输静态网页而去的。但是由于设计上的开放性几经演进到今天已经俨然成为一个通用传输协议了。
通用到什么程度DNS 地址簿这样的基础协议,也搞出来一个新的 HTTP DNS。当然今天 HTTP DNS 还只是传统 DNS 协议的补充,使用还并不广泛。但由此可知人们对 HTTP 协议的喜爱。
除了呈现网页之外HTTP 协议也经常被用来作为业务开放协议 RESTful API 的承载。另外,一些通用 RPC 框架也基于 HTTP 协议,比如 Google 的 gRPC 框架。
SMTP/POP3 协议是电子邮件Email应用所采用的它们没有像 HTTP 协议那么被广泛借用,只是局限于电子邮件应用领域。但 SMTP/POP3 协议使用仍然极为广泛,原因是因为电子邮件是最通用的连接协议,它连接了人和人,连接了企业和企业。
我们都很佩服微信的成功,因为它连接了几乎所有的中国人。但是相比电子邮件,微信仍然只是小巫见大巫,因为电子邮件连接了世界上的每一个人和企业。
这是怎么做到的?因为开放的力量。如果说有谁能够打败微信,那么我个人一个基本的思考是:用微信的方式打败微信恐怕很难,但微信是封闭协议,开放也许是一个打败微信的机会?
还有其他很多应用层协议上图没有列出来,比如 FTP、NFS、Telnet 等等。它们大都应用范围相对小,甚至有一些渐渐有被 HTTP 协议替代的趋势。
对于一个网络应用程序来说它往往还依赖存储和数据库DB/Storage。目前存储和数据库这块使用 HTTP 的还不多除了对象存储Object Storage大部分还是直接基于 TCP 协议为主。
对象存储作为一种最新颖的存储类型,现在主流都是基于 HTTP 协议来提供 RESTful API比如七牛云的对象存储服务。
所以你可以看到,网络应用程序所基于的基础平台,比单机软件要庞大得多。前面我们介绍的单机软件所依赖的 CPU + 编程语言 + 操作系统就不说了,它一样要依赖。
上图所示的网络世界所构建的庞大基础平台,从物理层 -&gt; 数据链路层 -&gt; 网络层 -&gt; 传输层 -&gt; 应用平台层,也都是我们业务架构的依赖点。选择自定义网络协议,基于 gRPC还是基于 HTTP 提供 RESTful API ?这是架构师需要做出的决策之一。
## 应用层协议与网关
上一讲 “[14 | IP 网络:连接世界的桥梁](https://time.geekbang.org/column/article/98406)” 中我们谈到两台主机是如何通讯时,我们介绍了让局域网主机能够上网的 NAT 技术。NAT 网关本质上是一个透明代理(中间人),工作在网络协议的第四层,即传输层,基于 TCP/UDP 协议。
如果我们限定传输的数据包一定是某种应用层协议时,就会出现所谓的应用层网关,工作在网络协议的第七层,所以有时候我们也叫七层网关。
我们熟知的 Nginx、Apache 都可以用作应用层网关。应用层协议通常我们采用的是 HTTP/HTTPS 协议。
为什么 HTTP 协议这么受欢迎,甚至获得了传输层协议才有的待遇,出现专用的网关?
这得益于 HTTP 协议的良好设计。
我们一起来看一看 HTTP 协议长什么样。先看获取资源的 GET 请求Request
```
GET /abc/example?id=123 HTTP/1.1
Host: api.qiniu.com
User-Agent: curl/7.54.0
Accept: */*
```
HTTP 协议的请求Request分协议头和正文两部分中间以空行分隔。GET 请求一般正文为空。
协议头的第一行是请求的命令行,具体分为三部分,以空格分隔。第一部分为命令,常见有 GET、HEAD、PUT、POST、DELETE 等。第二部分是请求的资源路径。第三部分为协议版本。
协议头从第二行开始每行均为请求的上下文环境或参数我们不妨统一叫字段Field。格式为
```
字段名: 字段值
```
HTTP 服务器收到一个请求后往往会返回这样一个回复Response
```
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 68
ETag: W/&quot;fb751fe2cb812eb5d466ed9e3c3cd519&quot;
&lt;html&gt;&lt;head&gt;&lt;title&gt;Hello&lt;/title&gt;&lt;/head&gt;&lt;body&gt;qiniu.com&lt;/body&gt;&lt;/html&gt;
```
HTTP 请求Request和回复Response格式上只有第一行不同。回复的第一行也分为三部分以空格分割。
第一部分为协议版本。
第二部分是状态码Status Code用来表征请求的结果200 表示成功4xx 通常表示请求Request本身不合法5xx 则通常表示 HTTP 服务器有异常。
第三部分是状态文本Status Text方便接收方看到回复后可以立刻判断问题而不用去查状态码对应的文档。
当协议正文非空的时候,往往还需要用 Content-Type 字段来指示协议正文的格式。例如这里我们用 text/html 表征返回的协议正文是一个 html 文档。Content-Length 字段则用来指示协议正文的长度。
我们再来看一下修改资源的 POST 请求:
```
POST /abc/example HTTP/1.1
Host: api.qiniu.com
User-Agent: curl/7.54.0
Authorization: Qiniu dXNlcj14dXNoaXdlaSZwYXNzd2Q9MTIzCg
Content-Type: application/x-www-form-urlencoded;charset=utf-8
Content-Length: 18
id=123&amp;title=Hello
```
和 GET 不一样,修改资源往往需要授权,所以往往会有 Authorization 字段。另外这里我们用 Content-Type 字段表示我们协议正文用了表单form格式。
最后我们看下删除资源的 DELETE 请求:
```
DELETE /abc/example HTTP/1.1
Host: api.qiniu.com
User-Agent: curl/7.54.0
Authorization: Qiniu dXNlcj14dXNoaXdlaSZwYXNzd2Q9MTIzCg
Content-Type: application/json
Content-Length: 11
{&quot;id&quot;: 123}
```
删除和修改完全类似。除了我这里刻意换了一种 Content-Type协议正文用 json 格式了。实际业务中当然不是这样,通常会选择一致的表达方法。
大致了解了 HTTP 协议的样子,我们一起来分析一下它到底好在哪里?
毫无疑问,最关键的是它的协议头设计。具体表现在如下这些方面。
- 极其开放的协议头设计。虽然 HTTP 定义了很多标准的协议头字段Field但是用户还是可以加自己的字段惯例上以 X- 开头。例如,七牛引入了 X-Reqid 作为请求的内部调用过程的跟踪线索。关于 X-Reqid 本专栏后续我们还会继续谈到。
- 规范了业务的表达范式。虽然业务有千千万万种可能,但是实质上不外乎有什么资源,以及对资源的 CURD创建-修改-读取-删除。相对应地在HTTP 协议中以 “资源路径” 表达资源,以 PUT-POST-GET-DELETE 表达 CURD 操作(也有一些服务以 POST 而不是用 PUT 请求来创建资源)。
- 规范了应用层的路由方式。我们知道,在传输层网络的路由基于 IP 地址但是对于应用而言IP 地址是一个无意义的字段,在 HTTP 协议头中,有一个字段是强制的,那就是 Host 字段,它用来表征请求的目标主机。通常,在正式生产环境下它是个域名,比如 api.qiniu.com 。以域名来表征目标主机,无疑更加能够体现业务特性。故而,对应用层而言,“域名+资源路径” 是更好的路由依据,方便进行业务的切分。
正因为 HTTP 协议的这些好处,逐渐地它成为了网络应用层协议的模板。无论业务具体是什么样子的,都可以基于 HTTP 协议表达自己的业务逻辑。
## TCP/IP 层编程接口
理解清楚了我们网络应用程序的结构,也理解了我们最主流的应用层协议 HTTP 协议,那么我们就可以考虑去实现一个互联网软件了。
从编程接口来说,网络的可编程性是从网络层 IP 协议开始。这是最底层的网络 “操作系统” 的能力体现。
从基于 IP 协议的网络视角来看数据并不是源源不断的流stream而是一个个大小有明确限制的 IP 数据包。IP 协议是无连接的,它可以在不连接对方的情况下向其发送数据。规格示意如下:
```
package net
type IPAddr struct {
IP IP
Zone string // IPv6 scoped addressing zone
}
func DialIP(network string, laddr, raddr *IPAddr) (*IPConn, error)
func ListenIP(network string, laddr *IPAddr) (*IPConn, error)
func (c *IPConn) Read(b []byte) (int, error)
func (c *IPConn) ReadFrom(b []byte) (int, Addr, error)
func (c *IPConn) ReadFromIP(b []byte) (int, *IPAddr, error)
func (c *IPConn) Write(b []byte) (int, error)
func (c *IPConn) WriteTo(b []byte, addr Addr) (int, error)
func (c *IPConn) WriteToIP(b []byte, addr *IPAddr) (int, error)
func (c *IPConn) Close() error
```
IP 协议本身只定义了数据的目标 IP那么这个 IP 地址对应的计算机收到数据后,究竟应该交给哪个软件应用程序来处理收到的数据呢?
为了解决这个问题,在 IP 协议的基础上定义了两套传输层的协议UDP 和 TCP 协议。它们都引入了端口port的概念。
端口很好地解决了软件间的冲突问题。一个IP地址+端口,我们通常记为 ip:port代表了软件层面上来说唯一定位的通讯地址。每个软件只处理自己所使用的 ip:port 的数据。
当然,既然 IP 和端口被传输层一起作为唯一地址,端口上一定程度上缓解了 IPv4 地址空间紧张的问题。
虽然从设计者的角度来说,最初端口的设计意图,更多是作为应用层协议的区分。例如 port = 80 表示 HTTP 协议port = 25 表示 SMTP 协议。
应用协议的多样化很容易理解,这是应用的多样化决定的。尽管从架构的角度,我们并不太建议轻易去选择创造新的协议,我们会优先选择 HTTP 这样成熟的应用层协议。但是随着时间的沉淀,还是会不断诞生新的优秀的应用层协议。
但是,**为什么需要有多套传输层的协议TCP 和 UDP**
还是因为应用需求是多样的。底层的 IP 协议不保证数据是否到达目标也不保证数据到达的次序。出于编程便捷性的考虑TCP 协议就产生了。
TCP 协议包含了 IP 数据包的序号、重传次数等信息,它可以解决丢包重传,纠正乱序,确保了数据传输的可靠性。
但是 TCP 协议对传输协议的可靠性保证,对某些应用场景来说并不是一个好特性。最典型的就是音视频的传输。在网络比较差的情况下,我们往往希望丢掉一些帧,但是由于 TCP 重传机制的存在,可能会反而加剧了网络拥塞的情况。
这种情况下UDP 协议就比较理想,它在 IP 协议基础上的额外开销非常小基本上可以认为除了引入端口port外并没有额外做什么非常适合音视频的传输需求。
编程接口来说TCP 的编程接口看起来是这样的:
```
package net
type TCPAddr struct {
IP IP
Port int
Zone string // IPv6 scoped addressing zone
}
func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error)
func ListenTCP(network string, laddr *TCPAddr) (*TCPListener, error)
func (c *TCPConn) Read(b []byte) (int, error)
func (c *TCPConn) Write(b []byte) (int, error)
func (c *TCPConn) Close() error
func (l *TCPListener) Accept() (Conn, error)
func (l *TCPListener) AcceptTCP() (*TCPConn, error)
func (l *TCPListener) Close() error
```
UDP 的编程接口看起来是这样的:
```
package net
type UDPAddr struct {
IP IP
Port int
Zone string // IPv6 scoped addressing zone
}
func DialUDP(network string, laddr, raddr *UDPAddr) (*UDPConn, error)
func ListenUDP(network string, laddr *UDPAddr) (*UDPConn, error)
func (c *UDPConn) Read(b []byte) (int, error)
func (c *UDPConn) ReadFrom(b []byte) (int, Addr, error)
func (c *UDPConn) ReadFromUDP(b []byte) (int, *UDPAddr, error)
func (c *UDPConn) Write(b []byte) (int, error)
func (c *UDPConn) WriteTo(b []byte, addr Addr) (int, error)
func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (int, error)
func (c *UDPConn) Close() error
```
对比看IP 和 UDP 的区别非常小,都是无连接的协议,唯一差别就是 UDPAddr 在 IPAddr 基础上增加了一个端口。也正因为如此,我们很少有应用程序会直接基于 IP 协议来编程。
客户端来说,无论 TCP 还是 UDP使用方式都很像其示意代码如下
```
c, err := net.Dial(&quot;tcp&quot;, addrServer)
c.Write(...)
c.Read(...)
c.Close()
```
net.Dial 背后会根据 network 字段选择调用 DialTCP 还是 DialUDP。然后我们就像操作一个文件一样来操作就行理解上非常简单只是 UDP 的读写在应用层面需要考虑可能会丢包。
但是服务端不太一样。服务端并不知道谁会给自己发信息,它只能监听自己的 “邮箱”,不时看看是不是有人来信了。
对于 TCP 协议,服务端示意代码如下:
```
l, err := net.Listen(&quot;tcp&quot;, addrServer)
for {
c, err := l.Accept()
if err != nil {
错误处理
continue
}
go handleConnection(c)
}
```
对于 UDP 协议,服务端示意代码如下:
```
c, err := net.ListenUDP(&quot;udp&quot;, addrServer)
for {
n, srcAddr, err := c.ReadFromUDP(...)
if err != nil {
错误处理
continue
}
// 根据 srcAddr.IP+port 确定是谁发过来的包,怎么处理
}
```
由于 TCP 基于连接connection所以每 Accept 一个连接后我们可以有一个独立的执行体goroutine去处理它。但是 UDP 是无连接的,需要我们手工根据请求的来源 IP+port 来判断如何分派。
## HTTP 层编程接口
尽管基于 TCP/IP 层编程是一个选择,但是在当前如果没有特殊的理由,架构师做业务架构的时候,往往还是优先选择基于 HTTP 协议。
我们简单来看一下 HTTP 层的编程接口:
```
package http
func Get(url string) (*Response, error)
func Post(url, contentType string, body io.Reader) (*Response, error)
func PostForm(url string, data url.Values) (*Response, error)
func NewRequest(method, url string, body io.Reader) (*Request, error)
var DefaultClient = new(Client)
func (c *Client) Do(req *Request) (*Response, error)
func NewServeMux() *ServeMux
func (mux *ServeMux) Handle(pattern string, handler Handler)
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request))
func ListenAndServe(addr string, handler Handler) error
func ListenAndServeTLS(addr, certFile, keyFile string, handler Handler) error
```
对于 HTTP 客户端,使用上要比 TCP/UDP 简单得多,常见情况下直接调用 Get、Post 这些函数调用就满足业务需求。
在需要在 HTTP 协议头写一些额外字段的,会略微麻烦一点,需要先 NewRequest 生成一个请求并添加一些字段Field然后再调用 Client.Do 去发起请求。整体上比调用 Read/Write 这样的基础 IO 函数要简便得多。
对于 HTTP 服务端,使用上的示意代码如下:
```
mux := http.NewServeMux()
mux.HandleFunc(&quot;/abc/example&quot;, handleAbcExampe)
mux.HandleFunc(&quot;/abc/hello/&quot;, handleAbcHello)
http.ListenAndServe(addServer, mux)
```
简单解释一下,一个 HTTP 服务器最基础的就是需要有根据 “资源路径” 的路由能力,这依赖 ServeMux 对象来完成。
简单对比可以看出,基于 HTTP 协议的编程接口,和基于 TCP/IP 协议裸写业务,其复杂程度完全不可同日而语。前者一个程序的架子已经呈现,基本上只需要填写业务逻辑就好。这也是采纳通用的应用层协议的威力所在。
## 结语
这一讲我们希望给大家呈现的是应用程序的全貌。当然,我们现在看到的仍然是非常高维的样子,后面在 “服务端开发” 一章,我们将进一步展开所有的细节。
在应用层协议介绍上,我们很难有全面的介绍,因而我们把侧重点放在 HTTP 协议的概要介绍上。同样,后面我们在 “服务端开发” 一章会进一步介绍 HTTP 协议。
最后,我们整理了基于 TCP/UDP 协议编程和基于 HTTP 协议编程的主体逻辑。虽然介绍非常简要,但通过对比我们仍然可以感受到业务架构基于成熟的应用层协议的优势所在。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。网络编程本章就到此结束,后面我们有专门的章节来进一步展开。下一讲,我们将探讨操作系统的最后一个子系统:安全管理。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,228 @@
<audio id="audio" title="16 | 安全管理:数字世界的守护" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/18/23/1844af0c82f3049c7314fffd1d743023.mp3"></audio>
你好,我是七牛云许式伟。今天我们要聊的话题是操作系统的最后一个子系统:安全管理。
数字世界是高效的,但数字世界也是脆弱的。在越来越多的日常生活被数字化的今天,安全问题也越来越凸显出了它的重要性。
有经验的安全工程师都知道,做好安全的基本逻辑是:不要开太多的门和窗,最好所有人都在同一道门进出,安全检查工作就可以非常便利地进行。
要想构建一个安全可靠的环境,从最底层就开始设计显然是最好的。所以安全管理是一个基础架构问题。现代操作系统必然会越来越关注安全性相关的问题。因为一旦安全问题严重到触及人们的心里防线,整个数字世界都有可能随之崩塌。
让我们从头回顾一下操作系统安全能力的演进。
## 病毒与木马
首先是实模式的操作系统,以微软的 DOS 系统为代表。实模式的操作系统进程都运行在物理地址空间下。
这意味着,每个软件进程都可以访问到其它软件进程(包括操作系统)的内存数据,也可以随意地修改它。所以这个时期的计算机是非常脆弱的,它选择的是信任模式:我相信你不会搞破坏。
不过,好在这个时期网络还并不发达,所以一个单机版本的恶意软件,能够干的真正恶意的事情也很有限。这一时期恶意软件以计算机病毒为主,其特征主要是繁衍自己(复制自己),对计算机系统本身做某种程度的破坏。
现代操作系统基本上都是保护模式的操作系统。保护模式就是让软件运行的内存地址空间隔离,进程之间相互不能访问(除非基于共享内存技术,那也是进程自己主动选择,与被动无感知的情况下被人窥视不同)。
这从安全角度来说,是很重要的进步。不管怎么说,内存数据是最为敏感的,因为它无所不包。况且,从 Windows 开始,互联网逐步进入人们的视野。计算机的联网,一下子让安全问题变得严峻起来。
恶意软件目的开始变得不单纯。它不再只是黑客的技术炫耀,而是切切实实的黑色产业链的关键依赖。
这一时期恶意软件开始以木马为主。木马和病毒一样会去繁衍自己(复制自己),但是它较少以破坏计算机的运行为目的,相反它默默隐藏起来,窃取着你的隐私。然后,它再通过互联网把窃取的信息默默地传递出去(比如通过电子邮件)。
**哪些信息是木马感兴趣的?有很多。比如以下这些信息:**
- 键盘按键;
- 剪贴板的内容;
- 内存数据;
- 文件系统中关键文件的内容;
- ……
你可能奇怪,前面不是说保护模式已经把内存数据隔离了么,为什么木马还是能够取到内存数据?
其实这一点不难想明白,虽然跨进程已经无法取得数据了,但是木马本来就是靠复制自己,把自己伪装成正常软件的一部分。这样,木马程序和正常的软件代码同属于一个进程内,所有信息对其仍然一览无余。
为了彻底阻止木马程序篡改正常的应用程序,聪明的操作系统创造者们想到了好方法:数字签名。
这本质上是白名单技术。所有正常发布的软件都到操作系统厂商那里登记一下。这样,一旦木马去修改软件,把自己附加上去,这个软件的签名验证就通不过,也就直接暴露了。
其实 Windows 操作系统已经引入了数字签名的概念,可以用以鉴别软件的可信度。但是考虑到从开放转向封闭有极大的历史负担,所以无论是 Windows 还是 Mac都没有完全杜绝无签名的软件最多当你运行无数字签名的软件时会给个不可信的警告。
**第一个大规模把软件发布变成一个封闭环境的是苹果的 iOS 操作系统。**苹果通过引入 App Store要求所有应用发布都必须通过 App Store 进行。今天无论是 Android 还是 iOS 操作系统都基于应用市场这样的封闭软件发布的形态。
这样一来,软件无法被非法修改,木马基本上就无所遁形了。当然,这并不代表木马在这些平台上就消失了。虽然不容易,但是通过感染开发人员的软件开发环境,还是可以在软件编译或其它环节中把木马注入到要发布的软件中。
要发现这种异常iOS 和 Android 系统的厂商对软件进行数字签名前,往往会对其进行安全扫描,以发现各种潜在的安全风险。一旦某个软件被鉴定为恶意软件,就无法通过数字签名,也无法发布到应用市场上。
通过这些机制,木马很难再有机会得到传播。
## 软件的信息安全
但是,这意味着我们没有安全风险了么?当然不是。在移动设备上,安全问题的大环境发生了巨大的变化。
首先移动时代随着我们数字世界对现实生活影响的加深我们越来越多的敏感信息更加容易被软件触及。有很多新增的敏感信息是PC时代所不具备的例如
- 通讯录和通话记录;
- 短信;
- 个人照片和视频;
- 个人地理位置GPS信息
- 移动支付的支付密码、支付验证码;
- 录像和录音权限;
- 通话权限;
- .……
正因为如此,尽管操作系统正变得越来越安全,但我们面临的安全威胁却也在日趋严重。
**其实, iOS 操作系统在安全管理上的考虑不可谓不周全。**
**首先**,在软件隔离机制上,除了基于 CPU 的保护模式确保软件之间的内存隔离外iOS 还引入了沙盒系统Sandbox确保软件之间文件系统隔离相互之间不能访问对方保存在磁盘上的文件。
**其次**,通过上面我们已经提及的数字签名机制,防止了软件被恶意篡改,让病毒和木马无法传播繁衍。
**最后**,对涉及敏感信息的系统权限进行管控。各类敏感信息的授予均是在应用程序使用的过程中进行提示,提醒用户注意潜在的安全风险。
在这一点上Android 操作系统往往则是在安装软件时索要权限。这两者看似只是时机不同,但是从安全管理角度来说, iOS 强很多。
还没有见到软件真身就让用户判断要不要给权限,用户往往只能无脑选择接受。而如果是在软件运行到特定场景时再索要权限,那么权限给不给就有合理的场景支持决策。
但是,在利益面前,软件厂商们是很难抵御住诱惑的。所以不仅仅是恶意软件会去过度索要系统权限,很多我们耳熟能详的常规软件也会索要运行该软件所不需要的权限。
移动时代,恶意软件的形态已经再一次发生变化。它既不是病毒也不是木马,而是“具备实用功能,但背地却通过获取用户的敏感信息来获利”的应用软件。
它通过诱导用户下载,然后在软件安装或者使用时索要敏感信息的获取权限。
**一个软件到底是正常的还是恶意的?边界已经越来越模糊了。**
以前病毒和木马都有复制和繁衍自己,这样一个显著的特征,但如今病毒和木马的复制繁衍能力已经被操作系统的安全机制所阻止,所以恶意软件和普通软件一样,都是通过某种手段吸引用户下载安装。
怎么保护好用户的隐私信息?道高一尺,魔高一丈。攻防之间的斗争仍将继续下去。
## 网络环境的信息安全
如果我们不轻易尝试不可信的软件,就可以一切安全无虞?并不然,我们还要考虑我们的计算机所处的网络环境安全问题。
我们上网过程需要经过一系列的中间节点,有交换机,有路由器。我们的上网产生的所有数据包,都经由这些中间节点,**这意味着我们有以下三个级别的安全风险。**
- 被窃听的风险。可能会有人在这些节点上监听你访问和提交的内容。
- 被篡改的风险。可能会有人在这些节点上截获并修改你访问的内容。
- 被钓鱼的风险。可能会有人冒充你要访问的服务提供方和你通讯。
虽然大部分的中间节点由网络运营商提供,我们刨除这些节点被黑客所黑的情形,基本上认为可信。但这并不绝对,至少在中国,运营商修改中转的数据包这样的事情是干得出来的,常见的手法有:
- 在正常的 HTML 页面插入广告;
- 修改用户下载的 apk 文件,替换成自己想分发的 apk 文件;
- 修改 404 类型的 HTML 页面,替换成自己的搜索引擎的搜索页;
- .……
其次是 WiFi 路由器。WiFi 路由器因为其提供方鱼龙混杂,天生是安全问题的大户。运营商能够干的事情它全都可以干,甚至可以更加肆无忌惮,以李鬼替换李逵,钓鱼的风险并不低。
比如你以为登录的是交通银行官网,它可能给你一个一模一样外观的网站,但是一旦你输入用户名和密码就会被它偷偷记录下来。
怎么解决中间人问题?
首先是怎么防篡改。应用场景是电子合同/公章、网络请求授权(例如你要用七牛的云服务,需要确认这个请求的确是你,而不是别人发出的)等。这类场景的特征是不在乎内容是否有人看到,在乎的是内容是不是真的是某个人写的。
解决方法是数字签名技术。一般来说,一个受数字签名保护的文档可示意如下:
<img src="https://static001.geekbang.org/resource/image/c1/3f/c191e43d0959abf907754286ed926f3f.png" alt="">
>
其中,“要防篡改的内容” 是信息原文。“密钥提示” 是在数字签名的 “密钥” 有多个的情况下,通过 “密钥提示” 找到对应的 “密钥”。如果用于保护信息的 “密钥” 只有一个,那么可以没有 “密钥提示”。“指纹” 则是对信息使用特定 “密钥” 和信息摘要算法生成的信息摘要。
大部分情况下,数字签名的信息摘要算法会选择 HMAC MD5 或者 HMAC SHA1。在 Go 语言中,使用上示意如下:
```
import &quot;crypto/hmac&quot;
import &quot;crypto/sha1&quot;
import &quot;encoding/base64&quot;
textToProtected := &quot;要防篡改的内容&quot;
keyHint := &quot;123&quot;
key := findKey(keyHint) // 根据 keyHint 查找到 key []byte
h := hmac.New(sha1.New, key) // 这里用sha1也可以改成别的
h.Write([]byte(textToProtected))
textDigest := base64.URLEncoding.EncodeToString(h.Sum(nil))
textResult := textToProtected + &quot;:&quot; + keyHint + &quot;:&quot; + textDigest
```
得到的 textResult 就是我们期望的不可篡改信息。验证信息是否被篡改和以上这个过程相反。
首先根据 textResult 分解得到 textToProtected、keyHint、textDigest然后根据 keyHint 查找到 key再根据 textToProtected 和 key 算一次我们期望的信息摘要 textDigestExp。
如果 textDigestExp 和 textDigest 相同,表示没被篡改,否则则表示信息不可信,应丢弃。
如果我们希望更彻底的隐私保护,避免被窃听、被篡改、被钓鱼,那么数字签名就不顶用了,而需要对内容进行加密。
加密算法上一般分为对称加密和非对称加密。对称加密是指用什么样的密钥key加密就用什么样的密钥解密这比较符合大家惯常的思维。
非对称加密非常有趣。它有一对钥匙分私钥private key和公钥public key。私钥自己拿着永远不要给别人知道。公钥顾名思义是可以公开的任何人都允许拿。
那么公私钥怎么配合?首先,通过公钥加密的文本,只有私钥才能解得开。这就解决了定向发送的问题。网络中间人看到加密后的信息是没有用的,因为没有私钥解不开。
另外,私钥拥有人可以用私钥对信息进行数字签名(防止篡改),所有有公钥的人都可以验证签名,以确认信息的确来自私钥的拥有者,这就解决了请求来源验证的问题。
那么 A、B 两个人怎么才能进行安全通讯呢首先A、B两人都要有自己的公私钥并把公钥发给对方。这样 A 就有 A-private-key、B-public-keyB 就有 B-private-key、A-public-key。通讯过程如下所示。
- A 向 B 发信息 R。具体来说A 首先用 A-private-key 对 R 进行签名得到RR-digest然后用 B-public-key 对RR-digest加密得到 encodedRR-digest然后把最终的加密信息发出去。
- B 收到 encodedRR-digest用 B-private-key 解密得到RR-digest然后再用 A-public-key 验证信息的确来自 A。
- B 理解了 R 后,回复信息给 A。这时两人的角色互换其他同上。
非对称加密机制非常有效地解决了在不可信的网络环境下的安全通讯问题。但是它也有一个缺点,那就是慢。相比之下,它的速度比对称加密慢很多。
所以,一个改善思路是结合两者。非对称加密仅用于传输关键信息,比如对称加密所需的密码。完整的通讯过程如下所示。
- A 生成一个临时用的随机密码 random-key。
- A 向 B 发送 random-key机制用的就是上面的非对称加密基于 B-public-key。
- B 收到 A 发送的 random-key把它记录下来并回复 A 成功。回复的信息可以基于 random-key 做对称加密。
- 此后A 向 B 发、B 向 A 发信息,都用 random-key 作对称加密,直到本次会话结束。
你可能发现,整个过程中 A 自己已经不再需要非对称的公私钥对了。只要 A 事先有 B 的公钥B-public-key就可以。
当然,上面我们的讨论,没有涉及 B 如何把自己的 B-public-key 交给对方的。在假设网络不可信的前提下,这似乎是个难题。
我觉得有两个可能性。一个是 A 和 B 很熟悉,平常都经常一起玩。那么他们交换 public-key 完全可以不依赖任何现代通讯设备,包括电话和互联网,而是写在一张纸上,某天聚会的时候交换给对方。
另一个是更为常见的互联网世界场景:我要访问一个网站。我怎么才能避免被窃听、被篡改、被钓鱼?
**通常我们用 HTTPS 协议。**
在 HTTPS 协议中,第一步是 A 作为客户端Client去获取 B 作为网站的公钥B-public-key
怎么获取?如果我们认为网络不可信,那么我们就需要找一个可信的中间人,第三方权威机构 G由它来证明我们网站 B 返回客户端 A 的公钥B-public-key的确来自于 B中间没有被其他人篡改。
这意味着网站 B 不能直接返回自己的公钥B-public-key给客户端 A而是需要返回由权威机构 G 做了数字签名的公证书(简称数字证书),里面记录了网站 B 的域名domain和对应的公钥B-public-key还有证书的颁发人 G 的代号。
这张数字证书的作用是什么?最重要的并不是它怎么在网络上传递的。而是它记录了这样一个事实:域名 domain 对应的公钥是 B-public-key它是由权威机构 G 做出的公证,因为上面有 G 的数字签名。
所以这张数字证书并不需要临时生成,而是提前在网站部署时就已经生成好了,而且也可以随意传递给任何人,因为它是完全公开的信息。
当然这里还有一个前提,我们客户端 A 已经提前拥有第三方权威机构 G 的公钥G-public-key了。整个过程如下
- 客户端 A 向 网站 B 请求网站的数字证书。
- 网站 B 返回它的数字证书。
- 客户端 A 收到数字证书,用 G-public-key 验证该数字证书的确由权威机构 G 认证,于是选择相信证书里面的 (domain, public-key) 信息。
- 客户端 A 检查证书中的 domain和我们要访问的网站 B 域名是否一致。如果不一致,那么说明数字证书虽然是真的,但是是别人找权威机构 G 认证的其他域名的证书,于是结束会话;如果一致,于是相信证书中的 public-key 就是网站 B 的公钥B-public-key
有了 B-public-key客户端 A 就可以愉快地上网,不必担心网络通讯的安全了。
但是HTTPS 并不能完全解决钓鱼问题。它假设用户对要访问的网站域名domain可靠性有自己的判断力。
这当然并不全是事实。所以,高级一点的浏览器(例如 Google Chrome它会建立不靠谱网站域名的数据库在用户访问这些网站时进行风险提示。
## 更多的信息安全话题
上面我们更多从服务终端用户角度,操作系统和浏览器以及我们的应用程序需要考虑的是信息安全问题。有以下这些信息安全问题没有涉及:
- 服务器的安全问题DDOS 攻击、漏洞与入侵);
- 企业信息安全;
- 社会工程学的安全问题;
- ……
## 结语
总结一下,我们今天聊了软件安全态势的演变过程,从最早的病毒和木马,演化到今天敏感信息如通讯录等内容的窃取,正常软件与恶意软件的判断边界越来越模糊。
我们也聊了网络环境带来的安全问题。今天主流的假设是网络链路是不可信的,在不可信的网络之上如何去做安全的通讯,可以做到防窃听、防篡改、防钓鱼。这也是苹果前几年强制要求 iOS App 必须走 HTTPS 协议的原因。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。本章关于操作系统的话题到此就结束了。下一讲我们结合前面的内容,讨论并实战架构第一步,怎么做需求分析。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,183 @@
<audio id="audio" title="17 | 架构:需求分析 (上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/df/a4/df52f55f3379632ab40d6e28a00087a4.mp3"></audio>
你好,我是七牛云许式伟。
前面我们多次提到过,架构的第一步是需求分析。那么,为什么要做需求分析?如何做好需求分析?
今天让我们一起聊一聊需求分析这个话题。
## 关于需求分析的那些事
为何要做需求分析?
**首先**,当然是因为我们做软件本身就是为了满足用户需求。那么,用户需求到底为何,我们需要清楚定义。
**其次**,需求边界定义的需要。用户需求理清楚了,不代表产品理清楚了。用户需求的满足一定会有行业分工,我们做什么,合作伙伴做什么,需要厘清大家的边界。
**最后**,架构设计的需要。架构需要切分子系统,需要我们梳理并对用户需求进行归纳与抽象。架构还需要防止过度设计,把简单的事情复杂化。
但什么是过度设计?不会发生的事情你考虑了并且为它做足了准备,就是过度设计。所以判断是不是过度设计是很困难的,需要对需求未来演化有很强的判断力。
从这几个维度来看,需求分析过程必然会涉及以下这些内容。
- 我们要面向的核心用户人群是谁?
- 用户原始需求是什么?最核心问题是哪几个?
- 已经有哪些玩家在里面?上下游有哪些类型的公司,在我们之前,用户是怎么解决他们的问题的?我们的替换方案又是怎样的?
- 进而,我们的产品创造的价值点是什么?用户最关注的核心指标是什么?
- 用户需求潜在的变化在哪些地方?区分出需求的变化点和稳定点。
当然,我并不是说,我们应该在需求分析的文档中完整地回答这些问题。需求分析文档目的并不是回答这些问题。但是在我们梳理需求的过程中,我们无法回避对这些问题的思考。
可能有人会认为,这些问题是 CEO 或产品经理这样的角色需要回答的,而不是架构师需要回答的。
某种意义上来说这句话没错。回答这些问题的首要责任方是 CEO 或产品经理。他们有责任让团队中的每一个人理解我们的产品逻辑。
但是,如果架构师只是被动地接受产品需求,以按图索骥的方式来做架构设计,是不足以成为顶级架构师的。原因在于两点。
**一方面,用户需求的深层理解是很难传递的。**你看到的产品文档,是产品经理和用户沟通交流后的二次理解,是需求的提炼和二次加工,很难原汁原味地传递用户的述求。
所以架构师自己亲身近距离地接触用户,和用户沟通,去体会用户的述求是非常有必要的。
况且,大部分人并不会那么仔仔细细地阅读别人写的文档。当然这不完全是看文档的人单方面的原因,如果团队文档平均质量不高的话,也会影响到阅读者的心态。
**另一方面,产品设计过程需要架构师的深度参与,而不是单向的信息传递。**产品经理非常需要来自架构师的建设性意见。
为什么我会有这样的看法呢?这涉及我对产品的理解。产品本身是运用先进的技术来满足用户需求过程的产物。
用户需求的变化是缓慢的,真正改变的是需求的满足方式。而需求满足方式的变化,深层次来说,其背后往往由技术迭代所驱动。
从这个角度来说,**产品是桥,它一端连接了用户需求,一端连接了先进的技术。**产品经理是需要有技术高度的,他不一定要深刻了解技术的原理,但是一定要深刻理解新技术的边界。
某项技术能够做什么,不能做到什么,顶级产品经理甚至比实现这项技术的开发人员还要清楚。
认为产品经理不需要理解技术,这可能是我们普遍存在的社会现象,但很可能并不符合这个岗位的内在诉求。
**回到架构师这个角色。**
我经常说一个观点,**产品经理和架构师其实是一体两面。两者都需要关心用户需求与产品定义。**
只不过产品经理更多从用户需求出发,而架构师更多从技术实现出发,两者是在产品这座桥的两端相向而行,最终必然殊途同归。
这也是我为什么说架构师需要深度参与产品设计的原因。产品经理很可能会缺乏他应该有的技术广度,这就需要架构师去补位。产品定义过程需要反复推敲琢磨,并最终成型。
需求分析并不是纯技术的东西,和编程这件事情无关。它关乎的是用户需求的梳理、产品的清晰定义、可能的演变方向。
需求分析的重要性怎么形容都不过分。准确的需求分析是做出良好架构设计的基础。
前面我也说过,我个人认为架构师在整个架构设计的过程中,至少应该花费三分之一的精力在需求分析上。
这也是为什么很多非常优秀的架构师换到一个新领域后,一上来并不能保证一定能够设计出良好的架构,而是往往需要经过几次迭代才趋于稳定。
原因就在于:领域的需求理解是需要一个过程的,对客户需求的理解不可能一蹴而就。
## 怎么做需求分析
那么怎么才能做好需求分析?
**首先,心态第一,心里得装着用户。**除了需要 “在心里对需求反复推敲” 的严谨态度外,对用户反馈的尊重之心也至关重要。
**其次,对问题刨根究底,找到根源需求。**有很多用户反馈需求的时候,往往已经带着他自己给出的解决方案。
这种需求反馈已经属于二次加工的需求,而非原始需求。这个时候我们要多问多推敲,把它还原到不带任何技术实现假设的根源需求。
<img src="https://static001.geekbang.org/resource/image/c9/0f/c9895fc36b9493576ae3a1bce763f60f.png" alt="">
如上图所示,根源需求可能会有非常非常多的技术方案可以满足它。我们上面示意图中的小圆点是一个个用户反馈的需求。在用户提这些需求的时候,往往可能会带着他熟悉的技术方案的烙印。
对于那些我们明显不关心的需求,如上图的小红点,相对容易排除在外。毕竟产品的边界意识大家还是会有的,产品不可能无限制膨胀下去。
但是对于上面的小绿点,决策上就比较难了。不做?可能会丢了这个客户。做?如果我们手放宽一点,最后产品需求就会被放大(如上图中蓝色的圆圈),做出一个四不像的产品。
**最后,在理清楚需求后,要对需求进行归纳整理。**一方面,将需求分别归类到不同的子类别中。另一方面,形成需求的变化点和稳定点的基本判断。
前面我们也强调过:在需求分析时,要区分需求的变化点和稳定点。稳定点往往是系统的核心能力,而变化点则需要对应地去考虑扩展性上的设计。
要注意的是,在讨论需求的变化点和稳定点的时候,我们需要有明确参考的坐标系。在不同视角下,稳定点和变化点的判断是完全不同的。
所以**需要明确的一点是,当我们说需求的变化点和稳定点时,这是站在我们要设计的产品角度来说的。**
比如我们要设计一台计算机,那么多样化的外部设备是一个变化点。但是如果我们今天是在设计一台显示器,问题域就完全变了,需求的变化点和稳定点也就完全发生了变化。
本质上来说,对变化点的梳理,是一次产品边界的确立过程。所谓的开放性设计,就是说我把这个功能交给了合作伙伴,但是我得考虑怎么和合作伙伴配合的问题。
开放性设计并不是一个纯粹的用户需求问题,它通常涉及技术方案的探讨。因此,产品边界的确立不是一个纯需求,也不是一个纯技术,而是两者合而为一的过程。
对变化点的梳理至关重要。产品功能必须是收敛的,必须是可完成的。
如果某个子类别的需求呈现出发散而无法收敛的趋势,这个事情,团队一定要坐下来一起去反复推敲。不断拷问,不断明确响应需求的正确姿势到底为何。
## 产品定义
需求分析的目标和最终结果,都是要最终形成清晰的产品定义。产品定义并不是简单的产品需求的归类。
<img src="https://static001.geekbang.org/resource/image/6f/14/6fdb28f9c90127d772e65e8388bd8214.png" alt="">
上面我也说过,产品是桥,它一端连接了用户需求,一端连接了先进的技术。所以产品定义不可能做到和技术方案完全没关系。
**首先,需要明确产品中有哪些元素,或者叫资源,以及这些资源的各类操作方式。**如果我们从技术的视角来理解,这就是定义对象和方法。当然这仅仅是这么理解,实际上一个我们技术上的对象方法,从产品需求角度会有多条路径的操作方式来达到相同的目的。
**其次,需要对产品如何满足用户需求进行确认。**用户的使用场景未必全部是我们的产品所能直接满足的,面向特定的行业,有可能需要相应的行业解决方案,把我们的产品整合进去。<br>
<img src="https://static001.geekbang.org/resource/image/75/52/75e4c17d083da8459468ada25d593752.jpg" alt=""><br>
我们要避免把行业方案视作产品的一部分。更多的情况下,需要我们更加开放的心态来看待这件事情,优先寻找合作伙伴来一起完成这类行业的需求覆盖。
**最后,产品定义还需要考虑市场策略,我们的产品如何进入市场,和既有市场格局中的其他主流解决方案的关系是什么样的。**
<img src="https://static001.geekbang.org/resource/image/4c/61/4c23a1f778f1d78ce379702cc8df0161.png" alt="">
我们希望获取的用户,可能大部分都已经有一个既有的产品和技术方案,在满足他的需求。在考虑如何让客户从既有方案迁移到我们的产品后,我们确定产品的边界时又会复杂很多。
在一些极其关键的市场,我们有可能会把迁移需求视作产品需求的一部分。但更多的情况下,我们产品上只为这些市场上的主流方案提供迁移路径,而不是完整的迁移方案。
## 为何架构课从基础平台开始?
很抱歉我说得很抽象,但是总结需求分析的方法论的确是一件很难的事情。
**为什么我们谈架构会从 “基础平台” 讲起?为什么从硬件架构,到编程语言,再到操作系统,我们似乎绕了一大圈,还没有谈到架构?**
有两个原因。
**最直接的原因是 “基础平台” 是我们所依赖的环境,是我们应用的业务架构的一部分。越了解我们所处的环境,我们就越能够运用自如。**
**但还有一个重要的原因是架构的探讨容易过度抽象。**所以我并没有先长篇大论谈架构方法论,谈需求应该怎么怎么去分析,而是围绕着基础平台的演进过程来谈需求分析。
信息世界的构建过程,本身就是一个最宏大的架构实践。我们通过对信息世界的骨架构成的参悟,自然能够感悟到架构思维的要点。
学内功需要悟心,学架构也需要悟心。怎么准确研判需求,对需求演进进行预测,这并不是靠技术技能,而是靠谦和求取的心态。
所以我们第一章 “基础平台” 篇整体来说,内容介绍以产品的需求分析为主、核心技术原理为辅。我们尝试把整个基础平台融为一个整体,宏观上不留任何疑惑。
实际上这一章的内容很难做到只看一遍就可以,可能要时时看,反复看。还需要查阅一些资料,也可以与人一起探讨。当然,我们也欢迎留言一起交流。
这一章我们介绍的内容,大部分内容都有一些对应的经典书籍,在后面 “基础平台篇: 回顾与总结” 一讲中,我也会给大家推荐一些经典的图书。
但我们并不是要重复这些书籍中的内容。**我们的关注点在于:一是构建信息世界的宏观骨架,二是需求演进。**
经典书籍虽然好,但是它们写作时候的历史背景和今天有很大不同。从架构视角来说,结合我们今天的现实情况来看,一方面我们可以总结今天区别于当初的所有变化,另一方面主动去思考为什么发生了这样的变化。以这样的视角去读经典书籍,会别有一番滋味。
## 结语
在我们介绍完第一章 “基础平台” 篇的所有内容后,今天我们终于正式开始谈架构思维。我们探讨的是架构的第一步:需求分析。
需求分析并不是纯技术的东西,和编程这件事情无关。它关乎的是用户需求的梳理、产品的清晰定义、可能的演变方向。
**怎么提升需求分析能力,尤其是预判能力?**
**首先**,心态第一,心里得装着用户。除了需要 “在心里对需求反复推敲” 的严谨态度外,对用户反馈的尊重之心也至关重要。
**其次**,对问题刨根究底,找到根源需求。
**最后**,对需求进行归纳整理。一方面,将需求分别归类到不同的子类别中。另一方面,形成需求的变化点和稳定点的基本判断。
需求分析的目标和最终结果,都是要最终形成清晰的产品定义。产品定义将明确产品的元素,明确产品的边界,与产业上下游、合作伙伴的分工。
为什么我们的架构课从日常最平常之处,我们日日接触的基础平台讲起?
你真了解它们吗?你真感悟到它们的不凡之处了吗?
学习架构,关键在于匠心与悟心。
**用思考的方式去记忆,而不是用记忆的方式去思考。**
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲将是 “架构: 需求分析(下)· 实战案例”。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,169 @@
<audio id="audio" title="18 | 架构:需求分析 (下) · 实战案例" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/08/16a3ef2a3e35fdfa8a23a28855375b08.mp3"></audio>
你好,我是七牛云许式伟。
今天,我们继续上一讲关于架构第一步 “需求分析” 的讨论。为了能够获得更加具体的观感,我们选了两个实战的案例,如下:
- 打造 “互联网”;
- 存储新兵 “对象存储”。
## 案例: 打造 “互联网”
从对信息科技的影响面来说,最为标志性的两个事件,一个是计算机的诞生,另一个是互联网的诞生。
我们前面在 “[05 | 思考题解读: 如何实现可自我迭代的计算机?](https://time.geekbang.org/column/article/93130)”这一讲中,已经剖析过一个 MVP 版本的计算机是什么样的。
今天,我们就以 “互联网” 这个产品为题,看看应该怎么去做需求分析。
我们想象一下,把我们自己置身于互联网诞生之前。互联网并不是第一张网。在此之前的信息世界中,更多的是某个企业专用的局域网。不同的企业会选择不同公司所提供的网络方案。这些网络方案缺乏统一的规划,彼此并不兼容。
那么,怎么才能打造一个连接人与人、企业与企业,甚至是物与物,能够 “连接一切” 的 “互联网”?
首先,从根源需求来说,我们期望这不是某个巨头公司的网,也不是政府的网。这是需求的原点,这一点上的不同,产生的结果可能就很不一样。
如果我们忽略这一点就有可能会把它做成微信网WechatNet或者中国网ChinaNet。它们可能会是一张巨大的网但都不是 “互联网”。
**所谓 “互联网” 首先应该是一张开放的网。它应该可以让很多国家很多公司参与其中,形成合力。它不应该存在 “造物主”,一个可以在这张网络中主宰一切的人。**
开放,最基础的层次来说,意味着需要定义网络协议标准,尤其是跨网的数据交换标准。这里的跨网,指的是跨不同的网络设备,不同的网络运营商。
开放,从另一个角度来说,是对应用程序软件的开放。想要 “互联网” 真正能够连接一切,只是把物理的网络连接在一起是不够的,还要有能够丰富的 “连接一切” 的应用。
为了能够让更多应用可以更便捷地连接网络,我们需要提供方便应用接入的高层协议。这个协议需要屏蔽掉网络连接的复杂性(丢包重传等)。
但这还不够。“互联网” 这样的基础设施,启动阶段没有应用去吸引用户是不行的。所以我们需要 “吃自己的狗粮”,开发若干互联网应用的典型代表。
有一些需求可能非常非常重要,但是我们需要阶段性放弃,例如安全。加密传输并没有作为互联网的内建特性,这极大降低了互联网的实施难度。
从另一个角度考虑,为什么不把安全放在最底层,也要考虑方案的可持续性。一个安全方案是否能够长期有效,这非常存疑。
但是物理网络一旦存在,就很难做出改变(想想我们从 IPv4 过渡到 IPv6 需要多少年吧)。所以从这个角度来说,我们也不希望安全是一个网络的底层设施。
这并不意味着安全问题可以不解决,只是把这事儿留给了软件层,留给操作系统和应用程序。这是一个极其明智的选择。相比物理网络而言,软件层更加能够经受得起变更。
**总结来说,要想把 “互联网” 这个项目做成,需要考虑这样一些事情。**
- 一个能够连接所有既有网络的协议标准我们不妨叫它互联网协议Internet Protocol简称 IP 协议。
- 一张连接城市的骨干网络,至少有两个城市互联的试点。
- 打通骨干网络和主流企业专用网络的路由器。
- 一套方便应用开发的高阶网络协议,工作在 IP 协议之上。
- 一份支撑互联网应用程序的基础网络协议栈源代码或包package方便主流操作系统厂商、网络设备厂商集成。
- 若干典型互联网应用如电子邮件Email、万维网WWW等。
- 一份安全传输的网络协议方案远期及其源代码或包package
让我们先来看下物理网络的构建。
首先,构建骨干网络。不同城市可以由若干个骨干网路由器相连。骨干路由器可以看做是由一个负责路由算法的计算机,和若干网络端口构成,如下图所示。
<img src="https://static001.geekbang.org/resource/image/41/bb/41355201ff809e671b599ddd7a43aabb.png" alt="">
每个端口可能和其他城市相连,也可能和该城市内的某些大型局域网相连。一个局域网和城际网络从抽象视角看,没有非常本质的不同,只不过是采用的网络技术有异,使用的网络协议有异。
一个局域网可以简化理解为由若干台交换机连接所有的计算机设备。而交换机同样也可以看做是由一个负责路由算法的计算机,和若干网络端口构成,如下图所示:
<img src="https://static001.geekbang.org/resource/image/1f/d0/1f778f97797e6b94f806fb6a3daaedd0.png" alt="">
剩下的问题是怎么对接骨干网络和局域网。这需要有人负责进行网络协议转换,它就是路由器。一台路由器上有两类端口,一类端口为本地端口,连接局域网内的设备,比如交换机,或者直接连普通的计算机。另一类端口为远程端口,负责接入互联网。
<img src="https://static001.geekbang.org/resource/image/53/b2/53211a8ff21d73d403a3b4dbd97cd5b2.png" alt="">
理清楚了物理网络后我们再来看应用构建。我们打算打造两个杀手级应用Killer Application电子邮件Email和万维网WWW
在考虑应用的用户交互体验时,我们发现,物理网络能够处理的 IP 地址和人类方便记忆的地址非常不同故而我们决定引入域名domain作为人与人交流用途的地址。为此我们引入了 DNS 地址簿协议,用于将域名解析为物理网络可理解的 IP 地址。
综上分析,最终我们得到 MVP 版本的 Internet 项目的各子系统如下:
<img src="https://static001.geekbang.org/resource/image/6c/4c/6c7bac541039e535deb6679c8c2b684c.png" alt="">
## 案例: 存储新兵 “对象存储”
对象存储是非常新兴的一种存储系统。是什么样的需求满足方式的变化,导致人们要创造一种新的存储呢?
对象存储是伴随互联网的兴起,尤其是移动互联网的兴起而产生的。
**首先,互联网应用兴起,软件不再是单机软件,用户在使用应用软件的过程中产生的数据,并不是跟随设备,而是跟随账号。**这样,用户可以随心所欲地切换设备,不必考虑数据要在设备间倒来倒去的问题。
数据跟随账号,这是互联网应用的第一大特征,区别于单机软件的关键所在。
**其次,用户交互方式的变化。**用户不再打字用纯文本沟通,而是用照片、视频、语音等多媒体内容来表达自己的想法。
移动化加剧了这一趋势,在手机上打字是非常痛苦的事情。拍拍照、拍拍视频、说说话(语音输入)更加符合人的天性,尤其是手机用户覆盖面越来越宽,大部分用户属于没有经过专业培训的普通用户,这些手段是最低准入门槛的交互方式。
**最后,用户体验诉求的提升。**计算机显示器早年是黑白的后来有了256色有了真彩色TrueColor显示器的屏幕分辨率也从320x240到640x480到今天我们再也不关心具体分辨率是多大。随之发生变化的是一张照片从100K到几兆到几十兆。
这些趋势,对存储系统带来的挑战是什么?
**其一,规模。**那么多用户的数据,一台机器显然放不下了,要很多很多台机器一起来保存。
**其二,可靠。**用户单机对存储的要求并不高,机器硬盘出问题了,不会想着找操作系统厂商或者软件应用厂商去投诉。但是,用户数据在服务端,数据丢了那就是软件厂商的责任,要投诉。
**其三,成本。**从软件厂商来说,那么多的用户数据,怎么做才能让成本更低一些。
**其四,并发吞吐能力。**大量的用户同时操作,有读有写,怎么保证系统是高效的。
另外,从存储系统的操作接口来说,我们分为关系型存储(数据库,结构化数据)和文件型存储(非结构化数据)。我们今天的关注点在文件型存储上。
对于文件型存储来说,相关的备选解决方案有很多,我们简单罗列如下。
<img src="https://static001.geekbang.org/resource/image/08/11/085ecbe7df53531f8af9cf7fec20de11.png" alt=""><br>
**第一类是大家最熟悉的、最古老的存储系统:本地文件系统。**虽然有很多种具体的实现方案,但是它们的使用接口大同小异,实现方案也只是在有限的几种选择中平衡。我们在 “[09 | 外存管理与文件系统](https://time.geekbang.org/column/article/94991)” 这一讲中已经有过详细的介绍,这里不提。
**第二类是网络文件系统**,可以统称为 NAS如上面的 NFS、FTP、SambaCIFS、WebDAV都只是 NAS 存储不同的访问接口。
**第三类是数据库**,它通常用于存储结构化数据,比较少作为文件型存储。但也有人在这么做,如果单个文件太大,会切成多个块放到多行。
**第四类是 SAN**,它是块存储。块存储和关系型存储、文件型存储都不同,它模拟的是硬盘,是非常底层的存储接口。很少会有应用直接基于块存储,更多的是 mount 到虚拟机或物理机上,然后供应用软件需要的存储系统使用。
**第五类是分布式文件系统 GFS/HDFS**。GFS 最早是为搜索引擎网页库的存储而设计,通常单个文件比较大,非常适合用于日志类数据的存储。这也是为什么 Hadoop最后从大数据领域跑出来原因就是因为大数据处理的就是日志。
你可以看到,除了数据库和 SAN我们不用细分析就知道它们不是文件型存储的最佳选择其他几类包括本地文件系统、NAS、GFS/HDFS 有一个共同特征就是它们的使用接口都是文件系统FileSystem
那么我们就来看下文件系统FileSystem对于大规模的文件型存储来说有什么问题。
最大的问题是文件系统是一棵树Tree。除了对单个文件的操作只需要锁住该文件外所有对树节点的修改操作比如把 A 节点移到 B 处,都是一次事务操作,需要锁住整棵树。
这对规模和并发吞吐能力都是伤害。从规模来说,分布式事务是很难的(这也是为什么分布式数据库很难做的原因),做出来性能也往往好不到哪里去。从并发吞吐能力来说,如果系统存在大锁,即在锁里面执行费时的操作,就会大幅降低系统的并发吞吐能力。
传统的 NAS 出现比较早,所以它没有考虑“大规模条件下存储会有什么样的挑战”是非常正常的。
GFS/HDFS 为什么没有考虑大规模问题?这是 Google 设计 GFS 的背景导致的,网页库存储,或者日志型存储的共同特征是单个文件很大,可以到几个 G 级别,这样的话文件系统的元数据就会减少到单台机器就可以存储的级别。
所以对象存储出现了。它打破了文件型存储访问接口一定是文件系统FileSystem的惯例。它用的是键值存储Key-Value Storage
从使用接口来说首先选择文件所在的桶Bucket它类似于数据库的表Table只是一个逻辑划分的手段然后选择文件的键Key就可以存取文件了。
这意味着文件之间并不存在关联(树型结构是文件之间的一种关联),可以通过某种算法将文件元信息分散到不同的机器上。
那么为什么文件型存储不必考虑文件之间的关联因为关系都在数据库里面文件型存储只需要负责文件内容的存储有个键Key能够找到文件内容即可。
从本质上来说,这是因为服务端和桌面软件面临的用户场景是完全不同的。文件系统是在桌面软件下的产物,桌面系统是单用户使用的,没有那么高的并发访问需求。
服务端一上来就面临着并发访问的问题,所以很早就出现了数据库这样的存储中间件。数据库的出现,其实已经证明文件系统并不适合服务端。只不过因为文件型存储在早期的服务端开发的比重并不大,所以没有被重视。
但是,互联网的发展极大地加速了文件型存储的发展。互联网增加的 90% 以上的数据,都是非结构化数据,包括图片、音频、视频、日志。
对象存储能够支撑的文件数量规模上非常非常大。比如七牛云存储,我们已经支持万亿级别的文件。
这在传统 NAS 这种基于文件系统访问接口的存储是难以想象的,我们看到的 NAS 存储 POC 测试要求,基本上都是要能够支持 1-2 亿级别的文件存储规模。
另外,对象存储的高速发展,很大程度上会逐步侵蚀 Hadoop 生态的市场。因为 HDFS 这种日志型存储,其实只是对象存储里面的一个特例。在人们习惯了对象存储后,他们并不希望需要学习太多的存储系统;所以大数据的整个生态会逐步过渡到以对象存储为基石。
这已经发生了。这两年你可能也能够听到Hadoop 生态的公司活得挺不好的,几家公司合并了也没有解决掉没落的问题。这和大数据生态向对象存储迁徙是分不开的,只不过这方面我们国内还处在相对比较落后的阶段。
## 案例分析
通过对打造“互联网”和存储新兵“对象存储”这两个案例的分析,我们可以看出不同市场差异还是很大的。“互联网” 这个产品它并不是替换某种既有的方案,而是把既有的方案连接在一起。所以 “互联网” 的历史包袱很少,基本上不太需要考虑历史问题。
“对象存储” 产品则不同。在对象存储之前,存储已经经历了很长时间的发展。只不过因为文件型的数据爆发式的增长,带来了存储系统的新挑战,从而给对象存储这样的新技术一个市场机会。
当然,另外一个原因是云服务的诞生,让存储有了新的交付形态。我们不再需要拿着硬件往用户家里搬,这就出现了一个新的空白市场。
但是解决了空白市场的需求后,对象存储还是要面临 “既有市场中用户采用的老存储方案怎么搬迁” 的问题。所以存储网关这样的产品就出现了。存储网关做什么?简单说,就是把对象存储包装成 NAS提供 NFS、FTP、SambaCIFS、WebDAV 这些访问接口给用户使用。
## 结语
需求分析相关的讨论就到此结束了。不同市场差异非常大,并不存在大一统的产品定义和市场策略,需要具体问题具体分析。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲将是我们第一章的回顾与总结。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,145 @@
<audio id="audio" title="19 | 基础平台篇:回顾与总结" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ef/82/efa787b542854d92be4d8100a9426882.mp3"></audio>
你好,我是七牛云许式伟。
到今天为止,我们第一章 “基础平台篇” 就要结束了。今天,让我们对整章的内容做一个回顾与总结。
## 抽象信息世界的骨架
基础平台篇主要涉及的内容如下。
<img src="https://static001.geekbang.org/resource/image/68/e6/68f2c948ff8c329ceb8b5fe76e34eee6.png" alt="">
这些内容如果展开来讲,每一系统(或模块)都会是很厚的一本书。我们的目的,当然不是为了取代这里每一个领域知识相关的专业书籍。
我们的核心目标是以架构为导向,抽象出系统的骨架,融会贯通,把这些领域知识串起来,拼出完整的信息世界的版图。
抽象出系统骨架的过程时信息必然是有损的,怎么才能做到忽略掉众多的实现细节,把系统以简洁易于理解的方式呈现出来?
这很大程度取决于你对系统的理解程度和抽象能力。如果我们把系统想象成一个人,大部分情况下我们比较容易对其进行详尽而具体的描述,好比下图。
<img src="https://static001.geekbang.org/resource/image/7d/57/7d0bf49d1cc2a1bc20964d694b67b257.png" alt="">
这相对容易。因为你只需要陈述你看到的事实,而不必拷问背后的原因。但实际上为了在最短的时间里让别人理解你的想法,你也许应该这样来描述它,见下图。
<img src="https://static001.geekbang.org/resource/image/d4/b3/d4557d1a21a2a017ce317ab8e6d465b3.png" alt="">
当你不是在描述这个系统本身,而是描述它与其他系统的相互关系时,你可能需要进一步简化它,变成如下图这样。
<img src="https://static001.geekbang.org/resource/image/11/bc/111cbf1adcb5effdb836979c7e44a3bc.png" alt="">
**抽象有助于记忆,因为骨架需要逻辑的自洽。**
这种抽象能力之所以重要,是因为它是融会贯通、疏通整个信息世界的知识脉络的关键。当你做到对世界的认知可宏观、可微观,自然一切皆在掌握。
比如,本章我们首先介绍的是冯·诺依曼体系结构,我们把它抽象为“**中央处理器CPU+ 存储 + 一系列的输入输出设备**”,并给出了系统的示意图如下。
<img src="https://static001.geekbang.org/resource/image/28/a9/28ef9c0241c5c34abb85148453379fa9.png" alt="">
这个图相当笼统并没有涉及中央处理器CPU指令设计的真正细节。比如我们没有介绍栈stack这个概念虽然它实际上也非常关键。
为什么需要引入栈?它在中央处理器中起到了什么样的作用?
要了解这个问题,你就需要深入到中央处理器的架构设计中去。如果你对梳理中央处理器的架构设计感兴趣,可以尝试写一篇介绍它的文字。
做这样的事情会对你非常的锻炼。**“你自己理解一个事物”和“把你的理解表述成文,去引导其他人也能够理解它”**,是完全不同难度的事情。
如果你对中央处理器的设计细节感兴趣,可以进一步查阅相关的参考资料。也欢迎与我分享你的心得体会。
## 基础平台篇的内容回顾
这一章前面我们讲了些什么?为了让大家对第一章内容有个宏观的了解,我画了一幅图,如下。
<img src="https://static001.geekbang.org/resource/image/2c/32/2c8357bd303f229ac98b67bec6e31932.png" alt="">
**首先,我们介绍了冯·诺依曼体系结构。**从需求演进角度看,虽然我们信息科技发展日新月异,但是底层设计并没有发生过变化,非常稳定。从这一点来说,我们不能不佩服他们的远见。
**随后,我们介绍了编程语言的演进。**从汇编语言的诞生,出现了程序员这个新职业开始,此后编程语言的演进便进入高速发展期。
然而尽管语言很多但是编程范式的演进却并不剧烈。大家熟知的过程式、函数式、面向对象基本上能够把几乎所有的语言都囊括其中。Go 语言独树一帜地宣称自己是面向连接的语言,**我们着重对比了面向对象与面向连接思想上的差异。**
编程语言本身与业务架构的设计关联性不大,虽然模块规格的描述会借助语言的文法。**但是语言长期演进所沉淀下来的社区资源,是我们架构设计所依赖的重要基础。**充分利用好这些资源可以大大降低系统的研发成本。
**最后,我们开始聊操作系统。**从 UNIX =&gt; DOS =&gt; Windows/Mac/Linux =&gt; iOS/Android从用户交互、进程管理、安全管理等角度看操作系统的需求演变非常剧烈。
传统操作系统主要包含五个子系统:设备管理(包括存储设备、输入/输出设备、网络设备)、进程管理和安全管理。
输入/输出设备主要和交互有关,我们概要描述,基本上一笔带过。我会在后面 “桌面软件开发” 这一章再详加讨论。而服务端的交互比较简单,命令行基本上就满足需求,所以 “服务端开发” 一章我们不会再特意去展开。
**另外,操作系统的商业模式也发生了剧烈的变化。**
早期操作系统的营收模式以软件销售收入为主。但是从苹果的 iOS 开始,操作系统都无一例外地增加了以下三个模块:
- 账号Account
- 支付Pay
- 应用市场AppStore
<img src="https://static001.geekbang.org/resource/image/d6/b7/d608db3b28f247ccb2886cc4e8cd99b7.jpg" alt="">
注意,这里我们说的账号是指互联网账号。传统操作系统虽然也有账号概念,但是,它是本地账号,属于多用户权限隔离所需。
而互联网账号的价值完全不同,它是支付和应用商店的基础。没有账号,就没有支付系统,也没有办法判断用户是否在应用市场上购买过软件。
实现了“**帐号-支付-应用市场**”这样的商业闭环,意味着操作系统的商业模式,从软件销售转向了收税模式。这类操作系统,我们称之为现代操作系统。所有现代操作系统,所凭借的都是自己拥有巨大的流量红利。
## 基础平台篇的参考资料
概要回顾了我们 “基础平台篇” 的内容后,我们这里补充一下有助于理解我们内容的相关资料,如下。
<img src="https://static001.geekbang.org/resource/image/b2/22/b26278cc56017617fac8572b88224b22.png" alt="">
有了本专栏梳理的骨架,相信对你学习和理解以上这些材料会一定的指引意义。
如果你有什么推荐的优秀参考资料,也欢迎在留言区分享,我补充到这个表格中来,我们一起来完善它。
## 架构之美在于悟
信息世界是无中生有创造出来的,我们不需要去记忆,而是要找到创造背后的骨架和逻辑。
**架构即创造。**
学架构在于匠心和悟心。它靠的是悟,不是记忆。**用思考的方式去记忆,而不是用记忆的方式去思考。**
我们日常所依赖的基础平台,随处可见的架构之美,**看到了,悟到了,就学到了。**如果你只能从你自己写业务代码中感受架构之道,那么你可能就要多留些心思了。
比如,如果你日常用的是 Go 语言,那么你可以做一个作业:“谈谈 Go 语言之美”。你从Go语言的设计中感悟到了什么样的架构思维当然如果你不常接触 Go 语言,可以给自己换一个题目,比如 “Java 语言之美”。
**作为架构师,如何构建需求分析能力,尤其是需求的预判能力?**
**首先,归纳总结能力很重要。**分析现象背后的原因,并对未来可能性进行推测。判断错了并不要紧,分析一下你的推测哪些地方漏判了,哪些重要信息没有考虑到。
**另外,批判精神也同样至关重要。**批判不是无中生有的批评,而是切实找到技术中存在的效率瓶颈和心智负担。尤其在你看经典书籍的时候,要善于找出现状与书的历史背景差异,总结技术演进的螺旋上升之路,培养科学的批判方法论。
## 结语
今天我们对本章内容做了概要的回顾,并借此对整个基础平台的骨架进行了一次梳理。
我们最为依赖,也最为强调的,是抽象能力。它对于构建信息世界的骨架至关重要。为此我们需要不断改造自己的抽象体系。例如,前面 “[02 | 大厦基石:无生有,有生万物](https://time.geekbang.org/column/article/91007)” 这一讲中提到过:
>
引入了输入输出设备的电脑,不再只能做狭义上的“计算”(也就是数学意义上的计算),如果我们把交互能力也看做一种计算能力的话,电脑理论上能够解决的“计算”问题变得无所不包。
有同学留言问:输入/输出设备提供的明明是一种 IO 能力,怎么能够算得上是“计算”?
但是实际上,我们人类其实就是在这种“否定自己,不断延展自己的抽象体系”,补全自己的想象力。我们以数学中最为基础的 “数” 为例子。数的演化大概经历了:
>
自然数 =&gt; 整数 =&gt; 有理数 =&gt; 实数 =&gt; 复数
**输入/输出能力算不算是“计算”?我们不妨以广义的“计算”角度来看。**
输入Input无非是采集物理世界的信息将其数字化所以一个输入设备其实可以看作是一个模数转换的“算子”。只不过这个算子非 CPU 的指令可以表达。
输出Output无非是将数字内容反作用于物理世界一个输出设备其实可以看作是一个数模转换的“算子”。同样这个算子非 CPU 的指令可以表达。
计算机 CPU 自身只能做数数转换,输入是比特信息,输出还是比特信息。结合了输入/输出设备提供的数模和模数转换的 “算子”,连接了数字世界和物理世界的计算机,在数学上也就完备了。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。本章到此结束,我们将开始第二章:桌面开发的宏观视角。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
### 限时放送
推荐阅读专栏《Go语言核心36讲》正在拼团中限时特惠79元点击[链接](https://time.geekbang.org/column/intro/112)订阅专栏。