Linux 守護進程的實現

Eloisa8912 9年前發布 | 11K 次閱讀 Linux 進程

昨天突然地來了場面試,讓我有點不知所措,好在好多好多天前復習了下,但是自感表現不是很好(面試官的聲音比較柔和,更是讓我不知所措)。詢問了做過的項目后,看我簡歷上有寫 Linux 進程相關的經歷,就開始追問了,從 IPC 到 Redis 再到 Nginx 模塊開發、網絡編程,還問了下 Golang(老實講,Go 初學,只照著官網文檔看了一遍,幾天后忘得差不多了),最后問了個問題,讓我遺憾了好多天,就是本文的題目:如何實現守護進程?

本來這個應該知道的,前面看過 Nginx 和 Redis 基礎架構,都是以 Daemon 的方式運行的。但是當時沒查詞典 “Daemon” 是什么意思,然而又感覺這個名詞好像在哪里見過,結果便懵了,只能說不知道“守護進程”這個東西……歸根到底還是因為沒有相關服務端開發經驗惹的禍。這不禁讓我記起當年老大問我“SQL綁定變量”的原因是什么,只記得當時臉紅過關羽;哎,只知道這樣用,卻不知這個東西叫啥……

那么守護進程到底是做什么的呢?該如何實現呢?經過一番深刻地反省和檢討之后,博主認真學習怎么去實現守護進程。

守護進程 Daemon

守護進程,也即通常所說的 Daemon 進程,是 Linux 下一種特殊的后臺服務進程,它獨立于控制終端并且周期性的執行某種任務或者等待處理某些發生的事件。守護進程通常在系統引導裝入時啟動,在系統關閉時終止。Linux 系統下大多數服務都是通過守護進程實現的。

守護進程的名稱通常以 “d” 結尾,如 “httpd”、“crond”、“mysqld”等。

控制終端是什么?
終端是用戶與操作系統進行交流的界面。在 Linux 系統中,用戶由終端登錄系統登入系統后會得到一個 shell 進程,這個終端便成為這個 shell 進程的控制終端(Controlling Terminal)。shell 進程啟動的其他進程,由于復制了父進程的信息,因此也都同依附于這個控制終端。
從終端啟動的進程都依附于該終端,并受終端控制和影響。終端關閉,相應的進程都會自動關閉。守護進程脫離終端的目的,也即是不受終端變化的影響不被終端打斷,當然也不想在終端顯示執行過程中的信息。

如果不想進程受到用戶、終端或其他變化的影響,就必須把它變成守護進程。

如何實現守護進程

守護進程屬于 Linux 進程管理的范疇。其首要的特性是后臺運行;其次,要與從啟動它的父進程的運行環境隔離開來,需要處理的內容大致包括會話、控制終端、進程組、文件描述符、文件權限掩碼以及工作目錄等。
守護進程可以在 Linux 啟動時從腳本 /etc/rc.d 啟動,也可以由作業規劃進程 crond 啟動,還可以通過用戶終端(一般是 Shell)啟動。

實現一個守護進程,其實就是將普通進程按照上述特性改造為守護進程的過程。
需要注意的一點是,不同版本的 Unix 系統其實現機制不同,BSD 和 Linux 下的實現細節就不同。

根據上述的特性,我們便可以創建一個簡單的守護進程,這里以 Linux 系統下從終端 Shell 來啟動為例。

1、創建子進程,父進程退出

編寫守護進程第一步,就是要使得進程獨立于終端后臺運行。為避免終端掛起,將父進程退出,造成程序已經退出的假象,而后面的工作都在子進程完成,這樣控制終端也可以繼續執行其他命令,從而在形式上脫離控制終端的控制。

由于父進程先于子進程退出,子進程就變為孤兒進程,并由 init 進程作為其父進程收養。

2、子進程創建新會話

