LOADING

加载过慢请开启缓存 浏览器默认开启

csapp:ch8_ExceptionalControlFlow

2024/9/21

异常控制流(完结?)

首先我们要研究,什么是控制流?

从计算机上电开始,就有一个一个的指令进入cpu,并等待执行,这些个指令,就形成了队列。这个队列,就是控制流。

改变控制流,意思就是要改变这些指令的执行顺序,最常见的,jxxx跳转指令,call,ret调用返回等,当程序的状态改变时,我们可以使用这些指令来改变控制流,使程序作出恰当的反应。

什么是程序状态呢?这里我们需要抽象的来理解:比如说,当程序的某个变量值发生改变,然后通过分支语句,跳转到了不同的地方,控制流改变。所以变量值改变,就算做“程序状态改变”。

通过我们学的知识,我们可以写一个程序了。

但只有这些并不充分,虽然可以反应程序状态的改变,但不能反应系统状态的改变。

系统除了要完成所需的功能,还需要能够感知自己的状态,例如,从键盘中输入,或者磁盘读数据,他怎么知道数据什么时候就绪?

所以我们要使用另一种机制:外部——》计算机系统——》程序改变。

再举一些例子。

1.执行read函数读文件,之后程序进入阻塞(block),完事之后通知程序。

2.整数除0,为什么我没有写关于处理这个的代码,但是程序运行到这里,还是直接退出

3.有时候程序进入死循环,人为打断按下ctrl + c,为什么,我明明没有写这个功能的代码。

这就是异常控制流。因此,这里的异常,不是计算过程中出错,而是一种机制

异常控制流的实现在计算机系统的各个层面上都有体现

纯硬件实现的:异常(为了区分,又被称作处理器级异常)

更高层:进程上下文切换,信号,非本地跳转。

所以总结一下,改变控制流有两种方式:

1.程序状态决定的,使用jxx等跳转指令

2.程序之外,系统状态改变,进入新的控制流:异常控制流

操作系统

这里我们先几句话简要介绍一下操作系统(operating system,OS)。

它分为两个部分:kernel(内核),shell(外壳)

kernel是操作系统启动之后,常驻在内存里面的部分,持续为用户提供服务。

shell是封装内核的外壳,主要提供用户和操作系统之间的交互。

比如linux的命令行就是shell。

(处理器级)异常

异常就是控制流中的突变,用来响应某种状态的变化。

如图,当处理器状态发生某种重要的变化时,此时处理器中正在执行当前指令$I_{curr}$,状态变化(被称为事件)之后,跳转到某个地方进行异常处理,完成之后有三种可能:

  1. 返回到当前指令 $I_{curr}$
  2. 返回到下一条指令$I_{next}$
  3. 程序终止

异常处理

具体这个异常是如何处理的?

当处理器触发异常时,根据异常号查找异常表,调用对应的异常处理函数。

这个异常表是由专用的寄存器来存储的:异常表基地址 寄存器

当计算机上电时,操作系统引导,将这些异常处理函数地址在内存中申请一块空间,并按照对应号码填入,以构成异常表。将其地址存入专用寄存器中。

那么$异常号\times sizeof(指针) + 基地址$就可以得到异常对应的函数,并调用。

这个函数的调用和普通的函数调用有些不同,具体请看黑皮书503页。

异常的类别

异常从大类上说分为两种:异步,同步

异步:它不是程序引起的,而是由外部原因引起的,因此什么时候触发,什么时候到达,都是不确定的。

同步:由程序引发,与程序的执行流程同步,是执行当前指令的结果,可以确定什么时候触发和到达。

异步异常有中断

同步异常有陷阱,故障,终止

中断

中断是来自处理器外部的I/O设备触发并送到cpu的。

cpu芯片上面有一个引脚(pin),它连接着外设,包括打印机,键盘等。当打印机状态变化时,线路上的电信号改变(电压变化),传入cpu,触发中断,然后cpu执行指令返回打印机,查看什么情况。

这个引脚在cpu上数量有限,但是可以在主板上进行引脚扩展,以支持多个外设。

只要是和外设交互,都离不开中断。

此时cpu正在执行指令,执行完当前指令之后,接收到中断,处理完之后返回下一条指令,从外面来看,就好像没有发生中断一样。

注意计时器也算是外设,他每隔一段时间就会发送一次中断

陷阱和系统调用

陷阱(trap)

这是一种软中断,所谓软中断,就是软件实现的中断。它的流程和中断一样,都是返回下一条指令,只不过是软件。

它最重要的用途就是提供应用程序和操作系统之间的像函数一样的接口,叫系统调用,意思是应用程序调用系统函数。

向系统请求的服务包括:读文件,创建进程,加载,终止进程等。

比如,x86 linux中的系统调用指令为syscall n,n为服务的类别。

你可能会奇怪,为什么要这么麻烦,直接调用那个系统函数不行吗

确实不行,因为没有权限,而且那个函数的地址也是每次启动的时候都会改变,并且系统内核与用户程序隔离,也不能让用户程序得知系统内核在内存中的全部的信息,防止恶意破环。

而且这也可以屏蔽掉底层的硬件实现细节,比如说读磁盘的时候如何控制磁道?磁头?这是由硬件生产商提供的驱动程序完成的,写app的人不用考虑这些。

不过,虽然驱动程序是由硬件生产商提供的,但是驱动程序也是操作系统的一部分,没有操作系统,驱动程序不能单独运行。

其他介绍请看黑皮书505页写的陷阱和系统调用那里(第二段)。

这里再举一个使用了trap 的例子,就是gdb调试的时候,打断点,程序执行到断点停止,就用了trap。

当cpu执行到断点(即trap)时,会停止当前程序,保存状态,进入异常处理函数,通知调试器,从而实现此功能,之后会执行下一条指令,这也是我们需要的。

故障(fault)

不是一提到故障,肯定是计算机出错了。

非有意,但有可能恢复

重新执行引起故障的(“当前”)指令或者终止

如图所示,进入异常处理程序之后有两种可能

例如:缺页故障(可恢复)、保护故障(不可恢复)、浮 点异常

下面具体举几个例子。

缺页:在第九章才能详细讲,书505页倒数第三段

保护故障:指针使用不当,数组越界访问(内存访问失败),它会打印一条错误信息:segmentation fault(段错误)

注:如果一个数组定义int a[10],那么你访问a[10],可能不会出错,因为在栈帧里面有缓冲区,但是缓冲区也有大小限制。

浮点异常:如果当前机器不支持浮点,就会抛出此异常,然后进入异常处理函数,然后将浮点转化为定点,模拟浮点重新执行指令,以实现浮点运算。

终止(abort)

非有意,不可恢复

终止当前程序

例如:非法指令、奇偶校验错、机器检查异常(硬件错误)

既然是不可恢复的错误,为什么还有异常处理函数?

因为可以留下遗言,写日志,把所有信息写进去,方便后人查找问题。

奇偶校验错:读数据的时候,使用校验码检查,发现读错了,只能终止,有时候,带着错误跑下去不如直接终止。

进程(process)

定义:一个进程是一个正在运行的程序的实例 (动态的概念)

