11.3 启动新进程
我们可以在一个程序的内部启动另一个程序,从而创建一个新进程。这个工作可以通过库函数system来完成。
system函数的作用是,运行以字符串参数的形式传递给它的命令并等待该命令的完成。命令的执行情况就如同在shell中执行如下的命令:
$ sh -c string
如果无法启动shell来运行这个命令,system函数将返回错误代码127;如果是其他错误,则返回-1。否则,system函数将返回该命令的退出码。
实 验 system函数
我们用system函数来编写一个程序,让它替我们运行ps命令。虽然这个程序本身的用处不是很大,但我们将在后面的例子中对这一技术做进一步开发。为了简单,我们在这个例子中也没有检查system调用是否能够真正的工作。
编译并运行这个程序system1.c时,将看到如下所示的输出:
因为system函数用一个shell来启动想要执行的程序,所以可以把这个程序放到后台执行。具体做法是将system1.c中的函数调用修改为下面这样:
编译并运行这个新版本的程序时,我们将看到:
实验解析
在第一个例子中,程序以字符串“ps ax”为参数调用system函数从而在程序中执行ps命令。我们的程序在ps命令完成后从system调用中返回。system函数很有用,但它也有局限性,因为程序必须等待由system函数启动的进程结束之后才能继续,因此我们不能立刻执行其他任务。
在第二个例子中,对system函数的调用将在shell命令结束后立刻返回。由于它是一个在后台运行程序的请求,所以ps程序一启动shell就返回了,这与我们在shell提示符下执行下面这条命令的效果是一样的。
$ ps ax &
在ps命令还未来得及打印出它的所有输出结果之前,system2程序就打印出字符串Done然后退出了。在system2程序退出后,ps命令继续完成它的输出。这类的处理行为往往会给用户带来很大的困惑。如果想要用好进程,我们就需要能够对它们的行为做更细致的控制。下面来看一个用来创建进程的底层接口exec。
一般来说,使用system函数远非启动其他进程的理想手段,因为它必须用一个shell来启动需要的程序。由于在启动程序之前需要先启动一个shell,而且对shell的安装情况及使用的环境的依赖也很大,所以使用system函数的效率不高。在下一节中,我们将看到一种更好的调用程序的方法,与system调用相比,我们应该总是在程序中优先使用这种方法。
1.替换进程映像
exec系列函数由一组相关的函数组成,它们在进程的启动方式和程序参数的表达方式上各有不同。exec函数可以把当前进程替换为一个新进程,新进程由path或file参数指定。你可以使用exec函数将程序的执行从一个程序切换到另一个程序。例如,你可以在启动另一个有着受限使用策略的程序前,检查用户的凭证。exec函数比system函数更有效,因为在新的程序启动后,原来的程序就不再运行了。
这些函数可以分为两大类。execl、execlp和execle的参数个数是可变的,参数以一个空指针结束。execv和execvp的第二个参数是一个字符串数组。不管是哪种情况,新程序在启动时会把在argv数组中给定的参数传递给main函数。
这些函数通常都是用execve实现的,虽然并不是必须要这样做。
以字母p结尾的函数通过搜索PATH环境变量来查找新程序的可执行文件的路径。如果可执行文件不在PATH定义的路径中,我们就需要把包括目录在内的使用绝对路径的文件名作为参数传递给函数。
全局变量environ可用来把一个值传递到新的程序环境中。此外,函数execle和execve可以通过参数envp传递字符串数组作为新程序的环境变量。
如果想通过exec函数来启动ps程序,我们可以从6个exec函数中选择一个,如下面的代码片段所示:
实 验 execlp函数
修改示例程序,使用execlp函数调用:
运行这个程序时,你会看到正常的ps输出,但字符串Done却根本没有出现。另外值得注意的是,ps的输出中没有pexec进程的任何信息。
实验解析
程序先打印出它的第一条消息,接着调用execlp,这个函数在PATH环境变量给出的目录中搜索程序ps。然后用这个程序替换pexec程序,就好像直接使用如下所示的shell命令一样:
$ ps ax
ps命令结束时,我们看到一个新的shell提示符,因为我们并没有再返回到pexec程序中,所以第二条消息是不会打印出来的。新进程的PID、PPID和nice值与原先的完全一样。事实上,这里发生的一切其实就是,运行中的程序开始执行exec调用中指定的新的可执行文件中的代码。
对于由exec函数启动的进程来说,它的参数表和环境加在一起的总长度是有限制的。上限由ARG_MAX给出,在Linux系统上它是128K字节。其他系统可能会设置一个非常有限的长度,这有可能会导致出现问题。POSIX规范要求ARG_MAX至少要有4096个字节。
一般情况下,exec函数是不会返回的,除非发生了错误。出现错误时,exec函数将返回-1,并且会设置错误变量errno。
由exec启动的新进程继承了原进程的许多特性。特别地,在原进程中已打开的文件描述符在新进程中仍将保持打开,除非它们的“执行时关闭标志”(close on exec flag)被置位(详细说明请参考第3章中对fcnt1系统调用的介绍)。任何在原进程中已打开的目录流都将在新进程中被关闭。
2.复制进程映像
要想让进程同时执行多个函数,我们可以使用线程(将在第12章介绍)或从原程序中创建一个完全分离的进程,后者就像init的做法一样,而不像exec调用那样用新程序替换当前执行的线程。
我们可以通过调用fork创建一个新进程。这个系统调用复制当前进程,在进程表中创建一个新的表项,新表项中的许多属性与当前进程是相同的。新进程几乎与原进程一模一样,执行的代码也完全相同,但新进程有自己的数据空间、环境和文件描述符。fork和exec函数结合在一起使用就是创建新进程所需要的一切了。
如图11-2所示,在父进程中的fork调用返回的是新的子进程的PID。新进程将继续执行,就像原进程一样,不同之处在于,子进程中的fork调用返回的是0。父子进程可以通过这一点来判断究竟谁是父进程,谁是子进程。
图 11-2
如果fork失败,它将返回-1。失败通常是因为父进程所拥有的子进程数目超过了规定的限制(CHILD_MAX),此时errno将被设为EAGAIN。如果是因为进程表里没有足够的空间用于创建新的表单或虚拟内存不足,errno变量将被设为ENOMEM。
一个典型的使用fork的代码片段如下所示:
实 验 fork函数
我们来看一个简单的例子fork1.c:
这个程序以两个进程的形式在运行。子进程被创建并且输出消息5次。原进程(即父进程)只输出消息3次。父进程在子进程打印完它的全部消息之前就结束了,因此我们将看到在输出内容中混杂着一个shell提示符。
实验解析
程序在调用fork时被分为两个独立的进程。程序通过fork调用返回的非零值确定父进程,并根据该值来设置消息的输出次数,两次消息的输出之间间隔一秒。
11.3.1 等待一个进程
当用fork启动一个子进程时,子进程就有了它自己的生命周期并将独立运行。有时,我们希望知道一个子进程何时结束。例如,在前面的示例程序中,父进程在子进程之前结束,由于子进程还在继续运行,所以得到的输出结果有点乱。我们可以通过在父进程中调用wait函数让父进程等待子进程的结束。
wait系统调用将暂停父进程直到它的子进程结束为止。这个调用返回子进程的PID,它通常是已经结束运行的子进程的PID。状态信息允许父进程了解子进程的退出状态,即子进程的main函数返回的值或子进程中exit函数的退出码。如果stat_loc不是空指针,状态信息将被写入它所指向的位置。
我们可以用sys/wait.h文件中定义的宏来解释状态信息,如表11-2所示。
表 11-2
实 验 wait函数
我们稍微修改一下程序,让父进程等待并检查子进程的退出状态。新程序被命名为wait.c:
程序的这一部分等待子进程完成:
运行这个程序时,我们将看到父进程等待子进程的情况:
实验解析
父进程(从fork调用中获得一个非零的返回值)用wait系统调用将自己的执行挂起,直到子进程的状态信息出现为止。这将发生在子进程调用exit的时候。我们将子进程的退出码设置为37。父进程然后继续运行,通过测试wait调用的返回值来判断子进程是否正常终止。如果是,就从状态信息中提取出子进程的退出码。
11.3.2 僵尸进程
用fork来创建进程确实很有用,但你必须清楚子进程的运行情况。子进程终止时,它与父进程之间的关联还会保持,直到父进程也正常终止或父进程调用wait才告结束。因此,进程表中代表子进程的表项不会立刻释放。虽然子进程已经不再运行,但它仍然存在于系统中,因为它的退出码还需要保存起来,以备父进程今后的wait调用使用。这时它将成为一个死(defunct)进程或僵尸(zombie)进程。
如果修改fork示例程序中的消息输出次数,我们就能看到僵尸进程。如果子进程输出消息的次数少于父进程,它就会率先结束并成为僵尸进程直到父进程也结束。
实 验 僵尸进程
fork2.c和fork1.c基本一样,只是父、子进程输出消息的次数对调了一下。下面是相关的代码行:
实验解析
如果用./fork2 &命令来运行上面这个程序,然后在子进程结束之后父进程结束之前调用ps程序,我们将会看到如下阴影显示的一行(一些系统可能使用<zombie>而不是<defunct>)。
如果此时父进程异常终止,子进程将自动把PID为1的进程(即init)作为自己的父进程。子进程现在是一个不再运行的僵尸进程,但因为其父进程异常终止,所以它由init进程接管。僵尸进程将一直保留在进程表中直到被init进程发现并释放。进程表越大,这一过程就越慢。应该尽量避免产生僵尸进程,因为在init清理它们之前,它们将一直消耗系统的资源。
还有另一个系统调用可用来等待子进程的结束,它是waitpid函数。你可以用它来等待某个特定进程的结束。
pid参数指定需要等待的子进程的PID。如果它的值为-1,waitpid将返回任一子进程的信息。与wait一样,如果stat_loc不是空指针,waitpid将把状态信息写到它所指向的位置。option参数可用来改变waitpid的行为,其中最有用的一个选项是WNOHANG,它的作用是防止waitpid调用将调用者的执行挂起。你可以用这个选项来查找是否有子进程已经结束,如果没有,程序将继续执行。其他的选项和wait调用的选项相同。
因此,如果想让父进程周期性地检查某个特定的子进程是否已终止,就可以使用如下的调用方式:
如果子进程没有结束或意外终止,它就返回0,否则返回child_pid。如果waitpid失败,它将返回-1并设置errno。失败的情况包括:没有子进程(errno设置为ECHILD)、调用被某个信号中断(EINTR)或选项参数无效(EINVAL)。
11.3.3 输入和输出重定向
已打开的文件描述符将在fork和exec调用之后保留下来,我们可以利用对进程这方面知识的理解来改变程序的行为。下一个例子涉及一个过滤程序:它从标准输入读取数据,然后向标准输出写数据,同时在输入和输出之间对数据做一些有用的转换。
实 验 重定向
下面是一个非常简单的过滤程序upper.c,它读取输入并将输入字符转换为大写:
运行这个程序时,它按照我们预期的那样执行,如下所示:
当然还可以利用shell的重定向把一个文件的内容全部转换为大写,如下所示:
如果我们想在另一个程序中使用这个过滤程序会发生什么情况呢?下面这个程序useupper.c接受一个文件名作为命令行参数,如果对它的调用不正确,它将响应一个错误信息。
重新打开标准输入,并再次检查有无错误发生,然后用execl调用upper程序:
不要忘记execl会替换掉当前的进程。如果没有发生错误,剩下的这些语句将不会被执行:
实验解析
运行这个程序时,我们可以提供给它一个文件,让它把该文件的内容全部转换为大写。这项工作由程序upper完成,但它并不处理文件名参数。注意,我们并不需要upper程序的源代码。我们可以利用这种方法运行任何可执行程序:
useupper程序用freopen函数先关闭标准输入,然后将文件流stdin与程序参数给定的文件名关联起来。接下来,它调用execl用upper程序替换掉正在运行的进程代码。因为已打开的文件描述符会在execl调用之后保留下来,所以upper程序的运行情况和它在shell提示符下的运行情况完全一样:
11.3.4 线程
Linux系统中的进程可以互相协作、互相发送消息、互相中断,甚至可以共享内存段。但从本质上来说,它们是操作系统内各自独立的实体,要想在它们之间共享变量并不是很容易。
在许多UNIX和Linux系统中都有一类进程叫做线程(thread)。涉及线程的编程是比较困难的,但它在某些应用软件(如多线程数据库服务器)中又有很大的用处。在Linux(或UNIX)系统中编写线程程序并不像编写多进程程序那么常见,因为Linux中的进程都是非常轻量级的,而且编写多个互相协作的进程比编写线程要容易得多。我们将在第12章介绍线程。