更多优质内容
请关注公众号

PHP常用扩展(一) PHP字节码缓存——Opcache-张柏沛IT博客

正文内容

PHP常用扩展(一) PHP字节码缓存——Opcache

栏目:PHP 系列:PHP常用扩展 发布时间:2022-04-20 09:38 浏览量:54

一、什么是opcache

Opcache是一种通过将PHP脚本预编译的字节码(Operate Code)存放在共享内存中,避免每次请求都要加载和解析PHP脚本,解析器可以直接从共享内存读取已经缓存的字节码(Operate Code)。


二、PHP脚本解释执行流程

1、php初始化执行环节,启动Zend引擎,加载注册的扩展模块;

2、初始化后读取脚本文件,Zend引擎对脚本文件进行词法分析(lex),语法分析(bison),生成语法树;

3、Zend 引擎编译语法树,生成Zend引擎要执行的代码opcode(即操作码);

4、Zend 引擎执行opcode,返回执行结果;

简洁来说就是:加载扩展、解析脚本、编译、执行opcode。


在PHP cli模式下,每次执行PHP脚本,四个步骤都会依次执行一遍;

在PHP-FPM模式下,步骤1在PHP-FPM启动时执行一次,后续的请求中不再执行;步骤2~4每个请求都要执行一遍;


三、基于共享内存

其实如果一个php脚本内容不发生改变,该脚本生成的语法树和opcode 每次请求时都是一样的。opcache模块可以在这种情况下通过将编译后的opcode缓存到一片共享内存,避免相同脚本重复编译,减少CPU和内存开销。缓存好的opcode可以供不同的php进程访问。

除了opcache之外,apc、xcache等扩展也可支持缓存脚本文件编译后的字节码,但是这些扩展大部分已经不再更新,因此官方推荐使用opcache。


四、缓存内容

opcache缓存的内容包括:

- opcode 操作码、字节码

- interned string 预留字符串 (可以理解为php请求生命周期中不需要释放的字符串,包括:变量名、类名、方法名、字符串、注释等)


缓存以key-value的形式存储,key是脚本文件名的真实路径。

例如一个index.php文件中引入了 a.php和b.php,b.php又引入了c.php。

当一个请求到达index.php时,php会分别编译 index.php、a.php、b.php、c.php,每个php文件分别作为一个key-value存储到opcache中。

需要注意的是,即使opcache可以缓存多个php脚本的操作码,避免每次请求都发生编译,这并不意味着这些缓存的脚本文件和.exe一样高效。因为即使消除了编译的开销,每次请求PHP还是需要从缓存获取文件,检查源文件是否被修改,对多个编译后的文件进行链接(如include和require),将用到的类或函数从opcache拷贝到进程的内存空间,这都需要开销。如果想要进一步节省链接和运行过程中从opcache缓存拷贝到php进程内存空间这两个过程,可以使用preloading功能。


五、opcache常见的配置

以下为默认值,阅读opcache的配置可以帮助你了解opcache的行为。

# 启用操作码缓存,默认开启
opcache.enable=1

# 仅针对 CLI 版本的 PHP 启用操作码缓存。 通常被用来测试和调试。
opcache.enable_cli=0

# OPcache 的共享内存大小,以兆字节为单位。
opcache.memory_consumption = 128

# 用来存储预留字符串的内存大小,以兆字节为单位
opcache.interned_strings_buffer=8

# OPcache 哈希表中可存储的脚本文件数量上限。 真实的取值是在质数集合 { 223, 463, 983, 1979, 3907, 7963, 16229, 32531, 65407, 130987 } 中找到的第一个大于等于设置值的质数。 设置值取值范围最小值是 200,最大值为1000000。
opcache.max_accelerated_files=10000

# 浪费内存的上限,以百分比计。 如果达到此上限,那么 OPcache 将产生重新启动续发事件。
opcache.max_wasted_percentage=5

# 如果启用,OPcache 将在哈希表的脚本键之后附加改脚本的工作目录, 以避免同名脚本冲突的问题。 禁用此选项可以提高性能,但是可能会导致应用崩溃。
opcache.use_cwd =1

