当前位置: 首页 > article >正文

进程控制~

一.进程控制

1.进程创建

我们可以通过./cmd来运行我们的程序,而我们运行的程序就是bash进程常见的子进程。当然我们也可以通过fork()系统调用来创建进程。

NAME
       fork - create a child process

SYNOPSIS
       #include <unistd.h>

       pid_t fork(void);

 fork会对子进程和父进程返回不同的返回值,对子进程返回0,对父进程返回子进程的pid,如果返回值小于0,则说明子进程创建失败。

fork引申出来的一系列问题:fork函数有两个返回值,fork函数对父子进程返回值不同,fork的返回值为什么大于0又等于0导致if if-else同时成立......这些问题已经在进程部分解释,这里就不再赘述了。【Linux】进程-CSDN博客

2.进程终止

进程终止的本质就是释放资源,就是释放进程创建时申请的内核数据结构和内存中的代码和数据。

而进程退出有三种场景:

  • 代码执行完,结果正确
  • 代码执行完,结果错误
  • 代码异常终止

而对于子进程来说,它是由父进程创建的,为了实现某种功能的,所以它也有上述三种退出场景。那么我们怎么区分进程的执行情况呢?

对于我们所写的C程序来说,为什么我们在main函数的最后要返回一个0呢?通常这表示程序正常结束,如果代码执行的结果不正确,此时就会返回一个非0值。所以对于一个C程序来说,我们可以通过返回值来判断代码的执行情况。而这个返回值就是错误码。

我们可以通过strerror()函数将错误码转换成错误信息,而errno则会记录最近的一次错误码。

就比如fopen这个函数,如果打开失败,会返回一个空的文件指针,并且设置错误码。我们可以故意让fopen失败,借助sterror(errno)来观察错误信息。

在C语言中,一共有134个错误码,大家可以用循环打印出每一种来看看。

 对一个C语言程序来说,它用错误码来标记执行情况,那么对于一个进程来说也一样!!!。一个进程的退出码,表示了该进程的执行情况。

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

对于上图来说,我们首先调用了ll,该进程正常结束,所以退出码为0,接着我们使用cd命令,跳转到不存在的路径,并且bash表示出现了错误,此时退出码就变成了1,在使用pwd命令,退出码就有变成了1. 

在Linux操作系统中,一个进程的退出码如果分为两种,0和非0,0表示成功,非0表示出错

  • 1:一般错误(如参数错误)
  • 2:误用shell命令(如rm删除只读文件)
  • 126:权限不足或命令不可执行
  • 127:命令未找到
  • 130:被CTRL+c终止
  • 139:段错误

 而进程的退出码会在该进程推出的时候写入该进程的pcb中

 我们可以通过main函数的返回值/exit/_exit来设置进程的退出码。

return语句在main函数处执行,才表示进程结束,如果在其他函数内执行,只表示该函数结束。

对于exit函数来说,不论在代码的那个地方执行到该语句,进程都将结束,并将exit设置的退出码写入到进程的pcb中。

而除了c标准库的的exit函数外,还有一个系统调用_exit函数。这两个函数有什么关系呢?

exit其实是对_exit函数的封装,调用exit函数最终还是会调用_exit。当然两者也是有区别的:

  • exit在结束进程之前会进行清理操作,刷新文件缓冲区
  • _exit会立刻结束进程,不进行清理操作

有了这个认识,我们就可以猜想,缓冲区的概念是C语言提出的,而不是系统内的缓冲区。 

3.进程等待

为什么要进行进程等待呢?

之前说过,当子进程结束之后,父进程没有回收子进程资源时,子进程就会处于僵尸状态,进而导致内存泄漏。

而且一旦进程进入僵尸状态,此时kill -9 也无法无能为力,因为kill -9 不能杀死一个已经死掉的进程。

我们父进程创建子进程是为了帮助我们执行任务的,执行的如何父进程得知道吧!

