【Nginx源码研究】Master进程浅析

44次阅读

共计 23589 个字符,预计需要花费 59 分钟才能阅读完成。

运营研发团队 季伟滨
一、前言
众所周如,Nginx 是多进程架构。有 1 个 master 进程和 N 个 worker 进程,一般 N 等于 cpu 的核数。另外,和文件缓存相关,还有 cache manager 和 cache loader 进程。
master 进程并不处理网络请求,网络请求是由 worker 进程来处理,而 master 进程负责管理这些 worker 进程。比如当一个 worker 进程意外挂掉了,他负责拉起新的 worker 进程,又比如通知所有的 worker 进程平滑的退出等等。本篇 wiki 将简单分析下 master 进程是如何做管理工作的。
二、nginx 进程模式
在开始讲解 master 进程之前,我们需要首先知道,其实 Nginx 除了生产模式(多进程 +daemon)之外,还有其他的进程模式,虽然这些模式一般都是为了研发 & 调试使用。
非 daemon 模式
以非 daemon 模式启动的 nginx 进程并不会立刻退出。其实在终端执行非 bash 内置命令,终端进程会 fork 一个子进程,然后 exec 执行我们的 nginx bin。然后终端进程本身会进入睡眠态,等待着子进程的结束。在 nginx 的配置文件中,配置【daemon off;】即可让进程模式切换到前台模式。
下图展示了一个测试例子,将 worker 的个数设置为 1,开启非 daemon 模式,开启 2 个终端 pts/ 0 和 pts/1。在 pts/ 1 上执行 nginx,然后在 pts/ 0 上看进程的状态,可以看到终端进程进入了阻塞态(睡眠态)。这种情况下启动的 master 进程,它的父进程是当前的终端进程 (/bin/bash),随着终端的退出(比如 ctrl+c),所有 nginx 进程都会退出。

single 模式
nginx 可以以单进程的形式对外提供完整的服务。这里进程可以是 daemon,也可以不是 daemon 进程,都没有关系。在 nginx 的配置文件中,配置【master_process off;】即可让进程模式切换到单进程模式。这时你会看到,只有一个进程在对外服务。
生产模式(多进程 +daemon)
想像一下一般我们是怎么启动 nginx 的,我在自己的 vm 上把 Nginx 安装到了 /home/xiaoju/nginx-jiweibin,所以启动命令一般是这样:
/home/xiaoju/nginx-jiweibin/sbin/nginx
然后,ps -ef|grep nginx 就会发现启动好了 master 和 worker 进程,像下面这样(warn 是由于我修改 worker_processes 为 1,但未修改 worker_cpu_affinity,可以忽略)

这里和非 daemon 模式的一个很大区别是启动程序(终端进程的子进程)会立刻退出,并被终端进程这个父进程回收。同时会产生 master 这种 daemon 进程,可以看到 master 进程的父进程 id 是 1,也就是 init 或 systemd 进程。这样,随着终端的退出,master 进程仍然可以继续服务,因为 master 进程已经和启动 nginx 命令的终端 shell 进程无关了。
启动 nginx 命令,是如何生成 daemon 进程并退出的呢?答案很简单,同样是 fork 系统调用。它会复制一个和当前启动进程具有相同代码段、数据段、堆和栈、fd 等信息的子进程(尽管 cow 技术使得复制发生在需要分离那一刻),参见图 -1。
图 1 - 生产模式 Nginx 进程启动示意图
三、master 执行流程
master 进程被 fork 后,继续执行 ngx_master_process_cycle 函数。这个函数主要进行如下操作:

1、设置进程的初始信号掩码,屏蔽相关信号
2、fork 子进程,包括 worker 进程和 cache manager 进程、cache loader 进程
3、进入主循环,通过 sigsuspend 系统调用,等待着信号的到来。一旦信号到来,会进入信号处理程序。信号处理程序执行之后,程序执行流程会判断各种状态位,来执行不同的操作。

图 2 - ngx_master_process_cycle 执行流程示意图
四、信号介绍
master 进程的主循环里面,一直通过等待各种信号事件,来处理不同的指令。这里先普及信号的一些知识,有了这些知识的铺垫再看 master 相关代码会更加从容一些(如果对信号比较熟悉,可以略过这一节)。
标准信号和实时信号
信号分为标准信号(不可靠信号)和实时信号(可靠信号),标准信号是从 1 -31,实时信号是从 32-64。一般我们熟知的信号比如,SIGINT,SIGQUIT,SIGKILL 等等都是标准信号。master 进程监听的信号也是标准信号。标准信号和实时信号有一个区别就是:标准信号,是基于位的标记,假设在阻塞等待的时候,多个相同的信号到来,最终解除阻塞时,只会传递一次信号,无法统计等待期间信号的计数。而实时信号是通过队列来实现,所以,假设在阻塞等待的时候,多个相同的信号到来,最终解除阻塞的时候,会传递多次信号。
信号处理器
信号处理器是指当捕获指定信号时(传递给进程)时将会调用的一个函数。信号处理器程序可能随时打断进程的主程序流程。内核代表进程来执行信号处理器函数,当处理器返回时,主程序会在处理器被中断的位置恢复执行。(主程序在执行某一个系统调用的时候,有可能被信号打断,当信号处理器返回时,可以通过参数控制是否重启这个系统调用)。
信号处理器函数的原型是:void (* sighandler_t)(int);入参是 1 -31 的标准信号的编号。比如 SIGHUP 的编号是 1,SIGINT 的编号是 2。
通过 sigaction 调用可以对某一个信号安装信号处理器。函数原型是:int sigaction(int sig,const struct sigaction act,struct sigaction oldact); sig 表示想要监听的信号。act 是监听的动作对象,这里包含信号处理器的函数指针,oldact 是指之前的信号处理器信息。见下面的结构体定义:
struct sigaction{
void (*sa_handler)(int);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
}

sa_hander 就是我们的信号处理器函数指针。除了捕获信号外,进程对信号的处理还可以有忽略该信号(使用 SIG_IGN 常量 ) 和执行缺省操作(使用 SIG_DFL 常量)。这里需要注意,SIGKILL 信号和 SIGSTOP 信号不能被捕获、阻塞、忽略的。
sa_mask 是一组信号,在 sa_handler 执行期间,会将这组信号加入到进程信号掩码中(进程信号掩码见下面描述),对于在 sa_mask 中的信号,会保持阻塞。
sa_flags 包含一些可以改变处理器行为的标记位,比如 SA_NODEFER 表示执行信号处理器时不自动将该信号加入到信号掩码 SA_RESTART 表示自动重启被信号处理器中断的系统调用。
sa_restorer 仅内部使用,应用程序很少使用。

