linux 学习笔记11 多进程——进程的创建
进程的创建
Linux下有4类创建子进程的函数,分别为:system,fork。exec,popen。其中,常用的为fork和exec,同时,fork和exec函数族均为系统调用函数。
system
int system(const char *command);
system 函数通过调用 shell 程序/bin/sh –c 来执行 command 所指定的命令,该函数在内部是通过调用
execve(“/bin/sh”,…)函数来实现的。
system函数调用成功返回0
例:system函数调用 " ls -l "命令
#include <func.h>
int main()
{
system("ls -l");
return 0;
}
执行结果:
fork
Linux中fork是一个特别重要的函数:它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。它和其他函数的区别在于:它执行一次返回两个值。其中父进程的返回值是子进程的进程号,而子进程的返回值为 0,若出错则返回-1,因此可以通过返回值来判断是父进程还是子进程。
fork 函数创建子进程的过程为:使用 fork 函数得到的子进程是父进程的一个复制品,它从父进程继
承了进程的地址空间,包括进程上下文(当时寄存器的状态)、进程堆栈、内存信息(进程地址空间)、打开的文件描述符(fd)、信号控制设定、进程优先级、进程组号、当前工作目录、根目录、资源限制(ulimit)、控制终端,而子进程所独有的只有它的进程号、资源使用和计时器等。通过这种复制方式创建出子进程后,原有进程和子进程都从函数 fork 返回,各自继续往下运行,但是原进程的fork返回值与子进程的fork返回值不同,在原进程中,fork返回子进程的pid,而在子进程中,fork返回0,如果fork返回负值,表示创建子进程失败。
以下对父子进程的堆栈空间,文件描述符,进程优先级的区别进行一一分析
Q:为什么父进程一般先于子进程执行
A:涉及fork函数原理:
task_struct记录了虚拟地址空间映射了哪一块物理内存
因此 task_struct描述了进程地址空间的存在
task_struct参考:https://blog.csdn.net/qq_36791466/article/details/90212212
fork函数创建父子进程:
#include <func.h>
//fork基本都是父亲先执行,孩子后执行
✹ int main(int argc,char*argv[])
{
pid_t pid;
pid=fork();
if(0==pid)
{
printf("child Mark1, pid=%d,ppid=%d\n",getpid(),getppid());
}else
{
printf("dad Mark1.pid=%d,ppid=%d\n",getpid(),getppid());
sleep(1);//等待子进程结束完
}
return 0;
}
Q:父进程修改或者子进程修改会对对方造成影响吗?(重要)
A:不会,因为子进程独有资源使用,和操作系统的写时复制。
父子进程的栈空间具有区别,父进程栈空间的值(假设为 i=10)的改变,对子进程没有影响,虽然子进程是父进程的复制,父进程的 i 的值改变,子进程的 i 仍为10,此时涉及到写时复制的原理(COW,copy on write)。
写时复制分析如下:
cow步骤:
step1:申请空间
step2:内容复制
step3:映射
step4:更改值(如10->5)
cow的优点在于任何一个进程崩溃了,另一个进程不受影响。
代码如下:
#include <func.h>
//父子进程的栈空间有何区别
✹ int main(int argc,char*argv[])
{
pid_t pid;
pid=fork();
int i=10;
if(0==pid)
{
printf("child Mark1,i=%d\n",i);
printf("&i=%p\n",&i);//&i在父进程改变后,i的地址不会发生变化
return 0;
}else
{
printf("dad Mark1.i=%d\n",i);
printf("&i=%p\n",&i);
i=5;
printf("After change i=%d\n",i);//父进程的变量i改变了,对子进程没有影响。
//涉及到写时复制的原理
printf("After &i=%p\n",&i);//i的虚拟地址不会发生变化,变化的是物理内存的空间。
sleep(1);
return 0;
}
}
实现效果如下:
Q:父进程的堆空间修改,是否会对子进程造成影响?
A:不会,原理同为写时复制。
代码如下:
#include <func.h>
//父子进程的堆空间
//验证父进程的堆空间进行改变对子进程是否有影响?
✹ int main(int argc,char*argv[])
{
pid_t pid;
pid=fork();
char *p=(char*)malloc(20);
strcpy(p,"hello,mark");
if(0==pid)
{//子进程
printf("child Mark1,%s\n",p);
return 0;
}else
{//父进程
printf("dad Mark1.%s\n",p);
strcpy(p,"world");
printf("after modification,%s\n",p);//子进程不会发生变化,原理同样为写时复制
sleep(1);
return 0;
}
}
实现效果如下:
Q:任一进程崩溃对另一进程是否有影响?
A:没有影响
代码如下:
#include <func.h>
//验证任意一个进程崩溃对另一个进程没有影响。
✹ int main(int argc,char*argv[])
{
pid_t pid;
pid=fork();
if(0==pid)
{
printf("child Mark1\n");
char *p=NULL;
*p=10;//对空指针赋值,令子进程崩溃
printf("I am Mark2\n");//进程崩溃,本句不会打印
//ps -elf|grep fork_crash查看子进程状态,此时子进程会成为僵尸(Z)状态
}else
{
printf("dad Mark1\n");
while(1);
sleep(1);
}
return 0;
}
实现效果如下:
此时,父进程3959正在运行,而子进程3960崩溃,成为僵尸进程,但对父进程没有影响。
注意,进程RST可以相互转换,但是Z不行。
Z(僵尸进程):代表进程不能再运行了,但是task_struct还在内核中。
Q:fork父子进程是否指向同一个文件对象?
A:是,文件对象和堆栈不同,父子进程指向相同的文件对象
fork 打开文件描述符原理:
代码如下:
$ touch file //创建文件
$ echo -n helloworld>file //向文件中写入helloworld
#include <func.h>
//验证fork后父子进程指向同一个文件对象
//先open后fork
int main(int argc,char*argv[])
{
ARGS_CHECK(argc,2);
pid_t pid;
int fd=open(argv[1],O_RDWR);
pid=fork();
char buf[128]={0};
if(0==pid)
{//子进程
read(fd,buf,5);
printf("child Mark1,buf is %s\n",buf);
return 0;
}else
{//父进程
read(fd,buf,5);
printf("dad Mark1,buf is %s\n",buf);
sleep(1);
return 0;
}
}
实现效果如下:
由于父子进程指向同一个文件对象,因此父进程先打印hello,子进程再打印world。
Q:先执行fork函数,再open文件描述符,和先open再fork是否有区别?
A:有区别,先fork再open将会打开两个不一样的文件对象,而先open再fork,父子进程指向同一个文件对象。
代码如下:
#include <func.h>
//验证先fork后open与先open后fork是否有区别
//先fork后open将会产生两个文件对象。
int main(int argc,char*argv[])
{
ARGS_CHECK(argc,2);
pid_t pid;
pid=fork();
int fd;
char buf[128]={0};
if(0==pid)
{
fd=open(argv[1],O_RDWR);
read(fd,buf,10);
printf("child Mark1,buf is %s\n",buf);//子进程指向文件对象1
close(fd);
return 0;
}else
{
fd=open(argv[1],O_RDWR);
read(fd,buf,10);
printf("dad Mark1,buf is %s\n",buf);//父进程指向文件对象2
close(fd);
sleep(1);
return 0;
}
}
实现效果如下:
指向了不同的文件对象,因此子进程也能打印。
exec函数族
exec函数族包含:
int execl(const char *path, const char arg, … / (char *) NULL */);
int execlp(const char *file, const char arg, … / (char *) NULL */);
int execle(const char *path, const char arg, … /, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
函数族中常用函数:
int execl(const char *path, const char *arg, …);
参数解析:
path 是包括执行文件名的全路径名
arg 是可执行文件的命令行参数,多个用,分割注意最后一个参数必须为 NULL
exec函数是采用第一个参数指定的程序覆盖现有的进程空间。假设已有程序add.c(加法函数),则第一个参数为./add(可执行文件),则代码为:
execl("./add","add","3","4",NULL);
// argv[0],argv[1],argv[2],NULL代表结尾
execl原理如下:
add函数代码如下:
#include <func.h>
int main(int argc,char*argv[])
{
puts(argv[0]);
ARGS_CHECK(argc,3);
int first=atoi(argv[1]);
int second=atoi(argv[2]);
printf("sum=%d\n",first+second);
return 0;
}
将add函数编译成为可执行文件add
execl代码如下:
#include <func.h>
//采用相对路径
✹ int main(int argc,char*argv[])
{
int ret=execl("./add","add","3","4",NULL);
//execl的原理是覆盖了代码段,成功时execl不会返回,因此以下代码不会执行
//只有当失败时才会打印
ERROR_CHECK(ret,-1,"execl");
printf("Mark Execl failed\n");
return 0;
}
实现效果如下:
其余不常用函数:
int execv(const char *path, char *const argv[]);
execv 功能和 execl 一样,区别如下:
#include <func.h>
int main(int argc,char *argv[])
{
char *args[]={"add","3","5",NULL};//execl和execv的区别在于需要将参数放在指针数组中。
int ret=execv("./add",args);
ERROR_CHECK(ret,-1,"execv");
printf("Mark Execl failed\n");
return 0;
}
实现效果如下:
Execle与execl区别在于execle需要先定义一个环境变量envp,利用环境变量执行execl
Test_path代码:
#include <func.h>
int main()
{
system("echo $PATH");//打印环境变量
return 0;
}
实现效果如下:
利用test_path 写execle例子
execle代码:
#include <func.h>
//int execle(const char *path, const char *arg, ...
//, (char *) NULL, char * const envp[] );
int main(int argc,char*argv[])
{
char *const envp[]={"PATH=/usr/bin",NULL};//注意,一定要写NULL
//如果没有写NULL,执行时没有任何报错,但是环境变量改不了
int ret=execle("./test_path","test_path",NULL,envp);
ERROR_CHECK(ret,-1,"execl");
printf("Mark Execl failed\n");
return 0;
}
实现效果如下:
execle与execlp区别在于,execlp可以直接用文件名add,不需要用可执行文件./add
同理,execvp与execv区别在于execvp可知直接使用文件名add
execvpe也是使用文件名和环境变量envp执行
popen
popen 函数类似于 system 函数,与 system 的不同之处在于它使用管道工作。
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
command 为可执行文件的全路径和执行参数;
type 可选参数为”r”或”w”
如果为”w”,则 popen 返回的文件流做为新进程的标准输入流(stdin),如果为”r”,则 popen 返回的文件流做为新进程的标准输出流(stdout)。
例子:设printf和scanf的可执行文件作为command
Print.c代码如下:
#include <func.h>
int main()
{
printf("I am print elf\n");
return 0;
}
Scanf.c代码如下:
#include <stdio.h>
int main()
{
int i,j;
scanf("%d%d",&i,&j);
printf("%d\n",i+j);
return 0;
}
标准输入popen的w模式,代码如下:
#include <func.h>
//标准流管道
//
int main()
{
FILE *fp;
fp=popen("./scanf","w");
ERROR_CHECK(fp,NULL,"popen");
char buf[128]="4 5";
fwrite(buf,sizeof(char),strlen(buf),fp);
pclose(fp);
return 0;
}
实现效果如下:
标准输出popen的r模式代码如下:
#include <func.h>
//标准流管道
//
int main()
{
FILE *fp;
fp=popen("./print","r");
//fp=popen("ls -l","r");//for test
ERROR_CHECK(fp,NULL,"popen");
char buf[1024]={0};
fread(buf,sizeof(char),sizeof(buf),fp);
printf("popen:%s",buf);
pclose(fp);
return 0;
}
实现效果如下:
补充:
内核暂停一个进程执行时,就会把几个相关处理器寄存器的内容保存在进程描述符中,这些寄存器
包括:
1、 程序计数器(PC)和栈指针(SP)寄存器
2、 通用寄存器
3、 浮点寄存器
4、 包含 CPU 状态信息的处理器控制寄存器(处理器状态字)
5、 用来跟踪进程对 RAM 访问的内存管理寄存器
内核决定恢复执行一个进程时,它用进程描述符中合适的字段来装载 CPU 寄存器。