第八章 异常控制流


异常


异常就是控制流中的突变,用来响应处理器状态中的某些变化。处理器中的状态被编码为不同的位和信号,状态变化称为事件。处理器检测到事件发生时,就会通过一张叫做异常表的跳转表,进行一个间接过程调用,到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序),处理程序完成后可能会返回到当前指令或下一条指令或终止程序。

系统中可能的 每种异常都分配了一个唯一的非负整数的异常号 。处理器检测到发生一个事件,并且确定了相应的异常号k,随后处理器触发异常,执行间接过程调用,通过异常表的表目k,转到相应的处理程序。异常号是异常表中的索引,异常表的起始地址存放在异常表基址寄存器里。

异常与过程调用的对比

  • 过程调用在跳转到处理程序之前,处理器将返回地址压到栈中;根据异常的类型,返回地址要么是当前指令,要么是下一条指令。
  • 处理异常时,处理器把一些额外的状态压到栈中,重新开始执行被中断的程序需要这些状态。
  • 异常控制从用户程序转移到内核时,所有这些项目都被压到内核栈中。
  • 异常处理程序运行在内核模式下,对所有的系统资源都有完全的访问权限。

异常的类别

  1. 中断:异步异常(由处理器外部的IO设备的事件产生),总是返回到下一条指令
  2. 陷阱:同步异常(执行一条指令的直接产物),有意的异常,总是返回到下一条指令,常用于为用户程序提供系统调用
  3. 故障:同步异常,潜在可恢复的错误,可能返回到当前指令,比如缺页异常
  4. 终止:同步异常,不可恢复的错误,通常是一些硬件错误,不会返回

进程


进程就是一个 执行中程序的实例 。系统中的每个程序都运行在某个进程的上下文中,上下文是由程序正确运行所需的状态组成的,上下文是内核重新启动的一个被抢占的进程所需的状态。

上下文切换的工作

  1. 保存当前进程的上下文
  2. 恢复某个先前被抢占的进程被保存的上下文
  3. 将控制传递给这个新恢复的进程

进程提供给应用程序的关键抽象:一个独立的逻辑控制流和一个私有的地址空间。

  • 一个逻辑流的执行在时间上与另一个流重叠,称为 并发流 ,这两个流被称为并发的运行。如果两个流并发的运行在不同的处理器核或计算机上,称为 并行流
  • 进程为每个程序提供自己的私有地址空间,和这个空间中某个地址相关联的内存字节是不能被其他进程读写的。

进程控制


使用getpid函数可以获取进程ID,每个进程都有一个唯一的正数进程ID(PID)。

#include <sys/type.h>  
#include <unistd.h>  
pid_t getpid();   // 返回调用者PID,Linux系统中pid_t被定义为int  
pid_t getppid();  // 返回父进程的PID  

终止进程一般有三种原因:
1. 收到默认行为是终止进程的信号
2. 从主程序返回
3. 调用exit函数

#include <stdlib.h>  
void exit(int status);  // 以status退出状态来终止进程  

父进程通过fork函数可以创建一个新的运行的子进程,该函数 被调用一次返回两次 ,在父进程中返回子进程PID,子进程中返回0。父子进程拥有相同但是独立的地址空间,每个进程有相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值以及相同的代码,且子进程继承了父进程所有的打开文件。
新创建的子进程几乎与父进程完全相同,最大的区别是它们有不同的PID。父进程和子进程是并发运行的独立进程。

#include <sys/type.h>  
#include <unistd.h>  
pid_t fork();  // 子进程返回0,父进程返回子进程PID,出错返回-1  

一个进程终止后,会保持在已终止状态,直到被它的父进程回收。父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,此时该进程就不存在了。一个终止但还未被回收的进程称为 僵死进程 ,僵死进程虽然没有运行,但是仍然消耗系统的内存资源。
如果一个父进程终止了,内核会安排init进程(PID为1,系统启动时由内核创建,是所有进程的祖先)成为它的孤儿进程的养父。进程通过使用waitpid函数等待它的子进程终止或停止。

#include <sys/types.h>  
#include <sys/wait.h>  
// 成功时返回子进程的PID且该进程被系统回收,options取WNOHANG时返回0,其他错误返回-1  
// 默认情况(options=0):waitpid挂起调用进程的执行,直到它的等待集合中的一个子进程终止  
// 等待集合中的一个进程如果在调用waitpid之前就已经终止,那么waitpid就立即返回  
// pid>0时等待集合就是进程ID等于pid的子进程,pid=-1时等待集合由父进程的所有子进程组成  
// options可设为常量WNOHANG,WUNTRACED和WCONTINUED的组合,可以修改waitpid的默认行为  
// statusp参数非空时,waitpid会在status中放上关于导致返回子进程的状态信息  
// 调用进程没有子进程时waitpid返回-1,且设置全局变量errno为ECHILD  
// 如果waitpid被一个信号中断时返回-1,且设置全局变量errno为EINTR  
// Unix函数的man页列出了使用该函数需要包含的头文件  
pid_t waitpid(pid_t pid, int *statusp, int options);  

// waitpid的简单版本,wait(&status)等价于waitpid(-1, &status, 0)  
pid_t wait(int *statusp);  

sleep函数将一个进程挂起一段指定的时间,如果请求的时间到了sleep函数返回0,否则返回还剩下的要休眠的秒数。sleep函数可能会被信号中断而过早地返回。
pause函数让调用进程休眠,直到该进程收到一个信号。

#include <unistd.h>  
unsigned int sleep(unsigned int secs);   // 挂起进程secs秒,返回还要休眠的秒数  
unsigned int pause();  // 让进程休眠,总是返回-1  

execve函数在当前进程的上下文中加载并运行一个新程序。
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。
execve调用一次且从不返回,只有当出现错误时(如找不到filename)才会返回-1。
argv变量指向一个以null结尾的指针数组,其中每个指针都是一个参数字符串,通常argv[0]是可执行目标文件名。
envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串形如name=value。
全局变量environ指向envp数组的第一个envp[0]。

#include <unistd.h>  
// 执行成功则不返回,错误则返回-1  
int execve(const char *filename, const char *argv[], const char *envp[]);  

main函数有三个参数

  1. argc:argv[]数组中非空指针的数量
  2. argv:指向argv[]数组中的第一个条目
  3. envp:指向envp[]数组中的第一个条目
int main(int argc, char **argv, char **envp);  

getenv函数可以有用来在环境变量数组中搜索字符串“name=value”,找到了则返回指向value的指针,否则返回NULL。
setenv函数可以用newvalue代替oldvalue,但只有overwrite非零时才会这样,name不存在时相当于直接设置name。
如果环境变量数组中存在“name=value”的字符串,那么unsetenv会删除它。

#include <stdlib.h>  
char *getenv(const char *name);  // 存在则返回指向name的指针,否则返回NULL  
int setenv(const char *name, const char *newvalue, int overwrite);  // 成功返回0, 错误返回-1  
void unsetenv(const char *name);  // 无返回  

程序是一堆代码和数据,可以作为目标文件存在于磁盘上,或者作为段存在于地址空间中。
进程是执行中程序的一个具体实例,程序总是运行在某个进程的上下文中。
fork函数在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。
execve函数在当前进程的上下文中加载并运行一个新的程序,会覆盖当前进程的地址空间,没有创建新进程,仍然有相同的PID,并继承了调用execve函数时已打开的所有文件描述符。

使用fork和execve构造一个简单的shell程序

#include "csapp.h"  
#define MAXARGS 128  

/* Function prototypes */  
void eval(char *cmdlines);  
int parseline(char *buf, char **argv);  
int builtin_command(char **argv);  

int main()  
{  
    char cmdline[MAXLINE];  /* Command line */  
    while(1)  
    {  
        /* Read */  
        printf("> ");  
        Fgets(cmdline, MAXLINE, stdin);  
        if(feof(stdin))  
            exit(0);  
        /* Evaluate */  
        eval(cmdline);  
    }  
}  

