云计算百科
云计算领域专业知识百科平台

深入了解linux系统—— 进程控制

进程创建

fork函数

在Linux操作系统中,我们可以通过fork函数来创建一个子进程;

在这里插入图片描述

这是一个系统调用,创建子进程成功时,返回0给子进程,返回子进程的pid给父进程;创建子进程失败则返回-1给父进程。

我们就可以通过返回值来对父子进程进行分流:

#include <stdio.h>
#include <unistd.h>
int main()
{
int id = fork();
if(id < 0){
//创建子进程失败
perror("fork");
return 1;
}
else if(id == 0){
//子进程
printf("子进程, pid : %d\\n",getpid());
}
else{
//父进程
printf("父进程, pid : %d\\n",getpid());
}
return 0;
}

使用fork函数创建子进程,这里简单复习一下就OK了;

我们来看通过fork创建子进程,操作系统内核做了什么:

  • 首先就是分配新的内存块和内核数据结构给子进程
  • 然后就是将父进程部分数据结构内容拷贝给子进程
  • 将子进程添加到系统进程列表当中
  • fork返回,调度器开始调度
  • 这里fork之前我们的父进程独立执行,fork创建子进程之后,父子进程两个执行流就分别执行。

    写时拷贝

    我们知道,当创建子进程之后,父子进程的代码和数据是共享的,而当有一个进程(父进程/子进程)要进行数据修改时,我们的操作系统就会进行写时拷贝,重新开辟一块空间给这个进程,然后修改它的页表(虚拟地址和物理地址)的对应关系;这样就完成了写时拷贝。

    那操作系统是如何知道一段代码和数据现在是共享的呢?

    这个问题就比较简单了,在我们的页表中不仅存放了虚拟地址和物理地址的对应关系,还存在对这一块内存的权限(r、w等)

    当我们通过fork创建子进程之后,我们的父子进程共享代码和数据,操作系统就会将我们父子进程对代码和数据的权限修改为只读;

    当我们应该进程想要修改数据时,操作系统通过页表发现进程对数据的权限为只读,操作系统就会报错,然后给我们进程重新开辟一块空间,然后将数据拷贝过来,再修改我们进程页表的地址映射关系;

    而写时拷贝之后,操作系统就会将我们父子进程对代码和数据的权限修改为可读可写。这样我们进程之间就不会相互影响了。

    在这里插入图片描述

    写时拷贝:是一种延时申请技术,可以提高整机内存的使用率

    fork函数的常规使用

    我们之前使用fork来创建子进程,然后让父子进程分流执行不同的代码,这是fork常规使用的一种,也就是:父子进程执行不同的代码段。

    除此之外呢,我们还可以通过fork创建进程,然后通过调用exec系列函数,让子进程执行不同的程序。

    • 创建子进程,让子进程执行不同的代码段
    • 创建子进程,让子进程执行不同的程序

    fork失败的原因

    我们知道fork创建子进程成功,返回0给子进程,返回子进程的pid给父进程;而创建子进程失败则返回-1给父进程。

    那fork为什么会失败呢?

    简单来说就是:

  • 系统中存在太多的进程
  • 实际用户的进程数量超过了限制
  • 进程终止

    进程退出

    在我们执行一个程序时,这个程序可能代码运行完了,且结果符合我们的预期;也可能代码运行完了,但是结果不符合我们的预期;当然也可能我们的程序代码根本就没有运行完,而是中途退出了。

    所以进程退出,也就存在三种可能:

    • 代码运行完,结果正确;
    • 代码运行完,结果不正确;
    • 代码异常终止;

    进程常见退出方法

    那我们知道了进程退出有三种可能;那我们进程如何退出呢?

  • 从main函数返回
  • exit函数
  • _exit
  • 当然我们使用Ctrl + C杀死一个进程属于进程的异常退出;通过信号让进程终止也属于异常退出。

    exit函数

    在这里插入图片描述

    通过查看手册,可以发现exit函数是3号手册,也就是C标准库的库函数;

    那它的作用就是,退出一个进程,然后并返回退出码;像我们之前在C中执行的exit(1)就是退出程序并返回1。

    _exit系统调用

    在这里插入图片描述

    通过查看手册,我们发现_exit位于2号手册,也就是系统调用;

    它的作用也是退出进程,并且返回退出码。

    exit和_exit的区别

    那这两个函数都是退出一个进程,那有什么不同之处呢?

    首先,_exit是操作系统提供的进程退出的函数,而exit是库函数,exit可以说是对_exit的封装,在进程退出之前做了一些其他的事情。

    这里直白一点,直接说了:exit底层也会调用_exit函数,但是在调用_exit函数之前,还做了:

    • 执行用户通过atexit/on_exit定义的清理函数;
    • 关闭所有打开的流,所有缓存数据均被写入。(简单理解就是刷新缓冲区)
    • 然后再调用_exit。

    这里我们不懂atexit/on_exit这些,但是我们好像知道缓冲区,现在来看下面代码,通过exit和_exit退出的区别:

    #include <stdio.h>
    #include <stdlib.h>
    int main()
    {
    printf("hello linux");
    exit(1);
    //_exit(1);
    return 0;
    }

    我们知道,在输出时,缓冲区是按行刷新的,这里我们输出hello linux没有换行,数据就在缓冲区当中;

    在这里插入图片描述

    return退出和exit退出的区别

    我们知道在main函数中,return也是退出进程,exit也可以退出进程,那它们有什么区别呢?

    在main函数中,没有什么区别,都是进程退出;

    在其他函数中,return是退出当前函数,而exit是直接退出进程。

    退出码

    退出码,它可以告诉我们最后一次执行的命令(程序)的状态;

    我们可以使用echo $?来查看最近一次程序退出的退出码:

    #include <stdio.h>
    int main()
    {
    return 1;
    }

    在这里插入图片描述

    当程序退出时,退出码为0通常表示程序执行成功,没有问题;

    如果退出码不是0就认为程序运行不成功。

    这里也要记住一个点:当程序异常终止时,程序的退出码是没有意义的。

    进程等待

    为什么要等待

  • 在进程状态中,我们了解到了僵尸进程,但是我们只是知道子进程比父进程先退出,会造成子进程僵尸状态,但是我们并不知道如何去解决僵尸进程;(这里我们需要让父进程等待,去解决僵尸进程问题,且获取子进程退出时的退出信息)
  • 此外,当一个进程进入僵尸状态,我们是无法杀死一个僵尸进程的;因为这个进程已经退出了,只不过保留了task_struct等待父进程获取退出信息。
  • 创建子进程是让子进程去执行父进程派给子进程的任务,在子进程退出后,无论是正常退出还是异常退出,都要让父进程知道子进程的运行结果。
  • 所以我们要让父进程通过进程等待,来回收子进程的资源,获取子进程的退出信息。

    如何等待

    那我们如何让父进程等待呢?

    通过系统调用wait和waitpid;

    在这里插入图片描述

    wait方法

    wait只有一个参数,这个status是一个输出型参数,简单来说就是:我们想要让父进程获得子进程的退出信息,就传递一个int* 指针,这样在函数调用结束后,父进程就拿到了子进程的退出信息;(如果不需要获取子进程退出信息,可以传NULL)。

    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <sys/wait.h>

    int main()
    {
    int id = fork();
    if(id < 0)
    return 1;
    else if(id == 0){
    //子进程
    int cnt = 3;
    while(cnt){
    printf("子进程, pid : %d, ppid : %d\\n",getpid(),getppid());
    sleep(1);
    }
    exit(1);
    }
    //父进程
    sleep(5);
    wait(NULL);
    while(1){ }
    return 0;
    }

    看上述代码,我们让子进程循环进行三次,每次打印一句话,然后sleep1秒钟;循环运行结束直接退出;

    父进程先seep5秒钟,然后再进程等待wait(这里我们先不获取子进程退出信息,传NULL)。

    我们预期的结果是:子进程运行三秒,然后子进程退出,而我们的父进程先睡眠5秒,所以子进程有两秒是处于僵尸状态的;在父进程睡眠结束时,进入等待,然后子进程结束僵尸状态。

    这里为了方便我们查看,父进程等到结束之后,我们让父进程进入死循环。

    在这里插入图片描述

    这里对于返回值,如果wait执行成功则返回子进程的pid给父进程,失败则返回-1给父进程。

    waitpid方法

    waitpid进程等待的方法,与wait不同的是:waitpid多了两个参数:pid和option;

    首先是pid

    在这里插入图片描述

    这里我们主要就看-1和<0的部分:

    -1:当我们传-1时,就表示我们当前父进程要等待任意个子进程结束。

    >0:我们父进程等待某一个子进程时,我们可以传子进程的pid,让父进程等待某一个子进程

    现在来看waitpid的第三个参数:options

    options参数默认为0,它表示阻塞等待;

    什么意思呢?简答来说,就是父进程在调用waitpid时,如果要等待在子进程还没有结束,那父进程就在waitpid中阻塞掉,直到子进程退出,waitpid再返回。

    而我们也可以传WNOHANG,当我们传WNOHANG时,如果要等待的子进程还没有结束,waitpid就直接0,我们的父进程不会在waitpid中阻塞掉;如果子进程结束,那就返回子进程的pid。

    这里举个例子:

    最近周末,你要和女朋友出去旅游,现在要出发了,你已经准备好了,你来到了女朋友的宿舍楼下,要等你女朋友下楼;

    你给她打了电话过去,询问她好了没有,她说没有,然后你就挂了;拿起手机玩起来了游戏了;玩了一局游戏,你又给你女朋友打了过去,然后她说还没有,然后你又挂了,又开了一局游戏。打完又给你女朋友打去了电话询问她好没好,她是快了马上,说让你不用挂电话,她准备好了给你说。

    然后你没有挂电话,就拿着手机等待你女朋友给你说她准备好了。

    这里你打一次电话询问你女朋友好了没有,没有然后你就挂了电话;这本质上不就是一次非阻塞等待吗。(你女朋友没有好,然后你就挂了电话)

    而当你打电话询问你女朋友好了没有,知道了没有你并没有挂断电话,而是拿着手机等待你女朋友准备好。这不就是一次阻塞等待吗。

    而体现到我们父进程等待上面就是,父进程调用waitpid,如果子进程没有结束,waitpid没有返回,而是等待子进程退出后再返回,再次期间父进程就阻塞在了waitpid中;这不就是阻塞等待吗。

    而父进程调用waitpid,如果子进程没有结束,函数直接返回0,父进程可以去做自己的事情,这不就是非阻塞等待吗。

    阻塞等待这里就不演示了,现在看一下非阻塞等待:

    #include <stdio.h>
    #include <unistd.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    int main()
    {
    int id = fork();
    if(id == 0){
    //子进程
    int cnt = 3;
    while(cnt){
    printf("子进程, pid : %d, ppid : %d\\n",getpid(),getppid());
    sleep(1);
    }
    exit(1);
    }
    //父进程
    printf("wait begin\\n");
    while(waitpid(1,NULL,WNOHANG) == 0)
    {
    printf("父进程: pid : %d\\n",getpid());
    sleep(1);
    }
    printf("wait end\\n");
    sleep(10);
    return 0;
    }

    在这里插入图片描述

    一般情况下,我们使用非阻塞调用要轮循调用waitpid,再等待过程中,父进程可以执行自己的代码(比如要完成一个或多个任务)。

    获取子进程的status

    在上述中,我们进程等待解决了僵尸进程的问题;但是我们进程等待不止可以解决子进程的僵尸问题,还可以让父进程获取子进程的退出信息。

    在上述中,我们没有获取子进程的退出信息,所以传的参数是NULL,现在我们来获取一下子进程的退出信息。

    我们先来看以下代码:

    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    int main()
    {
    int id = fork();
    if(id < 0)
    return 1;
    else if(id == 0){
    //子进程
    printf("子进程, pid : %d, ppid : %d\\n",getpid(),getppid());
    exit(1);
    }
    //父进程
    int status = 0;
    wait(&status);
    //阻塞等待
    //waitpid(-1,&status,0);
    printf("child status : %d\\n",status);
    return 0;
    }

    在这里插入图片描述

    我们发现,我们子进程的退出码是1,但是我们拿到的status是256;这是为何?

    这是因为,虽然status是类型是int;但是我们不能将它当做一个整形来看待,而是当做一个位图;

    int类型4字节,也就是32个bit位;我们要将这32个bit位划分成以下三部分:

    在这里插入图片描述

    其中,高16位没有使用,我们不考虑

    而使用到的低16位中的高8位表示进程退出时的退出码;低8位表示进程的退出信号

    退出码:

    先来看退出码的区域:

    在这里插入图片描述

    退出信号:

    现在来看退出信号部分,在退出信号部分中,还存在着一个标识位core dump;

    在这里插入图片描述

    这里注意:如果进程是异常退出(被信号杀死),那它的退出码就没有任何意义了。

    就好比考试作弊被发现了,考试成绩就没有意义了。

    进程异常退出,程序都没有执行完,那退出码就没有什么意义了。

    我们了解了status,那我们如果想要通过status获得进程的退出码或者退出信号呢?

    这里就要涉及到位操作了;

    在获取退出码和退出信号之前,我们要先判断一下进程是否是被信号杀死的。

    我们只需判断status按位与上ox7F(0111 1111),判断退出信号是否为0即可(因为没有0号信号)。

    首先获取退出码:

    我们只需让status>>8然后再按位与&上0xFF(1111 1111)即可获得退出码。

    获取退出信号:

    在上述中其实已经描述了,status按位与&上0x7F(0111 1111)即可获得进程退出信号。

    如果进程退出信号为0那就表示进程是正常退出的,因为不存在0号信号。

    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    int main()
    {
    int id = fork();
    if(id < 0)
    return 1;
    else if(id == 0){
    //子进程
    printf("子进程, pid : %d, ppid : %d\\n",getpid(),getppid());
    exit(1);
    }
    //父进程
    int status = 0;
    wait(&status);
    printf("child status : %d\\n",status);
    printf("status exit code : %d\\n",(status)&0xFF);
    printf("status exit signal : %d\\n",status&0x7F);
    return 0;
    }

    在这里插入图片描述

    可以看到,这样我们的确获得了进程的退出码和退出信息。

    当然在操作系统中还存在一些宏,我们可以直接使用这些宏来获取退出码和退出信息:

    退出码:

    • WIFEXITED(status):判断程序是否正常退出;

      返回true就表示程序正常退出;

    • WEXITSTATUE(status):当进程正常退出时,可以通过WEXITSTATUE获取进程的退出码。

    退出信号:

    • WTERMSIG(status):当进程异常退出时,可以使用WTERMSIG获取当前进程的退出信号。

    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    int main()
    {
    int id = fork();
    if(id < 0)
    return 1;
    else if(id == 0){
    //子进程
    printf("子进程, pid : %d, ppid : %d\\n",getpid(),getppid());
    exit(1);
    }
    //父进程
    int status = 0;
    pid_t rid = wait(&status);
    if(WIFEXITED(status)){
    //进程正常退出
    printf("wait succes,rid : %d, exit code : %d\\n",rid,WEXITSTATUS(status));
    }
    else{ //进程异常退出
    printf("status exit signal : %d\\n",WTERMSIG(status));
    }
    return 0;
    }

    在这里插入图片描述

    进程切换

    在我们之前创建子进程时,我们创建的子进程执行的代码,都是父进程代码的一部分;

    如果我们想要让我们子进程执行不同的代码,执行新的程序呢?

    此时,我们就要一个进程切换,让子进程执行新的程序;

    先来看一段进程切换的简单代码:

    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
    printf("进程切换 begin\\n");
    execl("/usr/bin/ls","ls","-a","-l",NULL);
    printf("进程切换 end\\n");
    return 0;
    }

    在这里插入图片描述

    原理

    程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据)然后让进程执行全新的程序。

    听起来进程替换好高级,但是它是如何实现的呢?

    当我们的一个进程想要进行进程切换时,原理非常简单,就是将磁盘中全新的程序(代码和数据)覆盖式的加载到当前程序代码和数据的位置,然后修改页表即可。

    而我们如果是子进程想要进行进程切换:

    我们知道当我们父进程通过fork创建子进程时,操作系统会给子进程分配新的内存块和内核数据结构给子进程,然后将父进程的部分数据结构内容拷贝到子进程中,其中就包含进程地址空间,以及页表;这样我们父子进程就指向同一个代码和数据;

    那我们现在要进行进程替换,也就是修改子进程指向的代码;

    此时操作系统就会报错,发生写时拷贝,给子进程重新开辟一块空间,然后将新的程序的(代码和数据)加载到这块空间内,然后让子进程指向这一块新的空间,并修改页表;

    这样就完成了子进程的程序替换。

    在这里插入图片描述

    替换函数

    理解了进程切换的原理,我们现在来看进程切换exec系列函数。

    在这里插入图片描述

    exec系列函数一共有6个,记起来非常麻烦;但是这些命名都是有规律的。

    注意:exec系列函数如果替换成功是没有返回值的,因为替换成功之后,我们原来代码后面的部分就不会被执行了。

    如果替换失败,则返回-1

    命名规律

  • l(list):表示参数使用列表的形式(可变参数列表)
  • v(vector):表示参数使用数组的形式
  • p(path):表示可以自动去PATH环境变量的路径中找对应指令(简单来说就去执行系统命令不需要带路径)。
  • e(env):表示环境变量表;
  • 现在来依次看一下这些函数的简单使用:

    execl

    execl函数参数存在两个

  • 一个表示要执行新的程序所在的路径
  • 另一个则是参数列表,表示要怎们执行这个新的程序
  • execl函数命名只有l表示以参数列表的形式调用,执行系统指令时也要带上路径。

    注意:参数列表要以NULL结尾;(命令行参数表以NULL结尾)

    执行系统命令

    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
    printf("replace begin\\n");
    execl("/usr/bin/ls","ls","-a","-l",NULL);
    printf("replaxe end\\n");
    return 0;
    }

    在这里插入图片描述

    执行自己写的程序(这里我们自己写一个程序,输出一下命令行参数)

    //test.c
    #include <stdio.h>
    int main(int argc, char* argv[])
    {
    for(int i = 0;i < argc;i++)
    {
    printf("argv[%d] : %s\\n", i, argv[i]);
    }
    return 0;
    }

    //code.c
    #include <stdio.h>
    #include <unistd.h>
    #include <sys/types>
    #include <sys/wait.h>
    int main()
    {
    printf("replace begin\\n");
    execl("./test","./test","-a","-b","-c",NULL);
    printf("replaxe end\\n");
    return 0;
    }

    在这里插入图片描述

    execlp

    这个函数就非常简单了,和execl相比唯一的不同就是,执行系统指令时不需要带路径(会通过PATH环境变量去寻找)

    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
    execl("ps","ps","-a","-l",NULL);
    return 0;
    }

    在这里插入图片描述

    当然使用execlp也可以执行自己写的程序,不过要带上路径。

    execle

    这个函数就存在三个参数了:

  • 新的程序所在的路径(系统指令也要带路径)
  • 参数列表,以NULL结尾
  • 环境变量表。
  • 我们知道全局变量environ它执行环境变量表,所以我们如果不使用我们自己的环境变量表,传environ即可。

    这里我们让test输出一下环境变量表

    //test.c
    #include <stdio.h>
    int main(int argc, char* argv[], char* env[])
    {
    for(int i = 0; env[i];i++)
    {
    printf("env[%d] : %s\\n",i,env[i]);
    }
    return 0;
    }

    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
    extern char** environ;
    execle("./test","./test",NULL,environ);
    return 0;
    }

    在这里插入图片描述

    execv

    execv第一个参数和execl相同,这里就不描述了;

    看第二个参数:char* const argv[],简单来说就是指针数组;也就是命令行参数表;

    (注意:这里的命令行参数列表要以NULL结尾)

    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
    char* const argv[] = {
    (char* const)"ls",
    (char* const)"-a",
    (char* const)"-l",
    NULL
    };
    execv("/usr/bin/ls",argv);
    return 0;
    }

    execvp

    execvp和execv的区别就是,在执行系统命令时,我们可以不带路径;execvp会在环境变量PATH中找指定程序。

    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
    char* const argv[] = {
    (char* const)"ls",
    (char* const)"-a",
    (char* const)"-l",
    NULL
    };
    execpv("ls",argv);
    return 0;
    }

    execvpe

    execvpe存在三个参数,file、argv和env

    file:第一个参数,和上面一样,指的是程序所在的路径。

    argv:第二个参数指的是命令行参数表。

    env:第三个参数指的是环境变量表。

    这里我们还是使用test来测试,让test输出命令行参数表和环境变量表;

    //test.c
    #include <stdio.h>
    int main(int argc, char* argv[], char* env[])
    {
    for(int i = 0;i < argc;i++)
    {
    printf("argv[%d] : %s\\n", i, argv[i]);
    }
    printf("\\n");
    for(int i = 0; env[i];i++)
    {
    printf("env[%d] : %s\\n",i,env[i]);
    }
    return 0;
    }

    //code.c
    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
    char* const argv[] = {
    (char* const)"./test",
    (char* const)"-a",
    (char* const)"-b",
    (char* const)"-c",
    NULL
    };
    //使用自己的环境变量表
    char* const env[] = {
    (char* const)"I=LXB",
    (char* const)"MYVAL=666",
    NULL
    };
    execvpe("./test",argv,env);
    //extern char** environ;
    //execvpe("ls",argv,environ);
    return 0;
    }

    在这里插入图片描述

    这里也可以执行系统指令,不需要带路径

    这里简单总结一下这些函数:

    函数名路径参数格式环境变量表
    execl 需要带路径 列表 使用当前环境变量表
    execlp 系统命令不需要带路径 列表 使用当前环境变量表
    execle 需要带路径 列表 可以使用自己的环境变量表
    execv 需要带路径 数组 使用当前环境变量表
    execvp 系统命令不需要带路径 数组 使用当前环境变量表
    execvpe 系统命令不需要带路径 数组 可以使用自己的环境变量表

    execve函数

    与上面的exec系列的函数不同,execve是一个系统调用

    在这里插入图片描述

    这里简单来说上面的exec系列是库函数,而execve是操作系统提供的系统调用;

    exec系列函数对系统调用做了封装;

    那也就是说,我们使用execlp、execvp执行系统命令不带路径时,最后也会调用execve时也会带上路径吗?

    我们在使用execl、execv等,不传递环境变量表时,最后调用execve也会传递当前环境变量表吗?

    我我们在使用execl系列时,传递的参数列表,也会被转化为参数数组,然后传递给execve吗?

    对的,当我们使用execlp执行系统命令不带路径时,execl会根据环境变量PATH找到对应程序的路径,然后调用execve传程序的路径。

    当我们使用execl、execv等没有传环境变量表时,exec系列在调用execve系统调用时会传当前环境变量表environ。

    当我们使用execl系列,传递的参数列表,都会被转化成参数数组,然后再将参数值数组传递给execve。

    到这里,本篇文章内容就结束了,干货满满!!!

    简单总结:

    • 进程创建fork

    • 进程退出,退出时的退出码

    • 进程等待

      解决僵尸进程的问题;

      获取子进程退出时的退出信息

      退出信息status

    • 进程切换:原理

      exec系列函数

    感谢各位的支持!

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 深入了解linux系统—— 进程控制
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!