Linux操作系统之进程(七):进程的控制(下)

前言:

大家好啊,距离我们上一篇博客已经过去了12天,不知道大家还记得到多少内容。

今天我们的任务依旧是进程的控制,作者菌想要通过本篇博客的内容,让大家更加清楚我们是如何控制进程的。

我会主要通过两个板块来为大家加固印象,第一个是继续介绍一下waitpid的阻塞非阻塞问题,并未大家简单写一个自动保存数据的代码。第二个部分是给大家介绍一下进程交换的函数exec函数,在了解这些之后,我会写一个自己的Shell命令行解释器的简单代码。

大家可以跟着我手动操作一下相关代码,这样也方便大家理解。


一、阻塞非阻塞

 我们上节课讲到了waitpid函数,他有三个参数,分别是:

pid_t pid, int *status, int options

其中第一个参数我们讲到,这是代表waitpid要等待的子进程id。当pid大于0时,等待进程ID等于pid的子进程;pid为-1时代表任意一个子进程,类似于wait;等于0时,等待与调用进程同进程组的任意子进程;小于0时,等待进程组ID等于pid绝对值的任意子进程。 

第二个参数我们讲到,这是一个输出型的参数,为一个指向整数类型的指针,用于存储子进程的退出信息,如果为空指针,就表示不关心子进程的退出状态。我们可以用这个参数来检查子进程的退出状态。

第三个参数我们没有提到,这个参数代表我们是否要等待子进程。

这是什么意思呢?

在wait函数里,我们等待一个进程时,大家可以发现, 此时只有子进程在跑自己的代码,而父进程则一直停留在原地,一直到子进程结束,随后被父进程回收后,父进程才会继续运行自己的代码。这个过程,就是阻塞的过程。

waitpid的第三个参数options默认是被设置为0的,表示阻塞等待,也就是跟wait一样,父进程等待时要停止运行自己的代码,专心致志的等待回收子进程。

大家不要以为阻塞等待限制了父进程就觉得他很low,实际上,我们以后用到阻塞等待的比例会会占到百分之八十。

waitpid的options参数也可以被设置为非阻塞等待,我们可以使用WNOHANG这个宏来充当options的参数,就代表父进程非阻塞等待。

关于阻塞与非阻塞等待,可以给大家举个例子。比如说你要和你的朋友张三去吃饭,但是你都准备好了,早早地站在楼下等张三,张三却一直不下来。于是你决定打电话给张三,这个时候就有两种情况,第一种就是你打这个电话,张三仍然还没下来,你就没挂这个电话,一直在通话中的等待。第二种就是你打电话催他之后,你就挂了电话,结果张三没下来,你过了一会还要继续打电话催他下来...如此往复。

可以理解,打电话其实就是我们做了一次系统调用。我们打了一次之后没挂电话,通话中的等待他下来,期间什么事情都没做,这就是阻塞等待。我们打了电话之后忙了一会自己的事情,比如说刷个短视频了,发现张三还没下来,又再一次打电话,这个过程就是非阻塞等待。

所以说非阻塞等待的过程不一定好,不知道大家注意到没有。因为你要不停的去打电话询问为什么不下来,这其实是是一个循环的过程,我们叫做非阻塞轮循。我们多次进行了打电话这个操作,这个操作要程序员自己用循环来调用非阻塞接口来完成。

对于非阻塞循环的返回值,当waitpid的返回大于0时,说明我们等待成功了,它返回的时等待成功的子进程pid,可以退出轮循调用waitpid的循环里了。如果返回值为0,表示子进程还未返回,我们仍然需要接下来轮循调用waitpid。返回值为-1,表示等待出错误。

我们写一段简单代码来看看这两种用法的区别:

int main()
{
    //如果函数运行成功,返回值id将会是子进程的pid
    pid_t id = fork();
    if(id == 0)
    {
        int cnt=3;
        while(cnt--)
        {
            printf("子进程运行中:%d\n",getpid());
            sleep(1);
        }   
        exit(0);
    }
    else if(id > 0)
    {
        //父进程阻塞等待pid为id的子进程
        waitpid(id,nullptr,0);
        while(1)
        {
            printf("父进程运行中:%d\n",getpid());
            sleep(1);
        }
    }
    else
    {
        printf("errno: %d ,errstring: %s\n",errno,strerror(errno));
        return errno;
    }
    return 0;
}

在这个代码里,就是我们简单的对一个waitpid函数进行的阻塞等待的调用,可以看见运行结果:

父进程是在子进程运行完成之后才继续执行自己的代码的,倘若我们把代码改一下呢: 

//非阻塞等待
int main()
{
    //如果函数运行成功,返回值id将会是子进程的pid
    pid_t id = fork();
    if(id == 0)
    {
        int cnt=3;
        while(cnt--)
        {
            printf("子进程运行中:%d\n",getpid());
            sleep(1);
        }   
        exit(0);
    }
    else if(id > 0)
    {
        //父进程阻塞等待pid为id的子进程
        int cnt=0;
        //轮循主体,调用waitpid进行等待
        while(1)
        {
            pid_t rid=waitpid(id,nullptr,WNOHANG);
           
            sleep(1);

            if(rid>0)
            {
                printf("等待成功!子进程成功返回!\n");
                //退出循环
                break;
            }
            else if (rid == 0)
            {
                printf("父进程运行中:%d,当前循环次数:%d\n",getpid(),cnt++);
            }
            else
            {
                printf("errno: %d ,errstring: %s\n",errno,strerror(errno));
                return errno;
            }
        }
        printf("退出轮循,继续完成父进程任务!\n");
    }
    else
    {
        printf("errno: %d ,errstring: %s\n",errno,strerror(errno));
        return errno;
    }
    return 0;
}

 可以看到,父进程与子进程都同时运行了自己的代码,在子进程退出后,父进程也收到了消息,退出了循环,继续执行了自己的其他代码:

以上就是我们进程等待函数的内容 ,那么接下来我们将会写一个自动备份数据的代码案例,方便大家进行总结。


二、自动备份数据代码案例

接下来我们写一个自动备份数据的代码,预计实现的功能是规定一个时间,每隔一次这个时间,就对数据进行备份,将其存储到文件的一个过程(10秒)。借此来帮大家温习与巩固我们所学的知识。

我们现在有以下文件:

process.cc,以及一个Makefile,负责对这个文件进行编译链接生成可执行文件。


首先,我们的process中有一个全局变量vector<int>数组,表示我们所想要备份的数据,我们在主函数里可以通过while循环不断插入数据,来模拟一个用户的行为:

#include<iostream>
#include<vector>
#include <unistd.h>

std::vector<int>data;
int main()
{
    int cnt=1;
    do
    {
        data.push_back(cnt++);
        sleep(1);
    }while(1);
}

为了方便,我们这里直接使用的do-while循环

而想要实现每隔一段时间(10秒)就进行备份,也很简单,我们可以通过if判断与时间标记cnt,在while循环里每隔十秒进行一个Save操作(因为我们的while循环里添加了一个sleep可以确定时间),这个Save也是我们要进行的存储操作,我们现在只是列出来还没有实现。

也就是,代码要改成:


void Save()
{
    
}
int main()
{
    int cnt=1;

     do
    {
        data.push_back(cnt++);
        sleep(1);
        if(cnt % 10 == 0)
        {
            Save(); 
        }

    }while(1);
    return 0;
}

为了存储的时候出错导致数据消失,我们之前学了创建子进程来执行操作(这样哪怕子进程出错了,也不会影响我们的父进程的数据)。为了防止阻碍我们用户的行为(主函数里的插入),我们这里使用非阻塞循环。

我们接下来就是要完成对Save函数的初始化:

