一个计算机系统是由一起协作来运行应用程序的硬件和系统软件组成。虽然计算机系统的具体实现随着时间而变化,但是底层的概念是不变的。所有的计算机系统都有着相似的硬件和系统软件等组成部分,这些硬件和系统软件执行着相似的功能。
这本书是为希望通过了解硬件和系统软件如何工作、如何影响程序的正确性和性能来提高自己技能的程序员而写的。
你要为这个令人激动的旅行做好准备。如果你致力于学习本书中的概念,那么你将受对底层计算机系统的理解及这种理解对应用程序的影响所启发,踏上成为稀有的程序员之路:
- 你将学习到一些实用技能,比如如何避免由计算机表示数的方式引起的奇怪的数值错误等;
- 你将学习到一些优化C代码的技巧,比如如何利用现代处理器和存储系统的设计等来优化C代码的技巧;
- 你将学习到编译器如何实现函数调用,及如何利用这个知识来避免因缓冲区溢出导致的安全漏洞;
- 你将学习到如何识别和避免在链接过程中出现的令人讨厌的错误;
- 你将学习到如何编写自己的Unix shell脚本、动态内存分配包以及web服务器等;
- 你将学习到并发的优点和缺点;
图1.1展示的是在C语言的经典教材K&R
里,Kernighan和Ritchie使用的hello
程序。
我们的系统研究之路是通过追踪hello
程序的生命周期开始的:从创建hello
程序开始,直到该程序在系统上运行,输出简单的消息,终止结束。
沿着该程序的生命周期,我们会简要介绍一些会用到的核心概念、术语、组成部分等。在后面的章节里,我们会拓展这些思想。
第1.1节 信息=位+上下文
程序hello
以源文件hello.c
开始它的生命。
源文件hello.c
是由位序列组成,每个位的值要么是0,要么是1。每个字节表示源程序中的一个文本字符。
大部分计算机系统使用ASCII标准来表示文本字符,ASCII标准使用一个唯一的、字节大小的整数值来表示一个字符。图1.2展示了hello.c
的ASCII表示。
hello.c
是以字节序列形式保存在一个文件里的。每个字节有一个整数值,该整数值对应一个字符。比如,第一个字节的整数值是35,对应字符#
。第2个字节的整数值是105,对应的字符是i
。注意,每个文本行以不可见的新行符\n
结尾,新行符\n
对应的整数值是10。
诸如hello.c
等由大量ASCII字符组成的文件叫文本文件,所有其他文件叫二进制文件。
源文件hello.c
的表示展示了一个基本思想:在一个系统里的所有信息都用bit位来表示,比如磁盘文件、存储在内存中的程序、存储在内存中的用户数据以及跨网络传输的数据等。唯一可用来区分不同数据对象之间的是我们使用数据对象的上下文。比如,在不同的上下文里,相同的字节序列可表示一个整数、浮点数、字符串或者一个机器指令。
作为程序员,我们需要理解数字的机器表示,因为计算表示的数字跟数学上的整数和实数不一样。计算机表示的数字是对数学上的数的有限近似。我们将在第2章中详细地探讨这种基本思想。
第1.2节 源程序被其他程序转换成不同的形式
hello
程序以高级C程序开始,因为这种形式能被人类阅读和理解。但是,为了在系统上运行hello.c
,每条C语句首先必须被其他程序转换成低级的机器语言指令。然后将这些机器语言指令打包成可执行目标文件,保存在一个二进制磁盘文件里。
在Unix系统上,从源文件到目标文件的转换是受编译器驱动执行的:
linux> gcc -o hello hello.c
这里,GCC编译器驱动读取文件hello.c
,将它转换成可执行目标文件hello
。
从源文件到可执行目标文件要经历4个过程:
- 预处理阶段
预处理器cpp根据以#
号开头的预处理器指令来修改最初的C程序。比如,在hello.c
里第一行的#include<stdio.h>
告诉预处理器读取系统头文件stdio.h
里的内容直接插入源程序文本中。结果是另一个C程序hello.i
。 - 编译阶段
编译器cc1将文本文件hello.i
转换成文本文件hello.s
。hello.s文件中包含一个汇编语言程序,包括了main函数的定义:
1 main:
2 subq $8, %rsp
3 movl $.LC0, %edi
4 call puts
5 movl $0, %eax
6 addq $8, %rsp
7 ret
在这个定义里的第2-7行的每一行以文本形式描述了一个底层的机器语言指令。汇编语言非常有用,因为汇编语言为不同语言的不同编译器提供一个公共的输出语言。比如,C编译器和Fortran编译器都使用相同的汇编语言生成输出文件。
- 汇编阶段
汇编程序as将hello.s
转换成机器语言指令,按照可重定向目标程序的格式来打包机器语言指令,把结果存储在目标文件hello.o
中。hello.o
文件是一个二进制文件,包含了17个字节,用于main函数的指令编码。如果我们使用文本编辑器打开hello.o
文件,则会出现乱码。 - 链接阶段
注意到hello
程序调用了printf
函数,其中printf
函数是由每个C编译器提供的C标准库的一部分,驻留在一个单独的预编译号的目标文件printf.o
里。链接器处理的就是printf.o
文件跟hello.o
程序的合并。结果是hello
可执行目标文件:可被加载进内存,被系统执行。
图1.3展示了转换的四个阶段。
第1.3节 理解编译系统如何工作是值得的
对于像hello.c
之类的简单程序,我们可以依靠编译系统来产生正确且有效的机器码。但是,程序员需要理解编译系统是如何工作的,原因有3个:
- 优化程序性能
现代编译器是可以产生优秀代码的先进工具。作为程序员,虽然编写效率高的代码,并不需要知道编译器的内部工作机制。但是,为了在写C程序时能做出好的代码决策,我们需要能基本理解机器码,以及编译器是给如何将不同的C语句转换成机器码的。比如,switch语句总是比多条if-else语句的效率更高吗?一次函数调用的开销有多大?while循环比for循环更有效率吗?指针引用比数组索引更有效率吗?为什么我们的循环对局部变量求和比对按引用传递的实参求和运行得更快?当我们重排一个算术表达式的括号时,是如何让一个函数运行的更快的?
在第3章里,我们介绍X86-64(这是Linux、Macintosh、Windows等计算机近代的机器语言),描述编译器是如何将C语言的各种结构转换成x86-64机器语言。在第5章里,你将学习到通过对C代码做简单的变换来优化C程序的性能,这样做可以帮助编译器更好地做好自身的工作。在第6章里,你将学习到有关存储系统的分层本质、C编译器是如何在内存中存储数组数据的以及C程序是怎样利用存储系统相关知识来更有效率地运行。 - 理解链接期错误
根据经验,一些很复杂的编程错误通常跟链接器的操作有关,尤其是当你在尝试构建大型软件系统时。比如,当链接器报告不能解析一个引用时,这意味着什么?如果你在不同的C文件里定义了两个相同名字的全局变量,则会发生什么?静态库和动态库之间的区别是什么?为什么我们在命令行上列出的库的顺序会有关系?最可怕的是,为什么有些跟链接相关的错误会直到运行时才出现?你将在第7章里找到这些问题的答案。 - 避免安全漏洞
多年以来,缓冲区溢出已成为在网路服务器和因特网服务器上发生的许多安全漏洞的原因。这些漏洞之所以存在,是因为很少有程序员能认识到有必要仔细地限制从未授信的来源接收的数据的量和数据形式。学习安全编程的第一步就是理解数据和控制信息在程序栈中的存储方式会有哪些后果。作为我们研究汇编语言的一部分,我们将在第3章里覆盖程序栈和缓冲区溢出漏洞等主题。我们也将学习到被程序员、编译器、操作系统用来降低攻击威胁的方法。
第1.4节 处理器读取和解释存储在内存中的指令
现在,我们的hello.c源程序已被编译系统转换成存储在磁盘上的可执行目标文件hello。为了在Unix系统上运行hello,我们在shell程序里键入它的名字:
linux> ./hello
hello, world
linux>
Shell程序是一个命令行解释器:输出一个提示符,等待着你在命令行上键入命令,然后执行命令。如果命令里的第一个词并不对应一个内置的shell命令,则shell会假设它是一个应该被shell程序加载和运行的可执行目标文件的名字。所以,在这种情况下,shell程序就加载和运行hello程序,然后等着hello程序运行结束。hello程序在屏幕上输出消息后结束运行。shell程序然后输出一个提示符,等待着下一个命令行输入。
第1.4.1小节 系统的硬件组织
为了理解运行hello程序时发生了什么,我们需要理解图1.4展示的典型系统的硬件组织。虽然该图是根据最新的Intel系统绘制的,但是所有的系统是相似的。现在别担心这张图有多复杂。我们会在全书的课程里分阶段介绍这张图的细节。
总线
总线是连接整个系统的电路集合,这些总线电路在组件间来回传输字节信息。总线常被设计成传输固定大小的字节块(称为字word)数据。在一个字中的字节数是一个基本的系统参数,该参数在不同的系统中是不同的。今天的大部分机器的字长要么是4字节(32位),要么是8字节(32位)。在本书中,我们不对字长大小做固定假设,而是在需要定义字长的上下文里定义字长是什么。
输入输出设备
输入输出设备连接的是系统跟外部世界。在我们的示例系统里有4个输入输出设备:键盘、鼠标、显示器、磁盘等。最初,可执行程序hello驻留在磁盘里。
每个输入输出设备要么通过控制器、要么通过适配器连接到I/O总线上。适配器和控制器的主要区别就是集成方式不一样。控制器是设备自身上的芯片组或者系统主板上的芯片组。适配器是可插入主板卡槽的一张卡。无论如何,两者的功能都是在I/O总线和I/O设备间来回传输信息。
第6章会对磁盘等设备如何工作做更多的介绍。在第10章里,你将学习到如何使用Unix的I/O接口来从应用程序中访问设备。虽然我们重点关注网络相关的一类设备,但是相关技术也可以推广到其他类型的设备。
主内存
主内存是一种临时存储设备:当处理器执行程序时,主内存同时持有程序和程序操作的数据。从物理上看,主内存由DRAM芯片组成。从逻辑上看,主内存被组织成字节数组,其中每个字节有从0开始的唯一索引。通常,构成程序的每条机器指令可由可变数量的字节组成。C程序变量对应的数据项的大小是根据类型而变化的。比如,在一台运行着Linux的X86-64的机器上,short类型需要2个字节,int类型需要4个字节,long类型需要8个字节。
第6章会详细描述DRAM芯片等存储技术如何工作,以及如何整合这些存储技术形成主内存等。
处理器
处理器是一个执行保存在主内存中的指令的引擎,其核心是程序计数器。程序计数器是一个存储单元是字大小的存储设备。在任何一个时间点,程序计数器都指向在主内存中的某个机器指令。
从系统加电到电源关闭,处理器重复执行由程序计数器指向的指令,更新程序计数器的值为下一条指令的地址。处理器似乎是根据由指令集架构定义的非常简单的执行模型来运作的。在这种模型里,指令按照严格的顺序来执行,执行单条指令可能需要若干步:
- 处理器从程序计数器指向的内存处读取指令;
- 解释指令中的位;
- 根据指令的指示执行简单的操;
- 然后更新程序计数器的值指向下一条指令的地址,注意下一条指令的地址可能不跟先前执行的指令在内存中是连续的。
存在一些只围绕主内存、寄存器文件、ALU运行的简单指令。寄存器问文件是一个小型设备,由存储单元是字大小的寄存器组成,每个寄存器都有唯一的名字。ALU用来计算新的数据和地址值。这里有一些CPU执行的简单操作示例:
- Load操作
从主内存中拷贝一个字节或者一个字到寄存器,副作用是会覆盖先前在寄存器中的内容。 - Store操作
从寄存器中拷贝一个字节或者一个字到主内存中的位置,副作用是会覆盖那个位置先前的内容。 - Operate操作
拷贝两个寄存器的值到ALU中,对这两个字执行算术操作,把结果存储到某个寄存器中,副作用是会覆盖那个寄存器先前的值。 - Jump操作
从指令中抽取一个字,拷贝该字到程序计数器中,副作用是会覆盖PC中先前的值。
虽然我们说处理器似乎是指令集架构的一个简单实现,但实际上现代处理器使用了更复杂的机制来加速程序执行。因此,我们可以要区分处理器的指令集架构和微架构,其中指令集架构描述了每个机器指令的作用,微体系架构描述了处理器实际上是如何实现的。当我们在第3章里研究机器码时,我们将会考虑由机器的指令集架构提供的抽象机制。第4章会对处理器实际上是如何实现的做更多的介绍。第5章描述了一个处理器是如何工作的模型,该模型使得预测和优化机器语言的程序的性能成为可能。
第1.4.2小节 运行hello程序
有了系统硬件组织和操作的简单视图,我们就能理解运行hello程序时发生了什么。我们必须省略许多细节留待后面来补充,但是现在我们将对整体情况感到满意。
- 最初,shell程序在执行指令,等待着我们键入命令。如图1.5所示,随着我们键入字符
./hello
,shell程序将每个字符读入一个寄存器,然后把字符存储在内存中。 - 当我们敲击enter键时,shell程序知道我们已完成命令的键入。然后,shell程序执行一系列指令,将hello可执行目标文件里的代码和数据从磁盘拷贝到内存。包含
hello, world\n
的数据最终将被打印输出。
如图1.6所示,可使用DMA技术将数据直接从磁盘传输到内存,不需用经过处理器。
如图1.7所示,一旦hello目标文件的代码和数据被加载到内存,处理器就开始执行在hello程序里的main函数里的机器指令。这些指令将字符串hello, world\n
中的字节从内存拷贝到寄存器文件,然后从寄存器文件拷贝到显示器上,在显示器上输出该字符串。
第1.5节 缓存相关主题
从前述简单示例中得到的一个重要教训就是:系统花大量的时间将信息从一个地方移动到另一个地方。比如,hello
程序的指令移动过程:
- 最初,
hello
程序的机器指令是保存在磁盘上的。 - 当
hello
程序被加载时,hello
程序里的机器指令被从磁盘拷贝到内存。 - 当处理器开始运行
hello
程序时,再把hello
程序里的机器指令从内存拷贝到处理器里。
hello
程序的数据移动过程:
- 最初在磁盘上的字符串
hello,world\n
数据被拷贝到内存; - 然后从内存拷贝到显示设备。
从程序员的角度看,这类拷贝中的大部分都是减慢了hello
程序实际运行的开销。因此,系统设计者的一个主要目标就是使这类拷贝操作尽可能地快。
根据物理定律:越大的设备比越小的设备慢;越快的设备比越慢的设备更贵。比如,在一个典型系统上的磁盘驱动要比内存慢1千倍,处理器从磁盘上读取一个字花费的时间要比从内存中慢1千万倍。类似的,一个典型的寄存器文件只能存储几百字节的信息,而内存则可以存储上百亿字节。处理器从寄存器文件里读取数据要比内存中快1百倍。更糟的是,随着半导体技术近年来的进步,这种处理器-内存的差距在不断加大。使处理器运行更快要比使内存运行更快来的更容易、成本更低。
为了处理处理器跟内存之间的差距,系统设计者引入了高速缓存,这些更小更快的设备充当的是处理器可能在将来需要的信息的临时暂存区。
图1.8展示了一个典型系统中的高速缓存。
- L1缓存在处理器上,可持有数万个字节,访问速度跟寄存器文件几乎一样快。
- L2缓存可持有数十万到数百万字节,通过特殊的总线连接到处理器的。
- 处理器访问L2缓存的时间要比L1缓存要长5倍,但要比内存快5到10倍。
- L1缓存和L2缓存是通过SRAM硬件技术实现的。
- 更新的、功能更强大的系统甚至有3级缓存:L1、L2、L3。
高速缓存背后的思想是:利用局部性(程序倾向于访问在局部区域的数据和代码的趋势)来实现大且快的内存。通过设置高速缓存来可能经常被访问的数据,我们可使用高速缓存来执行大部分内存操作。
本书的重要教训之一是了解高速缓存的程序员可以利用高速缓存来将程序的性能提升一个数量级。你将在第6章里学习到更多有关高速缓存的内容,以及如何利用高速缓存等。
第1.6节 存储层次结构
在处理器和一个更大、更慢的设备(比如内存)之间插入一个更小且更快的设备(比如高速缓存)这种想法被证明是通用的。事实上,每个计算机系统的存储设备都被组织成类似图1.9的存储层次结构。
- 从上到下,设备是越来越慢、越来越大、越来越便宜。
- 寄存器文件处在层次结构的最顶端,称作L0,占据层级0。
- 我们展示了3级缓存L1到L3,占据存储层级1到3。
- 内存占据层级4,以此类推。
存储层级结构的一个主要思想是:某一级的存储可以充当下一级存储的缓存。因此,
- 寄存器文件是L1缓存的缓存。
- 缓存L1和L2分别是缓存L2和缓存L3的缓存。
- L3缓存是内存的缓存。
- 内存是磁盘的缓存。
- 在一些具有分布式份文件系统的联网系统里,本地磁盘是存储在其他系统里的数据的缓存。
正如程序员可以利用高速缓存的知识来提升性能一样,程序员也利用利用对整个存储层次结构的理解来提升性能。第6章会对整个存储层次结构做更多的介绍。
第1.7节 操作系统管理着硬件
回到hello示例。当shell程序加载和运行hello程序时,当hello程序打印消息时,shell程序和hello程序都没有直接访问键盘、显示器、磁盘和内存。这两个程序都依赖操作系统提供的服务。
如图1.10所示,我们可以把操作系统看做是一个软件层,位于应用程序和硬件之间。由应用程序作出的所有操作硬件的尝试都必须经过操作系统。
操作系统主要有两个目标:
- 保护硬件不受失控应用的滥用;
- 为应用提供一个简单且一致的机制来操作复杂且通常差异很大的底层硬件设备;
操作系统通过如图1.11中所示的抽象机制来实现前述两个目标:进程、虚拟内存、文件等。
- 文件是对I/O设备的抽象;
- 虚拟内存是对内存和磁盘I/O设备的抽象;
- 进程是对处理器、内存、I/O设备的抽象。
下面,我们依次来讨论。
第1.7.1节 进程
当类似hello
的程序在现代系统上运行时,操作系统提供了一种错觉:
- 该程序是在系统上唯一运行的程序;
- 该程序似乎独占处理器、内存和I/O设备;
- 处理器似乎在不间断地依次执行程序中的指令;
- 程序中的代码和指令似乎是系统内存中唯一的对象;
注意:这些错觉都是由进程的概念提供的,进程的概念是计算机科学中最重要且最成功的思想之一。
一个进程是操作系统对一个运行程序的抽象。多个进程可在同一个系统上并发运行,每个进程似乎独占系统硬件。这里说的并发的意思是:一个进程的指令可以和另一个进程的指令交叉执行。
在大部分系统里,运行的进程要比运行这些的进程的CPU个数多。传统的系统一次仅能执行一个程序,而较新的多核处理器可同时执行多个程序。无论哪一种情况,通过在多个进程之间切换执行,单个CPU似乎能并发执行多个进程。操作系统是通过上下文切换机制来实现多进程交叉执行的。
为了简化接下来的讨论,我们仅考虑包含单个CPU的单处理器系统。我们将会在第1.9.2节重回到对多处理器的讨论。
操作系统会记录追踪运行一个进程所需的所有状态信息,即上下文信息,包括PC寄存器的当前值、寄存器文件、内存的内容等信息。在任何时刻,单处理器系统仅能执行一个进程的代码。当操作系统决定将控制权交给某个新的进程时,操作系统就执行一次上下文切换:保存当前进程的内容、还原新进程的上下文、将控制权交给新进程。新进程从上次被挂起的地方开始执行。
图1.12展示了示例hello程序场景的基本思想。
如图1.12所示,从一个进程到另一个进程的切换是受操作系统内核管理的。内核是操作系统代码中常驻内存的部分。当应用需要操作系统提供的某个操作时,比如读写文件等,应用会执行特殊的系统调用指令,将控制权交给内核。然后,内核执行被请求的操作,返回到应用程序。注意:内核不是一个单独的进程,而是操作系统用来管理所有进程的代码和数据结构的集合。
实现进程的抽象需要底层硬件和操作系统的紧密配合。我们将在第8章里探究进程抽象如何起作用,以及应用如何创建和控制自身的进程。
第1.7.2 线程
虽然我们通常认为进程是单个控制流,但是在一个现代系统里,一个进程实际上由多个执行单元(称作线程)组成,每个线程都在这个进程的上下文中运行,且共享同样的代码和全局数据。
由于
- 网络服务器对并发的要求;
- 跟多个进程间共享数据相比,在多个线程间共享数据更容易;
- 多线程比多进程更有效率;
等原因,多线程成为一个越来越重要的编程模型。
当有多个处理器可用时,多线程也是一种使得程序运行更快的方式,这一点我们将在第1.9.2节中讲到。你将在第12章里学习并发的基本概念,包括如何编写多线程程序等。
第1.7.3 虚拟内存
虚拟内存是一种抽象,为每个进程提供一种“该进程独占内存”的错觉。每个进程有一致的内存视图,即地址空间。
图1.13里展示了一个Linux进程的内存视图,即地址空间。
- 在Linux系统里,地址空间的最顶部区域是预留给所有进程都共享的操作系统的代码和数据。
- 地址空间的较低区域持有的是用户进程的代码和数据。
- 注意:在图中的地址是由下向上增长的。
每个进程看到的虚拟地址空间由若干个定义良好的区域组成,每个区域都有特定的用途。虽然你将在本书的后面章节里学习更多有关这些区域的知识,但是先从下向上粗略地看一下每个区域:
- 程序代码区和数据区
所有进程的代码都从相同的固定地址开始,接下来是对应全局C变量的数据位置。直接用可执行目标文件的内容来给代码区和数据区初始化。当我们在第7章里研究链接和加载时,你将学习到更多有关地址空间这一部分的知识。 - 堆
紧挨着代码区和数据区的是运行时堆。跟代码区和数据区不一样,堆会随着运行时对C标准库函数的调用(比如malloc
和free
)而动态增长和缩小。当我们在第9章学习有关虚拟内存管理时,我们将详细地研究堆。 - 共享库
靠近地址空间中部的区域是一个持有共享库(比如C语言标准库和数学库)代码和数据的区域。共享库是一个功能非常强大但有点难的概念。当我们在第7章中研究动态链接时,你会学习到动态链接库的工作原理。 - 栈
在用户虚拟地址空间的最顶部是用户栈,编译器使用栈来实现函数调用。跟堆一样,用户栈也能在程序执行时动态增长和缩小。特别地,每次我们调用一个函数,用户栈就增长。每次我们从一个函数中返回,用户栈就缩小。你将在第3章里学习到编译器如何使用用户栈。 - 内核虚拟内存
地址空间的最顶部区域是预留给内核的。应用程序不允许读或写这块区域的内容,不允许直接调用定义在内核代码中的函数。应用程序要触发内核让内核来执行这些操作。
为了让虚拟内存生效,需要硬件和操作系统之间的高级交互。基本思路是将一个进程的虚拟内存的内容存储在磁盘上,然后使用内存作为该磁盘内容的缓存。第9章会解释虚拟内存机制,以及虚拟内存对现代系统的运行很重要的原因。
第1.7.4小节 文件
一个文件不过就是字节序列而已。每个I/O设备(包括磁盘、键盘、显示器、甚至网络)都可抽象为一个文件。使用一小撮Unix I/O系统调用来读写文件,可实现在系统里的所有输入和输出操作。
文件概念是非常强大的,因为文件给应用提供了一个一致的看待所有各种各样的I/O设备的视图。比如,操作磁盘文件内容的程序员不需要了解具体的磁盘技术。而且,同样的程序可在使用了不同磁盘技术的系统上运行。你将在第10章学习有关Unix I/O的知识。
第1.8节 使用网络跟其他系统通信
截止目前我们的系统之旅,我们把系统当做是一个孤立的硬件和软件的集合体。在实践中,现代系统通常通过网络来跟其他系统连接。
如图1.14所示,从单个系统的角度来看,网络可看做是另一个I/O设备。
当系统从内存中拷贝字节序列到网卡时,数据通过网络流到另一台机器上,而不是流到本地的磁盘驱动。类似地,系统可读取从一台机器发出的数据,将这些数据从网卡拷贝到内存。
随着全球网络(比如因特网)的出现,从一台机器拷贝信息到另一台机器已成为计算机系统最重要的用途之一。比如,诸如电子邮件、即时消息、万维网、FTP以及telnet等应用都是基于这种通过网络拷贝信息的功能的。
回到我们的hello
示例,我们可使用熟悉的telnet应用来在一台远程机器上运行hello
。假设我们使用一个运行在我们本地机器上的telnet客户端连接上一台在远程机器的telnet服务器。在我们登录进入远程服务器和运行shell后,该远程shell就等着输入一个命令。
从现在起,远程运行hello程序就包括图1.15中的5个基本步骤。
- 在我们键入字符串"hello"到telnet客户端,敲击enter键后,客户端就将该字符串发送给telnet服务。
- telnet服务器在接收到来自网络的字符串后,就将其传递给远程shell程序。
- 接下来,远程shell运行hello程序,将输出结果传递给telnet服务器。
- 最后,telnet服务器把输出字符串通过网络转发给telnet客户端,由telnet客户端负责把输出字符串打印在我们的本地终端上。
这类客户端和服务器之间的交换在所有网络应用中比较常见。在第11章里,你将学习如何构建网络应用,应用这个知识来构建一个简单的服务器。
第1.9节 重要的主题
我们在这里总结一下最初的系统之旅。跟这里的讨论无关的一个重要思想是:一个系统不只是硬件。一个系统是相互交织在一起的硬件和软件组成的集合体,这些硬件和软件必须一起协作来实现运行应用程序的终极目标。
本书的其余部分将会填充一些有关软件和硬件的细节。通过这些细节将展示如何能编写更快、更可靠、更安全的程序。
作为本章的结束,我们重点突出几个贯穿计算系统所有方面的重要概念。我们将在本书的多个地方讨论这些概念的重要性。
第1.9.1小节 Amdahl定律
Gene Amdahl(计算机领域的早期先驱之一)对提升系统某个部分性能的有效性作出了一个简单但又远见的观察,即Amdahl定律。Amdahl定律的主要思想是:当我们加速系统某个部分时,对整个系统的性能的影响不仅取决于这一部分的重要性,还取决于这一部分的加速的比例。
考虑一个系统,该系统执行某个应用需要的时间为。
假设
- 该系统的某个部分花费的时间占比为,即这部分花费的时间为;
- 我们提升这一部分的性能比例是k,即这部分现在花费的时间为;
则该应用的新整体运行时间为
。
根据上述公式,我们计算出加速比。
示例1
假设一个系统的某部分优化前花费的时间比是,即,优化给该部分带来的加速比例是3,即,则优化这一部分给整体带来的加速比。
示例2
意味着我们能将系统的某个部分性能加速到其花费的时间可以忽略,则有:
所以,假设我们能将系统的加速到可以不花费时间,但是我们获得整体加速比仅为2.5。
总结一下:
即使我们对系统的主要部分作出了实质性的改进,我们获得的整体加速比也明显小于该部分获得的加速比。这是Amdahl定律的主要远见:为了显著地加速整个系统,我们必须提升整个系统很大一部分的速度。
Amdahl定律描述了优化任何流程的一般原则。除了可以应用在加速计算机系统外,Amdahl定律也可以指导一个尝试降低制造剃须刀成本的公司,或者一个尝试提高平均成绩的学生。在计算机世界中,这可能是最有意义的,因为我们通常要将性能提升2倍多。如此高的比例只能通过优化系统的大部分来实现。
第1.9.2小节 并发和并行
在整个数字计算机的历史中,有两个需求一直是驱动改进的动力:我们想要计算机做的更多,我们想要计算机运行的更快。当处理器一次能执行更多操作时,这两个因素都会得到改进。我们使用并发来表示一个系统同时有多个活动,并行来表示利用并发来使得系统运行的更快。在一个计算机系统里,可在多个抽象级别上利用并行。我们在这里突出3个级别:在系统层级结构里从高到底。
线程级别的并发
基于进程抽象,我们能设计出同时有多个程序在执行的系统来实现并发。有了多线程,我们甚至能在一个进程内部有多个在执行的控制流。
自从20世纪60年代分时系统出现以来,就在计算机系统中发现了对并发执行的支持。
传统意义上的并发是通过让单台计算机快速在多个执行进程之间切换来模拟的。这种形式的并发
- 允许多个使用者同时跟系统交互,比如许多人同时想从一台Web服务器获取页面等。
- 允许单个使用者同时忙于多个任务,比如在一个窗口里打开浏览器,在另一个窗口里打开一个文字处理器,同时播放流音乐等。
直到最近,实际上大部分计算都是由一个处理器做的,即使这个处理器不得不在多个任务间切换。这种配置是单处理器系统。
当我们在单操作系统内核的控制下构建一个由多个处理器组成的系统时,我们就有了一个多处理器系统。虽然自从20世纪80年代后,大规模计算就已经用上了多处理器系统,但是多处理器系统是随着多核处理器和超线程才变得普遍起来的。图1.16展示了这些不同处理器类型的分类。
多核处理器有多个被整合进单个集成芯片里的CPU。图1.17举例了一台典型的多核处理器的组织形式,其中每个CPU核都有各自的L1缓存和L2缓存,每个L1缓存被分成两个部分(一个用来持有最近被获取的指令,一个用来持有数据)。这些CPU核共用更高层级的缓存和内存操作的接口。工业专家预测将来会在一个芯片上有数十个,最终可能数百个CPU核。
超线程是一种技术,允许单个CPU执行多个控制流。超线程技术涉及
- CPU其他部分只有一份拷贝,比如执行浮点数运算的单元;
- CPU的一些部分有多分拷贝,比如程序计数器、寄存器文件等。
一个传统意义上的处理器在不同线程之间切换花费的时间是大约2万个时钟周期,而一个超线程处理器是基于时钟周期来决定执行哪个线程的。超线程处理器使得CPU能更好地利用其处理资源。比如,如果一个线程必须等待数据加载进缓存,则CPU可以执行一个不同的线程来继续工作。比如,Intel的i7处理器可让每个核执行两个线程,所以一个4核处理器可以并行执行8个线程。
使用多进程可从两个方面提升系统的性能。首先,当执行多个任务时,多进程减少了模拟并发的需要。如前所述,甚至一台个人电脑都能并发执行多个活动。其次,多进程使得一个程序运行的更快,注意前提是该程序使用了多线程。因此,虽然并发原理已被研究了50多年了,但是多核系统和超线程系统的出现才极大地增加了寻找编写可利用硬件上支持线程级别并行机制的方法的渴望。第12章,我们将深入研究并发,以及使用并发来提供对处理器资源的共享和使得程序运行时有更多的并行机制。
指令级别的并行
指令级别的并行是指在更低的抽象级别上,现代处理器能一次执行多条指令。比如,早期的处理器执行一条指令需要多个(3-10)时钟周期。较新的处理器可维持每个时钟周期执行2-4条指令的速率。虽然任何一条指令从开始到结束可能需要更长的时钟周期,比如20个,但是处理器使用若干个巧妙的技巧使其能一次处理多达100条指令。在第4章里,我们将研究流水线技术:
- 执行一条指令需要的动作可被分成不同的步骤;
- 处理器被组织成一系列的阶段,每个阶段执行前面分成的步骤;
- 这些阶段可并行运行,每个阶段处理不同指令的不同部分;
我们将看到一个相当简单的硬件设计可维持1个时钟周期一个指令的速率。
超标量处理器是可位置执行速率为1个时钟周期大于1条指令的处理器。大部分现代处理器都支持超标量操作。在第5章,我们将描述超标量处理器的一个高级模型。我们将看到应用程序员能利用这个模型来理解程序的性能。然后,他们能编写程序使得生成的代码能获取更高的指令级别并行度,从而运行的更快。
单指令多数据并行SIMD
单指令多数据并行模式处于抽象级别的最底层,许多现代处理器都有一个特殊的硬件,该硬件允许单条指令导致多个操作并行执行。比如,最新代的Intel和AMD处理器有可以并行将8对单精度浮点数相加的指令。这些被提供的SMID指令大部分用来加速处理图像、声音和视频数据。虽然一些编译器尝试自动从C程序中提取SIMD并行指令,但是更靠谱的做法是编写使用诸如gcc等编译器支持的特殊矢量数据类型的程序。我们将在第5章的旁白中介绍这种风格的编程,作为有关程序优化一般性介绍的补充内容。
第1.9.3小节 在计算机系统中抽象的重要性
对抽象的使用是计算机科学中最重要的概念之一。比如,良好的编程实践的一个方面就是为一组函数指定一个简单的API来允许程序员使用代码而不用关心代码的内部工作细节。不同的编程语言提供不同形式和级别的对抽象的支持,比如Java有类声明,C语言有函数原型等。
如图1.18所示,我们已引入了在计算机系统中会看到的若干个抽象。
从处理器的角度看,指令集提供了对实际处理器硬件的抽象。使用指令集提供的抽象,一个机器码程序的行为就像是其在一个一次仅执行一个指令的处理器上运行。底层的硬件非常复杂,虽并行执行多条指令,但以跟前述简单的顺序模型保持一致的方式执行。通过保持同样的执行模型,不同的处理器实现能在执行同样机器码的同时提供一系列的成本和性能。
从操作系统的角度看,我们介绍了3种抽象:
- 文件是对抽象I/O设备的抽象;
- 虚拟内存是对程序内存的抽象;
- 进程是对运行程序的抽象;
在这些抽象之外,我们新增一种抽象:虚拟机抽象。虚拟机抽象提供的是整台计算机的抽象,包括操作系统、处理器、程序等。虽然虚拟机抽象是由IBM在20世纪60年代引入的,但最近因作为一种管理计算机的方式而变得重要,这种管理计算机的方式是指:能运行为多个操作系统或者同一个操作系统不同版本而设计的程序。
我们将在本书接下来的章节里回到这些抽象。
第1.10节 总结
一个计算机系统由一起协作运行应用程序的硬件和系统软件组成。在计算机内部的信息被表示成位组,根据不同的上下文可解释成不同的含义。源程序可被其他程序转换成不同的形式,最初是ASCII文本形式,然后被编译器和链接器转换成二进制可执行文件。
处理器读取和解释存储在内存中的二进制指令。由于计算机花费大部分时间在内存、I/O设备、CPU寄存器之间拷贝数据,所以将在计算机系统中的存储设备排成一个层级结构,其中CPU寄存器在最顶端,接下来依次是多级硬件高速缓存、DRAM内存、磁盘存储等。在层级结构中更高一级的存储要比较低一级的存储更快、每个位的成本更高。在层级结构中更高一级的存储可以充当较低一级的存储的缓存。程序员通过理解和利用存储层级结构来优化C程序。
操作系统内核充当的是硬件和应用之间的中介。操作系统提供了3种基本的抽象:
- 文件是对I/O设备的抽象;
- 虚拟内存是对内存和磁盘的抽象;
- 进程是对处理器、内存、I/O设备的抽象;
最后,网络提供了不同计算机之间通信的方式。从一个特定系统来看,网路只不过是另一台I/O设备。