Разбираем, что под капотом у PHP циклов

25 September 2020
#PHP#Под капотом

В этой статье мы посмотрим, что находится под капотом у PHP циклов, а точнее сравним их опкоды.

А вот начнём с самого НЕ часто используемого вида циклов

for

<?php

for ($i = 0; $i < 10; $i++) {
}

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

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

➜  ~ php -d opcache.opt_debug_level=0x10000 -d opcache.enable_cli=1 for.php

$_main: ; (lines=7, args=0, vars=1, tmps=3)
    ; (before optimizer)
    ; /home/la/experiment/for.php:1-4
L0 (3):     ASSIGN CV0($i) int(0)
L1 (3):     JMP L4
L2 (3):     T2 = POST_INC CV0($i)
L3 (3):     FREE T2
L4 (3):     T3 = IS_SMALLER CV0($i) int(10)
L5 (3):     JMPNZ T3 L2
L6 (4):     RETURN int(1)

Описание всех опкодов есть на офф сайте.

Мои эксперименты происходят на версии 7.4.3

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

Итак, строка 0
L0: Присвоение в CV0 числа 0
Т.е. если бы мы написали в коде $i = 3, и $i была не первая переменная, а скажем вторая то строка в опкодах выглядела бы примерно так

L0 (3):     ASSIGN CV1($i) int(3)

L1: Прыжок (JMP) на строку 4 (L4)
L4: Переменная в CV0 меньше 10, если да то возвращает в T3 1 иначе 0
L5: А эта строка принимает то что возвратила предыдущая и если не 0 (JMPNZ, т.е jump if not zero) то прыжок на строку L2.
L2: POST_INC - INCрементируем то что лежит в CV0 и возвращаем в T2
L3: Так как не используем результат больше нигде, то освобождаем память выделенную для T2
Дальше цикл повторяется, пока в строке L4 IS_SMALLER не вернёт 0. Как только это случится,
JPNNZ не отработает и мы просто переёдем на строку L6 c которой мы выйдем возвратив 1

Ничего сложного, правда?

WHILE

Давайте теперь сравним с циклом while

<?php

$i = 0;
while ($i < 10) {
    $i++;
}
L0 (3):     ASSIGN CV0($i) int(0)
L1 (4):     JMP L4
L2 (5):     T2 = POST_INC CV0($i)
L3 (5):     FREE T2
L4 (4):     T3 = IS_SMALLER CV0($i) int(2)
L5 (4):     JMPNZ T3 L2
L6 (7):     RETURN int(1)

Выглядит знакомо? Да в точности так же!

Единственное, что можно заметить, это то что в скобках указаны другие строки. В варианте с for почти всё происходит в строке 3. Если бы в первом примере использовали цикл for так:

$i = 0;
for (; $i < 10; ) {
    $i++;
}

То по опкодам вы бы низачто не поняли, for это или while =) Он выглядел бы в точности так же как while.

do->while

<?php

$i = 0;
do {
    $i++;
} while($i < 10);
L0 (3):     ASSIGN CV0($i) int(0)
L1 (5):     T2 = POST_INC CV0($i)
L2 (5):     FREE T2
L3 (6):     T3 = IS_SMALLER CV0($i) int(10)
L4 (6):     JMPNZ T3 L1
L5 (6):     RETURN int(1)

Обратите внимание, в этом варианте нет прыжка на условие, а сначала выполняется тело цикла, собственно как и должно быть) В остальном всё точно так же.

В php 4 появился новый вид цикла, специально для переборки массива

foreach

<?php

$i = [];
foreach ($i as $value) {
}

Он конечно же сильно отличается

L0 (3):     ASSIGN CV0($i) array(...)
L1 (4):     V3 = FE_RESET_R CV0($i) L4
L2 (4):     FE_FETCH_R V3 CV1($value) L4
L3 (4):     JMP L2
L4 (4):     FE_FREE V3
L5 (5):     RETURN int(1)
LIVE RANGES:
        3: L2 - L4 (loop)

L1: создаём итератор V3 массива CV0. Если массив пуст, прыгаем на строку L4
L2: делаем шаг итерации итератора V3 извлекая значение в CV1. Если достигнули конец массива, прыгаем на строку L4
L3: Прыгаем на строку L2
Цикл будет повторяться пока в V3 есть что извлекать. Как только достигнем конец, прыгаем на L4
L4: Освобождаем V3

goto (for fan)

Это конечно не цикл.
К тому же так не следует делать, goto плохая практика в плане понимания кода в целом, но под капотом не сильно что меняется по сравнению с другими циклами.
Давайте сделаем подобие do-while

<?php

$i = 0;
a:
    $i++;
if ($i < 10) {
    goto a;
}
L0 (3):     ASSIGN CV0($i) int(0)
L1 (5):     T2 = POST_INC CV0($i)
L2 (5):     FREE T2
L3 (6):     T3 = IS_SMALLER CV0($i) int(10)
L4 (6):     JMPZ T3 L6
L5 (7):     JMP L1
L6 (8):     RETURN int(1)

По сравнению с опкодами do-while, различия в двух строках: L4 && L5

L4: Прыжок на L6 если в T3 ноль
L5: Безусловный переход на L1

Заключение

Это почти всё, что я хотел сказать о циклах. Но пока мы ещё не закончили, давайте рассмотрим оптимизированную версию нашего цикла на do-while и goto

Т.е если в статье мы рассматривали опкод до оптимизации (Магическое число 0x10000 в opcache.opt_debug_level первого примера - это в "переводе" "До оптимизации") мы видили коды операций в том виде, в каком они были созданы компилятором PHP. Сейчас же мы рассмотрим код после оптимизации (0x20000)

do-while

L0 (3):     ASSIGN CV0($i) int(0)
L1 (5):     PRE_INC CV0($i)
L2 (6):     T1 = IS_SMALLER CV0($i) int(10)
L3 (6):     JMPNZ T1 L1
L4 (6):     RETURN int(1)

goto

L0 (3):     ASSIGN CV0($i) int(0)
L1 (5):     PRE_INC CV0($i)
L2 (6):     T1 = IS_SMALLER CV0($i) int(10)
L3 (6):     JMPNZ T1 L1
L4 (8):     RETURN int(1)

Можете не искать отличия, они одинаковы)

Разница с неоптимизированым кодом, в обоих случаях POST_INC заменили на PRE_INC, что дало возможность отказаться от выделения памяти под результат и последующего высвобождения её.
И в случае с goto инструкции JMPZ T3 L6 и JMP L1 превратились в JMPNZ T1 L1

На этом всё! Если заметили ошибку или неточности, не стесняйтесь писать мне на почту