[php-src] PR #24448: ext/mysqlnd: Use relaxed atomics for statistics in ZTS builds

From: 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.

« previous php.git-pulls (#39613) next »