引言
本文主要介绍unix系统的进程控制,其中包括创建进程、执行程序和进程终止。
进程标识
每一个进程都有一个非负整型表示的唯一进程ID,这也就是我们常说的进程ID。
注:虽然进程ID是唯一的,但是它也是可复用的。当一个进程终止后,其进程ID就成为复用的候选者。但是大多数unix系统使用延迟复用算法。
系统中也会有一些专用进程,比如:
- 进程ID 0 通常是调度进程。
- 进程ID 1 通常是init 进程。它负责在自举内核后启动一个UNIX系统,
init
进程绝不会终止。它是一个普通进程,但是以超级用户特权运行,还是所有孤儿进程的父进程。 - 进程ID 2 通常是页守护进程,此进程负责支持虚拟存储器系统的分页操作。
可通过下面接口获取进程ID:
#include <unistd.h> //返回调用进程的ID pid_t getpid(void); //返回调用进程的父进程ID pid_t getppid(void);
创建进程
一个现有的进程可以调用fork
函数创建一个新的进程。
#include<unistd.h> pid_t fork(void); // 返回值:子进程返回0,父进程返回子进程ID;若出错,返回-1
分析:为什么fork
对父进程和子进程分别返回子进程ID和0?
- 对于父进程而言,它可以由多个子进程,并且没有相关接口获取指定进程的ID,因此需要在创建时刻获取子进程ID。
- 在第一点的基础上,若子进程返回父进程的ID,那么对于开发者而言,就区分不了哪一个是父进程,哪一个是子进程。所以用特殊值0表示子进程。若子进程需要获取父进程ID,可通过
getppid
接口。
子进程是父进程的副本:其中包括父进程数据空间、堆、栈的副本,并且父进程和子进程共享正文段。
示例如下:
#include <unistd.h> #include <stdio.h> #include <string.h> #include <stdint.h> int32_t g_i32Var = 6; int main() { int32_t i32Var; pid_t pid; i32Var = 88; write(STDOUT_FILENO,"test fork\n",strlen("test fork\n")); printf("before fork\n"); if((pid = fork()) < 0) { printf("fork failed\n"); } else if(pid == 0) { g_i32Var++; i32Var++; }else{ sleep(2); } printf("pid = %d , globvar = %d, var = %d\n",getpid(),g_i32Var,i32Var); return 0; }
编译输出如下:
// 标准输出到终端 xieyihua@xieyihua:~/test$ gcc fork.c xieyihua@xieyihua:~/test$ ./a.out test fork before fork pid = 3635 , globvar = 7, var = 89 pid = 3634 , globvar = 6, var = 88 xieyihua@xieyihua:~/test$ // 标准输出重定向到文件 xieyihua@xieyihua:~/test$ ./a.out > 1 xieyihua@xieyihua:~/test$ cat 1 test fork before fork pid = 3749 , globvar = 7, var = 89 before fork pid = 3748 , globvar = 6, var = 88 xieyihua@xieyihua:~/test$
分析:
- 标准输出到终端
- 从
globvar
和var
的值输出可知,子进程会复制父进程的栈和数据段,并且修改并不会影响父进程; - 在前面章节中,我们知道
write
是不带缓冲的,直接输出到标准输出; printf
是带缓冲的标准I/O,因为标准输出为终端,因此为行缓冲,直接输出;
- 标准输出重定向到文件
由于标准输出重定向到文件,那么printf
就是全缓冲;因此内部流程如下:
- 父进程第一次输出
before fork\n
时,并没输出到终端,而是缓存在数据区 - 父进程
fork
之后,子进程的数据区也copy了一份befor fork\n
。 - 当子进程结束时,开始清空缓存区,会将所有的缓存进行输出。
- 当父进程结束时,开始清空缓存区,会将所有的缓存进行输出。
因此,该输出中,第一个字符串before fork
实际是子进程输出的。这个你get到了吗?
思考:从上述现象中,我们可知:fork
函数,会将父进程的所有打开文件描述符都被复制到子进程中。在【unix高级编程系列】文件I/O了解到linux 对共享文件的处理方式。而父进程和子进程的关系如下:
这就引发了一个新的问题:父进程和子进程共享一个文件表项,若不进行同步,则会将两者的输出混合(描述符在fork之前打开)。
fork
函数失败的两个主要原因有:
- 系统中已经有太多的进程。(常见场景:系统中存在大量的僵尸进程)
- 该实际用户ID的进程数超过了系统限制。可通过下述命令查看:
xieyihua@xieyihua:~$ cat /proc/sys/kernel/pid_max 4194304 xieyihua@xieyihua:~$ sysctl kernel.pid_max kernel.pid_max = 4194304 xieyihua@xieyihua:~$
拓展: 现在很多的实现并不执行一个父进程数据段,栈和堆的完全副本。作为替代,使用了写时复制技术。这些区域由父进程和子进程共享,而且内核将它们的访问权限改为只读。如果父进程和子进程中的人一个试图修改这些区域,则内核只为修改区域的那块内存只做一个副本。
进程退出
在上章节【unix高级编程系列】进程环境中,介绍了进程退出的8种方式(5种正常终止、3种异常终止方式):
- 在
main
函数内执行return语句。 - 调用
exit
函数。 - 调用
_exit
或_Exit
函数。 - 进程的最后一个线程在其启动列成中执行
return
语句。 - 进程的最后一个线程调用
pthread_exit
函数。 - 调用
abort
。 - 进程收到某些信号时。
- 最后一个线程对取消请求做出响应。
不管进程如何终止,最后都期望执行内核中的同一段代码。这段代码的功能为相应进程关闭所有打开描述符,释放它所使用的存储器资源(PCB):进程ID、终止状态、以及该进程使用的CPU时间总量。
正常情况下,我们是期望父进程去执行上述操作,对子进程进行善后处理。流程:父进程调用wait
或waitpid
等待子进程结束,会释放上述资源。否则子进程的退出状态没有被获取,相关资源会一直保存在内核中,成为僵尸进程。
思考:由上可知,进程退出,其资源的回收时依赖父进程,否则会成为僵尸进程。那么若父进程在子进程之前终止,情况又是如何呢?
答:unix 系统中,当一个进程终止时,内核会逐个检查所有活动进程,以判断它是否为即将终止进程的子进程。如果是,则将该进程的父进程ID更改为1(init 进程ID)。而init的实现逻辑:只要有一个子进程终止,init就会调用一个wait函数取得其终止状态,并进行资源回收。
总结:僵尸进程的产生原因:子进程退出,且父进程依旧运行,没有调用wait
或waitpid
系统调用来回收子进程的终止状态。
获取子进程终止状态
在上章节我们了解到父进程可以通过wait
、waitpid
获取子进程的终止状态,本章介绍如何使用,以及介绍如何获取子进程的相关资源信息。
#include<sys/wait.h> pid_t wait(int* statloc); pid_t waitpid(pid_t pid,int* statloc,int option); //两个函数返回值:若成功,返回进程ID;若出错,返回0。
区别如下:
- 没有任何一个子进程终止前,
wait
使其调用者阻塞,而waitpid
有一选项,可使调用者不阻塞。 waitpid
可通过入参,控制调用进程。比如:
pid == -1 : 等待任一子进程退出,此时与wait函数等效 pid > 0 等待进程ID与pid相等的子进程。即使不是父子进程关系 pid == 0 等待组ID等于调用进程组ID的任一子进程 pid < -1 等待组ID等于oid绝对值的任一子进程
获取进程终止状态的常见用法如下:
- 场景一:
使用wait()系统调用: wait()系统调用会使父进程阻塞,直到任一子进程终止。当子进程终止时,wait()会回收子进程,并返回终止子进程的进程ID。
#include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <stdlib.h> int main() { pid_t pid = fork(); if (pid > 0) { // 父进程 int status; pid_t child_pid = wait(&status); // 等待子进程退出 if (child_pid > 0) { printf("子进程 %d 已退出,状态: %d\n", child_pid, status); } } else if (pid == 0) { // 子进程 printf("子进程开始执行...\n"); sleep(1); // 模拟子进程工作 printf("子进程结束。\n"); exit(0); // 子进程退出 } else { // fork失败 perror("fork"); exit(1); } return 0; }
- 场景二:使用waitpid()系统调用: waitpid()系统调用类似于wait(),但它允许父进程指定等待哪个子进程,以及是否阻塞等待。
#include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <stdlib.h> int main() { pid_t pid = fork(); if (pid > 0) { // 父进程 int status; // 等待特定的子进程退出,这里使用WUNTRACED | WCONTINUED以捕获停止或继续的子进程 pid_t child_pid = waitpid(pid, &status, 0); if (child_pid > 0) { printf("子进程 %d 已退出,状态: %d\n", child_pid, status); } } else if (pid == 0) { // 子进程 printf("子进程开始执行...\n"); sleep(1); // 模拟子进程工作 printf("子进程结束。\n"); exit(0); // 子进程退出 } else { // fork失败 perror("fork"); exit(1); } return 0; }
对于场景一,在子进程未终止前,父进程会一直阻塞,导致无法执行后续业务。若应用场景,父进程并不关注子进程的终止状态,仅为了避免产生僵尸进程,可通过信号处理:父进程可以设置一个信号处理函数来处理SIGCHLD信号,该信号在子进程退出时由内核发送给父进程。
#include <sys/types.h> #include <sys/wait.h> #include <signal.h> #include <stdio.h> #include <stdlib.h> void sigchld_handler(int sig) { int status; pid_t child_pid = wait(&status); if (child_pid > 0) { printf("子进程 %d 已退出,状态: %d\n", child_pid, status); } } int main() { signal(SIGCHLD, sigchld_handler); // 设置SIGCHLD的处理函数 pid_t pid = fork(); if (pid > 0) { // 父进程 // ... 父进程可以继续其他工作 } else if (pid == 0) { // 子进程 printf("子进程开始执行...\n"); sleep(1); // 模拟子进程工作 printf("子进程结束。\n"); exit(0); // 子进程退出 } else { // fork失败 perror("fork"); exit(1); } return 0; }
无论是wait
还是waitpid
只能获取进程的终止状态。实际上内核为终止进程还保存了其它信息,比如:系统资源信息,用户CPU时间总量、系统CPU时间总量、缺页次数、收到信号的次数等。我们可以通过wait4
函数获取。
#include <sys/types.h> #include <sys/wait.h> #include <sys/time.h> #include <sys/resource.h> pid_t wait4(pid_t pid, int* statloc,int options,struct rusage* rusage);
使用示例:
#include <sys/types.h> #include <sys/wait.h> #include <sys/resource.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> int main() { pid_t pid = fork(); if (pid == -1) { // fork失败 perror("fork"); exit(EXIT_FAILURE); } if (pid > 0) { // 父进程 int status; struct rusage usage; pid_t child_pid; // 等待子进程退出,并获取资源使用情况 child_pid = wait4(pid, &status, 0, &usage); if (child_pid == -1) { perror("wait4"); exit(EXIT_FAILURE); } // 检查子进程是否正常退出 if (WIFEXITED(status)) { printf("子进程 %d 已退出,退出状态: %d\n", child_pid, WEXITSTATUS(status)); } // 打印资源消耗 printf("用户态CPU时间: %ld.%06ld秒\n", usage.ru_utime.tv_sec, usage.ru_utime.tv_usec); printf("核心态CPU时间: %ld.%06ld秒\n", usage.ru_stime.tv_sec, usage.ru_stime.tv_usec); printf("最大驻留集大小: %ld 千字节\n", usage.ru_maxrss); printf("页面错误次数: %ld\n", usage.ru_majflt); printf("自愿上下文切换次数: %ld\n", usage.ru_nvcsw); printf("非自愿上下文切换次数: %ld\n", usage.ru_nivcsw); } else { // 子进程 printf("子进程开始执行...\n"); // 子进程做一些工作,这里只是简单地睡眠一段时间 sleep(2); printf("子进程结束。\n"); exit(EXIT_SUCCESS); // 子进程正常退出 } return 0; }
输出如下:
xieyihua@xieyihua:~/test$ gcc 3.c -o 3 xieyihua@xieyihua:~/test$ ./3 子进程开始执行... 子进程结束。 子进程 5472 已退出,退出状态: 0 用户态CPU时间: 0.001042秒 核心态CPU时间: 0.000000秒 最大驻留集大小: 1028 千字节 页面错误次数: 0 自愿上下文切换次数: 2 非自愿上下文切换次数: 0 xieyihua@xieyihua:~/test$
总结
文章主要介绍了Unix系统中进程控制的相关知识,包括进程标识、创建进程、进程退出以及获取子进程终止状态的方法。
- 进程标识。
每个进程都有一个唯一的进程ID(PID),用于标识系统中的进程。PID是可复用的,当一个进程终止后,其PID就成为复用的候选者。 - 创建进程。fork函数是用来创建新进程的。父进程通过fork创建子进程,子进程是父进程的副本,包括数据空间、堆、栈的副本,并且父进程和子进程共享正文段。
- 进程退出。进程有多种方式退出,包括正常退出(如return语句、exit函数)和异常退出(如信号处理)。正常情况下,父进程应该调用wait或waitpid来回收子进程的终止状态,否则子进程的退出状态没有被获取,相关资源会一直保存在内核中,成为僵尸进程。
- 获取子进程终止状态。父进程可以通过wait、waitpid和wait4系统调用获取子进程的终止状态。wait和waitpid可以获取进程的终止状态,而wait4还可以获取进程的资源使用情况。
若我的内容对您有所帮助,还请关注我的公众号。不定期分享干活,剖析案例,也可以一起讨论分享。
我的宗旨:
踩完您工作中的所有坑并分享给您,让你的工作无bug,人生尽是坦途