PHP7扩展开发教程[4] – zval的工作原理

确定你已经读完上一章《PHP7扩展开发教程[3] – 怎样定义函数?》。

回顾前篇

在章节3中,我们没有讲解myext_strtolower和myext_strtoupper两个函数的实现,因为涉及了zval的使用。

在本章节中,将讲述zval的数据结构,同时通过示例来演示zval的使用方法以及背后原理。最终,将回到之前的2个函数体,看看zval是如何实际应用的。

前言

zval是Zend对各种数据类型的包装,像string,int,array等都可以包装成zval。这样Zend只需要依赖zval即可操作各种数据类型,算是一种抽象和解耦的设计。

理论先行

先来看一下zval数据结构:

这个数据结构有3个字段。

  • value:保存了具体类型的值,稍后再说。
  • u1:是一个4字节的union,内部的type_info和v共享union的4字节,提供了两种访问方式而已。u1主要保存了value的类型信息,这也是zval包装多种数据类型的原理,具体来说type说明数据类型是字符串还是数组等等(对应宏IS_STRING,IS_DOUBLE等等…),而type_flags说明数据的属性,比如是否需要引用计数(对应宏IS_TYPE_CONSTANT,IS_TYPE_REFCOUNTED等等)。
  • u2:也是union,它也是4字节,里面的若干字段同一时刻只有1个有效,例如:当zval被保存在哈希表中时,u2.next用于维护哈希表的链表下一节点标识。

接着看一下value,它的类型是zend_value:

zend_value本身就是个union,它同一时刻就保存某一种数据类型的具体值。zval做了一个优化,就是像lval,dval这种简单类型,直接占用union自身内存来存储,而复杂类型例如arr,str才会通过指针指向堆分配的具体数据类型。

直接存储在zval本身的数据类型,例如:zend_long,zend_double是不需要引用计数的,因为zval存储在栈空间,彼此直接浅拷贝即可完成复制,资源也无需释放,因为没有堆内存分配。而对于复杂类型,例如zend_string,zend_array结构复杂,所以作为独立的底层数据类型在堆中分配内存,多个zval引用同一份底层数据,需要维护引用计数,以便保证底层数据生命期不会被提前结束。

在php7中,zval将复杂类型的引用计数保存在底层数据类型的结构体中,而不是放在zval本身,这样的设计更加清晰与易于理解,下面以zend_string,zend_array为例,它们内部都保存了一个叫做gc的引用计数字段:

而gc字段的结构体如下:

这个引用计数的结构体有2个字段,通常我们只会用到refcount字段,它用于维护一个底层数据结构的引用计数。当一个数据对象的引用计数减少为0时,说明资源没有人引用,则可以释放对象的堆内存,意思就是释放zend_string*自身的内存即可。而u字段的type_info实际上等于zval的u1字段,记录的都是底层数据的类型信息。

至于怎么操作zval,zend_string,zend_array等,它们之间通过什么API交互,引用计数怎么管理,内存怎么分配与释放,在下面的例子中会逐一讲解。

在开始示例之前,我最后要特别提到一点就是zend_value是一个union(如果忘记请回头看),而所有的底层数据结构的头部都是zend_refcounted_h。假设当前zend_value保存了str字段,我们知道zend_string的第一个字段是zend_ref_counted_h,所以zend_value这个union里的zend_refcounted *counted与str保存的是同一个地址,这个地址开始的sizeof(zend_refcounted_h)字节就是引用计数对象,对C语言熟悉的朋友应该很容易理解。在这样的设计下,我们可以通过zval.value.counted直接访问到zval.value.str.gc里的引用计数信息,算是一个捷径,巧妙吧。

实例讲解

打开本章代码:https://github.com/owenliang/php7-extension-explore/tree/master/course4-how-the-zval-works

在myext.c中,我按照第3章的方式注册了一个新的PHP函数叫做my_testzval,对应的C实现是zif_testzval。在这个函数中,我们将常见的底层数据类型与zval操作方式尽可能的展示出来,让大家对zval与底层数据结构的关系,引用计数的管理有比较清晰的认识。

