CSAPP学习笔记——chapter8 异常控制流

CSAPP学习笔记——chapter8 异常控制流

简介

异常控制流(Exceptional Control Flow,ECF)是在计算机系统中处理不寻常或异常情况的一种机制。它允许系统跳出正常的顺序控制流,响应那些并不直接由程序的控制流逻辑触发的事件。ECF在硬件、操作系统和应用程序层面都有体现,并且是现代计算机系统功能的一个重要组成部分。下面是ECF在不同层次上的体现及其重要性:

硬件层面和操作系统层面的ECF

在硬件层面,ECF通常表现为中断和异常。当硬件设备(如定时器、网络接口或磁盘)需要CPU注意时,它会发送信号导致中断。CPU响应中断请求,暂停当前执行的任务,转而执行一个中断处理程序,处理完中断后再返回到被中断的地方继续执行。这种机制允许系统以异步方式处理外部事件。

操作系统使用上下文切换来在用户进程间切换控制流,实现多任务处理。此外,系统调用也是一种ECF形式,它允许用户程序请求操作系统服务,如文件操作、进程控制等。

应用程序层面的ECF

应用程序可以通过信号处理来响应外部或系统生成的事件。当特定事件发生时(如除零错误、非法访问内存等),操作系统会向引起该事件的进程发送信号,进程可以定义信号处理函数来响应这些信号。

ECF的重要性

  1. 系统概念理解:ECF是实现I/O、进程和虚拟内存等操作系统概念的基础。要深入理解这些概念,必须先理解ECF。
  2. 与操作系统的交互:应用程序通过系统调用(一种ECF形式)来请求操作系统服务。理解系统调用机制有助于理解如何向磁盘写数据、创建进程等。
  3. 新应用程序开发:操作系统提供了强大的ECF机制供应用程序使用,如进程控制、事件通知等。理解这些机制可以帮助开发出如Unix shell和Web服务器等有趣的程序。
  4. 理解并发:ECF是实现系统并发的基础机制,包括异常处理程序、并发执行的进程和线程,以及信号处理程序。理解ECF是理解并发概念的起点。
  5. 理解ECF帮助理解软件异常是如何工作的。比如C++中的try,catch,throw;软件异常运行程序进行非本地跳转(违反通常的调用/返回栈规则的跳转)来响应错误情况。

硬件和操作系统层面的ECF

异常可以分为以下四类:

image-20240129094833896

这里简单提一下同步和异步的概念:

  • 在同步I/O操作中,进程或线程发起I/O请求后必须等待操作完成才能继续执行。在这种情况下,执行流程是线性的,控制流在等待I/O操作完成期间被阻塞。例如,在同步I/O中,程序发起一个读取磁盘文件的操作,并且直到文件读取完成并且数据被送入程序的缓冲区后,程序才会继续执行下一步操作。在这期间,程序不会执行其他任务。
  • 在异步I/O操作中,进程或线程发起I/O请求后可以立即继续执行其他任务,当I/O操作完成时,会通过回调函数、事件、信号或其他机制通知发起者。例如,在异步I/O中,程序可能发起一个读取磁盘文件的操作,然后立即执行其他逻辑。当文件读取操作完成后,操作系统会通知程序(例如,通过一个中断或在程序的某个事件循环中设置一个标志),程序随后可以处理读取到的数据。

中断(Interrupt)

中断是由硬件设备或条件触发的异步事件。它们通常发生在任意时刻,与CPU的主控制流程无关。中断使得CPU可以响应外部事件,如输入/输出设备请求数据传输、硬件计时器超时等。当中断发生时,CPU会暂停当前任务,保存其状态,并跳转到中断处理程序(Interrupt Service Routine, ISR)来处理该事件。处理完毕后,CPU可以恢复之前的任务继续执行。

image-20240129095929629

陷阱(Trap)

陷阱是由程序执行中的特定条件或指令(如系统调用)触发的同步事件。它是一种受控的异常控制流,允许用户程序向操作系统请求服务或通知操作系统发生了某个事件。例如,当程序执行系统调用时,会产生一个陷阱,导致控制权转移给操作系统以执行请求的服务。

image-20240129100010442

系统调用虽然也是一个函数,但是是运行在内核模式下的,普通的函数则是运行在用户模式下。当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达,这样可以提高处理器的吞吐量。

image-20240129100503052

同时我们也应该注意到,频繁的上下文切换是很拖慢系统的,如果一个进程有很多的小的I/O操作,我们可以可以利用一个缓冲区,将多次小的I/O操作合成一个大的I/O操作,从而减少上下文切换所造成的效率变低。

故障(Fault)

故障是由程序错误导致的同步事件,通常指示可能可恢复的错误条件。当发生故障时,系统将尝试纠正这个错误,例如,当一个程序试图访问未分配的内存时就会发生页故障(Page Fault)。如果故障可以被纠正,程序可以继续执行;否则,可能会升级为中止。

image-20240129100809621

中止(Abort)

中止指示了一个严重的错误,通常是不可恢复的。当中止事件发生时,程序不会继续执行。例如,当一个硬件故障发生或多个故障无法被纠正时,系统可能会中止执行当前的应用程序或操作。

之后书里介绍了一些进程相关的概念,包括获取进程ID,创建和终止进程,回收子进程,让进程休眠,加载并运行程序,就不一一展开介绍了,这些内容会在下面的信号章节进行一个统一的运用。

信号

传送一个信号到目的进程是由两个不同步骤组成的:

  1. 发送信号。内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。发送信号可以有如下两种原因:1)内核检测到一个系统事件,比如除零错误或者子进程终止。2)一个进程调用了 kill函数,显式地要求内核发送一个信号给目的进程。一个进程可以发送信号给它自己。
  2. 接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序(signal handler)的用户层函数捕获这个信号。图8-27 给出了信号处理程序捕获信号的基本思想。

