向着更深的知识进发,不断超越过去的自己的见识,了解程度。Project for Dream

《深入理解计算机系统》

计算机机系统漫游

#include <stdio.h>
int main()
{
    printf("hello, world\n"); 
    return 0;
}
Hello.c文件
Hello.c文件
hello 程序的生命周期是从一个源程序(或者说源文件)开始的,
即程序员通过编辑器创建并保存的文本文件,文件名是 hello.c。
源程序实际上就是一个由值 0 和 1 组成的位(又称为比特)序列, 
8个位被组织成一组,称为字节。每个字节表示程序中的某些文本字符。

注意 ,每个文本行都是以一个看不见的换行符 '\n' 来结束的,
它所对应的整数值为10。 像hello.c 这样只由 ASCII 
字符构成的文件称为文本文件,所有其他文件都称为二进制文件。
Hello.c的编译过程
Hello.c的编译过程
1.预处理阶段。预处理器 (cpp) 根据以字符#开头的命令,修改原始的 C 程序。
比如 hello . c 中第 1 行的五nclude <stdio.h> 命令告诉预处理器读取系统头文件 
stdio.h 的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,
通常是以 .i 作为文件扩展名。 

2.编译阶段。编译器(eel) 将文本文件 hello.i 翻译成文本文件 hello.s,
翻译成汇编语言

3.汇编阶段。把汇编语言转成0101的机器码

4.链接阶段(拼接二进制函数)。请注意,Hello程序调用了 printf 函数,
它是每个C编译器都提供的标准C库中的一个函数。 
printf 函数存在于一个名为printf.o 的单独的预编译好了的目标文件中,
而这个文件必须以某种方式合并到我们的Hello程序中 。 
链接器(Id)就负责处理这种合并。 结果就得到 hello文件,
它是一个可执行目标文件(或者简称为可执行文件),可以被加载到内存中,由系统执行。


shell 是一个命令行解释器,它输出一个提示符,等待输入一个命令行,
然后执行这 个命令。如果该命令行的第一个单词不是一个内置的 shell 命令,
那么 shell 就会假设这是一个可执行文件的名字,它将加载并运行这个文件。


计算机硬件的组成

1.总线
贯穿整个系统的是一组电子管道,称作总线,它携带信息字节并负责在各个部件间传
递。通常总线被设计成传送定长的字节块,也就是宇(word) 。
字中的字节数(即字长)是一 个基本的系统参数,各个系统中都不尽相同。
现在的大多数机器字长要么是 4 个字节(32位),要么是8个字节(64位)

2.I/O 设备
I/0(输入/输出)设备是系统与外部世界的联系通道

3.主存
主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。
从物理上来说,主存是由一组动态随机存取存储器(DRAM)芯片组成的。

4.处理器
是解释(或执行)存储在主存中指令的引擎
CPU 在指令的要求下可能会执行这些操作:
1.加载:从主存复制一个字节或者一个字到寄存器,以覆盖寄存器原来的内容。
2.存储:从寄存器复制一个字节或者一个字到主存的某个位置,以覆盖这个位置上原来的内容。
3.操作 : 把两个寄存器的内容复制到 ALU, ALU 对这两个字做算术运算,并将结果
存放到一个寄存器中,以覆盖该寄存器中原来的内容。
4.跳转:从指令本身中抽取一个字,并将这个字复制到程序计数器(PC) 中,以覆盖
PC 中原来的值。

I.高速缓存至关重要
hello 程序的机器指令最初是存放在磁盘上,当程序加载时,它们被复 制到主存;
当处理器运行程序时,指令又从主存复制到处理器。相似地,数据串 "hello, world/n" 
开始时在磁盘上,然后被复制到主存,最后从主存上复制到显示设备。 
从程序员的角度来看,这些复制就是开销,减慢了程序“真正”的工作。
因此,系统设计者的一个主要目标就是使这些复制操作尽可能快地完成。

类似地,一个典型的寄存器文件只存储几百字节的信息,而主存里可存放几十亿字
节。然而,处理器从寄存器文件中读数据比从主存中读取几乎要快 100 倍。更麻烦的是,
随着这些年半导体技术的进步,这种处理器与主存之间的差距还在持续增大。加快处理器
的运行速度比加快主存的运行速度要容易和便宜得多。

针对这种处理器与主存之间的差异,系统设计者采用了更小更快的存储设备,称为高
速缓存存储器 (cache memory, 简称为 cache 或高速缓存),
作为暂时的集结区域,存放处理器近期可能会需要的信息。

通过让高速缓存里存放可能经常访问的数据,大部分的内存操作都能在快速的高速缓存中完成。

正如可以运用不同的高速缓存的知识来提高程序性能一样,
程序员同样可以利用对整个存储器层次结构的理解来提高程序性能。
高速缓存器
高速缓存器
1.进程
像 hello 这样的程序在现代系统上运行时,操作系统会提供一种假象,
就好像系统上只有这个程序在运行。程序看上去是独占地使用处理器、主存和 I/0设备。
处理器看上去就像在不间断地一条接一条地执行程序中的指令,
即该程序的代码和数据是系统内存中唯一的对象。这些假象是通过进程的概念来实现的,
进程是计算机科学中最重要和最成功的概念之一。

2.上下文
操作系统保持跟踪进程运行所需的所有状态信息。这种状态,也就是上下文,
包括许多信息,比如 PC 和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单处理器
系统都只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进
程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控
制权传递到新进程。新进程就会从它上次停止的地方开始。

3.线程
尽管通常我们认为一个进程只有单一的控制流,但是在现代系统中,一个进程实际上
可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的
代码和全局数据。由于网络服务器中对并行处理的需求,线程成为越来越重要的编程模
型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高
效。当有多处理器可用的时候,多线程也是一种使得程序可以运行得更快的方法。

4.虚拟内存
虚拟内存是一个抽象概念,它为每个进程提供了一个假象,即每个进程都在独占地使用主存。
每个进程看到的内存都是一致的,称为虚拟地址空间。
基本思想是把一个进程虚拟内存的内容存储在磁盘上,然后用主存作为磁盘的高速缓存


Amdahl 定律的主要观点——要想显著加速整个系统,必须提升全系统中相当大的部分的速度。

井发
当处理器能够同时做更多的事情时,这两个因素都会改进。
我们用的术语并发(concurrency)是一个通用的概念,指一个同时具有多个活动的系统。

并行
而术语并行(parallelism)指的是用并发来使一个系统运行得更快。
并行可以在计算机系统的多个抽象层次上运用。

1.线程级并发
构建在进程这个抽象之上,我们能够设计出同时有多个程序执行的系统,
这就导致了并发。 使用线程,我们甚至能够在一个进程中执行多个控制流。

超线程,有时称为同时多线程 (simultaneous multi-threading), 
是一项允许一个 CPU 执行多个控制流的技术。它涉及 CPU 某些硬件有多个备份,
比如程序计数器和寄存器文件,而其他的硬件部分只有一份,比如执行浮点算术运算的单元。
常规的处理器需要大约20 000 个时钟周期做不同线程间的转换,
而超线程的处理器可以在单个周期的基础上决定要执行哪一个线程。
这使得 CPU 能够更好地利用它的处理资源。比如,假设一个线程必
须等到某些数据被装载到高速缓存中,那 CPU 就可以继续去执行另一个线程。举例来说,
Intel Core i7处理器可以让每个核执行两个线程,
所以一个4核的系统实际上可以并行地 执行 8 个线程。