經過上一步,子進程已經后臺運行,然而系統調用 fork 創建子進程,子進程便復制了原父進程的進程控制塊(PCB),相應地繼承了一些信息,包括會話、進程組、控制終端等信息。盡管父進程已經退出,但子進程的會話、進程組、控制終端的信息沒有改變。為使子進程完全擺脫父進程的環境,需要調用 setsid 函數。

這里有必要說一下兩個概念:會話進程組

進程組:一個或多個進程的集合。擁有唯一的標識進程組 ID,每個進程組都有一個組長進程,該進程的進程號等于其進程組的 ID。進程組 ID 不會因組長進程退出而受到影響,fork 調用也不會改變進程組 ID。

會話:一個或多個進程組的集合。新建會話時,當前進程(會話中唯一的進程)成為會話首進程,也是當前進程組的組長進程,其進程號為會話 ID,同樣也是該進程組的 ID。它通常是登錄 shell,也可以是調用 setsid 新建會話的孤兒進程。
注意:組長進程調用 setsid ,則出錯返回,無法新建會話。

通常,會話開始于用戶登錄,終止于用戶退出,期間的所有進程都屬于這個會話。一個會話一般包含一個會話首進程、一個前臺進程組和一個后臺進程組,控制終端可有可無;此外,前臺進程組只有一個,后臺進程組可以有多個,這些進程組共享一個控制終端。

  • 前臺進程組:
    該進程組中的進程可以向終端設備進行讀、寫操作(屬于該組的進程可以從終端獲得輸入)。該進程組的 ID 等于控制終端進程組 ID,通常據此來判斷前臺進程組。

  • 后臺進程組:
    會話中除了會話首進程和前臺進程組以外的所有進程,都屬于后臺進程組。該進程組中的進程只能向終端設備進行寫操作

下圖為會話、進程組、進程和控制終端之間的關系(登錄 shell 進程本身屬于一個單獨的進程組)。

Linux 守護進程的實現

想了解更多關于會話 Sessions 內容,可以認真讀一下 APUE 這本書。