我们根据之前的想法,首先就是要用fork创建一个子进程充当我们的打手来完成任务。但我们怎么知道fork创建子进程成功了吗?这就用通过返回值来判断,我们之前学过了strerror函数,可以通过这些函数来打印错误信息。

随后就是返回值了,我们之前说过返回值可以自己设定,所以我们可以定义一个枚举体,里面设置我们本次代码中可能需要用到的返回的宏。我们规定,正常完成工作返回为OK,宏值为0,fork进程失败返回1(FORK_ERROR),打开文件失败返回2(OPEN_FILE_ERROR)。有的同学说牢师牢师,为什么你后面两个宏没有初始化。亲爱的,回去把枚举体的有关内容复习一下吧,在枚举体中,如果没有显式赋值,编译器会自动递增前一个成员的值。

根据以上想法,我们新增、修改了有关部分代码如下:
 

enum 
{
    OK = 0,
    FORK_ERROR ,
    OPEN_FILE_ERROR,
};

void Save()
{
    pid_t id= fork();
    if(id<0)
    {
        printf("fork error:%s\n",strerror(errno));
        exit(FORK_ERROR);
    }

    //子进程代码块
    if(id==0)
    {
        //未完待续
    }

}

那么我们接下来的任务就是在Save中完善子进程与父进程的代码,先来子进程的:

我们最终的目的是为了让子进程执行一个文件管理操作,根据data数组里的数据生成一个备份文件,那么,我们在子进程的代码,自然就是要实现这些功能,为了我们代码的可读性、可维护性,我们继续定义一个新函数SaveBegin,让id==0时的代码复用这个函数。

这个SavaBegin函数,就是我们进行文件操作的战场。

我们创建备份文件时,通常以时间命名,所以我们可以创建一个变量,来专门控制我们的备份文件的名字。在此之后,通过一系列的文件操作来存储数据:

std::vector<int>data;

enum 
{
    OK = 0,
    FORK_ERROR ,
    OPEN_FILE_ERROR,
};

int SaveBegin()
{
    //我们创建一个name变量,负责控制我们生成文件的名字
    //通过time函数获取时间戳
    std::string name= std::to_string(time(nullptr));

    //.backup是个文件后缀,通常代表这个文件是一个备份的文件。
    name+=".backup";

    //我们要将data中的数据备份到文件中
    //当不存在该名称的文件时,以写方式打开的fopen会自动创建该名称文件
    FILE*fp=fopen(name.c_str(),"w");
    if(fp==nullptr)
    {
        printf("open file error:%s\n",strerror(errno));
        return OPEN_FILE_ERROR;
    }

    //我们通过datastr来备份data中的数据
    //它会在for循环中不断追加数据
    std::string datastr;

    for(auto d:data)
    {
        datastr+=std::to_string(d);
        datastr+=" ";
    }
    //使用fputs将datastr写入文件中。
    fputs(datastr.c_str(),fp);
    fclose(fp);
    return OK;
}

void Save()
{
    pid_t id= fork();
    if(id<0)
    {
        printf("fork error:%s\n",strerror(errno));
        exit(FORK_ERROR);
    }

    //子进程代码块
    if(id==0)
    {
        int ret=SaveBegin();
        exit(ret);
    }

}

随后就是我们万众瞩目的父进程代码了。

我们想的是让父进程非阻塞等待子进程,所以我们一定要有一个while循环。我们在main函数里已经有了一个while循环了。所以我们可以利用这个循环,当有子进程没被回收时,我们就在while循环里waitpid子进程。所以我们可以创建一个bool变量,负责告诉我们是否需要等待。也就是说,在创建子进程时将bool设置为true,表示有子进程被创建。当我们等待成功时,又将bool设置为false。

另外,我们还可以设置一个全局变量,负责保存我们所创建的子进程的PID。

具体情况如下

std::vector<int>data;
pid_t ID =getpid();

