一、进程组/作业/会话
0. 终端
在UNIX系统中,用户通过终端登录系统后得到一个Shell进程,这个终端成为Shell进程的控制终端 (Controlling Terminal),控制终端是保存在PCB中的信息,而我们知道fork会复制PCB中的信息,因此由Shell进程启动的其它进程的控制终端也是这个终端。
默认情况下(没有重定向),每个进程的标准输入、标准输出和标准错误输出都指向控制终端,进程从标准输入读也就是读用户的键盘输入,进程往标准输出或标准错误输出写也就是输出到显示器上。此外在控制终端输入一些特殊的控制键可以给前台进程发信号,例如Ctrl+C表示SIGINT。
每个进程都可以通过一个特殊的设备文件/dev/tty
访问它的控制终端。事实上每个终端设备都对应一个不同的设备文件,/dev/tty
提供了一个通用的接口,一个进程要访问它的控制终端既可以通过/dev/tty
也可以通过该终端设备所对应的设备文件来访问。
ttyname()
函数可以由文件描述符查出对应的文件名,该文件描述符必须指向一个终端设备而不能是任意文件。
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("fd : 0 %s\n", ttyname(0));
printf("fd : 1 %s\n", ttyname(1));
printf("fd : 2 %s\n", ttyname(2));
return 0;
}
[tian@localhost test]$ ./a.out
fd : 0 /dev/pts/2
fd : 1 /dev/pts/2
fd : 2 /dev/pts/2
1. 进程组
进程组是一个或多个进程的集合。
每个进程除了有一个进程ID之外,还属于一个进程组。
这些进程通常与同一作业相关联,可以接受来自同一终端的各种信号。
每个进程组有唯一进程组ID,每个进程组可以有一个组长进程,其进程ID和进程组ID一致。
组长进程可以创建一个进程组,创建该组中进程,然后终止。
只要某个进程组中有一个进程存在,该进程组就存在,与组长进程是否终止无关。
从进程组的创建到最后一个进程离开的时间去称为进程组的生命周期
ps选项:
a: 不仅列当前⽤用户的进程,也列出所有其他⽤用户的进程
x: 表⽰示不仅列有控制终端的进程,也列出所有⽆无控制终端的进程
j: 表⽰示列出与作业控制相关的信息
2. 作业
Shell分前后台来控制的不是进程而是作业(Job)或者进程组(Process Group)。
一个前台作业可以由多个进程组成,一个后台也可以由多个进程组成。
Shell可以运行一个前台作业和任意多个后台作业,这称为作业控制。
作业与进程组的区别: 如果作业中的某个进程又创建了子进程,则子进程不属于作业,但属于进程组。一旦作业运行结束,Shell就把自己提到前台(子进程还在,可是子进程不属于作业),如果原来的前台进程还
存在(如果这个子进程还没终止),它自动变为后台进程组。我们在重新理解一下,在前台新起作业,shell是无法运行,因为他被提到了后台。但是如果前台进程退出,shell就又被提到了前台,所以可以继续接受用户输入。
栗子:
#include <stdio.h>
#include <unistd.h>
int main(){
pid_t id = fork();
if(id < 0){
perror("fork()");
return 1;
} else if(id == 0){ // 子进程
while(1){
printf("child(%d)# I am running!\n", getpid());
sleep(1);
}
} else{ // 父进程
int i = 5;
while(i){
printf("parent(%d)# I am going to dead...%d\n", getpid(), i--);
sleep(1);
}
}
return 0;
}
刚开始父进程是一个作业,被提到前台,shell就只能放到后台,所以此时shell无法接受命令。5s后,父进程死亡,该作业结束,此时的子进程还在运行,但它不属于作业。当父进程死亡时,shell又被提到前台,这时子进程就会被放到后台运行,所以此时无法用 Ctrl+C 终止这个子进程(使用 kill -9 杀死)。
3. 会话
会话(Session)是一个或多个进程组的集合。
一个会话可以有一个控制终端。这通常是登陆到其上的终端设备(在终端登陆情况下)或伪终端设备(在网络登陆情况下)。
建立与控制终端连接的会话首进程被称为控制进程。
一个会话中的几个进程组可被分为一个前台进程组以及一个或多个后台进程组。
所以一个会话中,应该包括控制进程(会话首进程),一个前台进程组和任意后台进程组。
// 创建两个后台作业
[tian@localhost ~]$ sleep 1000 | sleep 2000 | sleep 3000 &
[1] 3318
[tian@localhost ~]$ sleep 4000 | sleep 5000 &
[2] 3320
// 查看后台作业
[tian@localhost ~]$ jobs
[1]- Running sleep 1000 | sleep 2000 | sleep 3000 &
[2]+ Running sleep 4000 | sleep 5000 &
// 把后台作业提到前台
[tian@localhost ~]$ fg 1
sleep 1000 | sleep 2000 | sleep 3000
// 使用 Ctrl+Z 把前台作业提到后台
^Z
[1]+ Stopped sleep 1000 | sleep 2000 | sleep 3000
[tian@localhost ~]$ jobs
[1]+ Stopped sleep 1000 | sleep 2000 | sleep 3000
[2]- Running sleep 4000 | sleep 5000 &
// 让后台作业执行
[tian@localhost ~]$ bg 1
[1]+ sleep 1000 | sleep 2000 | sleep 3000 &
[tian@localhost ~]$ jobs
[1]- Running sleep 1000 | sleep 2000 | sleep 3000 &
[2]+ Running sleep 4000 | sleep 5000 &
[tian@localhost ~]$ fg 1
sleep 1000 | sleep 2000 | sleep 3000
// Ctrl+C 杀掉的不是单个进程,而是整个作业
^C
[tian@localhost ~]$ jobs
[2]+ Running sleep 4000 | sleep 5000 &
二、守护进程
1. 什么是守护进程
守护进程也称精灵进程(Daemon),是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。
守护进程是一种很有用的进程。Linux的大多数服务器就是用守护进程实现的。比如,ftp服务器,ssh服务器,Web服务器httpd等。同时,守护进程完成许多系统任务。比如,作业规划进程crond等。
Linux系统启动时会启动很多系统服务进程,这些系统服务进程没有控制终端,不能直接和用户交互。其它进程都是在用户登录或运行程序时创建,在运行结束或用户注销时终止,但系统服务进程(守护进程)不受⽤用户登录注销的影响,它们一直在运行着。 这种进程有一个名称叫守护进程(Daemon)。
守护进程自成进程组,自成会话。
我们可以用ps axj
命令查看系统中的进程。参数a表示不仅列当前用户的进程,也列出所有其他用 户的进程,参数x表示不仅列有控制终端的进程,也列出所有无控制终端的进程,参数j表示列出与作业控制相关的信息。
凡是TPGID⼀一栏写着-1的都是没有控制终端的进程,也就是守护进程。
在COMMAND⼀一列⽤用[]括起来的名字表示内核线程,这些线程在内核里创建,没有用户空间代码,因此没有程序文件名和命令行,通常采用以k开头的名字,表示Kernel。
init进程我们已经很熟悉了,udevd负责维护/dev目录下的设备文件,acpid负责电源管理,syslogd负责维护/var/log下的日志文件可以看出,守护进程通常采用以d结尾的名字,表示Daemon。
2. 创建守护进程
创建守护进程最关键的一步是调用setsid()
函数创建一个新的Session,并成为Session Leader。
#include <unistd.h>
pid_t setsid(void);
// 该函数调用成功时返回新创建的Session的id(其实也就是当前进程的id),出错返回-1。
// 如果调用这个函数的进程是进程组的组长,那么 setsid() 就会出错,返回 -1。
// 要保证当前进程不是进程组的组长也很容易,只要先fork再调用setsid就行了。
// fork创建的子进程和父进程在同一个进程组中,进程组的组长必然是该组的第一个进程,
// 所以子进程不可能是该组的第一个进程,也就不可能会是组长,在子进程中调用setsid就不会有问题了。
成功调用该函数的结果是:
当前进程成为会话首进程(控制进程),当前进程的 id 就是会话的id。
创建一个新的进程组,当前进程成为进程组的组长,当前进程的 id 就是进程组的 id。
如果当前进程原本有一个控制终端,则它失去这个控制终端,成为一个没有控制终端的进程。
所谓失去控制终端是指,原来的控制终端仍然是打开的,仍然可以读写,但只是一个普通的打开文件而不是控制终端了。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/stat.h>
void mydaemon(void)
{
int i;
int fd0;
pid_t pid;
struct sigaction sa;
// 1. 调用 umask 将文件模式创建屏蔽字设置为 0
umask(0);
// 2. 调用 fork(),父进程退出(exit)
// 保证子进程不是一个进程组的组长进程
if((pid = fork()) < 0){
perror("fork");
}else if(pid > 0){
exit(0);
}
// 3. 调用setsid创建一个新会话
setsid();
// 4. 忽略 SIGCHLD 信号
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
// 注册子进程退出忽略信号
if(sigaction(SIGCHLD, &sa, NULL) < 0){
return;
}
// 现在,进程已经成为无终端的会话组长。但它可以重新申请打开一个控制终端。
// 这时可以通过使进程不再成为会话组长来禁止进程重新打开控制终端。
// 再次fork(),终止父进程,保证子进程不是话首进程,从而保证后续不会再和其他终端关联
// 这步并不是必须的
if((pid = fork()) < 0){
perror("fork()");
return;
} else if(pid != 0){
exit(0);
}
// 5.将当前工作目录改为根目录
// 守护进程会保护当前目录下的文件或目录不被删除
// 这是为了防止删除操作可能导致守护进程运行失败
if(chdir("/") < 0){
printf("child dir error\n");
return;
}
// 6.关闭不需要的文件描述符,或者重定向到 /dev/null
// 关闭了标准输入、标准输出和标准出错(0,1,2)
// 子进程继承了父进程的文件描述符表,当父进程死亡后,由于守护进程依旧维护这三个文件,
// 但守护进程因脱离了终端,也就用不到这三个文件,这就是一种浪费,所以需要关闭。
close(0);
fd0 = open("/dev/null", O_RDWR);
dup2(fd0, 1);
dup2(fd0, 2);
}
int main()
{
mydaemon();
while(1){
sleep(1);
}
}
3. 创建守护进程函数daemon
#include <unistd.h>
int daemon(int nochdir, int noclose);
// 参数
// 当 nochdir为 0 时,当前目录变为根目录,否则不变;
// 当 noclose为 0 时,标准输入、标准输出和错误输出重定向为/dev/null,也就是不输出任何信息,否则照样输出。
// 返回值:
// deamon()调用了fork()
// 如果fork成功,那么父进程就调用_exit(2)退出,
// 所以看到的错误信息 全部是子进程产生的。
// 如果成功函数返回0,否则返回-1并设置errno。
示例:
#include <stdio.h>
#include <unistd.h>
int main()
{
daemon(0, 0);
while(1){
sleep(1);
}
return 0;
}