作為PHP開發人員,我們並不經常需要擔心記憶體管理。PHP 引擎在我們背後做了很好的清理工作,短期執行上下文的 Web 伺服器模型意味著即使是最潦草的程式碼也不會造成持久的影響。
很少情況下我們可能需要走出這個舒適的地方 ——比如當我們試圖在一個大型專案上執行 Composer 來建立我們可以建立的最小的 VPS 時,或者當我們需要在一個同樣小的伺服器上讀取大檔案時。
後面的問題就是我們將在本教程中深入探討的。
在 GitHub上可以找到本教程的原始碼。
衡量成功的標準
確保我們對程式碼有改進的唯一方法是測試一個不好的情況,然後將我們修復之後的測量與另一個進行比較。換句話說,除非我們知道“解決方案”對我們有多大的幫助(如果有的話),否則我們不知道它是否真的是一個解決方案。
這裡有兩個我們可以關係的衡量標準。首先是CPU使用率。我們要處理的程式有多快或多慢?第二是記憶體使用情況。指令碼執行時需要多少記憶體?這兩個通常是成反比的 – 這意味著我們可以以CPU使用率為代價來降低記憶體使用,反之亦然。
在一個非同步執行模型(如多程式或多執行緒的PHP應用程式)中,CPU和記憶體的使用率是很重要的考量因素。在傳統的PHP架構中,當任何一個值達到伺服器的極限時,這些通常都會成為問題。
測量PHP內的CPU使用率是不切實際的。如果這是你要關注的領域,請考慮在Ubuntu或MacOS上使用類似top的工具。對於Windows,請考慮使用Linux子系統,以便在Ubuntu中使用top。
為了本教程的目的,我們將測量記憶體使用情況。我們將看看在“傳統”的指令碼中使用了多少記憶體。我們將執行一些優化策略並對其進行度量。最後,我希望你能夠做出一個有經驗的選擇。
我們檢視記憶體使用多少的方法是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// formatBytes is taken from the php.net documentation memory_get_peak_usage(); function formatBytes($bytes, $precision = 2) { $units = array("b", "kb", "mb", "gb", "tb"); $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); $bytes /= (1 << (10 * $pow)); return round($bytes, $precision) . " " . $units[$pow]; } |
我們將在指令碼的最後使用這些函式,以便我們能夠看到哪個指令碼一次使用最大的記憶體。
我們的選擇是什麼?
這裡有很多方法可以有效地讀取檔案。但是也有兩種我們可能使用它們的情況。我們想要同時讀取和處理所有資料,輸出處理過的資料或根據我們所讀取的內容執行其他操作。我們也可能想要轉換一個資料流,而不需要真正訪問的資料。
讓我們設想一下,對於第一種情況,我們希望讀取一個檔案,並且每10,000行建立一個獨立排隊的處理作業。我們需要在記憶體中保留至少10000行,並將它們傳遞給排隊的工作管理器(無論採取何種形式)。
對於第二種情況,我們假設我們想要壓縮一個特別大的API響應的內容。我們不在乎它的內容是什麼,但我們需要確保它是以壓縮形式備份的。
在這兩種情況下,如果我們需要讀取大檔案,首先,我們需要知道資料是什麼。第二,我們並不在乎資料是什麼。讓我們來探索這些選擇吧…
逐行讀取檔案
有許多操作檔案的函式,我們把部分結合到一個簡單的檔案閱讀器中(封裝為一個方法):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// from memory.php function formatBytes($bytes, $precision = 2) { $units = array("b", "kb", "mb", "gb", "tb"); $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); $bytes /= (1 << (10 * $pow)); return round($bytes, $precision) . " " . $units[$pow]; } print formatBytes(memory_get_peak_usage()); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// from reading-files-line-by-line-1.php function readTheFile($path) { $lines = []; $handle = fopen($path, "r"); while(!feof($handle)) { $lines[] = trim(fgets($handle)); } fclose($handle); return $lines; } readTheFile("shakespeare.txt"); require "memory.php"; |
我們讀取一個文字檔案為莎士比亞全集。檔案大小為5.5MB,記憶體佔用峰值為12.8MB。現在讓我們用一個生成器來讀取每一行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// from reading-files-line-by-line-2.php function readTheFile($path) { $handle = fopen($path, "r"); while(!feof($handle)) { yield trim(fgets($handle)); } fclose($handle); } readTheFile("shakespeare.txt"); require "memory.php"; |
文字檔案大小不變,但記憶體使用峰值只是393KB。即使我們能把讀取到的資料做一些事情也並不意味著什麼。也許我們可以在看到兩條空白時把文件分割成塊,像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// from reading-files-line-by-line-3.php $iterator = readTheFile("shakespeare.txt"); $buffer = ""; foreach ($iterator as $iteration) { preg_match("/\n{3}/", $buffer, $matches); if (count($matches)) { print "."; $buffer = ""; } else { $buffer .= $iteration . PHP_EOL; } } require "memory.php"; |
猜到我們使用了多少記憶體嗎?我們把文件分割為1216塊,仍然只使用了459KB的記憶體,這是否讓你驚訝?考慮到生成器的性質,我們使用的最多記憶體是使用在迭代中我們需要儲存的最大文字塊。在本例中,最大的塊為101985字元。
我已經撰寫了使用生成器提示效能和Nikita Popov的迭代器庫,如果你感興趣就去看看吧!
生成器還有其它用途,但是最明顯的好處就是高效能讀取大檔案。如果我們需要處理這些資料,生成器可能是最好的方法。
管道間的檔案
在我們不需要處理資料的情況下,我們可以把檔案資料傳遞到另一個檔案。通常被稱為管道(大概是因為我們看不到除了兩端的管子裡面,當然,它也是不透明的),我們可以通過使用流方法實現。讓我們先寫一個指令碼從一個檔案傳到另一個檔案。這樣我們可以測量記憶體的佔用情況:
1 2 3 4 5 6 7 |
// from piping-files-1.php file_put_contents( "piping-files-1.txt", file_get_contents("shakespeare.txt") ); require "memory.php"; |
不出所料,這個指令碼使用更多的記憶體來進行文字檔案複製。這是因為它讀取(和保留)檔案內容在記憶體中,直到它被寫到新檔案中。對於小檔案這種方法也許沒問題。當為更大的檔案時,就捉襟見肘了…
讓我們嘗試用流(管道)來傳送一個檔案到另一個:
1 2 3 4 5 6 7 8 9 10 11 |
// from piping-files-2.php $handle1 = fopen("shakespeare.txt", "r"); $handle2 = fopen("piping-files-2.txt", "w"); stream_copy_to_stream($handle1, $handle2); fclose($handle1); fclose($handle2); require "memory.php"; |
這段程式碼稍微有點陌生。我們開啟了兩檔案的控制程式碼,第一個是隻讀模式,第二個是隻寫模式,然後我們從第一個複製到第二個中。最後我們關閉了它,也許使你驚訝,記憶體只佔用了393KB
這似乎很熟悉。像程式碼生成器在儲存它讀到的每一行程式碼?那是因為第二個引數fgets規定了每行讀多少個位元組(預設值是-1或者直到下一行為止)。
第三個引數stream_copy_to_stream和第二個引數是同一類引數(預設值相同),stream_copy_to_stream一次從一個資料流裡讀一行,同時寫到另一個資料流裡。它跳過生成器只有一個值的部分(因為我們不需要這個值)。
這篇文章對於我們來說可能是沒用的,所以讓我們想一些我們可能會用到的例子。假設我們想從我們的CDN中輸出一張圖片,作為一種重定向的路由應用程式。我們可以參照下邊的程式碼來實現它:
1 2 3 4 5 6 7 8 9 10 11 |
// from piping-files-3.php file_put_contents( "piping-files-3.jpeg", file_get_contents( "https://github.com/assertchris/uploads/raw/master/rick.jpg" ) ); // ...or write this straight to stdout, if we don't need the memory info require "memory.php"; |
設想一下,一個路由應用程式讓我們看到這段程式碼。但是,我們想從CDN獲取一個檔案,而不是從本地的檔案系統獲取。我們可以用一些其他的東西來更好的替換file_get_contents(就像Guzzle),即使在引擎內部它們幾乎是一樣的。
圖片的記憶體大概有581K。現在,讓我們來試試這個
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// from piping-files-4.php $handle1 = fopen( "https://github.com/assertchris/uploads/raw/master/rick.jpg", "r" ); $handle2 = fopen( "piping-files-4.jpeg", "w" ); // ...or write this straight to stdout, if we don't need the memory info stream_copy_to_stream($handle1, $handle2); fclose($handle1); fclose($handle2); require "memory.php"; |
記憶體使用明顯變少(大概400K),但是結果是一樣的。如果我們不關注記憶體資訊,我們依舊可以用標準模式輸出。實際上,PHP提供了一個簡單的方式來完成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$handle1 = fopen( "https://github.com/assertchris/uploads/raw/master/rick.jpg", "r" ); $handle2 = fopen( "php://stdout", "w" ); stream_copy_to_stream($handle1, $handle2); fclose($handle1); fclose($handle2); // require "memory.php"; |
其它流
還有其它一些流,我們可以通過管道來寫入和讀取(或只讀取/只寫入):
- php://stdin (只讀)
- php://stderr (只寫, 如php://stdout)
- php://input (只讀) 這使我們能夠訪問原始請求體
- php://output (只寫) 讓我們寫入輸出緩衝區
- php://memory 和 php://temp (讀-寫) 是我們可以臨時儲存資料的地方。 不同之處在於一旦它變得足夠大 php://temp 會將資料儲存在檔案系統中,而 php://memory 將一直持儲存在記憶體中直到資源耗盡。
過濾器
還有一個我們可以在stream上使用的技巧,稱為過濾器。它們是一種中間的步驟,提供對stream資料的一些控制,但不把他們暴露給我們。想象一下,我們會使用Zip副檔名來壓縮我們的shakespeare.txt檔案。
1 2 3 4 5 6 7 8 9 10 |
// from filters-1.php $zip = new ZipArchive(); $filename = "filters-1.zip"; $zip->open($filename, ZipArchive::CREATE); $zip->addFromString("shakespeare.txt", file_get_contents("shakespeare.txt")); $zip->close(); require "memory.php"; |
這是一小段整潔的程式碼,但它測量記憶體佔用在10.75MB左右。使用過濾器的話,我們可以減少記憶體:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// from filters-2.php $handle1 = fopen( "php://filter/zlib.deflate/resource=shakespeare.txt", "r" ); $handle2 = fopen( "filters-2.deflated", "w" ); stream_copy_to_stream($handle1, $handle2); fclose($handle1); fclose($handle2); require "memory.php"; |
此處,我們可以看到名為php://filter/zlib.deflate的過濾器,它讀取並壓縮資源的內容。我們可以在之後將壓縮資料匯出到另一個檔案中。這僅使用了896KB.
我知道這是不一樣的格式,或者製作zip存檔是有好處的。你不得不懷疑:如果你可以選擇不同的格式並節省約12倍的記憶體,為什麼不選呢?
為了解壓此資料,我們可以通過執行另一個zlib filter將壓縮後的資料還原:
1 2 3 4 5 |
// from filters-2.php file_get_contents( "php://filter/zlib.inflate/resource=filters-2.deflated" ); |
Streams have been extensively covered in Stream在“理解PHP中的流”和“U高效使用PHP中的流”中已經被全面介紹了。如果你喜歡一個完全不同的視角,可以閱讀一下。
定製流
fopen和file_get_contents有它們自己的一套預設選項,但是這些都是完全可定製的。為了定義它們,我們需要建立一個新的流上下文:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// from creating-contexts-1.php $data = join("&", [ "twitter=assertchris", ]); $headers = join("\r\n", [ "Content-type: application/x-www-form-urlencoded", "Content-length: " . strlen($data), ]); $options = [ "http" => [ "method" => "POST", "header"=> $headers, "content" => $data, ], ]; $context = stream_content_create($options); $handle = fopen("https://example.com/register", "r", false, $context); $response = stream_get_contents($handle); fclose($handle); |
在這個例子中,我們正在嘗試向API發出POST請求。 API終端是安全的,但我們仍然需要使用http上下文屬性(用於http和https)。我們設定一些訊息頭引數,並開啟一個檔案控制程式碼到API。由於上下文處理寫操作,我們可以將控制程式碼開啟為只讀。
檢視文件瞭解更多。
制定自定義協議和過濾器
在我們結束之前,讓我們談談制定自定義協議。 如果你檢視文件,你可以找到一個示例類來實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
Protocol { public resource $context; public __construct ( void ) public __destruct ( void ) public bool dir_closedir ( void ) public bool dir_opendir ( string $path , int $options ) public string dir_readdir ( void ) public bool dir_rewinddir ( void ) public bool mkdir ( string $path , int $mode , int $options ) public bool rename ( string $path_from , string $path_to ) public bool rmdir ( string $path , int $options ) public resource stream_cast ( int $cast_as ) public void stream_close ( void ) public bool stream_eof ( void ) public bool stream_flush ( void ) public bool stream_lock ( int $operation ) public bool stream_metadata ( string $path , int $option , mixed $value ) public bool stream_open ( string $path , string $mode , int $options , string &$opened_path ) public string stream_read ( int $count ) public bool stream_seek ( int $offset , int $whence = SEEK_SET ) public bool stream_set_option ( int $option , int $arg1 , int $arg2 ) public array stream_stat ( void ) public int stream_tell ( void ) public bool stream_truncate ( int $new_size ) public int stream_write ( string $data ) public bool unlink ( string $path ) public array url_stat ( string $path , int $flags ) } |
我們不打算實現其中的一個,因為我認為它應該有自己的教程。這裡有很多工作需要完成。但是一旦這個工作完成,我們可以很容易地註冊我們的流包裝:
1 2 3 4 5 6 7 |
if (in_array("highlight-names", stream_get_wrappers())) { stream_wrapper_unregister("highlight-names"); } stream_wrapper_register("highlight-names", "HighlightNamesProtocol"); $highlighted = file_get_contents("highlight-names://story.txt"); |
同樣,也可以建立自定義流過濾器。該文件有一個示例過濾器類:
1 2 3 4 5 6 7 8 |
Filter { public $filtername; public $params public int filter ( resource $in , resource $out , int &$consumed , bool $closing ) public void onClose ( void ) public bool onCreate ( void ) } |
這可以很容易地註冊:
1 2 |
$handle = fopen("story.txt", "w+"); stream_filter_append($handle, "highlight-names", STREAM_FILTER_READ); |
突出顯示名稱需要匹配新的篩選器類的filtername屬性。也可以在php://filter/highligh-names/resource=story.txt字串中使用自定義過濾器。定義過濾器比定義協議要容易得多。因為協議需要處理目錄操作,而過濾器只需處理每個資料塊。
如果你有這個想法,我強烈建議你嘗試建立自定義協議和過濾器。如果你可以將過濾器應用於stream_copy_to_streamoperations,那麼即使在使用大容量檔案時,你的應用程式也可以在沒有記憶體的情況下使用。想象一下,編寫一個調整大小的影象過濾器或加密的應用程式過濾器。
總結
雖然這不是我們經常遇到的問題,但在處理大檔案時很容易搞砸。在非同步應用程式中,當我們不注意小心使用記憶體的話,很容易導致整個伺服器當機。
本教程希望向你介紹一些新的想法(或者讓你重新認識他們),以便你可以更多地考慮如何高效地讀取和寫入大型檔案。當我們開始熟悉流程和生成器,並停止使用像file_get_contents這樣的函式時,我們的應用程式中就會減少錯誤的類別,這看起來是很好。