wywwzjj's Blog

CSAPP 异常控制流 笔记

字数统计: 3.7k阅读时长: 13 min
2019/10/28 Share

应用是如何与操作系统进行交互的?

异常

异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。

在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表,进行一个间接过程调用,到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序)。

理解的还是比较模糊,得调调源码看看细节。

类别

  • 中断
  • 陷阱
  • 故障
  • 终止

进程

关键抽象:

  • 一个独立的逻辑控制流,它提供一个假象,好像程序独占了处理器。
  • 一个私有的地址空间,它提供了一个假象,好像程序独占了内存系统。

进程实体

程序段、数据段、PCB 三部分组成了进程实体(进程映像)。

定义

传统定义:

  • 进程是程序的一次执行过程
  • 进程是一个程序及其数据在处理机上顺序执行时所发生的活动
  • 进程是具有独立功能的程序在数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位

进程是进程实体的运行过程,是系统进行资源分配调度的一个独立单位。

一道程序在一个数据集上的一次执行过程。

PCB

  • 基本描述信息

    • 进程名(通常用文件名或命令名称表示)

    • 进程标识符 PID(唯一标识符)

    • 用户标识符 UID

    • 当前进程状态

  • 管理信息

    • 程序和数据的地址
    • I/O 操作相关参数
    • 进程通信信息
  • 控制信息

    • 现场信息(各种寄存器值,进程切换时这些运行情况都要保存到PCB中)
    • 调度参数
    • 同步、互斥信号量

组织形式

  • 链接方式:按照进程状态将 PCB 分为多个队列,操作系统持有指向各个队列的指针

  • 索引方式:根据进程状态的不同,建立几张索引表(底层是个啥),操作系统持有指向各个索引表的指针

特征

  • 动态性:进程是程序的一次执行过程,是动态地产生、变化和消亡的(最基本的特征)
  • 并发性:内存中有多个进程实体,各进程可并发执行
  • 独立性:进程是操作系统分配资源的基本单位,一个进程的程序和数据只能由该进程本身访问。
  • 异步性:各进程按各自独立的、不可预知的速度向前推进,操作系统要提供“进程同步机制”来解决异步问题
  • 结构性:每个进程都配置一个 PCB。结构上来看,进程由程序段、数据段和PCB组成

状态

运行态:CPU、其他资源均满足

就绪态:已具备运行条件,只欠CPU

阻塞态:因等待某一事件而不能执行。CPU、其他资源均不满足。等待操作系统或其他进程唤醒。

创建态:操作系统为进程分配资源、初始化PCB

终止态:进程正在从系统中撤销,操作系统将回收进程所拥有的资源、撤销PCB

image.png

注意:

  • 不能由阻塞态直接转换为运行态

    申请的资源被分配,或等待时间发生了,只代表其他资源满足,此时进入就绪态,还要等CPU。

  • 不能由就绪态转换成阻塞态。

    因为进入阻塞态需要进程主动请求,必然需要在运行时才能发出请求。

逻辑控制流

并发流

私有地址空间

用户模式和内核模式

运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法时通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。

上下文切换

上下文是由程序正确运行所需的状态组成的。

这个状态包括存放在内存中的程序的代码和数据,以及它的栈、寄存器、PC、环境变量和打开文件描述符的集合。
Golang 从 2009 年正式发布以来,依靠其极高运行速度和高效的开发效率,迅速占据市场份额。Golang 从语言级别支持并发,通过轻量级协程 Goroutine 来实现程序并发运行。

Goroutine 非常轻量,主要体现在以下两个方面:

上下文切换代价小: Goroutine 上下文切换只涉及到三个寄存器(PC / SP / DX)的值修改;而对比线程的上下文切换则需要涉及模式切换(从用户态切换到内核态)、以及 16 个寄存器、PC、SP…等寄存器的刷新;

内存占用少:线程栈空间通常是 2M,Goroutine 栈空间最小 2K;

Golang 程序中可以轻松支持10w 级别的 Goroutine 运行,而线程数量达到 1k 时,内存占用就已经达到 2G。

进程控制

