Lesson 05 / 10
Code Caching
How PHP keeping bytecode in a cache hides file changes in production: when the new version at the same path arrives, and what invalidation solves.
Published Updated
In this lesson
- Explain what bytecode and the code cache are, and why timestamp validation is turned off in production
- Show by measurement that a file rewritten in production can run with its old version
- Interpret how invalidation with opcache_invalidate() behaves in the two profiles
Contents
Before PHP runs a file, it translates the source into an intermediate form, the bytecode, and then executes that form. The code cache (opcode cache) keeps the translated bytecode in memory, so later requests skip the translation step. In PHP the OPcache extension does this work. The cache has a price: the copy in memory and the file on disk can drift apart. When you put new code live, which one runs depends on when the cache looks at the disk.
The question that measures this lesson is: does this behavior reach production? The side.php below is the shared core of the course. It runs a script first with the development directives and then with the production directives, in separate processes, and reports on which side the behavior was observed. In this lesson the file is only written to disk and produces no output.
<?php // side.php — does this behavior reach production? declare(strict_types=1); // The two configuration files PHP ships (php.ini-development, php.ini-production) differ // in seven directives; all seven are copied here verbatim. The last two production lines // are comments in the shipped file: code cache on for the command line too, file checks off. const PROFILES = [ 'development' => [ 'display_errors' => '1', 'display_startup_errors' => '1', 'error_reporting' => '32767', 'mysqlnd.collect_memory_statistics' => '1', 'zend.assertions' => '1', 'zend.exception_ignore_args' => '0', 'zend.exception_string_param_max_len' => '15', ], 'production' => [ 'display_errors' => '0', 'display_startup_errors' => '0', 'error_reporting' => '24575', 'mysqlnd.collect_memory_statistics' => '0', 'zend.assertions' => '-1', 'zend.exception_ignore_args' => '1', 'zend.exception_string_param_max_len' => '0', 'opcache.enable_cli' => '1', 'opcache.validate_timestamps' => '0', ], ]; /** Runs the script in a separate process with no configuration file, only the profile's directives. */ function run(string $script, array $directives): bool { $command = ['php', '-n']; foreach ($directives as $name => $value) { array_push($command, '-d', "$name=$value"); } $command[] = $script; $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); $output = stream_get_contents($pipes[1]); fclose($pipes[1]); proc_close($process); $lines = array_values(array_filter(array_map('trim', explode("\n", $output)))); return end($lines) === 'yes'; } /** Derives the side from whether the behavior was observed in the two runs. */ function side(bool $inDevelopment, bool $inProduction): string { return match (true) { $inDevelopment && $inProduction => 'both sides', $inDevelopment => 'development only', $inProduction => 'production only', default => 'not in the run', }; } function sideReport(string $name, string $script): string { $inDevelopment = run($script, PROFILES['development']); $inProduction = run($script, PROFILES['production']); $finalSide = side($inDevelopment, $inProduction); echo "$name → $finalSide\n development ", $inDevelopment ? 'yes' : 'no', ' · production ', $inProduction ? 'yes' : 'no', "\n"; return $finalSide; }
To see why the cache works so strictly, separate two jobs. The first is finding the file, reading it and translating it to bytecode; the second is running the bytecode. The cache skips only the first, and the second repeats on every request. When an application loads hundreds of files, the skipped work adds up, which is why the cache stays on in production. In development the wish is the opposite: every time you save a file, you want the result to change, so a cache holding an old copy gets in your way.
opcache.validate_timestamps is the switch between the two. When it is on, the cache looks at the file’s modification time to decide whether its copy is current. When it is off, the copy stays valid until the process restarts or the file is explicitly invalidated. The reason to turn it off in production is that nobody touches the running code except through a deployment: if the code is going to change, that should be a deployment step. The natural consequence is that a cache unaware of the deployment serves old code even when the file has changed.
In the production profile, opcache.validate_timestamps is 0: the cache never looks at the file’s modification time again. In the development profile the code cache is off on the command line, so every run reads the file from disk. We measure the visible result of that difference. The script below writes two probe files and runs both of them in the two profiles through side.php. Because OPcache does not cache a file that changed within the last two seconds (opcache.file_update_protection), the probes move the files’ times into the past with touch().
<?php // cache.php — does a rewritten file run with the old version? declare(strict_types=1); require __DIR__ . '/side.php'; file_put_contents('stale.php', <<<'PHP' <?php // stale.php — writes the same path with two versions, loads it twice $path = __DIR__ . '/version.php'; file_put_contents($path, '<?php return 1;'); touch($path, time() - 60); $first = include $path; file_put_contents($path, '<?php return 2;'); touch($path, time() - 30); $after = include $path; echo $first === $after ? "yes\n" : "no\n"; PHP); file_put_contents('invalidate.php', <<<'PHP' <?php // invalidate.php — does invalidation bring the new version? $path = __DIR__ . '/version.php'; file_put_contents($path, '<?php return 1;'); touch($path, time() - 60); include $path; file_put_contents($path, '<?php return 2;'); touch($path, time() - 30); $returned = opcache_invalidate($path, true); $after = include $path; echo $returned === true && $after === 2 ? "yes\n" : "no\n"; PHP); sideReport('a rewritten file runs with the old version', 'stale.php'); sideReport('invalidation returns true and the new version arrives', 'invalidate.php');
a rewritten file runs with the old version → production only
development no · production yes
invalidation returns true and the new version arrives → production only
development no · production yes
Before reading the output, look at what the script does, line by line. stale.php writes a small file named version.php to the same path; the file only returns a number, 1 in the first version and 2 in the second. After each write, touch() moves the file’s modification time into the past, sixty seconds back for the first and thirty for the second. It matters that the two times differ: had we given the same time, even a cache with checking turned on could not tell the change apart. The file is loaded twice with include, and the two return values are compared. If they are the same, meaning the second load did not see the new version, the probe prints yes.
invalidate.php instead calls opcache_invalidate($path, true) after the second write and before the second include. The second parameter, true, asks the call to treat the cached copy as invalid without looking at the file’s time. The probe looks for two conditions together: the call returns true, and the second load gives 2. With the cache off the call returns false, because there is no copy to invalidate; this false is not an error but a “nothing to do” answer.
On the production side the first probe gives yes (PO1. The measurement ran in a command-line process, with a single process; under a process manager several worker processes share the same code cache, and this lesson did not set up that arrangement): although the file was rewritten, the second load returned the same value as the first, because the cache already knew the path and did not look at the time. On the development side the new version appeared immediately. The second probe shows the way out: opcache_invalidate($path, true) returns true in production and the new version arrives in the same request. In development the code cache is off, so the call returns false and the file is always fresh anyway. In other words, a deployment script has to invalidate the files that changed; in development this is never noticed.
Is the File in the Cache?
So far we have seen the cache’s presence only indirectly: the file changed but the result did not. There is also a way to ask directly. opcache_is_script_cached($path) tells with a boolean whether the given file is in the cache right now, and opcache_get_status(false) returns the extension’s state as an array. The array also has fields that carry numbers, but the probe reads only the on/off information and prints yes or no. Three questions are asked: is the extension on, is the loaded file in the cache, and is the invalidated file still in the cache? All three are written so that yes is the positive direction. In the third, yes means that in production invalidation removed the copy from the cache; what it means in development is read after the output.
<?php // status.php — is the file in the cache? declare(strict_types=1); require __DIR__ . '/side.php'; file_put_contents('enabled.php', <<<'PHP' <?php // enabled.php — is the code cache on in this process? $status = opcache_get_status(false); echo is_array($status) && $status['opcache_enabled'] === true ? "yes\n" : "no\n"; PHP); file_put_contents('loaded.php', <<<'PHP' <?php // loaded.php — does an included file enter the cache? $path = __DIR__ . '/target.php'; file_put_contents($path, '<?php return 1;'); touch($path, time() - 60); include $path; echo opcache_is_script_cached($path) ? "yes\n" : "no\n"; PHP); file_put_contents('dropped.php', <<<'PHP' <?php // dropped.php — does an invalidated file leave the cache? $path = __DIR__ . '/target2.php'; file_put_contents($path, '<?php return 1;'); touch($path, time() - 60); include $path; opcache_invalidate($path, true); echo !opcache_is_script_cached($path) ? "yes\n" : "no\n"; PHP); sideReport('the code cache is on', 'enabled.php'); sideReport('an included file enters the cache', 'loaded.php'); sideReport('an invalidated file leaves the cache', 'dropped.php');
the code cache is on → production only
development no · production yes
an included file enters the cache → production only
development no · production yes
an invalidated file leaves the cache → both sides
development yes · production yes
The first line of the output says whether the extension is on in this process. The no on the development side agrees with what we said in the previous section: in a command-line process the code cache is off by default, and the development profile does not touch it. The yes on the production side is the work of the opcache.enable_cli=1 directive in the profile. This line is the probe’s ground check: while reading the next two lines you need to know that the extension is really on, otherwise you may mistake the no answers for behavior.
The second line answers the real question: did the file we included enter the cache? In development no, because there is no cache; in production yes. What is measured here is that the file has a copy in memory. That copy is the basis of the old-version behavior in the first section: once a file has entered, with validate_timestamps=0 the disk is never consulted again. Reading the two measurements together completes the chain. First the file enters the cache, then the disk changes, then the old version runs because the cache does not look at the disk.
The third line gives yes on both sides, and it has to be read carefully. In production, yes shows that the opcache_invalidate($path, true) call removed the copy from the cache. In development, yes proves nothing: since the cache is off, the file never entered it, so the answer to “did it leave” is positive from the start. This is why the side of the line is both sides, and here this side does not mean “the same behavior on both sides”; it means “the measurement says something only in production”. To name a side correctly you need to know what the side measures; a probe giving the same result on both sides does not always show that the behavior is the same on both sides.
In a real production setup these three questions are the basis of post-deployment verification. The question of whether the extension is on confirms that the directive reached the process; even when the configuration file is in the right place, the directives have no effect if the extension is not loaded. The question of whether the file is in the cache tells whether warming worked: if the critical files were cached before the first request, the first user does not pay the translation cost. The question of whether the invalidated file left confirms that your deployment script really let go of the old version. This lesson measured in the command line, in a single process; under a process manager every worker process takes these three answers from the same code cache, so the results are shared.
The Bounding Measurement
If we add opcache.validate_timestamps=1 and opcache.revalidate_freq=0 to the production profile, the cache looks at the file on every request. Does the old-version problem go away? The script below runs the stale.php probe with these two directives. stale.php remains on disk from the earlier block.
<?php // bound.php — if file checking is added to production, does the old version remain? declare(strict_types=1); require __DIR__ . '/side.php'; $checked = array_replace(PROFILES['production'], [ 'opcache.validate_timestamps' => '1', 'opcache.revalidate_freq' => '0', ]); $staleRemained = run('stale.php', $checked); echo 'did the old version remain: ', $staleRemained ? 'yes' : 'no', "\n";
did the old version remain: no
The probe gave no: with file checking on, the cache looks at the modification time on every request, compiles the new version because the rewritten file’s time changed, and the old version does not remain. This setting removes the old-version problem. The price of removing it moves elsewhere: on every request the cache goes to the disk to look at the file’s modification time. That price was not measured here; only the behavior was. Keeping file checking off in production and calling opcache_invalidate() or restarting the process at deployment loads the price onto the moment of deployment instead of every request. Which one to choose depends on what your deployment arrangement allows.
The limit of this measurement should be stated openly. The probe showed that with file checking on, the cache sees the new version; but because the refresh frequency is zero, we asked for every request to go to the disk. If the refresh frequency is raised to a number of seconds above zero, it becomes possible again for the old version to run for that long after a deployment. The lesson’s side therefore does not change: the old-version behavior is seen only on the production side, when checking is off or inside the refresh window, and never on the development side. A team that has never seen the problem in the development profile meets it for the first time on the day it goes to production. The habit the measurement teaches is this: do not look for a behavior that appears only in production in development; put into your deployment step an explicit invalidation or restart that closes that behavior, and verify that this step works with the three questions above.
The probes of this lesson were kept small on purpose. The files return a one-line number, because what we want to measure is not the file’s content but which version runs. The answer is yes or no. A byte count, an amount of memory or a timestamp changes from machine to machine; which version ran does not change, and that is what can be repeated.
Finally, we justify the side of the bounding measurement. The production profile with file checking added is still on the production side, but the question it measures is different: its saying no to “did the old version remain” does not mean it approaches the behavior of the development profile. This approach is not free; as we said throughout the lesson, the price moves to disk checking. The job of the bounding measurement is to show the condition of the lesson’s main claim: the old-version problem depends on checking being off, and when that condition is lifted, the problem is lifted too.
Summary
- Bytecode is the intermediate form into which source is translated before it runs; the code cache keeps this form in memory and skips the translation on later requests.
- The side of this lesson is production only: with
opcache.validate_timestamps=0, a rewritten file runs with the old version at the same path, and this is never seen in the development profile. opcache_invalidate($path, true)returnstruein production and brings the new version in the same request; in development it returnsfalsebecause the cache is off.- Adding
opcache.validate_timestamps=1andopcache.revalidate_freq=0to production removes the old-version problem, but moves file checking back onto every request. - In the measurements the files are moved into the past with
touch(), because OPcache does not cache a file that changed within the last two seconds. - The
opcache_is_script_cached()probe shows in production that the file entered the cache and givesnoin development; the line showing that the invalidated file left is meaningless in development, so it says something only on the production side (side: both sides, with a limit).
Next Step
The code cache decides which version runs; it does not say how much work gets done. To see where a request’s time and memory go, you need profiling. The next lesson measures how the same work is counted in the two profiles, and in which profile the act of counting itself can change the result.
To keep your progress and take notes, Log in
My notes
Log in to take notes.