2023-06-11
原文作者:奇小葩 原文地址:https://blog.csdn.net/u012489236/category_10946851.html

fork,vfork,clone都是linux系统调用,这三个函数分别调用sys_fork,sys_vfork,sys_clone,最终都会调用到do_fork函数。差别就在于参数的传递和一些准备工作的不同,上一章节已经详细学习了fork的流程,本章主要专注学习这三个接口函数的使用方法和差异点。

1 进程的四要素

linux进程所必须的四个要素:

  • 程序代码,有一段程序供其执行: 代码不一定是进程专有,可以与其它进程共享
  • 有自己专用系统堆栈空间:
  • 有进程控制块(task_struct):
  • 有独立的存储空间:

以上4条,缺一不可。如果缺少第四条,那么就称其为"线程"。如果完全没有用户空间,称其位”内核线程“;如果共享用户空间,则称其为”用户线程"。

2 fork

系统调用fork,允许父进程创建一个新的进程(子进程)。新的子进程是父进程的翻版:完全继承父进程的栈、数据段、堆和执行文本的拷贝。其接口如下:

    NAME
           fork - create a child process
    
    SYNOPSIS
           #include <sys/types.h>
           #include <unistd.h>
    
           pid_t fork(void);

完成对其调用后将存在两个进程,且每个进程都会从fork的返回处继续执行。这两个进程将执行相同的程序文本段,但却各自拥有不同的栈段、数据段以及堆段拷贝。子进程的栈、数据以及栈段开始时是对父进程内存相应各部分的完全复制。执行fork之后,每个进程均可修改各自的栈数据以及堆中的变量而不影响另一进程。

202306111304298011.png

为调用进程创建一个一模一样的新进程 ,但父子进程需要改变时候,执行一个copy,但是任何修改都造成分裂,如:chroot, open, 写memory,mmap,sigaction….

fork的示例

考虑以下代码的输出,假设test.txt中的内容”abcdefghijklmnopqrst…”

    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <unistd.h>
    #include<fcntl.h>
    
    int main(void)
    {
            char str[10];
            int count = 1;
            int fd = open("test.txt", O_RDWR);
            if(fork() == 0)
            {
                    int cnt = read(fd, str, 10);
                    printf("Child process : %s\n", (char *)str);
                    printf("This is son, his count is: %d (%p). and his pid is: %d\n", ++count, &count, getpid());
            }
            else
            {
                    int cnt = read(fd, str, 10);
                    printf("Child process : %s\n", (char *)str);
                    printf("This is father, his count is: %d (%p), his pid is: %d\n", count, &count, getpid());
            }
    
        return 0;
    }

输出为:

202306111304304082.png

  • 从结果来看,子进程和父进程的PID不同,内存资源count是值的复制,子进程改变了count的值,而父进程中的count的值没有改变,这个过程请参考之前章节的写时复制技术。

  • 两个进程共享了同一个指向文件的结构体,所以当子进程输出“abcdefghij”后,父进程就接着输出"klmnopqrst"

    202306111304311723.png

3 vfork

vfork也是创建子进程,但是子进程共享父进程的空间。在vfork创建子进程之后,父进程阻塞,直到子进程执行exec()或者exit()。vfork设计的最初是因为fork没有实现COW机制,很多情况下fork之后会紧跟着exec,而exec的执行相当于前面fork复制的空间全部变得无用,所以设计了vfork。而现在fork使用了COW,唯一的代价仅仅是复制父进程页表的代价,所以vfork的功能就变得越来越不重要。

    NAME
           vfork - create a child process and block parent
    
    SYNOPSIS
           #include <sys/types.h>
           #include <unistd.h>
    
           pid_t vfork(void);

vfork因为如下两个特性而更具效率,也是区别与fork所在:

  • 无需为子进程复制虚拟内存页或页表,相反,子进程共享父进程的内存,直至其成功执行exec或调用exit退出

  • 在子进程调用exit或exec之前,将暂停执行父进程,所以在使用vfork时,一般立即在vfork之后调用exec,如果exec调用失败,子进程应调用exit退出。

    202306111304317964.png

vfork示例

    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <unistd.h>
    
    
    int main(void)
    {
    	int count =1;
    	int child;
    
    	printf("Before create son, the father's count is %d\n",count);
    	if(!(child = vfork()))
    	{
    		int i = 0;
    		for( i = 0; i< 3; i++)
    		{
    			count++;
    			printf("This is son This i is: %d count: %d\n", i, count);
    			if(i == 2)
    			{
    				printf("This is son This pid is: %d count: %d\n", getpid(), count);	
    				exit(1);
    			}
    		}
    	}
    	else
    	{
    		printf("This is father This pid is: %d count: %d\n", getpid(), count); 
    	}
        return 0;
    }

输出:

202306111304325035.png

  • vfork创建的子进程与父进程共享地址空间,也就是说子进程完全运行在父进程的地址空间上,如果这时子进程修改了某个变量,这将影响到父进程
  • 子进程vfork()返回后直接运行在父进程的栈空间,并使用父进程的内存和数据。这意味着子进程可能破坏父进程的数据结构或栈,造成失败。为了避免这些问题,需要确保一旦调用vfork(),子进程就不从当前的栈框架中返回,并且如果子进程改变了父进程的数据结构就不能调用exit函数。子进程还必须避免改变全局数据结构或全局变量中的任何信息,因为这些改变都有可能使父进程不能继续。
  • 值得注意的是用vfork创建的子进程必须显示调用exit()来结束,否则子进程将不能结束,父进程就讲一直阻塞,出现异常

