您好,欢迎来到三六零分类信息网!老站,搜索引擎当天收录,欢迎发信息
免费发信息

一起学习PHP7内核之HashTable

2024/4/16 19:16:17发布5次查看
之前的俩篇文章深入理解php7内核之zval 和深入理解php7内核之reference, 我介绍了当时在开发php7的时候对zval和reference的一些改造思考和结果, 之后因为确实精力有限就没有继续往下写,时隔一年多以后,因为这场突如其来的疫情,在家办公的时间很多, 于是终于有了时间让我来继续介绍一下php7的中hashtable的变化, 以及当时我们做这些变化背后的考量.
php5
对于php内核一直有关注的同学, 应该对php5的hashtable会比较熟悉, 但我们还是先来简单回顾一下php5的hashtable:
在php5的实现中, hashtable的核心是存储了一个个指向zval指针的指针, 也就是zval**(我遇到不少的同学问为什么是zval**, 而不是zval*, 这个原因其实很简单, 因为hashtable中的多个位置都可能指向同一个zval, 那么最常见的一个可能就是在cow的时候, 当我们需要把一个变量指向一个新的zval的时候, 如果在符号表中存的是zval*, 那们我们就做不到对一处修改, 所有的持有方都有感知, 所以必须是zval**), 这样的设计在最初的出发点是为了让hashtable可以存储任何尺寸的任何信息, 不仅仅是指针, 还可以存储一段内存值(当然实际上大部分情况下,比如符号表还是存的zval的指针).
php5的代码中也用了比较hack的方式来判断存储的是什么:
#define update_data(ht, p, pdata, ndatasize) if (ndatasize == sizeof(void*)) { if ((p)->pdata != &(p)->pdataptr) { pefree_rel((p)->pdata, (ht)->persistent); } memcpy(&(p)->pdataptr, pdata, sizeof(void *)); (p)->pdata = &(p)->pdataptr; } else { if ((p)->pdata == &(p)->pdataptr) { (p)->pdata = (void *) pemalloc_rel(ndatasize, (ht)->persistent); (p)->pdataptr=null; } else { (p)->pdata = (void *) perealloc_rel((p)->pdata, ndatasize, (ht)->persistent); \ /* (p)->pdataptr is already null so no need to initialize it */ \ } memcpy((p)->pdata, pdata, ndatasize); }
它来判断存储的size是不是一个指针大小, 从而采用不同的方式来更新存储的内容。非常hack的方式。
php5的hashtable对于每一个bucket都是分开申请释放的。
而存储在hashtable中的数据是也会通过plistnext指针串成一个list,可以直接遍历,关于这块可以参看我很早的一篇文章深入理解php之数组
问题在写php7的时候,我们详细思考了几点可能优化的点,包括也从性能角度总结了以下目前这种实现的几个问题:
hashtable在php中,应用最多的是保存各种zval, 而php5的hashtable设计的太通用,可以设计为专门为了存储zval而优化, 从而能减少内存占用。2. 缓存局部性问题, 因为php5的hashtable的bucket,包括zval都是独立分配的,并且采用了list来串hashtable中的所有元素,会导致在遍历或者顺序访问一个数组的时候,缓存不友好。
比如如图所示在php代码中常见的foreach一个数组, 就会发生多次的内存跳跃.3. 和1类似,在php5的hashtable中,要访问一个zval,因为是zval**, 那需要至少解指针俩次,一方面是缓存不友好,另外一方面也是效率低下。
比如上图中,蓝色框的部分,我们找到数组中的bucket以后,还需要解开zval**,才可以读取到实际的zval的内容。 也就是需要发生俩次内存读取。效率低下。当然还有很多的其他的问题,此处不再赘述,说实在的毕竟俩年多了,当时怎么想的,现在有些也想不起来了, 现在我们来看看php7的
php7首先在php7中,我们当时的考虑是可能因为担心hashtable用的太多,我们新设计的结构体可能不一定能cover所有的场景,于是我们新定义了一个结构体叫做zend_array, 当然最后经过一系列的努力,发现zend_array可以完全替代hashtable, 最后还是保留了hashtable和zend_array俩个名字,只不过互为alias.
再下面的文章中,我会用hashtable来特指php5中的hashtable,而用zend_array来指代php7中的hashtable.
我们先来看看zend_array的定义:
struct _zend_array { zend_refcounted_h gc; union { struct { zend_endian_lohi_4( zend_uchar flags, zend_uchar _unused, zend_uchar niteratorscount, zend_uchar _unused2) } v; uint32_t flags; } u; uint32_t ntablemask; bucket *ardata; uint32_t nnumused; uint32_t nnumofelements; uint32_t ntablesize; uint32_t ninternalpointer; zend_long nnextfreeelement; dtor_func_t pdestructor;};
相比php5时代的hashtable,zend_array的内存占用从php5点72个字节,降低到了56个字节,想想一个php生命进程中成千上万的数组,内存降低明显。
此处再次特别说明下上面zend_array定义中的zend_endian_loht_4这个作用,这个是为了解决大小端问题,在大端小端上都能让其中的元素保证同样的内存存储顺序,可以方便我们写出通用的位操作。 在php7中,位操作应用的很多,因为这样一来一个字节就可以保存8个状态位, 很节省内存:)
#ifdef words_bigendian# define zend_endian_lohi_4(a, b, c, d) d; c; b; a;#else# define zend_endian_lohi_4(a, b, c, d) a; b; c; d;#endif
而数据会核心保存在ardata中,ardata是一个bucket数组,bucket定义:
typedef struct _bucket { zval val; zend_ulong h; /* hash value (or numeric index) */ zend_string *key; /* string key or null for numerics */} bucket
再对比看下php5多bucket:
typedef struct bucket { ulong h; /* used for numeric indexing */ uint nkeylength; void *pdata; void *pdataptr; struct bucket *plistnext; struct bucket *plistlast; struct bucket *pnext; struct bucket *plast; const char *arkey;} bucket;
内存占用从72字节,降低到了32个字节,想想一个php进程中几十万的数组元素,这个内存降低就更明显了.
对比的看,
现在的冲突拉链被bauck.zval->u2.next替代, 于是bucket->pnext, bucket->plast可以去掉了。zend_array->ardata是一个数组,所以也就不再需要plistnext, plistlast来保持顺序了, 他们也可以去掉了。 现在数组中元素的先后顺序,完全根据它在ardata中的index顺序决定,先加入的元素在低的index中。php7中的bucket现在直接保存一个zval, 取代了php5时代bucket中的pdata和pdataptr。最后就是php7中现在使用zend_string作为数组的字符串key,取代了php5时代bucket的*key, nkeylength.现在我们来整体看下zend_array的组织图:
回顾下深入理解php7内核之zval, 现在的zend_array就可以应付各种场景下的hashtable的作用了。
特别说明对是除了一个问题就是之前提到过的is_indirect, 不知道大家是否还记得. 上一篇我说过原来hashtable为什么要设计保存zval**, 那么现在因为_bucket直接保存的是zval了,那怎么解决cow的时候一处修改多处可见的需求呢? is_indirect就应用而生了,is_indirect类型其实可以理解为就是一个zval*的结构体。它被广泛应用在globals,properties等多个需要俩个hashtable指向同于一个zval的场景。
另外,对于原来一些扩展会使用hashtable来保存一些自己的内存,现在可以通过is_ptr这种zval类型来实现。
现在ardata因为是一个连续的数组了,那么当foreach的时候,就可以顺序访问一块连续的内存,而现在zval直接保存在bucket中,那么在绝大部分情况下(不需要外部指针的内容,比如long,bool之类的)就可以不需要任何额外的zval指针解引用了,缓存局部性友好,性能提升非常明显。
还有就是php5的时代,查找数组元素的时候,因为传递进来的是char *key,所有需要每次查找都计算key的hash值,而现在查找的时候传递进来的key是zend_string, hash值不需要重新计算,此处也有部分性能提升。
zend_api zval* zend_fastcall zend_hash_find(const hashtable *ht, zend_string *key);zend_api zval* zend_fastcall zend_hash_str_find(const hashtable *ht, const char *key, size_t len);zend_api zval* zend_fastcall zend_hash_index_find(const hashtable *ht, zend_ulong h);zend_api zval* zend_fastcall _zend_hash_index_find(const hashtable *ht, zend_ulong h);
当然,php7也保留了直接通过char* 查找的zend_hash_str_find api,这对于一些只有char*的场景,可以避免生成zend_string的内存开销,也是很有用的。
另外,我们还做了不少进一步的优化:
packed array对于字符串key的数组来说, zend_array在arhash中保存了hash值到ardata的对应,有同学可能会好奇怎么没有在zend_array中看到arhash? 这是因为arhash和ardata是一次分配的:
hashtable data layout===================== +=============================+pointer->| ht_hash(ht, ht->ntablemask) | | ... | | ht_hash(ht, -1) | +-----------------------------+ardata ->| bucket[0] | | ... | | bucket[ht->ntablesize-1] | +=============================+
如图,事实上ardata是一块分配的内存的中间部分,分配的内存真正的起始位置其实是pointer,而ardata是计算过的一处中间位置,这样就可以用一个指针来表达俩个位置,分别通过前后偏移来获取位置, 比如-1对应的是arhash[0], 这个技巧在php7的过程中我们也大量应用了,比如因为zend_object是变长的,所以不能在他后面有其他元素,为了实现一些自定义的object,那么我们会在zend_object前面分配自定义的元素等等。
而对于全部是数字key的数组,arhash就显得没那么必要了,所以此时我们就用了一种新的数组, packed array来优化这个场景。
对于hash_flag_packed的数组(标志在zend_array->u.flags)中,它们是只有连续数字key的数组,它们不需要hash值来映射,所以这样的数组读取的时候,就相当于你直接访问c数组,直接根据偏移来获取zval.
<?phpecho "packed array:\n";$begin = memory_get_usage();$array = range(0, 10000);echo "memory: ", memory_get_usage() - $begin, " bytes\n";$begin = memory_get_usage();$array[10001] = 1;echo "memory increased: ", memory_get_usage() - $begin, " bytes\n"; $start = microtime(true);for ($i = 0; $i < 10000; $i++) { $array[$i];}echo "time: ", (microtime(true) - $start) * 1000 , " ms\n"; unset($array); echo "\nmixed array:\n";$begin = memory_get_usage();$array = range(0, 10000);echo "memory: ", memory_get_usage() - $begin, " bytes\n";$begin = memory_get_usage();$array["foo"] = 1;echo "memory increased: ", memory_get_usage() - $begin, " bytes\n"; $start = microtime(true);for ($i = 0; $i < 10000; $i++) { $array[$i];}echo "time: ", (microtime(true) - $start) * 1000 ," ms\n";
如图所示的简单测试,在我的机器上输出如下(请注意,这个测试的部分结果可能会受你的机器,包括装了什么扩展相关,所以记得要-n):
$ /home/huixinchen/local/php74/bin/php -n /tmp/1.phppacked array:memory: 528480 bytesmemory increased: 0 bytestime: 0.49519538879395 ms mixed array:memory: 528480 bytesmemory increased: 131072 bytestime: 0.63300132751465 ms
可以看到, 当我们使用$array[“foo”]=1, 强迫一个数组从packed array变成一个mixed array以后,内存增长很明显,这部分是因为需要为10000个arhash分配内存。
而通过index遍历的耗时,packed array仅仅是mixed array的78%。
static key array对于字符串array来说, destructor的时候是需要释放字符串key的, 数组copy的时候也要增加key的计数,但是如果所有的key都是interned字符串,那事实上我们不需要管这些了。于是就有了这个hash_flag_static_keys。
empty array我们分析发现,在实际使用中,有大量的空数组,针对这些, 我们在初始化数组的时候,如果不特殊申明,默认是不会分配ardata的,此时会把数组标志为hash_flag_uninitialized, 只有当发生实际的写入的时候,才会分配ardata。
immutable array类似于interned string,php7中我们也引入了一种imuutable array, 标志在array->gc.flags中的is_array_immutable, 大家可以理解为不可更改的数组,对于这种数组,不会发生cow,不需要计数,这个也会极大的提高这种数据的操作性能,我的yaconf中也大量应用了这种数据特性。
simd后来的php7的版本中,我实现了一套simd指令集优化的框架,比如simd的base64_encode, 而在hashtable的初始化中,我们也应用了部分这样的指令集(此处应用虽然很微小,但有必要提一下):
ifdef __sse2__ do { __m128i xmm0 = _mm_setzero_si128(); xmm0 = _mm_cmpeq_epi8(xmm0, xmm0); _mm_storeu_si128((__m128i*)&ht_hash_ex(data, 0), xmm0); _mm_storeu_si128((__m128i*)&ht_hash_ex(data, 4), xmm0); _mm_storeu_si128((__m128i*)&ht_hash_ex(data, 8), xmm0); _mm_storeu_si128((__m128i*)&ht_hash_ex(data, 12), xmm0); } while (0);#else ht_hash_ex(data, 0) = -1; ht_hash_ex(data, 1) = -1; ht_hash_ex(data, 2) = -1; ht_hash_ex(data, 3) = -1; ht_hash_ex(data, 4) = -1; ht_hash_ex(data, 5) = -1; ht_hash_ex(data, 6) = -1; ht_hash_ex(data, 7) = -1; ht_hash_ex(data, 8) = -1; ht_hash_ex(data, 9) = -1; ht_hash_ex(data, 10) = -1; ht_hash_ex(data, 11) = -1; ht_hash_ex(data, 12) = -1; ht_hash_ex(data, 13) = -1; ht_hash_ex(data, 14) = -1; ht_hash_ex(data, 15) = -1;#endif
存在的问题在实现zend_array替换hashtable中我们遇到了很多的问题,绝大部份它们都被解决了,但遗留了一个问题,因为现在ardata是连续分配的,那么当数组增长大小到需要扩容到时候,我们只能重新realloc内存,但系统并不保证你realloc以后,地址不会发生变化,那么就有可能:
<?php$array = range(0, 7); set_error_handler(function($err, $msg) { global $array; $array[] = 1; //force resize;}); function crash() { global $array; $array[0] += $var; //undefined notice} crash();
比如上面的例子, 首先是一个全局数组,然后在函数crash中, 在+= opcode handler中,zend vm会首先获取array[0]的内容,然后+$var, 但var是undefined variable, 所以此时会触发一个未定义变量的notice,而同时我们设置了error_handler, 在其中我们给这个数组增加了一个元素, 因为php中的数组按照2^n的空间预先申请,此时数组满了,需要resize,于是发生了realloc,从error_handler返回以后,array[0]指向的内存就可能发生了变化,此时会出现内存读写错误,甚至segfault,有兴趣的同学,可以尝试用valgrind跑这个例子看看。
但这个问题的触发条件比较多,修复需要额外对数据结构,或者需要拆分add_assign对性能会有影响,另外绝大部分情况下因为数组的预先分配策略存在,以及其他大部分多opcode handler读写操作基本都很临近,这个问题其实很难被实际代码触发,所以这个问题一直悬停着。
推荐教程:《php教程》
以上就是一起学习php7内核之hashtable的详细内容。
该用户其它信息

VIP推荐

免费发布信息,免费发布B2B信息网站平台 - 三六零分类信息网 沪ICP备09012988号-2
企业名录