UNIX 环境高级编程|进程控制(上)

举报
debugzhang 发表于 2021/03/22 15:09:43 2021/03/22
【摘要】 对在 UNIX 环境中的高级编程而言,完整地了解 UNIX 的进程控制是非常重要的。其中必须熟练掌握的只有几个函数——fork、exec 系列、_exit、wait 和 waitpid。很多应用程序都使用这些简单的函数。本章说明了 system 函数和进程会计,这也使我们能进一步了解所有这些进程控制函数。本章还说明了 exec 函数的另一种变体:解释器文件以及它们的工作方式。

GitHub: https://github.com/storagezhang

Emai: debugzhang@163.com

本文为《UNIX 环境高级编程》第 8 章学习笔记


对在 UNIX 环境中的高级编程而言,完整地了解 UNIX 的进程控制是非常重要的。

其中必须熟练掌握的只有几个函数——forkexec 系列、_exitwaitwaitpid。很多应用程序都使用这些简单的函数。fork 函数也给了我们一个了解竞争条件的机会。

本章说明了 system 函数和进程会计,这也使我们能进一步了解所有这些进程控制函数。

本章还说明了 exec 函数的另一种变体:解释器文件以及它们的工作方式。

对各种不同的用户 ID 和组 ID(实际、有效和保存的)的理解,对编写安全的设置用户 ID 程序是至关重要的。

8.2 进程标识

每个进程都有一个非负整数表示的唯一进程 ID。

  • 因为进程 ID 标识总是唯一的,常将其用作其他标识符的一部分以保证其唯一性。

虽然是唯一的,但是进程 ID 是可复用的。

  • 当一个进程终止后,其进程 ID 就成为复用的候选者。
  • 大多数 UNIX 系统实现延迟复用算法,使得赋予新建进程的 ID 不同于最近终止进程所使用的 ID。
  • 这防止了将新进程误认为是使用同一 ID 的某个已终止的先前进程。

系统中有一些专用进程,但具体细节随实现而不同。

  • ID 为 0 的进程通常是调度进程,常常被称为交换进程。
    • 该进程是内核的一部分,它并不执行任何磁盘上的程序,因此也被称为系统进程。
  • ID 为 1 的进程通常是 init 进程,在自举过程结束时由内核调用。
    • 该进程的程序文件在 UNIX 早期版本是 /etc/init,在较新的版本中是 /sbin/init
    • 该进程负责在自举内核后启动一个 UNIX 系统。
    • 该进程通常读取与系统有关的初始化文件(/etc/rc* 文件,/etc/inittab 文件以及 /etc/init.d 中的文件),并将系统引导到一个状态(如多用户)。
    • 该进程永远不会终止。
    • 该进程是一个普通的用户进程(与交换进程不同,它不是内核中的系统进程),但是它以超级用户特权运行。

每个 UNIX 系统实现都有它自己的一套提供操作系统服务的内核进程。

除了进程 ID,每个进程还有一些其他标识符。下列函数返回这些标识符:

#include <unistd.h>

pid_t getpid(void);		// 返回值:调用进程的进程 ID
pid_t getppid(void);	// 返回值:调用进程的父进程 ID

uid_t getuid(void);		// 返回值:调用进程的实际用户 ID
uid_t geteuid(void);	// 返回值:调用进程的有效用户 ID

gid_t getgid(void);		// 返回值:调用进程的实际组 ID
gid_t getegid(void);	// 返回值:调用进程的有效组 ID

注意:这些函数都没有出错返回。

8.3 函数 fork

一个现有的进程可以调用 fork 函数创建一个新进程:

#include <unistd.h>

pid_t fork(void);
// 返回值:若成功,子进程返回 0,父进程返回子进程 ID;若出错,返回 -1

fork 创建的新进程被称为子进程。

fork 函数被调用一次,但返回两次。

将子进程 ID 返回给父进程的理由是:一个进程的子进程可以有多个,并且没有一个函数使一个进程可以获得其所有子进程的进程 ID。

子进程继续执行 fork 调用之后的指令。

  • 子进程是父进程的副本。
    • 获得父进程数据空间、堆和栈的副本。
  • 父进程和子进程并不共享这些存储空间部分。
  • 父进程和子进程共享正文段。

由于在 fork 之后经常跟随着 exec,所以现在的很多实现并不执行一个父进程数据段、栈和堆的完全副本。

  • 作为替代,使用了写时复制(Copy-On-Write,COW)技术,
  • 这些区域由父进程和子进程共享,而且内核将它们的访问权限改变为只读。
  • 如果父子进程中任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储系统中的一“页”。

一般来说,在 fork 之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的调度算法。如果要求父进程和子进程之间相互同步,则要求某种形式的进程间通信。

文件共享

在重定向父进程的标准输出时,子进程的标准输出也被重定向。

实际上,fork 的一个特性是父进程的所有打开文件描述符都被复制到子进程中。

  • 这里的“复制”,指对每个文件描述符来说,就好像执行了 dup 函数。父进程和子进程每个相同的打开描述符共享一个文件表项。

父进程和子进程共享同一个文件偏移量。

fork 之后处理文件描述符有以下两种常见的情况:

  • 父进程等待子进程完成。
    • 父进程无需对其描述符做任何处理。
    • 当子进程终止后,它曾经进行过读、写操作的任一共享描述符的文件偏移量已做了相应更新。
  • 父进程和子进程各自执行不同的程序段。
    • 父进程和子进程各自关闭它们不需要使用的文件描述符,这样就不会干扰对方使用的文件描述符。这种方法是网络服务进程经常使用的。

除了打开文件之外,父进程的很多其他属性也由子进程继承:

  • 实际用户 ID、实际组 ID、有效用户 ID、有效组 ID
  • 附属组 ID
  • 进程组 ID
  • 会话 ID
  • 控制终端
  • 设置用户 ID 标志和设置组 ID 标志
  • 当前工作目录
  • 根目录
  • 文件模式创建屏蔽字
  • 信号屏蔽和安排
  • 对任一打开文件描述符的执行时关闭标志
  • 环境
  • 连接的共享存储段
  • 存储映像
  • 资源限制

父进程和子进程之间的区别具体如下:

  • fork 的返回值不同
  • 进程 ID 不同
  • 各自的父进程 ID 不同
  • 子进程的 tms_utimetms_stimetms_cutimetms_ustime 的值设置为 0
  • 子进程不继承父进程设置的文件锁
  • 子进程的未处理闹钟被清除
  • 子进程的未处理信号集设置为空集

使 fork 失败的两个主要原因是:

  • 系统中已经有了太多的进程。
  • 该实际用户 ID 的进程总数超过了系统限制。
    • CHILD_MAX 规定了每个实际用户 ID 在任一时刻可拥有的最大进程数。

fork 有两种用法:

  • 一个父进程希望复制自己,使父进程和子进程同时执行不同的代码段。
    • 这在网络服务进程中是常见的:
      • 父进程等待客户端的服务请求。
      • 当这种请求到达时,父进程调用 fork,使子进程处理此请求。
      • 父进程继续等待下一个服务请求。
  • 一个进程要执行一个不同的程序。
    • 这对 shell 是常见的情况:
      • 子进程从 fork 返回后立即调用 exec

8.4 函数 vfork

vfork 函数的调用序列和返回值与 fork 相同,但两者的语义不同:

  • vfork 函数用于创建一个新进程,而该新进程的目的是执行一个新程序。
  • vforkfork 一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec(或 exit),于是也就不会引用该地址空间。
    • 不过在子进程调用 execexit 之前,它在父进程的空间中运行。
    • 这种优化工作方式在某些 UNIX 系统的实现中提高了效率,但如果子进程修改数据(除了用于存放 vfork 返回值的变量)、进行函数调用或者没有 execexit 就返回都可能会带来未知的结果。
  • vfokr 保证子进程先运行,在它调用 execexit 之后父进程才可能被调度运行,当子进程低啊用这两个函数中的任意一个时,父进程会恢复运行。
    • 如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。

8.5 函数 exit

进程有 8 种方式使进程终止,其中 5 种为正常终止,3 种为异常终止:

  • 正常终止:
    • main 函数内执行 return 语句。
      • 这等效于调用 exit
    • 调用 exit 函数。
      • 此函数由 ISO C 定义,其操作包括调用个终止处理程序(终止处理程序在调用 atexit 函数时登记),然后关闭所有标准 I/O 流等。
      • 因为 ISO C 并不处理文件描述符、多进程以及作业控制,所以这一定义对 UNIX 系统而言是不完整的。
    • 调用 _exit_Exit 函数。
      • ISO C 定义 _Exit,其目的是为进程提供一种无需运行终止处理程序或信号处理程序而终止的方法。
      • 在 UNIX 系统中,_exit_Exit 是同义的,并不冲洗标准 I/O 流。
      • _exit 是由 POSIX.1 说明的,它由 exit 调用,处理 UNIX 系统特定的细节。
    • 进程的最后一个线程在其启动例程中执行 return 语句。
      • 该线程的返回值不用作进程的返回值。
      • 当最后一个线程从其启动例程返回时,该进程以终止状态 0 返回。
    • 进程的最后一个线程调用 pthread_exit 函数。
      • 进程的终止状态总是 0,这与传送给 pthread_exit 的参数无关。
  • 异常终止:
    • 调用 abort
      • 它产生 SIGABRT 信号,是下一种异常终止的一个特例。
    • 接到一个信号。
      • 信号可由进程自身(如调用 abort 函数)、其他进程或内核产生。
    • 最后一个线程对“取消”请求作出响应。
      • 默认情况下,“取消”以延迟的方式发生:一个线程要求取消另一个线程,若干时间之后,目标线程终止。

