Перевод статьи Christopher Pitt.


PHP разработчикам не так уж часто приходится следить за расходом памяти в своих приложениях. Сам движок PHP неплохо подчищает мусор за нами, да и модель веб-сервера с контекстом исполнения, "умирающим" после выполнения каждого запроса, позволяет даже самому плохому коду не создавать больших долгих проблем.


Однако, в некоторых ситуациях, мы можем столкнуться с проблемами нехватки оперативной памяти — например, пытаясь запустить композер на маленьком VPS, или при открытии большого файла на сервере не богатом ресурсами.


Fragmented terrain


Последняя проблема и будет рассмотрена в этом уроке.


Весь код доступен по ссылке https://github.com/sitepoint-editors/sitepoint-performant-reading-of-big-files-in-php


Мерила Успеха


При проведении любых оптимизаций кода, мы всегда должны замерять результаты его выполнения до и после, для того чтобы оценивать эффективность(или пагубность) наших оптимизаций.


Обычно измеряют загрузку CPU и использование оперативной памяти. Часто бывает, что экономия одного, ведёт к увеличенным затратам другого и наоборот.


В асинхронной модели приложения(мультипроцессорные и многопоточные) всегда очень важно следить как за процессором, так и за памятью. В классических приложениях контроль ресурсов становится проблемой лишь при приближении к лимитам сервера.


Измерять использование CPU внутри PHP плохая идея. Лучше использовать какую-либо утилиту, как top из Ubuntu или macOS. Если вы у вас Windows, то можно использовать Linux Subsystem, чтобы иметь доступ к top.


В этом уроке мы будем измерять использование памяти. Мы посмотрим, как память расходуется в традиционных скриптах, а затем применим парочку фишек для оптимизации и сравним результаты. Надеюсь, к концу статьи, читатель получит базовое понимание основных принципов оптимизации расхода памяти при чтении больших объемов данных.


Будем замерять память так:


// 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];
}

Эту функцию мы будем использовать в конце каждого скрипта, и сравнивать полученные значения.


Какие есть варианты?


Существует много разных подходов для эффективного чтения данных, но всех их условно можно разделить на две группы: мы либо считываем и сразу же обрабатываем считанную порцию данных(без предварительной загрузки всех данных в память), либо вовсе преобразуем данные в поток, не заморачиваясь над его содержимым.


Давайте представим, что для первого варианта мы хотим читать файл и отдельно обрабатывать каждые 10000 строк. Нужно будет держать по крайней мере 10000 строк в памяти и передавать их в очередь(в какой бы форме она не была реализована).


Для второго сценария, предположим, мы хотим сжать содержимое очень большого ответа API. Нам не важно, что за данные там содержатся, важно вернуть их в сжатой форме.


В обоих случаях нужно считать большие объемы информации. В первом, нам известен формат данных, во втором, формат значения не имеет. Рассмотрим оба варианта.


Чтение Файла Строка За Строкой


Есть много функций для работы с файлами. Давайте напишем с их помощью свой ридер:


// 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.5MB и пиковое использование памяти 12.8MB.


А теперь, давайте воспользуемся генератором:


// 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! Но пока мы не выполняем со считываемыми данными никаких операций, это не имеет практической пользы. Для примера, мы можем разбивать документ на части, если встретим две пустые строки:


// 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 кусков, мы использовали лишь 459KB памяти. Всё это, благодаря особенности генераторов — объем памяти для их работы равен размеру самой большой итерируемой части. В данном случае, самая большая часть состоит из 101,985 символов.


Генераторы могут применяться и в других ситуациях, но данный пример хорошо демонстрирует производительность при чтении больших файлов. Возможно, генераторы один из лучших вариантов для обработки данных.


Пайпинг между файлами


В ситуациях, когда обработка данных не требуется, мы можем пробрасывать данные из одного файла в другой. Это называется пайпингом( pipe — труба, возможно потому что мы не видим что происходит внутри трубы, но видим что входит и выходит и неё). Это можно сделать с помощью потоковых методов. Но сперва, давайте напишем классический скрипт, который тупо передает данные из одного файла в другой:


// from piping-files-1.php

file_put_contents(
    "piping-files-1.txt", file_get_contents("shakespeare.txt")
);

require "memory.php";

Неудивительно, что этот скрипт использует намного больше памяти, чем занимает копируемый файл. Это связано с тем, что он должен читать и хранить содержимое файла в памяти до тех пор пока файл не будет скопирован полностью. Для маленьких файлов в этом нет ничего страшного, но не для больших...


Давайте попробуем стримить(или пайпить) файлы, один в другой:


// 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 и передать её в файл или в stdout. Мы могли бы сделать это так:


// 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";

Для того чтобы осуществить задуманное этим способом потребовалось 581KB. Теперь попробуем сделать то же самое с помощью потоков.


// 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";

Потратили немного меньше памяти(400KB) при одинаковом результате. А если б нам не нужно было сохранять картинку в памяти, мы могли бы сразу застримить её в stdout:


$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://input — только чтение(дает доступ к голому телу запроса)
  • php://output — только запись(позволяет писать в буфер вывода)
  • php://memory and php://temp — чтение и запись. Тут можно хранить временные данные, отличие в том что php://temp будет хранить данные в файловой системе при их разрастании, а php://memory будет писать всё в оперативную память до последнего.

Фильтры


Есть еще одна фишка, которую мы можем использовать — это фильтры. Промежуточный вариант, который дает нам немного контроля над потоком, без необходимости детально погружаться в его содержимое. Допустим, мы хотим сжать файл. Можно применить zip extension:


// 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";

Хороший код, но он потребляет почти 11MB. С фильтрами, получится лучше:


// 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 раз меньше памяти, стоит ли это делать?


Чтобы распаковать данные, применим другой zip фильтр.


// from filters-2.php

file_get_contents(
    "php://filter/zlib.inflate/resource=filters-2.deflated"
);

Вот парочка статей, для тех кому хотелось бы поглубже погрузиться в тему потоков: “Understanding Streams in PHP” и“Using PHP Streams Effectively”.


Кастомизация потоков


fopen и file_get_contents имеют ряд предустановленных опций, но мы можем менять их как душе угодно. Чтобы сделать это, нужно создать новый контекст потока:


// 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("http://example.com/register", "r", false, $context);
$response = stream_get_contents($handle);

fclose($handle);

В этом примере мы пытаемся сделать POST запрос к API. Прописываем несколько заголовков, и обращаемся к API по файловому дескриптору. Существует много других опций для кастомизации, так что не будет лишним ознакомиться с документацией по этому вопросу.


Создание своих протоколов и фильтров


Перед тем как закончить, давайте поговорим о создании кастомных протоколов. Если посмотреть в документацию, то можно увидеть пример:


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 )
}

Написание своей реализации такого тянет на отдельную статью. Но если все же озадачиться и сделать это, то можно будет легко зарегистрировать свою обертку для стримов:


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");

Аналогичным образом, можно создать и кастомные фильтры потока. Пример класса фильтра из доков:


Filter {
    public $filtername;
    public $params
    public int filter ( resource $in , resource $out , int &$consumed ,
        bool $closing )
    public void onClose ( void )
    public bool onCreate ( void )
}

И его также легко зарегистрировать:


$handle = fopen("story.txt", "w+");
stream_filter_append($handle, "highlight-names", STREAM_FILTER_READ);

Свойство filtername в новом классе фильтра должно быть равно highlight-names. Также можно использовать инлайновый фильтр php://filter/highligh-names/resource=story.txt. Создавать фильтры гораздо легче чем протоколы. Но протоколы, имеют более гибконастраеваемые возможности и функциональность. К примеру, дной из причин для которой фильтры не годятся, а требуются протоколы — это операции с директориями, где фильтр будет нужен для обработки каждой порции данных.