void eval(char *cmdline)  
{  
    char *argv[MAXARGS];  /* Argument list execve() */  
    char buf[MAXLINE];    /* Holds modified command line */  
    int bg;               /* Should the job run in bg or fg? */  
    pid_t pid;            /* Process id */  
    strcpy(buf, cmdline);  
    bg = parseline(buf, argv);  
    if(argv[0] == NULL)  
        return;   /* Ignore empty lines */  
    if(!builtin_command(argv))  
    {  
        if((pid = Fork()) == 0)  /* Child runs user job */  
        {  
            if(execve(argv[0], argv, environ) < 0)  
            {  
                printf("%s: Command not found.\n", argv[0]);  
                exit(0);  
            }  
        }  
        /* Parent waits for foreground job to terminate */  
        if(!bg)  
        {  
            int status;  
            if(waitpid(pid, &status, 0) < 0)  
                unix_error("waitfg: waitpid error");  
            else  
                printf("pid %d is terminated\n", pid);  
        }  
        else  
            printf("%d %s\n", pid, cmdline);  
    }  
    return;  
}  

/* If first arg is a builtin command, run it and return true */  
int builtin_command(char **argv)  
{  
    if(!strcmp(argv[0], "quit"))  /* quit command */  
        exit(0);  
    if(!strcmp(argv[0], "&")) /* Ignore singleton*/  
        return 1;  
    return 0;          /* Not a builtin command */  
}  

/* parseline - Parse the command line and build the argv array */  
int parseline(char *buf, char **argv)  
{  
    char *delim;   /* Points to first space delimiter */  
    int argc;      /* Number of args */  
    int bg;        /* Background job? */  
    buf[strlen(buf)-1] = ' ';  /* Replace trailing '\n' with space */  
    while(*buf && (*buf == ' '))  /* Ignore leading space */  
        ++buf;  
    /* Build the argv list */  
    argc = 0;  
    while((delim = strchr(buf, ' ')))  
    {  
        argv[argc++] = buf;  
        *delim = '\0';  
        buf = delim + 1;  
        while(*buf && (*buf == ' '))  /* Ignore space */  
            ++buf;  
    }  
    argv[argc] = NULL;  
    if(argc == 0)  /* Ignore blank line */  
        return 1;  
    /* Should the job run in the background? */  
    if((bg = (*argv[argc-1] == '&')) != 0)  
        argv[--argc] = NULL;  
    return bg;  
}  

信号


Linux信号是一种更高层的软件形式的异常,允许进程和内核中断其他进程。一个信号就是一条消息,通知进程系统中发生了一个某种类型的事件。每种信号类型都对应于某种系统事件,低层的硬件异常是由内核异常处理程序处理的。
信号提供了一种机制,通知用户进程发生了这些异常。比如一些常见的Linux信号有:

  • 进程试图除以0,内核发送SIGFPE信号(号码8)
  • 执行非法指令时,内核发送SIGILL信号(号码4)
  • 非法内存引用时,内核发送SIGSEGV信号(号码11)
  • 进程在前台运行时键入Ctrl+C,内核发送SIGINT信号(号码2)
  • 通过给进程发送SIGKILL信号(号码9)可以强制终止进程
  • 一个子进程终止或停止时,内核会发送SIGCHLD信号(号码17)给父进程

传送信号到目的进程

  • 发送信号:内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号有两种原因:1)内核检测到一个系统事件;2)一个进程调用kill函数。
  • 接收信号:目的进程被内核强迫以某种方式堆信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户函数捕获这个信号。

一个发出而没有被接收的信号叫做待处理信号,任何时刻, 一种类型至多有一个待处理信号 。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,只会被简单的丢弃。
一个进程可以选择性的阻塞接收某种信号,一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
一个待处理信号最多只能被接收一次,内核为每个进程在pending位向量中维护着待处理信号的集合,而在blocked位向量中维护着被阻塞的信号集合。

