# 进程

# 程序

  • 程序(program)

    • :本质上是一连串可以被 CPU 执行的计算机指令。
    • 例如在 Windows 系统上启动一个桌面软件、在 Linux 终端执行一个 shell 命令,都会启动一个程序。
    • 如何制作一个程序?
      1. 使用某种编程语言,编写该程序的源代码。
      2. 使用编译器,将源代码转换成能被 CPU 识别的二进制指令。
    • 启动一个程序时,通常流程如下:
      1. 用户编译生成程序的二进制文件,放在磁盘。
      2. 用户要求操作系统执行该文件。
      3. 操作系统将该文件,从磁盘拷贝到内存。
      4. CPU 从内存读取该文件,执行其中的二进制指令。该程序开始运行。
      5. CPU 执行完该文件的所有指令。该程序结束运行。
  • 软件(software)

    • :软件的概念比程序更大。一个软件可能包含多个不同功能的程序,以及一些非程序文件(比如图片)。

# 进程

  • 操作系统每次启动一个程序时,会创建一个进程(Process),代表一个正在运行的程序。

    • 为什么操作系统需要创建进程?这是引入一种逻辑对象,方便描述有多少个程序正在运行、每个程序的运行状态怎么样。
    • 运行 n 个程序,就会存在 n 个进程。
    • 操作系统将所有进程的数据记录在内存中。每当一个程序结束运行,就删除其进程数据。
  • 每个进程只能执行一份程序代码。

    • 每个进程可以创建任意个新进程。这些进程可以执行同一份程序代码,也可以执行不同程序代码。
    • 创建的新进程,默认会担任当前进程的子进程。也可以修改配置,不担任子进程。
  • 进程组(Process Group)

    • :包含一个父进程,和多个子进程。也可以没有子进程,只存在父进程。
    • 每个进程组中有且仅有一个 Leader 进程,是其它进程的父进程。

# 进程会话

:Process Session ,包含一个进程组,或多个具有父子关系的进程组。

  • 会话中的进程组又称为 job ,用于执行某个任务。
  • 一个 Session 中有且仅有一个 Leader 进程,是其它进程、进程组的根父进程。
    • 当 Session Leader 终止时,系统会给该 Session 的所有进程发送 SIGHUP 信号来终止它们。当 Session 中的所有进程都终止时,系统就会删除该 Session 。
  • 例如:用户登录时会创建一个 login shell ,还会创建一个 Session ,以 login shell 作为 Session Leader 。
    • 在该 Session 中,只有一个进程组能工作在前台,其它进程组都只能工作在后台。
    • 当用户登出时,属于该 Session 的所有进程组都会被系统终止。
  • 相关函数:
    #include <unistd.h>
    
    pid_t setsid(void);
        // 创建一个新的进程会话,然后返回其 SID
        // 创建的新会话中,由本进程担任 Group Leader 和 Session Leader
        // 如果本进程本来就是本进程组的 Group Leader ,则不允许创建,避免与本进程组的其它进程处于不同的会话中
    

# 进程退出

  • Unix 系统要求每个进程终止时,都返回一个整数值,称为退出码(Exit Code)、返回码(Return Code),表示该进程的执行结果是否成功。

    • 退出码的取值范围为 0~255 。
    • 通常退出码取值为 0 时表示正常退出,取值为非 0 时表示异常退出。
  • 终止进程的几种方式:

    • 该进程执行完程序代码,即执行到 main() 主函数的 return 语句。此时退出码取决于 return 语句。
    • 该进程调用 exit() 函数,提前退出。此时退出码取决于 exit() 函数。
    • 其它进程发送 SIGTERM 等信号到该进程,终止它。此时退出码等于 128 加上该信号的编码。
      • 例如发送 SIGTERM 信号时,退出码等于 128+15=143 。

# 守护进程

  • 前台进程
    • :绑定到当前终端的 stdin ,因此会阻塞当前终端。
    • 普通方式启动的进程默认会绑定到当前终端的 stdin、stdout、stderr 。
  • 后台进程
    • :没有绑定到终端的 stdin ,但可能绑定了 stdout、stderr 。
    • 前台进程、后台进程都是当前终端的子进程。如果用户关闭当前终端,系统就会给这些进程发送 SIGHUP 信号,终止它们。
  • 守护进程(daemon)
    • :一种特殊的后台进程。运行在一个独立的 Process Session 中。因此当用户关闭当前终端之后,也会继续运行。
    • 系统服务程序通常以守护进程的方式运行。

