您的位置 首页 php

php opcache 的工作原理

Opcache PHP 扩展实现了各种功能,以透明的方式加速 PHP。顾名思义,它的起源和主要目的是 操作码 缓存,但现在它还包含一个优化器和即时编译器。然而,这篇博文将只关注操作码缓存方面。

opcache 有三层缓存:原始共享内存缓存、PHP 7 中引入的文件缓存以及 PHP 7.4 中添加的预加载功能。我们将依次讨论所有这些。

虽然 opcache 名义上是一个独立的扩展,但它的功能紧密依赖于引擎的实现细节,并且对引擎的修改通常也需要对 opcache 进行更改。因此,opcache 的工作方式在 PHP 版本之间存在显着差异。本文描述了 PHP 8.1 的状态,并重点介绍了该版本中的一些变化。

共享内存

opcache 的主要目的是将编译工件缓存在共享内存中,以避免每次执行时都需要重新编译 PHP 脚本。

在类 Unix 系统上,在启动时分配一个固定大小的共享内存 (SHM) 段。为了处理请求,PHP 将派生额外的进程或产生额外的 线程 。这些进程/线程将在同一地址看到 SHM 段。

由于 Windows 不支持fork,因此通常会生成完全独立的 PHP 进程,它们没有任何共享地址空间。这对 opcache 来说是个大问题,因为它要求 SHM 段在每个进程中都映射到同一个地址。否则,指向 SHM 的指针将在进程间无效。

为了使这项工作,opcache 存储 SHM 基地址,并尝试将段映射到其他进程中的相同地址。如果这失败了,opcache 会回退到使用文件缓存。但是,即使成功,也有局限性:虽然这保证了 SHM 段的地址相同,但由于 ASLR,内部函数/类的地址可能在进程之间有所不同。这意味着在 Windows 上,缓存的工件不可能依赖于内部函数/类等。

Windows 是唯一一个两个不相关的 PHP 进程可以共享同一个 opcache SHM 的平台。例如,两个并发的 CLI 调用可以共享同一个缓存,这在其他操作系统上是不可能的。在这种情况下,存在该 opcache.cache_id 设置以强制使用不同的缓存。

因为为 Windows 维护单独的行为是一件很痛苦的事情,所以 opcache 将来可能会放弃对不相关进程重新附加的支持,这意味着在 Windows 上,将需要使用基于线程而不是基于进程的 SAPI。

锁定和不变性

当共享内存发挥作用时,考虑您的访问模型总是很重要的。由于我们不想在运行时执行任何细粒度的锁定操作或原子引用计数,因此 opcache 的内存模型最终非常简单:共享内存是不可变的。

Opcache 本质上只有两个锁:一个是写锁,它只能由一个允许修改 SHM 的进程持有。在持有写锁的同时,其他进程仍然可以读取 SHM。因此,持有写锁通常只允许您在 SHM 段中分配新内存并对其进行写入,但不能修改已分配和可能使用的共享内存(有一些例外)。

opcache.protect_memory 选项可用于在未持有写锁时保护整个 SHM 段,这对于检测违反不变性不变量的情况很有用(但出于性能原因不应在生产中启用)。

另一个锁是在请求第一次使用 SHM 时获取的读锁。它不会跟踪正在使用的内容以及是否停止使用。唯一的目的是记录在这个请求中 以某种方式使用了缓存。

这个锁的目的是促进 opcache 重新启动:因为我们不跟踪缓存的哪些部分正在以细粒度的方式使用,所以不可能从 opcode 缓存中删除任何内容。当缓存运行满时,将安排重新启动。

如果安排了重新启动,那么新启动的请求将不会使用 SHM 缓存(但可能会回退到文件缓存)。当用户数降为零时,整个缓存被清除,我们可以从头开始。如果用户数在 内没有降为零 opcache.force_restart_timeout ,则 opcache 将杀死剩余的用户。

映射指针

存储在 SHM 缓存中的一些结构需要(或至少想要)引用每个请求的数据。例如,虽然函数定义通常是不可变的,但它可能包含 静态变量 ,这些变量对于每个请求都是不同的。同样,函数使用运行时缓存来缓存特定于请求的符号解析。

由于我们无法将每个请求的信息存储在不可变的共享内存缓存中,因此我们使用“映射指针”间接寻址。我们不是存储指向静态变量的指针,而是存储对静态变量存储位置的引用。

在当前的实现中,映射指针采用以下两种形式之一:要么是指向实际存储的指针的普通指针,这是当结构未缓存在 SHM 中时使用的表示。间接指针通常是竞技场分配的。

或者,映射指针仅存储与基地址的偏移量,其中每个请求的基地址将不同。这是用于共享内存中不可变结构的表示。我们跟踪使用的映射指针区域需要多大,并在每次请求时将其归零。

 For mutable memory: map_ptr & 1 == 0

map pointer ----> indirection pointer ----->  static  variables
                  (arena allocated)


For immutable memory: map_ptr & 1 == 1

map base pointer: slot 0
                  slot 1
    + map offset: slot 2 -----> static variables
                  slot 3
  

虽然很清楚为什么我们在第二种情况下需要间接(每个请求的单独映射指针区域),但人们可能想知道在第一种情况下间接指针的目的是什么:由于内存是可变的,我们可以存储静态变量直接指针。这确实只是一个历史产物,不必要的间接性可能会在 PHP 8.2 中消失。

内部 字符串

在这一点上,让我们先简短地讨论一下实习字符串。PHP 中的字符串表示为一个引用计数结构,它存储字符串长度、其内容和哈希值。虽然字符串可以共享,但如果它们是独立创建的,也可能有多个具有相同内容的字符串。

内部字符串被 重复数据删除 :只有一个具有给定内容的内部字符串。这样可以节省内存并且可以使比较更有效,因为指针相等快速路径更容易触发。由于没有被引用计数,PHP 中的实习字符串也是不可变的。

如果没有 opcache,PHP 会将实习字符串分为持久字符串和每个请求。持久的实习字符串是在启动期间创建的,例如内部类/函数的名称。为 PHP 脚本中的符号和文字创建每个请求的字符串(如果尚不存在它们的持久性内部字符串)并在请求结束时丢弃。

启用 opcache 后,内部字符串存储在 SHM 中,因此它们在进程间进行重复数据删除,并且可以被缓存在 SHM 中的结构引用。在启动时,opcache 会尽最大努力将持久的实习字符串复制到 SHM 中(它可能不知道存储在某处的所有指针),但这对于正确性并不重要。

此外,在请求期间创建实习字符串被禁用。而是创建正常的非内部字符串。只有当编译的脚本被缓存(并且获得了 SHM 写锁)时,字符串才会被转换为 SHM 内部字符串。

类条目缓存

PHP 脚本包含大量对字符串形式的类的引用,例如, new Foo Foo $param 类型。由于请求之间的实际身份 Foo 可能不同,因此无法将它们编译为直接的类引用。

从类名中获取类条目的成本相对较高,因为它很常见:我们需要将字符串小写并在类哈希表中查找它。对于像 new Foo 这样的引用,查找缓存在函数运行时缓存中。但是,并不总是可以使用运行时缓存。例如,属性类型检查不能使用运行时缓存,并且在 PHP 8.1 之前使用直接在类型内部用类条目替换字符串名称,这意味着该类型不能存在于 SHM 中。

PHP 8.1 引入了一个类条目缓存,它结合了内部字符串和映射指针。对于在某些位置(类声明和类型名称)使用的内部字符串,分配了一个映射指针槽,它存储该名称的已解析类条目。为了避免增加字符串大小,这使用了一个技巧:

通常,interned 字符串的引用计数始终为 2。但是,实际引用计数无关紧要,它只需要大于 1 即可确保字符串在修改时重复。可以就地修改引用计数为 1 的字符串。因此,我们可以使用 refcount 字段来存储映射指针偏移量以用作类条目缓存。

这确实有一些限制,因为它绑定到内部字符串机制。例如,如果启用了 opcache 但未缓存脚本,则不会使用内部字符串,因此类条目缓存将不可用。

类条目缓存的优点之一是它相当通用,并且不受特定语言结构(如运行时缓存)的约束。如果你写 new Reflection Class(Foo::class) ,类查找可以被缓存,即使它是动态发生的。

持久性

脚本在共享内存中的实际持久性相对简单。该脚本首先像往常一样编译,除了一些选项以确保在编译期间不使用跨文件依赖项。编译结果从全局函数/类表中移出到一个自包含的持久脚本结构中。

然后计算所需共享内存段的大小。此步骤必须完全反映实际持久步骤的逻辑,但(大部分)不修改脚本。如果共享内存分配失败,我们仍然可以绕过 opcache 照常执行。“persist calc”步骤所做的唯一修改是尽可能将字符串转换为 SHM 实习字符串,因为实习字符串存储在与持久脚本分开的固定大小的段中。成功实习的字符串不计入脚本大小。

最后,persist 步骤将脚本复制到共享内存并释放原始脚本。为此,它会跟踪一个 xlat 表,该表将原始指针映射到共享内存中的新指针。这允许解决相同指针的重复使用。

继承缓存

类内部有两种形式。未链接的类代表您在代码中编写的类声明:它包含在该类中声明的方法,并将依赖项(父类、接口、特征)作为字符串引用。链接类表示已成功完成继承的类声明。它包含继承的方法/属性/等,并将依赖项引用为已解析的类条目。

查看单个脚本时,类通常以未链接的形式存在(除非它们碰巧没有依赖项)。链接类需要查看其他文件中的类。但是,使用的类声明可能因请求而异。