如果調用進程非組長進程,那么就能創建一個新會話:

  • 該進程變成新會話的首進程
  • 該進程成為一個新進程組的組長進程
  • 該進程沒有控制終端,如果之前有,則會被中斷(會話過程對控制終端的獨占性

也就是說:組長進程不能成為新會話首進程,新會話首進程必定成為組長進程

到此為止,我們熟悉了會話與進程間的關系,那么如何新建一個會話呢?

通過調用 setsid 函數可以創建一個新會話,調用進程擔任新會話的首進程,其作用有:

  • 使當前進程脫離原會話的控制
  • 使當前進程脫離原進程組的控制
  • 使當前進程脫離原控制終端的控制

這樣,當前進程才能實現真正意義上完全獨立出來,擺脫其他進程的控制。

另外,要提一下,盡管進程變成無終端的會話首進程,但是它仍然可以重新申請打開一個控制終端。可以通過再次創建子進程結束當前進程,使進程不再是會話首進程來禁止進程重新打開控制終端。

3、改變當前目錄為根目錄

直接調用 chdir 函數切換到根目錄下。
由于進程運行過程中,當前目錄所在的文件系統(如:“/mnt/usb”)是不能卸載的,為避免對以后的使用造成麻煩,改變工作目錄為根目錄是必要的。如有特殊需要,也可以改變到特定目錄,如“/tmp”。

4、重設文件權限掩碼

fork 函數創建的子進程,繼承了父進程的文件操作權限,為防止對以后使用文件帶來問題,需要重設文件權限掩碼

文件權限掩碼,設定了文件權限中要屏蔽掉的對應位。這個跟文件權限的八進制數字模式表示差不多,將現有存取權限減去權限掩碼(或做異或運算),就可產生新建文件時的預設權限。

調用 umask 設置文件權限掩碼,通常是重設為 0,清除掩碼,這樣可以大大增強守護進程的靈活性。

5、關閉文件描述符

同文件權限掩碼一樣,子進程可能繼承了父進程打開的文件,而這些文件可能永遠不會被用到,但它們一樣消耗系統資源,而且可能導致所在的文件系統無法卸下,因此需要一一關閉它們。由于守護進程脫離了終端運行,因此標準輸入、標準輸出、標準錯誤輸出這3個文件描述符也要關閉。通常按如下方式來關閉:

for (i=0; i < MAXFILE; i++)
    close(i);

這里要注意下,param.h 頭文件中定義了一個常量 NOFILE,表示最大允許的文件描述符,但是我們盡量不要用它,而是通過調用函數 getdtablesize 返回進程文件描述符表中的項數(即打開的文件數目):

/* The following are not really correct but it is a value we used for a long time
and which seems to be usable. People should not use NOFILE and NCARGS anyway. */
#define NOFILE 256
#define NCARGS 131072

至此為止,一個簡單的守護進程就建立起來了。
另外,有些 Unix 提供一個 daemon 的 C 庫函數,實現守護進程。(BSD 和 Linux 均提供這個函數):

NAME
     daemon - run in the background

SYNOPSIS
     #include <unistd.h>
     int daemon(int nochdir, int noclose);

DESCRIPTION
     The daemon() function is for programs wishing to detach themselves from the controlling terminal and run in the background as system daemons.

守護進程實例

代碼說明:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <sys/param.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

// 守護進程初始化函數
void init_daemon()
{
    pid_t pid;
    int i = 0;

    if ((pid = fork()) == -1) {
        printf("Fork error !\n");
        exit(1);
    }
    if (pid != 0) {
        exit(0);        // 父進程退出
    }

    setsid();           // 子進程開啟新會話,并成為會話首進程和組長進程
    if ((pid = fork()) == -1) {
        printf("Fork error !\n");
        exit(-1);
    }
    if (pid != 0) {
        exit(0);        // 結束第一子進程,第二子進程不再是會話首進程
    }
    chdir("/tmp");      // 改變工作目錄
    umask(0);           // 重設文件掩碼
    for (; i < getdtablesize(); ++i) {
       close(i);        // 關閉打開的文件描述符
    }

    return;
}

int main(int argc, char *argv[])
{
    int fp;
    time_t t;
    char buf[] = {"This is a daemon:  "};
    char *datetime;
    int len = 0;
    //printf("The NOFILE is: %d\n", NOFILE);
    //printf("The tablesize is: %d\n", getdtablesize());
    //printf("The pid is: %d\n", getpid());

    // 初始化 Daemon 進程
    init_daemon();

    // 每隔一分鐘記錄運行狀態
    while (1) {
        if (-1 == (fp = open("/tmp/daemon.log", O_CREAT|O_WRONLY|O_APPEND, 0600))) {
          printf("Open file error !\n");
          exit(1);
        }
        len = strlen(buf);
        write(fp, buf, len);
        t = time(0);
        datetime = asctime(localtime(&t));
        len = strlen(datetime);
        write(fp, datetime, len);
        close(fp);
        sleep(60);
    }

    return 0;
}

測試結果:

Linux 守護進程的實現

僵尸進程

提到守護進程,就不得不說一下另一類特殊進程——僵尸進程

那什么是僵尸進程呢?
以前看的書上大致都說“如果父進程中沒有等待子進程的結束,那么子進程就會變成僵尸進程”,所以就想當然地認為“如果父進程先于子進程結束,那么子進程就成為僵尸進程”。
事實上,這是完全錯誤的理解,父進程先于子進程結束,這時的子進程應該稱作“孤兒進程(Orphan)”,它將被 1 號進程(init 進程)接管,init 進程成為其父進程。而僵尸進程是子進程先于父進程結束,而且父進程沒有函數調用 wait() 或 waitpid() 等待子進程結束,也沒有注冊 SIGCHLD信號處理函數,結果使得子進程的進程列表信息無法回收,就變成了僵尸進程(Zombie)。

一個已經終止,但是其父進程尚未對其進行善后處理(獲取終止子進程的有關信息、釋放它仍占用的資源)的進程被稱為僵尸進程。

 

 本文由用戶 Eloisa8912 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!