摘要:內核代表進程來執行信號處理器函數,當處理器返回時,主程序會在處理器被中斷的位置恢復執行。進程信號掩碼內核會為每個進程維護一個信號掩碼。這個競態條件發生在主程序和信號處理器對同一個被解除信號的競爭關系。
運營研發團隊 季偉濱
一、前言眾所周如,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進程被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 channelnginx中進程之間通信的方式有多種,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。
升級binarynginx支持不停止服務的情況下,平滑升級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。希望大家有所收獲。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/40136.html
摘要:而對于堆內存,通常需要程序員進行管理。二內存池管理說明本部分使用的版本為具體源碼參見文件實現使用流程內存池的使用較為簡單可以分為步,調用函數獲取指針。將內存塊按照的整數次冪進行劃分最小為最大為。 運營研發團隊 施洪寶 一. 概述 應用程序的內存可以簡單分為堆內存,棧內存。對于棧內存而言,在函數編譯時,編譯器會插入移動棧當前指針位置的代碼,實現棧空間的自管理。而對于堆內存,通常需要程序...
摘要:的部分是基于以及協議的。例如父進程向中寫入子進程從中讀取子進程向中寫入父進程從中讀取。默認使用對進程進行分配交給對應的線程進行監聽線程收到某個進程的數據后會進行處理值得注意的是這個線程可能并不是發送請求的那個線程。 作者:施洪寶 一. 基礎知識 1.1 swoole swoole是面向生產環境的php異步網絡通信引擎, php開發人員可以利用swoole開發出高性能的server服務。...
摘要:在中,用戶主要通過配置文件的塊來控制和調節事件模塊的參數。中,事件會使用結構體來表示。初始化定時器,該定時器就是一顆紅黑樹,根據時間對事件進行排序。 運營研發團隊 譚淼 一、nginx模塊介紹 高并發是nginx最大的優勢之一,而高并發的原因就是nginx強大的事件模塊。本文將重點介紹nginx是如果利用Linux系統的epoll來完成高并發的。 首先介紹nginx的模塊,nginx...
摘要:可以通過等方式按照協議通信。上述都需要發送結束包。函數所需的變量在進入該函數之前認為已經初始化完成。和都有自己的,且互不干涉,后續發送的序列號以此為基準。 運營研發團隊 施洪寶 一. FastCGI協議簡介 1.1 簡介 FastCGI(Fast Common Gateway Interface, 快速通用網關接口)是一種通信協議。可以通過Unix Domain Socket, Na...
閱讀 3077·2023-04-26 00:53
閱讀 3522·2021-11-19 09:58
閱讀 1693·2021-09-29 09:35
閱讀 3279·2021-09-28 09:46
閱讀 3852·2021-09-22 15:38
閱讀 2692·2019-08-30 15:55
閱讀 3006·2019-08-23 14:10
閱讀 3822·2019-08-22 18:17