学习《apache源代码全景分析》之多任务并发处理摘录
1.如果要写服务器程序,按照正常的思路,通常主程序在进行了必要的准备工作后会调用诸如fork之类的函数产生一个新的进程或线程,然后由子进程进行并发处理。每个进程侦听某个端口,然后接受网络连接,并处理这些了连接上的请求数据。
2.当主程序调用了函数ap_mpm_run之后,整个主程序就算结束了。然后进入多进程并发处理状态,为了并发处理客户端请求,Apache会产生多个进程,每个进程又产生一定数目的线程,等等。
3.MPM中所使用到的公共数据结构,主要包括两种:记分板(ScoreBoard)和父子进程的终止通信管道。记分板类似于共享内存,主要用于父子进程之间的数据交换。任何一方都可以将对方需要的信息写入到记分板上,同时任何一方也可以到记分板上获取需要的数据。记分板通常用于主进程对子进程方向的通信,子进程再记分板中写入自己的状态,主进程则通过读取记分板从而了解子进程的状态。
终止通信管道则用于主进程通知子进程终止运行,它也是单方向的。
4.MPM的功能定位在Apache的主循环中,服务器的主程序主要完成所有的初始化及配置处理。这些都是在调用函数ap_mpm_run()之前完成。一旦调用了ap_mpm_run(),函数的指挥权从主程序切换到MPM中了。
MPM的主要任务就是创建进程或线程并对它们进行管理。另外一个职责就是在套接字上进行侦听客户端请求。
5.prefork MPM示意图
6.父进程、子进程及记分板之间关系
记分板的数据结构如下:
typedef struct
{
global_score *global; //描述全局信息的结构
process_score *parent; //进程间相互通信结构
worker_score **servers; //记录每个线程的运行信息
lb_score *balancers;
} scoreboard;
---------
typedef struct
{
int server_limit; //系统中存在的服务进程的最大数目
int thread_limit; //每个进程允许产生的线程的最大数目
ap_scoreboard_e sb_type;
ap_generation_t running_generation;
apr_time_t restart_time;
int lb_limit;
} global_score;
----------
typedef struct process_score process_score;
struct process_score
{
pid_t pid; //主进程的进程号
ap_generation_t generation; /* generation of this child 家族号*/
ap_scoreboard_e sb_type;
int quiescing; //记录将要被优雅终止的进程的进程号
};
----------
typedef struct worker_score worker_score;
struct worker_score
{
/*第一部分,主要描述线程本身的状态和信息*/
int thread_num; //Apache识别该线程的唯一标志
apr_os_thread_t tid; //该线程的线程号
unsigned char status; //当前线程的状态
/*第二部分,主要描述线程被访问的相关信息*/
unsigned long access_count; //当前线程在整个服务器运行期间所处理的请求数目。
apr_off_t bytes_served; //记录当前线程从客户端请求中所读取的所有字节总数
unsigned long my_access_count; //当前线程在本次连接处理中所读取的请求字节总数
apr_off_t my_bytes_served;
apr_off_t conn_bytes; //当前线程处理的最后一个连接中所处理的字节数目
unsigned short conn_count; //当前线程所处理的最后一个连接中的请求数目
/*第三部分,主要描述线程运行相关的时间信息*/
apr_time_t start_time; //记录线程的启动时间
apr_time_t stop_time; //记录线程的停止时间
#ifdef HAVE_TIMES
struct tms times;
#endif
apr_time_t last_used;//记录线程最后一次使用的时间
/*第四部分,主要描述线程的连接信息*/
char client[32]; //请求客户端的主机名称或IP地址
char request[64]; //客户端发送的请求行信息
char vhost[32]; //当前请求所请求的虚拟主机名称
};
7.创建记分板
通过ap_creatge_scoreboard函数实现记分板的创建,位于scoreboard.c文件中。
记分板内存大小计算
通过函数ap_calc_scoreboard_size计算记分板所需要占用的内存大小。
记分板初始化
通过调用ap_init_scoreboard()对该内存块进行初始化操作。
记分板内存分配图:
记分板插槽管理
最频繁的一项功能就是在记分板中查找指定进程信息,函数find_child_by_pid(apr_proc_t *pid)用以实现该功能。
记分板内存释放
通过ap_cleanup_scoreboard()完成内存释放,如果记分板没有被共享,那么对它的释放就非常简单,记分板中的两个内存块分别用ap_scoreboard_image和ap_scoreboard_image->global标识,因此直接调用free即可。
8.管道具有几个很鲜明的特点:
(1) 管道是半双工的通信手段,数据只能在一个方向上流动。在进行双向数据通信时,需要建立两个管道,分别用于不同方向。
(2) 管道通常用于父子进程或兄弟进程等具有亲缘关系的进程间的通信。
(3) 管道本质上是一个文件,对于管道两端的文件而言,实际上是对文件进行操作。
9.终止管道定义在pod.h中,
typedef struct ap_pod_t ap_pod_t;
struct ap_pod_t {
apr_file_t *pod_in;
apr_file_t *pod_out;
apr_pool_t *p;
};
在终止管道中,pod_out用于父进程向子进程中写入数据,而pod_in则用于子进程从管道中读取信息。
9.1 终止管道的创建使用ap_mpm_pod_open进行。
10.Inetd:通用的多任务处理结构
分为两个部分:主服务进程和客户服务进程。主服务器(Master Server)进程通常用于等待客户端的连接请求。一旦客户端发起一个请求,主服务器将建立连接,同时调用fork创建一个新的客户服务进程,并由客户服务进程处理客户端的请求,而主服务进程则继续返回进入等待状态,等待客户端的请求。整个体系如下图:
11.Leader/Follow模式
Apache中使用最多的Prefork MPM就是基于Leader/Follower MPM模型的。
12.Prefork MPM模型示意图
13.Prefork MPM的内部数据流程
14.所有的MPM都是从ap_mpm_run()函数开始执行的,预创建MPM也不例外。ap_mpm_run()函数通常由Apache核心在main()中进行调用,一旦调用,运行服务器的任务就从Apache核心移交给了MPM。这个函数是所有的MPM都必须实现的。通常情况下,ap_mpm_run的实现比较复杂。主服务进程的功能主要包括下面几部分:
(1) 接受进程外部信号进行重启、关闭及优雅启动等操作,外部进程通过发送信号给主服务进程以实现控制主服务进程的目的。
(2) 在启动时创建子进程或在优雅启动时用新进程替代原有进程。
(3) 监控子进程的运行状态并根据运行负载自动调节空闲子进程的数目:在存在过多空闲子进程时终止部分空闲进程;在空闲子进程较少时创建更多的空闲进程以便将空闲进程的数目维持在一定的数目之内。
15.Prefork MPM对各种信号的处理
16.主进程对空闲子进程的维护流程
17.整个子进程中函数调用的层次如下图:
子进程的创建
主服务进程是通过调用make_child函数来创建一个子进程的,函数定义如下:
static int make_child(server_rec *s, int slot)
18.子进程主体执行流程图
整个循环分为两部分:对客户端请求的等待及请求被接受后的处理。等待请求通过poll进行轮询。
对于所有的子进程而言,通常情况下第一件事情就是调用child_init挂钩,child_init挂钩的主要目的就是允许子进程初始化互斥锁,以及可能会由多个子进程共享的内存块。
19.在子进程循环中,子进程将通过poll来不断地对轮询进行处理,判断是否有客户端连接到来,如果有则立即接受该连接并进行处理。说道接受客户端的连接,就不得不提Unix socket API的一个缺点。如果Apache开放了多个端口或多个地址供客户端连接,Apache会使用select来检测每个socket是否就绪,select将表明一个socket有0个或者至少1个连接正等候处理。Apache的模型是多子进程的,所有空闲进程会同时检测新的连接。
for (;;) {
for (;;) {
fd_set accept_fds;
FD_ZERO(&accept_fds);
for (i = first_socket; i <= last_socket; ++i) {
FD_SET(i, &accept_fds);
}
rc = select(last_socket+1, &accept_fds, NULL, NULL, NULL);
if (rc < 1) continue;
new_connection = -1;
for (i = first_socket; i <= last_socket; ++i) {
if (FD_ISSET(i, &accept_fds)) {
new_connection = accept(i, NULL, NULL);
if (new_connection != -1) break;
}
}
if (new_connection != -1) break;
}
process the new_connection;
}
这种设想的实现方法有一个严重的“饥饿”问题。如果多个子进程同时执行这个循环,则在多个请求之间,进程会被阻塞在select,随即进入循环并试图accept此连接,只有一个进程可以成功执行(假设还有一个连接就绪),而其余的则会被阻塞在accept。这样,只有那一个socket可以处理请求,而其他都被锁住了,直到有足够多的请求将它们唤醒。此“饥饿”问题在PR#467中有专门的讲述。这里至少有两种解决方法。
(1) 使用非阻塞型socket,不阻塞子进程并允许它立即继续执行。但是,这样会浪费CPU时间。
(2) 使内层循环的入口串行化:
for (;;) {
accept_mutex_on();--------------------------
for (;;) {
fd_set accept_fds;
FD_ZERO(&accept_fds);
for (i = first_socket; i <= last_socket; ++i) {
FD_SET(i, &accept_fds);
}
rc = select(last_socket+1, &accept_fds, NULL, NULL, NULL);
if (rc < 1) continue;
new_connection = -1;
for (i = first_socket; i <= last_socket; ++i) {
if (FD_ISSET(i, &accept_fds)) {
new_connection = accept(i, NULL, NULL);
if (new_connection != -1) break;
}
}
if (new_connection != -1) break;
}
accept_mutex_off();-----------------------------
process the new_connection;
}
20.工作者(Worker)MPM是混合了线程和进程的MPM,内部结构如下图:
整个Worker MPM内部被细分为3个功能模块:主进程、工作子进程及工作线程。主进程启动后,它会建立一组数目不定的工作子进程,子进程的数目由主进程进行动态调整,这与Prefork MPM非常相似,每个子进程又会建立固定数据的工作线程。
每个子进程产生的线程属于一组,每组中的线程分为两种角色:侦听者线程和工作者线程。侦听者线程用于侦听网络并接受客户端连接,一旦接受完毕,就将它们放入连接队列中。然后,工作者线程负责从队列中获取连接,为所有来自它的请求提供服务。侦听线程和工作线程之间通过套接字队列进行异步通信。
当服务器繁忙时,通常需要产生更多的工作者线程。但是Worker MPM并不直接创建线程,它首先新创建一个进程,然后由这个进程再一次性地创建多个线程。因此Worker MPM中线程的创建总是批量的。与之类似,当服务器空闲时,MPM也不是逐个地终止某个特定的线程,它仍然以进程为单位,终止一个进程,然后该进程一次性地终止该进程下的所有线程。
关于函数的调用层次:
整个内部数据流程如下图:
Worker MPM的apr_mpm_run()的层次更加请求,整个模块可以被分割为3个功能部分,如下图所示:
Worker MPM使用server_main_loop函数使主进程进入循环处理:
server_main_loop(remaining_children_to_start);
21.子进程工作流程
对于子进程而言,它最重要的任务就是创建N个线程,其中包括一个侦听线程及过个工作线程。
创建线程通过start_threads函数实现
侦听线程和工作者线程之间通过连接套接字队列进行通信。侦听线程接受所有的客户端连接,并且将接收到的套接字放入队列中。同时工作线程不断地从队列中获取套接字并对其进行处理。
侦听线程将接收到的连接放入连接套接字队列中,而工作线程则不断地监视连接套接字队列,一旦发现有可用的套接字,工作线程将对该连接进行处理。
Worker MPM中使用fd_queue_t描述套接字队列:
struct fd_queue_t {
fd_queue_elem_t *data; //记录实际的套接字数据
int nelts;
int bounds;
apr_thread_mutex_t *one_big_mutex;
apr_thread_cond_t *not_empty;
int terminated;
};
typedef struct fd_queue_t fd_queue_t;
struct fd_queue_elem_t
{
apr_socket_t *sd; //套接字的描述符
apr_pool_t *p; //该套接字所使用的内存池
conn_state_t *cs; //新版本中新引入的一个成员,用于记录当前套接字的连接状态
};
typedef struct fd_queue_elem_t fd_queue_elem_t;
为了操作套接字队列,Apache提供了一系列的操作哈数,包括:
(1) 套接字队列初始化
apr_status_t ap_queue_init(fd_queue_t *queue, int queue_capacity, apr_pool_t *a);
queue_capacity是队列初始化时的容量大小,创建所需要的所有的内存均来自内存池a,最终生成的队列由queue返回。
(2) 套接字入队列
apr_status_t ap_queue_push(fd_queue_t *queue, apr_socket_t *sd,
conn_state_t *cs, apr_pool_t *p);
queue是目的套接字队列,sd是需要保存的套接字,cs则是当前的连接装填。
(3) 套接字出队列
apr_status_t ap_queue_pop(fd_queue_t *queue, apr_socket_t **sd,
conn_state_t **cs, apr_pool_t **p);
queue是操作队列,套接字的相关信息则由sd、cs以及p分别返回。
22.侦听线程工作流程
23.调用过程accept_mutex_on():申请互斥锁或等待直到该互斥锁可用。
调用过程accept_mutex_off():释放互斥锁。
24.工作者线程的数据流程
工作线程主要完成两个方面的任务:
(1) 从套接字队列中获取一个可用的套接字;
(2) 处理套接字。
工作线程的入口函数为worker_thread.
工作者线程将调用ap_queue_pop从套接字队列中获取一个可用的套接字,如果队列为空,那么ap_queue_pop将被阻塞。对于获取到的套接字,process_socket函数将被调用对其进行处理。
25.对于Apache而言,WinNT MPM是Window平台下唯一使用的MPM。
整个WinNT MPM内部可以分割为3个重要的组成部分:两个进程及一组多个工作进程。两个重要的进程分别是:监控进程和工作进程。
监控进程通常称为父进程,主要任务是执行master_main函数,监视子进程的运行情况并做出适当的处理,这些处理包括以下几个部分:
(1) 子进程处理部分。父进程主要负责创建子进程、关闭子进程等;
(2) 事件响应部分。事件主要包括三种:重启事件、终止事件及子进程处理。
工作进程由父进程创建,主要任务是创建工作线程并对工作线程进行管理。内部数据流程如下图:
工作进程创建的线程包括三大类:
(1) 一个主线程。主要负责启动一个或多个侦听线程用于侦听等待客户端的连接请求;
(2) 一个或多个侦听线程,一旦发现客户端的请求,它们将接受该请求并将该请求保存到队列中。
(3) 固定数目的工作线程。股则处理客户端的连接请求并对其进行响应。
主进程通过调用CreateProcess()创建子进程。
工作队列用于子进程和工作线程之间进行套接字传递,它基于生产者/消费者模式,子进程是生产者,它生产套接字,并写入工作队列;线程则是消费者,它从队列中读取套接字。对工作队列的操作必须保持互斥和同步:插入、删除都必须锁定;队列中没有套接字可读取时,线程必须等待,一旦有新的套接字放入队列,线程则被唤醒。在使用之前必须创建响应的互斥锁和信号灯。
完整的侦听过程包括创建套接字、调用Listen及accept三大步骤。
Windows NT下的连接接受: