Linux进程控制【概念 + 代码演示】

发布于:2022-11-08 ⋅ 阅读:(852) ⋅ 点赞:(0)

🏞️1. 认识fork函数

在Linux中fork函数是非常重要的函数,它从已存在进程中创建一个新进程,新进程为子进程,而原进程为父进程.

#include <unistd.h>
pid_t fork(void);
返回值:给子进程返回0, 父进程返回子进程pid, 出错返回-1

当进程调用fork后,控制转移到内核中的fork代码后,内核做:

  • 分配新的内存块和内核数据结构给子进程
  • 将父进程部分数据结构内容拷贝给子进程
  • 添加子进程到系统的进程列表当中
  • fork返回,调度器开始调度

image-20221105092705726

一个进程调用fork后,子进程与父进程具有相同的代码,而且此时CPU中对于它们的程序计数器都指向了相同的位置,也就是说,当这两个进程的fork返回后,它们从相同的位置开始运行.

接下来,我们使用一段代码观察一下fork函数执行后的现象:

#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>

int main()
{
  printf("fork之前的代码\n");

  pid_t id = fork();

  if(id == -1)
  {
    perror("fork() error\n");
    exit(1);
  }

  if(id == 0)
  {
    //子进程
    while(1)
    {
      sleep(1);
      printf("我是子进程 我的id是%d, 我的父进程是%d\n", getpid(), getppid());  
    }
  }
  else 
  {
    //父进程
    while(1)
    {
      sleep(1);
      printf("我是父进程, 我的id是%d\n", getpid());
    }
  }

  return 0;
}

运行结果:

image-20221105134104667

我们发现了一个现象:fork之后产生的子进程并没有执行fork之前的代码,而是从fork函数返回后往后执行.

这是因为在产生子进程后,子进程会拷贝父进程的程序计数器,所以子进程在产生后,会和父进程从同样的位置开始往后执行.

1.1 fork的常规用法

  1. 一个父进程希望复制自己,使父子进程执行不同的代码段,例如:父进程等待客户端请求,生成子进程来处理请求
  2. 一个进程要执行一个不同的程序,例如子进程从fork返回后,调用exec函数

1.2 fork调用失败的原因

  1. 系统中有太多的进程
  2. 实际用户的进程数超过了限制

🌁2. 写时拷贝

通常,父子进程共享代码和数据,当任意一方写入时,便以写时拷贝的方式各自拷贝一份副本:

image-20221105180328129

为什么要写时拷贝,为什么不在子进程创建的时候就把数据分开呢?

  1. 父进程的数据,子进程不一定全用,即便全用,也不一定全部写入
  2. 最理想的情况,在fork的时候,只把会被父子进程修改的数据,进行分离拷贝,不需要修改的共享即可(但实现太复杂)
  3. 如果fork的时候,就无脑拷贝数据给子进程,会增加fork的成本(时间和空间)

所以最终采用写时拷贝:只会拷贝父子修改的,就是拷贝数据的最小成本

🌠3. 进程终止

在我们写C/C++代码时,往往在程序的最终,我们都会写一句 return 0;,可是为什么要return 0呢?是给谁return 呢?

其实,return X,X代表进程退出码,当我们的进程退出时,如果return 0,表示结果正确,非0表示失败,那么进程退出码返回的非0值,就是用来表征进程失败退出的原因,而且这个进程退出码是让父进程来读取的.

3.1 进程退出场景

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

3.2 进程终止常见做法

  1. main函数中调用return

    注意,这里必须是在main函数中return,在非main函数中return代表函数调用结束,不代表进程退出.

  2. 在自己代码的任意地点,调用exit()函数

  3. 调用_exit()函数

_exit函数

#include <unistd.h>

void _exit(int status);

参数:status定义了进程的终止状态,父进程通过wait来获取此值

  • 说明:虽然statusint类型,但是仅有低8位被父进程所用,所以exit(-1)时,在终端执行$?发现返回值是255

exit函数:

void fun()
{
  printf("process exit\n");
  exit(111);
}

int main()
{
  fun();

  return 22;
}

运行结果:

image-20221105205859564

所以,在程序的任意位置调用exit()函数,进程就会立即退出,不会再执行return,进程退出码也以exit()为准.

exit最终也会调用_exit(),但在调用_exit()之前,还做了其他工作:

  1. 执行用户定义的清理函数
  2. 关闭所有打开的流,所有的缓存数据均被写入
  3. 调用_exit()

image-20221105210701115

所以exit()函数最终会刷新缓冲区,而_exit不会.

