Linux伺服器開發初步
陳晴陽
服 務器開發需要考慮的內容很多,比如伺服器的架構、穩定性、效能以及負載能力等等。事實上,在程式開發伺服器的過程中,需要綜合考慮各種因素,比如就用戶端串連 時間較短卻又比較頻繁的伺服器(例如HTTP伺服器)而言,在可選的伺服器結構中,預先派生進/線程的結構就要比並髮式結構高效,這一點將在後續的文章中 對其進行詳細的介紹。然後就是伺服器實現方面的細節,比如是否需要支援跨平台的能力、採用什麼樣的開發語言和開發工具、如何提高伺服器系統的效能。所有的 這些問題都需要在伺服器的定義與設計的過程中作出充分的考慮。 其 實,無論是Windows伺服器,還是Linux伺服器,它們之間都有共同的特點。首先就是後台運行,目前,絕大多數伺服器都是後台啟動並執行,這是因為服務 器的主要任務是給用戶端提供所請求的服務,通常情況下是不需要與使用者進行介面互動的,使用者只需要能夠啟動服務、暫停服務或者停止服務就可以了,因此,服務 器沒有必要去佔有一個終端會話(或者說是擁有一個可視化的使用者介面);其次,由於伺服器是後台啟動並執行,它並沒有一個可視化的使用者介面,所以伺服器運行時所 需的參數就只能通過檔案 [1] 讀入,然後根據從檔案中讀入的資料作不同的處理;再次,由於伺服器的後台運行,它無法通過介面將運行狀態以及一些必要的處理結果顯示給使用者,因此,它需要將這些資訊寫入一個檔案 [2] , 以便在伺服器出現問題的時候,使用者能夠根據該檔案中的內容對伺服器的故障進行診斷;最後,還是與伺服器的後台運行有關,對於電腦的使用者來說,伺服器並不 是一個需要經常互動的程式,與一般的應用程式相比,在伺服器設計的過程中,應該更多地考慮伺服器佔用系統資源的問題,這裡所說的資源套件括CPU、IO以及 儲存空間資源。對於Windows服務來說,這點尤為重要,因為Windows服務很有可能就是安裝在某一個使用者的機器上,而不是特定的Windows服務 器上。試想,如果某個Windows服務佔用了過多的系統資源,那麼該系統的使用者就很有可能無法正常地完成其他的工作。 上面總結了各種伺服器所共有的特點,下面將對這些共有特點的設計與實現進行詳細的描述,並對Windows伺服器與Linux伺服器之間的差別進行必要的說明。 一、 後台運行 在Linux 環境下,要實現伺服器的後台運行非常簡單,只需要在伺服器程式檔案名稱後面加上“&”符號就可以了。例如某伺服器程式的檔案名稱是 test_server,那麼啟動該伺服器並使其後台啟動並執行方法就是在提示符下輸入“test_server&”。假設現在有一個 test_server伺服器,其代碼如下: #include <stdio.h> int main (int argc, char *argv[]) { while (1) { // code to serve the client } return 0; } 在使用“test_server&”運行該程式以後,系統會自動回到提示符狀態而不佔用任何終端(如果直接使用程式名來啟動程式,那麼系統會因為while死迴圈而佔用終端,直到將程式進程殺死)。我們可以使用ps命令來查看程式是否已經在後台運行,我們可能會獲得類似下面的提示資訊: root 1417 1358 99 14:47 tty1 00:12:10 ./test_server 為什麼在這個簡單的程式中需要使用while死迴圈呢。原因有兩個,首先,如果不是死迴圈,那麼程式剛運行就會退出,我們根本就看不到它是否已經運行了,更不用說去檢查它是否已在後台運行;其次,這個程式結構是伺服器結構的一種抽象,大多數伺服器的資料接收、處理和發送等操作都是在這個while死迴圈中進行的,直到獲得一個訊號或指令後,伺服器程式才會退出。 遺憾的是,沒有一種讓人感覺比較專業的方法能夠讓伺服器程式退出,我們可以用kill命令將伺服器處理序殺死,這種做法似乎有點野蠻,但也不失為一種不錯的方法。在稍後的介紹中,我們會用另外一種看上去感覺更加專業的辦法來解決這個問題。 另一種使伺服器程式後台啟動並執行方法是使用守護進程(daemon process)。要將伺服器程式作為守護進程運行,通常的做法是先定義一個守護進程初始化函數,然後在main函數中調用這個初始化函數。一般情況下,該守護進程初始化函數可以定義如下: void InitDaemon() { pid_t pid; if ((pid = fork()) != 0) exit(0); setsid(); if ((pid = fork()) != 0) exit(0); chdir(“/”); umask(0); } 程式首先調用fork,並讓父進程退出,子進程繼續運行。由於子進程是由父進程通過fork系統調用派生的,因此子進程繼承父進程的進程組號,但它擁有自己的進程號;然後調用setsid建立一個新的session[3], 子進程是該session中唯一的進程,並且是該session的leader(或者說是creator),同時也是新建立的進程組的leader。在調 用setsid以後,調用進程將不再擁有控制終端。第二次fork的目的在於,確保當進程開啟終端裝置時,也無法獲得控制終端,這是因為由session leader派生的子進程一定不是session leader,也就無法獲得控制終端;接下來的chdir系統調用是更改守護進程的工作目錄,假設守護進程是在vfat檔案系統中啟動的,如果沒有使用 chdir來更改守護進程的工作目錄,那麼在守護進程進入後台運行後,此vfat檔案系統就有可能無法卸載(unmount)。事實上,在Linux系統 中,即使使用了chdir來更改守護進程的工作目錄,啟動該守護進程的檔案系統仍然無法卸載。在實際使用中,chdir更多地用於滿足伺服器本身的處理需 求;最後的umask(0)是為了在守護進程建立自己的檔案時,檔案許可權位不受原有檔案建立掩碼的許可權位的影響。 在定義了守護進程初始化函數以後,只需要在必要的時刻(一般是main函數中)調用該初始化函數就可以了,例如: int main (int argc, char *argv[]) { InitDaemon(); while (1) { } return 0; } 前 面已經提到,要使處於後台啟動並執行伺服器處理序退出,可以用kill命令將伺服器處理序殺死,這對於使用守護進程初始化函數實現後台啟動並執行伺服器程式同樣有效。 這種做法看起來似乎有點野蠻,但不失為一種使伺服器處理序退出的好方法。但如果採用這種方法,那麼在設計伺服器程式的時候,我們應該使其具有捕獲某種訊號 (這個訊號通常是由kill命令指定的)的能力,並在捕獲了該訊號以後能夠完成一些伺服器處理序退出前的掃尾工作,比如釋放記憶體、關閉檔案描述字等,否則很 有可能造成資源泄漏。 舉例 來說,我們可以使用kill命令向某個進程發出指定的訊號,這個訊號可以通過參數來指定,當然我們可以不指定這個參數,這樣的話kill命令會預設地向進 程發出SIGTERM訊號。現在我們要做的是,在伺服器程式中加入對SIGTERM訊號的處理函數,以便在獲得該訊號後執行一些必要的處理。我們可以使用 類似下面的代碼來實現這樣的功能: #include <signal.h> void handle_term_signal (int sig) { // 此處填寫必要的處理邏輯 } int main (int argc, char *argv[]) { // . . . signal (SIGTERM, handle_term_signal); // . . . return 0; } 然 後使用“kill –SIGTERM 進程號”或“kill 進程號”命令向指定的伺服器處理序發送SIGTERM訊號,伺服器處理序接收到這個SIGTERM訊號後,就會執行handle_term_signal函 數。由於SIGTERM訊號是可以被阻塞和攔截的,這一點與SIGKILL訊號是不同的,因此在伺服器程式中,我們需要捕獲的是SIGTERM訊號,而不 是SIGKILL訊號。 需要說明的是,在Linux系統中,進程接收到SIGTERM訊號後並不會退出,要讓運行中的進程退出,還需要向其發送SIGKILL訊號。也就是說,首先向伺服器處理序發送SIGTERM訊號,使其完成退出前的收尾工作,然後向其發送SIGKILL訊號,將進程殺死。 為了操作的方便,可以編寫一個很簡單的shell程式來完成上面所說的操作。開啟vi,輸入以下的shell命令,然後以killit檔案名稱儲存: kill –SIGTERM $1 sleep 2 kill –SIGKILL $1 這樣的話,只需要使用“killit 進程號”命令就可以讓後台啟動並執行伺服器處理序退出了。在上面的shell程式中,兩個kill命令間使用了sleep命令,這是為了確保伺服器處理序在接收到SIGKILL訊號前,已經完成了必要的收尾工作。 值得注意的是,如果在函數handle_term_signal()中加入exit()函數調用,那麼當進程接收到SIGTERM訊號後,會自動結束,而不需要再次使用kill –SIGKILL命令。 當 然,我們可以不使用kill命令來使伺服器處理序退出,而使用命令列參數這樣一種看上去相對專業的方法來實現。假如伺服器程式檔案名稱為 “test_server”,那麼我們可以使用“test_server start”來啟動伺服器;用“test_server stop”來停止伺服器;而用“test_server restart”來重新啟動伺服器。實現這種做法的基本思路是:對命令列參數進行判斷,如果是start,那就看是否已經有一個伺服器處理序在運行,如果是 的話,則提示說伺服器已經運行,否則就啟動伺服器;如果命令列參數是stop,那麼就看伺服器處理序是否處於運行狀態,如果是的話,則伺服器處理序退出,否則 就提示說伺服器還沒有運行;如果命令列參數是restart,那麼首先將原伺服器處理序退出,然後再啟動。這樣做既可以使我們的伺服器程式看上去更加專業, 又可以避免伺服器的多次重複啟動所造成的某些資源衝突以及資源浪費。基本的流程大致如下圖所示: 圖一 帶參伺服器程式啟動流程 那 麼伺服器程式在啟動的時候,應該如何判斷是否已經有一個進程正在運行呢(也就是圖一中兩個菱形框的判斷條件,通常將這樣的問題稱為程式的二重啟動問題)。 解決這個問題最直接的辦法是使用進程通訊,通過進程通訊,待啟動的伺服器處理序會首先查詢某些標誌位或試圖與已經啟動並執行伺服器處理序通訊。如果在全域域中設定 了標誌位,或者能夠成功地與已經啟動並執行伺服器處理序通訊,則表示伺服器處理序已經啟動,無需再次啟動;否則就啟動伺服器處理序。Linux下實現進程通訊的方法 很多,socket、管道、互斥鎖以及共用記憶體等,都可以實現進程間通訊。我們可以使用共用記憶體來實現,這是因為共用記憶體除了可以體現標誌位以外,還能夠 儲存一些有用的資料,比如進程的已運行伺服器處理序的pid。這樣的話,在啟動伺服器處理序的時候,首先判斷共用記憶體中的標誌位,如果標誌位存在,則表示已經 有一個伺服器處理序在運行,進而再判斷當前的伺服器程式參數是什麼,如果參數為“start”,那麼顯然是要讓當前待運行伺服器處理序直接退出的;如果參數為 “stop”,那麼就從共用記憶體中擷取已運行伺服器處理序的pid,然後使用kill函數向其發送SIGTERM訊號,已運行伺服器處理序在捕獲了這個 SIGTERM訊號後,立即轉入handle_term_signal()函數進行進程退出前的掃尾工作,然後進程退出。假設我們使用共用記憶體來解決二重 啟動與進程退出問題,那麼圖一中描述的流程可以細化為: 圖二 帶參伺服器啟動流程(細化圖) 注意,上圖中沒有畫出共用記憶體的具體實現流程,如共用記憶體申請失敗、共用記憶體讀寫失敗的處理流程。 由此可見,Linux下伺服器程式的開發比Windows下要複雜許多,它需要開發人員更多地考慮伺服器的啟動、停止和重啟的細節問題,不僅如此,開發人員還需要對Linux環境下C語言的進階應用程式有一定的瞭解。 二、 設定檔 前面已經提到,由於伺服器程式沒有使用者介面,所以使用者也就無法通過介面來設定伺服器運行所需要的參數。這一問題可以使用設定檔來解決,伺服器程式只要讀取設定檔中的資訊,就可以獲得所需要的參數。 通 常情況下,伺服器程式只有在啟動的時候才讀取設定檔,因此,如果使用者修改了設定檔的內容,要想使得所做的修改生效,就必須重新啟動伺服器程式。修改了 設定檔以後需要重新啟動伺服器的另一個原因是,在伺服器程式的運行過程中,某些參數是需要被多次使用的,如果在使用的過程中修改了這些參數的值,就有可 能影響伺服器程式的運行邏輯。 常 用的設定檔格式有兩種,一種是INI形式的檔案,另一種是XML格式的。設定檔使用什麼樣的格式其實並不重要,只要伺服器能夠從中正確地讀取資料就可 以了。當然,設定檔一定是文字檔,這是為了方便伺服器的管理員對設定檔進行修改。如果設計的系統能夠提供設定檔的編輯程式,那麼,設定檔也可以 是二進位檔案。 在Linux 系統中,使用C語言讀寫INI格式檔案或者是XML檔案都不是件簡單的事情,開發人員可以使用第三方提供的開發庫,但就我目前的情況,我使用的是INI格式 檔案,並且為INI格式檔案中資料的讀取編寫了一套函數庫。除非第三方的開發庫做得非常優秀、可信度非常高,否則盡量不要使用,這是因為你無法控制他人所 編寫的程式中的錯誤率,一旦程式的運行出現問題,調試他人的程式將會是件令人頭痛的事情。在Windows系統中,讀寫INI檔案非常簡單,開發人員不需要 自己去編寫檔案解析程式,使用Windows API中的GetPrivateProfileString、WritePrivateProfileString等函數就可以方便地讀寫INI檔案;讀 寫XML檔案也不會太難,.NET Framework為XML檔案的操作提供了很好的支援。在32位的Windows系統中,應該盡量將配置資訊寫在系統註冊表中以供伺服器程式讀取,而不 要使用INI檔案,這是Windows系統中應用程式讀寫配置資訊的最佳操作。如果所設計的伺服器系統需要與16位Windows系統中的某些程式相容, 那麼就可以根據情況來決定是否使用INI檔案。 三、記錄檔 日 志檔案是伺服器系統的重要組成部分。目前出現的絕大多數伺服器都有自己的記錄檔。由於伺服器程式的後台運行特性及其特殊的工作方式,它無法將一些過程、 狀態以及結果資訊顯示在螢幕上,記錄檔就成為了伺服器程式記錄資料的主要方式。由於記錄檔中記錄了伺服器程式處理過程、工作狀態和日期等關鍵資料,因 此,記錄檔是伺服器系統錯誤跟蹤的主要依據,如果伺服器程式在啟動並執行過程中出現問題,那麼系統管理員就可以根據記錄檔中的資料描述確定問題的來源,進 而解決問題,使伺服器正常工作。 在Linux環境下程式開發伺服器的過程中,程式員可以根據實際情況在伺服器程式的適當位置添加記錄日誌資訊的代碼,這樣,當伺服器程式運行到該位置時,會自動地向指定的記錄檔輸出資訊。下面的程式碼片段試圖在程式出現異常後將異常資訊輸出到記錄檔: void MyServerApp::Main (int argc, char *argv[]) { // . . . try { // . . . } catch (MyServerException &ex) { gAppLog->WriteLog (LOGLEV_ERR, “Exception raised: %s/n”, ex.Message); } // . . . } 現在,我們談談如何?記錄檔的寫入處理,也就是如何?上例中的gAppLog->WriteLog函數 [4] 。 不難發現,WriteLog函數是一個可變參的函數,參數的設定可以根據不同情況進行設定。例如,上面的WriteLog函數僅向記錄檔寫入了日誌條目 層級和必要的資訊字串,伺服器系統的設計者同樣可以修改這個WriteLog函數,使得該函數還能夠向記錄檔輸出伺服器名稱、日誌條目寫入時間等信 息。在Linux系統中,設計可變參的函數其實很簡單,只要在函式宣告的時候使用省略符格式,在函數定義的時候使用va_list相關的宏來實現具體的操 作即可。下面的例子示範了WriteLog函數的具體實現,真正的日誌輸出函數應該根據伺服器系統的具體需求情況進行定義。 int WriteLog (int level, char *fmt, . . .) { char loglev_str[512]; FILE *fp; va_list args; memset(loglev_str, 0x00, sizeof(loglev_str)); fp = fopen (“serversystem.log”, “a+”); // 此處第一個參數指定記錄檔名 // 第二個參數使用a+模式開啟檔案,保證日誌的正確寫入 if (NULL == fp) return -1; switch(level) { case LOGLEV_OK: strcpy(loglev_str, “[OK ]”); break; case LOGLEV_ERR: strcpy(loglev_str, “[ERR ]”); break; case LOGLEV_WAR: strcpy(loglev_str, “[WARN]”); break; default: break; } va_start(args, fmt); fprintf (fp, “%s”, loglev_str); vfprintf (fp, fmt, args); va_end(args); fclose(fp); return 0; } 在 上面的代碼中,WriteLog函數一味地向serversystem.log檔案寫入資訊,時間一長,勢必會導致記錄檔容量的無限期增加,這是一個非 常嚴峻的問題,過大的記錄檔可能佔用磁碟的大部分有效空間,從而造成伺服器系統因為沒有足夠的磁碟空間而出現異常甚至崩潰。由於伺服器程式啟動以後很少 需要人為的幹預,記錄檔容量無限期增加這一問題很容易被伺服器系統管理員忽視,而作為管理員來說,要每隔一定的時期去為伺服器清理記錄檔也是一件麻煩 的事情,並且稍不小心就有可能影響伺服器系統的正常運行,這些問題對於使命關鍵的伺服器(例如,大型商務系統的核心伺服器等)來說是無法容忍的。由此可 見,伺服器系統需要一個日誌管理機制,為伺服器系統日誌的管理提供一個中心位置,該日至管理機制至少需要兩個功能:①對過大的記錄檔進行備份,並重寫(overwrite)記錄檔;②刪除到期的備份記錄檔。 服 務器的日誌管理機制可以是當前伺服器系統的一個子系統,也可以是一個獨立的伺服器系統,這可以根據所設計的伺服器系統的規模來決定。日誌管理機制沒有必要 時時刻刻處於對日誌的清理狀態,因為日誌管理機制的運行也需要佔用系統資源,勢必會影響主伺服器系統的運行效率。通常的做法是,每天或每隔幾天,選擇一個 伺服器訪問率相對較小的時間(比如午夜或淩晨)進行系統日誌的清理工作。如果伺服器系統在此期間內無法停止運行,那麼Tlog模組還需要提供日誌寫入緩衝 和記錄檔鎖定機制,確保日誌資訊在日誌管理機制對記錄檔進行清理時也能正確寫入。
至此,對伺服器程式三大主要特點的基本介紹就告一段落。本文首先對伺服器程式的特點作了簡要的介紹,引出了伺服器程式的最主要的特點:後台運行,這一特點也就決定了伺服器程式設定檔和記錄檔存在的必要性;然後,本文對Linux環境下伺服器程式的後台運行等特點作了詳細的描述,並提出了一些設計和實現的方案。限於篇幅,本文無法將實現的每個細節都闡述清楚,比如上面所說的日誌寫入緩衝和日誌鎖定機制等,但在後續的Linux伺服器開發文章中,筆者會儘可能地闡明其中的具體細節問題,使得讀者對Linux伺服器程式的實現過程有更深一步的瞭解。
[1] 此檔案就是通常所說的伺服器設定檔 [2] 此檔案就是通常所說的伺服器記錄檔 [3] 確切地說,應該是在調用setsid時,如果調用進程不是進程組的leader,那麼setsid函數將會建立一個新的session。但在此處,派生的子進程並非進程組的leader,因此調用setsid將會建立一個新的session。 [4] 為說明方便,今後用WriteLog函數代替此記錄檔寫入函數 轉自:http://blog.csdn.net/empro/archive/2006/10/25/1350789.aspx