POSIX进程组笔记

题目起得有点大,这里加上POSIX只是为了说明本文描述并不是windows下的进程。

前几天遇到了一个问题,在terminal触发ctrl+c,也就是发送SIGINT信号后,程序的所有进程都收到了该信号,觉得有点奇怪,故收集并整理了相关资料。

本文为我的笔记,可能有不正确的点,望指正。

0x00 进程及进程组

在Linux下,创建一个进程是相当容易的,just fork it!

当然你也可以用posix_spawn,只不过这个接口更像是fork+exec的组合罢了。

fork的机制很灵活,如果要展开讲,会牵扯很多内容,甚至是调度器,无奈目前积累不够,就此略过。

创建进程后,除了PID,也就是所说的进程号,还包含其它信息,其中一个便是PGID——进程组编号。

进程组所描述的内容本身很简单,通过如下2个函数,便可获得进程组的编号了:

pid_t getpgrp(void);
pid_t getpgid(pid_t);

getpgrp用于获取本进程的PGID,而getpgid则可以获取指定进程的PGID,算是一个增强版,man中有关于参数值的更加详细的描述,这里不做赘述。

0x01 进程组的生命周期

每个进程组都有一个leader,这个leader进程的PID即为进程组的PGID。关于进程组的生命周期,就和这个leader有关。

关于进程组的生命周期,我们考虑:

  • 进程组何时创建
  • 进程组何时销毁

任何一个进程在创建时都已经指定了一个进程组,子进程通常和其父进程在同一个组,这里的“父子”关系通常指fork的关系。如果在fork后还有exec,通常我们会设置子进程的PGID,因为从语义上来说,此时这两个进程不太可能存在“父子”关系,当然也有例外。

可以看一个例子:

[codesun@lucode Documents]$ ps -o user,pid,pgid,ppid,comm
USER       PID  PGID  PPID COMMAND
codesun   2180  2180  1304 bash
codesun   5380  5380  2180 ps

在这里bash和ps存在父子关系,可以从PPID得出这个结论。具体的过程是这样的:

  1. bash fork一个子进程
  2. 父进程通过wait等待
  3. 子进程通过exec执行ps命令
  4. 子进程结束后,父进程继续运行

可以看到这里的exec就重新设置了PGID,否则在exec调用后PGID应该是保持不变的。

ps是该进程组唯一一个进程,很显然,它也是leader,因此它的PID == PGID。进程组的生命周期就是从一个它被指定这一刻开始的,那么通过什么方式为子进程指定新的进程组呢?可以用如下函数:

int setpgid(pid_t pid, pid_t pgid);

详细的用法,清大家参考man。

那么进程组何时销毁呢?是不是leader进程结束时?很显然,并不是,我们需要考虑进程组内的其它成员进程,如果此时不存在其它进程,那么该进程组确实会销毁,它已经没有存在的意义了。如果依旧存在其它进程,那么就得一直等到最后一个进程结束为止了。

0x02 进程组的分类

其实在进程组之上,还有一个叫做session的概念,这个通常和job control相关,内容较多,本文并不涉及。大家只需要记住job control的引入,使得进程组进一步分为foreground和background,不知道是不是就是所谓的“前台”和“后台”。一个session有一个session leader,负责建立到controlling terminal的连接,session内可以有一个foreground进程组和一个及以上background进程组。

两者最主要的区别和controlling terminal相关,规则如下:

  1. 对于foreground进程组,能够直接从tty读写,SIGINT和SIGQUIT触发后会直接被发送到所有成员
  2. 对于background进程组,如果有进程企图从tty读写,会触发SIGTIN和SIGTOUT,这两个信号默认的行为即STOP进程

这里的1即本文“废话”中描述的现象的原因,因为该程序的所有进程都位于foreground进程组,那么此时SIGINT自然是会发送到所有进程了。对于这种情况,我们可以通过如下几种方式解决:

  1. 阻塞相关signal
  2. 重新设置foreground进程组
  3. 使进程进入一个新的session

其中方案3是在创建守护进程的操作之一。

0x03 孤立进程组

这里所谓的孤立进程组即Orphaned进程组。

判断一个进程是否为孤立进程组的标准为:

  1. 进程组的所有成员的父进程都在本进程组内
  2. 进程组的成员的父进程不属于进程组所在的session

