011-Linux进程控制

Linux进程控制

1. 进程创建

进程创建通常有两种方式:

  • 执行你的可执行程序
  • 在你的程序中使用fork函数创建子进程

1.2 fork函数

c 复制代码
#include <unistd.h>

pid_t fork(void);
// 返回值:子进程返回0,父进程返回子进程id,出错返回-1

进程调用fork后,执行fork代码的过程中:

  1. 分配新的内存块和内核数据结构给子进程
  2. 将父进程的部分数据结构内容拷贝给子进程
  3. 添加子进程到进程控制列表中
  4. fork返回

1.3 写时拷贝

通常fork出来的子进程,代码与父进程共享,开始时,数据也是共享,当父子任意一方试图写入,就会以写时拷贝的方式各自一份副本:

1.4 fork常规用法

  • 父进程希望复制自己,使父子进程同时执行不同的代码段。
  • 父进程需要一个进程来执行不同的程序,如使用exec函数(后面的进程替换会介绍)。

1.5 fork调用失败的原因

  • 系统中进程过多
  • 实际用户的进程超过了限制

2. 进程中止

2.1 进程中止的本质

中止一个进程的本质就是就是:

  1. 释放曾经代码和数据所占据的空间
  2. 释放内核数据结构(其中task_struct如果没有被父进程回收,会被延期处理,保持Z状态,直到被父进程回收)

2.2 进程中止场景

先看一段简单的代码:

运行:

对于上面这段简单的代码,运行起来也是符合预期的,没有任何问题,接下来我们把return的返回值改成100:

运行:

依旧也是没有任何问题的,和上面没有修改返回值的代码输出完全一致(pid不一样是因为进程每次启动分配的pid都是不一样的,无需在意),那么这个返回值的意义在哪里呢?

我们知道,在命令行中运行的程序都是bash的子进程,而当进程退出的时候,这个返回值就是返回给它的父进程的,我们在命令行中启动的进程自然也就是返回给它们的父进程bash,而在bash中,使用了一个变量?来存储我们最近一次子进程返回的退出码(也就是返回值),我们查看?变量就可以看到。

而这个退出码,如果程序是正常退出的我们一般就设为0,如果是异常退出的,就设为其他值,而我们之前写C语言代码时,最后都是return 0,也就是说,我们认为我们执行到main函数结束时,是正常退出的。当我们的程序中如果触发了什么特殊情况导致无法正常结束,我们就会返回非零值,比如开辟内存失败、创建子进程失败等这种不会让程序直接挂掉的异常,就可以返回非零值告诉父进程,这个任务执行失败了,而具体是什么原因导致的失败,我们可以用不同的非零值来表示,而C语言中提供了对一些非零值的描述,我们可以通过strerror函数来获取。

我们用一个简单的程序来打印出这些错误码分别代表什么。

运行结果(部分):

我们可以看到0~133号错误码是有不同的定义的,分别代表了不同的错误。

父进程为什么要得到子进程的退出码?因为父进程需要知道子进程运行的情况(成功/失败),如果失败,失败的原因?从而给用户返回信息,告诉用户这个进程失败的原因是什么,比如使用ls查看一个不存在的目录:

此时退出码是2,而我们可以通过上面的查到,2代表的是No such file or directory也就是上面打印出来的错误信息"没有那个文件或目录",为了让用户能看懂,并没有直接告诉用户退出码,而是打印出退出码所对应的意思,让用户决定下一步怎么操作。

对于错误码,我们可以使用系统定义的错误码,也就是根据C语言定义的错误码的意思来使用,我们也可以自定义错误码的意思。、

来看一份简单的代码:

运行结果:

在上述代码中,我们的意思是让Div函数除零的时候,返回-1。但是,我们在程序外部看的时候,我们并不知道它输出出来的值究竟本来就是-1还是输出的除零信息,所以我们不能只通过printf来输出错误信息。

接下来修改一下代码:

此时,我们自定义了退出码,在enum类型中,我们规定了Div_Zero的退出码为1,此时我们输出了-1,并在退出时将退出码1进行返回。

