帶著老李折騰山寨Workerman(四)

  • 2019 年 12 月 11 日
  • 筆記

各位佬們腿子們好,我是老李。

不知不覺已經干到第四章了,三條消息,一好一壞一中性:

  • 好消息是進程部分的基礎內容再寫一個章節就終於能結束了
  • 壞消息是下下一章節我們要分析WM有關進程的部分實現
  • 然後是後面準備進入socket部分了,開不開心,刺不刺激

昨天晚上做夢夢到了棟子,就想起我倆那會兒一起摸魚的時光。那還是五年前在[ 黑 ]鷺引擎的時候,我倆被人稱為公司兩大門神,具體表現在於基本一整個白天都在公司門口歇著摸魚,就坐樓下的石凳上一邊一個,各摸各的十分對稱十分默契。

當然了我倆都認為各有各摸魚的道理,比如棟子總是覺得自己的UI設計風格屬於寫實主義而不被公司重用,而我則是認為這是因為我嘗試在公司推swoole推不動而覺得懷才不遇。相比之下由於我總是能在適當時候用類似於[ 行路難!行路難!多歧路,今安在? ]或[ 念天地之悠悠,獨愴然而涕下 ]等多種不同的詩句來花式地表達自己,而棟子則因為長期的文化匱乏每次只能狠嘬一口白沙後惡狠狠地說同一句話[ 尚能飯否!尚能飯否!]…

終於有一天棟子似乎良心發現了,那天我一下去就覺得他有話要說,果不其然(由於事隔已久遠,具體已經記不太清了,大概意思如下)。棟子悶了一口白沙後,漠然抬頭用散亂的眼神看了一眼前方,然後收了收嗓子很嚴肅認真地跟我說:

  • … …老李,我現在嚴重懷疑我被公司雪藏了……
  • 我:??????啥意思???
  • 我:… …或者說是有什麼具體表現嗎?…
  • 棟子看了我一眼,本來想嘬一口香煙又不自覺挪開了嘴巴,頓了頓神後偷偷摸摸看了看四周,然後神神叨叨地低聲跟我說:你說這都半年多了,公司啥活兒也不給我安排… …
  • 我:??????… …
  • 我:…扣你工資了?… …
  • 棟子:沒有啊,老樣子按時發啊…
  • 我:。。。。。。
  • 我:… 我說鐵子,真TM新鮮了,MD我這是頭次見有人把騙工資說的這麼清新脫俗… …

五年過去了,人已經回不到過去了,時代也回不去了…

記得後來沒多久,領導讓我研究一個爬蟲腳本,當時為了不讓腳本莫名其妙退出就天天看著電腦不關機,再後來就用Linux命令後加一個[ & ]符來跑…莫名其妙掛了幾次後,我決定徹底研究一下[ 如何使程式在後台保持穩定 ]這個話題,當然了這也是我們今天的話題。

如何才能使程式溜到後台里?我先說個著名的[ & ]符,感受下:

<?php  while ( true ) {    file_put_contents( './daemon.log', time().PHP_EOL, FILE_APPEND );    sleep( 1 );  }

上面程式碼保存成daemon.php,然後用下面命令就能放到後台工作:

php daemon.php &

其中的[ 1 ]表示[ 後台 ]任務的序號,daemon.php就是第一號任務,而20041就是其進程PID。如果我們想看下這種[ 後台 ]任務的列表,要在當前終端窗口輸入jobs命令,注意是只能在當前這個終端窗口。

如果想要將這些[ 後台 ]任務從後台撈出來,需要用fg + [ 序號 ]方式給撈出來:

此處需要注意的是當任務被撈出來後,使用Ctrl+Z命令會將任務[ 放入後台並暫停 ],暫停是表示程式碼不再運行了但是進程尚在,你們可以通過tail -f daemon.log文件來觀察。如果想讓[ 後台 ]任務再次運行起來,需要用bg + [ 序號 ]來恢復後台任務運行,如上圖中所示。

