Linux --进程控制

本文从以下五个方面来初步认识进程控制:

目录

进程创建

进程终止

进程等待

进程替换

模拟实现一个微型shell

进程创建

在Linux系统中我们可以在一个进程使用系统调用fork()来创建子进程,创建出来的进程就是子进程,原来的进程为父进程。

当进程调用fork以后就会进行分流,内核会分配新的内存块和内核数据结构给子进程,将父进程的部分数据结构内容拷贝到子进程,添加子进程到系统的进程列表之中。

通常,⽗⼦代码共享,⽗⼦在不写⼊时,数据也是共享的,当任意⼀⽅试图写⼊,便以写时拷⻉的⽅式各⾃⼀份副本。因为有写时拷⻉技术的存在,所以⽗⼦进程得以彻底分离离!完成了进程独⽴性的技术保证!写时拷⻉,是⼀种延时申请技术,可以提⾼整机内存的使⽤率,具体⻅下图

fork之前⽗进程独⽴执⾏,fork之后,⽗⼦两个执⾏流分别执⾏。注意,fork之后,谁先执⾏完 全由调度器决定。fork的返回值在父子进程之中有不同的体现,父进程中会返回子进程的pid,子进程返回0,如果fork失败返回-1,这里我们用一段代码解释上面提到的一些概念。

这里我们定义了一个全局变量gav,在没有fork以前我们会看到父进程的pid和gav等于100,fork以后我们通过不同的返回值能够达到不同分支完成不同任务的效果,这里能看到子进程已经将gav修改成了110,而父进程的gav没有改变,两个进程的gav地址一样是因为在各自的虚拟内存的地址,实际它们的物理地址已经发生了写时拷贝被分配了新的物理地址。

需要注意的一点是,子进程会拷贝父进程的的代码,所以在结束if()分支后两个进程都会执行剩下的代码,而不要误解成为剩下的代码会由父进程执行。

fork的常规用法:

⼀个⽗进程希望复制⾃⼰,使⽗⼦进程同时执⾏不同的代码段。例如,⽗进程等待客⼾端请求,⽣成⼦进程来处理请求。

⼀个进程要执⾏⼀个不同的程序。例如⼦进程从fork返回后,调⽤exec函数,这个在进程替换的时候会讲解。

进程终止

进程终⽌的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。

进程退出有三个场景:1.代码运行完毕,结果正确。2.代码运行完毕,结果不正确。3.代码异常终止,返回异常信号。

进程终止有三个方式:1.main()函数return 2.调用exit(),可以在代码任何地方,表示进程结束。 3._exit(),这是系统调用接口,exit()的就是底层调用这个接口。这三种方式结束进程都会有返回值,异常终止的进程只有异常信号,这些都会存在内核数据中,父进程可以等待获取。在linux命令行中我们可以使用echo $?来查看进程的退出码。注意这个退出码是程序员自己约定好的,一般来说进程成功运行返回0,其他非0返回则代表进程执行出现错误,异常信号是由系统中断进程再返回的。

Linux Shell 中的主要退出码:


退出码 0 表⽰命令执⾏⽆误,这是完成命令的理想状态。
退出码 1 我们也可以将其解释为 "不被允许的操作"。例如在没有 sudo 权限的情况下使⽤yum;再例如除以 0 等操作也会返回错误码 1 ,对应的命令为 let a=1/0
130 ( SIGINT 或 ^C )和 143 ( SIGTERM )等终⽌信号是⾮常典型的,它们属于128+n 信号,其中 n 代表终⽌码。
可以使⽤strerror函数来获取退出码对应的描述

这里我使用kill -8 结束了这个进程,然后会打印一个退出信号,这是一个浮点数异常信号,信号代码为8,一般出现在除0错误中,当查看退出码的时候发现正好是128+8,即退出信号。


当进程正常结束时就会返回我们约定好的退出码。
另外说明一下exit()会刷新缓冲去再结束进程,而_exit会直接退出进程,不会刷新缓冲区,因为缓冲区是语言层面的,系统层面没有缓冲区。
第一个是使用exit(),第二个使用_exit(),结果并没有打印i的值。

进程等待

