引言
本文主要介绍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,人生尽是坦途