这个实验通过实现一个支持作业控制的Unix Shell
,让我们对进程控制和信号控制更加熟悉。课程Lab
已经帮助我们搭建起了Shell
的整体框架,并实现了与本次实验不太相关的代码,核心部分需要我们自己完成。
整体框架
Shell
从标准输入(stdin
)读取用户输入的命令,然后解析命令,Shell
支持两种类型的命令:如果用户输入的是的内置命令(如quit
、jobs
等),那么直接执行该命令;如果用户输入的是某个可执行文件的路径,那么通过fork
一个子进程,在子进程中加载并执行命令。Shell
把每次用户输入的命令抽象为一个job
,一个job
可以包含多个进程(例如管道)。每个job
有两种运行方式,如果用户输入的命令以’&
‘结尾,那么job
将会在后台(background
)运行,否则,job
运行在前台(foreground
)。在任意时刻,只允许存在0
或1
个前台job
,但是可以有0
或多个后台job
运行。最后,为了支持用户能够向Shell
发送信号,我们还需要实现3
个信号处理程序,分别处理信号SIGCHLD
、SIGINT
和SIGTSTP
。
需要注意的地方
默认的,一个子进程和它的父进程同属于一个进程组,而
Unix
系统提供的大量向进程发送信号的机制,都是基于进程组这个概念的。当我们输入Ctrl + C
,内核会发送一个SIGINT
信号到前台进程组的每个进程,类似的,输入Ctrl + Z
会导致内核发送一个SIGTSTP
信号给前台进程组中的每个进程。这儿的“前台进程组”指的是Shell
进程所属的进程组。实验中,我们并不期望信号直接作用于Shell
进程本身(否则Shell
收到SIGINT
信号就终止了),而是需要让Shell
将信号转发给Shell
前台作业中的子进程及其所属进程组中的所有进程。所以,我们不能让子进程和Shell
进程同属一个进程组。具体做法是通过使用setpgid
函数来改变子进程的进程组,当调用setpgid(0, 0)
时,内核会创建一个新的进程组,其进程组ID
是调用者进程的PID
,并且会把调用者进程加入到这个进程组中。当
Shell
收到信号时,具体的工作需要信号处理函数来完成。例如收到SIGINT
信号,那么信号处理函数会把该信号发往前台job
中的进程及其所属进程组中的所有进程。实验中,我们是通过kill(pid_t pid, int sig)
来发送信号,注意到我们并不仅仅是向PID = pid
的进程发送信号,kill
函数帮助我们实现了这一点:如果pid
小于0
,kill
发送信号sig
给进程组|pid|
(pid
的绝对值)中的每个进程。我们可以意识到,上一点需要注意的地方正是为这一点做铺垫的。父进程(
Shell
)fork
了一个子进程后,父进程需要将这个进程作为一个job
添加到job
队列中去(addjob
),当子进程终止时,内核会发送一个SIGCHLD
信号给父进程,然后在相应的信号处理程序中,把终止的子进程对应的job
从job
队列中删除(deletejob
)。考虑一种情况:当父进程fork
了一个子进程之后,子进程先于父进程获得调度,并且在父进程执行addjob
前,子进程就已经终止了,并发送了SIGCHLD
信号给父进程。此时,在信号处理程序中deletejob
不会做任何操作,因为此时父进程还没有把job
加入到job
队列中。出现这个问题的根本原因是在addjob
之前调用了deletejob
。解决这个问题的方法是:在父进程fork
子进程之前,将SIGCHLD
信号阻塞,当完成addjob
之后,才解除对SIGCHLD
信号的阻塞,这样就能保证在子进程被添加到job
队列之后再回收该子进程。注意,子进程继承了它们父进程的被阻塞信号集合,所以我们必须在调用execve
之前,解除子进程中阻塞的SIGCHLD
信号。
代码
Shell Lab
的代码在这里。