注意这是一个动态的概念,只有已经运行的程序,才能叫进程,仅仅是编译好的可执行文件不叫进程。

这里的实例就像class一样,只定义不算实例,只有定义了对象才能叫实例。

一个程序可以运行多次,就会有多个实例。

进程上下文切换这个名字我们可能已经看出了是大概什么意思:对于一个单核处理器,怎样实现一遍听歌,一边写代码?一个答案就是先处理音乐的指令,一会之后,切换,处理写代码的指令,cpu在这两个进程之间来回切换执行,我们就看上去像是同时干这两件事一样。

这样的进程调度和一个个排队执行进程有什么优势吗?明明总时间是相同的

问出这个问题,说明没有考虑交互,如果一个程序需要用户输入yes/no,然后就一直卡在这里了,万一人不在电脑前呢?难道要等输完了,然后其他程序才能执行?就像刚才的写代码,你不输入,程序就卡在这里,等你写完代码才能听歌。

因此,排队不能实时地感知,反应外部世界。

逻辑控制流:不管怎么上下文切换,我自己程序的指令和执行的顺序都不变,按我的程序的逻辑走。

虚拟地址空间:这个要举例子

以前写应用程序,都要用到读内存,写内存,但是万一我代码里面的这个内存访问到其他程序,篡改了其他程序,或者我自己被其他程序篡改了,怎么办?那我们约定,你这个app用0–100,我用100–200…那万一我那个程序非常小众,那些不用我程序的人凭什么要留出这些空间呢?为了解决这个问题,提出了虚拟地址空间的概念,保证我们的程序既不篡改其他程序内存,又能够读写自己的内存。

上下文(context):这是一个计算机术语,可以理解为需要程序保持的一些信息,比如说打断的时候,要把程序状态保存下来,比如所寄存器的值。书中的定义在508页。

并发:(更宏观)一段时间内,多个程序同时运行

并行:(更微观)某时刻,同时工作(后面章节)

如图所示,对于程序A,B,C自己,就像他们自己在运行一样。

私有地址空间

下一章详细讲

用户模式和内核模式

为了保证操作系统内核在每个进程中安全封闭,处理器提供了一种机制,限制这个程序可以访问的地址范围。

处理器用某个控制寄存器的模式位(mode bit)。

当模式位被设置时,进程就运行在了内核模式中(root),它可以执行指令集的所有指令,也可以访问内存的所有位置。

没有设置模式位,程序就是用户模式,不允许执行特权指令(privileged instruction),也不能直接读写内核区域的内存,任何以上操作都会引起保护故障异常,只能用系统调用。

运行程序的代码初始在用户模式,只有当进入异常处理时,才会进入内核模式。

上下文切换

内核判断什么时候打断当前程序,启动之前被打断的另一个程序。这种决策,叫做调度(scheduling),由kernel中的调度器处理,当它选择启动了一个进程,就说调度了这个进程。

如图,粉色部分展示了上下文切换。

1.把进程A寄存器值等上下文写入一片内存

2.其他进程挑选一个进程B执行

3.恢复进程B的上下文

4.中断返回到进程B

5.一段时间后再次中断

系统调用可能会引发上下文切换,如果因为等待而发生阻塞,比如磁盘读数据,这段时间就可以切换,当磁盘数据就绪时,发出中断信号,表示读完了

硬件定时器也可以触发中断,来引发上下文切换。如linux的定时器每5ms触发一次。而这5ms就叫做一个时间片

处理系统调用的错误

linux中,c语言的编程风格:系统调用的函数返回值是一个int,如果它的计算结果是一个更加复杂的类型,那么会将那个类型的指针传入函数,用于承接计算的结果。

struct s;
func(..., ..., &s);//正确

struct *s;
func(..., ..., s)//错误,s是一个野指针
    
struct *s;
s = malloc();
func(..., ..., s);
free(s); //正确,但基本不这么干

那么,这个返回的int,表示的就是这个调用的执行状态,成功还是失败。(有一些必然能够执行成功的调用,就没有返回值)

linux约定,-1表示的就是失败。

同时全局变量errno表示失败的原因

我们在进行系统调用时,一定要检查是否成功调用,而不能假设一定能成功。如果有错,那么我们直接打印错误信息,并结束程序。

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

其中,fprintf是写文件,第一个参数是文件名,在这里是stderr,它是一个文件描述符,表示标准错误输出(standard error output),而fprintf虽然可以向文件中写,但也可以向标准文件里面写。而这里stderr和控制台相连,因此,这个字符串会显示在终端。

strerror(errno)用于将error转化为一个描述错误的字符串。

为了省事,我们可以封装一下

voidunix_error(char*msg) {/* Unix-style error */
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(0);
 }

if((pid= fork()) < 0)
    unix_error("fork error");

但是这样也有点麻烦,我们可以再封装一下

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

现在我们只要调用Fork就行了,这种命名方式是stevens风格,函数原型相同,但是首字母大写,表示这个函数经过了封装。

以后我们写所有系统调用都要这么写。

进程控制

linux中有很多的关于进程控制的c函数,我们详细介绍。

获取进程ID

#include <sys/types.h>
#include <unistd.h>
 pid_t getpid(void);   //获取当前进程的ID
 pid_t getppid(void);   //获取父进程的ID

如图所示,在Linux中,所有进程都是由其他进程启动的。

这被称为父/子关系。

进程”init”是所有用户进程的祖先进程。它在系 统启动时由内核创建。

进程形成一个层次结构,称为“进程树”。

Windows没有进程层次的概念。

创建和终止进程

首先我们先来了解一下进程的状态,对于程序员来说,有三种状态:

1.运行 :进程正在执行,或者等待执行,并最终将由内核调度(即被选择执行)。

2.停止(阻塞,block):进程的执行被暂停,直到进一步通知(信号)才会被调度。

3.终止 :进程永久停止。

首先解释一下停止状态

假设有以下代码:

int main(){
    int a;
    cin >> a;  //运行到这里,此时程序等待用户的输入,处于阻塞状态,只有按下回车,才会继续运行。
    ...
}

遇到cin,操作系统会通过一个信号,让进程进入阻塞状态,此时不会被调度,只有接收到解除阻塞的信号(输入完成),程序才能被再次调度。关于信号,将在本章后面介绍。

这和程序进入死循环不同,block状态不会导致cpu占用率增加,这可以大大减少因为等待输入而浪费的cpu资源。

除了cin,读磁盘等也会引起block

但是对于操作系统,进程却有4种状态,如图所示。

终止

进程因以下三个原因之一而终止:

1.收到默认操作是终止的信号 :比如段错误信号

2.从main函数返回

3.调用exit (系统调用)

在写一些大型程序时,如果函数调用嵌套的过多,要是一个个返回太过于麻烦,所以直接exit,因为对于大型的程序,不太好回到main

void exit(int status)

此函数终止进程并返回状态status ,父进程可以收到status

惯例:正常返回状态是 0,错误时是非零。

另一种显式设置退出状态的方法是从main函数返回一个整数值。

exit 被一旦被调用,就会结束进程,函数不会返回(因为exit一定会成功,所以不用返回int)

创建

父进程通过调用fork 来创建一个新的运行中的子进程。