为什么要进行进程等待必要性?之前讲过,⼦进程退出,⽗进程如果不管不顾,就可能造成'僵⼫进程'的问题,进⽽造成内存 泄漏。 另外,进程⼀旦变成僵⼫状态,那就⼑枪不⼊,"杀⼈不眨眼"的kill -9 也⽆能为⼒,因为谁也没有办法杀死⼀个已经死去的进程。最后,⽗进程派给⼦进程的任务完成的如何,我们需要知道。如⼦进程运⾏完成,结果对还是不对,或者是否正常退出。 ⽗进程通过进程等待的⽅式,回收⼦进程资源,获取⼦进程退出信息。、

进程等待通常有两种方式,一个是pid_t wait(int* status),等待成功会返回进程Pid,失败则返回-1,status是一个输出型参数,如果不关心子进程退出状态可以将参数设为NULL。

另外一个是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则为非阻塞等待,此时父进程可以完成自己的任务
WNOHANG: 若 pid 指定的⼦进程没有结束,则 waitpid() 函数返回 0 ,不予以等
待。若正常结束,则返回该⼦进程的 ID 。

这里我们用一个子进程提前结束但是没有及时被父进程等待回收的例子来说明wait

cpp 复制代码
 pid_t id = fork();
    if(id == 0)
    {
        int cnt = 5;
        while(cnt--)
        {
            cout<<"子进程 pid:"<<getpid()<<endl;
            sleep(1);
        }
        exit(0);
    }
    else if(id>0)
    {
        sleep(10);
        pid_t rid;
        rid = wait(nullptr);
        if(rid>0)
        {
            cout<<"等待子进程成功 rid : "<<rid<<endl;
        }
        sleep(10);
    }

当子进程结束以后父进程还需要等待5秒才会回收,此时子进程是僵尸状态,五秒以后等待成功,子进程被释放只有父进程

wait和waitpid,都有⼀个status参数,该参数是⼀个输出型参数,由操作系统填充。
如果传递NULL,表⽰不关⼼⼦进程的退出状态信息。
否则,操作系统会根据该参数,将⼦进程的退出信息反馈给⽗进程。
status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16
⽐特位):

我们一般不会使用status位移以后获取退出状态,我们可以使用两个宏来获取进程结束的状态和退出码,需要注意的一点是即使异常退出例如下面代码除0错误,父进程也能成功等待子进程,因为子进程需要释放,否则就会造成内存泄漏!

异常退出时:

代码执行完毕退出时会返回我们约定好的退出码和进程的pid

上面的代码使用的是阻塞等待,如果需要父进程在等待的同时执行一些其他的任务我们就需要用到 waitpid(pid,status,WNOHANG)来等待子进程。代码示例:

这里使用一个循环来获取等待信息,当最后一个参数为WNHANG时,rid每次获取如果为0则表示子进程还在运行,当rid返回子进程的pid则代表等待成功,此时父进程可以不用阻塞在等待而完成其他的任务。
了解了进程创建,进程终止,进程替换,我们可以利用这三点来完成一个简单的父进程一直增加数据,然后定时创建子进程进行备份数据的一个示例

cpp 复制代码
vector<int> data;
void backup_data(vector<int>& data)
{
    string name =to_string( time(nullptr));//
    name += ".backup";
    FILE* fp = fopen(name.c_str(),"w");//以时间戳命名备份文件
    if(fp == nullptr)
        exit(1);
    string numdata;
    for(auto d : data)
    {
        numdata+=to_string(d);//将数据转换为string类型写入文件
        numdata+=' ';
    }
    fputs(numdata.c_str(),fp);
    fclose(fp);
    exit(0);
}
int main()
{
    int num = 0;
    while(++num)
    {
        data.push_back(num);//插入数据
        if(num %10 == 0)//每增加十次数据则进行一次备份
        {
            int status = 0;
            pid_t id = fork();
            if(id == 0)
            {
                backup_data(data);//子进程进行备份
            }
        pid_t rid = wait(&status);
        if(rid > 0 && WIFEXITED(status) && WEXITSTATUS(status) == 0)//等待子进程成功并且正常退出(退出码为0)
        {
            cout<<"备份成功!"<<endl;
        }
        else {
            cout<<"备份失败请注意!"<<endl;
        }
        }
        sleep(1);
    }
}

运行结果:

进程替换

