尝试预加载(PHP 7.4)和RoadRunner



哈Ha!

我们经常写和谈论PHP性能:我们通常如何处理它如何在转换到PHP 7.0时节省 100万美元,还翻译了有关该主题的各种材料。 这是由于以下事实:我们产品的受众不断增长,并且用铁来扩展PHP后端非常昂贵-我们有600台使用PHP-FPM的服务器。 因此,在优化上投入时间对我们有利。

之前,我们主要讨论了通常的和已经建立的提高生产力的方法。 但是PHP社区处于戒备状态! JIT将出现在PHP 8中,预加载将出现在PHP 7.4中,并且将开发PHP开发核心之外的框架,这些框架假定PHP作为守护程序工作。 现在该尝试一些新的东西,看看它能给我们带来什么。

由于PHP 8的发布还有很长的路要走,并且异步框架不太适合我们的任务(为什么-我将在下面说明),因此今天我们将集中讨论预加载(将在PHP 7.4中出现)和用于妖魔化PHP的框架RoadRunner。

这是我的Badoo PHP Meetup#3报告的文本版本。 我们在这篇文章中收集的所有演讲的视频。

PHP-FPM,Apache mod_php,以及类似的运行PHP脚本和处理请求的方法(它们由绝大多数站点和服务运行;为简单起见,我将它们称为“经典” PHP)在无共享的基础上广泛地工作:

  • PHP工作人员之间的状态不赖。
  • 各种请求之间的状态没有变化。

考虑一个简单的脚本示例:

//  $app = \App::init(); $storage = $app->getCitiesStorage(); //   $name = $storage->getById($_COOKIE['city_id']); echo " : {$name}"; 

对于每个请求,脚本从第一行到最后一行执行:尽管最有可能的初始化与请求之间没有不同,并且可能可以执行一次(节省资源),但仍然必须为每个请求重复执行该脚本。 由于“经典” PHP的工作方式的特殊性,我们不能仅在请求之间获取并保存变量(例如$app )。

如果我们超出“经典” PHP的范围,将会是什么样? 例如,我们的脚本可以在不考虑请求的情况下运行,初始化并在其中包含一个查询循环,在该循环中,他将等待下一个查询,对其进行处理,然后在不清除环境的情况下重复该循环(以下,我将此解决方案称为“ PHP作为守护程序”)。

 //  $app = \App::init(); $storage = $app->getCitiesStorage(); $cities = $storage->getAll(); //    while ($req = getNextRequest()) {    $name = $cities[$req->getCookie('city_id')];    echo " : {$name}"; } 

我们不仅可以摆脱针对每个请求重复进行的初始化,而且还可以将城市列表一次保存到$cities变量中,并从各种请求中使用它,而无需访问内存以外的任何位置(这是获取任何数据的最快方法)。

这种解决方案的性能可能大大高于“经典” PHP。 但是通常生产力提高不是免费提供的-您必须为此付出一定的代价。 让我们看看我们的情况。

为此,让我们的脚本稍微复杂一些,而不是显示$name变量,我们将填充数组:

 -  $name = $cities[$req->getCookie('city_id')]; +  $names[] = $cities[$req->getCookie('city_id')]; 

对于“经典” PHP,将不会出现问题-在查询结束时, $name变量将被销毁,每个后续请求将按预期运行。 在将PHP作为守护程序启动的情况下,每个请求都将向该变量添加另一个城市,这将导致阵列的不受控制的增长,直到计算机上的内存用完为止。

通常,不仅内存可能会终止-还会发生其他一些错误,这些错误将导致进程终止。 遇到此类问题,“经典” PHP会自动处理。 在将PHP作为守护程序启动的情况下,我们需要以某种方式监视该守护程序,如果崩溃则将其重新启动。

这种类型的错误令人不快,但是有有效的解决方案。 如果由于错误而导致脚本没有掉下来,但是却意外地更改了某些变量的值(例如,它清除了$cities数组),那就更糟了。 在这种情况下,所有后续请求都将使用不正确的数据。

