2-6-1-1 QNX编程入门之进程和线程(三)
阅读前言
本文以QNX系统官方的文档英文原版资料“Getting Started with QNX Neutrino: A Guide for Realtime Programmers”为参考,翻译和逐句校对后,对在QNX操作系统下进行应用程序开发及进行资源管理器编写开发等方面,进行了深度整理,旨在帮助想要了解QNX的读者及开发者可以快速阅读,而不必查看晦涩难懂的英文原文,这些文章将会作为一个或多个系列进行发布,从遵从原文的翻译,到针对某些重要概念的穿插引入,以及再到各个重要专题的梳理,大致分为这三个层次部分,分不同的文章进行发布,依据这样的原则进行组织,读者可以更好的查找和理解。
1. 进程和线程
2-6-1-1 QNX编程入门之进程和线程(一)
2-6-1-1 QNX编程入门之进程和线程(二)
接前面章节内容继续。
1.3. 线程和进程
让我们回到线程和进程的讨论中来,这次我们将从真实系统的角度来讨论。然后,我们来看看都有哪些函数调用被用于处理线程和进程。
我们知道,一个进程可以有一个或多个线程。(如果一个进程没有线程, ,那么它就不能做任何事,可以说,它就没有人在家,无法执行任何有用的工作。)QNX Neutrino 系统可以有一个或多个进程。 (关于零个进程的讨论,同样也适用于QNX中微子系统。)
那么,这些进程和线程是做什么的呢?归根结底,它们组成了一个系统(一个执行某些目标的线程和进程的集合)。
在最高级别上,系统由多个进程组成。每个进程负责提供某种性质的服务(无论是文件系统、显示驱动程序、数据采集模块、 控制模块还是其他什么)。
每个进程内可能有多个线程,线程的数量各不相同。某个设计者只使用一个线程,而另一个设计者可能使用五个线程,但是他们可能完成相同的功能。有些问题适合用多线程来解决,而且解决起来相对简单; 而有些进程适合用单线程来解决,而且很难实现多线程化。
关于“线程如何设计”的话题,很容易就能占据另一本书的篇幅,我们在这里只谈最基本的内容。
1.3.1. 为什么需要进程
那么,为什么不只让一个进程拥有无数个线程呢?
虽然有些操作系统强迫你以这种方式编码,但将“事情”分解成多个线程的好处有很多:
- 解耦和模块化
- 可维护性
- 可靠性
将问题“分解”为多个独立问题的能力是一个强大的概念。这也是 QNX Neutrino 的核心所在。 QNX Neutrino 系统由许多独立模块组成,每个模块都承担一定的责任。这些独立模块是不同的进程。 QNX 软件系统公司的员工就是利用这一点独立地开发模块,使模块之间互不依赖。模块之间的唯一“依赖”就是少量的定义明确的接口。
由于仅有很少的相互依赖关系,这自然会提高可维护性。由于每个模块都有自己特定的定义, 因此修复某个模块会相当容易,尤其是它与其他模块没有任何关联。
但是,可靠性或许是最重要的一点。进程就像房子一样,有一些明确界定的“边界”。房子里的人很清楚自己什么时候在房子里,什么时候不在。房子的比喻中,对于线程来说,有一个非常棒的概念,那就是如果它访问的是进程内的内存,那么它就可以运行(live)。如果它超出了进程地址空间的范围,那么它就会被杀死(killed)。这意味着在不同进程中运行的两个线程实际上是相互隔离的。
进程地址空间由 QNX Neutrino 进程管理器模块维护和强制执行。进程启动时,进程管理器会为其分配一些内存,并启动线程运行。 内存被标记为该进程所有。
这意味着,如果该进程中有多个线程,内核需要在它们之间进行上下文切换 ,这是一个非常高效的操作,我们不需要改变地址空间,只需要改变运行的线程。但是,如果我们需要切换到处于另一个进程中的线程时,那么进程管理器就会参与进来,并且会导致地址空间的切换。别担心,虽然这种额外步骤会增加一些开销,但是在 QNX Neutrino 中速度仍然很快。
1.3.2. 启动一个进程
现在让我们来看看用来处理线程和进程的函数调用。任何线程都可以启动进程;唯一的限制来自于基本的安全性方面(文件访问、权限限制等)。几乎可以肯定,你已经启动过其他进程,比如,使用系统启动脚本、shell 工具或由某个程序代表你启动另一个程序等。
1.3.2.1. 从命令行启动进程
例如,您可以在 shell 中键入:
$ program1
这将指示 shell 启动名为 program1 的程序,并等待其完成。
或者,你也可以键入:
$ program2 &
这表示 shell 启动名为 program2 的程序,并且shell不用等待其完成而继续其他事情。我们会说“程序 program2 正在后台运行”。
如果想在启动程序前调整其优先级,可以使用 nice 命令,就像在 UNIX 中一样:
$ nice program3
这将指示 shell 以较低的优先级启动 program3。
是这样的吗?
实际上,我们告诉 shell 以常规优先级运行名为 nice 的程序。nice 命令程序会将自己的优先级调低(这就是“nice”名称的由来),然后再以较低的优先级去运行程序 program3。
1.3.2.2. 从程序内部启动进程
你通常不会关心 shell 创建进程这一事实,这是关于 shell 的一个基本假设。在某些应用程序设计中,你肯定会依赖 shell 脚本(存储在文件中的批处理命令)来完成工作,但在其他某些情况下,你可能会希望自己创建进程。例如,在大型多进程系统中,你可能会希望让一个主程序根据某种配置文件去启动应用程序的所有其他进程。另一个例子就是在检测到某些运行条件(事件)时启动进程。
让我们来看看 QNX Neutrino 为启动其他进程(或 转换为其他程序)提供的函数:
- system()
- exec() 函数系列
- posix_spawn() 函数系列
- spawn() 函数系列
- fork()
使用哪种函数取决于两个要求:可移植性和功能。和往常一样,这两者之间需要权衡。
在所有创建新进程的调用中,常见的情况如下。原进程中的线程调用上述某一个函数。最终,该函数会让进程管理器为新进程创建一个地址空间。然后,内核会在新进程中启动一个线程。这个线程会执行一些指令,然后调用 main() 函数 。(当然,在使用的是 fork() 函数的情况下,新线程从 fork() 函数返回后就开始在新进程中执行了;我们很快就会看到如何处理这个问题)。
1.3.2.2.1. 使用system()函数调用启动进程
system() 函数是最简单的;它接收“命令行”字符串作为参数,就像在 shell 提示符下输入内容一样,然后执行它。
实际上, system() 会启动一个 shell 来处理你要执行的命令。
我用来写这本书的编辑器使用了 system() 函数调用。当我在编辑时,我可能需要“shell out”,检查一些示例,然后回到编辑器,所有这些都不会失去我的位置。例如,在这个编辑器中我可以发出:!pwd
命令,从而显示当前工作目录。在编辑器中运行如下代码将会发出:!pwd
命令:
system ("pwd");
system() 函数的功能可以理解为:在程序中执行一些 shell 界面下的一些命令;比如我希望在C程序中关闭网卡,则可以使用下面程序实现:
system ("ifconfig eth0 down");
system()
是否适用于天下的所有事物?当然不是,但它对于你的许多进程创建需求来说,都非常有用。
1.3.2.2.2. 使用exec()和spawn()函数调用启动进程
让我们来看一些其他的进程创建函数。
我们接下来应该了解的进程创建函数是 exec()
系列函数和 spawn()
系列函数。在深入细节之前,先来看看这两组函数之间的区别。
exec()
系列函数会将当前进程转变为另一个进程。我的意思是,当一个进程发起 exec()
函数调用时,该进程就不再运行当前程序,而是开始运行另一个程序。进程 ID 不会改变,而是这个进程变成了另一个程序。那这个进程中的所有线程会怎样呢?等我们讲到 fork()
函数时再回过头来看这个问题。
而 spawn()
系列函数则不是这样的。调用 spawn()
系列函数中的某个函数会创建另一个进程(带有新的进程 ID),这个新进程与函数参数中指定的程序相对应。
让我们来看看 spawn()
和 exec()
函数的不同变体。在下面的表格中,你会看到哪些是符合 POSIX 标准的,哪些不是。当然,为了实现最大程度的可移植性,你会希望只使用符合 POSIX 标准的函数。(spawn()
和 spawnp()
函数都曾出现在 POSIX 草案中,但最终没能进入标准。POSIX 版本的函数是 posix_spawn()
和 posix_spawnp()
。)
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 语言库中,spawnlp()
、spawnvp()
和spawnlpe()
这几个函数都会调用spawnvpe()
,而spawnvpe()
又会调用spawnp()
。spawnle()
、spawnv()
和spawnl()
这些函数最终都会调用spawnve()
,然后spawnve()
再调用spawn()
。最后,spawnp()
也会调用spawn()
。所以,所有生成进程功能的根源就是spawn()
函数调用。
现在让我们详细了解一下各种spawn()
和exec()
函数变体,这样你就能体会到所使用的各种后缀的作用了。然后,我们再来看看spawn()
函数本身。
“l” 后缀
例如,如果我想用参数 “-t
”、“-r
” 和 “-l
”(意思是“按时间倒序对输出进行排序,并显示输出的详细版本”)来调用ls
命令,我可以用以下两种方式指定:
/* 运行ls命令并继续执行后续代码 */
spawnl (P_WAIT, "/bin/ls", "/bin/ls", "-t", "-r", "-l", NULL);
/* 将当前进程转变为ls命令进程 */
execl ("/bin/ls", "/bin/ls", "-t", "-r", "-l", NULL);
或者,使用带 “v” 后缀的变体:
char *argv [] =
{
"/bin/ls",
"-t",
"-r",
"-l",
NULL
};
/* 运行ls命令并继续执行后续代码 */
spawnv (P_WAIT, "/bin/ls", argv);
/* 将当前进程转变为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
会告诉 shell 创建一个新的环境变量(在这里就是DICTIONARY
),并给它赋值(/home/rk/.dict
)。
如果你想使用不同的词典,就必须在运行程序之前修改环境变量。从 shell 中进行修改很容易:
$ export DICTIONARY=/home/rk/.altdict
$ spellcheck document.1
但是从你自己编写的程序中该如何操作呢?就要使用带“e”后缀的spawn()
和exec()
函数,你需要指定一个表示环境的字符串数组:
char *env [] = {
"DICTIONARY=/home/rk/.altdict",
NULL
};
// 启动拼写检查程序
spawnle (P_WAIT, "/usr/bin/spellcheck", "/usr/bin/spellcheck", "document.1", NULL, env);
// 将当前进程转变为拼写检查程序进程
execle ("/usr/bin/spellcheck", "/usr/bin/spellcheck", "document.1", NULL, env);
“p” 后缀
带“p”后缀的版本会在你的 PATH 环境变量所指定的目录中搜索可执行文件。你可能已经注意到,上述所有示例中可执行文件都使用了硬编码的位置,比如/bin/ls
和/usr/bin/spellcheck
。那对于其他可执行文件呢? 除非你想先找出特定程序的确切路径,否则最好让用户告诉你的程序可以去某些地方搜索可执行文件。标准的 PATH 环境变量就能做到这一点。下面是一个最简系统中的 PATH 环境变量示例:
PATH=/proc/boot:/bin
这告诉 shell,当我输入一个命令时,它应该先在/proc/boot
目录中查找,如果在那里找不到该命令,就应该在二进制文件目录/bin
中查找。PATH 是一个用冒号分隔的查找命令的位置列表。你可以根据需要向 PATH 中添加任意数量的元素,但要记住,会按顺序搜索所有路径名组件来查找可执行文件。
如果你不知道可执行文件的路径,那么就可以使用带 “p” 后缀的变体。例如:
// 使用明确的路径
execl ("/bin/ls", "/bin/ls", "-l", "-t", "-r", NULL);
// 在 PATH 中搜索可执行文件
execlp ("ls", "ls", "-l", "-t", "-r", NULL);
如果execl()
函数在/bin
目录中找不到ls
命令,它就会返回一个错误。而execlp()
函数会在 PATH 环境变量指定的所有目录中搜索ls
命令,只有在这些目录中都找不到ls
命令时,它才会返回一个错误。这对于多平台支持也很有好处,你的程序不需要编写代码去了解不同的 CPU 名称,它只需要找到可执行文件就行。
如果你这样做会怎样呢?
execlp ("/bin/ls", "ls", "-l", "-t", "-r", NULL);
它会搜索环境变量吗?不会的。你告诉了execlp()
使用明确的路径名,这就覆盖了正常的 PATH 搜索规则。如果它在/bin
目录中找不到ls
命令,那就不会再做其他尝试了(在这种情况下,和execl()
的工作方式是一样的)。
将明确的路径和普通命令名混合使用(例如,路径参数是/bin/ls
,而命令名参数是ls
,而不是/bin/ls
)是否危险呢?通常来说是相当安全的,因为:
- 大量程序根本不会理会
argv[0]
; - 那些会关注
argv[0]
的程序通常会调用basename()
函数,该函数会去掉argv[0]
中的目录部分,只返回名称部分。
指定第一个参数为完整路径名的一个很有说服力的原因是,程序可以显示包含这个第一个参数的诊断信息,这样就能立即知道程序是从哪里被调用的。当程序可以在 PATH 环境变量所指定的多个位置被找到时,这一点可能就很重要了。
对于spawn()
函数都有一个额外的参数;在上述所有示例中,我总是指定P_WAIT
。有四个标志可以传递给spawn()
函数来改变它的行为:
- P_WAIT
调用spawn()
函数的进程(也就是你的程序)会被阻塞,直到新创建的程序运行完毕并退出。
- P_NOWAIT
调用程序在新创建的程序运行时不会被阻塞。这使得你可以在后台启动一个程序,然后在该程序运行的同时继续执行你的程序。
- P_NOWAITO
与P_NOWAIT
相同,除了会设置SPAWN_NOZOMBIE
标志,这意味着你不用担心要通过waitpid()
函数来清除进程的退出代码。
- P_OVERLAY
这个标志会把spawn()
函数调用变成对应的exec()
函数调用!你的程序会转变为指定的程序,并且进程 ID 不会改变。
如果你的本意就是进行exec()
函数调用那样的操作,那么直接使用exec()
函数调用通常会更清晰明了,这样可以让软件的维护人员不必去查阅 C 语言库参考手册中关于P_OVERLAY
的内容了!
最朴素的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
这几个参数的含义了,因为我们在上面已经看到它们分别代表可执行文件的位置(path
成员)、参数向量(argv
)和运行环境(envp
)。
fd_count
和fd_map
这两个参数是相关联的。如果你将fd_count
指定为 0,那么fd_map
就会被忽略,这意味着所有文件描述符(除了那些通过fcntl()
函数的FD_CLOEXEC
标志修改过的文件描述符)都会被新创建的进程继承。如果fd_count
不为 0,那么它表示fd_map
中包含的文件描述符的数量;只有指定的那些文件描述符才会被继承。
inherit
参数是一个指向结构体的指针,该结构体包含一组标志、信号掩码等等内容。如需了解更多详细信息,你应该查阅 QNX Neutrino C 语言库参考手册。
1.3.2.2.3. 使用fork()函数调用启动进程
假如你想要创建一个与当前进程完全相同的新进程,并让它并发运行。你可以使用spawn()
(并且要使用P_NOWAIT
参数)来实现这一点,要给新创建的进程提供足够的状态信息,以便它能够自行进行设置。然而,这可能会极其复杂;描述当前进程的“当前状态”可能会涉及大量数据。
有一种更简便的方法,那就是fork()
函数,它可以复制当前进程。所有代码都是一样的,而且数据也与创建者进程(或者说是父进程)的数据相同。
当然,要创建一个在各方面都与父进程完全相同的进程是不可能的。为什么呢?这两个进程之间最明显的区别便是进程 ID,我们无法创建两个具有相同进程 ID 的进程。如果你查看《QNX Neutrino C 语言库参考手册》中fork()
函数项的文档,你就会看到所列出的这两个进程之间存在的一系列差异。如果你打算使用fork()
,那么你就应该阅读这个列表,以确保知晓这些差异。
如果fork()
调用后的两边(即父进程和子进程)看起来很相似,那你如何区分它们呢?当你调用fork()
函数时,你会创建另一个在同一位置执行相同代码的进程,就如同父进程那样(也就是说,两者都即将从fork()
调用返回处继续执行)。让我们来看一些示例代码:
int main (int argc, char **argv)
{
int retval;
printf ("这绝对是父进程\n");
fflush (stdout);
retval = fork ();
printf ("是哪个进程打印了这个?\n");
return (EXIT_SUCCESS);
}
在fork()
被调用之后,两个进程都会执行第二个printf()
函数!如果你运行这个程序,它会打印出类似如下内容:
这绝对是父进程
是哪个进程打印了这个?
是哪个进程打印了这个?
两个进程都会打印第二行内容。
区分这两个进程的唯一方法就是变量retval
中接收到的fork()
函数的返回值。在新创建的子进程中,retval
的值为 0;而在父进程中,retval
的值是子进程的进程 ID。
是不是还不太明白?下面再来看另一段代码片段来解释一下:
printf ("父进程的进程 ID 是 %d\n", getpid ());
fflush (stdout);
if (child_pid = fork ()) {
printf ("这是父进程,子进程的进程 ID 是 %d\n", child_pid);
} else {
printf ("这是子进程,进程 ID 是 %d\n", getpid ());
}
这个程序将会打印出类似如下内容:
父进程的进程 ID 是 4496
这是父进程,子进程的进程 ID 是 8197
这是子进程,进程 ID 是 8197
在调用fork()
函数之后,你可以通过查看该函数的返回值来判断你代码所在的是哪个进程(父进程还是子进程)。
1.3.2.2.4. 在多线程进程中使用 fork() 函数
在多线程进程中使用fork()
可能会很棘手,尤其是当一个线程调用fork()
而其他线程正处于代码的临界区时。
POSIX 规定,在执行fork()
之后,子进程的内存状态是父进程的一个副本,但子进程只包含单个线程;父进程中的任何其他线程不会在子进程中被复制。如果在执行fork()
时,父进程中的某个线程正在操作某个数据结构,那么在fork()
执行后,该数据结构在子进程中将会处于一种未定义的、可能不一致的状态。常规的互斥锁(mutexes)可用于保护不同线程彼此互不干扰,但要防范fork()
带来的问题,则需要执行了fork()
的分叉代码,对你数据结构同步的内部机制有足够了解。
例如,假设父进程有两个线程,T1 和 T2,并且 T2 已经锁住了一个互斥锁。如果 T1 调用 fork()
,子进程是父进程的一个副本,包括任何互斥锁,但只有一个线程。如果子进程中的这个线程试图锁住该互斥锁,操作将会失败,因为在父进程中是 T2 锁住了它,而子进程中并没有 T2,所以没人能解锁这个互斥锁。这样就可能会导致死锁。
最简单的解决方案是只在单线程进程中调用 fork()
,或者在创建其他线程之前调用它。但是,如果你坚持要在多线程进程中使用 fork()
,你可以采取以下的一些做法:
- 使用
at-fork
handlers(处理者) - 使用
forksafe
互斥锁 - 在执行
fork()
之后尽快调用exec*()
函数。POSIX 要求子进程在调用exec*()
系列函数中某个函数之前,只能使用异步信号安全(async-signal-safe)的函数。
你可以使用 pthread_atfork()
来注册以下 handlers(处理者):
prepare
:在执行fork()
之前被调用parent
:在父进程中fork()
之后被调用child
:在子进程中fork()
之后被调用
如果你想注册更多的 handlers(处理者),可以多次调用 pthread_atfork()
;父进程和子进程 handlers(处理者)会按照你注册它们的顺序被调用,而 prepare
处理者则按照相反的顺序被调用。
在子进程 handlers 中,你只能调用异步信号安全(async-signal-safe)的函数(也就是那些可以在 signal handler(信号处理者)中进行安全调用的函数)。
在 prepare
handler 中,你可以锁住所有的互斥锁,然后在父进程和子进程处理器中解锁它们。这能确保在执行fork()
时没有其他线程持有互斥锁。然而,如果你在不同的、只是松散耦合的代码中有许多不同的互斥锁,要正确安排锁的顺序以避免死锁可能会很棘手。
在 QNX Neutrino 6.6 及更高版本中,我们实现了一种 forksafe
的互斥锁,作为解决这个问题的另一种方式。forksafe_mutex_t
是 pthread_mutex
的替代品,但它与 fork()
调用是互斥的。fork()
函数能知晓这种类型的互斥锁,并且当任何 forksafe
互斥锁被持有时,fork()
函数无法执行:当任何 forksafe
互斥锁被持有时,fork()
会阻塞,并且当 fork()
调用正在进行时,任何试图锁住 forksafe
互斥锁的操作都会阻塞,直到 fork()
完成。
你可以使用以下函数来操作 forksafe
互斥锁:
forksafe_mutex_destroy()
forksafe_mutex_init()
forksafe_mutex_lock()
forksafe_mutex_trylock()
forksafe_mutex_unlock()
它们取代了相应的 pthread_mutex_*()
函数;如需更多信息,请参阅《C 语言库参考手册》。
请注意以下几点:
- 在 QNX Neutrino 7.1 或更高版本中,
forksafe_mutex_*()
函数有两个版本:
-
- 在
libc
中的函数被声明为弱符号(weak symbols),并且使用常规的互斥锁,只是简单地调用相应的pthread_mutex_*()
函数。 - 在
libforksafe_mutex
中的函数使用上述的forksafe
互斥锁。
- 在
为了使用 forksafe
互斥锁,你需要将你的程序与 libforksafe_mutex
进行链接。
- 使用
forksafe
互斥锁会有性能开销。 forksafe
互斥锁相对于fork()
调用具有优先级。这意味着如果一个线程持有forksafe
互斥锁,第二个线程因试图执行fork()
而被阻塞,然后第三个线程试图锁住forksafe
互斥锁,第三个线程会成功 —— 它不会等到fork()
调用有机会执行。这意味着如果你有大量不同的线程获取和释放forksafe
互斥锁,那么调用fork()
的尝试可能会被无限期阻塞;在所有forksafe
互斥锁都被释放之前,不会允许fork()
继续执行。- 虽然
forksafe
互斥锁支持互斥锁使用者之间的优先级继承,但在互斥锁使用者和fork()
调用者之间不存在优先级继承。一个试图调用fork()
的高优先级线程可能会被持有forksafe
互斥锁的低优先级线程阻塞。 - 当你调用
fork()
时,绝对不能持有forksafe_mutex_t
互斥锁,否则确定会出现死锁。对于由at-fork
处理者进行保护的pthread_mutex_t
互斥锁,也是如此。
虽然at-fork
处理者和 forksafe
互斥锁会有所帮助,但是要在子进程中正确设置互斥锁仍然会可能很棘手,所以我们的建议是不要在多线程进程中使用 fork()
,或者改用 posix_spawn()
函数。
1.3.2.2.5. 使用 posix_spawn() 函数调用启动进程
正如我们之前提到的,spawn()
和 spawnp()
曾出现在 POSIX 草案中,但最终并未被纳入标准。取而代之的是,POSIX 增加了 posix_spawn()
和 posix_spawnp()
这两个函数。
函数 posix_spawn()
和 posix_spawnp()
旨在让你能够指定子进程应该如何运行以及运行什么内容。它们还被设计成易于扩展的形式;它们的参数包含一个指向 posix_spawnattr_t
结构体的指针,该结构体用于指定 spawn 属性,以及一个指向 posix_spawn_file_actions_t
结构体的指针,此结构体用于指定文件操作(例如,在子进程中要关闭或复制哪些文件描述符)。这两个都是不透明结构体,所以有相应的函数可供你用来初始化、获取以及设置它们内部的值。
对于属性方面(其中一些属性是 POSIX 标准所规定的,还有一些是 QNX Neutrino 扩展的属性),首先要做的是通过调用 posix_spawnattr_init()
函数将属性结构体的成员设置为它们的默认值。要设置某个属性,需调用相应的 posix_spawnattr_*()
函数并设置对应的标志。要设置 POSIX 标准的标志,可以调用 posix_spawnattr_setflags()
函数;要设置 QNX Neutrino 扩展的标志,则可以调用 posix_spawnattr_setxflags()
函数。例如:
- 要设置子进程的进程组(这是一个 POSIX 属性),在调用
posix_spawnattr_setflags()
函数时要包含POSIX_SPAWN_SETPGROUP
标志,然后调用posix_spawnattr_setpgroup()
函数来设置进程组的值。 - 要设置子进程的进程运行掩码(这是一个 QNX Neutrino 属性,用于控制子进程可以在哪些处理器上运行),在调用
posix_spawnattr_setxflags()
函数时要包含POSIX_SPAWN_EXPLICIT_CPU
标志,接着调用posix_spawnattr_setrunmask()
函数来设置运行掩码的值。
设置文件操作则更为简单,因为不需要设置标志;你只需调用 posix_spawn_file_actions_init()
函数,然后调用相应的 posix_spawn_file_actions_*()
函数即可。
当你准备好创建新进程时,就可以调用 posix_spawn()
或 posix_spawnp()
函数。这两个函数之间的区别如下:
- 对于
posix_spawn()
函数,你需要指定可执行文件的绝对路径; - 对于
posix_spawnp()
函数,你需要指定可执行文件的名称,并且该函数会在你的PATH
环境变量所列出的目录中查找这个可执行文件。
如需更多信息,请查阅《C 语言库参考手册》中关于这些函数的条目。
1.3.2.2.6. 所以你应该用哪种方式?
显然,如果你正在移植现有的代码,那么你会希望使用原代码所使用的函数。对于新编写的代码,只要有可能,就应该尽量避免使用 fork()
函数。
原因如下:
- 尽管
fork()
函数可以在多线程环境下工作,但你需要注册一个pthread_atfork()
handler(处理者),并且在执行fork()
操作前锁住每一个互斥锁,这会使设计变得复杂。 fork()
函数创建的子进程会复制所有打开的文件描述符。正如我们稍后在 “资源管理器” 章节中将会看到的那样,这会带来大量的工作,而且如果子进程随后马上执行exec()
函数并关闭所有文件描述符的话,那么其中大部分工作其实都是不必要的。
就可移植性而言,你的最佳选择是 posix_spawn()
函数。
未完待续,请继续关注本专栏内容……