然而這種做法有可能出現的情況是:如果關閉當前終端,該進程也有可能會被關閉。只不過我在Mac下和Ubuntu 16.04.1下試了一把,都沒能復現出來,諸位佬們知道詳細緣由的可以公眾號發消息告知下。我們現在依然假設這個結論成立,所以為了保證這種[ 後台 ]進程不會跟隨終端關閉而關閉,就有了nohup命令,他的用法非常簡單:

其實當我們平時關閉一個終端窗口時,會收到一種叫做SIGHUP的訊號,一些進程在收到SIGHUP訊號後就會終止退出,而nohup則是顧名思義了:就是忽略SIGHUP訊號。

所以,無論是末尾加上&符號亦或是頭部加上nohup,並非靠譜或最佳方案。我曾經見過不少nohup後幾天後莫名其妙進程丟失的案例,比如這位…

所以我們需要一種正規而又穩定化的進程後台方法。這會兒我又不得不說下當年去HomeLink基礎平台部面試時候的一道題目了:當你在終端里輸入一個命令按下回車後發生了什麼事情。

當然了,眾所周知(其實大概就四五個人知道)我回答的並不好。主要是我並不知道這道題是具體想問什麼,從馬後炮的角度看來我應該把進程組、會話組這些概念說明白就好了。

本質上終端bash也是一個進程,所以實際上在終端bash里輸入一個命令後,比如php daemon.php後敲回車,應該就是bash進程fork出了子進程,該子進程中去執行php daemon.php。所以下面的程式碼保存成daemon.php後在終端里執行,我們可以得到如下的進程樹關係:

<?php  $pid = pcntl_fork();  if ( 0 == $pid ) {    $ppid = pcntl_fork();    if ( 0 == $ppid ) {      while ( true ) {        sleep( 1 );      }    }    while ( true ) {      sleep( 1 );    }  }  while ( true ) {    sleep( 1 );  }

解釋一下上圖進程樹,可以看到bash的進程ID為32614,TA fork出來2095 PID並執行了php daemon.php,而後2095 PID又fork出來2096 PID,最後2096 PID又fork出來2097 PID。

這裡我們要引入進程組、會話組的概念了:

  • 進程組:一坨相關的進程會抱團組成一個進程組,每個進程組有一個組長,進程組ID等於組長進程的PID;只有當進程組裡沒有一個活著的進程了,這個進程組就算徹底完犢子了,否則只要有任何一個進程在,進程組都不算是死絕了。比如上面上圖中,PID 32614的bash進程自己就是一個進程組,而php daemon.php的三個2095、2096、2097三個進程組成了另外一個進程組
  • 會話組:一坨相關的進程組抱團形成一個會話組,每個會話組有一個組長。比如上述案例中,bash所在進程組和php daemon.php兩個不同的進程組則隸屬於同一個會話組。每個會話組都有一個會話首進程。關於會話組的重點難點,在這裡,下面這些用紅線圈住,考試要考的: 一、使用setsid()函數可以創建一個新的會話組 二、組長進程(此處你可以暫時認為是父進程)無法調用setsid,會報錯 三、非組長進程(此處你可以粗暴認為是子進程)可調用setsid創建出一個新的會話組,這個行為會導致[ 該進程會創建一個新的進程組且自身為該進程組組長,該進程會創建出一個新的會話組且自身為該會話組組長,該進程會脫離當前命令行控制終端 ]

大家可以利用[ ps -eo pid,ppid,pgid,sid,command | grep 關鍵字 ]來獲取進程PID、PPID、組ID、會話ID等,我們簡單演示一下,程式碼依然是上面的程式碼:

此處需仔細對照終端研究一下即可。

上面普及鋪墊完了,就可以正式步入正軌了,是時候表演真正的技術了!在*NIX里,後台進程有個標準說法叫做daemon進程,標準翻譯叫做守護進程。平日里Redis、Nginx等啟動完畢後,都會以守護進程方式跑在系統後台提供服務。包括我們正在山寨的對象Workerman在啟動後都是以守護進程方式跑在系統後台,穩穩地提供服務,那麼如何利用PHP實現daemon?