2.指令级并行
在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行。
 1978 年的 Intel 8086, 需要多个(通常是 3~10 个)时钟周期来执行一条指令。
 最近的处理器可以保持每个时钟周期 2~4 条指令的执行速率。其实每条指令从开
 始到结束需要长得多的时间,大约 20 个或者更多周期,但是处理器使用了非常多的聪明
技巧来同时处理多达 100 条指令。

如果处理器可以达到比一个周期一条指令更快的执行速率,
就称之为超标量(super scalar)处理器。大多数现代处理器都支持超标最操作。

3.单指令、多数据并行
在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执行的操作,
这种方式称为单指令、多数据,即 SIMD 并行。

计算机系统提供的一些抽象。
计算机系统中的一个重大主题就是提供不同层次的抽象表示,来隐藏实际实现的复杂性

文件是对 I/0 设备的抽象,虚拟内存是对程序存储器的抽象,
而进程是对一个正在运行的程序的抽象。
虚拟机,它提供对整个计算机的抽象,包括操作系统、处理器和程序。

信息的表示和处理

对于有 10个手指的人类来说,使用十进制表示法是很自然的事情,
但是当构造存储和处理信息的机器时,二进制值工作得更好。
二值信号能够很容易地被表示、存储和传输,例如,可以表示为穿孔卡片上有洞或无洞、
导线上的高电压或低电压,或者顺时针或逆时针的磁场。
对二值信号进行存储和执行计算的电子电路非常简单和可靠,
制造商能够在一个单独的硅片上集成数百万甚至数十亿个这样的电路。

 比如,许多程序员假设一个声明为 int 类型的程序对象能被用来存储一个指针。 
 这在大多数 32 位的机器上能正常工作,但是在一台 64 位的机器上却会导致问题。

我们已经看到了许多无符号运算的细微特性,尤其是有符号数到无符号数的隐式转
换,会导致错误或者漏洞的方式。避免这类错误的一种方法就是绝不使用无符号数。
实际上,除了 C 以外很少有语言支持无符号整数。很明显,
这些语言的设计者认为它们带来的麻烦要比益处多得多。比如, Java 只支持有符号整数,
并且要求以补码运算来实现。正常的右移运算符>>被定义为执行算术右移。
特殊的运算符>>>被指定为执行逻辑右移。

以往,在大多数机器上,整数乘法指令相当慢,需要 10 个或者更多的时钟周期,
然而其他整数运算(例如加法、减法、位级运算和移位)只需要 l 个时钟周期。
即使在我们的 参考机器 Intel Core i7 Haswell 上,其整数乘法也需要 3个时钟周期。
因此,编译器使用 了一项重要的优化,
试着用移位和加法运算的组合来代替乘以常数因子的乘法。
首先,我们会考虑乘以 2 的幂的情况,然后再概括成乘以任意常数。

由千整数乘法比移位和加法的代价要大得多,许多 C 语言编译器试图以移位、
加法和减法的组合来消除很多整数乘以常数的情况。例如,假设一个程序包含表达式 X * 14。 
利用 14=2的3次方+2的2次方+2,编译器会将乘法重写为 (x«3) + (x<<2) + (x<<l), 
将一个乘法替换为三个移位和两个加法。无论 x 是无符号的还是补码,
甚至当乘法会导致溢出时,两个计算都会得到一样的结果。

C语言的设计可以包容多种不同字长和数字编码的实现。 64 位字长的机器逐渐普及,
并正在取代统治市场长达 30 多年的 32 位机器。
由于 64 位机器也可以运行为 32 位机器编译的程序,
我们的重点就放在区分 32 位和 64 位程序,而不是机器本身。 
64 位程序的优势是可以突破 32 位程序具有的 4GB地址限制。

程序的机器级表示

机器级程序和它们的汇编代码表示,与 C 程序的差别很大。
各种数据类型之间的差别很小。程序是以指令序列来表示的,
每条指令都完成一个单独的操作。部分程序状态,如寄存器和运行时栈,
对程序员来说是直接可见的。本书仅提供了低级操作来支持数据处理和程序控制。
编译器必须使用多条指令来产生和操作各种数据结构,以及实现像条件、
循环和过程这样的控制结构。我们讲述了 C 语言和如何编译它的许多不同方面。
我们看到 C 语言中缺乏边界检查,使得许多程序容易出现缓冲区溢出。虽然最近
的运行时系统提供了安全保护,而且编译器帮助使得程序更安全,
但是这已经使许多系统容易受到恶意入侵者的攻击。

蠕虫病毒不仅能复制自己还能自动传播

处理器体系结构

指令集的一个重要性质就是字节编码必须有唯一的解释。任意一个字节序列要么是一
个唯一的指令序列的编码,要么就不是一个合法的字节序列。 Y86-64 就具有这个性质,
因为每条指令的第一个字节有唯一的代码和功能组合,给定这个字节,我们就可以决定所
有其他附加字节的长度和含义。这个性质保证了处理器可以无二义性地执行目标代码程
序。即使代码嵌入在程序的其他字节中,只要从序列的第一个字节开始处理,我们仍然可
以很容易地确定指令序列。反过来说,如果不知道一段代码序列的起始位置,我们就不能
准确地确定怎样将序列划分成单独的指令。

存储器和时钟
组合电路从本质上讲,不存储任何信息。相反,它们只是简单地响应输入信号,产生等
千输入的某个函数的输出。为了产生时序电路(sequential c订cuit), 
也就是有状态并且在这个状态上进行计算的系统,我们必须引入按位存储信息的设备。
存储设备都是由同一个时钟控制的,时钟是一个周期性信号,
决定什么时候要把新值加载到设备中。考虑两类存储器设备:

时钟寄存器(简称寄存器)存储单个位或字。时钟信号控制寄存器加载输入值。

随机访问存储器(简称内存)存储多个字,用地址来选择该读或该写哪个字。随机访
问存储器的例子包括: 1)处理器的虚拟内存系统,硬件和操作系统软件结合起来使
处理器可以在一个很大的地址空间内访问任意的字; 2)寄存器文件,在此,寄存器
标识符作为地址。在 IA32 或 Y86-64 处理器中,寄存器文件有 15 个程序寄存器(%
rax~%r14) 。
时钟的理解
时钟的理解
处理器的运行步骤
处理器的运行步骤
流水线化的一个重要特性就是提高了系统的吞吐量(throughput), 
也就是单位时间内服务的顾客总数,
不过它也会轻微地增加延迟Clatency), 也就是服务一个用户所需要的时间 。 
例如,自助餐厅里的一个只需要甜点的顾客,能很快通过一个非流水线化的系统,
只在甜点阶段停留。但是在流水线化的系统中,
这个顾客如果试图直接去甜点阶段就有可能招致其他顾客的愤怒了 。

优化程序性能

编写高效程序需要做到以下几点:
第一:我们必须选择一组适当的算法和数据结构。

第二:我们必须编写出编译器能够有效优化以转换成高效可执行代码的源代码。对于这第
二点,理解优化编译器的能力和局限性是很重要的。编写程序方式中看上去只是一点小小
的变动,都会引起编译器优化方式很大的变化。有些编程语言比其他语言容易优化。 C 语
言的有些特性,例如执行指针运算和强制类型转换的能力,使得编译器很难对它进行优化。
程序员经常能够以一种使编译器更容易产生高效代码的方式来编写他们的程序。

第三项:技术针对处理运算量特别大的计算,将一个任务分成多个部分,
这些部分可以在多核和多处理器的某种组合上并行地计算