所以,之所以要进行进程等待,就是为了让父进程回收子进程的资源,避免内存泄漏,并且获取子进程的退出信息。最重要的是回收子进程资源,有时候我们并不关系子进程的结束信息。

说的好,那怎么进行进程等待呢? 

 3.1wait

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

pid_t wait(int* status);

wait就可以使父进程进行等待子进程,而wait会等待父进程的任意一个子进程,一旦等到子进程,等待就结束了。而它还有一个输出型参数status,我们稍后再说

我们接着下面这个例子,来观察子进程由Z->X的过程

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

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        // dhild
        int cnt = 5;
        while (cnt)
        {
            printf("child pid:%d, ppid:%d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        exit(1);
    }

    // father  
    sleep(10);
    pid_t wid = wait(NULL);
    if (wid > 0)
    {
        printf("success %d\n", wid);
    }
    else
    {
        printf("failed\n");
    }

    sleep(10);
    return 0;
}

说明:创建子进程后,让子进程循环5秒后,然后退出,此时父进程还在休眠中,还没有执行等待代码,此时子进程状态为Z,接着过了又过了5秒之后,父进程休眠结束,执行等待,我们现在不用参数,可以传NULL,此时子进程状态转为X。接着我们让父进程继续睡眠10秒。

监控脚本,每隔一秒观察父子进程的状态

while :; do ps ajx | head -1 && ps ajx | grep proc | grep -v grep; echo "-----------------------------------------------------------------";  sleep 1 ; done

接下里我们只需要死盯着STAT即可。

 

至此,我们成功验证了父进程等待子进程

通过wait()方法,我们可以使进入僵尸状态的子进程,被父进程回收。

 3.2waitpid

但是今天的主角并不是wait,因为其功能简单,而是waitpid则是最优先考虑使用的!!!

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

pid_t waitpid(pid_t pid,  int  *status,  int options);

wait就好像是waitpid的一个子功能,waitpid是一个完全版的wait,可以进行多种等待过程。

返回值pid_t:

  • > 0 :等待成功
  • < 0 :等待失败

参数pid:

  • >0:是多少,就表示等待pid为该数的子进程
  • -1:表示等待任意一个子进程,类似wait

参数status:输出型参数,与wait的功能一致,获取等待的子进程的退出码。

option:可以控制不同方式的等待过程,默认参数为0,表示阻塞等待......

当我们以waitpid(-1, NULL, 0);的方式调用时,此时waitpid和wait是一样的功能。

上面说了,进程等待主要是为了回收子进程的资源,也可以获取子进程的退出信息,回收子进程资源很好理解,将其的状态从Z->X,操作系统就会回收。那么如何获取子进程的退出信息呢?这就是参数status的事了!!!

0x1参数status: 

status作为一个输出型参数,子进程执行结束后,会将自己的退出信息和退出码写到自己的pcb中,父进程等待到子进程后,操作系统会在子进程的pcb中拿出退出信息和退出码写入到status中,最后将信息带回给父进程。

但是退出码是代码执行完毕,结果正确或不正确时的标志,而进程终止还有可能是异常终止,此时子进程也会有退出码么?

首先,进程异常终止肯定收到了信号,比如我们使用kill -9 的方式杀死进程就是给进程传递了信号。另外,一旦进程异常终止了,此时的退出码就无意义了,此时更关心的是退出信号。

那么也就是说,子进程pcb中,除了要维护退出码,还要维护退出信号,但是我们只传了一个整型,如果获取两个内容?

答案就是位图。对于status来说,它的32个比特位被分为了三个部分,高16位,中8位和低8位。高16位没有被使用,中8位记录着退出码,低8位中的最高位是一个core dump标志位,是程序崩溃时生成的内存快照文件,用于调试分析,这部分与信号有关,这里不做解释,最后的7位存储的就是退出信号的编号了。