总而言之,为“经典” PHP(PHP-FPM,Apache mod_php等)编写代码更加容易-它使我们摆脱了许多问题和错误。 但是为此,我们付出了性能。

从上面的示例中,我们可以看到,在代码的某些部分中,PHP花费了在处理“经典”请求的每个请求时都不会花费(或浪费一次)的资源。 这些是以下领域:

  • 文件连接(包括,要求等);
  • 初始化(框架,库,DI容器等);
  • 从外部存储(而不是存储在内存)中请求数据。

PHP已经存在了很多年,甚至由于这种工作模型而可能变得流行。 在这段时间内,开发了许多不同程度的成功方法来解决上述问题。 我在上一篇文章中提到了其中一些。 今天,我们将为社区介绍两个相当新的解决方案:preload和RoadRunner。

预载


在上面列出的三点中, 预加载旨在处理第一点-连接文件时的开销。 乍一看,这似乎很奇怪而且毫无意义,因为PHP已经有了OPcache,它是专门为此目的而创建的。 为了理解本质,让我们在perf的帮助下进行概要分析,启用percache的情况下,命中率等于100%。



尽管使用了OPcache,但我们看到persistent_compile_file占用了查询执行时间的5.84%。

为了理解为什么会发生这种情况,我们可以查看zend_accel_load_script的来源。 从他们可以看出,尽管存在OPcache,但是每次调用include/require类和函数include/require签名都从共享内存复制到工作进程的内存中,并且完成了各种辅助工作。 并且应该为每个请求完成这项工作,因为在请求结束时会清除工作进程的内存。



我们通常在单个请求中进行的大量include / require调用使情况更加复杂。 例如,在执行第一条有用的代码行之前,Symfony 4包含大约310个文件。 有时这会隐式发生:要创建如下所示的类A的实例,PHP将自动加载所有其他类(B,C,D,E,F,G)。 特别是在这方面,Composer的声明函数的依赖关系非常突出:为了确保这些函数在用户代码执行期间可用,Composer必须始终连接它们而不管其使用方式,因为PHP没有自动加载功能,并且不能通话时已加载。

 class A extends \B implements \C {    use \D;    const SOME_CONST = \E::E1;    private static $someVar = \F::F1;    private $anotherVar = \G::G1; } 


预载如何工作


