Linux进程控制:从创建到终止,彻底搞懂进程管理

📝 前言
在前面学习Linux基础指令时,我们已经知道Linux是一个多用户、多任务的操作系统。但你是否想过:这些任务是怎么被管理起来的?当你在终端同时运行多个程序时,操作系统是如何调度它们的?
| 概念 | 说明 | 类比 |
|---|---|---|
| 进程 | 程序的一次执行过程,是资源分配的基本单位 | 工厂里的一个车间 |
| 程序 | 存储在磁盘上的可执行文件 | 工厂的设计图纸 |
| 线程 | 进程内的执行单元,是调度的基本单位 | 车间里的工人 |
🔍 问题来了: 操作系统是如何创建、管理和销毁这些进程的呢?进程之间又是如何协作的?
答案就是——进程控制!本文将从进程创建、终止到进程等待,带你彻底搞懂Linux进程管理的核心机制。
通过本文,你将掌握:
| 技能 | 应用场景 |
|---|---|
| 进程创建(fork/vfork) | 多任务并发处理 |
| 进程终止(exit/_exit) | 程序正常/异常退出处理 |
| 缓冲区机制 | 理解I/O效率与数据一致性 |
| 进程等待(wait/waitpid) | 避免僵尸进程,回收资源 |
| 进程状态监控(ps) | 实时查看进程运行状态 |
| 退出码与错误处理 | 程序调试与日志记录 |
📌 前置知识: C语言基础、Linux基本命令
文章目录
一、🌲 进程创建:fork函数
1.1 fork函数初识
在Linux中,fork() 函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
函数原型:
#include <unistd.h>
pid_t fork(void);
返回值:
- > 0:返回给父进程,值为子进程的PID
- == 0:返回给子进程
- < 0:创建失败
🤔 思考题:
- 为什么要给子进程返回0,父进程返回子进程pid?
- 为什么一个函数fork会有两个返回值?
- 为什么一个id既等于0,又大于0?
进程调用fork,当控制转移到内核中的fork代码后,内核做:
| 步骤 | 操作 |
|---|---|
| 1 | 分配新的内存块和内核数据结构给子进程 |
| 2 | 将父进程部分数据结构内容拷贝至子进程 |
| 3 | 添加子进程到系统进程列表当中 |
| 4 | fork返回,开始调度器调度 |
1.2 fork的执行流程
int main(void)
{
pid_t pid;
printf("Before: pid is %d\n", getpid());
if ((pid = fork()) == -1)
perror("fork()"), exit(1);
printf("After: pid is %d, fork return %d\n", getpid(), pid);
sleep(1);
return 0;
}
运行结果:
[root@localhost linux]# ./a.out
Before: pid is 43676
After: pid is 43676, fork return 43677
After: pid is 43677, fork return 0
💡 关键点: 这里看到了三行输出,一行before,两行after。进程43676先打印before消息,然后打印after。另一个after消息由43677打印的。注意到进程43677没有打印before,为什么呢?
答案: fork之前父进程独立执行,fork之后,父子两个执行流分别执行。fork之后,谁先执行完全由调度器决定。
1.3 写时拷贝(Copy-On-Write)
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。
💡 写时拷贝原理:
- 因为有写时拷贝技术的存在,所以父子进程得以彻底分离!完成了进程独立性的技术保证!
- 写时拷贝,是一种延时申请技术,可以提高整机内存的使用率
1.4 vfork函数
除了fork,Linux还提供了vfork()函数来创建子进程。
函数原型:
#include <unistd.h>
pid_t vfork(void);
vfork和fork的区别:
| 特性 | fork() | vfork() |
|---|---|---|
| 内存拷贝 | 写时拷贝 | 不拷贝,共享地址空间 |
| 执行顺序 | 父子进程同时运行 | 子进程先运行,直到exec或exit |
| 使用场景 | 通用场景 | 子进程立即调用exec |
| 安全性 | 高 | 低(子进程不能修改数据) |
⚠️ 注意: vfork创建的子进程必须立即调用exec系列函数或_exit()退出,否则会导致未定义行为。
1.5 fork常规用法
| 场景 | 说明 |
|---|---|
| 复制自身 | 一个父进程希望复制自己,使父子进程同时执行不同的代码段 |
| 执行新程序 | 一个进程要执行一个不同的程序,子进程从fork返回后调用exec函数 |
1.6 fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
二、🔄 进程终止
2.1 进程退出场景
| 场景 | 说明 |
|---|---|
| 代码运行完毕,结果正确 | 程序正常执行完成 |
| 代码运行完毕,结果不正确 | 程序执行完成但结果有误 |
| 代码异常终止 | 程序被信号终止或出现错误 |
2.2 进程常见退出方法
正常终止(可以通过 echo $? 查看进程退出码):
- 从main返回
- 调用exit
- _exit
异常退出:
- ctrl + c,信号终止
2.3 退出码
退出码(退出状态)可以告诉我们最后一次执行的命令的状态。程序返回退出代码0时表示执行成功,代码1或0以外的任何代码都被视为不成功。
Linux Shell中的主要退出码:
| 退出码 | 名称 | 含义 | 常见场景 |
|---|---|---|---|
| 0 | EXIT_SUCCESS | 命令执行无误 | 一切正常 |
| 1 | EXIT_FAILURE | 通用错误 | 权限不足、参数错误、除以0等 |
| 2 | - | 误用Shell命令 | 缺少关键字或权限 |
| 126 | - | 命令不可执行 | 文件没有执行权限 |
| 127 | - | 命令未找到 | 输入了不存在的命令 |
| 128+n | - | 被信号n终止 | 如130=SIGINT(2), 143=SIGTERM(15) |
| 130 | SIGINT | 被Ctrl+C终止 | 用户主动中断 |
| 143 | SIGTERM | 被kill终止 | 默认的kill信号 |
💡 获取退出码描述: 可以使用
strerror()函数将退出码转换为人类可读的描述字符串。
strerror函数使用示例:
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main()
{
FILE *fp = fopen("nonexistent.txt", "r");
if (fp == NULL) {
printf("Error code: %d\n", errno);
printf("Error message: %s\n", strerror(errno));
// 输出:Error code: 2
// Error message: No such file or directory
}
return 0;
}
💡 errno详解:
errno是一个全局变量,当系统调用或库函数发生错误时,会自动设置errno的值- 每个错误码对应一个特定的错误类型(如ENOENT=2表示文件不存在)
- 使用
perror()可以直接打印错误信息到stderr- 使用
strerror(errno)可以获取错误描述字符串
2.4 _exit函数
函数原型:
#include <unistd.h>
void _exit(int status);
- 参数:status定义了进程的终止状态,父进程通过wait来获取该值
- 说明:虽然status是int,但是仅有低8位可以被父进程所用。所以
_exit(-1)时,在终端执行$?发现返回值是255
2.5 exit函数
函数原型:
#include <stdlib.h>
void exit(int status);
exit最后也会调用_exit,但在调用_exit之前,还做了其他工作:
exit vs _exit 的区别:
- 执行用户通过atexit或on_exit定义的清理函数
- 关闭所有打开的流,所有的缓存数据均被写入
- 调用_exit
// 代码A:使用exit
int main() {
printf("hello"); // 注意:没有换行符
exit(0);
}
// 输出:hello[root@localhost linux]#
// 代码B:使用_exit
int main() {
printf("hello"); // 注意:没有换行符
_exit(0);
}
// 输出:[root@localhost linux]#
💡 原因: printf的输出是先写到用户态缓冲区的。exit()会在退出前刷新缓冲区,把"hello"真正输出到屏幕;而_exit()直接走人,缓冲区里的数据就丢了。
2.6 return退出
return是一种更常见的退出进程方法。执行 return n 等同于执行 exit(n),因为调用main的运行时函数会将main的返回值当做exit的参数。
💡 exit vs return的区别:
在main函数里,
return 0;和exit(0);效果一样。但在其他函数里,return只是返回到调用者,exit则是直接终止整个程序。
2.7 🧪 深入理解:缓冲区机制
上面提到 exit() 会刷新缓冲区而 _exit() 不会,那到底什么是缓冲区?为什么要有缓冲区?
2.7.1 什么是缓冲区
缓冲区(Buffer) 是内存中的一块临时存储区域,用于暂存数据,减少直接操作硬件(如屏幕、磁盘)的次数,从而提高I/O效率。
想象一下:你写信时,是写一个字就跑去邮局寄一次,还是写完一整封信再寄?显然是后者更高效。缓冲区就是同样的道理——先把数据攒起来,再一次性处理。
2.7.2 语言级缓冲区 vs 库级缓冲区
这里要区分两个容易混淆的概念:
| 类型 | 层级 | 管理者 | 特点 |
|---|---|---|---|
| 语言级缓冲区 | 语言运行时 | C标准库(stdio) | printf、scanf等函数使用,存在于用户空间 |
| 库级/系统级缓冲区 | 操作系统内核 | 内核缓冲区 | 文件系统缓存、页缓存,存在于内核空间 |
💡 关键区别:
- 语言级缓冲区:C标准库为了提升性能,在用户空间维护的缓冲。
printf先把数据写到这里,等缓冲区满了、遇到换行符、或者手动调用fflush()时,才通过write()系统调用发给内核- 库级缓冲区:操作系统内核管理的缓冲,用于减少对物理设备的访问次数
2.7.3 为什么 exit() 能刷新缓冲区,_exit() 不能?
// 代码A:使用exit
int main() {
printf("hello"); // 数据先进入C标准库的缓冲区
exit(0); // exit会调用fflush,把缓冲区数据写入内核,再调_exit
}
// 输出:hello
// 代码B:使用_exit
int main() {
printf("hello"); // 数据还在C标准库的缓冲区里
_exit(0); // 直接退出,不经过C库清理,缓冲区数据丢失
}
// 输出:(空)
💡 原理剖析:
printf("hello")→ 数据写入C标准库的用户态缓冲区(不是直接写到屏幕!)exit(0)→ 调用C库的清理函数 →fflush(stdout)→ 把用户态缓冲区数据通过write()发给内核 → 调用_exit()真正退出_exit(0)→ 直接陷入内核,跳过C库所有清理步骤,用户态缓冲区里的数据就丢了
2.7.4 缓冲区的三种刷新策略
C标准库为文件流提供了三种缓冲模式:
| 模式 | 触发条件 | 典型场景 |
|---|---|---|
| 全缓冲 | 缓冲区满才刷新 | 文件I/O(如fprintf到文件) |
| 行缓冲 | 遇到\n或缓冲区满 | 标准输出到终端(stdout) |
| 无缓冲 | 立即输出 | 标准错误(stderr) |
⚠️ 注意:
stderr默认是无缓冲的!这就是为什么错误信息总是能立即显示,而stdout可能需要等换行符。
2.7.5 手动刷新缓冲区
#include <stdio.h>
int main() {
printf("Loading...");
fflush(stdout); // 强制刷新stdout的缓冲区
// 现在"Loading..."会立即显示,即使没有换行符
// 或者设置无缓冲模式
setbuf(stdout, NULL); // 关闭stdout的缓冲
return 0;
}
💡 fork与缓冲区的经典陷阱:
int main() { printf("hello"); // 注意没有换行符! fork(); return 0; }这个程序会输出 两个 “hello”!因为fork时,C标准库的缓冲区也被复制到了子进程,父子进程各自刷新一次。解决方法是加
\n或fflush(stdout)在fork之前。
三、⏳ 进程等待
3.1 进程等待必要性
⚠️ 僵尸进程问题:
- 子进程退出,父进程如果不管不顾,就可能造成’僵尸进程’的问题,进而造成内存泄漏
- 进程一旦变成僵尸状态,那就刀枪不入,"杀人不眨眼"的
kill -9也无能为力,因为谁也没有办法杀死一个已经死去的进程- 父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
3.2 wait方法
函数原型:
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);
- 返回值: 成功返回被等待进程pid,失败返回-1
- 参数: 输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
3.3 waitpid方法
函数原型:
pid_t waitpid(pid_t pid, int *status, int options);
返回值:
- 当正常返回的时候waitpid返回收集到的子进程的进程ID
- 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0
- 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在
参数:
| 参数 | 取值 | 说明 |
|---|---|---|
| pid | Pid=-1 | 等待任一个子进程,与wait等效 |
Pid>0 | 等待其进程ID与pid相等的子进程 | |
| status | 输出参数 | 获取子进程的退出状态 |
| options | 0 | 阻塞等待 |
WNOHANG | 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待 |
3.4 获取子进程status
wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
💡 **status位图解析(只研究低16比特位):
15 14 13 12 11 10 9 8 | 7 6 5 4 3 2 1 0 退出码(高8位) | 终止信号(低7位) | 核心转储标志
位域 含义 低7位(0-6) 终止信号编号。如果是0,表示正常退出 第7位 核心转储标志(是否生成了core dump文件) 高8位(8-15) 退出码(仅当正常退出时有效)
如果传递NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
Linux提供了宏来简化位操作:
| 宏 | 功能 |
|---|---|
WIFEXITED(status) | 若为正常终止子进程返回的状态,则为真(查看进程是否是正常退出) |
WEXITSTATUS(status) | 若WIFEXITED非零,提取子进程退出码(查看进程的退出码) |
WIFSIGNALED(status) | 如果子进程被信号终止,返回真 |
WTERMSIG(status) | 提取终止子进程的信号编号 |
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
pid_t pid;
if ((pid = fork()) == -1)
perror("fork"), exit(1);
if (pid == 0) {
sleep(20);
exit(10);
} else {
int st;
int ret = wait(&st);
if (ret > 0 && (st & 0X7F) == 0) { // 正常退出
printf("child exit code:%d\n", (st >> 8) & 0XFF);
} else if (ret > 0) { // 异常退出
printf("sig code : %d\n", st & 0X7F);
}
}
}
3.5 阻塞与非阻塞等待
阻塞等待方式:
pid_t ret = waitpid(-1, &status, 0); // 阻塞式等待
非阻塞等待方式:
pid_t ret = waitpid(-1, &status, WNOHANG); // 非阻塞式等待
💡 非阻塞等待的特点:
- 如果子进程没有结束,waitpid返回0,不予以等待
- 如果正常结束,则返回该子进程的ID
- 可以配合循环,在等待期间执行其他任务
3.6 进程状态实时监控
在调试多进程程序时,我们经常需要实时查看进程的状态变化。Linux提供了强大的命令行工具来实现这一点。
实时监控脚本:
while :; do ps ajx | head -1 && ps ajx | grep proc; sleep 1; done
命令解析:
| 部分 | 作用 |
|---|---|
while :; do ... done | 无限循环 |
ps ajx | 显示所有进程的详细信息(a:所有用户, j:作业格式, x:无终端进程) |
head -1 | 显示ps的表头(列名) |
grep proc | 过滤出包含"proc"的进程(替换为你的程序名) |
sleep 1 | 每隔1秒刷新一次 |
ps ajx 输出字段说明:
| 字段 | 含义 | 说明 |
|---|---|---|
| PPID | 父进程ID | 创建该进程的父进程 |
| PID | 进程ID | 唯一标识进程 |
| PGID | 进程组ID | 一组相关进程的ID |
| SID | 会话ID | 终端会话标识 |
| TTY | 控制终端 | 进程关联的终端设备 |
| TPGID | 前台进程组ID | 当前终端前台进程组 |
| STAT | 进程状态 | R/S/D/T/Z等 |
| UID | 用户ID | 运行该进程的用户 |
| TIME | CPU时间 | 进程消耗的CPU时间 |
| COMMAND | 命令 | 启动进程的命令 |
💡 实战技巧:
- 查看僵尸进程:
ps ajx | grep Z(STAT列为Z的就是僵尸进程)- 查看孤儿进程:
ps ajx | awk '$2==1{print}'(PPID为1的是被init收养的孤儿进程)- 结合
watch命令:watch -n 1 'ps ajx | grep myprogram'
3.7 孤儿进程与init进程
如果父进程比子进程先死怎么办?
这时候子进程就变成了孤儿进程(Orphan Process)。但Linux不会让它们"无家可归"——内核会自动把孤儿进程的父进程改为init进程(PID=1)。
init进程是Linux系统的"老祖宗",所有进程的终极祖先。它会负责回收这些孤儿进程,所以孤儿进程不会变成僵尸进程。
💡 僵尸进程 vs 孤儿进程:
类型 成因 结果 僵尸进程 子进程死了,父进程不回收 占用进程表资源 孤儿进程 父进程死了,子进程还活着 被init收养,正常回收
四、📊 进程控制核心概念总结
4.1 进程生命周期
创建 (fork/vfork) → 执行 → 终止 (exit/_exit) → 回收 (wait/waitpid)
↓
可能的异常:被信号终止
4.2 关键函数对比
| 函数 | 作用 | 特点 |
|---|---|---|
| fork() | 创建子进程 | 写时拷贝,父子并行运行 |
| vfork() | 创建子进程(轻量) | 共享地址空间,子进程先运行 |
| exit() | 标准库退出 | 清理缓冲区,调用atexit,再调_exit |
| _exit() | 系统调用退出 | 直接退出,不清理 |
| wait() | 等待任意子进程 | 阻塞等待 |
| waitpid() | 等待指定子进程 | 可阻塞可非阻塞,功能更强大 |
| ps | 查看进程状态 | 系统监控和调试 |
4.3 进程状态速查
| 状态 | 代码 | 含义 |
|---|---|---|
| 运行 | R (Running) | 正在执行或等待CPU |
| 睡眠 | S (Sleeping) | 等待某事件完成(可中断) |
| 不可中断睡眠 | D (Disk Sleep) | 等待I/O完成(不可中断) |
| 停止 | T (Stopped) | 被信号停止 |
| 僵尸 | Z (Zombie) | 已终止,等待父进程回收 |
五、🤔 核心思考题与深度解析
1️⃣ 为什么要给子进程返回0,父进程返回子进程pid?
答: 这是Unix设计哲学的体现——每个进程只知道自己需要知道的信息。
父进程需要管理子进程,所以必须知道子进程的PID(比如发信号、等待回收)。而子进程只需要确认"我成功创建了",返回0即可。如果子进程需要父进程的PID,可以调用getppid()获取。
这种设计也避免了子进程需要"猜测"自己的PID(虽然可以调用getpid()获取),简化了API的使用。
2️⃣ 为什么一个函数fork会有两个返回值?
答: 严格来说,fork只调用了一次,但返回了两次——在两个不同的进程中。
调用fork时,内核复制父进程的地址空间创建子进程。然后,两个进程从fork返回处继续执行。父进程得到子进程的PID,子进程得到0。这不是函数"返回了两个值",而是两个独立的执行流各自返回了一个值。
3️⃣ exit() 和 _exit() 有什么区别?什么时候该用哪个?
答:
| 特性 | exit() | _exit() |
|---|---|---|
| 刷新缓冲区 | ✅ 会刷新 | ❌ 不刷新 |
| 关闭流 | ✅ 关闭所有FILE* | ❌ 直接关闭fd |
| atexit处理 | ✅ 调用 | ❌ 不调用 |
| 使用场景 | 正常退出程序 | 子进程退出、异常处理 |
推荐用法:
- 普通程序退出用
exit(),确保资源正确释放 - 子进程退出建议用
_exit(),避免重复刷新父进程的缓冲区(比如fork后子进程exit会刷新父进程已缓冲的内容,导致输出重复)
4️⃣ 为什么要用wait/waitpid等待子进程?不等待会怎样?
答: 不等待会导致僵尸进程。
子进程退出后,内核需要保留其退出状态供父进程查询。如果父进程不调用wait/waitpid,这个"尸体"就会一直占着进程表的位置。进程表是有限的,僵尸进程多了会导致无法创建新进程。
另外,父进程通常需要知道子进程的执行结果(通过退出码),wait/waitpid是获取这个信息的唯一途径。
5️⃣ 阻塞等待和非阻塞等待各有什么优缺点?
答:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 阻塞等待 | 简单直接,不浪费CPU | 父进程干不了其他事 | 父进程无事可做,专门等子进程 |
| 非阻塞等待 | 父进程可以并行处理其他任务 | 需要轮询逻辑,代码复杂 | 父进程需要同时处理多件事 |
现代高性能程序通常使用信号驱动或多线程/多进程+事件循环来处理子进程退出,而不是简单的阻塞或非阻塞等待。
本节完
✅ 本节完…
📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!
1537

被折叠的 条评论
为什么被折叠?



