程序的机器级表示
程序编码
编译
编译时,提升优化级别,能够提升运行速度,可是编译时间会更长,对代码调试会更困难node
编译过程
graph LR
A(C预处理器扩展源代码)-->B(编译器产生两个文件的汇编代码)
B-->C
C(汇编器将汇编代码转变为二进制目标代码)-->D(连接器将目标文件与标准Unix库函数合并,产生最终的可执行文件)
汇编代码与目标代码
汇编代码接近于机器代码,目标代码是二进制格式,汇编代码的特色是,可读性很好的文本格式表示程序员
数据格式
Intel用术语"字(word)"表示16位数据类型,所以称32位为双字,64位为4字算法
优化程序性能
编写高效程序
- 选择一组最好的算法和数据结构
- 编写出编译器可以有效优化以转换成高效可执行代码的源代码
编译技术
- 与机器有关:依赖机器的低级细节
- 与机器无关:不考虑计算机的特性
存储器别名使用
编译器必须假设不一样的指针可能指向存储器的同一个位置,阻碍编译器优化shell
消除循环的低效率
Amdahl定律
当咱们加快系统中某一部分的速度时,对系统总体的影响取决于这个部分有多重要和速度提升了多少数据库
编号高速缓存友好的代码
让最多见的状况运行的更快
核心函数的少许循环编程
循环内部,缓存不命中数量最小
时间局部性
被引用过的一次的存储器位置在不久的未来被屡次引用数组
空间局部性
若是一个存储器被引用一次,在不久的未来引用附近的一个存储器位置浏览器
存储器山
- run函数的参数size和stride容许咱们控制产生读序列的局部性程度
- size越小,每次读取的越小,变量引用更快,时间局部性越好;stride越小,写入越快,空间局部性越好
- 反复以不一样的size和stride调用run函数,造成读带宽的时间和空间局部性的二维函数,称为存储器山
第二部分
第七章 连接
7.1 编译器驱动程序
连接
- 连接就是将不一样部分的代码和数据组合成一个单一文件的过程,文件可被加载到存储器执行
- 连接能够执行于编译时(源代码->机器代码),也能够加载时(程序加载到内存中执行),,甚至在运行时由应用程序执行
- 早期,连接是手动执行,如今系统中,由叫作连接器的程序自动执行
7.3 目标文件
可重定位目标文件
包含二进制代码和数据,可在编译时与其余可重定位目标文件合并起来,建立一个可执行目标文件缓存
可执行目标文件
包含二进制代码和数据,可直接拷贝到存储器并执行安全
共享目标文件
一种特殊类型的可重定位目标文件,可在加载时或运行时,被动态的加载到存储器并连接
7.4 可重定位目标文件
7.9 加载可执行目标文件
p不是内置的shell命令,因此shell会认为p是一个可执行目标文件,经过调用驻留在存储器中称为加载器的操做系统代码来运行p
- 任何Unix程序均可以调用execve函数来调用加载器
- 加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,而后跳转到程序的第1条指令,即入口点(entry point),来运行程序
- 将程序拷贝到存储器并运行的过程叫作 加载
加载器其实是如何工做的?
- Unix系统中的每一个程序都运行在一个进程上下文中,这个进程上下文有本身的虚拟地址空间
- 当shell运行一个程序时,父shell进程生成一个子进程,它是父进程的一个复制品
- 子进程经过execve系统调用启动加载器,加载器删除子进程已有的虚拟存储器段,并建立一组新的代码、数据、堆和栈段
- 新的栈和堆段被初始化为零,经过将虚拟地址空间中的页映射到可执行文件的页大小组块,新的代码和数据段被初始化为可执行文件的内容
- 加载器跳转到_start地址,最终会调用应用的main函数
- 除了一些头部信息,加载过程当中没有任何从磁盘到存储器的数据拷贝,直到CPU引用一个被映射的虚拟页,才会进行拷贝,此时,操做系统利用它的页面调度机制自动将页面从磁盘传送到存储器
7.10 动态连接共享库
7.12 与位置无关的代码
共享库的目的,就是容许多个正在运行的进程,共享存储器中相同的库代码,节省宝贵的存储器资源
多个进程如何共享一个程序的一个拷贝呢?
给每一个共享库实现分配一个专用地址空间块,而后要求加载器老是在这个地址加载共享库
很差的:1,即便一个进程不使用这个库,那部分空间仍是会分配
2,难以管理: 咱们得保证没有组块会重叠,每次当一个库修改了,就必须确认它的已分配的组块仍是和以前的大小,而且,建立了新的库,得为它找空间,随着时间发展,一个系统有数百个库和各类版本的库,不免地址空间分裂成大量小的、未使用而又再也不能使用的小洞,并且,每一个系统,从库到存储器的分配都是不一样的
编译库代码
不须要连接器修改库代码,能够在任何地址加载和执行代码,这样的代码叫位置无关的代码(PIC代码)
7.13 处理目标文件的工具
第八章 异常控制流
8.1 异常
- 异常是一种形式的异常控制流,它一部分是由硬件实现的,一部分是由操做系统实现的
- 具体细节随系统不一样,而有所不一样
- 对于每一个系统而言,基本的思想都是相同的
异常 就是控制流中的突变,用来响应处理器状态中的某些变化

异常处理过程
在任何状况下,当处理器检测到有事件发生时,他就会经过一张叫作异常表(exception table) 的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操做系统子程序---异常处理程序(exception handler)
当异常处理程序完成处理后,根据异常类型,会发生如下三种状况中的一种:
- 将控制返回给当前指令Icurr(异常发生时正在执行的指令)
- 将控制返回Inext(若是没有发生异常将会执行的下一条指令)
- 终止被中断的程序
8.1.1 异常处理
- 每种类型的异常都分配了一个惟一的非负整数的异常号(exception number),一些是由处理器的设计者分配,其余由操做系统内核(操做系统常驻存储器的部分)的设计者分配
- 处理器设计者分配:被零除、缺页、存储器访问违例、断点以及算术溢出
- 操做系统内核分配: 系统调用、来自外部I/O设备的信号
- 系统启动时,操做系统分配和初始化一张称为 异常表 的跳转表
- 异常号是异常表中的索引,异常表的起始地址放在一个叫作 异常表基寄存器(exception table base register) 的特殊CPU寄存器里

异常相似于过程调用,不一样之处:
- 若是控制从一个用户程序转移到内核,全部这些项目item都被压到内核栈中,而不是压到用户栈中
- 异常处理程序运行在内核模式下,意味着他们对全部的系统资源都有彻底的访问权限
- 过程调用时,跳转以前,处理器将返回地址压到栈中,然而处理异常时,根据异常类型,返回地址要么是当前指令,要么是下一条指令
8.1.3 异常的类型

中断
- 硬件中断不是由任何一条专门的指令形成的,从这个意义上来讲是异步的
- I/O设备,例如网络适配器、磁盘控制器和定时器芯片,经过向处理器芯片上的一个管脚发信号,并把异常号放到系统总线上,来触发中断,这个异常号标识了引起中断的设备
陷阱
- 有意的异常,执行一条指令的结果
- 将控制返回到下一条指令
- 用途: 在用户程序和内核之间提供一个像过程同样的接口, 叫作系统调用
- 系统调用和普通的函数调用区别: 普通函数运行在用户模式(user model) ,用户模式限制了函数能够执行的指令的类型,只能访问与调用函数相同的栈;
- 系统调用运行在内核模式(kernel mode)中,内核模式容许系统调用执行指令,并访问定义在内核中的栈

故障
- 由错误状况引发,可能被故障处理程序修正
- 能修正,控制返回到故障指令,从新执行
- 不能修正,处理程序返回到内核中的abort例程,abort例程会终止引发故障的应用程序

终止
- 终止是不可恢复的致命错误形成的结果--典型的是一些硬件错误,好比DRAM或者SRAM位被损坏时发生的奇偶错误
- 将控制返回给一个abort例程,该例程会终止这个应用程序

8.2 进程
- 当咱们在一个现代系统上运行一个程序时,咱们会获得一个假象,就好像咱们的程序是系统中当前运行的惟一程序,咱们的程序好像是独占的使用处理器和存储器,处理器就好像是无间断的一条接一条地执行咱们程序中的指令,最后,咱们程序中的代码和数据显得好像是系统存储器中惟一的对象,这些假象都是经过进程的概念提供给咱们的
- 一个执行中程序的实例
- 每一个程序都运行在某个进程的上下文(context)中
- 上下文由程序正确运行所需的状态组成的,包括存放在存储器中的程序的代码和数据、栈、通用目的存储器的内容、程序计数器、环境变量以及打开文件描述符的集合
进程提供给应用程序的关键抽象
- 一个独立的逻辑控制流,使咱们以为独占使用处理器
- 一个私有的地址空间,使咱们以为独占的使用存储器系统
8.2.1 逻辑控制流
- 通常而言,每一个逻辑控制流与其它逻辑流想独立
- 的那个进程使用进程间通讯(IPC)机制,好比管道、套接口、共享存储器和信号量,显示地与其它进程交互时,惟一例外就会发生
- 若干个逻辑流在时间上重叠,被称为并发进程,这两个进程被称为并发运行
- 进程和其余进程轮换运行的概念叫 多任务,一个进程执行它的控制流的一部分的每一时间段叫作时间片
- 多任务 也叫作时间分片
8.2.2 私有地址空间
Linux进程的地址空间的结构
- 地址空间底部的四分之三是预留给用户程序的,包括一般的文本、数据、堆和栈段
- 顶部的四分之一是预留给内核的(好比执行系统调用时,使用的代码、数据和栈)

8.2.3 用户模式和内核模式
- 处理器是用某个控制寄存器中的一个方式位(mode bit)来提供这种功能,描述了进程当前享有的权力
- 运行在内核模式的进程,能够执行指令集中的任何指令,而且能够访问系统中任何存储器位置
- 用户模式中的进程不能直接引用地址空间中内核区内的代码和数据,必须经过系统调用接口间接访问内核代码和数据
- 进程从用户模式变为内核模式的惟一方法是经过诸如中断、故障或者陷入系统调用这样的异常
8.2.4 上下文切换
- 操做系统内核利用一种称为上下文切换(context switch)的较高级形式的异常控制流来实现多任务
- 内核能够决定抢占当前进程,从新开始一个先前被抢占的进程,这种决定叫 调度(scheduling) ,是由内核中称为 调度器(scheduler) 的代码处理的
上下文切换过程
- 保存当前进程的上下文
- 恢复某个先前被抢占进程所保存的上下文
- 将控制传递给这个新恢复的进程
- 中断也可能引起上下文切换,好比,全部的系统都有某种产生周期性定时器中断的机制,典型的为每1ms或10ms,每次发生定时器中断时,内核就能断定当前进程已经运行了足够长的时间了,并切换到一个新的进程

切换过程
- 磁盘读取数据须要较长时间(数量级为十几ms),因此内核执行从进程A到进程B的上下文切换,而不是等待
- 注意,切换以前,内核正表明进程A在用户模式下执行指令
- 在切换的第一步中,内核表明进程A在内核模式下执行指令,而后在某一时刻,内核开始表明B进程执行指令(内核模式),切换完成后,内核表明B在用户模式下执行指令
- B在用户模式下运行一下子,直到磁盘发出一个中断信号,表示数据已经传到存储器,内核断定B已经运行足够长时间,就执行一个从B切换A的上下文切换,将控制返回给A中紧随在read系统调用以后的那条指令,进程A继续运行,直到下一次异常发生
高速缓存污染和异常控制流
- 通常而言,硬件高速缓存存储器(L1,L2,L3很是小)不能和诸如中断和上下文切换这样的异常控制流很好的交互
- 若是当前进程被中断,那么对于中断处理程序来讲,高速缓存是冷的
- 若是处理程序从主存中访问了足够多的表目,那么被中断的进程继续时,高速缓存对它来讲也是冷的
- 当一个进程在上下文切换后继续执行时,高速缓存对于应用程序而言也是冷的,必须再次热身
8.3 系统调用和错误处理
- 咱们把系统调用和它们相关的包装函数可互换地 称为 系统级函数

8.4 进程控制
8.4.2
进程的三种状态
- 运行 进程要么在CPU上执行,要么等待被执行且最终会被调度
- 暂停 进程的执行被挂起(suspended) ,且不会被调度 ,当收到SIGSTOP、SIGTSTP、SIDTTIN或者SIGTTOU信号时,进程就暂停,而且保持暂停直到它收到一个SIGCONT信号,这时,进程再次开始运行
- 终止 进程永远地被中止了; 三种缘由会终止进程: 收到一个信号,该信号的默认行为是终止进程; 从主程序返回; 调用exit 函数.
fork函数
- 父进程经过调用fork函数建立一个新的运行子进程
- 新建立的子进程几乎,但不彻底与父进程相同
- 子进程获得与父进程用户级虚拟地址控件相同的一份拷贝,包括文本、数据和bss段、堆以及用户栈,子进程还得到与父进程 任何打开文件描述符 相同的拷贝,意味着 当父进程调用fork时,子进程能够读写父进程中打开的任何文件
- 父进程和新建立的子进程之间最大的区别在于它们有不一样的PID
- fork函数只被调用一次,却会返回两次: 一次是在调用进程(父进程)中,一次在子进程中, 父进程中,fork返回子进程的PID,子进程中fork返回0
- fork返回值用来分辨程序是在父进程仍是在子进程中执行的
8.4.3 回收子进程
- 当一个进程因为某种缘由终止时,内核并不当即从系统中清除,而是被保持在一种终止状态中,直到被它的父进程回收(reaped)
- 当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,而后抛弃已终止的进程,今后时开始,该进程就不存在;额
- 一个终止了但还未被回收的进程称为 僵死进程(zombie)
- 若是父进程没有回收它的僵死子进程,就终止了,内核会安排init进程来回收他们
- init进程的PID为1,而且在系统初始化时,由内核建立
8.4.4 让进程休眠
8.5 信号
- 更高层软件形式的异常,称为 Unix信号 ,它容许进程中断其余进程
- 一个信号就是一条消息,通知进程一个某种类型的事件已经在系统中发生了
- 每种信号类型都对应某个类型的系统事件
- 底层的硬件异常是由内核异常处理程序处理的,对用户进程而言一般是不可见的
- 信号提供了一种机制向用户进程通知这些异常的发生
信号的案例
- ctrl-c,内核会发送SIGINT信号给前台进程
- 一个进程能够经过发送一个SIGKILL(号码9)信号强制终止另一个进程
- 当一个子进程终止或者暂停时,内核会发送一个SIGCHLD(号码17)给父进程
8.5.1 信号术语
- 一个只发出而没有被接收的信号叫作待处理信号(pending signal)
- 在任什么时候刻,一个类型至多只会有一个待处理信号,接下来发送到这个进程的K类型信号不会排队等待,只是被简单地丢弃
- 一个进程能够选择性得阻塞接收某种信号,当一个信号被阻塞时,仍能够被发送,可是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞
8.5.2 发送信号
进程组
- 每一个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的
- 默认的,一个子进程和它的父进程同属于一个进程组
- 一个进程能够经过使用setpgid函数来改变本身或者其余进程的进程组
8.5.3 接收信号
8.5.4 信号处理问题
- 系统信号能够被中断. 像read、write和accept这样的系统调用潜在的会阻塞进程一段较长的时间,称之为 慢速系统调用
- 在某些系统中,当处理程序捕捉到一个信号时,被中断的慢速系统调用在信号处理程序返回时,再也不继续,而是当即返回给用户一个错误条件
8.5.5 可移植的信号处理
- Posix标准定义了sigaction函数,显式的指定他们想要的信号处理语义
- Signal包装函数设置了一个信号处理程序
- 只有这个处理程序当前正在处理的那种类型的信号被阻塞
- 和全部信号实现同样,信号不会排队等待
- 只要可能,被中断的系统调用会自动重启
8.6 非本地跳转
- C提供了一种形式的用户级异常控制流,称为 非本地跳转(nonlocal jump).它将控制直接从一个函数转移到另外一个当前正在执行的函数,而不须要通过正常的调用-返回序列
- 非本地跳转的一个重要应用就是容许从一个深层嵌套的函数调用中当即返回,一般是由检测到某个错误状况引发的
- 若是在一个深层嵌套的函数调用中发现一个错误状况,可使用非本地跳转直接返回到一个普通的本地化的错误处理程序,而不是费力地解开调用栈
- 非本地跳转的另外一个重要应用,是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置
8.7 操做进程的工具
Unix系统提供了大量的监控和操做进程的有用工具:
- strace: 打印一个程序和它的子进程调用的每一个系统调用的轨迹
- ps: 列出系统中当前的进程(包括僵死进程)
- top: 打印出关于当前进程资源使用的信息
- kill: 发送一个信号给进程
- /proc: 一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序能够读取这些内容;好比输入 "cat /proc/loadavg",观察在你的Linux系统上当前的平均负载
第九章 测量程序执行时间
9.1 计算机系统上的时间流
- 在宏观时间尺度上,处理器不停地在许多任务之间切换,一次分配给每一个任务大约5~20ms
- 用户感受上任务是在同时进行的,由于人不可以察觉短于大约100ms的时间段,在这段时间内,处理器能够执行几百万条指令

