进程、进程组和会话、作业控制 - 《Linux/UNIX系统编程手册》读书笔记【1】

本文是在阅读《Linux/UNIX系统编程手册》1相关内容做的笔记。由于实际工作中的一个需求从音频播放C++类的实现学习Linux系统子进程管理引起此次的系统学习和记录。此次学习对Linux系统中的进程有更加深入的理解,包括进程组和会话的概念、作业控制等以前并不深入了解的知识。

fork()系统调用创建新进程(child)几乎为调用进程(parent)的翻版。

c

#include <unistd.h>
pid_t fork();

程序代码可通过返回值来区分父/子进程。在父进程将返回子进程的PID,父进程可以通过子进程的PID管理子进程。而子进程将返回0,在子进程中可以通过getpidgetppid分别获取子进程和父进程的PID。但创建失败时则会返回-1,一般的原因是进程数量超过了系统的限制。

不应该对fork后父子进程执行顺序有任何假设,如果需要确保一定的顺序则需要进程间同步。在从音频播放C++类的实现学习Linux系统子进程管理中就通过父子进程在fork之后都调用相同作用的系统调用来设置子进程的进程组ID,从而保证在任何调用顺序下,父子进程后续的执行都能确保进程组设置成功。

执行fork后子进程会和父进程拥有一模一样的内存空间,现代UNIX实现都是采用写时复制(copy-on-write)技术来节省物理内存消耗,即:父子进程在fork之后共享物理内存,在二者对内存页面有修改是才将要修改的内存页面拷贝,将子进程对应的表项修改,之后父子进程就可以分别修改各自的页拷贝。在书中提到利用这个特性,可以在已知某个func会导致内存泄漏或是过渡内存碎片,可以使用子进程执行该函数,就不会影响主进程的内存占用,最后通过进程间通信来获取结果。

在fork后父子进程内存是一模一样的,打开的文件fd中存储的系统级打开文件表引用是一样的,故其当前文件的偏移量、打开文件的状态标志等都是共享的,参考从文件描述符和打开文件之间的关系重新理解Shell重定向。利用这个特性可以通过pipe等进行父子进程之间通信。

fork之后,子进程不会继承如下资源状态:

  1. 父进程的内存锁,mlock(2)mlockall(2)
  2. 父进程的文件锁,fcntl(2)
  3. 父进程的某些定时器,setitimer(2)alarm(2)timer_create(2)

如果在子进程对如上类的资源进行释放操作就会释放一个不拥有的资源,导致未定义行为。考虑在C++中,一般通过使用对象来包装资源,利用对象的生命周期管理资源(RAII),在构造对象时获取资源,在对象出作用域析构时释放资源。因为释放资源并不是显性调用,在fork()之后子进程可能“无意”释放了不拥有的资源,造成未定义行为。

fork()之后,对于子进程除了当前的线程之外,其他线程都消失了,会导致一个危险的局面。其他线程可能正持有某个锁,fork()之后对于子进程来说其他线程已经没有了再也没有机会解锁,如果试图再对该mutex加锁就会立刻死锁。所以fork()后只能调用可重入函数。对于诸如mallocprintf函数都不可使用,所以对于多线程程序来说调用fork()唯一安全的做法就是fork()后立即调用exec()执行另一个程序,彻底断绝子进程和父进程的关系。

SUSv3对可重入函数定义: 函数由多条线程调用时,即便交叉执行,其效果也与各线程任意顺序依次调用一致。

我的理解是“在执行过程中可以被中断,并且在中断后能够安全地再次调用(重入)函数“,更新全局变量(不是sig_atomic_t类型)或局部静态变量的函数都不是可重入的函数。所以诸如malloc函数由于涉及到全局状态的修改是不可重入函数。

系统调用execve()将新程序加载到某一进程的内存空间,进程的内存空间会被新程序所取代,在执行了各种初始化代码会从新程序的main()函数开始执行。

c

#include <unistd.h>
int execve(const char *pathname, char *const argv[], char *const envp[]);

参数pathname可以时绝对路径或者相对于调用进程当前目录的相对路径;参数argv指定了传给新进程的命令行参数,相当于main()函数的第2个参数(argv),以NULL结束;参数envp指定了新程序的环境列表,对应environ数组,以NULL结束,字符串格式为name=alue;