不管进程如何终止,最后都会执行内核中的同一段代码。这段代码为相应进程关闭所有打开描述符,释放它对所使用的存储器等。

对上述任意一种终止情形,我们都希望终止进程能够通知其父进程它是如何终止的。

  • 对于 3 个终止函数,实现这一点的方法是,将其退出状态作为参数传递给函数。
  • 在异常终止情况下,该终止进程的父进程都能用 waitwaitpid 函数取得其终止状态。
  • 注意,退出状态是传递给 3 个终止函数的参数,或 main 的返回值,在最后调用 _exit 时,内核将退出状态转换成终止状态。

如果子进程先终止:

  • 子进程将其终止状态返回给父进程。

如果父进程先终止:

  • 对于父进程已经终止的所有进程,它们的父进程都改为 init 进程。
  • 我们称这些进程由 init 进程收养,其操作过程大致是:
    • 在一个进程终止时,内核逐个检查所有活动进程,以判断它是否是正要终止进程的子进程,如果是,则该进程的父进程 ID 就更改为 1。
  • 这种处理方法保证了每个进程有一个父进程。

内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用 wait 函数或者 waitpid 函数时,可以得到这些信息。

  • 这些信息至少包括:终止进程的进程 ID、该进程的终止状态、该进程使用的 CPU 时间总量。
  • 一个已经终止、但是等待父进程对它进行善后处理的进程称作僵死进程,在 ps 命令中显示为 Z
    • 所谓善后处理,就是父进程调用 wait 函数或者 waitpid 函数读取终止进程的残留信息
    • 一旦父进程进行了善后处理,则终止进程的所有占用资源(包括残留信息)都得到释放,该进程被彻底销毁
  • 对于 init 进程:
    • 任何时候只要有一个子进程终止,就立即调用 wait 函数取得其终止状态。
    • 这种做法防止系统中塞满了僵死进程。

8.6 函数 waitwaitpid

当一个进程正常或异常终止时,内核就向其父进程发送 SIGCHLD 信号。

  • 因为子进程终止是一个异步事件,所以这种信号是内核向父进程发送的异步进程。

  • 父进程可以选择忽略该信号。这是系统的默认动作。

  • 父进程也可以提供一个该信号发生时即被调用执行的函数(信号处理程序)。

如果进程由于接收到 SIGCHLD 信号而调用 wait,我们期望 wait 会立即返回。

但是如果在随机时间点调用 wait,则进程可能会阻塞。

#include <sys/wait.h>

pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
// 返回值:若成功,返回进程 ID;若出错,返回 0 或 -1

区别:

  • 在一个子进程终止前,wait 使其调用者阻塞,而 waitpid 有一选项,可使调用者不阻塞。
  • waitpid 并不等待在其调用之后的第一个终止子进程,它有若干个选项,可以控制它所等待的进程。
  • 如果子进程已经终止,并且是一个僵死进程,则 wait 立即返回并取得该子进程的状态;否则 wait 使其调用者阻塞,直到一个子进程终止。
  • 如果调用者阻塞而且它有多个子进程,则在其某一子进程终止时,wait 就立即返回。
  • 因为 wait 返回终止子进程的进程 ID,所以它总能了解是哪一个子进程终止了。
  • waitpid 可等待一个特定进程,而 wait 则返回任一终止子进程的状态。
  • waitpid 提供了一个 wait 的非阻塞版本。有时希望获取一个子进程的状态,但不想阻塞。

参数:

  • staloc:存放子进程终止状态的地址。

    • 如果不关心子进程的终止状态,可以将该参数设为空指针。
  • pid

    • 如果 pid==-1:则等待任一子进程终止。
    • 如果 pid>0:则等待进程 ID 等于 pid 的那个子进程终止。
    • 如果 pid==0:则等待组 ID 等于调用进程组 ID 的任一子进程终止。
    • 如果 pid<0:等待组 ID 等于 pid 绝对值的任一子进程终止。
  • options:使我们进一步控制 waitpid 的操作。

    • 或者是0,或者是下列常量按位或的结果:

      常量 说明
      WCONTINUED 若实现支持作业控制,那么由 pid 指定的任一子进程在停止后已经继续,但其状态尚未报告,则返回其状态(POSIX.1 的 XSI 扩展)。
      WNOHANG 若由 pid 指定的子进程并不是立即可用的,则 waitpid 不阻塞,此时其返回值为 0。
      WUNTRACED 若实现支持作业控制,而由 pid 指定的任一子进程已处于停止状态,并且其状态自停止以来还未报告过,则返回其状态。WIFSTOPPED 宏确定返回值是否对应于一个停止的子进程。

