OSTEP

virtualization

进程 process

关键问题:如何提供有许多 CPU 的假象?

要实现 CPU 的虚拟化,要实现得好,操作系统就需要一些低级机制以及一些高级智能

  • 机制 mechanism

    机制是一些低级方法或协议,实现了所需的功能

  • 策略 policy

    策略是在操作系统内做出某种决定的算法

操作系统运行程序必须做的:

  • 将代码和所有静态数据 加载 (load) 到内存中
  • 必须为程序的 运行时栈 (run-time stack) 分配一些内存
  • 也可能为程序的 堆 (heap) 分配一些内存
  • 其他初始化任务 比如:输入/输出 I/O 相关的任务

我们已经了解

  • 我们已经介绍了操作系统的最基本抽象:进程
  • 它很简单地被视为一个正在运行的程序。有了这个概念,接下来将继续讨论具体细节:实现进程所需的低级机制和以智能方式调度这些进程所需的高级策略
  • 结合机制和策略,我们将加深对操作系统如何 虚拟化 CPU 的理解

进程 API

进程的非正式定义非常简单:进程就是运行中的程序

  • 机器状态 machine state

    • 内存
    • 寄存器
  • 进程列表 processlist

    有时候人们会将存储关于进程的信息的个体结构称为进程控制块 (Process Control Block, PCB)

进程 API 相关术语

  • 创建 create
  • 销毁 destroy
  • 等待 wait
  • 杂项控制 miscellaneous control
  • 状态 statu

进程状态 statu

  • 运行 running

    在运行状态下,进程正在处理器上运行,这意味着它正在执行指令

  • 就绪 ready

    在就绪状态下,进程已准备好运行,但由于某种原因,操作系统选择不在此时运行

  • 阻塞 blocked

    在阻塞状态下,一个进程执行了某种操作,直到发生其他事件时才会准备运行

以 UNIX 系统为例,讨论 fork(), exec(), wait()

使用 C 语言进行测试,其中 unistd.h 【 POSIX 标准定义的 unix 类系统 定义符号常量的头文件, 包含了许多 UNIX 系统服务的函数原型】

  • fork() 系统调用

    /*
    *   p1.c
    */
    
    #include<stdio.h>
    #include<stdlib.h>
    #include<unistd.h>
    
    int main(int argc, char *argv[]){
    
        printf("Hello World (pid: %d)\n",(int)getpid());        // 输出程序 p1 运行的 PID
        int rc = fork();                                        // 系统调用 fork() 用于创建新进程
    
        if(rc < 0){
            fprintf(stderr, "fork failed\n");
            exit(1);
        }else if(rc == 0){
            printf("Hello, I am child (pid: %d)\n", (int)getpid());
        }else {
            printf("hello, I am parent of %d (pid: %d)\n", rc, (int)getpid());
        }
    
        return 0;
    }
    
    • 进程描述符 (process identifier, PID)
    • 子进程被创建后,我我就需要关心系统中的两个活动进程了:子进程和父进程
    • 涉及 CPU 调度程序 (scheduler)
  • wait() 系统调用

    /*
    *   p2.c
    */
    
    #include<stdio.h>
    #include<stdlib.h>
    #include<unistd.h>
    #include<sys/wait.h>
    
    int main(int argc, char *argv[]){
        printf("Hello World (pid: %d)\n",(int)getpid());
        int rc = fork();
        if(rc < 0){
            fprintf(stderr, "fork failed\n");
            exit(1);
        }else if(rc == 0){
            printf("Hello, I am child (pid: %d)\n", (int)getpid());
        }else {
            int wc = wait(NULL);
            printf("hello, I am parent of %d (wc: %d) (pid: %d)\n", rc, wc, (int)getpid());
        }
        return 0;
    }
    
    • 父进程调用 wait() 等待子进程结束
  • exec() 系统调用

    /*
    *   p3.c
    */
    
    #include<stdio.h>
    #include<stdlib.h>
    #include<unistd.h>
    #include<string.h>
    #include<sys/wait.h>
    
    int main(int argc, char *argv[]){
        printf("Hello World (pid: %d)\n",(int)getpid());
        int rc = fork();
        if(rc < 0){
            fprintf(stderr, "fork failed\n");
            exit(1);
        }else if(rc == 0){
            printf("Hello, I am child (pid: %d)\n", (int)getpid());
            char* myargs[3];
            myargs[0] = strdup("wc");
            myargs[1] = strdup("p3.c");
            myargs[2] = NULL;
            execvp(myargs[0], myargs);
            printf("this shouldn't print out");
        }else {
            int wc = wait(NULL);
            printf("hello, I am parent of %d (wc: %d) (pid: %d)\n", rc, wc, (int)getpid());
        }
        return 0;
    }
    
    • 实际上,exec() 有几种变体:execl(), execle(), execlp(), execv(), execvp()
    • exec() 会从可执行程序中加载代码和静态数据,并用它覆写自己的代码段(以及静态数据)

为什么这样设计 API ?

  • 重要的是 LAMPSON 定律
  • fork() 和 exec() 的分离,让 shell 可以方便地实现很多有用的功能

RTFM 【阅读 man 手册】

更多的细节可以阅读 Advanced Programming in the UNIX Environment

机制:受限直接执行

为了虚拟化 CPU --> 操作系统需要以某种方式让许多任务共享物理 CPU

关键问题:如何高效、可控地虚拟化 CPU ?

  • 权限分离:

    • 用户模式 user mode
    • 内核模式 kernel mode

    切换通过 陷阱 (trap) 命令,内核通过在启动时设置陷阱表

    • LDE 协议
  • 进程切换

    • 协作方式:等待系统调用
    • 非协作方式:操作系统进行控制
      • 利用时钟中断重新获得控制权
      • 时钟中断的同时富有一定责任,需要为正在运行的系统保存足够的状态【保存和恢复上下文】

进程调度:介绍

我们还不知道操作系统调度程序采用的上层策略 (policy)

调度策略 sheduling policy

关键问题:如何开发调度策略

工作负载 workload

a fully-operational scheduling discipline 我们对操作系统中运行的进程(有时也叫工作任务)做出如下的假设

  1. 每一个工作运行相同的时间
  2. 所有的工作同时到达
  3. 一旦开始,每个工作保持运行直到完成
  4. 所有的工作只是用 CPU(即它们不执行 IO 操作)
  5. 每个工作的运行时间是已知的

一个标准周转时间 (turnaround time): 任务的周转时间定义为任务完成时间减去任务到达系统的时间,更正式的周转时间定义 \(T_{周转时间}=T_{完成时间}-T_{到达时间}\)

  • 周转时间是一个性能指标

注意:性能和公平在调度系统中往往是矛盾的

  • 先进先出 FIFO/FCFS

    • FIFO 有一些积极的特性:它很简单,而且易于实现
    • 护航效应 (convoy effect)
  • 最短任务优先 SJF

    • 因为它完全描述了这个策略:先运行最短的任务,然后是次短的任务,如此下去
  • 最短完成时间优先 STCF

新度量指标:响应时间 (response time)

  • 响应时间定义为从任务到达系统到首次运行的时间,更正式的定义是: \(T_{响应时间} = T_{首次运行} - T_{到达时间}\)

如何构建对响应时间敏感的调度程序?

  • 轮转 Round-Robin: RR

    • 时间片长度必须是时钟中断周期的倍数
    • 时间片长度越短,RR 在响应时间上表现越好
    • 时间片太短是有问题的:突然上下文切换的成本将影响整体性能
    • 摊销 amortize 权衡上下文切换的成本和响应时间上表现
    • 如果周转时间是我们的指标,那么 RR 确实是最糟糕的策略之一

权衡在系统中很常见

  • 第一种类型(SJF、STCF)优化周转时间,但对响应时间不
  • 第二种类型(RR)优化响应时间

调度:多级反馈队列 MLFQ

没有完备的知识如何调度?

MLFQ 的两条基本规则

  • 规则 1:如果 A 的优先级 > B 的优先级,运行 A(不运行 B)
  • 规则 2:如果 A 的优先级 = B 的优先级,轮转运行 A 和 B

如何改变优先级

  • 规则 3:工作进入系统时,放在最高优先级(最上层队列)

  • 规则 4

    • a:工作用完整个时间片后,降低其优先级(移入下一个队列)
    • b:如果工作在其时间片以内主动释放 CPU,则优先级不变

会有饥饿 starvation 问题,尝试 2:提升优先级

  • 规则 5:经过一段时间 S,就将系统中所有工作重新加入最高优先级队列

如果 S 设置得太高,长工作会饥饿;如果设置得太低,交互型工作又
得不到合适的 CPU 时间比例

尝试 3:更好的计时方式

  • 规则 4:一旦工作用完了其在某一层中的时间配额(无论中间主动放弃了多少次CPU),就降低其优先级(移入低一级队列)

操作系统很少知道什么策略对系统中的单个进程和每个进程算是好的,因此提供接口并允许用户或管理员给操作系统一些提示 (hint) 常常很有用。我们通常称之为建议 (advice)

调度:比例份额 Lottery

比例份额 (proportional-share)

调度程序,有时也称为公平份额 (fair-share) 调度程序

彩票调度

彩票调度最精彩的地方在于利用了 随机性

是确保每个工作获得 一定比例 的 CPU 时间,而不是优化周转时间和响应时间

机制

  • 彩票转让 (ticket transfer) 通过转让,一个进程可以临时将自己的彩票交给另一个进程

  • 彩票通胀 (ticket inflation) 利用通胀,一个进程可以临时提升或降低自己拥有的彩票数量 【前提:进程之间相互信任的环境】

步长调度

一个确定性的公平分配算法,我们通过用一个大数分别除以他们的票数来获得每个进程的步长,当需要进行调度时,选择目前拥有最小行程值的进程,并且在运行之后将该进程的行程值增加一个步长

有了可以精确控制的步长调度算法,为什么还要彩票调度算法呢?好吧,彩票调度有一个步长调度没有的优势——不需要全局状态

小结

虽然两者都很有趣,但由于一些原因,并没有作为 CPU 调度程序被广泛使用。一个原因是这两种方式都不能很好地适合 I/O];另一个原因是其中最难的票数分配问题并没有确定的解决方式

多处理器调度(高级)

使之能并行 (parallel) 执行,也许使用多线程 (thread)

多处理器架构

为了理解多处理器调度带来的新问题,必须先知道它与单 CPU 之间的基本区别。区别的核心在于对硬件缓存 (cache) 的使用,以及多处理器之间共享数据的方式。

缓存是基于局部性(locality)的概念,局部性有两种,即时间局部性和空间局部性。

  • 时间局部性是指当一个数据被访问后,它很有可能会在不久的将来被再次访问,比如循
    环代码中的数据或指令本身
  • 空间局部性指的是,当程序访问地址为 x 的数据时,很有可能会紧接着访问 x 周围的数据,比如遍历数组或指令的顺序执行

CPU 的情况下缓存要复杂得多,这一普遍的问题称为缓存一致性 (cache coherence) 问题

  • 在基于总线的系统中,一种方式是使用总线窥探 (bus snooping)

同步

跨 CPU 访问(尤其是写入)共享数据或数据结构时,需要使用互斥原语

设计多处理器调度时遇到的最后一个问题,是所谓的缓存亲和度 (cache affinity)

单处理器调度

最基本的方式是简单地复用单处理器调度的基本架构,将所有需要调度的工作放入一个单独的队列中,我们称之为单队列多处理器调度 (Single Queue Multiprocessor Scheduling, SQMS)

  • 第一个是缺乏可扩展性 (scalability)
  • 第二个主要问题是缓存亲和性

多队列调度

多队列的方案,比如每个 CPU 一个队列。我们称之为多队列多处理器调度 (Multi-Queue Multiprocessor Scheduling,MQMS)

MQMS 比 SQMS 有明显的优势,它天生更具有可扩展性。队列的数量会随着 CPU 的增
加而增加,因此锁和缓存争用的开销不是大问题。此外,MQMS 天生具有良好的缓存亲和
度。所有工作都保持在固定的 CPU 上,因而可以很好地利用缓存数据。

你可能会发现有一个新问题(这在多队列的方法中是根本的),即负载不均 (load imbalance)

最明显的答案是让工作移动,这种技术我们称为迁移 (migration)

系统如何决定发起这样的迁移?

一个基本的方法是采用一种技术,名为工作窃取 (work stealing)

Linux 多处理器调度

Linux 社区一直没有达成共识。一直以来,存在 3 种不同的调度程序:

  • O(1) 调度程序
  • 完全公平调度程序 (CFS)
  • BF 调度程序 (BFS)

抽象:地址空间

此操作系统需要提供一个易用 (easy to use) 的物理内存抽象。这个抽象叫作地址空间 (address space)

  • 一个进程的地址空间包含运行的程序的所有内存状态
  • 操作系统在单一的物理内存上为多个运行的进程(所有进程共享内存)构建一个私有的、可能
    很大的地址空间的抽象,称操作系统在虚拟化内存 (virtualizing memory)

隔离原则

  • 隔离是建立可靠系统的关键原则
  • 如果两个实体相互隔离,这意味着一个实体的失败不会影响另一个实体(操作系统力求让进程彼此隔离,从而防止相互造成伤害)
  • 通过内存隔离,操作系统进一步确保运行程序不会影响底层操作系统的操作
  • 一些现代操作系统通过将某些部分与操作系统的其他部分分离,实现进一步的隔离,这样的微内核 (microkernel) 可以比整体内核提供更大的可靠性

为了确保操作系统这样做,我们需要一些目标来指导

  • 虚拟内存 (VM) 系统的一个主要目标是 透明 (transparency)
  • 虚拟内存的另一个目标是 效率 (efficiency)
  • 虚拟内存第三个目标是保护 (protection)

我们将重点介绍虚拟化内存所需的基本机制 (mechanism),我们还将研究一些较相关的策略 (policy)

内存操作 API

内存类型

  • 第一种称为栈内存,它的申请和释放操作是编译器来隐式管理的,所以有时也称为自动内存 (automatic)
  • 要第二种类型的内存即所谓的堆 (heap) 内存,其中所有的申请和释放操作都由程序员显式地完成

C 语言中

  • malloc()调用: 传入要申请的堆空间的大小,它成功就返回一个指向新申请空
    间的指针,失败就返回 NULL,通过 man 手册 man malloc
  • free() 调用: 要释放不再使用的堆内存
  • calloc() 调用: 分配内存,并在返回之前将其置零
  • realloc() 调用: 创建一个新的本大的内存区域,将旧区域复制到其中,并返回新区域的指针

常见错误(工具:gdb, valgrind)

  • 忘记分配内存
  • 没有分配足够的内存
  • 忘记初始化分配的内存
  • 忘记释放内存
  • 在用完之前释放内存
  • 反复释放内存
  • 错误地调用 free()

底层操作系统支持

  • 一个这样的系统调用叫作 brk,它被用来改变程序分断(break)的位置:堆结束的位置,它需要一个参数(新分断的地址),从而根据新分断是大于还是小于当前分断,来增加或减小堆的大小
  • 另一个调用 sbrk 要求传入一个增量,但目的是类似的

机制:地址转换

实现 CPU 虚拟化时,我们遵循的一般准则被称为受限直接访问 (Limited Direct Execution, LDE)

  • LDE 背后的想法很简单:让程序运行的大部分指令直接访问硬件,只在一些关键点由操作系统介入来确保高效和控制,为了实现高效的虚拟化,操作系统应该尽量让程序自己运行,同时通过在关键点的及时介入 (interposing),来保持对硬件的控制
  • 高效和控制是现代操作系统的两个主要目标

在实现虚拟内存时,我们将追求类似的战略,在实现高效和控制的同时,提供期望的
虚拟化

  • 高效 决定了我们要利用硬件的支持
  • 控制 意味着操作系统要确保应用程序只能访问它自己的内存空间
  • 最后,我们对虚拟内存还有一点要求,即灵活性

如何高效、灵活地虚拟化内存

我们利用了一种通用技术,有时被称为 基于硬件的地址转换 (hardware-based address translation),简称为 地址转换 (address translation)

  • 将指令中的 虚拟 (virtual) 地址转换为数据实际存储的 物理 (physical) 地址
  • 操作系统必须在关键的位置介入,设置好硬件,以便完成正确的地址转换

在 20 世纪 50 年代后期,它在首次出现的时分机器中引入,那时只是一个简单的思想,称为基址加界限机制 (base and bound),有时又称为动态重定位 (dynamic relocation)

  • 每个 CPU 需要两个硬件寄存器:

    • 基址 (base) 寄存器
    • 界限 (bound) 寄存器,有时称为限制 (limit) 寄存器

    这组基址和界限寄存器,让我们能够将地址空间放在物理内存的任何位置,同时又能确保进程只能访问自己的地址空间

    physical address = virtual address + base
    
  • 将虚拟地址转换为物理地址,这正是所谓的 地址转换 (address translation) 技术

  • 我们甚至可以在进程开始运行后改变其地址空间,这种技术一般被称为 动态重定位 (dynamic relocation)

有时我们将 CPU 的这个负责地址转换的部分统称为 内存管理单元 (Memory Management Unit, MMU)

关于界限寄存器

  • 在一种方式中(像上面那样),它记录地址空间的大小,硬件在将虚拟地址与基址寄存器内容求和前,就检查这个界限
  • 另一种方式是界限寄存器中记录地址空间结束的物理地址,硬件在转化虚拟地址到物理地址之后才去检查这个界限

数据结构:空闲列表

  • 操作系统必须记录哪些空闲内存没有使用,以便能够为进程分配内存
  • 很多不同的数据结构可以用于这项任务,其中最简单的(也是我们假定在这里采用的)是空闲列表 (free list)
  • 它就是一个列表,记录当前没有使用的物理内存的范围

动态重定位硬件要求

  • 特权模式: 需要,以防用户模式的进程执行特权操作
  • 基址/界限寄存器: 每个 CPU 需要一对寄存器来支持地址转换和界限检查
  • 能够转换虚拟地址并检查它是否越界: 电路来完成转换和检查界限,在这种情况下,非常简单
  • 修改基址/界限寄存器的特权指令: 在让用户程序运行之前,操作系统必须能够设置这些值
  • 注册异常处理程序的特权指令: 操作系统必须能告诉硬件,如果异常发生,那么执行哪些代码
  • 能够触发异常: 如果进程试图使用特权指令或越界的内存

动态重定位操作系统的职责

  • 内存管理

    • 需要为新进程分配内存
    • 从终止的进程回收内存
    • 一般通过空闲列表 (free list) 来管理内存
  • 基址/界限管理

    • 必须在上下文切换时正确设置基址/界限寄存器
  • 异常处理

    • 当异常发生时执行的代码,可能的动作是终止犯错的进程

为了支持动态重定位,硬件添加了新的功能,使得操作系统有了一些必须处理的新问题

  • 第一,在进程创建时,操作系统必须采取行动,为进程的地址空间找到内存空间

  • 第二,在进程终止时(正常退出,或因行为不端被强制终止),操作系统也必须做一些工作,回收它的所有内存给其他进程或者操作系统使用

  • 第三,在上下文切换时,操作系统也必须执行一些额外的操作

    每个 CPU 毕竟只有一个基址寄存器和一个界限寄存器,但对于每个运行的程序,它们的值都不同,因为每个程序被加载到内存中不同的物理地址

    它必须将当前基址和界限寄存器中的内容保存在内存中,放在某种每个进程都有的结构中,如进程结构 (processstructure) 或进程控制块 (Process Control Block, PCB)

  • 第四,操作系统必须提供异常处理程序 (exception handler)

分段

泛化的基址/界限

  • 在 MMU 中引入不止一个基址和界限寄存器对,而是给地址空间内的每个逻辑段 (segment) 一对
  • 一个段只是地址空间里的一个连续定长的区域,在典型的地址空间里有 3 个逻辑不同的段:代码、栈、堆
  • 分段的机制使得操作系统能够将不同的段放到不同的物理内存区域,从而避免了虚
    拟地址空间中的未使用部分占用物理内存

因此:需要 MMU 中的硬件结构来支持分段

硬件在地址转换时使用段寄存器,它如何知道段内的偏移量,以及地址引用了哪个段?

  • 一种常见的方式,有时称为显式 (explicit) 方式,就是用虚拟地址的开头几位来标识不同的段,VAX/VMS 系统使用了这种技术
  • 隐式 (implicit) 方式中,硬件通过地址产生的方式来确定段

栈怎么办

  • 我们需要一点硬件支持,除了基址和界限外,硬件还需要知道段的增长方向(用一位区分)

支持共享

  • 为了支持共享,需要一些额外的硬件支持,这就是保护位 (protection bit)

细粒度与粗粒度的分段

  • 只有很少的几个段的系统(即代码、栈、堆),我们可以认为这种分段是粗粒度 (coarse-grained)
  • 允许将地址空间划分为大量较小的段,这被称为细粒度 (fine-grained) 分段
  • 支持许多段需要进一步的硬件支持,并在内存中保存某种段表 (segment table)

分段也带来了一些新的问题

  • 操作系统在上下文切换时应该做什么?
  • 第二个问题更重要,即管理物理内存的空闲空间

空闲空间管理

如果要管理的空闲空间由大小不同的单元构成,管理就变得困难

出现了外部碎片 (external fragmentation) 的问题:空闲空间被分割成不同大小的小块,成为碎片,后续的请求可能失败,因为没有一块足够大的连续空闲空间,即使这时总的空闲空间超出了请求的大小

在深入策略细节之前,我们先来介绍大多数分配程序采用的通用机制

  • 分割与合并

    • 分配程序会执行所谓的分割 (splitting) 动作:它找到一块可以满足请求的空闲空间,将其分割
    • 许多分配程序中因此也有一种机制,名为合并 (coalescing)
  • 追踪已分配空间的大小

  • 嵌入空闲列表

  • 让堆增长

基本策略

  • 最优匹配 (best fit) 策略非常简单:首先遍历整个空闲列表,找到和请求大小一样或更
    大的空闲块,然后返回这组候选者中最小的一块

    选择最接它用户请求大小的块,从而尽量避免空间浪费。然而,这有代价。简单的实现在遍历查找正确的空闲块时,要付出较高的性能代价

  • 最差匹配 (worst fit) 方法与最优匹配相反:它尝试找最大的空闲块,分割并满足用户
    需求后,将剩余的块(很大)加入空闲列表

    最差匹配尝试在空闲列表中保留较大的块,而不是向最优匹配那样可能剩下很多难以利用的小块。但是,最差匹配同样需要遍历整个空闲列表。更糟糕的是,大多数研究表明它的表现非常差,导致过量的碎片,同时还有很高的开销。

  • 首次匹配 (first fit) 策略就是找到第一个足够大的块,将请求的空间返回给用户。同样,剩余的空闲空间留给后续请求

    首次匹配有速度优势(不需要遍历所有空闲块),但有时会让空闲列表开头的部分有很多小块

  • 下次匹配 (next fit) 算法不同于首次匹配每次都从列表的开始查找,多维护一个指针指向上一次查找结束的位置

    其想法是将对空闲空间的查找操作扩散到整个列表中去,避免对列表开头频繁的分割。这种策略的性能与首次匹配很接它,同样避免了遍历查找

除了上述基本策略外,人们还提出了许多技术和算法,来改进内存分配

  • 分离空闲列表

    如果某个应用程序经常申请一种(或几种)大小的内存空间,那就用一个独立的列表,只管理这样大小的对象

    超级工程师 Jeff Bonwick为 Solaris 系统内核设计的厚块分配程序 (slab allocator)

  • 伙伴系统

    因为合并对分配程序很关键,所以人们设计了一些方法,让合并变得简单

分页

值得考虑第二种方法:将空间分割成固定长度的分片,在虚拟内存中,我们称
这种思想为分页

  • 分页不是将一个进程的地址空间分割成几个不同长度的逻辑段(即代码、堆、段),而是分割成固定大小的单元,每个单元称为一页
  • 我们把物理内存看成是定长槽块的阵列,叫作页帧 (page frame)
  • 为了记录地址空间的每个虚拟页放在物理内存中的位置,操作系统通常为每个进程保存一个数据结构,称为页表 (page table)
movl <virtual address>, %eax
  • 注意从地址 <virtual address> 到寄存器 eax 的数据显式加载

  • 为了转换 (translate) 该过程生成的虚拟地址,我们必须首先将它分成两个组件:

    • 虚拟页面号 (virtual page number, VPN)
    • 页内的偏移量 (offset)

页表存在哪里

  • 由于页表之大,我们没有在 MMU 中利用任何特殊的片上硬件,来存储当前正在运行的进程的页表,而是将每个进程的页表存储在内存中
  • 现在让我们假设页表存在于操作系统管理的物理内存中,稍后我们会看到,很多操作系统内存本身都可以虚拟化,因此页表可以存储在操作系统的虚拟内存中(甚至可以交换到磁盘上),但是现在这太令人困惑了,所以我们会忽略它

页表中究竟有什么

页表就是一种数据结构,用于将虚拟地址映射到物理地址

  • 最简单的形式称为线性页表 (linear page table),就是一个数组

    操作系统通过虚拟页号 (VPN) 检索该数组,并在该索引处查找页表项 (PTE) ,以便找到期望的物理帧号 (PFN)

  • PTE

    • 有效位 valid bit
    • 保护位 protection bit
    • 存在位 present bit: 表示该页是在物理存储器还是在磁盘上
    • 脏位 dirty bit: 表明页面被带入内存后是否被修改过
    • 参考位 reference bit/访问位 accessed bit: )有时用于追踪页是否被访问,也用于确定哪些页很受欢迎,因此应该保留在内存中

对于每个内存引用,分页都需要我们执行一个额外的内存引用,以便首先从页表中获取地址转换,问题额外的内存引用开销很大以及进程减慢

快速地址转换 TLB

使用分页作为核心机制来实现虚拟内存,可能会带来较高的性能开销,帮助常常来自操作系统的老朋友:硬件

  • 我们要增加所谓的地址转换旁路缓冲存储器 (translation-lookaside buffer, TLB)
  • 它就是频繁发生的虚拟到物理地址转换的硬件缓存,更好的名称应该是地址转换缓存 (address-translation cache)

缓存是计算机系统中最基本的性能改进技术之一
硬件缓存背后的思想是利用指令和数据引用的局部性

  • 时间局部性 (temporal locality): 最近访问过的指令或数据项可能很快会再次访问
  • 空间局部性 (spatial locality): 访问内存地址 x 时,可能很快会访问邻近 x 的内存

RISC 与 CISC

  • 一方是 CISC,即复杂指令集计算 (Complex Instruction Set Computing)
  • 另一方是 RISC,即精简指令集计算 (Reduced Instruction Set Computing)

上下文切换时对 TLB 的处理

  • 一种方法是在上下文切换时,简单地清空 (flush) TLB
  • 一些系统增加了硬件支持,实现跨上下文切换的 TLB 共享

TLB 和其他缓存一样,还有一个问题要考虑,即缓存替换 (cache replacement)

  • 一种常见的策略是替换最近最少使用 (least-recently-used, LRU)
  • 另一种典型策略就是随机 (random) 策略,即随机选择一项换出去

较小的表

我们现在来解决分页引入的第二个问题:页表太大,因此消耗的内存太多

更大的页

  • 简单的解决方案:更大的页,多种页大小
  • 这种方法的主要问题在于,大内存页会导致每页内的浪费,这被称为内部碎片 (internal fragmentation) 问题

混合方法:分页和分段

  • 我们的杂合方法不是为进程的整个地址空间提供单个页表,而是为每个逻辑分段提供一个
  • 杂合方案的关键区别在于,每个分段都有界限寄存器,每个界限寄存器保存了段中最大有效页的值

多级页表

另一种方法并不依赖于分段,称为多级页表 (multi-level page table)

  • 首先,将页表分成页大小的单元
  • 然后,如果整页的页表项 (PTE) 无效,就完全不分配该页的页表
  • 为了追踪页表的页是否有效,使用了名为页目录 (page directory) 的新结构

在一个简单的两级页表中,页目录为每页页表包含了一项

  • 它由多个页目录项 (PageDirectory Entries, PDE) 组成
  • PDE(至少)拥有有效位 (valid bit) 和页帧号 (page frame number,
    PFN),类似于 PTE

多级页表有一些明显的优势

  • 首先,也许最明显的是,多级页表分配的页表空间,与你正在使用的地址空间内存量成比例,因此它通常很紧凑,并且支持稀疏的地址空间
  • 其次,如果仔细构建,页表的每个部分都可以整齐地放入一页中,从而更容易管理内存
  • 有了多级结构,我们增加了一个间接层 (level of indirection),使用了页目录,它指向页表的各个部分,这种间接方式,让我们能够将页表页放在物理内存的任何地方

多级页表是有成本的

  • 需要从内存加载两次,才能从页表中获取正确的地址转换信息,多级表是一个时间—空间折中
  • 另一个明显的缺点是复杂性

我们假定多级页表只有两个级别:一个页目录和几页页表。在某些情况下,更深的树是可能的(超过两级)

反向页表

  • 在反向页表 (inverted page table) 中,可以看到页表世界中更极端的空间节省
  • 线性扫描是昂贵的,因此通常在此基础结构上建立散列表,以加速查找

超越物理内存:机制

假设我们需要支持许多同时运行的巨大地址空间,为了达到这个目的,需要在内存层级 (memory hierarchy) 上再加一层

  • 交换空间

    我们要做的第一件事情就是,在硬盘上开辟一部分空间用于物理页的移入和移出,一般这样的空间称为交换空间 (swap space)

    • 为了达到这个目的,操作系统需要记住给定页的硬盘地址 (disk address)

    • 交换空间的大小是非常重要的,它决定了系统在某一时刻能够使用的最大内存页数

    • 现在我们在硬盘上有一些空间,需要在系统中增加一些更高级的机制,来支持从硬盘交换页

      • 硬件将其转换为物理地址,再从内存中获取所需数据
      • 判断是否在内存中的方法,是通过页表项中的一条新信息,即存在位 (present bit)
      • 访问不在物理内存中的页,这种行为通常被称为页错误 (page fault),实际上,它应该被称为页未命中 (page miss)
      • 在页错误时,操作系统被唤起来处理页错误

页错误

  • TLB 未命中的情况下,我们有两种类型的系统:

    • 硬件管理的 TLB(硬件在页表中找到需要的转换映射)
    • 软件管理的 TLB(操作系统执行查找过程)
  • 如果一个页不存在,它已被交换到硬盘,在处理页错误的时候,操作系统需要将该页
    交换到内存中

那么,问题来了:操作系统如何知道所需的页在哪儿?

  • 因此,操作系统可以用 PTE 中的某些位来存储硬盘地址,这些位通常用来存储像页的 PFN 这样的数据
  • 当硬盘 I/O 完成时,操作系统会更新页表,将此页标记为存在,更新页表项 PTE 的
    PFN 字段以记录新获取页的内存位置,并重试指令

为什么硬件不能处理页错误

  • 首先,页错误导致的硬盘操作很慢,即使操作系统需要很长时间来处理故障,执行大量的指令,但相比于硬盘操作,这些额外开销是很小的
  • 其次,为了能够处理页故障,硬件必须了解交换空间,如何向硬盘发起 I/O 操作,以及很多它当前所不知道的细节

注意:当 I/O 在运行时,进程将处于阻塞状态 (blocked)

内存满了怎么办

因此,操作系统可能希望先交换出 (page out) 一个或多个页,以便为操作系统即将交换入的新页留出空间

选择哪些页被交换出或被替换 (replace) 的过程,被称为页交换策 (page-replacement policy)

交换何时真正发生

  • 为了保证有少量的空闲内存,大多数操作系统会设置高水位线 (High Watermark, HW) 和低水位线 (Low Watermark, LW),来帮助决定何时从内存中清除页
  • 原理是这样:当操作系统发现有少于 LW 个页可用时,后台负责释放内存的线程会开始运行,直到有 HW 个可用的物理页
  • 这个后台线程有时称为交换守护进程 (swap daemon) 或页守护进程 (pagedaemon)

超越物理内存:策略

操作系统如何决定从内存中踢出哪些页?

这个决定由系统的替换策略做出,替换策略通常会遵循一些通用的原则

缓存管理

  • 于内存只包含系统中所有页的子集,因此可以将其视为系统中虚拟内存页的缓存

  • 在为这个缓存选择替换策略时

    • 我们的目标是让缓存未命中最少,即使得从磁盘获取页的次数最少
    • 或者,可以将目标看成让缓存命中最多,即在内存中找到待访问页的次数最多

知道了缓存命中和未命中的次数,就可以计算程序的平均内存访问时间 (Average
Memory Access Time, AMAT)

\(AMAT=(P_{Hit} \cdot T_{M}) + (P_{Miss} \cdot T_{D})\)

  • \(P_{Hit}\) 表示在缓存中找到数据的概率
  • \(P_{Miss}\) 表示在缓存中找不到数据的概率
  • \(T_{M}\) 表示访问内存的成本
  • \(T_{D}\) 表示访问磁盘的成本