image-20240129102721248

image-20240129105103106

发送信号

不知道大家是否注意到终止一个进程的这个命令:

kill -9 <进程号>

kill是Linux定义的一个可以向其他进程发送信号的程序,其中的9就是上图的终止信号。

这里其实就有一个细节:就是我们应该如何合理地关闭shell控制台中卡死的进程

https://www.cnblogs.com/curiositywang/p/17994756

根据上面信号的定义,Ctrl + Z只是停止这个进程,但是没有被杀死,意味着进程所占有的资源还没有被释放;所以我们应该使用的是 Ctrl + C,这会终止这个进程,并且释放资源。

前面其实就涉及了两种发送信号的方式了,一个是使用kill,另一个则是键盘输入,其他的还有在应用程序内调用kill函数以及alarm函数等。

image-20240129110327701

接受信号

进程接受信号后的行为主要有:

  • 进程终止
  • 进程终止并转储内存
  • 进程停止(挂起)直到被SIGCONT信号重启
  • 进程忽略该信号

前面的图8-26介绍了进程收到信号的默认行为;有意思的地方是,我们可以通过signal函数修改进程对信号相关联的默认行为。唯一的例外是SIGSTOP和SIGKILL,这两个是不能被修改的。

image-20240129111021388

展示一个重新定义 Ctrl+C发送的SIGINT信号处理逻辑的程序:

/* $begin sigint */
#include "csapp.h"

void sigint_handler(int sig) /* SIGINT handler */   //line:ecf:sigint:beginhandler
{
    printf("Caught SIGINT!\n");    //line:ecf:sigint:printhandler       //line:ecf:sigint:exithandler
    exit(0);
}      

int main() 
{   
    /* Install the SIGINT handler */         
    if (signal(SIGINT, sigint_handler) == SIG_ERR)  //line:ecf:sigint:begininstall
	    unix_error("signal error");                 //line:ecf:sigint:endinstall
  
    int pid = getpid();
    printf("pid is %d \n", pid);
    while(1){
        sleep(2);
    }
    
    return 0;
}
/* $end sigint */

信号安全

这一小节作者介绍了编写信号的安全的处理程序的一些准则,包括使用异步信号安全的函数等(还提供了输入输出函数SIO包),再往后还介绍了非本地跳转,它的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的。如果在一个深层嵌套的函数调用中发现了一个错误情况,我们可以使用非本地跳转直接返回到一个普通的本地化的错误处理程序,而不是费力地解开调用栈,这里等真正落实到具体的项目的时候再回过头来看吧;

还分析了信号的一个特性是如何影响正确性的:

信号的一个与直觉不符的方面是未处理的信号是不排队的。因为 pending 位向量中每种类型的信号只对应有一位,所以每种类型最多只能有一个未处理的信号。因此,如果两个类型飞的信号发送给一个目的进程,而因为目的进程当前正在执行信号 k的处理程序,所以信号k 被阻塞了,那么第二个信号就简单地被丢弃了;它不会排队。关键思想是如果存在一个未处理的信号就表明至少有一个信号到达了。

#include "csapp.h"
/* $begin signal1 */
/* WARNING: This code is buggy! */

void handler1(int sig) 
{
    int olderrno = errno;

    if ((waitpid(-1, NULL, 0)) < 0)
        sio_error("waitpid error");
    Sio_puts("Handler reaped child\n");
    Sleep(1);
    errno = olderrno;
}

int main() 
{
    int i, n;
    char buf[MAXBUF];

    if (signal(SIGCHLD, handler1) == SIG_ERR)
        unix_error("signal error");

    /* Parent creates children */
    for (i = 0; i < 3; i++) {
        if (Fork() == 0) {
            printf("Hello from child %d\n", (int)getpid());
            exit(0);
        }
    }

    /* Parent waits for terminal input and then processes it */
    if ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0)
        unix_error("read");

    printf("Parent processing input\n");
    while (1)
        ;

    exit(0);
}
/* $end signal1 */

这段代码的输出是:

image-20240129144753322

  1. 当一个子进程终止时,内核会发送SIGCHLD信号给父进程。
  2. 如果父进程正在执行信号处理器,并且另一个子进程在此时终止,第二个SIGCHLD信号会被加入到待处理信号集合,因为UNIX信号默认不排队。这意味着,当第三个信号到达相同的信号到达时,它会被丢弃。
  3. 在这个特定的例子中,handler1中的Sleep(1);调用使得信号处理器执行时间较长,增加了在处理第一个SIGCHLD信号时丢失后续SIGCHLD信号的风险。

但是我们可以通过使用一个while循环,使其正确运行:

/* $begin signal2 */
void handler2(int sig) 
{
    int olderrno = errno;

    while (waitpid(-1, NULL, 0) > 0) {
        Sio_puts("Handler reaped child\n");
    }
    if (errno != ECHILD)
        Sio_error("waitpid error");
    Sleep(1);
    errno = olderrno;
}
/* $end signal2 */

image-20240129145307096

总结

本篇博文介绍了现代操作系统中异常的一些概念,我们常见的系统调用其实也是异常的一种,内核会先保存调用者的上下文,进入内核模式,执行系统调用,当执行完毕之后,再去恢复调用者的上下文,继续执行,另外还有中断,陷阱等,这些是操作系统和硬件层面的异常;而对于进程层面的异常,则主要围绕信号这一抽象概念,包括接受信号和处理信号,最后介绍了有关信号安全的知识,还引出了一个如何有效释放进程资源的例子。

posted @ 2024-01-29 16:14  CuriosityWang  阅读(72)  评论(0编辑  收藏  举报