美文网首页深入理解计算机系统
[c/c++]4.fork、vfork、clone和写时拷贝(c

[c/c++]4.fork、vfork、clone和写时拷贝(c

作者: MachinePlay | 来源:发表于2020-02-16 07:59 被阅读0次

    进程创建

    进程创建
    首先说明Linux下的进程与线程比较相近。这么说的一个原因是它们都需要相同的数据结构来表示,即task_struct。区别在于一个有独立的用户空间,一个是共享的用户空间(如果完全没有用户空间则是内核线程,不需要)。

    Linux的用户进程不能直接被创建出来,因为不存在这样的API。它只能从某个进程中复制出来,再通过exec这样的API来切换到实际想要运行的程序文件。

    复制的API包括三种:fork、clone、vfork。

    这三个API的内部实际都是调用一个内核内部函数do_fork,只是填写的参数不同而已。

    vfork,其实就是fork的部分过程,用以简化并提高效率。而forkclone是区别的。fork是进程资源的完全复制,包括进程的PCB线程的系统堆栈进程的用户空间进程打开的设备等。而在clone中其实只有前两项是被复制了的,后两项与父进程共享

    在四项资源的复制中,用户空间是相对庞大的,如果完全复制则效率会很低。在Linux中采用的“写时复制”技术,也就是说,fork执行时并不真正复制用户空间的所有页面,而只是复制页表。这样,无论父进程还是子进程,当发生用户空间的写操作时,都会引发“写复制”操作,仅仅需要为子进程的页面表指向的物理地址拷贝一个页块(通常是4KB)而另行分配一块可用的用户空间`,使其完全独立。这是一种提高效率的非常有效的方法。

    而对于clone来说,它们连这些页面表都是与父进程共享,故而是真正意义上的共享,因此对共享数据的保护必须有上层应用来保证。

    在linux源码中这三个调用的执行过程是执行fork(),vfork(),clone()时,通过一个系统调用表映射到sys_fork(),sys_vfork(),sys_clone(),再在这三个函数中去调用do_fork()去做具体的创建进程工作。

    1. fork

    一个现有的进程可以调用fork函数创建一个新进程。

    #include <unistd.h> 
    pid_t fork(void);
    

    返回值:子进程返回0,父进程返回子进程ID;若出错,返回−1

    由fork创建的新进程被称为子进程(child process)。fork函数被调用一次,但返回两次。两次返回的区别是子进程的返回值是 0,而父进程的返回值则是新建子进程的进程 ID

    父进程fork后为子进程生成一个PCB

    pcb内容:
    1)进程ID,进程组ID,用户ID和组ID
    2)环境
    3)工作目录
    4)程序说明(指令寄存器)
    5)寄存器
    6)栈
    7)堆
    8)打开的文件描述符表
    9)信号动作
    10)共享库
    11)进程间通信工具(例如消息队列,管道,信号量或共享内存)
    

    子进程ID返回给父进程的理由是:因为一个进程的子进程可以有多个,并且没有一个函数使一个进程可以获得其所有子进程进程 ID

    fork 使子进程得到返回值 0 的理 由是:一个进程只会有一个父进程,所以子进程总是可以调用 getppid 以获得其父进程的进程 ID(进程ID 0总是由内核交换进程使用,所以 一个子进程的进程ID不可能为0)。

    子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父进程和子进程并不共享这些存储空间部分。父进程和子进程共享正文段(我画了个内存布局的图)。

    image.png

    由于在fork之后经常跟随着exec,所以现在的很多实现并不执行一 个父进程数据段、栈和堆的完全副本。

    作为替代,使用了写时复制 (Copy-On-Write,COW)技术。这些区域由父进程和子进程共享,而且内核将它们的访问权限改变为只读。如果父进程和子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储系统中的一“页”。

    写时复制是在推迟真正的数据拷贝。若后来确实发生了写入,那意味着parent和child的数据不一致了,于是产生复制动作,每个进程拿到属于自己的那一份,这样就可以降低系统调用的开销。所以有了写时复制后呢,vfork其实现意义就不大了。

    某些平台提供 fork 函数的几种变体, 几乎所有平台都支持将要讨论vfork

    Linux 3.2.0 提供了另一种新进程创建函数—clone系统调用。 这是一种fork的推广形式,它允许调用者控制哪些部分由父进程和子进程共享

    FreeBSD 8.0提供了rfork系统调用,它类似于Linux的clone系 统调用。rfork调用是从Plan 9操作系统(Pike等[1995])派生出来 的。

    Solaris 10提供了两个线程库:一个用于POSIX线程 (pthreads),另一个用于Solaris线程。在这两个线程库中,fork 的 行为有所不同。对于 POSIX 线程,fork 创建一个进程,它仅包含调用该fork的线程,但对于Solaris线程,fork创建的进程包含了调用线程 所在进程的所有线程的副本。在Solaris 10中,这种行为改变了。不管 使用哪种线程库,fork创建的子进程只保留调用线程的副本。Solaris 也提供了fork1函数,它创建的进程只复制调用线程。还有forkall函 数,它创建的进程复制了进程中所有的线程。

    程序演示了fork函数,从中可以看到子进程对变量所做的改变 并不影响父进程中该变量的值。

    我们写一个代码演示一下

    #include <iostream>
    #include <unistd.h>
    #include <stdio.h>
    #include <string.h>
    #include <string>
    #include <vector>
    
    /* Intager in global segment. */
    int globalnum = 666;
    
    int main() {
        /*----------------------------------- test fork() ----------------------------------------*/
        
        /* Display str. */
        std::string str = "hello world\n";
    
        /* Intager in Stack (automatic variable on the stack). */
        int num = 233;
        /* Pid queue. */
        std::vector<pid_t> pid_queue;
    
        /* Pid. */
        pid_t new_pid;
    
        std::cout << "before fork()" ;//这里故意不刷新缓冲区
    
        if ((new_pid = fork()) < 0) {
            std::cout << "fork() error" << std::endl;
        }
    
        if (new_pid  == 0) {
            /* child. */
            ++globalnum;
            ++num;
        } else {
            /* parent. */
            sleep(2);
        }
    
        std::cout << "pid = " << getpid() << ", globalnum = " << globalnum << ", num = " << num << std::endl;
    
        return 0;
    }
    

    输出结果:

    image.png
    可以看到fork()之后的代码开始分支,并且子进程的改动并没有修改父进程的数据。

    一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的调度算法。如果要求父进程和子进程之间相互同步,则要求某种形式的进程间通信

    代码中父进程使自己休眠2 s,以此使子进程先执行。但并不保证2 s已经足够。讲述竟争条件时还将谈及这一问题及其他类型的同步方法。我们将说明在fork之后如何使用信号使父进程和子进程同步。

    回忆一下,如果标准输出连到终端设备,则它是行缓冲的;否则它是全缓冲的。当以交互方式运行该程序时,只得到该cout输出的行"before fork()"一次,其原因 是标准输出缓冲区由换行符冲洗。但是当将标准输出重定向到一个文件时,却得到cout输出的行"before fork()"输出行两次。其原因是,在fork之前调用了cout一次, 但当调用fork时,该行数据仍在缓冲区中,然后在将父进程数据空间复制到子进程中时,该缓冲区数据也被复制到子进程中,此时父进程和子进程各自有了带该行内容的缓冲区。在exit之前的第二个cout将其数据 追加到已有的缓冲区中。当每个进程终止时,其缓冲区中的内容都被写到相应文件中。

    我们可以重定向看看:


    image.png
    重定向到文件结果果然输出了两次 image.png

    文件共享

    要注意到的一点是,虽然子进程复制了父进程的数据段、堆和栈,生成了uid,但是PCB的其它部分却和父进程一致
    我们再看一眼PCB的内容

    pcb内容:
    1)进程ID,进程组ID,用户ID和组ID
    2)环境
    3)工作目录
    4)程序说明(指令寄存器)
    5)寄存器
    6)栈
    7)堆
    8)打开的文件描述符表
    9)信号动作
    10)共享库
    11)进程间通信工具(例如消息队列,管道,信号量或共享内存)
    

    实际上,fork的一个特性是父进程的所有打开文件描述符都被复制到子进程中。我们说“复制”是因为对每个文件描述符来说,就好像执行了dup函数。父进程和子进程每个相同的打开描述符共享一个文件表项

    考虑下述情况,一个进程具有3个不同的打开文件,它们是标准输 入、标准输出和标准错误。在从fork返回时,我们有了如图8-2中所示的 结构。


    image.png

    重要的一点是,父进程和子进程共享同一个文件偏移量。考虑下述 情况:一个进程fork了一个子进程,然后等待子进程终止。假定,作为普通处理的一部分,父进程和子进程都向标准输出进行写操作。如果父进程的标准输出已重定向(很可能是由 shell 实现的),那么子进程写到该标准输出时,它将更新与父进程共享的该文件的偏移量。在这个例 子中,当父进程等待子进程时,子进程写到标准输出;而在子进程终止 后,父进程也写到标准输出上,并且知道其输出会追加在子进程所写数据之后。如果父进程和子进程不共享同一文件偏移量,要实现这种形式 的交互就要困难得多,可能需要父进程显式地动作。

    如果父进程和子进程写同一描述符指向的文件,但又没有任何形式的同步(如使父进程等待子进程),那么它们的输出就会相互混合(假 定所用的描述符是在fork之前打开的)。虽然这种情况是可能发生的 (见图8-2),但这并不是常用的操作模式。

    在fork之后处理文件描述符有以下两种常见的情况。

    • (1)父进程等待子进程完成。在这种情况下,父进程无需对其描 述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享 描述符的文件偏移量已做了相应更新。
    • (2)父进程和子进程各自执行不同的程序段。在这种情况下,在fork之后,父进程和子进程各自关闭它们不需使用的文件描述符,这样 就不会干扰对方使用的文件描述符。这种方法是网络服务进程经常使用 的。

    除了打开文件之外,父进程的很多其他属性也由子进程继承,包括:

    •实际用户ID、实际组ID、有效用户ID、有效组ID •附属组ID
    •进程组ID
    •会话ID
    •控制终端
    •设置用户ID标志和设置组ID标志
    •当前工作目录
    •根目录
    •文件模式创建屏蔽字
    •信号屏蔽和安排 •对任一打开文件描述符的执行时关闭(close-on-exec)标志
    •环境
    •连接的共享存储段
    •存储映像
    •资源限制
    父进程和子进程之间的区别具体如下。
    •fork的返回值不同。
    •进程ID不同。 
    •这两个进程的父进程ID不同:子进程的父进程ID是创建它的进程的ID,而父进程的父进程ID则不变。 
    •子进程的tms_utime、tms_stime、tms_cutime和tms_ustime的值设置为0。
    •子进程不继承父进程设置的文件锁。
    •子进程的未处理闹钟被清除。
    •子进程的未处理信号集设置为空集。
    

    使fork失败的两个主要原因是:
    (a)系统中已经有了太多的进程(通常意味着某个方面出了问题),
    (b)该实际用户ID的进程总数超 过了系统限制。其中CHILD_MAX规定了每个实际用户ID 在任一时刻可拥有的最大进程数。

    fork有以下两种用法。
    (1)一个父进程希望复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务进程中是常见的—父进程等待客户端的服务请求。当这种请求到达时,父进程调用fork,使子进程处理此请求父进程继续等待下一个服务请求。
    (2)一个进程要执行一个不同的程序。这对 shell 是常见的情况。 在这种情况下,子进程从fork返回后立即调用exec

    某些操作系统将第 2 种用法中的两个操作(fork 之后执行 exec)组 合成一个操作,称为spawn

    UNIX系统将这两个操作分开,因为在很多 场合需要单独使用fork,其后并不跟随exec。另外,将这两个操作分 开,使得子进程在fork和exec之间可以更改自己的属性,如I/O重定向、 用户ID、信号安排等。

    2.vfork()

    vfork函数的调用序列和返回值与fork相同,但两者的语义不同。

    vfork 起源于较早的 2.9BSD。有些人认为,该函数是有瑕疵的。 但是本书讨论的 4 种平台都支持它。事实上,BSD 的开发者在 4.4BSD 中删除了该函数,但 4.4BSD 派生的所有开放源码BSD版本又将其收 回。在SUSv3中,vfork被标记为弃用的接口,在SUSv4中被完全删除。 我们只是由于历史的原因还是把它包含进来。可移植的应用程序不应该 使用这个函数。

    vfork函数用于创建一个新进程,而该新进程的目的是exec一个新程序.

    shell基本部分就是这类程序的一个例子。vforkfork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec(或exit),于是也就不会引用该地址空间。不过在子进程调用exec 或exit之前,它在父进程的空间中运行

    这种优化工作方式在某些UNIX 系统的实现中提高了效率,但如果子进程修改数据(除了用于存放vfork 返回值的变量)、进行函数调用、或者没有调用 exec 或 exit 就返回都可 能会带来未知的结果。

    就像上一节中提及的,实现采用写时复制技术 以提高fork之后跟随exec操作的效率,但是不复制比部分复制还是要快 一些。)

    vforkfork之间的另一个区别是: vfork保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行,当子进程调用这两个函数中的任意一个时,父进程会恢复运行。

    如果在调用这两个函数之前子进 程依赖于父进程的进一步动作,则会导致死锁

    我们尝试来用vfork代替fork

    #include <iostream>
    #include <unistd.h>
    #include <string.h>
    #include <string>
    
    /* Global Num. */
    int global_num = 666;
    
    int main() {
        /* Pid. */
        pid_t pid;
    
        /* Nun in Stack. */
        int num = 233;
    
        std::cout << "brefore vfork()\n";
    
        if ((pid = vfork()) < 0 ) {
            std::cout << "vfork() error" << std::endl;
        }
    
        if (pid == 0) {
            /* child. */
            ++global_num;
            ++num;
    
            /* must exit without change parent space. */
            _exit(0);
        }
    
        /* parent. */
        std::cout << "pid = " << getpid() << ", globalnum = " << global_num << ", num = " << num << std::endl;
    
        return 0;
    }
    
    执行结果

    从结果可以看出,子进程不仅先于父进程执行,并且改变了父进程的栈区和数据段内容,可以验证vfork子进程共享父进程空间不进行copy。子进程对变量做增1的操作,结果改变了父进程中的变量值,因为子进程在父进程的地址空间中运行,所以这并不令人惊讶。但是其作用 的确与fork不同。

    调用了_exit而不是exit。_exit并不执行标准I/O缓冲区的冲洗操作。如果调用的是exit而不是 _exit,则该程序的输出是不确定的。它依赖于标准I/O库的实现,我们 可能会看到输出没有发生变化,或者发现没有出现父进程的printf输出。

    如果子进程调用 exit,实现冲洗标准 I/O 流。如果这是函数库采取 的唯一动作,那么我们会见到这样操作的输出与子进程调用_exit所产生 的输出完全相同,没有任何区别。如果该实现也关闭标准I/O 流,那么 表示标准输出FILE 对象的相关存储区将被清 0。

    大多数exit的现代实现不再在流的关闭方面自找麻烦。因为进程即 将终止,那时内核将关闭在进程中已打开的所有文件描述符。在库中关 闭这些,只是增加了开销而不会带来任何益处。

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

    3.clone

    系统调用fork()vfork()是无参数的,而clone()则带有参数。fork()是全部复制,vfork()共享内存,而clone()是则可以将父进程资源有选择地复制给子进程,而没有复制的数据结构则通过指针的复制子进程共享,具体要复制哪些资源给子进程,由参数列表中的clone_flags决决定。
    fork不对父子进程的执行次序进行任何限制,fork返回后,子进程和父进程都从调用fork函数的下一条语句开始行,但父子进程运行顺序是不定的,它取决于内核的调度算法.而在vfork调用中,子进程先运行,父进程挂起,直到子进程调用了execexit之后,父子进程的执行次序才不再有限制;clone中由标志CLONE_VFORK来决定子进程在执行时父进程阻塞还是运行,若没有设置该标志,则父子进程同时运行,设置了该标志,则父进程挂起,直到子进程结束为止。

    相关文章

      网友评论

        本文标题:[c/c++]4.fork、vfork、clone和写时拷贝(c

        本文链接:https://www.haomeiwen.com/subject/dxoufhtx.html