聊聊 PHP 多進程模式下的孤兒進程和僵尸進程
大家好,我是碼農先森。
在 PHP 的編程實踐中多進程通常都是在 cli 腳本的模式下使用,我依稀還記得在多年以前為了實現從數據庫導出千萬級別的數據,第一次在 PHP 腳本中采用了多進程編程。
在此之前我從未接觸過多進程,只知道 PHP-FPM 進程管理器是多進程模型,但從未在編程中進行實踐。多進程雖然能帶來效率上的提升,但依然會帶來不少的問題,如果初學者使用多進程,那注定會遇到各種奇奇怪怪的 Bug 比如并發操作數據庫引起死鎖、共用內存變量資源造成串數據、忘記回收進程資源導致產生孤兒進程、僵尸進程等。
反正如果我們長期都是 PHP-FPM 模式下編程的話,在使用多進程編程時需要慎之又慎,避免出現意想不到的問題。不過這次我想分享的內容是多進程模式下的孤兒進程和僵尸進程,通過示例代碼來看看這兩者進程是如何產生的,又應該如何解決,內容不難但是在實際的編程中是可能比較容易忽視的點。
按照慣例我們先看看孤兒進程和僵尸進程的基礎概念。
- 孤兒進程:是指一個進程的父進程已經終止,但該子進程仍然在運行。當父進程結束時,操作系統會將其所有的子進程重新分配給 init 進程。init 進程會負責這些孤兒進程,并確保它們能夠正確結束。孤兒進程不會造成資源泄漏,因為最終它們會被 init 進程管理并正確清理。
- 僵尸進程:是指一個已經完成執行的進程,但仍在進程表中保留了一些信息。這通常發生在父進程未調用 wait() 或相關函數來獲取子進程的退出狀態時。僵尸進程處于 Z 狀態,是一種占用系統資源但不占用 CPU 的進程。僵尸進程會繼續占用系統的進程 ID,如果大量產生將導致進程 ID 耗盡,可能會影響系統的正常運行。
這兩者進程的基礎概念應該還比較好理解,孤兒進程的產生就是緣于父進程的不負責,自己先跑路了,導致自己的子進程變成了孤兒,最后孤兒進程被系統給回收了,可以理解為被政府的福利院收養了。
僵尸進程的產生就是兒子進程執行完了沒有退出,但是父進程又不知情,無法及時回收兒子進程的資源,導致自己的兒子進程變成了僵尸進程,僵尸進程往往比孤兒進程對系統的危害更大,接下來我們來看看具體的代碼示例。
首先看看孤兒進程示例,使用 pcntl_fork 函數創建了一個子進程,子進程會每間隔 1 秒鐘獲取一次自己進程的 ID 和父進程的 ID,而父進程在 2 秒鐘之后就退出跑路了,自此子進程就變成了孤兒進程,被系統進程收養了。
<?php
// 孤兒進程示例
$pid = pcntl_fork();
if ($pid < 0) {
exit('fork error');
} else if($pid > 0) {
// 父進程執行空間 ...
// getmypid 函數獲取當前父進程ID
echo "父進程ID: " . getmypid() . PHP_EOL;
// 2 秒之后退出當前的父進程
// 父進程先行跑路了
sleep(2);
exit();
}
// 子進程執行空間 ...
// getmypid 函數獲取當前子進程ID
$cid = getmypid();
echo "當前子進程: {$cid}" . PHP_EOL;
// 每隔 1 秒獲取一下進程ID
for($i = 1; $i <= 10; $i++){
// posix_getppid 函數獲取當前子進程的父進程ID
sleep(1);
echo "當前子進程ID: " . $cid. ", 父進程ID: " . posix_getppid() . PHP_EOL;
}
// 由于父進程跑路了,子進程變成了孤兒進程 ...
執行 php index.php 觀察輸出結果,可以看出間隔一段時間之后父進程的 ID 就變成 1 了,即為系統進程。
## 執行程序
[manongsen@root php_test]$ php index.php
父進程ID: 3484
當前子進程: 3485
當前子進程ID: 3485, 父進程ID: 3484
當前子進程ID: 3485, 父進程ID: 3484
當前子進程ID: 3485, 父進程ID: 1
當前子進程ID: 3485, 父進程ID: 1
當前子進程ID: 3485, 父進程ID: 1
當前子進程ID: 3485, 父進程ID: 1
當前子進程ID: 3485, 父進程ID: 1
當前子進程ID: 3485, 父進程ID: 1
當前子進程ID: 3485, 父進程ID: 1
當前子進程ID: 3485, 父進程ID: 1
然后再看看僵尸進程示例,同樣也使用 pcntl_fork 創建了一個子進程,然后子進程先行執行完了,父進程還未執行完,這時子進程變成為了僵尸進程。當然僵尸進程也不會一直存在,如果父進程退出了其也會結束自身進程,反之就會一直存在占用著系統資源。
<?php
// 僵尸進程示例
$pid = pcntl_fork();
if ($pid < 0) {
exit('fork error');
} else if($pid > 0) {
// 父進程執行空間 ...
// getmypid 函數獲取當前父進程ID
echo "父進程ID: " . getmypid() . PHP_EOL;
// 120 秒之后退出當前的父進程
sleep(120);
exit();
}
// 子進程執行空間 ...
// getmypid 函數獲取當前子進程ID
$cid = getmypid();
echo "當前子進程: {$cid}" . PHP_EOL;
// 10 秒之后退出子進程
sleep(10);
執行 php index.php 觀察輸出結果,通過查看子進程信息中有一個 Z+ 標識,則表示該進程已經成為了僵尸進程。
## 執行程序
[manongsen@root php_test]$ php index.php
父進程ID: 85804
當前子進程: 85805
## 查看進程信息
[manongsen@root php_test]$ ps aux | grep 85805
root 90776 0.0 0.0 408169072 1408 s060 U+ 22:06下午 0:00.00 grep 85805
root 85805 0.0 0.0 0 0 s062 Z+ 22:06下午 0:00.00 (php)
最后來看看正常進程的示例,也先使用 pcntl_fork 創建了一個子進程,但與上面兩個例子不同的是在其父進程中會調用 pcntl_wait 函數一直等待子進程結束。在子進程 10 秒鐘過后,父進程會接受到子進程執行完畢的通知,然后回收子進程的資源。
<?php
// 正常進程示例
$pid = pcntl_fork();
if ($pid < 0) {
exit('fork error');
} else if($pid > 0) {
// 父進程執行空間 ...
// getmypid 函數獲取當前父進程ID
echo "父進程ID: " . getmypid() . PHP_EOL;
// 一直等待到子進程結束后回收資源
$cid = pcntl_wait($status);
echo "父進程ID: " . getmypid() . ", 接收到子進程ID: {$cid} 退出" . PHP_EOL;
exit();
}
// 子進程執行空間 ...
// getmypid 函數獲取當前子進程ID
$cid = getmypid();
echo "當前子進程: {$cid}" . PHP_EOL;
// 睡眠 10 秒
sleep(10);
執行 php index.php 觀察輸出結果,可以看出子進程執行完畢之后,父進程接收到了子進程的通知。
## 執行程序
[manongsen@root php_test]$ php index.php
父進程ID: 49954
當前子進程: 49955
父進程ID: 49954, 接收到子進程ID: 49955 退出
## 查看進程 49955
[manongsen@root php_test]$ ps aux | grep 49955
root 19516 0.0 0.0 407972944 1216 s062 R+ 22:23下午 0:00.00 grep 49955
root 49955 0.0 0.0 437931336 372 s060 S+ 22:23下午 0:00.00 php index.php
## 再次查看進程 49955
[manongsen@root php_test]$ ps aux | grep 49955
root 26599 0.0 0.0 407963440 480 s062 R+ 22:24下午 0:00.00 grep 49955
通過這上面的例子可以看出,多進程中正確的使用方式是要在父進程中使用 pcntl_wait 函數等待子進程的結束,而不是只管 pcntl_fork 生產完子進程,然后就對子進程不聞不問了。
從生活化的例子來說就是,你不能只管生娃,生完之后就不管養育了,這種操作肯定是不行的,道德和法律層面這一關你都過不去。利用 pcntl_wait 這個函數可以很優雅的解決了孤兒進程和僵尸進程,但在實際的編程中很容易忽視這一點,因此這一點值得注意。