Асинхронное выполнение PHP скрипта на подпроцессах



    Добрый день, уважаемые хабровчане.

    Сегодня я хотел бы поговорить о таких нетривиальных вещах, как асинхронные (параллельные) расчеты в языке PHP.
    Сам по себе PHP — это скриптовый язык, который никогда и не претендовал на многопоточность. Но чем дальше в лес, тем более серьезные задачи стоят перед разработчиками, и тем больше приходится «извращаться» с пыхом, потому что мигрировать на более приспособленный под эти задачи язык программирования многие компании попросту боятся и не хотят. Следовательно, приходится работать с тем, что дают.
    Подробности под катом…


    Какое-то время назад передо мной стояла достаточно нетривиальная задача.
    Если вкратце, то в проекте было реализовано примерно 20 очень тяжеловесных модулей по расчету стоимости товара.
    Всё это висело на нескольких реляционных таблицах, каждый из модулей содержал свои собственные правила расчета и тп. Но выдавать на клиент всё это нужно было единым пакетом. И это должно было выполняться быстро. Очень быстро. Кеширование спасало, но в очень ограниченных объемах, совсем недостаточных для выполнения технических требований.

    Алгоритм был довольно прост: на вход подавались необходимые аргументы, потом инстанцировались в массив все модули, и в цикле всё это дело просчитывалось. Ответ собирался в единый объект и выплёвывался на клиент для постобработки.

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

    Как вы сами уже догадались, было предложено каким-либо образом распараллелить процесс. Но с PHP это непросто, потому что он этого не умеет делать из коробки.

    Были опробованы разные решения:


    К сожалению, так в итоге ни к чему и не пришли. Было решено свернуть проект.

    Но для меня вопрос остался открыт, потому что решение быть должно. И ещё тогда мы задумывались о некоем подобии “подпроцессов”, которые порождает основной скрипт (аналог exec() функции).

    С тех пор прошло довольно много времени, из проекта я давно ушел. Но вот буквально на прошлой неделе у меня появилась одна очень нетривиальная задача: написать скрипт, который определенным образом залогирует текущее состояние некоей entity и часть её тяжелых реляционных зависимостей. Для этого используется 2 класса, правильно подготавливающих данные и сохраняющих это в БД. Проблема в том, что таких объектов примерно 2800. Мой скрипт отваливается по

    PHP Fatal error:  Allowed memory size of <over9000> bytes exhausted.
    

    На каждый пакет из 50 entities тратится, в среднем, 190мб памяти, с каждым новым пакетом кол-во использованной памяти росло. При полном отключении ограничений на использование оперативки, я получил такую же ошибку плюс Segmentation Fault.

    Т.е. так или иначе, нужно было придумать как избежать переполнения оперативной памяти в скрипте, и постараться сделать его “чуточку” побыстрее. Сперва попытались разобраться, почему увеличивается потребление памяти из итерацию в итерацию. Оказалось, что ноги растут из особенностей работы симфового ServiceContainer и EventDispatcher. Там в event подпихивается весь контейнер, и потом это делается рекурсивно. Обходить нам это всё было, честно говоря, лень, и мой коллега предложил довольно изящное решение.

    В наборе компонентов Symfony2 есть такая замечательная штука, как Symfony Process Component.
    Эта вундервафля позволяет в ходе выполнения скрипта породить подпроцесс и запустить его в CLI-режиме (как обычную консольную команду).

    Сперва мы просто попробовали “отпочковывать” по одному процессу для ограничения использования RAM. Но потом в доках вычитали, что эта штука умеет работать асинхронно.

    Было решено опробовать это в деле. В итоге получилось нечто вроде этого(Ниже пример с Example-репозитория на GitHub. Логика самих подпроцессов очень простая, но утяжеленная):

    MainCommand
    <?php
    
    namespace Example\Command;
    
    use Symfony\Component\Console\Command\Command;
    use Symfony\Component\Console\Input\InputInterface;
    use Symfony\Component\Console\Output\OutputInterface;
    use Symfony\Component\Process\Process;
    
    class MainCommand extends Command
    {
       protected function configure()
       {
           $this->setName('example:main')
               ->setDescription('Run example command with optional number of CPUs')
               ->addArgument('CPUs', null, 'number of working CPUs', 2);
       }
    
       protected function execute(InputInterface $input, OutputInterface $output)
       {
           $channels    = [];
           $maxChannels = $input->getArgument('CPUs');
    
           $exampleArray = $this->getExampleArray();
           $output->writeln('<fg=green>Start example process</>');
           while (count($exampleArray) > 0 || count($channels) > 0) {
               foreach ($channels as $key => $channel) {
                   if ($channel instanceof Process && $channel->isTerminated()) {
                       unset($channels[$key]);
                   }
               }
               if (count($channels) >= $maxChannels) {
                   continue;
               }
    
               if (!$item = array_pop($exampleArray)) {
                   continue;
               }
               $process = new Process(sprintf('php index.php example:sub-process %s', $item), __DIR__ . '/../../../');
               $process->start();
               if (!$process->isStarted()) {
                   throw new \Exception($process->getErrorOutput());
               }
               $channels[] = $process;
           }
           $output->writeln('<bg=green;fg=black>Done.</>');
       }
    
       /**
        * @return array
        */
       private function getExampleArray()
       {
           $array = [];
           for ($i = 0; $i < 30; $i++) {
               $name = 'No' . $i;
               $x1   = rand(1, 10);
               $y1   = rand(1, 10);
               $x2   = rand(1, 10);
               $y2   = rand(1, 10);
    
               $array[] = $name . '.' . $x1 . '.' . $y1 . '.' . $x2 . '.' . $y2;
           }
    
           return $array;
       }
    }
    


    SubProcessCommand
    <?php
    
    namespace Example\Command;
    
    use Symfony\Component\Console\Command\Command;
    use Symfony\Component\Console\Input\InputInterface;
    use Symfony\Component\Console\Output\OutputInterface;
    
    class SubProcessCommand extends Command
    {
       protected function configure()
       {
           $this->setName('example:sub-process')
               ->setDescription('Run example sub-process command')
               ->addArgument('item');
       }
    
       protected function execute(InputInterface $input, OutputInterface $output)
       {
           $items = explode('.', $input->getArgument('item'));
           $pointName = $items[0];
           $x1        = $items[1];
           $y1        = $items[2];
           $x2        = $items[3];
           $y2        = $items[4];
    
           // Used for mocking heavy execution.
           $sum = 0;
           for ($i = 1; $i <= 30000000; $i++){
               $sum += $i;
           }
    
           $distance = bcsqrt(pow(($x2 - $x1),2) + pow(($y2 - $y1),2));
           $data = sprintf('Point %s: %s', $pointName, (string)$distance);
    
           file_put_contents(__DIR__.'/../../../output/Point'.$pointName , print_r($data, 1), FILE_APPEND);
       }
    }
    


    index.php
    <?php
    require __DIR__ . '/vendor/autoload.php';
    
    use Symfony\Component\Console\Application;
    
    $application = new Application();
    $application->add(new \Example\Command\MainCommand());
    $application->add(new \Example\Command\SubProcessCommand());
    $application->run();
    



    В итоге имеем примерно вот такую картину:


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

    Репозиторий

    Спасибо за внимание. Буду рад отзывам и комментариям.

    UPD
    Огромное спасибо AlmazDelDiablo и skvot за напоминание.
    Данное решение будет работать только в том случае, если в проекте не запрещена функция proc_open(), на которой базируется Symfony Process компонента

    Обновил скриншот htop. Теперь есть данные по процессам. Спасибо hell0w0rd
    Поделиться публикацией

    Комментарии 24

      +7
      Каждый из подпроцессов съедает 0% памяти и 0% CPU, при этом все ядра компьютера загружены. По-моему, это, как минимум, странно.
      Думается мне, что оптимизировать надо было код, а не пытаться его распилить на кусочки, дабы уложиться в memory_limit.

      PS: а вообще, вся суть статьи сводится к тому, что ты использовал Process из Symfony.
        +1
        Спасибо за отзыв)

        Действительно сложно отрицать несостыковку в показателях. Но скорее всего я просто поймал момент смены процессов или это баг самого htopa.
        В качестве пруфа я и выложил реп. Можно потестить на своей тачке.

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

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

        Еще раз спасибо за дельный отзыв ;)
          +1
          Если у вас, вдруг, osx, то htop надо запускать с sudo.
            0
            да, OS X. Спасибо)
        +1
        Неужели нельзя решить проблему с памятью средствами самого PHP, не прибегая к распаралелливанию процессов в принципе?
          0
          см. ответ к предыдущему комментарию.

          UPD
          Кстати. Решено всё было исключительно при помощи средств php. Symfony Process Component базируется на функции proc_open()
          0
          На каждый пакет из 50 entities тратится, в среднем, 190мб памяти, с каждым новым пакетом кол-во использованной памяти росло


          И вместо того, чтобы найти причину, почему Doctrine (doctrine ведь?) сжирает ресурсы, было принято решение распараллелить процессы?
            0
            Да, Doctrine.

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

            И ещё один момент)))
            Жуть как хотелось попробовать (=
              0
              Ну да, наименее затратным.
              А когда и параллельные процессы начнут жрать память, будете дальше распараллеливать и сервера закупать?
                0
                наименее затратным для одноразовой команды
                  0
                  Для одноразовой команды достаточно было лимит оперативной памяти увеличить, костыль ничем не лучше и не худе вашего.
                    0
                    Возможно. Но давайте оставим такие решения на суд кодревью. Так или иначе, никто из комментаторов того кода не видел. А смысл обсуждать то, что не видели — практически нулевой.

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

                    И нацелен он был не на умудрённых опытом кавалеристов с овер-9000ным уровнем скилла, а на людей ищущих примеров реализации асинхронности в php.
              +2
              Вот все говорят, что мол не ту проблему решал и т.п. А я похвалю — статья познавательная, личный опыт использования компонента Process для асинхронных задач был любопытен и подобная инфа может очень даже пригодиться.

              Спасибо, что поделился!
                +7
                Человек узнал про компонент Symfony и решил расказать о нем другим? На руководство по использованию компонента не дотягивает, хоть документация по нему и так прекрасна.
                PS. Утечка памяти для Doctrine это обычное дело и обычно это можно решить немного погуглив. Одна из причин — identity map
                  +1
                  Не Identity map а Unit of work, которому она нужна. И это не утечка памяти, так как мы эту память высвободить таки можем, просто уничтожив UoW текущий.

                  Вся проблема заключается по сути в том что после каждой операции не чистили UoW, что подтверждается следующей цитатой:

                  а с какой-то возрастающей прогрессией.


                  То есть на каждый flush у нас в UoW крутилось все больше и больше объектов. Это увеличивает расход памяти и снижает производительность.
                    0
                    Я именно об этом и говорил, только на более высоком уровне, оставив возможность читателю исследовать эту тему самостоятельно не привязываясь к контексту статьи. Но спасибо, что разложили конкретный случай по полочкам.
                      0
                      Спасибо большое за ценную обратную связь.
                      На досуге попробую решить проблему утечки памяти на описанном примере.

                      По теме хочу сказать лишь одно. Ребят, тема не «Оптимизация работы проекта с помощью асинхронного выполнения PHP скриптов» и не «Руководство по асинхронному выполнению PHP скриптов». Поэтому как-то неприятно, что ли, получилось.

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

                      Если же у вас такого опыта более, чем достаточно — я очень вам завидую.
                      Но сарказм в данном случае не самое архитектурно красивое решение.
                        0
                        ну… я рад за вас, проекты типа php-pm уже довольно давно крутятся, у кого-то в продакшене, у меня вот есть так же небольшой проектик с reactphp где логика вынесена в воркеры. event loop на пыхе это уже довольно давно не новинка. Вот только большинство еще не хочет отходить от классической умирающей модели.
                  –4
                  Хочу еще больше картинок с нулями и единицами в ленте
                    0
                    А почему нельзя было просто использовать какой-нибудь сервер очередй? Зачем нужно что-то с подпроцессами делать? Какие преимущества дает(кроме транспортных потерь у очередей, вестимо)?
                      0
                      Скорость. С подпроцессами можно было обрабатывать пакеты по 20-30 entity одновременно. Соответственно на 8 потоках это 160-240 величин секунд за 5-10. С очередями получилось бы медленнее.
                      0
                      Все 2800 объектов связаны между собой? Если нет, то можно ведь просто освобождать память перед обсчётом следующего.
                        0
                        выше в комментариях это уже обсуждалось. И что падение производительности связано с тем что доктрина хранит все объекты в UnitOrWork и что с каждой итерацией обход всех сущностей занимает все больше и больше времени. И просто про оптимизации алгоритма и т.д. говорилось. Но…
                          0
                          Мне вот к слову стало что-то любопытно… среди тех кто использует доктрину, какой процент людей потрудилось почитать документацию?

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

                        Самое читаемое