• 第八章 异常控制流 笔记


    异常控制流存在于操作系统的方方面面,最底层的机制称为异常(Exception),由硬件和操作系统共同实现。另外还有:

    进程切换(Process Context Switch): 硬件计时器和操作系统实现;

    信号(Signal): 操作系统实现;

    非本地跳转(Nonlocal Jumps):运行时实现。

    异常

    这里的异常指的是把控制交给系统内核来响应某些事件(例如处理器状态的变化),其中内核是操作系统常驻内存的一部分,而这类事件包括除以零、数学运算溢出、页错误、I/O 请求完成或用户按下了 ctrl+c 等等系统级别的事件。

    系统会通过异常表(Exception Table)来确定跳转的位置,每种事件都有对应的唯一的异常编号,发生对应异常时就会调用对应的异常处理代码。

    异步异常(Asynchronous Exception)称之为中断(Interrupt)

    比较常见的中断有两种:计时器中断和 I/O 中断。计时器中断是由计时器芯片每隔几毫秒触发的,内核用计时器终端来从用户程序手上拿回控制权。I/O 中断类型比较多样,比方说键盘输入了 ctrl-c,网络中一个包接收完毕,都会触发这样的中断。

    同步异常(Synchronous Exception)

    类型

     原因行为示例
    陷阱(Trap) 有意的异常 返回到下一条指令 系统调用,断点
    故障(Fault) 潜在可恢复的错误 返回到当前指令 页故障(page faults)
    终止(Abort) 不可恢复的错误 终止当前程序 非法指令

    实例

    系统调用

    系统调用看起来像是函数调用,但其实是走异常控制流的,在 x86-64 系统中,每个系统调用都有一个唯一的 ID,比如调用open(filename, options)时系统会调用__open,完成一个syscall

    Fault故障 

    以Page Fault为例,满足

    • 用户写入内存位置
    • 但该位置目前还不在内存中

    时发生Page Fault,代码通过异常处理把对应内容加载入内存并且重新执行对应导致Page Fault的语句。

    如果这个语句出现数组越界等问题了,那么会

    向用户发送SIGSEGV,用户显示为segmentation fault导致的退出。

    进程

    进程提供的抽象:
    1.逻辑控制流

    逻辑控制流通过称为上下文切换(context switching)的内核机制让每个程序都感觉自己在独占处理器。

    2.私有空间。有地址空间则是通过称为虚拟内存(virtual memory)的机制让每个程序都感觉自己在独占内存。

    进程切换 Process Context Switch

    进程控制 Process Control

    我们可以用下面两个函数获取进程的相关信息:

    • pid_t getpid(void) - 返回当前进程的 PID
    • pid_t getppid(void) - 返回当前进程的父进程的 PID

      

    可以认为,进程有三个主要状态:

    • 运行 Running
      • 正在被执行、正在等待执行或者最终将会被执行
    • 停止 Stopped
      • 执行被挂起,在进一步通知前不会计划执行
    • 终止 Terminated
      • 进程被永久停止

    另外的两个状态称为新建(new)和就绪(ready)

    在下面三种情况时,进程会被终止:

    1. 接收到一个终止信号
    2. 返回到 main
    3. 调用了 exit 函数

     创建进程

    int main()
    {
        pid_t pid;
        int x = 1;
        
        pid = Fork();
        if (pid == 0) 
        {   // Child
            printf("I'm the child!  x = %d
    ", ++x);
            exit(0);
        }
        
        // Parent
        printf("I'm the parent! x = %d
    ", --x);
        exit(0);
    }
    输出:
    I'm the parent! x = 0
    I'm the child!  x = 2
    • 调用一次,但是会有两个返回值
    • 并行执行,不能预计父进程和子进程的执行顺序
    • 子进程拥有自己独立的地址空间(也就是变量都是独立的),除此之外其他信息都相同。
    • 在父进程和子进程中 stdout 是一样的

    回收子进程

    即使主进程已经终止,子进程也还在消耗系统资源,我们称之为『僵尸』。为了『打僵尸』,就可以采用『收割』(Reaping) 的方法。父进程利用 waitwaitpid 回收已终止的子进程,然后给系统提供相关信息,kernel 就会把 zombie child process 给删除。

    如果父进程不回收子进程的话,通常来说会被 init 进程(pid == 1)回收,所以一般不必显式回收。但是在长期运行的进程中,就需要显式回收(例如 shell 和 server)。

    如果想在子进程载入其他的程序,就需要使用 execve 函数,execve会代替原来剩余的部分,也就是说不会返回原来的位置。

    信号 Signal

    Linux 的进程树,可以通过 pstree 命令查看。

    对于前台进程来说,我们可以在其执行完成后进行回收,而对于后台进程来说,因为不能确定具体执行完成的时间,所以终止之后就成为了僵尸进程,无法被回收并因此造成内存泄露。

    为了避免这一点,可以利用异常控制流,当后台进程完成时,内核会中断常规执行并通知我们,具体的通知机制就是『信号』(signal)。

    信号是 Unix、类 Unix 以及其他 POSIX 兼容的操作系统中进程间通讯的一种有限制的方式。它是一种异步的通知机制,用来提醒进程一个事件已经发生。当一个信号发送给一个进程,操作系统中断了进程 正常的控制流程,此时,任何非原子操作都将被中断。如果进程定义了信号的处理函数,那么它将被执行,否则就执行默认的处理函数。

     名称默认动作对应事件
    2 SIGINT 终止 用户输入 ctrl+c
    9 SIGKILL 终止 终止程序(不能重写或忽略)
    11 SIGSEGV 终止且 Dump 段冲突 Segmentation violation
    14 SIGALRM 终止 时间信号
    17 SIGCHLD 忽略 子进程停止或终止

    目标进程接收到信号后,内核会强制要求进程对于信号做出响应,可以有几种不同的操作:

    • 忽略这个型号
    • 终止进程
    • 捕获信号,执行信号处理器(signal handler),类似于异步中断中的异常处理器(exception handler)

    如果信号已被发送但是未被接收,那么处于等待状态(pending),同类型的信号至多只会有一个待处理信号(pending signal),一定要注意这个特性,因为内部实现机制不可能提供较复杂的数据结构,所以信号的接收并不是一个队列。比如说进程有一个 SIGCHLD 信号处于等待状态,那么之后进来的 SIGCHLD 信号都会被直接扔掉。

    当然,进程也可以阻塞特定信号的接收,但信号的发送并不受控制,所以被阻塞的信号仍然可以被发送,不过直到进程取消阻塞该信号之后才会被接收。内核用等待(pending)位向量和阻塞(blocked)位向量来维护每个进程的信号相关状态。

    进程组

    每个进程都只属于一个进程组,从前面的进程树状图中我们也能大概了解一二,想要了解相关信息,一般使用如下函数:

    • getpgrp() - 返回当前进程的进程组
    • setpgid() - 设置一个进程的进程组

    我们可以据此指定一个进程组或者一个单独的进程,比方说可以通过 kill 应用来发送信号

    # 创建子进程
    linux> ./forks 16
    Child1: pid=24818 pgrp=24817
    Child2: pid=24819 pgrp=24817
    # 查看进程
    linux> ps
      PID TTY      TIME  CMD
    24788 pts/2 00:00:00 tcsh
    24818 pts/2 00:00:02 forks
    24819 pts/2 00:00:02 forks
    24820 pts/2 00:00:00 ps
    # 可以选择关闭某个进程
    linux> /bin/kill -9 24818
    # 也可以关闭某个进程组,会关闭该组中所有进程
    linux> /bin/kill -9 -24817
    # 查看进程
    linux> ps
      PID TTY      TIME  CMD
    24788 pts/2 00:00:00 tcsh
    24820 pts/2 00:00:00 ps

    第一个命令只会杀掉编号为 24818 的进程。

    第二个命令,因为有两个进程都属于进程组 24817,所以会杀掉进程组中的每个进程。

    linux> ./forks 17
    Child: pid=28108 pgrp=28107
    Parent: pid=28107 pgrp=28107
    # 按下 ctrl+z
    Suspended # 进程被挂起
    linux> ps w
      PID TTY   STAT  TIME  COMMAND
    27699 pts/8 Ss    00:00 -tcsh
    28107 pts/8 T     00:02 ./forks 17
    28108 pts/8 T     00:02 ./forks 17
    28109 pts/8 R+    00:00 ps w
    linux> fg
    ./forks 17
    # 按下 ctrl+c,进程被终止
    linux> ps w
      PID TTY   STAT  TIME  COMMAND
    27699 pts/8 Ss    00:00 -tcsh
    28109 pts/8 R+    00:00 ps w
    
    STAT 部分的第一个字母的意思
    
        S: 睡眠 sleeping
        T: 停止 stopped
        R: 运行 running
    
    第二个字母的意思:
    
        s: 会话管理者 session leader
        +: 前台进程组

    接收信号

    所有的上下文切换都是通过调用某个异常处理器(exception handler)完成的,内核会计算对易于某个进程 p 的 pnb 值。pnb = pending & blocked

    signal 函数可以修改默认的动作,函数原型为 handler_t *signal(int signum, handler_t *handler)。我们通过一个简单的例子来感受下,这里我们屏蔽了 SIGINT 函数,即使按下 ctrl+c 也不会终止。

    void sigint_handler(int sig) // SIGINT 处理器
    {
        printf("想通过 ctrl+c 来关闭我?
    ");
        sleep(2);
        fflush(stdout);
        sleep(1);
        printf("OK. :-)
    ");
        exit(0);
    }
    int main()
    {
        // 设定 SIGINT 处理器
        if (signal(SIGINT, sigint_handler) == SIG_ERR)
            unix_error("signal error");
            
        // 等待接收信号
        pause();
        return 0;
    }

    信号处理器也可以被其他的信号处理器中断,控制流如下图所示:

    阻塞信号

    如果想要显式阻塞,就需要使用 sigprocmask 函数了,以及其他一些辅助函数:

    • sigemptyset - 创建空集
    • sigfillset - 把所有的信号都添加到集合中(因为信号数目不多)
    • sigaddset - 添加指定信号到集合中
    • sigdelset - 删除集合中的指定信号

    我们可以用下面这段代码来临时阻塞特定的信号:

    sigset_t mask, prev_mask;
    Sigemptyset(&mask); // 创建空集
    Sigaddset(&mask, SIGINT); // 把 SIGINT 信号加入屏蔽列表中
    // 阻塞对应信号,并保存之前的集合作为备份
    Sigprocmask(SIG_BLOCK, &mask, &prev_mask);
    ...
    ... // 这部分代码不会被 SIGINT 中断
    ...
    // 取消阻塞信号,恢复原来的状态
    Sigprocmask(SIG_SETMASK, &prev_mask, NULL);

    安全处理信号

    信号处理器的设计并不简单,因为它们和主程序并行且共享相同的全局数据结构,尤其要注意因为并行访问可能导致的数据损坏的问题,这里提供一些基本的指南(后面的课程会详细介绍)

    • 规则 1:信号处理器越简单越好
      • 例如:设置一个全局的标记,并返回
    • 规则 2:信号处理器中只调用异步且信号安全(async-signal-safe)的函数
      • 诸如 printf, sprintf, mallocexit 都是不安全的!
    • 规则 3:在进入和退出的时候保存和恢复 errno
      • 这样信号处理器就不会覆盖原有的 errno
    • 规则 4:临时阻塞所有的信号以保证对于共享数据结构的访问
      • 防止可能出现的数据损坏
    • 规则 5:用 volatile 关键字声明全局变量
      • 这样编译器就不会把它们保存在寄存器中,保证一致性
    • 规则 6:用 volatile sig_atomic_t 来声明全局标识符(flag)
      • 这样可以防止出现访问异常

    这里提到的异步信号安全(async-signal-safety)指的是如下两类函数:

    1. 所有的变量都保存在栈帧中的函数
    2. 不会被信号中断的函数

    Posix 标准指定了 117 个异步信号安全(async-signal-safe)的函数(可以通过 man 7 signal 查看)

    非本地跳转 Non local Jump

    所谓的本地跳转,指的是在一个程序中通过 goto 语句进行流程跳转,尽管不推荐使用goto语句,但在嵌入式系统中为了提高程序的效率,goto语句还是可以使用的。本地跳转的限制在于,我们不能从一个函数跳转到另一个函数中。如果想突破函数的限制,就要使用 setjmplongjmp 来进行非本地跳转了。

  • 相关阅读:
    好看的滚动条样式
    mysql常用语句
    实现点击左边菜单,然后右边弹出网页内容。
    封装一个tab思想方法实现点击的时候显示或隐藏效果
    JS对话框_JS模态对话框showModalDialog用法总结
    登录框
    git分支介绍和常用操作
    git 撤销删除恢复某次提交记录
    charles导出请求-转换格式应用至postman
    git工作流及提交操作
  • 原文地址:https://www.cnblogs.com/autoria/p/5897484.html
Copyright © 2020-2023  润新知