发送信号


每个进程属于一个进程组,进程组是由一个正整数进程组ID来标识的。getgrp函数返回当前进程的进程组ID。

#include <unistd.h>  
pid_t getgrp();  // 返回调用进程的进程组ID  

默认一个子进程和它的父进程同属于一个进程组。一个进程可以通过使用setpgid改变自己或其他进程的进程组,将进程pid的进程组改为pgid。

#include <unistd.h>  
// pid为0时,表示使用当前进程的pid;pgid为0时,就用pid指定的进程的PID作为进程组ID  
int setpgid(pid_t pid, pid_t pgid);  // 成功则返回0,错误返回-1  

/bin/kill程序可以向另外的进程发送任意信号。linux> /bin/kill -9 15213表示发送信号9(SIGKILL)给进程15213,一个负的PID会导致信号被发送到进程组PID中的每个进程。

Unix shell使用作业表示对一条命令行求值而创建的进程,任何时刻,至多只有一个前台作业和0或多个后台作业。
shell为每个作业创建一个独立的进程组,进程组ID通常取自作业中父进程中的一个。
键盘上输入Ctrl+C会导致内核发送SIGINT信号到前台进程组中的每个进程,结果是终止前台作业。
输入Ctrl+Z会发送一个SIGTSTP信号到前台进程组中的每个进程,结果是停止/挂起前台作业。

进程通过调用kill函数发送信号给其他进程(包括自己)。

#include <sys/types.h>  
#include <signal.h>  
// pid大于0时,发送信号号码sig给进程pid  
// pid等于0时,发送信号sig给调用进程所在进程组中的每个进程,包括调用进程自己  
// pid小于0时,发送信号sig给进程组|pid|中的每个进程  
// 成功时返回0,错误则返回-1  
int kill(pid_t pid, int sig);  

进程通过alarm函数向它自己发送SIGALRM信号。

#include <unistd.h>  
// 安排内核在secs秒后发送一个SIGALRM信号给调用进程,secs为0时不会调度安排新的闹钟  
// 返回前一次闹钟剩余的秒数,以前如果没有设定闹钟,则返回0  
unsigned int alarm(unsigned int secs);  

接收信号


当内核把进程p从内核模式切换到用户模式时,会检查检查p的未被阻塞的待处理信号集合。如果集合非空,内核会选择集合中的某个信号(通常是最小的k),并且强制p接收信号k,收到这个信号会触发进程采取某种行为。每个信号的默认行为是下面中的一种:

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

SIGKILL的默认行为是终止接收进程,SIGCHLD信号默认行为是忽略这个信号。进程可以通过signal函数修改和信号相关联的默认行为,唯一的例外是SIGSTOP和SIGKILL(这两个信号的默认行为不可修改)。
handler可以取以下三种:

  • 取SIG_IGN,那么忽略类型为signum的信号
  • 取SIG_DFL,类型为signum的信号行为恢复为默认行为
  • 使用用户定义的函数地址,即信号处理程序,只要进程接收到类型为signum的信号,就调用此程序,处理程序返回时,控制通常传递回控制流中进程被信号接收中断位置处的指令。信号处理程序可以被其他信号处理程序中断。
#include <signal.h>  
typedef void (*sighandler_t)(int);   // 函数指针  
// 成功时返回指向前次处理程序的指针,出错为SIG_ERR(不设置errno)  
sighandler_t signal(int signum, sighandler_t handler);  

阻塞和解除阻塞信号


隐式阻塞机制:内核默认阻塞任何当前处理程序正在处理信号类型的待处理信号。
显示阻塞机制:使用sigprocmask函数明确阻塞和解除阻塞选定的信号。
sigprocmask函数改变当前阻塞的信号集合,具体行为取决于how的值:

  • SIG_BLOCK:把set中的信号添加到blocked中
  • SIG_UNBLOCK:从blocked中删除set中的信号
  • SIG_SETMASK:blocked=set

