上层应用开发的多了之后,对底层技术的接触就越来越少了。以至于很多人有了“底层技术无用论”的观点。很多人认为学习框架多好啊,大家都在用,跳槽的时候也能用得上。学习那些底层技术干啥,平时都用不到。
本号并不这么认为。我们先举一个活生生的例子,比如我们现在有个Web服务应用,崩溃重启后在绑定套接字的时候出现报错(socket_bind():unabletobindaddress[98]:Addressalreadyinuse。),导致服务端无法工作。问题比较明确,是地址(端口)被占用了。你这时候可能会猜测那个程序占了端口呢?大家都清楚,服务器端口的使用都是严格受限的,肯定是这个程序。但是可能会疑惑:“这个程序不是刚刚起来吗?!”如果你只是使用API,不懂得底层的原理,别说解决问题,可能都不知道如何下手。这个问题我们先放到这里,后面再具体解释,这里只是想说明一下底层技术的重要性。
另外一个比较典型的例子是关于前端开发的。很多人热衷于学习各种框架。框架虽然能帮助我们解决一些问题,节省开发成本并降低开发周期。但是,学习框架并不能掌握技术的根本,从而导致自己能力没有本质的提升。我们以前端框架为例,在过去的几年当中,JQuery、Bootstrap、Angular和Vue等等等等,轮番上阵。这个框架你还没用熟悉呢,结果又来了一个新的框架,让你应接不暇。而这些框架最本质的东西其实就是JS、CSS和HTML等内容,只有学会这些基础技术,才能游刃有余。如果这些基础技术不熟悉,而投入大量精力学习框架,这就好像还没学会走,就想着跑,最后自己可能摔得满头是包。
可能扯的有点远,前面的例子只是想告诉大家底层技术的重要性。对于我们搞软件开发的人来说,底层技术其实相当于大厦的地基,地基不稳,大厦是很危险的。当然,计算机技术的细分领域很多,每个领域又有自己的底层技术,因此我们不可能都有涉及。今天我们介绍的底层技术则是最为通用的技术,也就是计算、存储、网络和数据结构与算法。
关于计算相关的内容
计算机技术自然核心是计算了。毫不夸张的说,所有应用都要依赖于计算,小到单机小游戏,大到电商或者云计算平台。因此,计算问题自然是我们最为关心的问题了。说到计算,最主要的自然是程序的性能了,如果我们开发的程序的性能提升一倍,就相当于硬件成本降低了50%。对于互联网这种需要大量计算资源的应用,其价值可见一斑。
我们先看一个具体的例子。下面是一段C语言的代码,代码很简单,就是将二维数组中的内容做加一操作。但是如果你测试一下两段代码的耗时的话,就会发现两者有四倍的性能差异。大家可以观察一下图中两端代码的差异,并思考一下为什么有如此之大的差异。
问题先放一下,我们回到我们今天的主角,CPU。CPU是计算依赖的硬件,大家都知道计算是在CPU内完成的。我们先看一下CPU长什么样。CPU是计算机的核心单元,它负责从存储设备读取数据,经过计算后将生成的新数据再存储起来。这就好像一个大型工厂的生产车间,将原材料加工成半成品或者成品(我们后面单独用一个章节介绍CPU相关的内容)。
了解了CPU的基本功能,我们再解剖了看看它的五脏六腑长什么样。下图是一个简化的CPU内部结构图,最为核心的组件就是计算单元(ALU)、寄存器(很多寄存器)和高速缓存。另外就是通过总线接口与外部的内存进行连接。这里面最核心的组件就是ALU了,其原理很简单,就是完成加减乘除运算。
CPU要进行运算,就需要原料,而原料需要从内存搬运。有一个事实我们需要记住,就是访问内存的代价(延时)是访问寄存器的倍左右。最早的CPU是直接访问内存的,后来随着ALU性能的提升,发现有问题,就在ALU和内存之间增加了缓存。现代CPU缓存通常为3级缓存,分别是L1、L2和L3,其中L1和L2是CPU核独有的,而L3是同一颗CPU的多核共享的。其基本的架构如下图所示。
这里面有个关键问题是缓存的容量是远远小于主(内)存的容量的,因此,缓存中的数据通常是主存数据的很小的一部分。由于应用访问数据有区域局部性的特点,因此缓存中的数据通常是程序需要的数据,也就是ALU接下来要用的数据。另外一个需要注意的地方是从主存读取数据到缓存是有一定粒度(专业术语叫缓存行)的,当前处理器通常是64字节。如下图所示,主存中的内容被读取到缓存中。
然后,我们回到一开始的关于上面两段程序的性能问题来。上面代码中一个是逐行访问二维数组,另外一个是逐列访问二维数组。具体示意图如下图所示。
在逐行访问时,访问的地址是以4字节为单位跳跃的,由于缓存行大小是64字节,因此很容易命中缓存。而逐列访问时,每次跳跃字节,远远超越了缓存行的大小,从而导致数据大部分是从内存读取的。也正是因为这个,导致两个程序有四倍的性能差异。
通过上面的介绍,我们应该记住两个关键点,一个是访问内存的代价比较高,因此在编程时尽量减少对内存的直接访问;另外一个是充分利用缓存的优势。关于如何做到上面两点,具体细节我们后续专门介绍。
关于存储相关的内容
数据最终都要存储在存储设备上,否则系统一断电所有东西都丢了,这个道理大家都懂。这里的存储包括磁盘和SSD硬盘等内容。本文主要从存储设备及管理设备的文件系统分析存储相关关键技术。存储中最为重要的有两个方面,一个是存储数据的可靠性,另外一个是存储数据的性能。
本文先从存储的性能说起,可靠性我们后续专门介绍。在存储领域使用最多的还是普通机械磁盘。机械磁盘的内部解剖图如下图所示,其数据的读写是通过一个机械臂完成的。机械臂摆来摆去,想想就知道不会太快。机械磁盘是IBM发明的,第一块磁盘的寻道时间(机械臂定位到目的位置的时间)在毫秒左右。而现代的机械磁盘寻道时间有了比较明显的改善,但由于其机械特性的原因,其耗时还是比较长的,大概是4-8毫秒的样子。
这个耗时是内存的近10万倍,是寄存器耗时的千万倍。因此机械磁盘的速度相对内存来说,无异于蜗牛对高铁的速度。鉴于机械磁盘的上述缺陷,在软件层面做了很多考量,从而保证性能最佳。
我们通常在使用硬盘的时候不会直接写代码访问(不排除个例),而是通过操作系统提供的接口访问。这个操作系统的接口通常是文件系统的接口。为了便于理解,我们先看一下对于Linux操作系统来说,磁盘系统的整个软硬件栈,从上到下分别是:文件系统、通用块层、设备驱动层和设备层(具体的硬件设备,可以理解为磁盘)。
在这里有两个层面的软件对磁盘的访问做了优化,一个是文件系统,另外一个是通用块层。其中文件系统的核心功能是磁盘数据管理的功能,但考虑到磁盘的缺点,因此在读写数据方法做了一些性能方面的优化。而通用块层则主要是针对磁盘的特性进行了各种优化。
文件系统对磁盘访问的性能优化是通过页缓存(页缓存其实就是内存)完成的,这个页缓存与CPU中的缓存有异曲同工之妙。文件系统通过页缓存在数据写和读两方面分别作了优化。
写方面的优化主要是延迟批量写,也就是数据先写到页缓存中,经过积累后再磁盘驱动提交。这种积累和延迟写主要目的是为了增加数据的连续性,也就是为了规避磁盘机械臂的摆动,因为磁盘机械臂摆动是最耗时的。
读方面的优化主要是预读功能,预读就是根据当前应用读取数据的模式,提前将数据读到内存当中。由于应用访问数据的区域局部性特点,这种预读就可以避免应用直接从磁盘读取数据的延时,从而提高读性能。
通用块层的主要作用是针对磁盘做IO调度,通俗的讲就是决定哪个IO先发送到磁盘,哪个后发送到磁盘。
针对机械磁盘来说,最为重要的就是通用块层会进行IO的重排序(根据逻辑地址排序)。如上图所示,假设上层应用按时间顺序发送1、2、3、4和5等5个请求的时候。此时,通用块层并不会按照时间顺序发送给磁盘,而是按照图中红色虚线箭头的顺序(1、5、2、4、3)发送给我。这样,磁盘的机械臂就不用来回摆动,从而大大提升其性能。
其实说了半天,这里有一点是需要我们注意的,那就是机械磁盘不善于处理IO地址差异比较大的请求(会导致机械臂频繁摆动),这是我们在做架构设计的时候需要注意的。虽然操作系统和通用块层为我们做了很多工作,但其能力毕竟有限,因此我们在设计的时候也必须考虑。后面我们会通过实例给大家介绍大牛公司在设计应用的时候是如何考虑的。
未完待续......