Настоятельно рекомендую поэкспериментировать с созданием собственных протоколов и фильтров. Если получится применить фильтр к функции stream_copy_to_stream, то вы получите колоссальную экономию памяти при работе с большими объемами данных. Представьте что у вас будет фильтр для ресайзинга изображений или фильтр для шифрования, а может и еще что покруче.


Итог


Хотя это не самая частая проблема, с которой мы мучаемся, очень легко накосячить при работе с большими файлами. В асинхронных приложениях, вообще очень просто положить весь сервер, если не контролировать использование памяти в своих скриптах


Надеюсь, что этот урок подарил вам несколько новых идей(или освежил их в памяти) и теперь вы сможете работать с большими файлами гораздо эффективнее. Познакомившись с генераторами и потоками( и перестав использовать функции по типу file_get_contents) можно избавить наши приложения от целого класса ошибок. That seems like a good thing to aim for!

Комментарии (46)


  1. malinichev
    18.12.2017 23:42
    +1

    Спасибо за статью! Очень полезная! В закладки однозначно!


  1. romy4
    19.12.2017 00:42
    +3

    Если б про то, как распарсить огромный json, не грохнув приложение с memory exceed.


    1. KirEv
      19.12.2017 02:19

      была похожая задача, но так как структура json была заведомо известна, json дробился на части, по сотни объектов в каждой используя спец. разделитель типа `},{` для первого уровня, потом обрабатывались полученные данные, вылезла другая проблема — время выполнения, но это другая история :)

      Кстати по поводу парсинга, машина Тьюринга помогает составить удобный алфавит с алгоритмом на бумажке для направленного прохода по большим данным, который очень легко запрограммировать.



    1. VisualIdeas
      19.12.2017 11:18

      Коллега, недавно парсил XML фиды Алиэкспресс и других гигантов.
      Для такого парсинга существует стриминг…
      Вам для JSON пригодится готовая библиотека github.com/salsify/jsonstreamingparser или github.com/kuma-giyomu/JSONParser


    1. wildraid
      19.12.2017 12:00

      Проще попросить у источника данных выгрузить CSV какой-нибудь.


  1. vtvz_ru
    19.12.2017 01:53

    Люблю статьи, которые освещают темные участки документации. Темные в том смысле, что сам не полез бы разбираться, а в статьях упоминаний мало. Спасибо большое.


  1. KirEv
    19.12.2017 02:13

    есть один момент, иногда файлы бывают большие, и в одну строчку,

    странно что не упомянута функция fread


  1. VolCh
    19.12.2017 11:17

    Необязательный, третий аругмент stream_copy_to_stream делает то же самое. stream_copy_to_stream читает первый поток по одной строке и пишет во второй.

    Поправка: stream_copy_to_stream не оперирует строками, третий аргумент задаёт размер копируемого участка в байтах (можно самому в цикле итеративно копировать чанками конкретного размера, не создавая буферных PHP переменных), если не задан (-1), то копируется от текущей позиции (может со смещением) до конца источника чанками размером 8192 байт.


    1. arturpanteleev Автор
      19.12.2017 11:25

      Хорошее замечание, спасибо. В оригинале значит не совсем корректно было:

      The third argument to stream_copy_to_stream is exactly the same sort of parameter (with exactly the same default). stream_copy_to_stream is reading from one stream, one line at a time, and writing it to the other stream.


  1. AlexLeonov
    19.12.2017 11:46

    Всё хорошо, с удовольствием прочел и поставил плюс
    Но
    Когда я вижу

    require "memory.php";

    относительные пути в ФС — мне становится плохо.
    Пожалуйста, не делайте так, особенно в статьях, которыми потом будут руководствоваться начинающие!


    1. VolCh
      19.12.2017 12:08

      Абсолютные указывать?! Мне вот от них становится плохо. Или вы имеете в виду, что будет по include path искать и вам от этого плохо?


      1. AlexLeonov
        19.12.2017 12:16

        Да, путь всегда должен быть абсолютным. Вы можете воспользоваться магической константой __DIR__, чтобы сделать абсолютный путь независимым от окружения.

        Указывать относительные пути — это дорога в ад отладки. Задайте себе простой вопрос «А относительно КАКОЙ конкретно директории будет отсчитываться этот путь?» На моей памяти ни один программист PHP не сумел правильно с первого раза ответить на этот простой вопрос.


        1. AntonAlekseevich
          19.12.2017 12:22

          А относительно КАКОЙ конкретно директории будет отсчитываться этот путь?

          Я попытаюсь ответить.
          Относительно рабочей директории процесса.
          (Ответ не претендует на абсолютное значение истины.)


          1. VolCh
            19.12.2017 12:40

            Если путь не указан вообще, то, по порядку:


            1. По списку include_path, слева направо
            2. В рабочей директории процесса
            3. В директории текущего файла

            Если указан относительный, то он вычислится относительно текущего файла. По сути та же подстановка DIR, но на уровне языка, а не пользовательского кода.


            1. AntonAlekseevich
              19.12.2017 14:02

              Если путь не указан вообще, то, по порядку:

              Я не уверен что строго по порядку, но прислушаюсь вашего мнения. (опишу случаи дополнительно. Хотя и они не претендуют на абсолютную истину.)


              По списку include_path, слева направо

              В случае если он указан.


              В рабочей директории процесса

              В случае если запускается иным процессом с измененной средой.


              В директории текущего файла

              В случае запроса файла клиентом.


              Если указан относительный, то он вычислится относительно текущего файла.

              И то не всегда.


              По сути та же подстановка __DIR__, но на уровне языка, а не пользовательского кода.

              Не только __DIR__, но и $PWD (это в sh).


              1. VolCh
                19.12.2017 14:16

                Насколько я знаю, тут точно без претензий на правоту:


                В случае если он указан.

                Он указан всегда, но может быть пустым, тогда поиск не производится


                В случае если запускается иным процессом с измененной средой.

                Используется то же значение, что возвращает \getcwd()


                В случае запроса файла клиентом.

                Просто не понял о чём речь.


                И то не всегда.

                Знаете исключения? Я не встречал.


                Не только DIR, но и $PWD (это в sh).

                DIR берёт директорию теущего файла, а $PWD — процесса, нет?


                1. AntonAlekseevich
                  19.12.2017 15:34

                  Знаете исключения?

                  Не клиентский запрос. (Документ А отдельно Документ Б. Для Б нужно содержимое А. В моем понимании это клиентский запрос. (


                  #document <!-- for example only "Документ Б" -->
                  <html>
                      <head>
                          <link rel="stylesheet" href="example.css" /> <!-- Документ А -->
                      </head>
                      <body>
                          <div class="example">
                              Example Text:
                              <p class="bold upcase">
                                  must
                             </p>
                             is bold or upcased.
                          </div>
                      </body>
                  </html>

                  в качестве примера клиентского запроса.))


                  $PWD — процесса

                  Да, это текущая директория процесса.


                  1. AntonAlekseevich
                    19.12.2017 16:48

                    Я знаю об ошибке в исходнике. Но время прошло для её исправления.


                    P. S. is bold or upcased. -> be bold or up-cased.


            1. AlexLeonov
              19.12.2017 15:17

              Если указан относительный, то он вычислится относительно текущего файла. По сути та же подстановка DIR, но на уровне языка, а не пользовательского кода.


              Ошибка. Относительно текущей директории. Которая заранее вам неизвестна и, скорее всего, равна рабочей директории процесса (и она тоже неизвестна и может изменяться)

              В общем не пользуйтесь относительными путями в ФС. Так надежнее.


        1. VolCh
          19.12.2017 12:29

          __DIR__ . "/memory.php" в этом контексте мало чем отличается от "./memory.php" с точки зрения семантики поиска. Только сообщения об ошибках разные и вычисление в PHP происходит в первом случае. Если совсем не указывать путь, только имя, то там есть нюансы, да.


          1. AlexLeonov
            19.12.2017 15:16

            Вы ошибаетесь. Указание текущей директории в виде точки или двух точек как раз и приводит к поиску относительно… правильно! текущей директории.

            А вот какова она — вы в общем случае заранее не знаете.


            1. VolCh
              19.12.2017 15:37

              Хм… похоже на то, как-то очень давно не пытался использовать скрипты в неизвестном окружении.


              1. AlexLeonov
                19.12.2017 17:11

                Исходите из того, что для PHP любое окружение — неизвестное.

                Вы, как разработчик не знаете, как будет запускаться ваш скрипт. Apache (модуль) или php-fpm? Или встроенный сервер? Или cli-режим? ВМ? Контейнер?

                Ваша задача сделать так, чтобы максимально абстрагироваться от окружения. А для этого есть отличное правило «явное лучше неявного».


                1. mjr27
                  19.12.2017 19:17

                  Рискну не согласиться. В подавляющем большинстве случаев (если только это не очередной вордпресс), зафиксировать окружение не только можно, но и необходимо.


                  А в нынешний контейнерный век это еще и удобно.


                1. VolCh
                  19.12.2017 19:31

                  Я не пишу "коробочные" продукты, которые будет устанавливать неизвестно кто. Мне окружение либо известно, либо я его задаю, раньше чаще путём постановки задачам админам, сейчас чаще путем всяких докер и докер-композ файлов.


    1. beat
      19.12.2017 14:15

      почти 2018 год на дворе, есть autoloading в composer, есть spl_autoload_register, какой require/include?


      1. VolCh
        19.12.2017 14:19

        Автолоадинги работаю только с классоподобными сущностями языка. Composer можно принудить загружать некоторые файлы при каждом require './vendor/autoload.php', но порядком загрузки управлять нельзя и, если не ошибаюсь, он различается в разных версиях.


  1. Andreyika
    19.12.2017 13:10

    Хотя мы разбили документ на 1,216 кусков, мы использовали лишь 459KB памяти. Всё это, благодаря особенности генераторов — объем памяти для их работы равен размеру самой большой итерируемой части. В данном случае, самая большая часть состоит из 101,985 символов.

    Нет, не разбили, а всего лишь посчитали кол-во \n\n\n в файле, причем довольно странным подходом (даже с т.з. ограниченния памяти — а вдруг у вас виртуалка на 486sx и 500кбайт оперативки, а вы зачем-то храните временную строку и ищите по ней регуляркой — явный перерасход ресурсов).


    И в чем тут особенность генераторов? В том, что вы, в отличие от первого примера, не прочитали весь файл целиком? это всего лишь отличие file_get_contents от fget(s).


  1. GreedyIvan
    19.12.2017 14:13

    С фильтрами есть ещё косяк, что обработка входящих данных в самом фильтра умножает расход памяти на 3 от величины чанка. Намного выгоднее обрабатывать данные без кастомных фильтров.


  1. Fragster
    19.12.2017 15:00

    А почему в reading-files-line-by-line-1.php хранится само содержимое файла в переменной? Почему в других примерах не хранится? Потому что иначе «все не так однозначно»?


    1. arturpanteleev Автор
      19.12.2017 19:47

      По большей части соглашусь.По началу также подумал, что это просто «читерство» для красивых цифр. Но по сути, автор создавал именно функцию для чтения. Т.е если бы мы в цикле не складывали содержимое в $lines а выполняли бы с ней операции, то памяти затратили бы в разы меньше, но потеряли бы в универсальности. Иными словами это уже была бы не функция чтения, а функция выполнения определенной операции над данными. Ну а генератор даёт нам именно такую возможность — многоразовое использование для любых целей. Хотя, откровенно говоря, это можно было бы повторить с помощью цикла и анонимных функций.


      1. Fragster
        20.12.2017 11:13

        Ну так пусть генератор также складывает данные в массив, а то нечестно получается. Ну, или в первый вариант в качестве параметра передается функция обработки одного куска.


  1. GreedyIvan
    19.12.2017 15:25

    Реальный контроль памяти, на который хоть как-то можно полагаться, дает только fread. Если нужно только читать с контролем памяти, то этой функции вполне достаточно.
    Если нужно ещё и писать, то делаем обертку для потока. Что дает обертка? Возможность преобразовывать данные и писать в исходящий поток без лишних операций копирования, что и позволяет контролировать расход памяти.

    А вот использовать просто фильтры, в которых будут преобразовываться данные, крайне не рекомендуется. Сначала вы получить копию чанка для обработки в фильтре. А потом копию чанка для того, чтобы его из фильтра отдать обратно в поток. Итого 3x вместо x, если бы вместо фильтра была бы обертка.

    Какое преимущество дает контроль памяти? Ускорение обработки данных. Если нашему скрипту доступен один гиг оперативки, то можно через обертку читать и отправлять чанками около 1 гига. А через фильтр доступный максимальный размер чанка будет в 3 раза меньше. Выигрыш в скорости будет очень заметен.


    1. lorc
      19.12.2017 16:15

      А какой-нибудь обертки на mmap() в php нет? Потому что это единственный простой способ читать большие файлы при ограниченной памяти.


      1. GreedyIvan
        19.12.2017 16:34

        Самый простой способ — это fread. Поддерживается из коробки с незапамятных времен.
        Для более сложных случаев существуют обертки над потоками, которые тоже существуют с незапамятных времен. Но почему-то иногда владение какой-то частью базового функционала языка рассматривается как продвинутый уровень.


        1. lorc
          19.12.2017 16:44

          Я так понимаю, что потоки все равно построены вокруг read()? Т.е. все равно надо выделять память для чтения файла?
          mmap() хорош тем, что он практически «бесплатен» по памяти для процесса. ОС в любом случае читает данные с диска в свой файловый кеш. Поэтому, почему бы просто не отобразить этот кеш в память процесса? И для программы это тоже плюс: она может обращаться к любой части файла, не тратя при этом ни байта памяти.

          Например, обертка для mmap() в питоне просто оборачивает замапленный файл в string-like object. И все. Пиши-читай сколько хочешь.


          1. GreedyIvan
            19.12.2017 17:52

            Я так понимаю, что потоки все равно построены вокруг read()?

            Наоборот. Потоки (php_stream) относятся к ядру. Функции работы с файловой системой являются примером реализации на их основе. Обертки являются интерфейсом для создания собственных реализаций.
            Весь функционал работы с данными реализован через потоки (соответствующие функции, структуры данных). Там обычное выделение памяти и копирование происходит (malloc и memcpy) через внутрение функции-обертки.

            Какая-то поддержка mmap на уровне ядра есть, но не более того. Есть полноценное внешнее расширение для работы с использованием mmap.


      1. VolCh
        19.12.2017 19:34

        Он под капотом задействуется в некоторых ситуациях, в частности при работе с stream


  1. ilih
    19.12.2017 17:31

    > Пайпинг между файлами

    зачем так сложно, когда есть copy?


  1. tester_toster
    19.12.2017 19:33
    -1

    Прошу прощения, но что стало с хабром? Почему я начинаю все чаще видеть этот бред?
    Почему теперь стало так мало дельных статей?
    И почему такой шлак пропускают?
    С самых первых строк начинается бред:
    чтение файла строками:
    — первый пример (якобы плохой):

    function readTheFile($path) {
        $handle = fopen($path, "r");
    
        while(!feof($handle)) {
            yield trim(fgets($handle));
        }
    
        fclose($handle);
    }
    
    readTheFile("shakespeare.txt");
    
    require "memory.php";
    

    — второй:
    function readTheFile($path) {
        $handle = fopen($path, "r");
    
        while(!feof($handle)) {
            yield trim(fgets($handle));
        }
    
        fclose($handle);
    }
    
    readTheFile("shakespeare.txt");
    
    require "memory.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 кусков, мы использовали лишь 459KB памяти. Всё это, благодаря особенности генераторов — объем памяти для их работы равен размеру самой большой итерируемой части. В данном случае, самая большая часть состоит из 101,985 символов.

    Какой особенности итераторов? Это тут совсем не причем?
    В первом примере тупо складывают в массив:
     $lines[] = trim(fgets($handle));

    Если в нем эту строку заменить на:
    preg_match("/\n{3}/", trim(fgets($handle)), $matches);
    if (count($matches)) {
       print ".";
    }
    

    Или во втором поставить:
    $lines[] = trim(fgets($iteration));
    

    Расход памяти будет одинаковым.
    Генератор нужен для того, чтобы за раз возвращать одно значение, а не все сразу. Какая разница, если все читается из потока одинаковыми кусками?
    Дальше даже читать не стал…


    1. tester_toster
      19.12.2017 19:41

      В первом пример вставил не тот код, вот этот код:

      $lines = [];
      $handle = fopen($path, "r");
      
      while(!feof($handle)) {
          $lines[] = trim(fgets($handle));
      }
      
      fclose($handle);
      

      Уже минусанули? Автор это вы? Разве я не прав?
      Сделайте тесты, чтобы убедиться…


  1. bolk
    19.12.2017 20:10

    Какая-то жесть это:


    preg_match("/\n{3}/", $buffer, $matches);
    
        if (count($matches)) {

    Зачем тут регулярка? Почему бы это не заменить на if (strpos($buffer, "\n\n\n") !== false)?


    Да и вообще, заявленного код не делает — вставляется PHP_EOL, а проверяется на три \n, в винде проблемы будут. Кроме того, у нас же построчно всё приходит, можно просто считать количество пустых строк, пришедших подряд, как только их нужно количество, делать что требуется.


  1. michael_vostrikov
    19.12.2017 21:11

    Файл тот же, а пиковое использование памяти упало до 393KB!

    Да, потому что мы его не прочитали.


    Всё это, благодаря особенности генераторов

    Не генераторов, а тому, что мы построчно читаем. Можно читать в цикле без всяких генераторов, потребление будет то же самое.


    но данный пример хорошо демонстрирует производительность при чтении больших файлов

    Не производительность, а потребление памяти. Производительность в обоих случаях примерно одинаковая.


    Не похоже ли это на генератор, читающий каждую строчку?

    Нет. Я что-то сомневаюсь, что в stream_copy_to_stream() генераторы используются.


    Теперь попробуем сделать то же самое с помощью потоков. Потратили немного меньше памяти(400KB) при одинаковом результате

    Ага, только не из-за потоков, а из-за того, что она теперь в памяти не хранится.


    Но задумайтесь, если у нас есть возможность выбрать иной формат сжатия, затратив в 12 раз меньше памяти

    Да не формат сжатия, а потому что file_get_contents() теперь не вызывается. Можно addFile() вместо addFromString() использовать, тоже будет меньше в 12 раз.


  1. mihmig
    20.12.2017 09:04

    Хорошая, годная статья. Возможно ли указать ссылку на неё в php.net/manual/ru/ (в соответствующем разделе).

    Пользуясь случаем спрошу у аудитории:
    В случае использования apache+mod_php и nginx-php_fpm:
    Как часто происходит «парсинг» php-файла — при каждом запросе или как-то «по-умному»? Сервер сам контролирует изменение файла и «перепарсивает» его?

    Или парсинг занимает незначительное количество времени относительно всего цикла запроса и не стоит беспокоиться об этом?


    1. VolCh
      20.12.2017 12:29

      В общем случае по умному, есть так называемый OPcache.