下面,我们就验证一个status的作用:

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

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        // child
        int cnt = 5;
        while (cnt)
        {
            printf("child pid:%d, ppid:%d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        exit(66); // 子进程退出码设置为66,方便观察
    }

    // father
    int status = 0;
    pid_t wid = waitpid(id, &status, 0);
    if (wid > 0)
    {
        printf("success pid:%d exit_code:%d\n", wid, (status >> 8 & 0xFF));
    }
    else
    {
        printf("failed\n");
    }
    return 0;
}

说明:创建子进程后,我们让子进程执行5秒,子进程退出时,设置其退出码为66,接着父进程等待子进程,等待成功后,打印信息,并打印退出码。因为退出码存储在status的次低8未,所以我们首先右移8位,接着&0xFF,就可以拿出次低8位的内容。

上面是正常退出的结果,我们也可以测试进程异常终止时它的退出信号: 

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

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        // child
        while (1)
        {
            printf("child pid:%d, ppid:%d\n", getpid(), getppid());
            sleep(1);
        }
        exit(66); // 子进程退出码设置为66,方便观察
    }

    // father
    int status = 0;
    pid_t wid = waitpid(id, &status, 0);
    if (wid > 0)
    {
        printf("success pid:%d exit_code:%d exit_signal:%d\n", wid, (status >> 8 & 0xFF), status & 0x7F);
    }
    else
    {
        printf("failed\n");
    }
    return 0;

说明:我们让子进程死循环,让父进程进行等待,接着使用kill -9 杀死子进程,观察退出码和退出信号。

自此,我们就验证了wait和waitpid的status参数的用途。当进程被信号杀死时,status的次低8位就被置为了0.

但是我们每一次获取子进程退出码都要进行位运算么? 不,操作系统为我们提供了宏接口,我们可以直接使用宏来获取退出码和退出信号。

获取子进程退出码

WEXITSTATUS(status)

如果自己是正常结束的,则返回true,反之返回false

WIFEXITED(status)

获取子进程退出信号 

WTERMSIG(status)

通过上述三个宏,我们就可以让我们的等待过程变得更加完整健壮:

// father
pid_t rid = waitpid(id, &status, 0);
if(rid>0) // 等待成功
{
    if(WIFEXITED(status)) // 正常退出
    {
        printf("terminated normally exit_code:%d\n", WEXITSTATUS(status));
    }
    else
    {
        printf("terminated by signal exit_signal:%d\n", WTERMSIG(status));
    }
}

了解了status之后,我们在了解下一个参数option,它可以设置等待的行为。

0x2参数option:

wait等待和waitpid的第三个参数为0时都表示阻塞等待,什么是阻塞等待呢?

 阻塞等待就像c里面的scanf和c++里面的cin。当父进程执行到等待语句时,父进程就卡住了,除非子进程死亡,否则父进程什么也做不了。

当然,除了阻塞等待,还有非阻塞等待。第三个参数我们可以传WNOHANG,来使父进程不阻塞等待,可以执行自己的事。它表示的是return immediately if no child has exited.即父进程等待子进程时,发现没有一个子进程结束,它立刻返回,接着执行自己的代码。

但是归根结底父进程还是得等待子进程结束,如果非阻塞等待询问一次直接结束,执行自己的代码去了,这不还是会造成僵尸状态,内存泄漏。

所以非阻塞等待主要的使用场景是结合循环来实现非阻塞轮询,再结合waitpid的返回值,就可以实现父进程在等待过程中,执行自己的逻辑:

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

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        // child
        while(1)
        {
            printf("child pid:%d, ppid:%d\n", getpid(), getppid());
            sleep(1);
        }
        exit(66);
    }

    // father
    while(1)
    {
        int status = 0;
        pid_t rid = waitpid(id, &status, WNOHANG);

        if(rid>0) // 等待成功
        {
            if(WIFEXITED(status)) // 正常退出
            {
                printf("terminated normally exit_code:%d\n", WEXITSTATUS(status));
            }
            else// 异常退出
            {
                printf("terminated by signal exit_signal:%d\n", WTERMSIG(status));
            }
            break;
        }
        else if(rid == 0)
        {
            // 询问结束,子进程未退出,执行自己的逻辑
            // ......
            printf("非阻塞轮询执行逻辑......\n");
        }
        else
        {
            printf("等待失败\n");
            break;
        }
    }
    return 0;
}

