我们正在准备PHP。 foreach,array_walk和其他一些令人恐惧的单词的时间如何



那天晚上,没有事可做。 现在是时候分析一下PHP中数组排序的一些内部方法之间的差异了。

来自master分支的源(现在是7.4,中间是8)
来自PHP 7.3.0的操作码生成器。
在7.3.6上进行测量。

对于书呆子的免责声明:提到几个纳秒和处理器周期,这就是一个名为“ hyperbole”的争论性技巧。

实际上,也许有几十或几百个纳秒和数千个度量,但是它仍然很小,以至于需要保存它们表明代码中有错误。

编译阶段


forforeachdowhile是该语言的关键字,而array_ *函数是标准库的函数。 因此,首先,ceteris paribus解析器将执行几纳秒的速度。

解析器


语句标记之前,所有路径均相同

start -> top_statement_list -> top_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和just expr之间的区别仅在于,对于第一个,允许写入多个expr ,以逗号分隔。

foreach_variable是一种构造,除了变量之外 ,还使用list[]跟踪解压缩。

for_statementforeach_statementwhile_statement与标准语句的不同之处在于它们增加了解析替代语法的能力:

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

函数调用被更深地埋藏:

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

callable_variable ,嗯...好笑,不是吗? :)

让我们继续操作码。


例如,让我们简单地对索引数组进行迭代,并打印每个键和值。 显然,为这样的任务使用forwhiledo是没有道理的,但是我们的目标只是显示内部设备。

前言


 $arr = ['a', 'b', 'c']; //        foreach($arr as $key => $value) echo $key.$value; 

 line #* EIO 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 :为数组0创建一个$ 4迭代器。 或者,如果数组为空,则直接转到指令7
FE_FETCH_R :执行迭代步骤,在〜5处检索当前键,在!1处取值。 或者,如果到达数组的末尾,则它前进到指令7
指令3-6并不是特别有趣。 这里有一个结论,然后返回FE_FETCH_R
FE_FREE :销毁迭代器。

对于...


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

这实际上是一阵子的特例

 $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将由于进入if的主体而更改为一对JMPZ + JMP
对于do循环,由于其验证后的性质,操作码将略有不同。

 line #* EIO 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 :计算$长度
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 #* EIO 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 


此选项很好,因为它适用于使用任何键迭代数组,而不仅仅是使用单调递增的整数。

resetnextkey函数非常轻巧,但是调用它们仍然有一些开销。 而且,正如我们稍后将看到的,这些成本很高。

尽管此方法与foreach的原理非常相似,但它们之间有两个根本区别。

1)尽管resetnextkey (以及current )直接与数组的内部指针一起工作,但是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 #* EIO op fetch ext return operands --------------------------------------------------------------------------- 0 E > RECV !0 1 RECV !1 2 CONCAT ~2 !1, !0 3 ECHO ~2 4 > RETURN null 

主要代号


 line #* EIO 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执行

接下来,调用数组的每个元素的lambda会发生魔术。

使用预定义函数的array_walk


与使用匿名方法进行调用没有什么不同,除了可能比在运行时创建lambda的开销少。

 line #* EIO 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 )中进行搜索来提取项目。 在我们的例子中,提取来自压缩数组 (键是一个连续的数字序列)-这是一个相当容易且快速的操作。 对于未包装的阵列,价格会稍高一些。

现在foreachFE_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 

总结一下


分析结果时,不要忘记考虑它们是在通过7万个元素的数组的10次传递中获得的。

绝对的反英雄是next / keyforeach的“模仿”。 除非绝对必要,否则不要这样做。

带着lambda的array_walk在他的背上呼吸,但有细微差别。 即将到来的准时制可以大大改变这种状况。 也许不变。 将会很有趣。
使用现成的函数进行array_walk是一个强项。

由于在链接上进行迭代时foreach的工作方式有所不同(它使用FE_FETCH_RW操作码而不是FE_FETCH_R ),因此对其进行了单独的测量。 他的结果确实更快。

事实证明,动态创建lambda并不是最便宜的操作。 似乎只创建了10次。 有必要学习。

所有其他方法均显示出大致相同的结果,但差距很小。

感谢您的关注!

如果您有任何建议,还可以“选择”-在评论中写。 我仍在考虑lambda-这样的性能下降非常奇怪。

UPD
为具有静态lambda的array_walk添加了计量。 区别不明显。

Source: https://habr.com/ru/post/zh-CN460285/


All Articles