# 布尔值,OPcache 会每隔 opcache.revalidate_freq 设定的秒数 检查脚本是否更新。 如果禁用此选项,你必须使用 opcache_reset() 或者 opcache_invalidate() 函数来手动重置 OPcache,也可以 通过重启 Web 服务器来使文件系统更改生效。
opcache.validate_timestamps=1

# 检查脚本时间戳是否有更新的周期,以秒为单位。 设置为 0 会导致针对每次请求OPcache 都会检查脚本是否更新。如果 opcache.validate_timestamps 配置指令设置为禁用,那么此设置项将会被忽略。
opcache.revalidate_freq =2

# 如果禁用此选项,在同一个 include_path 已存在的缓存文件会被重用。 因此,将无法找到不在包含路径下的同名文件。
opcache.revalidate_path = 0

# 如果禁用,脚本文件中的注释内容将不会被包含到操作码缓存文件, 这样可以有效减小优化后的文件体积。 禁用此配置指令可能会导致一些依赖注释或注解的 应用或框架无法正常工作, 比如: Doctrine, Zend Framework 2 以及 PHPUnit。
opcache.save_comments =1

# 如果禁用,则即使文件中包含注释,也不会加载这些注释内容。 本选项可以和 opcache.save_comments 一起使用,以实现按需加载注释内容。
opcache.load_comments =1

# 如果启用,则会使用快速停止续发事件。 所谓快速停止续发事件是指依赖 Zend 引擎的内存管理模块 一次释放全部请求变量的内存,而不是依次释放每一个已分配的内存块。从 PHP 7.2.0 开始,此配置指令被移除。 快速停止的续发事件的处理已经集成到 PHP 中, 只要有可能,PHP 会自动处理这些续发事件。
opcache.fast_shutdown =0

# 如果启用,则在调用函数 file_exists(), is_file() 以及 is_readable() 的时候, 都会检查操作码缓存,无论文件是否已经被缓存。 如果应用中包含检查 PHP 脚本存在性和可读性的功能,这样可以提升性能。 但是如果禁用了 opcache.validate_timestamps 选项, 可能存在返回过时数据的风险。
opcache.enable_file_override =0


需要注意:

1、enable_cli:

如果项目框架里有PHP_SAPI === 'cli'类似的判断,请不要开启enable_cli=true。因为开启这个并且设置了file_cache,php-fpm和cli会共用file_cache,如果php-fpm先执行了含有PHP_SAPI === 'cli'的脚本,再用cli方式执行该脚本,那么此时PHP_SAPI的返回并不是’cli’,因为他之前在以fpm模式执行时已经被opcache缓存。


2、opcache.validate_timestamps 和 opcache.revalidate_freq:

每隔若干秒检查1次某个请求到的php脚本是否修改以更新opcache缓存。每次检测都是一次 stat 系统调用,众所周知,系统调用会消耗一些 CPU 时间,并且 stat 系统调用会进行磁盘 I/O,更加浪费性能。

在生产环境中,如果某一次需求迭代更新了大量代码,并且发布到正式环境,可能会出现这样的情况:文件A 的opcache更新了,但是文件B的还没更新,A include B,由于B的opcache没有更新导致报错。

如何解决这个问题:

a. 将opcache.revalidate_freq置为0;每次请求都检查要访问的脚本是否更新。

b. 将 validate_timestamps 置为0,这样即使文件更新,用户访问到的也是缓存中的旧脚本内容。当代码发布到正式环境后,通过http方式手动调用一个含有 opcache_invalidate() 的php脚本重新编译上线的脚本。

c. 将 validate_timestamps 置为0,当新代码发布到正式环境后,平滑重启php-fpm,并运行一个自定义的预执行php脚本将项目中所有php脚本进行 opcache_compile_file()操作,然后才开始接收用户请求。

d. composer安装cachetool清除opcache。


3、max_wasted_percentage:

对于浪费内存我是这样理解的,如果一个文件更新了,opcache中该脚本的缓存就是一个过期缓存,该脚本在opcache中占用的内存就是所谓的“浪费内存”。

如果设置了revalidate_freq和validate_timestamps,并且一次性更新的文件内容和数量太多,就会产生较多的current wasted memory。如果超过了 max_wasted_percentage 的限定值,就会导致opcache被动清空,重新编译和缓存脚本文件。如果在并发量较大的情况下,这会导致系统负载飙升。

如果要避免opcache被动清空,需要做好以下2件事:

a. 配置足够的opcache.memory_consumption和opcache.max_accelerated_files;

b. 合理的代码上线发布策略;


4、memory_consumption:

不合理的发布策略可能会导致实际的脚本缓存内容超过opcache的memory_consumption从而导致opcache内存溢出。

尤其是在使用软链接的发布策略时,具体是指nginx配置项目根目录是一个目录L(是个软链接),它真实指向的是目录A。而代码发布后,新迭代的更新代码不是同步到目录A,而是创建一个新项目目录B,B的代码是最新代码。之后通过shell脚本将软链接目录L指向目录B,于是用户请求就会全部走到目录B。

由于opcache缓存脚本文件是针对文件的真实目录,因此用户请求B的脚本文件时就会将目录B下的脚本也缓存到opcache中。这么一来,opcache中缓存的文件既包含A目录的文件,又包含B目录的文件,导致内存溢出,应用崩溃。

要避免这种情况,要么就不使用软链接的发布策略,要么是设置原先2倍大的memory_consumption。



六、opcache预加载(preloading)

从 PHP 7.4版本开始,可以将PHP配置为在 Zend 引擎启动时将脚本预加载到 opcache 中,使得这些脚本中的任何函数、类、接口或 trait 对所有请求全局可用,而无需显式include即可直接使用。


- opcache预加载的好处和坏处:

使用opcache预加载可以把要预加载的脚本字节码常驻到内存中,使程序性能提升,类库和函数的调用更方便。当然,这是以一定内存作为代价交换的。

修改预加载的脚本不方便,如果想要修改预加载的脚本,必须要重启整个PHP进程树以刷新opcache,因此opcache预加载只适用于生产环境,不适合开发环境。


- opcache的适用场景:

opcache预加载适用于生产环境,不适用于开发环境;

opcache预加载适用于fpm进程这样作为web服务、用来接收用户请求的守护进程,因为这样才会多次访问到相同的php脚本,才能命中缓存;对于用于一次性执行脚本任务的cli进程而言是不适用的(当然,对于使用swoole扩展构建的服务器cli进程池则另当别论)。


- 如何使用opcache的预加载功能:

需要在php.ini中设置 opcache.preload 配置,该配置指定一个自定义的php文件,该php文件需要调用 opcache_compile_file() 函数预加载指定的php脚本文件。

opcache.preload='/var/www/preload.php'

该 preload.php 在服务器启动时(如nginx + PHP-FPM、apache + mod_php 等)会自动执行一次,将preload.php 所include、 include_once、require、require_once或 opcache_compile_file()引用的任何PHP文件编译一遍放入opcache共享内存。


下面是一个 preload.php 的官方示例:

<?php
function _preload($preload, string $pattern = "/\.php$/", array $ignore = []) {
  if (is_array($preload)) {
    foreach ($preload as $path) {
      _preload($path, $pattern, $ignore);
    }
  } else if (is_string($preload)) {
    $path = $preload;
    if (!in_array($path, $ignore)) {
      if (is_dir($path)) {
        if ($dh = opendir($path)) {
          while (($file = readdir($dh)) !== false) {
            if ($file !== "." && $file !== "..") {
              _preload($path . "/" . $file, $pattern, $ignore);
            }
          }
          closedir($dh);
        }
      } else if (is_file($path) && preg_match($pattern, $path)) {
        if (!opcache_compile_file($path)) {
          trigger_error("Preloading Failed", E_USER_ERROR);
        }
      }
    }
  }
}
 