int main()
{
  printf("process exit");
  exit(1);

  return 22;
}

image-20221105210900306

int main()
{
  printf("process exit");
  _exit(1);

  return 22;
}

image-20221105211011354

🌌4. 进程等待

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

  1. 子进程退出,父进程如果不管不顾,就可能造成僵尸进程的问题,造成内存泄露
  2. 另外,进程一旦变成僵尸状态,那就刀枪不入,"杀人不眨眼的kill -9"也无能为力,没有办法杀掉一个已经死去的进程.
  3. 父进程派发给子进程的任务完成的如何,我们需要知道,如:子进程运行完成,结果对还是不对,或者是否正常退出

父进程通过进程等待的方式,回收子进程资源,或者子进程退出信息.

4.2 进程等待的方法

  1. wait方法

    #include <sys/types.h>
    #include <sys/wait.h>
    
    pid_t wait(int* status);
    

    返回值:成功则返回被等待进程的pid,失败返回-1
    参数:输出型参数,获取子进程的退出状态,不关心则可以设置为NULL

  2. waitpid方法

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

    返回值:

    ​ 当正常返回的时候waitpid返回收集到的子进程的进程ID;

    ​ 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;

    ​ 如果调用中出错,则返回-1,这时errno会被设置

    所以:返回值 > 0等待成功,返回值 < 0等待失败.

    参数:
    pid:
    0:是几,就代表等待哪一个子进程pid,
    -1:等待任意子进程

    status:
    WIFEXITED(status):若为正常终止子进程返回的状态,则为真(查看子进程是否正常退出)
    WEXITSTATUS(status):若WIFEXITED非0,提取子进程退出码. (查看子进程的退出码)
    options:如果设置了WNOHANG,若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待,若正常结束,则返回该子进程的ID.

  3. 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获取子进程退出信息

  4. 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞

  5. 如果不存在该子进程,则立即出错返回.

参数statuswait/waitpid函数内部拿出子进程的退出信息,那么,它具体是从哪里拿到的呢?

子进程会将自己的退出信息保存在自己的task_struct中,然后父进程通过系统调用wait/waitpid从子进程的task_struct中拿出子进程的退出码

4.3 status参数

  • waitwaitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充
  • 如果传递NULL,表示不关心子进程的退出状态信息
  • 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程
  • status不能简单的当作整型来看,可以当作位图来看待,具体细节如下:

image-20221107165134363

int main()
{
    pid_t id = fork();

    if(id < 0)
    {
        printf("fork error\n");
        exit(1);
    }

    if(id == 0)
    {
        printf("我是子进程, 我正在运行, 我的pid是%d\n", getpid());
        sleep(5);

        exit(10);
    }
    else
    {
        int status = 0;
        printf("我是父进程, 我在等待子进程的退出并回收它\n");
        pid_t ret = waitpid(id, &status, 0);  //阻塞等待

        if(ret > 0)
        {
            printf("wait success, 等待子进程的pid: %d, 子进程的退出码: %d, 子进程的退出信号: %d\n", id, 
            (status>>8)&0xFF, status&0x7F);
        }
        else
        {
            printf("wait failed\n");
        }
    }

    return 0;
}

可以使用这段代码来验证.

4.4 非阻塞等待

如何理解父进程阻塞?

父进程的进程状态由R(运行态)->S(阻塞),并将父进程由运行队列投入到等待队列,等待子进程的退出

基于轮询的非阻塞等待:

int main()
{
    pid_t id = fork();

    if(id < 0)
    {
        printf("fork error\n");
        exit(1);
    }

    if(id == 0)
    {
        while(1)
        {
            printf("我是子进程, 我正在运行, 我的pid是%d\n", getpid());
            sleep(5);
        }
        
        exit(10);
    }
    else
    {
        int status = 0;

        while(1)
        {

            pid_t ret = waitpid(id, &status, WNOHANG);  //非阻塞等待
            if(ret > 0)
            {
                printf("wait success, 等待子进程的pid: %d, 子进程的退出码: %d, 子进程的退出信号: %d\n", id, 
                (status>>8)&0xFF, status&0x7F);

                break;
            }
            else if(ret == 0)
            {
                printf("子进程还没好, 我先做其他事情\n");
                sleep(1);
            }
            else
            {

            }

        }
    }

    return 0;
}

⛺5. 进程程序替换

5.1 原理

当我们用fork创建子进程后,子进程执行的是父进程的代码片段,如果我们想要创建出的子进程,执行全新的代码片段呢?