此时我们就可以通过查看退出码来轻易的判断这里的输出究竟是正常的答案还是输出的异常信息,当然,人是不太擅长直接阅读数字的,我们也可以实现一个类似于strerror的函数,来在程序中出现错误的时候直接输出错误信息(这很简单,在这里就不具体实现了)。

进程中止除了上面说的两种情况,还有一种特殊的情况,就是在程序还没有完的时候就异常终止了,而这种中止本质上就是因为OS给该进程发送了信号让其终止的,比如:

  • 我们在前面的文章中说过,使用kill -9 pid可以杀掉指定进程,而这个命令就是让OS发送9号信号给该进程,使其终止。
  • 或者我们在代码中对空指针进行写入,会报错(段错误),此时也是OS发现这个进程试图对空指针进行写入,给进程发送了11号信号,提前终止了进程。

这种情况下终止的进程就不能再看退出码来判断进程是什么原因退出的了(因为根本就没有正常退出,退出码可能根本就没被重新设置),此时我们应该关心该进程退出时收到的信号,可以根据OS发送给进程的信号来判断进程退出的原因。

【总结】进程退出有三种情况:

  • 进程运行完毕,结果正确
  • 进程运行完毕,结果不正确(可以通过进程的退出码判断)
  • 进程异常终止

如何判断进程是哪种情况退出:

退出码 退出信号 退出情况
0 0 正常退出
!0 0 非正常退出
任意 1 异常终止

在task_struct中,有int exit_signal和int exit_code两个变量,分别记录这个程序的退出信号和退出码,这也就是为什么,当程序退出后,父进程回收前,这个进程需要保持僵尸状态等待父进程的回收。

2.3 进程终止的方法

2.3.1 return

直接在mian函数中return,表示进程终止(在非main函数中return表示函数结束)。

2.3.2 exit函数

类似于return,也是返回指定的退出码,但是与return不同的是,exit函数表示进程终止,在当前代码的任意位置(包括非main中)都是直接退出进程。

2.3.3 _exit函数

与exit的作用几乎是一模一样的,都是可以在代码任意处结束当前进程并返回指定退出码。

不同的是,使用exit时会在退出进程前冲刷缓存区,但是_exit不会在退出前冲刷缓存区。

【引出】exit和_exit的本质区别是,从上面的man手册就能看出来,exit是C库函数,而_exit是系统调用,这里可以得出一个什么结论?我们之前提到的所谓的缓存区并不在OS内部,而是存在于语言层的东西,exit实际上是需要调用_exit的。

3. 进程等待

任何进程在退出的情况下,一般必须要被父进程进行等待。

进程在退出的时候,如果父进程不管不顾,子进程就会进入Z状态,造成内存泄漏。

3.1 等待的原因

  • 父进程通过等待,解决子进程退出的僵尸问题,回收系统资源(必须)
  • 获取子进程的退出信息,要知道子进程是因为什么原因退出的(可选)

3.2 等待的方法

3.2.1 wait
复制代码
pid_t wait(int *status);

作用:等待任意一个子进程退出。

参数:这里先不管,设为NULL即可,下面的waitpid中会详述。

返回值:返回等待成功的子进程pid。

当父进程调用wait时,父进程一直在阻塞等待,子进程本身就是软件,父进程本质是在等待某种软件条件就绪。

3.2.2 waitpid(重点)
复制代码
pid_t waitpid(pid_t pid, int *status, int options);

作用:等待指定子进程退出。