在现代系统中,磁盘访问的成本非常高,即使很小概率的未命中也会拉低正在运行的程序的总体 AMAT

缓存开始是空的,这种未命中有时也称作冷启动未命中 (cold-start miss) 或强制未命中 (compulsorymiss)

策略

  • 最优替换策略:OPT

    • 能达到总体未命中数量最少
    • 虽然最优策略非常不切实际,但作为仿真或其他研究的比较者还是非常有用的

    在计算机体系结构世界中,架构师有时会将未命中分为 3 类:强制性未命中、容量未命中、冲突未命中,有时称为 3C

  • 简单策略:FIFO

    许多早期的系统避免了尝试达到最优的复杂性,采用了非常简单的替换策略

    • 页在进入系统时,简单地放入一个队列,当发生替换时,队列尾部的页被踢出,遵守“先入先出”
    • FIFO 有一个很大的优势:实现相当简单
    • 缺点:根本无法确定页的重要性
  • 另一简单策略:RAND

    • 在内存满的时候它随机选择一个页进行替换
    • 实现我来很简单,但是它在挑选替换哪个页时不够智能
  • 利用历史数据:LRU

    • 为了提高后续的命中率,我们再次通过历史的访问情况作为参考

    • 可以使用的一个历史信息是频率

    • 这一系列的策略是基于人们所说的局部性原则,程序倾向于表现出两种类型的局部

      • 空间局部性:最经常使用策略 (MostFrequently-Used, MFU)
      • 时间局部性:最近使用策略 (Most-Recently-Used, MRU)

工作负载

  • 当工作负载不存在局部性时,使用的策略区别不大
  • 它表现出局部性时,LRU 明显突出
  • “循环顺序”工作负载时,RAND 明显突出

近似 LRU 替换策略

  • 由于实现完美的 LRU 代价非常昂贵,我们能否实现一个近似的 LRU 算法

  • 我们是否真的需要找到绝对最旧的页来替换

  • 时钟算法

    任何周期性地清除使用位,然后通过区分使用位是 1 和 0 来判定该替换哪个页的方法

  • 考虑脏页

    • 时钟算法的一个小修改,是对内存中的页是否被修改的额外考虑
    • 时钟算法可以被改变,以扫描既未使用又干净的页先踢出,因为如果页已被修改并因此变脏,则踢出它就必须将它写回磁盘这很昂贵

页面替换不是虚拟内存子系统采用的唯一策略

VAX/VMS 虚拟内存系统

开发人员也担心会有“自私贪婪的内存”,到目前为止,我们所看到的大部分策略都容易受到这种内存的影响,例如,LRU 是一种全局策略,不会在进程之间公平分享内存

  • 分段的 FIFO: 为了提高 FIFO 的性能,引入了两个二次机会列表
  • 页聚集:通过聚集,将大批量的页从全局脏列表中分组到一起,并将它们一举写入磁盘

其他漂亮的虚拟内存技巧

  • 按需置零 (demand zeroing): 惰性可以使得工作推迟,推迟工作可能会减少当前操作的延迟,从而提高响应能力
  • 写入时复制 (copy-on-write, COW): 如果操作系统需要将一个页面从一个地址空间复制到另一个地址空间,不是实际复制它,而是将其映射到目标地址空间,并在两个地址空间中将其标记为只读

concurrency

并发:介绍

我们已经看到了操作系统提供的基本抽象的发展

  • 如何将一个物理 CPU 变成多个虚拟 CPU,从而支持多个程序同时运行的假象
  • 了如何为每个进程创建巨大的、私有的虚拟内存的假象

将介绍为单个运行进程提供的新抽象:线程 (thread)

  • 经典观点是一个程序只有一个执行点,但多线程 (multi-threaded) 程序会有多个执行点
  • 换一个角度来看,每个线程类似于独立的进程,只有一点区别:它们共享地址空间,从而能够访问相同的数据

单个线程的状态与进程状态非常类似

  • 线程有一个程序计数器 (PC),记录程序从哪里获取指令
  • 每个线程有自己的一组用于计算的寄存器
  • 既然是多线,必定发生上下文切换 (context switch),需要将状态保存到线程控制块 (Thread Control Block, TCB)
  • 但是,与进程相比,线程之间的上下文切换有一点主要区别:地址空间保持不变
  • 在多线程的进程中,每个线程独立运行每个线程都有一个栈

使用 C 语言线程创建 myFirstThreadTest.c


#include <stdio.h>
#include <assert.h>
#include <pthread.h>

void *mythread(void *arg) {
    printf("%s\n", (char *) arg);
    return NULL;
}

int main(int argc, char *argv[]) {
    pthread_t p1, p2;
    int rc;

    printf("main: begin\n");
    rc = pthread_create(&p1, NULL, mythread, "A"); assert(rc == 0);
    rc = pthread_create(&p2, NULL, mythread, "B"); assert(rc == 0);
    
    // join waits for the threads to finish
    rc = pthread_join(p1, NULL); assert(rc == 0);
    rc = pthread_join(p2, NULL); assert(rc == 0);
    printf("main: end\n");
    
    return 0;
}
  • 编译 gcc myFirstThreadTest.c -o myFirstThreadTest -lpthread,最后执行 myFirstThreadTest
[learn@centos7 OSTEP]$ ./myFirstThreadTest 
main: begin
A
B
main: end

线程创建有点像进行函数调用,然而,并不是首先执行函数然后返回给调用者,而是为被调用的例程创建一个新的执行线程,它可以独立于调用者运行,可能在从创建者返回之前运行,但也许会晚得多

为什么更糟糕:共享数据

设想一个简单的例子,其中两个线程希望更新全局共享变量

  • 核心问题:不可控的调度

    我们的两个线程 A 进入这个代码区域,并且因此将要增加一个计数器,此时时钟中断发生操作系统将当前正在运行的线程的状态保存到线程的 TCB,加了但没有来得及同步上共享变量

    另一个线程 B 被选中运行,需要获取计数器的值并将其放入其寄存器,此时获取的值是并没有被改变的

    对于计算结构理应 +2,但实际只是 +1

    请记住:运行时每个线程都有自己的专用寄存器,上下文切换代码将寄存器虚拟化 (virtualized),保存并恢复它们的值

    • 这里展示的情况称为竞态条件 (race condition): 结果取决于代码的时间执行,由于运气不好我们得到了错误的结果,我们称这个结果是不确定的
    • 由于执行这段代码的多个线程可能导致竞争状态,因此我们将此段代码称为临界区 (critical section)
    • 我们真正想要的代码就是所谓的互斥 (mutual exclusion): 这个属性保证了如果一个线程在临界区内执行,其他线程将被阻止进入临界区

原子性愿望

解决这个问题的一种途径是拥有更强大的指令,单步就能完成要做的事,从而消除不合时宜的中断的可能性

  • 原子方式的意思是“作为一个单元”,它不能在指令中间中断,因为这正是我们从硬件获得的保证
  • 我们要做的是要求硬件提供一些有用的指令,可以在这些指令上构建一个通用的集合,即所谓的同步原语 (synchronization primitive)

还有一个问题:等待另一个线程

  • 要为临界区支持原子性,一个线程在继续之前必须等待另一个线程完成某些操作

实际上共享数据的问题在写操作的同步性问题,对应读操作并不存在

线程 API

操作系统应该提供哪些创建和控制线程的接口?这些接口如何设计得易用和实用?

在 POSIX 线程创建

#include <pthread.h>

// 创建线程
int
pthread_create( pthread_t *             thread,
                const pthread_attr_t *  attr,
                void *                  (*start_routine)(void*),
                void *                  arg); 

// 获取线程返回
int 
pthread_join( pthread_t thread,
              void **value_ptr); 
  • 该函数有 4 个参数:thread、attr、start_routine、arg

    • thread 指针指向 pthread_t 结构类型,我们将利用这个结构与该线程交互,因此需要将它传入 pthread_create(),以便将它初始化
    • attr 用于指定该线程可能具有的任何属性
    • start_routine 函数指针,这个线程应该在哪个函数中运行
    • arg 就是要传递给线程开始执行的函数的参数
  • 一旦你创建了一个线程,你确实拥有了另一个活着的执行实体,它有自己的调用栈,与程序中所有当前存在的线程在相同的地址空间内运行

  • 如果你想等待线程完成,会发生什么情况,你必须调用函数 pthread_join()

    • pthread_t 用于指定要等待的线程
    • value_ptr 指向你希望得到的返回值
#include <stdio.h>
#include <pthread.h>
#include <assert.h>
#include <stdlib.h>

// 传入数据包装
typedef struct myarg_t {
    int a;
    int b;
} myarg_t;

// 传出数据包装
typedef struct myret_t {
    int x;
    int y;
} myret_t;

// 线程回调的动作
void *mythread(void *arg) {
    // 打印传入数据
    myarg_t *m = (myarg_t *) arg;
    printf("%d %d\n", m->a, m->b);

    // 包装传出数据
    myret_t *r = malloc(sizeof(myret_t));
    r->x = 1;
    r->y = 2;
    return (void *) r;
}

int
main(int argc, char *argv[]) {
    int rc;
    pthread_t p;
    myret_t *m; 
    myarg_t args;
    args.a = 10;
    args.b = 20;
    pthread_create(&p, NULL, mythread, &args);      // 创建线程
    pthread_join(p, (void **) &m);                  // 获取线程返回的数据包裹
    printf("returned %d %d\n", m->x, m->y);         // 打印返回的数据
    return 0;
}
  • 首先,我们常常不需要这样痛苦地打包、解包参数。如果我们不需要参数,创建线程时传入 NULL 即可
  • 类似的,如果不需要返回值,那么 pthread_join() 调用也可以传入 NULL
  • 再次,我们应该注注,必须非常小心如何从线程返回值,特别是,永远不要返回一个指针,并让它指向线程调用栈上分配的东西

锁 lock 操作

POSIX 线程库提供的最有用的函数集,可能是通过锁(lock)来提供互斥进入临界区的那些函数

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

示范:

pthread_mutex_t lock;
pthread_mutex_lock(&lock);
x = x + 1; // or whatever your critical section is
pthread_mutex_unlock(&lock);
  • 如果在调用 pthread_mutex_lock() 时没有其他线程持有锁,线程将获取该锁并进入临界区
  • 如果另一个线程确实持有该锁,那么尝试获取该锁的线程将不会从该调用返回,直到获得该锁
  • 只有获得锁的线程才应该调用解锁

这段代码有两个重要的问题

  • 第一个问题是缺乏正确的初始化 (lack of proper initialization)

    • 一种方法是使用 PTHREAD_MUTEX_INITIALIZER

      pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
      
    • 初始化的动态方法(即在运行时)是调用 pthread_mutex_init()

      int rc = pthread_mutex_init(&lock, NULL);
      assert(rc == 0); // always check success! 
      
      • 此函数的第一个参数是锁本身的地址,而第二个参数是一组可选属性
      • 当你用完锁时,还应该相应地调用 pthread_mutex_destroy()

    通常使用方法二

  • 第二个问题是在调用获取锁和释放锁时没有检查错误代码

    • 至少要使用包装的函数,它对函数成功加上断言

条件变量

所有线程库还有一个主要组件,就是存在一个条件变量 (condition variable)

  • 希望以这种方式进行交互的程序使用两个主要函数

    int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
    int pthread_cond_signal(pthread_cond_t *cond); 
    
    • 要使用条件变量,必须另外有一个与此条件相关的锁

    • 函数 pthread_cond_wait() 使调用线程进入休眠状态,因此等待其他线程发出信号,通常当程序中的某些内容发生变化时,现在正在休眠的线程可能会关心它,还会让调用者睡眠时释放锁

    • pthread_cond_signal() 唤醒作用

      pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
      pthread_cond_t cond = PTHREAD_COND_INITIALIZER; 
      Pthread_mutex_lock(&lock);
      while (ready == 0){
          Pthread_cond_wait(&cond, &lock);
      }
      Pthread_mutex_unlock(&lock); 
      
      • 在这段代码中,在初始化相关的锁和条件之后①,一个线程检查变量 ready 是否已经被设置为零以外的值
      • 如果没有,那么线程只是简单地调用等待函数以便休眠,直到其他线程唤醒它

代码需要包括头文件 pthread.h 才能编译,链接时需要 pthread 库,增加-pthread 标记 gcc -o main main.c -Wall -pthread

当你使用 POSIX 线程库(或者实际上,任何线程库)来构建多线程程序时,需要记住一些小而重要的事情

  • 保持简洁:最重要的一点,线程之间的锁和信号的代码应该尽可能简洁
  • 让线程交互减到最少:尽量减少线程之间的交互
  • 初始化锁和条件变量:未初始化的代码有时工作正常,有时失败,会产生奇怪的结果
  • 检查返回值:任何 C 和 UNIX 的程序,都应该检查返回值,这里也是一样
  • 注意传给线程的参数和返回值:具体来说,如果传递在栈上分配的变量的引用,可能就是在犯错误
  • 每个线程都有自己的栈:线程之间共享数据,值要在堆或者其他全局可访问的位置
  • 线程之间总是通过条件变量发送信号:切记不要用标记变量来同步
  • 多查手册:尤其是 Linux 的 pthread 手册,有更多的细节、更丰富的内容

锁 lock

锁的基本思想

  • 锁就是一个变量,因此我们需要声明一个某种类型的锁变量 (lock variable)

  • 这个锁变量(简称锁)保存了锁在某一时刻的状态

    • 要么是可用的 (available、unlocked、free) 表示没有线程持有锁
    • 要么是被占用的 (acquired、locked、held) 表示有一个线程持有锁正处于临界区
  • 我们也可以保存其他的信息,比如持有锁的线程,或请求获取锁的线程队列,但这些信息会隐藏起来,锁的使用者不会发现

  • lock() 和 unlock() 函数的语义很简单

    • lock(): 获取锁,如果没有其他线程持有锁(即它是可用的),该线程会获得锁进入临界区,这个线程有时被称为锁的持有者 (owner)
    • unlock(): 锁的持有者一旦调用,锁就变成可用的

锁为程序员提供了最小程度的调度控制

  • 我们把线程视为程序员创建的实体,但是被操作系统调度,具体方式由操作系统选择
  • 锁让程序员获得一些控制权,通过给临界区加锁,可以保证临界区内只有一个线程活跃

Pthread 锁

POSIX 库将锁称为互斥量 (mutex),因为它被用来提供线程之间的互斥

可以理解为:即当一个线程在临界区,它能够阻止其他线程进入直到本线程离开临界区

  • POSIX 的 lock 和 unlock 函数会传入一个变量,因为我们可能用不同的锁来保护不同的变量

  • 这样可以增加并发:

    • 不同于任何临界区都使用同一个大锁(粗粒度的锁策略)
    • 通常大家会用不同的锁保护不同的数据和结构,从而允许更多的线程进入临界区(细粒度的方案)

实现一个锁

我们需要硬件和操作系统的帮助来实现一个可用的锁

  • 各种计算机体系结构的指令集都增加了一些不同的硬件原语

在实现锁之前,我们应该首先明确目标,因此我们要问,如何评价一种锁实现的效果

  • 第一是锁是否能完成它的基本任务,即提供互斥
  • 第二是公平性,是否每一个竞争线程有公平的机会抢到锁
  • 最后是性能,是使用锁之后增加的时间开销