void Save()
{
    pid_t id= fork();
    if(id<0)
    {
        printf("fork error:%s\n",strerror(errno));
        exit(FORK_ERROR);
    }

    //子进程代码块
    if(id==0)
    {
        ID=getpid();
        int ret=SaveBegin();
        exit(ret);
    }

}
int main()
{
    int cnt=1;
    bool flag=false;
    do
    {
        data.push_back(cnt++);
        sleep(1);
        if(cnt % 10 == 0)
        {
            Save(); 
            flag=true;
        }
        if(flag)
        {
            int ret=waitpid(ID,nullptr,WNOHANG);
            if(ret>0)
            {
                flag=false;
            }
        }
    }while(1);
    return 0;
}

我们分别在创建子进程时获取子进程的PID给ID变量,随后在满足flag为true时进入条件语句,进行waitpid不阻塞等待。倘若等待的返回值大于0,说明等待成功了,我们就可以再把flag更改为false。在下次创建子进程之前,我们的父进程都不会执行waitpid的代码。

总的代码汇总如下:

#include<iostream>
#include<vector>
#include <unistd.h>//sleep的头文件
#include <string.h>//strerror头文件
#include<sys/wait.h>
#include<sys/types.h>

std::vector<int>data;
pid_t ID =getpid();

enum 
{
    OK = 0,
    FORK_ERROR ,
    OPEN_FILE_ERROR,
};

int SaveBegin()
{
    //我们创建一个name变量,负责控制我们生成文件的名字
    //通过time函数获取时间戳
    std::string name= std::to_string(time(nullptr));

    //.backup是个文件后缀,通常代表这个文件是一个备份的文件。
    name+=".backup";

    //我们要将data中的数据备份到文件中
    //当不存在该名称的文件时,以写方式打开的fopen会自动创建该名称文件
    FILE*fp=fopen(name.c_str(),"w");
    if(fp==nullptr)
    {
        printf("open file error:%s\n",strerror(errno));
        return OPEN_FILE_ERROR;
    }

    //我们通过datastr来备份data中的数据
    //它会在for循环中不断追加数据
    std::string datastr;

    for(auto d:data)
    {
        datastr+=std::to_string(d);
        datastr+=" ";
    }
    //使用fputs将datastr写入文件中。
    fputs(datastr.c_str(),fp);
    fclose(fp);
    return OK;
}

void Save()
{
    pid_t id= fork();
    if(id<0)
    {
        printf("fork error:%s\n",strerror(errno));
        exit(FORK_ERROR);
    }

    //子进程代码块
    if(id==0)
    {
        //记录子进程的PID
        ID=getpid();
        int ret=SaveBegin();
        exit(ret);
    }

}
int main()
{
    int cnt=1;
    //标记是否需要等待子进程
    bool flag=false;
    do
    {
        data.push_back(cnt++);
        sleep(1);
        if(cnt % 10 == 0)
        {
            Save(); 
            flag=true;
        }
        if(flag)
        {
            int ret=waitpid(ID,nullptr,WNOHANG);
            if(ret>0)
            {
                //等待子进程成功,不再需要等待子进程
                flag=false;
            }
        }
    }while(1);
    return 0;
}

代码很简陋,但总体思想上是没有错误的。运行一下我们可以看见:

可以看见,代码的运行效果是符合我们预期的。 

三、进程替换

1、进程替换的基本概念

请思考以下问题,当我们创建了一个子进程后,如果我们不想让这个代码执行我们这个父进程的什么代码,而是去执行一个全新的程序呢?

程序替换,就是做这种事情的。

接下来,就让我们从最基础的execl开始,来认识一下进程替换的相关函数:

int execl(const char *path, const char *arg, ..., NULL);

先不要管函数的参数列表,先看以下代码的效果:

int main()
{
    execl("/bin/ls","ls","-l","-a",nullptr);
    return 0;
}

我们执行这一行代码,可以发现,效果怎么跟系统中的命令一样啊?

我们自己的程序,居然直接调用起来了系统中的命令。

