fork函数详解与进程替换(exec)

<1>fork定义

一个现有进程可以调用fork函数创建一个新进程。由fork创建的新进程被称为子进程(child process)。fork函数被调用一次但返回两次。两次返回的唯一区别是子进程中返回0值而父进程中返回子进程ID。

我们经常说fork后的子进程相当于是子进程的一个克隆,fork出来的父子进程并行fork之后的代码,但是子进程真的是完全复制了父进程吗?答案是不,那么到底子进程复制了父进程的那些东西?那些东西又没有复制呢?

<2>fork之后子进程到底复制了父进程什么?

我们先来看看这段代码


#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>

void main() 
{
    char str[6]="hello";
    pid_t pid=fork();

    if(pid==0)
    {

        str[0]='f';
        printf("子进程中str=%s\n",str);
        printf("子进程中str指向的首地址:%x\n",(unsigned int)str);
    }

    else
    {
        sleep(1);
        printf("父进程中str=%s\n",str);
        printf("父进程中str指向的首地址:%x\n",(unsigned int)str);
    }
}

这个打印出来的结果是什么呢?我们一起来看看

可以看出,父子进程之间打印的数据并不相同,说明子进程复制了父进程栈区的空间。但是为什么地址又是一样的呢?实际上这个是逻辑地址(虚拟地址),既然是逻辑地址(虚拟地址),那么又有什么所谓呢?映射到物理内存是不一样滴。地址映射-将程序地址空间中使用的逻辑地址变换成内存中的物理地址的过程。由内存管理单元(MMU)来完成。

实际上fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用(下文会讲),出于效率考虑,linux中引入了“写时拷贝“技术,也就是只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。

在fork之后exec之前两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间,如果不是因为exec,内核会给子进程的数据段、堆栈段分配相应的物理空间(至此两者有各自的进程空间,互不影响),而代码段继续共享父进程的物理空间(因为两者的代码完全相同)。但如果是因为exec,由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间。所以也就为什么不是直接在内存上给子进程也复制完完全全相同的区域呢,这就是原因。fork时子进程获得父进程数据空间、堆和栈的复制,所以变量的地址(当然是虚拟地址)也是一样的。每个进程都有自己的虚拟地址空间,不同进程的相同的虚拟地址显然可以对应不同的物理地址。因此地址相同(虚拟地址)相同没什么奇怪。