Linux特有的/proc/PID/exe是符号链接包含对应进程中正在运行的可执行文件的绝对路径名。

通常也不用检查execve()的返回值,因为如果成功则切换到新程序执行不会继续执行,失败的话返回值一定是-1并且代表发生了错误,可以通过errno来判断错误原因,有如下可能:

  • EACCES: 参数pathname指向的文件因为权限等原因不可执行。
  • ENOENT: pathname所指代的文件并不存在。
  • ENOEXEC: pathname所指代的文件不是ELF文件格式。
  • ETEXTBSY: 存在进程已经以写入方式打开pathname所指代的文件。

ELF(Executable and Linking Format)标准描述了可执行文件的布局,二进制可执行文件都需要符合该标准。同时该标准也允许定义一个解释器来运行程序以字符#!开头。

库函数为执行exec()提供了多种API选择,这些函数都是参数有所区别为了方便使用对execve()调用的封装。

下表对exec()函数之间的差异总结。简单来说:

  • 函数名带p,代表可以只ß提供程序的文件名,库函数通过环境变量$PATH的目录列表找到对应的可执行文件,与Shell对命令的搜索方式一致。
  • 函数名带l,代表可以使用参数列表来指定argv,并且以NULL指针结尾。带v的则是使用数组的形式传入argv,数组的最后一个元素也需要是NULL。
  • 函数名带e,代表可以通过envp以数组的形式显示指定函数的环境变量。其它不带e的函数则是继承调用者的环境变量。
函数名程序文件描述参数描述环境变量来源
execve()路径名数组envp 参数
execle()路径名列表envp 参数
execlp()文件名+PATH列表调用者的 environ
execvp()文件名+PATH数组调用者的 environ
execv()路径名数组调用者的 environ
execl()路径名列表调用者的 environ

当进程中任意线程调用exec(),那么除了进程的主线程(main函数线程)其它线程都会终止,新程序在主线程中执行。故任意线程调用exec()后的新程序中调用gettid()将会返回原主线程的线程ID

默认情况下exec()的调用程序打开的所有文件描述符在exec()成功后的程序中会保持打开状态。Shell利用这一特性为其所执行的程序处理I/O重定向。例如:

shell

$ls /tmp > dir.txt

Shell运行该命令时会执行如下步骤:

  1. fork()创建子进程。

  2. 子进程以描述符1(stdout)打开文件dir.txt作为输出,采取如下方式:

    • 使用dup2()强制将标准输出fd=1复制成文件dir.txt的fd的副本。

      c

      fd = open("dir.txt", ...);
      if (fd != STDOUT_FILENO) {
          dup2(fd, STDOUT_FILENO);
          close(fd);
      }
  3. 子Shell调用exec()执行程序ls,ls将其结果输出到df=1标准输出,亦即文件dir.txt中。

大部分情况下,出于节省和安全的角度来讲程序在执行exec()之前需要关闭某些特定的文件描述符。当然可以在exec()调用close()实现这一目的,但是有如下局限性:某些fd是由库函数打开,程序主动close()可能会比较困难;假设exec()失败了,之前close()的fd可能还需要。

由此内核为每个文件描述符提供了执行时关闭的标志。设置这一标志,在程序exec()成功时会自动关闭该文件描述符。可以使用fcntl()ioctl()等函数对某一个fd设置或清除这一标志位。

进程一般有两种结束方式,其一为一些信号触发的异常结束,其二就是程序主动调用_exit()系统调用正常结束程序。

c

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

参数status为进程的退出状态,父进程调用wait()可以获得该状态。虽然为int类型,但是仅有低8位有效,并且由于Shell在程序被信号终止时会将环境变量$?设置为128与信号值的和,所以返回值不能大于128,否则就与信号触发的退出区分不清了

c

#include <stdlib.h>
void exit(int status);

程序一般调用C语言库函数exit()主动退出,该函数会在退出前执行如下动作:

  1. 调用atexit()on_exit()注册的退出处理程序。
  2. 刷新stdio缓冲区。
  3. 执行exit_()系统调用退出程序。

另外main()函数中return n时等同于调用exit(n)。在C99标准中定义了,main()函数不写return等同于exit(0)