# 孤儿进程

  • 当父进程终止时,Linux 并不会自动杀死其子进程。如果子进程依然运行,则称为孤儿进程(Orphan Process)。
  • 出现孤儿进程时,Linux 会将它的 PPID 改为 1 ,成为 init 进程的子进程,被 init 进程管理。

# 僵尸进程

  • 类 Unix 系统中,进程退出的流程:

    1. 一个进程终止运行。可能是执行完 main() 主函数,主动退出。也可能是收到 kill 信号,被杀死。
    2. 内核回收该进程占用的内存、文件等资源,并向其父进程发送 SIGCHLD 信号,通知它有一个子进程退出了。
    3. 父进程调用 wait() 或 waitpid() 获取该进程的退出状态。
    4. 内核在进程表中删除该进程,删除其 /proc/<PID>/ 目录,使该进程完全消失。
  • 进程终止之后、被删除之前,处于 Zombie 状态,称为僵尸进程(Zombie Process)。

    • 僵尸进程不能被 kill 命令杀死,因为它已经终止运行了。
    • 如果僵尸进程越来越多,可能占用内核的所有 PID ,导致不能创建新进程。
  • Windows 系统中,进程退出时会被立即回收、删除,因此不会产生僵尸进程。

  • init 进程会不断调用 wait() 获取其子进程的退出状态,避免产生僵尸进程。

    • 因此,孤儿进程没有危害,会被 init 进程管理,不会变成僵尸进程。
  • 避免产生僵尸进程的几种措施:

    • 子进程终止时通知父进程,让父进程立即回收它。
      • 比如向父进程发送 SIGCHLD 信号,不过一般的进程会忽略该信号。
    • 让父进程不断轮询子进程是否终止。
    • 终止父进程,让子进程变为孤儿进程,从而被 init 进程管理。
      • 比如先让本进程 fork 一个子进程,再让子进程 fork 一个孙进程,然后立即回收子进程。这样孙进程就变为了孤儿进程。
  • 例:

    1. 在 Python 解释器中执行 p = subprocess.Popen('sleep 10', shell=True) 创建子进程。
    2. 执行 ps 命令查看:
      29457 root      20   0  147.2m   8.8m   3.4m S   0.0  0.1   0:00.09          `- python3
      29860 root      20   0  105.5m   0.3m   0.3m S   0.0  0.0   0:00.00              `- sleep 10
      
    3. 当子进程执行完之后,就会变成僵尸进程:
      29457 root      20   0  147.2m   8.8m   3.4m S   0.0  0.1   0:00.09          `- python3
      29860 root      20   0    0.0m   0.0m   0.0m Z   0.0  0.0   0:00.00              `- [sleep] <defunct>
      
    4. 在 Python 解释器中执行 p.wait() ,僵尸进程就会消失。
      • 另外,创建新的子进程时,Python 会自动清理所有僵尸态的子进程。

# 进程属性

# 标识符

Linux 系统会给每个进程、线程分配一些标识符(ID),有多种类型:

  • PID :进程(Process)的 ID ,在当前 pid namespace 中取值唯一。
  • PPID :父进程(Parent Process)的 ID 。
  • TID :线程(Thread)的 ID ,在当前线程组中取值唯一。
  • PGID :进程组(Process Group)的 ID ,等于其主进程的 ID 。
  • TGID :线程组(Thread Group)的 ID ,等于其主线程的 ID 。
  • SID :进程会话(Process Session)的 ID ,等于其主进程的 PID 。