int fork(void)

​ 对于子进程,fork 返回0,对于父进程,返回子进程的PID。

​ 子进程几乎与父进程相同:

  • 子进程获得父进程虚拟地址空间的相同(但是独立的)副本,包括代码,数据,堆,栈等。
  • 子进程获得父进程打开的文件描述符的相同副本(子进程可以读写父进程打开的任何文件)。
  • 子进程有不同于父进程的PID,这是最大的区别。

fork 很有趣(而且经常令人困惑),因为它被调用一次但返回两次。

fork意思是叉子,这个名字非常的生动形象。

通过返回0还是非零来判断在子进程还是父进程。

举个例子:

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);
 }

运行这个文件

linux> ./fork
 parent: x=0
 child : x=2

下面进行一些解释:

  • 调用一次,返回两次:对于这个例子很好理解,但是如果某个程序调用了多次fork,那就要仔细思考了
  • 并发执行:父进程和子进程都是并发运行的,独立的。具体如何切换上下文,由kernel进行,所以终端上的输出谁先谁后都可能。
  • 相同但是独立的地址空间:当fork返回时,在父进程和子进程的虚拟地址空间相同,且独立,因此,对于这两个进程,x都为1,之后对x的修改都是独立的。
  • 共享文件:两个进程都输出到了屏幕上,因为父进程已经打开了stdout,并指向屏幕,子进程可以直接写文件。

注:不是复制完从头执行,而是从fork返回到地方开始执行,因为就连程序计数器也复制了。

因此,打印的顺序取决于系统的调度策略,这是不可预测的。正因如此,在开发过程中,因为并发过程产生的错误复现非常困难(那种特定的出错的交错方式很难复现),这才是重量级的困难。

进程图

进程图可以很清晰的表现出这些进程的关系,以及执行的指令顺序。

如图所示,这是刚才例子的进程图。

  • 每个顶点都表示一条语句执行。
  • 而顶点到顶点的有向边a->b表示a在b之前执行,表示相对顺序,至于是不是a执行完立马执行b,这个不一定。
  • 可以在边上标注一些重要信息,比如说变量x的值。

注:进程图没有什么硬性要求,只要自己看懂即可,考试不考画图,没有意义。

对于单处理器计算机,进程图所有顶点的拓扑排序表示程序中,语句的一个可行的全序排列。

只要满足有向边规定的一个执行顺序,就是一个拓扑排序,这个排序有很多种可能,因为上下文切换,所以每个拓扑排序都是可能的执行顺序,对于大型项目,这就没有什么办法可以穷举出所有排列。
如图所示,我们简化一下刚才的那个进程图。

我们写出一个序列,只要满足b在e和c前面,而e和c谁前谁后都是一种可能,这就是拓扑排序。
比如说 a b e f c d可能,a b c e f d也可能,但是 a b d c e f 绝对不可能。
下面我们应用进程图来分析一些例子。

 voidfork2()
 {
 printf("L0\n");
 fork();
 printf("L1\n");
 fork();
 printf("Bye\n");
 }

例1:连续两次fork会创建4个进程。

那么,一种可能的输出就是 L0 L1 Bye Bye L1 Bye Bye

例2:在父进程中嵌套执行fork

void fork4(){
    printf("L0\n");
    if (fork() != 0) {
        printf("L1\n");
        if (fork() != 0) {
            printf("L2\n");
        }
    }
    printf("Bye\n");
}

例3:在子进程中嵌套执行fork。

void fork5() {
    printf("L0\n");
    if (fork() == 0) {
        printf("L1\n");
        if (fork() == 0) {
            printf("L2\n");
        }
    }
    printf("Bye\n");
}

回收子进程

当一个进程终止时,它不会被立即清除,仍然在占用资源,只有被回收,才会彻底清除掉。

一个终止但没有回收的进程,叫做僵尸进程,即半死不活,但是占用资源。

回收有两种方式,显式和隐式

显式回收由父进程执行,通过wait或waitpid函数。

如果父进程不进行回收,则会进行隐式回收。

子进程已经终止,而当父进程也终止时,子进程会被init进程回收。

所以,我们要保证一些长期运行的装置的进程进行显式回收,比如服务器,shell等。

下面我们做实验,验证shell和init的进程回收。

代码如下,我们终止子进程,同时在父进程中写一个死循环,防止父进程终止。

void fork7() {
    if (fork() == 0) {
    /* Child */
        printf("Terminating Child, PID = %d\n", getpid());
        exit(0);
        }
    else {
        printf("Running Parent, PID = %d\n", getpid());
        while (1); /* Infinite loop */
        }
}         //forks.c

然后在控制台输入指令,运行程序

linux> ./forks7 &
[1] 6639
Running Parent, PID = 6639
Terminating Child, PID = 6640

后面的&表示在后台运行,因为在前台运行死循环的时候,不能输入指令。
接下来输入ps来查看进程

linux> ps
 PID TTY          TIME CMD
6585 ttyp9   00:00:00 tcsh
6639 ttyp9   00:00:03 forks
6640 ttyp9   00:00:00 forks <defunct>
6641 ttyp9   00:00:00 ps

default意思就是僵尸进程

然后我们手动杀死父进程

linux> kill 6639

此时父进程终止,子进程由init回收,而父进程是在shell中生成的,因此由shell回收。

如果我们在子进程中写死循环会怎样呢

void fork8(){
    if (fork() == 0) {
    /* Child */
    printf("Running Child, PID = %d\n",getpid());
    while (1) ; /* Infinite loop */
    } 
    else {
        printf("Terminating Parent, PID = %d\n",getpid());
        exit(0);
    }
}

运行,并查看进程。

linux> ./forks8
Terminating Parent, PID = 6675
Running Child, PID = 6676
linux> ps
PID TTY        TIME CMD  
6585 ttyp9   00:00:00 tcsh
6676 ttyp9   00:00:06 forks
6677 ttyp9   00:00:00 ps

6675已经没了,父进程被终止,由shell回收了,但子进程还在运行。

接下来杀死子进程,该进程由init回收。

注:如果父进程终止,而子进程还在运行,那么子进程就会成为孤儿,kernel会安排init进程称为他的养父。

接下来,我们讨论一下wait和waitpid。

wait

wait即等待,意思就是等到子进程终止,然后回收它,在此期间把当前进程挂起。

#include <sys/types.h>
#include <sys/wait.h>
int wait(int *child_status)

其中传入的int指针是用于获取子进程的退出状态,就是exit的参数。

这个函数本身返回一个int,用于判断这个函数是否执行成功(如果没有子进程,就返回-1,同时errno设置为ECHILD,如果wait被一个信号中断,返回-1,设置为EINTR),如果成功,返回子进程pid

如果都通过返回值传递,那如果返回-1,我们不知道是回收失败还是子进程因为异常而终止。

如果父进程有多个子进程,那么此函数是等待这些子进程其中之一终止,并返回,之后,就认为被回收了。

如果child_status是一个空指针,那么不会获得子进程的退出状态。