在 PHP 8.1 之前,这意味着只缓存未链接的类模板,并且仍然必须对每个请求执行继承。由于继承是一个相当昂贵的过程,这对性能产生了不小的影响。PHP 8.1 通过引入继承缓存解决了这个问题。

继承缓存存储给定依赖项集的链接继承结果。当在运行时请求继承时,类名依赖项被解析为类条目,如果该组依赖项的缓存条目已经存在,则使用它。虽然请求之间的依赖关系 可能 不同,但实际上它们通常是相同的,因此只需要执行一次继承。

如果不存在缓存条目,则将未链接的类从 SHM 复制到可变的每进程内存中,并在其上执行继承过程(就地)。基本上使用正常的持久化过程将结果与该缓存条目对其有效的依赖项一起持久化到继承缓存中。

预加载

预加载是对继承问题更激进的解决方案:预加载脚本加载的任何内容都将在请求之间继续存在。因此,在这种情况下使用跨脚本依赖是安全的。缺点是不重启 PHP 就无法更改预加载状态。

PHP 8.1 中的继承缓存可能已经淘汰了一些预加载的好处,尽管预加载仍然具有一些优势:类在请求开始时以完全继承的形式可用。预加载的唯一每个请求成本是清除映射指针区域。正常的 opcache 使用仍然需要通过自动加载、查找持久脚本、在全局 哈希表 中注册条目、查找和检查继承缓存的依赖关系等。

预加载可以在两种模式下运行:当简单地使用 加载类时 require ,继承将像往常一样发生,并且预加载可以支持具有任意复杂继承场景(包括方差循环)的类。这也使得确保自动加载器提供任何必要的依赖关系变得容易。

或者,可以使用 opcache_compile_file() . 在这种情况下,如果所有依赖项都可用,opcache 将尝试预加载该类。否则,它将抛出警告并以老式方式缓存脚本。在 PHP 8.1 之前,“所有依赖项”要求相当成问题。

在早期的 PHP 版本中,未链接的类分为两部分:一个实际上是不可变的,另一个必须复制到每个请求的内存中,因为它可能在运行时被修改。这包括属性类型以及常量/属性初始化器。如果在预加载期间无法完全解决这些问题,则无法预加载该类,因为在这种情况下我们无法执行每个请求的副本。在 PHP 8.1 中,所有剩余的运行时可修改部分都切换为映射指针,从而放宽了对“依赖”的约束。现在这只包括父母/接口/特征,以及执行差异检查所需的类型。

方差检查是另一个问题:是否需要参数/返回类型来执行方差检查很难提前确定。这取决于一个方法是否实际上是一个覆盖(在存在特征的情况下是不明显的)以及是否可以在不加载类的情况下确定子类型关系(例如,如果父方法和子方法中的类型完全相同) . 以前的 PHP 版本启发式地解决了这个问题,需要更多的依赖项。相反,PHP 8.1 将简单地尝试继承类的副本,如果失败则丢弃它。

这意味着 opcache_compile_file() 基于预加载在 PHP 8.1 中应该更加可预测。

文件缓存

PHP 7 中引入的文件缓存可以单独使用 ( opcache.file_cache_only ) 或与 SHM 缓存一起用作二级缓存。在后一种情况下,它将在冷启动时使用,或者在 opcache 重新启动期间 SHM 缓存不可用时使用。在 Windows 上,默认启用文件缓存回退,以确保在 SHM 重新附加失败时至少有一些缓存可用。

文件缓存序列化从脚本的持久表示开始,在 SHM(二级)或临时内存区域(独立)中,但使用通常的持久性机制创建。然后,实际的 序列化 将所有带有偏移量的指针替换到内存区域中(“指针解调”)。这允许通过将新的基指针添加到所有指针来进行有效的反序列化。

该模型的主要复杂性是内部字符串,因为这些是唯一不指向持久内存区域的指针。引用的实习字符串被序列化到单独的内存区域中。在反序列化时,会尝试将这些转换回 SHM 实习字符串。

反序列化通过将文件内容(包括序列化脚本和内部字符串区域)复制到缓冲区来工作。在独立模式下,此缓冲区是非临时的,反序列化(指针混合)直接发生在此缓冲区中。

在二级模式下,这个缓冲区通常是临时的。而是进行 SHM 分配,将序列化的脚本复制到其中并取消序列化。在这种情况下,所有实习字符串也需要转换为 SHM 实习字符串。然后可以丢弃临时缓冲区。但是,如果由于实习字符串 缓冲区溢出 而不能插入所有实习字符串,则放弃 SHM 段并执行独立情况下的按请求反序列化。

文章来源:智云一二三科技

文章标题:php opcache 的工作原理

文章地址:https://www.zhihuclub.com/149200.shtml

关于作者: 智云科技

热门文章

网站地图