发送信号
一般我们给某个进程发送信号,可以使用 kill 这个 shell 命令。比如 kill -9 pid,就是发送 SIGKILL 信号。kill -INT pid,就可以发送 SIGINT 信号给进程。与 shell 命令类似,可以使用 kill 系统调用来向进程发送信号。
函数原型是:(注意,这里发送的一般都是标准信号,实时信号使用 sigqueue 系统调用来发送)。
int kill(pit_t pid, int sig);
另外,子进程退出,会自动给父进程发送 SIGCHLD 信号,父进程可以监听这一信号来满足相应的子进程管理,如自动拉起新的子进程。
进程信号掩码
内核会为每个进程维护一个信号掩码。信号掩码包含一组信号,对于掩码中的信号,内核会阻塞其对进程的传递。信号被阻塞后,对信号的传递会延后,直到信号从掩码中移除。
假设通过 sigaction 函数安装信号处理器时不指定 SA_NODEFER 这个 flag,那么执行信号处理器时,会自动将捕获到的信号加入到信号掩码,也就是在处理某一个信号时,不会被相同的信号中断。
通过 sigprocmask 系统调用,可以显式的向信号掩码中添加或移除信号。函数原型是:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
how 可以使下面 3 种:

SIG_BLOCK:将 set 指向的信号集内的信号添加到信号掩码中。即信号掩码是当前值和 set 的并集。
SIG_UNBLOCK:将 set 指向的信号集内的信号从信号掩码中移除。
SIG_SETMASK:将信号掩码赋值为 set 指向的信号集。

等待信号
在应用开发中,可能需要存在这种业务场景:进程需要首先屏蔽所有的信号,等相应工作已经做完之后,解除阻塞,然后一直等待着信号的到来(在阻塞期间有可能并没有信号的到来)。信号一旦到来,再次恢复对信号的阻塞。
linux 编程中,可以使用 int pause(void) 系统调用来等待信号的到来,该调用会挂起进程,直到信号到来中断该调用。基于这个调用,对于上面的场景可以编写下面的伪代码:
struct sigaction sa;
sigset_t initMask,prevMask;

sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sa.sa_handler = handler;

sigaction(SIGXXX,&sa,NULL); //1- 安装信号处理器

sigemptyset(&initMask);
sigaddset(&initMask,xxx);
sigaddset(&initMask,yyy);
….

sigprocmask(SIG_BLOCK,&initMask,&prevMask); //2- 设置进程信号掩码,屏蔽相关信号

do_something() //3- 这段逻辑不会被信号所打扰

sigprocmask(SIG_SETMASK,&prevMask,NULL); //4- 解除阻塞

pause(); //5- 等待信号

sigprocmask(SIG_BLOCK,&initMask,&prevMask); //6- 再次设置掩码,阻塞信号的传递

do_something2(); //7- 这里一般需要监控一些全局标记位是否已经改变,全局标记位在信号处理器中被设置
想想上面的代码会有什么问题?假设某一个信号,在上面的 4 之后,5 之前到来,也就是解除阻塞之后,等待信号调用之前到来,信号会被信号处理器所处理,并且 pause 调用会一直陷入阻塞,除非有第二个信号的到来。这和我们的预期是不符的。这个问题本质是,解除阻塞和等待信号这 2 步操作不是原子的,出现了竞态条件。这个竞态条件发生在主程序和信号处理器对同一个被解除信号的竞争关系。
要避免这个问题,可以通过 sigsuspend 调用来等待信号。函数原型是:
int sigsuspend(const sigset_t *mask);
它接收一个掩码参数 mask,用 mask 替换进程的信号掩码,然后挂起进程的执行,直到捕获到信号,恢复进程信号掩码为调用前的值,然后调用信号处理器,一旦信号处理器返回,sigsuspend 将返回 -1,并将 errno 置为 EINTR
五、基于信号的事件架构
master 进程启动之后,就会处于挂起状态。它等待着信号的到来,并处理相应的事件,如此往复。本节让我们看下 nginx 是如何基于信号构建事件监听框架的。
安装信号处理器
在 nginx.c 中的 main 函数里面,初始化进程 fork master 进程之前,就已经通过调用 ngx_init_signals 函数安装好了信号处理器,接下来 fork 的 master 以及 work 进程都会继承这个信号处理器。让我们看下源代码:
/* @src/core/nginx.c */

int ngx_cdecl
main(int argc, char *const *argv)
{
….
cycle = ngx_init_cycle(&init_cycle);

if (ngx_init_signals(cycle->log) != NGX_OK) {// 安装信号处理器
return 1;
}

if (!ngx_inherited && ccf->daemon) {
if (ngx_daemon(cycle->log) != NGX_OK) {//fork master 进程
return 1;
}
ngx_daemonized = 1;
}

}

/* @src/os/unix/ngx_process.c */

typedef struct {
int signo;
char *signame;
char *name;
void (*handler)(int signo);
} ngx_signal_t;

ngx_signal_t signals[] = {
{ngx_signal_value(NGX_RECONFIGURE_SIGNAL),
“SIG” ngx_value(NGX_RECONFIGURE_SIGNAL),
“reload”,
ngx_signal_handler },

{SIGCHLD, “SIGCHLD”, “”, ngx_signal_handler},

{SIGSYS, “SIGSYS, SIG_IGN”, “”, SIG_IGN},

{SIGPIPE, “SIGPIPE, SIG_IGN”, “”, SIG_IGN},

{0, NULL, “”, NULL}
};

