0


【Linux】进程控制

一、进程创建

1、fork

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

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

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

当一个进程调用 fork 之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以开始它们自己的旅程。即 fork 之前父进程独立执行, fork 之后,父子两个执行流分别执行。注意,** fork 之后,谁先执行完全由调度器决定**。

1.1、fork常规用法

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

1.2、fork调用失败的原因

  • 系统中有太多的进程,内存空间不足
  • 实际用户的进程数超过了限制

2、写时拷贝

通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:

二、进程终止

进程退出共有以下三种场景:

  • 代码运行完毕,结果正确
  • 代码运行完毕,结果不正确
  • 代码异常终止,因为某些原因,导致进程收到了来自操作系统的信号。

1、进程退出码

当进程正常执行完后,会返回退出码。一般而言,当结果正确,退出码为 0 ,当结果不正确,退出码为 非 0 值,比如 1、2、3、4... ,分别对应不同的错误原因,供用户进行进程退出健康状态的判断。

使用如下代码进程举例说明:

  1 #include <stdio.h>
  2 #include <assert.h>
  3 #include <unistd.h>
  4 
  5 
  6 int add_to_top(int top)
  7 {
  8   int sum = 0;
  9   for(int i = 0; i < top; ++i)
 10   {
 11     sum += i;
 12   }
 13   return sum;
 14 }
 15 
 16 int main()
 17 {
 18   int result = add_to_top(100);
 19   if(result == 5050) return 0;//结果正确
 20   else return 1;//结果不正确
 21 }

计算从 1 到 100 的累加,如果结果等于 5050 ,则说明结果正确,正常返回 0,否则说明结果不正确,返回 1 。

查看进程返回结果的指令:

echo $?

根据我们自己所写的代码,返回值为 1 ,说明结果错误。


补充说明

之后再输入 echo $? 后,显示的结果就都是 0 了:

这时因为** $? 只会保留最近一次执行的进程的退出码。**


关于C语言提供的进程退出码所代表的含义我们可以通过函数 strerror 来获取:

其中 errnum 为退出码。

我们编写如下代码来查看这些退出码的含义:

int main()
{
  for(int i = 0; i < 140; ++i)
  {
    printf("%d : %s\n", i, strerror(i));
  }

  return 0;
}

由于输出结果较长,这里就不再全部放出,我截取了其中一部分:

观察到:如果返回值为 0,说明进程成功,如果返回值为 2, 说明没有这个文件或目录。

但是并不是所有指令的退出码都是根据C语言提供的进程退出码为基准的,比如:

我们使用 kill -9 指令来杀死一个不存在的进程时所报的错误如果按照C语言的标准,退出码应该为3,但实际上退出码是 1 。

我们也可以自己来定义进程退出码的含义:

2、进程退出方式

当一个进程退出时,OS中就少了一个进程,就要释放该进程对应的内核数据结构 + 代码和数据。

进程正常退出有三种方式:

  • 从main函数return
  • 调用exit
  • _exit

众所周知,只有 main 函数 return 才标志进程退出,其他函数 return 仅仅代表函数返回,这说明进程执行的本质是 main 执行流执行

前面的内容已经介绍过 return 退出的方式,接下来讲解 exit 函数退出的方式:

编写如下代码:

使用指令 echo $? 查看进程退出码:

看到退出码为我们自己写入的 123 。由此我们得知函数 exit(int code) 中的参数 code 代表的就是进程退出码。在代码的任意地方调用 exit 函数都表示进程退出。


函数 exit 为C标准库函数,除此之外还有一个 _exit 函数,该函数为系统调用

其用法与 exit 相同。

exit 与 _exit 的区别在于,exit 中封装了 _exit, exit最后也会调用_exit, 但在调用_exit之前,还做了其他工作:

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

三、等待进程

1、进程等待必要性

  • 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
  • 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
  • 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
  • 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息

2、进程等待的方法