说明:这里采用非阻塞等待的方式,一创建子进程后,父进程即可进入非阻塞轮询状态,首先调用一次,判断子进程是否结束,如果返回值>0表示等待成功,此时退出循环,如果返回值 == 0,表示调用结束,子进程未退出,如果返回值小于0,表示等待失败,也退出循环。在非阻塞轮询期间内,每进行一次调用,如果子进程未退出,此时父进程就可以执行自己的代码逻辑。待下一次继续等待。

4.进程程序替换

fork()之后,父子进程各自执行代码的一部分,那如果子进程想要执行一个全新的程序呢?进程程序替换来实现!!!

4.1程序替换的原理

进程 = 内核数据结构pcb+代码和数据,当我们进行程序替换的时候,操作系统会从磁盘中,将要替换的程序的代码和数据覆盖式的放在原代码和数据的位置上。

那么照上面所说,我们程序替换之后,原代码和数据就被覆盖了,是不是替换上来的程序执行完了,那么整个进程就结束了?

没错!程序替换结束后,替换上来的程序结束了,整个进程就结束了。 

看个例子:

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


int main()
{
    printf("进程开始执行,pid:%d, ppid:%d\n", getpid(), getppid());

    execl("/usr/bin/ls", "ls", "-a", "-l", NULL); // 进行程序替换,执行ls命令

    printf("进程结束,pid:%d, ppid:%d\n", getpid(),getppid());

    return 0;
}

说明:进程一开始便打印信息,接着我们就进行程序替换,执行ls命令,按照上面所说,最后面的打印语句将不再执行。

总结:在进行程序替换的时候,并不会创建新的进程,而是用待替换的程序的代码和数据覆盖原来程序的代码和数据,并且替换之后,原代码的后半部分就不存在了,替换完的程序执行完毕,进程就结束了。 

4.2exec系列接口

0x1exec系列函数返回值

exec系列函数的接口在成功调用时没有返回值,只有失败才有返回值-1,并且设置错误码。

The exec() functions return only if an error has occurred.  The return value is -1, and errno is set to indicate the error.

当你程序都已经替换成功了,原来的进程上下文都已经不在了,你要返回值干嘛呢?返回值谁接受呢? 

失败返回-1,这是非常明确的,所以我们只需要判断返回值是否为-1即可,如果不是-1就说明成功,失败了,我们在根据错误码来判断错误的原因。

0x2execl

exec系列函数都有相同的前缀,那么不同的后缀,就能体现出不同函数的特点。

就比如execl来说,l可以理解为list,即我们传入的参数就好像在一个一个链表的节点中存储的一样,而链表最后一个节点的next指针为NULL,所以对于execl函数来说,它的参数也要以NULL结尾。

#include <unistd.h>

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

参数path表示要替换的程序所在的位置即路径+程序名,接下来的参数我们在命令行怎么写,在这就怎么传。

就比如,我们要执行ls命令,写法如下

execl("/usr/bin/ls", "ls", "-l" , "-na", NULL);

我们不仅可以让父进程进行程序替换,也可以创建出来一个子进程,让其执行要替换的程序: 

int main()
{
    printf("程序开始\n");
    if(fork() == 0)
    {
        sleep(1);
        execl("/usr/bin/ls", "ls", "-l", NULL);
        exit(22);
    }

    int status = 0;
    waitpid(-1, &status, 0);
    printf("%d\n", WEXITSTATUS(status));
    printf("程序结束\n");

    return 0;
}

首先,子进程进行替换之后,不会执行exit函数,因为后序代码已经被覆盖了,所以我们父进程打印子进程的退出码是不是设置的22,而是0.

其次,为什么子进程进行程序替换没有影响父进程呢?