我们能够更好地理解不同的锁技术对性能的影响

  • 控制中断:最早提供的互斥解决方案之一,就是在临界区关闭中断

    • 这个方法的主要优点就是简单

    • 问题

      • 不公平性,一个贪婪的程序可能在它开始时就调用,从而独占处理器
      • 这种方案不支持多处理器
      • 关闭中断导致中断丢失,可能会导致严重的系统问题
      • 最后一个不太重要的原因就是效率低
  • 测试并设置指令

    • 最简单的硬件支持是测试并设置指令 (test-and-set instruction),也叫作原子交换 (atomic exchange)

      • 我们实现一个不依赖它的锁,用一个变量标记锁是否被持有
    typedef struct lock_t { int flag; } lock_t;
    
    void init(lock_t *mutex) {
        // 0 -> lock is available, 1 -> held
        mutex->flag = 0;
    }
    
    void lock(lock_t *mutex) {
        while (mutex->flag == 1) // TEST the flag
            ; // spin-wait (do nothing)
        mutex->flag = 1; // now SET it!
    }
    
    void unlock(lock_t *mutex) {
        mutex->flag = 0;
    } 
    
    • 当第一个线程正处于临界区时,如果另一个线程调用 lock(),它会在 while 循环中自旋等待 (spin-wait),直到第一个线程调用 unlock() 清空标志
    • 显然没有满足最基本的要求:互斥
    • 性能问题:采用了自旋等待的技术,就是不停地检查标志的值

    实现可用的自旋锁

    • 在 SPARC 上,这个指令叫 ldstub (load/store unsigned byte),在 x86 上是 xchg (atomic exchange) 但它们基本上在不同的平台上做同样的事,通常称为测试并设置指令
    int TestAndSet(int *old_ptr, int new) {
        int old = *old_ptr;     // fetch old value at old_ptr
        *old_ptr = new;         // store 'new' into old_ptr
        return old;             // return the old value
    } 
    
    • 将测试(test 旧的锁值)和设置(set 新的值)合并为一个原子操作之后,我们保证了只有一个线程能获取锁,这就实现了一个有效的互斥原语

    • 自旋锁 (spin lock): 这是最简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用

      • 自旋锁一次只允许一个线程进入临界区,因此这是正确的锁
      • 自旋锁没有公平性,可能会导致饿死
      • 在单 CPU 的情况下,性能开销相当大
  • 比较并交换

    • 某些系统提供了另一个硬件原语,即比较并交换指令(SPARC 系统中是 compare-and-swap,x86 系统是 compare-and-exchange)

    • C 语言伪代码

      int CompareAndSwap(int *ptr, int expected, int new) {
          int actual = *ptr;
          if (actual == expected)
              *ptr = new;
          return actual;
      } 
      
      • 你可能会发现,比较并交换指令比测试并设置更强大
      • 如果只用它实现一个简单的自旋锁,它的行为等价于上面分析的自旋锁
  • 链接的加载和条件式存储指令

    • 链接的加载 (load-linked) 和条件式存储 (store-conditional) 可以用来配合使用
    int LoadLinked(int *ptr) {
        return *ptr;
    }
    
    int StoreConditional(int *ptr, int value) {
        if (no one has updated *ptr since the LoadLinked to this address) { 
            *ptr = value;
            return 1; // success!
        } else {
            return 0; // failed to update
        }
    }
    
    • 链接的加载指令和典型加载指令类似,都是从内存中取出值存入一个寄存器
    • 关键区别来自条件式存储指令,只有上一次加载的地址在期间都没有更新时才会成功(同时更新刚才链接的加载的地址的值)
  • 获取并增加

    • 最后一个硬件原语是获取并增加 (fetch-and-add) 指令

      它能原子地返回特定地址的旧值,并且让该值自增一

    • 实现一个更有趣的 ticket 锁

      int FetchAndAdd(int *ptr) {
          int old = *ptr;
          *ptr = old + 1;
          return old;
      }
      
      typedef struct lock_t {
          int ticket;
          int turn;
      } lock_t;
      
      void lock_init(lock_t *lock) {
          lock->ticket = 0;
          lock->turn = 0;
      }
      
      void lock(lock_t *lock) {
          int myturn = FetchAndAdd(&lock->ticket);
          while (lock->turn != myturn)
              ; // spin
      }
      
      void unlock(lock_t *lock) {
          FetchAndAdd(&lock->turn);
      }
      
    • 不同于之前的方法:本方法能够保证所有线程都能抢到锁

怎样避免自旋过多

只有硬件支持是不够的,我们还需要操作系统支持

  • 第一种简单友好的方法就是在要自旋的时候放弃 CPU

    • 我们假定操作系统提供原语 yield(): 线程可以调用它主动放弃 CPU,让其他线程运行
    • yield()系统调用能够让运行 running 变为就绪 ready

    真正问题是存在太多的偶然性

  • 使用队列:休眠替代自旋

    • 提供了两个调用:

      • park()能够让调用线程休眠
      • unpark(threadID) 则会唤醒 threadID 标识的线程
    typedef struct lock_t {
        int flag;
        int guard;
        queue_t *q;
    } lock_t;
    
    void lock_init(lock_t *m) {
        m->flag = 0;
        m->guard = 0;
        queue_init(m->q);
    }
    
    void lock(lock_t *m) {
        while (TestAndSet(&m->guard, 1) == 1)
            ; //acquire guard lock by spinning
        if (m->flag == 0) {
            m->flag = 1; // lock is acquired
            m->guard = 0;
        } else {
            queue_add(m->q, gettid());
            m->guard = 0;
            park();
        }
    }
    
    void unlock(lock_t *m) {
        while (TestAndSet(&m->guard, 1) == 1)
            ; //acquire guard lock by spinning
        if (queue_empty(m->q))
            m->flag = 0; // let go of lock; no one wants it
        else
            unpark(queue_remove(m->q)); // hold lock (for next thread!)
        m->guard = 0;
    } 
    
    • 首先,我们将之前的测试并设置和等待队列结合,实现了一个更高性能的锁
    • 其次,我们通过队列来控制谁会获得锁,避免饿死

    问题

    • 这个方法并没有完全避免自旋等待

    • 你可能注意到解决方案中的竞争条件,就在 park()调用之前

      • 如果不凑巧,一个线程将要 park,假定它应该睡到锁可用时
      • 这时切换到另一个线程(比如持有锁的线程),这可能会导致麻烦
      • 这种问题有时称为 唤醒/等待竞争 (wakeup/waiting race)
      • 通过增加了第三个系统调用 separk() 来解决这一问题
    • lock() 调用可以做一点小修改

      queue_add(m->q, gettid());
      setpark(); // new code
      m->guard = 0; 
      

不同操作系统,不同实现

  • Linux 提供了 futex

    • 每个 futex 都关联一个特定的物理内存位置,也有一个事先建好的内核队列
    void mutex_lock (int *mutex) {
        int v;
        /* Bit 31 was clear, we got the mutex (this is the fastpath) */
        if (atomic_bit_test_set (mutex, 31) == 0)
            return;
        atomic_increment (mutex);
        while (1) {
            if (atomic_bit_test_set (mutex, 31) == 0) {
                atomic_decrement (mutex);
                return;
            }
            /* We have to wait now. First make sure the futex value
            we are monitoring is truly negative (i.e. locked). */
            v = *mutex;
            if (v >= 0)
                continue;
            futex_wait (mutex, v);
        }
    }
    
    void mutex_unlock (int *mutex) {
        /* Adding 0x80000000 to the counter results in 0 if and only if
        there are not other interested threads */
        if (atomic_add_zero (mutex, 0x80000000))
            return;
    
        /* There are other threads waiting for this mutex,
        wake one of them up. */
        futex_wake (mutex);
    
    • 基本上,它利用一个整数

      • 同时记录锁是否被持有(整数的最高位)

        • 如果锁是负的,它就被持有
        • 如果锁是正的,它就可用的
      • 等待者的个数(整数的其余所有位)

    • 它展示了如何优化常见的情况,即没有竞争时:只有一个线程获取和释放锁,所做的工作很少(获取锁时测试和设置的原子位运算,释放锁时原子的加法)

两阶段锁

Linux 采用的是一种古老的锁方案,多年来不断被采用,可以追溯到 20 世纪 60 年代早期的 Dahm 锁,现在也称为两阶段锁

两阶段锁意识到自旋可能很有用,尤其是在很快就要释放锁的场景

  • 如果第一个自旋阶段没有获得锁,第二阶段调用者会睡眠,直到锁可用
  • 上文的 Linux 锁就是这种锁,不过只自旋一次
  • 更常见的方式是在循环中自旋固定的次数,然后使用 futex 睡眠

基于锁的并发数据结构

通过锁可以使数据结构线程安全 (thread safe)

并发计数器

typedef struct counter_t {
    int value;
} counter_t;

void init(counter_t *c) {
    c->value = 0;
}

void increment(counter_t *c) {
    c->value++;
}

void decrement(counter_t *c) {
    c->value--;
}

int get(counter_t *c) {
    return c->value;
} 

如何让这段代码线程安全

typedef struct counter_t {
    int value;
    pthread_mutex_t lock;
} counter_t;

void init(counter_t *c) {
    c->value = 0;
    Pthread_mutex_init(&c->lock, NULL);
}

void increment(counter_t *c) {
    Pthread_mutex_lock(&c->lock);
    c->value++;
    Pthread_mutex_unlock(&c->lock);
}

void decrement(counter_t *c) {
    Pthread_mutex_lock(&c->lock);
    c->value--;
    Pthread_mutex_unlock(&c->lock);
}

int get(counter_t *c) {
    Pthread_mutex_lock(&c->lock);
    int rc = c->value;
    Pthread_mutex_unlock(&c->lock);
    return rc;
} 
  • 它只是加了一把锁,在调用函数操作该数据结构时获取锁,从调用返回时释放锁

  • 这种方式类似基于观察者 (monitor) 的数据结构

  • 问题可能就是性能了

    • 理想情况下,你会看到多处理上运行的多线程就像单线程一样快,达到这种状态称为完美扩展 (perfect scaling)
    • 虽然总工作量增多,但是并行执行后,完成任务的时间并没有增加
  • 可扩展的计数

    • 这个方法是最近的研究提出的,称为懒惰计数器 (sloppy counter)

    • 懒惰计数器通过多个局部计数器一个全局计数器来实现一个逻辑计数器,其中每个 CPU 核心有一个局部计数器

    • 懒惰计数器的基本思想:

      • 如果一个核心上的线程想增加计数器,那就增加它的局部计数器,访问这个局部计数器是通过对应的局部锁同步的
      • 为了保持全局计数器更新(以防某个线程要读取该值),局部值会定期转移给全局计数器,方法是获取全局锁,让全局计数器加上局部计数器的值,然后将局部计数器置零
    • 这种局部转全局的频度,取决于一个阈值,这里称为 S (sloppiness)

      • S 越小,懒惰计数器则越趋近于非扩展的计数器
      • S 越大,扩展性越强,但是全局计数器与实际计数的偏差越大
    typedef struct counter_t {
        int global;                             // global count
        pthread_mutex_t glock;                  // global lock
        int local[NUMCPUS];                     // local count (per cpu)
        pthread_mutex_t llock[NUMCPUS];         // ... and locks
        int threshold;                          // update frequency
    } counter_t;
    
    // init: record threshold, init locks, init values
    // of all local counts and global count
    void init(counter_t *c, int threshold) {
        c->threshold = threshold;
    
        c->global = 0;
        pthread_mutex_init(&c->glock, NULL);
    
        int i;
        for (i = 0; i < NUMCPUS; i++) {
            c->local[i] = 0; 
            pthread_mutex_init(&c->llock[i], NULL);
        }
    }
    
    // update: usually, just grab local lock and update local amount
    // once local count has risen by 'threshold', grab global
    // lock and transfer local values to it
    void update(counter_t *c, int threadID, int amt) {
        pthread_mutex_lock(&c->llock[threadID]);
        c->local[threadID] += amt;                  // assumes amt > 0
        if (c->local[threadID] >= c->threshold) {   // transfer to global
            pthread_mutex_lock(&c->glock);
            c->global += c->local[threadID];
            pthread_mutex_unlock(&c->glock);
            c->local[threadID] = 0;
        }
        pthread_mutex_unlock(&c->llock[threadID]);
    }
    
    // get: just return global amount (which may not be perfect)
    int get(counter_t *c) {
        pthread_mutex_lock(&c->glock);
        int val = c->global;
        pthread_mutex_unlock(&c->glock);
        return val; // only approximate!
    } 
    

并发链表

// basic node structure
typedef struct node_t {
    int key;
    struct node_t *next;
} node_t;

// basic list structure (one used per list)
typedef struct list_t {
    node_t *head;
    pthread_mutex_t lock;
} list_t;

void List_Init(list_t *L) {
    L->head = NULL; 
    pthread_mutex_init(&L->lock, NULL);
}

int List_Insert(list_t *L, int key) {
    pthread_mutex_lock(&L->lock);
    node_t *new = malloc(sizeof(node_t));
    if (new == NULL) {
        perror("malloc");
        pthread_mutex_unlock(&L->lock);
        return -1;          // fail
    }
    new->key = key;
    new->next = L->head;
    L->head = new;
    pthread_mutex_unlock(&L->lock);
    return 0;               // success
}

int List_Lookup(list_t *L, int key) {
    pthread_mutex_lock(&L->lock);
    node_t *curr = L->head;
    while (curr) {
        if (curr->key == key) {
            pthread_mutex_unlock(&L->lock);
            return 0;       // success
        }
        curr = curr->next;
    }
    pthread_mutex_unlock(&L->lock);
    return -1;              // failure
} 
  • 如果 malloc 失败(在极少的时候),会有一点小问题,在这种情况下,代码在插入失败之前,必须释放锁

  • 具体来说,我们调整代码,让获取锁和释放锁只环绕插入代码的真正临界区

    • 假定 malloc() 是线程安全的,每个线程都可以调用它,不需要担心竞争条件和其他并发缺陷
    void List_Insert(list_t *L, int key) {
        // synchronization not needed
        node_t *new = malloc(sizeof(node_t));
        if (new == NULL) {
            perror("malloc");
            return;
        }
        new->key = key;
    
        // just lock critical section
        pthread_mutex_lock(&L->lock);
        new->next = L->head;
        L->head = new;
        pthread_mutex_unlock(&L->lock);
    }
    
    int List_Lookup(list_t *L, int key) {
        int rv = -1;
        pthread_mutex_lock(&L->lock);
        node_t *curr = L->head;
        while (curr) {
            if (curr->key == key) {
                rv = 0;
                break;
            }
            curr = curr->next;
        }
        pthread_mutex_unlock(&L->lock);
        return rv; // now both success and failure
    } 
    
  • 扩展链表

    尽管我们有了基本的并发链表,但又遇到了这个链表扩展性不好的问题

    • 研究人员发现的增加链表并发的技术中,有一种叫作过手锁 (hand-over-hand locking),也叫作锁耦合

    • 原理也很简单

      • 每个节点都有一个锁,替代之前整个链表一个锁
      • 遍历链表的时候,首先抢占下一个节点的锁,然后释放当前节点的锁
    • 从概念上说,过手锁链表有点道理,它增加了链表操作的并发程度

    • 但是实际上,在遍历的时候,每个节点获取锁、释放锁的开销巨大,很难比单锁的方法快

  • 更多并发不一定更快,实效的两种方案

    • 简单但少一点并发
    • 复杂但多一点并发
  • 当心锁和控制流:对并发代码和其他代码都有用,即注意控制流的变化导致函数返回和退出,或其他错误情况导致函数停止执行

并发队列

总有一个标准的方法来创建一个并发数据结构:添加一把大锁

  • 我们将跳过这种方法,假定你能弄明白

    typedef struct node_t {
        int value;
        struct node_t *next;
    } node_t;
    
    typedef struct queue_t {
        node_t *head;
        node_t *tail;
        pthread_mutex_t headLock;
        pthread_mutex_t tailLock;
    } queue_t;
    
    void Queue_Init(queue_t *q) {
        node_t *tmp = malloc(sizeof(node_t));
        tmp->next = NULL;
        q->head = q->tail = tmp;
        pthread_mutex_init(&q->headLock, NULL);
        pthread_mutex_init(&q->tailLock, NULL);
    }
    
    void Queue_Enqueue(queue_t *q, int value) {
        node_t *tmp = malloc(sizeof(node_t)); 
        assert(tmp != NULL);
        tmp->value = value;
        tmp->next = NULL;
    
        pthread_mutex_lock(&q->tailLock);
        q->tail->next = tmp;
        q->tail = tmp;
        pthread_mutex_unlock(&q->tailLock);
    }
    
    int Queue_Dequeue(queue_t *q, int *value) {
        pthread_mutex_lock(&q->headLock);
        node_t *tmp = q->head;
        node_t *newHead = tmp->next;
        if (newHead == NULL) {
            pthread_mutex_unlock(&q->headLock);
            return -1; // queue was empty
        }
        *value = newHead->value;
        q->head = newHead;
        pthread_mutex_unlock(&q->headLock);
        free(tmp);
        return 0;
    } 
    
    • 仔细研究这段代码,你会发现有两个锁,一个负责队列头,另一个负责队列尾

    • 这两个锁使得入队列操作和出队列操作可以并发执行

      • 入队列只访问 tail 锁
      • 出队列只访问 head 锁
    • Michael 和 Scott 使用了一个技巧,添加了一个假节点,该假节点分开了头和尾操作

并发散列表

我们只关注不需要调整大小的简单散列表

  • 简单实现

    #define BUCKETS (101)
    
    typedef struct hash_t {
        list_t lists[BUCKETS];
    } hash_t;
    
    void Hash_Init(hash_t *H) {
        int i;
        for (i = 0; i < BUCKETS; i++) {
            List_Init(&H->lists[i]);
        }
    }
    
    int Hash_Insert(hash_t *H, int key) {
        int bucket = key % BUCKETS;
        return List_Insert(&H->lists[bucket], key);
    }
    
    int Hash_Lookup(hash_t *H, int key) {
        int bucket = key % BUCKETS;
        return List_Lookup(&H->lists[bucket], key);
    } 
    
    • 本例的散列表使用我们之前实现的并发链表,性能特别好
    • 每个散列桶(每个桶都是一个链表)都有一个锁,而不是整个散列表只有一个锁,从而支持许多并发操作

Knuth 定律:避免不成熟的优化

条件变量

锁并不是并发程序设计所需的唯一原语

  • 父线程需要检查子线程是否执行完毕,这常被称为 join()

定义和程序

线程可以使用条件变量 (condition variable),来等待一个条件变成真

  • 条件变量是一个显式队列

    • 当某些执行状态不满足时,线程可以把自己加入队列,等待 (waiting) 该条件
    • 另外某个线程,当它改变了上述状态时,就可以唤醒一个或者多个等待线程

    Dijkstra 最早在“私有信号量”中提出这种思想

    Hoare 后来在关于观察者的工作中,将类似的思想称为条件变量

  • 条件变量有两种相关操作:wait() 和 signal()

    pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m);
    pthread_cond_signal(pthread_cond_t *c);
    
    • wait(): 职责是释放锁,并让调用线程休眠(原子的),当线程被唤醒时,它必须重新获取锁,再返回调用者
    • join 问题的解决方法
    int done = 0;
    pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
    pthread_cond_t c = PTHREAD_COND_INITIALIZER;
    
    void thr_exit() {
        Pthread_mutex_lock(&m);
        done = 1;
        Pthread_cond_signal(&c);
        Pthread_mutex_unlock(&m);
    }
    
    void *child(void *arg) {
        printf("child\n");
        thr_exit();
        return NULL;
    } 
    
    void thr_join() {
        Pthread_mutex_lock(&m);
        while (done == 0)
            Pthread_cond_wait(&c, &m);
        Pthread_mutex_unlock(&m);
    }
    
    int main(int argc, char *argv[]) {
        printf("parent: begin\n");
        pthread_t p;
        Pthread_create(&p, NULL, child, NULL);
        thr_join();
        printf("parent: end\n");
        return 0;
    } 
    
    • 你可能看到父线程使用了一个 while 循环,而不是 if 语句来判断是否需要等待

    • 虽然从逻辑上来说没有必要使用循环语句,但这样做总是好的

    • 变量 done 的重要性,它记录了线程有兴趣知道的值,睡眠或唤醒锁都离不开它

    • 发信号时总是持有锁

      • 即调用 wait 时持有锁,不只是建议,而是 wait 的语义强制要求的
      • 建议请在调用 signal 时持有锁
      • 调用 signal 和 wait 时要持有锁,你会保持身心健康的

