# 进程
# 程序
程序(program)
- :本质上是一连串可以被 CPU 执行的计算机指令。
- 例如在 Windows 系统上启动一个桌面软件、在 Linux 终端执行一个 shell 命令,都会启动一个程序。
- 如何制作一个程序?
- 使用某种编程语言,编写该程序的源代码。
- 使用编译器,将源代码转换成能被 CPU 识别的二进制指令。
- 启动一个程序时,通常流程如下:
- 用户编译生成程序的二进制文件,放在磁盘。
- 用户要求操作系统执行该文件。
- 操作系统将该文件,从磁盘拷贝到内存。
- CPU 从内存读取该文件,执行其中的二进制指令。该程序开始运行。
- CPU 执行完该文件的所有指令。该程序结束运行。
软件(software)
- :软件的概念比程序更大。一个软件可能包含多个不同功能的程序,以及一些非程序文件(比如图片)。
# 进程
操作系统每次启动一个程序时,会创建一个进程(Process),代表一个正在运行的程序。
- 为什么操作系统需要创建进程?这是引入一种逻辑对象,方便描述有多少个程序正在运行、每个程序的运行状态怎么样。
- 运行 n 个程序,就会存在 n 个进程。
- 操作系统将所有进程的数据记录在内存中。每当一个程序结束运行,就删除其进程数据。
每个进程只能执行一份程序代码。
- 每个进程可以创建任意个新进程。这些进程可以执行同一份程序代码,也可以执行不同程序代码。
- 创建的新进程,默认会担任当前进程的子进程,与当前进程属于同一个进程组。也可以不担任子进程,与当前进程属于不同进程组。
进程组(Process Group)
- :包含一个父进程,以及任意个(包括 0 个)子进程。
- 每个进程组中有且仅有一个 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 系统中,进程退出的流程:
- 一个进程终止运行。可能是执行完 main() 主函数,主动退出。也可能是收到 kill 信号,被杀死。
- 内核回收该进程占用的内存、文件等资源,并向其父进程发送 SIGCHLD 信号,通知它有一个子进程退出了。
- 父进程调用 wait() 或 waitpid() 获取该进程的退出状态。
- 内核在进程表中删除该进程,删除其
/proc/<PID>/
目录,使该进程完全消失。
进程终止之后、被删除之前,处于 Zombie 状态,称为僵尸进程(Zombie Process)。
- 僵尸进程不能被 kill 命令杀死,因为它已经终止运行了。
- 如果僵尸进程越来越多,可能占用内核的所有 PID ,导致不能创建新进程。
Windows 系统中,进程退出时会被立即回收、删除,因此不会产生僵尸进程。
init 进程会不断调用 wait() 获取其子进程的退出状态,避免产生僵尸进程。
- 因此,孤儿进程没有危害,会被 init 进程管理,不会变成僵尸进程。
避免产生僵尸进程的几种措施:
- 子进程终止时通知父进程,让父进程立即回收它。
- 比如向父进程发送 SIGCHLD 信号,不过一般的进程会忽略该信号。
- 让父进程不断轮询子进程是否终止。
- 终止父进程,让子进程变为孤儿进程,从而被 init 进程管理。
- 比如先让本进程 fork 一个子进程,再让子进程 fork 一个孙进程,然后立即回收子进程。这样孙进程就变为了孤儿进程。
- 子进程终止时通知父进程,让父进程立即回收它。
例:
- 在 Python 解释器中执行
p = subprocess.Popen('sleep 10', shell=True)
创建子进程。 - 执行 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
- 当子进程执行完之后,就会变成僵尸进程:
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>
- 在 Python 解释器中执行
p.wait()
,僵尸进程就会消失。- 另外,创建新的子进程时,Python 会自动清理所有僵尸态的子进程。
- 在 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 状态。
Z :Zombie
- 表示进程处于僵尸状态。
T :Stopped
- 表示进程处于暂停状态。
- 发送 SIGSTOP 信号可以暂停一个进程。该进程会一直存在,直到被终止,或者被 SIGCONT 信号恢复运行。
t :Traced
- 表示进程处于被跟踪状态,例如进程被 strace 命令跟踪时。
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 表示当前进程的子进程已终止,默认无动作 18 SIGCONT 继续运行 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 的进程组中的所有进程。
- 特别地,为了避免主机宕机,Linux 内核要求:PID 为 1 的进程(通常是 init、systemd 等),只会接收已注册 handler 的信号。
- 因此,发送 SIGKILL、SIGSTOP 等信号给 1 号进程,会被忽略。不过此时调用 kill() 依然会返回 0 。
- 因此,在主机上执行命令
kill -9 1
不会杀死 1 号进程。
- sig
- 可以填 int 值,也可以填 SIGTERM 等宏定义名。
- 如果为 0 ,则不发送信号,但依然会检测目标进程是否存在、是否有权限发送信号。
- pid
信号的常见用途是:用 kill 命令发送终止信号,终止指定 pid 的进程。
- 向一个进程发送一个普通的终止信号时,该进程默认会立即终止。但也可能捕捉信号,做完清理工作之后再终止(比如释放占用的资源)。甚至忽略信号,一直不终止。
- 向一个进程发送 SIGKILL 信号时,不能被进程忽略或捕捉,因此进程会立即被杀死(又称为强制杀死)。不过以下几种情况例外:
- 进程处于不可中断的睡眠状态,不会响应信号。
- Zombie 状态的进程不能被 SIGKILL 信号杀死,因为它已经终止运行了。
当进程收到一个信号时,有三种处理方式:
- 执行这种信号的默认动作
- 忽略信号,不执行动作
- 捕捉信号,然后执行自定义的动作
- 进程需要事先将自己的信号处理函数,传给内核,与一种信号绑定。当收到这种信号时,内核就会执行该函数,从而实现该进程自定义的动作。
- 代码示例:
#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() 会阻塞本进程,等新进程退出了,才继续执行本进程。
- 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 。
- 例:在 bash 终端执行
# 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); // 阻塞当前线程,直到所有子进程都终止