大家可以实际将上述例子中exit(1)这个注释掉后,会出现什么情况。对于Vfork和fork是类似的,除了下面两点:
1、阻塞父进程
2、不复制父进程的页表

之所以vfork要阻塞父进程是因为vfork后父子进程使用的是完全相同的mm_struct,也就是由完全相同的虚拟地址空间,包括栈也相同,所以两个进程就不能同时运行,否则栈就会乱掉。所以vfork后,父进程是阻塞的,直到调用了exec系列或者exit后,这个时候,子进程的mm需要释放,不再与父进程公用,这个时候就可以解除父进程的阻塞状态。

4 clone

clone是Linux为创建线程设计的,所以可以说clone是fork的升级版本,不仅可以创建进程或线程,还可以指定创建新的命名空间,有选择的继承父进程的内存、甚至可以将创建出来的进程编程父进程的兄弟进程等。

clone函数功能强大,待有很多参数,提供了一个非诚灵活自由的常见进程的方法,因此它创建进程要比前面两种方法更为复杂。clone可以有选择继承父进程的资源,你可以选择像vfork一样和父进程共享一个虚拟存储空间,也可以不和父进程共享,甚至可以选择创造出来的进程和父进程不再是父子关系,而是兄弟关系。

    NAME
           clone, __clone2 - create a child process
    
    SYNOPSIS
           /* Prototype for the glibc wrapper function */
    
           #define _GNU_SOURCE
           #include <sched.h>
    
           int clone(int (*fn)(void *), void *child_stack,
                     int flags, void *arg, ...
                     /* pid_t *ptid, void *newtls, pid_t *ctid */ );
参数 含义
fn为函数指针 此指针指向一个函数体,即想要创建进程的静态程序
child_stack 为给子进程分配系统堆栈的指针
arg 传给子进程的参数一般为(0)
flags 要复制资源的标志,描述你需要从父进程继承那些资源(是资源复制还是共享,在这里设置参数)

下面是flaga可以取得值

标志 含义
CLONE_PARENT 建的子进程的父进程是调用者的父进程,新进程与创建它的进程成了“兄弟”而不是“父子”
CLONE_FS 子进程与父进程共享相同的文件系统,包括root、当前目录、umask
CLONE_FILES 子进程与父进程共享相同的文件描述符(filedescriptor)表
CLONE_NEWNS 在新的namespace启动子进程,namespace描述了进程的文件hierarchy
CLONE_SIGHAND 子进程与父进程共享相同的信号处理(signalhandler)表
CLONE_PTRACE 若父进程被trace,子进程也被trace
CLONE_VFORK 父进程被挂起,直至子进程释放虚拟内存资源
CLONE_VM 子进程与父进程运行于相同的内存空间
CLONE_PID 子进程在创建时PID与父进程一致
CLONE_THREAD Linux2.4中增加以支持POSIX线程标准,子进程与父进程共享相同的线程群

202306111304330296.png

    #define _GNU_SOURCE
    #include <sys/wait.h>
    #include <sys/utsname.h>
    #include <sched.h>
    #include <string.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <unistd.h>
    #include <pthread.h>
    #include <sched.h>
    #define FIBER_STACK 8192
    
    int a;
    void *stack;
    
    int do_something()
    {
    	a = 10;
    	printf("This is son, the pid is: %d, the a is: %d\n",getpid(), a);
    	free(stack);
    	exit(1);
    }
    
    int main(void)
    {
    	void *stack;
    	a = 1;
    	stack = malloc(FIBER_STACK);
    	if(!stack)
    	{
    		printf("The stack failed\n");
    		exit(0);
    	}
    
    	printf("Create son thread \n");
    	clone(&do_something, (char *)stack + FIBER_STACK, CLONE_VM | CLONE_VFORK, 0);
    	printf("This is father, the pid is: %d, the a is: %d\n",getpid(), a);
        return 0;
    }

输出结果:

202306111304336717.png

inux创建线程的API,本质上去调 clone。要求把P2的所有资源的指针,都指向P1。线程,也被称为 Light weight process。而Linux在clone线程时也十分灵活,可以选择共享/不共享部分资源。

202306111304344878.png

POSIX标准要求,进程里面如果有多个线程,在用户空间 getpid() 看到的都是同一个id,这个id其实是TGID。一个进程里面创建了多个线程,在/proc 下 的是 tgid,/proc/tgid/task/{pidx,y,z}pthread_self() 看到的是用户空间pthread线程库里获得的id 。

5. 总结

下面是三个接口的优缺点对比

类型 优点 缺点
fork 1.接口非常简洁2.将进程“创建”和执行(exec)解耦,提高了灵活性3.刻画了进程间的内在关系(进程树、进程组) 1.完全拷贝,过于粗暴(不如clone)2.性能差,可扩展性差(不如vfork)3.不可组合(如fork()+pthread())
vfork 1.类似fork,但让父子进程共享同一地址空间2.连映射关系都不需要拷贝,性能更好 1.只能用在fork+exec的场景中2.共享地址空间存在安全问题
clone 1.fork的进阶版本,可以选择地不拷贝内存2.高度可控,可按照需求调整 接口比fork复杂,选择性拷贝容易出错
阅读全文