函数返回后,child_status指向的值就是退出状态。在wait.h中定义了解释这个状态的宏。

  • WIFEXITED(status): 如果子进程通过调用exit 或者一个返回(return)正常终 止,就返回真。
  • WEXITSTATUS(status): 返回一个正常终止的子进程的退出状态。只有在 WIFEXITED()返回为真时,才会定义这个状态。
  • WIFSIGNALED(status): 如果子进程是因为一个未被捕获的信号(本章后面讲)终止的,那么就返回真。
  • WTERMSIG(status): 返回导致子进程终止的信号的编号。只有在 WIFSIGNALED()返回为真时,才定义这个状态。
  • WIFSTOPPED(status): 如果引起返回的子进程当前是停止的,那么就返回真。注:停止和挂起差不多,不过停止是由信号引起的,具体在信号里面讲。
  • WSTOPSIG(status): 返回引起子进程停止的信号的编号。只有在 WIFSTOPPFD()返回为真时,才定义这个状态。
  • WIFCONTINUED(status): 如果子进程收到 SIGCONT信号重新启动,则返回真。

这些宏考试不考。

接下来,我们举个例子

voidfork9() {
    intchild_status;
    if(fork() == 0) {
        printf("HC: hello from child\n");
        exit(0);
    } 
    else{
        printf("HP: hellofrom parent\n");
        wait(&child_status);
        printf("CT: childhas terminated\n");
    }
    printf("Bye\n");
}

这个程序的进程图可以这么画

输出序列不写了。

使用wait函数,处理子进程的顺序是随机的。

void fork10() {
    pid_t pid[N];
    int i, child_status;
    for (i = 0; i < N; i++)
        if ((pid[i] = fork()) == 0) {
            exit(100+i); /* Child */
        }
    for (i = 0; i < N; i++) { /* Parent */
        pid_t wpid = wait(&child_status);
        if (WIFEXITED(child_status))
            printf("Child %d terminated with exit status %d\n",wpid, WEXITSTATUS(child_status));
        else
            printf("Child %d terminate abnormally\n", wpid);
    }
}

假设N为2,那么,按照什么顺序来回收这两个子进程,是随机的,即使在同一台机器上运行两次,这两次的顺序都可能不一样。

因此,我们写程序不能假定一定是某个顺序。

注:此代码只是一个示意,实际运行,wait不一定是N次,原因是信号,后面讲。

waitpid

waitpid与wait最大的不同就是waitpid会回收的是某个特定的子进程。

#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int &status, int options)

这个函数行为和wait相同,挂起调用进程,直到等待集合(wait set)中有一个子进程终止,就返回(如果调用时已经终止,就立即返回)此时,该子进程就被回收了。

第一个参数pid表示等待集合,如果大于0,就是一个单独的子进程。如果是-1,那就是所有的子进程。

第二个参数同wait

第三个参数为选项,默认为0。其他选项有WNOHANG、WUNTRACED和 WCONTINUED,具体看书517页,不用背。

旁注:相关的常量

WNOHANG和WUNTRACED这样的常量是由系统头文件定义的。比如这两个就是wait.h定义的

#define WNOHANG 1

#define WUNTRACED 2

为了使用他们,要包含头文件#include <sys/wait.h>

为了使用ECHILD和EINTR要包含errno.h。

为了方便,此书创建了一个头文件csapp.h,包含了所有用到的头文件。

我们将刚才的代码改为使用waitpid就可以按照顺序回收子进程了。

进程休眠

#include <unistd.h>
unsigned int sleep(unsigned int secs);

sleep可以主动将程序挂起一段时间。

secs的单位是秒

休眠指定时间后,返回0

休眠时被信号打断,返回剩下的秒数

注:这个函数不能用作高精度计时。

#include <unistd.h>
int pause(void);

pause将进程挂起直至收到信号

调用此函数之后,该进程休眠,收到信号后,返回-1,且errno设置为EINTR

加载并运行程序

execve

如何在一个进程中启动别的程序?

使用execve系统调用

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

第一个参数是可执行文件的路径(可以是可执行文件,也可以是#!interpreter开头的脚本文件,比如#!/bin/bash)

第二个参数是参数列表,其中argv[0] == filename

第三个参数是环境变量列表,格式为“name=value”

和fork调用一次,返回两次不同,这个函数调用一次,不返回。

调用之后,把当前进程删除干净,回收,然后启动其他程序(加载,运行,在第七章讲过),然后将控制传递给新的main。只保留PID,打开文件,和信号上下文

举个例子,如果在子进程中使用当前环境变量来执行“/bin/ls –lt /usr/include”

char* myargv[] = {"/bin/ls", "-lt", "/usr/include", NULL};  
char* environ[] = {"USER=droh", "HOME=/home/user", ..., NULL};  
if((pid= Fork()) == 0) {   /* Child runs program */
    if(execve(myargv[0], myargv, environ) < 0) {                                                        
        printf("%s: Command not found.\n", myargv[0]);                                                 
        exit(1);                                                                                     
    }                                                                                                
}

新程序启动时的栈

新的主函数main有以下原型:

int main(int argc, char *argv[],char *envp[]);

第一个参数表示argv[]中有多少项。

第二个参数argv指向argv[]数组的第一个条目

第三个参数envp指向envp[]中的第一个条目

如图所示,当main开始执行时,用户栈如图所示。从栈底(高地址)到栈顶(低地址)依次是:

  • 参数字符串和环境字符串
  • 环境变量指针数组(environ指向envp[0],是一个全局变量)
  • 参数列表指针数组(每个元素指向一个字符串)
  • 然后是启动函数的栈帧

操作环境变量

linux中一些函数可以操作环境变量数组

#include <stdlib.h>
 char *getenv(const char *name);

在环境数组中搜索“name=value”,找到了则返回指针,没找到则返回NULL

…(书523页)

利用 fork和 execve 运行程序

这是一个例子,实现了一个简单的shell,在524页自己看书。

这个实验也是本课程要求做的,我会在其他文章里面写他。

shell命令解释器

学习linux系统调用函数除了看ppt和书,还可以查看linux系统的手册。

linux>man [函数/命令]         //查看man手册
linux>man [数字] [函数/命令]   //查看第几个

尤其是之后要讲的信号,有非常多系统调用。

接下来介绍一下shell,以帮助做实验

linux下进程的层次结构

linux>pstree
linux>pstree -p

可以使用这两个指令来查看进程树,其中-p可以显示进程ID

shell程序

shell (外壳),为用户提供操作界面的软件,为用户运行应用程序的程序。人和kernel通过shell命令来沟通。

界面除了图形界面,还有字符界面(服务器为了节约资源一般没有图形界面),字符界面就是sshell

shell有多种,包括sh,csh/tcsh,bash,dsash,zsh等,其中bash用的最多。

下面是一个简单的shell实现。

int main(){
    /* command line */
    char cmdline[MAXLINE];     //缓冲区,用于存储命令输入字符串
    while (1) {                
        /* read */
        printf("> ");         //命令提示符,在linux中可以在.bashrc文件中定制(借助环境变量)
        Fgets(cmdline, MAXLINE, stdin);  //F大写,表明是封装后的函数。即fget [1]
        if (feof(stdin))          //[2]
            exit(0);
        /* evaluate */
        eval(cmdline);        //查看这个指令是否可以执行,如果可以,就执行。
    }   //[3]
} 

void eval(char *cmdline){
        char*argv[MAXARGS]; /* 参数列表 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); //复制到buf  
        bg = parseline(buf, argv);   //检查是前台命令还是后台命令,同时在argv中存入参数列表,后台命令适合运行慢,不在命令行输出的程序
        if (argv[0] == NULL)
            return;   /* 无视空的输入 */
        if (!builtin_command(argv)) {   //[4]
            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);
                }
            }
            /* 接下来父进程检查是否是后台命令 */
            if(!bg) {  //如果不是,就等到子进程结束返回
                int status;
                if(waitpid(pid, &status, 0) < 0)
                unix_error("waitfg: waitpid error");
                }
            else//如果是后台命令,打印
                printf("%d%s", pid, cmdline);
        }
    return;
}

