<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Roadrunner on Tarragon</title><link>https://tarrragon.github.io/blog/tags/roadrunner/</link><description>Recent content in Roadrunner on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 05 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/roadrunner/index.xml" rel="self" type="application/rss+xml"/><item><title>常駐 worker 模型：FrankenPHP worker 模式、Laravel Octane 與跨請求殘留的狀態</title><link>https://tarrragon.github.io/blog/programming/php/01-server-runtime/long-running-workers/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/php/01-server-runtime/long-running-workers/</guid><description>&lt;p>這篇說明常駐 worker 模型：應用程式在 worker 啟動時載入一次，之後同一份記憶體裡的應用程式連續處理很多個請求。用 &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/" data-link-title="PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命" data-link-desc="說明一個 HTTP 請求怎麼變成一次 PHP 執行：PHP 在程序啟動與每個請求開始、結束時各做什麼，SAPI 這一層介面怎麼讓同一個直譯器接上 CGI、Apache、FastCGI 與常駐伺服器，以及用直譯器住在哪裡、執行它的程序活多久、應用程式的狀態活多久這組問題比較各種執行模型">PHP 的請求生命週期與 SAPI&lt;/a> 那組問題來看，它換掉的是第三個答案：應用程式的狀態不再只活一個請求。文中的行為取自 FrankenPHP 1.13（PHP 8.5.11）與 Laravel Octane 2.20（Laravel 13）。&lt;/p>
&lt;h2 id="常駐-worker-省下的是哪一段成本">常駐 worker 省下的是哪一段成本&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/php-fpm/" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響">PHP-FPM&lt;/a> 的 worker 常駐，&lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/opcache/" data-link-title="OPcache 與程序壽命：編譯結果的快取在各種執行模型下的效果" data-link-desc="說明 OPcache 快取的是什麼、它存在哪裡，以及為什麼它的效果取決於執行 PHP 的程序活多久：CGI 下命中次數永遠為零、PHP-FPM 的 worker 共用同一份快取；框架請求開與關 OPcache 的延遲差、memory_consumption 與 max_accelerated_files 的估算，以及部署新程式碼時 validate_timestamps 與 revalidate_freq 決定舊版還會執行多久">OPcache&lt;/a> 讓每個檔案只編譯一次，可是框架的啟動流程每個請求仍然要跑一次：建立服務容器、執行每個服務提供者的註冊、讀設定、建立路由表。這段流程的成本由框架的規模決定，跟請求要做什麼無關，是 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/shared-nothing-request/" data-link-title="Shared-nothing 請求模型" data-link-desc="要判斷 PHP 的全域變數、static 變數或類別靜態屬性會不會留到下一個請求，或評估換成常駐 worker（Octane、FrankenPHP worker）時哪些程式碼會出問題時">shared-nothing&lt;/a> 的直接代價——每個請求都從空白開始，就得每個請求都重新建立一次應用程式。&lt;/p>
&lt;p>常駐 worker 模型把這段流程移到 worker 啟動時：應用程式啟動一次之後留在記憶體裡，每個請求進來只執行「處理這個請求」那一段。Laravel Octane 的文件把這件事寫成一句話：Octane boots your application once, keeps it in memory, and then feeds it requests。&lt;/p>
&lt;p>這個模型要求執行 PHP 的程序或執行緒有能力在請求之間不清空狀態，所以它需要特定的伺服器，PHP-FPM 做不到（它在每個請求結束時一定清空）。Octane 支援的伺服器有 FrankenPHP、Swoole、Open Swoole 與 RoadRunner：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>伺服器&lt;/th>
 &lt;th>寫成什麼&lt;/th>
 &lt;th>怎麼讓 PHP 常駐&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>FrankenPHP&lt;/td>
 &lt;td>Go（嵌入 Caddy）&lt;/td>
 &lt;td>PHP 嵌在 Go 程式裡，worker 是執行緒&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RoadRunner&lt;/td>
 &lt;td>Go&lt;/td>
 &lt;td>Go 程式管理一群常駐的 PHP 程序，預設用標準管道（goridge 協定）交換請求與回應&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Swoole&lt;/td>
 &lt;td>PHP 的 C 擴充&lt;/td>
 &lt;td>PHP 程序自己開伺服器、處理事件迴圈與協程&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="frankenphp-的兩種模式">FrankenPHP 的兩種模式&lt;/h2>