生产者/消费者(有界缓冲区)问题

假设有一个或多个生产者线程和一个或多个消费者线程

  • 生产者把生成的数据项放入缓冲区
  • 消费者从缓冲区取走数据项,以某种方式消费

因为有界缓冲区是共享资源,所以我们必须通过同步机制来访问它,以免产生竞态条件

int buffer;
int count = 0; // initially, empty

void put(int value) {
    assert(count == 0);
    count = 1;
    buffer = value;
}

int get() {
    assert(count == 1);
    count = 0;
    return buffer;
}

void *producer(void *arg) {
    int i;
    int loops = (int) arg;
    for (i = 0; i < loops; i++) {
        put(i);
    }
}

void *consumer(void *arg) {
    int i;
    while (1) {
        int tmp = get();
        printf("%d\n", tmp);
    }
}
  • 有问题的方案

    cond_t cond;
    mutex_t mutex;
    
    void *producer(void *arg) {
        int i;
        for (i = 0; i < loops; i++) {
            Pthread_mutex_lock(&mutex);             // p1
            if (count == 1)                         // p2
                Pthread_cond_wait(&cond, &mutex);   // p3
            put(i);                                 // p4
            Pthread_cond_signal(&cond);             // p5
            Pthread_mutex_unlock(&mutex);           // p6
        }
    }
    
    void *consumer(void *arg) {
        int i;
        for (i = 0; i < loops; i++) {
            Pthread_mutex_lock(&mutex);             // c1
            if (count == 0)                         // c2
                Pthread_cond_wait(&cond, &mutex);   // c3
            int tmp = get();                        // c4
            Pthread_cond_signal(&cond);             // c5
            Pthread_mutex_unlock(&mutex);           // c6 
            printf("%d\n", tmp);
        }
    }
    
    • 它第一个问题,它与等待之前的 if 语句有关

      • 在线程 1 被生产者唤醒后,但在它运行之前,缓冲区的状态改变了(由于线程 2)
      • 发信号给线程只是唤醒它们,暗示状态发生了变化(在这个例子中,就是值已被放入缓冲区),但并不会保证在它运行之前状态一直是期望的情况,信号的这种释义常称为 Mesa 语义 (Mesa semantic)
      • 为了纪念以这种方式建立条件变量的首次研究,另一种释义是 Hoare 语义 (Hoare semantic),虽然实现难度大,但是会保证被唤醒线程立刻执行
      • 实际上,几乎所有系统都采用了 Mesa 语义
  • 较好但仍有问题的方案:使用 While 语句替代 If

    cond_t cond;
    mutex_t mutex;
    
    void *producer(void *arg) {
        int i;
        for (i = 0; i < loops; i++) {
            Pthread_mutex_lock(&mutex);             // p1
            while (count == 1)                      // p2
                Pthread_cond_wait(&cond, &mutex);   // p3
            put(i);                                 // p4
            Pthread_cond_signal(&cond);             // p5
            Pthread_mutex_unlock(&mutex);           // p6
        }
    }
    
    void *consumer(void *arg) {
        int i;
        for (i = 0; i < loops; i++) {
            Pthread_mutex_lock(&mutex);             // c1
            while (count == 0)                      // c2
                Pthread_cond_wait(&cond, &mutex);   // c3
            int tmp = get();                        // c4
            Pthread_cond_signal(&cond);             // c5
            Pthread_mutex_unlock(&mutex);           // c6 
            printf("%d\n", tmp);
        }
    }
    
    • 由于 Mesa 语义,我们要记住一条关于条件变量的简单规则:总是使用 while 循环
    • 第二个问题:消费者再也不会唤醒消费者,生产者也不会唤醒生产者
    • 信号必须更有指向性,消费者不应该唤醒消费者,而应该只唤醒生产者,反之亦然,不然会出现线程都在睡眠
  • 单值缓冲区的生产者/消费者方案

    cond_t empty, fill;
    mutex_t mutex;
    
    void *producer(void *arg) {
        int i;
        for (i = 0; i < loops; i++) {
            Pthread_mutex_lock(&mutex);             // p1
            while (count == 1)                      // p2
                Pthread_cond_wait(&empty, &mutex);  // p3
            put(i);                                 // p4
            Pthread_cond_signal(&fill);             // p5
            Pthread_mutex_unlock(&mutex);           // p6
        }
    }
    
    void *consumer(void *arg) {
        int i;
        for (i = 0; i < loops; i++) {
            Pthread_mutex_lock(&mutex);             // c1
            while (count == 0)                      // c2
                Pthread_cond_wait(&fill, &mutex);   // c3
            int tmp = get();                        // c4
            Pthread_cond_signal(&empty);            // c5
            Pthread_mutex_unlock(&mutex);           // c6 
            printf("%d\n", tmp);
        }
    }
    
    • 在上述代码中:

      • 生产者线程等待条件变量 empty,发信号给变量 fill
      • 消费者线程等待 fill,发信号给 empty
    • 从设计上避免了上述第二个问题:消费者再也不会唤醒消费者,生产者也不会唤醒生产者

  • 最终的生产者/消费者方案

    int buffer[MAX];
    int fill = 0;
    int use = 0;
    int count = 0;
    
    void put(int value) {
        buffer[fill] = value;
        fill = (fill + 1) % MAX;
        count++;
    }
    
    int get() {
        int tmp = buffer[use];
        use = (use + 1) % MAX;
        count--;
        return tmp;
    }
    
    cond_t empty, fill;
    mutex_t mutex;
    
    void *producer(void *arg) {
        int i;
        for (i = 0; i < loops; i++) {
            Pthread_mutex_lock(&mutex);             // p1
            while (count == MAX)                    // p2
                Pthread_cond_wait(&empty, &mutex);  // p3
            put(i);                                 // p4
            Pthread_cond_signal(&fill);             // p5
            Pthread_mutex_unlock(&mutex);           // p6
        }
    }
    
    void *consumer(void *arg) {
        int i;
        for (i = 0; i < loops; i++) {
            Pthread_mutex_lock(&mutex);             // c1
            while (count == 0)                      // c2
                Pthread_cond_wait(&fill, &mutex);   // c3
            int tmp = get();                        // c4
            Pthread_cond_signal(&empty);            // c5
            Pthread_mutex_unlock(&mutex);           // c6
            printf("%d\n", tmp);
        }
    } 
    
    • 我们最后的修改是提高并发和效率

      • 具体来说,增加更多缓冲区槽位,这样在睡眠之前,可以生产多个值,同样睡眠之前可以消费多个值
      • 单个生产者和消费者时,这种方案因为上下文切换少,提高了效率
      • 多个生产者和消费者时,它甚至支持并发生产和消费,从而提高了并发

对条件变量使用 while 而不是 if

  • 多线程程序在检查条件变量时,使用 while 循环总是对的
  • if 语句可能会对,这取决于发信号的语义
  • 因此,总是使用 while,代码就会符合预期

覆盖条件

有时会遇到不知道应该唤醒哪个线程的问题?

  • Lampson 和 Redell 的解决方案也很直接:用 pthread_cond_broadcast() 代替上述代码中的 pthread_cond_signal(),唤醒所有的等待线程

    • 这样做,确保了所有应该唤醒的线程都被唤醒
    • 当然,不利的一面是可能会影响性能,因为不必要地唤醒了其他许多等待的线程
  • Lampson 和 Redell 把这种条件变量叫作覆盖条件 (covering condition)

  • 因为它能覆盖所有需要唤醒线程的场景(保守策略)

信号量

我们现在知道,需要锁和条件变量来解决各种相关的、有趣的并发问题

信号量的定义

信号量是有一个整数值的对象,可以用两个函数来操作它

  • 在 POSIX 标准中,是 sem_wait() 和 sem_post()
  • 因为信号量的初始值能够决定其行为,所以首先要初始化信号量,才能调用其他函数与之交互

初始化信号量

#include <semaphore.h>
sem_t s;
sem_init(&s, 0, 1); 
  • 其中申明了一个信号量 s
  • 通过第三个参数,将它的值初始化为 1
  • sem_init()的第二个参数,在我们看到的所有例子中都设置为 0,表示信号量是在同一进程的多个线程共享的
  • 信号量初始化之后,我们可以调用 sem_wait() 或 sem_post() 与之交互
int sem_wait(sem_t *s) {
    decrement the value of semaphore s by one
    wait if value of semaphore s is negative
}

int sem_post(sem_t *s) {
    increment the value of semaphore s by one
    if there are one or more threads waiting, wake one
} 
  • sem_wait()

    • 使用时减少信号量的值

      • 要么立刻返回(调用 sem_wait() 时,信号量的值 >= 1)
      • 要么会让调用线程挂起,直到之后的一个 post 操作
    • 也可能多个调用线程都调用 sem_wait(),因此都在队列中等待被唤醒

  • sem_post()

    • 它直接增加信号量的值,如果有等待线程,唤醒其中一个
    • 最后,当信号量的值为负数时,这个值就是等待线程的个数
  • 暂时不用考虑信号量内的竞争条件,假设这些操作都是原子的

二值信号量

  • 信号量的第一种用法是我们已经熟悉的:用信号量作为锁

    我们直接把临界区用一对 sem_wait()/sem_post() 环绕

    sem_t m;
    sem_init(&m, 0, 1);
    
    sem_wait(&m);
    // critical section here
    sem_post(&m);
    
  • 我们可以用信号量来实现锁了

  • 因为锁只有两个状态(持有和没持有),所以这种用法有时也叫作二值信号量

信号量用作条件变量

  • 信号量也可以用在一个线程暂停执行,等待某一条件成立的场景

  • 假设一个线程创建另外一线程,并且等待它结束

    sem_t s;
    
    void *
    child(void *arg) {
        printf("child\n");
        sem_post(&s);                           // signal here: child is done
        return NULL;
    }
    
    int
    main(int argc, char *argv[]) {
        sem_init(&s, 0, 0); 
        printf("parent: begin\n");
        pthread_t c;
        Pthread_create(c, NULL, child, NULL);
        sem_wait(&s);                           // wait here for child
        printf("parent: end\n");
        return 0;
    } 
    

生产者/消费者(有界缓冲区)问题

int buffer[MAX];
int fill = 0;
int use = 0;

void put(int value) {
    buffer[fill] = value; // line f1
    fill = (fill + 1) % MAX; // line f2
}

int get() {
    int tmp = buffer[use]; // line g1
    use = (use + 1) % MAX; // line g2
    return tmp;
}

sem_t empty;
sem_t full;

void *producer(void *arg) {
    int i;
    for (i = 0; i < loops; i++) {
        sem_wait(&empty);       // line P1
        put(i);                 // line P2
        sem_post(&full);        // line P3
    }
}

void *consumer(void *arg) {
    int i, tmp = 0;
    while (tmp != -1) {
        sem_wait(&full);        // line C1
        tmp = get();            // line C2
        sem_post(&empty);       // line C3
        printf("%d\n", tmp);
    } 
}

int main(int argc, char *argv[]) {
    // ...
    sem_init(&empty, 0, MAX);   // MAX buffers are empty to begin with...
    sem_init(&full, 0, 0);      // ... and 0 are full
    // ...
} 
  • 我们先假设 MAX=1(数组中只有一个缓冲区),验证程序是否有效是符合预期的

  • 我们现在假设 MAX > 1,假定有多个生产者,多个消费者,现在就有问题了:竞态条件

  • 解决方案:增加互斥,向缓冲区加入元素和增加缓冲区的索引是临界区,需要小心保护起来,我们使用二值信号量来增加锁

    sem_t empty;
    sem_t full;
    sem_t mutex;
    
    void *producer(void *arg) {
        int i;
        for (i = 0; i < loops; i++) {
            sem_wait(&mutex);       // line p0 (NEW LINE)
            sem_wait(&empty);       // line p1
            put(i);                 // line p2
            sem_post(&full);        // line p3
            sem_post(&mutex);       // line p4 (NEW LINE)
        }
    }
    
    void *consumer(void *arg) {
        int i;
        for (i = 0; i < loops; i++) {
            sem_wait(&mutex);       // line c0 (NEW LINE)
            sem_wait(&full);        // line c1
            int tmp = get();        // line c2
            sem_post(&empty);       // line c3
            sem_post(&mutex);       // line c4 (NEW LINE)
            printf("%d\n", tmp);
        }
    }
    
    int main(int argc, char *argv[]) {
        // ...
        sem_init(&empty, 0, MAX);   // MAX buffers are empty to begin with...
        sem_init(&full, 0, 0);      // ... and 0 are full
        sem_init(&mutex, 0, 1);     // mutex=1 because it is a lock (NEW LINE)
        // ...
    } 
    
  • 但仍然有问题,避免死锁

    sem_t empty;
    sem_t full;
    sem_t mutex;
    
    void *producer(void *arg) {
        int i;
        for (i = 0; i < loops; i++) {
            sem_wait(&empty);       // line p1
            sem_wait(&mutex);       // line p1.5 (MOVED MUTEX HERE...)
            put(i);                 // line p2
            sem_post(&mutex);       // line p2.5 (... AND HERE)
            sem_post(&full);        // line p3
        }
    }
    
    void *consumer(void *arg) {
        int i;
        for (i = 0; i < loops; i++) {
            sem_wait(&full);        // line c1
            sem_wait(&mutex);       // line c1.5 (MOVED MUTEX HERE...)
            int tmp = get();        // line c2
            sem_post(&mutex);       // line c2.5 (... AND HERE)
            sem_post(&empty);       // line c3
            printf("%d\n", tmp);
        }
    }
    
    int main(int argc, char *argv[]) {
        // ...
        sem_init(&empty, 0, MAX);   // MAX buffers are empty to begin with...
        sem_init(&full, 0, 0);      // ... and 0 are full
        sem_init(&mutex, 0, 1);     // mutex=1 because it is a lock (NEW LINE)
        // ...
    } 
    
  • 可以看到,我们把获取和释放互斥量的操作调整为紧挨着临界区,把 full、empty 的唤醒和等待操作调整到锁外面

  • 结果得到了简单而有效的有界缓冲区,多线程程序的常用模式

