当前位置: 首页 > news >正文

进程控制:从创建到终结的完整指南

在这里插入图片描述

文章目录

    • 一、进程创建:从`fork`开始的 "分身术"
      • 1.1 `fork`函数:一次调用,两次返回
      • 1.2 写时拷贝:高效的内存共享策略
      • 1.3 `fork`的常见用法与失败原因
    • 二、进程终止:优雅结束与资源释放
      • 2.1 进程退出的三种场景
      • 2.2 正常终止的三种方式
      • 2.3 退出码:进程的 "遗言"
    • 三、进程等待:避免僵尸进程,获取退出状态
      • 3.1 为什么需要进程等待?
      • 3.2 两种等待函数:`wait`与`waitpid`
        • 3.2.1 `wait`函数:简单的阻塞等待
        • 3.2.2 `waitpid`函数:更灵活的等待
      • 3.3 解析`status`参数:子进程的 "最后消息"
      • 3.4 阻塞与非阻塞等待
    • 四、进程程序替换:让子进程执行全新程序
      • 4.1 替换原理:不换进程换 "内容"
      • 4.2 `exec`函数簇:6 个函数的命名密码
      • 4.3 替换后的注意事项
    • 总结:进程控制的核心逻辑

进程是操作系统的基本执行单位,而进程控制则是管理进程生命周期的核心技术。无论是创建新进程、终止运行中的进程,还是回收子进程资源、替换进程执行的程序,都离不开关键的系统调用。本文将以通俗易懂的方式,带你梳理进程控制的四大核心环节: 进程创建进程终止进程等待程序替换,让你轻松掌握 forkwaitexec等核心函数的工作原理与实践技巧。

一、进程创建:从fork开始的 “分身术”

当你需要一个进程 “分身” 执行不同任务时,fork函数就是实现这一功能的关键。它能从已存在的进程(父进程)中创建一个全新的进程(子进程),二者如同 “双胞胎” 却又各自独立。

在这里插入图片描述

1.1 fork函数:一次调用,两次返回

fork函数的神奇之处在于:一次调用会产生两个返回值。父进程会得到子进程的 PID(进程 ID),而子进程则返回 0。如果调用失败,返回 - 1。

#include <unistd.h>
pid_t fork(void); // 父进程返回子进程PID,子进程返回0,失败返回-1

来看一个简单例子:

int main() {printf("Before: pid is %d\n", getpid()); // 父进程IDpid_t pid = fork();if (pid == -1) { perror("fork failed"); return 1; }printf("After: pid is %d, fork return %d\n", getpid(), pid);return 0;
}

运行结果会出现一行 “Before” 和两行 “After”:

Before: pid is 1234
After: pid is 1234, fork return 1235  // 父进程输出
After: pid is 1235, fork return 0      // 子进程输出

为什么子进程不打印 “Before”?

在这里插入图片描述

因为fork是在 “Before” 之后调用的,子进程从fork返回后才开始执行,相当于复制了父进程在fork时刻的执行状态。所以,fork之前父进程独立执行,fork之后,父子两个执行流分别执行。注意,fork之后,谁先执行完全由调度器决定。

1.2 写时拷贝:高效的内存共享策略

你可能会疑惑:子进程是完全复制父进程的内存吗?如果是这样,内存消耗也太大了。实际上,操作系统采用了写时拷贝(Copy-On-Write) 技术优化这一过程:

  • 未写入时:父子进程共享同一份代码和数据(只读),不占用额外内存。
  • 需要写入时:操作系统才会为修改的部分创建副本,仅复制被修改的数据,避免不必要的内存浪费。

在这里插入图片描述

  • 修改前:父子进程的页表(记录虚拟内存与物理内存映射),都指向同一块物理内存(粉色、蓝色块 ),且标记为“只读”,防止直接修改共享内容。
  • 修改后:假设父进程要改“页表项100”对应的数据,系统会拷贝一份物理内存(新蓝色块 ) 给父进程,父进程页表指向新拷贝,子进程仍指向原物理内存。这样父子数据分离,互不干扰。

这种 “延时复制” 策略大幅提高了内存利用率,也是进程独立性的技术保障(父子进程修改数据互不影响)。

1.3 fork的常见用法与失败原因

fork的典型应用场景有两个:

  1. 父子分工:父进程等待客户端请求,子进程处理具体请求(如 Web 服务器)。
  2. 执行新程序:子进程通过fork创建后,调用exec系列函数执行全新程序(后续会详细讲解)。

fork失败的情况较少见,主要原因包括:

  • 系统进程数量超过上限(如 Linux 的pid_max限制)。
  • 普通用户创建的进程数超过系统对该用户的限制。

二、进程终止:优雅结束与资源释放

进程终止的本质是释放系统资源(包括内核数据结构、内存、文件描述符等)。无论是正常结束还是异常崩溃,进程最终都会走向终止。

2.1 进程退出的三种场景

  • 正常结束,结果正确:如程序完成计算并输出正确结果。
  • 正常结束,结果错误:如逻辑错误导致计算结果错误,但程序未崩溃。
  • 异常终止:如被信号杀死(如ctrl+c触发的SIGINT信号)、访问非法内存等。

2.2 正常终止的三种方式

方式特点适用场景
return n(从main函数)等同于exit(n),由运行时库处理主程序退出
exit(int status)先执行清理函数(atexiton_exit定义),刷新缓冲区,再调用_exit需要释放资源的正常退出
_exit(int status)直接终止进程,不刷新缓冲区,不执行清理函数紧急退出(如内核态)

在这里插入图片描述

关键区别exit会确保缓冲区数据写入文件,而_exit直接丢弃缓冲区数据。例如:

// 使用exit:会打印"hello"(缓冲区被刷新)
printf("hello");
exit(0);// 使用_exit:不会打印"hello"(缓冲区数据被丢弃)
printf("hello");
_exit(0);

2.3 退出码:进程的 “遗言”

进程终止时会返回一个退出码(status),用于告知父进程执行结果。Linux 中:

  • 退出码为0表示执行成功。
  • 非 0 表示执行失败(具体含义由程序定义)。

可以通过echo $?在 Shell 中查看上一个进程的退出码,常见退出码及含义如下:

退出码含义示例
0成功执行ls命令正常列出文件
1通用错误无权限执行命令、除以 0
127命令未找到执行不存在的程序
130SIGINTctrl+c)终止手动中断运行中的程序
143SIGTERM终止kill命令默认信号
255/*退出码超过了零到255的范围,因此重新计算void _exit(int status),虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现 返回值是255。

通过strerror(int errnum)函数可获取退出码对应的描述文本,例如strerror(1)返回 “Operation not permitted”。

三、进程等待:避免僵尸进程,获取退出状态

如果父进程不处理子进程的终止,子进程会变成僵尸进程(Zombie)—— 保留 PID 和退出状态,占用系统资源且无法被杀死。进程等待就是父进程回收子进程资源、获取退出状态的关键机制。

3.1 为什么需要进程等待?

  • 之前的文章提到,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
  • 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill-9也无能为力,因为谁也没有办法杀死一个已经死去的进程。
  • 最后,我们需要知道,父进程派给子进程的任务完成的如何。如,子进程运行完成,结果对还是 不对,或者是否正常退出。
  • 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息

3.2 两种等待函数:waitwaitpid

3.2.1 wait函数:简单的阻塞等待
#include<sys/types.h>
#include <sys/wait.h>pid_t wait(int *status); 
// 返回值:成功返回子进程PID,失败返回-1
// 参数:输出型参数,获取子进程退出状态,不关心则可设为NULL

wait阻塞等待任意子进程终止,并回收其资源。例如:

pid_t pid = fork();
if (pid == 0) { exit(10); } // 子进程退出码10
else {int status;wait(&status); // 等待子进程if (WIFEXITED(status)) { // 判断是否正常退出printf("子进程退出码:%d\n", WEXITSTATUS(status)); // 输出10}
}
3.2.2 waitpid函数:更灵活的等待

waitpidwait的增强版,支持指定等待的子进程、非阻塞等待等:

pid_t waitpid(pid_t pid, int *status, int options);
参数含义
pidpid=-1:等待任意子进程(同wait);pid>0:等待 PID 为pid的子进程
statuswait,通过宏解析退出状态(如WIFEXITEDWEXITSTATUS
options0:阻塞等待;WNOHANG:非阻塞等待(若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。)

返回值:

  • 当正常返回的时候waitpid返回收集到的子进程的进程ID;
  • 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
  • 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
  1. “如果子进程已经退出,调用 wait / waitpid 时,会立即返回,释放资源,拿到子进程退出信息”
  • 比如:子进程早就跑完、退出了,父进程这时候调用 wait ,会 立刻得到结果(不用等),还能拿到子进程的退出状态(比如 exit(n) 里的 n ),同时系统会回收子进程占的资源。
  1. “如果任意时刻调用 wait / waitpid ,子进程还在正常运行,则父进程可能阻塞”
  • 比如:子进程还在忙活着(没执行 exit ),父进程调用 wait ,就会 卡住(阻塞),直到子进程退出,父进程才会“被唤醒”,继续往下执行。
  1. “如果不存在该子进程,则立即出错返回”
  • 比如:父进程要等的子进程压根没创建,或者已经被回收了,调用 wait 就会直接报错(返回错误码),告诉你“没这个子进程可以等”。

在这里插入图片描述

  1. 左边:父进程阻塞在 wait
  • 父进程执行到 parent_code() 里的 wait(&s) 时,子进程还在 child_code() 里跑(没 exit ),所以父进程会卡在 wait 这里不动(阻塞)。
  1. 右边:子进程退出后,父进程继续
  • 子进程执行 exit(n) 退出后,父进程的 wait(&s) 被“唤醒”,拿到子进程的退出状态(存在 s 里 ),然后父进程继续往下执行 parent_code() 后续代码(比如图里的 exit(&s) ,实际更常见的是处理退出状态后干别的活 )。

3.3 解析status参数:子进程的 “最后消息”

status是一个 32 位整数,其低 16 位存储退出信息,结构如下:

  • 高 8 位:正常退出时存储退出码(仅当低 7 位为 0 时有效)。
  • 低 7 位:存储终止信号(若进程被信号杀死,则非 0)。

在这里插入图片描述

1.wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。

2.如果传递NULL,表示不关心子进程的退出状态信息。

3.否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。

4.status不能简单的当作整形来看待,要按 二进制位(比特位)拆分 理解,低 16 位藏着关键信息,分两种场景:

(1)子进程“正常退出”(比如自己调用 exit(n) 或 return n )

  • 低 16 位里,高 8 位(第 8~15 位) 存的是 exit(n) 的 n (退出状态码 );
  • 低 8 位(第 0~7 位) 是 0 (标记“正常退出” )。

(2)子进程“被信号杀死”(比如被 kill 命令、段错误信号终止 )

  • 低 16 位里,高 8 位(第 8~15 位) 没用(标记为“未用” );
  • 低 7 位(第 0~6 位) 存的是“终止信号编号”(比如 9 是 SIGKILL ,强制杀死 );
  • 第 7 位 是 core dump 标志( 1 表示程序崩溃时生成了 core 文件,可用来调试;0表示未生成 )。

测试代码:

#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.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);//把st右移8位,再取低8位,就得到子进程的退出码} else if( ret > 0 ) { 	// 异常退出 printf("sig code : %d\n", st&0X7F );// st的低7位,这里存的是信号编号,(比如9表示SIGKILL,强制杀死)}}
}

测试结果:

# ./a.out #等20秒退出
child exit code:10 
# ./a.out #在其他终端kill掉
sig code : 9

通过以下宏可解析status

  • WIFEXITED(status):若为真,表示子进程正常退出。

    • 组成及含义: W 是 “wait” 的缩写,表明这是与等待子进程状态相关的操作; IF 表示 “是否”,用来进行条件判断; EXITED 意为 “已退出”。
    • 功能对应:这个宏用于判断子进程是否正常退出,从命名上就很清晰地表达出,是在等待子进程状态的过程中,判断子进程是否处于已正常退出的状态 。
  • WEXITSTATUS(status):若WIFEXITED为真,返回退出码。

    • 组成及含义:同样开头的 W 代表 “wait” ; EXIT 表示 “退出” , STATUS 表示 “状态、码值” 。
    • 功能对应:该宏的作用是在确认子进程正常退出(即 WIFEXITED 为真时 ),获取子进程的退出码,命名直观地体现了它是获取与子进程退出相关状态码的功能 。
  • WIFSIGNALED(status):若为真,表示子进程被信号杀死。

    • 组成及含义: W 还是 “wait” 的意思 ; IF 依旧是 “是否” 的判断含义 ; SIGNALED 表示 “被信号终止” 。
    • 功能对应:它用于判断子进程是否是被信号杀死的,命名直接反映出是在等待子进程状态时,判断子进程是否处于被信号终止的状态 。
  • WTERMSIG(status):若WIFSIGNALED为真,返回终止信号编号。

    • 组成及含义: W 为 “wait” ; TERM 是 “terminate(终止 )” 的缩写 , SIG 是 “signal(信号 )” 的缩写。
    • 功能对应:当确认子进程是被信号杀死(即 WIFSIGNALED 为真时 ),这个宏用于获取导致子进程终止的信号编号 ,从名字能看出其与获取子进程终止信号相关 。

WEXITSTATUS解析status时,只关注低8位(二进制最后8位),超出部分会被截断; WTERMSIG是取低7位。

3.4 阻塞与非阻塞等待

  • 阻塞等待:父进程暂停执行,直到子进程终止(如waitpid(-1, &status, 0))。

进程的阻塞等待方式:

int main()
{pid_t pid;pid = fork();if(pid < 0){printf("%s fork error\n",__FUNCTION__);//__FUNCTION__是当前函数名的快捷方式,这里是"main"return 1;} else if( pid == 0 ){ 	//childprintf("child is run, pid is : %d\n",getpid());sleep(5);exit(257);} else{int status = 0;pid_t ret = waitpid(-1, &status, 0);//阻塞式等待,等待5S printf("this is test for wait\n");if( WIFEXITED(status) && ret == pid ){printf("wait child 5s success, child return code is :%d.\n",WEXITSTATUS(status));}else{printf("wait child failed, return.\n");return 1;}}return 0;
}                

运行结果:

[root@localhost linux]# ./a.out
child is run, pid is : 45110
this is test for wait
wait child 5s success, child return code is :1.//257的二进制是1 0000 0001,但WEXITSTATUS解析status时只关注低8位,即0000 0001 所以是1
  • 非阻塞等待:父进程不暂停,若子进程未退出则立即返回 0,可用于 “轮询” 等待(如waitpid(-1, &status, WNOHANG))。

进程的非阻塞等待方式:

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>  // 提供waitpid等进程等待函数
#include <unistd.h>    // 提供fork、sleep、sleep等函数
#include <vector>      // 提供C++的vector容器// 定义函数指针类型,用于指向指向无参数无返回值的函数
typedef void (*handler_t)(); // 存储函数指针的vector容器,用于管理需要执行的任务
std::vector<handler_t> handlers; // 任务函数1
void fun_one() 
{printf("这是一个临时任务1\n");
}// 任务函数2
void fun_two() 
{printf("这是一个临时任务2\n");
}// 加载任务函数到vector容器中
void Load() 
{handlers.push_back(fun_one);  // 添加任务1handlers.push_back(fun_two);  // 添加任务2
}// 执行所有已加载的任务
void handler() 
{// 如果任务容器为空,则先加载任务if (handlers.empty())Load();// 遍历容器,执行每个任务函数for (auto iter : handlers)iter();  // 调用函数指针指向的任务
}int main() 
{pid_t pid;  // 用于存储fork的返回值(进程ID)// 创建子进程pid = fork();// fork失败的情况if (pid < 0) {// __FUNCTION__宏表示当前函数名(此处为"main")printf("%s fork error\n", __FUNCTION__);return 1;  // 异常退出} // 子进程执行的代码else if (pid == 0) { printf("child is run, pid is : %d\n", getpid());  // 输出子进程IDsleep(5);  // 子进程休眠5秒,模拟执行任务exit(1);   // 子进程退出,退出码为1} // 父进程执行的代码else {int status = 0;    // 用于存储子进程的退出状态pid_t ret = 0;     // 用于存储waitpid的返回值// 循环进行非阻塞等待do {// 非阻塞等待任意子进程:// -1表示等待任意子进程// status用于接收退出状态// WNOHANG表示非阻塞模式(子进程未退出则立即返回0)ret = waitpid(-1, &status, WNOHANG);// 如果ret为0,说明子进程还在运行if (ret == 0) {printf("child is running\n");  // 提示子进程正在运行}// 执行预设的任务(无论子进程是否退出,每次循环都执行)handler();} while (ret == 0);  // 当子进程还在运行时,继续循环等待// 子进程退出后,判断退出状态// WIFEXITED(status):检查子进程是否正常退出// ret == pid:确保回收的是目标子进程if (WIFEXITED(status) && ret == pid) {// WEXITSTATUS(status):提取子进程的退出码printf("wait child 5s success, child return code is :%d.\n",WEXITSTATUS(status));} // 等待失败的情况(如子进程被信号终止等)else {printf("wait child failed, return.\n");return 1;  // 异常退出}}return 0;  // 正常退出
}

四、进程程序替换:让子进程执行全新程序

fork创建的子进程默认执行与父进程相同的代码,若想让子进程执行全新程序(如从磁盘加载新的可执行文件),需通过程序替换实现。

4.1 替换原理:不换进程换 “内容”

程序替换通过exec系列函数实现,其核心是:

  • 替换用户空间的代码和数据:丢弃原进程的代码和数据,加载新程序的代码、数据和堆栈。
  • 不创建新进程:进程 PID 保持不变,仅替换进程的执行内容。

例如:子进程通过exec加载/bin/ls,之后就会执行ls命令,而非原父进程的代码。

在这里插入图片描述

4.2 exec函数簇:6 个函数的命名密码

exec系列有 6 个函数,均以exec开头,区别在于参数格式和是否自动搜索路径:

函数特点示例
execl(path, arg0, arg1, ..., NULL)参数列表形式(l=list),需指定程序全路径execl("/bin/ls", "ls", "-l", NULL)
execlp(file, arg0, ..., NULL)自动搜索PATH环境变量(p=path),无需全路径execlp("ls", "ls", "-l", NULL)
execle(path, arg0, ..., NULL, envp)自定义环境变量(e=env)char* env[] = {"PATH=/bin", NULL}; execle("/bin/ls", "ls", NULL, env);
execv(path, argv)参数数组形式(v=vector)char* argv[] = {"ls", "-l", NULL}; execv("/bin/ls", argv);
execvp(file, argv)结合vp,参数数组 + 自动搜路径execvp("ls", argv);
execve(path, argv, envp)系统调用原函数(其他函数均调用它),自定义环境变量内核级实现,用户态少直接使用

命名解释

  • 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
  • 如果调用出错则返回-1
  • 所以exec函数只有出错的返回值而没有成功的返回值。

命名规律

  • l(list):参数以列表形式传递(如arg0, arg1, NULL)。
  • v(vector):参数以数组形式传递(如argv[])。
  • p(path):自动从PATH环境变量搜索程序路径。
  • e(env):允许自定义环境变量(默认继承父进程环境)。

在这里插入图片描述

exec调用举例如下:

#include <unistd.h>
int main()
{char *const argv[] = {"ps", "-ef", NULL};char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};execl("/bin/ps", "ps", "-ef", NULL);// 带p的,可以使⽤环境变量PATH,⽆需写全路径 execlp("ps", "ps", "-ef", NULL);// 带e的,需要⾃⼰组装环境变量 execle("ps", "ps", "-ef", NULL, envp);execv("/bin/ps", argv);// 带p的,可以使⽤环境变量PATH,⽆需写全路径 execvp("ps", argv);// 带e的,需要⾃⼰组装环境变量 execve("/bin/ps", argv, envp);exit(0);
}

事实上,只有execve是真正的系统调用,其它五个函数最终都调用execve,所以execveman手册第2节, 其它函数在man手册第3节。

  • execve 是 “真·系统调用”:直接和操作系统内核交互,负责加载新程序替换当前进程,在 man 手册第 2 节(系统调用分类 )。
  • 其他 5 个( execlp / execl / execle / execvp / execv )是 “库函数封装”:它们最终会调用 execve ,只是帮你简化了参数准备(比如自动找路径、传环境变量 ),在 man 手册第 3 节(库函数分类 )。

这些函数之间的关系如下图所示。 下图exec函数簇一个完整的例子:

在这里插入图片描述

4.3 替换后的注意事项

  • 替换成功后,原进程的代码和数据被完全丢弃,不会返回(若返回则表示替换失败)。
  • 替换不改变进程的 PID、文件描述符(已打开的文件仍可访问)等内核属性。
  • 通常与fork配合使用:父进程fork后,子进程调用exec执行新程序,父进程继续等待或执行其他任务。

总结:进程控制的核心逻辑

进程控制围绕生命周期管理展开,四大环节环环相扣:

  • 创建fork通过写时拷贝高效创建子进程,父子进程 PID 不同是关键标识。
  • 终止exit_exit实现正常退出,退出码传递执行结果。
  • 等待wait/waitpid解决僵尸进程问题,通过status解析子进程状态。
  • 替换exec系列函数让子进程执行全新程序,实现进程功能的灵活扩展。

掌握这些机制,你就能轻松应对进程管理的各种场景,从简单的多进程程序到复杂的服务器架构,都能游刃有余。记住:进程控制的核心是资源管理生命周期把控,理解这一点,技术细节便迎刃而解。
在这里插入图片描述

http://www.lryc.cn/news/606164.html

相关文章:

  • 解决音视频开发中 因mp4中断 无法播放的问题
  • [特殊字符] 数据可视化结合 three.js:让 3D 呈现更精准,3 个优化经验谈
  • 前端框架Vue3(三)——路由和pinia
  • RabbitMQ安装与介绍
  • Disruptor高性能基石:Sequence并发优化解析
  • 去重、top_n()、pull()、格式化
  • 数据结构第4问:什么是栈?
  • BR/EDR PHY帧结构及其具体内容
  • 51c自动驾驶~合集12
  • python基础语法3,组合数据类型(简单易上手的python语法教学)(课后习题)
  • 从0到1了解热部署
  • 一天两道力扣(7)
  • 力扣 hot100 Day61
  • 银河麒麟桌面操作系统:自定义截图快捷键操作指南
  • 机器人学和自动化领域中的路径规划方法
  • 解决Git升级后出现的问题
  • 国产芯+单北斗防爆终端:W5-D防爆智能手机,助力工业安全通信升级
  • 将开发的软件安装到手机:环境配置、android studio设置、命令行操作
  • ClickHouse vs PostgreSQL:数据分析领域的王者之争,谁更胜一筹?
  • 2683. 相邻值的按位异或
  • USRP捕获手机/路由器数据传输信号波形(中)
  • DeepCompare文件深度对比软件:智能差异分析与可视化功能深度解析
  • visual studio 安装总结
  • 搭建文件共享服务器samba————附带详细步骤
  • Kubernetes (K8s) 部署Doris
  • Redis过期策略
  • 【嵌入式电机控制#23】BLDC:开环运动控制框架
  • 设计模式:命令模式 Command
  • 法国声学智慧 ,音响品牌SK (SINGKING AUDIO) 重构专业音频边界
  • Web开发-PHP应用原生语法全局变量数据接受身份验证变量覆盖任意上传(代码审计案例)