到目前为止,我们运用的优化都不依赖于目标机器的任何特性。这些优化只是简单
地降低了过程调用的开销,以及消除了一些重大的"妨碍优化的因素”,这些因素会给
优化编译器造成困难。随着试图进一步提高性能,必须考虑利用处理器微体系结构的优
化,也就是处理器用来执行指令的底层系统设计。要想充分提高性能,需要仔细分析程
序,同时代码的生成也要针对目标处理器进行调整。尽管如此,我们还是能够运用一些
基本的优化,在很大一类处理器上产生整体的性能提高。我们在这里公布的详细性能结
果,对其他机器不一定有同样的效果,但是操作和优化的通用原则对各种各样的机器都
适用。

为了理解改进性能的方法,我们需要理解现代处理器的微体系结构。由千大量的晶
体管可以被集成到一块芯片上,现代微处理器采用了复杂的硬件,试图使程序性能最大
化。带来的一个后果就是处理器的实际操作与通过观察机器级程序所察觉到的大相径
庭。在代码级上,看上去似乎是一次执行一条指令,每条指令都包括从寄存器或内存取
值,执行一个操作,并把结果存回到一个寄存器或内存位置。在实际的处理器中,是同时
对多条指令求值的,这个现象称为指令级并行。

内存操作的实现包括许多细微之处。 对于寄存器操作,在指令被译码成操作的时候,
处理器就可以确定哪些指令会影响其他哪些指令。另一方面,对于内存操作,
只有到计算出加载和存储的地址被计算出来以后 ,处理器才能确定哪些指令会影响
其他的哪些。高效地处理内存操作对许多程序的性能来说至关重要。 
内存子系统使用了很多优化,例如当操作可以独立地进行时 ,就利用这种潜在的并行性。

应用:性能提高技术
1.高级设计:为遇到的问题选择适当的算法和数据结构。要特别警觉,避免使用那
些会渐进地产生糟糕性能的算法或编码技术。

2.基本编码原则。避免限制优化的因素,这样编译器就能产生高效的代码。

消除连续的函数调用。在可能时,将计算移到循环外。
考虑有选择地妥协程序的模块性以获得更大的效率。

消除不必要的内存引用。引入临时变量来保存中间结果。 
只有在最后的值计算出来时,才将结果存放到数组或全局变量中。

3.低级优化。结构化代码以利用硬件功能。
展开循环,降低开销,并且使得进一步的优化成为可能。
通过使用例如多个累积变址和重新结合等技术,找到方法提高指令级并行。
用功能性的风格重写条件操作,使得编译采用条件数据传送。

至此,我们只考虑了优化小的程序,
在这样的小程序中有一些很明显限制性能的地方,因此应该是集中注意力对它们进行优化。
在处理大程序时,连知道应该优化什么地方都是很难的。

虽然关于代码优化的大多数论述都描述了编译器是如何能生成高效代码的,
但是应用程序员有很多方法来协助编译器完成这项任务。
没有任何编译器能用一个好的算法或数据结构代替低效率的算法或数据结构,
因此程序设计的这些方面仍然应该是程序员主要关心的。 
我们还看到妨碍优化的因素,例如内存别名使用和过程调用,
严重限制了编译器执行大掀优化的能力。
同样,程序员必须对消除这些妨碍优化的因素负主要的责任。
这些应该被看作好的编程习惯的一部分,因为它们可以用来消除不必要的工作。

当处理大型程序时,将注意力集中在最耗时的部分变得很重要。

存储器层次结构

随机访问存储器RAM
随机访问存储器RAM
作为一个程序员,你需要理解存储器层次结构,因为它对应用程序的性能有着巨大的
影响。 如果你的程序需要的数据是存储在 CPU 寄存器中的,那么在指令的执行期间,
在 0个周期内就能访问到它们。如果存储在高速缓存中,需要 4~75 个周期。
如果存储在主存中,需要上百个周期。而如果存储在磁盘上,需要大约几千万个周期!

这个思想围绕着计算机程序的一个称为局部性(locality) 的基本属性。
具有良好局部性的程序倾向于一次又一次地访问相同的数据项集合,
或是倾向千访问邻近的数据项集合。
具有良好局部性的程序比局部性差的程序更多地倾向于从存储器层次结构中较高层次处访
问数据项,因此运行得更快。例如,在 Core i7 系统,
不同的矩阵乘法核心程序执行相同数量的算术操作,但是有不同程度的局部性,
它们的运行时间可以相差 40 倍!

随机访问存储器(Random-Access Memory, RAM )分为两类:静态的和动态的。
静态 RAMCSRAM)比动态RAM (DRAM)更快,但也贵得多。SRAM用来作为高速缓存存储器,
既可以在CPU芯片上,也可以在片下。DRAM用来作为主存以及图形系统的帧缓冲区。
典型地,一个桌面系统的SRAM不会超过几兆字节,但是DRAM却有几百或几千兆字节

静态 RAM:SRAM 将每个位存储在一个双稳态的 (bistable)存储器单元里。
每个单元是用一个六晶体管电路来实现的。这个电路有这样一个属性,
它可以无限期地保持在两个不同的电压配置(configuration)或状态 (state) 之一。

DRAM 将每个位存储为对一个电容的充电,这个电容非常小,
通常只有大约 30 毫微微法拉.

DRAM 存储器可以制造得非常密集 每个单元由一个电容和一个访问品体管组成。 
但是,与 SRAM 不同, DRAM 存储器单元对干扰非常敏感。当电容的电压被扰乱之后,
它就永远不会恢复了。

只要有供电, SRAM 就会保持不变。
与 DRAM 不同,它不需要刷新 。 SRAM 的存取比 DRAM 快。 SRAM 对诸如光和电噪声
这样的干扰不敏感。代价是 SRAM 单元比 DRAM 单元使用更多的晶体管,因而密集度
低,而且更贵 ,功耗更大。

如果断电, DRAM 和 SRAM 会丢失它们的信息,从这个意义上说,
它们是易失的(volatile) 。另一方面,非易失性存储器 (nonvolatile memory) 
即使是在关电后,仍然保存 着它们的信息。 现在有很多种非易失性存储器。
由于历史原因,虽然 ROM 中有的类型既可以读也可以写,
但是它们整体上都被称为只读存储器 (Read-Only Memory, ROM)。 
ROM 是以它们能够被重编程(写)的次数和对它们进行重编程所用的机制来区分的。

数据流通过称为总线(bus) 的共享电子电路在处理器和 DRAM 主存之间来来回回 。 
每次CPU 和主存之间的数据传送都是通过一系列步骤来完成的,
这些步骤称为总线事务(bus transaction) 。读事务 (read transaction) 
从主存传送数据到 CPU 。写事务 (write trans- action)从 CPU 传送数据到主存。

总线是一组并行的导线,能携带地址、 数据和控制信号。 取决千总线的设计,数据和
地址信号可以共享同一组导线,也可以使用不同的。同时,两个以上的设备也能共享同一
总线。 

1.CPU执行指令movq A,%rax的过程

读事务是由三个步骤组成的。首先, CPU 将地址 A 放到系统总线上。 
I/0 桥将信号传递到内存总线(图 6-7a) 。 接下来,主存感觉到内存总线上的地址信号,
从内存总线读地址,从 DRAM 取出数据字,并将数据写到内存总线。 
I/0 桥将内存总线信号翻译成系统总线信号,然后沿着系统总线传递。
最后, CPU 感觉到系统总线上的数据,从总线上读数据,并将数据复制到寄存器

2.CPU执行指令movq %rax,A的过程

这里,寄存器%rax 的内容被写到地址 A, CPU 发起写事务。
同样,有三个基本步骤。首先, CPU 将地址放到系统总线上。内存从内存总线读出地址,
并等待数据到达。接下来, CPU 将%rax 中的数据字复制到系统总线 。
最后,主存从内存总线读出数据 字,并且将这些位存储到 DRAM 中。
====================================================================
磁盘存储