读者—写者锁

另一个经典问题源于对更加灵活的锁定原语的渴望,它承认不同的数据结构访问可能需要不同类型的锁

  • 例如,一个并发链表有很多插入和查找操作

    • 插入操作会修改链表状态(因此传统的临界区有用)
    • 查找操作只是读取该结构,只要没有进行插入操作,我们可以并发的执行多个查找操作
  • 读者—写者锁 (reader-writer lock) 就是用来完成这种操作

    typedef struct _rwlock_t {
        sem_t lock;         // binary semaphore (basic lock)
        sem_t writelock;    // used to allow ONE writer or MANY readers
        int readers;        // count of readers reading in critical section
    } rwlock_t;
    
    void rwlock_init(rwlock_t *rw) {
        rw->readers = 0;
        sem_init(&rw->lock, 0, 1);
        sem_init(&rw->writelock, 0, 1);
    }
    
    void rwlock_acquire_readlock(rwlock_t *rw) {
        sem_wait(&rw->lock);
        rw->readers++;
        if (rw->readers == 1)
            sem_wait(&rw->writelock);   // first reader acquires writelock
        sem_post(&rw->lock);
    }
    
    void rwlock_release_readlock(rwlock_t *rw) {
        sem_wait(&rw->lock);
        rw->readers--;
        if (rw->readers == 0)
            sem_post(&rw->writelock);   // last reader releases writelock
        sem_post(&rw->lock);
    }
    
    void rwlock_acquire_writelock(rwlock_t *rw) {
        sem_wait(&rw->writelock);
    }
    
    void rwlock_release_writelock(rwlock_t *rw) {
        sem_post(&rw->writelock);
    }
    
    • 获取读锁时,读者首先要获取 lock,然后增加 reader 变量,追踪目前有多少个读者在访问该数据结构

      • 当第一个读者获取该锁时,在这种情况下,读者也会持有写锁
      • 一旦一个读者获得了读锁,其他的读者也可以获取这个读锁
      • 想要获取写锁的线程,就必须等到所有的读者都结束
      • 最后一个退出的写者在 writelock 信号量上调用 sem_post(),从而让等待的写者能够获取该锁
    • 这一方案可行(符合预期),但有一些缺陷,尤其是公平性

    • 读者很容易饿死写者

Hill 定律:简单的笨办法可能更好

  • 某些时候简单的自旋锁反而是最有效的,因为它容易实现而且高效

哲学家就餐问题

下面是每个哲学家的基本循环:

while (1) { 
    etforks();
    eat();
    putforks();
} 
  • 关键的挑战就是如何实现 getforks() 和 putforks() 函数,保证没有死锁,没有哲学家饿死,并且并发度更高

根据 Downey 的解决方案,我们会用一些辅助函数,帮助构建解决方案

int left(int p) { return p; }
int right(int p) { return (p + 1) % 5; } 

有问题的解决方案

  • 假设我们把每个信号量(在 fork 数组中)都用 1 初始化

    void getforks() {
        sem_wait(forks[left(p)]);
        sem_wait(forks[right(p)]);
    }
    
    void putforks() {
        sem_post(forks[left(p)]);
        sem_post(forks[right(p)]);
    }
    
    • 为了拿到餐叉,我们依次获取每把餐叉的锁——先是左手边的,然后是右手边的
    • 问题是死锁 (deadlock): 假设每个哲学家都拿到了左手边的餐叉,他们每个都会阻塞住
  • 一种方案:破除依赖

    就是修改某个或者某些哲学家的取餐叉顺序

    void getforks() {
        if (p == 4) {
            sem_wait(forks[right(p)]);
            sem_wait(forks[left(p)]);
        } else {
            sem_wait(forks[left(p)]);
            sem_wait(forks[right(p)]);
        }
    } 
    

还有其他一些类似的“著名”问题,比如吸烟者问题 (cigarette smoker’s problem),理发师问题 (sleeping barber problem)

如何实现信号量

最后,我们用底层的同步原语(锁和条件变量),来实现自己的信号量,名字叫 Zemaphore

typedef struct _Zem_t {
    int value;
    pthread_cond_t cond;
    pthread_mutex_t lock;
} Zem_t;

// only one thread can call this
void Zem_init(Zem_t *s, int value) {
    s->value = value;
    Cond_init(&s->cond);
    Mutex_init(&s->lock);
}

void Zem_wait(Zem_t *s) {
    Mutex_lock(&s->lock);
    while (s->value <= 0)
        Cond_wait(&s->cond, &s->lock);
    s->value--;
    Mutex_unlock(&s->lock);
}

void Zem_post(Zem_t *s) { 
    Mutex_lock(&s->lock);
    s->value++;
    Cond_signal(&s->cond);
    Mutex_unlock(&s->lock);
}
  • 可以看到,我们只用了一把锁、一个条件变量和一个状态的变量来记录信号量的值。
    请自己研究这些代码,直到真正理解它
  • 我们实现的 Zemaphore 和 Dijkstra 定义的信号量有一点细微区别,就是我们没有保持当信号量的值为负数时,让它反映出等待的线程数
  • 事实上,该值永远不会小于 0。这一行为更容易实现,并符合现有的 Linux 实现

小心泛化

  • 在系统设计中,泛化的抽象技术是很有用处的
  • 我们可以把信号量当作锁和条件变量的泛化。但这种泛化有必要吗?考虑基于信号量去实现条件变量的难度,可能这种泛化并没有你想的那么通用

常见并发问题

第一个最明显的问题就是:在复杂并发程序中,有哪些类型的缺陷呢?

非死锁缺陷

  • 违反原子性缺陷

    • 违反了多次内存访问中预期的可串行性(即代码段本意是原子的,但在执行中并没有强制实现原子性)
    • 修复使用
  • 违反顺序缺陷

    • 两个内存访问的预期顺序被打破了(即 A 应该在 B 之前执行,但是实际运行中却不是这个顺序)
    • 修复使用条件变量

死锁缺陷

除了上面提到的并发缺陷,死锁 (deadlock) 是一种在许多复杂并发系统中出现的经典问题

  • 为什么发生死锁

    • 其中一个原因是在大型的代码库里,组件之间会有复杂的依赖
    • 另一个原因是封装,软件开发者一直倾向于隐藏实现细节,以模块化的方式让软件开发更容易,然而模块化和锁不是很契合
  • 产生死锁的条件

    • 互斥:线程对于需要的资源进行互斥的访问
    • 持有并等待:线程持有了资源(例如已将持有的锁),同时又在等待其他资源(例如,需要获得的锁)
    • 非抢占:线程获得的资源(例如锁),不能被抢占
    • 循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这个资源又是下一个线程要申请的
  • 预防

    • 循环等待

      也许最实用的预防技术(当然也是经常采用的),就是让代码不会产生循环等待

      • 最直接的方法就是获取锁时提供一个全序 (total ordering)
      • 更复杂的系统中不会只有两个锁,锁的全序可能很难做到,因此偏序 (partialordering) 可能是一种有用的方法

      全序和偏序都需要细致的锁策略的设计和实现

      • 另外,顺序只是一种约定,粗心的程序员很容易忽略,导致死锁

      通过锁的地址来强制锁的顺序:按照地址从高到低,或者从低到高的顺序加锁,数就可以保证不论传入参数是什么顺序,函数都会用固定的顺序加锁

    • 持有并等待

      死锁的持有并等待条件,可以通过原子地抢锁来避免

      • 代码保证了在抢锁的过程中,不会有不合时宜的线程切换,从而避免了死锁
      • 注意,出于某些原因,这个方案也有问题:和之前一样,它不适用于封装:因为这个方案需要我们准确地知道要抢哪些锁,并且提前抢到这些锁
      • 要提前抢到所有锁,而不是在真正需要的时候,所以可能降低了并发
    • 非抢占

      在调用 unlock 之前,都认为锁是被占有的,多个抢锁操作通常会带来麻烦,因为我们等待一个锁时,同时持有另一个锁

      • 但是会引来一个新的问题:两个线程有可能一直重复这一序列,又同时都抢锁失败,称为活锁 (livelock)

        也有活锁的解决方法:例如,可以在循环结束的时候,先随机等待一个时间,然后再重复整个动作,这样可以降低线程之间的重复互相干扰

      • 关于这个方案的最后一点:问题仍然是封装

    • 互斥

      最后的预防方法是完全避免互斥

      • 通常来说,代码都会存在临界区,因此很难避免互斥

      • Herlihy 提出了设计各种无等待 (wait-free) 数据结构的思想

        想法很简单:通过强大的硬件指令,我们可以构造出不需要锁的数据结构

  • 通过调度避免死锁

    除了死锁预防,某些场景更适合死锁避免 (avoidance)

    • Dijkstra 提出的银行家算法是一种类似的著名解决方案

    • 这些方案的适用场景很局限

      • 你需要知道所有任务以及它们需要的锁
      • 这种方法会限制并发
    • 因此,通过调度来避免死锁不是广泛使用的通用方案

  • 检查和恢复

    最后一种常用的策略就是允许死锁偶尔发生,检查到死锁时再采取行动

    • 举个例子,如果一个操作系统一年死机一次,你会重启系统,然后愉快地(或者生气地)继续工作
    • 很多数据库系统使用了死锁检测和恢复技术,死锁检测器会定期运行,通过构建资源图来检查循环

TOM WEST 定律:不要总是完美,不是所有值得做的事情都值得做好

基于事件的并发(进阶)

基于事件的并发针对两方面的问题

  • 一方面是多线程应用中,正确处理并发很有难度
  • 另一方面,开发者无法控制多线程在某一时刻的调度

不用线程,如何构建并发

  • 基本想法:事件循环 (event loop)

    • 我们使用的基本方法就是基于事件的并发 (event-based concurrency)

    该方法很简单:我们等待某事(即“事件”)发生;当它发生时,检查事件类型,然后做少量的相应工作(可能是 I/O 请求,或者调度其他事件准备后续处理)

  • 事件循环的伪代码

    while (1) {
        events = getEvents();
        for (e in events)
            processEvent(e);
    } 
    
    • 主循环等待某些事件发生,通过 getEvents() 调用
    • 处理事件的代码叫作事件处理程序 (event handler)
    • 重要的是,处理程序在处理一个事件时,它是系统中发生的唯一活动
    • 这种对调度的显式控制,是基于事件方法的一个重要优点

    但这也带来一个更大的问题:基于事件的服务器如何决定哪个事件发生?

重要 API: select()/poll()

大多数系统提供了基本的 API,即通过 select() 或 poll() 系统调用

  • 这些接口对程序的支持很简单:检查是否有任何应该关注的进入 I/O

  • 下面以 select() 为例

    int
    select( int nfds,
            fd_set *restrict readfds,
            fd_set *restrict writefds,
            fd_set *restrict errorfds,
            struct timeval *restrict timeout); 
    
    • select() 检查 I/O 描述符集合,它们的地址通过 readfds、writefds、errorfds 传入
    • 分别查看它们中的某些描述符是否已准备好读取是否准备好写入或有异常情况待处理
    • 每个集合中检查前 nfds 个描述符,即检查描述符集合中从 0 ~ nfds-1 的描述符
    • 返回时,select() 用给定请求操作准备好的描述符组成的子集替换给定的描述符集合,返回所有集合中就绪描述符的总数

阻塞与非阻塞接口

  • 阻塞或同步 (synchronous) 接口:在返回给调用者之前完成所有工作

    通常阻塞调用的主犯是某种 I/O

    例如,如果一个调用必须从磁盘读取才能完成,它可能会阻塞,等待发送到磁盘的 I/O 请求返回

  • 非阻塞或异步 (asynchronous) 接口:开始一些工作,但立即返回,从而让所有需要完成的工作都在后台完成

    非阻塞接口可用于任何类型的编程(例如,使用线程),但在基于事件的方法中非常重要,因为阻塞的调用会阻止所有进展

无论哪种方式,这些基本原语为我们提供了一种构建非阻塞事件循环的方法,它可以
简单地检查传入数据包,从带有消息的套接字中读取数据,并根据需要进行回复

使用 select()

#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int main(void) {
    // open and set up a bunch of sockets (not shown)
    // main loop
    while (1) {
        // initialize the fd_set to all zero
        fd_set readFDs;
        FD_ZERO(&readFDs);

        // now set the bits for the descriptors
        // this server is interested in
        // (for simplicity, all of them from min to max)
        int fd;
        for (fd = minFD; fd < maxFD; fd++)
            FD_SET(fd, &readFDs);

        // do the select
        int rc = select(maxFD+1, &readFDs, NULL, NULL, NULL);

        // check which actually have data using FD_ISSET()
        int fd;
        for (fd = minFD; fd < maxFD; fd++)
            if (FD_ISSET(fd, &readFDs))
                processFD(fd);
    }
} 
  • 初始化完成后,服务器进入无限循环

  • 在循环内部,它使用 FD_ZERO() 宏首先清除文件描述符集合

  • 然后使用 FD_SET() 将所有从 minFD 到 maxFD 的文件描述符包含到集合中

    这组描述符可能表示服务器正在关注的所有网络套接字

  • 服务器调用 select() 来查看哪些连接有可用的数据

  • 然后,通过在循环中使用 FD_ISSET(),事件服务器可以查看哪些描述符已准备好数据并处理传入的数据

为何更简单?无须锁

  • 使用单个 CPU 和基于事件的应用程序,并发程序中发现的问题不再存在
  • 具体来说,因为一次只处理一个事件,所以不需要获取或释放锁
  • 基于事件的服务器不能被另一个线程中断,因为它确实是单线程的
  • 因此,线程化程序中常见的并发性错误并没有出现在基本的基于事件的方法中

请勿阻塞基于事件的服务器

一个问题:阻塞系统调用

如果某个事件要求你发出可能会阻塞的系统调用,该怎么办?

  • 使用基于事件的方法时,没有其他线程可以运行:只是主事件循环
  • 这意味着如果一个事件处理程序发出一个阻塞的调用,整个服务器就会这样做:阻塞直到调用完成
  • 当事件循环阻塞时,系统处于闲置状态,因此是潜在的巨大资源浪费,所以我们在基于事件的系统中必须遵守一条规则:不允许阻塞调用

解决方案:异步 I/O

  • 为了克服这个限制,许多现代操作系统已经引入了新的方法来向磁盘系统发出 I/O 请求,一般称为异步 I/O (asynchronous I/O)

    • 这些接口使应用程序能够发出 I/O 请求,并在 I/O 完成之前立即将控制权返回给调用者
    • 另外的接口让应用程序能够确定各种 I/O 是否已完成

    在没有异步 I/O 的系统中,纯基于事件的方法无法实现,Pai 等人描述了一种使用事件处理网络数据包的混合方法,并且使用线程池来管理未完成的 I/O

另一个问题:状态管理

基于事件的方法的另一个问题是,这种代码通常比传统的基于线程的代码更复杂

  • 当事件处理程序发出异步 I/O 时,它必须打包一些程序状态,以便下一个事件处理程序在 I/O 最终完成时使用,这个额外的工作在基于线程的程序中是不需要的,因为程序需要的状态在线程栈中,Adya 等人称之为手工栈管理 (manual stack management),这是基于事件编程的基础

  • 如 Adya 等人所述,是使用一种称为“延续 (continuation)”的老编程语言结构

    虽然听起来很复杂,但这个想法很简单:

    • 基本上,在某些数据结构中,记录完成处理该事件需要的信息
    • 当事件发生时(即磁盘 I/O 完成时),查找所需信息并处理事件

什么事情仍然很难,基于事件的方法还有其他一些困难,我们应该指出

  • 当系统从单个 CPU 转向多个 CPU 时,基于事件的方法的一些简单性就消失了
  • 基于事件的方法的另一个问题是,它不能很好地与某些类型的系统活动集成
  • 还有一个问题是随着时间的推移,基于事件的代码可能很难管理,因为各种函数的确切语义发生了变化
  • 最后,虽然异步磁盘 I/O 现在可以在大多数平台上使用,但是花了很长时间才做到这一
    点,而且与异步网络 I/O 集成不会像你想象的那样有简单和统一的方式

persistence

I/O 设备

典型系统的架构

  • CPU 通过某种内存总线 (memory bus) 或互连电缆连接到系统内存
  • 图像或者其他高性能 I/O 设备通过常规的 I/O 总线 (I/O bus) 连接到系统,在许多现代系统中会是 PCI 或它的衍生形式
  • 最后,更下面是外围总线 (peripheral bus),它们将最慢的设备连接到系统,包括磁盘、鼠标及其他类似设备