Preload具有一个单一的主要设置opcache.preload,PHP脚本的路径被传递到其中。 启动PHP-FPM / Apache /等时,该脚本将执行一次,并且在此文件中声明的类,方法和函数的所有签名将对处理从其执行的第一行开始的请求的所有脚本可用(重要注意:这不适用于变量和全局常数-在预加载阶段结束后,它们的值将重置为零。 您不再需要进行include / require调用并将函数/类签名从共享内存复制到进程内存:它们全部被声明为不可变的 ,因此,所有进程都可以引用包含它们的同一内存位置。

通常,我们需要的类和函数位于不同的文件中,将它们组合为一个预加载脚本很不方便。 但这并不需要这样做:由于preload是常规的PHP脚本,因此我们可以简单地将preload脚本中的include / require或opcache_compile_file()用于我们需要的所有文件。 此外,由于所有这些文件都将被加载一次,因此PHP可以进行其他优化,而在查询时我们分别连接这些文件将无法实现。 PHP仅在每个单独文件的框架内进行优化,但在预加载的情况下,会对在预加载阶段加载的所有代码进行优化。

基准预载


为了在实践中演示预加载的好处,我采用了一个受CPU约束的端点Badoo。 我们的后端通常以受CPU限制的负载为特征。 这个事实是为什么我们不考虑异步框架的问题的答案:异步框架在CPU受限负载的情况下没有任何优势,同时使代码变得更加复杂(需要以不同的方式编写)以及用于网络,磁盘等。需要特殊的异步驱动程序。

为了充分了解预加载的好处,在实验中,我下载了工作中测试脚本所需的所有文件,并使用wrk2 (类似于Apache Benchmark的更高级的模拟)将其加载为正常的生产负载。 。

要尝试预加载,您必须首先升级到PHP 7.4(我们现在拥有PHP 7.2)。 我测量了PHP 7.2,没有预加载的PHP 7.4和有预加载的PHP 7.4的性能。 结果是这样的图片:



因此,从PHP 7.2到PHP 7.4的过渡使我们端点的性能提高了10%,而预加载使上述性能提高了10%。

在预加载的情况下,结果将很大程度上取决于连接文件的数量和可执行逻辑的复杂性:如果连接了许多文件并且逻辑简单,则预加载将比文件少且逻辑复杂的情况更多。

预紧力的细微差别


通常,提高生产率的不利因素。 预加载有很多细微差别,我将在下面给出。 所有这些都需要考虑在内,但只有一个(第一个)可以是基本的。

更改-重新启动


由于所有预加载文件仅在启动时进行编译,标记为不可变,以后不会重新编译,因此对这些文件应用更改的唯一方法是重新启动(重新加载或重新启动)PHP-FPM / Apache /等。

在重新加载的情况下,PHP会尝试尽可能准确地重新启动:用户请求不会被中断,但是尽管如此,在预加载阶段进行期间,所有新请求都将等待它完成。 如果预加载中没有很多代码,则可能不会引起问题,但是,如果您尝试下载整个应用程序,则重新启动过程中的响应时间将大大增加。

另外,重新启动(无论是重新加载还是重新启动)都具有重要功能-由于此操作,OPcache被清除。 也就是说,它之后的所有请求都将与冷操作码缓存一起使用,这会进一步增加响应时间。

未定义的字符


为了使预加载加载类,到目前为止必须定义它所依赖的所有内容。 对于下面的类,这意味着在编译该类之前,所有其他类(B,C,D,E,F,G),变量$someGlobalVar和常量SOME_CONST必须可用。 由于预加载脚本只是常规的PHP代码,因此我们可以定义一个自动加载器。 在这种情况下,与其他类连接的所有内容都将由它自动加载。 但这不适用于变量和常量:我们自己必须确保在声明此类时定义它们。

 class A extends \B implements \C {    use \D;    const SOME_CONST = \E::E1;    private static $someVar = \F::F1;    private $anotherVar = \G::G1;    private $varLink = $someGlobalVar;    private $constLink = SOME_CONST; } 

幸运的是,预加载功能包含足够的工具,可以帮助您了解是否有所碍事。 首先,这些是警告消息,其中包含有关加载失败的原因以及原因的信息:

 PHP Warning: Can't preload class MyTestClass with unresolved initializer for constant RAND in /local/preload-internal.php on line 6 PHP Warning: Can't preload unlinked class MyTestClass: Unknown parent AnotherClass in /local/preload-internal.php on line 5 

其次,preload在opcache_get_status()函数的结果中添加了一个单独的部分,该部分显示了在preload阶段成功加载的内容:



类字段/常量优化


正如我上面所写,preload解析类的字段/常量的值并将其保存。 这使您可以优化代码:在处理请求期间,数据已准备就绪,不需要从其他数据派生。 但是,这可能导致结果不明显,下面的示例演示了该结果:

 const.php: <?php define('MYTESTCONST', mt_rand(1, 1000)); 

 preload.php: <?php include 'const.php'; class MyTestClass {    const RAND = MYTESTCONST; } 

 script.php: <?php include 'const.php'; echo MYTESTCONST, ', ', MyTestClass::RAND; // 32, 154 

结果是一种违反直觉的情况:似乎常量应该相等,因为其中一个被赋予了另一个的值,但实际上并非如此。 这是由于以下事实:与类常量/字段相反,全局常量在预加载阶段结束后被强制清除,同时解析并保存了类常量/字段。 这导致以下事实:在执行请求期间,我们必须再次定义全局常量,因此它可以获取不同的值。

无法重新声明someFunc()


对于类,情况很简单:通常我们不显式连接它们,而是使用自动加载器。 这意味着,如果在预加载阶段定义了一个类,则自动加载器将不会在请求期间执行,并且我们也不会再次尝试连接该类。

函数的情况有所不同:我们必须显式连接它们。 这可能导致以下情况:在预加载脚本中,我们将使用功能连接所有必需的文件,并且在请求期间,我们将尝试再次执行此操作(典型示例是Composer引导加载程序:它将始终尝试将所有文​​件与功能连接)。 在这种情况下,我们会收到错误消息:该函数已经定义,无法重新定义。

这个问题可以通过不同的方式解决。 对于Composer,例如,您可以在预加载阶段连接所有内容,而在请求期间不连接与Composer相关的任何内容。 另一种解决方案是不直接将文件与函数连接,而是通过代理文件并通过对function_exists()的检查来做到这一点,例如Guzzle HTTP 那样



PHP 7.4尚未正式发布(尚未)


一段时间后,这种细微差别将变得无关紧要,但是直到尚未正式发布PHP 7.4版本并且PHP团队在发行说明中明确写道 :“请不要在生产中使用此版本,它是早期测试版本。” 在进行预加载的实验期间,我们遇到了多个错误,我们自己修复了它们,甚至上游发送了一些东西。 为避免意外,最好等待正式版本。

行军


RoadRunner是用Go编写的守护程序,一方面,它创建PHP工作程序并监视它们(根据需要启动/结束/重新启动),另一方面,接受请求并将它们传递给这些工作程序。 从这个意义上讲,它的工作与PHP-FPM的工作没有什么不同(PHP-FPM也有一个监视工作程序的主过程)。 但是仍然存在差异。 关键是在完成查询后,RoadRunner不会重置脚本的状态。

因此,如果我们回顾一下在“经典” PHP情况下使用了哪些资源的清单,RoadRunner允许您处理所有要点(正如我们所记得的,预加载仅用于第一个):

  • 文件连接(包括,要求等);
  • 初始化(框架,库,DI容器等);
  • 从外部存储(而不是存储在内存)中请求数据。

Hello World RoadRunner示例如下所示:

 $relay = new Spiral\Goridge\StreamRelay(STDIN, STDOUT); $psr7 = new Spiral\RoadRunner\PSR7Client(new Spiral\RoadRunner\Worker($relay)); while ($req = $psr7->acceptRequest()) {        $resp = new \Zend\Diactoros\Response();        $resp->getBody()->write("hello world");        $psr7->respond($resp); } 

我们将尝试使用经过预加载测试的当前端点,使其在RoadRunner上运行而无需修改,加载并评估性能。 请勿修改-否则基准将不会完全诚实。

让我们尝试为此改编Hello World示例。

首先,正如我上面所写,我们不希望工人在发生错误的情况下跌倒。 为此,我们需要将所有内容包装在全局try..catch中。 其次,由于我们的脚本对Zend Diactoros一无所知,因此对于答案,我们将需要转换其结果。 为此,我们使用ob_-functions。 第三,我们的脚本对PSR-7请求的性质一无所知。 解决方案是从这些实体中填充标准PHP环境。 第四,我们的脚本期望请求终止,并且整个状态将被清除。 因此,使用RoadRunner,我们需要自己进行此清洁。

因此,最初的Hello World版本变成这样:

 while ($req = $psr7->acceptRequest()) {    try {        $uri = $req->getUri();        $_COOKIE = $req->getCookieParams();        $_POST = $req->getParsedBody();        $_SERVER = [            'REQUEST_METHOD' => $req->getMethod(),            'HTTP_HOST' => $uri->getHost(),            'DOCUMENT_URI' => $uri->getPath(),            'SERVER_NAME' => $uri->getHost(),            'QUERY_STRING' => $uri->getQuery(),            // ...        ];        ob_start();        // our logic here        $output = ob_get_contents();        ob_clean();               $resp = new \Zend\Diactoros\Response();        $resp->getBody()->write($output, 200);        $psr7->respond($resp);    } catch (\Throwable $Throwable) {        // some error handling logic here    }    \UDS\Event::flush();    \PinbaClient::sendAll();    \PinbaClient::flushAll();    \HTTP::clear();    \ViewFactory::clear();    \Logger::clearCaches();       // ... } 


基准RoadRunner


好了,现在是发布基准测试的时候了。



结果不符合预期:RoadRunner允许您平分更多因素而导致性能损失,而不是预加载,但结果更糟。 让我们通过运行perf来找出为什么会如此发生的原因。



在性能结果中,我们看到phar_compile_file。 这是因为我们在脚本执行期间包含了一些文件,并且由于未启用OPcache(RoadRunner作为CLI运行脚本,默认情况下OPcache处于关闭状态),因此这些文件会在每次请求时再次进行编译。

编辑RoadRunner配置-启用OPcache:





这些结果已经更像我们预期的那样:RoadRunner开始显示比预加载更多的性能。 但是也许我们将能够得到更多!

perf似乎没有什么不寻常的-让我们看一下PHP代码。 对其进行分析的最简单方法是使用phpspy :它不需要对PHP代码进行任何修改-您只需要在控制台中运行它即可。 让我们做一个火焰图:



由于我们同意不为纯粹实验而修改应用程序的逻辑,因此我们对与RoadRunner工作相关的堆栈分支感兴趣:



它的主要部分归结为fread()的调用,对此几乎无法做任何事情。 但是,除了fread本身,我们还在\ Spiral \ RoadRunner \ PSR7Client :: acceptRequest()中看到其他一些分支。 通过查看源代码,您可以了解它们的含义:

    /**     * @return ServerRequestInterface|null     */    public function acceptRequest()    {        $rawRequest = $this->httpClient->acceptRequest();        if ($rawRequest === null) {            return null;        }        $_SERVER = $this->configureServer($rawRequest['ctx']);        $request = $this->requestFactory->createServerRequest(            $rawRequest['ctx']['method'],            $rawRequest['ctx']['uri'],            $_SERVER        );        parse_str($rawRequest['ctx']['rawQuery'], $query);        $request = $request            ->withProtocolVersion(static::fetchProtocolVersion($rawRequest['ctx']['protocol']))            ->withCookieParams($rawRequest['ctx']['cookies'])            ->withQueryParams($query)            ->withUploadedFiles($this->wrapUploads($rawRequest['ctx']['uploads'])); 

显而易见,RoadRunner试图使用序列化数组创建符合PSR-7的请求对象。 如果您的框架直接使用PSR-7查询对象(例如Symfony 不起作用 ),那么这是完全合理的。 在其他情况下,在将请求转换为您的应用程序可以使用的功能之前,PSR-7成为额外的链接。 让我们删除此中间链接,然后再次查看结果:



经过测试的脚本非常简单,因此我设法挤占了很大一部分性能-与纯PHP相比增加了17%(我记得同一脚本的预载为+ 10%)。

RoadRunner的细微之处


通常,RoadRunner的使用不仅是包含预载,而且是更严重的更改,因此,这里的细微差别甚至更重要。

-, RoadRunner, , PHP- , , , : , , .

-, RoadRunner , «» — . / RoadRunner ; , , , , - .

-, endpoint', , , RoadRunner. .


, «» PHP, , preload RoadRunner.

PHP «» (PHP-FPM, Apache mod_php ) . - , . , , preload JIT.

, , , RoadRunner, .

, (: ):

  • PHP 7.2 — 845 RPS;
  • PHP 7.4 — 931 RPS;
  • RoadRunner — 987 RPS;
  • PHP 7.4 + preload — 1030 RPS;
  • RoadRunner — 1089 RPS.

Badoo PHP 7.4 , ( ).

RoadRunner , , , , .

感谢您的关注!

Source: https://habr.com/ru/post/zh-CN472528/


All Articles