进程等待就是通过系统调用,获取子进程退出码或者退出信号的方式,顺便释放内存。

等待进程有两种方式,分别为 wait 与 waitpid 。

2.1、wait

返回值:成功返回被等待进程pid,失败返回-1。

参数:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL。

编写如下代码:

  1 #include <stdio.h>
  2 #include <string.h>
  3 #include <stdlib.h>
  4 #include <unistd.h>
  5 #include <sys/wait.h>
  6 #include <sys/types.h>
  7 
  8 int main()
  9 {
 10   pid_t id = fork();
 11   if(id == 0)
 12   {
 13     //子进程
 14     int cnt = 5;
 15     while(cnt)
 16     {
 17       printf("子进程,剩余%dS, pid: %d, ppid: %d\n", cnt--, getpid(), getppid());
 18       sleep(1);
 19     }
 20     exit(0);
 21   }
 22 
 23   //父进程
 24   sleep(10);
 25   pid_t ret_id = wait(NULL);
 26   printf("父进程,等待子进程成功, pid: %d, ppid: %d, ret_id: %d\n", getpid(), getppid(), ret_id);
 27   sleep(5);                                                                                                              
 28 
 29   return 0;
 30 }
      

在命令行输入

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

运行观察结果:

可以观察到 mytest 进程的三个阶段,第一阶段,父子进程都在运行。第二阶段,子进程变为僵尸进程,父进程继续运行。第三阶段,经过等待,僵尸进程被回收,父进程继续运行。

2.2、waitpid

函数 pid_ t waitpid(pid_t pid, int *status, int options);
** 1、返回值**:

  • 当正常返回的时候 waitpid 返回收集到的子进程的进程ID。
  • 如果设置了选项 WNOHANG ,而调用中waitpid发现没有已退出的子进程可收集,则返回0。
  • 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在。

2、参数

  • pid: Pid = -1,等待任一个子进程。与wait等效。 Pid > 0,等待其进程ID与pid相等的子进程。
  • status: WIFEXITED(status):若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出) WEXITSTATUS(status):若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
  • options: WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。

其中,waitpid函数的参数 status 是一个输出型参数,用于获取子进程的状态,即子进程的信号 + 退出码。我们可以把 status 看作位图:

整数 status 有 32 个比特位,我们只使用其中低 16 个比特位。

低16位中的次低 8 位代表退出状态,也成为退出码,低 7 位代表进程退出时收到的信号,如果为 0 ,就说明没有收到退出信号,为正常退出,如果信号不为 0 ,就说明进程是异常退出。只有在正常退出时,我们才会关注退出码。至于 core dump 以后再讲。

编写如下代码进行说明:

  1 #include <stdio.h>
  2 #include <string.h>
  3 #include <stdlib.h>
  4 #include <unistd.h>
  5 #include <sys/wait.h>
  6 #include <sys/types.h>                                  
  7                                                         
  8 int main()                                              
  9 {                                                       
 10   pid_t id = fork();                                    
 11   if(id == 0)                                           
 12   {                                                     
 13     //子进程                                            
 14     int cnt = 5;                                        
 15     while(cnt)                                                                                                
 16     {                                                                                                         
 17       printf("子进程,剩余%dS, pid: %d, ppid: %d\n", cnt--, getpid(), getppid());                             
 18       sleep(1);                                                                                               
 19     }                                                                                                         
 20     exit(123);                                                                                                
 21   }                                                                                                           
 22                                                                                                               
 23   //父进程                                                                                                    
 24   int status = 0;                                                                                             
 25   pid_t ret_id = waitpid(id, &status, 0);                                                                     
 26   printf("父进程,等待子进程成功, pid: %d, ppid: %d, ret_id: %d, child exit code: %d, child exit signal: %d\n", getpid(), getppid(), ret_id, (status>>8)&0xFF, status & 0x7F);                                                          
 28 
 29   return 0;
 30 }

