美文网首页
异常控制流

异常控制流

作者: KEEEPer | 来源:发表于2017-12-16 13:40 被阅读23次

    学习目标

    1.了解异步异常与同步异常,以及异常控制流与平时的逻辑控制流的差异
    2.理解进程的工作机制,如何通过异常来进行进程切换
    3.理解 Linux 的进程控制机制,掌握 fork 的基本用法
    4.了解信号的基本原理以及如何处理信号
    5.掌握如何避免进程竞争的方法
    6.了解非本地跳转的概念和意义

    前面提到过,进程可能是计算机系统中最伟大的抽象。进程这个概念背后,其实隐藏着一整套系统级机制,从进程切换、用户态与内核态的转换到系统实时响应各种事件,都离不开一个相当熟悉又陌生的概念——异常。在这个基础上,我们会一起来看看,操作系统到底是如何工作的,为什么可以同时执行不同的程序,具体又是通过什么机制来管理这一切的呢?这一讲我们就来看看这之中的奥秘。

    异常控制流

    从开机到关机,处理器做的工作其实很简单,就是不断读取并执行指令,每次执行一条,整个指令执行的序列,称为处理器的控制流。到目前为止,我们已经学过了两种改变控制流的方式:

    • 跳转和分支
    • 调用和返回

    这两个操作对应于程序的改变。但是这实际上仅仅局限于程序本身的控制,没办法去应对更加复杂的情况。系统状态发生变化的时候,无论是跳转/分支还是调用/返回都是无能为力的,比如:

    • 数据从磁盘或者网络适配器到达
    • 指令除以了0(内核会发送一个浮点溢出异常)
    • 用户按下了ctrl+c
    • 系统的计时器到时间

    这时候就要轮到另一种更加复杂的机制登场了,我们称之为异常控制流(exceptional control flow)。这里的异常与代码中所涉及的异常时不一样的(也就是那个try catch)

    异常控制流存在于系统的每一个层级,最底层的机制称之为异常 用以改变控制流响应系统事件,通常是由硬件和操作系统共同实现的。最高层次的异常控制流包括进程间的切换、信号和非本地跳转也可以看做是一种硬件过渡到操作系统和,再从操作系统过渡到语言库的过程。进程切换是由硬件计时器和操作系统共同实现的,而信号则只是操作系统层面的概念了,到了非本地跳转就已经是在 C 运行时库中实现的了。

    然后我们就来看看4个跨不同层级的异常控制流。

    异常Exception

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

    具体的过程可以用下图来表示:

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

    异步异常(中断)

    异步异常(Asynchronous Exception)称之为中断(Interrupt),是由处理器外面发生的事情引起的。对于执行的程序来说,这种“中断”的发生时完全异步的,因为不知道什么时候回发生。CPU对其的响应也是完全被动的,但是可以屏蔽掉。这种情况下:

    • 需要设置处理器的中断指针(interrupt pin)
    • 处理完成后会返回之前控制流中的『下一条』指令

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

    同步异常

    同步异常(Synchronous Exception)是因为执行某条指令所导致的事件,分为陷阱(Trap)、故障(Fault)和终止(Abort)三种情况。

    陷阱的最重要的用途就是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用

    这里需要注意三种不同类型的处理方式,比方说陷阱和中断一样,会返回执行『下一条』指令;而故障会重新执行之前触发事件的指令;终止则是直接退出当前的程序。

    系统调用的实例

    系统调用看起来像是函数调用,但其实是走异常控制流的,在 x86-64 系统中,每个系统调用都有一个唯一的 ID,如

    编号 名称 描述
    0 read 文件操作
    1 write 文件操作
    2 open 文件操作
    3 close 同上
    4 stat 获取文件信息
    57 fork 创建进程
    59 exece 执行一个程序
    60 _exit 关闭进程
    62 kill 向进程发送信号

    举个例子,假设用户调用了open(filename, options),系统实际上会执行 __open 函数,也就是进行系统调用 syscall,如果返回值是负数,则是出错,汇编代码如下:

    00000000000e5d70 <__open>:
        ...
        e5d79: b8 02 00 00 00     mov $0x2, %eax    # open 是编号 2 的系统调用
        e5d7e: 0f 05              syscall           # 调用的返回值会在 %rax 中
        e5d80: 48 3d 01 f0 ff ff  cmp $0xfffffffffffff001, %rax
        ...
        e5dfa: c3                 retq
    

    故障示例

    这里我们以 Page Fault 为例(缺页异常是一种故障示例),来说明 Fault 的机制。Page Fault 发生的条件是:

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

    比如:

    int a[1000];
    main()
    {
        a[500] = 13;
    }
    

    也就是引用非法地址的时候,整个流程就会变成:

    具体来说会像用户进程发送 SIGSEGV 信号,用户进程会以 segmentation fault 的标记退出。

    另外一种:

    当指令引用一个虚拟地址,然而与该虚拟地址对应的物理地址不在内存中,因此必须从磁盘中读取出的时候就会发生故障。当缺页处理程序从磁盘中加载适当的页面,然后把控制返回给引起故障的指令。当故障再次执行的时候,相应的物理页面就已经驻留在了内存中了,指令就可以没有故障的完成了。

    从上面我们就可以看到异常的具体实现是依靠在用户代码和内核代码间切换而实现的,是非常底层的机制

    进程

    进程是计算机科学中最为重要的思想之一,进程才是程序(指令和数据)的真正运行实例。之所以重要,是因为进程给每个应用提供了两个非常关键的抽象:一是逻辑控制流,二是私有地址空间。逻辑控制流通过称为上下文切换(context switching)的内核机制让每个程序都感觉自己在独占处理器。私有地址空间则是通过称为虚拟内存(virtual memory)的机制让每个程序都感觉自己在独占内存。这样的抽象使得具体的进程不需要操心处理器和内存的相关适宜,也保证了在不同情况下运行同样的程序能得到相同的结果。

    计算机会同时运行多个进程,有前台应用,也后台任务,在 Mac 的终端下输入 top(或者更酷炫的 htop),就可以看到进程的信息

    进程的切换 Process Context Switch

    这么多进程,具体是如何工作的呢?我们来看看下面的示意图:

    左边是单进程的模型,内存中保存着进程所需的各种信息,因为该进程独占 CPU,所以并不需要保存寄存器值。而在右边的单核多进程模型中,虚线部分可以认为是当前正在执行的进程,因为我们可能会切换到其他进程,所以内存中需要另一块区域来保存当前的寄存器值,以便下次执行的时候进行恢复(也就是所谓的上下文切换)。整个过程中,CPU 交替执行不同的进程,虚拟内存系统会负责管理地址空间,而没有执行的进程的寄存器值会被保存在内存中。切换到另一个进程的时候,会载入已保存的对应于将要执行的进程的寄存器值。

    而现代处理器一般有多个核心,所以可以真正同时执行多个进程。这些进程会共享主存以及一部分缓存,具体的调度是由内核控制的,示意图如下:

    切换进程时,内核会负者具体的调度,如下图所示

    进程控制 Process Control

    系统调用的错误处理

    在遇到错误的时候,Linux 系统级函数通常会返回 -1 并且设置 errno 这个全局变量来表示错误的原因。使用的时候记住两个规则:

    1.对于每个系统调用都应该检查返回值
    2.当然有一些系统调用的返回值为 void,在这里就不适用

    例如,对于fork(),我们应该这样写:

    if ((pid = fork()) < 0) {
        fprintf(stderr, "fork error: %s\n", strerror(errno));
        exit(0);
    }
    

    如果觉得这样写太麻烦,可以利用一个辅助函数:

    void unix_error(char *msg) /* Unix-style error */
    {
        fprintf(stderr, "%s: %s\n", msg, strerror(errno));
        exit(0);
    }
    // 上面的片段可以写为
    if ((pid = fork()) < 0)
        unix_error("fork error");
    

    我们甚至可以更进一步,把整个 fork() 包装起来,就可以自带错误处理,比如

    pid_t Fork(void)
    {
        pid_t pid;
        if ((pid = fork()) < 0)
            unix_error("Fork error");
        return pid;
    }
    

    获取进程的信息

    • pid_t getpid(void)
    • pid_t getppid(void)

    进程的三个状态
    终止进程
    创建进程

    进程图

    进程图是一个很好帮组我们理解进程执行的工具:
    示例一

    int main()
    {
        pid_t pid;
        int x = 1;
        
        pid = Fork();
        if (pid == 0) 
        {   // Child
            printf("child! x = %d\n", --x);
            exit(0);
        }
        
        // Parent
        printf("parent! x = %d\n", x);
        exit(0);
    }
    

    示例二

    int main() {
      Fork();
      Fork();
      printf("hello/n");
      exit(0);
    }
    

    示例三

    回收子进程

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

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

    如果想在子进程载入其他的程序,就需要使用 execve 函数,具体可以查看对应的 man page,这里不再深入

    信号 Signal

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

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

    这怎么办呢?同样可以利用异常控制流,当后台进程完成时,内核会中断常规执行并通知我们,具体的通知机制就是『信号』(signal)。

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

    这样看来,信号其实是类似于异常和中断的,是由内核(在其他进程的请求下)向当前进程发出的。信号的类型由 1-30 的整数定义,信号所能携带的信息极少,一是对应的编号,二就是信号到达这个事实。

    内核通过给目标进程发送信号,来更新目标进程的状态,具体的场景为:

    • 内核检测到了如除以零(SIGFPE)或子进程终止(SIGCHLD)的系统事件
    • 另一个进程调用了 kill 指令来请求内核发送信号给指定的进程

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

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

    然而有的信号是不能被忽略和忽略的

    具体过程如下:

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

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

    Sigpromask

    进程组

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

    • 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,所以会杀掉进程组中的每个进程。

    阻塞信号

    我们知道,内核会阻塞与当前在处理的信号同类型的其他正待等待的信号,也就是说,一个 SIGINT 信号处理器是不能被另一个 SIGINT 信号中断的。

    如果想要显式阻塞,就需要使用 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)的函数
    • 规则 3:在进入和退出的时候保存和恢复 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 来进行非本地跳转了。

    setjmp 保存当前程序的堆栈上下文环境(stack context),注意,这个保存的堆栈上下文环境仅在调用 setjmp 的函数内有效,如果调用 setjmp 的函数返回了,这个保存的堆栈上下文环境就失效了。调用 setjmp 的直接返回值为 0。

    我们可以利用这种方式,来跳转到其他的栈帧中,比方说在嵌套函数中,我们可以利用这个快速返回栈底的函数,我们来看如下代码

    jmp_buf env;
    P1()
    {
        if (setjmp(env))
        {
            // 跳转到这里
        } else 
        {
            P2();
        }
        
    }
    P2()
    {
        ...
        P2();
        ...
        P3();
    }
    P3()
    {
        longjmp(env, 1);
    }
    

    对应的跳转过程为:


    也就是说,我们直接从 P3 跳转回了 P1,但是也有限制,函数必须在栈中(也就是还没完成)才可以进行跳转,下面的例子中,因为 P2 已经返回,所以不能跳转了

    jmp_buf env;
    P1()
    {
        P2(); P3();
    }
    P2()
    {
        if (setjmp(env))
        {
            // 跳转到这里
        }
    }
    P3()
    {
        longjmp(env, 1);
    }
    

    因为 P2 在跳转的时候已经返回,对应的栈帧在内存中已经被清理,所以 P3 中的 longjmp 并不能实现期望的操作。

    相关文章

      网友评论

          本文标题:异常控制流

          本文链接:https://www.haomeiwen.com/subject/xslswxtx.html