ngx_int_t
ngx_init_signals(ngx_log_t *log)
{
ngx_signal_t *sig;
struct sigaction sa;

for (sig = signals; sig->signo != 0; sig++) {
ngx_memzero(&sa, sizeof(struct sigaction));
sa.sa_handler = sig->handler;
sigemptyset(&sa.sa_mask);
if (sigaction(sig->signo, &sa, NULL) == -1) {
#if (NGX_VALGRIND)
ngx_log_error(NGX_LOG_ALERT, log, ngx_errno,
“sigaction(%s) failed, ignored”, sig->signame);
#else
ngx_log_error(NGX_LOG_EMERG, log, ngx_errno,
“sigaction(%s) failed”, sig->signame);
return NGX_ERROR;
#endif
}
}

return NGX_OK;
}
全局变量 signals 是 ngx_signal_t 的数组,包含了 nginx 进程(master 进程和 worker 进程)监听的所有的信号。
ngx_signal_t 有 4 个字段,signo 表示信号的编号,signame 表示信号的描述字符串,name 在 nginx - s 时使用,用来作为向 nginx master 进程发送信号的快捷方式,例如 nginx -s reload 相当于向 master 进程发送一个 SIGHUP 信号。handler 字段表示信号处理器函数指针。
下面是针对不同的信号安装的信号处理器列表:

通过上表,可以看到,在 nginx 中,只要捕获的信号,信号处理器都是 ngx_signal_handler。ngx_signal_handler 的实现细节将在后面进行介绍。
设置进程信号掩码
在 ngx_master_process_cycle 函数里面,fork 子进程之前,master 进程通过 sigprocmask 系统调用,设置了进程的初始信号掩码,用来阻塞相关信号。
而对于 fork 之后的 worker 进程,子进程会继承信号掩码,不过在 worker 进程初始化的时候,对信号掩码又进行了重置,所以 worker 进程可以并不阻塞信号的传递。
void
ngx_master_process_cycle(ngx_cycle_t *cycle)
{

sigset_t set;

sigemptyset(&set);
sigaddset(&set, SIGCHLD);
sigaddset(&set, SIGALRM);
sigaddset(&set, SIGIO);
sigaddset(&set, SIGINT);
sigaddset(&set, ngx_signal_value(NGX_RECONFIGURE_SIGNAL));
sigaddset(&set, ngx_signal_value(NGX_REOPEN_SIGNAL));
sigaddset(&set, ngx_signal_value(NGX_NOACCEPT_SIGNAL));
sigaddset(&set, ngx_signal_value(NGX_TERMINATE_SIGNAL));
sigaddset(&set, ngx_signal_value(NGX_SHUTDOWN_SIGNAL));
sigaddset(&set, ngx_signal_value(NGX_CHANGEBIN_SIGNAL));

if (sigprocmask(SIG_BLOCK, &set, NULL) == -1) {
ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_errno,
“sigprocmask() failed”);
}

挂起进程
当做完上面 2 项准备工作后,就会进入主循环。在主循环里面,master 进程通过 sigsuspend 系统调用,等待着信号的到来,在等待的过程中,进程一直处于挂起状态(S 状态)。至此,master 进程基于信号的整体事件监听框架讲解完成,关于信号到来之后的逻辑,我们在下一节讨论。
void
ngx_master_process_cycle(ngx_cycle_t *cycle)
{
….
if (sigprocmask(SIG_BLOCK, &set, NULL) == -1) {
ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_errno,
“sigprocmask() failed”);
}

sigemptyset(&set); // 重置信号集合,作为后续 sigsuspend 入参,允许任何信号传递

ngx_start_worker_processes(cycle, ccf->worker_processes,
NGX_PROCESS_RESPAWN); //fork worker 进程
ngx_start_cache_manager_processes(cycle, 0); //fork cache 相关进程

for (;;) {

sigsuspend(&set); // 挂起进程,等待信号

… // 后续处理逻辑

}

} //end of ngx_master_process_cycle
六、主循环
进程数据结构
在展开说明之前,我们需要了解下,nginx 对进程的抽象的数据结构。
ngx_int_t ngx_last_process; //ngx_processes 数组中有意义(当前有效或曾经有效)的进程,最大的下标 +1(下标从 0 开始计算)
ngx_process_t ngx_processes[NGX_MAX_PROCESSES]; // 所有的子进程数组,NGX_MAX_PROCESSES 为 1024,也就是 nginx 子进程不能超过 1024 个。

typedef struct {
ngx_pid_t pid; // 进程 pid
int status; // 进程状态,waitpid 调用获取
ngx_socket_t channel[2]; // 基于匿名 socket 的进程之间通信的管道,由 socketpair 创建,并通过 fork 复制给子进程。但一般是单向通信,channel[0] 只用来写,channel[1] 只用来读。

ngx_spawn_proc_pt proc; // 子进程的循环方法,比如 worker 进程是 ngx_worker_process_cycle
void *data; //fork 子进程后,会执行 proc(cycle,data)
char *name; // 进程名称

unsigned respawn:1; // 为 1 时表示受 master 管理的子进程,死掉可以复活
unsigned just_spawn:1; // 为 1 时表示刚刚新 fork 的子进程,在重新加载配置文件时,会使用到
unsigned detached:1; // 为 1 时表示游离的新的子进程,一般用在升级 binary 时,会 fork 一个新的 master 子进程,这时新 master 进程是 detached,不受原来的 master 进程管理
unsigned exiting:1; // 为 1 时表示正在主动退出,一般收到 SIGQUIT 或 SIGTERM 信号后,会置该值为 1,区别于子进程的异常被动退出
unsigned exited:1; // 为 1 时表示进程已退出,并通过 waitpid 系统调用回收
} ngx_process_t;
比如我只启动了一个 worker 进程,gdb master 进程,ngx_processes 和 ngx_last_process 的结果如图 3 所示:

图 3 -gdb 单 worker 进程下 ngx_processes 和 ngx_last_process 的结果
全局标记
上面我们提到 ngx_signal_handler 这个函数,它是 nginx 为捕获的信号安装的通用信号处理器。它都干了什么呢?很简单,它只是用来标记对应的全局标记位为 1,这些标记位,后续的主循环里会使用到,根据不同的标记位,执行不同的逻辑。
master 进程对应的信号与全局标记位的对应关系如下表:

对于 SIGCHLD 信号,情况有些复杂,ngx_signal_handler 还会额外多做一件事,那就是调用 ngx_process_get_status 函数去做子进程的回收。在 ngx_process_get_status 内部,会使用 waitpid 系统调用获取子进程的退出状态,并回收子进程,避免产生僵尸进程。同时,会更新 ngx_processes 数组中相应的退出进程的 exited 为 1,表示进程已退出,并被父进程回收。
现在考虑一个问题:假设在进程屏蔽信号并且进行各种标记位的逻辑处理期间(下面会讲标记位的逻辑流程),同时有多个子进程退出,会产生多个 SIGCHLD 信号。但由于 SIGCHLD 信号是标准信号(非可靠信号),当 sigsuspend 等待信号时,只会被传递一个 SIGCHLD 信号。那么这样是否有问题呢?答案是否定的,因为 ngx_process_get_status 这里是循环的调用 waitpid,所以在一个信号处理器的逻辑流程里面,会回收尽可能多的退出的子进程,并且更新 ngx_processes 中相应进程的 exited 标记位,因此不会存在漏掉的问题。
static void
ngx_process_get_status(void)
{

for (;;) {
pid = waitpid(-1, &status, WNOHANG);

if (pid == 0) {
return;
}

if (pid == -1) {
err = ngx_errno;

if (err == NGX_EINTR) {
continue;
}

if (err == NGX_ECHILD && one) {
return;
}


return;
}

for (i = 0; i < ngx_last_process; i++) {
if (ngx_processes[i].pid == pid) {
ngx_processes[i].status = status;
ngx_processes[i].exited = 1;
process = ngx_processes[i].name;
break;
}
}

}
}
逻辑流程主循环,针对不同的全局标记,执行不同 action 的整体逻辑流程见图 4:
图 4 - 主循环逻辑流程
上面的流程图,总体还是比较复杂的,根据具体的场景去分析会更加清晰一些。在此之前,下面先就图上一些需要描述的给予解释说明:

1、临时变量 live,它表示是否仍有存活的子进程。只有当 ngx_processes 中所有的子进程的 exited 标记位都为 1 时,live 才等于 0。而 master 进程退出的条件是【!live && (ngx_terminate || ngx_quit)】,即所有的子进程都已退出,并且接收到 SIGTERM、SIGINT 或者 SIGQUIT 信号时,master 进程才会正常退出(通过 SIGKILL 信号杀死 master 一般在异常情况下使用,这里不算)。
2、在循环的一开始,会判断 delay 是否大于 0,这个 delay 其实只和 ngx_terminate 即强制退出的场景有关系。在后面会详细讲解。
3、ngx_terminate、ngx_quit、ngx_reopen 这 3 种标记,master 进程都会通过上面提到的 socket channel 向子进程进行广播。如果写 socket 失败,会执行 kill 系统调用向子进程发送信号。而其他的 case,master 会直接执行 kill 系统调用向子进程发送信号,比如发送 SIGKILL。关于 socket channel,后续会进行讲解。
4、除了和信号直接映射的标记位,我们看到,流程图中还有 ngx_noaccepting 和 ngx_restart 这 2 个全局标记位以及 ngx_new_binary 这个全局变量。ngx_noaccepting 表示当前 master 下的所有的 worker 进程正在退出或已退出,不再对外服务。ngx_restart 表示需要重新启动 worker 子进程,ngx_new_binary 表示升级 binary 时新的 master 进程的 pid,这 3 个都和升级 binary 有关系。

socket channel
nginx 中进程之间通信的方式有多种,socket channel 是其中之一。这种方式,不如共享内存使用的广泛,目前主要被使用在 master 进程广播消息到子进程,这里面的消息包括下面 5 种:
#define NGX_CMD_OPEN_CHANNEL 1 // 新建或者发布一个通信管道
#define NGX_CMD_CLOSE_CHANNEL 2 // 关闭一个通信管道
#define NGX_CMD_QUIT 3 // 平滑退出
#define NGX_CMD_TERMINATE 4 // 强制退出
#define NGX_CMD_REOPEN 5 // 重新打开文件
master 进程在创建子进程的时候,fork 调用之前,会在 ngx_processes 中选择空闲的 ngx_process_t,这个空闲的 ngx_process_t 的下标为 s(s 不超过 1023)。然后通过 socketpair 调用创建一对匿名 socket,相对应的 fd 存储在 ngx_process_t 的 channel 中。并且把 s 赋值给全局变量 ngx_process_slot,把 channel[1] 赋值给全局变量 ngx_channel。
ngx_pid_t
ngx_spawn_process(ngx_cycle_t *cycle, ngx_spawn_proc_pt proc, void *data,char *name, ngx_int_t respawn) {

…// 寻找空闲的 ngx_process_t,下标为 s

if (socketpair(AF_UNIX, SOCK_STREAM, 0, ngx_processes[s].channel) == -1) // 创建匿名 socket channel
{
ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_errno,
“socketpair() failed while spawning \”%s\””, name);
return NGX_INVALID_PID;
}

ngx_channel = ngx_processes[s].channel[1];

ngx_process_slot = s;
pid = fork(); //fork 调用,子进程继承 socket channel

