2022年,卷的第二篇,这一篇主要是描述进程的终止,回收,替换。内容是比较多。
11.1 进程终止
进程既然有创建,那肯定是有终止的,都是存在一个生命周期的。
在不考虑线程的情况下,进程的退出有以下5种方式:
- 在main函数内执行return语句。
- 调用exit函数。
- 调用_exit或_Exit函数。
异常退出条件有两种:
- 调用abort
- 当进程接收到某些信号时。
正常退出的我们来学习一下,异常退出等到信号的时候再说。
11.1.1 _exit函数
还是先来看看函数的原型:
#include <unistd.h>
void _exit(int status);
一看这个头文件就是linux系统提供的函数了,调用这个函数直接进入内核态,进程程序的退出。
如果我们需要返回子进程的状态给父进程,就需要用到status这个变量。(父进程怎么接收,下面介绍)
虽然这个status是int类型,但是其实只有低8位有效,也就是一个无符号字符型,这个我们等下写一个例子,返回-1,来看看是个什么值就明白了。
#include <unistd.h>
int main(int argc, char **argv)
{
_exit(-1);
}
这次代码简单了,不骗字数了,哈哈哈。
然后我们来编译执行一下:
root@ubuntu:~/c_test/10# gcc _exit.c -o _exit
root@ubuntu:~/c_test/10# ./_exit
root@ubuntu:~/c_test/10# $?
255: command not found
root@ubuntu:~/c_test/10#
$?就是shell中,返回上一条命令的返回值,我们明明返回的是-1,shell这边竟然是255,说明这是一个无符号的字符型。
返回255就算了,竟然还显示command not found,你说气人不?
这个就跟shell编程相关了,shell对这个0-255有如下的区别和含义:
值 | 含义 |
---|---|
0 | 命名成功执行并退出 |
1~125 | 命令未成功地退出,具体含义由各自的命令来定义 |
126 | 命令找到了,文件无法执行 |
127 | 命令找不到 |
>128 | 命令因收到信号而死忙 |
我试了几个返回值,发现都是命令找不到,真奇怪,难道这些都是需要配置的?
11.1.2 exit函数
我们还是来继续看函数原型:
#include <stdlib.h>
void exit(int status);
这个函数一看就是glic中的函数,也的确是这样,exit是对_exit函数的一个封装。封装的内核有:
- 执行用户通过调用atexit函数或on_exit定义的清理函数
- 关闭所有打开的流,所有缓冲区的数据均被写入(flush),通过tmpfile创建的临时文件都会被删除。(如果进程占用内存不释放,或者打开文件,不关闭文件,这里都是可以帮忙释放)
- 调用_exit。
看着exit函数这么好,是不是就没有缺点了呢?
其实是有的一定的局限性的:当进程正常退出时,会调用C库的exit;而当程序或被kill掉时,c库的exit则不会被调用,只会执行内核退出进程的操作。
下面我们试试冲刷的例子:
// _exit例子
#include <unistd.h>
#include <stdio.h>
int main(int argc, char **argv)
{
printf("hello world");
_exit(126);
}
// exit例子
#include <stdlib.h>
#include <stdio.h>
int main(int argc, char **argv)
{
printf("hello world");
exit(126);
}
各自编译运行的结果:
root@ubuntu:~/c_test/10# gcc _exit.c -o _exit
root@ubuntu:~/c_test/10# gcc exit.c -o exit
root@ubuntu:~/c_test/10# ./_exit
root@ubuntu:~/c_test/10# ./exit
hello worldroot@ubuntu:~/c_test/10#
11.1.3 return函数
return函数是我们经常在main函数中使用的退出进程的函数,其实执行return(n)等同于执行exit(n)。
下面是《unix环境高级编程》中的图:
这个描述的还真不错。
11.1.4 atexit函数
既然上面都说了这个绑定回调的函数,我们就来试一波这个函数吧。
#include <stdlib.h>
int atexit(void (*func)(void));
写一个例子测试一波就可以了:
#include <stdlib.h>
#include <stdio.h>
void exit1()
{
printf("exit1\n");
}
void exit2()
{
printf("exit2\n");
}
int main(int argc, char **argv)
{
atexit(exit1);
atexit(exit2);
printf("main return\n");
return 0;
}
都是比较简单的例子,编译执行:
root@ubuntu:~/c_test/10# ./atexit
main return
exit2
exit1
root@ubuntu:~/c_test/10#
有点想栈,先绑定的,后执行。
11.2 等待子进程
上面我们已经介绍了进程的终止,但是进程终止之后,父进程是不是需要知道子进程是怎么终止的么?
比如是正常的提出,或者是被信号终止的。所以linux系统提供了等待回收子进程的函数。
11.2.1 wait()
先来看看函数原型:
#include <sys/wait.h>
pid_t wait(int *status);
成功时,返回已退出子进程的进程ID;失败时,返回-1,并设置errno。
errno | 说 明 |
---|---|
ECHLD | 调用进程时发现并没有子进程需要等待 |
EINTR | 函数被信号中断 |
参数:
status:进程退出时的状态信息。
宏 | 说 明 |
---|---|
WIFEXITED(status) | 正常终止子进程返回状态。 WEXITSTATUS(status)获取状态 |
WIFSIGNALED(status) | 异常终止子进程返回状态。 WTERMSIG(status)获取使子进程终止的信号编号。 |
WIFSTOPPED(status) | 暂停子进程的返回状态。 WSTOPSIG(status)获取使子进程暂停的信号编号 |
WIFCONTINUED(status) | 在暂停后的子进程又开始运行,返回这个状态。 (仅用于waitpid) |
来写一个例子:
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void pr_exit(int status)
{
if(WIFEXITED(status))
printf("normal exit, exit status = %d\n", WEXITSTATUS(status));
else if(WIFSIGNALED(status))
printf("abnormal exit, signal number = %d\n", WTERMSIG(status));
else if(WIFSTOPPED(status))
printf("child stop, signal number = %d\n", WSTOPSIG(status));
}
int main(int argc, char **argv)
{
pid_t pid = fork();
if(pid < 0 )
{
printf("pid err\n");
return 0;
}
if(pid == 0)
{
exit(-1);
}
printf("pid = %d\n", pid);
int status = 0;
wait(&status); // 阻塞的函数
pr_exit(status);
pid = fork();
if(pid < 0 )
{
printf("pid err\n");
return 0;
}
if(pid == 0)
{
abort(); // 信号的函数
}
printf("pid = %d\n", pid);
status = 0;
wait(&status); // 阻塞的函数
pr_exit(status);
pid = fork();
if(pid < 0 )
{
printf("pid err\n");
return 0;
}
if(pid == 0)
{
status /= 0;
}
printf("pid = %d\n", pid);
status = 0;
wait(&status); // 阻塞的函数
pr_exit(status);
return 0;
}
编译运行:
root@ubuntu:~/c_test/10# ./wait
pid = 1997
normal exit, exit status = 255
pid = 1998
abnormal exit, signal number = 6
pid = 1999
abnormal exit, signal number = 8
这个例子把子进程中的三种状态都做了处理了。
但是这个wait函数也是有缺点的:
- 不能等待特定的子进程
- 阻塞函数,如果不存在子进程退出,只能一直阻塞
- wait函数只能获取子进程终止的事件,不能接收子进程先暂停再回复的事件。
所以linux又引入了waitpid()函数。
11.2.2 waitpid()
继续,先看看函数原型:
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
功能:等待子进程终止,如果进程终止了,此函数会回收子进程资源。
来看看pid参数的意义:
pid的值 | 说明 |
---|---|
pid > 0 | 等待进程ID与pid相等的子进程 |
pid = 0 | 等待与调用进程同一个进程组的任意子进程 |
pid = -1 | 等待任意子进程 |
pid < -1 | 等待进程组ID与pid绝对值相等的所有子进程 |
status的参数意义跟上一节的wait是一样的。
options的参数意义:
参数 | 说明 |
---|---|
WCONTINUED | 除了关心终止进程的信息,也关心那些因收到信号而恢复执行的子进程的状态 |
WNOHANG | 指定子进程并未发送状态变化,立刻返回,不会阻塞。 没有这个pid返回-1,并设置errno为ECHILD。如果没有子进程等待,返回0。 |
WUNTRACE | 除了关心终止子进程信息,也关心那些因信号而停止的子进程信息 |
如果是正在返回的话,返回值也是子进程的pid。
例子:
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void pr_exit(int status)
{
if(WIFEXITED(status))
printf("normal exit, exit status = %d\n", WEXITSTATUS(status));
else if(WIFSIGNALED(status))
printf("abnormal exit, signal number = %d\n", WTERMSIG(status));
else if(WIFSTOPPED(status))
printf("child stop, signal number = %d\n", WSTOPSIG(status));
}
int main(int argc, char** argv)
{
pid_t pid = -1;
pid = fork();
if(pid < 0)
{
printf("fork err\n");
return -1;
} else if(pid == 0)
{
sleep(1);
abort();
}
// 父进程
int status = 0;
// 非阻塞
int ret = waitpid(pid, &status, WNOHANG);
printf("ret = %d\n", ret);
if(ret != 0)
{
pr_exit(status);
}
// 阻塞
ret = waitpid(pid, &status, 0);
printf("ret = %d\n", ret);
if(ret != 0)
{
pr_exit(status);
}
return 0;
}
这个例子测试了一下,阻塞和非阻塞。以后有机会再用其他的。
root@ubuntu:~/c_test/11# ./waitpid
ret = 0
ret = 2375
abnormal exit, signal number = 6
root@ubuntu:~/c_test/11#
运行的结果,跟我们描述的差不多。
如果我们不想关心进程的终止事件,只关心进程的停止事件,好像waitpid函数也做不到,所以又引入一个新的函数,waitid()。(真的是一个接着一个)
11.2.3 waitid()
还是看函数原型:
#include <sys/wait.h>
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
不过是最后优化的函数,参数是真的多啊。
idtype跟id两个参数决定需要等待哪个进程:
常量 | 说明 |
---|---|
idtype == P_PID | 精准打击,等待进程ID等于id的进程 |
idtype == P_PGID | 在所有子进程中等待进程组ID等于id的进程 |
idtype == P_ALL | 等待任务子进程,id被忽略 |
options这个选项是需要位或了,(waitpid是固定的)
常量 | 说明 |
---|---|
WEXITED | 等待子进程的终止事件 |
WSTOPPED | 等待被信号暂停的子进程事件 |
WCONTINUED | 等待进程也暂停,然后再恢复执行的子进程 |
WNOHANG | 指定子进程并未发送状态变化,立刻返回,不会阻塞。 没有这个pid返回-1,并设置errno为ECHILD。如果没有子进程等待,返回0。 |
WNOWAIT | 不破坏子进程退出状态,只负责获取信息,之后可以由wait、waitpid、waitid调用取得 |
看一下第三个参数infop,这个参数是输出参数:
typedef struct
{
int si_signo; /* Signal number. */
int si_errno; /* If non-zero, an errno value associated with
this signal, as defined in <errno.h>. */
int si_code; /* Signal code. */
__pid_t si_pid; /* Sending process ID. */
__uid_t si_uid; /* Real user ID of sending process. */
void *si_addr; /* Address of faulting instruction. */
int si_status; /* Exit value or signal. */
long int si_band; /* Band event for SIGPOLL. */
__sigval_t si_value; /* Signal value. */
} siginfo_t;
因为是接收子进程的信号,所以si_signo=SIGCHLD.
si_code的意义:
常量 | 说明 |
---|---|
CLD_EXIT | 子进程正常退出 |
CLD_KILLED | 子进程被信号杀死 |
CLD_DUMPED | 子进程被信号杀死,并产生了core dump |
CLD_STOPPED | 子进程被信号暂停 |
CLD_CONTINUED | 子进程被SIGCONT信号恢复 |
CLD_TRAPPED | 子进程被跟踪 |
si_status跟之前的wait()和waitpid()意义相同。
返回值:
成功等到子进程的变化,并取回响应的信息,返回0,但是si_pid返回是子进程的pid.
设置了WNOHANG标志位,并且子进程状态无变化,也返回0,但是si_pid也是0.
搞一个例子:
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char **argv)
{
pid_t pid = 0;
pid = fork();
if(pid < 0)
{
printf("fork err\n");
return 0;
}
if(pid == 0)
{
// 子进程
sleep(10);
exit(-1);
}
//
siginfo_t info;
memset(&info, 0, sizeof(siginfo_t));
int ret = waitid(P_ALL, pid, &info, WEXITED | WNOHANG); // WNOHANG只填这个标记好像不行
printf("ret = %d\n", ret);
if(ret != 0)
{
perror("waitpid");
return 0;
}
if(info.si_pid == 0)
{
// 子进程没有发生变化
printf("info.si_pid = %d\n", info.si_pid);
} else {
// 子进程发现了变化
printf("info.si_pid = %d\n", info.si_pid);
}
memset(&info, 0, sizeof(siginfo_t));
ret = waitid(P_PID, pid, &info, WEXITED);
printf("ret = %d\n", ret);
if(ret != 0)
{
printf("waitid err\n");
return 0;
}
if(info.si_pid == 0)
{
// 子进程没有发生变化
printf("info.si_pid = %d\n", info.si_pid);
} else {
// 子进程发现了变化
printf("info.si_pid = %d\n", info.si_pid);
}
return 0;
}
编译运行结果:
root@ubuntu:~/c_test/11# gcc waitid.c -o waitid
root@ubuntu:~/c_test/11# ./waitid
ret = 0
info.si_pid = 0
ret = 0
info.si_pid = 1564
都是正常反应。
11.2.4 wait4()
这个函数就不做过多介绍了,这是一个系统调用,上面的wait、waitpid、waitid都是通过调用wait4这个函数来操作的,我们现在的目标先不研究内核,内核的东西太多了。
看看函数原型的就可以了:
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/time.h>
#include <sys/resource.h>
pid_t wait4(pid_t pid, int *status, int options, struct rusage *rusage);
11.2.5 僵尸进程
我们都知道进程是会终止的,终止的时候,内核会负责回收一部分资源,仍会保留子进程的pid,子进程的退出状态,就是上面我们说了好多的wait函数,就是获取子进程退出状态,并且回收剩下的资源。
但是有时候我们编程的时候,忘记调用了wait系列函数,这时候进程的一些资源得不到释放,就造成了这时候的进程处在了一个僵尸状态。(上一节讲进程状态的时候,有讲)
产生僵尸进程也很简单:
#include <stdio.h>
#include <unistd.h>
int main(int argc, char **argv)
{
pid_t pid = 0;
pid = fork();
if(pid < 0)
{
printf("fork err\n");
} else if(pid == 0)
{
exit(0); // 退出子进程
} else {
sleep(300); // 这个300秒钟,子进程就是僵尸状态
wait(NULL);
}
return 0;
}
那怎么查看这个进程是不是僵死进程,就利用到上一节讲的查看进程的状态了。
root 1690 0.0 0.0 4220 784 pts/1 S+ 18:04 0:00 ./jiangshi
root 1691 0.0 0.0 0 0 pts/1 Z+ 18:04 0:00 [jiangshi] <defunct>
root 1692 0.0 0.3 37364 3340 pts/2 R+ 18:04 0:00 ps aux
Z的状态就是僵尸状态。
如果真的不需要回收子进程的信号,可以通过设置信号,忽略这个子进程的信号。
11.2.6 孤儿进程
竟然都有忘记回收,那也有可能父进程先结束了,子进程还在,这时候的子进程被称为孤儿进程。出现孤儿进程的时候,内核是怎么处理的呢?
内核还是很人性化的,每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为init,而init进程会循环地wait()它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了自己的时候,init进程会代表政府出面处理它的一切善后工作。
写个例子:
#include <stdio.h>
#include <unistd.h>
int main(int argc, char **argv)
{
pid_t pid = 0;
pid = fork();
if(pid < 0)
{
printf("fork err\n");
} else if(pid == 0)
{
sleep(300); // 父进程先退出
exit(0);
} else {
exit(0); //
}
return 0;
}
这个也比较简单,父进程就直接退出,太不负责任了。
我们用ps -ef来查看一下,就得出ppid=1就是init进程。
root 1752 1 0 18:14 pts/1 00:00:00 ./guer
11.3 进程替换
还记得这篇文章写的重学计算机(六、程序是怎么运行的)execve函数么,没错终于到了exec函数家族的出现了,在程序是怎么运行的这篇execve函数只要是把程序中各个段加载到内存中,最后执行程序。
现在我们就来好好了解一波。
11.3.1 execve函数
我们还是先来看看函数原型:
#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp);
其实这个函数才是系统调用,exec家族中其他函数都是glibc封装的。
现在介绍一下参数,这些参数后面都会提到:
- const char *filename:程序文件名。好像是需要绝对路径和相对于当前工作目录的相对路径
- char *const argv[]:这个变量是不是很熟悉,就是我们main函数中的参数。
- char *const envp:最后一个是环境变量,我们之前写代码,是不是可以直接用环境变量,就是这里传参的,但是这个需要我们传入环境变量,感觉还是不是很方便。
复制过来的,哈哈哈。这几个参数,在我们详细讲main函数的时候,就会明白了。
这个execve函数有一个特点,就是执行成功之后,就不返回了,返回的话就一定是失败。这个也是我都把父进程的内存那些东西替换成自己的了,执行成功也就不必要返回了。
我们来看看常用的返回的错误码:
错误码 | 说明 |
---|---|
EACCESS | filename不是个普通文件,或者没有执行权限,目录不可搜索。 |
ENOENT | 文件不存在 |
ETXTBSY | 存在其他进程尝试修改filename所指向文件 |
ENOEXEC | 文件存在,无法执行。比如文件格式不对 |
11.3.2 exec家族
glibc在内核的基础上有进行了一次封装,glibc真的是贴心啊,内核提供的参数比较多,特别是环境变量,真难受。是不是这里就想到main函数不是也没有环境变量么?其实main函数还真有环境变量,只不过是不写就默认有,这个等到讲到main函数的时候就明白了。
我们来看看剩下的6个兄弟函数:
#include <unistd.h>
extern char **environ;
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *envp[]);;
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
总结一个这6个函数的特点:
函数名 | 参数格式 | 是否自动搜索path | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 否 | 是 |
execlp | 列表 | 是 | 是 |
execle | 列表 | 否 | 否 |
execv | 数组 | 否 | 是 |
execvp | 数组 | 是 | 是 |
execve | 数组 | 不是 | 否 |
写个例子来试试吧:
#include <unistd.h>
#include <stdio.h>
char *const ps_argv[] = {"ps", "-ax", NULL};
char *const ps_envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
int main()
{
// ps
execl("/bin/ps", "ps", "-ax", NULL); // 第一个argv是自己,NULL是argv的结束
execlp("ps", "ps", "-ax", NULL); // 这个自己搜索path的
execle("/bin/ps", "ps", "-ax", NULL, ps_envp); // 带e环境变量自己拼
// 参数都是数组了
execv("/bin/ps", ps_argv);
execvp("ps", ps_argv); //自己搜索path
execve("/bin/ps", ps_argv, ps_envp);
return 0;
}
11.3.3 exec简单实现
这个简单实现,其实在程序是怎么运行的,也应该讲过了,基本是差不多的。
我们知道linux下可以执行很多种程序,比如elf格式的,shell,python,还有java程序,那我们的linux系统是怎么知道这些格式的?
没错,就是一个文件的头信息,我记得当初有分析过hex,bin,还有elf文件,每种格式文件都有自己的头信息,所以linux就是根据这个头信息来执行的。
比如ELF的头就是0x7f、‘e’、‘l’、‘f’,java的可执行文件格式的头4个字节为’c’、‘a’、‘f’、‘e’,解释型语言,第一行就是"#!/bin/sh"或"#!/usr/bin/prel"或"#!/usr/bin/python"。
竟然头信息都找到,那接下来就,根据文件的类型,去匹配不同的加载器。不同的加载器处理不同的可执行文件。
如果是ELF文件,就可以回到这一篇重学计算机(六、程序是怎么运行的)
11.3.4 执行exec之后属性
我们之前都说执行了exec函数之后,就会抛弃原来的东西,那有没有什么是没有抛弃,继承下来的呢?
继承过来的属性:
属性 | 属性 |
---|---|
进程ID | 根目录 |
父进程ID | 文件模式创建掩码 |
进程组ID | 文件锁和记录锁 |
会话ID | 进程信号屏蔽 |
控制终端 | 进程挂起的信号 |
真实用户ID | 已用的时间 |
真实组ID | 资源限制 |
附加组ID | nice值 |
告警剩余时间 | semadj值 |
当前工作目录 |
进程挂起信号:子进程会将挂起信号初始化为空。
信号量调整semadj:子进程不继承父进程的改值。
记录锁(fcntl):子进程不继承父进程的记录锁。文件锁flock子进程是继承的
已用时间times:子进程将该值初始化为0.
11.3.5 system函数
system其实是fork exec waitpid三个函数的集合,glibc又给我们封装出来的一个函数,方便我们调用命令。
也正是因为方便了,导致这个system这个返回值,让我很难受,system返回值,是三个系统调用的返回值凑一起的,所以很难受。
先来看看函数原型:
#include <stdlib.h>
int system(const char* command);
我们自己实现一个system函数,(因为没讲信号,所以暂时忽略信号的处理,等到信号的部分,在加进来)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int system(const char *command)
{
pid_t pid;
int status;
// 先判断参数
if(command == NULL)
{
return 1;
}
pid = fork();
if(pid < 0)
{
status = -1; // fork失败返回-1
}
else if(pid == 0)
{
execl("/bin/sh", "sh", "-c", command, NULL);
_exit(127); // 如果execl执行的有问题,返回127
}
else
{
// 父进程负责回收
while(waitpid(pid, &status, 0) < 0)
{
if(errno != EINTR) // 如果不是系统调用中断的,所有有问题
{
status = -1;
break;
}
}
}
return status;
}
考虑了一下,发现这个system函数还是不完全的,就不做分析了,等到信号的时候,写一个全面的system函数再分析吧。
11.4 总结
这一篇讲的内容很多,其实可以每一节都分出来单独做一篇,但想想后面的东西还有很多,就凑一起了,不分开了。