【Linux系统编程】进程控制:创建、终止与进程等待

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

在这里插入图片描述

🌈 say-fall:个人主页
🚀 专栏:《系统深入Linux操作系统》 | 《手把手教你学会C++》 | 《数据结构与算法》
💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。

📝 前言

在前面学习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:创建失败

🤔 思考题:

  1. 为什么要给子进程返回0,父进程返回子进程pid?
  2. 为什么一个函数fork会有两个返回值?
  3. 为什么一个id既等于0,又大于0?

进程调用fork,当控制转移到内核中的fork代码后,内核做:

步骤操作
1分配新的内存块和内核数据结构给子进程
2将父进程部分数据结构内容拷贝至子进程
3添加子进程到系统进程列表当中
4fork返回,开始调度器调度

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 $? 查看进程退出码):

  1. 从main返回
  2. 调用exit
  3. _exit

异常退出:

  • ctrl + c,信号终止

2.3 退出码

退出码(退出状态)可以告诉我们最后一次执行的命令的状态。程序返回退出代码0时表示执行成功,代码1或0以外的任何代码都被视为不成功。

Linux Shell中的主要退出码:

退出码名称含义常见场景
0EXIT_SUCCESS命令执行无误一切正常
1EXIT_FAILURE通用错误权限不足、参数错误、除以0等
2-误用Shell命令缺少关键字或权限
126-命令不可执行文件没有执行权限
127-命令未找到输入了不存在的命令
128+n-被信号n终止如130=SIGINT(2), 143=SIGTERM(15)
130SIGINT被Ctrl+C终止用户主动中断
143SIGTERM被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 的区别:

  1. 执行用户通过atexit或on_exit定义的清理函数
  2. 关闭所有打开的流,所有的缓存数据均被写入
  3. 调用_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)printfscanf等函数使用,存在于用户空间
库级/系统级缓冲区操作系统内核内核缓冲区文件系统缓存、页缓存,存在于内核空间

💡 关键区别:

  • 语言级缓冲区: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库清理,缓冲区数据丢失
}
// 输出:(空)

💡 原理剖析:

  1. printf("hello") → 数据写入C标准库的用户态缓冲区(不是直接写到屏幕!)
  2. exit(0) → 调用C库的清理函数 → fflush(stdout) → 把用户态缓冲区数据通过write()发给内核 → 调用_exit()真正退出
  3. _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标准库的缓冲区也被复制到了子进程,父子进程各自刷新一次。解决方法是加\nfflush(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会被设置成相应的值以指示错误所在

参数:

参数取值说明
pidPid=-1等待任一个子进程,与wait等效
Pid>0等待其进程ID与pid相等的子进程
status输出参数获取子进程的退出状态
options0阻塞等待
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运行该进程的用户
TIMECPU时间进程消耗的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 | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值