fork 之后,子进程继承了这对 socket。因为他们共享了相同的系统级打开文件,这时 master 进程写 channel[0],子进程就可以通过 channel[1] 读取到数据,master 进程写 channel[1],子进程就可以通过 channel[0] 读取到数据。子进程向 master 通信也是如此。这样在 fork N 个子进程之后,实际上会建立 N 个 socket channel,如图 5 所示。
图 5 -master 和子进程通过 socket channel 通信原理
在 nginx 中,对于 socket channel 的使用,总是使用 channel[0] 作为数据的发送端,channel[1] 作为数据的接收端。并且 master 进程和子进程的通信是单向的,因此在后续子进程初始化时关闭了 channel[0],只保留 channel[1] 即 ngx_channel。同时将 ngx_channel 的读事件添加到整个 nginx 高效的事件框架中(关于事件框架这里限于篇幅不多谈),最终实现了 master 进程向子进程消息的同步。
了解到这里,其实 socket channel 已经差不多了。但是还不是它的全部,nginx 源码中还提供了通过 socket channel 进行子进程之间互相通信的机制。不过目前来看,没有实际的使用。
让我们先思考一个问题:如果要实现 worker 之间的通信,难点在于什么?答案不难想到,master 进程 fork 子进程是有顺序的,fork 最后一个 worker 和 master 进程一样,知道所有的 worker 进程的 channel[0],因此它可以像 master 一样和其他的 worker 通信。但是第一个 worker 就很糟糕了,它只知道自己的 channel[0](而且还是被关闭了),也就是第一个 worker 无法主动向任意其他的 woker 进程通信。在图 6 中可以看到,对于第二个 worker 进程,仅仅知道第一个 worker 的 channel[0],因此仅仅可以和第一个 worker 进行通信。
图 6 - 第二个 worker 进程的 channel 示意图
nginx 是怎么解决这个问题的呢?简单来讲,nginx 使用了进程间传递文件描述符的技术。关于进程间传递文件描述符,这里关键的系统调用涉及到 2 个,socketpair 和 sendmsg,这里不细讲,有兴趣的可以参考下这篇文章:https://pureage.info/2015/03/…。
master 在每次 fork 新的 worker 的时候,都会通过 ngx_pass_open_channel 函数将新创建进程的 pid 以及的 socket channel 写端 channel[0] 传递给所有之前创建的 worker。上面提到的 NGX_CMD_OPEN_CHANNEL 就是用来做这件事的。worker 进程收到这个消息后,会解析消息的 pid 和 fd,存储到 ngx_processes 中相应 slot 下的 ngx_process_t 中。
这里 channel[1] 并没有被传递给子进程,因为 channel[1] 是接收端,每一个 socket channel 的 channe[1] 都唯一对应一个子进程,worker A 持有 worker B 的 channel[1],并没有任何意义。因此在子进程初始化时,会将之前 worker 进程创建的 channel[1] 全部关闭掉,只保留的自己的 channel[1]。最终,如图 7 所示,每一个 worker 持有自己的 channel 的 channel[1],持有着其他 worker 对应 channel 的 channel[0]。而 master 则持有者所有的 worker 对应 channel 的 channel[0] 和 channel[1](为什么这里 master 仍然保留着所有 channel 的 channe[1],没有想明白为什么,也许是为了在未来监听 worker 进程的消息)。
图 7 -socket channel 最终示意图
进程退出
这里进程退出包含多种场景:

1、worker 进程异常退出
2、系统管理员使用 nginx -s stop 或者 nginx -s quit 让进程全部退出
3、系统管理员使用信号 SIGINT,SIGTERM,SIGQUIT 等让进程全部退出
4、升级 binary 期间,新 master 进程退出(当发现重启的 nginx 有问题之后,可能会杀死新 master 进程)

对于场景 1,master 进程需要重新拉起新的 worker 进程。对于场景 2 和 3,master 进程需要等到所有的子进程退出后再退出(避免出现孤儿进程)。对于场景 4,本小节先不介绍,在后面会介绍 binary 升级。下面我们了解下 master 进程是如何实现前三个场景的。
处理子进程退出
子进程退出时,发送 SIGCHLD 信号给父进程,被信号处理器处理,会更新 ngx_reap 全局标记位,并且使用 waitpid 收集所有的子进程,设置 ngx_processes 中对应 slot 下的 ngx_process_t 中的 exited 为 1。然后,在主循环中使用 ngx_reap_children 函数,对子进程退出进行处理。这个函数非常重要,是理解进程退出的关键。
图 8 -ngx_reap_children 函数流程图
通过上图,可以看到 ngx_reap_children 函数的整体执行流程。它遍历 ngx_processes 数组里有效(pid 不等于 -1)的 worker 进程:

一、如果子进程的 exited 标志位为 1(即已退出并被 master 回收)

1、如果子进程是游离进程(detached 为 1)

1.1、如果退出的子进程是新 master 进程(升级 binary 时会 fork 一个新的 master 进程),会将旧的 pid 文件恢复,即恢复使用当前的 master 来服务【场景 4】

(1)如果当前 master 进程已经将它下面的 worker 都杀掉了(ngx_noaccepting 为 1),这时会修改全局标记位 ngx_restart 为 1,然后跳到步骤 1.c。在外层的主循环里,检测到这个标记位,master 进程便会重新 fork worker 进程
(2)如果当前的 master 进程还没有杀死他的子进程,直接跳到步骤 1.c

1.2、如果退出的子进程是其他进程,直接跳到步骤 1.c(实际上这种 case 不存在,因为目前看,所有的 detached 的进程都是新 master 进程。detached 只有在升级 binary 时才使用到)

2、如果子进程不是游离进程(detached 为 0),通过 socket channel 通知其他的 worker 进程 NGX_CMD_CLOSE_CHANNEL 指令,管道需要关闭(我要死了,以后不用给我打电话了)

2.1、如果子进程是需要复活的(进程标记 respawn 为 1,并没有收到过相关退出信号),那么 fork 新的 worker 进程取代死掉的子进程,并通过 socket channel 通知其他的 worker 进程 NGX_CMD_OPEN_CHANNEL 指令,新的 worker 已启动,请记录好新启动进程的 pid 和 channel[0](大家好,我是新 worker xxx,这是我的电话,有事随时 call me),同时置 live 为 1,表示还有存活的子进程,master 进程不可退出。然后继续遍历下一个进程【场景 1】
2.2、如果不需要复活,直接跳到步骤 1.c【场景 2 + 场景 3】

3、对于退出的进程,置 ngx_process_t 中的 pid 为 -1,继续遍历下一个进程

二、如果子进程 exited 标志为 0,即没有退出

1、如果子进程是非游离进程,那么更新 live 为 1,然后继续遍历下一个进程。live 为 1 表示还有存活的子进程,master 进程不可退出(对这里的判断条件 ngx_processes[i].exiting || !ngx_processes[i].detached 存疑,大部分 worker 都是非游离,游离的进程只有升级 binary 时的新 master 进程,但是新 master 退出时,并不会修改 exiting 为 1,所以个人觉得这里的 ngx_processes[i].exiting 的判断没有必要,只需要判断是否游离进程即可)
2、如果子进程是游离进程,那么忽略,遍历下一个进程。也就是说,master 并不会因为游离子进程没有退出,而停止退出的步伐。(在这种 case 下,游离进程就像别人家的孩子一样,master 不再关心)