9.1.1 进程调度和计时器中断
- 外部事件,例如键盘、磁盘操做和网络活动,会产生中断信号,中断信号使得操做系统调度程序得以运行,可能还会切换到另外一个进程
- 咱们也但愿处理器从一个进程切换到另外一个,这样用户看上去就像处理器在同时执行许多程序
- 计算机有一个外部计时器,周期性向处理器发送中断信号,
- 中断信号之间的时间称为 间隔时间(interval time)
- 中断发生时,调度程序能够选择要么继续当前正在执行的进程,要么切换到另外一个进程
- 间隔时间必须足够短,保证任务间切换足够频繁
- 从一个进程切换到另一个进程须要几千个时钟周期来保存当前进程的状态,而且为下一个进程准备好状态,间隔时间过短会致使性能不好
- 典型的计时器间隔范围是 1~10ms
- 操做系统函数,例如处理缺页、输入或者输出(print函数),打印log很耗性能
9.1.2 从应用程序的角度看时间
9.2 经过间隔计数(interval counting)来测量时间
- 对得到程序性能的近似值有用,粒度太粗,不能用于持续时间小于100ms的测量
- 有系统误差,太高的估计计算时间,平均大约4%
- 优势: 它的准确性不是很是依赖于系统负载
9.3 周期计数器
- 运行在时钟周期级的计时器,是一个特殊的寄存器,每一个时钟周期他都会加1
- 不是全部的处理器都有这样的计数器
9.4 用周期计数器来测量程序执行时间
9.5 基于 gettimeofday 函数的测量
9.7 展望将来
系统中引入了几个对性能测量有很大影响的特性
- 频率变化的时钟: 为了下降功耗,将来的系统会改变时钟频率,由于功耗直接与时钟频率相关
9.8 现实生活: K次最优测量方法
9.9 获得的经验教训
- 每一个系统都是不一样的
- 试验能够是很是有启迪性的
- 在负载很重的系统上得到准确的计时特别困难
- 试验创建必须控制一些形成性能变化的因素 高速缓存可以极大地影响一个程序的执行时间,传统的技术是在计时开始以前,清空高速缓存中的全部有用的数据,或是在开始时,把一般会在高速缓存中的全部数据都加载进来
第十章 虚拟存储器
- 存储器很容易被破坏,若是某个进程不当心写了另外一个进程使用的存储器,那么进程可能以某种彻底和程序逻辑无关的使人迷惑的方式失败
- 虚拟存储器是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每一个进程提供了一个大的、一致的、私有地址空间
虚拟存储器提供了三个重要的能力:
- 他将主存当作是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据须要在磁盘和主存之间来回传送数据,经过这种方式,高效的使用了主存
- 它为每一个进程提供了一致的地址空间,从而简化了存储器管理
- 他保护了每一个进程的地址空间不被其余进程破坏
10.1 物理和虚拟寻址
- 计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,每一个字节都有一个惟一的物理地址(physical address),第一个字节的地址为0,接下来的字节地址为1,再下一个为2,依次类推
- CPU访问存储器的最天然的方式就是使用物理地址,这种方式称为 物理寻址(physical addressing)
- 为通用计算设计的现代处理器使用的是 虚拟寻址(virtual addressing)


虚拟寻址的过程
- CPU经过生成一个虚拟地址来访问主存,地址翻译(address translation)将虚拟地址转换为物理地址
- 地址翻译须要CPU硬件和操做系统之间的紧密合做
- CPU芯片上叫作MMU(memory management unit,存储器管理单元)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容由操做系统管理的
10.2 地址空间
- 地址空间是一个非负整数地址的有序集合
- 若是地址空间中的整数是连续的,那么是一个 线性地址空间
- 在一个带虚拟存储器的系统中,CPU从一个有
N = 2^n
个地址的地址空间中生成虚拟地址,称为虚拟地址空间
- 一个地址空间的大小是由表示最大地址所须要的位数来描述的
- 现代系统典型地支持32位或64位虚拟地址空间
- 一个系统还有一个物理地址空间,与系统中物理存储器的M个字节(byte)相对应
10.3 虚拟存储器做为缓存的工具
10.3.1 DRAM高速缓存的组织结构
10.3.2 页表
- 页表将虚拟页映射到物理页,每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表
- 操做系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页
10.3.3 页命中
10.3.4 缺页
- DRAM缓存不命中称为 缺页
- 在磁盘和存储器之间传送页的活动叫作 交换(swapping)或者页面调度(paging)
- 当有不命中发生时,才换入页面的这种策略被称为按需页面调度(demand paging)
10.3.5 分配页面
10.3.6 局部性再次搭救
- 只要咱们的程序有好的时间局部性,虚拟存储器系统就能工做得至关好
- 若是工做集的大小超出了物理存储器的大小,那么程序将产生一种不幸的状态,叫作颠簸(thrashing),这时页面将不断地换进换出
10.4 虚拟存储器做为存储器管理的工具
10.4.1 简化连接
- 独立的地址空间容许每一个进程为它的存储器映像使用相同的基本格式,而无论代码和数据实际存放在物理存储器的何处
10.4.2 简化共享
- 某些状况下,须要进程来共享代码和数据,例如,每一个进程必须调用相同的操做系统内核代码,而每一个C程序都会调用标准库中的程序,好比printf
- 操做系统经过将不一样进程中适当的虚拟页面映射到相同的物理页面,从而多个进程共享这部分代码的一个拷贝,而不是在每一个进程中都包括单独的内核和C标准库的拷贝

10.4.3 简化存储器分配
- 当进程要求额外的堆空间时,操做系统会分配一个适当数字(例如K)个连续的虚拟存储器页面,而且将它们映射到物理存储器中任意位置的K个任意的物理页面
- 因为页表的工做方式,操做系统没有必要分配K个连续的物理存储器页面,页面能够随机的分散在物理存储器中
10.4.4 简化加载
- 映射一个连续虚拟页面的集合到任意一个文件中的任意一个位置,叫 存储器映射(memory mapping)
10.5 虚拟存储器做为存储器保护的工具
- SUP位表示进程是否必须运行在内核模式下,才能访问该页
- 若是一条指令违反了这些许可条件,那么CPU就触发一个通常保护故障,将控制传递给一个内核中的异常处理程序,Unix Shell称这 为 段错误(segmentation fault)

10.6 地址翻译
- 地址翻译是一个N元素的虚拟地址空间中的元素和一个M元素的物理地址空间中元素之间的映射
10.7 案例研究:Pentium/Linux存储器系统
10.8 存储器映射
10.8.1 再看共享对象
- 一个对象能够被映射到虚拟存储器的一个区域,要么做为共享对象,要么做为私有对象
- 一个共享对象映射到的虚拟存储器区域叫作共享区域,私有对象映射到的虚拟存储器区域叫作 私有区域
- 一个进程对共享对象的任何写操做,对于也把这个共享对象映射到它们虚拟存储器的其它进程而言,是可见的,并且,这些变化会反映在磁盘上的原始对象中
- 私有对象使用一种叫作写时拷贝(copy-on-write)的巧妙技术被映射到虚拟存储器中的
- 两个进程将一个私有对象进程映射,共享这个对象同一个物理拷贝,对于每一个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,而且区域结构被标记为私有的写时拷贝
- 只要没有进程试图写它本身的私有区域,它们就能够继续共享物理存储器中对象的一个单独拷贝,然而,只要有一个进程试图写私有区域内的某个页面,这个写操做就会触发一个保护故障
写时拷贝过程
- 当写操做触发保护故障时,故障处理程序就会在物理存储器中建立这个页面的一个新拷贝,更新页表条目指向这个新的拷贝,而后恢复这个页面的可写权限,当故障处理程序返回时,CPU从新执行这个写操做,在新建立的页面上,这个写操做就能够正常执行了
- 经过延迟私有对象中的拷贝直到最后可能的时刻,写时拷贝最充分地使用了稀有的物理存储器