1:标准c库文件操作fread, fopen…来得到一个FILE *类型的句柄。有3个默认打开的文件:stdin stdout stderr,分别是标准输入,标准输出,标准错误。其中stdin在usr(unix system resource,不是user!)目录中。下面解释Fget(a, b, c),其中a表示缓冲区地址,b表示缓冲区大小,c表示从哪里读到缓冲区。可以从控制台或外设中读取,c语言中的这个抽象是非常好的,从这些里面读取和从文件中读取是完全一样的。一直读到\n为止。这个fget比普通的get更加安全。

2:feof()表示查看文件是否结束。控制台中也可以有文件结束符,使用ctrl + d打出来。fgets读到\n或者eof都会返回,这里检查有没有结束,如果结束,直接退出。

3:这里虽然是一个无限循环,但这并不会占满cpu,在等待输入的时候,会进入block状态。

4:检查是否是内部命令(内部命令如ps)。外部命令指在bin中有文件对应的或者可执行文件。如果是外部命令,则创建子进程,加载,传参…

存在的问题

这个示例前台作业可以回收,但是后台作业怎么办?

如果使用wait,父进程(shell)就会被阻塞,那就不能称为后台了。

由于shell要一直运行,因此,后台命令现在是回收不了的,这就要用到信号了。

子进程结束只有OS知道,那OS就可以发送一个信号,打断当前父进程。

信号

信号是一个简短的消息,通知进程系统中发生了某种类型的事件

它类似于异常和中断 ,从内核发送(有时是由另一个进程的请求触发)到一个进程

信号类型由小整数ID(1-30)进行标识

信号中唯一的信息是它的ID 和它到达的事实(表示这件事发生了,但是不能用收到时间作为发生时间)

ID Name Default Action Corresponding Event
2 SIGIN Terminate(终止) User typed ctrl-c
9 SIGKILL Terminate Kill program (cannot override or ignore)
11 SIGSEGV Terminate Segmentation violation
14 SIGALRM Terminate Timer signal
17 SIGCHLD Ignore Child stopped or terminated

更多信号请看书527页表格

键盘按下ctrl + c,命令行收到SIGIN,于是给对应进程发送SIGIN。

SIGKILL不能随便使用,有权限。而且此信号不能被重写,在关机的时候使用。先是操作系统给所有进程发送正常终止信号,有些进程,比如记事本,会弹出窗口,确认是否保存。然后windows弹出是否强制关机,如果点“仍要关机”,就发送SIGKILL强行结束进程。

回收进程要用到17号

信号术语

发送信号

内核通过更新目标进程的上下文中的某些状态(后面说)向目标进程发送(传递)信号 (每个进程)

内核发送信号的原因有以下几种:

  • 内核检测到系统事件,如:除零错误(SIGFPE)或子进程的终止(SIGCHLD)
  • 另一个进程调用了kill 系统调用,显式请求内核向目标进程发送信号

接收信号

当内核强制目标进程对信号的传递做出某种反应时,目标进程会收到一个信号

一些可能的响应方式:

  • 忽略信号(什么都不做)
  • 终止进程(可以生成核心转储core dump)(指程序崩了,将log存入磁盘,用于调试)
  • 通过执行称为信号处理程序的用户级函数捕获信号
  • 类似于硬件异常处理程序在响应异步中断时被调用(处理完后,跳转到下一条指令)

和中断有两个区别:来源不同,异常处理函数不在kernel中

挂起(Pending)和阻塞(Blocked)信号

如果信号已发送但尚未接收,则该信号处于挂起状态,此信号又叫待处理信号(pending signal)

