本章节简单罗列了一下在Linux进程中我们经常遇到的函数与情况,会持续更新补充
目录
进程的终止退出函数exit()、_exit()、信号杀死、return
一、孤儿进程 (内核给找新爹)
在学习到孤儿进程的时候有一个我刚开始没理解明白的点。根据概念来说,正常情况下,子进程B由父进程A创建并管理。如果父进程A先结束,子进程B就成了 “孤儿”—— 虽然它还在运行,但原来的父进程A已经不存在了,此时内核进行自动处理,当一个进程(比如父进程A)终止时,操作系统内核会做一件特殊检查,遍历所有正在运行的进程,找出哪些是这个终止进程的子进程B。对于找到的这些 “孤儿进程”,内核会将它们的父进程 ID(PPID)强制修改为 1—— 也就是系统的初始化进程(init)。从此,这些孤儿进程就被 init 进程 “领养” 了。
到这里我原来有一个误区就是我原本以为是内核将A进程ID改为init(ID=1)了?但是A进程不是已经崩溃退出了吗?难道又给抢救回来了?(形象比喻一下~)我认为也就是内核修改A的进行ID号为1,相当于只改了一个虚的标签,而A的实体已经没有了
其实是正确合理的解释是内核修改了B的程序控制块中的一个部分信息,当父进程 A 退出后,子进程 B 成为孤儿进程,内核会直接修改子进程 B 的进程控制块(PCB) 中的一个特定字段 ——“父进程 ID(PPID)”,将其从原来的 “A 的进程 ID” 更新为 “1(init 进程的 ID)”。
在这里我忽略了一个概念就是进程控制块(PCB)它是内核中记录进程状态的核心数据结构,包含进程 ID(PID)、父进程 ID(PPID)、内存地址、状态等关键信息。存储在内核空间的内存中,具体位置取决于操作系统的设计,但通常位于以下区域:
内核态内存区域
PCB 属于操作系统内核管理的数据,因此被严格保存在内核空间(用户进程无法直接访问的内存区域)。这是为了安全性和稳定性 —— 防止用户程序意外修改进程的关键控制信息(如进程状态、权限、调度信息等)。连续内存或专用数据结构中
不同操作系统会采用不同的存储方式:
- 有些系统会为 PCB 分配连续的内存块,形成一个 “进程表”(Process Table),集中管理所有进程的 PCB。
- 有些系统(如 Linux)会将 PCB 与其他内核数据结构(如任务结构体
task_struct
)结合,通过链表或哈希表等方式组织,便于快速查找和管理。与进程状态关联
即使进程处于休眠、阻塞等非运行状态,其 PCB 也会一直保存在内核空间中,直到进程终止并被内核彻底回收(此时 PCB 才会被释放)。
这个修改仅针对 B 的 PCB 中 “PPID” 这一项,不涉及其他内容,也不会影响已退出的 A(A 的 PCB 已被标记为终止,后续会被回收)。
二、僵死进程(爹的失职)
僵死进程(Zombie Process)是指子进程已经终止,但父进程没有及时回收它的资源(比如进程 ID、退出状态等),导致子进程的进程控制块(PCB)仍残留在内核中的状态。它就像一个 “已死亡但未被安葬的进程”,虽然不再运行,却还占用着系统的 “名额”(进程 ID 是有限资源)。
生动举例:餐馆里的 “空桌子”
想象你去一家餐馆吃饭:
- 父进程 是餐馆的服务员,负责接待你(子进程)、记录你的消费(管理子进程),最后等你吃完(子进程终止),需要收拾桌子(回收资源)。
- 子进程 是你,吃完饭后离开(进程终止)。
- 正常情况:你走后,服务员立刻过来收拾桌子、擦掉账单(父进程调用
wait()
等函数回收子进程资源),桌子(进程 ID)可以重新给新客人用。- 僵死进程情况:你走后,服务员突然走神了(父进程忘记调用回收函数),虽然桌子上没人了(子进程已终止),但桌子还被标记为 “有人”(PCB 未释放),账单也还留在桌上(退出状态未回收)。这时,这张桌子就成了 “僵死桌”—— 占着位置却没用,新来的客人(新进程)可能因为没桌子(进程 ID 耗尽)而无法入座。
技术角度再解释:
当子进程终止时,会先释放自己的代码、数据等内存,但会保留一小部分信息(如退出状态、进程 ID)在 PCB 中,等待父进程来 “取走” 这些信息(通过
wait()
或waitpid()
系统调用)。如果父进程一直不处理,这些残留的 PCB 就成了僵死进程。
僵死进程不会占用 CPU 或内存资源,但会占用进程 ID。如果系统中僵死进程太多,可能导致新进程无法创建(因为进程 ID 是有限的,比如 Linux 中默认最大 PID 为 32768)。
三、防止僵死进程的出现(wait、waitpid)
这两个函数就是用于避免僵死进程出现的用于父进程等待子进程结束并回收其资源,让父亲关注儿子的状态,在子进程终止时释放PCB等资源
wait是最简单的等待函数
#include <sys/wait.h>
pid_t wait(int *status);
功能:
- 父进程调用
wait
后会阻塞,直到它的任意一个子进程终止; - 回收该子进程的资源,彻底清除其 PCB;
- 通过
status
指针返回子进程的退出状态(如退出码、终止信号等); - 返回值:成功时返回终止子进程的 PID,失败时返回
-1
(如没有子进程)。
举例:基本用法
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
pid_t pid = fork(); // 创建子进程
if (pid == 0) {
// 子进程:执行一些任务后退出
printf("子进程 %d 开始执行\n", getpid());
sleep(2); // 模拟工作
printf("子进程 %d 结束\n", getpid());
exit(123); // 退出码为 123
} else if (pid > 0) {
// 父进程:等待子进程结束
int status;
pid_t exited_pid = wait(&status); // 阻塞等待
// 解析子进程退出状态
if (WIFEXITED(status)) { // 判断是否正常退出
printf("子进程 %d 正常结束,退出码:%d\n",
exited_pid, WEXITSTATUS(status)); // 123
}
printf("父进程回收完子进程,结束\n");
}
return 0;
}
子进程 1234 开始执行
子进程 1234 结束
子进程 1234 正常结束,退出码:123
父进程回收完子进程,结束
- 父进程调用
wait
后会暂停,直到子进程(1234)执行完exit(123)
; - 通过
WEXITSTATUS(status)
可以提取子进程的退出码(123)。
waitpid
是更灵活的等待函数
它可以支持指定的等待的子线程、非阻塞模式等
pid_t waitpid(pid_t pid, int *status, int options);
参数说明:
-
pid:指定等待的子进程(核心区别于
wait
):pid > 0
:只等待 PID 为pid
的子进程;pid = 0
:等待与父进程同组的任意子进程;pid = -1
:等待任意子进程(与wait
功能相同);pid < -1
:等待进程组 ID 为|pid|
的任意子进程。
-
status:同
wait
,用于存储子进程退出状态。 -
options:控制等待方式(常用选项):
0
:默认阻塞模式(与wait
一致);WNOHANG
:非阻塞模式 —— 如果没有子进程终止,立即返回0
,不阻塞;WUNTRACED
:除了终止的子进程,还返回被暂停的子进程状态。
返回值:
- 成功:返回终止子进程的 PID(阻塞模式)或
0
(非阻塞模式且无终止子进程); - 失败:返回
-1
(如无匹配的子进程)。// 父进程创建两个子进程,只等待第二个子进程 pid_t p1 = fork(); if (p1 == 0) { sleep(1); exit(1); } // 子进程1 pid_t p2 = fork(); if (p2 == 0) { sleep(3); exit(2); } // 子进程2(等待目标) // 只等待 p2 终止 int status; waitpid(p2, &status, 0); // 阻塞到子进程2结束 printf("回收了子进程 %d,退出码:%d\n", p2, WEXITSTATUS(status)); // 输出 2
四、进程的创建与退出
进程的创建函数fork()与vfork()+exec
对于fork来讲是创建一个完全与父进程相同的进程,子进程会复制父进程的代码段、数据段、堆、栈、文件描述符等资源。调用一次,返回两次,父进程中返回 子进程的 PID(大于 0),子进程中返回 0,失败时返回-1,而且子进程从 fork()
之后的代码开始执行,与父进程并发运行
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
// 错误处理
perror("fork failed");
return 1;
} else if (pid == 0) {
// 子进程逻辑
printf("子进程:PID = %d,父进程 PID = %d\n", getpid(), getppid());
} else {
// 父进程逻辑
printf("父进程:PID = %d,子进程 PID = %d\n", getpid(), pid);
}
return 0;
}
对于这块的理解开始运行的肯定是父进程,子进程是在父进程执行到 fork()
函数时才被创建出来的
fork()
是一个 “调用一次,返回两次” 的特殊函数:
- 当在父进程中时,
fork()
返回子进程的 PID(一个大于 0 的整数),因此代码会进入else
分支,打印父进程自身的 PID 和子进程的 PID。 - 当在子进程中时,
fork()
返回 0,因此代码会进入else if (pid == 0)
分支,打印子进程自身的 PID 和父进程的 PID(通过getppid()
获取)。 - 如果
fork()
失败(如系统资源不足),会返回 -1,进入错误处理分支。
为什么 pid == 0
代表子进程?
这是操作系统内核的人为规定,主要原因是:
- 父进程需要知道自己创建的子进程的 PID(以便后续通过
waitpid()
等函数管理子进程),因此父进程中返回子进程 PID 是必要的。 - 子进程不需要知道自己的 PID(可以通过
getpid()
获取),也不需要知道其他子进程的信息,因此用一个特殊值(0)来标识 “当前进程是子进程” 即可。 - 0 是一个不会被分配给任何实际进程的 PID(系统中进程 PID 从 1 开始,如 init 进程是 1 号),因此适合作为 “子进程标识”。
vfork()+exec的进程创建模式
vfork()
与 exec
系列函数配合使用是一种经典的进程创建模式,其设计初衷是快速创建子进程并立即执行新程序,避免 fork()
带来的资源复制开销。不过由于 vfork()
的特殊性,这种组合需要特别注意使用规范。
vfork()
创建的子进程有两个关键特性:
- 子进程共享父进程的地址空间(不复制内存,与
fork()
的写时复制不同)。 - 父进程会被阻塞,直到子进程调用
exec
系列函数或exit()
才会恢复运行。
这使得 vfork()
非常适合 “创建子进程后立即执行新程序” 的场景 —— 子进程无需复制父进程资源,直接通过 exec
加载新程序替换自身地址空间,效率极高。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = vfork(); // 创建子进程
if (pid < 0) {
perror("vfork failed");
exit(1);
}
// 子进程逻辑:立即调用exec执行新程序
else if (pid == 0) {
printf("子进程(PID=%d)准备执行新程序...\n", getpid());
// 调用execvp执行ls命令(参数为命令名和参数列表)
// 若exec成功,子进程会被新程序替换,后续代码不会执行
execl("/bin/ls", "ls", "-l", NULL);
// 若exec失败,必须立即调用_exit()终止子进程(不能用return)
perror("exec failed");
_exit(1); // 注意:子进程中必须用_exit()而非exit()
}
// 父进程逻辑:等待子进程执行完毕
else {
wait(NULL); // 回收子进程资源
printf("父进程(PID=%d):子进程已完成\n", getpid());
}
return 0;
}
对于这里的使用注意点,有以下几个方面
关键注意事项
子进程必须立即调用
exec
或_exit()
由于子进程共享父进程的地址空间,若子进程在调用exec
前修改了变量、调用了return
或未终止,会直接破坏父进程的状态(例如覆盖父进程的栈数据),导致程序崩溃。子进程中用
_exit()
而非exit()
exit()
会刷新缓冲区并执行清理函数,可能影响父进程的资源;_exit()
是系统调用,会直接终止子进程且不干扰父进程。父进程会被阻塞直到子进程完成转换
这是vfork()
的设计特性,确保子进程在父进程继续运行前完成exec
或退出,避免资源冲突。现代系统中
fork()
+exec
已足够高效
由于fork()
采用写时复制(Copy-On-Write)技术,仅在父子进程修改数据时才复制内存,性能已接近vfork()
,且更安全。因此vfork()
如今已很少使用,仅在极端追求效率的场景(如嵌入式系统)中可能见到。一个简单的流程:
- 父进程调用
vfork()
,内核创建子进程并共享父进程地址空间,父进程进入阻塞状态。- 子进程执行
execl("/bin/ls", ...)
,加载ls
程序并替换自身地址空间(不再共享父进程资源)。ls
程序执行完毕后退出,父进程从wait()
处唤醒,回收子进程资源并继续运行。
进程的终止退出函数exit()、_exit()、
信号杀死、return
终止方式 | 使用场景 | 核心特点 | 注意事项 |
---|---|---|---|
exit() | 正常终止进程,需要完整清理资源 | 1. 属于标准库函数(用户态) 2. 执行清理工作: - 刷新 I/O 缓冲区 - 调用 atexit() 注册的清理函数- 关闭所有打开的文件描述符 3. 最终调用 _exit() 进入内核 | 1. 不要在信号处理函数中使用(可能导致死锁) 2. 在多线程程序中会终止整个进程(包括所有线程) |
_exit() | 快速终止,无需用户态清理(如子进程) | 1. 属于系统调用(内核态) 2. 不执行任何用户态清理: - 不刷新缓冲区(除非缓冲区是未缓冲的) - 不调用 atexit() 函数3. 直接释放内核资源 | 1. 子进程中必须用_exit() 而非exit() (避免干扰父进程)2. 常用于 vfork() 创建的子进程中 |
return | main() 函数中正常退出 | 1. 等价于exit(n) (仅在main() 中有效)2. 会执行与 exit() 相同的清理工作 | 1. 仅在main() 中触发进程终止,其他函数中return 仅退出当前函数2. 返回值 n 即进程退出码 |
信号终止 | 异常情况(如错误、强制终止) | 1. 被动终止,非进程主动调用 2. 常见信号: - SIGKILL (9):强制终止,无法捕获- SIGSEGV (11):段错误,内存访问错误- SIGINT (2):终端中断(Ctrl+C) | 1. 父进程可通过wait() 获取终止信号(WTERMSIG(status) )2. 部分信号可通过信号处理函数捕获并自定义处理 |
补充说明:
- 进程退出码:
0
表示正常终止,非0
表示异常,父进程可通过wait()
系列函数获取(WEXITSTATUS(status)
)。 - 资源回收:无论哪种终止方式,内核都会回收进程的核心资源(如 PCB、内存等),但用户态资源(如缓冲区)的清理仅
exit()
和return
(main()
中)会处理。 - 线程影响:
exit()
和信号终止会终止整个进程(包括所有线程),而线程内的return
仅退出当前线程。
五、进程间通信的信号(核心重点)
1.进程间通信的信号
它可以理解为一种软件中断,通过整数标识(如 SIGINT
对应 2,SIGKILL
对应 9),每个信号对应特定事件,而且信号的产生和处理是异步的,进程无需主动等待,收到信号时会暂停当前操作,优先处理信号。
Linux定义的信号约有31种,常见如下:
信号名 | 编号 | 含义 | 默认处理动作 |
---|---|---|---|
SIGINT | 2 | 终端中断(用户按 Ctrl+C ) | 终止进程 |
SIGQUIT | 3 | 终端退出(用户按 Ctrl+\ ) | 终止进程并生成核心转储文件 |
SIGKILL | 9 | 强制终止进程(“必杀信号”) | 终止进程(不可捕获 / 忽略) |
SIGTERM | 15 | 请求终止进程(默认的 kill 命令信号) | 终止进程(可捕获) |
SIGSEGV | 11 | 段错误(非法内存访问) | 终止进程并生成核心转储文件 |
SIGPIPE | 13 | 向已关闭的管道写入数据 | 终止进程 |
SIGALRM | 14 | 定时器到期(alarm() 函数触发) | 终止进程 |
SIGCHLD | 17 | 子进程状态变化(终止或暂停) | 忽略(需手动处理) |
进程收到信号后,有 3 种预设处理方式(可通过函数自定义):
- 默认动作(Default):内核定义的处理方式(如终止、忽略、暂停等,见上表)。
- 忽略信号(Ignore):进程不做任何处理(
SIGKILL
和SIGSTOP
不可忽略)。- 捕获信号(Catch):进程执行自定义的信号处理函数(
SIGKILL
和SIGSTOP
不可捕获)。
2.信号相关的核心函数
1. 发送信号:kill()
和 raise()
kill(pid, sig)
:向 PID 为pid
的进程发送信号sig
(pid>0
为指定进程,pid=0
为同组进程,pid=-1
为所有进程)。raise(sig)
:向当前进程发送信号sig
(等价于kill(getpid(), sig)
)。
#include <signal.h>
#include <unistd.h>
// 向 PID=1234 的进程发送终止信号
kill(1234, SIGTERM);
// 向当前进程发送段错误信号(会崩溃)
raise(SIGSEGV);
2. 注册信号处理函数:signal()
和 sigaction()
signal(sig, handler)
:简单注册信号sig
的处理函数handler
(兼容性好,但功能有限)。sigaction(sig, &act, &oldact)
:更强大的信号处理函数,支持设置信号掩码、处理方式等(推荐使用)。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 自定义 SIGINT 信号处理函数(捕获 Ctrl+C)
void handle_sigint(int sig) {
printf("\n收到信号 %d(Ctrl+C),程序不会终止!\n", sig);
}
int main() {
// 注册信号处理函数:收到 SIGINT 时执行 handle_sigint
signal(SIGINT, handle_sigint);
while (1) {
printf("程序运行中...(按 Ctrl+C 测试)\n");
sleep(1);
}
return 0;
}
效果:按 Ctrl+C
不会终止程序,而是执行自定义函数打印提示。
其他常用函数
alarm(sec)
:sec
秒后向当前进程发送SIGALRM
信号(用于定时)。pause()
:阻塞进程,直到收到任意信号(常用于等待信号触发)。sigprocmask()
:设置信号掩码(屏蔽或解除屏蔽指定信号,避免处理时被其他信号干扰)。
内核在信号机制中扮演了 “中转站” 和 “监控者” 的双重角色,既负责传递进程间的信号,也会在系统事件发生时主动向进程 “报警”
3.信号集
本质上是一个位图(bitmap)—— 每个位对应一个信号(如第 2 位对应 SIGINT
,第 9 位对应 SIGKILL
),位的值为 1 表示该信号在集合中,0 表示不在。信号集的核心作用是批量管理信号,例如:指定需要屏蔽的信号、查询进程中处于 “未决状态” 的信号等。
信号集的主要用途
-
信号屏蔽(Signal Mask):
进程可以通过信号集设置 “屏蔽字”(mask),让内核暂时不递送集合中的信号(信号会处于 “未决” 状态,直到屏蔽解除)。这用于避免进程在关键操作(如信号处理函数执行时)被其他信号干扰。 -
未决信号集(Pending Set):
内核为每个进程维护一个未决信号集,记录哪些信号已产生但尚未被处理(可能因被屏蔽而暂时无法递送)。进程可以通过函数查询未决信号集,了解自己有哪些 “待处理” 的信号。 -
信号处理范围定义:
在设置信号处理函数时,可通过信号集指定哪些信号需要特殊处理,或排除某些信号。
信号集相关的核心函数
Linux 提供了一组标准函数用于操作信号集(定义在 <signal.h>
中):
函数 | 功能描述 |
---|---|
sigemptyset() | 初始化信号集,清空所有信号(所有位设为 0) |
sigfillset() | 初始化信号集,包含所有信号(所有位设为 1) |
sigaddset() | 向信号集中添加一个信号(将对应位设为 1) |
sigdelset() | 从信号集中删除一个信号(将对应位设为 0) |
sigismember() | 判断一个信号是否在信号集中(返回 1 表示存在,0 表示不存在,-1 表示错误) |
接下来我们来创建并操作一个信号集合:
#include <signal.h>
#include <stdio.h>
int main() {
sigset_t set; // 定义一个信号集
// 1. 初始化信号集(清空)
sigemptyset(&set);
// 2. 向集合中添加 SIGINT(2)和 SIGTERM(15)
sigaddset(&set, SIGINT);
sigaddset(&set, SIGTERM);
// 3. 判断 SIGKILL(9)是否在集合中
if (sigismember(&set, SIGKILL)) {
printf("SIGKILL 在信号集中\n");
} else {
printf("SIGKILL 不在信号集中\n"); // 此句会执行
}
// 4. 从集合中删除 SIGINT
sigdelset(&set, SIGINT);
return 0;
}
信号集最常见的用法是配合 sigprocmask()
函数设置进程的信号屏蔽字,例如:设置屏蔽之后5s内按ctrl+c是无法退出的,只有5s后才能退出
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
int main() {
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT); // 屏蔽 SIGINT(Ctrl+C)
// 设置信号屏蔽字:屏蔽 SIGINT
sigprocmask(SIG_BLOCK, &mask, NULL);
printf("已屏蔽 Ctrl+C,5 秒内按 Ctrl+C 无效...\n");
sleep(5);
// 解除屏蔽:恢复默认屏蔽字
sigprocmask(SIG_UNBLOCK, &mask, NULL);
printf("已解除屏蔽,按 Ctrl+C 可终止程序\n");
while (1) sleep(1); // 等待信号
return 0;
}
六、进程间通信的方式有哪些(核心重点)
进程是资源分配的基本单位,各个进程拥有独立的内存空间,彼此隔离。进程间通信(IPC,Inter-Process Communication)是指不同进程之间传递数据、协调行为的机制。接下里我们来逐个讲讲每一种通信方式。
一、管道(pipe)
管道是一种单向、基于字节流的通信方式,适用于父子进程或兄弟进程之间的简单通信。