磁盘是广为应用的保存大量数据的存储设备,存储数据的数量级可以达到几百到几于
千兆字节,而基千 RAM 的存储器只能有几百或几千兆字节。 不过,从磁盘上读信息的时
间为毫秒级,比从 DRAM 读慢了 10 万倍,比从 SRAM 读慢了 100 万倍。

访问一个磁盘扇区中 512 个字节的时间主要是寻道时间和旋转延迟。访问扇区中的
第一个字节用了很长时间,但是访问剩下的字节几乎不用时间。

因为寻道时间和旋转延迟大致相等,所以将寻道时间乘 2 是估计磁盘访问时间的简
单而合理的方法。

对存储在 SRAM 中的一个 64 位字的访问时间大约是 4ns, 
对DRAM 的访问时间是 60ns。因此,
从内存中读一个 512 个字节扇区大小的块的时间对SRAM 来说大约是 256ns,
对 DRAM 来说大约是 4000ns。磁盘访问时间,大约 lOms, 
是 SRAM 的大 约 40 000 倍,是 DRAM 的大约 2500 倍。

CPU 使用一种称为内存映射 I/O(memory-mapped I/0) 的技术来向 I/0 设备发射命令
在使用内存映射 I/0 的系统中,地址空间中有一块地址是为与 I/0 设备通信保留的

假设磁盘控制器映射到端口 OxaO。随后, CPU可能通过执行三 个对地址 OxaO的存储指令,
发起磁盘读:第一条指令是发送一个命令字,告诉磁盘发起一个读,
同时还发送了其他的参数,例如当读完成时,是否中断 CPU。
第二条指令指明应该读的逻辑块号。
第三条指令指明应该存储磁盘扇区内容的主存地址。

当 CPU 发出了请求之后,在磁盘执行读的时候,它通常会做些其他的工作。
回想一下,一个 1GHz 的处理器时钟周期为 lns, 在用来读磁盘的 16ms 时间里,
它潜在地可能执行 1600 万条指令。在传输进行时,只是简单地等待,
什么都不做,是一种极大的浪费。

在磁盘控制器收到来自 CPU 的读命令之后,它将逻辑块号翻译成一个扇区地址,读
该扇区的内容,然后将这些内容直接传送到主存,不需要 CPU 的干涉(设备可
以自己执行读或者写总线事务而不需要 CPU 干涉的过程,称为直接内存访问 (Direct
Memory Access, DMA) 。这种数据传送称为 DMA 传送CDMA transfer) 。 

在 DMA 传送完成,磁盘扇区的内容被安全地存储在主存中以后,磁盘控制器通过给
CPU 发送一个中断信号来通知 CPU基本思想是中断会发信号到 CPU 芯片的一个外部引脚上。
这会导致 CPU 暂停它当前正在做的工作,跳转到一个操作系统例程。
这个程序会记录下 1/0 已经完成,然后将控制返回到 CPU 被中断的地方。
固态硬盘
固态硬盘
固态硬盘

固态硬盘(Solid State Disk , SSD) 是一种基于闪存的存储技术
在某些情况下是传统旋转磁盘的极有吸引力的替代产品 。

注意,读 SSD 比写要快。随机读和写的性能差别是由底层闪存基本属性决定的。
一个闪存由 B 个块的序列组成,每个块由 P 页组成。 
通常,页的大小是 512 字节~4KB, 块是由 32~128 页组成的,块的大小为 16KB~512KB。 
数据是以页为单位读写的 。 只有在一页所属的块整个被擦除之后,
才能写这一页(通常是指该块中的所有位都被设置为 1) 。不过,一旦一个块被擦除了,
块中每一个页都可以不需要再进行擦除就写一次。 在大约进行 100 000 次重复写之后,
块就会磨损坏。一旦一个块磨损坏之后,就不能再使用了。

随机写很慢,有两个原因。首先,擦除块需要相对较长的时间, lms 级的,
比访问页所需时间要高一个数量级。其次,如果写操作试图修改一个包含已经有数据
(也就是不是全为1)的页, 
那么这个块中所有带有用数据的页都必须被复制到一个新(擦除过的)块, 
然后才能进行对页 p 的写。制造商已经在闪存翻译层中实现了复杂的逻辑,
试图抵消擦写块的高昂代价,最小化内部写的次数,但是随机写的性能不太可能和读一样好。

比起旋转磁盘, SSD 有很多优点。 它们由半导体存储器构成,没有移动的部件,因而
随机访问时间比旋转磁盘要快,能耗更低,同时也更结实。 不过,也有一些缺点。首先,
因为反复写之后,闪存块会磨损,所以 SSD 也容易磨损。
闪存翻译层中的平均磨损 (wear leveling)
逻辑试图通过将擦除平均分布在所有的块上来最大化每个块的寿命。
实际上,平均磨损逻辑处理得非常好,要很多年 SSD 才会磨损坏其次, 
SSD 每字节比旋转磁盘贵大约 30 倍,因此常用的存储容最比旋转磁盘小 100 倍。 
不过,随着 SSD变得越来越受欢迎,它的价格下降得非常快,而两者的价格差也在减少。

不同的存储技术有不同的价格和性能折中。 SRAM 比 DRAM 快一点,而 DRAM 比
磁盘要快很多。另一方面,快速存储总是比慢速存储要贵的。 SRAM 每字节的造价比
DRAM 高, DRAM 的造价又比磁盘高得多 。 SSD 位于 DRAM 和旋转磁盘之间。

例如, Web 浏览器将最近被引用的文档放在本地 磁盘上,利用的就是时间局部性。 
大容量的 Web 服务器将最近被请求的文档放在前端磁 盘高速缓存中,
这些缓存能满足对这些文档的请求,而不需要服务器的任何干预。

1.重复引用相同变量的程序有良好的时间局部性。
2.对千具有步长为 K 的引用模式的程序,步长越小,空间局部性越好。
具有步长为 l 的引用模式的程序有很好的空间局部性。
在内存中以大步长跳来跳去的程序空间局部性会很差。
3.对千取指令来说,循环有好的时间和空间局部性。循环体越小,
循环迭代次数越多,局部性越好。

存储器层次结构

存储技术:不同存储技术的访问时间差异很大。速度较快的技术每字节的成本要比
速度较慢的技术高,而且容量较小。 CPU 和主存之间的速度差距在增大。

计算机软件:一个编写良好的程序倾向千展示出良好的局部性。

概括来说,基于缓存的存储器层次结构行之有效,
是因为较慢的存储设备比较快的存储设备更便宜,还因为程序倾向于展示局部性:

1.利用时间局部性:由于时间局部性,同一数据对象可能会被多次使用。 一旦一个数
据对象在第一次不命中时被复制到缓存中,我们就会期望后面对该目标有一系列的
访问命中。因为缓存比低一层的存储设备更快,对后面的命中的服务会比最开始的
不命中快很多。

2.利用空间局部性:块通常包含有多个数据对象。由千空间局部性,我们会期望后面
对该块中其他对象的访问能够补偿不命中后复制该块的花费。

早期计算机系统的存储器层次结构只有三层: CPU 寄存器、 DRAM 主存储器和磁盘
存储。不过,由千 CPU 和主存之间逐渐增大的差距,系统设计者被迫在 CPU 寄存器文件
和主存之间插入了一个小的 SRAM 高速缓存存储器,称为 L1 高速缓存(一级缓存),
L1 高速缓存的访问速度几乎和寄存器一样快,典型地是大约 4 个时钟周期。

