對 PHP 的誤解頗深
網路上似乎存在一種現象,一提到 PHP 人們的第一反應是簡單且慢,這種簡單甚至已經到了簡陋的地步,比如不少人認為 PHP 無法獨立建立一個服務,只能配合 Apache 或 Nginx 一起使用,而且 PHP 只能在處理完請求後銷燬資源關閉程序,所以也無法處理長連線業務,這些都是對 PHP 的誤解,我想這種誤解的形成可能與 PHP 的發展歷史有關,實際上 PHP 能做的有很多,下面就先從 PHP 的發展歷史說起。
PHP 的發展簡史
在我看來,PHP 的發展路線確實與其他主流程式語言不太相同。PHP 天生就是為了 Web 而生的,早期的 Web 網頁都是靜態的,例如在個人主頁上展示一些固定的個人資訊,為了能夠讓網頁展示一些動態的統計資料和簡單的互動,Rasmus Lerdorf 在 1995 年開發了 Personal Home Page 工具集合,簡稱為 PHP,PHP 透過 CGI 協議與 Web 伺服器互動,透過實時計算生成動態的內容。這裡可能是與其他主流程式語言差別最大的點,其他語言的執行環境要麼是透過編譯後直接執行,要麼是在命令列中呼叫直譯器執行的。
因為 PHP 最初的目標就是做一些簡單的計算,所以並不具備主流程式語言中的一些高階特性,後來越來越多的網站開始使用 PHP 並希望能提供更多的功能,之後 Lerdorf 將 PHP 開源,在這之後 Zeev Suraski 和 Andi Gutmans 重寫了 PHP 的解析器,並從此開始 PHP 改為 Hypertext Preprocessor,新版的解析器命名為 Zend Engine。Zend 的命名來自於兩位作者的名字,至此 PHP 支援了物件導向、名稱空間等特性,已經脫胎換骨成為了一門完善的程式語言。在 2015 年 PHP 7 釋出,重構了 PHP 中很多重要且常用的資料結構,記憶體佔用得到顯著最佳化,效能也得到了大幅提升。
火爆的 LAMP 架構
雖然 PHP 經過幾次版本迭代已經具備了現代程式語言的必要特性,但依舊有很多人對 PHP 有著類似前面提到的種種誤解,造成這種誤解的原因很大程度上是因為曾經 Web 領域中應用最廣泛的架構 - LAMP 實在是太火了。在這套架構中 Linux 作為作業系統,MySQL 用於資料儲存,Apache 負責處理網路連線和 HTTP 協議,而 PHP 放在其後面負責處理動態內容。由於這套架構簡單有效且開源免費,可以低成本快速搭建起一個可用的服務,這對於初創團隊業務試錯來說十分具有吸引力,一度出現了很多一鍵安裝的整合軟體包,讓這套架構的上手門檻進一步降低,但長此以往可能讓不少人以為 PHP 只能配合 Apache 或 Nginx 使用,而 PHP 遠不止於此。放在 Apache 或 Nginx 後面只是 PHP 執行模式的一種,也就是 CGI 模式,此外 PHP 支援其他模式,下面做一個對比。
PHP 執行的幾種模式
按我的理解,PHP 執行模式嚴格來說就分兩種,CGI 模式和 CLI 模式,CGI 後來衍生出了 Apache mod、FastCGI、FPM 等模式。
CGI 模式
CGI (Common Gateway Interface)通用閘道器介面是一種協議,是早期 Web 伺服器與外部程式互動的一種方式,Web 伺服器與外部程式之間透過環境變數、標準輸入和標準輸出交換資料。
CGI 的 logo 是一個三稜鏡,其中一束光穿過三稜鏡被分解成不同顏色,象徵著 CGI 可以將網路請求分解並傳遞給不同應用程式處理,展現出了 CGI 的多樣性和靈活性。
遵循 CGI 協議的 Web 伺服器一般會有一個名為 cgi-bin 的目錄,目錄下面預設都是可執行 CGI 指令碼檔案,如果前端訪問到了這些檔案那麼 Web 伺服器並不會像處理普通檔案那樣直接將檔案返回給前端,而是會 fork 出子程序並在子程序中執行指定的 CGI 指令碼,指令碼執行完成後透過標準輸出將結果返回給 Web 伺服器,並關閉子程序。
執行前 Web 伺服器會將一些必要的請求資訊設定在環境變數中,CGI 指令碼執行後便可以透過讀取環境變數得到這些請求資訊,例如 uri、請求引數等。CGI 指令碼的標準輸出會重定向給 Web 伺服器,伺服器接到輸出後返回給前端,這就是為什麼早期的 CGI 模式下執行的 PHP 程式可以透過 echo
來返回結果的原因。
這種模式特點是比較簡單,並且由於每次處理完成後都會銷燬程序和資源,所以也不會出現記憶體洩漏等問題,但缺點是由於每次都需要重新建立新的程序並銷燬,效能開銷較大,也無法利用到長連線或池化技術,在處理大量併發請求時處理能力較低。
FastCGI 模式與 PHP-FPM
為了解決 CGI 模式下每次都要新建子程序並銷燬子程序導致的效能低下問題,FastCGI 模式在 CGI 基礎上做出了改進,這種模式下會預先建立出一些 CGI 程序常駐記憶體,當有請求到來時會分配一個空閒程序處理,完成後並不銷燬而是作為空閒程序重新等待處理請求。
FastCGI 是協議,而 PHP-FPM 是 FastCGI 的實現,全稱為 PHP FastCGI Process Manager。這種模式根本上還是基於 CGI 模式衍生出來的,主要最佳化的是引入常駐記憶體特性以及多個 FPM 程序的管理,減少了頻繁開啟關閉程序帶來的效能損耗,但由於 Web 伺服器與 FPM 程序之間還是短連線,所以這種模式不支援與客戶端的長連線。
CLI 模式
CLI 模式則是直接使用 PHP 直譯器來執行 PHP 程式碼,例如 php test.php
,在我看來無論哪種程式語言,CLI 模式才應該是最為廣大人民群眾所喜聞樂見的模式,但由於 PHP 以 CGI 以及 FastCGI 模式執行實在太過深入人心,以至於 CLI 模式反而對很多人來說較為陌生。
在這種模式下 PHP 的執行方式與其他高階程式語言區別並不大,支援常見的系統呼叫,就算不支援還可以透過擴充套件的形式支援,自然可以實現 socket 網路程式設計以及常駐記憶體,實現長連線也是很自然的事。
CLI 模式下實現 socket 程式設計常見的方式有兩種,一種是使用官方 sockets 擴充套件提供 socket 支援的方式,另一種是基於第三方擴充套件例如 swoole,本文主要介紹原生 PHP 的實現方式。
PHP CGI 與 CLI 示例
下面分別列出兩個例子,介紹 CGI 和 CLI 兩個典型模式是如何執行的。
CGI 模式示例
首先是一個 C 語言實現的伺服器,監聽 8080 埠,接到請求時如果請求的是指定 CGI 指令碼則會透過 fp = popen(cgi_script, "r");
以子程序的方式啟動 CGI 指令碼,由於使用了 setenv
來設定環境變數,所以在子程序中可以讀取到這些環境變數並做出一些計算處理。
下面就是 CGI 協議中規定的環境變數,是否很眼熟,例如 QUERY_STRING
環境變數就是 CGI 協議中規定的經過 URL-encoded 的引數:
下面實現一個最基本的 CGI server,接到請求會啟動一個 PHP 子程序處理,最後接到 PHP 的輸出後返回客戶端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#define PORT 8080
#define BUFFER_SIZE 1024
void handle_cgi(int client_fd, const char *cgi_script, const char *query_string) {
char buffer[BUFFER_SIZE];
FILE *fp;
// 設定 CGI 環境變數
setenv("REQUEST_METHOD", "GET", 1);
// QUERY_STRING 是用來設定請求引數的
setenv("QUERY_STRING", query_string, 1);
setenv("SCRIPT_FILENAME", cgi_script, 1);
setenv("SERVER_PROTOCOL", "HTTP/1.1", 1);
setenv("GATEWAY_INTERFACE", "CGI/1.1", 1);
setenv("SERVER_SOFTWARE", "MyServer/1.0", 1);
setenv("REMOTE_ADDR", "127.0.0.1", 1);
setenv("REDIRECT_STATUS", "200", 1);
// 啟動子程序啟動 CGI 指令碼
fp = popen(cgi_script, "r");
if (fp == NULL) {
perror("popen");
return;
}
// 傳送 HTTP 頭
write(client_fd, "HTTP/1.1 200 OK\r\n", 17);
// 讀取 CGI 指令碼的輸出併傳送給客戶端
while (fgets(buffer, sizeof(buffer), fp) != NULL) {
write(client_fd, buffer, strlen(buffer));
}
pclose(fp);
}
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
exit(EXIT_FAILURE);
}
if (listen(server_fd, 10) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
printf("Server is running on port %d\n", PORT);
while (1) {
client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept");
continue;
}
read(client_fd, buffer, sizeof(buffer) - 1);
char method[BUFFER_SIZE];
char path[BUFFER_SIZE];
char protocol[BUFFER_SIZE];
sscanf(buffer, "%s %s %s", method, path, protocol);
// 一個簡單的路由處理
if (strncmp(path, "/cgi-bin/script.php", 19) == 0) {
char *query_string = strchr(path, '?');
if (query_string != NULL) {
query_string++;
} else {
query_string = "";
}
handle_cgi(client_fd, "./script.php", query_string); // 確保指令碼路徑正確
} else {
write(client_fd, "HTTP/1.1 404 Not Found\r\n", 24);
write(client_fd, "Content-Type: text/html\r\n\r\n", 27);
write(client_fd, "<html><body>CGI script Not Found</body></html>", 38);
}
close(client_fd);
}
close(server_fd);
return 0;
}
PHP 指令碼,需要新增可執行許可權,指定預設使用 #!/usr/local/bin/php-cgi
執行,$_GET
和 $_SERVER
都是 PHP 根據 CGI 協議從環境變數中解析出來的,最終透過 echo
輸出結果,傳遞給 Web 伺服器。
#!/usr/local/bin/php-cgi
<?php
// 解析 GET 請求引數
echo "<h2>GET 請求引數:</h2>";
echo "<pre>";
print_r($_GET);
echo "</pre>";
// 解析通用請求引數(GET 和 POST)
echo "<h2>SERVER:</h2>";
echo "<pre>";
print_r($_SERVER);
echo "</pre>";
?>
透過編譯並啟動 server.c
就可以訪問 8080
埠,看到輸出結果。
> gcc -o server server.c
> ./server
Server is running on port 8080
> curl localhost:8080
<h2>GET 請求引數:</h2><pre>Array
(
)
</pre><h2>SERVER:</h2><pre>Array
(
[GATEWAY_INTERFACE] => CGI/1.1
[HOSTNAME] => a05d15a93523
[PHP_INI_DIR] => /usr/local/etc/php
[REMOTE_ADDR] => 127.0.0.1
[HOME] => /root
[QUERY_STRING] =>
[PHP_LDFLAGS] => -Wl,-O1 -pie
[PHP_CFLAGS] => -fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
[PHP_VERSION] => 8.2.21
[SCRIPT_FILENAME] => ./script.php
[GPG_KEYS] => 39B641343D8C104B2B146DC3F9C39DC0B9698544 E60913E4DF209907D8E30D96659A97C9CF2A795A 1198C0117593497A5EC5C199286AF1F9897469DC
[PHP_CPPFLAGS] => -fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
[PHP_ASC_URL] => https://www.php.net/distributions/php-8.2.21.tar.xz.asc
[PHP_URL] => https://www.php.net/distributions/php-8.2.21.tar.xz
[SERVER_SOFTWARE] => MyServer/1.0
[TERM] => xterm
[PATH] => /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[SERVER_PROTOCOL] => HTTP/1.1
[REDIRECT_STATUS] => 200
[REQUEST_METHOD] => GET
[PHPIZE_DEPS] => autoconf dpkg-dev file g++ gcc libc-dev make pkg-config re2c
[PWD] => /app
[PHP_SHA256] => 8cc44d51bb2506399ec176f70fe110f0c9e1f7d852a5303a2cd1403402199707
[PHP_SELF] =>
[REQUEST_TIME_FLOAT] => 1722446968.3435
[REQUEST_TIME] => 1722446968
[argv] => Array
(
)
[argc] => 0
)
</pre>
CLI 模式示例
PHP 透過 sockets 擴充套件提供了 socket 網路程式設計相關的系統呼叫封裝,下面程式碼中使用的是 socket_create
、socket_bind
、socket_listen
、socket_accept
、socket_read
、socket_write
、 socket_close
等一系列 socket 函式實現的 TCP 長連線服務
<?php
$address = '0.0.0.0';
$port = 8080;
$sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if (!$sock) {
die("Could not create socket: " . socket_strerror(socket_last_error()) . "\n");
}
if (!socket_bind($sock, $address, $port)) {
die("Could not bind socket: " . socket_strerror(socket_last_error($sock)) . "\n");
}
if (!socket_listen($sock, 5)) {
die("Could not listen on socket: " . socket_strerror(socket_last_error($sock)) . "\n");
}
echo "Server listening on $address:$port\n";
do {
$client = socket_accept($sock);
if ($client) {
$input = socket_read($client, 1024);
echo "client say:" . $input;
$output = "Hello, " . trim($input) . "\n";
socket_write($client, $output, strlen($output));
socket_close($client);
}
} while (true);
socket_close($sock);
服務端測試
> php server.php
Server listening on 0.0.0.0:8080
client say:123
客戶端測試
> nc localhost 8080
123
Hello, 123
除此了直接使用 socket 相關函式之外,PHP 還提供了以 stream 方式處理 socket 的一系列函式,如 stream_socket_server
相當於整合了 socket_create
、socket_bind
、socket_listen
函式。
Workerman 的實現
Workerman 是一款高效能 PHP 應用容器,是一個典型的基於 PHP socket 的以 CLI 模式執行的應用容器,結合 IO 多路複用和多程序達到了相當不錯的效能。下面就看看 Workerman 的核心部分是如何實現的。
以下程式碼來自 Workerman 4.1.0 版本,只展示了核心部分。
Workerman 入口函式是 runAll
public static function runAll()
{
static::checkSapiEnv();
static::init();
static::parseCommand();
static::daemonize();
static::initWorkers();
static::installSignal();
static::saveMasterPid();
static::displayUI();
static::forkWorkers();
static::resetStd();
static::monitorWorkers();
}
在 initWorkers
函式中初始化 server 例項,其中會根據 reusePort
屬性判斷是否要在主程序中呼叫 listen
初始化 socket。
protected static function initWorkers()
{
foreach (static::$_workers as $worker) {
// 如果沒開啟埠重用,則主程序會主動 listen
if (!$worker->reusePort) {
$worker->listen();
}
}
}
public function listen()
{
if (!$this->_mainSocket) {
// 建立 socket
$this->_mainSocket = \stream_socket_server($local_socket, $errno, $errmsg, $flags, $this->_context);
if (!$this->_mainSocket) {
throw new Exception($errmsg);
}
if (\function_exists('socket_import_stream') && static::$_builtinTransports[$this->transport] === 'tcp') {
\set_error_handler(function(){});
$socket = \socket_import_stream($this->_mainSocket);
\socket_set_option($socket, SOL_SOCKET, SO_KEEPALIVE, 1);
\socket_set_option($socket, SOL_TCP, TCP_NODELAY, 1);
\restore_error_handler();
}
// 設定非阻塞 socket
\stream_set_blocking($this->_mainSocket, 0);
}
$this->resumeAccept();
}
reusePort
屬性相當於 socket 的 SO_REUSEPORT
選項,表示是否開啟埠重用,這個選項涉及到驚群問題。
預設沒有開啟 SO_REUSEPORT
,那麼主程序會在 initWorkers
函式中主動呼叫一次 listen
函式建立 socket,之後在 forkWorkers
函式中 fork 出子程序,子程序會繼承這個 socket,並在其之上進行事件迴圈的阻塞等待。之後當客戶端請求到來時,所有子程序都會被喚醒嘗試去 accept
客戶端連線,但最終只有一個子程序可以 accpet
成功,其他子程序只能重新阻塞掛起,這種現象就是驚群,頻繁且大量的程序狀態切換會浪費系統資源。
而如果開啟 SO_REUSEPORT
那麼主程序中不會呼叫 listen
,而是在 forkOneWorkerForLinux
時由每個子程序各自建立 socket 並分別在自己的 socket 上進行事件迴圈,由於開啟了埠重用,所以作業系統執行不同程序監聽相同埠。當客戶端請求到來時,作業系統會以負載均衡的方式喚醒其中一個子程序處理請求,這樣就避免了驚群問題導致的效能損耗。
protected static function forkOneWorkerForLinux(self $worker)
{
$pid = \pcntl_fork();
// 主程序
if ($pid > 0) {
static::$_pidMap[$worker->workerId][$pid] = $pid;
static::$_idMap[$worker->workerId][$id] = $pid;
} // 子程序
elseif (0 === $pid) {
// 這裡決定是否應該呼叫 listen 函式建立自己的 socket
if ($worker->reusePort) {
$worker->listen();
}
$worker->run();
} else {
throw new Exception("forkOneWorker fail");
}
}
最終在 run
方法中建立並啟動事件迴圈
public function run()
{
// 建立事件迴圈
if (!static::$globalEvent) {
$event_loop_class = static::getEventLoopName();
static::$globalEvent = new $event_loop_class;
$this->resumeAccept();
}
// 啟動事件迴圈
static::$globalEvent->loop();
}
workerman 在 CLI 模式下結合多路複用 IO 和事件迴圈,並採用多程序模式執行,可以較好的支援高併發長連線場景。
PHP 不適合幹這個?
可能有的人會說 PHP 不適合幹這種活,不過在我看來適不適合應該以成本為前提。Web 應用屬於典型的 IO 密集型應用,這種場景下使用這種方案已經可以應對大部分業務規模,如果團隊是 PHP 為主語言那麼使用這個方案成本是最低的而且效果也相當不錯,或者說在業務發展到瓶頸之前這個方案一般不會先遇到瓶頸,如果遇到了那麼首先恭喜你的業務取得了長足進步,其次應該考慮的是透過架構的方式來解決更大規模問題,例如進行服務化和分層化等等。
總的來說 PHP 不僅僅停留在 FPM,也絕不是低效能的代名詞,結合業務場景和團隊實際情況,採用合適的 PHP 解決方案不僅能達到不錯的效果,開發和維護成本方面也具有一定優勢。