zoukankan      html  css  js  c++  java
  • TLPI读书笔记第24章:进程的创建

    本章以及随后的 3 章将探讨进程的创建和终止,以及进程执行新程序的过程。本章主要讨论进程的创建,不过,在切入正题之前,将首先概括一下这 4 章所涵盖的主要系统调用。

    24.1 fork()、 exit()、 wait()以及 execve()的简介

    本章以及随后几章的议题会集中在 fork()、 exit()、 wait()以及 execve()这几个系统调用上。上述每种系统调用都各有变体,后续会一一论及。此处将首先对这 4 个系统调用及其典型用法简单加以介绍。

    1.系统调用 fork()允许一进程(父进程)创建一新进程(子进程)。具体做法是,新的子进程几近于对父进程的翻版:子进程获得父进程的栈、数据段、堆和执行文本段( 6.3 节)的拷贝。可将此视为把父进程一分为二,术语 fork 也由此得名。

    2.库函数 exit( status)终止一进程,将进程占用的所有资源(内存、文件描述符等)归还内核,交其进行再次分配。参数 status 为一整型变量,表示进程的退出状态。父进程可使用系统调用 wait()来获取该状态。

    3.系统调用 wait (&status) 的目的有二: 其一, 如果子进程尚未调用 exit()终止, 那么 wait()会挂起父进程直至子进程终止;其二,子进程的终止状态通过 wait()的 status 参数返回。

    4.系统调用 execve(pathname, argv, envp)加载一个新程序(路径名为 pathname,参数列表为 argv, 环境变量列表为 envp) 到当前进程的内存。 这将丢弃现存的程序文本段,并为新程序重新创建栈、数据段以及堆。通常将这一动作称为执行( execing)一个新程序。稍后会介绍构建于 execve()之上的多个库函数,每种都为编程接口提供了实用的变体。在彼此差异无关宏旨的场合,循例会将此类函数统称为 exec(),尽管实际上并没有以之命名的系统调用或者库函数

    其他一些操作系统则将 fork()和 exec()的功能合二为一,形成单一的 spawn 操作—创建一个新进程并执行指定程序。比较而言, UNIX 的方案通常更为简单和优雅。两步走的策略使得 API 更为简单(系统调用 fork()无需参数),程序也得以在这两步之间执行一些其他操作,因而更具弹性。另外,只执行 fork()而不执行 exec()的场景也颇为常见。

    图 24-1 对 fork()、 exit()、 wait()以及 exece()之间的相互协同作了总结。(此图勾勒了 shell 执行一条命令所历经的步骤: shell 读取命令,进行各种处理,随之创建子进程以执行该命令,如此循环不已。 )

    图中对 execve()的调用并非必须。有时,让子进程继续执行与父进程相同的程序反而会有妙用。最终,两种情况殊途同归:总是要通过调用 exit()(或接收一个信号)来终止子进程,而父进程可调用 wait()来获取其终止状态。 同样,对 wait()的调用也属于可选项。父进程可以对子进程不闻不问,继续我行我素。不过,由后续内容可知,对 wait()的使用通常也是不可或缺的,每每在 SIGCHLD 信号的处理程序中使用。当子进程终止时,内核会为其父进程产生此类信号(默认的处理是忽略 SIGCHLD信号,下图将此标记为可选,原因正在于此)。

    24.2 创建新进程: fork()

    在诸多应用中,创建多个进程是任务分解时行之有效的方法。例如,某一网络服务器进程可在侦听客户端请求的同时,为处理每一请求而创建一新的子进程,与此同时,服务器进程会继续侦听更多的客户端连接请求。以此类手法分解任务,通常会简化应用程序的设计,同时提高了系统的并发性。 (即,可同时处理更多的任务或请求。 ) 系统调用 fork()创建一新进程( child),几近于对调用进程( parent)的翻版

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

    理解 fork()的诀窍是,要意识到,完成对其调用后将存在两个进程,且每个进程都会从 fork()的返回处继续执行。 这两个进程将执行相同的程序文本段,但却各自拥有不同的栈段、数据段以及堆段拷贝。子进程的栈、数据以及栈段开始时是对父进程内存相应各部分的完全复制。

    执行 fork()之后,每个进程均可修改各自的栈数据、以及堆段中的变量,而并不影响另一进程。 程序代码则可通过 fork()的返回值来区分父、子进程。在父进程中, fork()将返回新创建子进程的进程 ID。鉴于父进程可能需要创建,进而追踪多个子进程(通过 wait()或类似方法),这种安排还是很实用的。而 fork()在子进程中则返回 0。

    如有必要,子进程可调用 getpid()以获取自身的进程 ID,调用 getppid()以获取父进程 ID。 当无法创建子进程时, fork()将返回-1。失败的原因可能在于,进程数量要么超出了系统针对此真实用户( real user ID)在进程数量上所施加的限制,要么是触及允许该系统创建的最大进程数这一系统级上限。 调用 fork()时,有时会采用如下习惯用语

    pid_t childPid;
    switch(childPid=fork()){
       case -1:
           /*hand error*/
           break;
       case 0:
           /*child process*/
           break;
       default:
           /*parent process*/
    }

    调用 fork()之后,系统将率先“垂青”于哪个进程(即调度其使用 CPU),是无法确定的,意识到这一点极为重要。在设计拙劣的程序中,这种不确定性可能会导致所谓“竞争条件(racecondition)”的错误, 24.2 节会对此做进一步说明

    程序清单 24-1 展示了 fork()的用法。该程序创建一子进程,并对继承自 fork()的全局及自动变量拷贝进行修改。 使用 sleep()(存在于由父进程所执行的代码中),意在允许子进程先于父进程获得系统调度并使用 CPU,以便在父进程继续运行之前完成自身任务并退出。要想确保这一结果, sleep()的这种用法并非万无一失, 24.5 节中的方法更胜一筹。 运行程序清单 24-1 中程序,其输出如下。 以上输出表明,子进程在 fork()时拥有了自己的栈和数据段拷贝,且其对这些段中变量的修改将不会影响父进程

    24.2.1 父、子进程间的文件共享

    执行 fork()时,子进程会获得父进程所有文件描述符的副本。这些副本的创建方式类似于dup(),这也意味着父、子进程中对应的描述符均指向相同的打开文件句柄。正如 5.4 节所述,打开文件句柄包含有当前文件偏移量(由 read()、write()和 lseek()修改)以及文件状态标志(由 open()设置,通过 fcntl()的 F_SETFL 操作改变)。

    一个打开文件的这些属性因之而在父子进程间实现了共享。举例来说,如果子进程更新了文件偏移量,那么这种改变也会影响到父进程中相应的描述符。 程序清单 24-2 所展示的正是这样一个事实; fork()之后,这些属性将在父子进程之间共享。该程序使用 mkstemp()打开一个临时文件,接着调用 fork()以创建子进程。子进程改变文件偏移量以及文件状态标志,最后退出。父进程随即获取文件偏移量和标志,以验证其可以观察到由子进程所造成的变化。此程序运行结果如下:

    父子进程间共享打开文件属性的妙用屡见不鲜。例如,假设父子进程同时写入一文件,共享文件偏移量会确保二者不会覆盖彼此的输出内容。不过,这并不能阻止父子进程的输出随意混杂在一起。要想规避这一现象,需要进行进程间同步。

    比如,父进程可以使用系统调用 wait()来暂停运行并等待子进程退出。 shell 就是这么做的:只有当执行命令的子进程退出后, shell 才会打印出提示符(除非用户在命令行最后加上&符以显式在后台运行命令)。

    如果不需要这种对文件描述符的共享方式,那么在设计应用程序时,应于 fork()调用后注意两点:

    其一,令父、子进程使用不同的文件描述符;

    其二,各自立即关闭不再使用的描述符(亦即那些经由其他进程使用的描述符)。如果进程之一执行了 exec(),那么 27.4 节所描述的执行时关闭功能( close-on-exec)也会很有用处。图 24-2 展示了这些步骤。

    24.2.2 fork()的内存语义

    从概念上说来,可以将 fork()认作对父进程程序段、数据段、堆段以及栈段创建拷贝。的确,在一些早期的 UNIX 实现中,此类复制确实是原汁原味:将父进程内存拷贝至交换空间,以此创建新进程映像( image),而在父进程保持自身内存的同时,将换出映像置为子进程。 不过,真要是简单地将父进程虚拟内存页拷贝到新的子进程,那就太浪费了。原因有很多,其中之一是: fork()之后常常伴随着 exec(), 这会用新程序替换进程的代码段,并重新初始化其数据段、堆段和栈段。大部分现代 UNIX 实现(包括 Linux)采用两种技术来避免这种浪费。 1.内核( Kernel)将每一进程的代码段标记为只读,从而使进程无法修改自身代码。这样,父、子进程可共享同一代码段。系统调用 fork()在为子进程创建代码段时,其所构建的一系列进程级页表项( page-table entries) 均指向与父进程相同的物理内存页帧。 2.对于父进程数据段、堆段和栈段中的各页,内核采用写时复制( copy-on-write)技术来处理。最初,内核做了一些设置,令这些段的页表项指向与父进程相同的物理内存页,并将这些页面自身标记为只读。调用 fork()之后,内核会捕获所有父进程或子进程针对这些页面的修改企图,并为将要修改的( about-to-be-modified)页面创建拷贝。系统将新的页面拷贝分配给遭内核捕获的进程,还会对子进程的相应页表项做适当调整。 从这一刻起父、子进程可以分别修改各自的页拷贝,不再相互影响。图 24-3 展示了写时复制技术。

    控制进程的内存需求

    通过将 fork()与 wait()组合使用,可以控制一个进程的内存需求。进程的内存需求量,亦即进程所使用的虚拟内存页范围,受到多种因素的影响,例如,调用函数,或从函数返回时栈的变化情况,对 exec()的调用,以及因调用 malloc()和 free()而对堆所做的修改—这点对这里的讨论有着特殊意义。 假设以程序清单 24-3 所示方式调用 fork()和 wait(),且将对某函数 func()的调用置于括号之中。由执行程序可知,由于所有可能的变化都发生于子进程,故而从对 func()的调用之前开始,父进程的内存使用量将保持不变。这一用法的实用性则归于如下理由。 1.若已知 func()导致内存泄露,或是引发堆内存的过度碎片化,该技术则可以避免这些问题。 (要是无法访问 func()的源码,想要处理这些问题也就无从谈起。 )

    2.假设某一算法在做树状分析( tree analysis)的同时需要进行内存分配(例如,游戏程序需要分析一系列可能的招法以及对方的应手)。 本可以调用 free()来释放所有已分配的内存,不过在某些情况下,使用此处所描述的技术会更为简单,返回(父进程),且调用者(父进程)的内存需求并无改变。

    如程序清单 24-3 的实现所示,必须将 func()的返回结果置于 exit()的 8 位传出值中,父进程调用 wait()可获得该值。不过,也可以利用文件、管道或其他一些进程间通信技术,使 func()返回更大的结果集1。

    24.3 系统调用 vfork()

    在早期的 BSD 实现中, fork()会对父进程的数据段、堆和栈施行严格的复制。如前所述,这是一种浪费,尤其是在调用 fork()后立即执行 exec()的情况下。出于这一原因, BSD 的后期版本引入了 vfork()系统调用,尽管其运作含义稍微有些不同(实则有些怪异),但效率要远高于 BSD fork()。现代 UNIX 采用写时复制技术来实现 fork(),其效率较之于早期的 fork()实现要高出许多,进而将对 vfork()的需求剔除殆尽。虽然如此, Linux(如同许多其他的 UNIX 实现一样) 还是提供了具有 BSD 语义的 vfork()系统调用, 以期为程序提供尽可能快的 fork 功能。 不过,鉴于 vfork()的怪异语义可能会导致一些难以察觉的程序缺陷(bug),除非能给性能带来重大提升,否则应当尽量避免使用这一调用。

    #include<unistd.h>
    pid_t vfork(void)

    类似于 fork(), vfork()可以为调用进程创建一个新的子进程。然而, vfork()是为子进程立即执行 exec()的程序而专门设计的

    vfork()因为如下两个特性而更具效率,这也是其与 fork()的区别所在。

    1.无需为子进程复制虚拟内存页或页表。相反,子进程共享父进程的内存,直至其成功执行了 exec()或是调用

    2.在子进程调用 exec()或_exit()之前,将暂停执行父进程。

    这两点还另有深意:由于子进程使用父进程的内存,因此子进程对数据段、堆或栈的任何改变将在父进程恢复执行时为其所见。 此外, 如果子进程在 vfork()与后续的 exec()或_exit()之间执行了函数返回,这同样会影响到父进程。

    这与 6.8 节所描述的例子(试图以 longjmp()进入一个已经执行了返回的函数中)相类似。同样相似的还有这一乱局的收场—以典型的段错误( SIGSEGV)而告终。

    在不影响父进程的前提下,子进程能在 vfork()与 exec()之间所做的操作屈指可数。其中包括对打开文件描述符进行操作(但不能施之于 stdio 文件流)。因为系统是在内核空间为每个进程维护文件描述符表( 5.4 节),且在 vfork()调用期间将复制该表,所以子进程对文件描述符的操作不会影响到父进程。

    vfork()的语义在于执行该调用后,系统将保证子进程先于父进程获得调度以使用 CPU。 24.2节曾经提及 fork()是无法保证这一点的,父、子进程均有可能率先获得调度。 程序清单 24-4 展示了 vfork()的用法,将其区分于 fork()的两种语义特性显露无遗:子进程共享父进程的内存,父进程会一直挂起直至子进程终止或调用 exec()。运行该程序,其输出结果如下:

    除非速度绝对重要的场合,新程序应当舍 vfork()而取 fork()。原因在于,当使用写时复制语义实现 fork()(大部分现代 UNIX 实现皆是如此)时,在速度几近于 vfork()的同时,又避免了 vfork()的上述怪异行止。 ( 28.3 节会给出 fork()与 vfork()在速度方面的某些比较。 ) SUSv3 将 vfork()标记为已过时, SUSv4 则进一步将其从规范中删除。对于 vfork()运作的诸多细节, SUSv3 颇有些语焉不详,因而可能将其实现为对 fork()的调用。如此一来,那么vfork()的 BSD 语义将不复存在。一些 UNIX 系统还真就把 vfork()实现为对 fork()的调用, Linux系统在内核 2.0 及其之前的版本中也是如此。 在使用时, 一般应立即在 vfork()之后调用 exec()。 如果 exec()执行失败, 子进程应调用_exit()退出。 ( vfork()产生的子进程不应调用 exit()退出,因为这会导致对父进程 stdio 缓冲区的刷新和关闭。 25.4 节将会详述这一点。 ) vfork()的其他用法,尤其当其依赖于内存共享以及进程调度方面的独特语义时,将可能破坏程序的可移植性,其中尤以将 vfork()实现为简单调用 fork()的情况为甚。

    24.4 fork()之后的竞争条件(Race Condition)

    调用 fork()后,无法确定父、子进程间谁将率先访问 CPU。 (在多处理器系统中,它们可能会同时各自访问一个 CPU。 )就应用程序而言,如果为了产生正确的结果而或明或暗 ( implicitly or explicitly)地依赖于特定的执行序列,那么将可能因竞争条件( 5.1 节曾论及)而导致失败。由于此类问题的发生取决于内核根据系统当时的负载而做出的调度决定,故而往往难以发现。 可以用程序清单 24-5 中程序来验证这种不确定性。该程序循环使用 fork()来创建多个子进程。在每个 fork()调用后,父、子进程都会打印一条信息,其中包含循环计数器值以及标识父/子进程身份的字符串。例如,如果要求程序只产生一个子进程,其结果可能如下: 可以使用该程序来生成大量子进程,并且分析其输出,观察父、子进程间每次到底由谁率先输出了结果。在某一 Linux/x86-32 2.2.19 系统上令此程序生成一百万个子进程,其分析结果表明,除去 332 次之外,都是由父进程先行输出结果(占总数的 99.97%)

    依据这一结果可以推测,在 Linux 2.2.19 中, fork()之后总是继续执行父进程。而子进程之所以在 0.03%的情况中首先输出结果,是因为父进程在有机会输出消息之前,其 CPU 时间片( CPU time slice)就到期了。换言之,如果该程序所代表的情况总是依赖于如下假设,即fork()之后总是调度父进程,那么程序通常可以正常运行,不过每 3000 次将会出现一次错误。当然,如果希望父进程能在调度子进程前执行大量工作,那么出错的可能性将会大增。在一个复杂程序中调试这样的错误会很困难

    虽然 Linux 2.2.19 总是在 fork()之后继续运行父进程,但在其他 UNIX 实现上,甚至不同版本的 Linux 内核之间,却不能视其为理所当然。在内核稳定版 2.4 系列中,一度曾试验性地推出了一个“fork()之后由子进程先运行”的补丁,其调度结果与内核 2.2.19 完全相反。虽然这一改变之后又为 2.4 系列内核所舍弃,不过后来还是在 Linux 2.6 中采用,因此,程序假定于 2.2.19 内核的行为会在内核 2.6 中遭到推翻。 fork()之后对父、子进程的调度谁先谁后?其结果孰优孰劣?最近的一些实验又推翻了内核开发者关于这一问题的评估。从 Linux 2.6.32 开始,父进程再度成为 fork()之后,默认情况下率先调度的对象。将 Linux 专有文件/proc/sys/kernel/sched_child_runs_first 设为非 0 值可以改变该默认设置

    上述讨论清楚地阐明,不应对 fork()之后执行父、子进程的特定顺序做任何假设。若确需保证某一特定执行顺序,则必须采用某种同步技术。后续各章将会介绍多种同步技术,其中包括信号量( semaphore)、文件锁( file lock)以及进程间经由管道( pipe)的消息发送。接下来会描述另一种方法,那就是使用信号( signal)。

    24.5 同步信号以规避竞争条件

    调用 fork()之后,如果进程某甲需等待进程某乙完成某一动作,那么某乙(即活动进程)可在动作完成后向某甲发送信号;某甲则等待即可。 程序清单 24-6 演示了这一技术。该程序假设父进程必须等待子进程完成某些动作。如果是子进程反过来要等待父进程,那么将父、子进程中与信号相关的调用对掉即可。父、子进程甚至可能多次互发信号以协调彼此行为,尽管实际上更有可能采用信号量、文件锁或消息传递等技术来进行此类协调。

    需要注意:程序清单 24-6 在 fork()之前就阻塞了同步信号( SIGUSR1)。若父进程试图在fork()之后阻塞该信号,则避之唯恐不及的竞争条件恐怕将不期而遇。(此程序假设与子进程的信号掩码状态无关;如有必要,可以在 fork()之后的子进程中解除对 SIGUSR1 的阻塞。 )

  • 相关阅读:
    SpringMVC-------1.spriingMVC简介和简单案例
    MyBatis-----7.pageHelper分页助手
    MyBatis-----4.实现关联表查询
    MyBatis-----6.逆向工程(generator)
    MyBatis-----1.MyBatis简介和使用
    MyBatis-----2.通过映射接口实现CRUD
    Spring-2
    Spring-1
    php调用阿里云手机归属地查询
    php身份证验证
  • 原文地址:https://www.cnblogs.com/wangbin2188/p/14699566.html
Copyright © 2011-2022 走看看