PHP回顧之多進程編程

轉載請註明文章出處: tlanyan.me/php-review-…php

PHP回顧系列目錄

爲了更好的利用多核CPU,咱們須要多進程或多線程。但在常規web開發中,咱們極少用到這兩種併發技術(curl_multi等特殊函數除外)。若是腳本運行在CLI模式下,多進程和多線程技術是提升多核CPU的有力工具。html

相對於多線程,多進程的程序具備健壯、無鎖、對分佈式支持更好等特色。本文來學習一下PHP的多進程編程。linux

多進程

PHP中與(多)進程相關的兩個重要拓展是PCNTLPOSIXPCNTL主要用來建立、執行子進程和處理信號,POSIX拓展則實現了POSIX標準中定義的接口。因爲Windows不是POSIX兼容的,因此POSIX拓展在Windows平臺上不可用。git

先上簡單的代碼看多進程編程:web

// fork.php
$parentId = posix_getpid();
fwrite(STDOUT, "my pid: $parentId\n");
$childNum = 10;
foreach (range(1, $childNum) as $index) {
    $pid = pcntl_fork();
    if ($pid === -1) {
        fwrite(STDERR, "failt to fork!\n");
        exit;
    }
    // parent code
    if ($pid > 0) {
        fwrite(STDOUT, "fork the {$index}th child, pid: $pid\n");
    } else {
        $mypid = posix_getpid();
        $parentId = posix_getppid();
        fwrite(STDOUT, "I'm the {$index}th child and my pid: $mypid, parentId: $parentId\n");
        sleep(5);
        exit;               // 注意這一行
    }
}
複製代碼

關鍵的代碼是pcntl_fork函數,函數返回一個整數,小於0表示克隆失敗。克隆成功的狀況下返回兩個值:父進程拿到子進程的進程號,而子進程則獲得0。能夠根據函數的返回值判斷接下來的執行環境在父進程中仍是子進程中。shell

fork調用讓系統建立一個與當前進程幾乎徹底同樣的進程,除了進程號等少數信息不同,進程的代碼段、堆棧、數據段的值都一致。父進程打開了一個文件,複製的子進程一樣享有這個句柄,這是過去多進程能監聽同一個端口的原理;子進程基於父進程fork時的環境繼續執行(代碼段共享)直到退出。數據庫

去掉上述代碼中else語句塊的exit能將幫助你更好地理解上面這段話。程序的本意是生成10個子進程,去掉子進程執行代碼的exit後,子進程執行完else塊中代碼後繼續執行foreach循環,最終生成55個子進程(爲何是55個?)!鑑於此,一個良好的實踐是在子進程的執行代碼後老是加上exit終止語句,除非你真的有把握子進程會按照預期執行。編程

除了fork,另一種多進程技術是exec。systemexecproc_open等函數會生成一個新的進程執行外部命令(並返回結果)。這些函數的本質是fork一個進程,而後調用shell執行命令,主進程等待其執行結束。函數執行期間,主進程除了等待沒法處理其餘任務,因此通常不認爲這是多進程編程。實踐中能夠結合fork來併發執行外部命令。json

孤兒進程與殭屍進程

多進程編程須要考慮到的一個問題是孤兒進程和殭屍進程。進程結束前父進程已經退出,進程變成孤兒進程;進程退出後父進程在執行且未回收子進程,那麼進程變成殭屍進程。孤兒進程是仍在執行的進程,殭屍進程則已經中止執行,只剩下進程號一縷孤魂仍能被外界感知。segmentfault

孤兒進程會被系統的根進程(init進程,進程號爲1)接管,運行結束後由根進程回收。下面代碼演示孤兒進程的父進程的變化:

// orphan.php
$pid = pcntl_fork();
if ($pid === 0) {
    $myid = posix_getpid();
    $parentId = posix_getppid();
    fwrite(STDOUT, "my pid: $myid, parentId: $parentId\n");
    sleep(5);
    $myid = posix_getpid();
    $parentId = posix_getppid();
    fwrite(STDOUT, "my pid: $myid, parentId: $parentId\n");
} else {
    fwrite(STDOUT, "parent exit\n");
}
複製代碼

執行腳本:php orphan.php,能夠看到相似以下輸出:

parent exit
my pid: 14384, parentId: 14383
my pid: 14384, parentId: 1
複製代碼

父進程退出後子進程過繼給1號根進程,並由其負責回收子進程。

接着看殭屍進程。主進程長時間運行且不回收子進程,殭屍進程會一直存在,直到主進程退出後變成孤兒進程過繼給根進程;若是主進程一直運行,殭屍進程將一直存在。

下面代碼演示生成10個殭屍進程:

// zombie.php
foreach (range(1, 10) as $i) {
    $pid = pcntl_fork();
    if ($pid === 0) {
        fwrite(STDOUT, "child exit\n");
        exit;
    }
}
sleep(200);
exit;
複製代碼

打開終端執行php zombie.php,而後新打開一個終端執行ps aux | grep php | grep -v grep,一個可能的輸出以下:

vagrant  14336  0.3  0.8 344600 15144 pts/1    S+   05:09   0:00 php zombie.php
vagrant  14337  0.0  0.0      0     0 pts/1    Z+   05:09   0:00 [php] <defunct>
vagrant  14338  0.0  0.0      0     0 pts/1    Z+   05:09   0:00 [php] <defunct>
vagrant  14339  0.0  0.0      0     0 pts/1    Z+   05:09   0:00 [php] <defunct>
vagrant  14340  0.0  0.0      0     0 pts/1    Z+   05:09   0:00 [php] <defunct>
vagrant  14341  0.0  0.0      0     0 pts/1    Z+   05:09   0:00 [php] <defunct>
vagrant  14342  0.0  0.0      0     0 pts/1    Z+   05:09   0:00 [php] <defunct>
vagrant  14343  0.0  0.0      0     0 pts/1    Z+   05:09   0:00 [php] <defunct>
vagrant  14344  0.0  0.0      0     0 pts/1    Z+   05:09   0:00 [php] <defunct>
vagrant  14345  0.0  0.0      0     0 pts/1    Z+   05:09   0:00 [php] <defunct>
vagrant  14346  0.0  0.0      0     0 pts/1    Z+   05:09   0:00 [php] <defunct>
複製代碼

最後一列爲<defunct>的進程即是殭屍進程,這些進程的第八列的標誌是「Z+」,即Zombie。雖然除了進程號沒法回收,殭屍進程並不像殭屍那麼恐怖,但咱們應該在子進程執行結束後讓其安息,避免出現殭屍進程。

回收子進程有兩種方式,一種是主進程調用pcntl_wait/pcntl_waitpid函數等待子進程結束;另一種是處理SIGCLD信號。咱們先說使用wait函數回收子進程,信號處理放在下面的章節。

PCNT拓展中用於回收子進程的兩個函數是pcntl_waitpcntl_waitpidpcntl_waitpid能夠指定等待的進程。來看如何用這兩個函數回收子進程:

// wait.php
$pid = pcntl_fork();
if ($pid === 0) {
    $myid = posix_getpid();
    fwrite(STDOUT, "child $myid exited\n");
} else {
    sleep(5);
    $status = 0;
    $pid = pcntl_wait($status, WUNTRACED);
    if ($pid > 0) {
        fwrite(STDOUT, "child: $pid exited\n");
    }

    sleep(5);
    fwrite(STDOUT, "parent exit\n");
}
複製代碼

執行腳本:php wait.php,而後打開另一個終端執行:watch -n2 'ps aux | grep php | grep -v grep'。從watch輸出能夠看到子進程退出後的5秒內是殭屍進程,父進程回收後殭屍進程消失,最後父進程退出。

若是有多個子進程,父進程須要循環調用wait函數,不然某些子進程執行完畢後也會變成殭屍進程。

信號處理

PCNTL拓展中的pcntl_signal函數用於安裝信號函數,進程收到信號時會執行回調函數中的代碼。咱們知道Ctrl + C能夠中斷程序的執行,原理是按下組合鍵後系統向程序發出SIGINT信號。這個信號的默認操做是退出程序,因此係統終止了程序運行。SIGINT信號可捕捉信號,咱們能夠設置信號回調函數,收到信號後系統執行回調函數而非退出程序:

// signal.php
pcntl_signal(SIGINT, function () {
    fwrite(STDOUT, "receive signal: SIGINT, do nothing...\n");
});

while (true) {
    pcntl_signal_dispatch();
    sleep(1);
}
複製代碼

執行腳本:php signal.php,而後按Ctrl + C,輸出以下:

[vagrant@localhost ~]$ php signal.php
^Creceive signal: SIGINT, do nothing...

^Creceive signal: SIGINT, do nothing...
^Creceive signal: SIGINT, do nothing...
^Creceive signal: SIGINT, do nothing...

^Creceive signal: SIGINT, do nothing...
複製代碼

安裝了信號函數後,Ctrl + C再也不好使,程序依舊調皮的執行。要結束程序,能夠向進程發送沒法捕捉的信號,例如SIGKILLps aux | grep php找到程序的進程號,而後用kill命令發送SIGKILL信號:kill -SIGKILL 進程號。程序收到信號後被操做系統強制中斷執行。

若是在代碼中捕捉SIGKILL信號會怎麼樣?將上面代碼中的SIGINT改爲SIGKILL,執行腳本會提示:PHP Fatal error: Error installing signal handler for 9 in /home/vagrant/signal.php on line 2。9是SIGKILL的值,錯誤表示代碼中不能捕捉這個信號。

支持哪些信號,默認操做是什麼,和系統相關。絕大部分*nix系統支持SIGINTSIGKILL等31個常見異步信號,某些系統支持更多的信號。

內核收到進程信號後,會查看進程是否註冊了處理函數,若是未註冊則執行默認操做;不然當進程運行在用戶態時,內核回調信號處理函數並移除信號。PHP中收到信號後觸發信號回調函數的方式有三種:

  1. tick觸發,例如每執行100條低級指令檢查信號:declare(ticks=100)
  2. 使用pcntl_signal_dispatch手動觸發,用法見上文signal.php
  3. PHP7.1起可使用pcntl_async_signals異步智能觸發。

tick的方式十分低效,不建議使用;pcntl_signal_dispatch須要手動觸發,可能存在較大延遲。若是PHP的版本不低於7.1,建議使用pcnt_async_signals自動分發信號消息。這個函數效率上比tick高,實時性上比手動觸發強。其原理是當程序從內核態切出、函數返回等時機檢查是否有信號,有則執行回調。

理解了信號,再看看如何使用信號解決殭屍進程問題。子進程退出後,操做系統會發送SIGCLD信號到父進程,在信號回調函數中回收子進程便可,詳情見下面代碼:

// fork-signal.php
pcntl_async_signals(true);

pcntl_signal(SIGCLD, function () {
    $pid = pcntl_wait($status, WUNTRACED);
    fwrite(STDOUT, "child: $pid exited\n");
});

$pid = pcntl_fork();
if ($pid === 0) {
    fwrite(STDOUT, "child exit\n");
} else {
    // mock busy work
    sleep(1);
}
複製代碼

相對於手動pcntl_wait/pcntl_waitpid方式,信號處理無疑更爲簡潔高效。

信號也是進程中通訊的一種方式。接下來簡要說一下進程間通訊。

進程間通訊

fork出子進程後,兩個進程的數據段和堆棧(理論上)均分開。與多線程不一樣,全局變量在不一樣進程中沒法共享。進程間要進行數據交換,必須經過進程間通訊(Inter-Process Communication)技術。上文提到的信號是進程中通訊技術的一種,posix_kill函數能夠向指定進程發送信號,達到通訊的目的。

進程間通訊技術主要有:

  1. 管道(pipe),流管道(s_pipe)和有名管道(FIFO);
  2. 信號(signal);
  3. 消息隊列(message queue);
  4. 共享內存(share memory);
  5. 信號量(semaphore);
  6. 套接字(socket);

這些通訊技術的詳細內容請參考文末的連接,或者其餘文獻,本文再也不詳述。

守護進程

經過php test.php方式執行程序,關閉終端後程序會退出。要讓程序能長期執行,須要額外的手段。總結起來主要有三種:

  1. nohup
  2. screen/tmux等工具;
  3. fork子進程後,父進程退出,子進程升爲會話/進程組長,脫離終端繼續運行。