随着 CPU 和主存之间的性能差距不断增大,
系统设计者在 Ll 高速缓存和主存之间又插入了一个更大的高速缓存,
称为 L2 高速缓存,可以在大约 10 个时钟周期内访问到它。
有些现代系统还包括有一个更大的高速缓存,称为 L3 高速缓存,
在存储器层次结构中,它位于 L2 高速缓存和主存之间,可以在大约50个周期内访问到它。
虽然安排上有相当多的变化,但是通用原则是一样的。对于下一节中的讨论,
我们会假设一个简单的存储器层次结构, CPU 和主存之间只有一个 Ll 高速缓存。


正如我们看到的,高速缓存关于读的操作非常简单。首先,在高速缓存中查找所需字
w 的副本。如果命中,立即返回字 w 给 CPU。如果不命中,
从存储器层次结构中较低层中取出包含字 w 的块,
将这个块存储到某个高速缓存行中(可能会驱逐一个有效的行),然后返回字w。

写的情况就要复杂一些了。假设我们要写一个已经缓存了的字 w(写命中, write hit) 。 
在高速缓存更新了它的 w 的副本之后,怎么更新 w 在层次结构中紧接着低一层中的副本呢?
最简单的方法,称为直写 (write-through), 
就是立即将w的高速缓存块写回到紧接着的低一层中。
虽然简单,但是直写的缺点是每次写都会引起总线流量。另一种方法,称为写回(write-back), 
尽可能地推迟更新,只有当替换算法要驱逐这个更新过的块时,才把它写到紧接着的低一层中。
由于局部性,写回能显著地减少总线流扯,但是它的缺点是增加了复杂性。
高速缓存必须为每个高速缓存行维护一个额外的修改位(dirty bit), 
表明这个高速缓存块是否被修改过。
1 高速缓存大小的影响

一方面,较大的高速缓存可能会提高命中率。另一方面,
使大存储器运行得更快总是要难一些的。结果,较大的高速缓存可能会增加命中时间。
这解释了为什么 L1 高速缓存比 L2 高速缓存小,
以及为什么 L2 高速缓存比 L3 高速缓存小。

2 块大小的影响

大的块有利有弊。一方面,较大的块能利用程序中可能存在的空间局部性,帮助提高
命中率。不过,对千给定的高速缓存大小,块越大就意味着高速缓存行数越少,这会损害
时间局部性比空间局部性更好的程序中的命中率。较大的块对不命中处罚也有负面影响,
因为块越大,传送时间就越长。现代系统(如 Core i7)会折中使高速缓存块包含 64 个字节。

3 相联度的影响

较高的相联度(也就是 E 的值较大)的优点是降低了高速缓存
由于冲突不命中出现抖动的可能性。
不过,较高的相联度会造成较高的成本。
较高的相联度实现起来很昂贵,而且很难使之速度变快。
每一行需要更多的标记位,每一行需要额外的 LRU 状态位和额外的控制逻辑。 
较高的相联度会增加命中时间,因为复杂性增加了,另外,还会增加不命中处罚,
因为选择牺牲行的复杂性也增加了 。

4 写策略的影响

直写高速缓存比较容易实现,而且能使用独立于高速缓存的写缓冲区 (write buffer) , 
用来更新内存。 此外,读不命中开销没这么大,因为它们不会触发内存写。 
另一方面,写回高速缓存引起的传送比较少,
它允许更多的到内存的带宽用于执行DMA的I/O设备。
此外,越往层次结构下面走,传送时间增加,减少传送的数量就变得更加重要。
一般而言,高速缓存越往下层,越可能使用写回而不是直写。

我的理解:

因为CPU读取内存和计算速度有着巨大差异,于是设置了多级缓存来提升读取速度.

最消耗时间的是从主存地址中拿到数据,而CPU计算速度相对于读取内存数据快到忽律不计。

缓存把主存数据的一块连续值给拿过来,省去了从主存中获得数据的时钟周期。

但是缓存很小一次只能拿一小块数据,以Cache Line为单位,最小单位(64Bytes)

以for循环为例子,遍历一个二维数组即可以按行遍历,也可以按列遍历

但是缓存读取是一条横线这样读过去的。

如果按列读取,每读一次就要更新缓存数据,时间翻倍。

还有多线程同时遍历一块内存地址也会造成Cache Line的更新,这样适得其反。

系统最消耗时间的行为就是读取内存地址对应的数据

而作为一个程序员,提升效率的就是减少无意义的读取内存地址数据

链接

链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译
(separate com-pilation)成为可能。
我们不用将一个大型的应用程序组织为一个巨大的源文件,
而是可以 把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。
当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,
而不必重新编译其他文件。

1.理解链接器将帮助你构造大型程序。构造大型程序的程序员经常会遇到由千缺少模
块、缺少库或者不兼容的库版本引起的链接器错误。除非你理解链接器是如何解析
引用、什么是库以及链接器是如何使用库来解析引用的,否则这类错误将令你感到
迷惑和挫败。

2.理解链接器将帮助你避免一些危险的编程错误。 
Linux 链接器解析符号引用时所做的决定可以不动声色地影响你程序的正确性。 
在默认情况下,错误地定义多个全局变最的程序将通过链接器,而不产生任何警告信息。 
由此得到的程序会产生令人迷惑的运行时行为,而且非常难以调试。
我们将向你展示这是如何发生的,以及该如何避免它。

3.理解链接将帮助你理解语言的作用域规则是如何实现的。
例如,全局和局部变量之间的区别是什么? 当你定义一个具有 static属性的变量或者函数时,
实际到底意 味着什么?

4.理解链接将帮助你理解其他重要的系统概念。 
链接器产生的可执行目标文件在重要的系统功能中扮演着关键角色,
比如加载和运行程序、虚拟内存、分页、内存映射。

5.理解链接将使你能够利用共享库。 多年以来,链接都被认为是相当简单和无趣的。
然而,随着共享库和动态链接在现代操作系统中重要性的日益加强,
链接成为一个复杂的过程,为掌握它的程序员提供了强大的能力 。 
比如,许多软件产品在运行时使用共享库来升级压缩包装的(shrink-wrapped)二进制程序。
还有,大多数 Web 服务器都依赖于共享库的动态链接来提供动态内容。

静态链接

符号解析(symbol resolution) 。目标文件定义和引用符号,每个符号对应于一个函数、
一个全局变蜇或一个静态变量(即 C 语言 中任何以 static 属性声明的变量)。 
符号解析的目的是将每个符号引用正好和一个符号定义关联起来。

重定位(relocation) 。编译器和汇编器生成从地址 0 开始的代码和数据节。
链接器通 过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对
这些符号的引用,使得它们指向这个内存位置。 
链接器使用汇编器产生的重定位条目 (relocation entry) 的详细指令,
不加甄别地执行这样的重定位 。

