
目录
一、再谈fork函数
#include <unistd.h>
pid_t fork(void);
返回值:⼦进程中返回0,⽗进程返回⼦进程id,出错返回-1
上一篇文章我们也讲了fork函数的使用,这篇我们再来详细探究一下fork函数的底层原理,看看fork到底做了什么。
实际上,进程调⽤ fork ,当控制转移到内核中的 fork 代码后,内核做了这些:
- 分配新的内存块和内核数据结构给子进程(task_struct、mm_struct、页表)
- 将父进程部分数据结构的内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork准备返回时,开始调度器调度

也如图,所以,fork函数之前,父进程是独立执行的,fork函数之后,父进程和子进程两个执行流分别执行。但是,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;
}
运行结果如下,我们可以看到在fork函数之前,只有父进程一个进程在执行;在fork函数之后,父进程和子进程分别执行。

fork函数创建子进程以后,子进程将共享父进程的代码,这里的共享父进程的代码虽然看起来像是子进程只共享父进程在fork函数之后的所有代码,但实际上子进程是共享父进程的所有代码。只不过子进程只能从fork函数之后开始执行。
这里我们回答一个问题:为什么刚创建出的子进程就能够知道父进程的代码执行到哪里了呢?
原因是在CPU中有一种寄存器叫作eip(程序计数器),也有的地方叫作PC指针,这个寄存器能够保存进程当前正在执行指令的下一条指令。而当父进程创建出子进程以后,父进程的eip程序计数器会被拷贝给子进程,子进程便知道父进程接下来要执行的指令是什么了。
写时拷贝
在fork函数成功创建子进程后,通常情况下父子进程代码是共享的,如果父子进程都不写入或者修改数据的情况下,数据也是共享的。当任意一方试图写入或修改数据时,操作系统便以写时拷贝的方式拷贝一份副本。

所以正因为有写时拷贝技术的存在,所以父子进程得以彻底分离!完成了进程独立性的技术保证!
写时拷贝,是⼀种延时申请技术,可以提高整机内存的使⽤率
那么,为什么不一开始就拷贝,然后各写各的就好了?
在创建子进程的时候就把父子进程分开,这个方法是可以实现的。但是我们之所以不选择这个方法,是因为该方法并不是最优的。
为什么要有写时拷贝,我们先来看看其他方案的缺点:
- 首先,如果在创建子进程的时候就将父子进程的数据分离开,父进程的数据子进程不一定会全部使用,即便全部使用了,也不一定全部写入,所以就会有浪费空间的可能性。
- 除此之外,最理想的方案是只将会被父子修改的数据进行分离拷贝,不需要修改的数据父子进程共享即可。但这种方案从技术角度实现复杂。
所以最终采用写时拷贝!也就是只有真正需要修改的时候才拷贝,这就是延迟拷贝策略。
二、进程终止
关于进程的终止,我们必须要有正确的认识,首先我们要回答下面的几个问题:在我们写代码的时候,main函数都会有一个返回值,我们一般返回值写的是0,那么这个返回值是给谁返回的?这个返回值为什么是0?可以是其他值嘛?
要回答这个问题,我们就得来看进程退出的场景:
正确执行,结果正确
正确执行,结果错误
执行完部分,报错
我们上篇文章也讲到了有一种状态叫Z状态,也叫僵尸状态,本质就是子进程在等待父进程知道自己的退出码,所以我们常写的return 0;本质也是一种退出码。
看到这里,我们恍然大悟,一总结,我们发现进程终止的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。
那么我们来系统介绍一下什么是状态码:
退出码(退出状态)可以告诉我们最后⼀次执⾏的命令的状态。在命令结束以后,我们可以知道命令 是成功完成的还是以错误结束的。其基本思想是,程序返回退出代码0 时表示执行成功,没有问题。 代码除了1 或0 以外的任何代码都被视为不成功。