# 运行状态

  • R :Running

    • 进程正在运行。或者说,CPU 正在执行该进程的程序代码。
  • S :Sleeping

    • 此时进程处于睡眠状态,不需要运行。等到满足某些条件时(比如等待 1 分钟、等待磁盘 IO 、等待网络 IO),该进程才继续运行。
    • 例如:HTTP 服务器平时处于 Sleeping 状态,无事可做。通过网络 IO 收到 HTTP 请求时,才变为 Running 状态。
  • D :Disk Sleep

    • Sleeping 属于可中断的睡眠状态(Interruptible Sleep)。此时,进程如果收到信号,能立即响应,并中断正在执行的任务。
    • Disk Sleep 属于不可中断的睡眠状态(Uninterruptible Sleep)。此时,进程正在执行某个不可中断的任务(通常是调用系统接口),没空响应自己收到的信号。
    • 例如:进程等待磁盘 IO 时,会进入短暂几毫秒的 D 状态。此时用 kill 命令发送信号给该进程,并不能杀死它。甚至 kill -9 也不能杀死。
  • Z :Zombie ,僵尸状态。

  • T :Stopped ,暂停状态。

    • 发送 SIGSTOP 信号可以暂停一个进程。该进程会一直存在,直到被终止,或者被 SIGCONT 信号恢复运行。
  • t :Traced

    • 例如进程被断点调试时,处于被跟踪状态。
  • X :进程正在终止,这是一个很短暂的状态,因此很少见。

# 进程间通信

Linux 系统上,进程间通信(Inter Process Communication ,IPC)的主要方式如下:

# 信号

  • 信号(signal)可能来源于硬件(比如键盘信号),也可能来源于软件(比如用 kill 命令)。信号产生后,会被内核发送给进程,相当于软件层的模拟中断。

  • Linux 定义了多种信号,常见的如下:

    编码 宏定义名 默认动作
    1 SIGHUP 终止进程
    2 SIGINT 终止进程
    3 SIGQUIT 终止进程
    9 SIGKILL 终止进程
    10 SIGUSR1 终止进程
    12 SIGUSR2 终止进程
    15 SIGTERM 终止进程
    17 SIGCHLD 表示当前进程的子进程已终止,默认无动作
    19 SIGSTOP 暂停进程
    • 在不同的计算机平台上,信号的编码可能有差异,因此最好通过宏定义名来指定信号。
    • SIGINT 通常由键盘中断(Ctrl+C)引发。
    • 向某个进程发出一个普通的终止信号时,进程可能立即终止,也可能做完清理工作之后再终止(比如释放占用的资源),甚至不终止。
    • SIGKILL、SIGSTOP 两种信号,不能被进程忽略或捕捉,因此一定会立即执行。
    • SIGUSR1、SIGUSR2 两种信号,常用于自定义的信号处理函数。
      • SIGUSR1 的默认动作是终止进程,不过 Apache、Nginx 等很多程序收到 SIGUSR1 信号之后会进行复位操作,比如刷新缓存、重新加载配置文件、重新打开日志文件,接近于重启进程。
  • 相关 API :

    #include <signal.h>
    
    int kill(pid_t pid, int sig);
        // 向指定 pid 的进程发送 sig 信号
        // 如果该函数的返回值为 0 ,则表示成功发送了信号,但不知道目标线程会如何处理信号
    
    • pid
      • 如果为 n > 0 ,则选中 PID 等于 n 的进程。
      • 如果为 0 ,则选中本进程组的所有进程。
      • 如果为 -1 ,则选中其它所有进程,只要本进程有权限发送信号给它们。
      • 如果为 -n < -1 ,则选中 PGID 等于 n 的进程组中的所有进程。
      • 特别地,内核只允许将已注册 handler 的信号发送给 PID 为 1 的进程(通常是 init、systemd 等),会忽略 SIGKILL、SIGSTOP 等信号,避免主机宕机。不过此时调用 kill() 依然会返回 0 。
        • 因此,在主机上执行命令 kill -9 1 不会杀死 1 号进程。
    • sig
      • 可以填 int 值,也可以填 SIGTERM 等宏定义名。
      • 如果为 0 ,则不发送信号,但依然会检测目标进程是否存在、是否有权限发送信号。
  • 当进程收到一个信号时,有三种处理方式:

    • 执行信号的默认动作
    • 忽略信号
    • 捕捉信号:进程将自己的信号处理函数传给内核,与一个信号绑定。当该信号发生时,内核就会执行该函数,从而实现该进程自定义的动作。如下:
      #include <stdio.h>
      #include <signal.h>
      
      static void sig_handle(int sig_no)              // 定义信号处理函数
      {
          if(sig_no == SIGUSR1)
              printf("Received SIGUSR1\n");
          else
              printf("Received signal %d\n", sig_no);
      }
      
      int main(void){
          if(signal(SIGUSR1, sig_handle) == SIG_ERR)  // 绑定信号处理函数
              printf("Can not catch SIGUSR1\n");      // 如果不能绑定即不能捕捉,则报错
          return 0;
      }
      