10.8.2 再看fork函数
- fork函数被当前进程调用时,内核为新进程建立各类数据结构,并分配惟一的PID,对当前进程的mm_struct、区域结构和页表的原样拷贝,标记两个进程中的每一个页面为只读的,并标记两个进程中的每一个区域结构为私有的写时拷贝
- 当fork在新进程中返回时,新进程如今的虚拟存储器恰好和调用fork时存在的虚拟存储器相同,当两个进程中的任一个后来进行写操做时,写时拷贝机制就会建立新页面,所以,也就为每一个进程保持了私有地址空间的抽象概念
10.8.3 再看execve函数
execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地替代了当前程序
- 删除已存在的用户区域
- 映射私有区域
- 映射共享区域
- 设置程序计数器
10.8.4 使用mmap函数的用户级存储器映射
Unix进程可使用mmap函数来建立新的虚拟存储器区域,并将对象映射到这些区域中
10.9 动态存储器分配
- 虽然可使用低级的mmap和munmap函数来建立和删除虚拟存储器的区域,可是大部分C程序在运行时须要额外虚拟存储器时,使用一种 动态存储器分配器(dynamic memory allocator)
- 一个动态存储器分配器维护着一个进程的虚拟存储器区域,称为 堆(heap)
- 在大多数Unix系统中,堆是一个请求二进制零的区域
- 对于每一个进程,内核维护着一个变量brk(break),它指向堆的顶部

- 分配器将堆视为一组不一样大小的块(block)的集合来维护
- 每一个块就是一个连续的虚拟存储器组块(chunk),要么是已分配,要么是空闲的
- 已分配块显示地保留为供应用使用,直到被释放,这种释放要么是应用显示执行,要么是存储器分配器自身隐式执行(垃圾回收)
显示分配器(explicit allocator)
- 应用显示地释听任何已分配的块,例如,C标准库的malloc函数分配块,free函数来释放一个块
隐式分配器(implicit allocator)
- 要求分配器检测什么时候一个已分配块再也不被程序使用,而后释放这个块,隐式分配器也叫作 垃圾收集器(garbage collector)
- 自动释放未使用的已分配的块的过程叫作 垃圾收集(garbage collection)
10.9.1 malloc和free函数
- malloc函数返回一个指针,指向大小为至少size字节的存储器块
- 调用free后,指针仍然指向被释放的块,应用在这个块被一个新的malloc调用从新初始化以前,再也不使用这个指针
10.9.2 为何要使用动态存储器分配
10.9.3 分配器的要求和目标
显式分配器必须在一些约束条件下工做:
- 处理任意请求序列 分配器不能够假设分配和释放请求的顺序
- 当即响应请求 分配器必须当即响应分配请求,不容许分配器为了提升性能从新排列或者缓冲请求
- 只使用堆 为了使分配器是可扩展的,使用的任何非标量数据结构都必须保存在堆里
- 对齐块 分配器必须对齐块,使得它们能够保存任何类型的数据对象;在大多数系统中,意味着分配器返回的块是8字节(双字)边界对齐的(int64,float64等)
- 不修改已分配的块 分配器只能操做或改变空闲块;一旦被分配了,就不容许修改或者移动它
- 目标1:最大化吞吐率 吞吐率:在单位时间里完成的请求数(例如:1秒钟500个分配请求,500个释放请求,吞吐率就是没秒1000次操做)
- 目标2:最大化存储器利用率
10.9.4 碎片
- 当未使用的存储器但不能用来知足分配请求时, 碎片 现象
内部碎片
- 已分配块比有效载荷大 好比分配器对已分配块,最小分配8字节,知足对齐约束条件,可是某个有效载荷是2字节
外部碎片
- 空闲存储器合计起来足够知足一个分配请求,可是没有一个单独的空闲块足够大能够来处理这个请求
- 外部碎片是难以量化和不可预测的,因此分配器典型地采用启发式策略来试图维持少许的大空闲块,而不是维持大量的小空闲块
10.9.5 实现问题
- 空闲块组织: 咱们如何记录空闲块
- 放置: 咱们如何选择一个合适的空闲块来放置一个新分配的块
- 分割: 在将一个新分配的块放置到某个空闲块以后,如何处理这个空闲块中的剩余部分?
- 合并: 咱们如何处理一个刚刚被释放的块?
10.9.6 隐式空闲链表

- 块=一个字的头部(4字节,32bit)+有效载荷+填充
- 头部编码了快的大小(包括头部和全部的填充)

隐式空闲链表
- 优势: 简单
- 缺点: 任何操做的开销,例如放置分配的块,要求空闲链表的搜索与堆中已分配块和空闲块的总数呈线性关系
10.9.7 放置分配的块
- 当应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大、能够放置所请求块的空闲块,分配器执行这种搜索的方式是由 放置策略(placement policy) 肯定的
常见的策略
首次适配(first fit)
从头开始搜索空闲链表,选择一个合适的空闲块
- 优势: 趋向于将大的空闲块保留在链表的后面
- 缺点: 在靠近链表起始处留下小空闲块的"碎片",增长了对较大块的搜索时间
下一次适配(next fit)
和首次适配类似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始
- 源于这样的想法: 若是咱们上一次在某个空闲块里已经发现了一个匹配,那么极可能下一次咱们也能在这个剩余块中发现匹配
- 下一次适配比首次适配运行起来更快一些
- 下一次适配的存储器利用率比首次适配低得多
最佳适配(best fit)
检查每一个空闲块,选择匹配所需请求大小的最小空闲块
- 比首次适配和下一次适配的利用率都要高一些
- 缺点: 再简单空闲链表组织结构中,好比隐式空闲链表,使用最佳适配 要求对堆进行完全的搜索
10.9.8 分割空闲块
一旦找到一个匹配的空闲块,就必须决定,分配这个空闲块中多少空间
- 用整个空闲块,这种方式简单而快捷,缺点是 会形成内部碎片
- 将这个空闲块分红两部分,第一部分变成分配块,剩下的变成一个新的空闲块
10.9.9 获取额外的堆存储器
若是分配器不能找到合适的空闲块,将发生什么?
- 合并那些在存储器中物理上相邻的空闲块来建立一些更大的空闲块
- 若是1仍是不能生成一个足够大的块,或者空闲块已经最大程度地合并了,分配器会向内核请求额外的堆存储器,经过调用mmap,或者sbrk函数
3. 以上任一种状况下,分配器都会将额外的存储器转化成一个大的空闲块,插入到空闲链表中,而后将被请求的块放置在这个新的空闲块中
10.9.10 合并空闲块
- 当分配器释放一个已分配块,新释放的空闲块可能与其它块相邻,这些邻接的空闲块可能引发一种现象,叫作 假碎片(fault fragmentation)
- 这些假碎片被切割为小的、没法使用的空闲块,3个字+3个字 没法分配给4个字
- 为了解决假碎片问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为 合并(coalescing)
什么时候执行合并
当即合并(immediate coalescing)
在每次一个块被释放时,就合并全部的相邻块 ,能够在常数时间内执行完成
- 缺点: 在某些请求模式下,块会反复的合并,而后立刻分割,产生大量没必要要的分割和合并
推迟合并(deferred coalescing)
等到某个稍晚的时候再合并空闲块
- 例如,分配器能够推迟合并,直到某个分配请求失败,而后扫描整个堆,合并全部的空闲块
10.9.11 带边界标记的合并
- 当前块的头部指向下一个块的头部,能够检查这个指针以判断下一个块是不是空闲的,若是是,就将它的大小简单地加到当前块头部的大小上,这两个块在常数时间内被合并
- 边界标记(boundary tag),容许在常数时间内进行对前面块的合并
- 在每一个块的结尾处添加一个 脚部(footer边界标记),经过检查脚部,判断前面一个块的起始位置和状态