一些标准概念

  • 标准设备

    现在来看一个标准设备(不是真实存在的),通过它来帮助我们更好地理解设备交互的机制,一个包含两部分重要组件的设备

    • 第一部分是向系统其他部分展现的硬件接口 (interface),所有设备都有自己的特定接口以及典型交互的协议
    • 第二部分是它的内部结构 (internal structure),这部分包含设备相关的特定实现,负责具体实现设备展示给系统的抽象接口
  • 标准协议

    一个简化的设备接口包含 3 个寄存器

    • 状态 (status) 寄存器:可以读取并查看设备的当前状态
    • 命令 (command) 寄存器:用于通知设备执行某个具体任务
    • 数据 (data) 寄存器:将数据传给设备或从设备接收数据

    通过读写这些寄存器,操作系统可以控制设备的行为

    我们现在来描述操作系统与该设备的典型交互,以便让设备为它做某事

    While (STATUS == BUSY)
        ; // wait until device is not busy
    Write data to DATA register
    Write command to COMMAND register
        (Doing so starts the device and executes the command)
    While (STATUS == BUSY)
        ; // wait until device is done with your request 
    
    • 操作系统通过反复读取状态寄存器,等待设备进入可以接收命令的就绪状态

      我们称之为轮询 (polling) 设备(基本上,就是问它正在做什么)

    • 操作系统下发数据到数据寄存器

    • 操作系统将命令写入命令寄存器,这样设备就知道数据已经准备好了,它应该开始执行命令

    • 操作系统再次通过不断轮询设备,等待并判断设备是否执行完成命令

    这个简单的协议好处是足够简单并且有效,但是难免会有一些低效和不方便

如何减少轮询开销

利用中断减少 CPU 开销

  • 当设备完成了自身操作,会抛出一个硬件中断,引发 CPU 跳转执行操作系统预先定义好的中断服务例程 (Interrupt Service Routine, ISR) 或更为简单的中断处理程序

  • 中断处理程序是一小段操作系统代码,它会结束之前的请求(比如从设备读取到了数据或者错误码)并且唤醒等待 I/O 的进程继续执行

  • 中断允许计算与 I/O 重叠 (overlap)

  • 注意,使用中断并非总是最佳方案

    • 如果设备非常快,那么最好的办法反而是轮询
    • 如果设备比较慢,那么采用允许发生重叠的中断更好
    • 如果设备的速度未知,或者时快时慢,可以考虑使用混合 (hybrid) 策略
  • 最好不要使用中断的场景是网络

  • 基于中断的优化就是合并 (coalescing),多次中断可以合并为一次中断抛出,从而
    降低处理中断的代价,等待太长会增加请求的延迟,这是系统中常见的折中

利用 DMA 进行更高效的数据传送

  • 具体来说,如果使用编程的 I/O 将一大块数据传给设备,CPU 又会因为琐碎的任务而变得负载很重,浪费了时间和算力,本来更好是用于运行其他进程
  • DMA (Direct Memory Access) 引擎是系统中的一个特殊设备,它可以协调完成内存和设备间的数据传递,不需要 CPU 介入
  • 数据的拷贝工作都是由 DMA 控制器来完成的

设备交互的方法

随着技术的不断发展,主要有两种方式来实现与设备的交互

  • 第一种办法相对老一些,就是用明确的 I/O 指令
  • 第二种方法是内存映射 I/O (memory-mapped I/O)

两种方法没有一种具备极大的优势,内存映射 I/O 的好处是不需要引入新指令来实现设
备交互,但两种方法今天都在使用

如何实现一个设备无关的操作系统

最后我们要讨论一个问题:每个设备都有非常具体的接口,如何将它们纳入操作系统,而我们希望操作系统尽可能通用

  • 这个问题可以通过古老的抽象 (abstraction) 技术来解决
  • 在最底层,操作系统的一部分软件清楚地知道设备如何工作,我们将这部分软件称为设备驱动程序 (device driver)

一般流程

  • 等待驱动就绪
  • 向命令寄存器写入参数
  • 开启 I/O
  • 数据传送(针对写请求)
  • 中断处理
  • 错误处理

磁盘驱动器

磁盘驱动器 (hard disk drive):一直是计算机系统中持久数据存储的主要形式

  • 文件系统技术的大部分发展都是基于它们的行为

如何存储磁盘上的数据

  • 驱动器由大量扇区(512 字节块)组成,每个扇区都可以读取或写入
  • 在具有 n 个扇区的磁盘上,扇区从 0 ~ n−1 编号
  • 我们可以将磁盘视为一组扇区,0 ~ n−1 是驱动器的地址空间(address space)

驱动器制造商唯一保证的是单个 512 字节的写入是原子的

单磁道延迟:旋转延迟

  • 假设我们有一个单一磁道的简单磁盘
  • 在我们的简单磁盘中,磁盘不必做太多工作,具体来说它必须等待期望的扇区旋转到磁头下
  • 这种等待在现代驱动器中经常发生,并且是 I/O 服务时间的重要组成部分,它有
    一个特殊的名称:旋转延迟(rotational delay 或 rotation delay)

多磁道:寻道时间

  • 到目前为止,我们的磁盘只有一条磁道,这是不太现实的

  • 寻道有许多阶段:

    • 首先是磁盘臂移动时的加速阶段
    • 然后随着磁盘臂全速移动而惯性滑动
    • 然后随着磁盘臂减速而减速
    • 最后在磁头小心地放置在正确的磁道上时停下来
  • 寻道以及旋转最昂贵的磁盘操作之一

  • 多磁道的 I/O 时间图:寻道 -> 等待转动延迟 -> 传输

任何现代磁盘驱动器都有一个重要组成部分,即它的缓存 (cache)

  • 由于历史原因有时称为磁道缓冲区
  • 该缓存只是少量的内存
  • 驱动器可以使用这些内存来保存从磁盘读取或写入磁盘的数据

衡量磁盘性能

现在可以将 I/O 时间表示为 3 个主要部分之和: \(T_{I/O} = T_{寻道} + T_{旋转} + T_{传输}\)

比较驱动器用 I/O 速率:\(R_{I/O} = \dfrac{data{传输}}{T_{I/O}}\)

磁盘调度

  • SSTF:最短寻道时间优先

    按磁道对 I/O 请求队列排序,选择在最近磁道上的请求先完成

    • 第一个问题,主机操作系统无法利用驱动器的几何结构,而是只会看到一系列的块

      操作系统可以简单地实现最近块优先 (Nearest-Block-First,NBF),而不是 SSTF

    • 第二个问题更为根本:饥饿 (starvation)

  • SCAN/C-SCAN:电梯算法

    简单地以跨越磁道的顺序来服务磁盘请求,C-SCAN 是另一种常见的变体,不是在一个方向扫过磁盘,该算法从外圈扫到内圈,然后从内圈扫到外圈,如此下去

  • SPTF:最短定位时间优先

    这里的情况是旋转与寻道相比的相对时间

    在现代驱动器中,正如上面所看到的,查找和旋转大致相当(当然,视具体的请求而定),因此 SPTF 是有用的,它提高了性能

    它在操作系统中实现起来更加困难,操作系统通常不太清楚磁道边界在哪,也不知道磁头当前的位置(旋转到了哪里)

SCAN(甚至 SSTF)实际上并没有严格遵守 SJF 的原则,具体来说它们忽视了旋转

廉价冗余磁盘阵列(RAID)

我们如何构建一个大型、快速、可靠的存储系统?

RAID 是一个复杂的庞然大物,由多个磁盘、内存(包括易失性和非易失性)以及一个或多个处理器来管理系统

  • 性能:并行使用多个磁盘可以大大加快 I/O 时间
  • 容量:大型数据集需要大型磁盘
  • 稳定:通过某种形式的冗余 (redundancy),RAID 可以容许损失一个磁盘并保持运行

如何评估 RAID

  • 第一个方面是容量 (capacity)
  • 第二个方面是可靠性 (reliability)
  • 第三个方面是性能 (performance)

RAID 0 级:条带化

  • 以轮转方式将磁盘阵列的块分布在磁盘上:这种方法的目的是在对数组的连续块进行请求时,从阵列中获取 最大的并行性
  • 块的大小主要影响阵列的性能:较大块减少了这种文件内的并行性,但较大块减少了定位时间

RAID 1 级:镜像

  • 对于镜像系统,我们只需生成系统中每个块的多个副本,通过这样做我们可以容许磁盘故障【当然,每个副本应该放在一个单独的磁盘上】

RAID 4 级:通过奇偶校验节省空间

  • 基于奇偶校验的方法试图使用较少的容量,从而克服由镜像系统付出的巨大空间损失【不过代价是性能】

RAID 5 级:旋转奇偶校验

  • RAID-5 的工作原理与 RAID-4 几乎完全相同,只是它将奇偶校验块跨驱动器旋转

文件和目录

存储虚拟化形成了两个关键的抽象

  • 第一个是文件 file

    • 文件就是一个线性字节数组,每个字节都可以读取或写入
    • 每个文件都有 low-levelname,通常是某种数字,由于历史原因文件的低级名称通常称为 inode 号
  • 第二个抽象是目录 directory

    • 一个目录像一个文件一样,也有一个 inode 号
    • 它包含用户可理解的文件名称与对应 inode 号映射关系,因此称为“目录”

文件系统提供了一种统一的方式来访问磁盘、U 盘、CD-ROM、许多其他设备上的文件,事实上还有很多其他的东西,都位于单一目录树下

文件系统接口

创建文件

  • 通过调用 open() 并传入 O_CREAT 标志,程序可以创建一个新文件

    int fd = open("foo", O_CREAT | O_WRONLY | O_TRUNC); 
    
  • open()的一个重要方面是它的返回值:文件描述符 (file descriptor)

  • 文件描述符只是一个整数,是每个进程私有的,在 UNIX 系统中用于访问文件

读写文件

  • Linux 上工具 strace 来跟踪程序 cat 所做的系统调用

如果你在文本文件上构建了索引并利用它来查找特定单词,最终可能会从文件中的某些随机偏移量中读取数据

  • lseek() 系统调用
  • 请注意,调用 lseek()与移动磁盘臂的磁盘的寻道(seek)操作无关【对 lseek()的调用只是改变内核中变量的值】

用 fsync() 立即写入

  • 开发正确的恢复协议要求能够经常强制写入磁盘
  • 调用 write() 时,在将来的某个时刻,将此数据写入持久存储,可能会数据会丢失

文件重命名

  • 使用了系统调用 rename(char * old, char * new)

获取文件信息

  • 我们还希望文件系统能够保存关于它正在存储的每个文件的大量信息,我们通常将这些数据称为文件元数据 metadata
  • 可以使用命令行工具 stat 查看

删除文件

  • unlink() 只需要待删除文件的名称,并在成功时返回零

创建目录

  • 要创建目录,可以用系统调用 mkdir()
  • 空目录有两个条目:一个引用自身的条目 .,一个引用其父目录的条目 ..

读取目录

  • 序使用了 opendir()、readdir()、closedir() 这 3 个调用来完成工作

    int main(int argc, char *argv[]) {
        DIR *dp = opendir(".");
        assert(dp != NULL);
        struct dirent *d;
        while ((d = readdir(dp)) != NULL) {
            printf("%d %s\n", (int) d->d_ino, d->d_name);
        }
        closedir(dp);
        return 0;
    } 
    

删除目录

  • 可以通过调用 rmdir() 来删除目录

硬链接

  • 我们现在回到为什么删除文件是通过 unlink() 的问题,理解在文件系统树中创建条目的新方法,即通过所谓的 link() 系统调用
  • 这样的结果是因为当文件系统取消链接文件时,它检查 inode 号中的引用计数(reference
    count)
  • 该引用计数(有时称为链接计数,link count)允许文件系统跟踪有多少不同的文件名已链接到这个 inode
  • 只有当引用计数达到零时,文件系统才会释放 inode 和相关数据块,从而真正“删除”该文件

符号链接

  • 硬链接有点局限

    • 你不能创建目录的硬链接(因为担心会在目录树中创建一个环)
    • 你不能硬链接到其他磁盘分区中的文件(因为 inode 号在特定文件系统中是唯一的,而不是跨文件系统)
  • 符号链接实际上与硬链接完全不同

    • 第一个区别是符号链接本身实际上是一个不同类型的文件,我们已经讨论过常规文件和目录,符号链接是文件系统知道的第三种类型
  • 由于创建符号链接的方式,有可能造成所谓的悬空引用 (dangling reference)

创建并挂载文件系统

  • 为了创建一个文件系统,大多数文件系统提供了一个工具,通常名为 mkfs

  • 一旦创建了这样的文件系统,就需要在统一的文件系统树中进行访问,这个任务是通过 mount 程序实现的

    以现有目录作为 目标挂载点 (mount point),本质上是将新的文件系统粘贴到目录树的这个点上

文件系统实现

如何构建一个简单的文件系统?

考虑文件系统时,我们通常建议考虑它们的两个不同方面

  • 第一个方面是文件系统的数据结构 (data structure)
  • 文件系统的第二个方面是访问方法 (access method)

VSFS 文件系统

我们需要做的第一件事是将磁盘分成块 (block)

  • 首先想到的是用户数据,我们将用于存放用户数据的磁盘区域称为数据区域 (data region)

  • 文件系统必须记录每个文件的信息,该信息是元数据 (metadata) 的关键部分

  • 还需要某种方法来记录 inode 或数据块是空闲还是已分配

  • 在极简文件系统的磁盘结构设计中还有一块,我们将它保留给超级块 (superblock)

    在挂载文件系统时,操作系统将首先读取超级块,初始化各种参数,然后将该卷添加到文件系统树中

访问路径:读取和写入

  • 从磁盘读取文件

    • 当你发出一个 open("/foo/bar", O_RDONLY) 调用时,文件系统首先需要找到文件 bar 的 inode,从而获取关于该文件的一些基本信息
    • 所有遍历都从文件系统的根开始,即根目录 (root directory)
    • 一旦 inode 被读入,文件系统可以在其中查找指向数据块的指针,数据块包含根目录的内容
    • 下一步是递归遍历路径名,直到找到所需的 inode
    • open() 的最后一步是将 bar 的 inode 读入内存,然后文件系统进行最后的权限检查,在每个进程的打开文件表中,为此进程分配一个文件描述符,并将它返回给用户
    • 打开后,程序可以发出 read() 系统调用,从文件中读取
    • 第一次读取(除非 lseek() 已被调用,则在偏移量 0 处)将在文件的第一个块中读取,查阅 inode 以查找这个块的位置
    • 读取将进一步更新此文件描述符在内存中的打开文件表,更新文件偏移量,以便下一次读取会读取第二个文件块
    • 在某个时候,文件将被关闭,很明显文件描述符应该被释放
  • 写入磁盘

    • 与读取不同,写入文件也可能会分配 (allocate) 一个块(除非块被覆写)
    • 每次写入文件在逻辑上会导致 5 个 I/O:一个读取数据位图(然后更新以标记新分配的块被使用),一个写入位图(将它的新状态存入磁盘),再是两次读取,然后写入 inode(用新块的位置更新),最后一次写入真正的数据块本身
  • open 导致的 I/O 量与路径名的长度成正比

即使是最简单的操作,如打开、读取或写入文件,也会产生大量 I/O 操作,分散在磁盘上。文件系统可以做些什么,来降低执行如此多 I/O 的高成本?

缓存和缓冲

  • 读取和写入文件可能是昂贵的,会导致(慢速)磁盘的许多 I/O,这显然是一个巨大的性能问题,为了弥补大多数文件系统积极使用系统内存 (DRAM) 来缓存重要的块

  • 早期的文件系统因此引入了一个固定大小的缓存 (fixed-size cache) 来保存常用的块(大约占总内存的 10%)

  • 现代系统采用动态划分 (dynamic partitioning) 方法,解决静态的内存划分 (static partitioning) 可能导致浪费

  • 许多现代操作系统将虚拟内存页面和文件系统页面集成到统一页面缓存中 (unified page cache)

  • 尽管可以通过足够大的缓存完全避免读取 I/O,但写入流量必须进入磁盘,才能实现持久,因此,高速缓存不能减少写入流量,像对读取那样

    写缓冲(write buffering,人们有时这么说)肯定有许多优点

    • 首先,通过延迟写入,文件系统可以将一些更新编成一批 (batch),放入一组较小的 I/O 中
    • 其次,通过将一些写入缓冲在内存中,系统可以调度(schedule)后续的 I/O,从而提高性能
    • 最后,一些写入可以通过拖延来完全避免
  • 静态划分可确保每个用户共享一些资源,通常提供更可预测的性能,也更易于实现

  • 动态划分可以实现更好的利用率(通过让资源匮乏的用户占用其他空闲资源),但实现起来可能会更复杂,并且可能导致空闲资源被其他用户占用,然后在需要时花费很长时间收回,从而导致这些用户性能很差

