PHP Profi

Увеличение производительности памяти при помощи Generators и Nikic/Iter Перевод

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

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

Они появились в качестве функций, но мы можем использовать их как итераторы. Они предоставляют нам простой синтаксис для написаний, по сути, прерываемых и повторяемых функций. Они прекрасны!

Рассмотрим несколько областей применения. Также расскажем, какие при этом могут возникнуть проблемы. И наконец, изучим восхитительную библиотеку, написанную талантливым Никитой Поповым.

Примеры кода вы можете найти на гитхабе

О проблемах

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

Проще всего было бы сделать так:

function readCSV($file) {
    $rows = [];

    $handle = fopen($file, "r");

    while (!feof($handle)) {
        $rows[] = fgetcsv($handle);
    }

    fclose($handle);

    return $rows;
}

$authors = array_filter(
    readCSV("authors.csv")
);

$categories = array_filter(
    readCSV("categories.csv")
);

$posts = array_filter(
    readCSV("posts.csv")
);

Затем вы бы попробовали приделать зависимые элементы в итераторе или в функции для быстрой сортировки:

function filterByColumn($array, $column, $value) {
    return array_filter(
        $array, function($item) use ($column, $value) {
            return $item[$column] == $value;
        }
    );
}

$authors = array_map(function($author) use ($posts) {
    $author["posts"] = filterByColumn(
        $posts, 1, $author[0]
    );

    // make other changes to $author

    return $author;
}, $authors);

$categories = array_map(function($category) use ($posts) {
    $category["posts"] = filterByColumn(
        $posts, 2, $category[0]
    );

    // make other changes to $category

    return $category;
}, $categories);

$posts = array_map(function($post) use ($authors, $categories) {
    foreach ($authors as $author) {
        if ($author[0] == $post[1]) {
            $post["author"] = $author;
            break;
        }
    }

    foreach ($categories as $category) {
        if ($category[0] == $post[1]) {
            $post["category"] = $category;
            break;
        }
    }

    // make other changes to $post

    return $post;
}, $posts);


Вроде бы выглядит нормально, так? Ладно, а что случится, если нам понадобится парсить огромные CSV файлы? Давайте попробуем профилировать использование памяти:

function formatBytes($bytes, $precision = 2) {
    $kilobyte = 1024;
    $megabyte = 1024 * 1024;

    if ($bytes >= 0 && $bytes < $kilobyte) {
        return $bytes . " b";
    }

    if ($bytes >= $kilobyte && $bytes < $megabyte) {
        return round($bytes / $kilobyte, $precision) . " kb";
    }

    return round($bytes / $megabyte, $precision) . " mb";
}

print "memory:" . formatBytes(memory_get_peak_usage());

Этот пример кода подключается в generate.php, который вы можете использовать для создания этих CSV файлов...

У вас огромные CSV файлы, этот код только показывает, как много памяти расходуется для соединения этих огромных массивов данных. Как минимум, это размер файла, который вы должны прочитать, так как PHP должен держать его в памяти.

Генераторы спешат на помощь!

Один из способов решения этой проблемы — использование генераторов. Если вы еще с ним на "Вы", самое время узнать больше

Генераторы позволят выполнять загрузку  небольшими частями данных. Чтобы их использовать, надо сделать совсем немного:

function readCSVGenerator($file) {
    $handle = fopen($file, "r");

    while (!feof($handle)) {
        yield fgetcsv($handle);
    }

    fclose($handle);
}

Если вы пройдетесь в цикле по данным CSV, то сразу же заметите снижение потребления памяти:

foreach (readCSVGenerator("posts.csv") as $post) {
    // do something with $post
}

print "memory:" . formatBytes(memory_get_peak_usage());

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

Для начала: array_filter и array_map не умеют работать с генераторами. Так что вам придётся поискать какие-то другие инструменты для работы с этими типами данных. И вот что вы можете попробовать!

composer require nikic/iter

Эта библиотека содержит несколько функций для работы с итераторами и генераторами. Так как же получить эти реляционные данные без сохранения их в памяти?

function getAuthors() {
    $authors = readCSVGenerator("authors.csv");

    foreach ($authors as $author) {
        yield formatAuthor($author);
    }
}

function formatAuthor($author) {
    $author["posts"] = getPostsForAuthor($author);

    // make other changes to $author

    return $author;
}