程序员角度,可认为进程总是处于三种状态之一:

  • 运行:进程要么在 CPU 上执行,要么在等待被执行且最终会被内核调度。

  • 停止:进程的执行被挂起(suspended),且不会被调度,直到收到 SIGCONT 信号再次运行。

  • 终止:进程永远地停止了。

    三种原因可使得进程停止:

    • 收到一个信号,该信号的默认行为是终止进程。
    • 从主程序返回。
    • 调用 exit 函数。

主要功能:对系统中的所有进程实施有效的管理,包括创建新进程、撤销已有进程、实现进程状态转换。

原语:是一种特殊的程序,执行必须一气呵成,不可中断。用开、关中断实现的。

唤醒进程:

  • 从等待队列中移出

  • 修改 PCB 进程状态

  • 加入就绪队列

获取进程 ID

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);
pid_t getppid(void);

创建和终止进程

  • 建立 PCB,生成 pid
  • 初始化 PCB
  • 加入就绪队列
#inlcude <stdlib.h>

pid_t fork(void);
void exit(int status);

回收子进程

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

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

僵死进程已经终止了,但内核仍保留着它的某些状态直到父进程回收它为止。

一个进程可通过调用 waitpid() 来等待它的子进程终止或者停止。

#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int* statusp, int options);

(TODO:深入整理)

让进程休眠

  • 修改 PCB 中的进程状态
  • 现场保护
  • 将进程加入合适的等待队列
#include <unistd.h>

unsigned int sleep(unsigned int secs);

int pause(void); // 休眠至进程接收到一个信号

加载并运行程序

execve() 函数在当前进程的上下文加载并运行一个新程序。

fork() 函数则是在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。

#include <unistd.h>

int execve(const char* filename, const char** argv, const char** envp);

与 fork 一次调用两次返回不同,execve 调用一次并从不返回。

调度

一旦资源不足时就需要进行调度,比如现实中的十字路口,需要红绿灯来调度。

作业与进程调度的区别:作业是内外存的调度,进程是CPU与内存间的调度

性能指标

  • 周转时间和平均周转时间

    周转时间 = 完成时刻 - 提交作业时刻

    平均周转时间 = 总周转时间 / n

    加权平均周转算法 =

  • 响应时间

  • 评价调度性能的其他指标

    • 公平合理
    • 提高资源利用率
    • 吞吐量

作业调度

作业调度:按一定的策略从后备队列中选择一部分作业,为他们分配运行所必须的资源、创建进程的过程。

总的来说,都是一个作业执行结束后再开始调度。

提交状态:

后备状态:

执行状态:作业进入了内存

完成状态:

先来先服务算法(FCFS)

对长作业有利、短作业不利

短作业优先算法(SJF)

上一作业运行结束后,在就绪作业里选择时间最短的。

高响应比优先算法(HRN)

响应比 = (系统当前时间 - 作业提交时间) / 作业大小

优先选择响应比最大的作业

进程调度

从进程就绪队列中选一个进程,让其占用CPU运行。

时间片轮转算法(RR)

公平地、轮流地为各个进程服务,让每个进程在一定时间间隔内都可以得到响应。

优点:公平、响应快,适用于分时操作系统。

缺点:由于高频率的进程切换,因此有一定开销;不区分任务的紧急程度。

优先级算法(Priority)

调度时选择优先级最高的作业 / 进程。

优先级分配有静态和动态两种。

对于 I/O 繁忙和 CPU 繁忙的进程,应该赋予 I/O 繁忙进程更高的优先级,有利于提高并行程度。

多级反馈队列算法

时限调度算法

用于实时系统的调度。

交换调度

缓解内存紧张,将一部分就绪状态或阻塞状态进程调出到外存,需要的时候再调回来,即内外存交换。

设备调度

让哪个进程使用该设备。

死锁

根本原因:系统拥有的资源数量小于各进程对资源的需求总数。

死锁:各进程互相等待对方手里的资源,导致各进程都阻塞,无法向前推进。

​ 至少有两个或两个以上的进程同时死锁,死锁进程一定处于阻塞态。

饥饿:可能只有一个进程发生饥饿。

死循环:可能只有一个进程发生死循环,死循环的进程也可能就绪。

死锁和饥饿是操作系统要解决的问题,死循环是程序员的事情。

如果系统中的所有进程存在一个可完成的执行序列P1,…,Pn,则称系统处于安全状态。

