IIILinux系统编程进程进程控制
第30章进程3.进程控制3.1. fork 函数fork调用失败则返回-1,调用成功的返回值见下面的解释我们通过一个例子 来理解fork是怎样创建新进程的例 30.3. forkI | #inelude vsys/types・h>! #inelude vunistd.h>Ii■I—i #inelude
2. 父进程调用fork,这是一个系统调用,因此进入内核3. 内核根据父进程复制出一个子进程,父进程和子进程的 PCB信息相同,用户态代码和数据也相同因此,子进程现在的状态看起来 和父进程一样,做完了初始化,刚调用了 fork进入内核,还没有 从内核返回4. 现在有两个一模一样的进程看起来都调用了 fork进入内核等待从 内核返回(实际上fork只调用了一次),此外系统中还有很多别 的进程也等待从内核返回是父进程先返回还是子进程先返回, 还 是这两个进程都等待,先去调度执行别的进程,这都不一定,取决 于内核的调度算法5. 如果某个时刻父进程被调度执行了,从内核返回后就从 fork函数返回,保存在变量pid中的返回值是子进程的id,是一个大于0 的整数,因此执下面的else分支,然后执行for循环,打印"This is the pare nt\n" 三次之后终止6. 如果某个时刻子进程被调度执行了,从内核返回后就从 fork函数返回,保存在变量pid中的返回值是0,因此执行下面的if (pid == 0)分支,然后执行for循环,打印"This is the child'n" 六次之 后终止。
fork调用把父进程的数据复制一份给子进程,但此后二 者互不影响,在这个例子中,fork调用之后父进程和子进程的变量message和n被赋予不同的值,互不影响7. 父进程每打印一条消息就睡眠1秒,这时内核调度别的进程执行,在1秒这么长的间隙里(对于计算机来说 1秒很长了)子进程很有可能被调度到同样地,子进程每打印一条消息就睡眠1秒,在 这1秒期间父进程也很有可能被调度到所以程序运行的结果基本 上是父子进程交替打印,但这也不是一定的,取决于系统中其它进 程的运行情况和内核的调度算法,如果系统中其它进程非常繁忙则 有可能观察到不同的结果另外,读者也可以把 sleep(1);去掉看程序的运行结果如何8. 这个程序是在Shell下运行的,因此Shell进程是父进程的父进程父进程运行时Shell进程处于等待状态(第3.3节“wait和waitpid 函数”会讲到这种等待是怎么实现的),当父进程终止时 Shell进程认为命令执行结束了,于是打印 Shell提示符,而事实上子进程 这时还没结束,所以子进程的消息打印到了 Shell提示符后面最后光标停在This is the child的下一行,这时用户仍然可以敲命 令,即使命令不是紧跟在提示符后面, Shell也能正确读取。
fork函数的特点概括起来就是“调用一次,返回两次”,在父进程中调用一次, 在父进程和子进程中各返回一次 从上图可以看出,一开始是一个控制流程,调用fork之后发生了分叉,变成两个控制流程,这也就是“ fork” (分叉)这个名字的由来了子进程中fork的返回值是0,而父进程中fork的返回值则是子 进程的id (从根本上说fork是从内核返回的,内核自有办法让父进程和子进程 返回不同的值),这样当fork函数返回后,程序员可以根据返回值的不同让父 进程和子进程执行不同的代码fork的返回值这样规定是有道理的fork在子进程中返回0,子进程仍可以调 用getpid函数得到自己的进程id,也可以调用getppid函数得到父进程的id 在父进程中用getpid可以得到自己的进程id,然而要想得到子进程的id,只有 将fork的返回值记录下来,别无它法fork的另一个特性是所有由父进程打开的描述符都被复制到子进程中父、子 进程中相同编号的文件描述符在内核中指向同一个 file结构体,也就是说,file结构体的引用计数要增加用gdb调试多进程的程序会遇到困难,gdb只能跟踪一个进程(默认是跟踪父进 程),而不能同时跟踪多个进程,但可以设置 gdb在fork之后跟踪父进程还是 子进程。
以上面的程序为例:!$ gcc ma in .c -g! $ gdb a.outGNU gdb 6.8-debia nCopyright (C) 2008 Free Software Foundation.Inc.Lice nse GPLv3+: GNU GPL versio n 3 or latervhttp://g nu.o rg/lice nses/gpl.html>This is free software: you are free to changeand redistribute it.There is NO WARRANTY, to the extent permittedby law. Type "show copy ing"and "show warra nty" for details.〕This GDB was con figured as"i486-li nu x-g nu"...i (gdb) l2 #in clude
3.2. exec 函数用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码 分支),子进程往往要调用一种 exec函数以执行另一个程序当进程调用一种 exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启 动例程开始执行调用exec并不创建新进程,所以调用exec前后该进程的id 并未改变其实有六种以exec开头的函数,统称exec函数:#in clude vuni std.h>int execl(c onst char *path, const char*arg, ...);int execlp(c onst char *file, const char〕*arg,…);int execle(c onst char *path, const char*arg, ..., char *const en vp[]);int execv(c onst char *path, char *const[argv[]);int execvp(c onst char *file, char *constf argv[]);int execve(c onst char *path, char *constargv[], char *const en vp[]);i这些函数如果调用成功则加载新的程序从启动代码开始执行, 不再返回,如果调用出错则返回-1,所以exec函数只有出错的返回值而没有成功的返回值。
这些函数原型看起来很容易混,但只要掌握了规律就很好记不带字母 p (表示path )的exec函数第一个参数必须是程序的相对路径或绝对路径,例如"/bin/ls" 或"./a.out",而不能是"Is"或"a.out"对于带字母p的函数:•如果参数中包含/,贝U将其视为路径名•否则视为不带路径的程序名,在PATH^境变量的目录列表中搜索这 个程序带有字母I (表示list)的exec函数要求将新程序的每个命令行参数都当作一个 参数传给它,命令行参数的个数是可变的,因此函数原型中有…,…中的最后 一个可变参数应该是 NULL起sentinel的作用对于带有字母 v (表示vector) 的函数,则应该先构造一个指向各参数的指针数组, 然后将该数组的首地址当作参数传给它,数组中的最后一个指针也应该是 NULL就像main函数的argv参数或者环境变量表一样对于以e (表示environment)结尾的exec函数,可以把一份新的环境变量表传 给它,其他exec函数仍使用当前的环境变量表执行新程序exec调用举例如下:!char *const ps_argv[] ={"ps", "-o",["pid,ppid,pgrp,session,tpgid,comm".NULL};[char *const ps envp[]={"PATH=/bi n:/usr/bi n", "TERM=co nsole".NULL};execl("/bi n/ps", "ps", "-o","pid,ppid,pgrp,sessio n,tpgid,comm",l NULL);k1execv("/b in/ps", ps_argv);execle("/bi n/ps", "ps", "-o","pid,ppid,pgrp,sessi on ,tpgid,comm", NULL,i ps_e nvp); i hexecve("/b in/ps", ps_argv, ps_e nvp);execlp("ps", "ps", "-o","pid,ppid,pgrp,sessio n,tpgid,comm",! NULL);execvp("ps", ps_argv);事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve,所以execve在man手册第2节,其它函数在 man手册第3节。
这些函数之间的 关系如下图所示图30.5. exec函数族前各目录中 环境賁量表一个完整的例子:f | #inelude vunistd.h>#in elude
调用exec 后,原来打开的文件描述符仍然是打开的[37]利用这一点可以实现I/O 重定向先看一个简单的例子,把标准输入转成大写然后打印到标准输出:例 304 upper运行结果如下:if [$ ./upper|| | hello THEREiy_! HELLO THERE!(按 Ctrl-D 表示 EOF)我们如果希望把待转换的文件名放在命令行参数中, 而不是借助于输入重定向,可以利用upper程序的现有功能,再写一个包装程序 wrapper例 30.5. wrapperii J /* wrapper.c */j #inelude vunistd.h>f #include
运行结果如下:3.3. wait 和 waitpid 函数一个进程在终止时会关闭所有文件描述符, 释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状 态,如果是异常终止则保存着导致该进程终止的信号是哪个这个进程的父进程 可以调用wait或waitpid获取这些信息,然后彻底清除掉这个进程我们知道 一个进程的退出状态可以在 Shell中用特殊变量$?查看,因为Shell是它的父进 程,当它终止时Shell调用wait或waitpid得到它的退出状态同时彻底清除掉 这个进程如果一个进程已经终止,但是它的父进程尚未调用 wait或waitpid对它进行清理,这时的进程状态称为僵尸(Zombie )进程任何进程在刚终止时都是僵尸 进程,正常情况下,僵尸进程都立刻被父进程清理了,为了观察到僵尸进程,我 们自己写一个不正常的程序,父进程 fork出子进程,子进程终止,而父进程既不终止也不调用wait清理子进程:#in elude vuni std.h>#in clude
现在Shell是位于前台的,用户在终端的输入会被 Shell读取,后台进程是读不到终端输入的第二条命令 ps u是在前台运行的,在此期间Shell进程和./a.out进程都在后台运行,等到ps u命令结束时Shell 进程又重新回到前台在第33章 信号和第34章 终端、作业控制与守护进程 将会进一步解释前台(Foreground )和后台(Backgroud )的概念父进程的pid是6130,子进程是僵尸进程,pid是6131,ps命令显示僵尸进程 的状态为乙在命令行一栏还显示vdefunct>如果一个父进程终止,而它的子进程还存在(这些子进程或者仍在运行,或者已 经是僵尸进程了),则这些子进程的父进程改为 init进程init是系统中的一 个特殊进程,通常程序文件是/sbin/init ,进程id是1,在系统启动时负责启动 各种系统服务,之后就负责清理子进程,只要有子进程终止,init就会调用wait 函数清理它僵尸进程是不能用kill命令清除掉的,因为kill命令只是用来终止进程的,而 僵尸进程已经终止了思考一下,用什么办法可以清除掉僵尸进程?wait和waitpid函数的原型是:pid_t waitpid(pid_t pid, int *status, int opti on s);若调用成功则返回清理掉的子进程id,若调用出错则返回-1。
父进程调用wait 或waitpid时可能会:•阻塞(如果它的所有子进程都还在运行)•带子进程的终止信息立即返回(如果一个子进程已终止,正等待父 进程读取其终止信息)•出错立即返回(如果它没有任何子进程)这两个函数的区别是:•如果父进程的所有子进程都还在运行,调用 wait将使父进程阻塞,而调用waitpid时如果在options参数中指定 WNOHANG以使父进 程不阻塞而立即返回0♦ wait等待第一个终止的子进程,而 waitpid可以通过pid参数指定 等待哪一个子进程可见,调用wait和waitpid不仅可以获得子进程的终止信息,还可以使父进程 阻塞等待子进程终止,起到进程间同步的作用如果参数 status不是空指针, 则子进程的终止信息通过这个参数传出,如果只是为了同步而不关心子进程的终 止信息,可以将status参数指定为NULL例 306 waitpid#in clude
作为 练习,请读者从头文件里查一下这些宏做了什么运算,是如何取出字段值的习题1、请读者修改例30.6 “waitpid ”的代码和实验条件,使它产生“Child terminated abnormally ”的输出[37]事实上,在每个文件描述符中有一个 close-on-exec标志,如果该标志为1, 则调用exec时关闭这个文件描述符该标志默认为 0,可以用fcntl函数将它 置1,本书不讨论该标志为1的情况。




