Shared-Nothing vs. Persistent State: High-Conforming Benchmarks of PHP-FPM vs. Python WSGI/ASGI Servers
Benchmarking Methodology: Shared-Nothing vs. Persistent State
This benchmark aims to provide a nuanced comparison between PHP-FPM’s shared-nothing architecture and Python’s WSGI/ASGI servers, specifically focusing on their performance characteristics under varying state management strategies. We will simulate high-concurrency scenarios by employing a load testing tool and measure key metrics such as requests per second (RPS), latency, and resource utilization. The core distinction lies in how application state is handled: PHP-FPM’s typical per-request isolation versus Python servers that can maintain persistent application state across requests.
Test Environment and Configuration
A consistent environment is crucial for reliable benchmarking. We’ll utilize a single-node setup for simplicity, acknowledging that distributed systems introduce further complexities. The chosen hardware is a modern server with ample CPU cores and RAM to avoid I/O bottlenecks as much as possible.
Hardware:
- CPU: 16 vCPUs
- RAM: 64 GB
- Network: 10 Gbps
Software Stack:
- Operating System: Ubuntu 22.04 LTS
- Web Server (Reverse Proxy): Nginx 1.22.1
- Load Balancer (for testing): ApacheBench (ab) or k6
PHP-FPM Configuration: Shared-Nothing Baseline
For PHP-FPM, we’ll configure it to operate in its default shared-nothing mode. Each worker process is independent, meaning no state is shared between requests handled by different workers. This isolation is a strength for security and stability but can be a performance bottleneck for stateful applications.
PHP-FPM Configuration (/etc/php/8.2/fpm/pool.d/www.conf):
; Basic configuration for a shared-nothing environment pm = dynamic pm.max_children = 100 pm.start_servers = 10 pm.min_spare_servers = 5 pm.max_spare_servers = 20 pm.process_idle_timeout = 10s request_terminate_timeout = 30s catch_workers_output = yes clear_env = no ; Ensure no persistent state is carried over implicitly env[APP_ENV] = production
Nginx Configuration (/etc/nginx/sites-available/default):
server {
listen 80;
server_name localhost;
root /var/www/html;
index index.php;
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}
Sample PHP Application (/var/www/html/index.php):
<?php
// Simulate a simple stateless operation
header('Content-Type: application/json');
echo json_encode([
'message' => 'Hello from PHP-FPM (Stateless)',
'timestamp' => time(),
'request_id' => uniqid()
]);
?>
Python WSGI/ASGI Configuration: Persistent State Potential
For Python, we’ll explore two common scenarios: a traditional WSGI server (like Gunicorn) and a modern ASGI server (like Uvicorn). The key difference here is that these servers typically run as long-lived processes, allowing for application state to be held in memory across requests. We’ll demonstrate this with a simple in-memory counter.
Python Application (app.py):
import time
import os
# Simulate persistent state (in-memory counter)
# This state will be shared across requests handled by the same worker process.
request_count = 0
def increment_request_count():
global request_count
request_count += 1
return request_count
# --- WSGI Application ---
def wsgi_app(environ, start_response):
status = '200 OK'
headers = [('Content-type', 'application/json')]
start_response(status, headers)
current_count = increment_request_count()
response_data = {
'message': 'Hello from Python WSGI (Persistent State)',
'timestamp': time.time(),
'request_id': os.urandom(8).hex(),
'request_count': current_count
}
import json
return [json.dumps(response_data).encode('utf-8')]
# --- ASGI Application ---
async def asgi_app(scope, receive, send):
assert scope['type'] == 'http'
current_count = increment_request_count()
response_data = {
'message': 'Hello from Python ASGI (Persistent State)',
'timestamp': time.time(),
'request_id': os.urandom(8).hex(),
'request_count': current_count
}
import json
response_body = json.dumps(response_data).encode('utf-8')
await send({
'type': 'http.response.start',
'status': 200,
'headers': [
[b'content-type', b'application/json'],
],
})
await send({
'type': 'http.response.body',
'body': response_body,
})
Gunicorn Configuration (WSGI):
# Run Gunicorn with 4 worker processes # Each worker process can maintain its own in-memory state. gunicorn --workers 4 --bind 0.0.0.0:8000 app:wsgi_app
Uvicorn Configuration (ASGI):
# Run Uvicorn with 4 worker processes # Similar to Gunicorn, workers maintain state. uvicorn app:asgi_app --host 0.0.0.0 --port 8000 --workers 4
Nginx Configuration (Reverse Proxy for Python Servers):
# Example for Gunicorn
server {
listen 80;
server_name python-wsgi.localhost;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Example for Uvicorn (similar proxy_pass directive)
# server {
# listen 80;
# server_name python-asgi.localhost;
#
# location / {
# proxy_pass http://127.0.0.1:8000;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }
Load Testing Scenarios and Metrics
We will conduct tests with varying concurrency levels to observe how each setup scales. The primary metrics are:
- Requests Per Second (RPS): Throughput of the system.
- Average Latency: Time taken for a request to be processed.
- 95th Percentile Latency: To understand tail latency.
- CPU Utilization: Percentage of CPU used by the web server, application server, and PHP-FPM/Python processes.
- Memory Utilization: RAM consumed by the processes.
Test 1: Stateless Operations (High RPS, Low Latency Focus)
This scenario simulates typical API endpoints or simple page renders where no session data or long-lived state is required. PHP-FPM should excel here due to its low overhead per request and efficient process management for stateless tasks. Python servers might incur more overhead per request due to the interpreter startup and framework dispatch, but their persistent nature could still yield competitive results.
Command for ApacheBench (ab):
# Test PHP-FPM ab -n 10000 -c 200 http://localhost/ # Test Python WSGI (Gunicorn) ab -n 10000 -c 200 http://python-wsgi.localhost/ # Test Python ASGI (Uvicorn) ab -n 10000 -c 200 http://python-asgi.localhost/
Test 2: Stateful Operations (Simulated)
This scenario is where Python’s persistent state advantage *could* manifest. We’ll simulate a task that benefits from in-memory caching or counters. PHP-FPM, in its default configuration, would need to re-initialize any state per request (e.g., using Redis, Memcached, or file-based caching). Python servers, with their persistent workers, can hold this state directly in RAM, potentially reducing latency for repeated access.
To fairly compare PHP-FPM in a stateful context, we’d ideally use an external caching layer like Redis. However, for this direct comparison, we’ll rely on the in-memory counter in the Python app and acknowledge that PHP-FPM is inherently stateless per worker. The Python app’s `request_count` will demonstrate the effect of shared memory within a worker process.
Expected Observations:
- PHP-FPM (Stateless): High RPS, low latency for simple requests. Resource usage might spike and then drop as processes are recycled.
- Python WSGI/ASGI (Persistent State): Potentially lower RPS than PHP-FPM in purely stateless tests due to framework overhead. However, for stateful operations (like the in-memory counter), Python servers might show lower latency and higher RPS *if* the state access is significantly faster than fetching from an external cache. Resource usage will be more stable as workers persist.
- ASGI vs. WSGI: ASGI servers like Uvicorn, being asynchronous, often exhibit better concurrency handling and lower resource usage under high load compared to synchronous WSGI servers, especially for I/O-bound tasks.
Interpreting Results and Architectural Implications
The benchmark results will highlight the trade-offs. PHP-FPM’s shared-nothing model is robust for stateless web applications, microservices, and scenarios where security and isolation are paramount. Its ability to scale by simply adding more independent processes is a significant advantage.
Python’s WSGI/ASGI servers, by allowing persistent state within worker processes, offer a different performance profile. This is advantageous for applications that heavily rely on in-memory caches, session data, or complex object graphs that are expensive to reconstruct per request. The choice between WSGI and ASGI often comes down to the nature of the application’s workload: ASGI’s asynchronous capabilities shine for I/O-bound operations and high concurrency.
Key architectural considerations:
- State Management Strategy: If your application is inherently stateless, PHP-FPM is a strong contender. If it benefits from in-memory state, Python servers (especially ASGI) become more attractive.
- Complexity of State: For complex, shared state across *all* requests (not just within a worker), external solutions like Redis or Memcached are necessary regardless of the language or framework. The benchmark here focuses on state *within* the application server’s process lifecycle.
- Development Ecosystem: The maturity and availability of libraries, frameworks, and developer talent in each ecosystem are critical non-performance factors.
- Operational Overhead: Managing long-lived Python processes (and their potential memory leaks) versus the ephemeral nature of PHP-FPM workers presents different operational challenges.
Ultimately, the “better” choice depends entirely on the specific requirements of the application being built. This benchmark provides empirical data to inform that decision, moving beyond theoretical advantages to concrete performance metrics.