进程创建
fork()函数:
在原来的博客中已经写过一次进程创建了,在这儿就详细讲解一下进程是怎么创建的.
已经知道,在Linux中,创建进程可以通过调用fork函数来实现,那么当我们在调用fork函数时,操作系统内部到底是怎样实现的呢?
fork()函数在内部通过已经存在的进程创建出一个新进程,而新进程就是子进程.
#include<unistd.h>
pid_t fork(void);
有两个返回值,当返回值大于零时,返回的是父进程;等于零时,返回的是子进程;出错则返回1.
进程调用fork()函数时,在操作系统内部应该完成以下步骤:
- 分配新的内存块和内核数据结构给子进程.
- 将父进程数据结构部分内容拷贝至子进程.(父子进程代码和数据部分共享,但当有一方要进行写入时,就会发生写时拷贝.所以当在父子进程中有相同的变量,而改变子进程中变量的值时,父进程中的变量并不会改变.)
- 添加子进程到系统进程列表中.
- fork()返回,开始调度器调度.
如下图所示:
当调用fork()函数时.
调用fork()函数结束后.如下图,就生成了一个新进程.并且在此时父子进程同时从fork()函数结束后的下一步开始执行(也就是图中的after那步),而对于到底是父进程先执行还是子进程先执行,这是由操作系统调度器决定的,而我们并不知道.
当父子进程中有一方的数据要进行写入时,就会发生写实拷贝,而代码部分是相同的不会被改变,所以代码部分父子进程共享.
通过下面这个图我们就可以形象的观察到:
注意:在我们过程中使用的内存,都是虚拟内存,而不是真正的物理内存,在前面的博客中,我详细的讲解过虚拟内存,若想了解请戳:虚拟内存
那么在这儿,在实现写时拷贝时,虚拟内存是如何找到对应的物理内存并且实现写时拷贝的呢?
拷贝也就是实现页表的拷贝.
vfork()函数
作用:
也是用来创建子进程.
特点:
- 创建一个子进程,而子进程和父进程共享地址空间,但是fork()函数创建的子进程具有独立的地址空间.
- 保证子进程先运行,当调用了exec()或者_exit()函数后父进程才能被调度运行.
简单说一下exec()函数:当进程调用exec()函数时,该进程的用户空间和数据完全被新程序替换,从新程序的启动处(main()函数为入口)开始执行.调用exec()函数并不创建新进程,所以调用exec()函数前后该进程的ID并未改变.
我们可以通过下面的代码来简单模拟:
#include<unistd.h>
2 #include<stdio.h>
3 int g_val = 100;
4 int main()
5 {
6 pid_t pid = vfork();
7 if(pid == 0)
8 {
9 sleep(3);
10 g_val = 200;
11 printf("son:%d\n",g_val);
12 _exit(0);
13 }
14 else if(pid > 0)
15 {
16 printf("father:%d\n",g_val);
17 }
18 else
19 {
20 perror("vfork");
21 }
22 return 0;
23 }
所以:vfork()函数有很大的缺陷.
进程终止
一个进程可以通过那些方式正常的退出:
- 从main()函数返回,即通过return退出.
- 调用exit()函数 //库函数
- 调用_exit()函数 // 系统调用函数
当我们使用crtl + c退出一个进程时,这属于异常退出.
下面我们来区分一下这两种退出函数:
_exit()函数:
#include<unistd.h>
void _exit(int status);
void exit(int status);
上述的两个函数都是输出型函数,所以在传status进去时,可以直接传零进去.status:定义了进程的终止状态,父进程可以通过wait()函数(在该篇博客的后面讲解)来获取该值.敲黑板敲黑板:虽然status是int类型,但是只有低8位可以被父进程使用.所以当status的值为-1时,返回的结果就是255.此时,还有一种查看退出码的方法(就是函数执行结束后的返回结果):echo $?
那exit()函数和_exit()函数有什么不同呢?
在调用exit函数时,会先执行以下步骤:
- 执行用户的清理函数atexit()和unexit()函数.
- 冲刷缓冲区,关闭流等.
- 调用_exit()函数.
进程等待
在前面的博客中讲到,当一个子进程退出而给父进程发送退出码而父进程却并不接收是,此时子进程就会变成僵尸进程,僵尸进程会造成内存泄漏,僵尸进程并不能杀掉,因为从理论上讲此时的僵尸进程已经死了那么再去杀死它,可不没有意义吗?当时的做法时杀死父进程,子进程就会变成孤儿进程,然后让1号进程领养子进程,僵尸进程就会解除.可是,正确的做法应该是让父进程等待子进程,当子进程完成任务,提交退出码时父进程可以及时的接收它,那么子进程就可以正常退出了.
wait()函数:通常在父进程中使用,用来等待回收子进程的资源,从而防止僵尸进程的产生.如果父进程所有的子进程都在执行,没有终止,就会处于阻塞状态,等待执行结束的子进程.一旦有子进程结束,wait()就会返回.
敲黑板:wait()函数调用一次只能等待一个进程,若有多个子进程就要调用多个wait()函数来回收子进程.
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int *status); //同样也是输出型参数,获取子进程的退出状态,不关心则可以置为NULL.
若成功:返回被等待进程的pid,若失败,返回-1.
函数的参数是整形指针,用低两个2个字节来记录.
如果正常退出,则高8位用来记录进程正常退出时的退出码.
如果非正常退出,低8位用来记录非正常退出时接收到的信号.
可以通过代码来演示一下:
#include<unistd.h>
2 #include<stdio.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 int main()
6 {
7 pid_t pid = fork();
8 if(pid == 0)
9 {
10 sleep(10);
11 exit(10);
12 }
13 else if(pid > 0)
14 {
15 int status;
16 int ret = wait(&status);
17 if(ret > 0 && (status & 0x7F) == 0) //正常退出的判断
18 {
19 printf("child exit num:%d\n",(status>>8)&0xFF);
20 }
21 else if(ret > 0)//异常退出,可以在重新打开一个通道,来杀死子进程
22 {
23 printf("child code:%d\n",status&0xFF);
24 }
25 }
26 else
27 {
28 perror("fork");
29 }
30 return 0;
31 }
再比如下面这个程序:
1 #include<unistd.h>
2 #include<stdio.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 int main()
6 {
7 printf("父进程:%d\n",getpid());
8 pid_t pid = fork();
9 if(pid == 0)
10 {
11 //子进程
12 sleep(3);
13 printf("child1:%d\n",getpid());
14 }
15 else
16 {
17 pid = fork();
18 printf("父进程在搬砖.\n");
19 int status = 0;
20 pid_t ret = wait(&status);//只调用了一次wait()函数
21 printf("ret:%d\n",ret);
22 if(pid == 0)
23 {
24 printf("child2:%d\n",getpid());
25 }
26 else
27 {
28 while(1)
29 {
30 sleep(1);
31 }
32 }
33 }
上面这个程序只调用了一次wait()函数,所以第二次父进程产生的子进程就会变成僵尸进程.
所以要在程序中再调用一次wait()函数来杀死第二次父进程创建的子进程.
其中,在Linux中有提供特定的宏来处理判断正常退出和其退出码的.分别是:WIFEXITED(status)和WEXITSTATUS(status).
除了wait()函数,还有waitpid()函数也可以实现该功能.
waitpid()函数:
pid_t waitpid(pid_t pid,int* status,int options);
- 当正常返回时,waitpid()就返回收集到的子进程的进程PID.
- 如果设置了WNOHANG(就会按照非阻塞方式等待),而调用中的waitpid()发现没有已退出的子程序可以收集,就会返回0,不予以等待,就去执行父进程自己的事情;
- 如果出错,就会返回-1,此时errno会打印出对应的错误.
- options:默认为0.
代码实现:
1 #include<unistd.h>
2 #include<stdio.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 int main()
6 {
7 pid_t pid = fork();
8 if(pid == 0)
9 {
10 printf("child is running,pid is:%d\n",getpid());
11 sleep(3);
12 exit(257);
13 }
14 else if(pid < 0)
15 {
16 printf("error:%s\n",__FUNCTION__);
17 return 1;
18 }
19 else
20 {
21 int status = 0;
22 pid_t ret = waitpid(-1,&status,0);
23 printf("wait\n");
24 if(WIFEXITED(status) && ret == pid)
25 {
26 printf("wait child success,child code:%d\n",WEXITSTATUS(status));
27 }
28 else
29 {
30 printf("failed.\n");
31 return 1;
32 }
33 }
34 }
进程程序替换
当进程调用exec()函数后,该进程执行的程序就会完全替代为一个新程序,而新程序从main()函数开始执行(程序的数据段,代码段,包括堆栈都会替换为新进程的).
注意注意注意:进程程序替换,替换的是子进程:程序替换,让子进程去替换其它的函数.目的:为了保护自己不受伤害,其它程序可能会破坏shell.所以很多命令都是让bash去执行的.
替换函数有一下6种:
#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 env[]);
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 env[]);
上面6个函数特点:
- 如果调用成功,就加载新的程序从启动代码开始执行,并且不返回值.(成功不返回)
- 如果调用出错则返回-1.(所以此函数们只有在出错时才有返回值成功时没有返回值).
上面的6个函数,我们依次进行模拟实现:
6 // int ret = execl("./a.out","a.out",NULL);
7 // printf("ret:%d\n",ret);
8
9 // int ret = execlp("ls","ls","-l","-t","./",NULL);
10 // printf("ret:%d\n",ret);
11
12 // char* const envp[] = {"ls","-l","-t"};
13 // int ret = execle("/bin/ls","ls","-l","-t",NULL,envp);
14 // printf("ret:%d\n",ret);
15
16 // char* const argv[] = {"/bin/ps","-aux",NULL};
17 // int ret = execv("/bin/ps",argv);
18 // printf("ret:%d\n",ret);
19
20 // char* const argv[] = {"ps","-aux",NULL};
21 // int ret = execvp("ps",argv);
22 // printf("ret:%d\n",ret);
23
24 char* const envp[] = {"ps",NULL};
25 char* const argv[] = {"ls",NULL};
26 int ret = execve("/bin/ps",argv,envp);
27 printf("ret:%d\n",ret);
注意:参数envp和参数argv都是以NULL结束的.
在这6个函数中,它们都是以exec前缀开始的函数,那么后面的都怎么表示?
- l(list):表示参数是命令行参数列表
- v(vector):向量.表示参数采用的是数组.
- p(path):表示可以自动搜索环境变量PATH.
- e(envp):表示自己维护环境变量.
通过这些的学习,现在呢,我们就可以写一个简单的shell了.
后面添加代码.