参数:

  • pid:等待指定pid的子进程,-1表示等待任意子进程。

  • status:这个参数是一个输出型参数,就是需要传入一个变量的指针,函数会通过这个指针来输入内容到这个变量中,这个变量将会被存放子进程的退出信息,如果不关心退出信息,传入NULL即可。

    关于这个退出信息,包含了子进程的退出码和退出信号,其中我们只考虑它的低16位,在低16位中,高8位代表的是退出码,低7位代表的是退出信号,第8位core dump标志暂时不管,后面讲信号时再谈。

    我们可以使用位操作拿出我们需要的信息:退出码 = (status >> 8) & 0xFF退出信号 = status & 0x7F

    也可以使用内置的宏来获取一些信息:

    • WIFEXITED(status):通过正常返回,则为真(!0),否则返回假(0)。
    • WEXITSTATUS(status):获取退出码。
  • options:

    • 输入0代表阻塞等待:就是在子进程退出之前,父进程会一直被阻塞在这个位置等待子进程的退出。
    • 输入WNOHANG(一个内置的宏)代表非阻塞等待:使用这种方式等待,如果子进程还没有退出,父进程没等待成功,父进程将不会被阻塞在这个函数,而是直接可以接收这个函数的返回值,然后父进程就可以去干其他的事情,不必一直阻塞在此处,其中在这种方式等待的情况下,如果返回值>0代表等待成功,返回值<代表等待失败,返回值==0代表子进程还没退出(有这种方法,我们就可以每过一段时间进行等待,没有等待成功就可以干其他事情,这种方式我们称为非阻塞轮询)。

返回值:返回等待成功的子进程pid,如果等待失败返回0。

测试代码:

c 复制代码
// task.h

#pragma once 
#include <stdio.h>

void LoadTask();

void HandlerTask();
c 复制代码
// task.c

#include "task.h"

#define N 3

typedef void(*func_t)();

func_t tasks[N] = {NULL};

void func1()
{
  printf("begin func1...\n");
}

void func2()
{
  printf("begin func2...\n");
}

void func3()
{
  printf("begin func3...\n");
}

void LoadTask()
{
  tasks[0] = func1;
  tasks[1] = func2;
  tasks[2] = func3;
}

void HandlerTask()
{
  for (int i = 0; i < N; i++)
  {
    tasks[i]();
  }
}
c 复制代码
// myprocess.c

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

void DoOtherThing()
{
  HandlerTask();
}