在进程的用户空间中维护stdio缓冲区,因此通过fork()创建子进程时会复制该缓冲区。如果父/子进程都使用exit()退出会导致刷新各自的stdio缓冲区,从而导致重复的输出结果。fork()后的子进程应该使用_exit()退出

另外根据前边的介绍,如果父进程是多线程程序fork()后的子进程只会继承调用线程,假使fork()时正好有其它线程加锁操作stdio缓冲区,此时子进程的stdio缓冲区的锁已经被锁定并且不会被解锁,子进程调用exit()会发生死锁

系统调用wait()waitpid()都是用来等待子进程终止并且获取其退出状态status。wait()等待调用进程的任一子进程终止,waitpid()可以指定等待的子进程pid。

c

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

正常二者都会返回结束的子进程pid,出错时返回-1。错误原因之一是调用进程无可被等待的子进程,此时errno设置为ECHILD

对于waitpid(),其参数pid表示需要等待的具体子进程,意义如下:

  • pid > 0: 等待进程ID为pid的子进程。
  • pid == 0: 等待与调用进程在同一进程组的所有子进程。
  • pid == -1: 等待任意子进程,wait(&status)waitpid(-1, &staus, 0)等价
  • pid < -1: 等待进程组id==abs(pid)的所有子进程。

参数options时一个位掩码,可以设置如下标志位

  • WUNTRACED: 还返回因信号而停止的子进程信息。
  • WCONTINUED: 返回那些因受到SIGCONT信号而恢复执行的子进程信息。
  • WNOHANG: 如果指定的子进程未发生状态改变,则立即返回,不会阻塞。这时waitpid()返回0。

头文件<sys/wait.h>定义了一组解析等待状态值status的宏。

  • WIFEXITED(status): 若子进程是通过_exit()正常结束则返回true,此时WEXITSTATUS(status)返回子进程的退出状态。
  • WIFSIGNALED(status): 若子进程是被信号杀死时返回true,此时WTERNSIG(status)返回导致子进程终止的信号编号。若子进程产生coredump,则WCOREDUMP(status)返回true。
  • WIFSTOPPED(status): 若子进程因信号而停止,则返回true,此时WSTOPSIG(status)返回导致子进程停止的信号编号。
  • WIFCONTINUED(status): 若子进程因SIGCONT而恢复执行,则返回true。

子进程终止系统会向父进程发送SIGCHLD信号,该信号的默认处理就是忽略。在设置该信号的处理函数时,可以使用wait获取子进程退出状态,但是需要注意的是: 当调用信号处理程序时,默认会暂时将该信号阻塞起来,且不会对其进行排队处理。故当SIGCHILD信号处理程序正在执行中时,相继有两个子进程终止产生2个SIGCHLD信号被阻塞,父进程后边也只能捕获一次。所以SIGCHLD处理程序需要内部循环以WNOHANG标志来非阻塞的调用waitpid(),才可以避免出现僵尸进程,如以下代码。

c

while (waitpid(-1, NULL, WNOHANG) > 0) continue;

虽然对于SIGCHLD的默认处置就是忽略,但是显式的将SIGCHLD信号设置为SIG_IGN,系统会将其后产生的子进程立即删除,不会转为僵尸进程,同时后续的wait()调用不会返回子进程的任何信息。在这方面,信号SIGCHLD的处理不同于其它信号。

进程组和会话、控制终端是实现命令行的底层概念,用于命令行的输入、输出和Shell的作业控制。

进程组由一个或多个共享同一进程组标识符(PGID)的进程组成,进程组标识符(PGID)等于首次创建进程组的进程pid,其生命周期为创建时刻到最后一个进程退出进程组,在其生命周期不会有和PGID相同的pid分配给新进程。子进程会继承父进程的进程组。可以使用killpg()命令将信号发给进程组的每个成员进程,另一篇从音频播放C++类的实现学习Linux系统子进程管理就是利用这一特性实现杀死子进程和其产生的子进程。

会话则是一组进程组的集合。进程的会话成员关系是由其会话标识符(SID)确定的,会话标识符也是会话创建进程的pid。

fork()创建新进程时,内核会保证不会和已有的进程组ID和会话ID相同。从而保证即使进程组或会话的首进程退出,新进程也不会复用首进程的PID。