每种类型的信号最多只能有一个挂起的信号 ,因此,信号不是排队的。如果一个进程有一个类型为k 的挂起信号,那么发送到该进程的后续类型为k 的信号将被丢弃,挂起的信号最多只能接收一次(如果发送同一个信号到不同进程,还是可以接收的

进程可以阻塞接收某些信号

被阻塞的信号可以被传递,但是待处理信号没有被接收,直到信号解除阻塞时才会被接收

挂起和阻塞位

在每个进程的上下文中,都会维护标识信号挂起和阻塞的位向量

挂起位向量:表示挂起信号的集合

  • 当传递类型为k 的信号时,内核将挂起中的位k设置为1

阻塞位向量:表示阻塞信号的集合

  • 当接收到类型为k 的信号时,内核将挂起中的位k清零
  • 可以使用sigprocmask 函数设置和清除
  • 也称为信号屏蔽

发送一个信号之后,首先pending和mask按位取与,然后按位取反。得到的位就是要处理的。

发送信号

进程组

每个进程属于且仅属于一个进 程组

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

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

#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);
                                     //返回:若成功则为0,若错误则为-1

此函数用于把进程pid的进程组改为pgid

如果参数pid为0,就使用当前进程的PID。如果pgid为0,就用pid进程的PID作为进程组ID

比如,setpgid(0, 0)会将当前进程拉入一个进程组,该进程组ID就是当前进程的PID。

用/bin/kill 程序发送信号

可以向一个进程和一个进程组发 送某些信号

/bin/kill –9 24818 :向24818进程发送SIGKILL信号

/bin/kill –9 –24817 :向24817进程组中的每个进程发送SIGKILL信号(负的PID会解释为进程组ID)

不过在linux中输入命令,默认在bin下搜索,因此,直接kill也可以。

从键盘发送信号

按下Ctrl-C(Ctrl-Z)会导致内核 向前台进程组中的每个作业(作业(job)表示对一条命令行指令求值而创建的进程)发送 SIGINT(SIGTSTP)信号

SIGINT - 默认操作是终止每个进程

SIGTSTP- 默认操作是停止(暂停) 每个进程

下图是一个示例。

其中

fg表示将暂停的进程放在前台运行

bg表示……………………….后台…….

用 kill系统调用发送信号

#include <sys/types.h>
#include <signal.h>
 int kill(pid_t pid, int sig);
                                       //返回:若成功则为0,若错误则为一1。

如果pid大于零,那么kill函数发送信号号码sig给进程pid。

如果pid等于零,那么 kill发送信号sig给调用进程所在进程组中的每个进程,包括调用进程自己。

如果pid 小于零,kill发送信号sig给进程组|pid|(pid的绝对值)中的每个进程。

下面是一个示例,父进程给每个子进程发送一个终止信号。

void fork12(){
    pid_t pid[N];
    int i;
    int child_status;
    for (i = 0; i < N; i++)
        if ((pid[i] = fork()) == 0) {
            /* Child: Infinite Loop */
            while(1);
        }
    for (i = 0; i < N; i++) {
        printf("Killing process %d\n", pid[i]);
        kill(pid[i], SIGINT);
    }
    for (i = 0; i < N; i++) {
        pid_t wpid = wait(&child_status);
            if (WIFEXITED(child_status))
                printf("Child %d terminated with exit status %d\n",wpid, WEXITSTATUS(child_status));
            else
                printf("Child %d terminated abnormally\n", wpid);
        }
}

用 alarm函数发送信号(自学)

进程可以通过调用alarm函数向它自己发送SIGALRM信号。(设定闹钟)

#include <unistd.h>
 unsigned int alarm(unsigned int secs);
//返回:前一次闹钟剩余的秒数,若以前没有设定闹钟,则为0。

内核在 secs 秒后发送一个SIGALRM信号给调用进程。如果secs是0,不会设定闹钟。

接收信号

假设内核正在从异常处理函数返回,并准备将控制权交给进程p

此时,内核计算pnb= pending & ~blocked(这是进程p 的挂起非阻塞信号集合)

如果(pnb== 0),将控制传递给进程p的逻辑流中的下一条指令

否则,选择pnb中最小的非零位k,并强制进程p 接收信号k,接收信号触发p 中的某些操作,对于pnb中的所有非零位k重复上述过程

最后将控制传递给进程p 的逻辑流中的下一条指令

每种信号都有一个默认操作,为以下三种之一:

  • 进程终止
  • 进程暂停,直到收到SIGCONT信号重新启动
  • 进程忽略该信号

函数signal可以改变这些默认行为(即注册一个信号处理函数

#include <signal.h>
 typedef void (*sighandler_t)(int);
 sighandler_t signal(int signum, sighandler_t handler);
                                //返回:若成功则为指向前一次信号处理程序的指针,若出错则为SIG_ERR(不设置errno)
//有些大型程序需要使用不同的信号处理函数,返回老的程序的指针方便进行恢复注册上一个信号处理函数

这里sighandler是一个函数指针类型,signal可以改变信号signum的行为。

handler的值:

  • SIG_IGN:忽略类型为signum 的信号
  • SIG_DFL:收到类型为signum 的信号时恢复默认操作
  • 否则,handler 是用户级别信号处理函数的地址 ,这个函数称为信号处理程序。当进程接收到类型为signum 的信号时,调用该函数(称为捕获信号), 执行处理程序(称为处理信号) , 当处理程序执行其返回语句时,控制返回到被信号中断的进程控制流中的指令,handler函数指针的int参数表示信号的编号。

下面是一个示例

void sigint_handler(int sig) /* SIGINT handler */{
    printf("So you think you can stop the bomb with ctrl-c, do you?\n");
    sleep(2);
    printf("Well...");  //printf如果没有\n,不会立即输出,而是存到缓冲区中。(第10章解决)
    fflush(stdout);    //用于清空输出缓冲区,确保所有输出都输出到屏幕上,而不是等到缓冲区满了才输出
    sleep(1); 
    printf("OK. :-)\n");
    exit(0);
    }
int main(){
    /* Install the SIGINT handler */
    if(signal(SIGINT, sigint_handler) == SIG_ERR)
        unix_error("signal error");
    /* Wait for the receipt of a signal */
    pause();
    return 0;
}

信号处理程序也可以被其他信号处理打断。

注:

1.在并发流中,信号处理程序是一个独立的逻辑流(不是进程),和主程序“并发”运行。这里并发是指的通过中断机制实现的,而不是什么上下文切换。

比如,在上面的例子中,主程序到pause就完了,主程序的控制流只有这么多。而信号处理程序的控制流不是主程序的,因此说,独立的控制流。但进程还是主进程。

2.再次强调一下信号发送和接收的时机,是进程上下文切换到主进程的时候,检查一次。

信号的阻塞和解除阻塞

Linux 提供阻塞信号的隐式显式的机制:

隐式阻塞机制 :内核会阻塞当前正在处理的类型的任何挂起信号 ,假设正在处理信号s,同时又发送了信号s给当前进程,那么s会被阻塞,直到信号处理程序返回。例如,SIGINT处理程序无法被另一 个SIGINT中断

显式阻塞和解除阻塞机制 :sigprocmask函数可以明确的阻塞和解除阻塞某个信号。

#include <signal.h>
 int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);      //返回:如果成功则为0,若出错则为一1。

此函数的行为取决于how:

  • SIG_BLOCK: 把 set中的信号添加到 blocked中(blocked=blocked|set)
  • SIG_UNBLOCK: 从 blocked中删除 set中的信号(blocked=blocked &~ set)
  • SIG_SETMASK: block=set

如果oldset非空,那么blocked位向量之前的值保存在oldset中。

这里的sigset_t就是那些位向量构成的int型信号集合。

对于set,我们可以先声明一个此类型的变量,然后使用如下函数来操作它

sigset_t mask, prev_mask;
int sigemptyset(sigset_t *set);   //初始化set为空
int sigfillset(sigset_t *set);    //把所有信号添加到set
int sigaddset(sigset_t *set, int signum); //把signum添加到set
int sigdelset(sigset_t *set, int signum); //把signum从set中删去
//返回:如果成功则为0,若出错则为一1
int sigismember(const sigset_t *set, int signum); //如果signum是set的一个元素,就返回1,否则为0,出错为-1

于是我们可以临时阻塞一个信号了

 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, unblocking SIGINT */
 Sigprocmask(SIG_SETMASK, &prev_mask, NULL);  //注:大写的s表示封装了。
//这里的恢复指的是把原先的mask赋回来,而不是剔除掉SIGINT,万一在阻塞SIGINT之前,SIGINT已经阻塞了,那剔除了会发生问题。

编写信号处理程序

信号处理程序的可靠实现是一件很棘手的事情 ,因为它们与主程序并发运行并共享相同的全局数据结构,共享的数据结构可能会被损坏。

比如说,errno全局变量。信号处理程序进行系统调用,失败,主程序以为是它失败了。

后续课程中会讨论如何解决并发问题(12章) ,以下给出一些建议,以实现更可靠安全的信号处理

安全信号处理的原则:

1.保持处理程序尽可能简单,例如,设置一个全局标志并返回,所有与接收信号相关的处理都由主函数执行(周期性检查,复位这个标志即可。)

2.在处理程序中只调用异步信号安全的函数(简称安全的函数),printf、sprintf、malloc和exit是不安全的,详细请看书534页。

3.在进入和退出时保存和恢复errno,以防其他处理程序覆盖errno的值,只有在处理程序需要返回时,才这样做。如果要调用exit,那就不用了。

4.通过临时阻塞所有信号来保护对共享数据结构的访问(读或写),以防止可能的损坏。

​ 因为读写一个数据结构需要一系列指令完成。万一在其中一个指令,被打断了,而且,这个打断主程序的处理程序也要访问那个数据结构,那就会发生不可预知的问题了。

5.将全局变量声明为volatile,变量放在内存里,以防止编译器将它们存储在寄存器中。

​ 假设一个处理程序和一个main函数,它们共享一个全局变量g,信号处理程序更新g,main周期性地读g。对于一个优化编译器而言,main中g的值看上去从来没有变化过(因为信号处理程序没有在main中显式调用,信号触发是动态的,而编译器是静态的,预测不了。),因此使用缓存在寄存器中g的副本来每次引用。如果这样,min函数可能永远都无法看到信号处理程序更新过的值。而volatile类型限定符可以告诉编译器,不要缓存。(volatile int g),当然,一般也要在访问的时候临时阻塞。

6.将全局标志声明为volatile sig_atomic_t

​ 在一些信号处理程序中,会写一个全局标志来记录收到信号。主程序周期读这个标志,相应并清除。于是c提供了sig_atomic_i,这是一种整型数据类型,对这个数据类型的读写只需要一条指令就可以完成,因此不用临时阻塞。当然,一定要是仅被读取或写入的变量(例如,flag = 1, 而不是flag++)

注意,这里的这些建议都是非常保守的。如果你可以断言,你的信号处理程序没有读取errno,就不用管它,如果在进行信号处理的时候,不会被打断,就可以放心用printf等等等,不过,在编写大型程序的时候,没人敢这么断言,而且他们也很难证明。

正确的信号处理

下面的代码实现了shell中,回收后台子进程的功能,通过信号。

int ccount = 0;
void child_handler(int sig) {
    int olderrno = errno;
    pid_t pid;
    if ((pid = wait(NULL)) < 0)
        Sio_error("wait error");
    ccount--;
    Sio_puts("Handler reaped child ");
    Sio_putl((long)pid);
    Sio_puts(" \n");
    sleep(1);
    errno = olderrno;
}

其中,在主程序中,ccount表示有多少个子进程。

每接收到一个SIGCHILD信号,就回收一个子进程。

但这是不对的

如果在执行子进程回收程序的时候,又收到一个SIGCHILD,那么他会被阻塞,那如果再收到一个呢?这就直接丢弃了。因为每种类型的信号,最多一个被挂起。

我们要做的,就是每接收到一次SIGCHILD,就尽可能多的回收子进程,使用循环

void child_handler2(int sig){
 int olderrno= errno;
 pid_t pid;
 while((pid= wait(NULL)) > 0) { //NULl表示,我们不关心子进程的退出状态,而是仅仅销毁它
     ccount--;
     Sio_puts("Handler reaped child ");
     Sio_putl((long)pid);
     Sio_puts(" \n");
 }
 if(errno != ECHILD)
     Sio_error("wait error");
 errno= olderrno;
}

但这又有个问题,只要有一个子进程终止,父进程就强行回收所有子进程?这不太好,应该是回收所有已经终止的子进程。

所以我们可以使用waitpid中的选项WNOHANG,当没有子进程终止时,会返回0。

可移植的信号处理

不同版本的Unix可能具有不 同的信号处理语义

  • 一些旧系统在捕获信号后 会将操作还原为默认值
  • 一些被中断的系统调用可 能会返回errno== EINTR
  • 一些系统不会阻塞正在处 理的信号类型的信号

解决方案:使用sigaction

不过这个函数的参数比较复杂,于是有另一个包装函数Signal,调用方式和signal相同。

  • 只有这个处理程序当前正在处理的那种类型的信号被阻塞。
  • 和所有信号实现一样,信号不会排队等待。
  • 只要可能,被中断的系统调用会自动重启。
  • 一旦设置了信号处理程序,它就会一直保持,直到 Signal带着 handler参数为 SIG_IGN 或者SIG_DFL被调用。

下面是这个函数的定义

handler_t*Signal(int signum, handler_t *handler){
    struct sigaction action, old_action;
    action.sa_handler = handler;
    sigemptyset(&action.sa_mask); /* Block sigs of type being handled */
    action.sa_flags = SA_RESTART; /* Restart syscalls if possible */
    if (sigaction(signum, &action, &old_action) < 0)
        unix_error("Signal error");
    return (old_action.sa_handler);
}

同步流以避免并发错误

如何编写读写相同存储位置的并发流程序?

我们在12章详细讲。

这里举一个例子。

int main(int argc, char**argv){
 int pid;
 sigset_t mask_all, prev_all;
    
 Sigfillset(&mask_all);
 Signal(SIGCHLD, handler);
 initjobs(); /* Initializethe joblist*/
    
 while(1) {
     if((pid= Fork()) == 0) { /* Child */
         Execve("/bin/date", argv, NULL);
     }
     
     Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); /* Parent */
     addjob(pid);  /* Add the child to the job list */
     Sigprocmask(SIG_SETMASK, &prev_all, NULL);//在创建job时阻塞
 }
 exit(0);
}

这是一个shell实现,每当有进程创建时,都在表上addjob,每当有进程终止,都会删除这个job

void handler(int sig) {  //信号处理程序
    int olderrno = errno;
    sigset_t mask_all, prev_all;
    pid_t pid;
    
    Sigfillset(&mask_all);
    while((pid= waitpid(-1, NULL, 0)) > 0) { /* Reap child */
         Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
         deletejob(pid);                     /* Delete the child from the job list */
         Sigprocmask(SIG_SETMASK, &prev_all, NULL);
     }
    if(errno!= ECHILD)
         Sio_error("waitpid error");
    errno= olderrno;
}

但是可能发生:

  1. 父进程执行fork函数,内核调度新创建的子进程运行,而不是父进程。
  2. 在父进程能够再次运行之前,子进程就终止,并且变成一个僵死进程,使得内核 传递一个SIGCHLD信号给父进程。
  3. 后来,当父进程再次变成可运行但又在它执行之前,内核注意到有未处理的 SIGCHLD信号,并通过在父进程中运行处理程序接收这个信号。
  4. 信号处理程序回收终止的子进程,并调用deletejob, 这个函数什么也不做,因 为父进程还没有把该子进程添加到列表中。
  5. 在处理程序运行完毕后,内核运行父进程,父进程从fork返回,通过调用add job 错误地把(不存在的)子进程添加到作业列表中。

现在,这个条目永远也删除不了了。

这要怎么办?

这个案例叫做竞争(race),addjob和deletejob中存在竞争。如果addjob赢了,啥事没有。但如果没赢,就完了。

于是我们在主进程调用fork之前,直接将SIGCHILD阻塞掉,使得无论怎样,addjob都能赢。于是在addjob之后,解除阻塞。

注意,此时创建的子进程,由于和父进程完全一样,继承了block的信号,因此,我们在execve之前,要解除子进程中block的信号。

int main(intargc, char**argv){
    int pid;
    sigset_t mask_all, mask_one, prev_one;
    Sigfillset(&mask_all);
    Sigemptyset(&mask_one);
    Sigaddset(&mask_one, SIGCHLD);
    Signal(SIGCHLD, handler);
    initjobs(); /* Initialize the joblist*/
    while(1) {
        Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* Block SIGCHLD */
        if ((pid = Fork()) == 0) { /* Child process */
            Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
            Execve("/bin/date", argv, NULL);
        }
        Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* Parent process */
        addjob(pid);  /* Add the child to the job list */
        Sigprocmask(SIG_SETMASK, &prev_one, NULL);  /* Unblock SIGCHLD */
    }
    exit(0);
}