最终,ngx_reap_children 会妥善的处理好各种场景的子进程退出,并且返回 live 的值。即告诉主循环,当前是否仍有存活的子进程存在。在主循环里,当!live && (ngx_terminate || ngx_quit) 条件满足时,master 进程就会做相应的进程退出工作(删除 pid 文件,调用每一个模块的 exit_master 函数,关闭监听的 socket,释放内存池)。
触发子进程退出
对于场景 2 和场景 3,当 master 进程收到 SIGTERM 或者 SIGQUIT 信号时,会在信号处理器中设置 ngx_terminate 或 ngx_quit 全局标记。当主循环检测到这 2 种标记时,会通过 socket channel 向所有的子进程广播消息,传递的指令分别是:NGX_CMD_TERMINATE 或 NGX_CMD_QUIT。子进程通过事件框架检测到该消息后,同样会设置 ngx_terminate 或者 ngx_quit 标记位为 1(注意这里是子进程的全局变量)。子进程的主循环里检测到 ngx_terminate 时,会立即做进程退出工作 (调用每一个模块的 exit_process 函数,释放内存池),而检测到 ngx_quit 时,情况会稍微复杂些,需要释放连接,关闭监听 socket,并且会等待所有请求以及定时事件都被妥善的处理完之后,才会做进程退出工作。
这里可能会有一个隐藏的问题:进程的退出可能没法被一次 waitpid 全部收集到,有可能有漏网之鱼还没有退出,需要等到下次的 suspend 才能收集到。如果按照上面的逻辑,可能存在重复给子进程发送退出指令的问题。nginx 比较严谨,针对这个问题有自己的处理方式:

ngx_quit:一旦给某一个 worker 进程发送了退出指令(强制退出或平滑退出),会记录该进程的 exiting 为 1,表示这个进程正在退出。以后,如果还要再给该进程发送退出 NGX_CMD_QUIT 指令,一旦发现这个标记位为 1,那么就忽略。这样就可以保证一次平滑退出,针对每一个 worker 只通知一次,不重复通知。
ngx_terminate:和 ngx_quit 略有不同,它不依赖 exiting 标记位,而是通过 sigio 的临时变量(不是 SIGIO 信号)来缓解这个问题。在向 worker 进程广播 NGX_CMD_TERMINATE 之前,会置 sigio 为 worker 进程数 +2(2 个 cache 进程),每次信号到来(假设每次到来的信号都是 SIGCHLD,并且只 wait 了一个子进程退出),sigio 会减一。直到 sigio 为 0,又会重新广播 NGX_CMD_TERMINATE 给 worker 进程。sigio 大于 0 的期间,master 是不会重复给 worker 发送指令的。(这里只是缓解,并没有完全屏蔽掉重复发指令的问题,至于为什么没有像 ngx_quit 一样处理,不是很明白这么设计的原因)

ngx_terminate 的 timeout 机制
还记得上面提到的 delay 吗?这个变量只有在 ngx_terminate 为 1 时才大于 0,那么它是用来干什么的?实际上,它用来在进程强制退出时做倒计时使用。
master 进程为了保证所有的子进程最终都会退出,会给子进程一定的时间,如果那时候仍有子进程没有退出,会直接使用 SIGKILL 信号杀死所有子进程。
当最开始 master 进程处理 ngx_terminate(第一次收到 SIGTERM 或者 SIGINT 信号)时,会将 delay 从 0 改为 50ms。在下一个主循环的开始将设置一个时间为 50ms 的定时器。然后等待信号的到来。这时,子进程可能会陆续退出产生 SIGCHLD 信号。理想的情况下,这一个 sigsuspend 信号处理周期里面,将全部的子进程进行回收,那么 master 进程就可以立刻全身而退了,如图 9 所示:
图 9 - 理想退出情况
当然,糟糕的情况总是会发生,这期间没有任何 SIGCHLD 信号产生,直到 50ms 到了产生 SIGALRM 信号,SIGALRM 产生后,会将 sigio 重置为 0,并将 delay 翻倍,设置一个新的定时器。当下个 sigsuspend 周期进来的时候,由于 sigio 为 0,master 进程会再次向 worker 进程广播 NGX_CMD_TERMINATE 消息(催促 worker 进程尽快退出)。如此往复,直到所有的子进程都退出,或者 delay 超过 1000ms 之后,master 直接通过 SIGKILL 杀死子进程。
图 10- 糟糕的退出场景 timeout 机制
配置重新加载
nginx 支持在不停止服务的情况下,重新加载配置文件并生效。通过 nginx -s reload 即可。通过前面可以看到,nginx -s reload 实际上是向 master 进程发送 SIGHUP 信号,信号处理器会置 ngx_reconfigure 为 1。
当主循环检测到 ngx_reconfigure 为 1 时,首先调用 ngx_init_cycle 函数构造一个新的生命周期 cycle 对象,重新加载配置文件。然后根据新的配置里设定的 worker_processes 启动新的 worker 进程。然后 sleep 100ms 来等待着子进程的启动和初始化,更新 live 为 1,最后,通过 socket channel 向旧的 worker 进程发送 NGX_CMD_QUIT 消息,让旧的 worker 优雅退出。
if (ngx_reconfigure) {
ngx_reconfigure = 0;

if (ngx_new_binary) {
ngx_start_worker_processes(cycle, ccf->worker_processes,
NGX_PROCESS_RESPAWN);
ngx_start_cache_manager_processes(cycle, 0);
ngx_noaccepting = 0;

continue;
}

ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0, “reconfiguring”);

cycle = ngx_init_cycle(cycle);
if (cycle == NULL) {
cycle = (ngx_cycle_t *) ngx_cycle;
continue;
}

ngx_cycle = cycle;
ccf = (ngx_core_conf_t *) ngx_get_conf(cycle->conf_ctx,
ngx_core_module);
ngx_start_worker_processes(cycle, ccf->worker_processes, //fork 新的 worker 进程
NGX_PROCESS_JUST_RESPAWN);
ngx_start_cache_manager_processes(cycle, 1);

/* allow new processes to start */
ngx_msleep(100);

live = 1;
ngx_signal_worker_processes(cycle, // 让旧的 worker 进程退出
ngx_signal_value(NGX_SHUTDOWN_SIGNAL));
}
可以看到,nginx 并没有让旧的 worker 进程重新 reload 配置文件,而是通过新进程替换旧进程的方式来完成了配置文件的重新加载。
对于 master 进程来说,如何区分新的 worker 进程和旧的 worker 进程呢?在 fork 新的 worker 时,传入的 flag 是 NGX_PROCESS_JUST_RESPAWN,传入这个标记之后,fork 的子进程的 just_spawn 和 respawn2 个标记会被置为 1。而旧的 worker 在 fork 时传入的 flag 是 NGX_PROCESS_RESPAWN,它只会将 respawn 标记置为 1。因此,在通过 socket channel 发送 NGX_CMD_QUIT 命令时,如果发现子进程的 just_spawn 标记为 1,那么就会忽略该命令(要不然新的 worker 进程也会被无辜杀死了),然后 just_spwan 标记会恢复为 0(不然未来 reload 时,就无法区分新旧 worker 了)。
细心的同学还可以看到,在上面还有一个当 ngx_new_binary 为真时的逻辑分支,它竟然直接使用旧的配置文件,fork 新的子进程就 continue 了。对于这段代码我得理解是这样:
ngx_new_binary 上面提到过,是升级 binary 时的新 master 进程的 pid,这个场景应该是正在升级 binary 过程中,旧的 master 进程还没有推出。如果这时通过 nginx -s reload 去重新加载配置文件,只会给新的 master 进程发送 SIGHUP 信号(因为这时的 pid 文件记录的新 master 进程的 pid),因此走到这个逻辑分支,说明是手动使用 kill -HUP 发送给旧的 master 进程的,对于升级中这个中间过程,旧的 master 进程并没有重新加载最新的配置文件,因为没有必要,旧的 master 和旧 worker 进行最终的归宿是被杀死,所以这里就简单的 fork 了下,其实这里我觉得旧 master 进程忽略这个信号也未尝不可。
重新打开文件
在日志切分场景,重新打开文件这个 feature 非常有用。线上 nginx 服务产生的日志量是巨大的,随着时间的累积,会产生超大文件,对于排查问题非常不方便。
所以日志切割很有必要,那么日志是如何切割的?直接 mv nginx.log nginx.log.xxx,然后再新建一个 nginx.log 空文件,这样可行吗?答案当然是否。这涉及到 fd,打开文件表和 inode 的概念。在这里简单描述下:
见图 11(引用网络图片),fd 是进程级别的,fd 会指向一个系统级的打开文件表中的一个表项。这个表项如果指代的是磁盘文件的话,会有一个指向磁盘 inode 节点的指针,并且这里还会存储文件偏移量等信息。磁盘文件是通过 inode 进行管理的,inode 里会存储着文件的 user、group、权限、时间戳、硬链接以及指向数据块的指针。进程通过 fd 写文件,最终写到的是 inode 节点对应的数据区域。如果我们通过 mv 命令对文件进行了重命名,实际上该 fd 与 inode 之间的映射链路并不会受到影响,也就是最终仍然向同一块数据区域写数据,最终表现就是,nginx.log.xxx 中日志仍然会源源不断的产生。而新建的 nginx.log 空文件,它对应的是另外的 inode 节点,和 fd 毫无关系,因此,nginx.log 不会有日志产生的。
图 11-fd、打开文件表、inode 关系(引用网络图片)
那么我们一般要怎么切割日志呢?实际上,上面的操作做对了一半,mv 是没有问题的,接下来解决内存中 fd 映射到新的 inode 节点就可以搞定了。所以这就是重新打开文件发挥作用的时候了。
向 master 进程发送 SIGUSR1 信号,在信号处理器里会置 ngx_reopen 全局标记为 1。当主循环检测到 ngx_reopen 为 1 时,会调用 ngx_reopen_files 函数重新打开文件,生成新的 fd,然后关闭旧的 fd。然后通过 socket channel 向所有 worker 进程广播 NGX_CMD_REOPEN 指令,worker 进程针对 NGX_CMD_REOPEN 指令也采取和 master 一样的动作。
对于日志分割场景,重新打开之后的日志数据就可以在新的 nginx.log 中看到了,而 nginx.log.xxx 也不再会有数据写入,因为相应的 fd 都已 close。
升级 binary
nginx 支持不停止服务的情况下,平滑升级 nginx binary 程序。一般的操作步骤是:
– 1、先向 master 进程发送 SIGUSR2 信号,产生新的 master 和新的 worker 进程。(注意这时同时存在 2 个 master+worker 集群)

– 2、向旧的 master 进程发送 SIGWINCH 信号,这样旧的 worker 进程就会全部退出。

– 3、新的集群如果服务正常的话,就可以向旧的 master 进程发送 SIGQUIT 信号,让它退出。

master 进程收到 SIGUSR2 信号后,信号处理器会置 ngx_change_binary 为 1。主循环检测到该标记位后,会调用 ngx_exec_new_binary 函数产生一个新的 master 进程,并且将新 master 进程的 pid 赋值给 ngx_new_binary。
让我们看下 ngx_exec_new_binary 如何产生新 master 进程的。首先会构建一个 ngx_exec_ctx_t 类型的临时变量 ctx,ngx_exec_ctx_t 结构体如下:“typedef struct {
char *path; //binary 路径
char *name; // 新进程名称
char *const *argv; // 参数
char *const *envp; // 环境变量
} ngx_exec_ctx_t;“ 如图 12 所示,所示将 ctx.path 置为启动 master 进程的 nginx 程序路径,比如 ”/home/xiaoju/nginx-jiweibin/sbin/nginx”,ctx.name 置为 ”new binary process”,ctx.argv 置为 nginx main 函数执行时传入的参数集合。对于环境变量,除了继承当前 master 进程的环境变量外,会构造一个名为 NGINX 的环境变量,它的取值是所有监听的 socket 对应 fd 按 ”;” 分割,例如:NGINX=”8;9;10;…”。这个环境变量很关键,下面会提到它的作用。
图 12-ngx_exec_ctx_t ctx 示意图
构造完 ctx 后,将 pid 文件重命名,后面加上 ”.old” 后缀。然后调用 ngx_execute 函数。这个函数内部会通过 ngx_spawn_process 函数 fork 一个新的子进程,该进程的标记 detached 为 1,表示是游离进程。该子进程一旦启动后,会执行 ngx_execute_proc 函数,这里会执行 execve 系统调用,重新执行 ctx.path,即 exec nginx 程序。这样,新的 master 进程就通过 fork+execve2 个系统调用启动起来了。随后,新 master 进程会启动新的的 worker 进程。
ngx_pid_t
ngx_execute(ngx_cycle_t *cycle, ngx_exec_ctx_t *ctx)
{
return ngx_spawn_process(cycle, ngx_execute_proc, ctx, ctx->name, //fork 新的子进程
NGX_PROCESS_DETACHED);
}