<?php  $i_pid = pcntl_fork();  // 在子進程中...  if ( 0 == $i_pid ) {    // setsid創建新會話組    if ( posix_setsid() < 0 ) {      exit();    }    // 在子進程中二次fork(),這裡據說是為了避免SVR4種一次fork有時候無法脫離控制終端    //$i_pid = pcntl_fork();    //if ( $i_pid > 0  ) {      //exit;    //}      // 守護進程的業務邏輯從這裡開始    // while使得進程不會退出,一般http伺服器等都是event-loop不會退出    while ( true ) {      sleep( 1 );    }    }  // 父進程退出  else if ( $i_pid > 0 ) {    exit();  }

上述程式碼中我注釋了一行關於二次fork的程式碼,這段程式碼你可以用,也可以不用,注釋里我寫了一下原因。上述程式碼保存好運行一下,然後我們用ps命令感受一下:

此時daemon.php在調用了setsid後自己新建了一個進程組且自己為組長進程、自己新建了一個會話組且自己為會話組長、自己脫離了控制終端且由於父進程已經exit退出所以由1號進程即init進程收養。為了說明我們的daemon進程是完美的、是和大廠出品一樣牛13的,我特意整了一把Redis,你們對比感受一下。

現在好了,我們有了daemon的標準做法,就可以嘗試做一些東西了,我簡單舉個例子你們感受一下:

<?php  function daemonize() {    $i_pid = pcntl_fork();    // 在子進程中...    if ( 0 == $i_pid ) {      // setsid創建新會話組      if ( posix_setsid() < 0 ) {        exit();      }      // 在子進程中二次fork(),這裡據說是為了避免SVR4種一次fork有時候無法脫離控制終端      $i_pid = pcntl_fork();      if ( $i_pid > 0  ) {        exit;      }      //echo "here".PHP_EOL;    }    // 父進程退出    else if ( $i_pid > 0 ) {      exit();    }  }  // 首先執行daemonize函數,使得進程daemon化  daemonize();  // 連接redis,在後台做一些事情  $o_redis = new Redis();  $o_redis->connect( '127.0.0.1', 6379 );  while ( true ) {  echo $o_redis->get( 'user:1' ).PHP_EOL;    sleep( 1 );  }

上面程式碼運行後,你一定會發現一個問題:那就是當前終端會不會列印出空行。這個嘛,哈,這個是因為我們沒有重定向標準輸出到文件中導致的,所以上述的daemonize函數實際上並不完善,只是完成了最重要的功能。一個較為完善的daemonize函數,應該具備如下要點:

  • 設置好umask
  • 將目錄切換到根目錄,避免默認工作目錄被daemon進程佔據無法卸載
  • 關閉標準輸出等或將其重定向到指定地方

所以一個稍微完善點兒的daemonize應該是這樣的,你們趕緊複製粘貼走試試:

<?php  function daemonize() {    // 設置許可權掩碼,umask大家可以搜一下    umask( 0 );    // 將目錄更換到指定某個目錄,一般是根目錄    // 如果不更換,存在一種問題就是:daemon進程默認目錄無法被卸載unmount    chdir( '/' );    $i_pid = pcntl_fork();    // 在子進程中...    if ( 0 == $i_pid ) {      // setsid創建新會話組      if ( posix_setsid() < 0 ) {        exit();      }      // 在子進程中二次fork(),這裡據說是為了避免SVR4種一次fork有時候無法脫離控制終端      $i_pid = pcntl_fork();      if ( $i_pid > 0  ) {        exit;      }      // 關閉 標準輸入      // 這裡僅僅是關閉,你可以根據你的需要重定向到其他位置,比如某些文件      fclose( STDOUT );    }    // 父進程退出    else if ( $i_pid > 0 ) {      exit();    }  }  // 首先執行daemonize函數,使得進程daemon化  daemonize();  // 連接redis,在後台做一些事情  $o_redis = new Redis();  $o_redis->connect( '127.0.0.1', 6379 );  while ( true ) {    echo $o_redis->get( 'user:1' ).PHP_EOL;    sleep( 1 );  }