6.3.3.5工作子进程管理
子进程通常被视为工作者,其组成了HTTP服务器的核心。它们负责处理对客户端的请求的处理。尽管多任务体系结构并不负责对请求的处理,不过他仍然负责创建子进程、对其进行初始化并且将客户端请求转交给它们进行处理。子进程的所有的行为都被封状在函数child_main()中。
6.3.3.5.1子进程的创建
在深入到子进程工作的内部细节之前,我们有必要了解一下主服务进程是如何创建子进程的。事实上,从主服务进程的最后的代码中也可以看出,主服务进程是通过调用make_child函数来创建一个子进程的,该函数定义如下:
static int make_child(server_rec *s, int slot)
该函数具有两个参数,slot是当前进程在记分板中的索引。
u的代码主要用于处理单进程。如前所述,单进程主要用于调试。对于单进程服务器而言,唯一的进程就是主服务进程,因此不需要创建任何额外的子进程,需要处理的就是转换主服务进程的角色为子服务进程,这种转变包括两部分:
...{ int pid; if (slot + 1 > ap_max_daemons_limit) ...{ ap_max_daemons_limit = slot + 1; } if (one_process) ...{ apr_signal(SIGHUP, sig_term); apr_signal(SIGINT, sig_term); apr_signal(SIGQUIT, SIG_DFL);u apr_signal(SIGTERM, sig_term); child_main(slot); return 0; }上面1)、处理信号。
6.3.3.5.2初始化、配置以及服务重启
(void) ap_update_child_status_from_indexes(slot, 0, SERVER_STARTING, (request_rec *) NULL); if ((pid = fork()) == -1) ...{ ap_log_error(APLOG_MARK, APLOG_ERR, errno, s, "fork: Unable to fork new process"); (void) ap_update_child_status_from_indexes(slot, 0, SERVER_DEAD, (request_rec *) NULL); sleep(10); return -1; } if (!pid) ...{ RAISE_SIGSTOP(MAKE_CHILD); AP_MONCONTROL(1); apr_signal(SIGHUP, just_die); apr_signal(SIGTERM, just_die); apr_signal(AP_SIG_GRACEFUL, stop_listening); child_main(slot); } ap_scoreboard_image->parent[slot].pid = pid; return 0;}主服务进程使用fork()创建子进程。每个子进程都具有独立的内存区域并且不允许读取其余子进程的内存。因此由主服务器一次处理配置文件要比由各个子进程各自处理明智的多。配置文件的相关信息可以保存在共享内存区域中,该内存区域可以被每一个子进程读取。由于不是每一个操作系统平台都支持共享内存的概念,因此主服务进程在创建子进程之前处理配置文件。子服务进程通常是父进程的克隆,因此它们与主服务进程具有相同的配置信息而且从来不会改变。
任何时候,如果管理员想更改服务器配置。他都必须让主服务进程重新读取配置信息。当前存在的子进程所拥有的则是旧的配置信息,因此它们都必须被替换为新产生的子进程。为了避免打断正在处理的HTTP请求,Apache提供了一种平稳启动的方法,该模式下允许子进程使用旧的配置信息进行处理直到其退出。
子进程的初始化可以在相应的MPM中找到,对于预创建Preforking MPM而言就是child_main()。该函数包括下面的几个步骤:
调用ap_init_child_modules()重新初始化模块:每一个模块都由主进程预先进行初始化。如果模块中分配系统资源或者决定于进程号,那么模块重新进行初始化就是必须的。
建立超时处理句柄:为了避免子进程的无线阻塞,Apache对客户端请求使用超时处理。其中使用警告,警告的概念与信号的概念非常类似,通常将报警时钟设置为给定的时间,而当报警响起的时候系统将离开请求的处理。
循环内还有两种初始化:
清除超时设置:重置报警定时器
清除透明内存池:在请求响应循环中每个内存的分配都涉及到透明内存池。在循环的开始,内存池必须进行清理。
将公告板中的status设置为ready。
ptrans的创建,以及访问公告板,同时由于多个进程之间可能存在竞争,因此另外一个准备工作就是创建进程间的接受互斥锁。由于通常情况下,父进程都是使用fork生成子进程,此时子进程基本是父进程的克隆。一般情况下,Apache的启动都是使用超级用户进行的,因此子进程实际上也就具有与父进程等同的操作权限,父进程能够访问的资源子进程都能够访问。
static void child_main(int child_num_arg)...{ apr_pool_t *ptrans; apr_allocator_t *allocator; conn_rec *current_conn; apr_status_t status = APR_EINIT; int i; ap_listen_rec *lr; int curr_pollfd, last_pollfd = 0; apr_pollfd_t *pollset; int offset; void *csd; ap_sb_handle_t *sbh; apr_status_t rv; apr_bucket_alloc_t *bucket_alloc; mpm_state = AP_MPMQ_STARTING; my_child_num = child_num_arg; ap_my_pid = getpid(); csd = NULL; requests_this_child = 0; ap_fatal_signal_child_setup(ap_server_conf); apr_allocator_create(&allocator); apr_allocator_max_free_set(allocator, ap_max_mem_free); apr_pool_create_ex(&pchild, pconf, NULL, allocator); apr_allocator_owner_set(allocator, pchild); apr_pool_create(&ptrans, pchild); apr_pool_tag(ptrans, "transaction"); ap_reopen_scoreboard(pchild, NULL, 0); rv = apr_proc_mutex_child_init(&accept_mutex, ap_lock_fname, pchild); if (rv != APR_SUCCESS) ...{ ap_log_error(APLOG_MARK, APLOG_EMERG, rv, ap_server_conf, "Couldn't initialize cross-process lock in child"); clean_child_exit(APEXIT_CHILDFATAL); }每个子进程在真正处理请求之前,都必须进行相关的资源准备工作,包括信号设置,私有内存池Apache通常会将子进程的用户设置为一个普通的用户,比如nobody或者WWWRun之类从而来降低子进程的执行权限,原则上,子进程用户的权力应该尽可能的小。Unixd_setup_child将用户的ID从正在运行父进程的用户改变为在配置文件中规定的用户,如果不能改变用户的ID,子进程就立即退出。另外相关的初始化工作必须在unixd_setup_child()调用之前进行,因为一旦子进程权限降低,一些只能超级用户进行的初始化可能无法正常进行。
if (unixd_setup_child()) ...{ clean_child_exit(APEXIT_CHILDFATAL); }但是子进程具有与父进程相同的权限具有一定的潜在的危险。由于网络连接通常由子进程直接处理,因此如果黑客通过某种权限控制了子进程,那么他就能够任意的控制系统的。因此通常在进行了资源准备工作之后,
Ap_run_child_init调用child_init挂钩进行子进程本身的初始化。
ap_run_child_init(pchild, ap_server_conf);ap_create_sb_handle(&sbh, pchild, my_child_num, 0);(void) ap_update_child_status(sbh, SERVER_READY, (request_rec *) NULL);当所有的准备工作结束以后,子进程可以与客户进行会话。
80,但是在Apache中,则允许服务器在多个端口上同时进行侦听,这些侦听端口用结构ap_listen_rec进行描述:
listensocks = apr_pcalloc(pchild, sizeof(*listensocks) * (num_listensocks));for (lr = ap_listeners, i = 0; i < num_listensocks; lr = lr->next, i++) ...{ listensocks[i].accept_func = lr->accept_func; listensocks[i].sd = lr->sd;} pollset = apr_palloc(pchild, sizeof(*pollset) * num_listensocks);pollset[0].p = pchild;for (i = 0; i < num_listensocks; i++) ...{ pollset[i].desc.s = listensocks[i].sd; pollset[i].desc_type = APR_POLL_SOCKET; pollset[i].reqevents = APR_POLLIN; }mpm_state = AP_MPMQ_RUNNING; bucket_alloc = apr_bucket_alloc_create(pchild);一般的情况下,服务器只会侦听固定的端口,比如
描述了绑定到该端口的套接字,而bind_addr则描述了套接字必需关联的地址。Accept_func是一个回调函数,当从该侦听端口上接受到客户端连接的时候,该函数将被执行从而来处理连接。Active用以描述当前端口是否处于活动状态。
struct ap_listen_rec ...{ ap_listen_rec *next; apr_socket_t *sd; apr_sockaddr_t *bind_addr; accept_function accept_func; int active;};sd
对于服务器端的多个侦听端口,Apache使用链表进行保存,因此next用以指向下一个侦听套接字结构。整个链表的用ap_listen_rec全局变量记录,因此沿着ap_listen_rec可以遍历所有的侦听套接字。与此同时,侦听端口的数目也保存在全局变量num_listensocks中。
上面的代码所作的事情无非就是生成指定的需要逐一遍历的文件结果集合。
任何时候,子进程如果要正常退出,其都必须由主进程通过“终止管道”通知,另一方面,子进程也将不停的检查终止管道。一旦发现需要退出,子进程将die_now设置为1,这时候实际上就自动退出循环。相反,如果子进程不需要退出,那么它所作的事情只有一个,就是使用poll对所有的端口进行轮询,直到某个端口准备完毕,则调用相关的连结处理函数进行处理。
while (!die_now) ...{ current_conn = NULL; apr_pool_clear(ptrans); if ((ap_max_requests_per_child > 0 && requests_this_child++ >= ap_max_requests_per_child)) ...{ clean_child_exit(0); } (void) ap_update_child_status(sbh, SERVER_READY, (request_rec *) NULL);SAFE_ACCEPT(accept_mutex_on());
虽然多个子进程同属于一个父进程,但是多个子进程之间则是相互并行的,当多个子进程同时扫描侦听端口的时候,很可能发生多个子进程同时竞争一个侦听端口的情况。因此所有的子进程有必要互斥的等待TCP请求。
接受互斥锁能够确保只有一个子进程独占的等待TCP请求(使用系统调用accept())——这些都是侦听者所做的事情。接受互斥锁是控制访问TCP/IP服务的一种手段。它的使用能够确保在任何时候只有一个进程在等待TCP/IP的连接请求。
不同的操作系统有不同的接受互斥锁(Accept Mutex)的实现。有一些操作系统对于每一个子进程需要一个特殊的初始化阶段。它的工作方式如下:
调用过程accept_mutex_on():申请互斥锁或者等待直到该互斥锁可用
调用过程accept_mutex_off():释放互斥锁
prefork MPM中通过SAFE_ACCEPT(accept_mutex_on())实现子进程对互斥锁的锁定;而SAFE_ACCEPT(accept_mutex_off())则是完成互斥锁的释放。
if (num_listensocks == 1) {
offset = 0;
}
对于整个侦听套接字数组而言,任何时候只有一个侦听端口能被处理。Offset实际上描述了当前正在被处理的侦听端口在数组中的索引。如果当前服务器的侦听端口只有一个,那么几乎没有任何事情要做,也就没有所谓的轮询。
如果服务器配置使用多个侦听端口,那么Apache就必须使用poll()来确定客户正在连接哪个端口,然后我们就可以知道哪个端口正在受到访问,这样才能在这个端口上调用接受函数。如果轮询返回的值是EBADF,EINTR或者EINVAL之类的错误,那么轮询并不应该被终止,但是如果返回的不是这些错误,那么子进程应该调用clean_child_exit退出。
else ...{ for (;;) ...{ apr_status_t ret; apr_int32_t n; ret = apr_poll(pollset, num_listensocks, &n, -1); if (ret != APR_SUCCESS) ...{ if (APR_STATUS_IS_EINTR(ret)) ...{ continue; } ap_log_error(APLOG_MARK, APLOG_ERR, ret, ap_server_conf, "apr_poll: (listen)"); clean_child_exit(1); }尽管服务器可能会同时侦听多个端口,但有的时候各个端口的重要性并不是一样的。比如某个服务器开通了80和8080两个端口,但是可能频繁使用的是80,而8080则只是偶尔使用,如果不加任何控制的话,8080端口可能会被忽略。为此,大多数MPM都会记住最后提供服务的那个端口,并且从这个端口开始搜索新的连接,通过这种方法,就可以确保服务器不会忽略任何端口。Last_pollfd用于标记最后提供服务的端口。Curr_pollfd是当前需要处理的端口的索引。
curr_pollfd = last_pollfd; do ...{ curr_pollfd++; if (curr_pollfd >= num_listensocks) ...{ curr_pollfd = 0; } if (pollset[curr_pollfd].rtnevents & APR_POLLIN) ...{ last_pollfd = curr_pollfd; offset = curr_pollfd; goto got_fd; } } while (curr_pollfd != last_pollfd); continue; } }got_fd:
status = listensocks[offset].accept_func(&csd,
&listensocks[offset], ptrans);
SAFE_ACCEPT(accept_mutex_off()); /* unlock after "accept" */
if (status == APR_EGENERAL) {
clean_child_exit(1);
}
else if (status != APR_SUCCESS) {
continue;
}
上面的代码用于接受客户端的连接。事实上,从上面的代码可以看到,接受客户端连接并没有直接使用apr_accept函数,而是将其作为上面描述的ap_listen_rec结构组成部分的函数指针。接受函数会进行有效的错误检查,并返回有效的套接字。使用这种函数指针策略的还会得到额外的好处。模块可以在侦听套接字列表中增加他们自己的通信原语。如果这些套接字不是正常的套接字,比如是UNIX IPC套接字,那么他们可能就需要不同的函数进行处理,此时,accept_func指着就可以指向这些函数。增加这么代码,不仅可以允许在内核缓存和Web服务器之间进行通信,而且可以用于其余的方面,例如,许多UNIX MPM都同通过POD向子进程发出关闭信号,如前所述,如果将POD强行编入代码中,它就不容易维护,而使用accept_func函数,POD就可以实现特殊的处理函数,实现无缝处理。
一旦接受了连接请求,一个子进程将释放互斥锁同时处理请求——此时它变成一个工作者,而下一个进程将继续申请互斥锁从而进行等待。这通常称之为Leader-follower模式:侦听者是leader,而空闲的工作者则是follower。由于Apache实现互斥锁使用的是操作系统相关技术,因此有些操作系统上,当一个子进程接受到连接释放互斥锁的时候,当前的所有的阻塞的子进程都将被唤醒。如果是这样,那么一些过分的调度将是不必要的,因此只有一个子进程会得到互斥锁,而其余的都将继续被阻塞睡眠。这个问题是Leader MPM需要解决的,在该MPM中,所有的follower进行一定的组织,从而当互斥锁释放的时候,只有它们中间的一个会被唤醒。
一旦子进程接受到一个客户端连接,那么多任务模块的职责也就结束了。子进程将继续调用请求处理例程进行处理。不管对于任何MPM,它们都是一样的。
current_conn = ap_run_create_connection(ptrans, ap_server_conf, csd, my_child_num, sbh, bucket_alloc);
if (current_conn) {
ap_process_connection(current_conn, csd);
ap_lingering_close(current_conn);
}
客户端连接一旦接受成功,此时就在客户端和服务器之间存在一条TCP连接。ap_process_connection会处理这个连接上的所有的请求,然后退出。完成这些工作的第一步就是建立连接结构,在该结构中会存储客户端的套接字,以及所有的连接相关信息,比如,服务器的IP地址和唯一标示符ID。一旦连接确定好,它就可以通过ap_process_connection接受服务了。ap_process_connection的内部隐藏了相当多的细节,这些细节,我们在后面的部分会详细介绍。
在每个请求的最后阶段,我们都要调用ap_lingering_close。这个函数据说是Apache中最糟糕的函数之一,这不仅是因为它很难理解,而且也涉及到了许多的OS的问题。必须要解决的问题是,在客户承认已经接受到所有的响应数据之前,都不可以关闭连接的服务器端。如果这样做了,那么客户就会丢失你最后发送的数据包。为了防止这种情况的发生,就必须要保持连接处于打开状态,直到出现超时或者客户端关闭连接。大多数OS都可以设置套接字选项来实现延迟关闭。遗憾的是,对于Web服务器而言,套接字选先不会总是进行了设置,与此相反,服务器需要实现延迟关闭,并且确保在每个连接结束时被调用。为了完成这项工作,核心服务器需要为请求的终止注册清除程序,以确保可以调用lingering_close。然而,类似Window的一些OS可以让你在某些条件下重用套接字。如果你打算重用套接字,那么就不需要将其关闭,因而也就不需要调用lingering_close函数。如果你正在编写用鱼支持重用套接字的MPM,ap_process_connection之后的程序就可以删除。
if (ap_mpm_pod_check(pod) == APR_SUCCESS) { /* selected as idle? */
die_now = 1;
}
else if (ap_my_generation !=
ap_scoreboard_image->global->running_generation) { /* restart? */
die_now = 1;
}
}
clean_child_exit(0);
每次连接处理完毕,子进程都必须检查终止管道。如果父进程通知它退出,那么此时die_now=1,下次子进程直接跳出循环执行clean_child_exit退出。
另一个可能导致子进程立即退出的原因就是该子进程属于上一家族的残留子进程。这通常是因为非强制启动引起的。因此每个子进程在退出之前要将自己的家族号ap_my_generation与父进程的家族号即记分板中的running_generation进行对比。如果不符合,表示子进程不属于本家族,将立即退出。除此两种情况之外,子进程将继续循环。