Skip to content

Commit f458ebf

Browse files
committed
docs: improver worker docs, and add internals docs
1 parent efbbfa3 commit f458ebf

File tree

2 files changed

+275
-0
lines changed

2 files changed

+275
-0
lines changed

docs/internals.md

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
# Internals
2+
3+
This document explains FrankenPHP's internal architecture, focusing on thread management, the state machine, and the CGO boundary between Go and C/PHP.
4+
5+
## Overview
6+
7+
FrankenPHP embeds the PHP interpreter directly into Go via CGO. Each PHP execution runs on a real POSIX thread (not a goroutine) because PHP's ZTS (Zend Thread Safety) model requires it. Go orchestrates these threads through a state machine, while C handles the PHP SAPI lifecycle.
8+
9+
The main layers are:
10+
11+
1. **Go layer** (`frankenphp.go`, `phpthread.go`, `threadworker.go`, `threadregular.go`, `scaling.go`): Thread pool management, request routing, auto-scaling
12+
2. **C layer** (`frankenphp.c`): PHP SAPI implementation, script execution loop, superglobal management
13+
3. **State machine** (`internal/state/`): Synchronization between Go goroutines and C threads
14+
15+
## Thread Types
16+
17+
### Main Thread (`phpmainthread.go`)
18+
19+
The main PHP thread (`phpMainThread`) initializes the PHP runtime:
20+
21+
1. Applies `php.ini` overrides
22+
2. Takes a snapshot of the environment (`main_thread_env`) for sandboxing
23+
3. Starts the PHP SAPI module
24+
4. Signals readiness to the Go side
25+
26+
It stays alive for the lifetime of the server. All other threads are started after it signals `Ready`.
27+
28+
### Regular Threads (`threadregular.go`)
29+
30+
Handle classic one-request-per-invocation PHP scripts. Each request:
31+
32+
1. Receives a request via `requestChan` or the shared `regularRequestChan`
33+
2. Returns the script filename from `beforeScriptExecution()`
34+
3. The C layer executes the PHP script
35+
4. `afterScriptExecution()` closes the request context
36+
37+
### Worker Threads (`threadworker.go`)
38+
39+
Keep a PHP script alive across multiple requests. The PHP script calls `frankenphp_handle_request()` in a loop:
40+
41+
1. `beforeScriptExecution()` returns the worker script filename
42+
2. The C layer starts executing the PHP script
43+
3. The PHP script calls `frankenphp_handle_request()`, which calls `waitForWorkerRequest()` in Go
44+
4. Go blocks until a request arrives, then sets up the request context
45+
5. The PHP callback handles the request
46+
6. `go_frankenphp_finish_worker_request()` cleans up the request context
47+
7. The PHP script loops back to step 3
48+
49+
Worker threads are restarted when the script exits (exit code 0), with exponential backoff on failure.
50+
51+
## Thread State Machine
52+
53+
Each thread has a `ThreadState` (defined in `internal/state/state.go`) that governs its lifecycle. The state machine uses a `sync.RWMutex` for all state transitions and a channel-based subscriber pattern for blocking waits.
54+
55+
### States
56+
57+
```
58+
Lifecycle: Reserved → Booting → Inactive → Ready ⇄ (processing)
59+
60+
Shutdown: ShuttingDown → Done → Reserved
61+
62+
Restart: Restarting → Yielding → Ready
63+
64+
Handler transition: TransitionRequested → TransitionInProgress → TransitionComplete
65+
```
66+
67+
| State | Description |
68+
|-------|-------------|
69+
| `Reserved` | Thread slot allocated but not booted. Can be booted on demand. |
70+
| `Booting` | Underlying POSIX thread is starting up. |
71+
| `Inactive` | Thread is alive but has no handler assigned. Minimal memory footprint. |
72+
| `Ready` | Thread has a handler and is ready to accept work. |
73+
| `ShuttingDown` | Thread is shutting down. |
74+
| `Done` | Thread has completely shut down. Transitions back to `Reserved` for potential reuse. |
75+
| `Restarting` | Worker thread is being restarted (e.g., via admin API or file watcher). |
76+
| `Yielding` | Worker thread has yielded control and is waiting to be re-activated. |
77+
| `TransitionRequested` | A handler change has been requested from the Go side. |
78+
| `TransitionInProgress` | The C thread has acknowledged the transition request. |
79+
| `TransitionComplete` | The Go side has installed the new handler. |
80+
81+
### Key Operations
82+
83+
**`RequestSafeStateChange(nextState)`**: The primary way external goroutines request state changes. It:
84+
- Atomically succeeds from `Ready` or `Inactive` (under mutex)
85+
- Returns `false` immediately from `ShuttingDown`, `Done`, or `Reserved`
86+
- Blocks and retries from any other state, waiting for `Ready`, `Inactive`, or `ShuttingDown`
87+
88+
This guarantees mutual exclusion: only one of `shutdown()`, `setHandler()`, or `drainWorkerThreads()` can succeed at a time on a given thread.
89+
90+
**`WaitFor(states...)`**: Blocks until the thread reaches one of the specified states. Uses a channel-based subscriber pattern so waiters are efficiently notified.
91+
92+
**`Set(nextState)`**: Unconditional state change. Used by the thread itself (from C callbacks) to signal state transitions.
93+
94+
**`CompareAndSwap(compareTo, swapTo)`**: Atomic compare-and-swap. Used for boot initialization.
95+
96+
### Handler Transition Protocol
97+
98+
When a thread needs to change its handler (e.g., from inactive to worker):
99+
100+
```
101+
Go side (setHandler) C side (PHP thread)
102+
───────────────── ─────────────────
103+
RequestSafeStateChange(
104+
TransitionRequested)
105+
close(drainChan)
106+
detects drain
107+
Set(TransitionInProgress)
108+
WaitFor(TransitionInProgress)
109+
→ unblocked WaitFor(TransitionComplete)
110+
handler = newHandler
111+
drainChan = make(chan struct{})
112+
Set(TransitionComplete)
113+
→ unblocked
114+
newHandler.beforeScriptExecution()
115+
```
116+
117+
This protocol ensures the handler pointer is never read and written concurrently.
118+
119+
### Worker Restart Protocol
120+
121+
When workers are restarted (e.g., via admin API):
122+
123+
```
124+
Go side (RestartWorkers) C side (worker thread)
125+
───────────────── ─────────────────
126+
RequestSafeStateChange(
127+
Restarting)
128+
close(drainChan)
129+
detects drain in waitForWorkerRequest()
130+
returns false → PHP script exits
131+
beforeScriptExecution():
132+
state is Restarting →
133+
Set(Yielding)
134+
WaitFor(Yielding)
135+
→ unblocked WaitFor(Ready, ShuttingDown)
136+
drainChan = make(chan struct{})
137+
Set(Ready)
138+
→ unblocked
139+
beforeScriptExecution() recurse:
140+
state is Ready → normal execution
141+
```
142+
143+
## CGO Boundary
144+
145+
### Exported Go Functions
146+
147+
C code calls Go functions via CGO exports. The main callbacks are:
148+
149+
| Function | Called when |
150+
|----------|-----------|
151+
| `go_frankenphp_before_script_execution` | C loop needs the next script to execute |
152+
| `go_frankenphp_after_script_execution` | PHP script has finished executing |
153+
| `go_frankenphp_worker_handle_request_start` | Worker's `frankenphp_handle_request()` is called |
154+
| `go_frankenphp_finish_worker_request` | Worker request handler has returned |
155+
| `go_ub_write` | PHP produces output (`echo`, etc.) |
156+
| `go_read_post` | PHP reads POST body (`php://input`) |
157+
| `go_read_cookies` | PHP reads cookies |
158+
| `go_write_headers` | PHP sends response headers |
159+
| `go_sapi_flush` | PHP flushes output |
160+
| `go_log_attrs` | PHP logs a structured message |
161+
162+
All these functions receive a `threadIndex` parameter identifying the calling thread. This is a thread-local variable in C (`__thread uintptr_t thread_index`) set during thread initialization.
163+
164+
### C Thread Main Loop
165+
166+
Each PHP thread runs `php_thread()` in `frankenphp.c`:
167+
168+
```c
169+
while ((scriptName = go_frankenphp_before_script_execution(thread_index))) {
170+
php_request_startup();
171+
php_execute_script(&file_handle);
172+
php_request_shutdown();
173+
go_frankenphp_after_script_execution(thread_index, exit_status);
174+
}
175+
```
176+
177+
Bailouts (fatal PHP errors) are caught by `zend_catch`, which marks the thread as unhealthy and forces cleanup.
178+
179+
### Memory Management
180+
181+
- **Go → C strings**: `C.CString()` allocates with `malloc()`. The C side is responsible for freeing (e.g., `frankenphp_free_request_context()` frees cookie data).
182+
- **Go string pinning**: `thread.Pin()` / `thread.Unpin()` pins Go memory so C can safely reference it during script execution without copying. Unpinned after each script execution.
183+
- **PHP memory**: Managed by Zend's memory manager (`emalloc`/`efree`). Automatically freed at request shutdown.
184+
185+
## Auto-Scaling
186+
187+
FrankenPHP can automatically scale the number of PHP threads based on demand (`scaling.go`).
188+
189+
### Configuration
190+
191+
- `num_threads`: Initial number of threads started at boot
192+
- `max_threads`: Maximum number of threads allowed (includes auto-scaled)
193+
194+
### Upscaling
195+
196+
A single goroutine (`startUpscalingThreads`) reads from an unbuffered `scaleChan`:
197+
198+
1. A request handler can't find an available thread
199+
2. It sends the request context to `scaleChan`
200+
3. The scaling goroutine checks:
201+
- Has the request been stalled long enough? (minimum 5ms)
202+
- Is CPU usage below the threshold? (80%)
203+
- Is the thread limit reached?
204+
4. If all checks pass, a new thread is booted and assigned
205+
206+
### Downscaling
207+
208+
A separate goroutine (`startDownScalingThreads`) periodically checks (every 5s) for idle auto-scaled threads. Threads idle longer than `maxIdleTime` (default 5s) are shut down, up to 10 per cycle.
209+
210+
## Environment Sandboxing
211+
212+
FrankenPHP sandboxes environment variables per-thread:
213+
214+
1. At startup, the main thread snapshots `os.Environ()` into `main_thread_env` (a PHP `HashTable`)
215+
2. Each request copies `main_thread_env` into `$_SERVER`
216+
3. `frankenphp_putenv()` / `frankenphp_getenv()` use a thread-local `sandboxed_env` copy, preventing race conditions on the global environment
217+
4. The sandboxed environment is reset between requests via `reset_sandboxed_environment()`
218+
219+
## Request Flow (Regular Mode)
220+
221+
1. HTTP request arrives at Caddy
222+
2. FrankenPHP's Caddy module resolves the PHP script path
223+
3. A `frankenPHPContext` is created with the request and script info
224+
4. The context is sent to an available regular thread via `requestChan`
225+
5. The thread's `beforeScriptExecution()` returns the script filename
226+
6. The C layer executes the PHP script
227+
7. During execution, Go callbacks handle I/O (`go_ub_write`, `go_read_post`, etc.)
228+
8. After execution, `afterScriptExecution()` signals completion
229+
9. The response is sent to the client
230+
231+
## Request Flow (Worker Mode)
232+
233+
1. HTTP request arrives at Caddy
234+
2. FrankenPHP's Caddy module resolves the worker for this request
235+
3. A `frankenPHPContext` is created
236+
4. The context is sent to the worker's `requestChan` or a specific thread's `requestChan`
237+
5. The worker thread's `waitForWorkerRequest()` receives it
238+
6. PHP's `frankenphp_handle_request()` callback is invoked
239+
7. After the callback returns, `go_frankenphp_finish_worker_request()` cleans up
240+
8. The worker loops back to `waitForWorkerRequest()`