- 边界标记的概念是简单优雅的,对不一样类型的分配器和空闲链表组织都是通用的
- 缺陷: 要求每一个块都保持一个头部和一个脚部,在应用程序操做许多个小块时,会产生显著的存储器开销,例如: 若是一个图形应用反复的调用malloc和free,来动态的建立和销毁图形节点,而且每一个图形节点都只要求两个存储器字,那么头部和脚部将占用每一个已分配块的一半的空间
边界标记的优化方法
只有在前面的块是空闲时,才会须要用到它的脚部,若是咱们把前面块的已分配/空闲位存放在当前块中多出来的低位中,那么已分配的块就不须要脚部了,空闲块仍然须要脚部
10.9.12 综合: 实现一个简单的分配器
10.9.13 显示空闲链表(双向空闲链表)

- 堆能够组织成一个双向空闲链表,每一个空闲块中,都包含一个pred(祖先)和succ(后继)指针
- 使用双向链表,使首次适配的分配时间从块总数的线性时间减小到了空闲块数量的线性时间
空闲链表中对块排序的策略
新释放的块放置在链表的开始处,释放块能够在常数时间内完成,若是使用边界标记,合并也能够在常数时间内完成
释放一个块须要线性时间的搜索,来定位合适的祖先; 有更高的存储器利用率,接近最佳适配的利用率
显式链表的缺点
- 空闲块必须足够大,以包含全部须要的指针,以及头部和可能的脚部
- 致使更大的最小块大小,潜在地提升了内部碎片的程度
10.9.14 分离的空闲链表(分离存储)
- 一种流行的减小分配时间的方法,一般称为 分离存储(segregated storage),维护多个空闲链表,其中每一个链表中的块有大体相等的大小
- 通常的思路是将全部可能的块大小分红一些等价类,也叫作 大小类(size class)
- 分配器维护着一个空闲链表数组,每一个大小类就是一个空闲链表,按照大小的升序排列,当分配器须要一个大小为n的块时,它就搜索相应的空闲链表,若是它不能找到合适的块与之匹配,它就搜索下一个链表,以此类推
- 有不少种分离存储方法,主要区别在于: 如何定义大小类,什么时候进行合并,什么时候向操做系统请求额外的堆存储器,是否容许分割,等等
简单分离存储(simple segregated storage)
- 每一个大小类的空闲链表包含大小相等的块,每一个块的大小就是这个大小类中最大元素的大小,例如,若是某个大小类定义为{17-32},那么这个类的空闲链表全由大小为32的块组成
- 分配时,检查相应的空闲链表,若是链表为非空,简单的分配其中第一个块的所有,空闲块是不会分割以知足分配请求的
- 若是链表为空,分配器就向操做系统请求一个固定大小的额外存储器组块,将这个组块(chunk)分红大小相等的块,并将这些块连接起来造成新的空闲链表
- 要释放一个块,只须要简单地将这个块插入到相应的空闲链表的前部
理解
- 将空闲内存按照固定大小分块,方便管理,好比大小为32的块组成的链表,当系统向分配器请求分配的内存大小在17-32这个范围内是,就从32的链表中取一个空闲块使用
- 有效减小空闲链表的数量,下降维护难度
- 有时为了减小内部碎片,须要减少17-32这个范围
优势
- 分配和释放块都是很快的常数时间操做
- 每一个组块中都是大小相等的块,不分割,不合并,这意味着每一个块只有不多的存储器开销
- 没有合并,因此已分配块不须要头部(已分配/空闲标记),也不须要脚部
- 分配和释放操做都是在空闲链表的起始处操做,因此链表只须要是单向的
- 惟一在任何块中都须要的字段是每一个空闲块中的一个字的succ指针(后继),所以最小的块大小就是一个字
缺点
- 由于空闲块是不会被分割的,因此会形成内部碎片,好比大量17大小的对象,使用32的块
- 由于不会合并空闲块,所以,某些引用模式会引发极多的外部碎片,好比说请求的都是较大对象,大小类比较小的空闲链表的利用率就很低,不会合并,被浪费着
合并方式对付外部碎片
分配器记录操做系统返回的每一个存储器组块(chunk)中的空闲块的数量,不管什么时候,若是有一个组块(好比32的大小类组块)彻底由空闲块组成,那么分配器就从当前大小类中删除这个组块,回收内存,供其它大小类使用
分离适配(segregated fit)
- 每一个空闲链表是和一个大小类相关联的,而且被组织成某种类型的显式或隐式链表
- 每一个链表包含潜在的大小不一样的块,这些块的大小是大小类的成员
过程
- 肯定请求的大小类,对适当的空闲链表作首次适配,查找一个合适的块
- 若是找到一个,那么分割它,将剩余的部分插入到适当的空闲链表中
- 若是找不到合适的块,就搜索下一个更大的大小类的空闲链表,如此重复,直到找到一个合适的块
- 若是最后未找到合适的块,就请求额外的堆存储器,从这个新的堆存储器中分配一个块,将剩余的部分放置到最大的大小类中
- 要释放一个块,咱们执行合并,将结果放置到相应的空闲链表中
优缺点
- 是一种常见的选择,C标准库中提供的GNU malloc包就是采用这种方法
- 既快速,对存储器的使用也颇有效率
- 搜索时间减小了,由于搜索被限制在堆的某个部分,而不是整个堆
- 有一个有趣的事实: 对分离空闲链表的简单的首次适配搜索至关于对整个堆的最佳适配搜索
伙伴系统
- 伙伴系统(buddy system) 是分离匹配的一种特例,其中每一个大小类都是2的幂,基本的思路是假设一个堆的大小为2的m次方个字,咱们为每一个块大小为2的k次方维护一个分离空闲链表,其中0<=k<=m
- 请求块大小向上舍入到最接近的2的幂
- 最开始时,只有一个大小为2的m次方个字的空闲块
过程

优势
缺点
- 要求块大小为2的幂可能致使显著的内部碎片,所以伙伴系统分配器不适合通用目的的工做负载
- 对于某些与应用相关的工做负载,其中块大小预支知道是2的幂,伙伴系统分配器就颇有吸引力了
10.10 垃圾收集
- 在诸如C malloc包这样的显示分配器中,应用经过调用malloc和free来分配和释放堆块,应用要负责释放全部再也不须要的已分配块
- 垃圾收集器(garbage collector) 是一种动态存储分配器, 它自动释放程序再也不须要的已分配块,这些块被称为 垃圾(garbage)
- 自动回收堆存储的过程叫作 垃圾收集(garbage collection)
- 在一个支持垃圾收集的系统中,应用显式分配堆块,可是从不显式地释放它们,垃圾收集器按期识别垃圾块,并相应地调用free,将这些块放回到空闲链表中
10.10.1 垃圾收集器的基本要素
- 垃圾收集器将存储器视为一张 有向可达图(reachability graph)
- 一组 根节点(root node) 和一组 堆节点(heap node),每一个堆节点对应于堆中的一个已分配块
- 根节点对应于这样一种不在堆中的位置,包含指向堆中的指针,能够是寄存器,栈里的变量,或者是虚拟存储器中读写数据区域内的全局变量

- 当存在一条从任意根节点出发并到达p的有向路径时,节点 p是可达(reachable)
- 在任什么时候刻,和垃圾相对应的不可达节点是不能被应用再次使用的
- 垃圾收集器的角色是 维护可达图的某种表示,并经过释放不可达节点并将它们返回给空闲链表,来按期地回收它们
- 像Java这样的语言的垃圾收集器,对应用如何建立和使用指针有很严格的控制,可以维护可达图的一种精确的表示,所以可以回收全部垃圾
- 诸如C和C++这样的语言的收集器一般不能维持可达图的精确表示,这样的收集器叫作 保守的垃圾收集器(conservative garbage collector). 从某种意义上来讲它们是保守的,也就是每一个可达块都被正确地标记为可达,而一些不可达节点却可能被错误地标记为可达
10.10.2 Mark&Sweep垃圾收集器
- 由标记(mark)阶段和清除(sweep)阶段组成
- 标记阶段标记出根节点的全部可达的和已分配的后继,然后面的清除阶段释放每一个未被标记的已分配块
- 块头部中空闲的低位中的一位用来表示这个块是否被标记了
10.10.3 C程序的保守Mark&Sweep
10.11 C程序中常见的与存储器有关的错误
10.11.1 间接引用坏指针
10.11.2 读未初始化的存储器
10.11.3 容许栈缓冲区溢出
- 若是一个程序不检查输入串的大小就写入栈中的目标缓冲区,程序就会缓冲区溢出错误(buffer overflow bug)
10.11.4 假设指针和它们指向的对象是相同大小的
10.11.5 形成错位错误
- 建立一个n个元素的指针数组,可是随后试图初始化这个数组的n+1个元素.这个过程当中覆盖了A数组后面的某个存储器
10.11.6 引用指针,而不是它所指向的对象
第3部分 程序间的交互和通讯
第十一章 系统级I/O
- 输入/输出(I/O)是在主存(main memory)和外部设备(例如磁盘驱动器、终端和网络)之间拷贝数据的过程
- 输入: 从I/O设备拷贝数据到主存
- 输出: 从主存拷贝数据到I/O设备
- 在Unix系统中,是经过使用由内核提供的系统级Unix I/O函数来实现这些较高级别的I/O函数的
11.1 Unix I/O
- 全部的I/O设备,例如网络、磁盘和终端,都被模型化为文件,而全部的输入和输出都被当作对相应文件的读和写来执行
- 打开文件
- 改变当前的文件位置
- 读写文件
- 关闭文件: 不管进程由于何种缘由终止,内核都会关闭全部打开的文件并释放它们的存储器资源
11.2 打开和关闭文件
11.3 读和写文件
11.5 读取文件元数据
11.6 共享文件
11.7 I/O重定向
第十二章 网络编程
12.1 客户端-服务器编程模型
- 每一个网络应用都是基于 客户端-服务器模型的
- 一个应用是由一个服务器进程和一个或者多个客户端进程组成
- 基本操做是 事务(transaction)

客户端-服务器事务与数据库事务
它不是数据库事务,并且也没有数据库事务的特性,例如原子性,在这里,事务仅仅是客户端和服务器之间执行的一系列步骤
12.2 网络

- 物理上而言,网络是一个按照地理远近组成的层次系统,最低层是LAN(Local Area Network,局域网),范围在一个建筑或者校园内
- 最流行的局域网技术是以太网(Ethernet),被证实在3Mb/s~1Gb/s之间都是至关适合的
- 每一个以太网适配器都有一个全球惟一的48位地址
- 一个以太网段包括一些电缆和一个叫作集线器的小盒子,一般服务于一个小的区域,例如一个房间或者一个楼层。集线器不加分辨地将从一个端口上收到的每一个位复制到其余全部的端口上,所以,每台主机都能看到每一个位
- 一台主机能够发送一段位,称为 帧(frame) ,到这个网段内其余任何主机,每一个主机适配器都能看到这个帧,可是只有目的主机实际读取它
- 帧=头位(header,标识源和目的地址以及帧的长度)+有效载荷(payload)


- 网桥比集线器更充分地利用了电缆带宽,利用一种聪明的分配算法,它们随着时间自动学习哪一个主机能够经过哪一个端口可达.而后有必要时,有选择地将帧从一个端口拷贝到其它端口
- 例如,若是主机A发送一个帧到同网段上的主机B,当该帧到达网桥X的输入端口时,它将丢弃此帧,于是节省了其它网段上的带宽
- 若是主机A发送一个帧到一个不一样网段上的主机C,那么网桥X只会把此帧拷贝到和网桥Y相连的端口上,网桥Y会只把此帧拷贝到与主机C的网桥相连的端口
- 在层次更高级别中,多个不兼容的局域网能够经过叫作 路由器(router) 的特殊计算机链接起来,组成一个 internet(互联网络)
- internet描述通常概念,而用大写字母的Internet来描述一种特殊的实际应用(全球IP因特网)
- WAN(Wide-Area Network,广域网),覆盖的地理范围比局域网大

- internet(互联网络),它能由采用彻底不一样和不兼容技术的各类局域网和广域网组成,
如何使得某台源主机跨过全部这些不兼容的网络发送数据位到另外一台目的主机成为可能呢?
一层运行在每台主机和路由器上的协议软件,它消除了不一样网络之间的差别,这个软件执行一种协议,控制主机和路由器如何协同工做来实现数据传输
- 命名方法 不一样的局域网技术有不一样和不兼容的方式来为主机分配地址,internet协议经过定义一种的一致的主机地址格式,消除差别,这个地址唯一的标识了它
- 传送机制 在电缆上编码位和将这些位封装成帧方面,不一样的网络互联技术有不一样的和不兼容的方式,internet协议经过定义一种把数据位捆扎成不连续的组块(chunk)--也就是包--的统一方式,消除差别
- 一个包由包头(header)和有效载荷(payload)组成,其中包头包括包的大小以及源主机和目的主机的地址,有效载荷包括从源主机发出的数据位

12.3 全球IP因特网

能够把因特网看作一个世界范围的主机集合,有如下特性:
- 主机集合被映射为一组32位的IP地址
- 这组IP地址被映射为一组称为因特网域名(Internet domain name)的标识
- 一个因特网主机上的进程可以经过一个链接(connection)和任何其余因特网主机上的进程通讯
12.3.1 IP地址
- 一个IP地址就是一个32位无符号整数
- IP地址以点分十进制表示法表示
12.3.2 因特网域名
- 因特网客户端和服务器互相通讯时使用IP地址
- 对人们而言,大整数很难记住,因此定义了一组更加人性化的域名(domain name),以及一种将域名映射到IP地址的机制
- 每台因特网主机都有本地定义的域名 localhost,老是映射为本地回送地址(loopback address) 127.0.0.1

12.3.3 因特网链接
- 客户端和服务器经过在链接(connection)上发送和接收字节流来通讯
- 从链接一对进程的意义上,链接是 点对点(point-to-point) 的
- 从数据能够双向流动的角度,它是 全双工(full-duplex) 的
- 套接字(socket)是链接的端点(end-point),每一个套接字都有相应的套接字地址,由一个IP地址和一个16位的整数端口组成,"地址:端口"表示
- 客户端发起链接请求时,客户端socket地址中的端口是由内核自动分配的,称为 临时端口(ephemeral port)
- 服务器socket中的端口一般是某个知名的端口,和服务对应的,例如Web服务器一般用端口80,电子邮件服务器使用端口25
- 一个链接是由两端的套接字地址惟一肯定的,这对套接字地址叫作 套接字对(socket pair)

12.4 套接字接口
- 套接字接口(socket interface) 是一组用来结合Unix I/O函数建立网络应用的函数,大多数现代系统上都实现了它

12.4.1 套接字地址结构
12.4.2 socket函数
- 客户端和服务器使用socket函数来建立一个 套接字描述符(socket descriptor)
12.4.3 connect函数
- 客户端经过调用connect函数来创建和服务器的链接的
12.4.4 open_clientfd函数
- 将socket和connect函数包装成一个叫作open_clientfd的辅助函数是很方便的,当connect函数返回时,咱们返回套接字描述符给客户端,客户端就能够当即开始用Unix I/O和服务器通讯了
12.4.5 bind函数
12.4.6 listen函数
12.4.7 open_listenfd函数
- 将socket、bind和listen函数结合成一个叫作 open_listenfd的辅助函数是颇有帮助的,服务器能够用它来建立一个监听描述符
12.4.8 accept函数
- 服务器经过它来等待来自客户端的链接请求
- accept函数等待来自客户端的请求 到达侦听描述符listenfd,而后在addr中填写客户端的套接字地址,并返回一个已链接描述符(connected descriptor),这个描述符可被用来利用Unix I/O函数与客户端通讯
12.4.9 echo客户端和服务器的示例
- 简单的echo服务器一次只能处理一个客户端,在客户端间迭代,称为 迭代服务器(iterative server)
EOF意味什么?
- 并无像EOF字符这样的一个东西
- EOF是由内核检测到的一种条件,应用程序在它接收到一个由read函数返回的零返回码时,就会发现出EOF条件
- 对于磁盘文件,当前文件位置超出文件长度时,会发生EOF
- 对于网络链接,当一个进程关闭链接,在链接的另外一端会发生EOF,另外一端的进程在试图读取流中最后一个字节以后,会检测到EOF
12.5 Web服务器
12.5.1 Web基础
- Web客户端和服务器之间的交互用的是一个基于文本的应用级协议,叫 HTTP(Hypertext Transfer Protocol,超文本传输协议)
- FTP,文件检索服务
- HTML(Hypertext Markup Language,超文本标记语言)
12.5.2 Web内容
- 内容是与一个 MIME(Multipurpose Internet Mail Extensions,多用途的网际邮件扩充协议) 类型相关的字节序列

