在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include
pid_t fork(void);
返回值:自进程中返回0,父进程返回子进程id,出错返回-1
进程调用fork,当控制转移到内核中的fork代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
当一个进程调用fork之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以 开始它们自己的旅程,看如下程序。
int main( void )
{
pid_t pid;
printf("Before: pid is %d\n", getpid());
if ( (pid=fork()) == -1 )
perror("fork()"),exit(1);
printf("After:pid is %d, fork return %d\n", getpid(), pid);
sleep(1);
return 0;
}
运行结果:
[root@localhost linux]# ./a.out
Before: pid is 43676
After:pid is 43676, fork return 43677
After:pid is 43677, fork return 0
这里看到了三行输出,一行before,两行after。进程43676先打印before消息,然后它有打印after。另一个after 消息有43677打印的。注意到进程43677没有打印before,为什么呢?如下图所示
所以,fork之前父进程独立执行,fork之后,父子两个执行流分别执行。注意,fork之后,谁先执行完全由调度器决定。
- 子进程返回0
- 父进程返回的是子进程的pid。
问:fork()之后,是否只有fork之后的代码是被父子进程共享的?
答:一般情况下,fork()之后,父子共享所有的代码。
注意:子进程执行的代码!=共享的所有代码,只不过子进程只能从这里开始执行(eip程序计数器保存当前正在执行指令的下一条指令,在fork()函数执行的时候,eip作为父进程的上下文数据会被拷贝给子进程,所以子进程和父进程都是从fork()位置后开始执行)。
问:fork()之后,操作系统做了什么?
答:进程 = 内核的数据结构 + 进程的代码和数据。fork()之后,创建了子进程的内核数据结构(struct task_struct + struct mm_struct + 页表)+ 代码继承父进程,数据以写时拷贝的方式,来进程共享。
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副 本。具体见下图:
问:为什么要写时拷贝?创建子进程的时候,就把数据分开,不行吗?
答:1. 父进程的数据,子进程不一定全用,即便全用,也不一定全部写入,如果创建子进程的时候,就把数据分开,就会存在浪费空间的现象。2. 最理想的情况就是,只有被子进程修改的数据,进行分离拷贝,不需要修改的共享即可,但是从技术上来讲很难实现。3. 如果fork的时候,就无脑拷贝数据给子进程,会增加fork()的成本(内存和时间)。最终就是为了提高内存的使用效率。
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
- 系统中有太多的进程(内存资源有限)
- 实际用户的进程数超过了限制(Linux中一般规定每一个用户运行的进程是有限的)
代码测试:
运行结果:
- 代码运行完毕,结果正确(return 0)
- 代码运行完毕,结果不正确(return 0)
- 代码异常终止(返回值为非0,表示异常的原因)
注意:return X,X即进程退出码,表征进程退出的信息,要被父进程读取。
正常终止(可以通过 echo $?
查看进程退出码):
$?
表示在bash中,最近一次进程执行完毕时,对应的进程的退出码。
使用举例:
注意:此时进程退出码为0的原因是因为最近一次执行的进程是ls
指令所对应的进程,且成功执行,所以进程退出码为0。
- 从main返回(return)
- 调用exit()(任意位置处)
- _exit
异常退出:
- ctrl + c,信号终止
说明:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值 是255。
exit最后也会调用exit, 但在调用exit之前,还做了其他工作:
- 执行用户通过atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入
- 调用_exit
对比执行:
代码:
执行结果:
执行结果:
- 子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
- 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法 杀死一个已经死去的进程。
- 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对, 或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
#include
#include
pid_t wait(int*status);
返回值:
成功返回被等待进程pid,失败返回-1。
参数:
输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
使用举例:
注意:检测进程运行状态的脚本:
while :; do ps ajx | grep proc | grep -v grep; sleep 1; echo "--------------------------------"; done
代码:
运行截图:
刚开始运行:
使用kill
命令杀死子进程后,但是父进程并未受到子进程的返回的PID,所以变成了僵尸状态的进程:
当父进程等待子进程后,子进程被回收:
pid_ t waitpid(pid_t pid, int *status, int options);
返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数:
pid:
Pid=-1,等待任一个子进程。与wait等效。
Pid>0.等待其进程ID与pid相等的子进程。
status(输出型参数:通过调用该函数,从函数内部拿出特定的):
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进
程的ID。阻塞等待是0(等待的一方处于等待状态,注意不是被等待的一方)。
- 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息(从子进程的task_struct中获得退出码)。
- 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
- 如果不存在该子进程,则立即出错返回。
- wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
- 如果传递NULL,表示不关心子进程的退出状态信息。
- 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
- status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特 位):
0~7:表示处理异常情况(代码跑完和代码异常退出的两种情况)
8~15:保存退出码(代码跑完,结果对还是代码跑完结果不对这两种情况)
终止信号(低7位):进程退出时受到的信号。进程退出,如果异常退出,是因为这个进程受到了特定的信号。
注意:使用
kill -l
命令可以查看所有终止信号:注意:没有0号信号。
使用举例:
代码:
执行结果:
代码:
执行结果:
程序刚开始执行:
执行kill -9 子进程PID
后:
问:是否可以通过一个全局变量来保存子进程的退出码然后父进程访问呢?
答:不可以,因为进程具有独立性,且子进程的数据具有写时拷贝的特性。
问:我们先看退出码还是退出信号?
答:先看退出信号,确定程序是否正常结束,如果正常结束,再看退出码,查看程序出现的问题;如果异常终止,就没必要再看退出码了,此时的退出码毫无意义,即使退出码此时为0。
阻塞等待
当我们调用某些函数的时候,因为条件不就绪,需要我们阻塞等待,本质:就是当前进程自己变成阻塞状态等条件(可能是任意的软硬件)就绪的时候。再被唤醒。
举例:之前的都是阻塞等待,此处不再举例。
非阻塞等待
当我们调用某些函数的时候,条件并没有就绪,此时调用函数立即返回,并且此时可以继续做一些其它的任务,同时可以采用轮询的方式对条件是否满足进行询问,当条件满足的时候调用函数。
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<stdlib.h>
4 #include<sys/types.h>
5 #include<sys/wait.h>
6 int main()
7 {
8 pid_t id = fork();
9 if(id == 0)
10 {
11 //子进程
12 while(1)
13 {
14 printf("我是子进程,我的pid:%d,我的ppid:%d\n", getpid(), getppid());
15 sleep(1);
16 }
17 exit(1004);
18 }
19 else if(id > 0)
20 {
21 //父进程
22 //printf("我是父进程,我的pid:%d,我的ppid:%d\n", getpid(), getppid());
23 //int status = 0;
24 //pid_t ret = waitpid(-1, &status, 0 );
25 //if(ret >0)
26 //{
27 // printf("等待成功!%d, exit sig:%d,exit code:%d\n", ret, status&0x7F, (status>>8)&0xFF);
28 //}
29 int status = 0;
30 while(1)
31 {
32 pid_t ret = waitpid(-1, &status, WNOHANG);//基于非阻塞的轮询等待方案:WNOHANG
33 if(ret > 0)
34 {
35 printf("等待成功!%d, exit sig:%d,exit code:%d\n", ret, status&0x7F, (status>>8)&0xFF);
36 break;
37 }
38 else if(ret == 0)
39 {
40 //等待成功了,但是子进程没有退出
41 printf("子进程未准备好,父进程做其他事情...\n");
42 sleep(1);
43 }
44 else
45 {
46 //出错了
47 }
48 }
49 }
50 else
51 {
52 //错误情况
53 }
54
55 return 0;
56 }
运行截图:
让创建出来的子进程执行全新的程序。
我们一般在服务器设计(Linux编程)的时候,往往需要子进程干两种事情。
程序替换的原理:
效果:让父进程和子进程彻底分离,并让子进程执行一个全新的程序。
注意:上面的这个过程由操作系统来完成,我们调用的是接口。
问:这个过程有没有创建新的进程?
答:没有创建新的进程,因为子进程的内核数据结构根本没变(包括子进程的PID),改变的只是页表的映射关系,仍旧是原来的进程,只是执行的是新的程序。
使用man execl
和man execve
指令来查看相关的函数
int execl(const char *path, const char *arg, ...);//l可以理解成list
path指的是路径,arg就是程序如何执行即执行方式,比如我们平时执行的ls -a -l
,那么这个参数就是"ls","-a","-l",NULL
这就是我们的执行方式。注意:最后一个必须是NULL,标识如何执行程序的参数传递完毕。
注意:path路径的写法无论是绝对路径还是相对路径都是可以的。
使用举例:
运行截图:
问:为什么后面的printf语句没有执行?
答:因为一旦替换成功,是将当前进程的所有代码和数据全部都替换了,而printf就是代码,替换后代码就没了,所以后面的printf语句没有执行。
问:execl这个程序替换函数是否需要判断返回值?
答:不需要。例如:
ret = execl(.....)
,程序一旦替换成功了,就不会有返回值返回到原进程中,自然也不会被接收,那么这个返回值是否还有意义呢?答案是有意义的,但是只有程序替换失败了才会有返回值返回到原进程中,才是才会被接收,并且执行ret = execl(......)
语句 后面的代码。这个返回值最多只能得到什么原因导致的替换失败。所以不需要判断返回值,替换成功了执行替换后的代码,替换失败了执行ret = execl(......)
代码后面的代码。
用子进程来实现进程替换:
代码:
运行截图:
结论:子进程进行进程替换,并不会影响父进程(进程具有独立性)。
问:在进程替换的时候是如何做到父进程和子进程独立的?
答:数据层面发生写时拷贝,当进行程序替换的时候,我们可以理解成为代码和数据都发生了写时拷贝,
int execv(const char *path, char *const argv[]);//v可以理解为vector
argv是指针数组类型,数组中的元素的类型是char*。
代码:
运行截图:
int execlp(const char *file, const char *arg, ...);//p表示的是PATH
//file:我们想要执行的程序,命名带p的,可以不带路径(注意,只有在PATH路径下的才可以,自己写的不可以),只说出执行哪一个程序即可
代码:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/wait.h>
4 #include<stdlib.h>
5 int main()
6 {
7 printf("我是一个进程,我的pid:%d\n", getpid());
8 //ls -a -l
9 //execl("/usr/bin/ls","ls","-a", "-l", NULL);
10 //printf("我是一个进程,我执行完毕了,我的pid:%d\n", getpid());
11 pid_t id = fork();
12 if(id == 0)
13 {
14 //子进程
15 //子进程执行全新的程序
16 printf("我是子进程,我的pid:%d\n", getpid());
17 //execl("/usr/bin/ls","ls", "-a", "-l", NULL);
18 //char *const argv[] = {(char*)"ls", (char*)"-a", (char*)"-l",(char*)NULL};
19 //execv("/usr/bin/ls", argv);
20 execlp("ls","ls", "-a", "-l", NULL);
21 exit(-1);//只要exit执行了,就意味着execl程序替换失败了
22 }
23 //父进程
24 int status = 0;
25 int ret = waitpid(id, &status, 0);
26 if(ret == id)
27 {
28 printf("父进程等待成功!\n");
29 sleep(2);
30 }
31 return 0;
32 }
执行结果:
int execvp(const char *file, char *const argv[]);
代码练习:
int execle(const char *path, const char *arg, ...,char *const envp[]);//envp就是环境变量
//注意:添加环境变量给目标进程是覆盖式的,如果手动传了环境变量,原来默认的环境变量就不存在了
//问:如何手动传系统自带的PATH环境变量?
//答:extern char**environ;然后将environ传过去即可
代码练习:
myexec.c文件
1 #include<stdio.h> 2 #include<unistd.h>
3 #include<sys/wait.h>
4 #include<stdlib.h>
5 int main()
6 {
7 printf("我是一个进程,我的pid:%d\n", getpid());
8 //ls -a -l
9 //execl("/usr/bin/ls","ls","-a", "-l", NULL);
10 //printf("我是一个进程,我执行完毕了,我的pid:%d\n", getpid());
11 pid_t id = fork();
12 if(id == 0)
13 {
14 //子进程
15 //子进程执行全新的程序
16 printf("我是子进程,我的pid:%d\n", getpid());
17 char* const _env[] = {(char*)"MYTH=Hello, this is MYPATH!", NULL};
18 //execl("/usr/bin/ls","ls", "-a", "-l", NULL);
19 //char *const argv[] = {(char*)"ls", (char*)"-a", (char*)"-l",(char*)NULL};
20 //execv("/usr/bin/ls", argv);
21 //execvp("ls",argv);
22 //execl("/home/ljg/linux_for_practice/2022_10_1/mycmd", "mycmd",NULL);
23 execle("./mycmd", "mycmd", NULL,_env);
24 exit(-1);//只要exit执行了,就意味着execl程序替换失败了
25 }
26 //父进程
27 int status = 0;
28 int ret = waitpid(id, &status, 0);
29 if(ret == id)
30 {
31 printf("父进程等待成功!\n");
32 sleep(2);
33 }
34 return 0;
35 }
makefile文件:
mycmd.cpp文件:
1 #include<iostream>
2 #include<stdlib.h>
3 using namespace std;
4 int main()
5 {
6 cout << "MYTH=" << getenv("MYTH") << endl; 7 cout << "hello world" << endl;
8 cout << "hello world" << endl;
9
10 return 0;
11 }
运行截图:
int execve(const char *path, char *const argv[], char *const envp[]);
int execvpe(const char *file, char *const argv[],char *const envp[]);
问:为什么要有这么多接口?
答:是为了适配更多的应用场景。
问:为什么execve是单独的?
答:execve位于2号手册,是单独的系统调用,其它的严格意义上来讲并不是真正的系统接口,是基于execve系统接口之上进行的封装。
问:各种字母代表的是什么意思?
答:l(list):表示参数使用列表 v(vector):参数用数组 p(path):有p自动搜索环境变量PATH e(env):表示自己维护环境变量
函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 不是 | 是 |
execlp | 列表 | 是 | 是 |
execle | 列表 | 不是 | 不是,需要自己组装环境变量 |
execv | 数组 | 不是 | 是 |
execvp | 数组 | 是 | 是 |
execve | 数组 | 不是 | 不是,需要自己组装环境变量 |
1 #include<stdio.h>
2 #include<stdlib.h>
3 #include<string.h>
4 #include<unistd.h>
5 #include<sys/wait.h>
6 #include<sys/types.h>
7 #define NUM 1024
8 #define SIZE 128
9 #define SEP " "
10 char command_line[NUM];
11 char* command_args[SIZE];
12 int main()
13 {
14 //shell本质上就是一个死循环
15 while(1)
16 {
17 //不关心获取这些属性的接口
18 //1.显示提示符
19 printf("[zs@VM-0-3-centos 当前目录]# ");
20 fflush(stdout);
21 //2.获取用户输入
22 memset(command_line,'\0',sizeof(command_line)*sizeof(char));
23 fgets(command_line, NUM, stdin);//从标准输入中获取的,获取到的是C风格的字符串
24 command_line[strlen(command_line)-1] = '\0';
25 //3.分割字符串 "ls -a -l" "ls" "-a" "-l"
26 command_args[0] = strtok(command_line, SEP);
27 int index = 1;
28 while(command_args[index++] = strtok((char*)NULL, SEP));
29 //5.创建进程,执行
30 pid_t id = fork();
31 if(id == 0)
32 {
33 //child
34 execvp(command_args[0], command_args);
35 exit(-1);//执行到这里,子进程一定替换失败了
36 }
37 int status = 0;
38 pid_t ret = waitpid(id, &status, 0);
39 if(ret>0)
40 {
41 printf("等待子进程成功:sig:%d,code:%d\n", status&0x7F, (status>>8)&0xFF);
42 }
43 }//end while
44
45
46 return 0;
47 }