&lt;p>FrankenPHP 是一個講 HTTP 的 web 伺服器，PHP 嵌在它的程序裡（SAPI 名稱是 &lt;code>frankenphp&lt;/code>），所以它不需要前面再接一個 Nginx。它有兩種模式，差別正好就是狀態壽命。&lt;/p>
&lt;p>&lt;strong>傳統模式&lt;/strong>：每個請求執行一次 PHP 腳本，執行完清空狀態，跟 PHP-FPM 一樣是 shared-nothing。同一支計數腳本（腳本內容見 &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/#%e4%b8%80%e5%80%8b%e8%ab%8b%e6%b1%82%e5%9c%a8-php-%e8%a3%a1%e7%b6%93%e9%81%8e%e7%9a%84%e9%9a%8e%e6%ae%b5" data-link-title="PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命" data-link-desc="說明一個 HTTP 請求怎麼變成一次 PHP 執行：PHP 在程序啟動與每個請求開始、結束時各做什麼，SAPI 這一層介面怎麼讓同一個直譯器接上 CGI、Apache、FastCGI 與常駐伺服器，以及用直譯器住在哪裡、執行它的程序活多久、應用程式的狀態活多久這組問題比較各種執行模型">PHP 的請求生命週期與 SAPI&lt;/a>）請求三次：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">pid=1 static=1 global=1 sapi=frankenphp
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">pid=1 static=1 global=1 sapi=frankenphp
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">pid=1 static=1 global=1 sapi=frankenphp&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>PID 一直是 1，因為 FrankenPHP 只有一個程序，PHP 在它的執行緒裡跑（這個 PHP 是以執行緒安全編譯的，&lt;code>Thread Safety =&amp;gt; enabled&lt;/code>，見 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/zts/" data-link-title="ZTS（Zend Thread Safety）" data-link-desc="在 php -i 看到 Thread Safety 是 enabled 或 disabled，或要判斷某個 PHP 能不能跑在多執行緒的伺服器（Apache worker/event MPM、FrankenPHP）裡時">ZTS&lt;/a>）。計數器每次都是 1。&lt;/p></description><content:encoded><![CDATA[<p>這篇說明常駐 worker 模型：應用程式在 worker 啟動時載入一次，之後同一份記憶體裡的應用程式連續處理很多個請求。用 <a href="/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/" data-link-title="PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命" data-link-desc="說明一個 HTTP 請求怎麼變成一次 PHP 執行：PHP 在程序啟動與每個請求開始、結束時各做什麼，SAPI 這一層介面怎麼讓同一個直譯器接上 CGI、Apache、FastCGI 與常駐伺服器，以及用直譯器住在哪裡、執行它的程序活多久、應用程式的狀態活多久這組問題比較各種執行模型">PHP 的請求生命週期與 SAPI</a> 那組問題來看，它換掉的是第三個答案：應用程式的狀態不再只活一個請求。文中的行為取自 FrankenPHP 1.13（PHP 8.5.11）與 Laravel Octane 2.20（Laravel 13）。</p>
<h2 id="常駐-worker-省下的是哪一段成本">常駐 worker 省下的是哪一段成本</h2>
<p><a href="/blog/programming/php/01-server-runtime/php-fpm/" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響">PHP-FPM</a> 的 worker 常駐，<a href="/blog/programming/php/01-server-runtime/opcache/" data-link-title="OPcache 與程序壽命：編譯結果的快取在各種執行模型下的效果" data-link-desc="說明 OPcache 快取的是什麼、它存在哪裡，以及為什麼它的效果取決於執行 PHP 的程序活多久：CGI 下命中次數永遠為零、PHP-FPM 的 worker 共用同一份快取；框架請求開與關 OPcache 的延遲差、memory_consumption 與 max_accelerated_files 的估算，以及部署新程式碼時 validate_timestamps 與 revalidate_freq 決定舊版還會執行多久">OPcache</a> 讓每個檔案只編譯一次，可是框架的啟動流程每個請求仍然要跑一次：建立服務容器、執行每個服務提供者的註冊、讀設定、建立路由表。這段流程的成本由框架的規模決定，跟請求要做什麼無關，是 <a href="/blog/programming/php/knowledge-cards/shared-nothing-request/" data-link-title="Shared-nothing 請求模型" data-link-desc="要判斷 PHP 的全域變數、static 變數或類別靜態屬性會不會留到下一個請求，或評估換成常駐 worker（Octane、FrankenPHP worker）時哪些程式碼會出問題時">shared-nothing</a> 的直接代價——每個請求都從空白開始，就得每個請求都重新建立一次應用程式。</p>
<p>常駐 worker 模型把這段流程移到 worker 啟動時：應用程式啟動一次之後留在記憶體裡，每個請求進來只執行「處理這個請求」那一段。Laravel Octane 的文件把這件事寫成一句話：Octane boots your application once, keeps it in memory, and then feeds it requests。</p>
<p>這個模型要求執行 PHP 的程序或執行緒有能力在請求之間不清空狀態，所以它需要特定的伺服器，PHP-FPM 做不到（它在每個請求結束時一定清空）。Octane 支援的伺服器有 FrankenPHP、Swoole、Open Swoole 與 RoadRunner：</p>
<table>
  <thead>
      <tr>
          <th>伺服器</th>
          <th>寫成什麼</th>
          <th>怎麼讓 PHP 常駐</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>FrankenPHP</td>
          <td>Go（嵌入 Caddy）</td>
          <td>PHP 嵌在 Go 程式裡，worker 是執行緒</td>
      </tr>
      <tr>
          <td>RoadRunner</td>
          <td>Go</td>
          <td>Go 程式管理一群常駐的 PHP 程序，預設用標準管道（goridge 協定）交換請求與回應</td>
      </tr>
      <tr>
          <td>Swoole</td>
          <td>PHP 的 C 擴充</td>
          <td>PHP 程序自己開伺服器、處理事件迴圈與協程</td>
      </tr>
  </tbody>
</table>
<h2 id="frankenphp-的兩種模式">FrankenPHP 的兩種模式</h2>
<p>FrankenPHP 是一個講 HTTP 的 web 伺服器，PHP 嵌在它的程序裡（SAPI 名稱是 <code>frankenphp</code>），所以它不需要前面再接一個 Nginx。它有兩種模式，差別正好就是狀態壽命。</p>
<p><strong>傳統模式</strong>：每個請求執行一次 PHP 腳本，執行完清空狀態，跟 PHP-FPM 一樣是 shared-nothing。同一支計數腳本（腳本內容見 <a href="/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/#%e4%b8%80%e5%80%8b%e8%ab%8b%e6%b1%82%e5%9c%a8-php-%e8%a3%a1%e7%b6%93%e9%81%8e%e7%9a%84%e9%9a%8e%e6%ae%b5" data-link-title="PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命" data-link-desc="說明一個 HTTP 請求怎麼變成一次 PHP 執行：PHP 在程序啟動與每個請求開始、結束時各做什麼，SAPI 這一層介面怎麼讓同一個直譯器接上 CGI、Apache、FastCGI 與常駐伺服器，以及用直譯器住在哪裡、執行它的程序活多久、應用程式的狀態活多久這組問題比較各種執行模型">PHP 的請求生命週期與 SAPI</a>）請求三次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">pid=1 static=1 global=1 sapi=frankenphp
</span></span><span class="line"><span class="ln">2</span><span class="cl">pid=1 static=1 global=1 sapi=frankenphp
</span></span><span class="line"><span class="ln">3</span><span class="cl">pid=1 static=1 global=1 sapi=frankenphp</span></span></code></pre></div><p>PID 一直是 1，因為 FrankenPHP 只有一個程序，PHP 在它的執行緒裡跑（這個 PHP 是以執行緒安全編譯的，<code>Thread Safety =&gt; enabled</code>，見 <a href="/blog/programming/php/knowledge-cards/zts/" data-link-title="ZTS（Zend Thread Safety）" data-link-desc="在 php -i 看到 Thread Safety 是 enabled 或 disabled，或要判斷某個 PHP 能不能跑在多執行緒的伺服器（Apache worker/event MPM、FrankenPHP）裡時">ZTS</a>）。計數器每次都是 1。</p>
<p><strong>worker 模式</strong>：一支 worker 腳本啟動一次，在一個迴圈裡反覆呼叫 <code>frankenphp_handle_request()</code>，每呼叫一次處理一個請求：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="o">&lt;?</span><span class="nx">php</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">// 這段只在 worker 啟動時執行一次：模擬框架啟動
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="nv">$bootedAt</span> <span class="o">=</span> <span class="nx">microtime</span><span class="p">(</span><span class="k">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nv">$requestLog</span> <span class="o">=</span> <span class="p">[];</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nv">$handler</span> <span class="o">=</span> <span class="k">static</span> <span class="k">function</span> <span class="p">()</span> <span class="k">use</span> <span class="p">(</span><span class="nv">$bootedAt</span><span class="p">,</span> <span class="o">&amp;</span><span class="nv">$requestLog</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">static</span> <span class="nv">$count</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nv">$count</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nv">$requestLog</span><span class="p">[]</span> <span class="o">=</span> <span class="nv">$_SERVER</span><span class="p">[</span><span class="s1">&#39;REQUEST_URI&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">echo</span> <span class="s2">&#34;static=&#34;</span><span class="p">,</span> <span class="nv">$count</span><span class="p">,</span> <span class="s2">&#34; log_entries=&#34;</span><span class="p">,</span> <span class="nx">count</span><span class="p">(</span><span class="nv">$requestLog</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">         <span class="s2">&#34; booted=&#34;</span><span class="p">,</span> <span class="nx">round</span><span class="p">(</span><span class="nv">$bootedAt</span><span class="p">,</span> <span class="mi">3</span><span class="p">),</span> <span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">// 每次迴圈處理一個請求；伺服器要這個 worker 結束時回傳 false
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></span><span class="k">while</span> <span class="p">(</span><span class="nx">frankenphp_handle_request</span><span class="p">(</span><span class="nv">$handler</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">gc_collect_cycles</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>連續請求四次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">static=1 log_entries=1 booted=1791203186.703
</span></span><span class="line"><span class="ln">2</span><span class="cl">static=2 log_entries=2 booted=1791203186.703
</span></span><span class="line"><span class="ln">3</span><span class="cl">static=3 log_entries=3 booted=1791203186.703
</span></span><span class="line"><span class="ln">4</span><span class="cl">static=4 log_entries=4 booted=1791203186.703</span></span></code></pre></div><p><code>booted</code> 的時間戳四次都一樣，證明迴圈前的那段只跑了一次；<code>static</code> 與 <code>$requestLog</code> 一路累加，證明前一個請求留下的變數被下一個請求看到了。FrankenPHP 的文件對此寫得很直接：Static variables, class static properties, global variables, and in-memory caches all persist across requests。</p>
<h2 id="laravel-octane框架層的常駐-worker">Laravel Octane：框架層的常駐 worker</h2>
<p>自己寫 worker 迴圈要處理的事情很多：每個請求之間重設框架的哪些物件、例外怎麼處理、什麼時候重啟 worker。Laravel Octane 是 Laravel 官方的套件，把這些包好，讓既有的 Laravel 應用程式跑在上面那幾種伺服器上，用 <code>php artisan octane:frankenphp</code>（或 <code>octane:swoole</code>、<code>octane:roadrunner</code>）啟動。</p>
<p>Octane 會在每個請求之間重設 Laravel 自己的請求相關狀態，但應用程式程式碼留下的狀態它管不到。在 Laravel 骨架加一條路由，用一個類別的靜態屬性計數，並在每個請求往一個靜態陣列塞 100 KB 的資料——模擬把查詢結果或 log 暫存在靜態屬性裡這種在 PHP-FPM 下無害的寫法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">Route</span><span class="o">::</span><span class="na">get</span><span class="p">(</span><span class="s1">&#39;/state&#39;</span><span class="p">,</span> <span class="k">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">RequestLog</span><span class="o">::</span><span class="nv">$count</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">RequestLog</span><span class="o">::</span><span class="nv">$entries</span><span class="p">[]</span> <span class="o">=</span> <span class="nx">str_repeat</span><span class="p">(</span><span class="s1">&#39;x&#39;</span><span class="p">,</span> <span class="mi">100</span> <span class="o">*</span> <span class="mi">1024</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="s1">&#39;count&#39;</span> <span class="o">=&gt;</span> <span class="nx">RequestLog</span><span class="o">::</span><span class="nv">$count</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">            <span class="s1">&#39;php_mem_mb&#39;</span> <span class="o">=&gt;</span> <span class="nx">round</span><span class="p">(</span><span class="nx">memory_get_usage</span><span class="p">()</span> <span class="o">/</span> <span class="mi">1048576</span><span class="p">,</span> <span class="mi">1</span><span class="p">)];</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>在 Octane 加 FrankenPHP、一個 worker、<code>--max-requests=500</code> 下連續請求：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">request #1   -&gt; count=1   php_mem=5.6MB
</span></span><span class="line"><span class="ln">2</span><span class="cl">request #100 -&gt; count=100 php_mem=15.8MB
</span></span><span class="line"><span class="ln">3</span><span class="cl">request #200 -&gt; count=200 php_mem=26.1MB
</span></span><span class="line"><span class="ln">4</span><span class="cl">request #400 -&gt; count=400 php_mem=46.5MB
</span></span><span class="line"><span class="ln">5</span><span class="cl">request #499 -&gt; count=499 php_mem=56.6MB
</span></span><span class="line"><span class="ln">6</span><span class="cl">request #500 -&gt; count=1   php_mem=1.2MB    # 處理滿 500 個請求，worker 重啟
</span></span><span class="line"><span class="ln">7</span><span class="cl">request #600 -&gt; count=101 php_mem=11.5MB</span></span></code></pre></div><p>同一段程式碼在 PHP-FPM 下，<code>count</code> 永遠是 1、記憶體在請求結束時全部釋放。在常駐 worker 下它變成兩個問題：</p>
<ul>
<li><strong>狀態洩漏到下一個請求</strong>：<code>count</code> 一路累加。換成「目前登入的使用者」「這個請求的語系」這類資料，就是前一個使用者的資料被下一個使用者看到。</li>
<li><strong>記憶體隨請求數線性增長</strong>：每個請求多 0.1 MB，<code>max-requests</code> 到了 worker 重啟才歸零。Octane 的文件寫明它預設在 worker 處理滿 500 個請求後 gracefully restart，理由正是 to help prevent stray memory leaks，並舉了同一個例子：adding data to a statically maintained array will result in a memory leak。</li>
</ul>
<p>這裡的記憶體增長跟 <a href="/blog/programming/php/01-server-runtime/php-fpm/#max_requests%e5%ae%9a%e6%9c%9f%e6%8f%9b%e6%8e%89-worker-%e4%bb%a5%e6%8e%a7%e5%88%b6%e8%a8%98%e6%86%b6%e9%ab%94" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響">PHP-FPM 那篇〈max_requests〉一節</a> 量到的不同。PHP-FPM 下 PHP 程式碼建立的記憶體每個請求都會釋放，worker 的記憶體反映最近一次請求的峰值；常駐 worker 下，靜態屬性裡的資料不會被釋放，記憶體跟著累計的請求數往上長。<code>max-requests</code> 在兩個模型下都存在，防的記憶體成長不一樣。在常駐 worker 下，它的值是兩個成本的取捨：設得低，洩漏累積到的上限低，代價是 worker 重啟頻繁、每次重啟都要重新啟動一次框架；設得高，重啟少，代價是洩漏能長到的量大。先用上面那種逐請求記錄記憶體的方式量出每個請求漲多少，再依可接受的記憶體上限回推。</p>
<p>worker 是程序還是執行緒，也決定一個 worker 崩潰時影響多大。RoadRunner 的 worker 是各自獨立的 PHP 程序，一個因為擴充的錯誤而 segfault 時只有它自己結束；FrankenPHP 與 Swoole 的 worker 跑在同一個程序裡，程序層級的崩潰會連同其他 worker 一起帶走。</p>
<p>FrankenPHP 的 worker 是執行緒，所以重啟時 PID 不變（這次實測一直是同一個 PID），換掉的是執行緒裡的 PHP 狀態。看 PID 判斷 worker 有沒有重啟，在這個伺服器上不成立。</p>
<h2 id="寫給常駐-worker-的程式要避開的寫法">寫給常駐 worker 的程式要避開的寫法</h2>
<p>Octane 的文件列出的幾類問題，根源都是「物件比請求活得久」：</p>
<ul>
<li><strong>靜態屬性與全域變數存放請求的資料</strong>：上面那個例子。請求的資料放在請求範圍的物件裡，或在請求結束時清掉。</li>
<li><strong>把服務容器或請求物件注入到長壽物件的建構子</strong>：Octane 文件的原文是 that object may have a stale version of the container or request on subsequent requests——一個 singleton 在第一個請求時拿到了那個請求的 <code>Request</code>，之後的請求它手上一直是第一個請求的版本。</li>
<li><strong>在 singleton 裡快取會變的值</strong>：設定、目前的使用者、語系，在 PHP-FPM 下每個請求都重新算，在常駐 worker 下只算一次。</li>
</ul>
<p>既有的 Laravel 應用程式搬上 Octane 之前，要把程式碼與用到的套件逐一檢查過這幾類寫法；套件作者沒有考慮常駐 worker 的話，問題會出在套件裡，而它們在 PHP-FPM 下從來不會顯現。</p>
<h2 id="速度差多少要在自己的應用程式上量">速度差多少要在自己的應用程式上量</h2>
<p>常駐 worker 省下的是框架的啟動流程，所以省下多少由框架啟動流程佔每個請求多少時間決定。在 Laravel 13 骨架的 <code>/healthz</code> 上，同一時段各循序送 300 個請求：</p>
<table>
  <thead>
      <tr>
          <th>執行方式</th>
          <th>中位數</th>
          <th>p95</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PHP-FPM（容器內 Nginx 加 php-fpm，開 OPcache）</td>
          <td>3.20 ms</td>
          <td>7.57 ms</td>
      </tr>
      <tr>
          <td>Octane 加 FrankenPHP worker</td>
          <td>2.43 ms</td>
          <td>4.07 ms</td>
      </tr>
  </tbody>
</table>
<p>兩者的中位數只差不到一毫秒，而這個差距還包含 PHP-FPM 那一側多經過的容器內 Nginx 一跳，所以兩邊不完全對等。量測對象是一個幾乎沒有服務提供者的空骨架，框架啟動流程本來就短，開了 OPcache 之後能省的不多。正式專案的服務提供者、套件與設定檔越多，啟動流程越長，常駐 worker 省下的越多。所以這組數字只說明量測的方式，不說明手上的專案會差多少：在自己的應用程式、自己的主要請求上量一次，才知道省下的時間值不值得上一節那些改寫與檢查的成本。</p>
<h2 id="常駐-worker-在執行模型裡的位置">常駐 worker 在執行模型裡的位置</h2>
<p>這一系列的執行模型可以排成一條線，每一步讓程序或應用程式狀態活得更久：<a href="/blog/programming/php/01-server-runtime/cgi/" data-link-title="CGI：每個請求啟動一個 PHP 程序的執行模型" data-link-desc="說明 CGI 怎麼把一個 HTTP 請求交給外部程式：web 伺服器為每個請求啟動一個程序，用環境變數與標準輸入輸出傳遞請求和回應；PHP 以 php-cgi 執行時每個請求付出的啟動成本、OPcache 為什麼沒有作用、以每個站台的使用者身分執行帶來的隔離，以及請求資料與程式呼叫參數之間的邊界問題">CGI</a> 的程序與狀態都只活一個請求；<a href="/blog/programming/php/01-server-runtime/mod-php/" data-link-title="mod_php：PHP 直譯器嵌進 Apache 程序的執行模型" data-link-desc="說明 mod_php 怎麼把 PHP 直譯器載入 Apache 的每個子程序：為什麼它要搭配 prefork MPM、每個子程序都帶著 PHP 對記憶體與並發的影響、所有站台共用 Apache 的執行帳號、php.ini 與 .htaccess 的設定方式，以及從 mod_php 搬到 PHP-FPM 時 .htaccess 的 php_value 與 .user.ini 的差異">mod_php</a> 與 <a href="/blog/programming/php/01-server-runtime/php-fpm/" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響">PHP-FPM</a> 讓程序活過很多請求，<a href="/blog/programming/php/01-server-runtime/opcache/" data-link-title="OPcache 與程序壽命：編譯結果的快取在各種執行模型下的效果" data-link-desc="說明 OPcache 快取的是什麼、它存在哪裡，以及為什麼它的效果取決於執行 PHP 的程序活多久：CGI 下命中次數永遠為零、PHP-FPM 的 worker 共用同一份快取；框架請求開與關 OPcache 的延遲差、memory_consumption 與 max_accelerated_files 的估算，以及部署新程式碼時 validate_timestamps 與 revalidate_freq 決定舊版還會執行多久">OPcache</a> 因此有編譯結果可以重用；常駐 worker 讓應用程式的狀態也活過很多請求。</p>
<p>每往前一步，省下一段每個請求都要重付的成本，也失去一層隔離：CGI 的程序隔離讓一個請求的錯誤隨程序消失；shared-nothing 讓一個請求的資料不會被下一個請求看到。常駐 worker 走到最後一步，代價是這兩層隔離的責任從執行環境移到了程式碼身上——PHP-FPM 替程式清空的那些狀態，現在要程式自己不留下來。</p>
]]></content:encoded></item></channel></rss>