这种特性,就是系统的程序替换。他的本质就是将自己的代码段与数据段的许多数据用新的那个重新的数据与代码来做替换,更改一下页表的映射关系,随后执行新的代码与数据。值得注意的是,进程替换不等于创建新的进程。PCB与PID这些都不会变。

我们以最基础的execl函数为例,path变量是一个带路径的可执行程序,后面的是可变参数。例如在我这里使用过的:

execl("/bin/ls","ls","-l","-a",nullptr);

 其实,我们输入ls -l -a也是一样的效果,所以我们命令行上怎么输入,我们在传参数的时候也就怎么输入,nullptr表示结束。

替换的过程如图所示,就是将代码与数据在内存上进行覆盖,这个过程也就是我们以前所说的“加载” 。exec这样的接口,本质就是相当于把可执行程序,加载到内存。

 如何证明这并不是新创建的进程呢?

我们可以通过替换自己的程序来简单验证一下。

比如我现在有两个.cc文件分别生成可执行文件test与other:

test.cc:
int main()
{
    printf("我是test,PID为:%d\n",getpid());
    execl("./other","./other",nullptr);
    return 0;
}
oterh.cc:
#include<iostream>
#include<unistd.h>


int main()
{
    printf("我是other,PID为:%d\n",getpid());
    return 0;
}

我们在test中使用execl,将代码与数据替换为other的数据与代码,执行后我们可以看见,二者的PID是一样的,也就是说并没有新建一个进程。

值得注意,exec系列的函数的返回值情况。如果替换数据与代码成功,自然是不需要返回值的,但如果替换失败了,就会返回-1!

所以为了防止替换失败,而造成数据的丢失,我们一般也是使用子进程来进行进程替换的相关操作:

int main()
{
    printf("我是父进程,PID为:%d",getpid());
    pid_t id=fork();
   
    if(id==0)
    {
        printf("我是子进程,执行execl\n");
        execl("/bin/ls","ls","-l","-a",nullptr);
    }
    return 0;
}

Linux中所有的程序运行其实都是fork-exec的模式,这也就是shell命令行解释器的基本原理。

还记得之前在学习创建进程时,我们提出了一个问题,创建进程时,是先有的内核数据结构还是先加载的代码和数据。

答案是先创建的内核数据结构。

Linux进程创建遵循"先建骨架,后换血肉"的机制:当fork()被调用时,内核首先构建完整的进程控制结构(如task_struct),此时子进程如同一个空壳,承载着父进程的执行上下文但尚未拥有独立内容;直到exec()执行时,这个空壳才会被注入新的生命——清除原有内存映射,加载全新的程序代码和数据,完成从"形似父进程"到"脱胎换骨"的蜕变。

这种fork造壳、exec换芯的两段式设计,既确保了进程创建的原子性,又通过写时复制机制实现了高效的内存管理,这正是Unix哲学中"各司其职"原则的完美体现。

2、认识exec函数们 

除去我们最先讲的execl函数,exec系列中还有六个函数。 

execv:

int execv(const char *path, char *const argv[]);

这个函数与我们一开始使用的execl函数相比并没有多大改变,他只是把我们执行程序所需要的路径信息,用一个字符串数组记录下来而不是可变参数列表。

char *args[] = {"ls", "-l", NULL};
execv("/bin/ls", args);

我们可以这样记忆,exec l,后面的l代表是list链表的意思,象征着我们传参要像链表一样一节一节的传。而exec v后面的v是vector数组,代表这我们的变量是vector

execlp:

int execlp(const char *file, const char *arg, ..., NULL);

这个函数与execl相比只是后面多了一个p。而带p和不带p的区别是什么呢? 

第一个参数变成了file,你想要执行谁,就把谁填入,不要求带上路径。因为他是通过文件名加载程序,p的含义就是PATH环境变量,代表我要执行这个程序,我会自动搜索PATH环境变量(环境变量我们之前讲到过),如果没找到,就会替换失败。

