一、进程创建
1.fork函数初识
在 Linux 中 fork 函数是一个非常重要的函数,它从已存在进程中创建一个新进程,此时新进程为子进程,而原进程为父进程
。
#include <unistd.h>
pid_t fork(void);
返回值:子进程中返回0,父进程返回子进程id,出错返回-1
进程调用 fork 函数以后,当控制转移到内核中的 fork 代码后,内核做:
•分配新的内存块和内核数据结构给子进程
•将父进程部分数据结构内容拷贝至子进程
•添加子进程到系统进程列表当中
•fork 返回,开始调度器调度
如下图所示:
代码示例:
int main()
{
printf("Before: pid is %d\n", getpid());
pid_t id = fork();
if (id == -1)
{
perror("fork()");
exit(1);
}
printf("After: pid is %d, fork return %d\n", getpid(), id);
sleep(1);
return 0;
}
运行结果:
注意: fork之后,父进程和子进程谁先执行完全由调度器决定
2.fork函数返回值
我们再来看一段代码:
int main()
{
printf("Before fork: pid=%d\n", getpid());
pid_t id = fork();
if (id > 0)
{
// 父进程执行此分支
printf("父进程: pid=%d, ppid=%d, 子进程pid=%d\n",
getpid(), getppid(), id);
}
else if (id == 0)
{
// 子进程执行此分支
printf("子进程: pid=%d, ppid=%d, fork返回值=%d\n",
getpid(), getppid(), id);
}
else
{
perror("fork失败");
return 1;
}
// 父子进程都会执行这里
printf("pid=%d 即将退出\n", getpid());
return 0;
}
当我们使用 fork 创建进程以后,思考下面这些问题?
2.1 如何理解 fork 函数有两个返回值问题?
答:父进程调用fork函数后,为了创建子进程,fork函数内部将会进行一系列操作,包括创建子进程的进程控制块、创建子进程的进程地址空间、创建子进程对应的页表等等。子进程创建完毕后,操作系统还需要将子进程的进程控制块添加到系统进程列表当中,此时子进程便创建完毕了。
也就是说,在fork函数内部执行return语句之前,子进程就已经创建完毕了,那么之后的return语句不仅父进程需要执行,子进程也同样需要执行,这就是fork函数有两个返回值的原因。
2.2为什么给父进程返回子进程 PID,给子进程返回 0?
答:父进程需要子进程的 PID 来管理子进程(如等待子进程结束、发送信号等)。
子进程可以通过 getpid() 获取自己的 PID,通过 getppid() 获取父进程的 PID,因此不需要额外返回父进程的 PID。返回 0 是为了让子进程能够区分自己的身份。
2.3如何理解同一个 id 值,怎么可能会保存两个不同的值?换句话说,也就是如何让 if 和 else if 同时去执行各自的代码?
答:fork() 执行后,父进程和子进程拥有独立的内存空间。虽然代码看起来是同一个变量 id(虚拟地址相同),但实际上:
•父进程中的 id 存储子进程的 PID
•子进程中的 id 存储 0(内存发生写时拷贝)
因此,if 和 else if 并非在同一个进程中同时执行,而是分别在父进程和子进程中执行。
3.写时拷贝
当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。
这种在需要进行数据修改时再进行拷贝的技术,称为写时拷贝技术。
1、为什么数据要进行写时拷贝?
答:进程具有独立性。多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程。
2、为什么不在创建子进程的时候就进行数据的拷贝?
答: 子进程不一定会使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,没有必要对数据进行拷贝,我们应该按需分配,在需要修改数据的时候再分配(延时分配),这样可以高效的使用内存空间。
3.数据代码会不会进行写时拷贝?
答:90%的情况下是不会的,但这并不代表代码不能进行写时拷贝,例如在进行进程替换的时候,则需要进行代码的写时拷贝。
4. fork 常规用法
• ⼀个⽗进程希望复制⾃⼰,使⽗⼦进程同时执⾏不同的代码段。例如,⽗进程等待客⼾端请求,
⽣成⼦进程来处理请求。
• ⼀个进程要执⾏⼀个不同的程序。例如⼦进程从fork返回后,调⽤exec函数。
5. fork 调用失败的原因
•系统中有太多的进程。
•实际用户的进程数超过了限制
二、进程终止
1. 进程退出场景
进程退出的情况主要分为三种:
• 代码运⾏完毕,结果正确 —> return 0
• 代码运⾏完毕,结果不正确–> return !0
• 代码没跑完,程序异常了,退出码无意义
2.进程退出码
我们都知道main函数是代码的入口,但实际上main函数只是用户级别代码的入口,main函数也是被其他函数调用的,例如在VS2013当中main函数就是被一个名为__tmainCRTStartup的函数所调用,而__tmainCRTStartup函数又是通过加载器被操作系统所调用的,也就是说main函数是间接性被操作系统所调用的。
既然main函数是间接性被操作系统所调用的,那么当main函数调用结束后就应该给操作系统返回相应的退出信息,而这个所谓的退出信息就是以退出码的形式作为main函数的返回值返回,我们一般以0表示代码成功执行完毕,以非0表示代码执行过程中出现错误,这就是为什么我们都在main函数的最后返回0的原因。
当我们的代码运行起来就变成了进程,当进程结束后main函数的返回值实际上就是该进程的进程退出码,我们可以使用echo $?命令查看最近一次进程退出的退出码信息。
为什么以0表示代码执行成功,以非0表示代码执行错误?
答: 因为代码执行成功不会在意成功背后的原因,而代码执行错误却有多种原因,例如内存空间不足、非法访问以及栈溢出等等,我们就可以用这些非0的数字分别表示代码执行错误的原因。
C语言当中的strerror函数可以通过错误码,获取该错误码在C语言当中对应的错误信息:
运行代码后我们就可以看到各个错误码所对应的错误信息:
实际上Linux中的ls、pwd等命令都是可执行程序,使用这些命令后我们也可以查看其对应的退出码。
可以看到,这些命令成功执行后,其退出码也是0。
但是命令执行错误后,其退出码就是非0的数字,该数字具体代表某一错误信息。
总结: 退出码都有对应的字符串含义,帮助用户确认执行失败的原因,而这些退出码具体代表什么含义是人为规定的,不同环境下相同的退出码的字符串含义可能不同。
3. 进程常见退出方法
3.1 return退出
在main函数中使用return退出进程是我们常用的方法。
main 函数中的 return 0 是什么意思?这个 0 返回给谁的呢?为什么要写 0 呢?
答:这个 0 是该进程所对应的退出码,它能标定进程执行的结果是否正确。
总结:return 是一种常见的退出进程方法,执行 return n 等同于执行 exit(n),因为调用 main 的运行时函数会将 main 的返回值当做 exit 的参数。
3.2 _exit函数
_exit() 是系统调用,会直接终止进程,不进行任何用户空间的清理工作(如刷新缓冲区)。
#include <unistd.h>
void _exit(int status);
例如,以下代码中使用_exit终止进程,则缓冲区当中的数据将不会被输出。
运行结果:
3.3 exit 函数
exit() 是 C 库函数,会执行清理操作,包括刷新 stdio 缓冲区,然后调用 _exit (或类似的系统调用) 来终止进程。
例如,以下代码中exit终止进程前会将缓冲区当中的数据输出。
运行结果:
return、exit和_exit之间的区别与联系是什么?
答:只有在main函数当中的return才能起到退出进程的作用,子函数当中return不能退出进程,而exit函数和_exit函数在代码中的任何地方使用都可以起到退出进程的作用。
使用exit函数退出进程前,exit函数会执行用户定义的清理函数、冲刷缓冲,关闭流等操作,然后再终止进程,而_exit函数会直接终止进程,不会做任何收尾工作。
三、进程等待
1. 进程等待必要性
• 之前讲过,⼦进程退出,⽗进程如果不管不顾,就可能造成‘僵⼫进程’的问题,进⽽造成内存
泄漏。
• 另外,进程⼀旦变成僵⼫状态,那就⼑枪不⼊,“杀⼈不眨眼”的kill -9 也⽆能为⼒,因为谁也
没有办法杀死⼀个已经死去的进程。
• 最后,⽗进程派给⼦进程的任务完成的如何,我们需要知道。如,⼦进程运⾏完成,结果对还是
不对,或者是否正常退出。
• ⽗进程通过进程等待的⽅式,回收⼦进程资源,获取⼦进程退出信息。
2.进程等待的方法
2.1 wait 方法
功能:父进程调用 wait() 会阻塞,直到任意一个子进程结束。
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
返回值:
•成功:返回结束的子进程的 PID。
•失败:返回 -1(如无子进程)。
参数 status:
•指向一个整数变量,用于存储子进程的退出状态码。
•若不关心退出状态,可传入 NULL。
我们可以假设这样一个场景:当父进程休眠 15 秒时,子进程会在运行 10 秒后退出。由于父进程尚未执行 wait() 操作,子进程会进入僵尸状态(Z 状态)并持续约 5 秒。直到父进程休眠结束调用 wait(),僵尸进程的资源才会被回收,状态标记从 Z 变为终止。
代码示例:
int main()
{
pid_t id = fork();
if (id == 0)
{
// child
int cnt = 10;
while (cnt)
{
printf("我是子进程: %d, 父进程: %d, cnt: %d\n", getpid(), getppid(), cnt--);
sleep(1);
}
exit(0); // 进程退出
}
// 父进程
sleep(15);
pid_t ret = wait(NULL);
if (id > 0)
{
printf("wait success: %d\n", ret);
}
sleep(5);
return 0;
}
我们写一个监控脚本来看看运行结果:
while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep;sleep 1;done
子进程行为:
•子进程循环 10 秒(cnt 从 10 递减到 1),每秒打印一次信息。
•10 秒后子进程调用 exit(0) 退出,此时子进程变成僵尸进程(Z 状态),等待父进程回收。
父进程行为:
•父进程休眠 15 秒,期间未调用 wait()。
•在子进程退出后的前 5 秒(第 11~15 秒),子进程处于僵尸状态(可通过脚本观察到 Z+ 标记)。
•父进程休眠 15 秒结束后,调用 wait(NULL) 回收子进程,僵尸状态消失。
•父进程再休眠 5 秒后退出。
2.2 waitpid 方法
功能:父进程调用 waitpid() 等待指定子进程或任意子进程结束,并获取其退出状态。
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
返回值:
成功:返回结束的子进程的 PID。
失败:返回 -1(如无子进程、参数错误等)。
参数:
pid:
Pid=-1,等待任⼀个⼦进程。与wait等效。
Pid>0.等待其进程ID与pid相等的⼦进程。
status: 指向整数变量,用于存储子进程的退出状态(若不关心可传 NULL)。
•两个与statuus有关的宏函数:
WIFEXITED(status): 若为正常终⽌⼦进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED⾮零,提取⼦进程退出码。(查看进程的退出码)
options: 控制等待行为的标志位,默认为0,表⽰阻塞等待。
•WNOHANG:非阻塞模式,若子进程未结束立即返回 0。
2.3获取子进程status
先来看一段代码:
代码示例 1:
int main()
{
pid_t id = fork();
if (id == 0)
{
// child
int cnt = 5;
while (cnt)
{
printf("我是子进程: %d, 父进程: %d, cnt: %d\n", getpid(), getppid(), cnt--);
sleep(1);
}
exit(10); // 进程退出
}
// 父进程
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 设置为0表示阻塞等待
if (id > 0)
{
printf("wait success: %d, ret: %d\n", ret, status);
}
sleep(5);
return 0;
}
运行结果:
为什么 status 的值是 2560 呢?
答:这是由于对 waitpid 返回的状态码的解读方式不正确导致。
wait 和 waitpid,都有一个 status 参数,该参数是一个输出型参数,由操作系统填充。
如果传递 NULL,表示不关心子进程的退出状态信息;否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status参数并非直接返回子进程的退出码,而是一个32 位整数,包含多个状态标志位,也就是说不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究 status 低 16 比特位):
在status的低16比特位当中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是core dump标志。
其他位:存储更多状态信息(如进程是否暂停、是否继续执行等)。
而在上面的例子中:
子进程调用 exit(10),退出码为 10。
10 的二进制表示为 00001010。
当这个值被存储在 status 的 8-15 位时,实际数值为 10 << 8 = 2560(十进制)。
所以我们重新对代码进行修改:
代码示例 2:
int main()
{
pid_t id = fork();
if (id == 0)
{
// child
int cnt = 5;
while (cnt)
{
printf("我是子进程: %d, 父进程: %d, cnt: %d\n", getpid(), getppid(), cnt--);
sleep(1);
}
exit(10); // 进程退出
}
// 父进程
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 设置为0表示阻塞时等待
if (id > 0)
{
printf("wait success: %d, singal number: %d, child exit code: %d\n", ret, (status & 0x7F), (status >> 8) & 0xFF);
}
sleep(5);
return 0;
}
运行结果:
解释一下(status & 0x7F)和 (status >> 8) & 0xFF):
1、(status & 0x7F)
0x7F的二进制是0000 0000 0000 0000 0000 0000 0111 1111(低 7 位全为 1)。
status & 0x7F会屏蔽所有高位,只保留低 7 位,即信号编号。
示例:若子进程被 SIGTERM(信号 15)终止,status & 0x7F结果为 15。
2、(status >> 8) & 0xFF
status >> 8将整个状态码右移 8 位,把退出码部分移到低 8 位。
0xFF的二进制是0000 0000 0000 0000 0000 0000 1111 1111(低 8 位全为 1)。
(status >> 8) & 0xFF会屏蔽右移后的高位,只保留低 8 位的退出码。
示例:若子进程exit(10),(status >> 8) & 0xFF结果为 10。
3、假设子进程正常退出,退出码为 10:
status的二进制表示:
0000 0000 0000 0000 0000 1010 0000 0000
(退出码 10 位于第 8-15 位,其余位为 0)
3.1 计算信号编号
status: 0000 0000 0000 0000 0000 1010 0000 0000
& 0x7F: 0000 0000 0000 0000 0000 0000 0111 1111
---------------------------------------------
结果: 0000 0000 0000 0000 0000 0000 0000 0000 → 十进制值为0
结论:信号编号为 0,表示子进程未被信号终止(正常退出)。
3.2 计算退出码
1. 右移8位: 0000 0000 0000 0000 0000 1010 0000 0000 >> 8
↓
0000 0000 0000 0000 0000 0000 0000 1010
2. 与0xFF: 0000 0000 0000 0000 0000 0000 0000 1010
& 0xFF: 0000 0000 0000 0000 0000 0000 1111 1111
---------------------------------------------
结果: 0000 0000 0000 0000 0000 0000 0000 1010 → 十进制值为10
结论:退出码为 10,与子进程exit(10)一致。
总结如下:
•child exit code 是子进程通过 exit(n) 或 return n 返回的退出状态值(即代码中的 exit(10) )。它反映了子进程执行的结果。其中:
0:表示子进程正常结束(成功执行完毕)。
非 0:通常表示子进程异常结束,非 0 值可用于指示具体错误类型(由程序自定义,如 1 表示参数错误,2 表示文件不存在等)。
•sig number 表示终止子进程的信号编号。如果子进程是被信号杀死的(而非正常退出),该值会指示具体是哪个信号。
0:表示子进程正常退出(通过 exit() 或 return),未收到任何终止信号。
非 0:表示子进程被信号终止。例如:11 对应 SIGSEGV(段错误)。
2.4 阻塞等待 与 非阻塞等待
2.4.1感性理解
再谈进程退出:
•当进程退出时,会进入僵尸状态,此时进程虽然终止运行,但其 task_struct 结构会保留,其中记录了进程的退出状态码等信息。
•wait/waitpid 作为系统调用,由操作系统内核执行。内核通过这两个接口可以访问并读取子进程的 task_struct 数据。
因此,父进程调用 wait/waitpid 获取的子进程退出信息,实际上是内核从子进程的 task_struct 中提取并返回的。
进程退出搞明白了,那么阻塞等待和非阻塞等待又是啥呢?别急,我们先来看两个小故事。
故事一:
你约李四一起去吃饭,你已经到了李四楼下,然后你给李四打电话,可李四好一会儿才接听。你便对他说:“你先别挂电话呀”。说完,你就一直拿着电话等待李四的回应,并且时不时的问李四收拾好了没有
过了大概 30 分钟,电话那头传来李四的声音:“张三,走吧,我已经到楼下了,都能看到你了呢”。听到这话,你赶忙挂了电话,也清楚了李四现在的状态,知道他已经下楼了。想到马上就能见到李四,你心里很高兴,随后便和李四开开心心地去吃饭了。
其中,你不挂电话是为了检测李四的状态,那么这种就叫做:阻塞等待。
故事二:
我给你打电话,然后问道:“你现在状态好了没呀?” 你回复说还没好呢,那我就说:“那咱们先把电话挂了吧,你先好好收拾,我就在楼下等你。” 李四听了,回了句 “行”,于是双方就这么约定好了。
之后,张三又给李四打了个电话,再次问李四:“你现在好了没?” 李四回答说还没好呢,张三便挂了电话。挂了电话后,张三就在楼底下待着,一会儿看看书,一会儿玩玩手机,偶尔还会和几个半生不熟的朋友打打招呼。
又等了一两分钟,张三实在等不及了,就又给李四打电话问:“好了没呀?” 李四还是说还没好,让张三再等等,张三无奈地回了句 “好吧”,接着就把电话挂了,继续在楼下耐心等候。
就这样,前前后后张三给李四打了十几通电话呢。大概过了二三十分钟,李四终于回复说:“我好了啊,我已经看到你了,我已经到楼下了。”随后,两人开开心心地去吃饭了。
张三给李四打电话的本质是进行状态检测:若李四处于未就绪状态,张三会直接挂断电话,这一单次行为属于非阻塞操作;而张三反复多次进行这种非阻塞尝试的过程,即构成了轮询机制。
我们可以把打电话的过程类比为系统调用 wait/waitpid:
•你 → 父进程
•李四 → 子进程
当父进程调用 wait/waitpid 等待子进程时,若采用传统的阻塞式方式:
•若子进程未退出,父进程会被阻塞在 wait/waitpid 调用处
•直到子进程退出后,wait/waitpid 才会返回结果
这就是第一种情况:阻塞式等待
而非阻塞等待的机制则不同:
•父进程调用 wait/waitpid 检测子进程状态时
•若子进程未退出,系统会立即返回结果(不会阻塞父进程)
•父进程可以继续执行其他任务
这种每次检测后立即返回的方式,就是非阻塞等待。
2.4.2 进程的阻塞等待方式
代码示例:
int main()
{
pid_t id = fork();
assert(-1 != id);
if (0 == id)
{
// child
int cnt = 5;
while (cnt)
{
printf("child running, pid is %d, ppid is %d\n", getpid(), getppid());
cnt --;
sleep(1);
}
exit(111);
}
// parent
// 1. 让OS释放子进程的Z状态
// 2. 获取子进程的退出结果
// 在等待期间, 子进程没有退出的时候, 父进程只能阻塞等待
int status = 0; // child的退出信息
int ret = waitpid(id, &status, 0); //waitpid的第三个参数options为0,表示默认使用阻塞模式
if (ret > 0) // 等待成功
{
// 判断是否正常退出
if (WIFEXITED(status)) // 正常退出为真
{
// 判断子进程运行结果是否OK
printf("wait child 5s success, exit code: %d\n", WEXITSTATUS(status));
}
else
{
//
printf("child exit not normal!\n");
}
}
return 0;
}
运行结果:
2.4.3进程的非阻塞等待方式
在下面这段代码里,子进程每三秒会打印一次信息,并且会持续运行十秒。父进程则以每秒一次的频率对其进行非阻塞检查,一旦子进程结束运行,父进程就能马上获取到它的退出状态。
int main()
{
pid_t id = fork();
assert(-1 != id);
if (0 == id)
{
// child
int cnt = 10;
while (cnt)
{
printf("child running, pid is %d, ppid is %d, cnt: %d\n", getpid(), getppid(), cnt);
cnt --;
sleep(3);
}
exit(111);
}
// parent
int status = 0;
while (1)
{
pid_t ret = waitpid(id, &status, WNOHANG); // WNOHANG: 非阻塞 --> 子进程没有退出, 父进程检测之后, 立即返回
if (ret == 0)
{
// waitpid调用成功 && 子进程没有退出
// 子进程没有退出, 我的waitpid没有等待失败, 仅仅是检测到了子进程没退出.
printf("wait done, but child is running...\n");
}
else if (ret > 0)
{
// 1. 等待成功 --> waitpid调用成功, 并且子进程退出了
printf("wait successful, exit code: %d, signal code: %d\n", (status >> 8)&0xFF, status & 0x7F);
break;
}
else
{
// waitpid调用失败
printf("waitpid call failed\n");
break;
}
sleep(1);
}
return 0;
}
运行结果:
非阻塞等待:waitpid(id, &status, WNOHANG) 以非阻塞模式检查子进程状态:
函数返回值:
•ret == 0:子进程仍在运行。
•ret > 0:子进程已退出,返回值为子进程 PID。
•ret < 0:等待失败(如子进程不存在)。
解析退出状态:
•(status >> 8) & 0xFF:提取子进程的退出状态码(exit(111) 中的 111)。
•status & 0x7F:提取终止子进程的信号编号(正常退出时为 0)。
•轮询间隔:父进程每次检查后 sleep(1),避免 CPU 资源浪费。
执行流程:
•父进程创建子进程后进入循环,每秒检查一次子进程状态。
•子进程运行 10 秒,期间父进程每次检查都会输出 “wait done, but child is running…”。
•子进程退出后,父进程捕获到退出状态,打印退出码(111)和信号码(0),然后结束。
关键点:
•非阻塞等待:父进程无需挂起,可以继续执行其他任务(这里是每秒检查一次)。
•资源回收:通过 waitpid 确保子进程不会变成僵尸进程(Zombie Process)。
•状态解析:status 参数包含子进程的退出状态和终止信号信息,通过位运算提取。
我们为什么需要非阻塞等待?它的优势体现在哪里?
答:
回到之前的例子:张三给李四打电话后,如果李四尚未就绪,张三不会一直干等着。他可以在楼下自由活动,比如:看看手机消息、与旁人闲聊几句,甚至掏出《C和指针》研读一番。这种处理方式在计算机领域被称为非阻塞等待。
非阻塞等待的核心优势在于:它不会让父进程陷入 “停滞” 状态。父进程在等待子进程的过程中,完全可以并行处理其他任务。这种始终处于「运行 / 就绪态」,可继续执行其他任务,使得系统资源得到更高效的利用。
这种模式在需要同时处理多个任务的程序中尤为重要。例如,服务器程序可以在等待子进程处理特定请求的同时,继续响应其他客户端的连接请求,从而显著提升系统的并发处理能力。
那么我们可以通过函数指针数组和非阻塞等待,展示如何在 Linux C 中实现轻量级的任务调度系统,代码如下:
#define NUM 10
typedef void (*func_t)(); // func_t 是一个函数指针类型,指向「无参数、返回值为 void 的函数」。
func_t handlerTask[NUM];
// 任务1
void task1()
{
printf("handle task 1\n");
}
// 任务2
void task2()
{
printf("handle task 2\n");
}
// 任务3
void task3()
{
printf("handle task 3\n");
}
// 任务4
void task4()
{
printf("handle task 4\n");
}
void loadTask()
{
memset(handlerTask, 0, sizeof(handlerTask));
handlerTask[0] = task1;
handlerTask[1] = task2;
handlerTask[2] = task3;
}
int main()
{
pid_t id = fork();
assert(-1 != id);
if (0 == id)
{
// child
int cnt = 10;
while (cnt)
{
printf("child running, pid is %d, ppid is %d, cnt: %d\n", getpid(), getppid(), cnt--);
sleep(1);
}
exit(111);
}
loadTask();
// parent
int status = 0;
while (1)
{
pid_t ret = waitpid(id, &status, WNOHANG); // WNOHANG: 非阻塞 --> 子进程没有退出, 父进程检测之后, 立即返回
if (ret == 0)
{
// waitpid调用成功 && 子进程没有退出
// 子进程没有退出, 我的waitpid没有等待失败, 仅仅是检测到了子进程没退出.
printf("wait done, but child is running...parent running other things\n");
for (int i = 0; handlerTask[i] != NULL; ++ i)
{
handlerTask[i](); // 采用回调的方式, 让父进程在空闲的时候执行其它任务
}
}
else if (ret > 0)
{
// 1. 等待成功 --> waitpid调用成功, 并且子进程退出了
printf("wait successful, exit code: %d, signal code: %d\n", (status >> 8)&0xFF, status & 0x7F);
break;
}
else
{
// waitpid调用失败
printf("waitpid call failed\n");
break;
}
sleep(1);
}
return 0;
}
运行结果:
这种设计模式在服务器编程中很常见,可以在等待 IO 操作的同时继续处理其他任务,提高系统并发能力。
2.5 wait() 和 waitpid() 函数的三种核心行为模式
Linux 系统中 wait() 和 waitpid() 函数的三种核心行为模式,它们完全由子进程的当前状态决定:
• 如果⼦进程已经退出,调⽤wait/waitpid时,wait/waitpid会⽴即返回,并且释放资源,获得⼦进程退出信息。
• 如果在任意时刻调⽤wait/waitpid,⼦进程存在且正常运⾏,则进程可能阻塞。
• 如果不存在该⼦进程,则⽴即出错返回。
如下图所示:
总结:当我们讨论僵尸进程时,一个关键问题是:进程退出后,其状态信息究竟存储在哪里?
实际上,进程变为僵尸状态时:
1)代码段和数据段 会被操作系统回收,因为进程已停止运行。
2)** 进程控制块(PCB)** 必须被保留,其中存储了两项关键信息:
•退出状态码(exit code)
•终止信号(if any)
这些信息被保存在 PCB 中,直到父进程通过 wait() 或 waitpid() 系统调用读取。具体流程是:
•子程退出时,操作系统将退出状态写入其 PCB。
•父进程调用 wait() 获取该 PCB 中的状态信息(通过status参数)。
•父进程解析状态后,子进程的 PCB 才会被彻底释放。
在应用层,子进程的退出情况可归纳为三类:
•正常结束且结果正确(退出码通常为 0)
•正常结束但结果错误(退出码为非零值,如 1、2 等)
•异常终止(由信号触发,如 SIGSEGV、SIGFPE 等)
3. 进程等待总结
定义:
•进程等待是操作系统提供的一种同步机制,通过 wait/waitpid 等系统调用,允许父进程获取子进程的运行状态并回收其资源。
核心目的:
•资源回收:释放子进程结束后残留的僵尸进程(Zombie Process)
•状态获取:获取子进程的退出状态码(exit status)或终止信号(termination signal)
实现方式:
// 阻塞式等待:父进程暂停直到子进程退出
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, 0);
// 非阻塞式等待:立即返回并继续执行父进程
pid_t waitpid(pid_t pid, int *status, WNOHANG);
•阻塞模式:父进程挂起直到子进程状态变更
•非阻塞模式:通过轮询机制周期性检查子进程状态
•状态解析:使用 WIFEXITED/WEXITSTATUS 等宏解析返回值
典型应用场景:
•多进程服务器程序的子进程管理
•任务调度系统中的异步任务监控
•需要精确控制子进程生命周期的应用
四、进程程序替换
1. 替换原理
⽤fork创建⼦进程后执⾏的是和⽗进程相同的程序(但有可能执⾏不同的代码分⽀),⼦进程往往要调⽤⼀种 exec 函数以执⾏另⼀个程序。当进程调⽤⼀种 exec 函数时,该进程的⽤⼾空间代码和数据完全被新程序替换,从新程序的启动例程开始执⾏。调⽤ exec 并不创建新进程,所以调⽤ exec 前后该进程的 id 并未改变。
我们思考一个问题:当进行进程程序替换时,有没有创建新的进程?
答:没有。进程程序替换之后,该进程对应的PCB、进程地址空间以及页表等数据结构都没有发生改变,只是进程在物理内存当中的数据和代码发生了改变,所以并没有创建新的进程,而且进程程序替换前后该进程的pid并没有改变。
子进程进行进程程序替换后,会影响父进程的代码和数据吗?
答:不会。子进程刚被创建时,与父进程共享代码和数据,但当子进程需要进行进程程序替换时,也就意味着子进程需要对其数据和代码进行写入操作,这时便需要将父子进程共享的代码和数据进行写时拷贝,此后父子进程的代码和数据也就分离了,因此子进程进行程序替换后不会影响父进程的代码和数据。
2. 替换函数
其实有六种以 exec 开头的函数,统称为 exec 函数:
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
2.1函数解释
•这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回;
•如果调用出错则返回 -1。
•所以 exec 函数只有出错的返回值而没有成功的返回值。
2.2 命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记:
• l(list) : 表⽰参数采⽤列表
• v(vector) : 参数⽤数组
• p(path) : 有 p ⾃动搜索环境变量 PATH
• e(env) : 表⽰⾃⼰维护环境变量
如下:
下面我们分别来使用一下这些接口:
a. execl
函数原型
int execl(const char *path, const char *arg, ...);
参数说明
•path:要执行的程序的绝对路径或相对路径(相对于当前工作目录)。例如:“/usr/bin/ls”
。
•arg:传递给新程序的第一个参数,通常是程序名本身(与 argv[0] 对应)。例如:“ls”
。
•…:可变参数列表,后续参数为传递给新程序的命令行参数(以 NULL 结尾)。例如:“-l”, “-a”, NULL
。
返回值
•成功:不返回(原进程的代码段、数据段等被新程序完全替换)。
•失败:返回 -1,并设置 errno(如文件不存在、权限不足等)。
代码示例:
int main()
{
printf("process is running...\n");
pid_t id = fork();
assert(id != -1);
if (0 == id)
{
sleep(1);
// child
// 类比: 命令行怎么写, 这里就怎么传
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
exit(1); // exec一定出错了
}
// father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0) // 等待成功
{
printf("wait successful, exit_code is %d, signal_code is %d\n", (status >> 8)&0xFF, status & 0x7F);
}
}
运行结果:
使用进程替换来执行 ls 命令,此时父进程等待成功就得到了退出结果,运行之后可以看到退出码和推出信号也是没问题的。
注意这个退出结果是子进程执行 ls 的执行结果。(执行成功,所以它是 0)
b. execlp
函数原型
int execlp(const char *file, const char *arg, ...);
参数说明
•file:要执行的程序名(无需完整路径)。例如:“ls”。
注意:系统会自动在 PATH 环境变量指定的目录中搜索该程序。
•arg:传递给新程序的第一个参数,通常是程序名本身(对应 argv[0])。例如:“ls”。
•…:可变参数列表,后续参数为传递给新程序的命令行参数(以 NULL 结尾)。例如:“-l”, “-a”, NULL。
代码实现:
int main()
{
printf("process is running...\n");
pid_t id = fork();
assert(id != -1);
if (0 == id)
{
sleep(1);
// child
execlp("ls", "ls", "-a", "-l", "--color=auto", NULL);
exit(1); // exec一定出错了
}
// father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0) // 等待成功
{
printf("wait successful, exit_code is %d, signal_code is %d\n", (status >> 8)&0xFF, status & 0x7F);
}
}
c. execv
函数原型
int execv(const char *path, char *const argv[]);
参数说明
•path:要执行的程序的绝对路径或相对路径(相对于当前工作目录)。例如:“/bin/ls”。
•argv:传递给新程序的参数数组,类型为 char *const [],必须以 NULL 指针结尾。数组的第一个元素 argv[0] 通常是程序名本身(与命令行调用方式一致)。
代码实现:
int main()
{
printf("process is running...\n");
pid_t id = fork();
assert(id != -1);
if (0 == id)
{
sleep(1);
// child
char *const argv[] = { "ls", "-a", "-l", NULL };
execv("/usr/bin/ls", argv);
exit(1); // exec一定出错了
}
// father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0) // 等待成功
{
printf("wait successful, exit_code is %d, signal_code is %d\n", (status >> 8)&0xFF, status & 0x7F);
}
}
d. execvp
函数原型
int execvp(const char *file, char *const argv[]);
参数说明
•file:要执行的程序名(无需完整路径)。例如:“ls”。
注意:系统会自动在 PATH 环境变量指定的目录中搜索该程序。
•argv:传递给新程序的参数数组,类型为 char *const [],必须以 NULL 指针结尾。
数组的第一个元素 argv[0] 通常是程序名本身(与命令行调用方式一致)。
代码实现:
int main()
{
printf("process is running...\n");
pid_t id = fork();
assert(id != -1);
if (0 == id)
{
sleep(1);
// child
char *const argv[] = { "ls", "-a", "-l", "--color=auto", NULL };
execvp("ls", argv);
exit(1); // exec一定出错了
}
// father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0) // 等待成功
{
printf("wait successful, exit_code is %d, signal_code is %d\n", (status >> 8)&0xFF, status & 0x7F);
}
}
用 test.c 去调用 mybin.c
上面所有的话题都在执行系统命令,如果我想执行我自己写的程序呢?比如用 test.c 去调用 mybin.c?
其实也很简单,代码如下:
// test.c
int main()
{
printf("process is running...\n");
pid_t id = fork();
assert(id != -1);
if (0 == id)
{
sleep(1);
// child
execl("./mybin", "mybin", NULL);
exit(1); // exec一定出错了
}
// father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0) // 等待成功
{
printf("wait successful, exit_code is %d, signal_code is %d\n", (status >> 8)&0xFF, status & 0x7F);
}
}
// mybin.c
int main()
{
printf("这是另一个 C 程序\n");
return 0;
}
同时我们还要把 Makefile 修改一下:
.PHONY:all
all: mybin myproc
mybin:mybin.c
g++ -o $@ $^
myproc:test.cpp
g++ -o $@ $^
.PHONY:clean
clean:
rm -f myproc mybin
运行结果:
注意,可以使用程序替换调用任何后端语言对应的可执行程序。
e. execle
函数原型
int execle(const char *path, const char *arg, ..., char * const envp[]);
参数说明
•path:要执行的程序的绝对路径或相对路径(相对于当前工作目录)。例如:“/bin/ls”。
•arg:传递给新程序的第一个参数,通常是程序名本身(对应 argv[0])。例如:“ls”。
•…:可变参数列表,后续参数为传递给新程序的命令行参数(以 NULL 结尾)。例如:“-l”, “-a”, NULL。
•envp:自定义环境变量数组,类型为 char *const [],必须以 NULL 指针结尾。每个元素格式为 “NAME=VALUE”(如 “PATH=/usr/bin”)。
test.c 代码如下:
int main()
{
printf("process is running...\n");
pid_t id = fork();
assert(id != -1);
// 修改子进程中的部分代码
if (0 == id)
{
sleep(1);
// child
extern char **environ;
putenv((char*)"MYENV=87654321"); // 将指定环境变量导入到系统中environ指向的环境变量表中
execle("./mybin", "mybin", NULL, environ);
exit(1); // exec一定出错了
}
// father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0) // 等待成功
{
printf("wait successful, exit_code is %d, signal_code is %d\n", (status >> 8)&0xFF, status & 0x7F);
}
}
mybin.c 代码如下:
int main()
{
printf("PATH: %s\n", getenv("PATH"));
printf("PWD: %s\n", getenv("PWD"));
printf("MYENV: %s\n", getenv("MYENV"));
printf("这是另一个 C 程序\n");
return 0;
}
运行结果:
注意:该函数适用于替换自己的程序,同时使用自己设置的环境变量的情况
f.execve
这个接口和前面所讲的 5 个接口不同,这才是真正的执行程序替换的系统调用接口,而上面那些接口都是基于系统调用(该接口)做的封装。
所以 execve 在 man 手册第 2 节,其它函数在 man 手册第 3 节。
函数原型
int execve(const char *filename, char *const argv[], char *const envp[]);
代码实现:
int main()
{
printf("process is running...\n");
pid_t id = fork();
assert(id != -1);
if (0 == id)
{
sleep(1);
// child
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
char *const argv[] = {"ps", "-ef", NULL};
execve("/usr/bin/ps", argv, envp);
exit(1); // exec一定出错了
}
// father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0) // 等待成功
{
printf("wait successful, exit_code is %d, signal_code is %d\n", (status >> 8)&0xFF, status & 0x7F);
}
}
运行结果:
3.总结
如下图所示,是 exec 函数族的完整的例子:
实践、实现自定义shell
shell也就是命令行解释器,其运行原理就是:当有命令需要执行时,shell创建子进程,让子进程执行命令,而shell只需等待子进程退出即可。
其实shell需要执行的逻辑非常简单,其只需循环执行以下步骤:
- 获取命令⾏
- 解析命令⾏
- 建⽴⼀个⼦进程(fork)
- 替换⼦进程(execvp)
- ⽗进程等待⼦进程退出(wait)
根据这些思路,和我们前面的学的技术,就可以自己来实现一个 shell 了。
代码实现:
#include <iostream>
#include <ctype.h>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstring>
#include <unordered_map>
#include <sys/stat.h>
#include <fcntl.h>
#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s %s]# "
// 下面是shell定义的全局数据
// 1. 命令行参数表
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0;
// 2. 环境变量表
#define MAX_ENVS 100
char *g_env[MAX_ENVS];
int g_envs = 0;
// 3. 别名映射表
std::unordered_map<std::string, std::string> alias_list;
// 4. 关于重定向,我们关心的内容
#define NONE_REDIR 0
#define INPUT_REDIR 1
#define OUTPUT_REDIR 2
#define APPEND_REDIR 3
//#define TrimSpace(CMD,index) do{
// while(isspace(*(CMD+index)))
// {
// index++;
// }
//}while(0)
int redir = NONE_REDIR;
std::string filename;
// for test
char cwd[1024];
char cwdenv[1024];
// last exit code
int lastcode = 0;
const char *GetUserName()
{
const char *name = getenv("USER");
return name == NULL ? "None" : name;
}
const char *GetHostName()
{
const char *hostname = getenv("HOSTNAME");
return hostname == NULL ? "None" : hostname;
}
const char *GetPwd()
{
//const char *pwd = getenv("PWD");
const char *pwd = getcwd(cwd, sizeof(cwd));
if(pwd != NULL)
{
snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);
putenv(cwdenv);
}
return pwd == NULL ? "None" : pwd;
}
const char *GetHome()
{
const char *home = getenv("HOME");
return home == NULL ? "" : home;
}
void InitEnv()
{
extern char **environ;
memset(g_env, 0, sizeof(g_env));
g_envs = 0;
//本来要从配置文件来
//1. 获取环境变量
for(int i = 0; environ[i]; i++)
{
// 1.1 申请空间
g_env[i] = (char*)malloc(strlen(environ[i])+1);
strcpy(g_env[i], environ[i]);
g_envs++;
}
g_env[g_envs++] = (char*)"HAHA=for_test"; //for_test
g_env[g_envs] = NULL;
//2. 导成环境变量
for(int i = 0; g_env[i]; i++)
{
putenv(g_env[i]);
}
environ = g_env;
}
//command
bool Cd()
{
// cd argc = 1
if(g_argc == 1)
{
std::string home = GetHome();
if(home.empty()) return true;
chdir(home.c_str());
}
else
{
std::string where = g_argv[1];
// cd - / cd ~
if(where == "-")
{
// Todu
}
else if(where == "~")
{
// Todu
}
else
{
chdir(where.c_str());
}
}
return true;
}
void Echo()
{
if(g_argc == 2)
{
// echo "hello world"
// echo $?
// echo $PATH
std::string opt = g_argv[1];
if(opt == "$?")
{
std::cout << lastcode << std::endl;
lastcode = 0;
}
else if(opt[0] == '$')
{
std::string env_name = opt.substr(1);
const char *env_value = getenv(env_name.c_str());
if(env_value)
std::cout << env_value << std::endl;
}
else
{
std::cout << opt << std::endl;
}
}
}
// / /a/b/c
std::string DirName(const char *pwd)
{
#define SLASH "/"
std::string dir = pwd;
if(dir == SLASH) return SLASH;
auto pos = dir.rfind(SLASH);
if(pos == std::string::npos) return "BUG?";
return dir.substr(pos+1);
}
void MakeCommandLine(char cmd_prompt[], int size)
{
snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), DirName(GetPwd()).c_str());
//snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}
void PrintCommandPrompt()
{
char prompt[COMMAND_SIZE];
MakeCommandLine(prompt, sizeof(prompt));
printf("%s", prompt);
fflush(stdout);
}
bool GetCommandLine(char *out, int size)
{
// ls -a -l => "ls -a -l\n" 字符串
char *c = fgets(out, size, stdin);
if(c == NULL) return false;
out[strlen(out)-1] = 0; // 清理\n
if(strlen(out) == 0) return false;
return true;
}
// 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
bool CommandParse(char *commandline)
{
#define SEP " "
g_argc = 0;
// 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
g_argv[g_argc++] = strtok(commandline, SEP);
while((bool)(g_argv[g_argc++] = strtok(nullptr, SEP)));
g_argc--;
return g_argc > 0 ? true:false;
}
void PrintArgv()
{
for(int i = 0; g_argv[i]; i++)
{
printf("argv[%d]->%s\n", i, g_argv[i]);
}
printf("argc: %d\n", g_argc);
}
bool CheckAndExecBuiltin()
{
//如果内键命令做重定向,更改shell的标准输入,输出,错误
std::string cmd = g_argv[0];
if(cmd == "cd")
{
Cd();
return true;
}
else if(cmd == "echo")
{
Echo();
return true;
}
else if(cmd == "export")
{
}
else if(cmd == "alias")
{
// std::string nickname = g_argv[1];
// alias_list.insert(k, v);
}
return false;
}
int Execute()
{
pid_t id = fork();
if(id == 0)
{
int fd = -1;
// 子进程检测重定向情况
if(redir == INPUT_REDIR)
{
fd = open(filename.c_str(), O_RDONLY);
if(fd < 0) exit(1);
dup2(fd,0);
close(fd);
}
else if(redir == OUTPUT_REDIR)
{
fd = open(filename.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd < 0) exit(2);
dup2(fd, 1);
close(fd);
}
else if(redir == APPEND_REDIR)
{
fd = open(filename.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0666);
if(fd < 0) exit(2);
dup2(fd, 1);
close(fd);
}
else
{}
// 进程替换,会影响重定向的结果吗?不影响
//child
execvp(g_argv[0], g_argv);
exit(1);
}
int status = 0;
// father
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
lastcode = WEXITSTATUS(status);
}
return 0;
}
void TrimSpace(char cmd[], int &end)
{
while(isspace(cmd[end]))
{
end++;
}
}
void RedirCheck(char cmd[])
{
redir = NONE_REDIR;
filename.clear();
int start = 0;
int end = strlen(cmd)-1;
//"ls -a -l >> file.txt" > >> <
while(end > start)
{
if(cmd[end] == '<')
{
cmd[end++] = 0;
TrimSpace(cmd, end);
redir = INPUT_REDIR;
filename = cmd+end;
break;
}
else if(cmd[end] == '>')
{
if(cmd[end-1] == '>')
{
//>>
cmd[end-1] = 0;
redir = APPEND_REDIR;
}
else
{
//>
redir = OUTPUT_REDIR;
}
cmd[end++] = 0;
TrimSpace(cmd, end);
filename = cmd+end;
break;
}
else
{
end--;
}
}
}
int main()
{
// shell 启动的时候,从系统中获取环境变量
// 我们的环境变量信息应该从父shell统一来
InitEnv();
while(true)
{
// 1. 输出命令行提示符
PrintCommandPrompt();
// 2. 获取用户输入的命令
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline, sizeof(commandline)))
continue;
// 3. 重定向分析 "ls -a -l > file.txt" -> "ls -a -l" "file.txt" -> 判定重定向方式
RedirCheck(commandline);
// printf("redir: %d, filename: %s\n", redir, filename.c_str());
// 4. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
if(!CommandParse(commandline))
continue;
//PrintArgv();
// 检测别名
// 5. 检测并处理内键命令
if(CheckAndExecBuiltin())
continue;
// 6. 执行命令
Execute();
}
//cleanup();
return 0;
}
函数和进程之间的相似性
我们的 exec/exit 就像 call/return 一样,一个 C 程序有很多函数组成,而一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值,每个函数都有它的局部变量,不同的函数通过 call/return 系统进行通信。
这种通过参数和返回值,在拥有私有数据的函数间通信的模式,是结构化程序设计的基础。Linux 鼓励将这种应用于程序之内的模式扩展到程序之间。
如下图所示:
一个 C 程序可以 fork/exec 另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过 exit(n) 来返回值。调用它的进程可以通过 wait(&ret) 来获取 exit 的返回值。