screen/tmux方式程序實際上仍停留在終端,只是運行在一個長期存在的終端中。nohup和fork方式纔是讓程序脫離(detach)終端,達到肉體飛昇的正道(成爲daemon)。

下面的代碼經過fork的方式讓程序成爲守護進程:

// daemon.php
$pid = pcntl_fork();
switch ($pid) {
case -1:
    fwrite(STDOUT, "fork failed!\n");
    exit(1);
    break;

case 0:
    if (posix_setsid() === -1) {
        fwrite(STDERR, "fail to set child as the session leader!\n");
        exit;
    }
    file_put_contents("/tmp/daemon.out", "php daemon example\n", FILE_APPEND);
    while (true) {
        sleep(5);
        file_put_contents("/tmp/daemon.out", "now: " . date("Y-m-d H:i:s") . "\n", FILE_APPEND);
    }
    break;

default:
    // parent exit
    exit;
}
複製代碼

fork以後最重要的一個操做是posix_setsid,該函數把當前進程設置爲會話組長(被設置的進程當前不能是組長)。某些開源庫中會fork兩次,防止第一次fork的進程無心間打開終端(非會話組長沒法打開終端)。

執行程序:php daemon.php,而後關閉終端,或者從新登陸,經過ps aux | grep daemon.php查看程序均在執行。檢測/tmp/daemon.out,不斷有內容輸出,說明程序已經成爲在後臺持續運行的守護進程。

注意後臺的多進程應當在進程脫離終端後再fork,即最終在後臺幹活的進程不能直接從腳本啓動的進程fork,而應該至少是腳本啓動進程的孫子進程。

應用

下面來講一個多進程的簡單應用。在上一篇博文「PHP回顧之Socket編程」,咱們的服務端已經能作到幾乎實時響應客戶端的請求,可是客戶端不是實時收到服務端下發的消息。利用多進程,咱們用一個進程專門負責讀取服務端的消息,另外一個進程則負責收集用戶在終端的輸入,而後發送到服務端。下面是多進程的客戶端代碼:

// client.php
<?php
$host = "127.0.0.1";
$port = 8000;
$socket = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errMsg);
if ($socket === false) {
    throw new \RuntimeException("unable to create socket: " . $errMsg);
}
stream_set_blocking($socket, false);

fwrite(STDOUT, "success connect to server: [{$host}:{$port}]...\n");

$pid = pcntl_fork();
switch ($pid) {
case -1:
    fwrite(STDOUT, "fail to fork!\n");
    exit(1);
    break;

    // child
case 0:
    while (true) {
        $read = [$socket];
        $write = null;
        $except = null;
        @stream_select($read, $write, $except, null);
        if (count($read)) {
            while (true) {
                $msg = fread($socket, 4096);
                if ($msg) {
                    fwrite(STDOUT, "receive server: $msg\n");
                } else {
                    if (feof($socket)) {
                        fwrite(STDOUT, "server closed.\n");
                        posix_kill(posix_getppid(), SIGINT);
                        exit;
                    }
                    break;
                }
            }
        }
    }
    exit;

    // parent
default:
    while (true) {
        fwrite(STDOUT, "please enter the input:\n");
        $msg = trim(fgets(STDOUT));
        if ($msg) {
            $args = [$msg];
            $message = json_encode([
                "method" => "echo",
                "args" => $args,
            ]);

            fwrite($socket, $message);
        }
    }
}
複製代碼

執行客戶端:php client.php,會發現終端輸入和服務端消息都能及時響應。同時,鏈接斷開的信號也被正確的廣播。

總結

本文簡要介紹了多進程編程的幾個方面,最後給出一個應用的例子,但願對學習多進程的同行有幫助。

感謝閱讀!

參考

  1. php.net/manual/en/b…
  2. php.net/manual/en/b…
  3. www.cnblogs.com/hicjiajia/a…
  4. gityuan.com/2015/12/20/…
  5. www.cnblogs.com/hoys/archiv…
  6. www.cnblogs.com/taobataoma/…
  7. www.jianshu.com/p/c1015f5ff…
  8. blog.csdn.net/column/deta…
  9. segmentfault.com/a/119000000…
相關文章
相關標籤/搜索