oldset非空时,blocked位向量的就职会保存在oldset中。

#include <signal.h>  
// 成功返回0, 出错返回-1  
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);  
// 初始化set为空集合  
int sigemptyset(sigset_t *set);  
// 把所有信号都添加到set中  
int sigfillset(sigset_t *set);  
// 把signum添加到set  
int sigaddset(sigset_t *set, int signum);  
// 从set中删除signum  
int sigdelset(sigset_t *set, int signum);  
//signum是set的成员则返回1,不是返回0,出错返回-1  
int sigismember(const sigset_t *set, int signum);  
// 临时阻塞接收一个信号  
sigset_t mask, prev_mask;  
Sigemptyset(&mask);  
Sigaddset(&mask, SIGINT);  
// Block SIGINT and save previous blocked set  
Sigprocmask(SIG_BLOCK, &mask, &prev_mask);  
//... Code region that will not be interrupted by SIGINT  
// Restore previous blocked set  
Sigprocmask(SIG_SETMASK, &prev_mask, NULL);  

编写信号处理程序


安全的信号处理需要让程序能安全的并发运行。

保守编写信号处理程序的原则

  1. 处理程序尽可能简单。处理程序可能只是简单地设置全局标志并立即返回,相关信号处理由主程序执行。
  2. 处理程序中只调用异步信号安全的函数,它们要么可重入,要么不能被信号处理程序中断,能被处理程序安全调用。
  3. 保存和恢复errno,一些异步信号安全的函数会在出错返回时设置errno,在进入处理程序时可以把errno保存在一个局部变量中,处理程序返回前再恢复errno。
  4. 阻塞所有信号,保护对共享全局数据结构的访问。
  5. volatile声明全局变量,可以告诉编译器不缓存这个变量,强迫编译器每次在代码中使用该变量时都从内存读取。
  6. sig_atomic_t声明标志,处理程序可能会写全局标志来记录收到信号,主程序周期性读这个标志,相应信号,再清除该标志。C提供的类型sig_atomic_t,对它读写保证会是原子的(不可中断)。volatile sig_atomic_t flag;

未处理的信号是不排队的,因此不可以使用信号来对其他进程中发生的事件计数,因为pending位向量中每种类型的信号只对应一位,所以每种类型最多只有一个未处理的信号。正确处理信号需要牢记一个关键思想: 如果存在一个未处理的信号就表明至少有一个信号到达了

Unix信号处理的另一个缺陷是不同的系统有不同的信号处理语义,比如signal函数语义各有不同、系统调用可以被中断。解决这些问题,可以使用Posix标准定义的sigaction函数,允许用户再设置信号处理时明确指定信号处理语义。但是sigaction函数运用并不广泛,一个更简洁的方式是定义一个包装函数Signal,它调用sigaction

Posix : Portable Operating System Interface,可移植操作系统接口

编写并发程序时,还需要以某种方式 同步流 ,保证不存在竞争导致的错误。

使用sigsuspend函数可以用mask替换当前的阻塞集合,然后挂起该进程,直到收到一个信号,其行为要么是运行一个处理程序,要么是终止该进程。这个函数在显式等待某个信号处理程序运行时,比起循环等待不浪费资源,避免了引入pause()带来的竞争,且比slepp()更高效。

#include <signal.h>  
int sigsuspend(const sigset_t *mask);  
// 等价于下述代码的原子的版本(不可中断)  
// sigprocmask(SIG_SETMASK, &mask, &prev);  
// pause();  
// sigprocmask(SIG_SETMASK, &prev, NULL);  

操作进程的工具


ps:列出当前系统中的进程(包括僵死进程)(Process Status)
top:打印出关于当前进程资源使用的信息
/proc:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,比如cat /proc/loadavg输出Linux系统当前的平均负载

评论

还没有登陆?评论请先登陆注册

还没有评论,抢个沙发吧!

 联系方式 contact me

Github
Email
QQ
Weibo