如果我们想要执行ls,用execlp可以这样表示:

execlp("ls", "ls", "-l", NULL);

有些人会疑问这两个"ls"之间不冲突吗?

这两个ls之间是不重复的。第一个参数永远解决的问题是你想执行谁?

后面的可变参数列表解决的问题是你想怎么执行,我们这里想执行ls,执行的方法为:ls -l。他们所代表的语法含义是不一样的。

execvp:

int execvp(const char *file, char *const argv[]);

 通过文件名加载程序,参数为字符串数组,这个函数还是比较常用的函数之一,同样是执行ls命令,我们可以:

char *args[] = {"ls", "-l", NULL};
execvp("ls", args);

这样来执行。

甚至于说,你还可以偷会懒,这样执行:

char *args[] = {"ls", "-l", NULL};
execvp(args[0], args);

execvpe:

int execvpe(const char *file, char *const argv[], char *const envp[]);

这个函数结合了execvp和execle的特性,支持文件名搜索自定义环境变量。 第三个参数支持我们添加一个自己定义的环境变量。注意,这个参数会替代默认的系统的环境变量,也就是说,如果你传递的环境变量只有一个"/bin",那么你进行进程替换后,环境变量也只会有一个"/bin"。所以我们一般都会在原环境变量中追加。例如:
我们有如下other.cc的可执行程序

:

#include<iostream>
#include<unistd.h>
extern char** environ;

int main()
{
    //printf("我是other,PID为:%d\n",getpid());
    for(int i=0;environ[i]!=nullptr;i++)
    {
        std::cout<<environ[i]<<std::endl;
    }
    return 0;
}

这是一个打印自己的环境变量的程序,因为我们之前已经验证了进程替换不是创建新进程,所以按道理说执行到这里的应该是一个子进程。

我们有如下test.cc:
 

const std::string envvv="PATH=/home/ubuntu/dailycode/进程控制博客代码";
int main()
{
    pid_t id=fork();
    char *const args[] = {(char*)"other", NULL};
    putenv((char*)envvv.c_str());
    if(id==0)
    {
        execvpe("./other",args,nullptr);
    }
   
    return 0;
}

运行./test,可以看见结果:

可以看到,我们在运行了test程序后,首先这个进程,使用了putenv临时修改了环境变量。随后,我们fork出的子进程继承了环境变量,并且通过进程替换打印出了环境变量的信息。 

 除此之外,还有execve,execle两个函数,使用的方法也是大同小异,所以我们这里就不再过多介绍。

这些函数原型看起来很容易混,但只要掌握了规律就很好记:

l就是list,v就是vector,这两个代表传参的方式。p就是path,代表你前面不用加路径,会自动到环境变量里搜索。e就是env,环境变量,表示可以使用自己维护的环境变量。

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

 这么多exec函数,实际上是为了满足我们不同的应用场景,但不论使用谁,最底层一定调用的是execve。


总结

本章博客对上篇文章进行了一个简单的首尾,为大家介绍了进程等待一节中的waitpid的最后一个参数。随后给大家提供了一份自动备份数据的代码案例。最后,是关于进程替换的话题。进程替换实际上一点也不抽象,大家如果觉得有困难可能是因为平时接触较少。

总结的来说,进程创建(fork)就是先复制PCB和页表搭建"空壳",通过写时拷贝机制共享父进程的虚拟地址空间。进程替换(exec)则清空这个壳,重建页表映射,将新的程序代码/数据从磁盘载入物理内存,最终完成"旧瓶装新酒"的变身——PCB保持不变的进程身份证,页表负责虚拟到物理地址的翻译,而exec通过更新页表让同一虚拟地址指向全新的物理内容

接下来我会为大家带来Shell命令行解释器的模拟实现,这个部分会涉及到我们之前所讲的大部分内容,也算是我们为进程这一章节带来的一个总结!

如果有任何问题,或者作者的错误与不足,请在评论区指正!!谢谢!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

渡我白衣

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值