具体过程是这样的:
fork子进程完全复制父进程的虚拟地址空间,也复制了页表,但没有复制物理页面,所以这时虚拟地址相同,物理地址也相同,但是会把父子共享的页面标记为“只读”(类似mmap的private的方式),如果父子进程一直对这个页面是同一个页面,直到其中任何一个进程要对共享的页面“写操作”,这时内核会复制一个物理页面给这个进程使用,同时修改页表。而把原来的只读页面标记为“可写”,留给另外一个进程使用。(简洁来说就是:内核只为新生成的子进程创建虚拟空间,它们来复制于父进程的虚拟空间,但是不为这些段分配物理空间,它们共享父进程的物理空间,当父子进程中有写内存的行为发生时,再为子进程相应的段分配物理空间,这就是写时复制技术


这就是所谓的“写时拷贝”技术。正因为fork采用了这种写时拷贝的机制,所以fork出来子进程之后,父子进程哪个先调度呢?内核一般会先调度子进程,因为很多情况下子进程是要马上执行exec,会清空栈、堆。。。这些和父进程共享的空间,加载新的代码段。。。这就避免了“写时拷贝”共享页面的机会。如果父进程先调度很可能写共享页面,会产生“写时拷贝”的无用功。所以,一般是子进程先调度滴。

假定父进程malloc的指针指向0x12345678, fork 后,子进程中的指针也是指向0x12345678,但是这两个地址都是虚拟内存地址 (virtual memory),经过内存地址转换后所对应的 物理地址是不一样的。所以两个进城中的这两个地址相互之间没有任何关系。

(注1:在理解时,你可以认为fork后,这两个相同的虚拟地址指向的是不同的物理地址,这样方便理解父子进程之间的独立性)
(注2:但实际上,linux为了提高 fork 的效率,采用了 copy-on-write 技术,fork后,这两个虚拟地址实际上指向相同的物理地址(内存页),只有任何一个进程试图修改这个虚拟地址里的内容前,两个虚拟地址才会指向不同的物理地址(新的物理地址的内容从原物理地址中复制得到))

<3>父子进程之间的资源共享

Unix环境高级编程中8.3节中说,“子进程是父进程的副本。例如,子进程获得父进程数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父进程和子进程并不共享这些存储空间部分。父进程和子进程共享正文段(当然这是进程不执行exec)。”

对于父子进程之间的数据:.data段,.bss段 ,heap区,stack区的数据都是不共享的,但是.text段是共享的(因为代码一样鸭,没有进程替换的情况下。。。。)

那么对于文件,父子进程共享吗?

fork之前打开的文件描述符是共享的,fork之后的文件描述符是不共享的,为什么呢?

实际上子进程也复制了父进程 的PCB,所以也将父进程中的文件描述符复制了,struct file是内核文件表,每个进程只要有它的地址,就可以找到,所以子进程便可以找到这个文件,对文件进行操作。子进程对文件操作也会影响父进程,实际上是操作的文件中的偏移量,共享了文件偏移量。但是在fork之后打开的文件,那就是各自进程打开各自的了,这当然是不共享的了。

ps:你在子/父进程中close文件描述符,并不会影响另一个进程对这个文件操作,因为内核中实现的时候,要看struct file中的count引用计数,如果不为0,就不会删除文件,只是将你PCB中的指针赋为空而已,对其他操作该文件的进程没什么影响。

实际上,fork后子进程和父进程共享的资源还包括:

  • 实际用户ID、实际组ID、有效用户ID、有效组ID
  • 添加组ID
  • 进程组ID
  • 会话期ID
  • 控制终端
  • 设置-用户-ID标志和设置-组-ID标志
  • 当前工作目录
  • 根目录
  • 文件方式创建屏蔽字
  • 信号屏蔽和排列
  • 对任一打开文件描述符的在执行时关闭标志
  • 环境
  • 连接的共享存储段(共享内存)
  • 资源限制

父子进程之间的区别是:

  • fork的返回值
  • 进程ID
  • 不同的父进程ID
  • 子进程的tms_utime,tms_stime,tms-cutime以及tms_ustime设置为0
  • 父进程设置的锁,子进程不继承
  • 子进程的未决告警被清除
  • 子进程的未决信号集设置为空集

<4>exec进程替换

实际上,当进程调用一种exec函数时,该进程执行的程序完全替换为新的程序,而新程序则从其main函数开始执行。因为exec不创建新的进程,所以前后的进程ID(当然还有父进程号、进程组号、当前工作目录……)并未改变,exec只是用一个全新的程序替换了当前进程的正文,数据,堆和栈段。(注意区分程序和进程,可以将进程比作一个容器,程序就是里面装的物品)

exec家族一共有六个函数,分别是:

#include<unistd.h>

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

 

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

 

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

 

(4)int execve(const char *path, char *const argv[], char *const envp[]);

 

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

 

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

                                         //6个函数返回值,出错返回-1,成功不返回值

其中只有execve是真正意义上的系统调用,其它都是在此基础上经过包装的库函数。

    exec函数族的作用是根据指定的文件名找到可执行文件,并用它来取代调用进程的内容,换句话说,就是在调用进程内部执行一个可执行文件。这里的可执行文件既可以是二进制文件,也可以是任何Linux下可执行的脚本文件。

与一般情况不同,exec函数族的函数执行成功后不会返回,因为调用进程的实体,包括代码段,数据段和堆栈等都已经被新的内容取代,只留下进程ID等一些表面上的信息仍保持原样,颇有些神似"三十六计"中的"金蝉脱壳"。看上去还是旧的躯壳,却已经注入了新的灵魂。只有调用失败了,它们才会返回一个-1,从原程序的调用点接着往下执行。

exec 函数族的 6 个函数看起来似乎很复杂,但实际上无论是作用还是用法都非常相似,只有很微小的差别。

l(list):参数地址列表,以空指针结尾。

v(vector):存有各参数地址的指针数组的地址。

p(path):按 PATH 环境变量指定的目录搜索可执行文件。

e(environment):存有环境变量字符串地址的指针数组的地址。

exec 函数族装入并运行可执行程序 path/file,并将参数 arg0( arg1, arg2, argv[], envp[] ) 传递给此程序

 excel代码:


#include <stdio.h>

#include <unistd.h>

 

int main(int argc, char *argv[])

{

	printf("before exec\n\n");

	

	/* /bin/ls:外部程序,这里是/bin目录的 ls 可执行程序,必须带上路径(相对或绝对)

	   ls:没有意义,如果需要给这个外部程序传参,这里必须要写上字符串,至于字符串内容任意

	   -a,-l,-h:给外部程序 ls 传的参数

	   NULL:这个必须写上,代表给外部程序 ls 传参结束

	*/

	execl("/bin/ls", "ls", "-a", "-l", "-h", NULL);

	

	// 如果 execl() 执行成功,下面执行不到,因为当前进程已经被执行的 ls 替换了

	perror("execl");

	printf("after exec\n\n");

	

	return 0;

}

运行结果: 

execv()示例代码:

execv() 和 execl() 的用法基本是一样的,无非将列表传参,改为用指针数组。


#include <stdio.h>

#include <unistd.h>

 

int main(int argc, char *argv[])

{

	// execv() 和 execl() 的用法基本是一样的,无非将列表传参,改为用指针数组

	// execl("/bin/ls", "ls", "-a", "-l", "-h", NULL);

	

	/* 指针数组

	   ls:没有意义,如果需要给这个外部程序传参,这里必须要写上字符串,至于字符串内容任意

	   -a,-l,-h:给外部程序 ls 传的参数

	   NULL:这个必须写上,代表给外部程序 ls 传参结束

	*/

	char *arg[]={"ls", "-a", "-l", "-h", NULL};

	

	// /bin/ls:外部程序,这里是/bin目录的 ls 可执行程序,必须带上路径(相对或绝对)

	// arg: 上面定义的指针数组地址

	execv("/bin/ls", arg);

	

	perror("execv");

		

	return 0;

}

execlp() 或 execvp() 示例代码:

execlp() 和 execl() 的区别在于,execlp() 指定的可执行程序可以不带路径名,如果不带路径名的话,会在环境变量 PATH指定的目录里寻找这个可执行程序,而 execl() 指定的可执行程序,必须带上路径名。


#include <stdio.h>

#include <unistd.h>

 

int main(int argc, char *argv[])

{

	// 第一个参数 "ls",没有带路径名,在环境变量 PATH 里寻找这个可执行程序

	// 其它参数用法和 execl() 一样

	execlp("ls", "ls", "-a", "-l", "-h", NULL);

	

	/*

	char *arg[]={"ls", "-a", "-l", "-h", NULL};

	execvp("ls", arg);

	*/

	

	perror("execlp");

	

	return 0;

}

    

execle() 或 execve() 示例代码:

execle() 和 execve() 改变的是 exec 启动的程序的环境变量(只会改变进程的环境变量,不会影响系统的环境变量),其他四个函数启动的程序则使用默认系统环境变量。

execle()示例代码:


#include <stdio.h>

#include <unistd.h>

#include <stdlib.h> // getenv()

 

int main(int argc, char *argv[])

{

	// getenv() 获取指定环境变量的值

	printf("before exec:USER=%s, HOME=%s\n", getenv("USER"), getenv("HOME"));

	

	// 指针数据

	char *env[]={"USER=MIKE", "HOME=/tmp", NULL};

	

	/* ./mike:外部程序,当前路径的 mike 程序,通过 gcc mike.c -o mike 编译

		mike:这里没有意义

		NULL:给 mike 程序传参结束

		env:改变 mike 程序的环境变量,正确来说,让 mike 程序只保留 env 的环境变量

	 */

	execle("./mike", "mike", NULL, env);

	

	/*

	char *arg[]={"mike", NULL};		

	execve("./mike", arg, env);	

	*/

	

	perror("execle");

	

	return 0;

}

   外部程序,mike.c 示例代码:


#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

 

int main(int argc, char *argv[])

{

	printf("\nin the mike fun, after exec: \n");

	printf("USER=%s\n", getenv("USER"));

	printf("HOME=%s\n", getenv("HOME"));

	

	return 0;

}

 

猜你喜欢

转载自blog.csdn.net/Eunice_fan1207/article/details/81569192
今日推荐