set_include_path(get_include_path() . PATH_SEPARATOR . realpath("/var/www/ZendFramework/library"));
_preload(["/var/www/ZendFramework/library"]);


预加载文件中,include和 opcache_compile_file ()函数指定的文件都能被预加载,但对代码的处理方式有不同的影响。

a、include会执行指定的文件中的代码,而opcache_compile_file()不会。

b、因为include将执行代码,凡是执行到嵌套的 include 文件也将被解析并预加载它们,例如a.php内引入了一个b.php,则用include预加载a.php时也会预加载b.php。

c、opcache_compile_file()可以按任何顺序加载文件。也就是说,如果 a.php定义了 classA而b.php定义 B extends A,那么opcache_compile_file()可以按任意顺序加载这两个文件。但是,当使用include时,必须首先包含a.php 。


因此,哪种方法更好取决于所需的行为。对于使用自动加载器的代码,使用opcache_compile_file()更加灵活。对于手动加载的代码,使用include则更加健壮。

预加载的文件无法用opcache_reset()清除其在opcache中的缓存,如需更新预加载的文件只能重启php服务。

可以通过 opcache_get_status() 的 preload_statistics 查看所有有关预加载函数、类和脚本的信息。


最后需要注意:

在满足继承等依赖关系的情况下,class, funciton, trait, interface能够进行预加载。而预加载文件中的全局变量,define,const无法被预加载。



七、opcache的常见函数

opcache_compile_file()

该函数可以用于在不运行某个 PHP 脚本的情况下,编译该 PHP 脚本并将其添加到字节码缓存中去。 该函数可用于在 Web 服务器重启之后初始化opcache缓存,以供后续请求使用。


opcache_get_configuration()

获取缓存的配置信息


opcache_invalidate()

-调用该函数并不会将某个脚本从缓存移除,而是让该脚本重新编译并更新该脚本的缓存。如果需要删除缓存请使用 opcache_reset(),可以使用opcache_get_status()验证。

-只有存在的脚本文件的缓存才可以被该函数废除,如果某个脚本在opcache有缓存但是后来该脚本文件的路径不存在了(例如你手动删除了该文件),则你无法opcache_invalidate一个被删除的文件。

-调用opcache_invalidate时,会获取 shm 锁。如果并发调用该函数,可能导致一部分调用结果失败。


opcache_reset()

-opcache基于共享内存或者文件存储,而PHP的共享内存只在一个进程家族(即一棵进程树中的所有进程)中才能共享(换句话说,opcache是进程树之间相互隔离的)。

这意味着,fpm进程和cli进程之间不能共享一个opcache缓存,它们的opcache缓存是相互独立的。两个fpm master进程下的fpm worker进程池之间也是两个独立的opcache缓存(例如 终端执行了两次php-fpm命令,一次监听9000,一次监听9001)。

因此我们无法使用一个调用 opcache_reset() 的cli脚本清除一个fpm进程池内的opcache缓存。

这个时候该怎么清除fpm进程中的opcache呢?

可以在某个脚本reset.php调用opcache_reset(),并以fpm进程的形式运行该reset.php脚本,也就是使用http请求该脚本。

在较高并发的情况下,如果使用opcache_reset()可能导致对所有请求涉及的脚本文件,zend都会对其编译和构建缓存,导致系统负载瞬间飙升。

为避免该情况,可以使用 opcache_invalidate() 而非 opcache_reset()。

顺带一提,如果希望cli和fpm这两个进程树共享一个opcache或者跨php进程生命周期使用opcache缓存,可以使用file_cache,即只开启opcache文件缓存(将编译后的opcode缓存到文件中),不使用opcache内存缓存。




更多内容请关注微信公众号
zbpblog微信公众号

如果您需要转载,可以点击下方按钮可以进行复制粘贴;本站博客文章为原创,请转载时注明以下信息

张柏沛IT技术博客 > PHP常用扩展(一) PHP字节码缓存——Opcache

热门推荐
推荐新闻