#### 第12章: #### 计算机系统漫游 本节以x86-64的机器代码进行讲解。 ##### 信息就是上下文 计算机系统是由硬件和系统软件组成的,它们共同工作来运行应用程序。 系统中的所有的信息------包括磁盘文件、内存中的程序、内存中存放的用户数据以及网络上传送的数据,都是由一串比特表示的。区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文。比如,在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或者机器指令。 :-: ![](https://img.kancloud.cn/ac/db/acdb2987305c27bde12ee4e45ac95ebb_604x530.png) ASCII码 例如hello.c程序: ~~~ #include<stdio.h> ​ int main() {       printf("hello, world\n");   return 0;     } ~~~ hello.c程序是以字节的方式存储在文件中。每个字节都有一个整数值,对应于某些字符。例如,第一个字节的整数值是35,它对应字符'#'。第二个字节的整数值是105,它对应的字符是'i',以此类推。注意,每个文本行都以一个看不见的换行符'\\n'来结束,它所对应的整数值为10。 问题:中文用多少个字节存储?中文与ASCII码不同,数量远远大于ASCII码,所以想要保存区别每一个中文字符就需要超过1字节更大的空间。 ##### 程序被其他程序翻译成不同的格式 hello.c程序的生命周期是从一个高级C语言程序开始的,因为这种形式能够被人读懂。然而,为了在系统上运行hello.c程序,每条C语言都必须被其他程序转化为一系列的`低级机器语言指令`。然后这些指令按照一种称为可执行目标程序的格式打包,并以二进制磁盘文件的形式存放起来(持久化)。目标程序也称为可执行目标文件。 在Unix系统上,从源文件到目标文件的转化是由编译器驱动程序完成的: ~~~ [root@VM-0-8-centos wwwroot]# gcc hello.c -o hello ~~~ 在这里,GCC编译器驱动程序读取源程序文件hello.c,并把它翻译成一个可执行目标文件hello。这个翻译过程可分为四个阶段完成。执行这四个阶段的程序(预处理器、编译器、汇编器、链接器)一起构成了编译系统。 :-: ![](https://img.kancloud.cn/56/84/56841590f7393c8787774e002f8f031a_758x132.png) 编译系统 1. `预处理阶段`:预处理器 (cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第一行的#include命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另外一个C程序,通常是以.i作为文件扩展名。 2. `编译阶段`:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。该程序包含函数main的定义,如下所示: ~~~ mian: subq   %8, %rsp movl   $.LCO, %edi call puts movl $0, %eax addq $8, %rsp ret //hello.i被编译器处理成了hello.s汇编语言文件 ~~~ 定义中的2-7行的每条语句都以一种文本格式描述了一条低级机器语言指令。 汇编语言是非常有用的,因为它为不同的高级语言的不同编译器提供了通用的输出语句。例如,C编译器和Fortran编译器产生的输出文件用的都是一样的汇编语言。 3. `汇编阶段`。汇编器将hello.s翻译成机器语言指令,把这些指令打包成一种叫做`可重定位目标文件`的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它包含的17个字节是函数mian的指令编码。如果我们在文本编辑器打开hello.o文件,将看到一对乱码。 4. `链接阶段`。请注意,hello程序调用了printf函数,它是每个C编译器提供的标准C库中的一个函数。printf函数存在一个名为printf.o的单独的预编译好的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中。链接器(ld)就负责处理这种合并。结果就得到了hello文件,它是一个`可执行目标文件` ,可以被加载到内存,由系统执行。 ##### 了解编译系统如何工作是大有益处的 对于像hello.c这样简单的程序,我们可以依靠编译系统生成正确有效的机器代码。但是有一些重要的原因促使程序员必须知道编译系统是如何工作的。 1. `优化程序性能`:现代编译器都是成熟的工具,通常可以生成很好的代码。作为程序员,我么无需为了写出高效代码而去了解编译器的内部工作。但是,为了在C程序中做出好的编码选择,我们确实需要了解一些机器代码以及编码器将不同的C语言转化为机器代码的方式。 比如: 一个switch语句是否总是比一系列的if-else语句高效得多?一个函数调用得开销有多大?while循环比for循环更有效吗?指针引用比数组索引更有效吗?为什么将循环求和的结果放到一个本地变量中,会比将其放到一个通过引用传递过来的参数中,运行起来快得多呢?为什么我们只是简单地重新排列以下算数表达式中地括号就能让函数运行得更快? 2. `理解链接时出现的错误`。根据我们的经验,一些最令人困扰i的程序错误往往都在与链接器操作有关,尤其是当你试图构建大型的软件系统时。比如链接器报告说它无法解析一个引用,这是什么意思?静态变量和全局变量的区别是什么?如果你在不同的C文件中定义了名字相同的两个全局变量会发生什么?静态库和动态库的区别是什么?我们在命令行上排列的顺序有什么影响?最严重的是,为什么有些链接知道运行时才出现。 3. `避免安全漏洞`:多年来,缓冲区溢出错误是造成大多数网络和Internet服务器上安全漏洞的主要原因。存在这些错误是因为很少有程序员能够理解需要限制从不受信的源接收数据的数量和格式。学习安全编程的第一步就是理解数据和控制信息存在程序上的方式会引起的后果。 ##### 高速缓存至关重要(硬件缓存) 系统总是花费大量的时间把信息从一个地方挪到另一个地方。hello程序的机器指令最初存放在磁盘,当程序加载时,他们被复制到主存;当处理器运行程序时,指令又从主存复制到处理器。相似的,数据串"hello world\\n"开始在磁盘上,然后被复制到主存,最后从主存上复制到显示设备。从程序员的角度看,这些复制就是开销,减慢了程序"真正"的工作。因此系统设计者的一个主要目标就是使这些复制操作尽可能快地完成。 根据机械原理,较大的存储设备比较小的存储设备运行得慢,而快速设备的造价远高于同类的低速设备。比如说,一个典型的系统上的磁盘驱动器可能比主存大1000倍,但是对于处理器而言,从磁盘驱动器上读取一个字的时间开销要比从主存中读取的开销大1000万倍。 类似的,一个寄存器文件只存储几百字节的信息,而主存里可存放几十亿字节。然而,处理器从寄存器文件中读信息比从主存中取几乎要快100倍。这些年,随着半导体技术的进步,这种处理器与主存之间的差距还在持续增大。加快处理器的运行速度比加快主存的运行速度要容易和便宜得多。 针对这种处理器与主存之间的差异,系统设计者采用了更小更快的存储设备,称之为高速缓存存储器。简称为cache或高速缓存,作为暂时的集结区域,存放处理器近期可能会需要的信息。 位于处理器芯片上的L1高速缓存的容量可以达到数万字节,访问速度几乎和访问寄存器文件一样快。一个容量为数十万到数百万字节的更大的L2高速缓通过一条特殊的总线连接到处理器。进程访问L2高速缓存的时间要比访问L1高速缓存的时间长5倍,但仍然比访问主存的时间快5-10倍。L1和L2高速缓存用的是一种叫做静态随机访问存储器的硬件技术实现的。比较新的、处理能力更强大的系统甚至有三级高速缓存:L1、L2和L3。系统可以获得一个很大的存储器,同时访问速度也很快,原因是利用了高速缓存的局部性原理,即程序具有访问局部区域里的数据和代码的趋势。通过让高速缓存里存放可能经常访问的数据,大部分的内存操作都能在快速的高速缓存中完成。 ##### 存储设备层次结构 每个计算机系统的存储设备都被组织成了一个存储器层次结构。 存储器层次结构的主要思想是上一层的存储器作为低一层存储器的高速缓存。 :-: ![](https://img.kancloud.cn/70/fe/70fe4b722b7713865da774cda290febc_801x501.png) ##### 操作系统管理硬件 当shell (称之为壳子,命令解释器)运行hello程序: ~~~ [root@VM-0-8-centos wwwroot]# ./hello hello, world [root@VM-0-8-centos wwwroot]# ~~~ 以及hello程序输出自己的消息时,shell和hello程序都没有直接访问键盘、显示器、磁盘或主存。取而代之的是,他们依靠`操作系统`提供的服务。我们可以把操作系统看成是应用程序和硬件之间插入的一层软件。 :-: ![](https://img.kancloud.cn/11/87/11872395343ad7f1f8d2e14c584b520f_500x153.png) 计算机系统的分层视图 操作系统有两个基本功能:(1)防止硬件被失控的应用程序滥用;(2)向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。 操作系统通过几个抽象的基本概念(进程、虚拟内存、文件)来实现这两个功能。文件是对I/O设备的抽象表示,虚拟内存是对主存和磁盘的I/O设备的抽象表示,进程则是处理器、主存和I/O设备的抽象表示。 :-: ![](https://img.kancloud.cn/22/1a/221ad59cc9573b3435f4422566a5af69_500x283.png) 操作系统提供的抽象表示 ##### 进程 程序运行再现代系统上时,操作系统会提供一种假象,就好像系统只有这个程序再运行。程序看上去是独占地使用处理器、主存和I/O设备。处理器看上去就像不断地一条接一条地执行程序中的指令,即该程序是计算机科学中最重要和成功的概念之一。 `进程`是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。而`并发运行`,则是说一个进程的指令和另一个进程的指令是交错执行的。在大多数系统中,需要运行的进程数是多于可以运行它们的CPU个数的。传统系统在一个时刻只能执行一个程序,而先进的`多核处理器`同时能够执行多个进程,这是通过处理器在进程间切换来实现的。操作系统实现这种交错执行的机制称为`上下文切换`。 上下文:操作系统保持跟踪进程运行所需的所有状态信息。包括许多信息,比如PC和寄存器文件的当前值,以及主存的内容、进程初始化时的环境变量、进程的虚拟内存空间、进程维护的数据结构、进程需要处理的事件队列缓存等。在任何时候,单核处理器只能执行一个进程的代码(多核在这一刻可以执行多个进程的代码)。当系统决定把控制权从当前进程转移到某个新进程的时候,就会进行`上下文切换`,即保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新的进程。新进程就会从它上次停止的地方开始。 :-: ![](https://img.kancloud.cn/e7/f2/e7f2825c9cd3ab593650eb78c0772625_600x372.png) · 进程的上下文切换 ##### 线程 尽管通常我们认为一个进程只有单一的控制流,但是现代系统中,一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中拥有自己的栈区,并共享同样的代码和全局数据(共享内存)。由于网络服务器中对并行处理的需求,线程成为了越来越重要的模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高效。当由多处理器可用的时候,多线程也是一种使得程序可以运行得更快的方法。 ##### 线程模型 在现代计算机结构中,先后提出过两种线程模型:用户级线程(user-level threads)和内核级线程(kernel-level threads)。所谓用户级线程是指,应用程序在操作系统提供的单个控制流的基础上,通过在某些控制点(比如系统调用)上分离出一些虚拟的控制流,从而模拟多个控制流的行为。由于应用程序对指令流的控制能力相对较弱,所以,用户级线程之间的切换往往受线程本身行为以及线程控制点选择的影响,线程是否能公平地获得处理器时间取决于这些线程的代码特征。而且,支持用户级线程的应用程序代码很难做到跨平台移植,以及对于多线程模型的透明。用户级线程模型的优势是线程切换效率高,因为它不涉及系统内核模式和用户模式之间的切换;另一个好处是应用程序可以采用适合自己特点的线程选择算法,可以根据应用程序的逻辑来定义线程的优先级,当线程数量很大时,这一优势尤为明显。但是,这同样会增加应用程序代码的复杂性。有一些软件包(如 POSIXThreads 或 Pthreads 库)可以减轻程序员的负担。 内核级线程往往指操作系统提供的线程语义,由于操作系统对指令流有完全的控制能力,甚至可以通过硬件中断来强迫一个进程或线程暂停执行,以便把处理器时间移交给其他的进程或线程,所以,内核级线程有可能应用各种算法来分配`处理器时间`。线程可以有优先级,高优先级的线程被优先执行,它们可以抢占正在执行的低优先级线程。在支持线程语义的操作系统中,处理器的时间通常是按线程而非进程来分配,因此,系统有必要维护一个全局的线程表,在线程表中记录每个线程的寄存器、状态以及其他一些信息。然后,系统在适当的时候挂起一个正在执行的线程,选择一个新的线程在当前处理器上继续执行。这里“适当的时候”可以有多种可能,比如:当一个线程执行某些系统调用时,例如像 sleep 这样的放弃执行权的系统函数,或者像 wait 或 select 这样的阻塞函数;硬中断(interrupt)或异常(exception);线程终止时,等等。由于这些时间点的执行代码可能分布在操作系统的不同位置,所以,在现代操作系统中,线程调度(thread scheduling)往往比较复杂,其代码通常分布在内核模块的各处。 `内核级线程的好处是,应用程序无须考虑是否要在适当的时候把控制权交给其他的线程,不必担心自己霸占处理器而导致其他线程得不到处理器时间`。应用线程只要按照正常的指令流来实现自己的逻辑即可,内核会妥善地处理好线程之间共享处理器的资源分配问题。然而,这种对应用程序的便利也是有代价的,`对于在用户模式下运行的线程来说,一个线程被切换出去,以及下次轮到它的时候再被切换进来,要涉及两次模式切换:从用户模式切换到内核模式,再从内核模式切换回用户模式,此时用户线程提供了表面抽象的逻辑控制流,实际上是内核线程分配处理器时间`(内核模式对CPU、硬件有绝对的操作权,运行在内核模式的指令有极致的速度,但是内核模式下程序异常是致命的;用户模式对非权限的指令需要切换到内核模式运行,用如果用户模式下的程序抛出异常程序会崩溃,但是系统不会崩溃,所以用户模式运行保证了操作系统稳定)。在 Intel 的处理器上,这种模式切换大致需要几百个甚至上千个处理器指令周期。但是,随着处理器的硬件速度不断加快,模式切换的开销相对于现代操作系统的线程调度周期(通常几十毫秒)的比例正在减小,所以,这部分开销是完全可以接受的。 除了线程切换的开销是一个考虑因素以外,线程的创建和删除也是一个重要的考虑指标。当线程的数量较多时,这部分开销是相当可观的。虽然线程的创建和删除比起进程要轻量得多,但是,在一个进程内建立起一个线程的执行环境,例如,分配线程本身的数据结构和它的调用栈,完成这些数据结构的初始化工作,以及完成与系统环境相关的一些初始化工作,这些负担是不可避免的。另外,当线程数量较多时,伴随而来的线程切换开销也必然随之增加。所以,当应用程序或系统进程需要的线程数量可能比较多时,通常可采用线程池技术作为一种优化措施,以降低创建和删除线程以及线程频繁切换而带来的开销。 在支持内核级线程的系统环境中,进程可以容纳多个线程,这导致了多线程程序设计(multithreaded programming)模型。由于多个线程在同一个进程环境中,它们共享了几乎所有的资源,所以,线程之间的通信要方便和高效得多,这往往是进程间通信(IPC,Inter-Process Communication)所无法比拟的,但是,这种便利性也很容易使线程之间因同步不正确而导致数据被破坏,而且,这种错误存在不确定性,因而相对来说难以发现和调试。 **线程模型小节引用author:快乐成长** ##### 虚拟内存 `虚拟内存`是一个抽象的概念,它为每个进程提供一个假象,即每个进程都独占地使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间。在Linux中,地址空间最上面的区域是保留给操作系统中的代码和数据的,这对所有进程来说都是一样。地址空间的底部区域存放用户进程定义的代码和数据。 :-: ![](https://img.kancloud.cn/cc/4a/cc4a3982a9ed8565b25731eba4abcf47_677x602.png) 每个进程看到的虚拟地址空间由大量准确定义的区构成,每个区都有专门的功能。 1. `程序代码和数据`: 对所有的进程来说,代码是从同一固定地址开始,紧接着的是和C全局变量相对应的数据位置。代码和数据区是直接按照可执行目标文件的内容初始化的。 2. `堆`:代码和数据区后面紧随着的是运行时堆。代码和数据区在进程一开始运行时就被指定了大小,与此不同,当调用像malloc和free这样的C标准库函数时,堆可以在运行时动态扩展和收缩。 3. `共享库`:大约在地址空间的中间部分是一块用来存放像C标准库和数据库这样的共享库的代码和数据的区域。共享库的概念非常强大,也相当难懂。 4. `栈`:位于用户虚拟地址空间顶部的是`用户栈`,编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。特别地,每当我们调用一个函数时,栈就会增长;从一个函数返回时,栈九会收缩。 5. `内核虚拟内存`:地址空间顶部的区域是为内核保留的。不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。相反它们必须调用内核来执行这些操作。 ##### 文件 文件就是字节序列,仅此而已。每个I/O设备,包括磁盘、键盘、显示器,甚至网络,都可以看成文件。系统中的所有输入输出都是通过使用一组称为Unix I/O的系统函数调用读写文件来实现的。 文件这个简单而精致的概念是非常强大的,因为它向应用程序提供了一个统一的视图,来看待系统中可能含有的所有各式各样的 I/O设备。例如:同一个程序可以在不同磁盘技术的不同系统上运行。 ##### 并发与并行 `并发`:同一时具有多个活动的系统。 `并行`:用并行来使一个系统运行得更快。 1. 线程级并发 构建在进程的抽象之上,我们能够设计出同时有多个程序执行的系统,这就导致了并发。使用线程,我们甚至能在一个进程中执行多个控制流(同一刻单核系统只能执行一个控制流)。不过这种并发只是模拟出来,是通过使一台计算机在它正在执行的进程间快速切换来实现的。在以前,即使处理器必须在多个任务之间切换,大多数实际的计算也都是由一个处理器来完成的。这种配置称为`单处理器系统`。 当构建一个由单操作系统内核控制的多处理器组成的系统时,我们就得到一个`多处理器系统`。 随着`多核处理器`和`超线程`的出现,大规模计算系统变得常见。 `多核处理器`是将多个CPU(核)集成到一个集成电路芯片上。 `超线程`,有时称`同时多线程`,是一项允许一个CPU执行多个控制流的技术(一个进程里的多个线程系统同时执行)。 2. 指令级并行 在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行。 3. 单指令、多数据并行 在低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执行的操作,这种方式称为单指令、多数据。 #### I/O是什么 I/O是在主存和外部设备(例如磁盘驱动器、终端核网络)之间复制数据的过程。 ##### 协程 什么是协同式和抢占式? 许多协同式多任务操作系统,也可以看成协程运行系统。说到协同式多任务系统,一个常见的误区是认为协同式调度比抢占式调度“低级”,因为我们所熟悉的桌面操作系统,都是从协同式调度(如 Windows 3.2, Mac OS 9 等)过渡到抢占式多任务系统的。实际上,调度方式并无高下,完全取决于应用场景。抢占式系统允许操作系统剥夺进程执行权限,抢占控制流,因而天然适合服务器和图形操作系统,因为调度器可以优先保证对用户交互和网络事件的快速响应(打开某程序时立刻进行抢占)。当年 Windows 95 刚刚推出的时候,抢占式多任务就被作为一大买点大加宣传。协同式调度则等到进程时间片用完或系统调用时转移执行权限,因此适合实时或分时等等对运行时间有保障的系统。 另外,抢占式系统依赖于 CPU 的硬件支持。 因为调度器需要“剥夺”进程的执行权,就意味着调度器需要运行在比普通进程高的权限上,否则任何“流氓(rogue)”进程都可以去剥夺其他进程了。只有 CPU 支持了执行权限后,抢占式调度才成为可能。x86 系统从 80386 处理器开始引入 Ring 机制支持执行权限,这也是为何 Windows 95 和 Linux 其实只能运行在 80386 之后的 x86 处理器上的原因。而协同式多任务适用于那些没有处理器权限支持的场景,这些场景包含资源受限的嵌入式系统和实时系统。在这些系统中,程序均以协程的方式运行。调度器负责控制流的让出和恢复。通过协程的模型,无需硬件支持,我们就可以在一个“简陋”的处理器上实现一个多任务的系统。我们见到的许多智能设备,如运动手环,基于硬件限制,都是采用协同调度的架构。 协程基本概念 “协程”(Coroutine)概念最早由 Melvin Conway 于 1958 年提出。协程可以理解为纯用户态的线程,其通过协作而不是抢占来进行切换。相对于进程或者线程,协程所有的操作都可以在用户态完成,创建和切换的消耗更低。总的来说,协程为协同任务提供了一种运行时抽象,这种抽象非常适合于协同多任务调度和数据流处理。在现代操作系统和编程语言中,因为用户态线程切换代价比内核态线程小,协程成为了一种轻量级的多任务模型。 从编程角度上看,协程的思想本质上就是控制流的主动让出(yield)和恢复(resume)机制,迭代器常被用来实现协程,所以大部分的语言实现的协程中都有 yield 关键字,比如 Python、PHP、Lua。但也有特殊比如 Go 就使用的是通道来通信。 协程的历史其实要早于线程。 **本小节引用author:快乐成长** ##### 进程、线程、协程的特点及区别: 进程(process) * 进程是资源分配的最小单位 * 进程间不共享内存,每个进程拥有自己独立的内存 * 进程间可以通过信号、信号量、共享内存、管道、队列等来通信 * 新开进程开销大,并且 CPU 切换进程成本也大 * 进程由操作系统调度 * 多进程方式比多线程更加稳定 线程(thread) * 线程是程序执行流的最小单位 * 线程是来自于进程的,一个进程下面可以创建多个线程 * 每个线程都有自己一个栈,不共享栈,但多个线程能共享同一个属于进程的堆 * 线程因为是在同一个进程内的,可以共享内存 * 线程也是由操作系统调度,线程是 CPU 调度的最小单位 * 新开线程开销小于进程,CPU 在切换线程成本也小于进程 * 某个线程发生致命错误会导致整个进程崩溃 * 线程间读写变量存在锁的问题处理起来相对麻烦 协程(coroutine) * 对于操作系统来说只有进程和线程,协程的控制由应用程序显式调度,非抢占式的 * 协程的执行最终靠的还是线程,应用程序来调度协程选择合适的线程来获取执行权 * 切换非常快,成本低。一般占用栈大小远小于线程(协程 KB 级别,线程 MB 级别),所以可以开更多的协程 * 协程比线程更轻量级 **本小节引用author:快乐成长**