Skip to content
academia.sh

Lesson 08 / 10

The Process Manager and the Web Server

Measuring with the built-in web server who serves the requests and what worker processes do not share between requests: why state must be kept outside the process.

Published Updated

In this lesson

  • Distinguish the concepts of web server, process manager and worker process
  • Start the built-in web server with two workers and measure that a page returns the same status code in two profiles
  • Interpret that state kept in a worker process's memory is not carried across requests, while state written to a file is
Contents

When a browser sends a request to a PHP application, two layers come between. The web server accepts the connection and reads the request. The process manager starts the worker processes that run the PHP code, watches them, and distributes requests among them. The manager can increase or decrease the number of workers and can put a new one in place of a worker that crashed. All of the details depend on the production setup; in this lesson, instead of that setup, a small model of the same job is measured: the built-in web server.

The built-in server runs only with php -S and is not designed for production; support for multiple worker processes is an experimental feature that came with PHP 7.4.0 and is not supported on Windows. In production, the web server and the process manager are set up with a separate configuration. The built-in server reduces this structure to a measurable scope: the PHP_CLI_SERVER_WORKERS environment variable sets the number of worker processes, and each worker is a separate process. The measuring question is the shared question of this course: does this behavior reach production? side.php is below verbatim and is only written to disk.

<?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;
}

First we write the script that starts the server in the two profiles. The server is always opened with -d display_errors=0 and only on the 127.0.0.1 address. The profile directives are taken from the PROFILES table in side.php; the one deliberate difference is the display_errors directive: since this lesson measures the log, not the response body, it is given as 0 in both profiles, and the development value 1 in the table is not used here. Both profiles start with the same number of workers. The script writes two pages: counter.php increments an in-memory counter on every request and prints it, and warning.php produces a warning by reading an undefined variable. It waits with curl until the server is ready and stops it with kill at the end. The script also writes its results to the server-result.txt file.

# server.sh — starts the built-in server with two workers in two profiles
cat > counter.php <<'EOF'
<?php
$GLOBALS['counter'] = ($GLOBALS['counter'] ?? 0) + 1;
echo $GLOBALS['counter'];
EOF
cat > warning.php <<'EOF'
<?php
echo $undefined;
echo 'done';
EOF

DEVELOPMENT="-d display_startup_errors=1 -d error_reporting=32767 -d mysqlnd.collect_memory_statistics=1 -d zend.assertions=1 -d zend.exception_ignore_args=0 -d zend.exception_string_param_max_len=15"
PRODUCTION="-d display_startup_errors=0 -d error_reporting=24575 -d mysqlnd.collect_memory_statistics=0 -d zend.assertions=-1 -d zend.exception_ignore_args=1 -d zend.exception_string_param_max_len=0 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0"
: > server-result.txt

