PHP多進程、號誌及孤兒進程和殭屍進程

2020-07-16 10:06:02

未標題-9.png

PHP多進程、號誌及孤兒進程和殭屍進程

實際上PHP是有多執行緒的,只是很多人不常用。使用PHP的多執行緒首先需要下載安裝一個執行緒安全版本(ZTS版本)的PHP,然後再安裝pecl的 pthread 擴充套件。

實際上PHP是有多進程的,有一些人再用,總體來說php的多進程還算湊合,只需要在安裝PHP的時候開啟pcntl模組(是不是跟UNIX中的fcntl有點兒…. ….)即可。在*NIX下,在終端命令列下使用php -m就可以看到是否開啟了pcntl模組。

所以我們只說php的多進程,至於php多執行緒就暫時放到一邊兒。

注意:不要在apache或者fpm環境下使用php多進程,這將會產生不可預估的後果。

PHP多進程初探

進程是程式執行的範例,舉個例子有個程式叫做 「 病毒.exe 」,這個程式平時是以檔案形式儲存在硬碟上,當你雙擊執行後,就會形成一個該程式的進程。系統會給每一個進程分配一個唯一的非負整數用來標記進程,這個數位稱作進程ID。當該進程被殺死或終止後,其進程ID就會被系統回收,然後分配給新的其餘的進程。

說了這麼多,這鬼東西有什麼用嗎?我平時用CI、YII寫個CURD跟這個也沒啥關聯啊。實際上,如果你了解APACHE PHP MOD或者FPM就知道這些東西就是多進程實現的。以FPM為例,一般都是nginx作為http伺服器擋在最前面,靜態檔案請求則nginx自行處理,遇到php動態請求則轉發給php-fpm進程來處理。如果你的php-fpm設定只開了5個進程,如果處理任意一個使用者的請求都需要1秒鐘,那麼5個fpm進程1秒中就最多只能處5個使用者的請求。所以結論就是:如果要單位時間內幹活更快更多,就需要更多的進程,總之一句話就是多進程可以加快任務處理速度。

在php中我們使用pcntl_fork()來建立多進程(在*NIX系統的C語言程式設計中,已有進程通過呼叫fork函數來產生新的進程)。fork出來新進程則成為子進程,原進程則成為父進程,子進程擁有父進程的副本。這裡要注意:

  • 子進程與父進程共用程式正文段

  • 子進程擁有父進程的資料空間和堆、棧的副本,注意是副本,不是共用

  • 父進程和子進程將繼續執行fork之後的程式程式碼

  • fork之後,是父進程先執行還是子進程先執行無法確認,取決於系統排程(取決於信仰)

這裡說子進程擁有父進程資料空間以及堆、棧的副本,實際上,在大多數的實現中也並不是真正的完全副本。更多是採用了COW(Copy On Write)即寫時複製的技術來節約儲存空間。簡單來說,如果父進程和子進程都不修改這些 資料、堆、棧 的話,那麼父進程和子進程則是暫時共用同一份 資料、堆、棧。只有當父進程或者子進程試圖對 資料、堆、棧 進行修改的時候,才會產生複製操作,這就叫做寫時複製。

在呼叫完pcntl_fork()後,該函數會返回兩個值。在父進程中返回子進程的進程ID,在子進程內部本身返回數位0。由於多進程在apache或者fpm環境下無法正常執行,所以大家一定要在php cli環境下執行下面php程式碼。

第一段程式碼,我們來說明在程式從pcntl_fork()後父進程和子進程將各自繼續往下執行程式碼:

$pid = pcntl_fork();
if( $pid > 0 ){
  echo "我是父親".PHP_EOL;
  } else if( 0 == $pid ) {
    echo "我是兒子".PHP_EOL;
  } else {
      echo "fork失敗".PHP_EOL;
  }

將檔案儲存為test.php,然後在使用cli執行,結果如下圖所示:

第二段程式碼,用來說明子進程擁有父進程的資料副本,而並不是共用:

 // 初始化一個 number變數 數值為1
 $number = 1;
 $pid = pcntl_fork(); if( $pid > 0 ){
   $number += 1;
   echo "我是父親,number+1 : { $number }".PHP_EOL;
 } else if( 0 == $pid ) {
   $number += 2;
   echo "我是父親,number+2 : { $number }".PHP_EOL;
 } else {   echo "fork失敗".PHP_EOL;
 }

第三段程式碼,比較容易讓人思維混亂,pcntl_fork()配合for迴圈來做些東西,問題來了:會顯示幾次 「 兒子 」?