不过,对全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义
的符号(变蜇或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符
号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引
用符号的定义,就输出一条(通常很难阅读的)错误信息并终止。

C++ 和 Java 都允许重载方法,这些方法在源代码中有相同的名字,却有不同的参数 列表。
那么链接器是如何区别这些不同的重栽函数之间的差异呢? C++ 和 Java 中能使用重载函数,
是因为编译器将每个唯一的方法和参数列表组合编码成一个对链接器来说唯一的名字 。 
这种编码过程叫做重整(mangling), 而相反的过程叫做恢复(demangling) 。 

加载器将可执行文件的内容映射到内存,并运行这个程序。
链接器还可能生成部分链接的可执行目标文件,
这样的文件中有对定义在共享库中的例程和数据的未解析的引用。
在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,
它通过加载共享库和重定位程序中的引用来完成链接任务。

异常控制流

但是系统也必须能够对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕
获的,而且也不一定要和程序的执行相关。 比如, 一个硬件定时器定期产生信号,这个事件
必须得到处理。 包到达网络适配器后,必须存放在内存中。程序向磁盘请求数据,然后休
眠,直到被通知说数据己就绪。当子进程终止时,创造这些子进程的父进程必须得到通知。

现代系统通过使控制流发生突变来对这些情况做出反应。一般而言,我们把这些突变
称为异常控制流 (Exceptional Control Flow, ECF) 。
异常控制流发生在计算机系统的各个层次。
比如, 在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。
在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。
在应用层,一个进程可以发送信号到另一个进程,
而接收者会将控制突然转移到它的一个信号处理程序。 
一个程序可以通过回避通常的栈规则,
并执行到其他函数中任意位置的非本地跳转来对错误做出反应。

理解 ECF 将帮助你理解重要的系统概念。 ECF 是操作系统用来实现 I/0、进程和
虚拟内存的基本机制。在能够真正理解这些重要概念之前,你必须理解 ECF。

理解 ECF 将帮助你理解应用程序是如何与操作系统交互的 。应用程序通过使用一
个叫做陷阱(trap) 或者系统调用 (system call) 的 ECF 形式,向操作系统请求服务。
比如,向磁盘写数据、从网络读取数据、创建一个新进程,以及终止当前进程,都
是通过应用程序涸用系统调用来实现的。 理解基本的系统调用机制将帮助你理解这
些服务是如何提供给应用的。

理解 ECF 将帮助你编写有趣的新应用程序。操作系统为应用程序提供了强大的
ECF 机制,用来创建新进程、等待进程终止、通知其他进程系统中的异常事件,以
及检测和响应这些事件。如果理解了这些 ECF 机制,那么你就能用它们来编写诸
如 Unix shell 和 Web 服务器之类的有趣程序了。 

理解 ECF 将帮助你理解并发。 ECF 是计算机系统中实现并发的基本机制。在运行
中的并发的例子有:中断应用程序执行的异常处理程序,在时间上重叠执行的进程
和线程,以及中断应用程序执行的信号处理程序。理解 ECF 是理解并发的第一步。

理解 ECF 将帮助你理解软件异常如何工作。像 C++ 和 Java 这样的语言通过 try、
catch 以及 throw 语句来提供软件异常机制。软件异常允许程序进行非本地跳转 
(即违反通常的调用/返回栈规则的跳转)来响应错误情况。
非本地跳转是一种应用层 ECF, 在 C 中是通过 setjmp 和 longjmp 函数提供的。
理解这些低级函数将帮助 你理解高级软件异常如何得以实现。

在任何情况下,当处理器检测到有事件发生时,
它就会通过一张叫做异常表(exception table)的跳转表,进行一个间接过程调用(异常),
到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序 (exception handler))。
当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下 3 种情况中的一种:
1) 处理程序将控制返回给当前指令, 即当事件发生时正在执行的指令。
2) 处理程序将控制返回给下一条指令, 如果没有发生异常将会执行的下一条指令。
3) 处理程序终止被中断的程序。

当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。
相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped) 。
当父进程回收己终止的子 进程时,内核将子进程的退出状态传递给父进程,
然后抛弃己终止的进程,从此时开始,该进程就不存在了。
一个终止了但还未被回收的进程称为僵死进程(zombie) 。

一个终止了但还未被回收的进程称为僵死进程(zombie) 。

在民间传说中,僵尸是活着的尸体,一种半生半死的实体。僵死进程已经终止了,
而内核仍保留着它的某些状态直到父进程回收它为止,从这个意义上说它们是类似的。

从程序员的角度,我们可以认为进程总是处于下面三种状态之一:
1.运行。进程要么在 CPU 上执行,要么在等待被执行且最终会被内核调度

2.停止。进程的执行被挂起 (suspended), 且不会被调度。当收到 SIGSTOP、 SIGT-STP、 
SIGTTIN 或者 SIGTTOU 信号时,进程就停止, 
并且保持停止直到它收到一个 SIGCONT 信号,在这个时刻,进程再次开始运行。
信号是一种软件中断的形式。

3.终止。 进程永远地停止了 。 进程会因为三种原因终止: 
1)收到一个信号,该信号的默认行为是终止进程, 
2)从主程序返回
3)调用 exit 函数。

在本节中,我们将研究一种更高层的软件形式的异常,称为Linux 信号,
它允许进程和内核中断其他进程。

一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。
每个信号类型都有一个预 定义的默认行为,是下面中的一种:
1.进程终止 。
2.进程终止并转储内存。
3.进程停止(挂起)直到被 SIGCONT 信号重启。
4.进程忽略该信号。

这里我们的目标是给你一些保守的编写处理程序的原则,
使得这些处理程序能安全地并发运行。 如果你忽视这些原则,
就可能有引入细微的并发错误的风险。如果有这些错误, 
程序可能在绝大部分时候都能正确工作。然而当它出错的时候,
就会错得不可预测和不可重复,这样是很难调试的。一定要防患于未然! 

C 语言提供了一种用户级异常控制流形式,称为非本地跳转(nonlocal jump), 
它将控制直接从一个函数转移到另一个当前正在执行的函数,
而不需要经过正常的调用-返回序列。
非本地跳转是通过 setjmp 和 longjmp 函数来提供的 。
setjmp可以直接当前函数调转到另一个函数,设置调整点

C++ 和 Java 提供的异常机制是较高层次的,
是C语言的setjmp和longjmp函数的更加结构化的版本。
你可以把 try 语句中的 catch 子句看做类似于 setjmp 函数。 
相似地, throw 语句就类似于 longjmp 函数。

在硬件层 ,异常是由处理器中的事件触发的控制流中的突变。
控制流传递给一个软件处理程序,该处理程序进行一些处理,
然后返回控制给被中断的控制流。

有四种不同类型的异常:中断、故障、终止和陷阱。 
当一个外部 1/0 设备(例如定时跺芯片或者磁盘控制器)设置了处理器芯片上的中断管脚时,
(对千任意指令)中断会异步地发生。控制返回到故障指令后面的那条指令。
一条指令的执行可能导致故障和终止同步发生。 故障处理程序会重新启动故障指令,
而终止处理程序从不将控制返回给被中断的流。最后,
陷阱就像是用来实现向应用提供到操作系统代码的受控的入口点的系统调用的函数调用 。

在操作系统层,内核用 ECF 提供进程的基本概念。 
进程提供给应用两个重要的抽象: 
1)逻辑控制流,它提供给每个程序一个假象,好像它是在独占地使用处理器, 
2)私有地址空间,它提供给每个程序一个假象,好像它是在独占地使用主存。

在操作系统和应用程序之间的接口处,应用程序可以创建子进程,
等待它们的子进程停止或者终止,运行新的程序,以及捕获来自其他进程的信号。
信号处理的语义是微妙的,并且随系统不同而不同。
然而, 在与 Posix 兼容的系统上存在着一些机制,
允许程序清楚地指定期望的信号处理语义。 

虚拟内存

有一个最大疑惑就是硬盘的速度和内存的速度有着巨大差距的。
虚拟内存如何解决这个问题?

为了更加有效地管理内存并且少出错,现代系统提供了一种对主存的抽象概念,叫做
虚拟内存(VM) 。虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完
美交互,它为每个进程提供了一个大的、 一致的和私有的地址空间 。 通过一个很清晰的机
制,虚拟内存提供了三个重要的能力: 1)它将主存看成是一个存储在磁盘上的地址空间的
高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过
这种方式,它高效地使用了主存。 2)它为每个进程提供了一致的地址空间,从而简化了内
存管理。 3)它保护了每个进程的地址空间不被其他进程破坏。