换句话说,只要进程组成员的父进程在本session内且不属于本进程组即可说明该进程组不是孤立进程。

POSIX规定,新孤立进程组内的所有进程都会先后收到SIGHUP和SIGCONT信号。为何要发SIGCONT?因为此时进程组内的成员可能处于被STOP的状态,而通常情况下,它们可能永远都不会被continue。

在很多情况下,如果foreground进程组内是由leader构造的一棵进程树,那么如果leader终止,此时leader的直接子进程由init接管,那么可以想象,这个进程组成了孤立进程组。因为父进程不是位于组内就是在session之外。

这个时候这些进程通常由SIGHUP信号终止,如果依旧有进程留存,那么这个组就成了孤立进程组。

在《APUE》书中相关章节有描述:“Finally, note that out child was placed in a background process group when the parent terminated”。这个描述对于书上的例子并没有错误,但不免令人产生疑问,是不是所有foreground进程组在其leader结束,且成为孤立进程组后都会变为background进程组?

事实并不是这样的,因为书中这个例子是由shell执行的,如果父进程结束后,shell进程就能够收到返回(从wait函数或者SIGCHLD),又由于shell和剩余的进程位于同一session,且shell持有controlling terminal,很明显它需要从tty进行读写,因此这个时候shell所在的进程组就会成为foreground进程组,又由于一个session只能有一个foreground进程组,很自然,该孤立进程组只能成为background进程组。

口说无凭,代码为证:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
    pid_t id;
    if((id = fork()) == 0) {
    setpgid(0, 0);
        if(fork() == 0) {
            sleep(2);
            printf("[2] %d:%d | %d\n", getpid(), getpgrp(), tcgetpgrp(0));
            sleep(1);
            printf("[2] %d:%d | %d\n", getpid(), getpgrp(), tcgetpgrp(0));
        } else {
            sleep(2);
            printf("[1] %d:%d | %d\n", getpid(), getpgrp(), tcgetpgrp(0));
            exit(1);
        }
    } else {
        printf("[0] %d:%d | %d\n", getpid(), getpgrp(), tcgetpgrp(0));
        sleep(1);
        tcsetpgrp(0, id);
        printf("[0] %d:%d | %d\n", getpid(), getpgrp(), tcgetpgrp(0));
    }
    pause();
    return 0;
}

运行的结果为:

[codesun@lucode ~]$ ./test
[0] 6439:6439 | 6439
[0] 6439:6439 | 6440
[1] 6440:6440 | 6440
[2] 6441:6440 | 6440
[2] 6441:6440 | 6440

例子中没有采用可靠的同步机制,而是使用sleep这种low办法实现一定的先后顺序,具体行为视平台实现及调度器而定,但一般结果基本和本文一致。

这里有3个进程,它们的PID分别为6439,6440,6441。6440在创建后,通过setpgid为自己分配新的进程组,并将自己作为leader。然后6439进程将foreground设置为其子进程,也就是6440,可以从结果看到TPGID的值确实变成了6440。从6440和6441输出的结果来看,也证实了这一点。然后,6440进程结束,6441依旧存活,此时PGID=6440的进程组就成了孤立进程组。

打开htop,切换到tree模式,定位到./test。此时我们在terminal触发SIGINT,我们会发现6441进程结束了,而6439进程没有动静,terminal一直“卡”着,这是为什么呢?因为此时6441位于foreground进程组,自然能够收到SIGINT,而6439在background进程组,是不会收到SIGINT的。

所以,上述代码验证了我的结论。

等等。。。不是说好了有SIGHUP和SIGCONT的吗?

如果你发现了这一点,说明你看的很用心,确实这一点在APUE中的描述似乎有点疏漏,关于何时发送SIGCONT和SIGHUP,应该改为:

当新的孤立进程组出现,且内部有成员处于STOP状态,那么就会向所有成员先后发送SIGHUP和SIGCONT信号

0xFF 总结

尽管使用fork真的很方便,简直是随心所欲。但进程创建背后的隐藏信息量还是很大的,值得琢磨,当然这也是深入内核代码的前提。

关于进程组的作用,我目前只知道是为了便于管理,所谓的“层次化”,比如通过kill向整个组发送signal,比如通过wait系列函数等待进程组内的任一进程结束等。或许还有更多优点和作用,想到了再补充吧。

仅有一条评论
  1. 不错不错,虽然看不懂

说两句: