【Nginx源碼研究】Master進程淺析

運營研發團隊 季偉濱html

1、前言

衆所周如,Nginx是多進程架構。有1個master進程和N個worker進程,通常N等於cpu的核數。另外, 和文件緩存相關,還有cache manager和cache loader進程。 node

master進程並不處理網絡請求,網絡請求是由worker進程來處理,而master進程負責管理這些worker進程。好比當一個worker進程意外掛掉了,他負責拉起新的worker進程,又好比通知全部的worker進程平滑的退出等等。本篇wiki將簡單分析下master進程是如何作管理工做的。linux

2、nginx進程模式

在開始講解master進程以前,咱們須要首先知道,其實Nginx除了生產模式(多進程+daemon)以外,還有其餘的進程模式,雖然這些模式通常都是爲了研發&調試使用。nginx

非daemon模式

以非daemon模式啓動的nginx進程並不會馬上退出。其實在終端執行非bash內置命令,終端進程會fork一個子進程,而後exec執行咱們的nginx bin。而後終端進程自己會進入睡眠態,等待着子進程的結束。在nginx的配置文件中,配置【daemon off;】便可讓進程模式切換到前臺模式。shell

下圖展現了一個測試例子,將worker的個數設置爲1,開啓非daemon模式,開啓2個終端pts/0和pts/1。在pts/1上執行nginx,而後在pts/0上看進程的狀態,能夠看到終端進程進入了阻塞態(睡眠態)。這種狀況下啓動的master進程,它的父進程是當前的終端進程(/bin/bash),隨着終端的退出(好比ctrl+c),全部nginx進程都會退出。編程

clipboard.png

clipboard.png

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,能夠忽略)bash

clipboard.png

這裏和非daemon模式的一個很大區別是啓動程序(終端進程的子進程)會馬上退出,並被終端進程這個父進程回收。同時會產生master這種daemon進程,能夠看到master進程的父進程id是1,也就是init或systemd進程。這樣,隨着終端的退出,master進程仍然能夠繼續服務,由於master進程已經和啓動nginx命令的終端shell進程無關了。 網絡

啓動nginx命令,是如何生成daemon進程並退出的呢?答案很簡單,一樣是fork系統調用。它會複製一個和當前啓動進程具備相同代碼段、數據段、堆和棧、fd等信息的子進程(儘管cow技術使得複製發生在須要分離那一刻),參見圖-1。

clipboard.png
圖1-生產模式Nginx進程啓動示意圖

3、master執行流程

master進程被fork後,繼續執行ngx_master_process_cycle函數。這個函數主要進行以下操做:

  • 一、設置進程的初始信號掩碼,屏蔽相關信號
  • 二、fork子進程,包括worker進程和cache manager進程、cache loader進程
  • 三、進入主循環,經過sigsuspend系統調用,等待着信號的到來。一旦信號到來,會進入信號處理程序。信號處理程序執行以後,程序執行流程會判斷各類狀態位,來執行不一樣的操做。

clipboard.png
圖2- ngx_master_process_cycle執行流程示意圖

4、信號介紹

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

5、基於信號的事件架構

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字段表示信號處理器函數指針。

下面是針對不一樣的信號安裝的信號處理器列表:

clipboard.png

經過上表,能夠看到,在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

6、主循環

進程數據結構

在展開說明以前,咱們須要瞭解下,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所示:

clipboard.png

圖3-gdb單worker進程下ngx_processes和ngx_last_process的結果

全局標記

上面咱們提到ngx_signal_handler這個函數,它是nginx爲捕獲的信號安裝的通用信號處理器。它都幹了什麼呢?很簡單,它只是用來標記對應的全局標記位爲1,這些標記位,後續的主循環裏會使用到,根據不一樣的標記位,執行不一樣的邏輯。

master進程對應的信號與全局標記位的對應關係以下表:

clipboard.png

對於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:

clipboard.png
圖4-主循環邏輯流程

上面的流程圖,整體仍是比較複雜的,根據具體的場景去分析會更加清晰一些。在此以前,下面先就圖上一些須要描述的給予解釋說明:

  • 一、臨時變量live,它表示是否仍有存活的子進程。只有當ngx_processes中全部的子進程的exited標記位都爲1時,live纔等於0。而master進程退出的條件是【!live && (ngx_terminate || ngx_quit)】,即全部的子進程都已退出,而且接收到SIGTERM、SIGINT或者SIGQUIT信號時,master進程纔會正常退出(經過SIGKILL信號殺死master通常在異常狀況下使用,這裏不算)。
  • 二、在循環的一開始,會判斷delay是否大於0,這個delay其實只和ngx_terminate即強制退出的場景有關係。在後面會詳細講解。
  • 三、ngx_terminate、ngx_quit、ngx_reopen這3種標記,master進程都會經過上面提到的socket channel向子進程進行廣播。若是寫socket失敗,會執行kill系統調用向子進程發送信號。而其餘的case,master會直接執行kill系統調用向子進程發送信號,好比發送SIGKILL。關於socket channel,後續會進行講解。
  • 四、除了和信號直接映射的標記位,咱們看到,流程圖中還有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所示。

clipboard.png
圖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進行通訊。

clipboard.png
圖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進程的消息)。

clipboard.png
圖7-socket channel最終示意圖

進程退出

這裏進程退出包含多種場景:

  • 一、worker進程異常退出
  • 二、系統管理員使用nginx -s stop或者nginx -s quit讓進程所有退出
  • 三、系統管理員使用信號SIGINT,SIGTERM,SIGQUIT等讓進程所有退出
  • 四、升級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函數,對子進程退出進行處理。這個函數很是重要,是理解進程退出的關鍵。

clipboard.png
圖8-ngx_reap_children函數流程圖

經過上圖,能夠看到ngx_reap_children函數的總體執行流程。它遍歷ngx_processes數組裏有效(pid不等於-1)的worker進程:

  • 1、若是子進程的exited標誌位爲1(即已退出並被master回收)

    • 一、若是子進程是遊離進程(detached爲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.二、若是退出的子進程是其餘進程,直接跳到步驟1.c(實際上這種case不存在,由於目前看,全部的detached的進程都是新master進程。detached只有在升級binary時才使用到)
    • 二、若是子進程不是遊離進程(detached爲0),經過socket channel通知其餘的worker進程NGX_CMD_CLOSE_CHANNEL指令,管道須要關閉(我要死了,之後不用給我打電話了)

      • 2.一、若是子進程是須要復活的(進程標記respawn爲1,並無收到過相關退出信號),那麼fork新的worker進程取代死掉的子進程,並經過socket channel通知其餘的worker進程NGX_CMD_OPEN_CHANNEL指令,新的worker已啓動,請記錄好新啓動進程的pid和channel[0](你們好,我是新worker xxx,這是個人電話,有事隨時call me),同時置live爲1,表示還有存活的子進程,master進程不可退出。而後繼續遍歷下一個進程【場景1】
      • 2.二、若是不須要復活,直接跳到步驟1.c【場景2+場景3】
    • 三、對於退出的進程,置ngx_process_t中的pid爲-1,繼續遍歷下一個進程
  • 2、若是子進程exited標誌爲0,即沒有退出

    • 一、若是子進程是非遊離進程,那麼更新live爲1,而後繼續遍歷下一個進程。live爲1表示還有存活的子進程,master進程不可退出(對這裏的判斷條件ngx_processes[i].exiting || !ngx_processes[i].detached存疑,大部分worker都是非遊離,遊離的進程只有升級 binary時的新master進程,可是新master退出時,並不會修改exiting爲1,因此我的以爲這裏的ngx_processes[i].exiting的判斷沒有必要,只須要判斷是否遊離進程便可)
    • 二、若是子進程是遊離進程,那麼忽略,遍歷下一個進程。也就是說,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所示:

clipboard.png
圖9-理想退出狀況

固然,糟糕的狀況老是會發生,這期間沒有任何SIGCHLD信號產生,直到50ms到了產生SIGALRM信號,SIGALRM產生後,會將sigio重置爲0,並將delay翻倍,設置一個新的定時器。當下個sigsuspend週期進來的時候,因爲sigio爲0,master進程會再次向worker進程廣播NGX_CMD_TERMINATE消息(催促worker進程儘快退出)。如此往復,直到全部的子進程都退出,或者delay超過1000ms以後,master直接經過SIGKILL殺死子進程。

clipboard.png
圖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不會有日誌產生的。

clipboard.png
圖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程序。通常的操做步驟是:

- 一、先向master進程發送SIGUSR2信號,產生新的master和新的worker進程。(注意這時同時存在2個master+worker集羣)

    - 二、向舊的master進程發送SIGWINCH信號,這樣舊的worker進程就會所有退出。

    - 三、新的集羣若是服務正常的話,就能夠向舊的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;..."。這個環境變量很關鍵,下面會提到它的做用。

clipboard.png
圖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;
}

7、總結

本篇wiki分析了master進程啓動,基於信號的事件循環架構,基於各類標記位的相應進程的管理,包括進程退出,配置文件變動,從新打開文件,升級binary以及master和worker通訊的一種方式之一:socket channel。但願你們有所收穫。

相關文章
相關標籤/搜索