company_banner

Препарируем PHP. Как устроены while, foreach, array_walk и некоторые другие страшные слова



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

    Исходники от master ветки (это сейчас 7.4 с вкраплениями 8)
    Генератор опкодов от php 7.3.0.
    Замеры производились на 7.3.6.

    Дисклеймер для зануд: упоминание пары наносекунд и тактов процессора – это такой полемический приём под названием «гипербола».

    Может быть, на самом деле, там десятки или сотни наносекунд и тысячи тактов, но это всё равно настолько малые величины, что необходимость экономить на них говорит о том, что что-то в вашем коде не так.

    Этап компиляции


    for, foreach, do и while являются ключевыми словами языка, тогда как функции array_* – это функции стандартной библиотеки. Следовательно, при прочих равных, по первым парсер отработает на пару наносекунд быстрее.

    Парсер


    До токена statement путь будет одинаков для всех

    start -> top_statement_list -> top_statement -> statement

    Циклы определены на уровне statement:
    ->T_FOR '(' for_exprs ';' for_exprs ';' for_exprs ')' for_statement
    ->T_FOREACH '(' expr T_AS foreach_variable ')' foreach_statement
    ->T_FOREACH '(' expr T_AS foreach_variable T_DOUBLE_ARROW foreach_variable ')' foreach_statement
    ->T_DO statement T_WHILE '(' expr ')' ';'
    ->T_WHILE '(' expr ')' while_statement

    Отличие for_exprs от просто expr только в том, что для первого допустима запись нескольких expr через запятую.

    foreach_variable – это конструкция, которая помимо просто variable, также отслеживает распаковку с помощью list или [].

    for_statement, foreach_statement, while_statement отличаются от стандартного statement тем, что в них добавлена возможность разбора альтернативного синтаксиса:

    foreach($array as $element): 
      #do something 
    endforeach; 

    Вызов функций закопан гораздо глубже:

    -> expr -> variable -> callable_variable -> function_call -> name argument_list

    callable_variable, хм… Забавно, не правда ли? :)

    Перейдём к опкодам


    Для примера давайте возьмём простой перебор индексированного массива с печатью каждого ключа и значения. Понятно, что использование for, while и do для такой задачи не оправдано, но у нас цель просто показать внутреннее устройство.

    foreach


    $arr = ['a', 'b', 'c'];   // в дальнейших примерах будем использовать его же
    foreach($arr as $key => $value) echo $key.$value;

    line     #* E I O op                 fetch          ext  return  operands
    ---------------------------------------------------------------------------
       2     0  E >   ASSIGN                                         !0, <array>
       4     1      > FE_RESET_R                             $4      !0, ->7
             2    > > FE_FETCH_R                             ~5      $4, !1, ->7
             3    >   ASSIGN                                         !2, ~5
             4        CONCAT                                 ~7      !2, !1
             5        ECHO                                           ~7
             6      > JMP                                            ->2
             7    >   FE_FREE                                        $4
       5     8      > RETURN                                         1
    

    Что тут происходит:



    FE_RESET_R: создаёт итератор $4 для массива !0. Либо, если массив пуст, сразу переходит к инструкции 7.
    FE_FETCH_R: совершает шаг итерации, извлекает текущий ключ в ~5, а значение в !1. Либо, если достигнут конец массива, переходит к инструкции 7.
    Инструкции 3-6 не особо интересны. Тут происходит вывод и возврат к FE_FETCH_R.
    FE_FREE: уничтожает итератор.

    for, while, ...


    $length = count($arr);
    for($i = 0; $i < $length; $i++) echo $i.$arr[$i];

    На самом деле это частный случай while

    $i = 0;
    while($i < $length) {
        echo $i.$arr[$i];
        $i++;
    }

    На самом деле это частный случай if+goto

    $i = 0;
    goto check;
    body:
        echo $i.$arr[$i];
        $i++;
    check:
    if($i < $length) goto body;

    Опкоды для всех трёх случаев будут практически идентичны. Разве что в случае с if, JMPNZ поменяется на пару JMPZ+JMP из-за входа в тело if'а.
    Для цикла do опкоды будут незначительно отличаться из-за его постпроверочной природы.

    line     #* E I O op                 fetch          ext  return  operands
    ---------------------------------------------------------------------------
       3     0  E >   ASSIGN                                         !0, <array>
       5     1        COUNT                                  ~4      !0
             2        ASSIGN                                         !1, ~4
       6     3        ASSIGN                                         !2, 0
             4      > JMP                                            ->10
             5    >   FETCH_DIM_R                            ~7      !0, !2
             6        CONCAT                                 ~8      !2, ~7
             7        ECHO                                           ~8
             8        POST_INC                               ~9      !2
             9        FREE                                           ~9
            10    >   IS_SMALLER                             ~10     !2, !1
            11      > JMPNZ                                           ~10, ->5
            12    > > RETURN                                         1

    Опкодов, ожидаемо, стало больше.
    0-2: вычисление $length.
    3: $i = 0
    4, 10, 11: первичная проверка условия выхода и, собственно, либо выход, либо переход к телу цикла.
    5(FETCH_DIM_R): $arr[$i]
    6-7: вывод
    8-9: $i++ (обратите внимание, что опкод инкремента в любом случае возвращает значение, даже если оно не нужно. Поэтому требуется дополнительная инструкция FREE, для его очистки).
    10-11: проверка условия выхода после инкремента.

    А можно ещё и так поитерировать


    $value = reset($arr);
    $key = key($arr);
    while($key !== null) {
        echo $key.$value;
        $value = next($arr);
        $key = key($arr);
    }

    Простыня, так что под спойлер
    line     #* E I O op                 fetch          ext  return  operands
    ---------------------------------------------------------------------------
       3     0  E >   ASSIGN                                         !0, <array>
       5     1        INIT_FCALL                                     'reset'
             2        SEND_REF                                       !0
             3        DO_ICALL                               $4      
             4        ASSIGN                                         !1, $4
       6     5        INIT_FCALL                                     'key'
             6        SEND_VAR                                       !0
             7        DO_ICALL                               $6      
             8        ASSIGN                                         !2, $6
       7     9      > JMP                                            ->20
       8    10    >   CONCAT                                 ~8      !2, !1
            11        ECHO                                           ~8
       9    12        INIT_FCALL                                     'next'
            13        SEND_REF                                       !0
            14        DO_ICALL                               $9      
            15        ASSIGN                                         !1, $9
      10    16        INIT_FCALL                                     'key'
            17        SEND_VAR                                       !0
            18        DO_ICALL                               $11     
            19        ASSIGN                                         !2, $11
       7    20    >   IS_NOT_IDENTICAL                       ~13     !2, null
            21      > JMPNZ                                          ~13, ->10
      11    22    > > RETURN                                         1


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

    Функции reset, next и key довольно легковесные, но накладные расходы на их вызов всё же есть. И, как мы увидим дальше, расходы эти велики.

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

    1) Тогда как reset, next и keycurrent тоже) работают напрямую с внутренним указателем массива, foreach использует собственный итератор и не меняет состояние внутреннего указателя.

    Т.е.

    foreach($arr as $v) echo $v.' - '.current($arr).PHP_EOL;
    
    //----------------
    
    a - a
    b - a
    c - a

    2) При использовании foreach для итерации по значению, что бы вы не делали с массивом внутри цикла, проитерирован будет именно первоначальный набор данных

    foreach($arr as $v) {
        $arr = ['X','Y','Z'];
        echo $v.PHP_EOL;
    }
    var_dump($arr);
    
    //----------------
    
    a
    b
    c
    array(3) {
      [0]=>
      string(1) "X"
      [1]=>
      string(1) "Y"
      [2]=>
      string(1) "Z"
    }

    Что будет при итерации по ссылке, можно почитать в этом RFC. Там всё не очень просто.

    array_walk с анонимной функцией


    array_walk($arr, function($value, $key){ echo $key.$value;});

    Так как используется пользовательская функция, то будет дополнительный набор опкодов.

    Функция


    line     #* E I O op                 fetch          ext  return  operands
    ---------------------------------------------------------------------------
             0  E >   RECV                                   !0      
             1        RECV                                   !1      
             2        CONCAT                                 ~2      !1, !0
             3        ECHO                                           ~2
             4      > RETURN                                         null
    

    Основной код


    line     #* E I O op                 fetch          ext  return  operands
    ---------------------------------------------------------------------------
       2     0  E >   ASSIGN                                         !0, <array>
       4     1        INIT_FCALL                                     'array_walk'
             2        SEND_REF                                       !0
             3        DECLARE_LAMBDA_FUNCTION                        '%00%7Bclosure%7D%2Fin%2FHuNEK0x7f9fa62d105a'
             4        SEND_VAL                                       ~2
             5        DO_ICALL                                       
             6      > RETURN                                         1

    Поскольку array_walk, как и остальные функции стандартной библиотеки, является интринсиком, то в скомпилированных опкодах механизм итерации отсутствует.

    INIT_FCALL: инициализируем вызов array_walk
    SEND_REF: кладём ссылку на массив на стек вызова
    DECLARE_LAMBDA_FUNCTION: объявляем анонимную функцию
    SEND_VAL: кладём анонимную функцию на стек вызова
    DO_ICALL: запускаем array_walk на выполнение

    Далее там происходит магия с вызовом нашей лямбды для каждого элемента массива.

    array_walk с использованием предопределённой функции


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

    line     #* E I O op                 fetch          ext  return  operands
    ---------------------------------------------------------------------------
       3     0  E >   ASSIGN                                         !0, <array>
       9     2        INIT_FCALL                                     'array_walk'
             3        SEND_REF                                       !0
             4        SEND_VAL                                       'my_echo'
             5        DO_ICALL                                       
             6      > RETURN                                         1

    Выводы банальны. foreach заточен под итерирование массивов, тогда как остальные циклы – просто обёртка над if+goto.

    Функции же стандартной библиотеки работают по принципу чёрного ящика.

    Погружаемся чуть глубже


    Для начала рассмотрим случай с for и его опкодом FETCH_DIM_R, использующимся для извлечения значения по ключу. Извлечение элемента идёт через поиск в хеш-таблице (ZEND_HASH_INDEX_FIND). В нашем случае извлечение идёт из упакованного массива (ключи – непрерывная числовая последовательность) – это довольно лёгкая и быстрая операция. Для неупакованных массивов она будет чуть подороже.

    Теперь foreach (FE_FETCH_R). Тут все банально:

    1. Проверяем, не вышел ли указатель итератора за пределы массива
    2. Извлекаем текущие ключ и значение
    3. Инкрементируем указатель

    Ну а что происходит с функциями типа array_walk? Хоть все они делают разные вещи, но в основе у них у лежит один и тот же принцип.

    Если совсем упрощённо, то (псевдокод):

    reset_pointer()
    do {
      value = get_current_value()
      if (value == NULL) break         // Именно NULL, а не zval типа NULL
      key = get_current_key()
      call_function(myFunction, key, value)
      increment_pointer()
    } while(true)

    На самом деле внутри всё сложнее, но суть одна – идёт довольно быстрый перебор хеш-таблицы без участия виртуальной машины PHP (не учитывая вызова пользовательской функции).

    Ну и немного замеров


    А то ведь какая же статья без замеров (по памяти получилось настолько одинаково, что убрал её измерение).

    В качестве массива, по традиции, возьмём zend_vm_execute.h на 70.108 строк.

    Скрипт замера под спойлером
    <?php
    $array = explode("\n", file_get_contents('/Users/rjhdby/CLionProjects/php-src/Zend/zend_vm_execute.h'));
    
    function myEcho($key, $value){
        echo $key . $value;
    }
    
    ob_start();
    $startTime = microtime(true);
    for ($i = 0; $i < 10; $i++) {
    
    //    foreach ($array as $key => $value) {
    //        echo $key . $value;
    //    }
    
    //    foreach ($array as $key => &$value) {
    //        echo $key . $value;
    //    }
    
    //    $length = count($array);
    //    for ($j = 0; $j < $length; $j++) {
    //        echo $j . $array[$j];
    //    }
    
    //    array_walk($array, function($value, $key) {
    //        echo $key . $value;
    //    });
    
    //    array_walk($array, static function($value, $key) {
    //        echo $key . $value;
    //    });
    
    //    array_walk($array, 'myEcho');
    
    //    $value = reset($array);
    //    $key = key($array);
    //    while($key !== null) {
    //        echo $key.$value;
    //        $value = next($array);
    //        $key = key($array);
    //    }
    
    }
    $endTime = microtime(true);
    ob_clean();
    
    echo "time: " . ($endTime - $startTime);


    Каждое измерение запускал раз по 10, выбирая наиболее часто встречающееся по первым 4-м цифрам.

    foreach                   time: 0.12159085273743
    foreach(reference)        time: 0.11191201210022
    for, while                time: 0.13130807876587
    array_walk(lambda)        time: 0.87953400611877
    array_walk(static lambda) time: 0.87544417497211
    array_walk(name)          time: 0.50753092765808
    next,key                  time: 1.06258893013

    Подведём итоги


    Анализируя результаты, не забываем учитывать, что они получены на 10 проходах по массиву из 70 тысяч элементов.

    Абсолютным антигероем оказалась «эмуляция» foreach с помощью next/key. Не делайте так без крайней на то необходимости.

    array_walk с лямбдой дышит ему в спину, но тут есть нюанс. Грядущий JIT может кардинально изменить ситуацию. А может и не изменить. Интересно будет посмотреть.
    array_walk с использованием готовой функции – крепкий середнячок.

    Так как при итерации по ссылке foreach работает несколько иначе (использует опкод FE_FETCH_RW вместо FE_FETCH_R), то сделал для него отдельный замер. Он действительно чуть-чуть быстрее получился.

    Как оказалось, создание лямбды на лету – не самая дешёвая операция. Казалось бы, создаётся она всего 10 раз. Надо будет поизучать.

    Все остальные способы показали примерно одинаковые результаты, с очень незначительным разрывом.

    Спасибо за внимание!

    Если есть предложения, что ещё можно «поковырять» – пишите в комментариях. Я пока подумываю о лямбдах – уж очень странна такая просадка производительности.

    UPD
    Добавил замер для array_walk со статической лямбдой. Разницы не видно.
    FunCorp
    212,12
    Разработка развлекательных сервисов
    Поделиться публикацией

    Похожие публикации

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

      0
      Спасибо тебе добрый человек. По тестам всегда знал, что в PHP foreach for while самые быстрые и использовал их — но вот залезть под капот всё не было ни времени ни желания, а ты это сделал для нас всех, таких ленивых.
        0

        А что насчет лямбды объявленной со словом static


        array_walk($arr, static function($value, $key){ echo $key.$value;});

        По идее должно быть быстрее если вызовы горячие

          0
          Добавил замер для статической функции. Разницы не заметно.
            +1

            Шторм советует добавить статик с пометкой "микрооптимизация". Я добавляю :)

              +1
              Ну да, как тут не нажать ALT+ENTER, если подсвечено!?
              Вообще забавно, как среда разработки сама формирует нужный ей стиль кода. Добавят завтра джетбрейновцы A/B эксперимет, который будет советовать, в качестве оптимизации, заменить while на if/goto и ведь многие поведутся и будут потом уверены, что это влияет на производительность.

              PS Я тоже добавляю :D
                0

                Ну так на то она и "микро", что влияние незначительное

                  0

                  Эта микрооптимизация работает в случае, когда вы находитесь в контексте класса.
                  Т.е. просто анонимная функция биндит контекст по-умолчанию, а статическая — не биндит контекст и не пробрасывает внутрь анониманой функции $this. Вот в чем микрооптимизация

                    0

                    Шторм подсвечивает только в контесте класса :)

            +4
            Перевод на английский был бы очень кстати для ссылок при дискуссиях в англоязычной среде. Если кто-то возьмется, был бы очень благодарен. :-)
              0
              Самому, что ли, взяться… Хотя я такого наперевожу…
                0
                Немного оффтоп: неужели может быть такое, что у кого-то не было английского в школе? Или есть еще какая-то причина? Ибо на таком уровне даже моя девушка сможет перевести, если вы понимаете о чем я :/
                  0
                  Нет не понимаю. Я работаю в англофонной среде последние 8 лет. По работе общаюсь 100% по-английски. Но взяться за перевод этой статьи не считаю возможным. Если ваша девушка обладает необходимой квалификацией, то пусть попробует. Но я подозреваю что заминусуют ее тут именно за качество перевода слету.
                  Хотя возможно это я такой туповатый, а вокруг все люди поспособнее и поскромнее ходят и живут.
                    0
                    Просто мне показалось что это типичный технический английский «на уровне чтения документации», который ожидает 99% рекрутеров… А может и показалось…
                      0

                      Это совершенно другой процесс переводить текст на английский и читать английский текчт

                        0
                        Просто я всегда считал что любое русское явление в программировании — это калька с английского, и любой разраб должен знать как оно произносится по-английски, банально чтобы загуглить его в буржунете, ибо в русском материала до сих пор катастрофически мало.
                          0
                          Не продолжайте. Просто переведите. Мы все будем рады и благодарны.
                            0
                            А, ладно, подумаю над этим на досуге…
                            0

                            Термины — может быть. Но грамматику, да и простые слова, не говоря об идиомах. Вот первая же предложение "Дело было вечером, делать было нечего" — можно перевести дословно, конечно, но наверняка что-то лучше есть.

                              0
                              Я тоже об этом думал, когда просматривал ее уже как носитель языка, а не как разраб, не буду скрывать :) Однако если предназначение статьи — скидывать ее в англоязычных дискуссиях, то на мой взгляд использование в ней идиом (особенно русскоязычных, которые в большинстве случаев в английском имеют совершенно другой смысл) несколько бессмысленно, это все-таки не дискуссии филологов…
                      0

                      Да, кстати, у меня в школе был французский :)

                  0

                  Вроде как ещё можно array_map потрогать. Вроде там что то крутили

                    0
                    Особенно если надо вызвать какую-то функцию на каждом элементе массива типа array_map('gettype', $arr), мне кажется будет быстрее чем foreach
                      0
                      По принципу работы array_map ничем не отличается от array_walk. Нет, быстрее не будет.
                        0

                        Принцип то один, гляньте внутри, я сам в коде использую map вместо foreach, а вот подробностей не помню, почему так

                          0

                          Так я внутри и смотрю. Тут не в способе перебора дело, а в вызове пользовательской функции, при которой управление передается виртуальной машине.
                          И там и там zend_call_function(&fci, &fci_cache) дергается.

                        0
                        Я под капот не залазил. Но 500 лет назад сравнивал «foreach vs array_map» на примере парсинга csv файла: romanitalian.github.io/sections/php/csv_parse_foreach_vs_array_map

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

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