转载

PHP7革新与性能优化

  有幸参与 2015 年的 PHP 技术峰会(PHPCON),听了鸟哥(惠新宸)的关于 PHP7 的新特性和性能优化的分享,一切都令人感到激动。鸟哥是国内最权威的 PHP 专家,他的分享有很多非常有价值的东西,我通过整理分享的 PPT 和收集相关资料,整理为这篇解读性质的技术文章,希望能给做 PHP 开发的同学一些帮助。

  PHP 已经走过了 20 年的历史,直到今天,PHP7 都发布了 RC 版,据说,PHP7 正式版应该会在 2015 年 11 月份左右发布。PHP7 对于上一个系列的 PHP5.*,可以说是一个大规模的革新,尤其是在性能方面实现跨越式的大幅提升。

  PHP 是一种在全球范围内被广泛使用的 Web 开发语言,PHP7 的革新也当然会给这些 Web 服务带来更深刻的变化。这里引用鸟哥 PPT 中的一个图表(82% 的 Web 站点有使用 PHP 作为开发语言):

PHP7革新与性能优化

  (注:一个 web 站点可以会使用多种语言作为它的开发语言)

  (注:本文含有不少从鸟哥 PPT 里的截图,图片版权归鸟哥所有)

  我们先看看两张激动人心的性能测试结果图:

  Benchmark 对比(图片来自于 PPT):

PHP7革新与性能优化

  PHP7 的性能测试结果,性能压测结果,耗时从 2.991 下降到 1.186,大幅度下降 60%。

  WordPress 的 QPS 压测(图片来自于 PPT):

PHP7革新与性能优化

  而在 WordPress 项目中,PHP7 对比 PHP5.6,QPS 提升 2.77 倍。

  看完令人激动的性能测试结果对比,我们就进入正题哈。PHP7 的新增特性很多,不过,我们会更聚焦于那些主要的变化。

  一、新增特性和改变

  1.  标量类型和返回类型声明(Scalar Type Declarations & Scalar Type Declarations)

  PHP 语言一个非常重要的特点就是“弱类型”,它让 PHP 的程序变得非常容易编写,新手接触 PHP 能够快速上手,不过,它也伴随着一些争议。支持变量类型的定义,可以说是革新性质的变化,PHP 开始以可选的方式支持类型定义。除此之外,还引入了一个开关指令 declare (strict_type=1);,当这个指令一旦开启,将会强制当前文件下的程序遵循严格的函数传参类型和返回类型。

  例如一个 add 函数加上类型定义,可以写成这样:

PHP7革新与性能优化

  如果配合强制类型开关指令,则可以变为这样:

PHP7革新与性能优化

  如果不开启 strict_type,PHP 将会尝试帮你转换成要求的类型,而开启之后,会改变 PHP 就不再做类型转换,类型不匹配就会抛出错误。对于喜欢“强类型”语言的同学来说,这是一大福音。

  更为详细的介绍:PHP7 标量类型声明 RFC[翻译>

  2.  更多的 Error 变为可捕获的 Exception

  PHP7 实现了一个全局的 throwable 接口,原来的 Exception 和部分 Error 都实现了这个接口(interface), 以接口的方式定义了异常的继承结构。于是,PHP7 中更多的 Error 变为可捕获的 Exception 返回给开发者,如果不进行捕获则为 Error,如果捕获就变为一个可在程序内处理的 Exception。这些可被捕获的 Error 通常都是不会对程序造成致命伤害的 Error,例如函数不存。PHP7 进一步方便开发者处理,让开发者对程序的掌控能力更强。因为在默认情况下,Error 会直接导致程序中断,而 PHP7 则提供捕获并且处理的能力,让程序继续执行下去,为程序员提供更灵活的选择。

  例如,执行一个我们不确定是否存在的函数,PHP5 兼容的做法是在函数被调用之前追加的判断 function_exist,而 PHP7 则支持捕获 Exception 的处理方式。

  如下图中的例子(截图来源于 PPT 内):

PHP7革新与性能优化

  3.  AST(Abstract Syntax Tree,抽象语法树)

  AST 在 PHP 编译过程作为一个中间件的角色,替换原来直接从解释器吐出 opcode 的方式,让解释器(parser)和编译器(compliler)解耦,可以减少一些 Hack 代码,同时,让实现更容易理解和可维护。

  PHP 5:

PHP7革新与性能优化

  PHP 7:

PHP7革新与性能优化

  更多 AST 信息:https://wiki.php.net/rfc/abstract_syntax_tree

  4.  Native TLS(Native Thread local storage,原生线程本地存储)

  PHP 在多线程模式下(例如,Web 服务器 Apache 的 woker 和 event 模式,就是多线程),需要解决“线程安全”(TS,Thread Safe)的问题,因为线程是共享进程的内存空间的,所以每个线程本身需要通过某种方式,构建私有的空间来保存自己的私有数据,避免和其他线程相互污染。而 PHP5 采用的方式,就是维护一个全局大数组,为每一个线程分配一份独立的存储空间,线程通过各自拥有的 key 值来访问这个全局数据组。

  而这个独有的 key 值在 PHP5 中需要传递给每一个需要用到全局变量的函数,PHP7 认为这种传递的方式并不友好,并且存在一些问题。因而,尝试采用一个全局的线程特定变量来保存这个 key 值。

  相关的 Native TLS 问题:https://wiki.php.net/rfc/native-tls

  5.  其他新特性

  PHP 7 新特性和变化不少,我们这里并不全部展开来细说哈。

  1. Int64 支持,统一不同平台下的整型长度,字符串和文件上传都支持大于 2GB。
  2. 统一变量语法(Uniform variable syntax)。
  3. foreach 表现行为一致(Consistently foreach behaviors)
  4. 新的操作符 <=>, ??
  5. Unicode 字符格式支持(/u{xxxxx})
  6. 匿名类支持(Anonymous Class)

  ……

  二、跨越式的性能突破:全速前进

  1.  JIT 与性能

  Just In Time(即时编译)是一种软件优化技术,指在运行时才会去编译字节码为机器码。从直觉出发,我们都很容易认为,机器码是计算机能够直接识别和执行的,比起 Zend 读取 opcode 逐条执行效率会更高。其中,HHVM(HipHop Virtual Machine,HHVM 是一个 Facebook 开源的 PHP 虚拟机)就采用 JIT,让他们的 PHP 性能测试提升了一个数量级,放出一个令人震惊的测试结果,也让我们直观地认为 JIT 是一项点石成金的强大技术。

  而实际上,在 2013 年的时候,鸟哥和 Dmitry(PHP 语言内核开发者之一)就曾经在 PHP5.5 的版本上做过一个 JIT 的尝试(并没有发布)。PHP5.5 的原来的执行流程,是将 PHP 代码通过词法和语法分析,编译成 opcode 字节码(格式和汇编有点像),然后,Zend 引擎读取这些 opcode 指令,逐条解析执行。

PHP7革新与性能优化

  而他们在 opcode 环节后引入了类型推断(TypeInf),然后通过 JIT 生成 ByteCodes,然后再执行。

PHP7革新与性能优化

  于是,在 benchmark(测试程序)中得到令人兴奋的结果,实现 JIT 后性能比 PHP5.5 提升了 8 倍。然而,当他们把这个优化放入到实际的项目 WordPress(一个开源博客项目)中,却几乎看不见性能的提升,得到了一个令人费解的测试结果。

  于是,他们使用 Linux 下的 profile 类型工具,对程序执行进行 CPU 耗时占用分析。

  执行 100 次 WordPress 的 CPU 消耗的分布(截图来自 PPT):

PHP7革新与性能优化

  注解:

  21%CPU 时间花费在内存管理。

  12%CPU 时间花费在 hash table 操作,主要是 PHP 数组的增删改查。

  30%CPU 时间花费在内置函数,例如 strlen。

  25%CPU 时间花费在 VM(Zend 引擎)。

  经过分析之后,得到了两个结论:

  (1)JIT 生成的 ByteCodes 如果太大,会引起 CPU 缓存命中率下降(CPU Cache Miss)

  在 PHP5.5 的代码里,因为并没有明显类型定义,只能靠类型推断。尽可能将可以推断出来的变量类型,定义出来,然后,结合类型推断,将非该类型的分支代码去掉,生成直接可执行的机器码。然而,类型推断不能推断出全部类型,在 WordPress 中,能够推断出来的类型信息只有不到 30%,能够减少的分支代码有限。导致 JIT 以后,直接生成机器码,生成的 ByteCodes 太大,最终引起 CPU 缓存命中大幅度下降(CPU Cache Miss)。

  CPU 缓存命中是指,CPU 在读取并执行指令的过程中,如果需要的数据在 CPU 一级缓存(L1)中读取不到,就不得不往下继续寻找,一直到二级缓存(L2)和三级缓存(L3),最终会尝试到内存区域里寻找所需要的指令数据,而内存和 CPU 缓存之间的读取耗时差距可以达到 100 倍级别。所以,ByteCodes 如果过大,执行指令数量过多,导致多级缓存无法容纳如此之多的数据,部分指令将不得不被存放到内存区域。

PHP7革新与性能优化

  CPU 的各级缓存的大小也是有限的,下图是 Intel i7 920 的配置信息:

PHP7革新与性能优化

  因此,CPU 缓存命中率下降会带来严重的耗时增加,另一方面,JIT 带来的性能提升,也被它所抵消掉了。

  通过 JIT,可以降低 VM 的开销,同时,通过指令优化,可以间接降低内存管理的开发,因为可以减少内存分配的次数。然而,对于真实的 WordPress 项目来说,CPU 耗时只有 25% 在 VM 上,主要的问题和瓶颈实际上并不在 VM 上。因此,JIT 的优化计划,最后没有被列入该版本的 PHP7 特性中。不过,它很可能会在更后面的版本中实现,这点也非常值得我们期待哈。

  (2)JIT 性能的提升效果取决于项目的实际瓶颈

  JIT 在 benchmark 中有大幅度的提升,是因为代码量比较少,最终生成的 ByteCodes 也比较小,同时主要的开销是在 VM 中。而应用在 WordPress 实际项目中并没有明显的性能提升,原因 WordPress 的代码量要比 benchmark 大得多,虽然 JIT 降低了 VM 的开销,但是因为 ByteCodes 太大而又引起 CPU 缓存命中下降和额外的内存开销,最终变成没有提升。

  不同类型的项目会有不同的 CPU 开销比例,也会得到不同的结果,脱离实际项目的性能测试,并不具有很好的代表性。

  2.  Zval 的改变

  PHP 的各种类型的变量,其实,真正存储的载体就是 Zval,它特点是海纳百川,有容乃大。从本质上看,它是C语言实现的一个结构体(struct)。对于写 PHP 的同学,可以将它粗略理解为是一个类似 array 数组的东西。

  PHP5 的 Zval,内存占据 24 个字节(截图来自 PPT):

PHP7革新与性能优化

  PHP7 的 Zval,内存占据 16 个字节(截图来自 PPT):

PHP7革新与性能优化

  Zval 从 24 个字节下降到 16 个字节,为什么会下降呢,这里需要补一点点的C语言基础,辅助不熟悉C的同学理解。struct 和 union(联合体)有点不同,Struct 的每一个成员变量要各自占据一块独立的内存空间,而 union 里的成员变量是共用一块内存空间(也就是说修改其中一个成员变量,公有空间就被修改了,其他成员变量的记录也就没有了)。因此,虽然成员变量看起来多了不少,但是实际占据的内存空间却下降了。

  除此之外,还有被明显改变的特性,部分简单类型不再使用引用。

  Zval 结构图(来源于 PPT 中):

PHP7革新与性能优化

  图中 Zval 的由 2 个 64bits(1 字节=8bit,bit 是“位”)组成,如果变量类型是 long、bealoon 这些长度不超过 64bit 的,则直接存储到 value 中,就没有下面的引用了。当变量类型是 array、objec、string 等超过 64bit 的,value 存储的就是一个指针,指向真实的存储结构地址。

  对于简单的变量类型来说,Zval 的存储变得非常简单和高效。

  不需要引用的类型:NULL、Boolean、Long、Double

  需要引用的类型:String、Array、Object、Resource、Reference

  3.  内部类型 zend_string

  Zend_string 是实际存储字符串的结构体,实际的内容会存储在 val(char,字符型)中,而 val 是一个 char 数组,长度为1(方便成员变量占位)。

PHP7革新与性能优化

  结构体最后一个成员变量采用 char 数组,而不是使用 char*,这里有一个小优化技巧,可以降低 CPU 的 cache miss。

  如果使用 char 数组,当 malloc 申请上述结构体内存,是申请在同一片区域的,通常是长度是 sizeof (_zend_string) + 实际 char 存储空间。但是,如果使用 char*,那个这个位置存储的只是一个指针,真实的存储又在另外一片独立的内存区域内。

  使用 char[1>和 char*的内存分配对比:

PHP7革新与性能优化

  从逻辑实现的角度来看,两者其实也没有多大区别,效果很类似。而实际上,当这些内存块被载入到 CPU 的中,就显得非常不一样。前者因为是连续分配在一起的同一块内存,在 CPU 读取时,通常都可以一同获得(因为会在同一级缓存中)。而后者,因为是两块内存的数据,CPU 读取第一块内存的时候,很可能第二块内存数据不在同一级缓存中,使 CPU 不得不往 L2(二级缓存)以下寻找,甚至到内存区域查到想要的第二块内存数据。这里就会引起 CPU Cache Miss,而两者的耗时最高可以相差 100 倍。

  另外,在字符串复制的时候,采用引用赋值,zend_string 可以避免的内存拷贝。

  4.  PHP 数组的变化(HashTable 和 Zend Array)

  在编写 PHP 程序过程中,使用最频繁的类型莫过于数组,PHP5 的数组采用 HashTable 实现。如果用比较粗略的概括方式来说,它算是一个支持双向链表的 HashTable,不仅支持通过数组的 key 来做 hash 映射访问元素,也能通过 foreach 以访问双向链表的方式遍历数组元素。

  PHP5 的 HashTable(截图来自于 PPT):

PHP7革新与性能优化

  这个图看起来很复杂,各种指针跳来跳去,当我们通过 key 值访问一个元素内容的时候,有时需要 3 次的指针跳跃才能找对需要的内容。而最重要的一点,就在于这些数组元素存储,都是分散在各个不同的内存区域的。同理可得,在 CPU 读取的时候,因为它们就很可能不在同一级缓存中,会导致 CPU 不得不到下级缓存甚至内存区域查找,也就是引起 CPU 缓存命中下降,进而增加更多的耗时。

  PHP7 的 Zend Array(截图来源于 PPT):

PHP7革新与性能优化

  新版本的数组结构,非常简洁,让人眼前一亮。最大的特点是,整块的数组元素和 hash 映射表全部连接在一起,被分配在同一块内存内。如果是遍历一个整型的简单类型数组,效率会非常快,因为,数组元素(Bucket)本身是连续分配在同一块内存里,并且,数组元素的 zval 会把整型元素存储在内部,也不再有指针外链,全部数据都存储在当前内存区域内。当然,最重要的是,它能够避免 CPU Cache Miss(CPU 缓存命中率下降)。

  Zend Array 的变化:

  1. 数组的 value 默认为 zval。
  2. HashTable 的大小从 72 下降到 56 字节,减少 22%。
  3. Buckets 的大小从 72 下降到 32 字节,减少 50%。
  4. 数组元素的 Buckets 的内存空间是一同分配的。
  5. 数组元素的 key(Bucket.key)指向 zend_string。
  6. 数组元素的 value 被嵌入到 Bucket 中。
  7. 降低 CPU Cache Miss。

  5.  函数调用机制(Function Calling Convention)

  PHP7 改进了函数的调用机制,通过优化参数传递的环节,减少了一些指令,提高执行效率。

  PHP5 的函数调用机制(截图来自于 PPT):

PHP7革新与性能优化

  图中,在 vm 栈中的指令 send_val 和 recv 参数的指令是相同,PHP7 通过减少这两条重复,来达到对函数调用机制的底层优化。

  PHP7 的函数调用机制(截图来自于 PPT):

PHP7革新与性能优化

  6.  通过宏定义和内联函数(inline),让编译器提前完成部分工作

  C 语言的宏定义会被在预处理阶段(编译阶段)执行,提前将部分工作完成,无需在程序运行时分配内存,能够实现类似函数的功能,却没有函数调用的压栈、弹栈开销,效率会比较高。内联函数也类似,在预处理阶段,将程序中的函数替换为函数体,真实运行的程序执行到这里,就不会产生函数调用的开销。

  PHP7 在这方面做了不少的优化,将不少需要在运行阶段要执行的工作,放到了编译阶段。例如参数类型的判断(Parameters Parsing),因为这里涉及的都是固定的字符常量,因此,可以放到到编译阶段来完成,进而提升后续的执行效率。

  例如下图中处理传递参数类型的方式,从左边的写法,优化为右边宏的写法。

PHP7革新与性能优化

  三、小结

  鸟哥的 PPT 里放出过一组对比数据,就是 WordPress 在 PHP5.6 执行 100 次会产生 70 亿次的 CPU 指令执行数目,而在 PHP7 中只需要 25 亿次,减少 64.2%,这是一个令人震撼的数据。

  在鸟哥的整个分享中,给我最深刻的一个观点是:要注意细节,很多个细小的优化,一点点持续地积累,积少成多,最终汇聚为惊艳的成果。为山九仞,岂一日之功,我想大概也是这个道理。

  毫无疑问,PHP7 在性能方面实现跨越式的提升,如果能够将这些成果应用在 PHP 的 Web 系统中,也许我们只需要更少的机器,就可以支撑起更高请求量的服务。PHP7 正式版的发布,令人充满无限憧憬。

  参考&引用资料:鸟哥(惠新宸)的分享 PPT,http://www.laruence.com/,http://php.net/

致谢:感谢鸟哥(惠新宸)提供的帮助与支持。

作者介绍:徐汉彬曾在阿里巴巴和腾讯从事 4 年多的技术研发工作,负责过日请求量过亿的 Web 系统升级与重构,目前在小满科技创业,从事 SaaS 服务技术建设。 

正文到此结束
Loading...