一、进程创建
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之前,还做了其他工作:
- 执行用户通过 atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入
- 调用_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 来实现非阻塞轮询。
非阻塞轮询有三种结果:
- waitpid > 0:好了
- waitpid == 0:没好,再等等
- 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、替换原理
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 。
关于进程控制的相关内容就讲到这里,希望同学们多多支持,如果有不对的地方欢迎大佬指正,谢谢!
版权归原作者 世间是否此山最高 所有, 如有侵权,请联系我们删除。