运行结果如下:

子进程的信号为 0 ,退出码为 123 。符合我们的预期。

对于status,除了我们自己按位操作以外,也可以使用库提供的宏来替换:

WIFEXITED(status) :若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status) :若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)

3、获取子进程退出信息

我们知道子进程拥有自己的PCB结构 task_struct ,在task_struct中存在两个变量,分别为 int exit_code 与 int exit_signal 。当子进程退出时,OS会把退出码填写到 exit_code 中,把退出信号填写到 exit_signal 中,并维护子进程的 task_struct ,此时子进程的状态就是僵尸状态。通过 wait 或者 waitpid 系统调用可以访问到该内核数据结构,并把退出信息以上面所讲过的格式存放在 status 中,顺便释放该数据结构占用的内存空间。

了解了以上知识后,我们应该有一个疑问,父进程在等待子进程退出,并回收子进程。那么如果子进程一直都没有退出,父进程又在做什么呢?

默认情况下,在子进程没有退出的时候,父进程只能一直在调用 wait 或 waitpid 进行等待,我们称之为阻塞等待。关于阻塞的内容可以参考文章《进程概念》。

当子进程退出时,通过 parent 指针找到父进程,并把父进程放到运行队列中,继续执行 wait 或 waitpid 指令。

如果不想让父进程阻塞等待,则可以通过设置 waitpid 系统调用的参数 options 为 WNOHANG 来实现非阻塞轮询。

非阻塞轮询有三种结果:

  1. waitpid > 0:好了
  2. waitpid == 0:没好,再等等
  3. waitpid < 0:出错
  1 #include <stdio.h>
  2 #include <string.h>
  3 #include <stdlib.h>
  4 #include <unistd.h>
  5 #include <sys/wait.h>
  6 #include <sys/types.h>
  7 
  8 int main()
  9 {
 10   pid_t id = fork();
 11   if(id == 0)
 12   {
 13     //子进程
 14     while(1)
 15     {
 16       printf("子进程, pid: %d, ppid: %d\n", getpid(), getppid());
 17       sleep(1);
 18     }
 19     exit(0);
 20   }
 21   //父进程
 22   while(1)
 23   {
 24 
 25     int status = 0;                                                                                                                 
 26     pid_t ret_id = waitpid(id, &status, WNOHANG);
 27     if(ret_id < 0)
 28     {
 29       printf("waitpid error!\n");
 30       exit(1);
 31     }
 32     else if(ret_id == 0)
 33     {
 34       printf("子进程没退出,处理其他事情。。\n");
 35       sleep(1);
 36       continue;
 37     }
 38     else
 39     {
 40       printf("父进程,等待子进程成功, pid: %d, ppid: %d, ret_id: %d, child exit code: %d, child exit signal: %d\n", \
 41               getpid(), getppid(), ret_id, (status>>8)&0xFF, status & 0x7F);
 42       break;
 43     }
 44 
 45   }
 46   return 0;
 47 }

运行观察结果:

这就叫做父进程的非阻塞状态。