显式等待信号

有时,如果shell创建了一个前台程序,那么在接收下一条指令之前,必须要等待子程序回收。

下面是一个基本思路:

首先设置信号处理程序,然后在父进程阻塞SIGCHILD,然后启动子程序,之后父进程进入无限循环,等待全局变量pid被修改,来告知子进程回收了,之后在做一些其他事情。

volatilesig_atomic_t pid;
voidsigchld_handler(int s){
    int olderrno= errno;
    /* Main is waiting for nonzero pid */
    pid = Waitpid(-1, NULL, 0); 
    errno= olderrno;
}

void sigint_handler(int s){
}

int main(intargc, char**argv) {
    sigset_tmask, prev;
    Signal(SIGCHLD, sigchld_handler);
    Signal(SIGINT, sigint_handler);
    Sigemptyset(&mask);
    Sigaddset(&mask, SIGCHLD);
    while (1) {
        /* Block SIGCHLD */
        Sigprocmask(SIG_BLOCK, &mask, &prev);
        if(Fork() == 0) /* Child */
            exit(0);
        /* Parent */
        pid = 0;
        /* Unblock SIGCHLD */
        Sigprocmask(SIG_SETMASK, &prev, NULL); 
        /* Wait for SIGCHLD to be received (wasteful!) */
        while(!pid);
        /* Do some work after receiving SIGCHLD */
        printf(".");
    }
    exit(0);
}

这个逻辑是对的,但是这个无限循环的等待有点浪费资源。

最适合的解决方法是用sigsuspend,其他不适合的方法,请看书545页。

 #include <signal.h>
 int sigsuspend(const sigset_t *mask);   //返回-1

等效于不可中断的:

sigprocmask(SIG_SETMASK, &mask, &prev);
pause();
sigprocmask(SIG_SETMASK, &prev, NULL);

收到信号之后,先运行信号处理,再返回。

修改后:

int main(intargc, char**argv) {
    sigset_tmask, prev;
    Signal(SIGCHLD, sigchld_handler);
    Signal(SIGINT, sigint_handler);
    Sigemptyset(&mask);
    Sigaddset(&mask, SIGCHLD);
    while (1) {
        /* Block SIGCHLD */
        Sigprocmask(SIG_BLOCK, &mask, &prev);
        if(Fork() == 0) /* Child */
            exit(0);
        pid = 0;
        /* Wait for SIGCHLD to be received (wasteful!) */
        while(!pid);
             Sigsuspend(&prev);
        /* Do some work after receiving SIGCHLD */
        printf(".");
    }
    exit(0);
}

非本地跳转(自学)

这是c语言提供的用户级异常控制流形式,它可以将控制流从一个正在执行的函数直接跳转到另一个地方,而不进行任何其他措施。

强大但危险的用户级机制,用于将控制权传递到任意位置

  • 打破了“过程调用/返回规则”的受控方式
  • 用于错误恢复和信号处理

非本地跳转是通过setjmp和longjmp函数来提供的

#include <setjmp.h>
 int setjmp(jmp_buf env);
 int sigsetjmp(sigjmp_buf env, int savesigs);
//返回:setjmp返回0,longjmp返回非零。

setjmp 函数在 env缓冲区中保存当前调用环境,以供后面的 longjmp使用,并返回 0。调用环境包括程序计数器、栈指针和通用目的寄存器。出于某种超出本书描述范围的原因,setjmp返回的值不能被赋值给变量。int i = setjmp(buf) 这是错误的!!!

 #include <setjmp.h>
 void longjmp(jmp_buf env, int retval);
 void siglongjmp(sigjmp_buf env, int retval);

longjmp 函数从 env缓冲区中恢复调用环境,然后直接回到最近一次初始化env的setjmp处,就相当于setjmp返回了两次。并带有非零的返回值retval。

setjmp函数只被调 用一次,但返回多次:一次是当第一次调用setjmp(),调用环境保存在缓冲区buf时, 一次是每个相应的longjmp调用。另一方面,longjmp函数被调用一次,但从不返回。

非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是 由检测到某个错误情况引起的。于是不需要解开调用栈。

jmp_buf buf;
int error1 = 0;
int error2 = 1;
void foo(void), bar(void);

/* Deeply nested function foo */
void foo(void){
    if(error1)
    longjmp(buf, 1);
    bar();
}

void bar(void){
    if(error2)
    longjmp(buf, 2);
}

int main(){
    switch(setjmp(buf)) {
    case 0:
        foo();
        break;
    case 1:
        printf("Detected an error1 condition in foo\n");
        break;
    case 2:
        printf("Detected an error2 condition in foo\n");
        break;
    default:
        printf("Unknown error condition in foo\n");
    }
exit(0);
}

这是一个示例,使用jmp

注:longjmp会跳过所有中间过程。因此,如果中间函数调用中分配了某些数据结构,本来预期在函数结尾处释放它们,那么这些释放代码会被跳 过,因而会产生内存泄漏。

非本地跳转的限制:只能跳转到已经调用,没有返回的函数环境中。

非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。

sigsetjmp 和 siglongjmp函数是 setjmp和 longjmp的可以被信号处理程序使用的版本。

下面是一个使用信号的非本地跳转。

当按下ctrl+c时,会重启程序。

#include "csapp.h"
sigjmp_buf buf;
void handler(int sig){
    siglongjmp(buf, 1);
}
int main(){
    if (!sigsetjmp(buf, 1)) {
        Signal(SIGINT, handler);
        Sio_puts("starting\n");
    }
    else
        Sio_puts("restarting\n");
    while(1) {
        Sleep(1);
        Sio_puts("processing...\n");
    }
    exit(0); /* Control never reaches here */
}

为了避免竞争,必须在调用了sigsetjmp之 后再设置处理程序。否则,就会可能在初始调用sigsetjmp之前 运行处理程序。

sigsetjmp和siglongjmp函数不属于异步信号安全的函数。原因是一般来说siglongjmp可以跳到任意代码,所以我 们必须小心,在siglongjmp可达的代码中调用安全的函数。在本例中,我们调用安全的 sio_puts 和 sleep函数。不安全的 exit函数是不可达的。

旁注:C++和Java中的软件异常

C++和Java提供的异常机制是较高层次的,是C语言的 setjmp和longjmp函数的更加结构化的版本。你可以把try语句中的catch子句看做类似于setjmp函数。相 似地,throw语句就类似于 longjmp函数。

linux中操作进程的工具(自学)

  • STRACE: 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。用-static编译你的程序,能得到一个更干净的、不带有大量与共享库相关的输出的轨迹。
  • PS: 列出当前系统中的进程(包括僵死进程)。
  • TOP: 打印出关于当前进程资源使用的信息。
  • PMAP: 显示进程的内存映射。
  • /proc: —个虚拟文件系统,以 ASCII文本格式输出大量内核数据结构的内容,用户 程序可以读取这些内容。比如,输入“cat/proc/loadavg”可以看到你的 Linux系统上 当前的平均负载。

小结自己看书

完结