有 4 个互斥的宏可用来取得进程终止的原因,它们的名字都以 WIF 开始。基于这 4 个宏中哪一个值为真,就可选用其他宏来取得退出状态、信号编号等。

说明
WIFEXITED(status) 若为正常终止子进程返回的状态,则为真。对于这种情况可执行 WEXITSTATUS(status),获取子进程传送给 exit_exit 参数的低 8 位。
WIFSIGNALED(status) 若为异常终止子进程返回的状态,则为真(接到一个不捕捉的信号)。对于这种情况,可执行 WTERMSIG(status),获取使子进程终止的信号编号。另外,有些实现定义宏 WCOREDUMP(status),若已产生终止进程的 core 文件,则它返回真。
WIFSTOPPED(status) 若为当前暂停子进程的返回的状态,则为真。对于这种情况,可执行 WSTOPSIG(status),获取使子进程暂停的信号编号。
WIFCONTINUED(status) 若在作业控制暂停后已经继续的子进程返回了状态,则为真(POSIX.1 的 XSI 扩展,仅用于 waitpid)。

8.7 函数 waitid

Single UNIX Specification 包括了另一个取得进程终止状态的函数——waitid,此函数类似于 waitpid,但提供了更多的灵活性。

#include <sys/wait.h>

int waitid(id_type_t idtype, id_t id, siginfo_t *infop, int options);
// 返回值:若成功,返回 0;若出错,返回 -1

参数:

  • idtype:可以为下列常量:

    常量 说明
    P_PID 等待一特定进程:id 包含要等待子进程的进程 ID
    P_PGID 等待一特定进程组中的任一子进程:id 包含要等待子进程的进程组 ID
    P_ALL 等待任一子进程:忽略 id
  • id:指定要等待的子进程 ID,其作用与 idtype 的值相关。

  • infop:一个缓冲区的地址。

    • 该缓冲区由 waitid 填写,包含了造成子进程状态改变的有关信号的详细信息。
  • options:指示调用者关注哪些状态变化。可以是下列常量的按位或:

    常量 说明
    WCONTINUED 等待一进程,它以前曾被停止,此后又已继续,但其状态尚未报告。
    WEXITED 等待已退出的进程
    WNOHANG 如无可用的子进程退出状态,立即返回而非阻塞
    WNOWAIT 不破坏子进程退出状态。该子进程退出状态可由后续的 waitwaitidwaitpid 调用取得
    WSTOPPED 等待一进程,它已经停止,但其状态尚未报告

    WCONTINUEDWEXITEDWSTOPPED 这 3 个常量之一必须在 options 参数中指定。

8.8 函数 wait3wait4

函数 wait3wait4 提供的功能比 waitwaitpidwaitid 所提供的功能要多一个,这与附加参数有关。该参数允许内核返回由终止进程及其所有子进程使用的资源概况。

#include <sys/types.h>
#include <sys/wait.h>
#include <sys/time.h>
#include <sys/resource.h>

pid_t wait3(int *statloc, int options, struct rusage *rusage);
pid_t wait4(pid_t pid, int *statloc, int options, struct rusage *rusage);
// 返回值:若成功,返回进程 ID;若出错,返回 -1

参数:

  • statloc:存放子进程终止状态的缓冲区的地址。
    • 如果不关心子进程的终止状态,可以将它设为空指针。
  • rusage:存放由 wait3wait4 返回的终止子进程的资源统计信息的缓冲区地址。
    • 资源统计信息包括用户 CPU 时间总量、系统 CPU 时间总量、缺页次数、接收到信号的次数等。
  • pidoptions 参数的意义与 waitpid 相同。

8.9 竞争条件

当多个进程都企图对共享数据进行某种处理,而最后的结果又取决于进程运行的顺序时,我们认为发生了竞争条件。

如果在 fork 之后的某种逻辑显式或隐式地依赖于在 fork 之后是父进程先运行还是子进程先运行,那么 fork 函数就会是竞争条件活跃的滋生地。

如果一个进程希望等待一个子进程终止,则它必须调用 wait 函数中的一个。如果一个进程要等待其父进程终止,则可使用下列形式的循环:

while (getppid() != 1) {
	sleep(1);
}

这种形式的循环称为轮询,它的问题是浪费了 CPU 时间,因为调用者每隔 1s 都被唤醒,然后进行条件测试。

为了避免竞争条件和轮询,在多个进程之间需要有某种形式的信号发送和接收的方法。在 UNIX 中可以使用信号机制,各种形式的进程间通信(IPC)也可以使用。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。