Web服务器以两种方式向客户端提供内容
- 取一个磁盘文件,返回给客户端,瓷盘文件称为 静态内容(static content) ,返回文件给客户端的过程称为 服务静态内容(serving static content)
- 运行一个可执行文件,将输出返回给客户端, 可执行文件产生的输出称为 动态内容(dynamic content),运行程序并返回它的输出到客户端的过程称为 服务动态内容(serving dynamic content)
12.5.3 HTTP事务
- HTTP标准要求每一个文本行都由一个回车和换行符对来结束
HTTP请求
- HTTP/1.1定义了一些附加的报头,例如 缓存和安全等高级特性,还支持一种机制,容许客户端和服务器在同一条持久链接(persistent connection)上执行多个事务
- HTTP/1.0和HTTP/1.1是互相兼容的,HTTP/1.0的客户端和服务器会简单地忽略HTTP/1.1的报头
- 请求报头为服务器提供额外的信息,例如浏览器的商标名,或者MIME类型
HTTP响应
- 响应行(response line)+响应报头(response header)+响应主体(response body)
- 响应报头提供了关于响应的附加信息,两个最重要的报头是Content-Type,它告诉客户端响应主体中内容的MIME类型;以及Content-Length,用来指示响应主体的字节大小

12.5.4 服务动态内容
- 服务器如何向客户端提供动态内容? 一个叫作 CGI(Common Gateway Interface,通用网关接口) 的实际标准解决了这个问题
客户端如何将程序参数传递给服务器?
- GET请求的参数在URI中传递,"?"字符分隔了文件名和参数,每一个参数用一个"&"分隔开,参数中不容许有空格,必须用字符串""%20"来表示,其它特殊字符,也存在相似的编码
- POST请求中的参数是在请求主体(request body)中
服务器如何将参数传递给子进程
- 在服务器接收到以下请求后(GET /cgi-bin/adder?15000&213 HTTP/1.1),调用fork来建立一个子进程,子进程将CGI环境变量QUERY_STRING设置为 "15000&213",adder程序在运行时能够用Unix getenv函数来引用它,并调用execve在子进程的上下文中执行/cgi-bin/adder程序,经常被称为CGI程序(遵照CGI标准,经常使用Perl脚本编写,也常被称为CGI脚本)
- 对于POST请求,子进程也须要重定向标准输入到已链接描述符,CGI程序从标准输入中读取请求体中的参数
服务器如何将其余信息传递给子进程?
CGI定义了大量的其余环境变量,CGI程序在运行时,能够设置这些环境变量