下面采用分段讲解,如果需要翻看完整代码,请你打开上面的链接。

PHP7的zval只需要放在栈内存里,一个没有承载任何底层数据的zval类型叫做IS_UNDEF,相当于在PHP中对一个变量执行unset。

ZVAL_UNDEF是初始化这个zval为IS_UNDEF类型,看一下这个宏:

为了方便你加深zval的数据结构理解,进一步展开:

正如在理论环节所说,zval.u1.type_info是4字节整形,保存了数据的各种类型信息(zval.u1.v.type、zval.u1.v.type_flags、zval.u1.v.const_flags),赋值IS_UNDEF即可标明zval的数据类型。

这样就完成了一个简单zval的初始化,至于zval有哪些类型呢?先列举一下,你心里有个数:

IS_NULL类型也是类似的:

上面用的Z_TYPE用于获取zval的类型:

可以看出,数据类型信息是保存在zval.u1.v.type这个字节中的,而数据的属性信息主要保存在zval.u1.v.type_flags中,后面会看到相关用法。

接下来是LONG类型,也就是PHP的整形类型:

ZVAL_LONG这个宏除了设置zval的type信息,还需要将1024这个值保存到zval.value.lval中去:

通过这3个简单类型,我们对zval的真实面目有了更加具体的了解。这些简单类型直接保存在zval自身,所以不需要释放内存,用起来还算比较安心,下面进入复杂类型。


首先从zend_string开始:

首先zval str_zval是一个未经初始化的zval,复杂类型的zval需要底层数据结构作为支撑,所以首先通过zend_string(Zend/zend_string.h)的api来创建一个zend_string对象,传入字符串和字符串长度,最后一个参数是分配临时内存(后面会讲到临时内存和持久内存的意义)。然后,就像ZVAL_LONG一样,将这个zend_string初始化到str_zval上:

这个宏除了给zval设置IS_STRING类型信息外,还把zend_string保存到了zval.value.str字段,仅此而已。zend_string_init返回的zend_string引用计数为1,所以无需额外增加引用计数。

通过上面的断言,判断zval是字符串类型。

通过上面的断言,避免zval底层保存的字符串等于”IS_STRING”,Z_STRVAL_P的实现再简单不过,它取出zval.value.str,它是一个zend_string*,接着取出这个zend_string的val字段,也就是保存字符串内容的内存段,相关的代码如下:

接下来,我对这个保存了zend_string的zval做了3次引用计数操作:

首先,我通过zval_addref_p为其增加了2个引用计数,此时底层zend_string的引用计数是3,这个函数的实现如下:

这个函数首先断言当前的zval保存的底层数据类型是支持引用计数的复杂类型,否则这个调用将失败(对于long,double类型不能使用该方法)。我们知道zval.u1.v.type_flags保存了数据类型的一些属性信息,比如是否支持引用计数(IS_TYPE_REFCOUNTED)。接着就是对引用计数做+1的操作,我们知道zval.value.counted实际上就是对zval.value.str中引用计数对象的直接访问,上面的宏其实就是在做这个事情。

增加这2个引用计数是因为下面的代码中有2处需要用到这个zval,但是当前这段代码对这个zval的使用已经结束,因此调用zval_ptr_dtor来释放这个zval,它的对应实现如下:

如果zval是复杂类型(支持引用计数),并且减少1个引用计数后为0,那么说明没有人持有这个zval,所以可以调用_zval_dtor_func彻底释放这个zval的底层数据对象。

我们知道,zval.value.counted和zval.value.str/arr等共享一个地址,所以在这个释放函数里对p指针做了类型转换,通过switch case释放了对应的资源类型,例如string类型调用了zend_string_free,与zend_string_init相对。

当然,因为当前的str_zval有3个引用计数,所以调用一次只会释放掉1个引用计数,仍旧保留其生命期,并且预留了2个引用计数为后面的代码使用。


接下来看一个不太一样的数据结构:zend_array。

它相当于PHP里的array,既能充当哈希表,有能充当普通数组。当然,今天不是详细讲这个数据结构的实现(后面有必要会单独讲),而是介绍基本用法与引用计数相关的注意点。