第一,进程具有独立性;第二,父子进程本来共享代码和数据,子进程进行程序替换,对代码和数据进行了修改,此时会进行写时拷贝。

综上,所以子进程进行程序替换是不会影响父进程的!!! 

 那么可不可以替换我们自己写的程序呢?

当然也是可以的!! 

首先我们写一段C++程序,并编译成可执行文件。

接着便是修改execl的参数

execl("./other", "other", NULL); // 第二个参数可以带./,也可以不带

看结果:

 0x2execlp
#include <unistd.h>

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

该函数多了一个后缀p,这个p的意思是环境变量PATH,所以使用这个接口时,第一个参数如果不带路径只是文件名,该函数就会从PATH环境变量里面去搜索该文件。

所以,execlp会自动在PATH环境变量中搜素指定的命令,如果找不到,就会失败返回-1并设置错误码!!!

int main()
{
    printf("程序开始\n");
    if(fork() == 0)
    {
        sleep(1);
        //execlp("ls", "ls", "-l", NULL);  // 成功替换
        execlp("other", "other", NULL);    // 替换失败 , 并设置错误码为22
        exit(22);
    }
    int status = 0;
    waitpid(-1, &status, 0);
    printf("%d\n", WEXITSTATUS(status));
    printf("程序结束\n");
    return 0;
}

说明:对于ls命令来说,他所处的路径/usr/bin/ls,在PATH环境变量中,所以不带路径是可以找到的。但是我们的other是在当前目录下,函数找不到,就会失败!!!

0x3execv

不一样的来了

#include <unistd.h>

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

第一个参数依旧是可执行文件的路径+文件名,表示我要执行谁;

第二个参数则不一样了,不在像命令行那样传参数了,而是传了一个命令行参数表!!!所以这里后缀的v其实就是vector的意思。

只需要将命令行参数放入放入一个指针数组里面即可,当然也要以NULL结尾。

int main()
{
    printf("程序开始\n");
    if(fork() == 0)
    {
        sleep(1);

        char* const argv[] = {
            (char* const)"ls",
            (char* const)"-l",
            (char* const)"-a",
            NULL
        };
        execv("/usr/bin/ls", argv);

        exit(22);
    }
    int status = 0;
    waitpid(-1, &status, 0);
    printf("%d\n", WEXITSTATUS(status));
    printf("程序结束\n");
    return 0;
}

 0x4execvp
#include <unistd.h>

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

有了上面的基础,这个就很好理解了,第一个参数会默认在PATH中搜索命令,第二个参数表示将命令行参数以指针数组的方式传过去,并且以NULL结尾。

这个就不做示例了,相信大家没问题

0x5execvpe 
#include <unistd.h>

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

这个就又不一样了,多了一个后缀e,表示的是环境变量envion。使用该接口除了传递文件名和命令行参数表外,还要传一个环境变量表。传了该环境变量表后就会将原来全局的环境变量表给覆盖掉,子进程只会看到传的环境变量了。

// proc.c
int main()
{
    printf("程序开始\n");
    if(fork() == 0)
    {
        sleep(1);
        char* const argv[] = {
            (char* const)"other",
            NULL
        };

        char* const envp[] = {
            (char* const)"MYENV = 112233445566778899",
            NULL
        };

        execvpe("./other", argv, envp);

        exit(22);
    }

    int status = 0;
    waitpid(-1, &status, 0);

    printf("程序结束\n");
    return 0;
}

// other.cc

#include <iostream>
#include <cstdio>

int main()
{
    extern char** environ;
    for(int i=0; environ[i]; ++i)
    {
        printf("env[%d] -> %s\n", i, environ[i]);
    }
    return 0;
}

说明:为了测试execvpe接口对环境变量的影响,我们利用proc替换我们自己的.cc程序。该.cc程序主要工作就是打印环境环境变量。我们让该接口替换我们自己的程序,并且在替换前设置了环境变量envp,按照上面所说,会将替换程序的环境变量给替换掉,所有.cc打印出来的环境变量应该只有我们自己设置的。