每个会话可以拥有一个控制终端,终端是用户的交互设备,现代的图形界面都是使用终端模拟器xterm等软件,通过伪终端技术模拟终端设备。

下图表示了进程组和会话、控制终端之间的关系。

进程组、会话和控制终端之间的关系进程组
进程组、会话和控制终端之间的关系进程组

以上图举个实际的栗子: 打开Ubuntu系统的Terminal终端模拟软件,该软件模拟了一个终端设备并且执行/bin/bash进程pid=400。bash进程成为会话SID=400的首进程和终端的控制进程,也是进程组PGID=400的唯一成员。

用户输入执行find / 2> /dev/null | wc -l &,bash进程会把管道相连的两个进程find和wc放入同一个PGID=658进程组。由于命令以&结尾被放入后台进程组中。

用户接着输入执行sort < longlist | uniq -c,同样会将两个管道相连的进程放入同一个PGID=660进程组,并且处于前端进程组,可以接受用户的输入。显而易见,同一时间只能有一个进程组成为前端进程组。如果输入ctrl+c终端会将中断信号发送给前端进程组的每个成员,正常情况下两个进程都会结束。

由于本人不打算开发一个终端软件或者是Shell软件所以对以下内容并不准备深入了解细节,只求懂个大概,简单写一写。

通过pid_t getpgrp()int setpgid(pid_t pid, pid_t pgid)获取和设置进程的进程组。参考另一篇使用进程组实现kill信号同时发给子进程和其产生的子进程中的实际应用。

通过pid_t getsid(pid_t pid)pid_t setsid()来获取进程的sid和设置当前进程为新会话的首进程和该会话中的新进程组的首进程

注意:已经是进程组的首进程就不能调用setsid()设置自己为新会话的首进程。原因是如果可以的话,调用后进程组中原有的其他成员还属于原来的会话中,就会出现该进程组的成员不属于同一个会话中。

当控制进程(bash)失去与其终端的连接后,内核会向控制进程发送SIGHUP信号;当控制进程结束时,内核会向该控制进程关联的终端前台进程组发送SIGHUP信号。SIGHUP信号默认的行为是终止进程。bash进程设置了SIGHUP信号的处置函数,在退出前将SIGHUP信号发送给由它创建的所有进程组。

所以我们在ssh断开连接或者关闭终端时,仍在运行的任务会被结束。常用的nohup(1)命令在执行的命令时将SIGHUP信号设置为SIG_IGN,这样在终端退出时执行的命令不会结束。

作业控制用来支持一个Shell用户同时执行多个命令,其中最多一个命令在前台执行,其余命令在后台运行。命令可以被停止和恢复以及在前后台之间移动。

首先,Shell会为每个后台任务赋予一个唯一的作业号从1开始,在将任务放入后台时会将作业号放在[作业号]方括号中打印出来,后边跟着进程的PID或管道的最后一个进程的PID。通过jobs命令可以列出所有后台作业及其状态。

shell

$ sleep 60&
[1] 24599
$ sleep 600
^Z
[2]+  Stopped                 sleep 600
$ jobs
[1]-  Running                 sleep 60 &
[2]+  Stopped                 sleep 600

使用%num可以引用作业,num为作业号。省略%num则默认指在前台最新被放入后台的作业。

产生后台作业的方式有使用ctrl+z向前台进程组发送SIGSTP(SIGKILL一样不可修改信号默认处理函数,SIGSTOP才是不可修改)暂停进程并且放入后台,或者在命令执行时加上&符号,该命令会立即在后台执行,不会暂停。可以使用fg %num命令在前台恢复作业号num的任务,也可以使用bg %num命令发送SIGCONT信号来恢复被暂停的作业。通过kill -STOP %num也可以将后台作业暂停。

在后台的作业尝试从读取标准输入,就会收到SIGTTIN信号,默认动作就是暂停作业。默认情况下,后台作业是可以写入标准输出打印在终端中的。如果终端设置了TOSTOP标志,那么当后台作业尝试输出到终端时会收到SIGTTOU信号,默认动作也是暂停作业。

shell

$ stty tostop
$ date &
[4] 34046
$
[4]+  Stopped                 date
$ fg
date
Wed Jul 10 11:21:32 PM CST 2024

下图对作业的状态转换进行了总结。

作业控制状态转换
作业控制状态转换