如何查看退出码
在 Shell 中,上一条命令的退出码存放在特殊变量 $? 中,我们可以通过下面的命令查看:
echo $?
正如我们之前写的c语言代码一样,返回值便是0.
如果我们想查看全部的退出码,可以使用strerror函数
strerror

进程终止的常见办法
- 在main函数中return,就代表进程结束退出了(必须是main函数return才代表进程退出,其他函数return代表函数调用结束)
- 在自己的代码中,任意位置调用 exit 函数

#include <unistd.h>
void exit(int status);
这里exit函数的status参数就是进程退出码。
和exit函数具有非常相似功能的函数是_exit函数,_exit是一个系统调用函数,二者都能让进程退出,但有小小的差别:
- exit函数终止进程,顺便会刷新缓冲区。
- _exit函数直接终止进程,不会有任何其它的刷新操作。
虽然exit最后也会调⽤_exit,但在调用_exit之前,还做了其他⼯作:
- 执行用户通过atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写⼊
- 调用_exit

总结:
我们现在知道了进程=内核结构+进程代码和数据,当一个进程不再运行了,首先要做的是进入Z状态,也就是僵尸状态,父进程此时会去等待该进程,回收该进程的退出信息。然后将进程设置为X状态,此时才叫真正的进程退出。
三、进程等待
进程等待的必要性
我们现在只知道,进程终止的时候要等待,
- 当子进程退出的时候,如果父进程不对子进程做任何处理,就可能导致子进程进入僵尸状态,从而变成僵尸进程,会造成内存泄漏。
- 进程一旦进入僵尸状态,我们没有办法杀死这个进程,因为谁也没有办法杀死一个已经死去的进程。
- 父进程需要知道子进程的任务完成得如何,子进程运行是否完成,结果是否正确。
可以说,进程等待,就是父进程用来回收资源,获取子进程状态的。
进程等待的方法
wait方法
wait是一个Linux系统调用接口,我们先用man手册查看一下wait函数:

#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int* status);
- 返回值: 成功返回被等待进程pid,失败返回-1。
- 参数: 输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
我们这里来测试一下:
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <cstdlib>
int main() {
std::cout << "Parent (PID:" << getpid() << ") creating child..." << std::endl;
pid_t pid = fork();
if (pid < 0) {
std::cerr << "Fork failed!" << std::endl;
return 1;
}
else if (pid == 0) { // 子进程
std::cout << "Child (PID:" << getpid() << ") working..." << std::endl;
sleep(2);
std::cout << "Child exiting with code 42" << std::endl;
exit(42);
}
else { // 父进程
std::cout << "Parent waiting for child..." << std::endl;
int status;
pid_t terminated_pid = wait(&status);
std::cout << "Child (PID:" << terminated_pid << ") terminated" << std::endl;
if (WIFEXITED(status)) {
std::cout << "Exit status: " << WEXITSTATUS(status) << std::endl;
}
}
return 0;
}

可以看到,我们的父进程调用了wait函数回收了子进程的资源。
waitpid方法

我们会发现这是wait()的升级版,也是回收子进程的,同时可以可以获得更多的选择,wait是可以等待任意进程,而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: 输出型参数
WIFEXITED(status): 若为正常终⽌⼦进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED⾮零,提取⼦进程退出码。(查看进程的退出码)
options:默认为0,表⽰阻塞等待
WNOHANG: 若pid指定的⼦进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该⼦进程的ID。
我们再来详细了解一下status这个参数:我们的进程退出信息其实是包含了进程退出状态、进程退出码、进程退出信号等内容的,这些内容都叫作进程退出信息,也就是都存放在status这个输出型参数当中。
实际上,虽然status是一个整数,但它并不是整体当作一个整数使用的,而是被当作一个位图结构使用的。关于status这个整数,我们只需要关心该整数的低16个比特位,这低16个比特位是会被分为下图的三个部分:
其中我们能够通过次低八位来得到子进程的退出码,最低七位来得到异常信息,我们来用一段代码验证一下:


这里我们确实拿到了退出码是1
总结一下
进程等待的时候,父进程可以通过waitpid函数拿到子进程的进程退出码和进程退出信号了,如果子进程异常退出,那么父进程就受到进程退出信号;如果子进程正常退出,那么父进程就通过进程退出码判断子进程是代码跑完结果正确还是代码跑完结果不正确。
阻塞与非阻塞等待
- 阻塞等待:父进程在等待子进程的时候,如果子进程此时并没有退出,还在执行着任务,那么父进程只能够等待子进程退出,父进程的状态会由R状态变为S状态,也就是阻塞态,一直等待着子进程退出然后回收子进程。这就是阻塞等待。
- 非阻塞等待:父进程在等待子进程的时候,如果子进程此时并没有退出,还在执行着任务,父进程在waitpid函数内部不会阻塞式的等待子进程,而是在得知子进程暂时还没有退出的时候直接返回,从而继续执行其它任务,等到子进程退出的时候再去等待回收子进程。这种等待方案也叫作轮询等待方案
阻塞等待我们已经在上面看过了,接下来我们看看非阻塞等待:
这里需要补充的是,如果进程等待设置的是非阻塞等待,它的返回值有以下三种情况:
- 等待成功且子进程退出了,返回的是被等待进程的pid
- 等待成功但子进程并没有退出,返回的值是0
- 等待失败了,返回的值是-1
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
typedef void (*handler_t)(); // 函数指针类型
std::vector<handler_t> handlers; // 函数指针数组
void fun_one() {
printf("这是?个临时任务1\n");
}
void fun_two() {
printf("这是?个临时任务2\n");
}
void Load() {
handlers.push_back(fun_one);
handlers.push_back(fun_two);
}
void handler() {
if (handlers.empty())
Load();
for (auto iter : handlers)
iter();
}
int main() {
pid_t pid;
pid = fork();
if (pid < 0) {
printf("%s fork error\n", __FUNCTION__);
return 1;
} else if (pid == 0) { // child
printf("child is run, pid is : %d\n", getpid());
sleep(5);
exit(1);
} else {
int status = 0;
pid_t ret = 0;
do {
ret = waitpid(-1, &status, WNOHANG); // ?阻塞式等待
if (ret == 0) {
printf("child is running\n");
}
handler();
} while (ret == 0);
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;
}

我们可以看到,屏幕已经被打满了,原因就是父进程一直不断的询问,这势必会占用资源造成一定的卡顿,那怎么减少轮询次数呢?没错,就是回调函数,让子进程结束后触发回调函数,从而告知父进程他已经好了,就像食堂用餐,老板会在菜品上好以后把你手上的取餐感应器震动,从而避免你一次次去窗口轮询。
四、进程程序替换
当我们用fork()创建子进程之后,子进程会和父进程共享代码和数据(数据不发生修改的情况下),那如果我们需要创建子进程用来运行其他的程序呢?这该怎么实现呢?
这就要用到我们所说的进程程序替换。如下图所示,当我们想让子进程创建出来去运行其它程序的时候,同样也是先将要运行的程序从磁盘加载到内存,然后让子进程重新建立页表映射关系(准确地说应该是谁执行程序替换,就让谁重新建立页表映射关系,这里以子进程为例子),这样就能够让我们的父进程和子进程彻底分离,并且让子进程去运行一个全新的程序。

如何进行程序替换
我们如果想要执行一个全新的程序,我们需要怎么做?
- 首先必须是找到程序所在的位置,也就是知道程序在哪里
- 然后必须要知道程序是怎么执行的
这里我们就用到了六个程序替换函数:

#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[]);
这些函数原型看起来很容易混,但只要掌握了规律就很好记
- l (list) :参数采⽤列表
- v (vector) :参数⽤数组
- p (path) :有 p ⾃动搜索环境变量 PATH
- e (env) :表示⾃⼰维护环境变量

我们拿代码来举个例子:
#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 是Linux内核提供的唯一系统调用(位于man手册第2节),其他exec函数都是glibc基于它封装的库函数(位于第3节)

Linux进程控制详解


4079

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



