PHP可变长参数(...)和生成器问题

问题来源于 https://segmentfault.com/q/10... 这里。看了@elarity 的回答,他使用了200000的元素插入到redis集合。于是乎我使用了1百万个元素数组来插入,在我这里是内存溢出的,所以我使用了生成器的方式

function xrange() {
        for ($i=0; $i<1000000; $i++) {
                yield $i;
        }
}
$r = xrange();

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$key = 'jimu';
$redis->del($key);
$begin = microtime(true);
$redis->sadd($key, ...$r);

$end = microtime(true);
echo ($end - $begin) . "\n";

输出结果:

[vagrant@localhost ~]$ php redis.php 
1.2786898612976
[vagrant@localhost ~]$

然后redis-cli中确实有了一百万个元素。那么当我把代码中的一百万修改为一千万的时候又报内存溢出

[vagrant@localhost ~]$ php redis.php 
PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted (tried to allocate 32 bytes) in /home/vagrant/redis.php on line 6

Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 32 bytes) in /home/vagrant/redis.php on line 6

根据我理解的生成器知识不应该出现内存溢出的情况。因为自始至终生成器xrange只占用一个变量($i)内存?
所以我猜测是不是$redis->sadd($key, ...$r);这一步的时候...$r依然会解析成大数组。 现在不知道如何证实。


补充:
我在sadd之前使用var_dump(...$r);exit;发现输出的都是

int(999775)
int(999776)
int(999777)
int(999778)
int(999779)
int(999780)
int(999781)

这样可以证明生成器确实是一个一个产出值的。那么为什么将...$r传入到sadd()的时候还报内存不足呢?不明白这个...的原理,还请大佬们指点。

阅读 4.4k
2 个回答

好久没看到想答的问题了,来一波

a. 这个问题和redis毫无关系

b. 上代码

<?php
//splat.php
function gen() {
  global $argv;
  $max = $argv[1];
  while($max--) {
    yield(str_repeat('x', 10000));
  }
}

function noop() {

}

function getargs() {
  $arg = func_get_args();
}

function splat(...$arg) {

}

function printmemory($msg) {
  printf("%s: %d/%d\n", $msg, memory_get_usage(), memory_get_peak_usage());
}

printmemory(__LINE__);
$gen = gen();
printmemory(__LINE__);
foreach(gen() as $r) {
  crc32($r);
}
printmemory(__LINE__);
$argv[2](...$gen);
printmemory(__LINE__);
~/Desktop $ php splat.php 10000 getargs
27: 357896/394272
29: 358504/394272
33: 370816/394272
35: 382912/123779064
~/Desktop $ php splat.php 10000 noop
27: 357896/394272
29: 358504/394272
33: 370816/394272
35: 382912/123250912
~/Desktop $ php splat.php 10000 splat
27: 357896/394272
29: 358504/394272
33: 370816/394272
35: 382912/123779064
~/Desktop $ php splat.php 1000 splat
27: 357896/394272
29: 358504/394272
33: 370816/394272
35: 382912/12695544
~/Desktop $ php splat.php 100 splat
27: 357896/394272
29: 358504/394272
33: 370816/394272
35: 382912/1607672

c. 解释

27-29-33之间,几乎没有内存占用,这是所谓的"生成器节省内存”的现象,也就是各种相关文章里都会解释的,在30行迭代生成器的时候,每次循环都会进到生成器内部去yield一次,产生一个大字符串,下次循环的时候循环变量又重新被赋值,之前的字符串自然会被GC回收,所以无论循环多大多少次,占用的内存是稳定的(包括上面的$gen=gen()也是几乎不占内存的)

33-35,无论被调用的函数如何,甚至noop函数,都一样会占用大量内存,占用内存的量明显和次数成正比,也就是说生成器的内容被合并到一起而占用了一整块内存。这其实很容易解释,几乎的所有语言“调用函数”的过程都是类似的

  1. 首先计算所有参数,形成参数列表
  2. 生成call frame(其中包含调用被调双方、文件行号、参数列表等等信息),压入call stack中
  3. 控制权移交给函数内部

(当然省略了超级多的细节,比如实参形参的映射/copy啊,内存管理啊等等什么的,和本题无关)

...$args这个操作符其实影响的就是第一个阶段,计算参数的时候,看到...操作符,就需要展开其中的参数来形成参数列表,那么用生成器的场合,这个阶段内存就从原有生成器的少量占用变成了完整的占用了,所以即使是空的noop函数也会占用几乎一样多的内存,你的理解是正确的

回到原题的那个redis问题的话,因为重复调用redis方法一定会占用大量的额外网络开销,而一次性批量插入又铁定逃不开内存占用(其实你想redis扩展要发送这个批量的指令给redis,那么这块内存肯定是要的),比较好的方式就是分组了,每1000个或者10000个合并成一次$redis调用,mysql也好其他场景也是类似的

您说的这个问题 , 我也知道 . 但没有深究 . 借着这次机会 , 工作完了后一会儿我也深究一下 .
最简单 , 可以先用 memory_get_usage 来简单测试一下 .

感谢补刀

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题
宣传栏