C語言分佈式系統中的進程標識

本文來自:智趣網-C/C++語言編程技術交流論壇http://www.bczh.net
mysql

本文假定一臺機器 (host) 只有一個 IP,不考慮 multihome 的狀況。同時假定分佈式系統中的每一臺機器都正確運行了 NTP,各臺機器的時間大致同步。 「進程 process」是操做系統的兩大基本概念之一,指的是在內存中運行的程序。在平常交流中,「進程」這個詞一般不止這一個意思。有時候咱們會說 「httpd 進程」或者「mysqld 進程」,指的實際上是 program,而不必定是特指某一個「進程」——某一次 fork() 系統調用的產物。一個「httpd 進程」重啓了,它仍是「一個 httpd 進程」。本文討論的是,如何爲一個程序每次運行 的進程取一個惟一標識符。也就是說,httpd 程序第一次運行,進程是 httpd_1,它原地重啓了,進程是 httpd_2。
本文所指的「進程標識符」是用來惟一標識一個程序的「一次運行」的。每次啓動一個進程,這個進程應該被賦予一個惟一的標識符,與當前正在運行的全部進程都不一樣;不只如此,它應該與歷史上曾經運行過,目前已消亡的進程也都不一樣(這兩條的直接推論是,與未來可能運行的進程也都不一樣)。「爲每一個進程命名」在分佈式系統中有至關大的實際意義,特別是在考慮 failover 的時候。由於一個程序重啓以後的新進程和它的「前世進程」的狀態一般不同,凡是與它打交道的其餘進程(s)最好能經過它的進程標識符變動來很容易地判斷該程序已經重啓,而採起必要的救災措施,防止搭錯話。
本文先假定每一個服務端程序的端口是靜態分配的,在公司內部有一個公用 wiki 來記錄端口和程序的對應關係(而後經過 NIS 或 DNS 發佈)。好比端口 11211 始終對應 memcached,其餘程序不會使用 11211 端口;3306 始終留給 mysqld;3690 始終留給 svnserve。在分佈式系統的初級階段,這是一般的作法;到了高級階段,多半會用動態分配端口號,由於端口號只有 6 萬多個,是稀缺資源,在公司內部也有分配完的一天。本文只考慮 TCP 協議,不考慮 UDP 協議,「端口」都指的是 TCP 端口。
另外,咱們假定在一臺機器上,一個 listening port 同時只能由一個進程使用,不考慮古老的 listen() + fork() 模型(多個進程能夠 accept 同一個端口上進來的鏈接),關於這點陳碩已經寫的不少,見《Linux 新增系統調用的啓示 》《多線程服務器的適用場合 》。
錯誤作法
在分佈式系統中,如何指涉(refer to)某一個進程呢,或者說一個進程如何取得本身的全局標識符 (如下簡稱 gpid)?容易想到的有兩種作法:
*ip:port (port 是這個進程對外提供網絡服務的端口號,通常就是它的 tcp listening port)
*host:pid
而這兩種作法都有問題。爲何?
若是進程自己是無狀態的,或者重啓了也沒有關係,那麼用 ip:port 來標識一個「服務」是沒問題的,好比常見的 httpd 和 memcached 均可以用它們的慣用 port (80 和 11211)來標識。咱們能夠在其餘程序裏安全地引用(refer to)「運行在 10.0.0.5:80 的那個 http 服務器」,或者「10.0.0.6:11211 的 memcached」,就算這兩個 service 重啓了,也不會有太惡劣的後果,大不了客戶端重試一下,或者自動切換到備用地址。
若是服務是有狀態的,那麼 ip:port 這種標識方法就有大問題,由於客戶端沒法區分從頭至尾和本身打交道的是一個進程仍是前後多個進程。在開發服務端程序的時候,爲了能快速重啓,咱們通常都會設置 SO_REUSEADDR,這樣的結果是前一秒鐘站在 10.0.0.7:8888 後面的進程和後一秒鐘佔據 10.0.0.7:8888 的進程可能不相同——服務端程序快速重啓了。
比方說,考慮一個相似 GFS 的分佈式文件系統的 master,若是它僅以 ip:port 來標識本身,而後它向 shadows (不是 chunk server)下達同步指令,那麼 shadows 如何得知 master 是否是已經重啓呢?發指令的是 master 的「前世」仍是「此生」?是否是應該拒絕「前世」的遺命?
若是考慮改爲 host:pid 這種標識方式會不會好一點?我認爲換湯不換藥,由於 pid 的狀態空間很小,重複的機率比較大。好比 Linux 的 pid 的最大值是 32768 (/proc/sys/kernel/pid_max),一個程序重啓以後,得到與「前世」相同 pid 的機率是 1/32768。或許有讀者不相信重啓以後 pid 會重複,由於 pid 是遞增的,遇到上限再回到目前空閒的最小 pid。考慮一個服務端程序 A,它的 pid 是 1234,它已經穩定運行了好幾天,這期間,pid 已經增加了幾個輪迴(由於這臺機器時常會啓動一些 scripts 執行一些輔助工做)。在 A 崩潰的前一刻,最近被使用的 pid 已經回到了 1232,當 A 崩潰以後,某個守護進程啓動一個腳本(pid = 1233)來清理 A 的 log,而後再重啓 A 程序;這樣一來,重啓以後的 A 程序的 pid 碰巧和它的前世相同,都是 1234。也就是說,用 host:pid 不能惟一標識進程。
那麼合在一塊兒,用 ip:port:pid 呢?也不能作到惟一。它和 host:pid 面臨的問題是同樣的,由於 ip:port 這部分在重啓以後不會變,pid 可能輪迴。
我猜這時有人會想,建一箇中心服務器,專門分配系統的 gpid 好了,每一個進程啓動的時候向它詢問本身的 gpid。這錯得更遠:這個全局 pid 分配器的 gpid 由誰來定?如何保證它分配的 gpid 不重複(考慮這個程序也可能意外重啓)?它是否是成爲系統的 single point of failure?若是要對該 gpid 分配器作容錯,是否是面臨分佈式系統的基本問題:狀態遷移?
還有一種辦法,用一個足夠強的隨機數作 gpid,這樣一來確實不會重複,可是這個 gpid 自己也沒有多大額外的意義,不便於管理和維護(比方說根據 gpid 找到是哪一個機器上運行的哪一個進程)。
正確作法:以四元組 ip:port:start_time:pid 做爲分佈式系統中進程的 gpid,其中 start_time 是 64-bit 整數,表示進程的啓動時刻(UTC 時區,muduo::Timestamp)。理由以下:
*容易保證惟一性。若是程序短期重啓,那麼兩個進程的 pid 一定不重複(尚未走完一個輪迴:就算每秒建立 1000 個進程,也要 30 多秒纔會輪迴,而以這麼高的速度建立進程的話,服務器已基本癱瘓了。);若是程序運行了至關長一段時間再重啓,那麼兩次啓動的 start_time 一定不重複。(見下文關於時間重複的解釋)
*產生這種 gpid 的成本很低(幾回低成本系統調用),沒有用到全局服務器,不存在 single point of failure。
*gpid 自己有意義,根據 gpid 馬上就能知道是什麼進程(port),運行在哪臺機器(ip),是什麼時間啓動的,在 /proc 目錄中的位置 (/proc/pid) 等,進程的資源使用狀況也能夠經過運行在那臺機器上的監控程序報告出來。
*gpid 具備歷史意義,便於未來追溯。比方說進程 crash,那麼我知道它的 gpid,就能夠去歷史記錄中查詢它 crash 以前的 cpu/mem 負載有多大。
若是僅以 ip:port:start_time 做爲 gpid,則不能保證惟一性,若是程序短期重啓(間隔一秒或幾秒),start_time 可能會往回跳變(NTP 在調時間)或暫停(正好處於閏秒期間)。關於時間跳變的問題留給下一篇博客《〈程序中的日期與時間〉第二章:計時與定時》,簡單地說,計算機上的時鐘不必定是單調遞增的。
沒有 port 怎麼辦?通常來講,一個網絡服務程序會偵聽某個端口來提供服務,若是它是個純粹的客戶端,只主動發起鏈接,沒有主動偵聽端口,gpid 該如何分配呢?根據陳碩在《分佈式系統的工程化開發方法 》一文中的觀點「在程序裏內置 http 服務器」,分佈式系統中的每一個長期運行的、會與其餘機器打交道的進程都應該提供一個管理接口,對外提供一個維修探查通道,能夠查看進程的所有狀態。這個管理接口就是一個 TCP server,它會偵聽某個 port。
使用這樣的維修通道的一個額外好處是,能夠自動防止重複啓動程序。由於若是重複啓動,bind 到那個運維 port 的時候會出錯(端口已被佔用),程序會馬上退出。更妙的是,不用擔憂進程 crash 沒來得及清理鎖(若是用跨進程的 mutex 就有這個風險),進程關閉的時候操做系統會自動把它打開的 port 都關上,下一個進程能夠順利啓動。
進一步,還能夠把程序的名稱和版本號做爲 gpid 的一部分,這起到錦上添花的做用。
TCP 協議的啓示
我在《分佈式系統的工程化開發方法 》中提到「從 TCP 協議能學到什麼?」,今天講的這個 gpid 其實也是由 TCP 協議啓發而來。TCP 用 ip:port 來表示 endpoint,兩個 endpoint 構成一個 socket。這彷佛符合一開始提到的以 ip:port 來標識進程的作法。其實否則。在發起 TCP 鏈接的時候,爲了防止前一次一樣地址的鏈接(相同的 local_ip:local_port:remote_ip:remote_port)的干擾(稱爲 wandering duplicates ,即流浪的 packets),TCP 協議使用 seq 號碼(這種在 SYN packet 裏第一次發送的 seq 號碼稱爲 initial sequence number, ISN)來區分本次鏈接和以往的鏈接。TCP 的這種思路與咱們防止進程的「前世」干擾「此生」很相像。內核每次新建 TCP 鏈接的時候會設法遞增 ISN 以確保與上次鏈接最後使用的 seq 號碼不一樣。至關於說把 start_time 加入到了 endpoint 之中,這就很接近咱們後面提到的「正確的 gpid」作法了。(固然,原始 BSD 4.4 的 ISN 生成算法有安全漏洞,會致使 TCP sequence prediction attack,Linux 內核已經採用更安全的辦法來生成 ISN算法

相關文章
相關標籤/搜索