# 信号量

  • :semophore ,一个非负整数,用于记录某个资源的可用数量。为 0 时表示资源不可用。

# 套接字

# 管道文件

  • 进程可以创建一个管道文件(pipe),和另一个进程同时连接到它,从中读写数据。
  • 采用半双工通信,当一个进程写数据时,另一个进程只能读数据。
  • 匿名管道(PIPE):保存在内存中,没有文件描述符,只能用于父子进程之间的通信。比如管道符 | 。
  • 命名管道(FIFO):保存为文件系统中的一个文件,常用于两个独立进程之间的通信。

# 消息队列

  • :message queue ,一个链表结构,允许多个进程从中读写数据。

# 共享内存

  • :shared memory ,一块内存空间,允许被多个进程同时访问。

# 相关 API

# task_struct

  • Linux 内核会在内存中保存一个进程表(process table),记录当前存在的所有进程,包括其 PID、PCB 。
    • 用户可以通过 /proc/<PID>/ 目录获取进程的 PCB 信息。
  • 每个进程用一个 task_struct 结构体记录其元数据,称为进程控制块(Process Control Block ,PCB)、进程描述符。如下:
    struct task_struct
    {
        volatile long state;              // 进程的运行状态。-1 表示 unrunnable,0 表示 runnable,大于 0 表示 stopped
        void *stack;                      // 进程的内核栈
        atomic_t usage;                   // 进程描述符的使用计数
        unsigned int flags;               // 进程的状态标志
    
        int exit_state;                   // 进程的退出码
        pid_t pid;                        // 进程的 ID
        pid_t session;                    // 进程会话的 ID
        pid_t tgid;                       // 线程组的 ID
    
        struct task_struct *parent;       // 一个指针,指向父进程
        struct task_struct *group_leader; // 一个指针,指向本进程组的主进程
        struct list_head children;        // 一个链表,其中每个元素表示一个子进程
    
        cputime_t utime, stime;           // 进程占用的用户态、内核态 CPU 时长
        struct timespec start_time;       // 进程的启动时刻
    
        struct mm_struct *mm;             // 内存描述符,用于描述当前进程的虚拟内存空间等信息
                                          // 同一个进程的所有线程,共用一个虚拟内存空间,因此 task->mm 取值相同
                                          // 内核线程没有虚拟内存空间,因此 task->mm 取值为 null
        struct mm_struct *active_mm;      // 该指针专供内核线程使用,用于记录最近运行的一个用户态进程的 task->mm ,从而访问其中的数据
    
        ...
    }
    

# fork()

#include <unistd.h>

pid_t fork(void);
    // 拷贝本进程,创建一个新进程。这个新进程属于本进程的子进程
    // 该函数的返回值:
    // 如果执行成功,则在本进程中返回新进程的 PID ,在新进程中返回 0
    // 如果执行失败,则在本进程中返回 -1

pid_t vfork(void);
  • fork() 会将本进程的大部分上下文拷贝给新进程,例如:

    • 拷贝本进程的虚拟内存空间。
      • 拷贝之后,新进程可以修改自己虚拟内存空间的内容,变得与本进程不同。
      • 如果直接拷贝整个虚拟内存空间,则时间、内存开销较大。
        • 因此 fork() 采用写时复制方式,让新进程先读取父进程的内存空间,当新进程需要修改某块内存数据时才拷贝到自己的内存空间。
        • 不过依然需要拷贝本进程的 Page Table ,存在少量开销。
    • 拷贝本进程打开的文件描述符列表。
  • fork() 创建新进程之后,新进程会执行什么任务?

    • 拷贝本进程的虚拟内存空间时,包括了程序代码,因此新进程会执行与本进程相同的程序代码。
    • 但新进程不会从头开始执行代码,而是从调用 fork() 的那条语句之后继续执行。
    • 简单来看,执行 fork() 语句时,会从本进程拷贝出一个几乎相同的进程实例。
  • fork() 创建的新进程,是一个独立运行的新进程,例如:

    • 与本进程的 PID 不同。
    • 将资源使用率、CPU 使用时长重置为零,重新计算。
    • 将待处理的信号集清空。
  • vfork() 与 fork() 类似,但区别在于:

    • vfork() 创建新进程时,不会拷贝本进程的虚拟内存空间,而是共享本进程的虚拟内存空间。
      • 优点:不需要拷贝本进程的 Page Table ,减少了开销。
      • 缺点:新进程不能与本进程同时执行。
    • fork() 会让新进程、本进程同时执行。而 vfork() 会阻塞本进程,等新进程退出了,才继续执行本进程。
  • 用法示例:

    #include<stdio.h>
    #include <stdlib.h>
    #include<unistd.h>
    
    int main(int argc, char **argv) {
        // 创建新进程
        pid_t id = fork();
    
        // 判断当前进程是否为新进程
        if (id == 0) {
            printf("This is the child process. pid=%d\n", getpid());
            exit(0);
        } else if (id > 0) {
            printf("This is the parent process. pid=%d\n", getpid());
        } else {
            printf("fork() failed\n");
            exit(1);
        }
        return 0;
    }
    

    执行 gcc test.c -o test 编译上述代码,然后运行,输出示例:

    This is the parent process. pid=5315
    This is the child process. pid=5316
    

    如果上述代码改用 vfork() ,则输出会变成:

    This is the child process. pid=5316
    This is the parent process. pid=5315  # 等新进程退出了,才继续执行本进程
    