fork() 之后,⽗⼦各⾃执⾏⽗进程代码的⼀部分如果⼦进程就想执⾏⼀个全新的程序就需要进程的程序替换来完成这个功能。程序替换是通过特定的接⼝,加载磁盘上的⼀个全新的程序(代码和数据),加载到调⽤进程的地址空间中。

替换原理:⽤fork创建⼦进程后执⾏的是和⽗进程相同的程序(但有可能执⾏不同的代码分⽀),⼦进程往往要调⽤⼀种exec函数以执⾏另⼀个程序。当进程调⽤⼀种exec函数时,该进程的⽤⼾空间代码和数据完全被新程序替换,从新程序的启动例程开始执⾏。调⽤exec并不创建新进程,所以调⽤exec前后该进程的id并未改变。这里用一个简单代码可以体现

其实有六种以exec开头的函数,统称exec函数:


这些函数原型看起来很容易混,但只要掌握了规律就很好记。
l(list) : 表⽰参数采⽤列表
v(vector) : 参数⽤数组
p(path) : 有p⾃动搜索环境变量PATH
e(env) : 表⽰⾃⼰维护环境变量

调用实例

cpp 复制代码
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);

事实上,只有execve是真正的系统调⽤,其它五个函数最终都调⽤execve,所以execve在man⼿册 第2节, 其它函数在man⼿册第3节。这些函数之间的关系如下图所⽰。

模拟实现一个微型shell

所以我们利用上诉的几个理论知识就可以手搓一个简易版的shell,我们首先要知道shell的工作原理其实就是:

1.获取命令行 2.解析命令行 3.执行命令

在此之前我们需要先实现shell循环打印我们的信息框 比如:[toutie40@VM-8-16-centos repos]$,这个信息框是由 [+用户名+@+主机名+当前地工作目录]

此时就可以打印处理命令行信息了

接下来我们需要实现地是获取命令行

此时我们就可以获取用户输入地命令了

然后我们需要将获取到地命令进行解析,将comman_buffer的字符分割到gargv的每一个元素中

接着就可以正确解析命令了

下一步就是将我们解析的命令使用子进程调用需要的进程即可

然后我们就可以实现一个简易版的shell了!​​​​​

但是​​它只能够正常调用一些系统,cd命令并没有调用成功,或者说调用了但是没有起效,这是因为调用的cd其实是更改了子进程的工作目录,并不能影响我们父进程shell的工作目录,所以这部分需要shell本身调用自身函数来实现功能的命令我们成为自建命令,那么我们可以使用chdir进行更改当前的工作目录,记得要将环境变量中的pwd一起修改,因为Linux中的cd命令也是这样实现的。

通过检查解析后的命令是否与内建命令相同,如果相同则shell本身调用函数实现,命令行的pwd也要记得修改

env,export等命令也是内建命令,我们在系统中export会添加加属于shell维护的env表里面,我们自己写的shell目前的环境变量表是直接继承shell进程的,我们应该也要维护自己的env表,那么这里用系统shell进程环境变量来创建我们自己的env表

使用了自定义的环境变量表以后记得修改命令行获取pwd的使用自定义的环境变量表,替换程序也可以使用我们自定义的环境变量表

至此我们就完成了一个"手搓的"shell,这个shell遵循了系统中shell的基本原理,有自己的环境变量表,能够实现一般命令和内建命令。通过这个案例我们能够知道为什么在export环境变量是一个内存级的修改,因为在每次重新启动shell以后就会重新加载环境变量,而环境变量是以参数的方式传给我们的替换程序从而实现全局变量。

总结

函数和进程之间的相似性 exec/exit就像call/return⼀个C程序有很多函数组成。⼀个函数可以调⽤另外⼀个函数,同时传递给它⼀些参数。被调⽤的函数执⾏⼀定的操作,然后返回⼀个值。每个函数都有他的局部变量,不同的函数通过call/return系统进⾏通信。
这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux⿎励将这种应⽤于程序之内的模式扩展到程序之间。如下图


⼀个C程序可以fork/exec另⼀个程序,并传给它⼀些参数。这个被调⽤的程序执⾏⼀定的操作,然后通过exit(n)来返回值。调⽤它的进程可以通过wait(&ret)来获取exit的返回值。
需要完整代码可以在我的gitee仓库中找到 study_code: 学习路上写的一些代码