• 欢迎访问IT乐园(o゚▽゚)o
  • 推荐使用最新版火狐浏览器和Chrome浏览器访问本网站。

深入理解 PHP7 之zval

php fhy 7个月前 (12-19) 182次浏览 0个评论 扫描二维码

深入理解 PHP7 之 zval

PHP7 已经发布, 如承诺, 我也要开始这个系列的文章的编写, 今天我想先和大家聊聊 zval 的变化. 在讲 zval 变化的之前我们先来看看 zval 在 PHP5 下面是什么样子

版权申明:

转自鸟哥 github 之 php7-internal: https://github.com/laruence/php7-internal/blob/master/zval.md
包括文字、资料、图片、网页格式,转载时请标注作者与来源。非经允许,不得用于赢利目的。

PHP5

zval 回顾

在 PHP5 的时候, zval 的定义如下:

对 PHP5 内核有了解的同学应该对这个结构比较熟悉, 因为 zval 可以表示一切 PHP 中的数据类型, 所以它包含了一个 type 字段, 表示这个 zval 存储的是什么类型的值, 常见的可能选项是IS_NULL, IS_LONG, IS_STRING, IS_ARRAY, IS_OBJECT等等.

根据 type 字段的值不同, 我们就要用不同的方式解读 value 的值, 这个 value 是个联合体, 比如对于 type 是IS_STRING, 那么我们应该用value.str来解读zval.value字段, 而如果 type 是IS_LONG, 那么我们就要用value.lval来解读.

另外, 我们知道 PHP 是用引用计数来做基本的垃圾回收的, 所以 zval 中有一个refcount__gc字段, 表示这个 zval 的引用数目, 但这里有一个要说明的, 在 5.3 以前, 这个字段的名字还叫做refcount, 5.3 以后, 在引入新的垃圾回收算法来对付循环引用计数的时候, 作者加入了大量的宏来操作refcount, 为了能让错误更快的显现, 所以改名为refcount__gc, 迫使大家都使用宏来操作refcount.

类似的, 还有is_ref, 这个值表示了 PHP 中的一个类型是否是引用, 这里我们可以看到是不是引用是一个标志位.

这就是 PHP5 时代的 zval, 在 2013 年我们做 PHP5 的 opcache JIT 的时候, 因为 JIT 在实际项目中表现不佳, 我们转而意识到这个结构体的很多问题. 而 PHPNG 项目就是从改写这个结构体而开始的.

存在的问题

PHP5 的 zval 定义是随着 Zend Engine 2 诞生的, 随着时间的推移, 当时设计的局限性也越来越明显:

首先这个结构体的大小是(在 64 位系统)24 个字节, 我们仔细看这个zval.value联合体, 其中zend_object_value是最大的长板, 它导致整个 value 需要 16 个字节, 这个应该是很容易可以优化掉的, 比如把它挪出来, 用个指针代替,因为毕竟IS_OBJECT也不是最最常用的类型.

第二, 这个结构体的每一个字段都有明确的含义定义, 没有预留任何的自定义字段, 导致在 PHP5 时代做很多的优化的时候, 需要存储一些和 zval 相关的信息的时候, 不得不采用其他结构体映射, 或者外部包装后打补丁的方式来扩充 zval, 比如 5.3 的时候新引入专门解决循环引用的 GC, 它不得采用如下的比较 hack 的做法:

它用zval_gc_info劫持了 zval 的分配:

然后用zval_gc_info来扩充了 zval, 所以实际上来说我们在 PHP5 时代申请一个 zval 其实真正的是分配了 32 个字节, 但其实 GC 只需要关心IS_ARRAY 和 IS_OBJECT类型, 这样就导致了大量的内存浪费.

还比如我之前做的 Taint 扩展, 我需要对于给一些字符串存储一些标记, zval 里没有任何地方可以使用, 所以我不得不采用非常手段:

就是把字符串的长度扩充一个 int, 然后用 magic number 做标记写到后面去, 这样的做法安全性和稳定性在技术上都是没有保障的

第三, PHP 的 zval 大部分都是按值传递, 写时拷贝的值, 但是有俩个例外, 就是对象和资源, 他们永远都是按引用传递, 这样就造成一个问题, 对象和资源在除了 zval 中的引用计数以外, 还需要一个全局的引用计数, 这样才能保证内存可以回收. 所以在 PHP5 的时代, 以对象为例, 它有俩套引用计数, 一个是 zval 中的, 另外一个是 obj 自身的计数:

除了上面提到的两套引用以外, 如果我们要获取一个 object, 则我们需要通过如下方式:

经过漫长的多次内存读取, 才能获取到真正的 objec 对象本身. 效率可想而知.

这一切都是因为 Zend 引擎最初设计的时候, 并没有考虑到后来的对象. 一个良好的设计, 一旦有了意外, 就会导致整个结构变得复杂, 维护性降低, 这是一个很好的例子.

第四, 我们知道 PHP 中, 大量的计算都是面向字符串的, 然而因为引用计数是作用在 zval 的, 那么就会导致如果要拷贝一个字符串类型的 zval, 我们别无他法只能复制这个字符串. 当我们把一个 zval 的字符串作为 key 添加到一个数组里的时候, 我们别无他法只能复制这个字符串. 虽然在 PHP5.4 的时候, 我们引入了 INTERNED STRING, 但是还是不能根本解决这个问题.

还比如, PHP 中大量的结构体都是基于 Hashtable 实现的, 增删改查 Hashtable 的操作占据了大量的 CPU 时间, 而字符串要查找首先要求它的 Hash 值, 理论上我们完全可以把一个字符串的 Hash 值计算好以后, 就存下来, 避免再次计算等等

第五, 这个是关于引用的, PHP5 的时代, 我们采用写时分离, 但是结合到引用这里就有了一个经典的性能问题:

当我们调用 dummy 的时候, 本来只是简单的一个传值就行的地方, 但是因为$array 曾经引用赋值给了$b, 所以导致$array 变成了一个引用, 于是此处就会发生分离, 导致数组复制, 从而极大的拖慢性能, 这里有一个简单的测试:

我们在 5.6 下运行这个例子, 得到如下结果:

相差 1 万倍之多. 这就造成, 如果在一大段代码中, 我不小心把一个变量变成了引用(比如 foreach as &$v), 那么就有可能触发到这个问题, 造成严重的性能问题, 然而却又很难排查.

第六, 也是最重要的一个, 为什么说它重要呢? 因为这点促成了很大的性能提升, 我们习惯了在 PHP5 的时代调用MAKE_STD_ZVAL在堆内存上分配一个 zval, 然后对他进行操作, 最后呢通过RETURN_ZVAL把这个 zval 的值”copy”给return_value, 然后又销毁了这个 zval, 比如pathinfo这个函数:

这个 tmp 变量, 完全是一个临时变量的作用, 我们又何必在堆内存分配它呢? MAKE_STD_ZVAL/ALLOC_ZVAL在 PHP5 的时候, 到处都有, 是一个非常常见的用法, 如果我们能把这个变量用栈分配, 那无论是内存分配, 还是缓存友好, 都是非常有利的

还有很多, 我就不一一详细列举了, 但是我相信你们也有了和我们当时一样的想法, zval 必须得改改了, 对吧?

PHP7

现在的 zval

到了 PHP7 中, zval 变成了如下的结构, 要说明的是, 这个是现在的结构, 已经和 PHPNG 时候有了一些不同了, 因为我们新增加了一些解释 (联合体的字段), 但是总体大小, 结构, 是和 PHPNG 的时候一致的:

虽然看起来变得好大, 但其实你仔细看, 全部都是联合体, 这个新的 zval 在 64 位环境下,现在只需要 16 个字节(2 个指针 size), 它主要分为俩个部分, value和扩充字段, 而扩充字段又分为u1u2俩个部分, 其中u1是 type info, u2是各种辅助字段.

其中value部分, 是一个size_t大小(一个指针大小), 可以保存一个指针, 或者一个long, 或者一个double.

而 type info 部分则保存了这个 zval 的类型. 扩充辅助字段则会在多个其他地方使用, 比如next, 就用在取代 Hashtable 中原来的拉链指针, 这部分会在以后介绍 HashTable 的时候再来详解.

类型

PHP7 中的 zval 的类型做了比较大的调整, 总体来说有如下 17 种类型:

其中 PHP5 的时候的IS_BOOL类型, 现在拆分成了IS_FALSEIS_TRUE俩种类型. 而原来的引用是一个标志位, 现在的引用是一种新的类型.

对于IS_INDIRECTIS_PTR来说, 这俩个类型是用在内部的保留类型, 用户不会感知到, 这部分会在后续介绍 HashTable 的时候也一并介绍.

从 PHP7 开始, 对于在 zval 的value字段中能保存下的值, 就不再对他们进行引用计数了, 而是在拷贝的时候直接赋值, 这样就省掉了大量的引用计数相关的操作, 这部分类型有:

当然对于那种根本没有值, 只有类型的类型, 也不需要引用计数了:

而对于复杂类型, 一个size_t保存不下的, 那么我们就用value来保存一个指针, 这个指针指向这个具体的值, 引用计数也随之作用于这个值上, 而不在是作用于 zval 上了.

深入理解 PHP7 之 zval

IS_ARRAY为例:

zval.value.arr将指向上面的这样的一个结构体, 由它实际保存一个数组, 引用计数部分保存在zend_refcounted_h结构中:

所有的复杂类型的定义, 开始的时候都是zend_refcounted_h结构, 这个结构里除了引用计数以外, 还有 GC 相关的结构. 从而在做 GC 回收的时候, GC 不需要关心具体类型是什么, 所有的它都可以当做zend_refcounted*结构来处理.

另外有一个需要说明的就是大家可能会好奇的ZEND_ENDIAN_LOHI_4宏, 这个宏的作用是简化赋值, 它会保证在大端或者小端的机器上, 它定义的字段都按照一样顺序排列存储, 从而我们在赋值的时候, 不需要对它的字段分别赋值, 而是可以统一赋值, 比如对于上面的 array 结构为例, 就可以通过:

一次完成相当于如下的赋值序列:

还有一个大家可能会问到的问题是, 为什么不把 type 类型放到 zval 类型的前面, 因为我们知道当我们去用一个 zval 的时候, 首先第一点肯定是先去获取它的类型. 这里的一个原因是, 一个是俩者差别不大, 另外就是考虑到如果以后 JIT 的话, zval 的类型如果能够通过类型推导获得, 就根本没有必要去读取它的 type 值了.

标志位

除了数据类型以外, 以前的经验也告诉我们, 一个数据除了它的类型以外, 还应该有很多其他的属性, 比如对于 INTERNED STRING,它是一种在整个 PHP 请求期都存在的字符串(比如你写在代码中的字面量), 它不会被引用计数回收. 在 5.4 的版本中我们是通过预先申请一块内存, 然后再这个内存中分配字符串, 最后用指针地址来比较, 如果一个字符串是属于 INTERNED STRING 的内存范围内, 就认为它是 INTERNED STRING. 这样做的缺点显而易见, 就是当内存不够的时候, 我们就没有办法分配 INTERNED STRING 了, 另外也非常丑陋, 所以如果一个字符串能有一些属性定义则这个实现就可以变得很优雅.

还有, 比如现在我们对于IS_LONG, IS_TRUE等类型不再进行引用计数了, 那么当我们拿到一个 zval 的时候如何判断它需要不需要引用计数呢? 想当然的我们可能会说用:

但是你忘了, 还有 INTERNED STRING 的存在啊, 所以你也许要这么写了:

是不是已经让你感觉到有点不对劲了? 嗯,别急, 还有呢, 我们还在 5.6 的时候引入了常量数组, 这个数组呢会存储在 Opcache 的共享内存中, 它也不需要引用计数:

你是不是也觉得这简直太丑陋了, 简直不能忍受这样墨迹的代码, 对吧?

是的,我们早想到了,回头看之前的 zval 定义, 注意到type_flags了么? 我们引入了一个标志位, 叫做IS_TYPE_REFCOUNTED, 它会保存在zval.u1.v.type_flags中, 我们对于需要引用计数的类型就赋予这个标志, 所以上面的判断就可以变得很优雅:

而对于 INTERNED STRING 来说, 这个IS_STR_INTERNED标志位应该是作用于字符串本身而不是 zval 的.

那么类似这样的标志位一共有多少呢?作用于 zval 的有:

作用于字符串的有:

作用于数组的有:

作用于对象的有:

有了这些预留的标志位, 我们就会很方便的做一些以前不好做的事情, 就比如我自己的 Taint 扩展, 现在把一个字符串标记为污染的字符串就会变得无比简单:

这个标记就会一直随着这个字符串的生存而存在的, 省掉了我之前的很多 tricky 的做法.

ZVAL 预先分配

前面我们说过, PHP5 的 zval 分配采用的是堆上分配内存, 也就是在 PHP 预案代码中随处可见的 MAKE_STD_ZVAL 和 ALLOC_ZVAL 宏. 我们也知道了本来一个 zval 只需要 24 个字节, 但是算上 gc_info, 其实分配了 32 个字节, 再加上 PHP 自己的内存管理在分配内存的时候都会在内存前面保留一部分信息:

从而导致实际上我们只需要 24 字节的内存, 但最后竟然分配 48 个字节之多.

然而大部分的 zval, 尤其是扩展函数内的 zval, 我们想想它接受的参数来自外部的 zval, 它把返回值返回给 return_value, 这个也是来自外部的 zval, 而中间变量的 zval 完全可以采用栈上分配. 也就是说大部分的内部函数都不需要在堆上分配内存, 它需要的 zval 都可以来自外部.

于是当时我们做了一个大胆的想法, 所有的 zval 都不需要单独申请.

而这个也很容易证明, PHP 脚本中使用的 zval, 要么存在于符号表, 要么就以临时变量(IS_TMP_VAR)或者编译变量(IS_CV)的形式存在. 前者存在于一个 Hashtable 中, 而在 PHP7 中 Hashtable 默认保存的就是 zval, 这部分的 zval 完全可以在 Hashtable 分配的时候一次性分配出来, 后面的存在于 execute_data 之后, 数量也在编译时刻确定好了, 也可以随着 execute_data 一次性分配, 所以我们确实不再需要单独在堆上申请 zval 了.

所以, 在 PHP7 开始, 我们移除了 MAKE_STD_ZVAL/ALLOC_ZVAL 宏, 不再支持存堆内存上申请 zval. 函数内部使用的 zval 要么来自外面输入, 要么使用在栈上分配的临时 zval.

在后来的实践中, 总结出来的可能对于开发者来说最大的变化就是, 之前的一些内部函数, 通过一些操作获得一些信息, 然后分配一个 zval, 返回给调用者的情况:

要么修改为, 这个 zval 由调用者传递:

要么修改为, 这个函数返回原始素材:

总结

(这块还没想好怎么说, 本来我是要引出 Hashtable 不再存在 zval**, 从而引出引用类型的存在的必要性, 但是如果不先讲 Hashtable 的结构, 这个引出貌似很突兀, 先这么着吧, 以后再来修改)

到现在我们基本上把 zval 的变化概况介绍完毕, 抽象的来说, 其实在 PHP7 中的 zval, 已经变成了一个值指针, 它要么保存着原始值, 要么保存着指向一个保存原始值的指针. 也就是说现在的 zval 相当于 PHP5 的时候的 zval *. 只不过相比于 zval *, 直接存储 zval, 我们可以省掉一次指针解引用, 从而提高缓存友好性.

其实 PHP7 的性能, 我们并没有引入什么新的技术模式, 不过就是主要来自, 持续不懈的降低内存占用, 提高缓存友好性, 降低执行的指令数的这些原则而来的, 可以说 PHP7 的重构就是这三个原则.


IT 乐园 , 版权所有丨如未注明 , 均为原创丨本网站采用BY-NC-SA协议进行授权
转载请注明原文链接:深入理解 PHP7 之 zval
喜欢 (0)
关于作者:
九零后挨踢男
发表我的评论
取消评论
表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址