# clone()

#include <sched.h>

int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...);
    // 拷贝本进程,创建一个新进程。这个新进程默认属于本进程的子进程,但也可以是同级进程
    // fn 是一个函数指针,作为新进程执行的主函数
    // stack 是一个内存指针。新进程不应该共享本进程的栈区,因此需要用 malloc() 申请一块内存空间,用作新进程的栈区,存放 fn、arg 等数据
    // arg 是 fn 函数的入参
    // 该函数的返回值:如果执行成功,则返回子进程的 PID 。如果执行失败,则返回 -1
  • 目前 Linux 源代码中,创建进程、线程时,底层都是调用 clone() 来实现的。例如 fork() 也是基于 clone() 。

  • clone() 与 fork() 类似,但功能更多。例如:

    • 可以改变新进程执行的 main() 主函数。
    • 可以将新进程放到新的 Linux namespace 中。
    • 可以定制新进程的上下文。
  • flags 参数用于定制新进程的上下文。有多种策略:

    • 新建
      • :新建一份上下文(通常是空的),给新进程使用。
    • 共享
      • :将本进程的上下文共享,给新进程使用。例如 CLONE_VM 是让两个进程共用同一个虚拟内存空间。
      • 优点:创建新进程的开销更小。
      • 缺点:一个进程修改上下文数据时,会影响另一个进程。因此同时运行两个进程时,可能冲突。
    • 拷贝
      • :将本进程的上下文拷贝一份,给新进程使用。此时两个进程的上下文内容相同,但之后新进程可以修改自己的上下文,变得不同。
      • 优点:两个进程能独立运行,互不干扰。
      • 优点:创建新进程的开销更大,时间、内存开销更多。
  • flags 举例:

    #include <sched.h>
    
    #define CLONE_VM             0x00000100 // 让新进程共享本进程的虚拟内存空间。如果不启用该标志,则会将本进程的虚拟内存空间拷贝一份,给新进程使用
    #define CLONE_FS             0x00000200 // 共享文件系统的信息,比如当前目录、根目录、umask
    #define CLONE_FILES          0x00000400 // 共享打开的文件描述符列表
    #define CLONE_SIGHAND        0x00000800 // 共享 signal handlers 表
    #define CLONE_VFORK          0x00004000 // 阻塞本进程,等新进程退出时,才继续执行本进程
    #define CLONE_PARENT         0x00008000 // 让新进程的父进程,指向本进程的父进程。如果不启用该标志,则新进程的父进程是本进程
    #define CLONE_THREAD	       0x00010000 // 让新进程加入本进程的 thread group ,从而模拟线程
    
    #define CLONE_NEWCGROUP      0x02000000 // 新建一个 Cgroup 给新进程使用。如果不启用该标志,则新进程会共享本进程的 Cgroup
    #define CLONE_NEWIPC         0x08000000 // 新建一个 ipc namespace 。如果不启用该标志,则新进程会共享本进程的 ipc namespace
    #define CLONE_NEWPID         0x20000000 // 新建一个 pid namespace
    #define CLONE_NEWNET         0x40000000 // 新建一个 network namespace
    #define CLONE_NEWNS          0x00020000 // 新建一个 mnt namespace 给新进程使用
    #define CLONE_NEWUSER        0x10000000 // 新建一个 user namespace
    #define CLONE_NEWUTS         0x04000000 // 新建一个 uts namespace
    
  • 用法示例:

    #define _GNU_SOURCE   // 添加该宏定义,才能访问非标准的 GNU/Linux 函数
    #define STACK_SIZE (1024 * 1024)
    #include <sched.h>
    #include <sys/wait.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    
    static int child_func(void* arg) {
        char* buffer = (char*)arg;
        printf("This is the child process. buffer=\"%s\"\n", buffer);
        strcpy(buffer, "hello world");
        sleep(3);   // 避免子进程太早退出,导致父进程执行 wait() 时因为不存在子进程而失败
        return 0;
    }
    
    int main(int argc, char** argv) {
        // 申请一块内存空间,用作子进程的栈区
        char* stack = malloc(STACK_SIZE);
        if (!stack) {
            perror("malloc() failed");
            exit(1);
        }
    
        // 创建子进程
        char buffer[10];
        strcpy(buffer, "hello");
        // 下面传入了 stack + STACK_SIZE ,指向栈区的最高地址,因为 Linux 的栈区从高位地址开始写入
        // 下面添加了 SIGCHLD 作为 flags 的最低位值,表示子进程退出时,发送什么信号给主进程
        if (clone(child_func, stack + STACK_SIZE, CLONE_VM | CLONE_FS | SIGCHLD, buffer) == -1) {
            perror("clone() failed");
            exit(1);
        }
    
        // 等待子进程退出
        int status;
        if (wait(&status) == -1) {
            perror("wait() failed");
            exit(1);
        }
    
        printf("This is the parent process. buffer=\"%s\"\n", buffer);
        return 0;
    }
    

    编译并运行上述代码,输出示例:

    This is the child process. buffer="hello"
    This is the parent process. buffer="hello world"
    

