.2 进程创建
6.2.1 Unix系统中进程创建
APR中通过apr_proc_create函数实现进程的创建,不过对于APR而言,创建进程并不仅仅是调用fork生成子进程就完毕了。整个创建可以用下面的伪码描述:
apr_proc_create
{
if (attr->errchk)
对attr做有效性检查,让错误尽量发生在parent process中,而不是留给child process; ----(1)
fork子进程;
{ /* 在子进程中 */
清理一些不必要的从父进程继承下来的描述符等,为exec提供一个“干净的”环境;------(2)
关闭attr->parent_in、parent_out和parent_err,
并分别重定向attr->child_in、child_out和child_err为STDIN_FILENO、
STDOUT_FILENO和STDERR_FILENO; -----(3)
判断attr->cmdtype,选择执行exec函数; ------(4)
}
/* 在父进程中 */
关闭attr->child_in、child_out和child_err;
}
下面我们将上面的部分展开详细描述。
{
int i;
new->in = attr->parent_in;
new->err = attr->parent_err;
new->out = attr->parent_out;
除了调用fork简单的生成子进程之外,创建进程的一个重要的任务就是创建父进程和子进程之间的管道、重定向并确保父进程和子进程之间能够通过管道通信。
if (attr->errchk) {
if (attr->currdir) {
if (access(attr->currdir, X_OK) == -1) {
return errno;
}
}
if (attr->cmdtype == APR_PROGRAM ||
attr->cmdtype == APR_PROGRAM_ENV ||
*progname == '/') {
if (access(progname, R_OK|X_OK) == -1) {
return errno;
}
}
else {
/* todo: search PATH for progname then try to access it */
}
}
是否需要对子进程进行安全性检查由父进程的errchk成员决定。通常情况下推荐进行检查,这样一旦子进程有问题的话,该错误将被扼杀在“襁褓”之中,而不错遗留到子进程中。可以通过函数apr_procattr_error_check_set设置该成员。检查包括:
1)、检查子进程是否具有对当前父进程路径的更改权限。因为在子进程中需要调用chdir函数,如果没有权限,自然不成功。
2)、如果子进程任务是普通的应用程序,并且使用的路径名称是绝对路径,那么必须确保它具有读取和修改权限,因此子进程中需要调用exec()函数,如果权限不具备,该调用将不成功。
错误预处理的目的只有一个,就是让错误发生在fork前,不要等到在子进程中出错。
if ((new->pid = fork()) < 0) {
return errno;
}
函数真正的调用fork产生子进程,此时程序将兵分两组执行。我们首先来看父进程中的工作:
6.2.1.1父进程中的处理
/* Parent process */
if (attr->child_in) {
apr_file_close(attr->child_in);
}
if (attr->child_out) {
apr_file_close(attr->child_out);
}
if (attr->child_err) {
apr_file_close(attr->child_err);
}
父进程在创建apr_procattr_t结构的时候创建了in、out和err三个管道共计六个描述符:parent_in、parent_out、parent_err、child_in、child_out和child_err,但是正如我们前面所言,对于父进程而言,与子进程通信仅仅需要parent_in、parent_out、parent_err三个,另外三个child_XXX则可以关闭。
6.2.1.2子进程中的处理
子进程中所作的工作与父进程类似:
/* Part 1 */
if (attr->child_in) {
apr_pool_cleanup_kill(apr_file_pool_get(attr->child_in),
attr->child_in, apr_unix_file_cleanup);
}
if (attr->child_out) {
apr_pool_cleanup_kill(apr_file_pool_get(attr->child_out),
attr->child_out, apr_unix_file_cleanup);
}
if (attr->child_err) {
apr_pool_cleanup_kill(apr_file_pool_get(attr->child_err),
attr->child_err, apr_unix_file_cleanup);
}
/*Part 2 */
apr_pool_cleanup_for_exec();
/*Part 3 */
if (attr->child_in) {
apr_file_close(attr->parent_in);
dup2(attr->child_in->filedes, STDIN_FILENO);
apr_file_close(attr->child_in);
}
if (attr->child_out) {
apr_file_close(attr->parent_out);
dup2(attr->child_out->filedes, STDOUT_FILENO);
apr_file_close(attr->child_out);
}
if (attr->child_err) {
apr_file_close(attr->parent_err);
dup2(attr->child_err->filedes, STDERR_FILENO);
apr_file_close(attr->child_err);
}
上面的代码可以分为三部分:
① 子进程清理
由于子进程中的大部分属性都是从父进程进程而来,这些属性中并不是全部有用,为此子进程首先清除从父进程中进程的与自己无关的垃圾信息,从而为exec提供一个干净的环境。清理工作由函数apr_pool_cleanup_for_exec实现。我们来看一下函数内到底对子进程进行了哪些清理:
APR_DECLARE(void) apr_pool_cleanup_for_exec(void)
{
cleanup_pool_for_exec(global_pool);
}
static void cleanup_pool_for_exec(apr_pool_t *p)
{
run_child_cleanups(&p->cleanups);
for (p = p->child; p; p = p->sibling)
cleanup_pool_for_exec(p);
}
从上面的代码中可以看出,清理的过程实际上是一个递归的过程。它从内存池根结点开始,逐一遍历内存池中的每一个结点,并调用结点内部对应的cleanup_t链表中的各个cleaup_t函数,对于管道而言,cleanup_t函数的注册是在使用apr_file_pipe_create函数的时候注册的:
apr_pool_cleanup_register((*in)->pool, (void *)(*in),
apr_unix_file_cleanup,apr_pool_cleanup_null);
apr_pool_cleanup_register((*out)->pool, (void *)(*out),
apr_unix_file_cleanup,apr_pool_cleanup_null);
因此,cleanup_pool_for_exec函数对于每一个内存池结点调用的实际上就是apr_unix_file_cleanup和apr_pool_cleanup_null函数。在apr_unix_file_cleanup中,对于普通文件描述符,如果该文件描述符进行了缓冲,那么首先要调用apr_file_flush进行缓冲刷新。由于管道是不使用缓冲的,因此缓冲的处理对管道不进行任何处理。事实上,对于管道描述符,清理操作所作的事情主要就是调用close关闭,如果文件的标志为APR_DELONCLOSE,意味着该文件在关闭后必须删除,那么同时调用unlink删除该文件。
/*
* If we do exec cleanup before the dup2() calls to set up pipes
* on 0-2, we accidentally close the pipes used by programs like
* mod_cgid.
*
* If we do exec cleanup after the dup2() calls, cleanup can accidentally
* close our pipes which replaced any files which previously had
* descriptors 0-2.
*
* The solution is to kill the cleanup for the pipes, then do
* exec cleanup, then do the dup2() calls.
*/
② 建立子进程与父进程的通信管道
父进程在创建apr_procattr_t时就建立了若干个管道,fork后子进程继承了这些管道,因此子进程内部同时也具备了in、out和err三个管道共计六个描述符:parent_in、parent_out、parent_err、child_in、child_out和child_err,但是子进程仅仅需要child_in、child_out、child_err三个,另外三个parent_XXX则可以关闭,如下图的(1),(2)所示。整个子进程中的描述符变化如下图所示:
子进程中的管道描述符变化
关于子进程,另外的问题就是子进程所拥有的描述符。通常的进程都拥有三个最基本的描述符:标准输入描述符,标准输出描述符以及标准错误描述符,分别对应stdin,stdout和stderr三个标准设备。除此之外,APR中创建的进程还拥有child_XXX和parent_XXX六个描述符,共计九个描述符。当所有的parent_XXX描述符关闭后,子进程中还拥有六个描述符。
子进程中标准输入,标准输出以及标准错误三个描述符的存在,意味着子进程能够从标准输入接受数据,并向标准输出设备和标准错误设备输出数据。APR中并不希望子进程具有这种能力,它希望子进程所有的交互都来自父进程。如果需要从输入设备接受数据,也是父进程进程接受,然后通过管道传递给子进程;同样,如果子进程需要输出数据到屏幕,也必须首先将数据通过管道传递给父进程,然后由父进程输出。这样带来的好处就是避免了子进程的中可能遇到的错误,而由父进程统一管理。比如最简单的,如果子进程需要接受命令行,那么每个子进程必须对命令行进行预处理,这样无疑使得子进程变得臃肿和复杂。为此APR中对子进程中的STDIN_FILENO,STDOUT_FILENO和STDERR_FILENO使用管道描述符进行了重定向:
dup2(attr->child_in->filedes, STDIN_FILENO);
apr_file_close(attr->child_in);
上面的代码将child_in重定向到STDIN_FILENO,这样,由于STDIN_FILENO被覆盖,子进程所有的数据只能来自父进程;与此类似,子进程所有的数据只能输出到父进程,而不能输出到其余的输出设备。这样,即使在子进程中调用scanf或者printf,实际上也并不来自stdin和stdout,而是来自父进程。重定向后的父子进程的描述符和管道的关系如下:
③ 启动程序前的准备工作
在执行应用程序之前,子进程进行一些启动相关的准备工作,包括:
1)、包括切换执行目录。子进程的工作目录必须与父进程相同。
2)、切换用户组Id和用户Id。Apache中子进程通常是实际的与客户进行通信的实体,为了防止可能潜在的黑客攻击,APR中希望子进程在正常运行的情况下,执行权限保持尽可能的低,这样即使黑客控制了子进程也对系统不会产生太大的影响。这种设置通常只有父进程使用root权限创建子进程的时候才需要设置。如果父进程本身的权限比较低,那么子进程继承的权限自然也很低,此时就不需要调整。
3)、设置进程极限值,包括CPU的极限,子进程使用内存的极限,启动的子进程的数目以及打开的文件描述符的数目。设置通过专门的limit_proc过程实现。函数内部无非调用的是setrlimit函数,比如:
setrlimit(RLIMIT_CPU, attr->limit_cpu);
setrlimit(RLIMIT_NPROC, attr->limit_nproc);
④ 启动应用程序
尽管子进程被fork后它就被处于活动状态,但是它到目前为止还没有获得实际的执行任务。Unix中通常通过exec系列函数来启动一个新的应用程序。
对于子进程最后的任务就是执行实际的任务。如果启动应用程序,由需要启动的程序类型即cmd_type决定。cmd_type的值以及含义如下表所示:
cmd_type类型 |
含义 |
是否需要指定程序路径 |
是否使用自定义环境变量 |
APR_PROGRAM |
启动的是普通的应用程序 |
是 |
是 |
APR_PROGRAM_ENV |
启动的是普通的应用程序 |
是 |
否 |
APR_PROGRAM_PATH |
启动的是普通的应用程序 |
否 |
是 |
APR_SHELLCMD |
启动的是Shell应用程序 |
否 |
是 |
APR_SHELLCMD_ENV |
启动的是Shell应用程序 |
否 |
否 |
对于每一种类型,函数处理如下:
1)、普通的应用程序(cmdtype=APR_PROGRAM)
if (attr->cmdtype == APR_PROGRAM) {
if (attr->detached) {
apr_proc_detach(APR_PROC_DETACH_DAEMONIZE);
}
execve(progname, (char * const *)args, (char * const *)env);
}
APR_PROGRAM类型对应的是普通的应用程序,但是它并不使用全局的环境变量environ,而是使用自定义的环境变量数组。因此函数必须调用execve。由于APR中不支持参数列表,为此execle不再考虑。另外如果子进程需要与父进程脱离开成后后台进程,那么还需调用apr_proc_detach进行脱离操作。
2)、普通应用程序(cmdtype=APR_PROGRAM_ENV)
if (attr->cmdtype == APR_PROGRAM_ENV) {
if (attr->detached) {
apr_proc_detach(APR_PROC_DETACH_DAEMONIZE);
}
execv(progname, (char * const *)args);
}
对于APR_PROGRAM_ENV,它与APR_PROGRAM的唯一的区别就是它使用默认的全局环境变量,因此不需要在函数参数中明确传递环境变量数组。这可以由execv函数实现。
3)、普通应用程序(cmdtype=APR_PROGRAM_PATH)
if(attr->cmdtype == APR_PROGRAM_PATH)
{
if (attr->detached) {
apr_proc_detach(APR_PROC_DETACH_DAEMONIZE);
}
execvp(progname, (char * const *)args);
}
如果程序的类型为APR_PROGRAM_PATH,那么意味着这是一个普通的应用程序,但是与APR_PROGRAM和APR_PROGRAM_ENV不同,它允许仅仅指定应用程序的名称,而不需要指明完整的绝对路径,具体的路径则由操作系统在PATH目录下查找。这种情况使用execvp正好合适。
4)、普通Shell程序
if (attr->cmdtype == APR_SHELLCMD ||
attr->cmdtype == APR_SHELLCMD_ENV) {
int onearg_len = 0;
const char *newargs[4];
newargs[0] = SHELL_PATH;
newargs[1] = "-c";
i = 0;
while (args[i]) {
onearg_len += strlen(args[i]);
onearg_len++; /* for space delimiter */
i++;
}
switch(i) {
case 0:
break;
case 1:
newargs[2] = args[0];
break;
default:
{
char *ch, *onearg;
ch = onearg = apr_palloc(pool, onearg_len);
i = 0;
while (args[i]) {
size_t len = strlen(args[i]);
memcpy(ch, args[i], len);
ch += len;
*ch = ' ';
++ch;
++i;
}
--ch; /* back up to trailing blank */
*ch = '\0';
newargs[2] = onearg;
}
}
newargs[3] = NULL;
if (attr->detached) {
apr_proc_detach(APR_PROC_DETACH_DAEMONIZE);
}
if (attr->cmdtype == APR_SHELLCMD) {
execve(SHELL_PATH, (char * const *) newargs, (char * const *)env);
}
else {
execv(SHELL_PATH, (char * const *)newargs);
}
}
对于Shell程序而言,它也是一个普通的应用程序,无非需要程序的名称,程序提供的参数等等,因此与前面的类似,可以通过execve和execv执行,具体调用哪一个,则取决于程序的类型。APR_SHELLCMD类型意味着应用程序是Shell,但是使用自定义的环境变量数组;而APR_SHELLCMD_ENV则意味着使用默认的environ数组。
不过与普通的应用程序不同的是,对于shell程序仅仅给定程序的名称和路径还不够,还必须给出shell执行程序的路径名称,对于Unix系统,通常执行shell通常是位于“/bin/sh”,而Window下则通常是“cmd.exe”。因此如果shell应用的用法是”run –n tingya”,则真正执行的时候应该变换为”/bin/sh run –n tingya”,所以对于shell应用而言不能像APR_PROGRAM直接将传入的args参数传递给exec函数,而需要进行额外的处理。这就是为什么要把SHELL单独辟出来成为两个类型的应用。
参数数组的转换非常的简单,只是在原有的数组的前面插入两个新的字符串“/bin/sh –c”,这样传入的所有的命令xxx都变为“/bin/sh –c xxx”,-c选项的用途是通知shell处理程序把-c后面的字符串作为一个参数处理