function getPostsForAuthor($author) {
    $posts = readCSVGenerator("posts.csv");

    foreach ($posts as $post) {
        if ($post[1] == $author[0]) {
            yield formatPost($post);
        }
    }
}

function formatPost($post) {
    foreach (getAuthors() as $author) {
        if ($post[1] == $author[0]) {
            $post["author"] = $author;
            break;
        }
    }

    foreach (getCategories() as $category) {
        if ($post[2] == $category[0]) {
            $post["category"] = $category;
            break;
        }
    }

    // make other changes to $post

    return $post;
}

function getCategories() {
    $categories = readCSVGenerator("categories.csv");

    foreach ($categories as $category) {
        yield formatCategory($category);
    }
}

function formatCategory($category) {
    $category["posts"] = getPostsForCategory($category);

    // make other changes to $category

    return $category;
}

function getPostsForCategory($category) {
    $posts = readCSVGenerator("posts.csv");

    foreach ($posts as $post) {
        if ($post[2] == $category[0]) {
            yield formatPost($post);
        }
    }
}

// testing this out...

foreach (getAuthors() as $author) {
    foreach ($author["posts"] as $post) {
        var_dump($post["author"]);
        break 2;
    }
}

Или вот полаконичнее:

function filterGenerator($generator, $column, $value) {
    return iter\filter(
        function($item) use ($column, $value) {
            return $item[$column] == $value;
        },
        $generator
    );
}

function getAuthors() {
    return iter\map(
        "formatAuthor",
        readCSVGenerator("authors.csv")
    );
}

function formatAuthor($author) {
    $author["posts"] = getPostsForAuthor($author);

    // make other changes to $author

    return $author;
}

function getPostsForAuthor($author) {
    return iter\map(
        "formatPost",
        filterGenerator(
            readCSVGenerator("posts.csv"), 1, $author[0]
        )
    );
}

function formatPost($post) {
    foreach (getAuthors() as $author) {
        if ($post[1] == $author[0]) {
            $post["author"] = $author;
            break;
        }
    }

    foreach (getCategories() as $category) {
        if ($post[2] == $category[0]) {
            $post["category"] = $category;
            break;
        }
    }

    // make other changes to $post

    return $post;
}

function getCategories() {
    return iter\map(
        "formatCategory",
        readCSVGenerator("categories.csv")
    );
}

function formatCategory($category) {
    $category["posts"] = getPostsForCategory($category);

    // make other changes to $category

    return $category;
}

function getPostsForCategory($category) {
    return iter\map(
        "formatPost",
        filterGenerator(
            readCSVGenerator("posts.csv"), 2, $category[0]
        )
    );
}


Немного затратно постоянно перечитывать каждый кусок данных. Можно рассмотреть сохранение данных еще меньшими частями в памяти (например, авторы и категории).

Другие прикольные штуки

Когда дело касается Никитиной библиотеки, это только верхушка айсберга. Хотели когда-нибудь преобразовывать массив (ну или итератор/генератор) к одномерному?

$array = iter\toArray(
    iter\flatten(
        [1, 2, [3, 4, 5], 6, 7]
    )
);

print join(", ", $array); // "1, 2, 3, 4, 5"

Можно возвращать срез по итерируемым значениям используя slice и take

$array = iter\toArray(
    iter\slice(
        [-3, -2, -1, 0, 1, 2, 3],
        2, 4
    )
);

print join(", ", $array); // "-1, 0, 1, 2"

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

$mapper = iter\map(
    function($item) {
        return $item * 2;
    },
    [1, 2, 3]
);

print join(", ", iter\toArray($mapper));
print join(", ", iter\toArray($mapper));

Если вы попробуете запустить такой код, то получите следующее exception:  “Cannot traverse an already closed generator” (невозможно выполнить travers, генератор уже закрыт). У каждой функции в этой библиотеке есть возможность переиспользовать генераторы.

$mapper = iter\rewindable\map(
    function($item) {
        return $item * 2;
    },
    [1, 2, 3]
);

Вы можете делать маппинг много раз. Можете создать свой generator rewindable:

$rewindable = iter\makeRewindable(function($max = 13) {
    $older = 0;
    $newer = 1;

    do {
        $number = $newer + $older;

        $older = $newer;
        $newer = $number;

        yield $number;
    }
    while($number < $max);
});

print join(", ", iter\toArray($rewindable()));

И вот мы получили переиспользуемый генератор!

Выводы

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

2015-10-29 оригинал

Последние посты

Комментарии

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