局部性和快速文件系统

如何组织文件系统数据结构以提高性能?

伯克利的一个小组决定建立一个更好、更快的文件系统,称之为快速文件系统 (Fast File System,FFS)

  • 思路是让文件系统的结构和分配策略具有“磁盘意识”,从而提高性能,这正是他们所做的

组织结构:柱面组

  • FFS 将磁盘划分为一些分组,称为柱面组(cylinder group,而一些现代文件系统,如 Linux ext2 和 ext3,就称它们为块组,即 block group)
  • 出于可靠性原因,每个组中都有超级块 (super block) 的一个副本

策略:如何分配文件和目录

  • 首先是目录的放置,FFS 采用了一种简单的方法:找到分配数量少的柱面组(因为我们希望跨组平衡目录)和大量的自由 inode(因为我们希望随后能够分配一堆文件),并将目录数据和 inode 放在该分组中

  • 对于文件,FFS 做两件事

    • 首先,它确保(在一般情况下)将文件的数据块分配到与其 inode 相同的组中,从而防止 inode 和数据之间的长时间寻道(如在老文件系统中)
    • 其次,它将位于同一目录中的所有文件,放在它们所在目录的柱面组中
  • 在 FFS 中,文件放置的一般策略有一个重要的例外,它出现在大文件中

    • 在将一定数量的块分配到第一个块组(例如,12 个块,或 inode 中可用的直接指针的数量)之后,FFS 将文件的下一个“大”块(即第一个间接块指向的那些部分)放在另一个块组中(可能因为它的利用率低而选择)
    • 然后,文件的下一个块放在另一个不同的块组中,依此类推
  • 引入了一种称为符号链接的新概念

  • FFS 还引入了一个原子 rename() 操作,用于重命名文件

崩溃一致性:FSCK 和日志

鉴于崩溃可能发生在任意时间点,如何确保文件系统将磁盘上的映像保持在合理的状态?

  • 首先检查较老的文件系统采用的方法,即 fsck,文件系统检查程序(file system checker)
  • 然后,我们将注意力转向另一种方法,称为日志记录(journaling,也称为预写日志,write-ahead logging),这种技术为每次写入增加一点开销,但可以更快地从崩溃或断电中恢复

文件系统检查程序

  • fsck 首先检查超级块是否合理,主要是进行健全性检查
  • 接下来,fsck 扫描 inode、间接块、双重间接块等,以了解当前在文件系统中分配的块
  • 检查每个 inode 是否存在损坏或其他问题
  • fsck 还会验证每个已分配的 inode 的链接数
  • fsck 还检查重复指针,即两个不同的 inode 引用同一个块的情况
  • 在扫描所有指针列表时,还会检查坏块指针
  • fsck 不了解用户文件的内容

日志(或预写日志)

  • 更新磁盘时,在覆写结构之前,首先写下一点小注记(在磁盘上的其他地方,在一个众所周知的位置),描述你将要做的事情
  • 通过将注释写入磁盘,可以保证在更新(覆写)正在更新的结构期间发生崩溃时,能够返回并查看你所做的注记,然后重试

日志结构文件系统

在 20 世纪 90 年代早期,由 John Ousterhout 教授和研究生 Mendel Rosenblum 领导的伯克利小组开发了一种新的文件系统,称为日志结构文件系统

  • 因此,理想的文件系统会专注于写入性能,并尝试利用磁盘的顺序带宽
  • 此外,它在常见工作负载上表现良好,这种负载不仅写出数据,还经常更新磁盘上的元数据结构
  • 最后,它可以在 RAID 和单个磁盘上运行良好。

引入的新型文件系统 Rosenblum 和 Ousterhout 称为 LFS,是日志结构文件系统(Log-structured File System)的缩写

  • 写入磁盘时,LFS 首先将所有更新(包括元数据!)缓冲在内存段中
  • 当段已满时,它会在一次长时间的顺序传输中写入磁盘,并传输到磁盘的未使用部分
  • LFS 永远不会覆写现有数据,而是始终将段写入空闲位置
  • 由于段很大,因此可以有效地使用磁盘,并且文件系统的性能接近其峰值

简单地将所有更新(例如数据块、inode 等)顺序写入磁盘的这一基本思想是 LFS 的核心

  • 顺序而高效地写入,使用写入缓冲
  • LFS 的设计者通过名为 inode 映射(inode map,imap)的数据结构,在 inode 号和 inode 之间引入了一个间接层(level of indirection)
  • 检查点区域,检查点区域包含指向最新的 inode 映射片段的指针(即地址),因此可以通过首先读取 CR 来很到 inode 映射片段
  • inode 映射还解决了 LFS 中存在的另一个严重问题,称为递归更新问题(recursive update problem)

新问题:垃圾收集

  • 你可能已经注意到 LFS 的另一个问题;它会反复将最新版本的文件(包括其 inode 和数据)写入磁盘上的新位置

  • 我们现在有两个问题

    • 第一个是机制:LFS 如何判断段内的哪些块是活的,哪些块已经死了?

      该信息记录在一个数据结构中,位于段头部,称为段摘要块(segment summary block)

    • 第二个是策略:清理程序应该多久运行一次,以及应该选择清理哪些部分?

      作者描述了一种试图分离冷热段的方法

最后一个问题:如果系统在 LFS 写入磁盘时崩溃会发生什么?

  • 在正常操作期间,LFS 将一些写入缓冲在段中,然后(当段已满或经过一段时间后),将段写入磁盘
  • 我们先介绍第二种情况,为了确保 CR 更新以原子方式发生,LFS 实际上保留了两个 CR,每个位于磁盘的一端,并交替写入它们。
  • 我们现在关注第一种情况,由于 LFS 每隔 30s 左右写入一次 CR,因此文件系统的最后一致快照可能很旧
  • 为了改进这一点,LFS 尝试通过数据库社区中称为前滚(roll forward)的技术,重建其中许多段

LFS 引入了一种更新磁盘的新方法

  • LFS 总是写入磁盘的未使用部分,然后通过清理回收旧空间,而不是在原来的位置覆盖文件
  • 这种方法在数据库系统中称为影子分页(shadowpaging),在文件系统中有时称为写时复制(copy-on-write),可以实现高效写入,因为 LFS 可以将所有更新收集到内存的段中,然后按顺序一起写入
  • 这种方法的缺点是它会产生垃圾

数据完整性和保护

两种类型的单块故障是常见的,值得考虑:潜在扇区错误(Latent-Sector Errors,LSE)和块讹误(block corruption)

  • 当磁盘扇区(或扇区组)以某种方式讹误时,会出现 LSE

    为了解决这个问题,一些系统增加了额外的冗余度,用于校验

  • 还存在一些情况,磁盘块出现讹误(corrupt),但磁盘本身无法检测到

    现代存储系统用于保持数据完整性的主要机制称为校验和(checksum)

系统中常见的权衡取决于此:通常,你获得的保护越多,成本就越高

常见的校验和函数

  • 有人使用一个简单的校验和函数,它基于异或(XOR)
  • 另一个简单的校验和函数是加法
  • 稍微复杂的算法被称为 Fletcher 校验和(Fletcher checksum)
  • 最后常用的校验和称为循环冗余校验(CRC)

一个新问题:错误的写入

  • 答案很简单:在每个校验和中添加更多信息。在这种情况下,添加物理标识符(Physical Identifier,物理 ID)非常有用

最后一个问题:丢失的写入

  • 一种经典方法[BS04]是执行写入验证(write verify),或写入后读取(read-after-write)。通过在写入后立即读回数据,系统可以确保数据确实到达磁盘表面【这种方法非常慢,使完成写入所需的 I/O 数量翻了一番】
  • 某些系统在系统的其他位置添加校验和,以检测丢失的写入

分布式

分布式系统

构建分布式系统时会出现许多新的挑战

  • 我们关注的主要是故障(failure)
  • 系统性能(performance)通常很关键
  • 最后,安全(security)也是必要的考虑因素

本章将介绍分布式系统中最基本的新方面:通信(communication)

  • 现代网络的核心原则是,通信基本是不可靠的

    一个简单的方法是:我们不处理它

    由于某些应用程序知道如何处理数据包丢失,因此让它们用基本的不可靠消息传递层进行通信有时很有用,这是端到端的论点(end-to-end argument)的一个例子

    这种不可靠层的一个很好的例子,就是几乎所有现代系统中都有的 UDP/IP 网络栈

    这并不意味着 UDP 根本不能防止任何故障,例如 UDP 包含校验和(checksum),以检测某些形式的数据包损坏

    // client code
    int main(int argc, char *argv[]) {
        int sd = UDP_Open(20000);
        struct sockaddr_in addr, addr2;
        int rc = UDP_FillSockAddr(&addr, "machine.cs.wisc.edu", 10000);
        char message[BUFFER_SIZE];
        sprintf(message, "hello world");
        rc = UDP_Write(sd, &addr, message, BUFFER_SIZE);
        if (rc > 0) {
            int rc = UDP_Read(sd, &addr2, buffer, BUFFER_SIZE);
        }
        return 0;
    }
    // server code
    int main(int argc, char *argv[]) {
        int sd = UDP_Open(10000);
        assert(sd > -1);
        while (1) {
            struct sockaddr_in s;
            char buffer[BUFFER_SIZE];
            int rc = UDP_Read(sd, &s, buffer, BUFFER_SIZE);
            if (rc > 0) {
                char reply[BUFFER_SIZE];
                sprintf(reply, "reply");
                rc = UDP_Write(sd, &s, reply, BUFFER_SIZE);
            }
        }
        return 0;
    }
    
  • 为了构建可靠的通信层,我们需要一些新的机制和技术来处理数据包丢失

    • 我们要使用的技术称为确认(acknowledgment),或简称为 ack
    • 为了处理这种情况,我们需要一种额外的机制,称为超时(timeout)

    如果目标是可靠的消息层,我们通常还希望保证接收方每个消息只接收一次(exactly once)

    • 只需要很少的内存,解决了这个问题,该机制被称为顺序计数器(sequence counter)

    最常用的可靠通信层称为 TCP/IP,或简称为 TCP

构建分布式系统时,应该使用什么抽象通信?

  • 分布式共享内存(Distributed Shared Memory,DSM)系统使不同机器上的进程能够共享一个大的虚拟地址空间

    • 大多数 DSM 系统的工作方式是通过操作系统的虚拟内存系统
    • 由于许多原因,这种方法今天并未广泛使用,DSM 最大的问题是它如何处理故障
  • 远程过程调用(RPC)

    • 操作系统抽象对于构建分布式系统来说是一个糟糕的选择,但编程语言(PL)抽象要有意义得多
    • 最主要的抽象是基于远程过程调用(Remote Procedure Call),或简称 RPC
    • 远程过程调用包都有一个简单的目标:使在远程机器上执行代码的过程像调用本地函数一样简单直接

    RPC 系统通常有两部分:

    • 存根生成器(stub generator,有时称为协议编译器,protocol compiler)
    • 运行时库(run-timelibrary)

Sun 的网络文件系统(NFS)

分布式客户端/服务器计算的首次使用之一,是在分布式文件系统领域

  • 在定义 NFS 时,Sun 采取了一种不寻常的方法:Sun 开发了一种开放协议(open protocol),它只是指定了客户端和服务器用于通信的确切消息格式,而不是构建专有的封闭系统

  • 出于这些原因,NFS 的设计者决定采用无状态方法:每个客户端操作都包含完成请求所需的所有信息

    不需要花哨的崩溃恢复,服务器只是再次开始运行,最糟糕的情况下,客户端可能必须重试请求

如何定义协议,让它既无状态,又支持 POSIX 文件系统 API?

  • 理解 NFS 协议设计的一个关键是理解文件句柄(file handle)

    文件句柄用于唯一地描述文件或目录

    可以认为文件句柄有 3 个重要组件:卷标识符、inode 号、世代号

  • 在 NFSv2 中,客户端以唯一、统一和优雅的方式处理所有这些故障:就是重试请求

提高性能:客户端缓存

  • 在任何系统中添加缓存,导致包含多个客户端缓存,都会引入一个巨大且有趣的挑战,我们称之为缓存一致性问题
  • 为了解决更新可见性,客户端实现了有时称为“关闭时刷新”(flush-on-close,即 close-to-open)的一致性语义
  • 对服务器的写入,在返回成功之前,必须强制写入稳定存储(否则数据可能会丢失)

Andrew 文件系统(AFS)

AFS 与 NFS 的不同之处也在于,从一开始,合理的、用户可见的行为就是首要考虑的问题

  • 所有 AFS 版本的基本原则之一,是在访问文件的客户端计算机的本地磁盘(local disk)上,进行全文件缓存(whole-file caching)

  • 在他们的研究中,作者发现了 AFSv1 的两个主要问题

    • 路径查找成本过高
    • 客户端发出太多 TestAuth 协议消息

AFSv1 实际上还存在另外两个问题:服务器之间的负载不均衡,服务器对每个客户端使用一个不同的进程,从而导致上下文切换和其他开销

  • AFSv2 引入了回调(callback)的概念,以减少客户端/服务器交互的数量

    • 回调就是服务器对客户端的承诺,当客户端缓存的文件被修改时,服务器将通知客户端
    • 通过将此状态(state)添加到服务器,客户端不再需要联系服务器,以查明缓存的文件是否仍然有效
    • 实际上,它假定文件有效,直到服务器另有说明为止,这里类似于轮询(polling)与中断(interrupt)
  • AFSv2 还引入了文件标识符(File Identifier,FID)的概念(类似于 NFS 文件句柄),替代路径名,来指定客户端感兴趣的文件

  • 讨论 NFS 时,我们考虑了缓存一致性的两个方面:更新可见性(update visibility)和缓存陈旧(cache staleness)

    • 由于回调和全文件缓存,AFS 提供的缓存一致性易于描述和理解
  • 构建更具可扩展性和合理性的缓存模型需要付出代价,崩溃恢复比 NFS 更复杂

在考虑系统管理方面,AFS 遥遥领先

由于 NFS 是一个开放标准,许多不同的供应商都支持它,它与 CIFS(基于 Windows 的分布式文件系统协议)一起,在市场上占据了主导地位

实际上,NFSv4 现在添加了服务器状态(例如,“open”协议消息),因此与基本 AFS 协议越来越像

虚拟机监视器

如果组织希望同时在机器上运行不同的操作系统,该怎么办?

  • 有些应用程序是在一个操作系统上开发的,有些是在其他操作系统上开发的,因此出现了该问题。作为一种解决方案,IBM 以虚拟机监视器(Virtual Machine Monitor,VMM)(也称为管理程序,hypervisor)的形式,引入了另一个间接层
  • 具体来说,监视器位于一个或多个操作系统和硬件之间,并为每个运行的操作系统提供控制机器的假象
  • 实际上,VMM 作为操作系统的操作系统,但在低得多层次上,操作系统仍然认为它与物理硬件交互

到目前为止操作系统已经成为假象提供大师,欺骗毫无怀疑的应用程序,让它们认为拥有自己私有的 CPU 和大型虚拟内存,同时在应用程序之间进行切换,并共享内存

  • 由于多种原因,VMM 再次流行起来,服务器合并就是一个原因

现在我们要 VMM 去欺骗操作系统

  • 虚拟化 CPU

  • 虚拟化内存

  • 信息沟

    • VMM 通常不太了解操作系统正在做什么或想要什么,这种知识缺乏有时被称为 VMM 和 OS 之间的信息沟(information gap)
    • 在许多情况下,最好是假定,无法为了更好地使用虚拟机监视器而修改操作系统,一个设计合理的半虚拟化系统,只需要正确的操作系统更改,就可以接近没有 VMM 时的效率
  • 隐含信息可以成为分层系统中的一个强大工具,在这种系统中很难改变系统之间的接口,但需要更多关于系统不同层的信息

最后,硬件支持改变了平台支持虚拟化的方式,英特尔和 AMD 等公司现在直接支持额外的虚拟化层,从而避免了本章中的许多软件技术

关于书的相关信息

M: OSTEP (Operating Systems: Three Easy Pieces)

P: Remzi H.Arpaci-Dusseau & Andrea C.Arpaci-Dusseau