必要条件

  • 互斥:对必须互斥使用的资源的争夺才会导致死锁。
  • 不剥夺:进程保持的资源只能主动释放,不能被强行剥夺。
  • 请求与保持:保持着某些资源不放的同时,请求别的资源。
  • 环路等待:存在一种进程资源的循环等待链。循环等待未必死锁。

处理策略

预防:破坏死锁产生的四个必要条件

避免:避免系统进入不安全状态(银行家算法)

检测和解除:允许死锁发生,系统负责检测出死锁并解除。

忽略:鸵鸟算法

系统调用错误处理

参见 error.h,这里想说的还是对错误返回处理的封装。

信号

一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的时间。在 Linux 上支持了 30 中不同类型的信号。每个信号类型都对应于某种系统事件。

低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。

信号提供了一种机制,通知余户进程发生了这些异常。

发送

内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。

发送信号可以有如下两种原因:

  • 内核检测到一个系统事件,比如除零错误或者子进程终止。
  • 一个进程调用了 kill 函数,显示地要求内核发送一个信号给目的进程。

一个进程可以发送信号给它自己。

Unix 系统发送信号的机制

  • 进程组

    每个进程都只属于一个进程组,进程组是由一个正整数进程组 ID 来标识的。

    默认一个子进程和它父进程同属于一个进程组。

    #include <unistd.h>

    pid_t getpgrp(void); // 返回调用进程的进程组 ID

    int setpgid(pid_t pid, pid_t pgid); // 设置进程组成功返回 0,否则为 -1
  • /bin/kill 程序

    kill -9 1023	# 杀掉 1023 进程
    kill -9 -1023 # 杀掉 1023 进程组的每个进程
  • 从键盘发送

    CTRL + C / Z :终止 / 挂起

  • 用 kill 函数

    #include <sys/types.h>
    #include <signal.h>

    int kill(pid_t pid, int sig);
  • 用 alarm 函数发送

    进程可通过调用 alarm 函数向自己发送 SIGALRM 信号,网络编程中可拿来处理超时。

    #include <unistd.h>

    unsigned int alarm(unsigned int secs);

接收

当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个信号处理函数的用户层函数捕获这个信号。

处理

非本地跳转

操作进程的工具

  • strace

    打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。

    这是一个超级牛逼的工具,比如你想跟进 PHP 内核底层实现,这就能收获大量信息。

  • ps

    列出当前系统中的进程(包括僵尸进程)。

  • top

    打印出关于当前进程资源使用的信息。

  • pmap

    显示进程的内存映射。

  • /proc

    一个虚拟文件系统,以 ASCII 文本格式输出大量内核数据结构的内容(从这也能感受到 Linux 文件的重要性),用户程序可以读取这些内容。

    (TODO:补充详细结构及其作用)

CATALOG
  1. 1. 异常
    1. 1.1. 类别
  2. 2. 进程
    1. 2.1. 进程实体
    2. 2.2. 定义
    3. 2.3. PCB
    4. 2.4. 组织形式
    5. 2.5. 特征
    6. 2.6. 状态
    7. 2.7. 逻辑控制流
    8. 2.8. 并发流
    9. 2.9. 私有地址空间
    10. 2.10. 用户模式和内核模式
    11. 2.11. 上下文切换
  3. 3. 进程控制
    1. 3.1. 获取进程 ID
    2. 3.2. 创建和终止进程
    3. 3.3. 回收子进程
    4. 3.4. 让进程休眠
    5. 3.5. 加载并运行程序
  4. 4. 调度
    1. 4.1. 性能指标
    2. 4.2. 作业调度
      1. 4.2.1. 先来先服务算法(FCFS)
      2. 4.2.2. 短作业优先算法(SJF)
      3. 4.2.3. 高响应比优先算法(HRN)
    3. 4.3. 进程调度
      1. 4.3.1. 时间片轮转算法(RR)
      2. 4.3.2. 优先级算法(Priority)
      3. 4.3.3. 多级反馈队列算法
      4. 4.3.4. 时限调度算法
    4. 4.4. 交换调度
    5. 4.5. 设备调度
    6. 4.6. 死锁
      1. 4.6.1. 必要条件
      2. 4.6.2. 处理策略
  5. 5. 系统调用错误处理
  6. 6. 信号
    1. 6.1. 发送
    2. 6.2. 接收
    3. 6.3. 处理
  7. 7. 非本地跳转
  8. 8. 操作进程的工具