void ChildRun()
{
  int cnt = 5;
  while (cnt)
  {
    printf("child, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
    sleep(1);
    cnt--;
  }
  exit(123);
}

int main()
{
  printf("father, pid: %d, ppid: %d\n", getpid(), getppid());
  
  pid_t id = fork();
  if (id == 0)
  {
    // child
    ChildRun();
    printf("child quit...\n");
    exit(123);
  }
  // father
  int cnt = 0;
  LoadTask();
  while (1)
  {
    int status = 0;
    pid_t rid = waitpid(id, &status, WNOHANG);
    if (rid > 0)
    {
      if (WIFEXITED(status))
      {
        printf("wait success! rid: %d, exit_code: %d\n", rid, WEXITSTATUS(status));
      }
      else 
      {
        printf("quit unnormal!\n");
      }
      break;
    }
    else if (rid == 0)
    {
      usleep(10000);
      printf("child is running, father check next time!(%d)\n", ++cnt);
      DoOtherThing();
    }
    else 
    {
      printf("wait error!\n");
      break;
    }
  }

  return 0;
}

【补充】这里份文件的原因是使任务代码和父进程代码进行解耦,当添加新的任务时,只需要在task.c文件中修改即可。

运行结果(部分):

4. 进程替换

4.1 先看现象

有关进程替换的函数有7个,其中只有execve是系统调用,其他都是调用的execv,这里我们先使用,看现象,后面再解释。

先看代码:

运行结果:

我们可以发现,上面的代码执行完第一个printf后,就执行了我们指定的ls -a -l命令,然后,我们第二个printf没有被执行了,就结束了。

4.2 原理介绍

当我们运行我们的程序后,相应的,这个进程的代码和数据被加载到内存,task_struct、地址空间、页表被创建并初始化,然后运行execl时,将会用指定的程序的代码和数据覆盖掉当前进程的代码和数据,这就是程序替换,简单来说就是将当前进程的代码和数据进行替换(可以理解为"夺舍")。

我们使用execl的时候,并没有创建新的进程,而是指覆盖了代码和数据,进程还是原来的进程。

exec系列函数类似于一种Linux上的加载函数,作用是把代码和数据加载到内存里。

而前面的代码中我们的第二个printf函数没有运行的原因就是,它的原来的代码被覆盖了,随后就只执行我们指定的ls的代码了,所以,虽然exec*系列的函数有返回值,我们也不用去关心,因为只要替换成功,我们后续的代码就不会被运行,如果替换失败,我们后续的代码就会被运行。

4.3 将代码改为多进程版

我们使用上面的方法,就会把当前进程的全部代码和数据都替换掉,但是此时如果我们不想要替换掉当前的代码,而是需要另一个程序独立去执行,我们就可以使用多进程的方法,创建子进程,让子进程替换成我们需要执行的程序。

此时,当父进程创建子进程时,子进程会拥有自己的task_struct、地址空间和页表,此时子进程和父进程共享代码和数据,随后,当发生程序替换时,新的程序的代码和数据需要覆盖原有的代码和程序,但是子进程此时和父进程共享代码和数据,这时为了不影响父进程(进程独立性),就会发生写时拷贝,此时父进程和子进程的代码和数据层面,完全独立了。

4.4 使用所有的替换方法,认识函数参数的含义

我们上面介绍了,exec*函数一共有七个,而这七个应该如何分辨?我们可以通过名字来区分,名字中的部分字母代表下面的含义:

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

下面挑几个特殊的单独讲解,其他方法的使用方法都可以类推出来。

4.4.1 execl
c 复制代码
int execl(const char *path, const char *arg, ...);
  • path:需要替换的程序所在的路径,如:"/usr/bin/ls"

  • arg, ...:在命令行中执行的命令以及选项,分别用双引号引起来,最后以空指针NULL结尾,如:"ls", "-l", "-a", NULL

  • 举例:

    c 复制代码
    execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
4.4.2 execv
c 复制代码
int execv(const char *path, const char *argv[]);
  • path:与execl相同

  • argv:将execl中的arg, ...部分放在数组中,传一个数组指针即可

  • 举例:

    c 复制代码
    char *const argv[] = {"ls", "-l", "-a", NULL};
    execv("/usr/bin/ls", argv);
4.4.3 execvp
c 复制代码
int execvp(const char *file, const char *argv[]);
  • file:不必和path一样带上路径,直接传文件名即可,execvp会自己在环境变量中寻找该程序,如:ls

  • argv:与execv相同

  • 举例:

    c 复制代码
    char *const argv[] = {"ls", "-l", "-a", NULL};
    execvp("ls", argv);
4.4.4 execvpe
c 复制代码
int execvpe(const char *file, const char *argv[], char *envp[]);
  • file/argv:与execvp相同

  • envp:自己添加环境变量,传递的方式和argv类似。

  • 举例:

    c 复制代码
    // 假设我们在/111目录下有一个程序a.out
    char *const argv[] = {"ls", "-l", "-a", NULL};
    char *const envp[] = {"MYPATH=/111", NULL};
    execvpe("a.out", argv, envp);

【补充】添加环境变量时:

  • 父进程可以给子进程传递全新的环境变量(如上面的举例)
  • 也可以传递父进程中原有的环境变量(bash继承给父进程的,在父进程中声明extern char **environ,将environ传递给子进程)
  • 传递上面两个的组合,使用putenv(const char* path)添加新的环境变量到environ中,然后传递给子进程。
相关推荐
_OP_CHEN2 小时前
【Linux系统编程】(三十六)深挖信号保存机制:未决、阻塞与信号集的底层实现全解析
linux·运维·操作系统·进程·c/c++·信号·信号保存
IvanCodes2 小时前
六、Linux核心服务与包管理
linux
ayaya_mana2 小时前
Linux一键部署Docker与镜像加速配置
linux·运维·docker
七夜zippoe2 小时前
模拟与存根实战:unittest.mock深度使用指南
linux·服务器·数据库·python·模拟·高级摸您
bitbot2 小时前
Linux是什麼與如何學習
linux·运维·服务器
哈哈浩丶2 小时前
ATF (ARM Trusted Firmware) -2:完整启动流程(冷启动)
android·linux·arm开发·驱动开发
哈哈浩丶3 小时前
ATF (ARM Trusted Firmware) -3:完整启动流程(热启动)
android·linux·arm开发
哈哈浩丶3 小时前
OP-TEE-OS:综述
android·linux·驱动开发
哈哈浩丶3 小时前
ATF (ARM Trusted Firmware) -1:综述
linux·arm开发·驱动开发