Close idle LSAPI connections when several httpd workers share one lsphp by bugfloyd · Pull Request #524 · litespeedtech/openlitespeed · GitHub
Skip to content

Close idle LSAPI connections when several httpd workers share one lsphp - #524

Open
bugfloyd wants to merge 1 commit into
litespeedtech:masterfrom
bugfloyd:fix/issue-523-lsapi-idle-conn-pinning
Open

bugfloyd wants to merge 1 commit into
litespeedtech:masterfrom
bugfloyd:fix/issue-523-lsapi-idle-conn-pinning

Conversation

@bugfloyd

Copy link
Copy Markdown

With more than one httpd worker process, idle connections parked in the processes' pools can hold every
PHP_LSAPI_CHILDREN child of a self-managed lsphp. Requests in the other processes then wait in lsphp's
listen queue until LSAPI's idle timeout frees a child, 300 s by default. With the stock
extProcessor lsphp (LSAPI_AVOID_FORK) that is a full stall:

  • a single burst of about 40 requests stalls PHP for 4–5 minutes, with 503 after 180 s;
  • steady load of just 4 concurrent clients on a trivial page eventually does the same.

Details, evidence and a reproduction are in #523.

Cause

  • Each httpd worker process keeps its own pool for the application, sized by maxConns to the whole
    application. All of them share one lsphp and its PHP_LSAPI_CHILDREN children.
  • An LSAPI connection holds one child for as long as it is open.
  • Idle connections are kept indefinitely. ExtWorker::recycleConn() and processPending() park a
    connection with no queued request, and idle LSAPI connections are only closed after
    pcKeepAliveTimeout, which defaults to INT_MAX.

So connections held by processes that don't need them can pin every child, while the processes that do
need one queue new connections that no child will accept. With LSAPI_AVOID_FORK, LSAPI allows no extra
children and reclaims no idle ones, so nothing breaks the deadlock until the idle timeout.

Change

  • ExtWorker::reuseConn() is now the one place that returns a connection with no request to the
    pool. If the worker doesn't keep idle connections, it closes the connection first. The pool keeps the
    object, and the next request connects it again through the existing reconnect path in assignReq().
  • ExtWorker::keepIdleConn() is a new virtual, 1 by default. LsapiWorker::keepIdleConn()
    returns 0 only for a self-managed application running with LSAPI_AVOID_FORK when httpdWorkers > 1.
    • In that mode LSAPI's children persist and accept from the shared listener themselves, so a finished
      child goes straight back to it, and reconnecting costs one connect().
    • Without LSAPI_AVOID_FORK, LSAPI's parent forks a child for each new connection, so closing would
      mean a fork per request. There LSAPI reclaims idle children itself, and connections are kept as
      before.

Unchanged:

  • a single httpd worker process;
  • LSAPI without LSAPI_AVOID_FORK;
  • LSAPI applications that aren't self-managed;
  • FastCGI, proxy and every other handler type.

Alternatives I measured and rejected:

  • A short idle timeout (pcKeepAliveTimeout 1) fixes a single burst, but not steady load. The
    processes still hold more connections than there are children: 49 req/s at 40 clients on the stock
    config.
  • A per-process share of maxConns caps a process at 1–2 connections with the stock 10 children
    and 8 workers. Bursts often arrive in one process, over one CDN connection.
  • Closing idle connections in every LSAPI mode fixed everything, but without LSAPI_AVOID_FORK it
    made LSAPI fork a child per request: about 1,100 req/s for one client instead of 6,600.

Testing

Built from master with ./build.sh -o OFF -l OFF -p OFF on Ubuntu 24.04. For each run, the binary of a
fresh stock 1.9.2 install from repo.litespeed.sh was replaced with the build. Everything else was stock:
extProcessor lsphp untouched, 8 httpd worker processes (a 12-CPU host), lsphp83 8.3.33. "master" below
is the same build without this commit. The scripts are in #523.

A burst of 40 requests to a PHP page taking 0.7 s:

master with this PR
Burst 10 in 1 s, 11 in 61–62 s, 8 in 121 s, 11 × 503 at 180 s all 40 in ≤ 3 s (20 / 10 / 10 in 1 / 2 / 3 s)
"Reached max children" in stderr.log 2,108 lines over 210 s 21 lines over 2 s
A single visitor during it 503 after 180 s 0.7 s

3 s is the floor: 40 × 0.7 s ÷ 10 children. httpdWorkers 1 gives the same on master.

Five bursts of 40, 20 s apart, over steady traffic of about 5 req/s, for 2 minutes:

master with this PR
Burst requests 136 × 200, 64 × 500 200 × 200
Steady requests 362 × 200, 181 × 500 542 × 200
Slowest 301 s 3.5 s
error.log 110 × "No request delivery notification … possible dead lock", 38 × "ExtConn timed out while processing" nothing
Connections to lsphp.sock 8 s after the last request 8, children still pinned 0

Throughput of a trivial PHP page (<?php echo "ok";), ab -n 10000, in req/s:

Config Build 1 client 4 10 40
stock (LSAPI_AVOID_FORK) master 6,611 stalls (ab timeout) stalls —
this PR 5,757 17,665 19,104 18,666
LSAPI_AVOID_FORK removed master 6,665 4,972 2,836 —
this PR (not engaged) 6,511 4,972 2,845 1,333

The cost is the reconnect: about 22 µs per request for a single client on a page that does nothing,
5,757 req/s against 6,611. Real PHP pages take milliseconds, and with concurrency the change is a large
gain rather than a cost.

No change where it shouldn't engage:

  • httpdWorkers 1: the fix never engaged. Idle connections were kept (10 still open after the
    burst), and the burst took ≤ 2.8 s.
  • LSAPI_AVOID_FORK removed: the fix never engaged, and all 40 were served in ≤ 8.8 s.

With the patch, error.log showed no connection errors in any run.

Unit tests: ols_unittest, in a Debug build with AddressSanitizer as the repo's CMake sets it up,
passes 177 of 177, the same as master. AddressSanitizer reported nothing. None of those tests exercise
ExtWorker, LsapiWorker or the connection pool, which is why the tests above ran against the real
server.

Not addressed here

Without LSAPI_AVOID_FORK the pinning still happens, but LSAPI's own reclaim keeps it from stalling
outright.
Under sustained concurrency it still costs a lot: 17 to 1,333 req/s at 40 clients across
runs of the trivial page, against about 6,600 for one client. Fixing that cheaply needs LSAPI's help,
for example a child idle on a connection giving it up when others are waiting in the listen queue. That
belongs in sapi/litespeed, and is described in #523.

Fixes #523

…e lsphp

Every httpd worker process keeps its own pool of connections to an
external application, sized by maxConns to the whole application. A
self-managed lsphp is shared by all of them, and each open LSAPI
connection holds one of its PHP_LSAPI_CHILDREN children. recycleConn()
and processPending() parked connections with no queued request, and
idle LSAPI connections were only closed after pcKeepAliveTimeout,
INT_MAX by default. After a burst, or under steady concurrency above
the number of children, connections parked in processes that did not
need them could hold every child, while requests in the other processes
waited in lsphp's listen queue until LSAPI's idle timeout (300 s) freed
one. With LSAPI_AVOID_FORK, as in the default config, LSAPI allows no
extra children and reclaims no idle ones, so that is a stall of several
minutes with 5xx after 180 s.

Return connections with no request to the pool through reuseConn(),
which closes them when the worker does not keep idle connections; the
pool keeps the object and the next request reconnects it.
LsapiWorker::keepIdleConn() returns 0 for a self-managed application
running with LSAPI_AVOID_FORK when httpdWorkers > 1. Its children
persist and accept from the shared listener themselves, so a finished
child goes straight back to it and reconnecting costs one connect().
Without LSAPI_AVOID_FORK, LSAPI forks a child for each new connection
and reclaims idle children itself, so connections are kept there, as
they are for a single httpd worker and every other handler type.
@GoldenSW

Copy link
Copy Markdown

Reproduced #523 on OpenLiteSpeed 1.9.3 (vendor binary built from e11d64a) with Remi PHP 8.5.11 on AlmaLinux 10.2, 12 cores, 32 GB RAM. WordPress runs in one detached PHP process group:

httpdWorkers           2
maxConns               32
PHP_LSAPI_CHILDREN=32
LSAPI_AVOID_FORK=200M
LSAPI_ACCEPT_NOTIFY=1
pcKeepAliveTimeout     0
autoStart              2
runOnStartUp           3

With pcKeepAliveTimeout 0, idle connections close at the next one-second timer, so the stall after a burst stays under a second, as you describe for a short idle timeout. Sustained load still leaves some requests waiting until it ends.

Adding only LSAPI_MAX_BUSY_WORKER=1 to the group's environment removed both, with the same OLS binary. This tests the PHP setting, not this PR. Load came from the same host, over the plain-HTTP loopback listener.

Test Before With LSAPI_MAX_BUSY_WORKER=1
1 ms PHP request on worker B, right after a burst of 32 WordPress requests on worker A up to 667 ms up to 2.1 ms
Uncached homepage, 48 concurrent clients slowest 9.2 s (the whole run), 25 over 1 s slowest 0.65 s, none over 1 s
Throughput in that test 104 req/s 114 req/s
Median in that test 309 ms 421 ms

The median rises because no request is left waiting until the load ends. Sequential requests to the uncached homepage show no difference beyond run-to-run variation. A trivial PHP script went from a 0.11 ms median to 0.13–0.16 ms across three runs.

On the PR itself: recycleConn() hands a freed connection to the worker's next queued request and reaches reuseConn() only once that queue is empty, while LSAPI_MAX_BUSY_WORKER=1 closes after every request. A worker whose queue never empties may therefore keep holding its children with this PR, a case worth testing. I have not built the PR.

Could LSAPI_MAX_BUSY_WORKER=1 be documented as a workaround for shared process groups until this is merged?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Idle LSAPI connections held by each httpd worker pin every lsphp child; the stock config stalls PHP for about 5 minutes after a burst

2 participants