我们看结果,确实和我们预料的一样。 

对于第三个参数来说,如果envp为空的话,即只有一个NULL,新程序就会继承原来的环境变量,不会改变。

但是我们使用该接口时想给子进程新增环境变量,而不是覆盖原来的环境变量,怎么实现呢?

我们可以借助putenv函数来实现新增环境变量

#include <string.h>

int putenv(char *string);

在每一个进程的进程地址空间上,有一段空间专门用来存储当前进程的环境变量,putenv就是将指定环境变量加载到该进程地址空间上的指定位置。这样,我们再使用该接口时,第三个参数传NULL,新程序就会继承原来的环境变量,当然也包含我们新增的环境变量。

// proc.c

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


int main()
{
    printf("程序开始\n");
    if(fork() == 0)
    {
        sleep(1);
        char* const argv[] = {
            (char* const)"other",
            NULL
        };

        // 设置新增环境变量
        char* const addNewEnv[] = {
            (char* const)"MYENV1=11111111",
            (char* const)"MYENV2=22222222",
            (char* const)"MYENV3=33333333",
            NULL
        };

        // 加载新增环境变量
        for(int i=0; addNewEnv[i]; i++)
        {
            putenv(addNewEnv[i]);
        }

        extern char** environ; // 指向环境变量表的全局指针

        execvpe("./other", argv, environ);

        exit(22);
    }

    int status = 0;
    waitpid(-1, &status, 0);

    printf("程序结束\n");
    return 0;
}

// other.cc

#include <iostream>
#include <cstdio>

int main()
{
    extern char** environ;
    for(int i=0; environ[i]; ++i)
    {
        printf("env[%d] -> %s\n", i, environ[i]);
    }
    return 0;
}

说明:我们先定义出想要新增的环境变量,然后通过putenv将其加载到子进程的环境变量上,然后我们将全局的environ传给execvpe函数,让其替换程序,此时替换上来的程序就有了我们新增的环境变量。但是这个新增的环境变量只有子进程可以看到,父进程看不到。

 

0x6execle 

经过上面的分析,相比大家已经知道了该接口的用法了

#include <unistd.h>

int execle(const char *path, const char *arg,
                  ..., char * const envp[]);

4.3execve

通过对上面exec家族的了解,我们发现,按照规律应该有一个execve的函数啊,为什么找不到呢?

因为,这个函数是系统调用,而上面的exec家族都是对其在语言层上的封装

#include <unistd.h>

int execve(const char *filename, char *const argv[],
                  char *const envp[]);

但是我们以及可以用上面了解到的知识来解读它,v表示我们要传一个命令行参数表,e表示我们得传一个环境变量表!!!


上述,便是进程控制的全部内容~~~

本章完~


http://www.kler.cn/a/592328.html

相关文章:

  • 第6章:Dockerfile最佳实践:多阶段构建与镜像优化
  • 【Java】——方法的使用(从入门到进阶)
  • 人工智能助力家庭机器人:从清洁到陪伴的智能转型
  • 计算机网络基础:展望未来网络发展趋势
  • 自然语言处理入门4——RNN
  • Java 的 正则表达式
  • 【海螺AI视频】蓝耘智算 | AI视频新浪潮:蓝耘MaaS与海螺AI视频创作体验
  • 基于Spring Boot的项目申报系统的设计与实现(LW+源码+讲解)
  • JVM的一些知识
  • 浏览器工作原理深度解析(阶段四):排版系统与布局计算一、引言
  • 基于百度翻译的python爬虫示例
  • C++高频(五)之虚函数
  • pipost 如何提升团队协作效率 [特殊字符]
  • 【SoC基础】单片机常用总线
  • spring 配置websocket
  • 好数 第十五届蓝桥杯大赛软件赛省赛C/C++ 大学 B 组
  • 5.2 Alpha to coverage in Depth
  • MySQL 调优
  • Vue的watchEffect的追踪逻辑
  • Docker 内部通信(网络)