下面我们来写一个完整的父进程非阻塞代码:

  1 #include <stdio.h>
  2 #include <string.h>
  3 #include <stdlib.h>
  4 #include <unistd.h>
  5 #include <sys/wait.h>
  6 #include <sys/types.h>
  7 
  8 #define TASK_NUM 10
  9 
 10 //预设任务
 11 void sync_dick()
 12 {
 13   printf("刷新数据\n");
 14 }
 15 
 16 void sync_log()
 17 {
 18   printf("同步日志\n");
 19 }
 20 
 21 void net_send()
 22 {
 23   printf("网络发送\n");
 24 }
 25 
 26 //要保存的任务
 27 typedef void (*func_t)();                                                                                                           
 28 func_t other_task[TASK_NUM] = {NULL};
 29 
 30 
 31 int LoadTask(func_t func)
 32 {
 33   int i = 0;
 34   for(; i < TASK_NUM; ++i)
 35   {
 36     if(other_task[i] == NULL) break;                                                                                                
 37   }
 38   if(i == TASK_NUM) return -1;
 39   else other_task[i] = func;
 40 
 41   return 0;
 42 }
 43 
 44 
 45 void InitTask()
 46 {
 47   for(int i = 0; i < TASK_NUM; ++i) other_task[i] = NULL;
 48   LoadTask(sync_dick);
 49   LoadTask(sync_log);
 50   LoadTask(net_send);
 51 }
 52 
 53 void RunTask()
 54 {
 55   for(int i = 0; i <TASK_NUM; ++i)
 56   {
 57     if(other_task[i] == NULL) continue;
 58     other_task[i]();
 59   }
 60 }
 61 
 62 int main()
 63 {                                                                                                                                   
 64   pid_t id = fork();
 65   if(id == 0)
 66   {
 67     //子进程
 68     int cnt = 5;
 69     while(cnt--)
 70     {
 71       printf("子进程, pid: %d, ppid: %d\n", getpid(), getppid());
 72       sleep(1);
 73     }
 74     exit(123);
 75   }
 76   //父进程
 77   InitTask();
 78   while(1)
 79   {
 80 
 81     int status = 0;
 82     pid_t ret_id = waitpid(id, &status, WNOHANG);
 83     if(ret_id < 0)
 84     {
 85       printf("waitpid error!\n");
 86       exit(1);
 87     }
 88     else if(ret_id == 0)
 89     {
 90       RunTask();
 91       sleep(1);
 92       continue;
 93     }
 94     else
 95     {
 96       if(WIFEXITED(status))
 97       {
 98         printf("wait success, child exit code: %d\n", WEXITSTATUS(status));
 99       }
100       else 
101       { 
102         printf("wait success, child exit code: %d\n", status & 0x7F);
103       }
104     //  printf("父进程,等待子进程成功, pid: %d, ppid: %d, ret_id: %d, child exit code: %d, child exit signal: %d\n", \
105     //          getpid(), getppid(), ret_id, (status >> 8) & 0xFF ,  status & 0x7F);
106       break;
107     }
108 
109   }
110   return 0;
111 }

四、进程程序替换

创建子进程无非就两种目的:

  1. 让子进程执行父进程的一部分代码
  2. 让子进程执行全新的程序代码

为了让子进程执行全新的程序代码,就需要进行程序替换。

1、替换原理

1.1、进程的角度

用 fork 创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种 exec 函数以执行另一个程序。当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用 exec 并不创建新进程,所以调用 exec 前后该进程的 id 并未改变。

1.2、程序的角度

程序原本存放在磁盘中,当调用 exec 函数时,被加载到了内存中。所以程序替换就相当于程序加载器,我们平常说程序被加载到内存中,其实就是调用了 exec 。在创建进程的时候,是先创建的进程数据结构PCB,再把代码和数据加载到内存的。

2、替换函数

程序替换的接口函数:

2.1、execl

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

函数参数列表中的 "..." 为可变参数。可以让我们给C函数传递任意个数个参数。 path 为程序路径, arg 为命令 + 命令参数,最后一定要以 NULL 为结尾。

编写如下代码进行解释说明:

  1 #include <unistd.h>
  2 #include <stdio.h>
  3 #include <stdlib.h>
  4 
  5 int main()
  6 {
  7   printf("hello world\n");
  8   printf("hello world\n");
  9   printf("hello world\n");
 10   printf("hello world\n");
 11 
 12   execl("/bin/ls", "ls", "-a", "-l", NULL);                                                                                          
 13 
 14   printf("can you see me\n");
 15   printf("can you see me\n");
 16   printf("can you see me\n");
 17   printf("can you see me\n");
 18   return 0;
 19 }

需要注意的是,命令参数一定要以 NULL 为结尾。

运行程序观察结果:

可以看到 execl 函数后,执行程序替换,新的代码和数据就被加载进内存了,后续的代码属于老代码,直接被替换掉,没机会再执行了。程序替换是整体替换,不能局部替换

** 进程替换只会影响调用 execl 的进程,不会影响其他进程,包括父进程,因为进程具有独立性。换句话说,子进程加载新程序的时候,是需要进行程序替换的,发生代码的写时拷贝**。


补充内容

我们知道 execl 是一个函数,也有可能调用失败。如果程序替换失败,进程会继续执行老代码,并且 execl 一定会有返回值。反之,如果程序替换成功,则 execl 一定没有返回值。只要 execl 有返回值,则程序替换一定失败了。

如果程序替换成功,新程序的退出码会返回给子进程,同样可以被父进程拿到:

  1 #include <unistd.h>
  2 #include <stdio.h>
  3 #include <stdlib.h>
  4 #include <sys/wait.h>
  5 
  6 int main()
  7 {
  8   pid_t id = fork();
  9   if(id == 0)
 10   {
 11     printf("子进程, pid: %d\n", getpid());
 12     execl("/bin/ls", "ls", "not found", NULL);
 13     exit(1);
 14   }
 15 
 16   sleep(5); 
 17 
 18   int status = 0;
 19   printf("父进程, pid: %d\n", getpid());
 20   waitpid(id, &status, 0);
 21   printf("child exit code: %d\n", WEXITSTATUS(status));                                                                                 
 22                                                                                                    
 23   return 0;                                                                                        
 24 }                                 

因为 ls 指令没有 "not found" 命令选项,所以新程序一定会返回对应的退出码给子进程,并最终被父进程获取:


2.2、execv

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

函数参数列表中, path 为程序路径, argv 数组内存放 命令 + 命令参数。 execl 与 execv 只在传参形式上有所不同。

观察结果:

2.3、execlp

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

函数参数列表中, file 为程序名, arg 为命令 + 命令参数, "..." 为可变参数。除了 file 外,其他用法与 execl 相同。

当我们执行指定程序的时候,只需要指定程序名即可,系统会自动在环境变量 PATH 中进行查找。

查看运行结果:

2.4、execvp

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

函数参数列表中, file 为程序名, argv 数组内存放命令 + 命令参数

2.5、execle

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

execle 的函数参数列表中,比 execl 多了一个 envp , envp 为自定义环境变量。

我们在当前目录的子目录 exec 里再编写一个可执行文件 otherproc :

观察运行结果:

可以发现该子进程没有环境变量 MYENV 。

现在我们接着编写之前的 myproc 程序:

在 myproc 中使用 execle 函数调用 otherproc 程序,并给该程序传递环境变量 MYENV 。

运行并观察结果:

发现 otherproc 进程中已经有了环境变量 MYENV ,但是 PATH 却没有了。这是因为函数 execle 传递环境变量表是覆盖式传递的,老的环境变量表被全部清空了,只保留我们传递的自定义环境变量。

如果我们想在原有环境变量的基础上给进程添加环境变量,则可以使用函数 putenv :

运行观察结果:

此时就可以得到预期的结果了。

因为所有的进程都是 bash 的子进程,而 bash 执行所有的指令都可以直接通过 exec 来执行,如果需要把环境变量交给子进程,只需要调用 execle 就可以了。因此,我们成环境变量具有全局属性。

2.6、总结

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

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

事实上,只有 execve 是真正的系统调用,其它函数都是 execve 的封装,最终都要调用 execve 。


关于进程控制的相关内容就讲到这里,希望同学们多多支持,如果有不对的地方欢迎大佬指正,谢谢!

标签: linux 服务器 运维

本文转载自: https://blog.csdn.net/weixin_74078718/article/details/130080280
版权归原作者 世间是否此山最高 所有, 如有侵权,请联系我们删除。

“【Linux】进程控制”的评论:

还没有评论