虚拟内存是核心的。虚拟内存遍及计算机系统的所有层面,在硬件异常、汇编器、
链接器、加载器、共享对象、文件和进程的设计中扮演着重要角色。理解虚拟内存
将帮助你更好地理解系统通常是如何工作的。

虚拟内存是强大的。虚拟内存给予应用程序强大的能力,可以创建和销毁内存片
(chunk) 、将内存片映射到磁盘文件的某个部分,以及与其他进程共享内存。 
比如, 你知道可以通过读写内存位置读或者修改一个磁盘文件的内容吗?或者可以加载一
个文件的内容到内存中,而不需要进行任何显式地复制吗?理解虚拟内存将帮助你
利用它的强大功能在应用程序中添加动力。

虚拟内存是危险的。每次应用程序引用一个变最、间接引用一个指针,或者调用一个
诸如 malloc 这样的动态分配程序时,它就会和虚拟内存发生交互。 
如果虚拟内存使用不当,应用将遇到复杂危险的与内存有关的错误。 
例如,一个带有错误指针的程序可以立即崩溃千"段错误”或者“保护错误",
它可能在崩溃之前还默默地运行了几个小时,或者是最令人惊慌地,
运行完成却产生不正确的结果。 
理解虚拟内存以及诸如 malloc 之类的管理虚拟内存的分配程序,可以帮助你避免这些错误。 

内存映射的概念来源千一个聪明的发现:
如果虚拟内存系统可以集成到传统的文件系统中,
那么就能提供一种简单而高效的把程序和数据加载到内存中的方法。

malloc 函数返回一个指针,指向大小为至少 size 字节的内存块,
这个块会为可能包含在这个块内的任何数据对象类型做对齐。
实际中,对齐依赖于编译代码在 32 位模式(gcc -m32)还是 64位模式(默认的)中运行。
在 32 位模式中, malloc 返回的块的地址总是8的倍数。
在 64 位模式中,该地址总是 16 的倍数。

malloc 不初始化它返回的内存。
那些想要已初始化的动态内存的应用程序可以使用 calloc, 
calloc 是一个基千 malloc 的瘦包装函数,它将分配的内存初始化为零。
想要改变一个以前已分配块的大小,可以使用 realloc 函数。 

动态分配内存的好处:节省内存空间

实际上,一个系统中被所有进程分配的虚拟内存的全部数量是受磁盘上交
换空间的数最限制的。好的程序员知道虚拟内存是一个有限的空间,
必须高效地使用。对千可能被要求分配和释放大块内存的动态内存分配器来说,尤其如此。

造成堆利用率很低的主要原因是一种称为碎片的现象,
当虽然有未使 用的内存但不能用来满足分配请求时,就发生这种现象。
有两种形式的碎片:内部碎片和外部碎片 

内部碎片是在一个已分配块比有效载荷大时发生的。很多原因都可能造成这个问题。
例如,一个分配器的实现可能对已分配块强加一个最小的大小值,
而这个大小要比某个请求的有效载荷大。

内部碎片的量化是简单明了的。它就是已分配块大小和它们的有效载荷大小之差的和。
因此,在任意时刻,内部碎片的数最只取决于以前请求的模式和分配器的实现方式。

外部碎片是当空闲内存合计起来足够满足一个分配请求,
但是没有一个单独的空闲块足够大可以来处理这个请求时发生的。


虚拟内存是对主存的一个抽象。
支持虚拟内存的处理器通过使用一种叫做虚拟寻址的间接形式来引用主存。
处理器产生一个虚拟地址,在被发送到主存之前,这个地址被翻译成一个物理地址。
从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。
专门的硬件通过使用页表来翻译虚拟地址,而页表的内容是由操作系统提供的。

虚拟内存提供三个重要的功能。
第一,它在主存中自动缓存最近使用的存放磁盘上的虚拟地址空间的内容。
虚拟内存缓存中的块叫做页。
对磁盘上页的引用会触发缺页,缺页将控制转移到操作系统中的一个.缺页处理程序。
缺页处理程序将页面从磁盘复制到主存缓存,如果必要,将写回被驱逐的页。

第二,虚拟内存简化了内存管理,进而又简化了链接、在进程间共享数据、
进程的内存分配以及程序加载。最后,虚拟内存通过在每条页表条目中加入保护位,
从而了简化了内存保护。

现代系统通过将虚拟内存片和磁盘上的文件片关联起来,来初始化虚拟内存片,
这个过程称为内存映射。内存映射为共享数据、
创建新的进程以及加载程序提供了一种高效的机制。
应用可以使用 mmap 函 数来手工地创建和删除虚拟地址空间的区域。
然而,大多数程序依赖千动态内存分配器,例如 malloc, 
它管理虚拟地址空间区域内一个称为堆的区域。
动态内存分配器是一个感觉像系统级程序的应用级程序,它直接操作内存,
而无需类型系统的很多帮助。分配器有两种类型。
显式分配器要求应用显式地释放它们的内存块。
隐式分配器(垃圾收集器)自动释放任何未使用的和不可达的块。

对于 C 程序员来说,管理和使用虚拟内存是一件困难和容易出错的任务。
常见的错误示例包括:间接引用坏指针,读取未初始化的内存,允许栈缓冲区溢出,
假设指针和它们指向的对象大小相同,引用指针而不是它所指向的对象,
误解指针运算,引用不存在的变量,以及引起内存泄漏。

程序间的交互和通信

了解 Unix I/O 将帮助你理解其他的系统概念。 I/O是系统操作不可或缺的一部分,
因 此,我们经常遇到 I/O 和其他系统概念之间的循环依赖。例如, I/O 在进程的创建和
执行中扮演着关键的角色。 反过来,进程创建又在不同进程间的文件共享中扮演着关
键角色。 因此,要真正理解 I/O, 你必须理解进程,反之亦然。在对存储器层次结构、
链接和加载、进程以及虚拟内存的讨论中,我们已经接触了 I/O 的某些方面。 既然你
对这些概念有了比较好的理解,我们就能闭合这个循环,更加深入地研究 I/O。

有时你除了使用 Unix I/O 以外别无选择。 在某些重要的情况中,
使用高级 I/O 函 数不太可能,或者不太合适。
例如,标准 I/O 库没有提供读取文件元数据的方式,
例如文件大小或文件创建时间。另外, I/O 库还存在一些问题,
使得用它来进行网络编程非常冒险。

Linux 提供了少星的基于 Unix I/O 模型的系统级函数,
它们允许应用程序打开、关闭、读和写文件,提取文件的元数据,以及执行 I/O 重定向 。 
Linux 的读和写操作会出现不足值,应用程序必须能正确地 预计和处理这种情况。
应用程序不应直接调用 Unix I/O 函数,而应该使用 RIO 包,
RIO 包通过反复执行读写操作,直到传送完所有的请求数据,自动处理不足值。

网络编程

sockaddr_in,_in 后缀是互联网络 (internet) 的缩写,而不是输入(input) 的缩写。

EOF 的概念常常使人们感到迷惑,尤其是在因特网连接的上下文中 。 
首先,我们需要理解其实并没有像 EOF 宇符这样的一个东西 。 
进一步来说, EOF 是由内核检测到的一种条件。
应用程序在它接收到一个由 read 函数返回的零返回码时,
它就会发现出 EOF 条件。 对于磁盘文件,当前文件位置超出文件长度时,会发生 EOF。
对于因特网连接,当一个进程关闭连接它的那一端时,会发生 EOF。 
连接另一端的进程在试图读取流中最后一个字节之后的字节时,会检测到 EOF。

