【Linux我做主】进程退出和终止详解
进程退出和终止
- 进程退出和终止
- github地址
- 0. 前言
- 1. 写时拷贝的细节
- 写时拷贝的触发机制
- 1. fork 之后(修改内容之前)
- 2. 写入保护触发时
- 3. 总结:写时拷贝的触发规则
- 2. 循环创建多个子进程
- 3. 进程终止
- 0. 进程终止的原因/情景
- 1. 为什么要有返回值
- 2. 获取程序的返回值
- 4. 退出码向错误信息的转换
- 库函数由错误码得到错误信息
- 自定义退出码体系
- 5. 父进程为什么要关心子进程的退出码
- 6. errno
- 7. 进程异常终止
- 异常终止后退出码无意义
- 异常终止时的场景
- 进程出现异常的本质
- 用发送信号模拟进程出现异常
- 8. exit终止进程
- 库函数exit的使用
- exit和return的区别
- 系统调用 _exit
- 现象对比
- 结论
- 9. 结语
进程退出和终止
github地址
有梦想的电信狗
0. 前言
在Linux系统编程中,进程的创建、执行和终止构成了操作系统最核心的生命周期管理。当我们使用fork()
创建子进程时,操作系统通过写时拷贝(Copy-on-Write) 机制高效地共享内存;当进程完成任务后,它如何向父进程传递执行结果?当程序意外崩溃时,操作系统又是如何捕获并处理异常?这些机制不仅关系到程序的正确性,更直接影响着系统的稳定性和可靠性。
本文将深入探讨以下核心问题:
- 写时拷贝的实现细节:操作系统如何通过页表权限控制实现高效内存共享
- 进程终止的完整流程:从正常退出的返回码到异常终止的信号机制
- 父子进程间状态传递:父进程如何获取子进程的执行结果和终止状态
- 系统调用与库函数的差异:
exit()
与_exit()
在缓冲区处理上的关键区别
1. 写时拷贝的细节
问题引入
- 在操作系统层面,他是如何知道,父子进程共享的这部分数据,是需要发生写时拷贝的呢
写时拷贝的触发机制
写时拷贝(Copy-on-Write)是操作系统在 fork 系统调用中提升效率的一种机制:
- 父进程在创建子进程时,不会立即复制整个进程的数据段和堆栈段。
- 父子进程先共享相同的物理内存页,只在需要写入时再进行真正的复制。
结合图片说明:
1. fork 之后(修改内容之前)
- 父进程和子进程的 虚拟内存空间 看起来各自独立,但它们的 页表项 都指向相同的物理内存页。
- 为了保证写时拷贝的正确触发,操作系统在 fork 完成后,会将 父子进程中所有可写的页表项临时标记为只读。
- 因此:
- 父进程的数据段页表项:只读
- 子进程继承父进程页表,也同样是只读
👉 这样做的目的是:不论父进程还是子进程**,一旦尝试写入共享数据**,就会触发一次 页保护异常(Page Fault)。
(对应图片左边的情况)
2. 写入保护触发时
当父进程或子进程尝试写入共享数据时:
- CPU 访问内存 → 发现页表项标记为只读 → 触发缺页异常(Page Fault)。
- 操作系统检查:这块内存页在历史上原本是可写的,只是因为写时拷贝机制被临时设置为只读。
- 发生此类缺页异常,操作系统并不做异常处理,而是执行写时拷贝:
- 给当前写入的进程分配一块新的物理内存页。
- 将旧的内容拷贝到新的物理页中。
- 更新当前进程的页表项,映射到新的物理页,并重新设置为可写。
- 这样,父子进程就不再共享这一块物理内存,各自拥有独立的副本。
👉 谁先写,谁就会得到一份新的独立拷贝。
(对应图片右边的情况)
3. 总结:写时拷贝的触发规则
- fork 时:
父进程的所有可写页表项被临时改为只读,子进程继承相同的只读页表。 - 写入时:
父子进程中的任意一方,只要尝试写入共享数据,就会触发页表权限错误(缺页异常)。 - 操作系统处理方式:
不直接报错,而是进行 拷贝-更新-恢复可写 的流程。谁先写,谁就会得到一份新的独立拷贝 - 最终效果:
读操作仍然共享物理内存,写操作才会真正复制,极大地提升了 fork 的效率。
以上规则仅适用于数据段,关于代码区,如果尝试对代码区进行修改,不会触发写时拷贝,具体的原理请读者自行研究
2. 循环创建多个子进程
我们可以利用循环创建多个子进程:
- 父进程
for
循环创建子进程,创建完成后不做任何事,接着创建 if (id == 0)
:id == 0
时为子进程,子进程执行特定的任务,执行结束后exit(0)
正常退出
#define N 5void runChild() {int cnt = 10;while (cnt--) {printf("I am child: pid: %d, ppid: %d\n", getpid(), getppid());sleep(1);}
}
// 如何创建 5个子进程
int main() {for (int i = 0; i < N; ++i) {pid_t id = fork();if (id == 0) {// 子进程runChild();exit(0); // 父进程没有等待,退出后,子进程会变成僵尸进程}// if 之后是父进程,什么都没做,接着执行循环}// 父进程等待 1000秒sleep(1000);return 0;
}
- 当调用
fork()
创建子进程后,父子进程会被同时放入操作系统的就绪队列中等待执行。它们的运行顺序由操作系统的进程调度器决定,用户无法预测或干预。这是操作系统的核心设计原则之一:调度器尽可能保证公平性,而非确定性。因此fork()
后父子进程谁先运行,无法确定,取决于调度器。
3. 进程终止
int main() {// ... return 0;
}
- 为什么
main
函数总是会return 0
,返回1, 2可以吗,这个返回值给了谁? 为什么要返回这个值
0. 进程终止的原因/情景
-
代码运行完毕,结果正确
-
代码运行完毕,结果不正确
-
代码异常终止
通常代码运行完毕,结果正确时,我们不会关注代码结果为什么正确。
我们最关注的是,代码运行完毕,为什么结果会不正确。或者代码异常终止,出现异常的原因是什么
1. 为什么要有返回值
int main() {printf("模拟一个逻辑的实现\n");return 0;
}
为什么
main
函数要返回 0
- 这里的 0 称为进程的退出码,用于表示进程的运行结果是否正确
0
通常表示运行success
- 为什么
main
函数要返回 0? 因为想通过return 0
告诉其他人,当前的程序:- 程序的代码运行完毕
- 运行结果正确
- 这里的返回值,是返回给了当前进程的父进程,告诉父进程子进程的运行结果如何。我们这里的父进程是
bash
,因此bash
中可以获取到子进程的退出码
进程的运行,我们不会关心进程为什么运行正确。我们关心的是,为什么进程运行不正确。
那么谁会关心一个进程的运行情况呢?
答案是:
- 父进程要关心子进程的运行结果,并且更多地要关心子进程运行结果不正确的原因。
如何表示子进程的运行结果呢?
可以用 return
返回不同的数字,表示不同的出错原因
即,进程不同的退出码,代表了不同的运行结果。
-
main
函数的返回值,本质表示:-
进程运行完成时,是否是正确的结果
-
如果不是,用返回不同的数字,表示不同的出错原因
-
2. 获取程序的返回值
- 这里将
main
函数的返回值手动设置为11
int main() {printf("模拟一个逻辑的实现\n");return 11;
}
- 命令行中,
echo $?
,用于获取最近一次执行的进程退出时的退出码
bash
中,$?
用于表示命令行中,最近一次执行的进程退出时的退出码./myproc
运行完后,退出码就被保存到了bash
中的?
变量中- 多次执行
echo $?
时,结果变成了0,这是因为最近一次执行的程序为echo $?
,echo
正确执行,返回值为0
4. 退出码向错误信息的转换
我们之前写的大多数是数据结构的代码,运行的结果我们可以通过
printf
或cout
,在终端中人工判断执行结果是否正确。但是,并不是所有的程序都会向终端中打印,因此,进程退出码的存在是很有必要的
库函数由错误码得到错误信息
尽管不同的错误需要返回不同的退出码,但我们仅仅通过退出码,无法快速地得知到底出现了什么错误
因此,C语言为我们提供相应的接口,用于将进程的退出码翻译成相应的错误信息
man strerror # 查看错误码转换的接口
- 查看不同的退出码对应的错误信息
int main() {for (int i = 0; i < 150; ++i) {printf("%d: %s\n", i, strerror(i));}return 0; // 进程的退出码 表征进程的运行结果是否正确 0->success
}
- 后面的数字,没有对应的错误信息,说明C语言提供的错误信息是有限的
- ls命令退出码的演示:用
ls
显示一个不存在的文件
- ls查看一个不存在的文件,执行错误。终端打印提示消息
No such file or directory
,细心的同学可以发现,这里的信息其实就是上述错误信息中的2号错误 - 发生错误后,使用
echo $?
查看上个进程的退出码,为2
,符合预期 - 再次执行
ls
,执行正确后,echo $?
结果为0,符合预期
自定义退出码体系
C语言提供的错误信息是有限的,系统提供的错误码和错误码描述是有对应关系的。
如果我们不满意系统提供的错误码和错误信息,我们可以自定义出一套错误码体系。实现时只需将相关的错误信息存在一个字符串数组中即可,以下给出示意
- 可以自定义不同的下标对应的错误信息。用下标表示退出码,下标对应的字符串表示错误信息
// 自定义错误码体系
const char* errorString[] = {"success", // 0表示成功"error1", // 可以自定义不同的下标对应的错误信息"error2","error3","error4","error5"
}
5. 父进程为什么要关心子进程的退出码
这里
ls
发生错误时,父进程bash
确实拿到了错误码,也就是进程ls
的退出码
- 但真正关心错误码的,本质是用户。
- 子进程是用户创建的,用户需要关注子进程是否执行成功。用户可以通过父进程,获取子进程执行的退出信息(退出码),方便用户根据子进程的退出信息(执行结果),做下一阶段的执行决策
综上:代码运行的结果是否正确,统一使用进程的退出码进行表示!!
6. errno
观察以下代码的运行结果:
int main() {int ret = 0;char* p = (char*) malloc(1000 * 1000 * 1000 * 4);if (p == NULL) {printf("malloc error\n");ret = 1;} else {// 使用内存的逻辑 ...printf("malloc success\n");}return ret;
}
- 上述的运行结果,我们调用C语言的库函数
malloc
函数出现错误,通过echo $?
,我们获取到进程的退出码为1,正是我们设置的malloc
出错时ret
的值
除了进程的退出码,C语言会为我们提供一个全局变量errno
- 我们调用C语言提供的库函数,如果库函数内部调用失败了,函数内会默认将全局变量
errno
设置成对应的数字,这个数字表示调用该函数时出错的错误码
-
全局变量
errno
中保存的是最近一次库函数出错时对应的错误码,多次出错,错误码会被覆盖。因此每次出错后,我们应该及时进行处理-
通过
printf
和strerror
函数,配合全局变量errno
获取malloc
的错误码及其错误信息的写法 -
printf("malloc error: %d, %s\n", errno, strerror(errno));
-
-
代码及运行结果展示:
int main() {int ret = 0;char* p = (char*) malloc(1000 * 1000 * 1000 * 4);if (p == NULL) {// 这么写,既能知道错误码,还能知道错误信息printf("malloc error: %d, %s\n", errno, strerror(errno));ret = errno; // 还能将错误码转换成进程的退出码,让父进程也知道出错了} else {// 使用内存的逻辑printf("malloc success\n");}return ret;
}
综上:
malloc
或其他库函数调用出现问题时,我们都可以通过**errno
配合strerror
函数获取到相应的错误码和错误信息**,同时通过进程的返回值,向父进程中返回相应的错误码
7. 进程异常终止
异常终止后退出码无意义
思考,如果代码异常退出,进程的退出码还有意义吗
-
正常情况:
main
函数的返回值-
在语言层面上,
main
函数执行return
语句时,会返回一个值。 -
在操作系统系统层面上,当
main
函数返回时,C 运行时库会调用exit()
,从而结束进程,并将main
的返回值作为 进程退出码。 -
因此,正常情况下,进程的退出码是有意义的,能够反映程序的返回状态。
-
-
异常情况:程序异常终止
-
如果程序在执行过程中发生了错误(如非法访问内存、除零错误、接收到致命信号等),进程可能会 提前终止。
-
在这种情况下,进程可能根本没有执行到
main
函数的最后一行return
,甚至可能main
已经返回了,但在随后的代码中又触发了异常。 -
结果就是:
- 进程并不是通过
main
的返回值退出的; - 系统会根据异常情况(信号)来终止进程;
- 此时我们看到的 退出状态 只说明进程是被信号杀死的,而不再携带有意义的退出码。
- 进程并不是通过
-
-
我们能否知道进程是否执行了
return
?-
答案是:无法确定。
-
原因在于:
- 进程的异常终止是由操作系统在信号机制下完成的;
- 操作系统不会告诉我们进程是否执行过
main
中的return
; - 我们只能通过
wait/waitpid
提供的宏(如WIFSIGNALED
、WTERMSIG
)来判断进程是否因信号终止,但无法得知异常发生的具体代码位置。
-
- 结论:
- 当进程 正常退出 时,退出码反映的是
main
的返回值,具有实际意义。 - 当进程 异常终止 时,进程的退出码就无意义了,我们不再关心进程的退出码,而是关注进程 异常终止的信号。
- 当进程 正常退出 时,退出码反映的是
但对于异常退出的进程,我们不关心退出码,如何知道进程是正常运行结束,还是异常退出终止呢?
我们如何知道进程发生了什么异常,以及发生异常的原因呢?
因此进程退出时,我们要:
- 先关注进程有没有出现异常。
- 如果没有异常,再看进程运行的结果是否正确(再关注退出码)
异常终止时的场景
- 对空指针解引用的异常
// 对空指针解引用的异常
int main() {int* p = NULL;*p = 10;return 0;
}
-
这里对空指针进行解引用,本质上是要访问虚拟地址,但该虚拟地址并没有在页表中建立和物理内存的映射关系,或者该虚拟地址在页表中的权限位被设置为只读
-
除0异常
int main() {int a = 10;a /= 0;return 0;
}
进程出现异常的本质
这里给出结论:
- 不论进程出现什么异常,本质都是进程收到了对应的信号
用发送信号模拟进程出现异常
- 死循环程序,向该进程发送信号使其中止
int main() {while (1) {printf("hello Linux: pid: %d\n", getpid());sleep(1);}return 0;
}
-
向进程发送8号信号
-
kill -8 757104
-
发送完8号信号后,进程出现了浮点数异常
-
向进程发送11号信号
-
kill -11 757113
-
发送完11号信号后,进程出现了段错误异常
结论:
- 不论进程出现什么异常,本质都是进程收到了对应的信号
- 判断子进程退出时有没有异常,只需要判断子进程退出时,有没有收到信号
8. exit终止进程
库函数exit的使用
man
手册中exit
的描述
// exit 退出进程
int main() {printf("hello Linux\n");exit(12);
}
可以看到,调用
exit
后,该进程退出,并向**父进程(bash进程)**返回了exit()
中的数字
总结:
-
在程序当中调用
exit(int status)
时,传入的数字,会作为进程退出的退出码。 -
exit(12)
和return 12
在main
函数中是等价的
exit和return的区别
- 在
show()
函数中exit(13)
,同时在main
函数中return 12;
void show() {printf("hello show 1\n");printf("hello show 2\n");printf("hello show 3\n");printf("hello show 4\n");exit(13);return;
}
int main() {show();printf("hello Linux\n");return 12;
}
运行结果如下:获取的退出码为13,且main
函数中的printf("hello Linux\n");
没有被执行
- 在
show()
函数中仅return
,只在main
函数中return 12;
void show() {printf("hello show 1\n");printf("hello show 2\n");printf("hello show 3\n");printf("hello show 4\n");// exit(13);return;
}
int main() {show();printf("hello Linux\n");return 12;
}
运行结果如下:获取的退出码为12,main
函数中的printf("hello Linux\n");
正确执行
总结exit和return的区别:
-
return:
-
在其他函数中
return
,代表函数结束,返回到调用者,控制的是**“当前函数结束**” -
在
main
函数中return
,等价于调用exit
,代表进程退出,返回值作为退出码
-
-
exit:
exit
在任意地方调用,都代表进程退出,控制的是“进程结束”
系统调用 _exit
现象对比
_exit
是系统调用,和exit
都有终止进程的作用,那exit
和_exit
有什么区别呢,我们看如下代码示例
exit
示例
int main() {printf("You can see me ");sleep(1);exit(11);// _exit(11);
}
运行结果如下:
- 我们的进程正常退出,字符串
"You can see me "
被正常打印到显示器中。 - 我们在进度条的实现中知道,
printf()
函数向显示器中打印时,是先把数据写入到缓冲区,合适的时候再把数据刷新出来 - 因此这里是,
exit()
退出进程时,缓冲区中的数据被刷新了出来
_exit
示例
int main() {printf("You can see me");sleep(1);// exit(11);_exit(11);
}
- 调用
_exit
,字符串并没有被正常打印,也就是缓冲区中的内容没有被刷新出来,因为我们没有写可以刷新缓冲区的return
或\n
结论
-
_exit()
的头文件是<unistd.h>
,属于系统调用。系统调用_exit
直接在内核层面终止当前进程 -
exit()
的头文件是<stdlib.h>
,属于库函数;exit()
执行时,会先执行用户定义的清理函数,再重刷缓冲区,关闭流等,最终再调用系统调用_exit()
终止进程
- 推断一下缓冲区所处的位置:缓冲区一定不在内核空间中,而是处于进程地址空间的用户空间中
- 调用
exit
时的现象,会先执行fflush()
→ 刷新标准 I/O 缓冲区 → 写到内核。 - 调用
_exit
时的现象,不做刷新 ,直接在内核层面终止进程→ 缓冲区内容丢失。
- 调用
如果缓冲区在内核空间,二者结果应该一致,不会因为 _exit
跳过用户态步骤而丢失数据。
9. 结语
通过本文的探讨,我们对Linux进程的退出和终止机制有了全面深入的理解:
-
写时拷贝的精密控制
操作系统通过临时设置共享页为只读,在首次写入时触发缺页异常完成物理拷贝,实现了高效的内存共享。这种机制在
fork()
调用中显著提升了性能。 -
进程退出的双通道信令
- 正常退出:通过返回值传递退出码(0表示成功,非0表示错误类型)
- 异常终止:由信号机制触发(如SIGSEGV/SIGFPE),此时退出码失去意义
-
终止调用的层次差异
调用方式 缓冲区处理 执行位置 使用场景 exit()
刷新用户空间缓冲区 库函数 需要清理资源的场景 _exit()
不刷新缓冲区 系统调用 立即终止的紧急场景
关键收获:
- 程序应通过返回有意义的退出码帮助父进程诊断问题
- 异常处理时优先检查
errno
获取详细错误信息 - 在信号处理函数中必须使用
_exit()
防止缓冲区操作冲突
以上就是本文的所有内容了,如果觉得文章对你有帮助,欢迎 点赞⭐收藏 支持!如有疑问或建议,请在评论区留言交流,我们一起进步
分享到此结束啦
一键三连,好运连连!
你的每一次互动,都是对作者最大的鼓励!
征程尚未结束,让我们在广阔的世界里继续前行!
🚀