这时我们就可以使用程序替换,让子进程调用exec函数以执行另一个程序,当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的例程开始执行,调用exec并不创建新进程,所以exec调用前后该进程的id并未改变.

为什么要进行程序替换呢?

我们一般在服务器设计的时候,往往需要子进程干两种事情:

  1. 让子进程执行父进程的代码片段(服务器代码)
  2. 让子进程执行磁盘中一个全新的程序,通过我们的进程,执行其他人写的代码等.

程序替换的原理:

  1. 将磁盘中的程序,加载进内存
  2. 重新建立页表映射,谁执行程序替换,就重新建立谁的映射,可以令父进程和子进程分离,并让子进程执行一个全新的程序

image-20221107202252177

5.2 替换函数

替换函数共有六种,统称为exec函数:

#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[]);

我们用一个替换函数来演示一下:

image-20221107204819071

#include<stdio.h>
#include<unistd.h>

int main()
{
    printf("我是一个进程, 我的id是 %d\n", getpid());

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

    return 0;
}

我们可以使用execl让我们当前的进程转去执行ls指令(ls也是一个可执行程序).

有一个问题:我们需不需要判断这个函数的返回值呢?

一旦替换成功,就将当前进程的代码和数据全部替换了,那该函数后面的代码也就被替换了,所以这个程序替换函数一旦替换成功,就不会有返回值,而失败的时候,会有返回值,并且会继续向后执行,所以返回值最多能让我们得到什么原因导致的替换失败.

如果我们使用fork创建一个子进程,子进程执行程序替换,会不会影响父进程呢?

不会,进程具有独立性,当程序替换时,子进程的代码和数据发生写时拷贝,完成父子进程的分离.

5.3 测试不同接口

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

image-20221107211545073

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>

int main()
{
    pid_t id = fork();

    if(id == 0)
    {
        //子进程
        char* const _argv[] = {
            "top",
            NULL
        };

        execv("/usr/bin/top", _argv);
    }
    else
    {
        //父进程
        int status = 0;

        int ret = waitpid(id, status, 0);

        if(ret == id)
        {
            printf("wait success\n");
        }
    }

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

image-20221107212130123

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>

int main()
{
    pid_t id = fork();

    if(id == 0)
    {
        //子进程
        execlp("ls", "ls", "-a", "-l", NULL);
    }
    else
    {
        //父进程
        int status = 0;

        int ret = waitpid(id, status, 0);

        if(ret == id)
        {
            printf("wait success\n");
        }
    }

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

image-20221107212502912

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>

int main()
{
    pid_t id = fork();

    if(id == 0)
    {
        //子进程
        char* const _argv[] = {
            "top",
            NULL
        };

        execvp("top", _argv);
    }
    else
    {
        //父进程
        int status = 0;

        int ret = waitpid(id, status, 0);

        if(ret == id)
        {
            printf("wait success\n");
        }
    }

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

image-20221107222647857

可以使用这段代码验证环境变量的导入是覆盖式的:

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

int main()
{
    extern char** environ;

    pid_t id = fork();

    if(id == 0)
    {
        //子进程
        char* const _env[] = {
            (char*)"MYPATH=YouCanSeeMe",
            NULL
        };

        execle("./mycmd", "mycmd", NULL,  environ);
    }
    else
    {
        //父进程
        int status = 0;

        pid_t ret = waitpid(id, &status, 0);

        if(ret == id)
        {
            printf("wait success\n");
        }
    }

    return 0;
}

//mycmd.cpp
#include<iostream>
#include<stdlib.h>
using namespace std;

int main()
{
    cout << "PATH: " << getenv("PATH") << endl;
    cout <<"----------------------------------"<< endl;
    cout << "MYPATH: " << getenv("MYPATH") << endl;
    cout <<"----------------------------------"<< endl;
    
    cout << "hello C++" <<endl;
    cout << "hello C++" <<endl;
    cout << "hello C++" <<endl;
    cout << "hello C++" <<endl;
    cout << "hello C++" <<endl;
    return 0;
}
int execvpe(const char *file, char *const argv[], char *const envp[]);

image-20221107223230758

在程序替换函数中,还有一个特殊的函数:

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

为什么它是单独列出的呢?

execve是一个系统调用,其他的六个替换函数都是对它的封装.

所以,最终我们可以总结出这些接口的规则:

  • l (list):表示参数采用列表
  • v (vector)参数采用数组
  • p (path):有p自动搜索环境变量PATH
  • e (env)表示自己维护环境变量

image-20221107233639672


网站公告

今日签到

点亮在社区的每一天
去签到