程序的机器级表示
程序编码
编译
编译时,提高优化级别,可以提高运行速度,但是编译时间会更长,对代码调试会更困难
编译过程
graph LR
A(C预处理器扩展源代码)-->B(编译器产生两个文件的汇编代码)
B-->C
C(汇编器将汇编代码转变为二进制目标代码)-->D(链接器将目标文件与标准Unix库函数合并,产生最终的可执行文件)
汇编代码与目标代码
汇编代码接近于机器代码,目标代码是二进制格式,汇编代码的特点是,可读性很好的文本格式表示
数据格式
Intel用术语"字(word)"表示16位数据类型,因此称32位为双字,64位为4字
优化程序性能
编写高效程序
- 选择一组最好的算法和数据结构
- 编写出编译器能够有效优化以转换成高效可执行代码的源代码
编译技术
- 与机器有关:依赖机器的低级细节
- 与机器无关:不考虑计算机的特性
存储器别名使用
编译器必须假设不同的指针可能指向存储器的同一个位置,阻碍编译器优化
消除循环的低效率
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
给每个共享库实现分配一个专用地址空间块,然后要求加载器总是在这个地址加载共享库
不好的:1,即使一个进程不使用这个库,那部分空间还是会分配
2,难以管理: 我们得保证没有组块会重叠,每次当一个库修改了,就必须确认它的已分配的组块还是和之前的大小,并且,创建了新的库,得为它找空间,随着时间发展,一个系统有数百个库和各种版本的库,难免地址空间分裂成大量小的、未使用而又不再能使用的小洞,而且,每个系统,从库到存储器的分配都是不同的
- 方法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(后继)指针
- 使用双向链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间
空闲链表中对块排序的策略
- 后进先出(LIFO)
新释放的块放置在链表的开始处,释放块可以在常数时间内完成,如果使用边界标记,合并也可以在常数时间内完成
- 按照地址顺序来维护链表
释放一个块需要线性时间的搜索,来定位合适的祖先; 有更高的存储器利用率,接近最佳适配的利用率
显式链表的缺点
- 空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部
- 导致更大的最小块大小,潜在地提高了内部碎片的程度
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 设备
当一个应用正在等待来自慢速 I/O 设备(例如磁盘)的数据到达时,内核会运行其他进程,使 CPU 保持繁忙,通过交替执行 I/O 请求和其他有用的工作,来使用并发性
- 与人交互
与计算机交互的人要求计算机能同时执行多个任务的能力,例如,打印文档时,可能想要调整一个窗口的大小,每次用户请求某种操作时,一个独立的并发逻辑流被创建来执行这个操作
- 通过推迟工作以减少执行时间
比如,一个动态存储分配器可以通过推迟与一个运行在较低优先级上的并发"合并"流的合并,使用空闲时的 CPU 周期,来降低单个 free 操作的延迟
- 服务多个网络客户端
创建一个并发服务器,为每个客户端创建各自独立的逻辑流,同时为多个客户端服务
三种基本的构造并发程序的方法
- 进程
每个逻辑控制流都是一个进程,由内核来调度和维护。进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的 进程间通信(interprocess communication,IPC) 机制
- I/O 多路复用
应用程序在一个进程的上下文中显式地调度它们自己的逻辑流,逻辑流被模型化为状态机,作为数据到达文件描述符的结果,主程序显式的从一个状态转换到另一个状态,程序是一个单独的进程,所有的流都共享同一个地址空间
- 线程
线程是运行在一个单一进程上下文中的逻辑流,由内核调度。可以理解成其他两种方式的混合体,像进程流一样由内核进行调度,而像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 共享变量
- 变量v只有一个运行时实例,并且被两个以上线程引用
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)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。