扫一扫
分享文章到微信
扫一扫
关注官方公众号
至顶头条
对于大负载的服务器而言,系统内部的并发处理以及进程之间的通信处理非常的重要。良好的设计可以使服务器的效率和稳定性加倍提升。Apache是服务器设计的一个典范,尤其是它的进程和线程处理,一直为人称道。本章我们详细分析APR中对进程和线程的封装。在下一章,我们将分析进程和线程间的通信。这两张综合起来构成了进程和线程的大部分内容。
APR中所有进程封装相关的源代码都保存在$(APR_HOME)/threadproc目录下,其相应头文件则为$(APR_HOME)/include/apr_thread_proc.h。
尽管都称之为进程,但是不同的操作系统对进程的实现却是不同的。由于APR支持五种大的操作系统平台,OS/2,BeOS,Unix,Window32以及NetWare,因此进程的封装也细分为五种。我们仅仅讨论Window和Unix平台上的进程和线程的封装细节。本章我们首先分别讨论Unix和Window上的进程,继而讨论Unix和Window上的线程。
尽管APR提供了五种进程的封装实现,但是Apache中统一使用数据结构apr_proc_t来描述某个进程:
typedef struct apr_proc_t {
pid_t pid;
apr_file_t *in;
apr_file_t *out;
apr_file_t *err;
#if defined(WIN32)
HANDLE hproc;
#endif
} apr_proc_t;
上述的进程数据结构非常容易理解,pid描述的是进程id号,对于Unix平台,该变量可以用getpid()函数的值进行填充,而对于Window平台,则用PROCESS_INFORMATION结构中的dwProcessId进行填充。in、out和err则是与该进程关联的输入,输出以及错误描述符,正常情况下这三个对应标准输入输出stdin、stdout和stderr。不过如果你愿意,你可以进行重定向。使用的最多的就是使用管道进行重定向。具体的细节部分,我们在后面的部分描述。
对于Window平台而言,描述一个进程,除了需要一个DWORD类型的全局进程ID号之外,还需要一个更重要的进程句柄。两者结合在一起才能完整地描述一个Window进程,因此对于Window平台而言,apr_proc_t中还需要额外的hproc成员描述进程句柄。
与进程相关联的另外一个最重要的数据结构应该就是进程属性描述数据结构apr_procattr_t,每一个进程都会关联一个apr_procattr_t结构,该结构是个大杂烩,凡是跟进程相关的属性都可以塞到该结构中。不过由于Unix和Window中的进程属性描述存在一定的差异,因此我们分开描述
Unix下的进程属性结构定义在srclib\apr\include\arch\unix\apr_arch_threadproc.h中,如下:
struct apr_procattr_t {
/*Part 1*/
apr_pool_t *pool;
/*Part 2*/
apr_file_t *parent_in;
apr_file_t *child_in;
apr_file_t *parent_out;
apr_file_t *child_out;
apr_file_t *parent_err;
apr_file_t *child_err;
/*Part 3*/
char *currdir;
apr_int32_t cmdtype;
apr_int32_t detached;
/*Part 4*/
struct rlimit *limit_cpu;
struct rlimit *limit_mem;
struct rlimit *limit_nproc;
struct rlimit *limit_nofile;
/*Part 5*/
apr_child_errfn_t *errfn;
apr_int32_t errchk;
/*Part 6*/
apr_uid_t uid;
apr_gid_t gid;
};
apr_procattr_t结构中描述的进程信息包括六个部分:
■ 第一部分
pool描述了apr_procattr_t结构的关联内存池,apr_procattr_t结构分配所需要的内存都来自pool。
■ 第二部分
该部分描述了父进程和子进程之间通信的管道文件。 六个描述符对应三个管道,分别用于父进程和子进程之间的通信:一个管道用于从父进程写入数据,从子进程中读出;一个管道用于从子进程写入数据,从父进程中读出;一个管道负责子进程写入错误信息通知父进程。父进程或者子进程通常只使用六个描述符中的三个,而其余三个则关闭;至于关闭哪三个则由进程的角色决定:对于父进程,通常关闭child_XXX,保留parent_XXX,而对于子进程则通常关闭parent_XXX,保留child_XXX。
它们之间的管道通信示意图如下描述:
为什么需要三对管道而不是一对,两对或者四对?这是跟前面的apr_proc_t结构中的描述符对应的,正如前面所说,每一个进程都维持三个描述符,正常情况下为stdin、stdout和stderr,如果需要进行父子进程通信,那么我们就可以很快地将上面的管道描述符重定向到对应进程的描述符中。这就是为什么要建立三对的原因。
尽管如此,上面三对管道与进程并没有必然的联系。进程是否创建并不影响上面三对管道的创建,不过通常情况下总是先创建上面的管道,然后创建进程,然后再进程重定向。apr_procattr_t结构中的六个描述符一旦确定,那么APR就可以使用apr_procattr_io_set函数创建三对管道:
APR_DECLARE(apr_status_t) apr_procattr_io_set(apr_procattr_t *attr,
apr_int32_t in,
apr_int32_t out,
apr_int32_t err)
attr是管道的进程的描述结构,创建后的三对管道通过其返回出去。in、out和err都是整数类型。in用以标示是否需要创建上图中的“in管道”,即从父进程到子进程的通信管道;out则标记是否需要创建“out管道”,即使从子进程到父进程的管道;err则标记是否需要创建“err管道”。这三个标记可能的取值包括下面五种:
1)、APR_NO_PIPE:该标记意味着不需要创建对应的管道,因此如果仅仅需要实现父进程到子进程的通信,那么out和err都可以设置为APR_NO_PIPE。如果标记为不是该值,则必须创建管道,至于管道的属性则由具体的标记决定。
2)、APR_FULL_BLOCK:如果是该标记,则意味着必须创建管道,并且管道的两端都是阻塞的。默认情况下,管道创建后就是阻塞的,因此这个标记处理最简单,不需要做额外的工作。
3)、APR_FULL_NOBLOCK:创建管道并同时将管道对应的两个描述符都设置为非阻塞。将管道设置为非阻塞我们在管道部分描述过,请参考apr_file_pipe_timeout_set函数。
4)、APR_PARENT_BLOCK:仅仅父进程端的管道描述符设置为阻塞,这意味着子进程的管道描述符必须设置为非阻塞。
5)、APR_CHILD_BLOCK:仅仅子进程的管道描述符设置为阻塞,而父进程则设置为非阻塞。
下面是对in管道部分的处理,out和err部分类似:
apr_status_t status;
if (in != 0) {
if ((status = apr_file_pipe_create(&attr->child_in, &attr->parent_in,
attr->pool)) != APR_SUCCESS) {
return status;
}
switch (in) {
case APR_FULL_BLOCK:
break;
case APR_PARENT_BLOCK:
apr_file_pipe_timeout_set(attr->child_in, 0);
break;
case APR_CHILD_BLOCK:
apr_file_pipe_timeout_set(attr->parent_in, 0);
break;
default:
apr_file_pipe_timeout_set(attr->child_in, 0);
apr_file_pipe_timeout_set(attr->parent_in, 0);
}
}
下面是一个典型的创建双向通信管道的示例代码:
apr_status_t rv;
apr_procattr_t *attr;
…
rv = apr_pool_create(&p, NULL);
rv = apr_procattr_create(&attr, p);
rv = apr_procattr_io_set(attr, APR_FULL_BLOCK, APR_FULL_BLOCK,APR_NO_PIPE);
除了可以使用apr_procattr_io_set函数进行批量的一次性创建管道之外,APR中还可以使用apr_procattr_child_XXX_set函数单独创建三个管道中的某一个,比如如果创建in管道,则函数名称为apr_procattr_child_in_set,其定义如下:
APR_DECLARE(apr_status_t) apr_procattr_child_in_set(apr_procattr_t *attr,
apr_file_t *child_in,
apr_file_t *parent_in)
{
apr_status_t rv = APR_SUCCESS;
if (attr->child_in == NULL && attr->parent_in == NULL)
rv = apr_file_pipe_create(&attr->child_in, &attr->parent_in, attr->pool);
if (child_in != NULL && rv == APR_SUCCESS)
rv = apr_file_dup2(attr->child_in, child_in, attr->pool);
if (parent_in != NULL && rv == APR_SUCCESS)
rv = apr_file_dup2(attr->parent_in, parent_in, attr->pool);
return rv;
}
该过程可以创建管道,但是它的更重要的责任并不仅仅限于此。它隐含的另外一个重要的功能就是实现管道的重定向。
■ 第三部分
这部分描述了进程的一些常规属性。
currdir标识新进程启动时的工作路径(执行路径),默认时为和父进程相同;
cmdtype标识新的子进程将执行什么类型的任务,APR中使用枚举类型apr_cmd_type_e总共定义了五种任务类型,默认为APR_PROGRAM:
typedef enum {
APR_SHELLCMD, /**< use the shell to invoke the program */
APR_SHELLCMD_ENV /**< use the shell to invoke the program replicating our environment*/
APR_PROGRAM, /**< invoke the program directly, no copied env */
APR_PROGRAM_ENV, /**< invoke the program, replicating our environment */
APR_PROGRAM_PATH, /**< find program on PATH, use our environment */
} apr_cmdtype_e;
为了能够深入理解APR中为什么将程序划分为这几种类型,我们有必要详细了解Unix中是如何执行一个新的应用程序的。Unix中执行一个新的程序通常使用exec函数实现。当进程调用exec的时候,调用进程将被新的执行程序完全替代。新程序从main开始执行。由于exec函数的执行并不创建新的进程,因此新程序的进程号仍然是原有的进程号。exec只是用另外一个程序替换了当前进程的正文、数据、堆栈。
Unix中提供六个exec函数的变种供使用,APR中应用程序的类型正是根据这六种类型划分的。
#include <unistd.h>
int execl(const char * pathname, const char * arg 0, ... /* (char *) 0 */);
int execv(const char * pathname, char *const argv [] );
int execle(const char * pathname, const char * arg 0, .../* (char *)0, char *const envp [] */);
int execve(const char * pathname, char *const argv [], char *const envp [] );
int execlp(const char * filename, const char * arg 0, ... /* (char *) 0 */);
int execvp(const char * filename, char *const argv [] );
上面的六个函数都能执行一个新的应用程序,不过它们之间的差异主要在三方面:
1)、应用程序参数的传递方法的差别
exec允许使用两种途径进行应用程序的参数传递。或者每一个命令行参数都单独作为函数的参数,或者先构造一个参数数组,将所有的命令行参数保存在该数组中,然后用数组的形式传递给函数。前一种的参数格式通常如下:
char *arg0, char *arg1, char *arg2,…,char *argn, (char*)0
最后一个实际的命令行参数后面必须跟一个(char*)0空指针,这个不能省略,而且也不能写为整数0,必须将其转换为字符指针。ASCII为0的字符正是空字符\0。
后一种用法的参数通常就是直接一个char* argv[]数组而已,因此如果用这种策略传递的话,通常先将所有的参数生成字符串数组,然后再将该数组传入,在后面的使用示例中可以看到。
函数名称中通过l和v来进行这两个格式的区分,l意味着参数为列表,使用第一个方法传递;v意味着参数为向量,使用第二种途径传递。因此execl,execle,execlp属于前一种传递策略,execv,execve,execvp属于后一种传递策略。不过APR中仅仅使用向量传递的方法,因此APR中只是用到了execv,execve和execvp。
2)、环境变量表传递的差别
Unix中每个进程都将收到两份表,一份就是参数表,另一份就是环境变量表。在最原先的main函数中参数是三个而不是我们现在所看到的两个:
int main(int argc, char *argv[], char *envp[] ) /*原始的main函数参数*/
第三个参数就是必须传递的环境变量表,通常情况下它是一个字符指针数组,其中每一个指针包含一个以NULL结尾的字符串的地址。不过ANSI C规定了main只能有两个参数,因此现在的做法通常是使用全局环境变量environ进行传递,比如:
上面函数的第二个差异就是对环境参数表的传递的差异。一种策略就是传递默认的系统全局环境参数表environ,这种情况下该参数列表将是隐含的,因此不需要在参数列表中明确列出。另一种策略就是传递自定义的环境参数表,此时通过environ行不通,因此必须通过函数参数明确传递。上面的六个函数中,名称最后为e的则表明可以传递自定义的环境变量数组,比如execle和execve。
应用程序类型APR_SHELLCMD_ENV和APR_PROGRAM_ENV都是需要传递自定义的环境变量参数,因此毫无疑问,对于这两种程序类型肯定是调用execve(execle是传递参数列表,APR中不使用这种策略)。
3)、启动应用程序路径的差异
前面的四个程序都必须使用路径名称明确指定需要执行的程序的路径和名称,而且路径必须是绝对路径。而后两者则不一定,它们仅仅使用文件名称指名启动的程序。如果该名称是绝对路径名称(以/开始),那么该名称被视为路径。如果给出的仅仅是文件名或者一个相对的文件名称,那么程序将在PATH和当前目录下查找对应的文件。
APR中不支持使用程序名称启动程序的策略,毕竟在PATH指定的目录下进行搜索与直接给定程序路径相比显然要耗费时间的多。
下面是几个函数的使用示例:
1. execl(“/bin/ls”,”ls”,”-al”,”/etc/passwd”,(char * )0);
2. execlp(“ls”,”ls”,”-al”,”/etc/passwd”,(char *)0);
3. char * argv[ ]={“ls”,”-al”,”/etc/passwd”,(char*) }};
execv(“/bin/ls”,argv);
4. char * argv[ ]={“ls”,”-al”,”/etc/passwd”,(char *)0};
char * envp[ ]={“PATH=/bin”,0}
execve(“/bin/ls”,argv,envp);
5. char * argv[ ] ={ “ls”,”-al”,”/etc/passwd”,0};
execvp(“ls”,argv);
6. char * envp[ ]={“PATH=/bin”,0}
execve(“/bin/ls”,”ls”,”-al”,”/etc/passwd”,(char * )0,envp);
因此尽管UNIX提供了六种调用接口,不过APR中仅仅使用了三种而已:execv,execve和execvp。根据前面的分析,我们可以看到上面五种任务的区别,如下表所示:
任务类型 |
含义 |
环境变量传递方式 |
执行路径方式 |
APR_SHELLCMD |
执行普通SHELL命令 |
自定义环境变量传递方式 |
|
APR_SHELLCMD_ENV |
执行普通SHELL命令 |
默认隐含传递方式 |
|
APR_PROGRAM |
普通应用程序 |
自定义环境变量传递方式 |
|
APR_PROGRAM_ENV |
普通应用程序 |
默认隐含传递方式 |
|
APR_PROGRAM_PATH |
普通应用程序 |
|
|
detached标识新进程是否为分离后台进程,默认为前台进程。
■ 第四部分
这4个字段标识平台对进程资源的限制,一般我们接触不到。struct rlimit的定义在/usr/include/sys/resource.h中。这四个字段的值是与Apache配置文件中的指令对应的。
为了防止某个子进程占用过多的CPU,RLimitCPU指令限制了Apache载入的子进程的CPU占用率。如果没有定义的话,则使用操作系统的默认值,该配置值最终保存在进程结构的limit_cpu中。
与此类似,为了防止子进程过度占用内存,Apache中提供了RLimitMEM指令,该指令参数值最终保存在limit_mem中。为了限制由Apache载入的子进程的数目,配置中提供了RLimitNPROC指令,它的参数值由limit_nproc保存。
Apache中可能会遇到的另外一个问题就是文件描述符耗尽。Unix操作系统限制了每个进程允许使用的文件描述符的数目,典型的默认上限是64。不过64个有时候不够用,为此需要进行扩充,直到到达一个很大的硬限制位置。调整后的文件描述符上限保存在limit_nofile中。
■ 第五部分
errfn为一函数指针,原型为typedef void (apr_child_errfn_t)(apr_pool_t *proc, apr_status_t err, const char *description); 这个函数指针如果被赋值,那么当子进程遇到错误退出前将调用该函数,从而可以完成一些清理工作。
errchk一个标志值,用于告知apr_proc_create是否对子进程属性进行检查,如检查curdir的access属性等。
■ 第六部分
最后两个成员描述了创建进程的用户ID和组ID,用于检索允许该用户所使用的权限。
如果您非常迫切的想了解IT领域最新产品与技术信息,那么订阅至顶网技术邮件将是您的最佳途径之一。
现场直击|2021世界人工智能大会
直击5G创新地带,就在2021MWC上海
5G已至 转型当时——服务提供商如何把握转型的绝佳时机
寻找自己的Flag
华为开发者大会2020(Cloud)- 科技行者