进程的启动
任何线程都能够启动一个进程;唯一的限制就是安全原因的因素,比如文件访问、权限限制等等。实际上,你可以通过系统启动脚本、命令行以及程序调用的方式来启动一个进程。
从命令行启动一个进程
在命令行键入:
$ program1
就会启动一个叫做program1的程序并等待其运行结束。你也可以键入:
$ program2 &
来启动一个叫做program2的程序而不需要等待其运行结束。我们可以称为这个程序在后台运行。
如果你想要在启动一个程序之前先调整这个程序的优先级,你可以使用nice命令,就像在UNIX系统中一样:
$ nice program3
这个指令将在降低的优先级启动这个程序program3。
在程序中启动一个进程
你可能有时会忽略命令行能创建进程,这也是命令行的基本功能。在一些应用程序设计中,可能会用到脚本程序(文件中有批处理指令)来完成某些工作,不过在其他情况下,你需要在程序中自己创建进程。
例如,在一个大型的多进程系统中,你需要一个主进程按照某种配置来启动其他的进程。另外的例子就是当检测到某些操作条件或事件时启动某个进程。
系统提供的能够启动其他进程的函数包括了:
- system()
- exec()
- spawn()
- fork()
- vfork()
你要使用哪个函数取决于两个因素:可移植性与功能性。一般来说,要在这两个因素之间寻找最佳的平衡。
所有的这些启动新进程的函数调用一般情况如下:在初始进程中的一个线程调用了上面所有函数其中的一个。之后,这个函数另进程管理器为新的进程创建一个地址空间。之后,系统内核在这个新的进程里面启动一个线程。这个线程执行几条指令,之后调用main()函数。(在fork()与vfork()这两个函数中,新线程在从fork()与vfork()返回后才开始在新进程里面运行)
使用system()开始新进程
函数system()是最简单的,它接收一个命令行指令(就像我们在命令行输入的一样的指令),之后开始执行组各国指令。
实际上,这个函数也确实启动了一个命令行来处理这条你想要执行的指令。
使用exec()与spawn()开始新进程
现在看看其他的进程创建函数。下面的就是exec()与spawn()这两个系列的进程创建函数。先看看这两个系列函数之间的差别。
exec()系列的函数将当前进程转变为另外的进程。也就是说当一个进程调用exec()函数之后,这个进程就会终止当前程序的运行并开始运行其他的程序。而进程的ID还没有改变,不过进程的程序已经变成另外一个了。那么进程中的线程都怎么样了?这个在下面再做讲解。
spawn()系列的函数就不会这样做。它会调用spawn()系列函数中的一个函数创建另外一个有新的ID的进程,在这个进程里面运行函数参数所指定的程序。
你可以查看下表中的spawn()与exec()两个函数的不同变体。在下表中你可以看出哪个函数是POSIX标准的,以及哪个函数不是POSIX标准的。为了最大的可移植性,最好是使用POSIX函数。
| Spawn | POSIX? | Exec | POSIX? |
|---|---|---|---|
| spawn() | No | ||
| spawnl() | No | execl() | Yes |
| spawnle() | No | execle() | Yes |
| spawnlp() | No | execlp() | Yes |
| spawnlpe() | No | execlpe() | No |
| spawnp() | No | ||
| spawnv() | No | execv() | Yes |
| spawnve() | No | execve() | Yes |
| spawnvp() | No | execvp() | Yes |
| spawnvpe() | No | execvpe() | No |
这么多变体可能会比较混乱,实际上它们的后缀是有固定模式的,如下表所示:
| 后缀 | 含义 |
|---|---|
| l (“L”的小写) | 函数的参数表通过调用设定的参数表而定,并使用NULL作为终止。 |
| e | 环境变量被设定。 |
| p | PATH环境变量在程序的完整路径未确定时将被使用。 |
| v | 函数参数通过指向参数向量的指针所确定。 |
参数表就是传递给程序的一个命令行参数的列表。
另外,在C库中,spwanlp()、spawnvp()、spawnlpe()全都调用spawnvpe()函数,而这个函数还会接着调用spawnp()函数。spawnle()、spawnv()、spawnl()函数最终都会调用spawnve()函数,而这个函数还会接着调用spawn()函数。最后,spawnp()也会调用spawn()函数。也就是说这个spawning功能都是通过spawn()实现的。
下面看几个例子:
“l”后缀
如果我们想要启动ls命令并使用-t、-r以及-l参数(分别表示用时间排列、反序排列以及使用长文件名显示),我们就可以这样使用:
/* To run ls and keep going: */
spawnl (P_WAIT, "/bin/ls", "/bin/ls", "-t", "-r", "-l", NULL);
/* To transform into ls: */
execl ("/bin/ls", "/bin/ls", "-t", "-r", "-l", NULL);
或者是使用v后缀变量:
char *argv [] =
{
"/bin/ls",
"-t",
"-r",
"-l",
NULL
};
/* To run ls and keep going: */
spawnv (P_WAIT, "/bin/ls", argv);
/* To transform into ls: */
execv ("/bin/ls", argv);
之所以这样使用就是为了方便。你可能在你的程序中内置了解释器,这样传递字符串数组就很方便了。这时就推荐使用“v”后缀的变体。如果,你对你的编程中所使用的程序的参数非常熟悉,这时就没有必要使用字符串数组了,你可以使用那个后缀为 l 的变体函数了。
你可以看到程序中我们传送的是程序的实际路径(/bin/ls)以及在第一个参数中再次写出程序的名称。以让程序能够按照调用时所设定的条件执行。
例如,GNU的压缩以及解压缩功能(gzip和gunzip)实际上连接到了同一个执行文件。当这个执行文件启动时,它会先看参数argv[0](传递给main()函数)并确定是压缩还是解压缩。
“e”后缀
e后缀会将环境变量传递给程序。所谓环境就是指程序操作时所给的外部环境。举例来说,比如你有一个拼写检查程序,它有一个单词字典。在每次使用命令行调用这个程序的时候就没有必要每次都设定字典的路径,你可以将这个路径设置到环境变量中,如下面的代码所示:
$ export DICTIONARY=/home/rk/.dict $ spellcheck document.1
这里的export命令告诉命令行创建一个新的环境变量(在这里就是DICTIONARY),并将这个环境变量的值指定为/home/rk/.dict。
如果你要使用不同的字典,就需要在运行这个程序之前修改这个环境变量。在命令行中很简单:
$ export DICTIONARY=/home/rk/.altdict $ spellcheck document.1
不过如果在你的程序中该如何完成呢?就可以使用“e”版本的spawn()与exec()函数了,你可以设定一个代表环境变量的字符串数组:
char *env [] =
{
"DICTIONARY=/home/rk/.altdict",
NULL
};
// To start the spell-checker:
spawnle (P_WAIT, "/usr/bin/spellcheck", "/usr/bin/spellcheck",
"document.1", NULL, env);
// To transform into the spell-checker:
execle ("/usr/bin/spellcheck", "/usr/bin/spellcheck",
"document.1", NULL, env);
“p”后缀
p后缀版本的函数将在你的PATH环境变量里面所指定的目录中寻找可执行文件。你可能注意到了前面的例子中所有的可执行文件都有一个给定的地址——/bin/ls以及/usr/bin/spellcheck。那么其他的可执行文件呢?除非你先找到了那个特定程序的准确地址,那么最好是让用户来告诉你的程序到哪里去寻找可执行文件,标准的PATH环境变量就是做这个用的。下面就是一个极小系统中的一个例子:
PATH=/proc/boot:/bin
这就告诉了命令行,当我键入一个指令的时候,它应该现在/proc/boot目录中查找,如果在那里找不到的话,再到/bin目录下查找。PATH是使用冒号来分开的目录列表。你可以为PATH添加任意多的元素,不过你需要留意的就是所有的路径都会被按照先后顺序搜索可执行文件。
如果你不知道可执行文件的路径,就可以使用p的函数变体。举例如下:
// Using an explicit path:
execl ("/bin/ls", "/bin/ls", "-l", "-t", "-r", NULL);
// Search your PATH for the executable:
execlp ("ls", "ls", "-l", "-t", "-r", NULL);
如果execl()在/bin目录中找不到ls,它就会返回一个错误。exexlp()函数将会在PATH中所给的全部路径中寻找ls,只有在所有的路径中都找不到ls才会返回错误。这对于多平台的支持是有利的,你的程序没有必要在编程是就知道不同的CPU名称,它只需要找到可执行文件就行。
那么下面的这条语句会做什么呢?
execlp ("/bin/ls", "ls", "-l", "-t", "-r", NULL);
它会搜索环境变量里的路径么?不会的。这里是让execlp()使用一个明确的路径,并让其忽略了普通的PATH搜索规则。如果它在/bin目录中没有找到ls,那么它就不会做进一步的努力了。
将明确的路径与命令名混在是不是危险的?(就像这样,路径参数为/bin/ls,命令名参数为ls,而不是/bin/ls)实际上这一般是很安全的,因为:
- 很多程序忽略第一个命令行参数argv[0];
- 那些不忽略的一般都调用basename()函数,这个函数就会将argv[0]中的目录部分去掉,只返回名字。
为第一个命令行参数指定明确路径的唯一的原因就是让程序能够输出诊断信息,并在诊断信息里面有第一个参数,这个参数可以清晰的告诉你程序是从哪里启动的。在程序可以在PATH中的多个路径都可以找到的时候是很有帮助的。
spawn()都有一个额外的参数,在上面所有的例子中,我们都一直指定P_WAIT。一共有四种标志可以用来传递给spawn()来改变其行为。
P_WAIT
调用进程(也就是你的程序)将闭塞,知道这个新建的程序运行结束并退出。
P_NOWAIT
调用程序在新建程序运行时将不闭塞。你可以这样在后台启动一个程序并在这个程序做自己的事的时候继续运行。
P_NOWAITO
与P_NOWAIT功能相同,不同的地方是SPWAN_NOZOMBIE标志被设定,也就是说你不用再使用waitpid()函数来清除那个进程的退出码了。
P_OVERLAY
这个标志将spawn()函数转换为exec()函数。你的程序转换为那个指定的程序并且进程ID不变。如果你确实想这样做的话,最好还是使用exec()函数,这样就更清晰些。
“单纯”spawn()
就像我们上面说提的,所有的spawn()函数最终都会调用spawn()函数。下面就是spawn()函数的原型:
#include <spawn.h>
pid_t
spawn (const char *path,
int fd_count,
const int fd_map [],
const struct inheritance *inherit,
char * const argv [],
char * const envp []);
现在我们可以直接省略path、argv以及envp参数了,这几个参数在前面的例子中已经详细讲解过。
fd_count和fd_map两个参数是互相关联的。如果你的指定fd_count为0,那么fd_map就被忽略,这就意味着所有的文件描述符(除了被fcntl()所修改的)将被继承到新的进程。如果fd_count不是0,那它就是指定了在fd_map所包含的文件描述符的数量;这些指定的文件将被继承。
inherit参数是一个指向结构的指针,在这个结构中包含了一组标志位、信号掩码等等。
使用fork()开始新进程
如果你想要创建一个新的进程,这个进程与当前执行的进程一模一样并让其与当前进程同时运行。你可以使用spawn()函数缤纷使用p_NOWAIT参数,提供给这个新的进程足够的信息,包括你的进程的确切状态。以让其能够设置自己。不过,这个方法可能是非常复杂的,因为描述当前进程的状态牵涉到大量的数据。
其实有个更简单的方式,就是fork()函数,这个函数可以复制当前进程。所有的代码将是一样的,而且数据与创建进程或父进程也是一样的。
当然了,创建一个与父进程一模一样的进程是不可能的。这两个进程之间最明显的不同就是它们的进程ID,两个进程的ID是不能相同的。如果在手册中仔细查看fork()的文档就会发现这两个进程之间还有很多的不同。在你使用这个函数之前最好要搞清楚这些差别。
如果fork()函数两方面的进程非常像,该如何分辨它们呢?当你调用fork()函数的时候,就在与父进程相同的地方创建了一个运行相同代码的新进程。例子代码如下:
int main (int argc, char **argv)
{
int retval;
printf ("This is most definitely the parent process\n");
fflush (stdout);
retval = fork ();
printf ("Which process printed this?\n");
return (EXIT_SUCCESS);
}
在fork()调用之后,两个进程都会执行第二条的printf()函数。如果你运行这个程序,它打印出来的结果就像下面这样:
This is most definitely the parent process Which process printed this? Which process printed this?
两个进程打印了第二行。
区分两个进程的唯一方法就是fork()函数的返回值retval。在新建的子进程中,retval为0;在父进程中,这个变量的值是子进程的ID。
下面是另一段用来搞清概念的代码:
printf ("The parent is pid %d\n", getpid ());
fflush (stdout);
if (child_pid = fork ()) {
printf ("This is the parent, child pid is %d\n",
child_pid);
} else {
printf ("This is the child, pid is %d\n",
getpid ());
}
这个程序的可能输出如下:
The parent is pid 4496 This is the parent, child pid is 8197 This is the child, pid is 8197
通过fork()函数的返回值你可以区别两个进程了。
使用vfork()开始新进程
vfork()函数要比普通的fork()函数消耗更少的资源,因为它共享了父进程的地址空间。
vfork()函数创建一个子进程,之后就暂停父线程直到子进程调用exec()函数或退出。另外,vfork()函数将在物理内存模型系统上工作,而fork()函数是不能做的,fork()函数要创建一样的地址空间,而这不可能是物理内存模型的。
启动进程与线程
假设你有一个进程,并且在这个进程中尚未创建任何线程。当你调用fork()函数,另外的进程就被创建并且也只有一个线程。这是简单的例子。
现在假设在你的进程中,使用pthread_create()函数创建了一个新的线程。再调用fork()函数,就会返回ENOSYS(表示不支持该函数)参数。这是为什么?
不过,这确实是POSIX兼容的,因为POSIX说fork()函数可以返回ENOSYS。实际的情况是这样的:系统的C函数库中没有处理有多个线程的进程的分支功能。当调用pthread_create()函数,在各个函数会设置一个标志,这个标志表明“别处理fork()函数,我没准备处理它”。另外在函数库的fork()函数中,将检查这个标志,如果这个标志已经被设定了,就返回ENOSYS。
这是故意设定这样做的,而起因就应该是线程与互斥体。如果没有这个限制,新建的进程就与原始进程有同样多的线程。这可能是你所希望的。不过,由于原始线程可能拥有互斥体,这就变得复杂了。由于新建进程有与原始进程一样的数据空间内容,库函数就必须跟踪在原始进程中哪个互斥体被哪个线程所拥有,并将这个拥有关系复制到新进程里面。实际上这是不可能的,有个pthread_atfork()函数可以来处理这个,不过并不是所有的系统互斥体支持这个函数的。
我们该如何做?
当你移植已有代码的时候,你肯定是保留越多的原代码越好。对于新代码来说,要尽量避免使用fork()函数。原因如下:
- fork()不支持多线程;
- 由于fork()不支持多线程,你就需要注册一个pthread_arfork()句柄并在你使用fork之前锁定每一个互斥体,这会增加你的设计的复杂度;
- fork()函数创建的子进程会复制所有打开的文件的描述符。这将导致很多无用的操作。
vfork()函数与spawn()函数之间的选择归结到一点就是可移植性,以及你想要父进程与子进程做些什么。函数vfork()会暂停父进程直到子进程调用exec()或退出,而spawn()系列的函数可以让两者同时运行。而vfork()在不同的操作系统之间也有轻微的不同。
Related posts:
最近评论