for profile in development production; do
  if [ "$profile" = development ]; then directives=$DEVELOPMENT; else directives=$PRODUCTION; fi
  rm -f log-$profile.txt
  PHP_CLI_SERVER_WORKERS=2 php -n -d display_errors=0 -d log_errors=1 -d error_log=log-$profile.txt \
    $directives -S 127.0.0.1:8471 >/dev/null 2>&1 &
  pid=$!
  for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8471/counter.php && break; sleep 0.1; done
  first=$(curl -s http://127.0.0.1:8471/counter.php)
  second=$(curl -s http://127.0.0.1:8471/counter.php)
  third=$(curl -s http://127.0.0.1:8471/counter.php)
  status=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8471/warning.php)
  kill $pid; wait $pid 2>/dev/null
  warnings=$(grep -c Warning log-$profile.txt)
  echo "$profile $first $second $third $status $warnings" >> server-result.txt
  echo "$profile: counter $first $second $third · warning page $status · warnings in log $warnings"
done
development: counter 1 1 1 · warning page 200 · warnings in log 1
production: counter 1 1 1 · warning page 200 · warnings in log 1

Now we read the results of the two profiles and derive the side. There are three measuring questions: is the counter 1 on every request, is the warning page 200, and did the warning reach the log? The script checks whether each is the same in the two profiles and names it with the side() function in side.php.

<?php
// server-side.php — derives the side of the server measurements
declare(strict_types=1);
require __DIR__ . '/side.php';

$rows = array_map(
    fn(string $row): array => explode(' ', trim($row)),
    file('server-result.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES),
);
[$development, $production] = $rows;

$measurements = [
    'the counter is 1 on every request' => fn(array $r): bool => array_slice($r, 1, 3) === ['1', '1', '1'],
    'a warning page returns 200' => fn(array $r): bool => $r[4] === '200',
    'a warning reaches the log' => fn(array $r): bool => (int) $r[5] === 1,
];

foreach ($measurements as $name => $measurement) {
    echo $name, ' → ', side($measurement($development), $measurement($production)), "\n";
}
the counter is 1 on every request → both sides
a warning page returns 200 → both sides
a warning reaches the log → both sides

The counter came out 1 on every request in both profiles; because the two worker processes carry separate memory, the in-memory counter did not accumulate across requests. The warning page returned 200 in both profiles, and the warning was written to the log, not to the screen. This is why in production a page can look healthy even though it produces a warning: the status code does not report the warning, and the way to see it is the log. (PO4. Each profile restarted the server from scratch with its own settings; changing the settings of a running server was not measured.)

State per Request

The previous measurement showed that an in-memory counter does not accumulate across requests. This section asks the same question for four different kinds of state. The places where state can live in PHP are known: a class’s static property, a global variable, a static variable inside a function, and the side effect of a file being loaded with include_once. Each of the four places keeps a counter, and the page prints all four at once. Our expectation is that all come out 1, because PHP builds the execution environment from scratch on every request. Three requests are sent and only the response of the third is printed: if there were accumulation, the numbers would show 3 there.

# state.sh — what state is carried per request?
cat > loaded.php <<'EOF'
<?php
$GLOBALS['loaded'] = ($GLOBALS['loaded'] ?? 0) + 1;
EOF
cat > state.php <<'EOF'
<?php
final class Holder {
    public static int $n = 0;
}
function tick(): int {
    static $c = 0;
    return ++$c;
}
Holder::$n++;
$GLOBALS['g'] = ($GLOBALS['g'] ?? 0) + 1;
include_once __DIR__ . '/loaded.php';
echo 'static property ', Holder::$n, ' · global ', $GLOBALS['g'],
    ' · static inside function ', tick(), ' · include_once ', $GLOBALS['loaded'];
EOF

DEVELOPMENT="-d display_startup_errors=1 -d error_reporting=32767 -d mysqlnd.collect_memory_statistics=1 -d zend.assertions=1 -d zend.exception_ignore_args=0 -d zend.exception_string_param_max_len=15"
PRODUCTION="-d display_startup_errors=0 -d error_reporting=24575 -d mysqlnd.collect_memory_statistics=0 -d zend.assertions=-1 -d zend.exception_ignore_args=1 -d zend.exception_string_param_max_len=0 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0"

for profile in development production; do
  if [ "$profile" = development ]; then directives=$DEVELOPMENT; else directives=$PRODUCTION; fi
  PHP_CLI_SERVER_WORKERS=2 php -n -d display_errors=0 $directives -S 127.0.0.1:8471 >/dev/null 2>&1 &
  pid=$!
  for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8471/state.php && break; sleep 0.1; done
  curl -s -o /dev/null http://127.0.0.1:8471/state.php
  curl -s -o /dev/null http://127.0.0.1:8471/state.php
  last=$(curl -s http://127.0.0.1:8471/state.php)
  kill $pid; wait $pid 2>/dev/null
  echo "$profile: $last"
done
development: static property 1 · global 1 · static inside function 1 · include_once 1
production: static property 1 · global 1 · static inside function 1 · include_once 1

We read the arrangement the script sets up. state.php increments a counter in four separate places. The first is the static property of the Holder class; a static property belongs to the class, not to an object, and so one might think it “lives for the whole process”. The second is the g key in the global array. The third is the static $c variable inside the tick() function; this variable keeps its value between function calls. The fourth is the loaded.php file: include_once loads a file once, and the code inside the file increments a global counter. The “once” in the name include_once means once within a request; our question is whether the file is loaded again in a new request.

Both lines of the output show all four numbers as 1. This is the measured form of PHP’s request model: every request is built from scratch as far as user code is concerned, even if the worker process stays the same. The static property is reset, because the class definition is created again with its values on every request. The global array is emptied when the request ends. The static variable inside the function starts from zero, because the function is defined anew on every request. include_once loads the file again on every request and increments the counter inside it from zero. All four are the result of the same rule, and that they are the same in the two profiles shows that the rule does not depend on the settings; this is why the side of the section is both sides.

This result has two aspects in real production. The positive aspect is that the dirt a request leaves behind does not spoil the next one: a request that leaks does not affect the variables of the next request. The model that gives this property cheaply takes its price from another place: every request builds the application from scratch. The web application framework reading its configuration, loading classes and opening connections is repeated on every request. The code cache removes only one part of this price, the work of translating files. The remaining part, building the objects, is the natural result of per-request state. Long-lived execution models keep state between requests to reduce this price, and in return they have to manage the risk of leaks themselves. Those models were not set up in this lesson; what was measured is only the classic model of one request per worker process.

The Bounding Measurement

If state in memory is not carried across requests, would writing state to a file be a solution? The script below starts the page that keeps the counter in a file in the two profiles and prints the values returned by three requests.

# bound.sh — if state is written to a file, is it carried across requests?
cat > file-backed.php <<'EOF'
<?php
$path = __DIR__ . '/counter.txt';
$n = (int) @file_get_contents($path) + 1;
file_put_contents($path, (string) $n);
echo $n;
EOF

DEVELOPMENT="-d display_startup_errors=1 -d error_reporting=32767 -d mysqlnd.collect_memory_statistics=1 -d zend.assertions=1 -d zend.exception_ignore_args=0 -d zend.exception_string_param_max_len=15"
PRODUCTION="-d display_startup_errors=0 -d error_reporting=24575 -d mysqlnd.collect_memory_statistics=0 -d zend.assertions=-1 -d zend.exception_ignore_args=1 -d zend.exception_string_param_max_len=0 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0"

for profile in development production; do
  if [ "$profile" = development ]; then directives=$DEVELOPMENT; else directives=$PRODUCTION; fi
  rm -f counter.txt
  PHP_CLI_SERVER_WORKERS=2 php -n -d display_errors=0 $directives -S 127.0.0.1:8472 >/dev/null 2>&1 &
  pid=$!
  for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8472/file-backed.php && break; sleep 0.1; done
  rm -f counter.txt
  first=$(curl -s http://127.0.0.1:8472/file-backed.php)
  second=$(curl -s http://127.0.0.1:8472/file-backed.php)
  third=$(curl -s http://127.0.0.1:8472/file-backed.php)
  kill $pid; wait $pid 2>/dev/null
  echo "$profile: file counter $first $second $third"
done
development: file counter 1 2 3
production: file counter 1 2 3

The counter in the file came out 1 2 3 in both profiles: the two workers saw the same file, and state was carried across requests. The in-memory counter had stayed 1 1 1. The difference is not in the worker’s memory but in where the state is kept. Variables tied to worker processes, static properties and cached objects are not reliably shared across requests; state that must be shared is kept outside the process, in a file, a database or a cache server. Here we used the file because it is the smallest external store for measurement; three sequential requests do not collide. For two workers writing at the same time, a file lock or a real store is needed, and this was not measured.

These measurements together shed light on the decision about the number of workers. Each worker process runs with its own request memory; two workers can serve two requests at the same time, but they do not see each other’s memory. So increasing the number of worker processes increases the number of requests that can be served at the same time and also increases total memory use. Raising the number without knowing the typical memory amount of a worker process can lead to exceeding the machine’s memory and slowing the system. Concurrency comes with a number, but memory sets the upper limit of the number. Writing “this much memory per worker, this many workers in total” in your deployment document instead of “two workers” makes the decision explainable afterwards.

(PO6. The server’s profile directives are the same as the PROFILES table in side.php, except that display_errors is given as 0 in both profiles; the development value in the table is 1. This lesson measured only the log and the status code, not the response body; the effect of the display_errors difference on the response was not measured and is covered in the next configuration tuning lesson.) The built-in server’s worker processes were chosen for measurement. How the process manager determines the number of workers in production, after how many requests workers are restarted, and what happens when a worker crashes are configuration topics and are outside the scope of this measurement. The measurement showed that even with two workers the two profiles give the same state and that state is not carried across requests. These two pieces of information will be the ground when discussing configuration differences in the next lesson. In short: state lives outside the worker process, and the number of worker processes is therefore a deployment decision that weighs concurrency against memory.

Summary

  • The web server takes the connection; the process manager starts the worker processes that run the PHP code, watches them and distributes requests among them.
  • The built-in web server runs only with php -S and is not for production; in this lesson it was used as a small model with two workers.
  • The side of this lesson is both sides: the in-memory counter gave 1 on every request in both profiles, the warning page returned 200 in both profiles, and the warning reached the log.
  • The status code does not report a warning; in production the way to see the warning is the log.
  • State in memory is not carried across requests, state in a file is: state to be shared is kept outside the process.
  • The static property, global variable, static variable inside a function and include_once counters all gave 1 on the third request; per-request state is the same in the two profiles (side: both sides).

Next Step

The next lesson starts with the worker process settings: the memory limit, error reporting and code cache directives are given when the process starts. The last lesson writes how the two profiles diverge in seven directives and measures in which profile changing a directive at run time with ini_set() is accepted.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close