Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions caddy/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) {
http_port `+testPort+`

frankenphp {
num_threads 3
# one thread for regular requests, one per worker
num_threads 1
max_threads 6
worker ../testdata/worker-with-counter.php 1
worker ../testdata/index.php 1
Expand Down Expand Up @@ -140,7 +141,8 @@ func TestAutoScaleWorkerThreads(t *testing.T) {

frankenphp {
max_threads 10
num_threads 2
# one thread for regular requests, one for the worker
num_threads 1
worker ../testdata/sleep.php {
num 1
max_threads 3
Expand Down Expand Up @@ -372,7 +374,8 @@ func TestRegisteredModuleWorkerPoolsMustBeCorrect(t *testing.T) {
admin localhost:2999

frankenphp {
num_threads 4
# one thread for regular requests, one per worker
num_threads 1
worker ../testdata/worker-with-env.php 1
}
}
Expand Down
4 changes: 2 additions & 2 deletions caddy/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ func RegisterWorkers(name, fileName string, num int, wo ...frankenphp.WorkerOpti
// }
// }
type FrankenPHPApp struct {
// NumThreads sets the number of PHP threads to start. Default: 2x the number of available CPUs.
// NumThreads sets the number of PHP threads to start for the requests no worker serves, worker threads coming on top of it. Default: what is left of 2x the number of available CPUs once the workers have their threads, one at the very least.
NumThreads int `json:"num_threads,omitempty"`
// MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads
// MaxThreads limits how many threads may run at once, workers included. Default: the threads started at boot
MaxThreads int `json:"max_threads,omitempty"`
// Workers configures the worker scripts to start
Workers []workerConfig `json:"workers,omitempty"`
Expand Down
2 changes: 1 addition & 1 deletion caddy/caddy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1806,7 +1806,7 @@ func TestOpcacheReset(t *testing.T) {
metrics

frankenphp {
num_threads 40
num_threads 20
php_ini {
opcache.enable 1
opcache.log_verbosity_level 4
Expand Down
4 changes: 2 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ You can also explicitly configure FrankenPHP using the [global option](https://c
```caddyfile
{
frankenphp {
num_threads <num_threads> # Sets the number of PHP threads to start. Default: 2x the number of available CPUs.
max_threads <num_threads> # Limits the number of additional PHP threads that can be started at runtime. Default: num_threads. Can be set to 'auto'.
num_threads <num_threads> # Sets the number of PHP threads to start for the requests no worker serves, worker threads coming on top of it. Default: what is left of 2x the number of available CPUs once the workers have their threads, one at the very least.
max_threads <num_threads> # Limits how many PHP threads may run at once, workers included. Default: the threads started at boot. Can be set to 'auto'.
max_wait_time <duration> # Sets the maximum time a request may wait for a free PHP thread before timing out. Default: disabled.
max_idle_time <duration> # Sets the maximum time an autoscaled thread may be idle before being deactivated. Default: 5s.
max_requests <num> # (experimental) Sets the maximum number of requests a PHP thread will handle before being restarted, useful for mitigating memory leaks. Applies to both regular and worker threads. Default: 0 (unlimited).
Expand Down
4 changes: 2 additions & 2 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,8 @@ FrankenPHP can automatically scale the number of PHP threads based on demand (`s

### Auto-scaling configuration

- `num_threads`: Initial number of threads started at boot
- `max_threads`: Maximum number of threads allowed (includes auto-scaled)
- `num_threads`: Threads started at boot for the requests no worker serves, the worker threads come on top
- `max_threads`: Maximum number of threads allowed, workers and auto-scaled threads included

### Upscaling

Expand Down
7 changes: 5 additions & 2 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,24 @@ However, it is possible to substantially improve performance using an appropriat
By default, FrankenPHP starts 2 times more threads and workers (in worker mode) than the available number of CPU cores.

The appropriate values depend heavily on how your application is written, what it does, and your hardware.
We strongly recommend changing these values. For best system stability, it is recommended to have `num_threads` x `memory_limit` < `available_memory`.
We strongly recommend changing these values. For best system stability, it is recommended to have `max_threads` x `memory_limit` < `available_memory`, since `max_threads` bounds every thread the process runs.

To find the right values, it's best to run load tests simulating real traffic.
[k6](https://k6.io) and [Gatling](https://gatling.io) are good tools for this.

To configure the number of threads, use the `num_threads` option of the global `frankenphp` directive.

To change the number of workers, use the `num` option of the `worker` section of the `frankenphp` directive.

`num_threads` counts the threads serving the requests no worker serves, and the threads a worker starts come on top of it, so giving a worker more threads never takes capacity away from the rest of the site. `max_threads` limits the whole process, workers included, which is what the memory heuristic of `auto` bounds. The startup log reports what that adds up to, as `total_threads` and `worker_threads`.

### `max_threads`

While it's always better to know exactly what your traffic will look like, real-life applications tend to be more
unpredictable. The `max_threads` [configuration](config.md#caddyfile-config) allows FrankenPHP to automatically spawn additional threads at runtime up to the specified limit.
`max_threads` can help you figure out how many threads you need to handle your traffic and can make the server more resilient to latency spikes.
If set to `auto`, the limit will be estimated based on the `memory_limit` in your `php.ini`. If not able to do so,
`auto` will instead default to 2x `num_threads`. Keep in mind that `auto` might strongly underestimate the number of threads needed.
`auto` will instead default to twice the threads started at boot. Keep in mind that `auto` might strongly underestimate the number of threads needed.
`max_threads` is similar to PHP-FPM's [pm.max_children](https://www.php.net/manual/install.fpm.configuration.php#pm.max-children). The main difference is that FrankenPHP uses threads instead of
processes and automatically delegates them across different worker scripts and 'classic mode' as needed.

Expand Down
23 changes: 14 additions & 9 deletions frankenphp.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,13 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) {
}
}

// num_threads counts the threads serving the requests no worker serves:
// the worker threads come on top of it, so raising a worker's num never
// takes capacity away from the rest of the site
if opt.numThreads > 0 {
opt.numThreads += numWorkers
}

numThreadsIsSet := opt.numThreads > 0
maxThreadsIsSet := opt.maxThreads != 0
maxThreadsIsAuto := opt.maxThreads < 0 // maxthreads < 0 signifies auto mode (see phpmaintread.go)
Expand All @@ -258,9 +265,6 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) {

if numThreadsIsSet && !maxThreadsIsSet {
opt.maxThreads = opt.numThreads
if opt.numThreads <= numWorkers {
return 0, fmt.Errorf("num_threads (%d) must be greater than the number of worker threads (%d)", opt.numThreads, numWorkers)
}

return numWorkers, nil
}
Expand All @@ -275,8 +279,9 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) {
}

if !numThreadsIsSet {
// default: what is left of 2x the CPUs once the workers have their
// threads, and one thread at the very least
if numWorkers >= maxProcs {
// Start at least as many threads as workers, and keep a free thread to handle requests in non-worker mode
opt.numThreads = numWorkers + 1
} else {
opt.numThreads = maxProcs
Expand All @@ -287,11 +292,11 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) {
}

// both num_threads and max_threads are set
if opt.numThreads <= numWorkers {
return 0, fmt.Errorf("num_threads (%d) must be greater than the number of worker threads (%d)", opt.numThreads, numWorkers)
}

if !maxThreadsIsAuto && opt.maxThreads < opt.numThreads {
if numWorkers > 0 {
return 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d) plus the worker threads (%d)", opt.maxThreads, opt.numThreads-numWorkers, numWorkers)
}

return 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d)", opt.maxThreads, opt.numThreads)
}

Expand Down Expand Up @@ -402,7 +407,7 @@ func Init(options ...Option) error {
activateServers()

if globalLogger.Enabled(globalCtx, slog.LevelInfo) {
globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "FrankenPHP started 🐘", slog.String("php_version", Version().Version), slog.Int("num_threads", mainThread.numThreads), slog.Int("max_threads", mainThread.maxThreads), slog.Int("max_requests", maxRequestsPerThread))
globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "FrankenPHP started 🐘", slog.String("php_version", Version().Version), slog.Int("total_threads", mainThread.numThreads), slog.Int("worker_threads", workerThreadCount), slog.Int("max_threads", mainThread.maxThreads), slog.Int("max_requests", maxRequestsPerThread))

if EmbeddedAppPath != "" {
globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "embedded PHP app 📦", slog.String("path", EmbeddedAppPath))
Expand Down
10 changes: 6 additions & 4 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ func WithContext(ctx context.Context) Option {
}
}

// WithNumThreads configures the number of PHP threads to start.
// WithNumThreads configures the number of PHP threads to start for the
// requests no worker serves. Worker threads come on top of it.
func WithNumThreads(numThreads int) Option {
return func(o *opt) error {
o.numThreads = numThreads
Expand All @@ -77,6 +78,8 @@ func WithNumThreads(numThreads int) Option {
}
}

// WithMaxThreads limits how many threads may run at once, workers included.
// A negative value derives that limit from the memory available.
func WithMaxThreads(maxThreads int) Option {
return func(o *opt) error {
o.maxThreads = maxThreads
Expand Down Expand Up @@ -124,9 +127,8 @@ func WithWorkers(name, fileName string, num int, options ...WorkerOption) Option
//
// Workers are designed to run indefinitely and will be gracefully shut down when FrankenPHP shuts down.
//
// Extension workers receive the lowest priority when determining thread allocations. If the requested number of threads
// cannot be allocated, then FrankenPHP will panic and provide this information to the user (who will need to allocate
// more total threads). Don't be greedy.
// Extension workers count as worker threads: they come on top of num_threads, and Init() returns an error when
// max_threads has no room for them. Don't be greedy.
func WithExtensionWorkers(name, fileName string, numThreads int, options ...WorkerOption) (Workers, Option) {
w := &extensionWorkers{
name: name,
Expand Down
16 changes: 10 additions & 6 deletions phpmainthread_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,31 +330,35 @@ func TestCorrectThreadCalculation(t *testing.T) {
testThreadCalculation(t, maxProcs, maxProcs, &opt{})
testThreadCalculation(t, maxProcs, maxProcs, &opt{workers: oneWorkerThread})

// num_threads is set
// num_threads is set: it counts the threads for regular requests, the
// worker threads coming on top of it
testThreadCalculation(t, 1, 1, &opt{numThreads: 1})
testThreadCalculation(t, 2, 2, &opt{numThreads: 2, workers: oneWorkerThread})
testThreadCalculation(t, 3, 3, &opt{numThreads: 2, workers: oneWorkerThread})
testThreadCalculation(t, 2, 2, &opt{numThreads: 1, workers: oneWorkerThread})
testThreadCalculation(t, maxProcs+2, maxProcs+2, &opt{numThreads: 2, workers: []workerOpt{{}}})

// max_threads is set
testThreadCalculation(t, 1, 10, &opt{maxThreads: 10})
testThreadCalculation(t, 2, 10, &opt{maxThreads: 10, workers: oneWorkerThread})
testThreadCalculation(t, 5, 10, &opt{numThreads: 5, maxThreads: 10, workers: oneWorkerThread})
testThreadCalculation(t, 6, 10, &opt{numThreads: 5, maxThreads: 10, workers: oneWorkerThread})

// automatic max_threads
testThreadCalculation(t, 1, -1, &opt{maxThreads: -1})
testThreadCalculation(t, 2, -1, &opt{maxThreads: -1, workers: oneWorkerThread})
testThreadCalculation(t, 2, -1, &opt{numThreads: 2, maxThreads: -1})
testThreadCalculation(t, 3, -1, &opt{numThreads: 2, maxThreads: -1, workers: oneWorkerThread})

// max_threads should be thread minimum + sum of worker max_threads
testThreadCalculation(t, 2, 6, &opt{workers: []workerOpt{{num: 1, maxThreads: 5}}})
testThreadCalculation(t, 6, 9, &opt{workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 4, maxThreads: 4}}})
testThreadCalculation(t, 10, 14, &opt{numThreads: 10, workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 3, maxThreads: 4}}})
testThreadCalculation(t, 14, 18, &opt{numThreads: 10, workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 3, maxThreads: 4}}})

// max_threads should remain equal to overall max_threads
testThreadCalculation(t, 2, 5, &opt{maxThreads: 5, workers: []workerOpt{{num: 1, maxThreads: 3}}})
testThreadCalculation(t, 3, 5, &opt{maxThreads: 5, workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 1, maxThreads: 4}}})

// not enough num threads
testThreadCalculationError(t, &opt{numThreads: 1, workers: oneWorkerThread})
// a worker never eats into num_threads, so there is no floor to miss;
// max_threads still has to fit what starts
testThreadCalculationError(t, &opt{numThreads: 1, maxThreads: 1, workers: oneWorkerThread})

// not enough max_threads
Expand Down
3 changes: 2 additions & 1 deletion scaling_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) {

workerName := "worker1"
workerPath := filepath.Join(testDataPath, "transition-worker-1.php")
// one thread for regular requests, one for the worker, one to scale into
assert.NoError(t, Init(
WithNumThreads(2),
WithNumThreads(1),
WithMaxThreads(3),
WithWorkers(workerName, workerPath, 1,
WithWorkerEnv(map[string]string{}),
Expand Down
Loading