[php-src] PR #24448: ext/mysqlnd: Use relaxed atomics for statistics in ZTS builds
| From: | ArtUkrainskiy | Date: | Mon, 19 Oct 2026 19:43:13 +0000 |
| Subject: | [php-src] PR #24448: ext/mysqlnd: Use relaxed atomics for statistics in ZTS builds | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-39613@lists.php.net to get a copy of this message | ||
Pull Request: https://github.com/php/php-src/pull/24448
Author: ArtUkrainskiy
Fixes GH-24447.
In ZTS builds, every mysqlnd statistics update locks a mutex. The global statistics
(
mysqlnd_global_stats) are one object for the whole process, so all threads take the
same mutex on every counted packet and row. Statistics are collected by default
(mysqlnd.collect_statistics=1). With the official FrankenPHP image (PHP 8.5.11 ZTS) in
worker mode, 16 threads and one prepared SELECT of 20 rows per request, the process
makes 722.74 pthread_mutex_lock calls per request: 371.38 on the global mutex and
351.36 on the 16 per-connection mutexes (722.78 in a second pass). All of them are statistics: with
collect_statistics=0 the process makes none.
This PR replaces the mutex with relaxed atomic additions:
- Under ZTS, struct st_mysqlnd_stats holds atomic_uint_least64_t values[],
and the LOCK_access member is removed. MYSQLND_STATS_UPDATE_VALUE() is
atomic_fetch_add_explicit(..., memory_order_relaxed), and
mysqlnd_fill_stats_hash() reads with atomic_load_explicit(...,
memory_order_relaxed).
- NTS builds keep plain uint64_t values and a plain +=. The machine code
of the 455 functions whose debug information points into ext/mysqlnd is the same as on master in an
NTS build (GCC 13.3, disassembly compared with addresses normalized).
- For C++ extensions that include the mysqlnd headers (mysql_xdevapi uses the statistics macros),
mysqlnd_structs.h brings std::atomic_uint_least64_t,
atomic_fetch_add_explicit, atomic_load_explicit and
memory_order_relaxed into scope under ZTS, the same way zend_portability.h
does for atomic_bool and atomic_int; zend_globals.h already
shares atomic_bool members between C and C++.
### Why this is correct
- The mutex protected only the increments. mysqlnd_fill_stats_hash() already read the
counters without it, and nothing resets them, so readers never got a consistent snapshot of several
counters; that does not change. The unlocked read was a data race with the locked increments; it is
now an atomic load.
- The counters are independent, so no ordering between them is needed and
memory_order_relaxed is enough.
- The values are zeroed by pecalloc() as before; atomic_uint_least64_t is
lock-free on the targets checked below, so all-zero bytes are the value 0.
- On 32-bit targets the unlocked 64-bit read could tear before; an atomic load cannot.
Exact counts under concurrency: 50,000 requests at 50 at a time to FrankenPHP with 16 threads, then
mysqli_get_client_stats(). Master and this PR both count exactly 50,000
com_query and 1,000,000 rows_fetched_from_server_normal. A deliberately
racy variant (atomic_store(atomic_load() + value)) counts 49,460 and 997,554, so the
check can see lost updates. These three builds have ext/mysqli for
mysqli_get_client_stats(); the requests use PDO.
### Results
AMD Ryzen 9 5950X (two CCDs of 8 cores), Ubuntu 26.04, GCC 15.2. PHP master b7256eee301 and this PR,
ZTS, embed, the compiler flags of the official Docker image (-O2 -fstack-protector-strong
-fpic -fpie). FrankenPHP 1.13.1 built against each, worker mode, 16 PHP threads. MySQL 8.4.11
in Docker, wrk -t4 -c50. The workload is one prepared SELECT of 20 rows of
a 1000-row table per request, PDO with its default emulated prepares (text protocol) or with
PDO::ATTR_EMULATE_PREPARES => false (binary protocol). The figures are per request
for the FrankenPHP process (all threads): CPU time (user + system) and cycles (perf
stat), median over alternating passes of 20 seconds each; the change is the median of the
per-pass ratios, with their range.
| FrankenPHP CPUs | protocol | passes | statistics | master | this PR | change |
|---|---|---:|---|---:|---:|---:|
| 4 cores of one CCD | text | 15 | on (default) | 0.3029 ms, 1.30 M cycles | 0.2722 ms, 1.17 M
cycles | −10% (−18% to −3.5%) |
| | | | off | 0.2705 ms, 1.16 M cycles | 0.2703 ms, 1.16 M cycles | 0% |
| 4 cores on each CCD | text | 10 | on (default) | 0.5274 ms, 2.22 M cycles | 0.2781 ms, 1.17 M
cycles | −47% (−53% to −46%) |
| | | | off | 0.2747 ms, 1.16 M cycles | 0.2750 ms, 1.16 M cycles | 0% |
| 4 cores of one CCD | binary | 5 | on (default) | 0.3712 ms, 1.58 M cycles | 0.3292 ms, 1.39 M
cycles | −11% (−19% to −11%) |
| | | | off | 0.3271 ms, 1.39 M cycles | 0.3027 ms, 1.27 M cycles | −7% (−9% to +9%)¹ |
What statistics on cost over statistics off of the same build, CPU per request, median of the
per-pass ratios (text protocol unless noted; FrankenPHP's 16 threads on 4 or 8 cores, on one
CCD or split evenly over both):
| FrankenPHP cores | passes | master | this PR |
|---|---:|---:|---:|
| 4, one CCD | 15 | +12.1% | +0.6% |
| 4, two on each CCD | 10 | +22.7% | +1.4% |
| 8, one CCD | 10 | +40.8% | +0.8% |
| 8, four on each CCD | 10 | +90.9% | +1.0% |
| 4, one CCD, binary protocol | 5 | +13.9% | +1.4% |
On master the cost grows with the number of cores the threads run on, and at the same number of
cores splitting them over two CCDs about doubles it. With 8 cores split over both CCDs, throughput
drops from 28,855 to 21,450 req/s. With this PR the cost is 0.6–1.4% in every placement: the
global counters are still shared by all threads, and about 350 uncontended lock add per
request remain on the per-connection counters.
Most variants, on both builds, fall into two states from pass to pass: for example 1.05 M or 1.16 M
cycles and 658 k or 722–723 k instructions per request with statistics off on 4 cores of one CCD.
Master with statistics on is the exception: it stays in one state in all 5 binary-protocol passes
and in 14 of the 15 text-protocol passes on one CCD. Instructions per request differ between the
states as well, so it is not only frequency; I have not found the cause. This is why the per-pass
ranges are wide while the medians are stable.
¹ With statistics off both builds run the same code. In the 5 binary-protocol passes, this PR was
in the faster state (0.301–0.303 ms) in 3 passes and master (0.302 ms) in 1, which moves the
medians apart; with 15 and 10 passes the two builds are within 0.3% of each other. Placement
(FrankenPHP / MySQL / wrk): 4 cores of one CCD = 8–11,24–27 / 0–5,16–21 /
6–7,22–23; 4 cores, two on each CCD = 6–9,22–25 / 0–5,16–21 / 12–13,28–29; 8 cores
of one CCD = 8–15,24–31 / 0–5,16–21 / 6–7,22–23; 8 cores, four on each CCD =
4–11,20–27 / 0–3,16–19 / 12–15,28–31. Every pass, script and build step:
[report](https://github.com/ArtUkrainskiy/php-src-bench/tree/a52dd550dfad0805d08d40d3958c5c800540f768/reports/mysqlnd-zts-stats).
With the JSON API of GH-24447 (four queries per request) on PHP 8.5.11 and the same change written
with __atomic_fetch_add, statistics cost 7.2% / 9.4% / 17.7% at 1000 / 2000 / 4000
req/s and 32.2% with the database saturated, and the atomic counters 0.8–2.8%
([data](https://github.com/ArtUkrainskiy/web-request-cost/tree/ba122ac9e4feae2d883d4a815720cd4d264b32cd/results/2026-10-mysqlnd)).
FrankenPHP 1.13.1 does not build against master as is: it uses zend_atomic_bool
(removed in #23927) and initialises the removed terminate_process SAPI member (#23524).
For these measurements, zend_atomic_bool and its load/store calls were replaced with
atomic_bool/atomic_store/atomic_load and the initializer line
was dropped, the same for both builds.
### Verification
- ext/mysqlnd, ext/mysqli and ext/pdo_mysql tests on ZTS
debug builds against MySQL 8.4.11 (796 tests, 28 skipped, 4 expected to fail): master and this PR
763 passed, 1 failed, pdo_mysql___construct.phpt on both, because of the local DSN
setup.
- --enable-werror builds of this commit with GCC 13.3 and clang 18.1, ZTS and NTS.
- A C++ file including php.h, mysqlnd.h and
mysqlnd_statistics.h and using MYSQLND_INC_STATISTIC,
MYSQLND_INC_STATISTIC_W_VALUE2, MYSQLND_DEC_STATISTIC and
MYSQLND_INC_GLOBAL_STATISTIC compiles with g++ 13 and clang++ 18/20 in C++11, C++17 and
C++20 under ZTS, and emits only lock-prefixed additions (lock
add/sub with g++, lock add/inc/dec with
clang++).
- The hot functions have no tsrm_mutex_lock calls left:
php_mysqlnd_rowp_read_text_protocol has 4 lock add instructions where
master has 4 tsrm_mutex_lock calls.
### Not tested
- Windows. CI covers x64 ZTS; x86 ZTS and MSVC compiling the header as C++ (a flexible array of
std::atomic) are not covered and not tested.
- 32-bit Linux: a standalone C11 file with the same type compiles with gcc -m32 and
clang -m32 to inline lock cmpxchg8b without libatomic; the PHP build is
left to the LINUX_X32 ZTS CI job.
- Targets without lock-free 64-bit atomics (e.g. 32-bit ARM, MIPS, PowerPC) may need libatomic.
- ARM64, where relaxed and sequentially consistent additions compile differently.
- More than one socket.
- Other ZTS servers (Apache with mod_php ZTS and a threaded MPM, ext/parallel), and TSan.
### Compatibility
struct st_mysqlnd_stats is in a public header and its layout changes under ZTS, so this
is for master only. Extensions using the MYSQLND_INC_STATISTIC*() macros need a
rebuild, as for any minor version. MYSQLND_STATS_LOCK() and
MYSQLND_STATS_UNLOCK() are removed, and MYSQLND_STATS_UPDATE_VALUE() is
now an expression. UPGRADING.INTERNALS has an entry.