for( $i = 1; $i <= 3 ; $i++ ){
    $pid = pcntl_fork();    if( $pid > 0 ){       // do nothing ...
    } else if( 0 == $pid ){
        echo "兒子".PHP_EOL;
    }
}

上面程式碼執行結果如下:

仔細數數,竟然是顯示了7次 「 兒子 」。好奇怪,難道不是3次嗎?… …
下面我修改一下程式碼,結合下面的程式碼,再思考一下為什麼會產生7次而不是3次。

for( $i = 1; $i <= 3 ; $i++ ){
     $pid = pcntl_fork();     if( $pid > 0 ){        // do nothing ...
     } else if( 0 == $pid ){
         echo "兒子".PHP_EOL;
         exit;
     }
 }

執行結果如下圖所示:

前面強調過:父進程和子進程將繼續執行fork之後的程式程式碼。這裡就不解釋,實在想不明白的,可以動手自己畫畫思考一下。

孤兒與殭屍進程

實際上,你們一定要記住:PHP的多進程是非常值得應用於生產環境具備高價值的生產力工具。

但我認為在正式開始吹牛之前還是要說兩個基本概念:孤兒進程、殭屍進程。

上文我整篇尬聊的都是pcntl_fork(),只管fork生產,不管產後護理,實際上這樣並不符合主流價值觀,而且,作業系統本身資源有限,這樣無限生產不顧護理,作業系統也會吃不消的。

孤兒進程是指父進程在fork出子進程後,自己先完了。這個問題很尷尬,因為子進程從此變得無依無靠、無家可歸,變成了孤兒。用術語來表達就是,父進程在子進程結束之前提前退出,這些子進程將由init(進程ID為1)進程收養並完成對其各種資料狀態的收集。init進程是Linux系統下的奇怪進程,這個進程是以普通使用者許可權執行但卻具備超級許可權的進程,簡單地說,這個進程在Linux系統啟動的時候做初始化工作,比如執行getty、比如會根據/etc/inittab中設定的執行等級初始化系統等等,當然了,還有一個作用就是如上所說的:收養孤兒進程。

殭屍進程是指父進程在fork出子進程,而後子進程在結束後,父進程並沒有呼叫wait或者waitpid等完成對其清理善後工作,導致改子進程進程ID、檔案描述符等依然保留在系統中,極大浪費了系統資源。所以,殭屍進程是對系統有危害的,而孤兒進程則相對來說沒那麼嚴重。在Linux系統中,我們可以通過ps -aux來檢視進程,如果有[Z+]標記就是殭屍進程。

在PHP中,父進程對子進程的狀態收集等是通過pcntl_wait()和pcntl_waitpid()等完成的。依然還是要通過程式碼還演示說明:
演示並說明孤兒進程的出現,並演示孤兒進程被init進程收養:

$id = pcntl_fork();if( $pid > 0 ){    // 顯示父進程的進程ID,這個函數可以是getmypid(),也可以用posix_getpid()
    echo "Father PID:".getmypid().PHP_EOL;    // 讓父進程停止兩秒鐘,在這兩秒內,子進程的父進程ID還是這個父進程
    sleep( 2 );
} else if( 0 == $pid ) {    // 讓子進程迴圈10次,每次睡眠1s,然後每秒鐘獲取一次子進程的父進程進程ID
    for( $i = 1; $i <= 10; $i++ ){
        sleep( 1 );        // posix_getppid()函數的作用就是獲取當前進程的父進程進程ID
        echo posix_getppid().PHP_EOL;
    }
} else {    echo "fork error.".PHP_EOL;
}

執行結果如下圖:

可以看到,前兩秒內,子進程的父進程進程ID為4129,但是從第三秒開始,由於父進程已經提前退出了,子進程變成孤兒進程,所以init進程收養了子進程,所以子進程的父進程進程ID變成了1。(php視訊教學

演示並說明僵屍進程的出現,並演示殭屍進程的危害:

$pid = pcntl_fork(); if( $pid > 0 ){     // 下面這個函數可以更改php進程的名稱
     cli_set_process_title('php father process');     // 讓主進程休息60秒鐘
     sleep(60);
 } else if( 0 == $pid ) {
     cli_set_process_title('php child process');     // 讓子進程休息10秒鐘,但是進程結束後,父進程不對子進程做任何處理工作,這樣這個子進程就會變成殭屍進程
     sleep(10);
 } else {     exit('fork error.'.PHP_EOL);
 }

執行結果如下圖:

通過執行ps -aux命令可以看到,當程式在前十秒內執行的時候,php child process的狀態列為[S+],然而在十秒鐘過後,這個狀態變成了[Z+],也就是變成了危害系統的殭屍進程。

那麼,問題來了?如何避免僵屍進程呢?PHP通過pcntl_wait()和pcntl_waitpid()兩個函數來幫我們解決這個問題。了解Linux系統程式設計的應該知道,看名字就知道這其實就是PHP把C語言中的wait()和waitpid()包裝了一下。

通過程式碼演示pcntl_wait()來避免僵屍進程,在開始之前先簡單普及一下pcntl_wait()的相關內容:這個函數的作用就是 「 等待或者返回子進程的狀態 」,當父進程執行了該函數後,就會阻塞掛起等待子進程的狀態一直等到子進程已經由於某種原因退出或者終止。換句話說就是如果子進程還沒結束,那麼父進程就會一直等等等,如果子進程已經結束,那麼父進程就會立刻得到子進程狀態。這個函數返回退出的子進程的進程ID或者失敗返回-1。

我們將第二個案例中程式碼修改一下:

$pid = pcntl_fork();if( $pid > 0 ){    // 下面這個函數可以更改php進程的名稱
    cli_set_process_title('php father process');    // 返回$wait_result,就是子進程的進程號,如果子進程已經是僵屍進程則為0
    // 子進程狀態則儲存在了$status引數中,可以通過pcntl_wexitstatus()等一系列函數來檢視$status的狀態資訊是什麼
    $wait_result = pcntl_wait( $status );
    print_r( $wait_result );
    print_r( $status );    // 讓主進程休息60秒鐘    sleep(60);
} else if( 0 == $pid ) {
    cli_set_process_title('php child process');    // 讓子進程休息10秒鐘,但是進程結束後,父進程不對子進程做任何處理工作,這樣這個子進程就會變成殭屍進程    sleep(10);
} else {    exit('fork error.'.PHP_EOL);
}

將檔案儲存為wait.php,然後php wait.php,在另外一個終端中通過ps -aux檢視,可以看到在前十秒內,php child process是[S+]狀態,然後十秒鐘過後進程消失了,也就是被父進程回收了,沒有變成殭屍進程。

但是,pcntl_wait()有個很大的問題,就是阻塞。父進程只能掛起等待子進程結束或終止,在此期間父進程什麼都不能做,這並不符合多快好省原則,所以pcntl_waitpid()閃亮登場。pcntl_waitpid( $pid, &$status, $option = 0 )的第三個引數如果設定為WNOHANG,那麼父進程不會阻塞一直等待到有子進程退出或終止,否則將會和pcntl_wait()的表現類似。

修改第三個案例的程式碼,但是,我們並不新增WNOHANG,演示說明pcntl_waitpid()功能:

$pid = pcntl_fork(); if( $pid > 0 ){     // 下面這個函數可以更改php進程的名稱
     cli_set_process_title('php father process');     // 返回值儲存在$wait_result中
     // $pid參數列示 子進程的進程ID
     // 子進程狀態則儲存在了引數$status中
     // 將第三個option引數設定為常數WNOHANG,則可以避免主進程阻塞掛起,此處父進程將立即返回繼續往下執行剩下的程式碼
     $wait_result = pcntl_waitpid( $pid, $status );
     var_dump( $wait_result );
     var_dump( $status );     // 讓主進程休息60秒鐘
     sleep(60);
 } else if( 0 == $pid ) {
     cli_set_process_title('php child process');     // 讓子進程休息10秒鐘,但是進程結束後,父進程不對子進程做任何處理工作,這樣這個子進程就會變成殭屍進程
     sleep(10);
 } else {     exit('fork error.'.PHP_EOL);
 }

下面是執行結果,一個執行php程式的終端視窗,另一個是ps -aux終端視窗。實際上可以看到主進程是被阻塞的,一直到第十秒子進程退出了,父進程不再阻塞:

那麼我們修改第四段程式碼,新增第三個引數WNOHANG,程式碼如下:

$pid = pcntl_fork(); if( $pid > 0 ){     // 下面這個函數可以更改php進程的名稱
     cli_set_process_title('php father process');     // 返回值儲存在$wait_result中
     // $pid參數列示 子進程的進程ID
     // 子進程狀態則儲存在了引數$status中
     // 將第三個option引數設定為常數WNOHANG,則可以避免主進程阻塞掛起,此處父進程將立即返回繼續往下執行剩下的程式碼
     $wait_result = pcntl_waitpid( $pid, $status, WNOHANG );
     var_dump( $wait_result );
     var_dump( $status );     echo "不阻塞,執行到這裡".PHP_EOL;     // 讓主進程休息60秒鐘
     sleep(60);
 } else if( 0 == $pid ) {
     cli_set_process_title('php child process');     // 讓子進程休息10秒鐘,但是進程結束後,父進程不對子進程做任何處理工作,這樣這個子進程就會變成殭屍進程
     sleep(10);
 } else {     exit('fork error.'.PHP_EOL);
 }

下面是執行結果,一個執行php程式的終端視窗,另一個是ps -aux終端視窗。實際上可以看到主進程是被阻塞的,一直到第十秒子進程退出了,父進程不再阻塞:

問題出現了,竟然php child process進程狀態竟然變成了[Z+],這是怎麼搞得?回頭分析一下程式碼:
我們看到子進程是睡眠了十秒鐘,而父進程在執行pcntl_waitpid()之前沒有任何睡眠且本身不再阻塞,所以,主進程自己先執行下去了,而子進程在足足十秒鐘後才結束,進程狀態自然無法得到回收。如果我們將程式碼修改一下,就是在主進程的pcntl_waitpid()前睡眠15秒鐘,這樣就可以回收子進程了。但是即便這樣修改,細心想的話還是會有個問題,那就是在子進程結束後,在父進程執行pcntl_waitpid()回收前,有五秒鐘的時間差,在這個時間差內,php child process也將會是殭屍進程。那麼,pcntl_waitpid()如何正確使用啊?這樣用,看起來畢竟不太科學。

那麼,是時候引入號誌了!

PHP 號誌

信號是一種軟體中斷,也是一種非常典型的非同步事件處理方式。在NIX系統誕生的混沌之初,信號的定義是比較混亂的,而且最關鍵是不可靠,這是一個很嚴重的問題。所以在後來的POSIX標準中,對信號做了標準化同時也各個發行版的NIX也都提供大量可靠的信號。每種信號都有自己的名字,大概如SIGTERM、SIGHUP、SIGCHLD等等,在*NIX中,這些信號本質上都是整形數位(遊有心情的可以參觀一下signal.h系列標頭檔案)。

信號的產生是有多種方式的,下面是常見的幾種:

  • 鍵盤上按某些組合鍵,比如Ctrl+C或者Ctrl+D等,會產生SIGINT信號。

  • 使用posix kill呼叫,可以向某個進程傳送指定的信號。

  • 遠端ssh終端情況下,如果你在伺服器上執行了一個阻塞的指令碼,正在阻塞過程中你關閉了終端,可能就會產生SIGHUP信號。

  • 硬體也會產生信號,比如OOM了或者遇到除0這種情況,硬體也會向進程傳送特定信號。

而進程在收到信號後,可以有如下三種響應:

  • 直接忽略,不做任何反映。就是俗稱的完全不鳥。但是有兩種信號,永遠不會被忽略,一個是SIGSTOP,另一個是SIGKILL,因為這兩個進程提供了向核心最後的可靠的結束進程的辦法。

  • 捕捉信號並作出相應的一些反應,具體響應什麼可以由使用者自己通過程式自定義。

  • 系統預設響應。大多數進程在遇到信號後,如果使用者也沒有自定義響應,那麼就會採取系統預設響應,大多數的系統預設響應就是終止進程。

用人話來表達,就是說假如你是一個進程,你正在幹活,突然施工隊的喇叭裡沖你嚷了一句:「吃飯了!」,於是你就放下手裡的活兒去吃飯。你正在幹活,突然施工隊的喇叭裡沖你嚷了一句:「發工資了!」,於是你就放下手裡的活兒去領工資。你正在幹活,突然施工隊的喇叭裡沖你嚷了一句:「有人找你!」,於是你就放下手裡的活兒去看看是誰找你什麼事情。當然了,你很任性,那是完全可以不鳥喇叭裡喊什麼內容,也就是忽略信號。也可以更任性,當喇叭裡沖你嚷「吃飯」的時候,你去就不去吃飯,你去睡覺,這些都可以由你來。而你在幹活過程中,從來不會因為要等某個信號就不幹活了一直等信號,而是信號隨時隨地都可能會來,而你只需要在這個時候作出相應的回應即可,所以說,信號是一種軟體中斷,也是一種非同步的處理事件的方式。

回到上文所說的問題,就是子進程在結束前,父進程就已經先呼叫了pcntl_waitpid(),導致子進程在結束後依然變成了殭屍進程。實際上在父進程不斷while迴圈呼叫pcntl_waitpid()是個解決辦法,大概程式碼如下:

$pid = pcntl_fork();if (0 > $pid) {    exit('fork error.' . PHP_EOL);
} else {    if (0 < $pid) {        // 在父進程中
        cli_set_process_title('php father process');        // 父進程不斷while迴圈,去反復執行pcntl_waitpid(),從而試圖解決已經退出的子進程
        while (true) {
            sleep(1);
            pcntl_waitpid($pid, &$status, WNOHANG);
        }
    } else {        if (0 == $pid) {            // 在子進程中
            // 子進程休眠3秒鐘後直接退出
            cli_set_process_title('php child process');
            sleep(20);            exit;
        }
    }
}

下圖是執行結果:

解析一下這個結果,我先後三次執行了ps -aux | grep php去檢視這兩個php進程。

  • 第一次:子進程正在休眠中,父進程依舊在迴圈中。

  • 第二次:子進程已經退出了,父進程依舊在迴圈中,但是程式碼還沒有執行到pcntl_waitpid(),所以在子進程退出後到父進程執行回收前這段空隙內子進程變成了殭屍進程。

  • 第三次:此時父進程已經執行了pcntl_waitpid(),將已經退出的子進程回收,釋放了pid等資源。

但是這樣的程式碼有一個缺陷,實際上就是子進程已經退出的情況下,主進程還在不斷while pcntl_waitpid()去回收子進程,這是一件很奇怪的事情,並不符合社會主義主流價值觀,不低碳不節能,程式碼也不優雅,不好看。所以,應該考慮用更好的方式來實現。那麼,我們篇頭提了許久的信號終於概要出場了。

現在讓我們考慮一下,為何信號可以解決「不低碳不節能,程式碼也不優雅,不好看」的問題。子進程在退出的時候,會向父進程傳送一個信號,叫做SIGCHLD,那麼父進程一旦收到了這個信號,就可以作出相應的回收動作,也就是執行pcntl_waitpid(),從而解決掉殭屍進程,而且還顯得我們程式碼優雅好看節能環保。

梳理一下流程,子進程向父進程傳送SIGCHLD信號是對人們來說是透明的,也就是說我們無須關心。但是,我們需要給父進程安裝一個響應SIGCHLD信號的處理器,除此之外,還需要讓這些信號處理器執行起來,安裝上了不執行是一件尷尬的事情。那麼,在php裡給進程安裝信號處理器使用的函數是pcntl_signal(),讓信號處理器跑起來的函數是pcntl_signal_dispatch()。

  • pcntl_signal(),安裝一個信號處理器,具體說明是pcntl_signal ( int $signo , callback $handler [, bool $restart_syscalls = true ] ),引數signo就是信號,callback則是響應該信號的程式碼段,返回bool值。

  • pcntl_signal_dispatch(),呼叫每個等待信號通過pcntl_signal() 安裝的處理器,引數為void,返回bool值。

下面結合新引入的兩個函數來解決一下樓上的醜陋程式碼:

$pid = pcntl_fork();if( 0 > $pid ){    exit('fork error.'.PHP_EOL);
} else if( 0 < $pid ) {    // 在父進程中
    // 給父進程安裝一個SIGCHLD信號處理器
    pcntl_signal( SIGCHLD, function() use( $pid ) {        echo "收到子進程退出".PHP_EOL;
        pcntl_waitpid( $pid, $status, WNOHANG );
    } );
    cli_set_process_title('php father process');    // 父進程不斷while迴圈,去反復執行pcntl_waitpid(),從而試圖解決已經退出的子進程
    while( true ){
        sleep( 1 );        // 注釋掉原來老掉牙的程式碼,轉而使用pcntl_signal_dispatch()
        //pcntl_waitpid( $pid, &$status, WNOHANG );
        pcntl_signal_dispatch();
    }
} else if( 0 == $pid ) {    // 在子進程中
    // 子進程休眠3秒鐘後直接退出
    cli_set_process_title('php child process');
    sleep( 20 );    exit;
}

執行結果如下:

推薦教學:

以上就是PHP多進程、號誌及孤兒進程和殭屍進程的詳細內容,更多請關注TW511.COM其它相關文章!