子进程将它的输出发送到哪里?
- 在子进程加载并运行CGI程序以前,它使用Unix dup2函数将标准输出重定向到和客户端相关联的已链接描述符
- CGI程序将它的动态内容发送到标准输出
- 父进程不知道子进程生成的内容的类型和大小,因此子进程要负责生成Content-type和Content-length响应报头,以及报头后的空行
第十三章 并发编程
- 若是逻辑控制流在时间上重叠,那么它们就是 并发(concurrent) 的,这种现象,称为 并发性(concurrency),出如今计算机系统的许多不一样层面中,例如 硬件异常处理程序、进程和 Unix 信号处理程序
- 使用应用级并发的应用程序称为 并发程序(concurrent program)
应用级并行
在只有一个 CPU 的单处理器上,并发流是交替的,在任什么时候间点上,都只有一个流在 CPU 上实际执行,然而在有多个 CPU 的机器,称为多处理器,能够真正地同时执行多个流,被分红并发流的并行应用,在多处理器的机器上运行得快不少
当一个应用正在等待来自慢速 I/O 设备(例如磁盘)的数据到达时,内核会运行其余进程,使 CPU 保持繁忙,经过交替执行 I/O 请求和其余有用的工做,来使用并发性
与计算机交互的人要求计算机能同时执行多个任务的能力,例如,打印文档时,可能想要调整一个窗口的大小,每次用户请求某种操做时,一个独立的并发逻辑流被建立来执行这个操做
好比,一个动态存储分配器能够经过推迟与一个运行在较低优先级上的并发"合并"流的合并,使用空闲时的 CPU 周期,来下降单个 free 操做的延迟
建立一个并发服务器,为每一个客户端建立各自独立的逻辑流,同时为多个客户端服务
三种基本的构造并发程序的方法
每一个逻辑控制流都是一个进程,由内核来调度和维护。进程有独立的虚拟地址空间,想要和其余流通讯,控制流必须使用某种显式的 进程间通讯(interprocess communication,IPC) 机制
应用程序在一个进程的上下文中显式地调度它们本身的逻辑流,逻辑流被模型化为状态机,做为数据到达文件描述符的结果,主程序显式的从一个状态转换到另外一个状态,程序是一个单独的进程,全部的流都共享同一个地址空间
线程是运行在一个单一进程上下文中的逻辑流,由内核调度。能够理解成其余两种方式的混合体,像进程流同样由内核进行调度,而像I/O多路复用流同样共享一个虚拟地址空间
13.1 基于进程的并发编程
- 在父进程中接受客户端链接请求,而后建立一个新的子进程为每一个新客户端提供服务
- 在服务器派生一个子进程,这个子进程获取服务器描述符表的完整拷贝,子进程关闭它的监听描述符,而父进程关闭它的已链接描述符
13.1.1 基于进程的并发服务器
- 一般服务器会运行很长时间,因此须要一个SIGCHLD处理程序,来回收僵死(zombie)子进程的资源,当SIGCHLD处理程序执行时,SIGCHLD信号是阻塞的,而Unix信号是不排队的,因此SIGCHLD处理程序必须准备好回收多个僵死子进程的资源
- 父子进程必须关闭它们各自的connfd拷贝(描述符),以免存储器泄漏
- 由于套接字的文件表表项中的引用计数,直到父子进程的connfd都关闭了,到客户端的链接才会终止
13.1.2 关于进程的优劣
- 父子进程间共享状态信息,模型:共享文件表,可是不共享用户地址空间
独立的进程地址空间
- 优势: 一个进程不可能不当心覆盖另外一个进程的虚拟存储器
- 缺点: 使得进程共享状态信息变得更加困难,为了共享信息,它们必须使用显式的IPC机制(每每比较慢,进程控制和IPC的开销很高)
13.2 基于I/O多路复用的并发编程
- 服务器必须响应两个互相独立的I/O事件: 网络客户端发起链接请求;用户在键盘输入命令行
- I/O多路复用(I/O multiplexing)技术, 基本思路是,使用select函数,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序
- 问题: 一旦链接到某个客户端,就会连续回送输入行,直到客户端关闭链接,此时,输入一个命令到标准输入,将不会获得响应,直到服务器和客户端之间结束,更好的办法是更细粒度的多路复用,服务器每次循环(至多)回送一个文本行
13.2.1 基于I/O多路复用的并发事件驱动服务器
13.2.2 I/O多路复用技术的优劣
优势
- 比基于进程的设计给了程序员更多的对程序行为的控制,例如,能够设想编写一个事件驱动的并发服务器,为某些客户端提供它们须要的服务
- 运行在单一进程上下文中,每一个逻辑流均可以访问进程的所有地址空间,流之间共享数据很容易,能够利用调试工具(例如GDB)来调试程序,就像对顺序程序那样
- 事件驱动设计经常比基于进程的设计要明显高效的多,不要求有进程上下文切换来调度新的流
缺点
- 编码复杂,例如,事件驱动的并发服务器的代码比基于进程的服务器多三倍,而且随着并发性粒度的减少,复杂性还会上升
- 粒度: 指每一个逻辑流每次时间片执行的指令数目
13.3 基于线程的并发编程
- 基于进程和基于I/O多路复用两种方法的混合
- 一个线程是运行在一个进程上下文中的逻辑流,由内核自动调度,每一个线程有本身的线程上下文(thread context),包括一个惟一的整数线程ID(Thread ID,TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码
- 运行在一个进程里的全部线程共享该进程的整个虚拟地址空间
13.3.1 线程执行模型
- 每一个进程开始生命周期时,都是单一线程,这个线程称为主线程(main thread)
- 在某一时刻,主线程建立一个 对等线程(peer thread),从这个时间点开始,两个线程就并发运行

进程和线程不一样点
- 线程上下文比进程上下文小得多,切换快得多
- 线程不像进程那样,不是按照严格的父子层次来组织的,和一个进程相关的线程组成一个对等(线程)池(a pool of peers),独立于其它进程建立的线程,一个线程能够杀死它的任何对等线程,或者等待它的任意对等线程终止,每一个对等线程都能读写相同的共享数据
13.3.2 Posix线程
- Posix线程(Pthreads)是在C程序中处理线程的一个标准接口,在大多数Unix系统上均可用
- 定义了大约60个函数,容许程序建立、杀死和回收线程,与对等线程安全地共享数据,通知对等线程系统状态的变化
- 线程的代码和本地数据被封装在一个 线程例程(thread routine) 中,能够理解成Golang中,传一个func进去
13.3.3 建立线程
13.3.4 终止线程
一个线程是如下列方式之一来终止的
- 当顶层的线程例程返回时,线程会隐式地终止
- pthread_exit 函数: 子线程调用pthread_exit 函数,线程会 显式的终止 ,该函数会返回一个指向返回值 thread_return的指针
- 主线程调用用pthread_exit 函数,会等待全部其它对等线程终止,而后再终止主线程和整个进程,返回值为thread_return
- Unix的exit函数: 某个对等线程调用Unix的exit函数,终止进程以及全部与该进程相关的线程
- pthread_cancle函数: 另外一个对等线程,经过调用pthread_cancle函数来终止指定线程ID对应的线程
13.3.5 回收已终止线程的资源
- 线程经过调用 pthread_join函数 等待指定线程终止
- pthread_join函数会阻塞,直到线程tid终止,而后回收已终止线程占用的全部存储器资源
13.3.6 分离线程
- 在任何一个时间点上,线程是 可结合的(joinable) 或者 分离的(detached)
- 一个结合的线程可以被其它线程收回其资源和杀死,在被回收前,它的存储器资源(栈)是不释放的
- 一个分离的线程是不能被其它线程回收或杀死的,它的存储器资源在它终止时由系统自动释放
- 默认下,线程被建立成可结合的
- 为了不存储器泄漏,每一个可结合线程都应该要么被其它线程显式的收回,要么调用 pthread_detach函数 被分离
13.3.7 初始化线程
- pthread_once函数容许你初始化与线程例程相关的状态
13.3.8 一个基于线程的并发服务器
- 传递已链接描述符的指针给子线程
- 不显式的收回线程,必须分离每一个线程,资源才能在终止时被系统收回
13.4 多线程中的共享变量
13.4.1 线程存储器模型
- 一组并发线程运行在一个进程的上下文中,每一个线程都有本身独立的线程上下文(包括线程ID、栈、栈指针、程序计数器、条件代码和通用目的的寄存器值)
- 多个线程共享进程上下文,包括整个用户虚拟地址空间,打开文件的集合
- 寄存器从不共享,虚拟存储器老是共享的
13.4.2 将变量映射到存储器
多线程的C程序中的变量
- 全局变量 定义在函数以外,虚拟存储器的读/写区域只包含一个实例
- 本地自动变量(局部变量) 函数内部定义的没有static属性的变量,在运行时,每一个线程的栈都包含它本身的全部局部变量的实例,即便多个线程执行同一个函数,局部变量都属于线程各自独有
- 本地静态变量 定义在函数内部并static属性的变量,和全局变量同样,虚拟存储器的读/写区域只包含一个实例
13.4.3 共享变量
13.5 用信号量同步线程
13.5.2 利用信号量访问共享变量
- 一种叫作信号量(semaphore)的特殊类型变量,信号量s是具备非负整数值的全局变量,只能由两个特殊的操做来处理,称为P和V
- P(s):若是s非零,P将s减1,而且当即返回,若是s为零,就挂起进程,阻塞直到s变为非零,而后被V操做重启,完成P操做,得到控制权
- V(s):将s加1,若是有任何进程阻塞在P操做,那么V操做会重启这些进程中的一个
二进制信号量
- 将每一个共享变量(或相关共享变量集合)与一个信号量s(初始为1)联系起来,而后用P和V操做将相应的临界区包围起来,它的值老是0或者1,因此叫作 二进制信号量
- 进度图

- 由P和V操做建立的禁止区使得在任什么时候间点上,在被包围的临界区中,不可能有多个线程在执行指令,信号量操做确保了对临界区的互斥访问,通常现象称为 互斥(mutual exclusion)
- 目的是提供互斥的二进制信号量一般叫作互斥锁(mutex),在互斥锁上执行一个P操做叫作加锁,V操做叫作解锁,一个线程已经对一个互斥锁加锁但尚未解锁,被称为占用互斥锁
13.5.3 Posix信号量
- Posix标准定义了许多操做信号量的函数,三个基本的操做是sem_init、sem_wait(P操做)和sem_post(V操做)
13.5.4 利用信号量来调度共享资源

其它同步机制
- Java线程是用一种叫作Java监控器(Java Monitor)的机制来同步的,提供了对信号量互斥和调度能力的更高级别的抽象
13.6 综合:基于预线程化的并发服务器

13.7 其它并发性问题
13.7.1 线程安全
- 一个函数,当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果,被称为线程安全的(thread-safe)
第1类:不保护共享变量的函数
- 修改一个未受保护的变量
- 解决: 利用相似P和V操做这样的同步操做来保护变量,优势是调用程序不须要作任何修改,缺点是同步操做将减慢程序的执行时间
第2类:保护跨越多个调用的状态的函数
- 函数共享一个全局变量
- 解决:调用者在函数参数中传递状态信息,缺点:须要被迫修改调用程序中的代码
第3类:返回指向静态变量的指针的函数
- 共享了一个全局变量
- 解决:1,重写函数 2,使用lock-and-copy(加锁-拷贝)技术,定义一个线程安全的包装函数(wrapper),执行lock-and-copy,经过调用这个包装函数来取代全部对线程不安全函数的调用
第4类:调用线程不安全函数的函数
13.7.2 可重入性
- 一类重要的线程安全函数,叫作 可重入函数(reentrant function)
- 特色: 当被多个线程调用时,不会引用任何共享数据
- 可重入函数一般比不可重入的线程安全的函数高效一些,由于不须要同步操做

- 若是全部的函数参数都是传值传递(没有指针),而且全部的数据引用都是本地的自动栈变量(也就是,没有引用静态或全局变量),那么函数就是 显式可重入的(explicitly reentrant) ,不管它是如何被调用,均可以判定它是可重入的
- 加入显式可重入函数中一些参数能够传指针,那么就获得一个 隐式可重入函数(implicitly reentrant)函数 ,即,在调用线程当心地传递指向非共享数据的指针时,它是可重入的
13.7.3 在多线程中使用已存在的库函数
- 大多数Unix函数和定义在标准c库中的函数都是线程安全的,只有一小部分是例外
- Unix系统提供大多数线程不安全函数的可重入版本,老是以"_r"后缀结尾
13.7.4 竞争
- 缘由: 程序员假设线程将按照某种特殊的轨线穿过执行状态空间
- 多线程必须对任何可行的轨线都正确工做
13.7.5 死锁
- 死锁(deadlock) ,指的是一组线程被阻塞了,等待一个永远也不会为真的条件
避免死锁
- 互斥锁加锁顺序规则: 若是对于程序中每对互斥锁(s,t),每一个既包含s也包含t的线程都按照相同的顺序同时对它们加锁,那么这个程序就是无死锁的
- 即,两个线程都是从P(s)->P(t)