PHP回顧之多程式程式設計

tlanyan發表於2018-06-24

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

PHP回顧系列目錄

為了更好的利用多核CPU,我們需要多程式或多執行緒。但在常規web開發中,我們極少用到這兩種併發技術(curl_multi等特殊函式除外)。如果指令碼執行在CLI模式下,多程式和多執行緒技術是提高多核CPU的有力工具。

相對於多執行緒,多程式的程式具有健壯、無鎖、對分散式支援更好等特點。本文來學習一下PHP的多程式程式設計。

多程式

PHP中與(多)程式相關的兩個重要擴充是PCNTLPOSIXPCNTL主要用來建立、執行子程式和處理訊號,POSIX擴充則實現了POSIX標準中定義的介面。由於Windows不是POSIX相容的,所以POSIX擴充在Windows平臺上不可用。

先上簡單的程式碼看多程式程式設計:

// 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。可以根據函式的返回值判斷接下來的執行環境在父程式中還是子程式中。

fork呼叫讓系統建立一個與當前程式幾乎完全一樣的程式,除了程式號等少數資訊不一樣,程式的程式碼段、堆疊、資料段的值都一致。父程式開啟了一個檔案,複製的子程式同樣享有這個控制程式碼,這是過去多程式能監聽同一個埠的原理;子程式基於父程式fork時的環境繼續執行(程式碼段共享)直到退出。

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

除了fork,另外一種多程式技術是exec。systemexecproc_open等函式會生成一個新的程式執行外部命令(並返回結果)。這些函式的本質是fork一個程式,然後呼叫shell執行命令,主程式等待其執行結束。函式執行期間,主程式除了等待無法處理其他任務,所以一般不認為這是多程式程式設計。實踐中可以結合fork來併發執行外部命令。

孤兒程式與殭屍程式

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

孤兒程式會被系統的根程式(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…

相關文章