zend_array有个别名HashTable。字段dtor_func_t pDestructor对引用计数的管理非常重要,它保存了一个资源销毁函数指针,是本章关注的重点。当你向zend_array覆盖一个value、删除一个value、或者释放zend_array时,它都会将value传给这个函数指针进行销毁,下面会讲到。

在最初,arr_zval是未经初始化的。

接下来分配底层数据对象,这里通过emalloc分配临时内存,所谓临时内存就是从Zend的内存池中分配的,当请求结束时Zend可以得知这个内存是否被归还,如果因为开发者疏漏没有释放,Zend会发出警告错误并帮我们释放掉这个内存,因此它应该只用于分配请求期间的内存,请求结束它就无效了。

之后调用了zend_hash_init初始化这个未经初始化的zend_array:

它等价于_zend_hash_init,我们传入的nSize=0也就是初始数组大小为0,pHashFunction传NULL因为用不到,pDestructor传ZVAL_PTR_DTOR这个宏(稍后看),persistant=0表示这个hash表内部分配内存应该使用临时内存(也就是请求结束就失效)。

那么ZVAL_PTR_DTOR宏是什么呢?

_zval_dtor_func似曾相识,上面的代码做的事情等价于调用一次:zval_ptr_dtor,先对数组里的value减少一个引用计数,如果为0就释放资源。

完成了底层zend_array的初始化,现在将其赋值给zval即可:

接下来,把之前的str_zval更新到这个zend_array中去:

需要传入的就是zend_array,key,key的长度,还有zval。还记得之前已经对str_zval增加过1次额外的引用计数,就是因为这个str_zval要托付给这个zend_array才需要增加计数。

现在,从zend_array中读出这个zval:

没有什么特殊的,它返回的zval指针就是zend_array中保存的value(zval)的地址,这个value底层引用的zend_string与str_zval的底层引用是相同的。

第一个断言确认返回值不是NULL说明找到了这个Key,第二个断言判断它是string类型的zval,第三个断言判定当前zval(zend_string)有2个引用计数,因为它就是str_val的一个副本而已。

接下来:

获取一下zend_array的元素个数,通过Z_ARRVAL_P可以拿到zval.value.arr,就不列举这个宏了。

最后通过zval_ptr_dtor释放这个arr_zval,因为我不再使用它了。因为arr_zval只有初始化时的唯一1个引用计数,所以zend_array将要析构自身,最终将进入这个函数释放array:

该函数内部会遍历所有value,将其传入ZVAL_PTR_DTOR进行资源释放,也就是对zval调用zval_ptr_dtor。因此,在arr_zval销毁后,我们的str_zval的引用计数将减1,还剩余1。


接下来是reference,也就是PHP中的&引用,连续的引用一个变量,对任意一个引用修改都可以反馈给原始变量,这一点在zval中有对应的设计表现。

ref_zval未经初始化,先分配底层数据结构zend_reference:

zend_reference并没有类似zend_hash_init的函数来初始化自身,只能手动处理。主要是设置2个信息,一个是初始化gc的引用计数为1,再就是设置gc内的type=IS_REFERENCE,之前提到过:zend_refcounted_h内也保存了和zval.u1.type_info一样的值,用于垃圾回收时判定数据类型用,了解即可。

接下来,将str_zval拷贝到zend_reference.val上,之前给str_zval额外增加的另外1个引用计数就是为此准备的。

现在,可以将zend_reference对象赋值给zval了:

和之前的其他类型一样,ZVAL_REF将底层数据对象赋值给zval:

现在的ref_zval.value保存了一个zend_reference* ref,而ref里保存了str_zval的副本。所以,ref_zval就是对str_zval的一个引用。

因为下面我还要继续使用ref_zval,所以我给它增加1个额外的引用计数,此时有2个引用计数,然后尝试销毁自身,剩余1个引用计数。


接下来,我定义另一个zval,它也是reference类型,复制自ref_zval,共享同样的底层zend_reference,因为此前ref_zval已经额外增加过1个引用计数,所以这个复制操作只需要这样:

现在ref_ref_zval拥有对zend_reference的唯一引用计数,我决定对底层的zend_reference引用的zval进行一次偷梁换柱:

先取出ref_ref_zval.value.ref.val,也就是zend_reference.val,它其实是str_zval的副本。

然后从这个zval中取出底层的zend_string:

释放掉它最后的1个引用计数,令其析构:

其实现:

然后,重新分配一个zend_string,并赋值到ref_ref_zval.value.ref.val,完成”换柱”:

现在ref_ref_zval的reference引用的zval是一个全新的字符串数据,引用计数为1。同时,ref_ref_zval也只有1个引用计数,我们最后对其进行析构:

这个ref_ref_zval底层的zend_reference引用计数降低为0,内存将被释放,释放前它会对zend_reference.val也进行一次析构,因为这个新字符串zval的底层zend_string引用计数也降为0,所以也被释放。


接下来的例子,告诉我们只有复杂类型可以支持引用计数:

对于double_zval来说,double_zval.u1.v.type_flags中没有IS_REFCOUNTED属性,所以不属于复杂类型,不能使用zval_addref_p/zval_delref_p。


接下来,我们初始化一个string类型的zval:

使用一个方便的宏来完成zval的复制(并非真的副本,而是基于引用计数复制):

这个宏的实现如下:

其实等价于将copy_from通过memcpy拷贝给copy_to,然后判断一下如果是复杂类型,那么引用计数+1。

上面的这段代码等价于下面的这段代码,当然这个Z_REFCOUNTED_P判定在此是多余的,因为我们知道这是一个带引用计数的复杂类型:

因为copy_from,copy_to1,copy_to2都保存了同一份zend_string,所以最终释放3次就会对zend_string资源完成最终的清理:

这里你也许奇怪的是为什么对同一个copy_from做了3次释放,一个调用过zval_ptr_dtor的zval还可以访问吗?其实观察zval_ptr_dtor的实现可知,它并不会修改zval本身的任何信息,仅仅是对其底层的zend_string进行释放,所以对copy_from多次调用zval_ptr_dtor等价于先后为copy_from,copy_to1,copy_to2调用zval_ptr_dtor。

实现真正有意义的函数

在章节3中,我们实现了my_strtolower和my_strtoupper函数,但是因为还没有介绍zval所以没有深入讲解。

现在我们回头看它们的实现就变得很简单了:

这里zend_execute_data是PHP调用函数时的信息,可以简单看一下其数据结构:

获取函数参数是通过zval This获取的,我们知道zval有一个u2字段,这里使用了u2.num_args保存了函数的参数个数:

而真正的参数列表是一个zval数组,它保存在zend_execute_data结构体末尾之后的内存区域中,大概了解一下即可:

所以,ZEND_CALL_ARG(execute_data, 1)就是第一个参数的zval地址,后续的zval连续排列成数组。

接下来将第一个参数传递给自定义的C函数strcase_convert,实现大小写的转换:

首先判断zv是否为字符串类型,如果不是就通过ZVAL_BOOL设置retval为false。

否则首先获取zv的底层zend_string对象叫做raw,通过zend_string_init产生一个相同内容的拷贝zend_string叫做dup,之后遍历dup的字符串内存,逐字节转换大小写,最后通过ZVAL_STR赋值dup给retval这个zval。

赋值给zif返回值return_value的zval要求至少有1个引用计数,在Zend引擎层使用完成后会帮我们释放掉这个计数并试图析构。对于zif函数的参数来说,一定不要直接对其进行修改,因为在开启zend opcache的情况下,传入的参数极有可能被多个php-fpm进程保存到共享内存(不是并发安全的),因此正确的做法是先对函数参数进行拷贝,然后对拷贝的内存进行修改。

结语

本章你应该掌握:

  • zval的数据结构。
  • 常见的底层数据类型的结构。
  • 底层数据结构的引用计数与操作。
  • zval与底层数据结构之间的协作关系。
  • zif(Zend Internal Function)函数的参数处理和返回值处理。

 

如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~