docs/worker.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,38 @@ $handler = static function () use ($workerServer) {
168168

169169
// ...
170170
```
171+
172+
Most superglobals (`$_GET`, `$_POST`, `$_COOKIE`, `$_FILES`, `$_SERVER`, `$_REQUEST`) are automatically reset between requests.
173+
However, **`$_ENV` is not reset between requests** for performance reasons.
174+
This means that any modifications made to `$_ENV` during a request will persist and be visible to subsequent requests handled by the same worker thread.
175+
Avoid storing request-specific or sensitive data in `$_ENV`.
176+
177+
## State Persistence
178+
179+
Because worker mode keeps the PHP process alive between requests, the following state persists across requests:
180+
181+
- **Static variables**: Variables declared with `static` inside functions or methods retain their values between requests.
182+
- **Class static properties**: Static properties on classes persist between requests.
183+
- **Global variables**: Variables in the global scope of the worker script persist between requests.
184+
- **In-memory caches**: Any data stored in memory (arrays, objects) outside the request handler persists.
185+
186+
This is by design and is what makes worker mode fast. However, it requires attention to avoid unintended side effects:
187+
188+
```php
189+
<?php
190+
function getCounter(): int {
191+
static $count = 0;
192+
return ++$count; // Increments across requests!
193+
}
194+
195+
$handler = static function () {
196+
echo getCounter(); // 1, 2, 3, ... for each request on this thread
197+
};
198+
199+
for ($nbRequests = 0; ; ++$nbRequests) {
200+
if (!\frankenphp_handle_request($handler)) break;
201+
}
202+
```
203+
204+
When writing worker scripts, make sure to reset any request-specific state at the beginning of each request handler.
205+
Frameworks like [Symfony](symfony.md) and [Laravel Octane](laravel.md) handle this automatically.

0 commit comments

Comments
 (0)