并发编程

访问慢速I/O设备。当一个应用正在等待来自慢速I/O 设备(例如磁盘)的数据到达
时,内核会运行其他进程,使 CPU 保持繁忙。每个应用都可以按照类似的方式,
通过交替执行I/O请求和其他有用的工作来利用并发。

与人交互。和计算机交互的人要求计算机有同时执行多个任务的能力。例如,他们
在打印一个文档时,可能想要调整一个窗口的大小。 现代视窗系统利用并发来提供
这种能力。每次用户请求某种操作(比如通过单击鼠标)时,一个独立的并发逻辑流
被创建来执行这个操作。

通过推迟工作以降低延迟。有时,应用程序能够通过推迟其他操作和并发地执行它
们,利用并发来降低某些操作的延迟。比如, 一个动态内存分配器可以通过推迟合
并,把它放到一个运行在较低优先级上的并发“合并“流中,在有空闲的 CPU 周
期时充分利用这些空闲周期,从而降低单个 free 操作的延迟。 

服务多个网络客户端。我们在第 11 章中学习的迭代网络服务器是不现实的,因为它
们一次只能为一个客户端提供服务。因此,一个慢速的客户端可能会导致服务器拒绝
为所有其他客户端服务。对千一个真正的服务器来说,可能期望它每秒为成百上千的
客户端提供服务,由千一个慢速客户端导致拒绝为其他客户端服务,这是不能接受
的。一个更好的方法是创建一个并发服务器,它为每个客户端创建一个单独的逻辑
流。这就允许服务器同时为多个客户端服务,并且也避免了慢速客户端独占服务器。

在多核机器上进行并行计算。许多现代系统都配备多核处理器,多核处理器中包含
有多个 CPU。被划分成并发流的应用程序通常在多核机器上比在单处理器机器上运
行得快,因为这些流会并行执行,而不是交错执行。

无论是在单处理器还是多处理器上运行程序,都要同步你对共享变量的访问 。

进程是由内核自动调度的,而且因为它们有各自独立的虚拟地址空间,所以要实现共享数据,
必须要有显式的 IPC 机制。事件驱动程序创建它们自己的并发逻辑流,
这些逻辑流被模型化为状态机,用I/0多路复用来显式地调度这些流。
因为程序运行在一个单一进程中,所以在流之间共享数据速度很快而且很容易。
线程是这些方法的混合。同基于进程的流一样,线程也是由内核自动调度的。
同基千 I/ O 多路复用的流一样,线程是运行在一个单一进程的上下文中的,
因此可以快速而方便地共享数据。

无论哪种并发机制,同步对共享数据的并发访问都是一个困难的问题。
提出对信号量的 P 和 V 操作就是为了帮助解决这个问题。
信号量操作可以用来提供对共享数据的互斥访问,
也对诸如生产者-消费者程序中有限缓冲区和读者-
写者系统中的共享对象这样的资源访问进行调度。
一个并发预线程化的 echo 服务器提供了信号摄使用场景的很好的例子。

线程

不同的语言有着不同的创建线程方式,但功能是非常接近的。

我理解的线程:反复执行一个不可变的函数指令体。

我有一个疑惑就是线程是为了多核心CPU设计的吗?

没有多核心的CPU是没有必要使用线程的,因为它只能中断,而不能并行?

多核心CPU是零几年才开始商用(IBM,AMD,Intel)。

1991的Linux和C89标准我没有找到有关线程的信息,

在维基百科找到了POSIX线程(通常被称为pthreads)也是并行执行模型,

它允许程序控制多个不同工作流,这些工作在时间上重叠。每个工作流称为线程.

https://en.wikipedia.org/wiki/POSIX

根据查询资料来看,单核CPU也可以使用线程,给CPU发中断信号,

让CPU轮流执行函数来提升计算效率。这只是并发执行代码。

现代线程是并发执行代码还是并行执行代码呢,是否可以控制呢?


Linux的pthread.h

int pthread_create(
1.线程的唯一标识ID
typedef unsigned long int pthread_t;

2.线程的属性结构体,pthread_attr_t/NULL
typedef struct
{
       int                       detachstate;   // 线程的分离状态
       int                       schedpolicy;   // 线程调度策略
       struct   sched_param      schedparam;    // 线程的调度参数
       int                       inheritsched;  // 线程的继承性
       int                       scope;         // 线程的作用域
       size_t                    guardsize;     // 线程栈末尾的警戒缓冲区大小
       int                       stackaddr_set; // 线程的栈设置
       void*                     stackaddr;     // 线程栈的位置
       size_t                    stacksize;     // 线程栈的大小
} pthread_attr_t;

3.函数块的入口地址;

4.函数传参的首地址, 地址/NULL;
)

创建成功返回0

这里面最复杂的就是线程的属性设置,原来我肤浅认为线程只有优先级,中断设置。

这是我牛毛的理解

1.detachstate

PTHREAD_CREATE_DETACHED告诉CPU我这个线程要一直跑,直到跑完

则该线程一退出,便可重用其线程 ID 和其他资源

PTHREAD_CREATE_JOINABLE默认值,这个线程可以被暂停去执行其它函数

设置这个值需要使用函数pthread_attr_setdetachstate()

2.schedpolicy

假设一个线程被分成了很多的时间段运行,可是CPU只能一下执行一段线程代码段,应该执行谁呢?

宏定义 肤浅理解
SCHED_FIFO 告诉CPU, 我这段被执行的代码段全都要
SHCED_RR 告诉CPU, 这段代码段我已经分成好几份了,一下执行一份就可以了
SCHED_OTHER|NORMAL 默认的,告诉CPU干完就可以了
SCHED_BATCH 批处理,压缩文件
SCHED_IDLE 节能,不唤醒休眠CPU,等待在使用中的CPU
SCHED_DEADLINE 精准,当事件发生后,它必须在确定的时间范围内做出响应

3.sched_param

当两个线程同时请求CPU,默认策略都是一样的怎么决定执行权呢?

struct sched_param { 
    int32_t  sched_priority; 
    int32_t  sched_curpriority; 
    union { 
        int32_t  reserved[8]; 
        struct {    
            int32_t  __ss_low_priority;  
            int32_t  __ss_max_repl;  
            struct timespec     __ss_repl_period;//上一次运行时间   
            struct timespec     __ss_init_budget;//预算时间
        }           __ss;   
    }           __ss_un;    
}

typedef long time_t;
struct timespec {
    time_t tv_sec; // seconds 
    long tv_nsec; // and nanoseconds 
};

//priority优先级
//SCHED_OTHER 是不支持优先级使用的,
//而 SCHED_FIFO 和 SCHED_RR 支持优先级的使用,
//他们分别为1和99,自己定义等级大小

4.inheritsched

一个调度策略可以反复被pthread_create()使用,同时这个调度策略又可以动态改变

但是调度策略每产生一个线程就有一个副本,通过这个属性可以设置副本是否随着

动态修改而改变。

PTHREAD_INHERIT_SCHED,跟随调度策略的设置

PTHREAD_EXPLICIT_SCHED,不跟随动态设置发生改变

5.scope

告诉CPU,线程是要跟谁抢执行权,是跟同进程的线程,还是与系统抢执行权?

PTHREAD_SCOPE_SYSTEM 表示与系统中所有线程同时竞争 CPU

PTHREAD_SCOPE_PROCESS表示只与主进程内的其他线程竞争 CPU