进程创建
首先说明Linux下的进程与线程比较相近。这么说的一个原因是它们都需要相同的数据结构来表示,即
task_struct
。区别在于一个有独立的用户空间,一个是共享的用户空间(如果完全没有用户空间则是内核线程,不需要)。
Linux的用户进程
不能直接被创建出来,因为不存在这样的API。它只能从某个进程中复制出来,再通过exec
这样的API来切换到实际想要运行的程序文件。
复制的API包括三种:fork、clone、vfork。
这三个API的内部实际都是调用一个内核内部函数do_fork
,只是填写的参数不同
而已。
vfork
,其实就是fork
的部分过程,用以简化并提高效率。而fork
与clone
是区别的。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调用之后的指令
。子进程是父进程的副本
。例如,子进程获得父进程数据空间、堆和栈的副本
。注意,这是子进程所拥有的副本
。父进程和子进程并不共享这些存储空间部分
。父进程和子进程共享正文段
(我画了个内存布局的图)。
由于在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;
}
输出结果:
可以看到
fork()
之后的代码开始分支,并且子进程的改动并没有修改父进程的数据。
一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的
,这取决于内核所使用的调度算法
。如果要求父进程和子进程之间相互同步,则要求某种形式的进程间通信
。
代码中父进程使自己休眠2 s,以此使子进程先执行。但并不保证2 s已经足够。讲述竟争条件时还将谈及这一问题及其他类型的同步方法。我们将说明在fork之后如何使用信号使父进程和子进程同步。
回忆一下,如果标准输出连到终端设备
,则它是行缓冲的
;否则它是全缓冲的
。当以交互方式运行该程序时,只得到该cout输出的行"before fork()"
一次,其原因 是标准输出缓冲区由换行符冲洗
。但是当将标准输出重定向到一个文件时,却得到cout输出的行"before fork()"
输出行两次。其原因是,在fork
之前调用了cout
一次, 但当调用fork时,该行数据仍在缓冲区
中,然后在将父进程数据空间复制到子进程中时,该缓冲区数据也被复制到子进程中
,此时父进程和子进程各自有了带该行内容的缓冲区
。在exit之前的第二个cout
将其数据 追加到已有的缓冲区中。当每个进程终止时,其缓冲区中的内容都被写到相应文件中。
我们可以重定向看看:
重定向到文件结果果然输出了两次
文件共享
要注意到的一点是,虽然子进程复制了父进程的数据段、堆和栈,生成了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中所示的 结构。
重要的一点是,父进程和子进程共享同一个文件偏移量
。考虑下述 情况:一个进程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基本部分就是这类程序的一个例子。vfork
与fork
一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程
中,因为子进程会立即调用 exec(或exit)
,于是也就不会引用该地址空间
。不过在子进程调用exec 或exit之前,它在父进程的空间中运行
。
这种优化工作方式在某些UNIX 系统的实现中提高了效率
,但如果子进程修改数据
(除了用于存放vfork 返回值的变量)、进行函数调用
、或者没有调用 exec 或 exit 就返回
都可 能会带来未知的结果。
就像上一节中提及的,实现采用写时复制技术 以提高fork之后跟随exec操作的效率,但是不复制比部分复制还是要快 一些。)
vfork
和fork
之间的另一个区别是: 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
调用中,子进程
先运行,父进程
挂起,直到子进程
调用了exec
或exit
之后,父子进程的执行次序才不再有限制;clone
中由标志CLONE_VFORK
来决定子进程在执行时父进程
是阻塞
还是运行
,若没有设置该标志,则父子进程同时运行
,设置了该标志,则父进程挂起
,直到子进程结束
为止。