[c/c++]4.fork、vfork、clone和写时拷贝(copy on write)技术

进程创建

进程创建

首先说明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  
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 
#include 
#include 
#include 
#include 
#include 

/* 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_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 
#include 
#include 
#include 

/* 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和写时拷贝(copy on write)技术)