От автора: PHP разработчики редко заботятся об управлении памятью. PHP движок превосходно выполняет свою работу и подчищает за нами. Серверная модель кратковыполняемого контекста значит, что даже самый плохой код имеет долгосрочный эффект.
Нам мало когда необходимо выходить за эти комфортные рамки. Например, когда мы пытаемся запустить Composer в большом проекта на минимальном VPS, или когда необходимо произвести в PHP чтение большого файла на все таком же маленьком сервере.
В этом уроке мы обсудим последнюю проблему. Код к уроку можно найти на GitHub.
Измеряем успех
Единственный способ понять, что мы что-то улучшили в коде, это измерить плохой участок, после чего сравнить эти измерения после фикса. Другими словами, если мы не знаем, насколько «решение» помогло нам (если вообще помогло), мы не можем утверждать, что это вообще решение.
Нас заботят два фактора. Первый – потребление CPU. С какой скоростью работает процесс, над которым мы будем работать? Второй фактор – потребление памяти. Сколько памяти выделяется на выполнение скрипта? Зачастую эти два фактора обратно пропорциональны – т.е. мы можем разгрузить память за счет CPU и наоборот.
В асинхронной модели выполнения (многопроцессовые или многопоточные приложения PHP) потребление CPU и памяти важные факторы. В стандартной архитектуре PHP они становятся проблемой, когда один из факторов достигает ограничений сервера.
Внутри PHP измерять потребление CPU непрактично. Если вы хотите сосредоточиться на этой области, попробуйте использовать что-то типа top, Ubuntu или macOS. В Windows попробуйте использовать Linux Subsystem, чтобы использовать top в Ubuntu.
В рамках урока мы будем измерять потребление памяти. Мы посмотрим, сколько памяти используется в «обычных» скриптах. Проведем пару стратегий оптимизации и измерим их. В конце я хочу, чтобы вы могли делать образованный выбор.
Методы, которые мы будем использовать для измерения потребления памяти:
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]; } |
Эти функции мы будем вызывать в конце скриптов и смотреть, какой скрипт использует больше всего памяти.
Какие у нас варианты?
Эффективно читать файлы можно множеством разных способов. Также есть 2 вероятных сценария их использования. Нам может понадобиться считывать и обрабатывать все данные одновременно, выводить обработанные данные или выполнять другие действия на основе считанного. Также нам может понадобиться трансформировать поток данных без необходимости получать к нему доступ.
Представим, что для первого сценария мы хотим иметь возможность читать файл и создавать отдельные очереди фоновых задач каждые 10 000 строк. Нам понадобится хранить в памяти минимум 10 000 строк и передавать их в менеджер очереди фоновой задачи (какую бы форму она не принимала).
Для второго сценария представим, что мы хотим сжать контент определенного большого ответа от 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 30 31 32 |
// 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()); // 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.5Мб, пиковое потребление памяти составляет 12.8Мб. Теперь давайте считаем все строки через генератор:
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"; |
Размер текстового файла тот же, но пиковое потребление памяти уже 393Кб. Но это ничего не значит, пока мы на начнем выполнять операции со считанными данными. Можно разбить документ на куски по двум пустым строкам. Вот так:
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"; |
Как думаете, сколько теперь памяти используется? Удивитесь ли вы, когда узнаете, что несмотря на разбиение документа на 1 216 кусков, у нас все равно используется 459Кб памяти? Природа генераторов такова, что больше всего памяти используется, когда хранится самый большой кусок текста в итерации. У нас самый большой кусок составляет 101 985 символов.
Я уже писал об ускорении производительности с помощью генераторов и библиотеке итераторов от Nikita Popov. Можете почитать, если интересно!
У генераторов есть другое применение, но наш пример отлично подходит для быстрого чтения больших файлов. Для работы с данными генераторы, возможно, подходят лучше всего.
Сообщение между файлами
В ситуации, когда нам не нужно работать с данными, мы можем передавать данные файла в другой файл. Процесс называется piping (по-видимому, потому что мы не знаем, что внутри «трубы», но знаем что на ее концах… пока труба непрозрачна, конечно!). Для этого нам понадобятся поточные методы. Давайте сначала напишем скрипт передачи данных из одного файла в другой, чтобы измерить потребление памяти:
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"; |
Код немного странный. Мы открываем обработчики в обоих файлах, первый в режиме чтения, второй в режиме записи. Далее данные копируются из первого файла во второй. В конце оба файла закрываются. Возможно, вы удивитесь, используется 393Кб памяти.
Знакомо. Не столько ли генератор использовал для хранения при считывании построчно? Объем памяти такой, потому что второй аргумент в 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( "//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), но внутри все то же самое.
Памяти используется (для изображения) около 581Кб. Так как же передавать изображение через поток?
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( "//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"; |
Потребление памяти немного меньше 400Кб, но результат тот же. Если нам не нужна информация о памяти, можно было бы выводить по-старому. Для этого в PHP есть простой способ:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$handle1 = fopen( "//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 (read-only)
php://stderr (write-only, как php://stdout)
php://input (read-only) дает доступ к тексту запроса
php://output (write-only) позволяет писать в буфер вывода
php://memory и php://temp (read-write) места временного хранения данных. Разница в том, что php://temp будет хранить данные в файловой системе, пока они не перепрыгнут определенную отметку, а php://memory хранит данные, пока не кончится память.
Фильтры
С потоками можно использовать другой трюк – фильтры. Это что-то промежуточное. Фильтры дает небольшой контроль над данным поток без необходимости их открытия. Представьте, что нам необходимо сжать shakespeare.txt. Мы можем использовать Zip расширение:
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.75Мб. С фильтрами можно лучше:
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, который читает и сжимает контент ресурса. Далее сжатые данные можно передать в другой файл. И операция занимает всего 896Кб.
Знаю, формат другой, и с созданием zip архивов есть проблемы. Однако нужно подумать: если вы можете выбрать другой формат и использовать в 12 раз меньше памяти, стоит ли оно того?
Для распаковки данных можно запустить файл через другой zlib фильтр:
1 2 3 4 5 |
// from filters-2.php file_get_contents( "php://filter/zlib.inflate/resource=filters-2.deflated" ); |
Потоки подробно разобраны в «понятие потоков в PHP» и «Эффективное использование потоков в 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("//example.com/register", "r", false, $context); $response = stream_get_contents($handle); fclose($handle); |
В этом примере мы пытаемся отправить POST запрос в API. 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); |
highlight-names должен совпадать со свойством filtername нового класса фильтра. Также в строке php://filter/highligh-names/resource=story.txt можно использовать кастомные фильтры. Намного легче определить фильтры, чем протоколы. Одна из причин – протоколы обрабатывают операции над директориями, а фильтры обрабатывают только куски данных.
Если вы уловили суть, рекомендую вам поэкспериментировать с кастомными протоколами и фильтрами. Если вы можете применить фильтры к операциям stream_copy_to_stream, то ваше приложение почти не будет использовать память, даже при работе с неприлично большими файлами. Представьте, что вы пишите фильтр resize-image или encrypt-for-application.
Заключение
С этой проблемой мы сталкиваемся не так часто, однако ее легко получить при работе с большими файлами. В асинхронных приложениях очень легко положить весь сервер, если не думать о потреблении памяти.
Надеюсь, этот урок показал вам пару новых идей (или освежил их в вашей памяти), чтобы вы могли подумать, как эффективно читать и писать большие файлы. Когда мы познакомимся с потоками и генераторами и перестанем использовать функции типа file_get_contents, из приложения исчезнет целый ряд ошибок. Хорошая цель!
Автор: Christopher Pitt
Источник: //www.tutorialspoint.com/
Редакция: Команда webformyself.