static void
ngx_execute_proc(ngx_cycle_t *cycle, void *data) //fork 新的 mast
{
ngx_exec_ctx_t *ctx = data;

if (execve(ctx->path, ctx->argv, ctx->envp) == -1) {
ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_errno,
“execve() failed while executing %s \”%s\””,
ctx->name, ctx->path);
}

exit(1);
}
其实这里是有一个问题要解决的:旧的 master 进程对于 80,8080 这种监听端口已经 bind 并且 listen 了,如果新的 master 进程进行同样的 bind 操作,会产生类似这种错误:nginx: [emerg] bind() to 0.0.0.0:8080 failed (98: Address already in use)。所以,master 进程是如何做到监听这些端口的呢?
让我们先了解 exec(execve 是 exec 系列系统调用的一种 ) 这个系统调用,它并不改变进程的 pid,但是它会用新的程序(这里还是 nginx)替换现有进程的代码段,数据段,BSS,堆,栈。比如 ngx_processes 这个全局变量,它处于 BSS 段,在 exec 之后,这个数据会清空,新的 master 不会通过 ngx_processes 数组引用到旧的 worker 进程。同理,存储着所有监听的数据结构 cycle.listening 由于在进程的堆上,同样也会清空。但 fd 比较特殊,对于进程创建的 fd,exec 之后仍然有效 (除非设置了 FD_CLOEXEC 标记,nginx 的打开的相关文件都设置了这个标记,但监听 socket 对应的 fd 没有设置)。所以旧的 master 打开了某一个 80 端口的 fd 假设是 9,那么在新的 master 进程,仍然可以继续使用这个 fd。所以问题就变成了,如何让新的 master 进程知道这些 fd 的存在,并重新构建 cycle.listening 数组?
这就用到了上面提到的 NGINX 这个环境变量,它将所有的 fd 通过 NGINX 传递给新 master 进程,新 master 进程看到这个环境变量后,就可以根据它的值,重新构建 cycle.listening 数组啦。代码如下:
static ngx_int_t
ngx_add_inherited_sockets(ngx_cycle_t *cycle)
{
u_char *p, *v, *inherited;
ngx_int_t s;
ngx_listening_t *ls;

inherited = (u_char *) getenv(NGINX_VAR);

if (inherited == NULL) {
return NGX_OK;
}

ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0,
“using inherited sockets from \”%s\””, inherited);

if (ngx_array_init(&cycle->listening, cycle->pool, 10,
sizeof(ngx_listening_t))
!= NGX_OK)
{
return NGX_ERROR;
}

for (p = inherited, v = p; *p; p++) {
if (*p == ‘:’ || *p == ‘;’) {
s = ngx_atoi(v, p – v);
if (s == NGX_ERROR) {
ngx_log_error(NGX_LOG_EMERG, cycle->log, 0,
“invalid socket number \”%s\” in ” NGINX_VAR
” environment variable, ignoring the rest”
” of the variable”, v);
break;
}

v = p + 1;

ls = ngx_array_push(&cycle->listening);
if (ls == NULL) {
return NGX_ERROR;
}

ngx_memzero(ls, sizeof(ngx_listening_t));

ls->fd = (ngx_socket_t) s;
}
}

ngx_inherited = 1;

return ngx_set_inherited_sockets(cycle);
}
这里还有一个需要知道的细节,旧 master 进程 fork 子进程并 exec nginx 程序之后,并不会像上面的 daemon 模式一样,再 fork 一个子进程作为 master,因为这个子进程不属于任何终端,不会随着终端退出而退出,因此这个 exec 之后的子进程就是新 master 进程,那么 nginx 程序是如何区分这 2 种启动模式的呢?同样也是基于 NGINX 这个环境变量,如上面代码所示,如果存在这个环境变量,ngx_inherited 会被置为 1,当 nginx 检测到这个标记位为 1 时,就不会再 fork 子进程作为 master 了,而是本身就是 master 进程。
当旧的 master 进程收到 SIGWINCH 信号,信号处理器会置 ngx_noaccept 为 1。当主循环检测到这个标记时,会置 ngx_noaccepting 为 1,表示旧的 master 进程下的 worker 进程陆续都会退出,不再对外服务了。然后通过 socket channel 通知所有的 worker 进程 NGX_CMD_QUIT 指令,worker 进程收到该指令,会优雅的退出(注意,这里的 worker 进程是指旧 master 进程管理的 worker 进程,为什么通知不到新的 worker 进程,大家可以想下为什么)。
最后,当新的 worker 进程服务正常之后,可以放心的杀死旧的 master 进程了。为什么不通过 SIGQUIT 一步杀死旧的 master+worker 呢?之所以不这么做,是为了可以随时回滚。当我们发现新的 binary 有问题时,如果旧的 master 进程被我干掉了,我们还要使用 backup 的旧的 binary 再启动,这个切换时间一旦过长,会造成比较严重的影响,可能更糟糕的情况是你根本没有对旧的 binary 进程备份,这样就需要回滚代码,重新编译,安装。整个回滚的时间会更加不可控。所以,当我们再升级 binary 时,一般都要留着旧 master 进程,因为它可以按照旧的 binary 随时重启 worker 进程。
还记得上面讲到子进程退出的逻辑吗,新的 master 进程是旧 master 进程的 child,当新 master 进程退出,并且 ngx_noaccepting 为 1,即旧 master 进程已经杀了了它的 worker(不包括新 master,因为它是 detached),那么会置 ngx_restart 为 1,当主循环检测到这个全局标记位,会再次启动 worker 进程,让旧的 binary 恢复工作。
if (ngx_restart) {
ngx_restart = 0;
ngx_start_worker_processes(cycle, ccf->worker_processes,
NGX_PROCESS_RESPAWN);
ngx_start_cache_manager_processes(cycle, 0);
live = 1;
}
七、总结
本篇 wiki 分析了 master 进程启动,基于信号的事件循环架构,基于各种标记位的相应进程的管理,包括进程退出,配置文件变更,重新打开文件,升级 binary 以及 master 和 worker 通信的一种方式之一:socket channel。希望大家有所收获。

正文完
 0