# execve()

#include <unistd.h>

int execve(const char *pathname, char *const argv[], char *const envp[]);
    // 执行 pathname 路径的二进制文件,并传入参数 argv 、环境变量 envp
    // 函数执行成功时,没有返回码。因为 execve() 函数所在的旧程序已被忘记,以后执行的是新程序
    // 函数执行失败时,会返回 -1
  • 每个进程只能执行一份程序代码。但一个进程可以调用 execve() ,忘记当前程序,改为执行另一个程序。
    • execve() 会将当前进程的虚拟内存空间,映射到新的物理内存地址,然后释放旧的物理内存地址。因此会替换代码段、数据段、堆栈等内容,取消 mmap 内存映射。
    • execve() 不会影响当前进程的 PID 、打开的文件描述符列表。
    • 一旦执行完新程序的 main() 主函数,当前进程就会退出。不会继续执行旧程序,毕竟旧程序已被忘记。
  • 调用 fork() 加 execve() ,就可以改变子进程执行的程序代码。与之相比,调用 clone() 并指定 fn 函数指针,只是改变子进程执行的主函数,依然使用同一份程序代码。
    • 例:在 bash 终端执行 ls -l 命令时,是先调用 fork() 创建一个 bash 子进程。然后子进程调用 execve("/usr/bin/ls", ["ls", "-l"], 0x7ffc0e3d0910 /* 29 vars */) ,将执行的程序从 bash 改为 ls 。

# exit()

#include <stdlib.h>

void exit(int status);
    // 让当前进程终止,且采用 status 的值作为退出码

# wait()

#include <sys/wait.h>

pid_t wait(int *status);
  // 阻塞当前线程,直到任意一个子进程终止,然后返回其 PID
  // 如果函数执行失败(可能是不存在子进程),则返回 -1

pid_t waitpid(pid_t pid, int *status, int options);
  // 阻塞当前线程,直到指定 PID 的那个子进程终止,或者进入 stopped 状态、退出 stopped 状态
  • pid
    • 如果为 n > 0 ,则等待 PID 等于 n 的子进程。
    • 如果为 0 ,则等待本进程的任一子进程。
    • 如果为 -1 ,则等待任一子进程。
    • 如果为 -n < -1 ,则等待 PGID 等于 n 的进程组中的任一子进程。
  • 例:
    wait(NULL);               // 相当于 waitpid(-1, NULL, 0);
    while (wait(NULL) > 0);   // 阻塞当前线程,直到所有子进程都终止